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-');