diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d43b9..9e0dd71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `w:cantSplit` on table rows: `TableRow::$cantSplit` and + `TableRowBuilder::cantSplit()` keep a row from breaking across pages + (Word's "Allow row to break across pages" turned off). The writer emits it + in `w:trPr`, the reader parses it back, and the HTML serializer writes + `break-inside: avoid` on the ``. +- `w:keepLines` on paragraphs: `ParagraphStyle::$keepLines` ("Keep lines + together") is written, read, and serialized to HTML as + `break-inside: avoid`. + +### Fixed +- The writer now emits `w:keepNext` for `ParagraphStyle::$keepWithNext`. It was + read from DOCX and serialized to HTML, but dropped on the way back into a + DOCX, so a heading kept with its next paragraph lost the rule. + ## [1.15.0] — 2026-10-19 ### Added diff --git a/README.md b/README.md index 00ce16f..c65bc02 100644 --- a/README.md +++ b/README.md @@ -643,7 +643,7 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace. | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker elements | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips }` | +| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit }` | | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` | | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` | | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` | diff --git a/docs/de/README.md b/docs/de/README.md index ca091a8..51a186f 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -566,7 +566,7 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`. | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | beides | Marker-Elemente | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips }` | +| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit }` | | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` | | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` | | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` | diff --git a/docs/ru/README.md b/docs/ru/README.md index 144dc03..769b362 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -559,7 +559,7 @@ foreach ($variables as $v) { | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker-элементы | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips }` | +| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit }` | | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` | | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` | | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` | diff --git a/docs/zh/README.md b/docs/zh/README.md index 819c280..c4644c3 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -550,7 +550,7 @@ foreach ($variables as $v) { | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`、`PageBreak`、`HorizontalRule` | 两者 | 标记元素 | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips }` | +| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit }` | | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` | | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` | | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` | diff --git a/src/Build/TableRowBuilder.php b/src/Build/TableRowBuilder.php index 10d766d..e72852f 100644 --- a/src/Build/TableRowBuilder.php +++ b/src/Build/TableRowBuilder.php @@ -30,6 +30,8 @@ final class TableRowBuilder private ?int $heightTwips = null; + private bool $cantSplit = false; + public function cell(string|callable $textOrBuilder): self { $cellBuilder = new TableCellBuilder; @@ -81,8 +83,15 @@ public function height(int $twips): self return $this; } + public function cantSplit(bool $value = true): self + { + $this->cantSplit = $value; + + return $this; + } + public function build(): TableRow { - return new TableRow($this->cells, $this->isHeader, $this->heightTwips); + return new TableRow($this->cells, $this->isHeader, $this->heightTwips, $this->cantSplit); } } diff --git a/src/Element/TableRow.php b/src/Element/TableRow.php index 902260c..00190ca 100644 --- a/src/Element/TableRow.php +++ b/src/Element/TableRow.php @@ -12,10 +12,12 @@ /** * @param list $cells * @param bool $isHeader When true, Word repeats the row on every page (``). + * @param bool $cantSplit When true, the row must not break across pages (``; Word's "Allow row to break across pages" turned off). */ public function __construct( public array $cells, public bool $isHeader = false, public ?int $heightTwips = null, + public bool $cantSplit = false, ) {} } diff --git a/src/Html/Converter.php b/src/Html/Converter.php index 8dc81dc..7c3c6d2 100644 --- a/src/Html/Converter.php +++ b/src/Html/Converter.php @@ -644,7 +644,7 @@ private function expandRowSpans(array $rows): array $origIdx++; } - $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips); + $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit); } return $newRows; @@ -773,6 +773,7 @@ private function balanceFirstRowWidths(TableRow $row): TableRow cells: $newCells, isHeader: $row->isHeader, heightTwips: $row->heightTwips, + cantSplit: $row->cantSplit, ); } diff --git a/src/Html/Serializer.php b/src/Html/Serializer.php index 51c3841..5b377d4 100644 --- a/src/Html/Serializer.php +++ b/src/Html/Serializer.php @@ -498,7 +498,10 @@ private function renderTableRow(TableRow $row): string $cells .= $this->renderTableCell($cell, isHeader: $row->isHeader); } - return ''.$cells.''; + // The standard CSS property: "do not break inside this block". + $style = $row->cantSplit ? ' style="break-inside:avoid"' : ''; + + return ''.$cells.''; } private function renderTableCell(TableCell $cell, bool $isHeader): string @@ -579,6 +582,10 @@ private function paragraphStyleCss(ParagraphStyle $s): string // The standard CSS property: "do not break after this block". $parts[] = 'break-after:avoid'; } + if ($s->keepLines) { + // The standard CSS property: "do not break inside this block". + $parts[] = 'break-inside:avoid'; + } if ($s->borders !== null) { foreach ($this->borderSetCss($s->borders) as $cssLine) { $parts[] = $cssLine; diff --git a/src/Reader/OoxmlPropertyParser.php b/src/Reader/OoxmlPropertyParser.php index 0055731..6287383 100644 --- a/src/Reader/OoxmlPropertyParser.php +++ b/src/Reader/OoxmlPropertyParser.php @@ -78,7 +78,7 @@ public function parseRunProperties(?\DOMElement $rPr): array * * Keys: alignment, spaceBeforeTwips, spaceAfterTwips, * indentLeftTwips, indentRightTwips, indentFirstLineTwips, - * lineSpacingTwips, pageBreakAfter, borders, shadingColor, + * lineSpacingTwips, pageBreakAfter, keepWithNext, keepLines, borders, shadingColor, * pStyleId, numId, ilvl. * * @return array @@ -101,6 +101,7 @@ public function parseParagraphProperties(?\DOMElement $pPr): array 'spacing' => $this->parseSpacing($node, $out), 'pageBreakBefore' => $out['pageBreakBefore'] = OoxmlNs::boolToggle($node), 'keepNext' => $out['keepWithNext'] = OoxmlNs::boolToggle($node), + 'keepLines' => $out['keepLines'] = OoxmlNs::boolToggle($node), 'pBdr' => $out['borders'] = $this->parseBorders($node), 'shd' => $out['shadingColor'] = $this->parseShdFill($node), 'numPr' => $this->parseNumPr($node, $out), diff --git a/src/Reader/StylesResolver.php b/src/Reader/StylesResolver.php index 4eb9064..72207f3 100644 --- a/src/Reader/StylesResolver.php +++ b/src/Reader/StylesResolver.php @@ -318,6 +318,7 @@ private function arrayToParagraphStyle(array $arr): ParagraphStyle keepWithNext: (bool) ($arr['keepWithNext'] ?? false), borders: $borders instanceof BorderSet ? $borders : null, shadingColor: $arr['shadingColor'] ?? null, + keepLines: (bool) ($arr['keepLines'] ?? false), ); } diff --git a/src/Reader/TableReader.php b/src/Reader/TableReader.php index f512514..edcff09 100644 --- a/src/Reader/TableReader.php +++ b/src/Reader/TableReader.php @@ -21,7 +21,7 @@ * `` → Table: * - `` → TableStyle (width, alignment, borders, layout, cellMargin) * - `/` → gridColumnsTwips - * - `` → TableRow (with trHeight, tblHeader) + * - `` → TableRow (with trHeight, tblHeader, cantSplit) * - `` → TableCell (gridSpan, vMerge restart/continue, width, * borders, shading, padding, vAlign) * @@ -153,7 +153,9 @@ private function readRow(\DOMElement $tr): TableRow $trPr = OoxmlNs::firstChild($tr, OoxmlNs::W, 'trPr'); $isHeader = false; $heightTwips = null; + $cantSplit = false; if ($trPr !== null) { + $cantSplit = OoxmlNs::firstChild($trPr, OoxmlNs::W, 'cantSplit') !== null; $isHeader = OoxmlNs::firstChild($trPr, OoxmlNs::W, 'tblHeader') !== null; $h = OoxmlNs::firstChild($trPr, OoxmlNs::W, 'trHeight'); if ($h !== null) { @@ -169,7 +171,7 @@ private function readRow(\DOMElement $tr): TableRow $cells[] = $this->readCell($tcEl); } - return new TableRow($cells, $isHeader, $heightTwips); + return new TableRow($cells, $isHeader, $heightTwips, $cantSplit); } private function readCell(\DOMElement $tc): TableCell @@ -331,7 +333,7 @@ private function reconstructRowSpans(array $rows): array $restartRowSpan[$rowIdx][$cellIdx] ?? ($cell->style->vMergeContinue ? 1 : 1), ); } - $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips); + $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit); } return $newRows; @@ -380,7 +382,7 @@ private function resetTemporaryRowSpans(array $rows): array } $newCells[] = $cell; } - $out[] = new TableRow($newCells, $row->isHeader, $row->heightTwips); + $out[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit); } return $out; diff --git a/src/Style/ParagraphStyle.php b/src/Style/ParagraphStyle.php index fc72bc9..c1f8b2e 100644 --- a/src/Style/ParagraphStyle.php +++ b/src/Style/ParagraphStyle.php @@ -37,6 +37,13 @@ public function __construct( public ?BorderSet $borders = null, /** The paragraph fill (`` in `w:pPr`) — hex without the `#`, e.g. `0f766e`. */ public ?string $shadingColor = null, + /** + * `w:keepLines` — "keep lines together". + * + * Word does not split the paragraph across pages: when it does not fit + * in what is left of the page, the whole paragraph moves to the next. + */ + public bool $keepLines = false, ) {} public function isEmpty(): bool @@ -51,7 +58,8 @@ public function isEmpty(): bool && ! $this->pageBreakAfter && ! $this->keepWithNext && $this->borders === null - && $this->shadingColor === null; + && $this->shadingColor === null + && ! $this->keepLines; } /** @@ -71,6 +79,7 @@ public function copy( ?bool $keepWithNext = null, ?BorderSet $borders = null, ?string $shadingColor = null, + ?bool $keepLines = null, ): self { return new self( alignment: $alignment ?? $this->alignment, @@ -85,6 +94,7 @@ public function copy( keepWithNext: $keepWithNext ?? $this->keepWithNext, borders: $borders ?? $this->borders, shadingColor: $shadingColor ?? $this->shadingColor, + keepLines: $keepLines ?? $this->keepLines, ); } } diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index c8c392f..8728c47 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -164,11 +164,8 @@ private function renderTreeItems(ListNode $list, int $numId, int $depth): string $xml = ''; foreach ($list->items as $item) { if (! $item->markerHidden) { - $pPr = $this->renderParagraphProperties($item->style, null); $numPr = ''; - $pPr = $pPr === '' - ? ''.$numPr.'' - : (string) preg_replace('/^/', ''.$numPr, $pPr, 1); + $pPr = $this->renderParagraphProperties($item->style, null, $numPr); $children = ''; foreach ($item->children as $child) { $children .= $this->renderInline($child); @@ -224,13 +221,29 @@ private function renderParagraph(Paragraph $p): string return ''.$pPr.$children.''; } - private function renderParagraphProperties(ParagraphStyle $style, ?int $headingLevel): string + /** + * @param string $numPr A ready `` of a list item: it is placed at its + * position in the sequence (and `ListParagraph` is the style). + */ + private function renderParagraphProperties(ParagraphStyle $style, ?int $headingLevel, string $numPr = ''): string { $pPr = ''; if ($headingLevel !== null && $headingLevel >= 1 && $headingLevel <= 6) { $pPr .= ''; + } elseif ($numPr !== '') { + $pPr .= ''; + } + if ($style->keepWithNext) { + $pPr .= ''; } + if ($style->keepLines) { + $pPr .= ''; + } + // numPr sits after keepNext/keepLines/pageBreakBefore and before pBdr in + // CT_PPrBase; placing it here (not by prefixing the finished string) + // keeps it there when those elements are written. + $pPr .= $numPr; if ($style->borders !== null) { $pPr .= $this->renderBorderSet($style->borders, 'w:pBdr'); } @@ -548,6 +561,9 @@ private function computeGridColumns(Table $t): array private function renderTableRow(TableRow $row): string { $trPr = ''; + if ($row->cantSplit) { + $trPr .= ''; + } if ($row->isHeader) { $trPr .= ''; } diff --git a/tests/Build/TableBuilderTest.php b/tests/Build/TableBuilderTest.php index bf93ba1..acfc9d0 100644 --- a/tests/Build/TableBuilderTest.php +++ b/tests/Build/TableBuilderTest.php @@ -191,6 +191,23 @@ public function row_height_and_explicit_header_flag(): void self::assertSame(800, $t->rows[0]->heightTwips); } + #[Test] + public function row_cant_split_flag(): void + { + $doc = DocumentBuilder::new() + ->table(fn (TableBuilder $t) => $t + ->row(fn (TableRowBuilder $r) => $r->cantSplit()->cell('A')) + ->row(fn (TableRowBuilder $r) => $r->cantSplit()->cantSplit(false)->cell('B')) + ->row(['C']) + ) + ->build(); + /** @var Table $t */ + $t = $doc->section->body[0]; + self::assertTrue($t->rows[0]->cantSplit); + self::assertFalse($t->rows[1]->cantSplit); + self::assertFalse($t->rows[2]->cantSplit); + } + #[Test] public function nested_table_in_cell(): void { diff --git a/tests/Html/SerializerTest.php b/tests/Html/SerializerTest.php index cdb7d09..12700d6 100644 --- a/tests/Html/SerializerTest.php +++ b/tests/Html/SerializerTest.php @@ -92,6 +92,29 @@ public function alignment_center_emits_text_align(): void self::assertStringContainsString('text-align:center', $html); } + #[Test] + public function keep_lines_emits_break_inside_avoid(): void + { + $doc = new Document(section: new Section([ + new Paragraph([new Run('x')], new ParagraphStyle(keepLines: true)), + new Paragraph([new Run('y')]), + ])); + $html = (new Serializer)->serialize($doc)->bodyHtml; + self::assertSame(1, substr_count($html, 'break-inside:avoid')); + } + + #[Test] + public function cant_split_row_emits_break_inside_avoid(): void + { + $cell = new TableCell([new Paragraph([new Run('x')])]); + $doc = new Document(section: new Section([ + new Table([new TableRow([$cell], cantSplit: true), new TableRow([$cell])]), + ])); + $html = (new Serializer)->serialize($doc)->bodyHtml; + self::assertSame(1, substr_count($html, '')); + self::assertSame(1, substr_count($html, '')); + } + #[Test] public function bullet_list_serializes_to_ul(): void { diff --git a/tests/Reader/BodyReaderTest.php b/tests/Reader/BodyReaderTest.php index cd0e0d3..66db856 100644 --- a/tests/Reader/BodyReaderTest.php +++ b/tests/Reader/BodyReaderTest.php @@ -316,6 +316,21 @@ public function keep_next_reaches_the_paragraph_style(): void self::assertFalse($blocks[1]->style->keepWithNext); } + #[Test] + public function keep_lines_reaches_the_paragraph_style(): void + { + $body = $this->loadBody( + 'kept together' + .'off' + .'plain' + ); + $blocks = (new BodyReader)->read($body); + + self::assertTrue($blocks[0]->style->keepLines); + self::assertFalse($blocks[1]->style->keepLines); + self::assertFalse($blocks[2]->style->keepLines); + } + #[Test] public function footnote_text_is_read_from_the_notes_part(): void { diff --git a/tests/Reader/TableReaderTest.php b/tests/Reader/TableReaderTest.php index ff4a98c..512c707 100644 --- a/tests/Reader/TableReaderTest.php +++ b/tests/Reader/TableReaderTest.php @@ -149,6 +149,21 @@ public function row_with_tblHeader_marked_header(): void self::assertTrue($t->rows[0]->isHeader); } + #[Test] + public function row_with_cantSplit_marked(): void + { + $tbl = $this->loadTable( + '' + .'' + .'A' + .'B' + .'' + ); + $t = (new TableReader)->read($tbl); + self::assertTrue($t->rows[0]->cantSplit); + self::assertFalse($t->rows[1]->cantSplit); + } + #[Test] public function trHeight_extracted(): void { diff --git a/tests/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php index f4fe608..36a74fb 100644 --- a/tests/Writer/BodyXmlBuilderTest.php +++ b/tests/Writer/BodyXmlBuilderTest.php @@ -7,6 +7,8 @@ use Dskripchenko\PhpDocx\Element\HorizontalRule; use Dskripchenko\PhpDocx\Element\Hyperlink; use Dskripchenko\PhpDocx\Element\LineBreak; +use Dskripchenko\PhpDocx\Element\ListItem; +use Dskripchenko\PhpDocx\Element\ListNode; use Dskripchenko\PhpDocx\Element\PageBreak; use Dskripchenko\PhpDocx\Element\Paragraph; use Dskripchenko\PhpDocx\Element\Run; @@ -14,6 +16,7 @@ use Dskripchenko\PhpDocx\Element\TableCell; use Dskripchenko\PhpDocx\Element\TableRow; use Dskripchenko\PhpDocx\Style\Alignment; +use Dskripchenko\PhpDocx\Style\Border; use Dskripchenko\PhpDocx\Style\BorderSet; use Dskripchenko\PhpDocx\Style\CellStyle; use Dskripchenko\PhpDocx\Style\ParagraphStyle; @@ -161,6 +164,99 @@ public function it_renders_table_header_row(): void self::assertStringContainsString('', $xml); } + #[Test] + public function it_renders_keep_next_after_pstyle_and_before_borders(): void + { + $style = new ParagraphStyle( + alignment: Alignment::Center, + borders: BorderSet::all(new Border), + keepWithNext: true, + ); + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run('x')], $style, headingLevel: 2)]); + + self::assertStringContainsString('', $xml); + self::assertLessThan(strpos($xml, '')); + + $plain = (new BodyXmlBuilder)->render([new Paragraph([new Run('x')])]); + self::assertStringNotContainsString('keepNext', $plain); + } + + #[Test] + public function it_renders_keep_lines_after_keep_next_and_before_numpr(): void + { + $style = new ParagraphStyle(keepWithNext: true, keepLines: true, borders: BorderSet::all(new Border)); + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run('x')], $style, headingLevel: 2)]); + self::assertStringContainsString('', $xml); + + $list = (new BodyXmlBuilder)->render([ + new ListNode([ + new ListItem([new Run('x')], style: new ParagraphStyle(keepLines: true)), + ], ordered: true, levelText: '%1.'), + ]); + self::assertStringContainsString('', $list); + + $plain = (new BodyXmlBuilder)->render([new Paragraph([new Run('x')])]); + self::assertStringNotContainsString('keepLines', $plain); + } + + #[Test] + public function it_renders_cant_split_row_before_header_and_height(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Table([ + new TableRow([ + new TableCell([new Paragraph([new Run('H')])]), + ], isHeader: true, heightTwips: 400, cantSplit: true), + new TableRow([ + new TableCell([new Paragraph([new Run('B')])]), + ]), + ]), + ]); + + self::assertStringContainsString('', $xml); + self::assertSame(1, substr_count($xml, '')); + } + + #[Test] + public function it_puts_numpr_after_keep_next_in_a_list_item(): void + { + $xml = (new BodyXmlBuilder)->render([ + new ListNode([ + new ListItem([new Run('x')], style: new ParagraphStyle(alignment: Alignment::Center, keepWithNext: true)), + ], ordered: true, levelText: '%1.'), + ]); + + self::assertStringContainsString('', $xml); + } + + #[Test] + public function it_places_numpr_before_the_other_properties_of_a_list_item(): void + { + // CT_PPrBase is a sequence: pStyle, numPr, pBdr, shd, spacing, ind, jc. + $xml = (new BodyXmlBuilder)->render([ + new ListNode([ + new ListItem([new Run('x')], style: new ParagraphStyle( + alignment: Alignment::Center, + spaceAfterTwips: 120, + borders: BorderSet::all(new Border), + shadingColor: 'eeeeee', + )), + ], ordered: true, levelText: '%1.'), + ]); + + $order = ['', '', 'render([new ListNode([new ListItem([new Run('x')])], ordered: true, levelText: '%1.')]); + self::assertStringContainsString('', $bare); + } + #[Test] public function it_renders_cell_style_with_width_padding_bg(): void { diff --git a/tests/Writer/IntegrationTest.php b/tests/Writer/IntegrationTest.php index 5f0d924..2e658c1 100644 --- a/tests/Writer/IntegrationTest.php +++ b/tests/Writer/IntegrationTest.php @@ -4,7 +4,16 @@ namespace Dskripchenko\PhpDocx\Tests\Writer; +use Dskripchenko\PhpDocx\Document; +use Dskripchenko\PhpDocx\Element\Paragraph; +use Dskripchenko\PhpDocx\Element\Run; +use Dskripchenko\PhpDocx\Element\Table; +use Dskripchenko\PhpDocx\Element\TableCell; +use Dskripchenko\PhpDocx\Element\TableRow; use Dskripchenko\PhpDocx\Html\Converter; +use Dskripchenko\PhpDocx\Reader\DocxReader; +use Dskripchenko\PhpDocx\Section; +use Dskripchenko\PhpDocx\Style\ParagraphStyle; use Dskripchenko\PhpDocx\Writer\Word2007Writer; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -127,6 +136,42 @@ public function it_renders_polis_like_template_without_errors(): void self::assertStringContainsString('', $xml); // hr } + #[Test] + public function keep_next_survives_a_write_and_read_roundtrip(): void + { + $doc = new Document(section: new Section([ + new Paragraph([new Run('Heading')], new ParagraphStyle(keepWithNext: true)), + new Paragraph([new Run('Body')]), + ])); + $bytes = (new Word2007Writer)->write($doc); + + self::assertStringContainsString('', self::extractXml($bytes, 'word/document.xml')); + + $read = (new DocxReader)->read($bytes); + self::assertTrue($read->section->body[0]->style->keepWithNext); + self::assertFalse($read->section->body[1]->style->keepWithNext); + } + + #[Test] + public function cant_split_and_keep_lines_survive_a_write_and_read_roundtrip(): void + { + $cell = new TableCell([new Paragraph([new Run('A')])]); + $doc = new Document(section: new Section([ + new Paragraph([new Run('kept')], new ParagraphStyle(keepLines: true)), + new Table([new TableRow([$cell], cantSplit: true), new TableRow([$cell])]), + ])); + $bytes = (new Word2007Writer)->write($doc); + + $xml = self::extractXml($bytes, 'word/document.xml'); + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); + + $read = (new DocxReader)->read($bytes); + self::assertTrue($read->section->body[0]->style->keepLines); + self::assertTrue($read->section->body[1]->rows[0]->cantSplit); + self::assertFalse($read->section->body[1]->rows[1]->cantSplit); + } + private static function extractXml(string $bytes, string $internalPath): string { $tmp = tempnam(sys_get_temp_dir(), 'docx-int-');