Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<tr>`.
- `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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
2 changes: 1 addition & 1 deletion docs/de/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
2 changes: 1 addition & 1 deletion docs/ru/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
11 changes: 10 additions & 1 deletion src/Build/TableRowBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
2 changes: 2 additions & 0 deletions src/Element/TableRow.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
/**
* @param list<TableCell> $cells
* @param bool $isHeader When true, Word repeats the row on every page (`<w:tblHeader/>`).
* @param bool $cantSplit When true, the row must not break across pages (`<w:cantSplit/>`; 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,
) {}
}
3 changes: 2 additions & 1 deletion src/Html/Converter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -773,6 +773,7 @@ private function balanceFirstRowWidths(TableRow $row): TableRow
cells: $newCells,
isHeader: $row->isHeader,
heightTwips: $row->heightTwips,
cantSplit: $row->cantSplit,
);
}

Expand Down
9 changes: 8 additions & 1 deletion src/Html/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,10 @@ private function renderTableRow(TableRow $row): string
$cells .= $this->renderTableCell($cell, isHeader: $row->isHeader);
}

return '<tr>'.$cells.'</tr>';
// The standard CSS property: "do not break inside this block".
$style = $row->cantSplit ? ' style="break-inside:avoid"' : '';

return '<tr'.$style.'>'.$cells.'</tr>';
}

private function renderTableCell(TableCell $cell, bool $isHeader): string
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/Reader/OoxmlPropertyParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/Reader/StylesResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}

Expand Down
10 changes: 6 additions & 4 deletions src/Reader/TableReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* `<w:tbl>` → Table:
* - `<w:tblPr>` → TableStyle (width, alignment, borders, layout, cellMargin)
* - `<w:tblGrid>/<w:gridCol>` → gridColumnsTwips
* - `<w:tr>` → TableRow (with trHeight, tblHeader)
* - `<w:tr>` → TableRow (with trHeight, tblHeader, cantSplit)
* - `<w:tc>` → TableCell (gridSpan, vMerge restart/continue, width,
* borders, shading, padding, vAlign)
*
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 11 additions & 1 deletion src/Style/ParagraphStyle.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ public function __construct(
public ?BorderSet $borders = null,
/** The paragraph fill (`<w:shd>` 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
Expand All @@ -51,7 +58,8 @@ public function isEmpty(): bool
&& ! $this->pageBreakAfter
&& ! $this->keepWithNext
&& $this->borders === null
&& $this->shadingColor === null;
&& $this->shadingColor === null
&& ! $this->keepLines;
}

/**
Expand All @@ -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,
Expand All @@ -85,6 +94,7 @@ public function copy(
keepWithNext: $keepWithNext ?? $this->keepWithNext,
borders: $borders ?? $this->borders,
shadingColor: $shadingColor ?? $this->shadingColor,
keepLines: $keepLines ?? $this->keepLines,
);
}
}
26 changes: 21 additions & 5 deletions src/Writer/BodyXmlBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<w:numPr><w:ilvl w:val="'.min(8, $depth).'"/><w:numId w:val="'.$numId.'"/></w:numPr>';
$pPr = $pPr === ''
? '<w:pPr><w:pStyle w:val="ListParagraph"/>'.$numPr.'</w:pPr>'
: (string) preg_replace('/^<w:pPr>/', '<w:pPr><w:pStyle w:val="ListParagraph"/>'.$numPr, $pPr, 1);
$pPr = $this->renderParagraphProperties($item->style, null, $numPr);
$children = '';
foreach ($item->children as $child) {
$children .= $this->renderInline($child);
Expand Down Expand Up @@ -224,13 +221,29 @@ private function renderParagraph(Paragraph $p): string
return '<w:p>'.$pPr.$children.'</w:p>';
}

private function renderParagraphProperties(ParagraphStyle $style, ?int $headingLevel): string
/**
* @param string $numPr A ready `<w:numPr>` 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 .= '<w:pStyle w:val="Heading'.$headingLevel.'"/>';
} elseif ($numPr !== '') {
$pPr .= '<w:pStyle w:val="ListParagraph"/>';
}
if ($style->keepWithNext) {
$pPr .= '<w:keepNext/>';
}
if ($style->keepLines) {
$pPr .= '<w:keepLines/>';
}
// 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');
}
Expand Down Expand Up @@ -548,6 +561,9 @@ private function computeGridColumns(Table $t): array
private function renderTableRow(TableRow $row): string
{
$trPr = '';
if ($row->cantSplit) {
$trPr .= '<w:cantSplit/>';
}
if ($row->isHeader) {
$trPr .= '<w:tblHeader/>';
}
Expand Down
17 changes: 17 additions & 0 deletions tests/Build/TableBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
23 changes: 23 additions & 0 deletions tests/Html/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<tr style="break-inside:avoid">'));
self::assertSame(1, substr_count($html, '<tr>'));
}

#[Test]
public function bullet_list_serializes_to_ul(): void
{
Expand Down
15 changes: 15 additions & 0 deletions tests/Reader/BodyReaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<w:p><w:pPr><w:keepLines/></w:pPr><w:r><w:t>kept together</w:t></w:r></w:p>'
.'<w:p><w:pPr><w:keepLines w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>'
.'<w:p><w:r><w:t>plain</w:t></w:r></w:p>'
);
$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
{
Expand Down
Loading
Loading