diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ea6ca..1fa95cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,80 @@ 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 +- `DocumentBuilder::coreProperties(CoreProperties)`: title, author, dates + and the rest of `docProps/core.xml` from the fluent builder. Until now + `build()` never set them, and builder users had to construct the + `Document` by hand to get a core part. +- `Paragraph::$styleId` and `ParagraphBuilder::styleId()`: any paragraph + style id (`Normal`, `Title`, `Quote`, a custom one registered with + `StyleRegistry::paragraph()`) is written as ``. Only `Heading1..6` + (through `headingLevel`) could be referenced before. An explicit id wins + over the heading level. +- `ParagraphStyle::$pageBreakBefore` (`w:pageBreakBefore`) and + `ParagraphStyle::$widowControl` (`w:widowControl`; `null` leaves it to the + style, `false` writes `w:val="0"`), with `ParagraphBuilder::pageBreakBefore()` + and `widowControl()`. Both are written at their place in `w:pPr` and read + back. A page break before a paragraph no longer needs a `PageBreak` block and + its empty paragraph. HTML: `pageBreakBefore` is serialized as + `break-before: page`, and `break-before: page` / `page-break-before: always` + are parsed back. +- `RunStyle::$fontFamilyEastAsia` (`w:eastAsia` on ``), with + `RunStyle::withFontFamilyEastAsia()` and + `RunStyleBuilder::fontFamilyEastAsia()`: the font Word uses for CJK text. + Written in document and style run properties, and read back from an + explicit `w:eastAsia`. +- Table indent: `TableStyle::$indentTwips` and `TableBuilder::indent()` / + `indentMm()` / `indentPt()` write ``, which moves + the table in from the leading margin (negative values pull it out). The + reader parses it back; HTML carries it as `margin-left` on the `` + in both directions. +- Exact row height: `TableRow::$heightRule` (`RowHeightRule::AtLeast`, the + default, or `RowHeightRule::Exact`) and `TableRowBuilder::exactHeight()`. + The writer emitted every `w:trHeight` as `w:hRule="atLeast"`, so a row could + not be held to a fixed height; the rule is now written and read back. + +### Changed +- ZIP entries no longer carry the time of the write. Every entry is stamped + with `CoreProperties::$modified` (as a UTC wall-clock time), or with + 1980-01-01 00:00:00, the earliest DOS time, when it is not set. Pass + `modified: new DateTimeImmutable()` to get the current time back. +- HTML: a length `margin-left` on a `
` is now read as the table indent + and written as ``, so a table that set it is indented in Word too. + `margin-left: auto` (centring) is not an indent and is ignored. + +### Fixed +- A tab or a newline in run text was written as is inside ``, where + Word shows it as a space at best. The writer now splits the text into + ``, `` and `` (for "\n", "\r\n" and "\r"). The reader + reads `` as "\t", so a document read and written back lost its + tabs; a `
` from HTML lost its line breaks.
+- Text with an invalid UTF-8 sequence was written as an empty string: the
+  whole run (or attribute) was dropped without a word. `XmlEscape` now
+  replaces the sequence with U+FFFD and keeps the rest.
+- A control character in text (`\x01`, `\x0B`, …) went into the XML as is,
+  and Word refused to open the file. In run text, a vertical tab (`\x0B`,
+  Word's manual line break) is now written as `` and a form feed
+  (`\x0C`, Word's page break) as ``; `XmlEscape` removes
+  every other character XML 1.0 does not allow.
+- The same document now gives the same bytes on every write. ZipArchive put
+  the current time on every entry, so two writes a couple of seconds apart
+  differed, and a checksum, a cache key or a diff of generated files was
+  useless. Every entry now carries `CoreProperties::$modified` (as a UTC
+  wall-clock time), or 1980-01-01 00:00:00 when it is not set. The time is
+  written into the ZIP headers directly: `ZipArchive::setMtimeName()` alone
+  converts through the process's `TZ`, so the bytes would still differ
+  between machines in different time zones.
+- The table grid computed when `Table::$gridColumnsTwips` is not set repeated
+  a spanning cell's full width for every column it spans: a 2000-twip cell
+  with `gridSpan: 2` gave two 2000-twip columns, and the grid came out wider
+  than the table. The spanned columns now share the cell's width, and a
+  column takes its width from the first row where an unspanned cell with a
+  width covers it (a title row spanning the table no longer decides the
+  columns below it).
+
 ## [1.16.0] — 2026-10-20
 
 ### Added
diff --git a/README.md b/README.md
index c65bc02..99d4357 100644
--- a/README.md
+++ b/README.md
@@ -317,10 +317,12 @@ HTML pipeline produces.
 
 ### DocumentBuilder
 
-Entry point. Accumulates body, header/footer, watermark, page setup.
+Entry point. Accumulates body, header/footer, watermark, page setup,
+core properties (`docProps/core.xml`: title, author, dates).
 
 ```php
 use Dskripchenko\PhpDocx\Build\DocumentBuilder;
+use Dskripchenko\PhpDocx\Style\CoreProperties;
 use Dskripchenko\PhpDocx\Style\PageSetup;
 use Dskripchenko\PhpDocx\Style\PaperSize;
 use Dskripchenko\PhpDocx\Style\Orientation;
@@ -331,6 +333,7 @@ $doc = DocumentBuilder::new()
         orientation: Orientation::Portrait,
     ))
     ->watermark('CONFIDENTIAL')
+    ->coreProperties(new CoreProperties(title: 'Report', creator: 'Acme Inc.'))
     ->heading(1, 'Report')
     ->paragraph('Body')
     ->build();           // → Document AST
@@ -339,6 +342,9 @@ $bytes = DocumentBuilder::new()->paragraph('Hi')->toBytes();
 $count = DocumentBuilder::new()->paragraph('Hi')->toFile('out.docx');
 ```
 
+Output is byte-for-byte reproducible: every ZIP entry carries
+`CoreProperties::$modified` (UTC), or 1980-01-01 00:00:00 when it is not set.
+
 ### ParagraphBuilder
 
 Inside `->paragraph(fn ($p) => …)`:
@@ -371,12 +377,18 @@ Paragraph-level styling:
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // or alignRight()/alignJustify()
+    ->styleId('Quote')        // a style id from the StyleRegistry
+    ->pageBreakBefore()       // start on a new page; widowControl(false) turns widow control off
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('Indented & spaced')
 )
 ```
 
+`styleId()` takes effect only for a style the document defines: `Heading1..6`,
+`ListParagraph`, `Caption`, or one registered with `StyleRegistry::paragraph()`.
+Word shows a paragraph with an unknown id as Normal.
+
 ### TableBuilder
 
 ```php
@@ -450,6 +462,7 @@ RunStyleBuilder::new()
     ->backgroundColor('eeeeee')
     ->highlight('yellow')
     ->fontFamily('Arial')
+    ->fontFamilyEastAsia('SimSun')   // w:eastAsia: the font for CJK text
     ->fontSizePt(14.5)
     ->build();
 ```
@@ -471,7 +484,7 @@ Length::px(100);    // 1500  (CSS px @ 96 DPI)
 
 Most builders expose unit-aware shortcuts:
 
-- TableBuilder: `widthPt/widthMm/widthCm/widthInches`, `cellMarginsMm/cellMarginsPt`
+- TableBuilder: `widthPt/widthMm/widthCm/widthInches`, `cellMarginsMm/cellMarginsPt`, `indent/indentMm/indentPt`
 - TableCellBuilder: `widthPt/Mm/Cm/Inches`, `paddingMm/Pt/Cm/Inches`
 - ColumnBuilder: `widthPt/Mm/Cm/Inches/Px`
 - ParagraphBuilder: `indentMm/Cm/Pt/Inches`, `spacingPt/Mm`
@@ -635,7 +648,7 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace.
 |---|---|---|
 | `Document` | root | `{ section: Section, watermarkText: ?string }` |
 | `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` |
-| `Paragraph` | BlockElement | `{ children: InlineElement[], style: ParagraphStyle, headingLevel: ?int }` |
+| `Paragraph` | BlockElement | `{ children: InlineElement[], style: ParagraphStyle, headingLevel: ?int, styleId: ?string }` |
 | `Run` | InlineElement | `{ text: string, style: RunStyle }` |
 | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children: InlineElement[] }` |
 | `Bookmark` | InlineElement | `{ name: string, children: InlineElement[] }` |
@@ -643,17 +656,17 @@ 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, cantSplit }` |
+| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit, heightRule }` |
 | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` |
 | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` |
 | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` |
 
 Styles live under `Dskripchenko\PhpDocx\Style`:
 
-- `RunStyle` — font, weight, italic, color, size, highlight, …
-- `ParagraphStyle` — alignment, indents, spacing, borders
+- `RunStyle` — font (and `fontFamilyEastAsia` for CJK text), weight, italic, color, size, highlight, …
+- `ParagraphStyle` — alignment, indents, spacing, borders, pagination (keepWithNext, keepLines, pageBreakBefore, widowControl)
 - `CellStyle` — width, padding, borders, valign, gridSpan, rowSpan
-- `TableStyle` — width, borders, alignment, cell margins, layout
+- `TableStyle` — width, borders, alignment, cell margins, layout, indent (`indentTwips`)
 - `PageSetup`, `PaperSize`, `Orientation`, `Alignment`, `VerticalAlign`,
   `BorderStyle`, `Border`, `BorderSet`
 
diff --git a/docs/de/README.md b/docs/de/README.md
index 51a186f..bbf6e25 100644
--- a/docs/de/README.md
+++ b/docs/de/README.md
@@ -238,10 +238,12 @@ HTML-Pipeline.
 ### DocumentBuilder
 
 Einstiegspunkt. Sammelt Body, Kopf-/Fußzeilen, Wasserzeichen,
-Seiteneinrichtung.
+Seiteneinrichtung, Dokumenteigenschaften (`docProps/core.xml`: Titel,
+Autor, Daten).
 
 ```php
 use Dskripchenko\PhpDocx\Build\DocumentBuilder;
+use Dskripchenko\PhpDocx\Style\CoreProperties;
 use Dskripchenko\PhpDocx\Style\PageSetup;
 use Dskripchenko\PhpDocx\Style\PaperSize;
 use Dskripchenko\PhpDocx\Style\Orientation;
@@ -252,6 +254,7 @@ $doc = DocumentBuilder::new()
         orientation: Orientation::Portrait,
     ))
     ->watermark('VERTRAULICH')
+    ->coreProperties(new CoreProperties(title: 'Bericht', creator: 'Acme GmbH'))
     ->heading(1, 'Bericht')
     ->paragraph('Inhalt')
     ->build();           // → Document AST
@@ -260,6 +263,9 @@ $bytes = DocumentBuilder::new()->paragraph('Hallo')->toBytes();
 $count = DocumentBuilder::new()->paragraph('Hallo')->toFile('out.docx');
 ```
 
+Die Ausgabe ist byte-genau reproduzierbar: jeder ZIP-Eintrag trägt
+`CoreProperties::$modified` (UTC) bzw. 1980-01-01 00:00:00, wenn nicht gesetzt.
+
 ### ParagraphBuilder
 
 Innerhalb `->paragraph(fn ($p) => …)`:
@@ -292,12 +298,18 @@ Absatz-Stil:
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // oder alignRight()/alignJustify()
+    ->styleId('Quote')        // eine Style-ID aus der StyleRegistry
+    ->pageBreakBefore()       // auf neuer Seite beginnen; widowControl(false) schaltet die Absatzkontrolle aus
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('Eingerückt')
 )
 ```
 
+`styleId()` wirkt nur bei einem Style, den das Dokument definiert: `Heading1..6`,
+`ListParagraph`, `Caption` oder einer, der mit `StyleRegistry::paragraph()` registriert ist.
+Einen Absatz mit unbekannter ID zeigt Word als Normal.
+
 ### TableBuilder
 
 ```php
@@ -371,6 +383,7 @@ RunStyleBuilder::new()
     ->backgroundColor('eeeeee')
     ->highlight('yellow')
     ->fontFamily('Arial')
+    ->fontFamilyEastAsia('SimSun')   // w:eastAsia: die Schrift für CJK-Text
     ->fontSizePt(14.5)
     ->build();
 ```
@@ -393,7 +406,7 @@ Length::px(100);    // 1500  (CSS px @ 96 DPI)
 Die meisten Builder bieten einheiten-bewusste Shortcuts:
 
 - TableBuilder: `widthPt/widthMm/widthCm/widthInches`,
-  `cellMarginsMm/cellMarginsPt`
+  `cellMarginsMm/cellMarginsPt`, `indent/indentMm/indentPt`
 - TableCellBuilder: `widthPt/Mm/Cm/Inches`, `paddingMm/Pt/Cm/Inches`
 - ColumnBuilder: `widthPt/Mm/Cm/Inches/Px`
 - ParagraphBuilder: `indentMm/Cm/Pt/Inches`, `spacingPt/Mm`
@@ -558,7 +571,7 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`.
 |---|---|---|
 | `Document` | root | `{ section: Section, watermarkText: ?string }` |
 | `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` |
-| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int }` |
+| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` |
 | `Run` | InlineElement | `{ text: string, style: RunStyle }` |
 | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` |
 | `Bookmark` | InlineElement | `{ name: string, children }` |
@@ -566,18 +579,18 @@ 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, cantSplit }` |
+| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit, heightRule }` |
 | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` |
 | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` |
 | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` |
 
 Styles liegen unter `Dskripchenko\PhpDocx\Style`:
 
-- `RunStyle` — Schrift, Gewicht, Kursiv, Farbe, Grösse, Highlight, …
-- `ParagraphStyle` — Ausrichtung, Einzüge, Abstände, Rahmen
+- `RunStyle` — Schrift (und `fontFamilyEastAsia` für CJK-Text), Gewicht, Kursiv, Farbe, Grösse, Highlight, …
+- `ParagraphStyle` — Ausrichtung, Einzüge, Abstände, Rahmen, Seitenumbruch (keepWithNext, keepLines, pageBreakBefore, widowControl)
 - `CellStyle` — Breite, Padding, Rahmen, vertikale Ausrichtung,
   gridSpan, rowSpan
-- `TableStyle` — Breite, Rahmen, Ausrichtung, Zellenränder, Layout
+- `TableStyle` — Breite, Rahmen, Ausrichtung, Zellenränder, Layout, Einzug (`indentTwips`)
 - `PageSetup`, `PaperSize`, `Orientation`, `Alignment`, `VerticalAlign`,
   `BorderStyle`, `Border`, `BorderSet`
 
diff --git a/docs/ru/README.md b/docs/ru/README.md
index 769b362..b5f918d 100644
--- a/docs/ru/README.md
+++ b/docs/ru/README.md
@@ -234,10 +234,12 @@ Namespace `Build` предоставляет fluent API для поэтапно
 
 ### DocumentBuilder
 
-Точка входа. Накапливает body, header/footer, watermark, page setup.
+Точка входа. Накапливает body, header/footer, watermark, page setup,
+свойства документа (`docProps/core.xml`: название, автор, даты).
 
 ```php
 use Dskripchenko\PhpDocx\Build\DocumentBuilder;
+use Dskripchenko\PhpDocx\Style\CoreProperties;
 use Dskripchenko\PhpDocx\Style\PageSetup;
 use Dskripchenko\PhpDocx\Style\PaperSize;
 use Dskripchenko\PhpDocx\Style\Orientation;
@@ -248,6 +250,7 @@ $doc = DocumentBuilder::new()
         orientation: Orientation::Portrait,
     ))
     ->watermark('КОНФИДЕНЦИАЛЬНО')
+    ->coreProperties(new CoreProperties(title: 'Отчёт', creator: 'ООО «Ромашка»'))
     ->heading(1, 'Отчёт')
     ->paragraph('Тело документа')
     ->build();           // → Document AST
@@ -256,6 +259,9 @@ $bytes = DocumentBuilder::new()->paragraph('Привет')->toBytes();
 $count = DocumentBuilder::new()->paragraph('Привет')->toFile('out.docx');
 ```
 
+Результат воспроизводим байт в байт: у каждой записи ZIP время
+`CoreProperties::$modified` (UTC), а без него — 1980-01-01 00:00:00.
+
 ### ParagraphBuilder
 
 Внутри `->paragraph(fn ($p) => …)`:
@@ -288,12 +294,18 @@ $count = DocumentBuilder::new()->paragraph('Привет')->toFile('out.docx');
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // alignRight()/alignJustify()
+    ->styleId('Quote')        // id стиля из StyleRegistry
+    ->pageBreakBefore()       // с новой страницы; widowControl(false) выключает запрет висячих строк
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('С отступами')
 )
 ```
 
+`styleId()` действует только для стиля, который есть в документе: `Heading1..6`,
+`ListParagraph`, `Caption` или зарегистрированный через `StyleRegistry::paragraph()`.
+Абзац с неизвестным id Word показывает как Normal.
+
 ### TableBuilder
 
 ```php
@@ -367,6 +379,7 @@ RunStyleBuilder::new()
     ->backgroundColor('eeeeee')
     ->highlight('yellow')
     ->fontFamily('Arial')
+    ->fontFamilyEastAsia('SimSun')   // w:eastAsia: шрифт для текста CJK
     ->fontSizePt(14.5)
     ->build();
 ```
@@ -387,7 +400,7 @@ Length::px(100);    // 1500  (CSS px @ 96 DPI)
 
 Большинство builder'ов имеют unit-aware shortcut'ы:
 
-- TableBuilder: `widthPt/widthMm/widthCm/widthInches`, `cellMarginsMm/cellMarginsPt`
+- TableBuilder: `widthPt/widthMm/widthCm/widthInches`, `cellMarginsMm/cellMarginsPt`, `indent/indentMm/indentPt`
 - TableCellBuilder: `widthPt/Mm/Cm/Inches`, `paddingMm/Pt/Cm/Inches`
 - ColumnBuilder: `widthPt/Mm/Cm/Inches/Px`
 - ParagraphBuilder: `indentMm/Cm/Pt/Inches`, `spacingPt/Mm`
@@ -551,7 +564,7 @@ foreach ($variables as $v) {
 |---|---|---|
 | `Document` | root | `{ section: Section, watermarkText: ?string }` |
 | `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` |
-| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int }` |
+| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` |
 | `Run` | InlineElement | `{ text: string, style: RunStyle }` |
 | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` |
 | `Bookmark` | InlineElement | `{ name: string, children }` |
@@ -559,17 +572,17 @@ 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, cantSplit }` |
+| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit, heightRule }` |
 | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` |
 | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` |
 | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` |
 
 Стили — namespace `Dskripchenko\PhpDocx\Style`:
 
-- `RunStyle` — шрифт, weight, italic, color, size, highlight, …
-- `ParagraphStyle` — alignment, indents, spacing, borders
+- `RunStyle` — шрифт (и `fontFamilyEastAsia` для текста CJK), weight, italic, color, size, highlight, …
+- `ParagraphStyle` — alignment, indents, spacing, borders, разбиение на страницы (keepWithNext, keepLines, pageBreakBefore, widowControl)
 - `CellStyle` — width, padding, borders, valign, gridSpan, rowSpan
-- `TableStyle` — width, borders, alignment, cell margins, layout
+- `TableStyle` — width, borders, alignment, cell margins, layout, отступ (`indentTwips`)
 - `PageSetup`, `PaperSize`, `Orientation`, `Alignment`, `VerticalAlign`,
   `BorderStyle`, `Border`, `BorderSet`
 
diff --git a/docs/zh/README.md b/docs/zh/README.md
index c4644c3..4103c09 100644
--- a/docs/zh/README.md
+++ b/docs/zh/README.md
@@ -229,10 +229,12 @@ pipeline 相同的不可变 AST。
 
 ### DocumentBuilder
 
-入口点。积累 body、页眉/页脚、水印、页面设置。
+入口点。积累 body、页眉/页脚、水印、页面设置、文档属性
+(`docProps/core.xml`:标题、作者、日期)。
 
 ```php
 use Dskripchenko\PhpDocx\Build\DocumentBuilder;
+use Dskripchenko\PhpDocx\Style\CoreProperties;
 use Dskripchenko\PhpDocx\Style\PageSetup;
 use Dskripchenko\PhpDocx\Style\PaperSize;
 use Dskripchenko\PhpDocx\Style\Orientation;
@@ -243,6 +245,7 @@ $doc = DocumentBuilder::new()
         orientation: Orientation::Portrait,
     ))
     ->watermark('机密')
+    ->coreProperties(new CoreProperties(title: '报告', creator: 'Acme Inc.'))
     ->heading(1, '报告')
     ->paragraph('正文')
     ->build();           // → Document AST
@@ -251,6 +254,9 @@ $bytes = DocumentBuilder::new()->paragraph('你好')->toBytes();
 $count = DocumentBuilder::new()->paragraph('你好')->toFile('out.docx');
 ```
 
+输出逐字节可复现:每个 ZIP 条目的时间为 `CoreProperties::$modified`(UTC),
+未设置时为 1980-01-01 00:00:00。
+
 ### ParagraphBuilder
 
 在 `->paragraph(fn ($p) => …)` 内:
@@ -283,12 +289,17 @@ $count = DocumentBuilder::new()->paragraph('你好')->toFile('out.docx');
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // 或 alignRight() / alignJustify()
+    ->styleId('Quote')        // StyleRegistry 中的样式 id
+    ->pageBreakBefore()       // 从新页开始;widowControl(false) 关闭孤行控制
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('带缩进与间距')
 )
 ```
 
+`styleId()` 仅对文档中已定义的样式生效:`Heading1..6`、`ListParagraph`、`Caption`,
+或通过 `StyleRegistry::paragraph()` 注册的样式。id 未知的段落在 Word 中按 Normal 显示。
+
 ### TableBuilder
 
 ```php
@@ -362,6 +373,7 @@ RunStyleBuilder::new()
     ->backgroundColor('eeeeee')
     ->highlight('yellow')
     ->fontFamily('Arial')
+    ->fontFamilyEastAsia('SimSun')   // w:eastAsia:中日韩文字所用字体
     ->fontSizePt(14.5)
     ->build();
 ```
@@ -383,7 +395,7 @@ Length::px(100);    // 1500(CSS px @ 96 DPI)
 大多数构造器都暴露了单位感知快捷方法:
 
 - TableBuilder:`widthPt/widthMm/widthCm/widthInches`、
-  `cellMarginsMm/cellMarginsPt`
+  `cellMarginsMm/cellMarginsPt`、`indent/indentMm/indentPt`
 - TableCellBuilder:`widthPt/Mm/Cm/Inches`、`paddingMm/Pt/Cm/Inches`
 - ColumnBuilder:`widthPt/Mm/Cm/Inches/Px`
 - ParagraphBuilder:`indentMm/Cm/Pt/Inches`、`spacingPt/Mm`
@@ -542,7 +554,7 @@ foreach ($variables as $v) {
 |---|---|---|
 | `Document` | root | `{ section: Section, watermarkText: ?string }` |
 | `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` |
-| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int }` |
+| `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` |
 | `Run` | InlineElement | `{ text: string, style: RunStyle }` |
 | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` |
 | `Bookmark` | InlineElement | `{ name: string, children }` |
@@ -550,17 +562,17 @@ 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, cantSplit }` |
+| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, cantSplit, heightRule }` |
 | `TableCell` | element | `{ children: BlockElement[], style: CellStyle }` |
 | `ListNode` | BlockElement | `{ items: ListItem[], ordered, format, startAt }` |
 | `ListItem` | element | `{ children: InlineElement[], nestedList: ?ListNode }` |
 
 样式位于 `Dskripchenko\PhpDocx\Style`:
 
-- `RunStyle` — 字体、weight、italic、color、size、highlight 等
-- `ParagraphStyle` — alignment、缩进、间距、边框
+- `RunStyle` — 字体(中日韩文字用 `fontFamilyEastAsia`)、weight、italic、color、size、highlight 等
+- `ParagraphStyle` — alignment、缩进、间距、边框、分页(keepWithNext、keepLines、pageBreakBefore、widowControl)
 - `CellStyle` — width、padding、边框、valign、gridSpan、rowSpan
-- `TableStyle` — width、边框、对齐、单元格边距、布局
+- `TableStyle` — width、边框、对齐、单元格边距、布局、缩进(`indentTwips`)
 - `PageSetup`、`PaperSize`、`Orientation`、`Alignment`、`VerticalAlign`、
   `BorderStyle`、`Border`、`BorderSet`
 
diff --git a/src/Build/DocumentBuilder.php b/src/Build/DocumentBuilder.php
index 9d02d62..c61c402 100644
--- a/src/Build/DocumentBuilder.php
+++ b/src/Build/DocumentBuilder.php
@@ -6,6 +6,7 @@
 
 use Dskripchenko\PhpDocx\Document;
 use Dskripchenko\PhpDocx\Section;
+use Dskripchenko\PhpDocx\Style\CoreProperties;
 use Dskripchenko\PhpDocx\Style\PageSetup;
 use Dskripchenko\PhpDocx\Style\StyleRegistry;
 use Dskripchenko\PhpDocx\Writer\Word2007Writer;
@@ -45,6 +46,8 @@ final class DocumentBuilder
 
     private ?StyleRegistry $styles = null;
 
+    private ?CoreProperties $coreProperties = null;
+
     /** @var list<\Dskripchenko\PhpDocx\Element\BlockElement> */
     private array $headerBlocks = [];
 
@@ -97,6 +100,18 @@ public function styles(StyleRegistry $registry): self
         return $this;
     }
 
+    /**
+     * Title, author, dates and the rest of `docProps/core.xml` — what Word
+     * shows under File → Info. `CoreProperties::$modified` also becomes the
+     * time of every entry in the package.
+     */
+    public function coreProperties(CoreProperties $properties): self
+    {
+        $this->coreProperties = $properties;
+
+        return $this;
+    }
+
     /**
      * The header section (repeated on every page).
      *
@@ -198,6 +213,7 @@ public function build(): Document
                 evenFooter: $this->evenFooterBlocks,
             ),
             watermarkText: $this->watermarkText,
+            coreProperties: $this->coreProperties,
         );
     }
 
diff --git a/src/Build/ParagraphBuilder.php b/src/Build/ParagraphBuilder.php
index 8c1fc33..1e2c341 100644
--- a/src/Build/ParagraphBuilder.php
+++ b/src/Build/ParagraphBuilder.php
@@ -28,6 +28,8 @@ final class ParagraphBuilder
 
     private ?int $headingLevel = null;
 
+    private ?string $styleId = null;
+
     public function __construct(
         ?ParagraphStyle $style = null,
         ?RunStyle $defaultRunStyle = null,
@@ -52,6 +54,17 @@ public function headingLevel(?int $level): self
         return $this;
     }
 
+    /**
+     * A paragraph style id written as `` (`Normal`, `Title`, a custom
+     * id registered in the StyleRegistry). It wins over the heading level.
+     */
+    public function styleId(?string $styleId): self
+    {
+        $this->styleId = $styleId;
+
+        return $this;
+    }
+
     // ─────────── Paragraph-style shortcuts ────────────────────────────────
 
     public function align(Alignment $alignment): self
@@ -152,6 +165,22 @@ public function spacingMm(?float $before = null, ?float $after = null, ?float $l
         );
     }
 
+    /** The paragraph starts on a new page (`w:pageBreakBefore`). */
+    public function pageBreakBefore(bool $value = true): self
+    {
+        $this->style = $this->style->copy(pageBreakBefore: $value);
+
+        return $this;
+    }
+
+    /** Widow/orphan control (`w:widowControl`); `false` turns it off explicitly. */
+    public function widowControl(bool $value = true): self
+    {
+        $this->style = $this->style->copy(widowControl: $value);
+
+        return $this;
+    }
+
     public function borders(BorderSet $borders): self
     {
         $this->style = $this->style->copy(borders: $borders);
@@ -173,6 +202,7 @@ public function build(): Paragraph
             children: $this->children,
             style: $this->style,
             headingLevel: $this->headingLevel,
+            styleId: $this->styleId,
         );
     }
 }
diff --git a/src/Build/RunStyleBuilder.php b/src/Build/RunStyleBuilder.php
index 00e3165..f6951b9 100644
--- a/src/Build/RunStyleBuilder.php
+++ b/src/Build/RunStyleBuilder.php
@@ -27,6 +27,8 @@ final class RunStyleBuilder
 
     private ?string $fontFamily = null;
 
+    private ?string $fontFamilyEastAsia = null;
+
     private bool $bold = false;
 
     private bool $italic = false;
@@ -56,6 +58,7 @@ public static function from(RunStyle $base): self
         $b->color = $base->color;
         $b->backgroundColor = $base->backgroundColor;
         $b->fontFamily = $base->fontFamily;
+        $b->fontFamilyEastAsia = $base->fontFamilyEastAsia;
         $b->bold = $base->bold;
         $b->italic = $base->italic;
         $b->underline = $base->underline;
@@ -135,6 +138,14 @@ public function fontFamily(string $name): self
         return $this;
     }
 
+    /** The font for East Asian text (`w:eastAsia` on ``). */
+    public function fontFamilyEastAsia(string $name): self
+    {
+        $this->fontFamilyEastAsia = $name;
+
+        return $this;
+    }
+
     /**
      * The size in points (pt). Converted into the half-points of OOXML.
      */
@@ -171,6 +182,7 @@ public function build(): RunStyle
             superscript: $this->superscript,
             subscript: $this->subscript,
             highlight: $this->highlight,
+            fontFamilyEastAsia: $this->fontFamilyEastAsia,
         );
     }
 }
diff --git a/src/Build/TableBuilder.php b/src/Build/TableBuilder.php
index 785c7ca..462d801 100644
--- a/src/Build/TableBuilder.php
+++ b/src/Build/TableBuilder.php
@@ -57,6 +57,8 @@ final class TableBuilder
 
     private int $cellMarginLeftTwips = 80;
 
+    private ?int $indentTwips = null;
+
     /**
      * A header row — the short form with a list of strings, or the long one
      * with a RowBuilder.
@@ -199,6 +201,26 @@ public function alignCenter(): self
         return $this->alignment(Alignment::Center);
     }
 
+    /**
+     * Indent of the table from the leading margin (``), in twips.
+     */
+    public function indent(int $twips): self
+    {
+        $this->indentTwips = $twips;
+
+        return $this;
+    }
+
+    public function indentMm(float $mm): self
+    {
+        return $this->indent(Length::mm($mm));
+    }
+
+    public function indentPt(float $pt): self
+    {
+        return $this->indent(Length::pt($pt));
+    }
+
     public function borders(BorderSet $borders): self
     {
         $this->borders = $borders;
@@ -250,6 +272,7 @@ public function build(): Table
                 cellMarginRightTwips: $this->cellMarginRightTwips,
                 cellMarginBottomTwips: $this->cellMarginBottomTwips,
                 cellMarginLeftTwips: $this->cellMarginLeftTwips,
+                indentTwips: $this->indentTwips,
             ),
             caption: $this->caption,
             gridColumnsTwips: $this->gridColumnsTwips,
diff --git a/src/Build/TableRowBuilder.php b/src/Build/TableRowBuilder.php
index e72852f..a698807 100644
--- a/src/Build/TableRowBuilder.php
+++ b/src/Build/TableRowBuilder.php
@@ -6,6 +6,7 @@
 
 use Dskripchenko\PhpDocx\Element\TableCell;
 use Dskripchenko\PhpDocx\Element\TableRow;
+use Dskripchenko\PhpDocx\Style\RowHeightRule;
 
 /**
  * A fluent builder for a table row.
@@ -32,6 +33,8 @@ final class TableRowBuilder
 
     private bool $cantSplit = false;
 
+    private RowHeightRule $heightRule = RowHeightRule::AtLeast;
+
     public function cell(string|callable $textOrBuilder): self
     {
         $cellBuilder = new TableCellBuilder;
@@ -76,9 +79,25 @@ public function header(bool $value = true): self
         return $this;
     }
 
+    /**
+     * A minimum row height in twips: the row grows with its content.
+     */
     public function height(int $twips): self
     {
         $this->heightTwips = $twips;
+        $this->heightRule = RowHeightRule::AtLeast;
+
+        return $this;
+    }
+
+    /**
+     * An exact row height in twips (`w:hRule="exact"`): content that does not
+     * fit is clipped.
+     */
+    public function exactHeight(int $twips): self
+    {
+        $this->heightTwips = $twips;
+        $this->heightRule = RowHeightRule::Exact;
 
         return $this;
     }
@@ -92,6 +111,6 @@ public function cantSplit(bool $value = true): self
 
     public function build(): TableRow
     {
-        return new TableRow($this->cells, $this->isHeader, $this->heightTwips, $this->cantSplit);
+        return new TableRow($this->cells, $this->isHeader, $this->heightTwips, $this->cantSplit, $this->heightRule);
     }
 }
diff --git a/src/Element/Paragraph.php b/src/Element/Paragraph.php
index 92d669b..b1f8456 100644
--- a/src/Element/Paragraph.php
+++ b/src/Element/Paragraph.php
@@ -18,10 +18,16 @@
     /**
      * @param  list  $children
      * @param  int|null  $headingLevel  1..6 when this is a heading, null otherwise
+     * @param  string|null  $styleId  A paragraph style id written as ``
+     *                                (`Normal`, `Title`, `Quote`, a custom id). It wins
+     *                                over `$headingLevel`. The style must exist in the
+     *                                document's styles (see `StyleRegistry::paragraph()`);
+     *                                Word falls back to Normal for an unknown id.
      */
     public function __construct(
         public array $children,
         public ParagraphStyle $style = new ParagraphStyle,
         public ?int $headingLevel = null,
+        public ?string $styleId = null,
     ) {}
 }
diff --git a/src/Element/TableRow.php b/src/Element/TableRow.php
index 00190ca..e3ef205 100644
--- a/src/Element/TableRow.php
+++ b/src/Element/TableRow.php
@@ -4,6 +4,8 @@
 
 namespace Dskripchenko\PhpDocx\Element;
 
+use Dskripchenko\PhpDocx\Style\RowHeightRule;
+
 /**
  * A table row — ``.
  */
@@ -13,11 +15,13 @@
      * @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).
+     * @param  RowHeightRule  $heightRule  How `$heightTwips` applies (`w:hRule`): a minimum (default) or an exact height.
      */
     public function __construct(
         public array $cells,
         public bool $isHeader = false,
         public ?int $heightTwips = null,
         public bool $cantSplit = false,
+        public RowHeightRule $heightRule = RowHeightRule::AtLeast,
     ) {}
 }
diff --git a/src/Html/Converter.php b/src/Html/Converter.php
index 7c3c6d2..60de928 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, $row->cantSplit);
+            $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit, $row->heightRule);
         }
 
         return $newRows;
@@ -774,6 +774,7 @@ private function balanceFirstRowWidths(TableRow $row): TableRow
             isHeader: $row->isHeader,
             heightTwips: $row->heightTwips,
             cantSplit: $row->cantSplit,
+            heightRule: $row->heightRule,
         );
     }
 
diff --git a/src/Html/Serializer.php b/src/Html/Serializer.php
index 5b377d4..4baf81b 100644
--- a/src/Html/Serializer.php
+++ b/src/Html/Serializer.php
@@ -586,6 +586,9 @@ private function paragraphStyleCss(ParagraphStyle $s): string
             // The standard CSS property: "do not break inside this block".
             $parts[] = 'break-inside:avoid';
         }
+        if ($s->pageBreakBefore) {
+            $parts[] = 'break-before:page';
+        }
         if ($s->borders !== null) {
             foreach ($this->borderSetCss($s->borders) as $cssLine) {
                 $parts[] = $cssLine;
@@ -606,6 +609,8 @@ private function tableStyleCss(TableStyle $s): string
         if ($s->alignment === Alignment::Center) {
             $parts[] = 'margin-left:auto';
             $parts[] = 'margin-right:auto';
+        } elseif ($s->indentTwips !== null && $s->indentTwips !== 0) {
+            $parts[] = 'margin-left:'.$this->twipsToPt($s->indentTwips).'pt';
         }
         if ($s->borders !== null) {
             foreach ($this->borderSetCss($s->borders) as $cssLine) {
diff --git a/src/Html/StyleApplier/ParagraphStyleApplier.php b/src/Html/StyleApplier/ParagraphStyleApplier.php
index 311b5ad..b7a7f7b 100644
--- a/src/Html/StyleApplier/ParagraphStyleApplier.php
+++ b/src/Html/StyleApplier/ParagraphStyleApplier.php
@@ -19,6 +19,7 @@
  *  - margin-left/right → indentLeft/Right
  *  - line-height (a multiplier or an absolute value)
  *  - page-break-after: always
+ *  - page-break-before: always / break-before: page
  *  - background-color / background → the paragraph fill (``)
  */
 final class ParagraphStyleApplier
@@ -36,6 +37,7 @@ public static function apply(ParagraphStyle $base, array $properties): Paragraph
         $indentFirst = $base->indentFirstLineTwips;
         $lineSpacing = $base->lineSpacingTwips;
         $pageBreakAfter = $base->pageBreakAfter;
+        $pageBreakBefore = $base->pageBreakBefore;
         $shading = $base->shadingColor;
 
         foreach ($properties as $prop => $value) {
@@ -118,6 +120,12 @@ public static function apply(ParagraphStyle $base, array $properties): Paragraph
                 case 'break-after':
                     $pageBreakAfter = in_array(strtolower(trim($value)), ['always', 'page'], true);
 
+                    break;
+
+                case 'page-break-before':
+                case 'break-before':
+                    $pageBreakBefore = in_array(strtolower(trim($value)), ['always', 'page'], true);
+
                     break;
             }
         }
@@ -133,6 +141,8 @@ public static function apply(ParagraphStyle $base, array $properties): Paragraph
             pageBreakAfter: $pageBreakAfter,
             borders: $base->borders,
             shadingColor: $shading,
+            pageBreakBefore: $pageBreakBefore,
+            widowControl: $base->widowControl,
         );
     }
 
diff --git a/src/Html/StyleApplier/RunStyleApplier.php b/src/Html/StyleApplier/RunStyleApplier.php
index f1a41ef..dc2e08e 100644
--- a/src/Html/StyleApplier/RunStyleApplier.php
+++ b/src/Html/StyleApplier/RunStyleApplier.php
@@ -142,6 +142,8 @@ public static function apply(RunStyle $base, array $properties): RunStyle
             // highlighting.
             highlight: $base->highlight,
             letterSpacingTwips: $letterSpacing,
+            // CSS has no East Asian font slot: keep the base's.
+            fontFamilyEastAsia: $base->fontFamilyEastAsia,
         );
     }
 
diff --git a/src/Html/StyleApplier/TableStyleApplier.php b/src/Html/StyleApplier/TableStyleApplier.php
index 6f0487a..a3dcfae 100644
--- a/src/Html/StyleApplier/TableStyleApplier.php
+++ b/src/Html/StyleApplier/TableStyleApplier.php
@@ -11,7 +11,8 @@
 use Dskripchenko\PhpDocx\Style\TableStyle;
 
 /**
- * Applies CSS to a TableStyle. It parses width/border-collapse/table-layout.
+ * Applies CSS to a TableStyle. It parses width/border-collapse/table-layout
+ * and margin-left (the table indent).
  */
 final class TableStyleApplier
 {
@@ -26,6 +27,7 @@ public static function apply(TableStyle $base, array $properties, array $attrs =
         $fixed = $base->fixedLayout;
         $borders = $base->borders;
         $align = $base->alignment;
+        $indent = $base->indentTwips;
 
         if (isset($attrs['width'])) {
             self::applyWidth($attrs['width'], $widthTwips, $widthPercent);
@@ -50,6 +52,14 @@ public static function apply(TableStyle $base, array $properties, array $attrs =
                 case 'border':
                     $borders = BorderSet::all(BorderParser::parse($value));
 
+                    break;
+                case 'margin-left':
+                    // `auto` centres the table and is not an indent.
+                    $t = LengthParser::parseTwips($value);
+                    if ($t !== null) {
+                        $indent = $t;
+                    }
+
                     break;
             }
         }
@@ -64,6 +74,7 @@ public static function apply(TableStyle $base, array $properties, array $attrs =
             cellMarginRightTwips: $base->cellMarginRightTwips,
             cellMarginBottomTwips: $base->cellMarginBottomTwips,
             cellMarginLeftTwips: $base->cellMarginLeftTwips,
+            indentTwips: $indent,
         );
     }
 
diff --git a/src/Reader/OoxmlPropertyParser.php b/src/Reader/OoxmlPropertyParser.php
index 6287383..935bb47 100644
--- a/src/Reader/OoxmlPropertyParser.php
+++ b/src/Reader/OoxmlPropertyParser.php
@@ -27,7 +27,7 @@ public function __construct(
     /**
      * Parses `` (or null) → a partial run-style array.
      *
-     * Keys (when present): sizeHalfPoints, color, fontFamily,
+     * Keys (when present): sizeHalfPoints, color, fontFamily, fontFamilyEastAsia,
      * bold, italic, underline, strikethrough, superscript, subscript,
      * highlight, backgroundColor, letterSpacingTwips.
      *
@@ -60,7 +60,7 @@ public function parseRunProperties(?\DOMElement $rPr): array
                 'vertAlign' => $this->parseVertAlign($node, $out),
                 'color' => $out['color'] = $this->parseColor($node),
                 'sz' => $out['sizeHalfPoints'] = $this->parseInt($node),
-                'rFonts' => $out['fontFamily'] = $this->parseFontFamily($node),
+                'rFonts' => $this->parseFonts($node, $out),
                 'highlight' => $out['highlight'] = OoxmlNs::wVal($node),
                 'shd' => $out['backgroundColor'] = $this->parseShdFill($node),
                 // A namesake of pPr/spacing (line spacing), but here it is
@@ -78,7 +78,8 @@ public function parseRunProperties(?\DOMElement $rPr): array
      *
      * Keys: alignment, spaceBeforeTwips, spaceAfterTwips,
      * indentLeftTwips, indentRightTwips, indentFirstLineTwips,
-     * lineSpacingTwips, pageBreakAfter, keepWithNext, keepLines, borders, shadingColor,
+     * lineSpacingTwips, pageBreakAfter, pageBreakBefore, keepWithNext, keepLines,
+     * widowControl, borders, shadingColor,
      * pStyleId, numId, ilvl.
      *
      * @return array
@@ -102,6 +103,7 @@ public function parseParagraphProperties(?\DOMElement $pPr): array
                 'pageBreakBefore' => $out['pageBreakBefore'] = OoxmlNs::boolToggle($node),
                 'keepNext' => $out['keepWithNext'] = OoxmlNs::boolToggle($node),
                 'keepLines' => $out['keepLines'] = OoxmlNs::boolToggle($node),
+                'widowControl' => $out['widowControl'] = OoxmlNs::boolToggle($node),
                 'pBdr' => $out['borders'] = $this->parseBorders($node),
                 'shd' => $out['shadingColor'] = $this->parseShdFill($node),
                 'numPr' => $this->parseNumPr($node, $out),
@@ -169,6 +171,29 @@ private function parseInt(\DOMElement $node): ?int
         return (int) $val;
     }
 
+    /**
+     * @param  array  $out
+     */
+    private function parseFonts(\DOMElement $rFonts, array &$out): void
+    {
+        $out['fontFamily'] = $this->parseFontFamily($rFonts);
+        $eastAsia = $this->parseEastAsiaFont($rFonts);
+        if ($eastAsia !== null) {
+            $out['fontFamilyEastAsia'] = $eastAsia;
+        }
+    }
+
+    /**
+     * Only an explicit `w:eastAsia`: the theme map holds the Latin faces, so an
+     * `eastAsiaTheme` reference is left to Word.
+     */
+    private function parseEastAsiaFont(\DOMElement $rFonts): ?string
+    {
+        $val = $rFonts->getAttributeNS(OoxmlNs::W, 'eastAsia');
+
+        return $val !== '' ? $val : null;
+    }
+
     private function parseFontFamily(\DOMElement $rFonts): ?string
     {
         // Order priority: ascii > hAnsi > cs > eastAsia.
diff --git a/src/Reader/StylesResolver.php b/src/Reader/StylesResolver.php
index 72207f3..e7b88fe 100644
--- a/src/Reader/StylesResolver.php
+++ b/src/Reader/StylesResolver.php
@@ -295,6 +295,7 @@ private function arrayToRunStyle(array $arr): RunStyle
             letterSpacingTwips: isset($arr['letterSpacingTwips']) ? (int) $arr['letterSpacingTwips'] : null,
             allCaps: (bool) ($arr['allCaps'] ?? false),
             smallCaps: (bool) ($arr['smallCaps'] ?? false),
+            fontFamilyEastAsia: $arr['fontFamilyEastAsia'] ?? null,
         );
     }
 
@@ -319,6 +320,8 @@ private function arrayToParagraphStyle(array $arr): ParagraphStyle
             borders: $borders instanceof BorderSet ? $borders : null,
             shadingColor: $arr['shadingColor'] ?? null,
             keepLines: (bool) ($arr['keepLines'] ?? false),
+            pageBreakBefore: (bool) ($arr['pageBreakBefore'] ?? false),
+            widowControl: isset($arr['widowControl']) ? (bool) $arr['widowControl'] : null,
         );
     }
 
@@ -340,6 +343,9 @@ private function runStyleToArray(RunStyle $style): array
         if ($style->fontFamily !== null) {
             $out['fontFamily'] = $style->fontFamily;
         }
+        if ($style->fontFamilyEastAsia !== null) {
+            $out['fontFamilyEastAsia'] = $style->fontFamilyEastAsia;
+        }
         if ($style->highlight !== null) {
             $out['highlight'] = $style->highlight;
         }
diff --git a/src/Reader/TableReader.php b/src/Reader/TableReader.php
index edcff09..4546f84 100644
--- a/src/Reader/TableReader.php
+++ b/src/Reader/TableReader.php
@@ -12,6 +12,7 @@
 use Dskripchenko\PhpDocx\Style\Alignment;
 use Dskripchenko\PhpDocx\Style\BorderSet;
 use Dskripchenko\PhpDocx\Style\CellStyle;
+use Dskripchenko\PhpDocx\Style\RowHeightRule;
 use Dskripchenko\PhpDocx\Style\TableStyle;
 use Dskripchenko\PhpDocx\Style\VerticalAlign;
 
@@ -106,6 +107,7 @@ private function readTableStyle(?\DOMElement $tblPr): TableStyle
         $cellMarBottom = 0;
         $cellMarLeft = 80;
         $cellMarRight = 80;
+        $indentTwips = null;
 
         foreach ($tblPr->childNodes as $node) {
             if (! $node instanceof \DOMElement || $node->namespaceURI !== OoxmlNs::W) {
@@ -122,6 +124,14 @@ private function readTableStyle(?\DOMElement $tblPr): TableStyle
                         default => Alignment::Start,
                     };
                     break;
+                case 'tblInd':
+                    // Only dxa carries a length; `nil` and `auto` mean no indent.
+                    $type = $node->getAttributeNS(OoxmlNs::W, 'type');
+                    $w = $node->getAttributeNS(OoxmlNs::W, 'w');
+                    if (($type === '' || $type === 'dxa') && preg_match('/^-?\d+$/', $w) === 1) {
+                        $indentTwips = (int) $w;
+                    }
+                    break;
                 case 'tblLayout':
                     $fixedLayout = $node->getAttributeNS(OoxmlNs::W, 'type') === 'fixed';
                     break;
@@ -145,6 +155,7 @@ private function readTableStyle(?\DOMElement $tblPr): TableStyle
             cellMarginRightTwips: $cellMarRight,
             cellMarginBottomTwips: $cellMarBottom,
             cellMarginLeftTwips: $cellMarLeft,
+            indentTwips: $indentTwips,
         );
     }
 
@@ -154,6 +165,7 @@ private function readRow(\DOMElement $tr): TableRow
         $isHeader = false;
         $heightTwips = null;
         $cantSplit = false;
+        $heightRule = RowHeightRule::AtLeast;
         if ($trPr !== null) {
             $cantSplit = OoxmlNs::firstChild($trPr, OoxmlNs::W, 'cantSplit') !== null;
             $isHeader = OoxmlNs::firstChild($trPr, OoxmlNs::W, 'tblHeader') !== null;
@@ -163,6 +175,11 @@ private function readRow(\DOMElement $tr): TableRow
                 if ($v !== null && ctype_digit($v)) {
                     $heightTwips = (int) $v;
                 }
+                // `atLeast` and `auto` (and a missing hRule) read as a minimum,
+                // as before; only `exact` fixes the height.
+                if ($h->getAttributeNS(OoxmlNs::W, 'hRule') === 'exact') {
+                    $heightRule = RowHeightRule::Exact;
+                }
             }
         }
 
@@ -171,7 +188,7 @@ private function readRow(\DOMElement $tr): TableRow
             $cells[] = $this->readCell($tcEl);
         }
 
-        return new TableRow($cells, $isHeader, $heightTwips, $cantSplit);
+        return new TableRow($cells, $isHeader, $heightTwips, $cantSplit, $heightRule);
     }
 
     private function readCell(\DOMElement $tc): TableCell
@@ -333,7 +350,7 @@ private function reconstructRowSpans(array $rows): array
                     $restartRowSpan[$rowIdx][$cellIdx] ?? ($cell->style->vMergeContinue ? 1 : 1),
                 );
             }
-            $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit);
+            $newRows[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit, $row->heightRule);
         }
 
         return $newRows;
@@ -382,7 +399,7 @@ private function resetTemporaryRowSpans(array $rows): array
                 }
                 $newCells[] = $cell;
             }
-            $out[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit);
+            $out[] = new TableRow($newCells, $row->isHeader, $row->heightTwips, $row->cantSplit, $row->heightRule);
         }
 
         return $out;
diff --git a/src/Style/ParagraphStyle.php b/src/Style/ParagraphStyle.php
index c1f8b2e..1a3ad99 100644
--- a/src/Style/ParagraphStyle.php
+++ b/src/Style/ParagraphStyle.php
@@ -44,6 +44,20 @@ public function __construct(
          * in what is left of the page, the whole paragraph moves to the next.
          */
         public bool $keepLines = false,
+        /**
+         * `w:pageBreakBefore` — the paragraph starts on a new page.
+         *
+         * Unlike a `PageBreak` block, it adds no empty paragraph: the break
+         * belongs to this paragraph and moves with it.
+         */
+        public bool $pageBreakBefore = false,
+        /**
+         * `w:widowControl` — Word keeps at least two lines of the paragraph at
+         * the top and at the bottom of a page. `null` writes nothing (the
+         * style or document default applies), `true` turns it on, `false`
+         * writes `w:val="0"` to turn it off.
+         */
+        public ?bool $widowControl = null,
     ) {}
 
     public function isEmpty(): bool
@@ -59,7 +73,9 @@ public function isEmpty(): bool
             && ! $this->keepWithNext
             && $this->borders === null
             && $this->shadingColor === null
-            && ! $this->keepLines;
+            && ! $this->keepLines
+            && ! $this->pageBreakBefore
+            && $this->widowControl === null;
     }
 
     /**
@@ -80,6 +96,8 @@ public function copy(
         ?BorderSet $borders = null,
         ?string $shadingColor = null,
         ?bool $keepLines = null,
+        ?bool $pageBreakBefore = null,
+        ?bool $widowControl = null,
     ): self {
         return new self(
             alignment: $alignment ?? $this->alignment,
@@ -95,6 +113,8 @@ public function copy(
             borders: $borders ?? $this->borders,
             shadingColor: $shadingColor ?? $this->shadingColor,
             keepLines: $keepLines ?? $this->keepLines,
+            pageBreakBefore: $pageBreakBefore ?? $this->pageBreakBefore,
+            widowControl: $widowControl ?? $this->widowControl,
         );
     }
 }
diff --git a/src/Style/RowHeightRule.php b/src/Style/RowHeightRule.php
new file mode 100644
index 0000000..49b01ad
--- /dev/null
+++ b/src/Style/RowHeightRule.php
@@ -0,0 +1,17 @@
+`).
+ */
+enum RowHeightRule: string
+{
+    /** The row is at least this high and grows with its content. */
+    case AtLeast = 'atLeast';
+
+    /** The row is exactly this high; content that does not fit is clipped. */
+    case Exact = 'exact';
+}
diff --git a/src/Style/RunStyle.php b/src/Style/RunStyle.php
index 88161a6..373a3e7 100644
--- a/src/Style/RunStyle.php
+++ b/src/Style/RunStyle.php
@@ -36,6 +36,11 @@ public function __construct(
         public bool $allCaps = false,
         /** `` — small caps: lower case is drawn as reduced capitals. */
         public bool $smallCaps = false,
+        /**
+         * The font for East Asian text (`w:eastAsia` on ``): Word
+         * draws CJK characters with it, and the rest with `fontFamily`.
+         */
+        public ?string $fontFamilyEastAsia = null,
     ) {}
 
     public static function fromPt(
@@ -99,6 +104,11 @@ public function withFontFamily(string $family): self
         return $this->copy(fontFamily: $family);
     }
 
+    public function withFontFamilyEastAsia(string $family): self
+    {
+        return $this->copy(fontFamilyEastAsia: $family);
+    }
+
     public function withHighlight(string $highlight): self
     {
         return $this->copy(highlight: $highlight);
@@ -129,6 +139,7 @@ private function copy(
         ?int $letterSpacingTwips = null,
         ?bool $allCaps = null,
         ?bool $smallCaps = null,
+        ?string $fontFamilyEastAsia = null,
     ): self {
         return new self(
             sizeHalfPoints: $sizeHalfPoints ?? $this->sizeHalfPoints,
@@ -145,6 +156,7 @@ private function copy(
             letterSpacingTwips: $letterSpacingTwips ?? $this->letterSpacingTwips,
             allCaps: $allCaps ?? $this->allCaps,
             smallCaps: $smallCaps ?? $this->smallCaps,
+            fontFamilyEastAsia: $fontFamilyEastAsia ?? $this->fontFamilyEastAsia,
         );
     }
 
@@ -154,6 +166,7 @@ public function isEmpty(): bool
             && $this->color === null
             && $this->backgroundColor === null
             && $this->fontFamily === null
+            && $this->fontFamilyEastAsia === null
             && $this->highlight === null
             && $this->letterSpacingTwips === null
             && ! $this->bold && ! $this->italic && ! $this->underline
diff --git a/src/Style/TableStyle.php b/src/Style/TableStyle.php
index 5d29888..93986d1 100644
--- a/src/Style/TableStyle.php
+++ b/src/Style/TableStyle.php
@@ -13,6 +13,9 @@
      * @param  bool  $fixedLayout  true → ``: the
      *                             cell widths are applied literally, without
      *                             Word's auto-fitting.
+     * @param  int|null  $indentTwips  Indent of the table from the leading
+     *                                 margin in twips (``); negative
+     *                                 values pull it into the margin.
      */
     public function __construct(
         public ?int $widthTwips = null,
@@ -24,5 +27,6 @@ public function __construct(
         public int $cellMarginRightTwips = 80,
         public int $cellMarginBottomTwips = 0,
         public int $cellMarginLeftTwips = 80,
+        public ?int $indentTwips = null,
     ) {}
 }
diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php
index da0799f..d60eea4 100644
--- a/src/Writer/BodyXmlBuilder.php
+++ b/src/Writer/BodyXmlBuilder.php
@@ -212,7 +212,7 @@ private function renderListItem(ListItem $item, int $numId, int $level): string
 
     private function renderParagraph(Paragraph $p): string
     {
-        $pPr = $this->renderParagraphProperties($p->style, $p->headingLevel);
+        $pPr = $this->renderParagraphProperties($p->style, $p->headingLevel, styleId: $p->styleId);
         $children = '';
         foreach ($p->children as $child) {
             $children .= $this->renderInline($child);
@@ -224,12 +224,20 @@ private function renderParagraph(Paragraph $p): string
     /**
      * @param  string  $numPr  A ready `` of a list item: it is placed at its
      *                         position in the sequence (and `ListParagraph` is the style).
+     * @param  string|null  $styleId  An explicit paragraph style id; it wins over the
+     *                                heading level and the list style.
      */
-    private function renderParagraphProperties(ParagraphStyle $style, ?int $headingLevel, string $numPr = ''): string
-    {
+    private function renderParagraphProperties(
+        ParagraphStyle $style,
+        ?int $headingLevel,
+        string $numPr = '',
+        ?string $styleId = null,
+    ): string {
         $pPr = '';
 
-        if ($headingLevel !== null && $headingLevel >= 1 && $headingLevel <= 6) {
+        if ($styleId !== null && $styleId !== '') {
+            $pPr .= '';
+        } elseif ($headingLevel !== null && $headingLevel >= 1 && $headingLevel <= 6) {
             $pPr .= '';
         } elseif ($numPr !== '') {
             $pPr .= '';
@@ -240,6 +248,13 @@ private function renderParagraphProperties(ParagraphStyle $style, ?int $headingL
         if ($style->keepLines) {
             $pPr .= '';
         }
+        if ($style->pageBreakBefore) {
+            $pPr .= '';
+        }
+        // framePr would sit here; widowControl follows it.
+        if ($style->widowControl !== null) {
+            $pPr .= $style->widowControl ? '' : '';
+        }
         // 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.
@@ -350,11 +365,42 @@ private function renderField(Field $f): string
     private function renderRun(Run $r): string
     {
         $rPr = $this->renderRunProperties($r->style);
-        $text = XmlEscape::text($r->text);
-        // xml:space="preserve" keeps the leading and trailing spaces.
-        $textNode = ''.$text.'';
 
-        return ''.$rPr.$textNode.'';
+        return ''.$rPr.self::renderRunContent($r->text).'';
+    }
+
+    /**
+     * The content of a `` for a piece of text. A tab inside `` is
+     * not a tab to Word, and a newline is not a line break: both are shown as
+     * a space at best. Each "\t" becomes a `` and each "\n", "\r\n"
+     * or "\r" a ``, between `` segments; empty segments are left
+     * out. The reader turns `` back into "\t".
+     *
+     * "\v" and "\f" are not XML characters, but text taken from Word (its
+     * object model, the clipboard) uses them for a manual line break and a
+     * page break, so they become `` and ``
+     * instead of being dropped by XmlEscape and gluing the words together.
+     */
+    private static function renderRunContent(string $text): string
+    {
+        $parts = preg_split('/(\t|\r\n|\r|\n|\x0B|\x0C)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        if ($parts === false || $parts === []) {
+            // An empty run keeps its (empty) , as before.
+            return ''.XmlEscape::text($text).'';
+        }
+
+        $xml = '';
+        foreach ($parts as $part) {
+            $xml .= match ($part) {
+                "\t" => '',
+                "\r\n", "\r", "\n", "\x0B" => '',
+                "\x0C" => '',
+                // xml:space="preserve" keeps the leading and trailing spaces.
+                default => ''.XmlEscape::text($part).'',
+            };
+        }
+
+        return $xml;
     }
 
     private function renderRunProperties(RunStyle $s): string
@@ -363,10 +409,7 @@ private function renderRunProperties(RunStyle $s): string
             return '';
         }
         $rPr = '';
-        if ($s->fontFamily !== null) {
-            $f = XmlEscape::attr($s->fontFamily);
-            $rPr .= '';
-        }
+        $rPr .= RunFonts::render($s);
         if ($s->bold) {
             $rPr .= '';
         }
@@ -481,19 +524,17 @@ private function renderTable(Table $t): string
      */
     private function renderCaption(string $text): string
     {
-        $escaped = XmlEscape::text($text);
-
         return ''
             .''
             .''
-            .''.$escaped.''
+            .self::renderRunContent($text).''
             .'';
     }
 
     private function renderTableProperties(TableStyle $s): string
     {
         // The order of the elements in a  per the OOXML CT_TblPr
-        // schema: tblW → jc → tblBorders → tblLayout → tblCellMar. Word and
+        // schema: tblW → jc → tblInd → tblBorders → tblLayout → tblCellMar. Word and
         // Pages ignore the properties when the order is broken.
         $tblPr = '';
 
@@ -511,6 +552,11 @@ private function renderTableProperties(TableStyle $s): string
             $tblPr .= '';
         }
 
+        // indent
+        if ($s->indentTwips !== null) {
+            $tblPr .= '';
+        }
+
         // borders
         if ($s->borders !== null) {
             $tblPr .= $this->renderBorderSet($s->borders, 'w:tblBorders');
@@ -536,9 +582,13 @@ private function renderTableProperties(TableStyle $s): string
     }
 
     /**
-     * Computes the gridCols from the cells of the first row. When the cells
-     * have no widthTwips we distribute them equally (5000 twips per column by
-     * default).
+     * Computes the gridCols when the table has no explicit gridColumnsTwips.
+     *
+     * The number of columns is the first row's total gridSpan. Each column
+     * takes its width from the first row in which an unspanned cell with a
+     * known width covers it; failing that, from the first spanning cell with
+     * a known width that covers it, divided by its span (so the columns of a
+     * spanning cell add up to its width); failing that, 2000 twips.
      *
      * @return list
      */
@@ -547,29 +597,61 @@ private function computeGridColumns(Table $t): array
         if ($t->rows === []) {
             return [];
         }
-        // We take the first row and, for every cell, its widthTwips or 0.
-        $firstRow = $t->rows[0];
-        $cols = [];
-        foreach ($firstRow->cells as $cell) {
-            $w = $cell->style->widthTwips;
-            if ($w === null && $cell->style->widthPercent !== null) {
-                // A pct is invalid in the grid — a gridCol requires dxa. The
-                // approximation: a percentage of 9000 (roughly an A4 page minus
-                // the margins).
-                $pct = $cell->style->widthPercent / 50;
-                $w = (int) round($pct / 100 * 9000);
-            }
-            $cols[] = $w ?? 2000;
-            // Accounting for the colspan: every "extra" column is added as
-            // well (with an equal width).
-            for ($i = 1; $i < $cell->style->gridSpan; $i++) {
-                $cols[] = $w ?? 2000;
+        $count = 0;
+        foreach ($t->rows[0]->cells as $cell) {
+            $count += max(1, $cell->style->gridSpan);
+        }
+
+        /** @var array $exact widths from unspanned cells */
+        $exact = [];
+        /** @var array $shared widths split from spanning cells */
+        $shared = [];
+        foreach ($t->rows as $row) {
+            $col = 0;
+            foreach ($row->cells as $cell) {
+                $span = max(1, $cell->style->gridSpan);
+                $w = $this->cellWidthForGrid($cell);
+                if ($w !== null) {
+                    for ($i = $col; $i < $col + $span && $i < $count; $i++) {
+                        if ($span === 1) {
+                            $exact[$i] ??= $w;
+                        } else {
+                            $shared[$i] ??= intdiv($w, $span);
+                        }
+                    }
+                }
+                $col += $span;
             }
         }
 
+        $cols = [];
+        for ($i = 0; $i < $count; $i++) {
+            $cols[] = $exact[$i] ?? $shared[$i] ?? 2000;
+        }
+
         return $cols;
     }
 
+    /**
+     * A cell's width in twips for the grid, or null when it has none.
+     */
+    private function cellWidthForGrid(TableCell $cell): ?int
+    {
+        if ($cell->style->widthTwips !== null) {
+            return $cell->style->widthTwips;
+        }
+        if ($cell->style->widthPercent !== null) {
+            // A pct is invalid in the grid — a gridCol requires dxa. The
+            // approximation: a percentage of 9000 (roughly an A4 page minus
+            // the margins).
+            $pct = $cell->style->widthPercent / 50;
+
+            return (int) round($pct / 100 * 9000);
+        }
+
+        return null;
+    }
+
     private function renderTableRow(TableRow $row): string
     {
         $trPr = '';
@@ -580,7 +662,7 @@ private function renderTableRow(TableRow $row): string
             $trPr .= '';
         }
         if ($row->heightTwips !== null) {
-            $trPr .= '';
+            $trPr .= '';
         }
         $trPrXml = $trPr === '' ? '' : ''.$trPr.'';
 
diff --git a/src/Writer/RunFonts.php b/src/Writer/RunFonts.php
new file mode 100644
index 0000000..0c3c681
--- /dev/null
+++ b/src/Writer/RunFonts.php
@@ -0,0 +1,32 @@
+` of a run style: `fontFamily` for the ascii, hAnsi and cs
+ * slots, `fontFamilyEastAsia` for the eastAsia slot. Empty when neither is
+ * set.
+ */
+final class RunFonts
+{
+    public static function render(RunStyle $s): string
+    {
+        $attrs = '';
+        if ($s->fontFamily !== null) {
+            $f = XmlEscape::attr($s->fontFamily);
+            $attrs .= ' w:ascii="'.$f.'" w:hAnsi="'.$f.'"';
+        }
+        if ($s->fontFamilyEastAsia !== null) {
+            $attrs .= ' w:eastAsia="'.XmlEscape::attr($s->fontFamilyEastAsia).'"';
+        }
+        if ($s->fontFamily !== null) {
+            $attrs .= ' w:cs="'.XmlEscape::attr($s->fontFamily).'"';
+        }
+
+        return $attrs === '' ? '' : '';
+    }
+}
diff --git a/src/Writer/StylesXmlBuilder.php b/src/Writer/StylesXmlBuilder.php
index 78369fe..79f6042 100644
--- a/src/Writer/StylesXmlBuilder.php
+++ b/src/Writer/StylesXmlBuilder.php
@@ -92,10 +92,7 @@ private function renderRunProperties(RunStyle $s): string
             return '';
         }
         $rPr = '';
-        if ($s->fontFamily !== null) {
-            $f = XmlEscape::attr($s->fontFamily);
-            $rPr .= '';
-        }
+        $rPr .= RunFonts::render($s);
         if ($s->bold) {
             $rPr .= '';
         }
diff --git a/src/Writer/Word2007Writer.php b/src/Writer/Word2007Writer.php
index fb3502c..a5eeb10 100644
--- a/src/Writer/Word2007Writer.php
+++ b/src/Writer/Word2007Writer.php
@@ -182,7 +182,9 @@ public function write(Document $document): string
         $contents = (string) file_get_contents($tmpFile);
         @unlink($tmpFile);
 
-        return $contents;
+        // ZipArchive stamps every entry with the current time: without a
+        // fixed time the same document gives different bytes on every write.
+        return ZipTimestamp::apply($contents, $core?->modified);
     }
 
     /**
diff --git a/src/Writer/XmlEscape.php b/src/Writer/XmlEscape.php
index cf7e435..59ae554 100644
--- a/src/Writer/XmlEscape.php
+++ b/src/Writer/XmlEscape.php
@@ -8,17 +8,34 @@
  * A minimal helper for XML-escaping text content and attribute values.
  *
  * OOXML XML is UTF-8; `& < > " '` need escaping in content and attribute
- * values.
+ * values. The result is always well-formed: an invalid UTF-8 sequence becomes
+ * U+FFFD, and a character XML 1.0 does not allow (a C0 control other than
+ * tab, newline and carriage return, U+FFFE, U+FFFF) is removed — Word refuses
+ * to open a part that contains one.
  */
 final class XmlEscape
 {
+    /**
+     * Everything outside the XML 1.0 `Char` production.
+     */
+    private const NOT_XML_CHAR = '/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u';
+
     public static function text(string $value): string
     {
-        return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
+        return self::escape($value);
     }
 
     public static function attr(string $value): string
     {
-        return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
+        return self::escape($value);
+    }
+
+    private static function escape(string $value): string
+    {
+        // ENT_SUBSTITUTE: without it an invalid sequence makes
+        // htmlspecialchars return '' and the whole string is lost.
+        $escaped = htmlspecialchars($value, ENT_QUOTES | ENT_XML1 | ENT_SUBSTITUTE, 'UTF-8');
+
+        return preg_replace(self::NOT_XML_CHAR, '', $escaped) ?? $escaped;
     }
 }
diff --git a/src/Writer/ZipTimestamp.php b/src/Writer/ZipTimestamp.php
new file mode 100644
index 0000000..1704ace
--- /dev/null
+++ b/src/Writer/ZipTimestamp.php
@@ -0,0 +1,119 @@
+ $eocd || substr($zip, $pos, 4) !== self::CENTRAL_HEADER) {
+                return $zip;
+            }
+            /** @var array{nameLength: int, extraLength: int, commentLength: int, localOffset: int} $entry */
+            $entry = unpack('vnameLength/vextraLength/vcommentLength/x8/VlocalOffset', $zip, $pos + 28);
+            $local = $entry['localOffset'];
+            if ($local + 30 > $length || substr($zip, $local, 4) !== self::LOCAL_HEADER) {
+                return $zip;
+            }
+
+            $offsets[] = $pos + 12;
+            $offsets[] = $local + 10;
+
+            $pos += 46 + $entry['nameLength'] + $entry['extraLength'] + $entry['commentLength'];
+        }
+
+        // Two records pointing at one local header give the same offset twice.
+        $offsets = array_unique($offsets);
+        sort($offsets);
+        $stamp = pack('vv', $time, $date);
+        $chunks = [];
+        $from = 0;
+        foreach ($offsets as $offset) {
+            $chunks[] = substr($zip, $from, $offset - $from);
+            $chunks[] = $stamp;
+            $from = $offset + 4;
+        }
+        $chunks[] = substr($zip, $from);
+
+        return implode('', $chunks);
+    }
+
+    /**
+     * MS-DOS time and date words. The DOS range is 1980–2107; a time outside it
+     * is clamped to the nearest end.
+     *
+     * @return array{int, int}
+     */
+    private static function dosDateTime(?\DateTimeInterface $at): array
+    {
+        if ($at === null) {
+            return [0, (1 << 5) | 1];
+        }
+
+        $utc = \DateTimeImmutable::createFromInterface($at)->setTimezone(new \DateTimeZone('UTC'));
+        $year = (int) $utc->format('Y');
+        if ($year < 1980) {
+            return [0, (1 << 5) | 1];
+        }
+        if ($year > 2107) {
+            return [(23 << 11) | (59 << 5) | 29, (127 << 9) | (12 << 5) | 31];
+        }
+
+        $time = ((int) $utc->format('G') << 11) | ((int) $utc->format('i') << 5) | intdiv((int) $utc->format('s'), 2);
+        $date = (($year - 1980) << 9) | ((int) $utc->format('n') << 5) | (int) $utc->format('j');
+
+        return [$time, $date];
+    }
+}
diff --git a/tests/Build/DocumentBuilderTest.php b/tests/Build/DocumentBuilderTest.php
index 7fcaeb2..af38219 100644
--- a/tests/Build/DocumentBuilderTest.php
+++ b/tests/Build/DocumentBuilderTest.php
@@ -11,6 +11,7 @@
 use Dskripchenko\PhpDocx\Element\PageBreak;
 use Dskripchenko\PhpDocx\Element\Paragraph;
 use Dskripchenko\PhpDocx\Element\Run;
+use Dskripchenko\PhpDocx\Style\CoreProperties;
 use Dskripchenko\PhpDocx\Style\PageSetup;
 use Dskripchenko\PhpDocx\Style\PaperSize;
 use PHPUnit\Framework\Attributes\Test;
@@ -212,4 +213,46 @@ public function paragraph_builder_callback_does_not_lose_heading_level(): void
         self::assertCount(2, $p->children);
         self::assertTrue($p->children[0]->style->bold);
     }
+
+    #[Test]
+    public function core_properties_reach_the_document_and_core_xml(): void
+    {
+        $props = new CoreProperties(title: 'Invoice #42', creator: 'Acme Inc.');
+        $builder = DocumentBuilder::new()->coreProperties($props)->paragraph('x');
+
+        self::assertSame($props, $builder->build()->coreProperties);
+
+        $tmp = (string) tempnam(sys_get_temp_dir(), 'docxbuilder-');
+        file_put_contents($tmp, $builder->toBytes());
+        $zip = new \ZipArchive;
+        $zip->open($tmp);
+        $core = (string) $zip->getFromName('docProps/core.xml');
+        $zip->close();
+        @unlink($tmp);
+
+        self::assertStringContainsString('Invoice #42', $core);
+        self::assertStringContainsString('Acme Inc.', $core);
+    }
+
+    #[Test]
+    public function no_core_properties_by_default(): void
+    {
+        self::assertNull(DocumentBuilder::new()->build()->coreProperties);
+    }
+
+    #[Test]
+    public function paragraph_builder_sets_style_id(): void
+    {
+        $p = (new ParagraphBuilder)->styleId('Quote')->text('x')->build();
+        self::assertSame('Quote', $p->styleId);
+        self::assertNull((new ParagraphBuilder)->build()->styleId);
+    }
+
+    #[Test]
+    public function paragraph_builder_sets_page_break_before_and_widow_control(): void
+    {
+        $p = (new ParagraphBuilder)->pageBreakBefore()->widowControl(false)->build();
+        self::assertTrue($p->style->pageBreakBefore);
+        self::assertFalse($p->style->widowControl);
+    }
 }
diff --git a/tests/Build/RunStyleBuilderTest.php b/tests/Build/RunStyleBuilderTest.php
index 311e485..3d9a6aa 100644
--- a/tests/Build/RunStyleBuilderTest.php
+++ b/tests/Build/RunStyleBuilderTest.php
@@ -186,4 +186,13 @@ public function styled_in_list_item(): void
         self::assertTrue($secondRun->style->italic);
         self::assertSame('888888', $secondRun->style->color);
     }
+
+    #[Test]
+    public function east_asian_font_is_built_and_carried_by_from(): void
+    {
+        $style = RunStyleBuilder::new()->fontFamily('Arial')->fontFamilyEastAsia('SimSun')->build();
+        self::assertSame('SimSun', $style->fontFamilyEastAsia);
+        self::assertSame('SimSun', RunStyleBuilder::from($style)->build()->fontFamilyEastAsia);
+        self::assertSame('MS Mincho', (new RunStyle)->withFontFamilyEastAsia('MS Mincho')->fontFamilyEastAsia);
+    }
 }
diff --git a/tests/Build/TableBuilderTest.php b/tests/Build/TableBuilderTest.php
index acfc9d0..169faf5 100644
--- a/tests/Build/TableBuilderTest.php
+++ b/tests/Build/TableBuilderTest.php
@@ -14,6 +14,7 @@
 use Dskripchenko\PhpDocx\Element\Table;
 use Dskripchenko\PhpDocx\Style\Alignment;
 use Dskripchenko\PhpDocx\Style\VerticalAlign;
+use Dskripchenko\PhpDocx\Style\RowHeightRule;
 use PHPUnit\Framework\Attributes\Test;
 use PHPUnit\Framework\TestCase;
 
@@ -208,6 +209,38 @@ public function row_cant_split_flag(): void
         self::assertFalse($t->rows[2]->cantSplit);
     }
 
+    #[Test]
+    public function table_indent(): void
+    {
+        $doc = DocumentBuilder::new()
+            ->table(fn (TableBuilder $t) => $t->indentPt(18)->row(['A']))
+            ->table(fn (TableBuilder $t) => $t->row(['B']))
+            ->build();
+        /** @var Table $t */
+        $t = $doc->section->body[0];
+        self::assertSame(360, $t->style->indentTwips);
+        /** @var Table $t2 */
+        $t2 = $doc->section->body[1];
+        self::assertNull($t2->style->indentTwips);
+    }
+
+    #[Test]
+    public function row_exact_height(): void
+    {
+        $doc = DocumentBuilder::new()
+            ->table(fn (TableBuilder $t) => $t
+                ->row(fn (TableRowBuilder $r) => $r->exactHeight(300)->cell('A'))
+                ->row(fn (TableRowBuilder $r) => $r->exactHeight(300)->height(400)->cell('B'))
+            )
+            ->build();
+        /** @var Table $t */
+        $t = $doc->section->body[0];
+        self::assertSame(300, $t->rows[0]->heightTwips);
+        self::assertSame(RowHeightRule::Exact, $t->rows[0]->heightRule);
+        self::assertSame(400, $t->rows[1]->heightTwips);
+        self::assertSame(RowHeightRule::AtLeast, $t->rows[1]->heightRule);
+    }
+
     #[Test]
     public function nested_table_in_cell(): void
     {
diff --git a/tests/Html/ConverterTest.php b/tests/Html/ConverterTest.php
index b037dc1..8d7eb38 100644
--- a/tests/Html/ConverterTest.php
+++ b/tests/Html/ConverterTest.php
@@ -212,4 +212,15 @@ private function findRunWithText(array $children, string $text): Run
         }
         self::fail("Run with text '{$text}' not found");
     }
+
+    #[Test]
+    public function it_parses_break_before_page_into_page_break_before(): void
+    {
+        $doc = (new Converter)->fromHtml(
+            '

A

B

C

' + ); + self::assertTrue($doc->section->body[0]->style->pageBreakBefore); + self::assertTrue($doc->section->body[1]->style->pageBreakBefore); + self::assertFalse($doc->section->body[2]->style->pageBreakBefore); + } } diff --git a/tests/Html/SerializerTest.php b/tests/Html/SerializerTest.php index 12700d6..044e24a 100644 --- a/tests/Html/SerializerTest.php +++ b/tests/Html/SerializerTest.php @@ -22,6 +22,7 @@ use Dskripchenko\PhpDocx\Style\ParagraphStyle; use Dskripchenko\PhpDocx\Style\RunStyle; use Dskripchenko\PhpDocx\Writer\Word2007Writer; +use Dskripchenko\PhpDocx\Style\TableStyle; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -103,6 +104,17 @@ public function keep_lines_emits_break_inside_avoid(): void self::assertSame(1, substr_count($html, 'break-inside:avoid')); } + #[Test] + public function page_break_before_emits_break_before_page(): void + { + $doc = new Document(section: new Section([ + new Paragraph([new Run('x')], new ParagraphStyle(pageBreakBefore: true)), + new Paragraph([new Run('y')]), + ])); + $html = (new Serializer)->serialize($doc)->bodyHtml; + self::assertSame(1, substr_count($html, 'break-before:page')); + } + #[Test] public function cant_split_row_emits_break_inside_avoid(): void { @@ -115,6 +127,17 @@ public function cant_split_row_emits_break_inside_avoid(): void self::assertSame(1, substr_count($html, '
')); } + #[Test] + public function table_indent_emits_margin_left(): void + { + $row = new TableRow([new TableCell([new Paragraph([new Run('x')])])]); + $doc = new Document(section: new Section([ + new Table([$row], new TableStyle(indentTwips: 360)), + ])); + $html = (new Serializer)->serialize($doc)->bodyHtml; + self::assertStringContainsString('margin-left:18pt', $html); + } + #[Test] public function bullet_list_serializes_to_ul(): void { diff --git a/tests/Html/TableExtensionsTest.php b/tests/Html/TableExtensionsTest.php index 6293de1..ffab983 100644 --- a/tests/Html/TableExtensionsTest.php +++ b/tests/Html/TableExtensionsTest.php @@ -78,6 +78,20 @@ public function colgroup_css_width_pt(): void self::assertSame([2000, 4000], $t->gridColumnsTwips); } + #[Test] + public function margin_left_becomes_table_indent(): void + { + $doc = (new Converter)->fromHtml('
A
'); + /** @var Table $t */ + $t = $doc->section->body[0]; + self::assertSame(360, $t->style->indentTwips); + + $doc = (new Converter)->fromHtml('
A
'); + /** @var Table $t */ + $t = $doc->section->body[0]; + self::assertNull($t->style->indentTwips); + } + #[Test] public function colgroup_uses_gridcol_widths_in_tblGrid_xml(): void { diff --git a/tests/Reader/BodyReaderTest.php b/tests/Reader/BodyReaderTest.php index 66db856..de5d53c 100644 --- a/tests/Reader/BodyReaderTest.php +++ b/tests/Reader/BodyReaderTest.php @@ -331,6 +331,23 @@ public function keep_lines_reaches_the_paragraph_style(): void self::assertFalse($blocks[2]->style->keepLines); } + #[Test] + public function page_break_before_and_widow_control_reach_the_paragraph_style(): void + { + $body = $this->loadBody( + 'new page' + .'off' + .'plain' + ); + $blocks = (new BodyReader)->read($body); + + self::assertTrue($blocks[0]->style->pageBreakBefore); + self::assertTrue($blocks[0]->style->widowControl); + self::assertFalse($blocks[1]->style->pageBreakBefore); + self::assertFalse($blocks[1]->style->widowControl); + self::assertNull($blocks[2]->style->widowControl); + } + #[Test] public function footnote_text_is_read_from_the_notes_part(): void { diff --git a/tests/Reader/StylesResolverTest.php b/tests/Reader/StylesResolverTest.php index 7eabbd7..1c9ffb5 100644 --- a/tests/Reader/StylesResolverTest.php +++ b/tests/Reader/StylesResolverTest.php @@ -326,6 +326,23 @@ public function font_family_priority_ascii_over_hAnsi(): void self::assertSame('Arial', $runStyle->fontFamily); } + #[Test] + public function east_asian_font_is_read_into_its_own_slot(): void + { + $xml = $this->wrapDocument( + '' + .'' + .'X' + ); + $resolver = new StylesResolver; + $p = $this->firstParagraph($xml); + [, $base] = $resolver->effectiveStylesForParagraph($p); + $runStyle = $resolver->effectiveStylesForRun($this->firstRun($p), $base); + + self::assertSame('Arial', $runStyle->fontFamily); + self::assertSame('MS Mincho', $runStyle->fontFamilyEastAsia); + } + private function wrapDocument(string $bodyXml): \DOMDocument { return $this->loadXml( diff --git a/tests/Reader/TableReaderTest.php b/tests/Reader/TableReaderTest.php index 512c707..f363780 100644 --- a/tests/Reader/TableReaderTest.php +++ b/tests/Reader/TableReaderTest.php @@ -14,6 +14,7 @@ use Dskripchenko\PhpDocx\Reader\StylesResolver; use Dskripchenko\PhpDocx\Reader\TableReader; use Dskripchenko\PhpDocx\Writer\Word2007Writer; +use Dskripchenko\PhpDocx\Style\RowHeightRule; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -164,6 +165,21 @@ public function row_with_cantSplit_marked(): void self::assertFalse($t->rows[1]->cantSplit); } + #[Test] + public function tblInd_extracted(): void + { + $read = fn (string $ind) => (new TableReader)->read($this->loadTable( + ''.$ind.'' + .'x' + .'' + ))->style->indentTwips; + + self::assertSame(360, $read('')); + self::assertSame(-108, $read('')); + self::assertNull($read('')); + self::assertNull($read('')); + } + #[Test] public function trHeight_extracted(): void { @@ -177,6 +193,42 @@ public function trHeight_extracted(): void self::assertSame(800, $t->rows[0]->heightTwips); } + #[Test] + public function trHeight_rule_extracted(): void + { + $tbl = $this->loadTable( + '' + .'' + .'x' + .'' + .'y' + .'' + .'z' + .'' + ); + $t = (new TableReader)->read($tbl); + self::assertSame(RowHeightRule::Exact, $t->rows[0]->heightRule); + self::assertSame(RowHeightRule::AtLeast, $t->rows[1]->heightRule); + self::assertSame(RowHeightRule::AtLeast, $t->rows[2]->heightRule); + } + + #[Test] + public function exact_row_height_survives_row_span_reconstruction(): void + { + $tbl = $this->loadTable( + '' + .'' + .'x' + .'' + .'' + .'' + ); + // A vMerge goes through reconstructRowSpans(), which rebuilds the rows. + $t = (new TableReader)->read($tbl); + self::assertSame(RowHeightRule::Exact, $t->rows[0]->heightRule); + self::assertSame(RowHeightRule::Exact, $t->rows[1]->heightRule); + } + #[Test] public function nested_table_inside_cell(): void { diff --git a/tests/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php index 36a74fb..003b303 100644 --- a/tests/Writer/BodyXmlBuilderTest.php +++ b/tests/Writer/BodyXmlBuilderTest.php @@ -24,6 +24,7 @@ use Dskripchenko\PhpDocx\Style\TableStyle; use Dskripchenko\PhpDocx\Style\VerticalAlign; use Dskripchenko\PhpDocx\Writer\BodyXmlBuilder; +use Dskripchenko\PhpDocx\Style\RowHeightRule; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -199,6 +200,81 @@ public function it_renders_keep_lines_after_keep_next_and_before_numpr(): void self::assertStringNotContainsString('keepLines', $plain); } + #[Test] + public function it_renders_an_explicit_style_id_as_pstyle(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([new Run('x')], new ParagraphStyle(keepWithNext: true), styleId: 'Title'), + ]); + self::assertStringContainsString('', $xml); + } + + #[Test] + public function it_prefers_the_style_id_over_the_heading_level(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([new Run('x')], headingLevel: 1, styleId: 'ChapterTitle'), + ]); + self::assertStringContainsString('', $xml); + self::assertStringNotContainsString('Heading1', $xml); + } + + #[Test] + public function it_escapes_the_style_id(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([new Run('x')], styleId: 'A"&B'), + ]); + self::assertStringContainsString('', $xml); + } + + #[Test] + public function it_renders_page_break_before_and_widow_control_before_numpr(): void + { + $style = new ParagraphStyle(keepLines: true, pageBreakBefore: true, widowControl: true, borders: BorderSet::all(new Border)); + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run('x')], $style, headingLevel: 1)]); + self::assertStringContainsString( + '', + $xml, + ); + + $list = (new BodyXmlBuilder)->render([ + new ListNode([ + new ListItem([new Run('x')], style: new ParagraphStyle(pageBreakBefore: true, widowControl: false)), + ], ordered: true, levelText: '%1.'), + ]); + self::assertStringContainsString( + '', + $list, + ); + + $plain = (new BodyXmlBuilder)->render([new Paragraph([new Run('x')])]); + self::assertStringNotContainsString('pageBreakBefore', $plain); + self::assertStringNotContainsString('widowControl', $plain); + } + + #[Test] + public function it_renders_the_east_asian_font_in_rfonts(): void + { + $both = (new BodyXmlBuilder)->render([new Paragraph([ + new Run('x', new RunStyle(fontFamily: 'Arial', fontFamilyEastAsia: 'MS Mincho')), + ])]); + self::assertStringContainsString( + '', + $both, + ); + + $only = (new BodyXmlBuilder)->render([new Paragraph([ + new Run('x', new RunStyle(fontFamilyEastAsia: 'SimSun')), + ])]); + self::assertStringContainsString('', $only); + + $latin = (new BodyXmlBuilder)->render([new Paragraph([ + new Run('x', new RunStyle(fontFamily: 'Arial')), + ])]); + self::assertStringContainsString('', $latin); + } + #[Test] public function it_renders_cant_split_row_before_header_and_height(): void { @@ -217,6 +293,102 @@ public function it_renders_cant_split_row_before_header_and_height(): void self::assertSame(1, substr_count($xml, '')); } + #[Test] + public function it_splits_a_spanning_cell_width_across_its_grid_columns(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Table([ + new TableRow([ + new TableCell([new Paragraph([new Run('A')])], new CellStyle(widthTwips: 2000, gridSpan: 2)), + new TableCell([new Paragraph([new Run('B')])], new CellStyle(widthTwips: 2000)), + ]), + ]), + ]); + + self::assertStringContainsString( + '', + $xml, + ); + } + + #[Test] + public function it_takes_grid_widths_from_a_later_row_where_the_columns_are_unspanned(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Table([ + new TableRow([ + new TableCell([new Paragraph([new Run('Title')])], new CellStyle(widthTwips: 5000, gridSpan: 2)), + ]), + new TableRow([ + new TableCell([new Paragraph([new Run('A')])], new CellStyle(widthTwips: 1500)), + new TableCell([new Paragraph([new Run('B')])], new CellStyle(widthTwips: 3500)), + ]), + ]), + ]); + + self::assertStringContainsString( + '', + $xml, + ); + } + + #[Test] + public function it_defaults_grid_columns_without_widths_to_2000(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Table([ + new TableRow([ + new TableCell([new Paragraph([new Run('A')])]), + new TableCell([new Paragraph([new Run('B')])], new CellStyle(gridSpan: 2)), + ]), + ]), + ]); + + self::assertStringContainsString( + '', + $xml, + ); + } + + #[Test] + public function it_writes_table_indent_after_jc_and_before_borders(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Table([ + new TableRow([new TableCell([new Paragraph([new Run('A')])])]), + ], new TableStyle(borders: BorderSet::none(), alignment: Alignment::Center, indentTwips: 360)), + ]); + + self::assertStringContainsString( + '', + $xml, + ); + } + + #[Test] + public function it_omits_table_indent_by_default(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Table([new TableRow([new TableCell([new Paragraph([new Run('A')])])])]), + ]); + + self::assertStringNotContainsString('render([ + new Table([ + new TableRow([new TableCell([new Paragraph([new Run('A')])])], heightTwips: 300, heightRule: RowHeightRule::Exact), + new TableRow([new TableCell([new Paragraph([new Run('B')])])], heightTwips: 300), + ]), + ]); + + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); + } + #[Test] public function it_puts_numpr_after_keep_next_in_a_list_item(): void { diff --git a/tests/Writer/DeterministicOutputTest.php b/tests/Writer/DeterministicOutputTest.php new file mode 100644 index 0000000..fc4441c --- /dev/null +++ b/tests/Writer/DeterministicOutputTest.php @@ -0,0 +1,224 @@ +write($this->document()); + + $stamps = $this->entryStamps($docx); + self::assertGreaterThan(5, count($stamps), 'header, media and rels parts are all stamped'); + foreach ($stamps as $name => $stamp) { + self::assertSame([self::DOS_EPOCH, self::DOS_EPOCH], $stamp, $name); + } + } + + #[Test] + public function entriesTakeTheModifiedTimeAsUtcWallClock(): void + { + // 11:30:44 in Moscow is 08:30:44 UTC; DOS keeps seconds in steps of 2. + $modified = new \DateTimeImmutable('2026-08-06 11:30:44', new \DateTimeZone('Europe/Moscow')); + $docx = (new Word2007Writer)->write($this->document(new CoreProperties(title: 'T', modified: $modified))); + + $expected = [(8 << 11) | (30 << 5) | 22, ((2026 - 1980) << 9) | (8 << 5) | 6]; + foreach ($this->entryStamps($docx) as $name => $stamp) { + self::assertSame([$expected, $expected], $stamp, $name); + } + } + + #[Test] + public function aModifiedTimeBeforeDosTimeIsClampedTo1980(): void + { + $docx = (new Word2007Writer)->write($this->document(new CoreProperties( + title: 'T', + modified: new \DateTimeImmutable('1970-01-01 00:00:00', new \DateTimeZone('UTC')), + ))); + + foreach ($this->entryStamps($docx) as $name => $stamp) { + self::assertSame([self::DOS_EPOCH, self::DOS_EPOCH], $stamp, $name); + } + } + + #[Test] + public function twoWritesGiveIdenticalBytes(): void + { + $writer = new Word2007Writer; + + self::assertSame($writer->write($this->document()), $writer->write($this->document())); + } + + #[Test] + public function theStampedArchiveStillPassesConsistencyChecks(): void + { + $docx = (new Word2007Writer)->write($this->document()); + + $tmp = (string) tempnam(sys_get_temp_dir(), 'docx-test-'); + file_put_contents($tmp, $docx); + try { + $zip = new \ZipArchive; + self::assertTrue($zip->open($tmp, \ZipArchive::CHECKCONS)); + self::assertStringContainsString('Hello', (string) $zip->getFromName('word/document.xml')); + $zip->close(); + } finally { + @unlink($tmp); + } + } + + #[Test] + public function anArchiveItCannotParseIsReturnedUnchanged(): void + { + self::assertSame('not a zip', ZipTimestamp::apply('not a zip')); + } + + #[Test] + public function aTruncatedCentralDirectoryIsReturnedUnchanged(): void + { + $docx = (new Word2007Writer)->write($this->document()); + $eocd = strlen($docx) - 22; + // Point the central directory at a record signature 8 bytes before + // the end record: the record cannot fit, and the bounds check has to + // stop before its fields are read past the end of the string. + $broken = substr_replace($docx, "PK\x01\x02", $eocd - 8, 4); + $broken = substr_replace($broken, pack('V', $eocd - 8), $eocd + 16, 4); + + self::assertSame($broken, ZipTimestamp::apply($broken)); + } + + #[Test] + public function aLargeArchiveIsPatchedInLinearTime(): void + { + // 2000 stored 8 KB entries: patching the string record by record made + // this take seconds (every record copied the whole 16 MB archive). + $tmp = (string) tempnam(sys_get_temp_dir(), 'zip-test-'); + $zip = new \ZipArchive; + $zip->open($tmp, \ZipArchive::OVERWRITE); + for ($i = 0; $i < 2000; $i++) { + $zip->addFromString('word/media/image'.$i.'.png', random_bytes(8192)); + } + $zip->close(); + $bytes = (string) file_get_contents($tmp); + @unlink($tmp); + + $started = microtime(true); + $patched = ZipTimestamp::apply($bytes); + $elapsed = microtime(true) - $started; + + self::assertLessThan(2.0, $elapsed); + self::assertSame(strlen($bytes), strlen($patched)); + $stamps = $this->entryStamps($patched); + self::assertCount(2000, $stamps); + foreach ($stamps as $name => $stamp) { + self::assertSame([self::DOS_EPOCH, self::DOS_EPOCH], $stamp, $name); + } + } + + #[Test] + public function theBytesDoNotDependOnTheProcessTimeZone(): void + { + // libzip converts entry times through the C localtime() of the + // process, so the time zone has to be set before PHP starts. + $script = 'paragraph("Hi")->toBytes());'; + $file = (string) tempnam(sys_get_temp_dir(), 'tz-test-'); + file_put_contents($file, $script); + + try { + $hashes = []; + foreach (['UTC', 'Asia/Tokyo', 'America/New_York'] as $tz) { + $process = proc_open( + [PHP_BINARY, $file], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + ['TZ' => $tz] + getenv(), + ); + self::assertIsResource($process); + $hashes[$tz] = trim((string) stream_get_contents($pipes[1])); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $tz); + } + } finally { + @unlink($file); + } + + self::assertMatchesRegularExpression('/^[0-9a-f]{40}$/', $hashes['UTC']); + self::assertSame(['UTC' => $hashes['UTC'], 'Asia/Tokyo' => $hashes['UTC'], 'America/New_York' => $hashes['UTC']], $hashes); + } + + private function document(?CoreProperties $core = null): Document + { + // A 1×1 PNG, so the package has a media part and a part-level rels file. + $png = (string) base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + ); + + return new Document( + new Section( + body: [new Paragraph([new Run('Hello')])], + header: [new Paragraph([Field::page(), new Image($png, ImageFormat::Png, 9525, 9525)])], + ), + coreProperties: $core, + ); + } + + /** + * The DOS [time, date] of every entry, from its central directory record + * and from its local file header. + * + * @return array + */ + private function entryStamps(string $zip): array + { + $end = unpack('ventries/x4/Voffset', $zip, strlen($zip) - 22 + 10); + self::assertIsArray($end); + + $stamps = []; + $pos = $end['offset']; + for ($i = 0; $i < $end['entries']; $i++) { + $cd = unpack('vtime/vdate/x12/vnameLength/vextraLength/vcommentLength/x8/VlocalOffset', $zip, $pos + 12); + self::assertIsArray($cd); + $name = substr($zip, $pos + 46, $cd['nameLength']); + $local = unpack('vtime/vdate', $zip, $cd['localOffset'] + 10); + self::assertIsArray($local); + + $stamps[$name] = [[$cd['time'], $cd['date']], [$local['time'], $local['date']]]; + $pos += 46 + $cd['nameLength'] + $cd['extraLength'] + $cd['commentLength']; + } + + return $stamps; + } +} diff --git a/tests/Writer/RunTextTest.php b/tests/Writer/RunTextTest.php new file mode 100644 index 0000000..ea9b151 --- /dev/null +++ b/tests/Writer/RunTextTest.php @@ -0,0 +1,176 @@ +` is not a tab or a line break to Word: + * run text is split into ``, `` and ``. + */ +final class RunTextTest extends TestCase +{ + #[Test] + public function a_tab_becomes_w_tab(): void + { + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run("Name:\tValue")])]); + + self::assertStringContainsString( + 'Name:Value', + $xml, + ); + self::assertStringNotContainsString("\t", $xml); + } + + #[Test] + public function every_newline_form_becomes_w_br(): void + { + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run("a\nb\r\nc\rd")])]); + + self::assertStringContainsString( + 'a' + .'b' + .'c' + .'d', + $xml, + ); + self::assertStringNotContainsString("\n", $xml); + self::assertStringNotContainsString("\r", $xml); + } + + #[Test] + public function word_line_and_page_break_characters_become_w_br(): void + { + // Word's object model and clipboard give a manual line break as "\v" + // and a page break as "\f"; neither may go into the XML as is. + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run("a\x0Bb\x0Cc\x01d")])]); + + self::assertStringContainsString( + 'a' + .'b' + // Other control characters are still dropped by XmlEscape. + .'cd', + $xml, + ); + self::assertStringNotContainsString("\x0B", $xml); + self::assertStringNotContainsString("\x0C", $xml); + } + + #[Test] + public function no_empty_w_t_around_tabs(): void + { + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run("\t\tx\t")])]); + + self::assertStringContainsString( + 'x', + $xml, + ); + self::assertStringNotContainsString('', $xml); + } + + #[Test] + public function the_run_properties_stay_first(): void + { + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run("a\tb", new RunStyle(bold: true))])]); + + self::assertMatchesRegularExpression('#.*?.*?]*>a#', $xml); + } + + #[Test] + public function plain_and_empty_runs_are_unchanged(): void + { + $xml = (new BodyXmlBuilder)->render([new Paragraph([new Run('plain'), new Run('')])]); + + self::assertStringContainsString('plain', $xml); + self::assertStringContainsString('', $xml); + } + + #[Test] + public function hyperlink_text_and_table_caption_are_split_too(): void + { + $xml = (new BodyXmlBuilder)->render([ + new Paragraph([Hyperlink::internal('x', [new Run("a\tb")])]), + new Table( + [new TableRow([new TableCell([new Paragraph([new Run('c')])], new CellStyle)])], + new TableStyle, + "Table 1\tTotals", + ), + ]); + + self::assertStringContainsString('a', $xml); + self::assertStringContainsString('Table 1Totals', $xml); + } + + #[Test] + public function header_text_is_split_too(): void + { + $bytes = (new Word2007Writer)->write(new Document(new Section( + body: [new Paragraph([new Run('body')])], + header: [new Paragraph([new Run("left\tright")])], + ))); + + $header = self::part($bytes, 'word/header1.xml'); + self::assertStringContainsString('leftright', $header); + } + + #[Test] + public function tabs_and_line_breaks_survive_a_read_write_round_trip(): void + { + $source = (new Word2007Writer)->write(new Document(new Section([ + new Paragraph([new Run("Name:\tValue"), new LineBreak, new Run('next')]), + ]))); + + // The reader gives the tab back as "\t" in the run text. + $read = (new DocxReader)->read($source); + $bytes = (new Word2007Writer)->write(new Document($read->section)); + + $xml = self::part($bytes, 'word/document.xml'); + self::assertStringContainsString('Name:Value', $xml); + self::assertStringContainsString('', $xml); + self::assertStringNotContainsString("\t", $xml); + } + + #[Test] + public function newlines_in_html_pre_become_line_breaks(): void + { + // The converter keeps the newlines of a
 in the run text.
+        $doc = (new Converter)->fromHtml("
line 1\nline 2
"); + $xml = self::part((new Word2007Writer)->write($doc), 'word/document.xml'); + + self::assertStringContainsString('line 1line 2', $xml); + } + + private static function part(string $bytes, string $name): string + { + $tmp = tempnam(sys_get_temp_dir(), 'docx-test-'); + self::assertIsString($tmp); + file_put_contents($tmp, $bytes); + $zip = new \ZipArchive; + $zip->open($tmp); + $xml = $zip->getFromName($name); + $zip->close(); + unlink($tmp); + self::assertIsString($xml); + + return $xml; + } +} diff --git a/tests/Writer/StylesTest.php b/tests/Writer/StylesTest.php index 49914de..7201616 100644 --- a/tests/Writer/StylesTest.php +++ b/tests/Writer/StylesTest.php @@ -130,4 +130,16 @@ public function writer_uses_custom_registry_when_provided(): void // Heading1 only — the caller passed no other headings self::assertStringNotContainsString('w:styleId="Heading2"', $styles); } + + #[Test] + public function style_rpr_carries_the_east_asian_font(): void + { + $registry = (new StyleRegistry)->paragraph( + 'Cjk', + new RunStyle(fontFamily: 'Calibri', fontFamilyEastAsia: 'SimSun'), + new ParagraphStyle, + ); + $xml = (new StylesXmlBuilder($registry))->render(); + self::assertStringContainsString('w:eastAsia="SimSun"', $xml); + } } diff --git a/tests/Writer/XmlEscapeTest.php b/tests/Writer/XmlEscapeTest.php new file mode 100644 index 0000000..282a6d3 --- /dev/null +++ b/tests/Writer/XmlEscapeTest.php @@ -0,0 +1,83 @@ + "D" \'E\'')); + self::assertSame('&"', XmlEscape::attr('&"')); + } + + #[Test] + public function invalid_utf8_keeps_the_valid_part(): void + { + // Before, htmlspecialchars returned '' and the whole run was lost. + self::assertSame("caf\u{FFFD} au lait", XmlEscape::text("caf\xE9 au lait")); + self::assertSame("x\u{FFFD}y", XmlEscape::attr("x\xFFy")); + } + + #[Test] + public function characters_xml_does_not_allow_are_removed(): void + { + self::assertSame('ab', XmlEscape::text("a\x01\x0B\x1Fb")); + self::assertSame('ab', XmlEscape::text("a\u{FFFE}\u{FFFF}b")); + self::assertSame('ab', XmlEscape::attr("a\x00b")); + } + + #[Test] + public function allowed_whitespace_and_other_characters_are_kept(): void + { + self::assertSame("a\tb\nc\rd", XmlEscape::text("a\tb\nc\rd")); + self::assertSame("Привет 你好 \u{1F600} \u{FFFD}", XmlEscape::text("Привет 你好 \u{1F600} \u{FFFD}")); + } + + #[Test] + public function a_control_character_in_run_text_gives_a_well_formed_document(): void + { + $bytes = (new Word2007Writer)->write(new Document(new Section([ + new Paragraph([ + new Run("before\x01after"), + new Run("bad \xC3( utf8"), + new Hyperlink("https://example.com/?q=\x02", [new Run('link')]), + ]), + ]))); + + foreach (['word/document.xml', 'word/_rels/document.xml.rels'] as $name) { + $dom = new \DOMDocument; + self::assertTrue(@$dom->loadXML(self::part($bytes, $name)), $name.' is not well-formed'); + } + $xml = self::part($bytes, 'word/document.xml'); + self::assertStringContainsString('>beforeafter<', $xml); + self::assertStringContainsString("bad \u{FFFD}( utf8", $xml); + } + + private static function part(string $bytes, string $name): string + { + $tmp = tempnam(sys_get_temp_dir(), 'docx-test-'); + self::assertIsString($tmp); + file_put_contents($tmp, $bytes); + $zip = new \ZipArchive; + $zip->open($tmp); + $xml = $zip->getFromName($name); + $zip->close(); + unlink($tmp); + self::assertIsString($xml); + + return $xml; + } +}