From 5e22872440db5567ed2d6fbd272a8f6680d62ba4 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:13:28 -0700 Subject: [PATCH 01/14] fix: write tabs and newlines in run text as w:tab and w:br Run text went into a single as is, so a "\t" or "\n" ended up inside it, where Word shows it as a space at best. The reader turns into "\t", so a document read and written back lost its tabs, and the converter keeps the newlines of a
, which then lost its
line breaks.

The writer now splits the text on "\t", "\r\n", "\r" and "\n" and
writes  /  between  segments in the same ,
leaving out empty segments. This covers runs anywhere (body, headers,
footers, hyperlinks, lists, cells) and the table caption.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                  |   9 ++
 src/Writer/BodyXmlBuilder.php |  37 ++++++--
 tests/Writer/RunTextTest.php  | 158 ++++++++++++++++++++++++++++++++++
 3 files changed, 197 insertions(+), 7 deletions(-)
 create mode 100644 tests/Writer/RunTextTest.php

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5ea6ca..dc48d98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ 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]
+
+### 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.
+
 ## [1.16.0] — 2026-10-20
 
 ### Added
diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php
index da0799f..9bc9ea5 100644
--- a/src/Writer/BodyXmlBuilder.php
+++ b/src/Writer/BodyXmlBuilder.php
@@ -350,11 +350,36 @@ 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".
+     */
+    private static function renderRunContent(string $text): string
+    {
+        $parts = preg_split('/(\t|\r\n|\r|\n)/', $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" => '',
+                // xml:space="preserve" keeps the leading and trailing spaces.
+                default => ''.XmlEscape::text($part).'',
+            };
+        }
+
+        return $xml;
     }
 
     private function renderRunProperties(RunStyle $s): string
@@ -481,12 +506,10 @@ private function renderTable(Table $t): string
      */
     private function renderCaption(string $text): string
     {
-        $escaped = XmlEscape::text($text);
-
         return ''
             .''
             .''
-            .''.$escaped.''
+            .self::renderRunContent($text).''
             .'';
     }
 
diff --git a/tests/Writer/RunTextTest.php b/tests/Writer/RunTextTest.php
new file mode 100644
index 0000000..77d3910
--- /dev/null
+++ b/tests/Writer/RunTextTest.php
@@ -0,0 +1,158 @@
+` 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 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; + } +} From 2f1111aa94dab8e304ce6876ac81a57efd3efc22 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:14:08 -0700 Subject: [PATCH 02/14] fix: XmlEscape keeps invalid UTF-8 text and drops non-XML characters htmlspecialchars without ENT_SUBSTITUTE returns '' for a string with an invalid UTF-8 sequence, so the whole run text or attribute value was silently lost. And C0 control characters other than tab, newline and carriage return passed through, which makes the part malformed: Word refuses to open the file. text() and attr() now escape with ENT_SUBSTITUTE (an invalid sequence becomes U+FFFD) and remove every character outside the XML 1.0 Char production (C0 controls, U+FFFE, U+FFFF). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ src/Writer/XmlEscape.php | 23 ++++++++-- tests/Writer/XmlEscapeTest.php | 83 ++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 tests/Writer/XmlEscapeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index dc48d98..273db7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ``, `` 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`, a vertical tab pasted from Word, …)
+  went into the XML as is, and Word refused to open the file. `XmlEscape`
+  now removes every character XML 1.0 does not allow.
 
 ## [1.16.0] — 2026-10-20
 
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/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;
+    }
+}

From 8b2fed5ca53f10645867f716a58b2c388ef6114d Mon Sep 17 00:00:00 2001
From: Dan Krieger 
Date: Mon, 19 Oct 2026 15:13:53 -0700
Subject: [PATCH 03/14] fix: deterministic ZIP output (fixed entry timestamps)

ZipArchive stamped every entry with the current time, so two writes of
the same document differed once a 2-second DOS tick passed. Every entry
now carries CoreProperties::$modified as a UTC wall-clock time, or
1980-01-01 00:00:00 when it is not set.

ZipArchive::setMtimeName() is not enough on its own: libzip converts the
Unix timestamp to a DOS date/time with the C localtime() and the
process's TZ (not PHP's date.timezone, and putenv('TZ=...') before
close() does not change it), so one timestamp still gave different
bytes on machines in different zones. ZipTimestamp writes the DOS
time/date fields into each local file header and central directory
record after close(). libzip writes no extended-timestamp extra field,
and the CRC does not cover these fields. An archive it cannot parse
(ZIP64, comment) is returned unchanged.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                             |   8 ++
 src/Writer/Word2007Writer.php            |   4 +-
 src/Writer/ZipTimestamp.php              | 101 ++++++++++++++++
 tests/Writer/DeterministicOutputTest.php | 147 +++++++++++++++++++++++
 4 files changed, 259 insertions(+), 1 deletion(-)
 create mode 100644 src/Writer/ZipTimestamp.php
 create mode 100644 tests/Writer/DeterministicOutputTest.php

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 273db7b..91accd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,14 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - A control character in text (`\x01`, a vertical tab pasted from Word, …)
   went into the XML as is, and Word refused to open the file. `XmlEscape`
   now removes every 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.
 
 ## [1.16.0] — 2026-10-20
 
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/ZipTimestamp.php b/src/Writer/ZipTimestamp.php
new file mode 100644
index 0000000..e899a01
--- /dev/null
+++ b/src/Writer/ZipTimestamp.php
@@ -0,0 +1,101 @@
+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/Writer/DeterministicOutputTest.php b/tests/Writer/DeterministicOutputTest.php
new file mode 100644
index 0000000..2add527
--- /dev/null
+++ b/tests/Writer/DeterministicOutputTest.php
@@ -0,0 +1,147 @@
+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'));
+    }
+
+    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;
+    }
+}

From d983456b556bfcacb8a549814c28a4bafef13113 Mon Sep 17 00:00:00 2001
From: Dan Krieger 
Date: Mon, 19 Oct 2026 15:15:03 -0700
Subject: [PATCH 04/14] feat: DocumentBuilder::coreProperties()

DocumentBuilder::build() never set Document::$coreProperties and the
builder had no way to pass them, so docProps/core.xml (title, author,
dates) was only reachable by building the Document by hand.
coreProperties(CoreProperties) stores them and build() passes them on.
The README documents it (in all four languages) along with the fixed
entry time from the previous commit.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                        |  6 ++++++
 README.md                           |  8 +++++++-
 docs/de/README.md                   |  8 +++++++-
 docs/ru/README.md                   |  8 +++++++-
 docs/zh/README.md                   |  8 +++++++-
 src/Build/DocumentBuilder.php       | 16 ++++++++++++++++
 tests/Build/DocumentBuilderTest.php | 27 +++++++++++++++++++++++++++
 7 files changed, 77 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91accd0..c29dfbb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,12 @@ 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.
+
 ### 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
diff --git a/README.md b/README.md
index c65bc02..3471b7b 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) => …)`:
diff --git a/docs/de/README.md b/docs/de/README.md
index 51a186f..4defd62 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) => …)`:
diff --git a/docs/ru/README.md b/docs/ru/README.md
index 769b362..eb58e9d 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) => …)`:
diff --git a/docs/zh/README.md b/docs/zh/README.md
index c4644c3..bb3341a 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) => …)` 内:
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/tests/Build/DocumentBuilderTest.php b/tests/Build/DocumentBuilderTest.php
index 7fcaeb2..6cb0b6c 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,30 @@ 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);
+    }
 }

From c113298fb5e30dc223588a0057cfcca3d895c88d Mon Sep 17 00:00:00 2001
From: Dan Krieger 
Date: Mon, 19 Oct 2026 15:13:34 -0700
Subject: [PATCH 05/14] feat: arbitrary paragraph style id (w:pStyle)

Paragraph::$styleId (appended to the constructor) and
ParagraphBuilder::styleId() write any paragraph style id as :
Normal, Title, Quote, or a custom id registered with
StyleRegistry::paragraph(). Before, only Heading1..6 could be referenced
(through headingLevel). An explicit id wins over the heading level; list
items keep ListParagraph. The id is XML-escaped.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                        |  5 +++++
 README.md                           |  3 ++-
 docs/de/README.md                   |  3 ++-
 docs/ru/README.md                   |  3 ++-
 docs/zh/README.md                   |  3 ++-
 src/Build/ParagraphBuilder.php      | 14 ++++++++++++++
 src/Element/Paragraph.php           |  6 ++++++
 src/Writer/BodyXmlBuilder.php       | 16 ++++++++++++----
 tests/Build/DocumentBuilderTest.php |  8 ++++++++
 tests/Writer/BodyXmlBuilderTest.php | 28 ++++++++++++++++++++++++++++
 10 files changed, 81 insertions(+), 8 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c29dfbb..dd9aa3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,11 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   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.
 
 ### Fixed
 - A tab or a newline in run text was written as is inside ``, where
diff --git a/README.md b/README.md
index 3471b7b..54c0416 100644
--- a/README.md
+++ b/README.md
@@ -377,6 +377,7 @@ Paragraph-level styling:
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // or alignRight()/alignJustify()
+    ->styleId('Quote')        // a style id from the StyleRegistry
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('Indented & spaced')
@@ -641,7 +642,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[] }` |
diff --git a/docs/de/README.md b/docs/de/README.md
index 4defd62..7288135 100644
--- a/docs/de/README.md
+++ b/docs/de/README.md
@@ -298,6 +298,7 @@ Absatz-Stil:
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // oder alignRight()/alignJustify()
+    ->styleId('Quote')        // eine Style-ID aus der StyleRegistry
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('Eingerückt')
@@ -564,7 +565,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 }` |
diff --git a/docs/ru/README.md b/docs/ru/README.md
index eb58e9d..4d02912 100644
--- a/docs/ru/README.md
+++ b/docs/ru/README.md
@@ -294,6 +294,7 @@ $count = DocumentBuilder::new()->paragraph('Привет')->toFile('out.docx');
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // alignRight()/alignJustify()
+    ->styleId('Quote')        // id стиля из StyleRegistry
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('С отступами')
@@ -557,7 +558,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 }` |
diff --git a/docs/zh/README.md b/docs/zh/README.md
index bb3341a..99384ca 100644
--- a/docs/zh/README.md
+++ b/docs/zh/README.md
@@ -289,6 +289,7 @@ $count = DocumentBuilder::new()->paragraph('你好')->toFile('out.docx');
 ```php
 ->paragraph(fn ($p) => $p
     ->alignCenter()           // 或 alignRight() / alignJustify()
+    ->styleId('Quote')        // StyleRegistry 中的样式 id
     ->indentMm(left: 20, firstLine: 10)
     ->spacingPt(before: 6, after: 12)
     ->text('带缩进与间距')
@@ -548,7 +549,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 }` |
diff --git a/src/Build/ParagraphBuilder.php b/src/Build/ParagraphBuilder.php
index 8c1fc33..7334eb8 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
@@ -173,6 +186,7 @@ public function build(): Paragraph
             children: $this->children,
             style: $this->style,
             headingLevel: $this->headingLevel,
+            styleId: $this->styleId,
         );
     }
 }
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/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php
index 9bc9ea5..d065125 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 .= '';
diff --git a/tests/Build/DocumentBuilderTest.php b/tests/Build/DocumentBuilderTest.php
index 6cb0b6c..72781b9 100644
--- a/tests/Build/DocumentBuilderTest.php
+++ b/tests/Build/DocumentBuilderTest.php
@@ -239,4 +239,12 @@ 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);
+    }
 }
diff --git a/tests/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php
index 36a74fb..f710972 100644
--- a/tests/Writer/BodyXmlBuilderTest.php
+++ b/tests/Writer/BodyXmlBuilderTest.php
@@ -199,6 +199,34 @@ 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_cant_split_row_before_header_and_height(): void
     {

From 96bd2cb03f623317eba1ab1d6cfbb88f34226343 Mon Sep 17 00:00:00 2001
From: Dan Krieger 
Date: Mon, 19 Oct 2026 15:15:05 -0700
Subject: [PATCH 06/14] feat: w:pageBreakBefore and w:widowControl on
 paragraphs

ParagraphStyle::$pageBreakBefore and ParagraphStyle::$widowControl
(?bool: null writes nothing, false writes w:val="0") are written in
CT_PPrBase order (after keepLines, before numPr), read back by
OoxmlPropertyParser/StylesResolver, and available on ParagraphBuilder.
The reader already parsed pageBreakBefore but dropped it on the way into
the model, so a paragraph that started a new page lost its break on a
read/write round trip.

HTML: pageBreakBefore serializes as break-before: page; the converter
parses break-before: page and page-break-before: always. widowControl has
no HTML mapping.

Co-Authored-By: Claude Opus 5.5 
---
 CHANGELOG.md                                  |  8 ++++++
 README.md                                     |  3 ++-
 docs/de/README.md                             |  3 ++-
 docs/ru/README.md                             |  3 ++-
 docs/zh/README.md                             |  3 ++-
 src/Build/ParagraphBuilder.php                | 16 ++++++++++++
 src/Html/Serializer.php                       |  3 +++
 .../StyleApplier/ParagraphStyleApplier.php    | 10 ++++++++
 src/Reader/OoxmlPropertyParser.php            |  4 ++-
 src/Reader/StylesResolver.php                 |  2 ++
 src/Style/ParagraphStyle.php                  | 22 +++++++++++++++-
 src/Writer/BodyXmlBuilder.php                 |  7 ++++++
 tests/Build/DocumentBuilderTest.php           |  8 ++++++
 tests/Html/ConverterTest.php                  | 11 ++++++++
 tests/Html/SerializerTest.php                 | 11 ++++++++
 tests/Reader/BodyReaderTest.php               | 17 +++++++++++++
 tests/Writer/BodyXmlBuilderTest.php           | 25 +++++++++++++++++++
 17 files changed, 150 insertions(+), 6 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd9aa3f..f491d6f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,14 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   `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.
 
 ### Fixed
 - A tab or a newline in run text was written as is inside ``, where
diff --git a/README.md b/README.md
index 54c0416..ddac15e 100644
--- a/README.md
+++ b/README.md
@@ -378,6 +378,7 @@ Paragraph-level styling:
 ->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')
@@ -658,7 +659,7 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace.
 Styles live under `Dskripchenko\PhpDocx\Style`:
 
 - `RunStyle` — font, weight, italic, color, size, highlight, …
-- `ParagraphStyle` — alignment, indents, spacing, borders
+- `ParagraphStyle` — alignment, indents, spacing, borders, pagination (keepWithNext, keepLines, pageBreakBefore, widowControl)
 - `CellStyle` — width, padding, borders, valign, gridSpan, rowSpan
 - `TableStyle` — width, borders, alignment, cell margins, layout
 - `PageSetup`, `PaperSize`, `Orientation`, `Alignment`, `VerticalAlign`,
diff --git a/docs/de/README.md b/docs/de/README.md
index 7288135..6c578e8 100644
--- a/docs/de/README.md
+++ b/docs/de/README.md
@@ -299,6 +299,7 @@ Absatz-Stil:
 ->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')
@@ -581,7 +582,7 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`.
 Styles liegen unter `Dskripchenko\PhpDocx\Style`:
 
 - `RunStyle` — Schrift, Gewicht, Kursiv, Farbe, Grösse, Highlight, …
-- `ParagraphStyle` — Ausrichtung, Einzüge, Abstände, Rahmen
+- `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
diff --git a/docs/ru/README.md b/docs/ru/README.md
index 4d02912..8b56984 100644
--- a/docs/ru/README.md
+++ b/docs/ru/README.md
@@ -295,6 +295,7 @@ $count = DocumentBuilder::new()->paragraph('Привет')->toFile('out.docx');
 ->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('С отступами')
@@ -574,7 +575,7 @@ foreach ($variables as $v) {
 Стили — namespace `Dskripchenko\PhpDocx\Style`:
 
 - `RunStyle` — шрифт, weight, italic, color, size, highlight, …
-- `ParagraphStyle` — alignment, indents, spacing, borders
+- `ParagraphStyle` — alignment, indents, spacing, borders, разбиение на страницы (keepWithNext, keepLines, pageBreakBefore, widowControl)
 - `CellStyle` — width, padding, borders, valign, gridSpan, rowSpan
 - `TableStyle` — width, borders, alignment, cell margins, layout
 - `PageSetup`, `PaperSize`, `Orientation`, `Alignment`, `VerticalAlign`,
diff --git a/docs/zh/README.md b/docs/zh/README.md
index 99384ca..d1e3105 100644
--- a/docs/zh/README.md
+++ b/docs/zh/README.md
@@ -290,6 +290,7 @@ $count = DocumentBuilder::new()->paragraph('你好')->toFile('out.docx');
 ->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('带缩进与间距')
@@ -565,7 +566,7 @@ foreach ($variables as $v) {
 样式位于 `Dskripchenko\PhpDocx\Style`:
 
 - `RunStyle` — 字体、weight、italic、color、size、highlight 等
-- `ParagraphStyle` — alignment、缩进、间距、边框
+- `ParagraphStyle` — alignment、缩进、间距、边框、分页(keepWithNext、keepLines、pageBreakBefore、widowControl)
 - `CellStyle` — width、padding、边框、valign、gridSpan、rowSpan
 - `TableStyle` — width、边框、对齐、单元格边距、布局
 - `PageSetup`、`PaperSize`、`Orientation`、`Alignment`、`VerticalAlign`、
diff --git a/src/Build/ParagraphBuilder.php b/src/Build/ParagraphBuilder.php
index 7334eb8..1e2c341 100644
--- a/src/Build/ParagraphBuilder.php
+++ b/src/Build/ParagraphBuilder.php
@@ -165,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);
diff --git a/src/Html/Serializer.php b/src/Html/Serializer.php
index 5b377d4..81f18ff 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;
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/Reader/OoxmlPropertyParser.php b/src/Reader/OoxmlPropertyParser.php
index 6287383..934dfda 100644
--- a/src/Reader/OoxmlPropertyParser.php
+++ b/src/Reader/OoxmlPropertyParser.php
@@ -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),
diff --git a/src/Reader/StylesResolver.php b/src/Reader/StylesResolver.php
index 72207f3..531af7c 100644
--- a/src/Reader/StylesResolver.php
+++ b/src/Reader/StylesResolver.php
@@ -319,6 +319,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,
         );
     }
 
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/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php
index d065125..5c6fa7e 100644
--- a/src/Writer/BodyXmlBuilder.php
+++ b/src/Writer/BodyXmlBuilder.php
@@ -248,6 +248,13 @@ private function renderParagraphProperties(
         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.
diff --git a/tests/Build/DocumentBuilderTest.php b/tests/Build/DocumentBuilderTest.php
index 72781b9..af38219 100644
--- a/tests/Build/DocumentBuilderTest.php
+++ b/tests/Build/DocumentBuilderTest.php
@@ -247,4 +247,12 @@ public function paragraph_builder_sets_style_id(): void
         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/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..178956b 100644 --- a/tests/Html/SerializerTest.php +++ b/tests/Html/SerializerTest.php @@ -103,6 +103,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 { 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/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php index f710972..61ffffa 100644 --- a/tests/Writer/BodyXmlBuilderTest.php +++ b/tests/Writer/BodyXmlBuilderTest.php @@ -227,6 +227,31 @@ public function it_escapes_the_style_id(): void 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_cant_split_row_before_header_and_height(): void { From d519371b5e973c680fdbd91d93550eb18eea3971 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:16:54 -0700 Subject: [PATCH 07/14] feat: East Asian font (w:eastAsia) on run styles RunStyle::$fontFamilyEastAsia (appended to the constructor), with withFontFamilyEastAsia() and RunStyleBuilder::fontFamilyEastAsia(), is written as w:eastAsia on , in document runs and in style run properties (a shared RunFonts helper). is written when either font is set; with only fontFamily the output is unchanged. The reader keeps an explicit w:eastAsia in its own slot; an eastAsiaTheme reference is not resolved (the theme map holds the Latin faces only). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++ README.md | 3 ++- docs/de/README.md | 3 ++- docs/ru/README.md | 3 ++- docs/zh/README.md | 3 ++- src/Build/RunStyleBuilder.php | 12 +++++++++ src/Html/StyleApplier/RunStyleApplier.php | 2 ++ src/Reader/OoxmlPropertyParser.php | 27 +++++++++++++++++-- src/Reader/StylesResolver.php | 4 +++ src/Style/RunStyle.php | 13 +++++++++ src/Writer/BodyXmlBuilder.php | 5 +--- src/Writer/RunFonts.php | 32 +++++++++++++++++++++++ src/Writer/StylesXmlBuilder.php | 5 +--- tests/Build/RunStyleBuilderTest.php | 9 +++++++ tests/Reader/StylesResolverTest.php | 17 ++++++++++++ tests/Writer/BodyXmlBuilderTest.php | 22 ++++++++++++++++ tests/Writer/StylesTest.php | 12 +++++++++ 17 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 src/Writer/RunFonts.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f491d6f..2f64dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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`. ### Fixed - A tab or a newline in run text was written as is inside ``, where diff --git a/README.md b/README.md index ddac15e..61d4f26 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,7 @@ RunStyleBuilder::new() ->backgroundColor('eeeeee') ->highlight('yellow') ->fontFamily('Arial') + ->fontFamilyEastAsia('SimSun') // w:eastAsia: the font for CJK text ->fontSizePt(14.5) ->build(); ``` @@ -658,7 +659,7 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace. Styles live under `Dskripchenko\PhpDocx\Style`: -- `RunStyle` — font, weight, italic, color, size, highlight, … +- `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 diff --git a/docs/de/README.md b/docs/de/README.md index 6c578e8..c860d03 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -379,6 +379,7 @@ RunStyleBuilder::new() ->backgroundColor('eeeeee') ->highlight('yellow') ->fontFamily('Arial') + ->fontFamilyEastAsia('SimSun') // w:eastAsia: die Schrift für CJK-Text ->fontSizePt(14.5) ->build(); ``` @@ -581,7 +582,7 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`. Styles liegen unter `Dskripchenko\PhpDocx\Style`: -- `RunStyle` — Schrift, Gewicht, Kursiv, Farbe, Grösse, Highlight, … +- `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 diff --git a/docs/ru/README.md b/docs/ru/README.md index 8b56984..b6ebfc6 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -375,6 +375,7 @@ RunStyleBuilder::new() ->backgroundColor('eeeeee') ->highlight('yellow') ->fontFamily('Arial') + ->fontFamilyEastAsia('SimSun') // w:eastAsia: шрифт для текста CJK ->fontSizePt(14.5) ->build(); ``` @@ -574,7 +575,7 @@ foreach ($variables as $v) { Стили — namespace `Dskripchenko\PhpDocx\Style`: -- `RunStyle` — шрифт, weight, italic, color, size, highlight, … +- `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 diff --git a/docs/zh/README.md b/docs/zh/README.md index d1e3105..a467cec 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -370,6 +370,7 @@ RunStyleBuilder::new() ->backgroundColor('eeeeee') ->highlight('yellow') ->fontFamily('Arial') + ->fontFamilyEastAsia('SimSun') // w:eastAsia:中日韩文字所用字体 ->fontSizePt(14.5) ->build(); ``` @@ -565,7 +566,7 @@ foreach ($variables as $v) { 样式位于 `Dskripchenko\PhpDocx\Style`: -- `RunStyle` — 字体、weight、italic、color、size、highlight 等 +- `RunStyle` — 字体(中日韩文字用 `fontFamilyEastAsia`)、weight、italic、color、size、highlight 等 - `ParagraphStyle` — alignment、缩进、间距、边框、分页(keepWithNext、keepLines、pageBreakBefore、widowControl) - `CellStyle` — width、padding、边框、valign、gridSpan、rowSpan - `TableStyle` — width、边框、对齐、单元格边距、布局 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/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/Reader/OoxmlPropertyParser.php b/src/Reader/OoxmlPropertyParser.php index 934dfda..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 @@ -171,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 531af7c..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, ); } @@ -342,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/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/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index 5c6fa7e..7ed298e 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -403,10 +403,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/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/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/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/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php index 61ffffa..9b02e23 100644 --- a/tests/Writer/BodyXmlBuilderTest.php +++ b/tests/Writer/BodyXmlBuilderTest.php @@ -252,6 +252,28 @@ public function it_renders_page_break_before_and_widow_control_before_numpr(): v 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 { 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); + } } From abf1a952e19b99fb3c39898b78878c4d751f9f9a Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:12:49 -0700 Subject: [PATCH 08/14] fix: split a spanning cell's width across its grid columns computeGridColumns() repeated a spanning cell's full width for each column it spans, so a 2000-twip cell with gridSpan 2 produced two 2000-twip gridCols and the grid was wider than the cell. The spanned columns now share the width (width / span). The grid was also taken from the first row only. A column now takes its width from the first row where an unspanned cell with a known width covers it, falling back to a spanning cell's share, then to 2000 twips. A title row spanning the whole table no longer decides the columns of the rows below it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++ src/Writer/BodyXmlBuilder.php | 76 +++++++++++++++++++++-------- tests/Writer/BodyXmlBuilderTest.php | 57 ++++++++++++++++++++++ 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f64dee..431c58b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,13 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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 diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index 7ed298e..e699a86 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -571,9 +571,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 */ @@ -582,29 +586,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 = ''; diff --git a/tests/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php index 9b02e23..f019219 100644 --- a/tests/Writer/BodyXmlBuilderTest.php +++ b/tests/Writer/BodyXmlBuilderTest.php @@ -292,6 +292,63 @@ 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_puts_numpr_after_keep_next_in_a_list_item(): void { From 53352605de5f84023b37828e2781a5c399486c1c Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 15:14:40 -0700 Subject: [PATCH 09/14] feat: table indent (w:tblInd) TableStyle::$indentTwips and TableBuilder::indent() / indentMm() / indentPt() write in CT_TblPr order (after jc, before tblBorders). TableReader parses a dxa tblInd back; nil and auto read as no indent. The HTML serializer writes it as margin-left on the (unless the table is centred), and the converter reads a length margin-left back as the indent. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ README.md | 4 ++-- docs/de/README.md | 4 ++-- docs/ru/README.md | 4 ++-- docs/zh/README.md | 4 ++-- src/Build/TableBuilder.php | 23 +++++++++++++++++++ src/Html/Serializer.php | 2 ++ src/Html/StyleApplier/TableStyleApplier.php | 13 ++++++++++- src/Reader/TableReader.php | 10 +++++++++ src/Style/TableStyle.php | 4 ++++ src/Writer/BodyXmlBuilder.php | 7 +++++- tests/Build/TableBuilderTest.php | 15 +++++++++++++ tests/Html/SerializerTest.php | 12 ++++++++++ tests/Html/TableExtensionsTest.php | 14 ++++++++++++ tests/Reader/TableReaderTest.php | 15 +++++++++++++ tests/Writer/BodyXmlBuilderTest.php | 25 +++++++++++++++++++++ 16 files changed, 151 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 431c58b..0d2bd9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `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. ### Fixed - A tab or a newline in run text was written as is inside ``, where diff --git a/README.md b/README.md index 61d4f26..2757d46 100644 --- a/README.md +++ b/README.md @@ -480,7 +480,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` @@ -662,7 +662,7 @@ Styles live under `Dskripchenko\PhpDocx\Style`: - `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 c860d03..a14e1da 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -402,7 +402,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` @@ -586,7 +586,7 @@ Styles liegen unter `Dskripchenko\PhpDocx\Style`: - `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 b6ebfc6..850cc91 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -396,7 +396,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` @@ -578,7 +578,7 @@ foreach ($variables as $v) { - `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 a467cec..ced5a7d 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -392,7 +392,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` @@ -569,7 +569,7 @@ foreach ($variables as $v) { - `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/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/Html/Serializer.php b/src/Html/Serializer.php index 81f18ff..4baf81b 100644 --- a/src/Html/Serializer.php +++ b/src/Html/Serializer.php @@ -609,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/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/TableReader.php b/src/Reader/TableReader.php index edcff09..ed29c03 100644 --- a/src/Reader/TableReader.php +++ b/src/Reader/TableReader.php @@ -106,6 +106,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 +123,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 +154,7 @@ private function readTableStyle(?\DOMElement $tblPr): TableStyle cellMarginRightTwips: $cellMarRight, cellMarginBottomTwips: $cellMarBottom, cellMarginLeftTwips: $cellMarLeft, + indentTwips: $indentTwips, ); } 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 e699a86..d6323de 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -528,7 +528,7 @@ private function renderCaption(string $text): string 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 = ''; @@ -546,6 +546,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'); diff --git a/tests/Build/TableBuilderTest.php b/tests/Build/TableBuilderTest.php index acfc9d0..89409d5 100644 --- a/tests/Build/TableBuilderTest.php +++ b/tests/Build/TableBuilderTest.php @@ -208,6 +208,21 @@ 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 nested_table_in_cell(): void { diff --git a/tests/Html/SerializerTest.php b/tests/Html/SerializerTest.php index 178956b..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; @@ -126,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/TableReaderTest.php b/tests/Reader/TableReaderTest.php index 512c707..f8128a6 100644 --- a/tests/Reader/TableReaderTest.php +++ b/tests/Reader/TableReaderTest.php @@ -164,6 +164,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 { diff --git a/tests/Writer/BodyXmlBuilderTest.php b/tests/Writer/BodyXmlBuilderTest.php index f019219..0db3950 100644 --- a/tests/Writer/BodyXmlBuilderTest.php +++ b/tests/Writer/BodyXmlBuilderTest.php @@ -349,6 +349,31 @@ public function it_defaults_grid_columns_without_widths_to_2000(): void ); } + #[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(' Date: Mon, 19 Oct 2026 15:16:05 -0700 Subject: [PATCH 10/14] feat: exact row height (w:hRule="exact") The writer wrote every row height as w:hRule="atLeast". TableRow gains $heightRule (RowHeightRule::AtLeast, the default, or ::Exact), written as w:hRule, and TableRowBuilder::exactHeight(). TableReader reads hRule="exact" back; atLeast, auto and a missing hRule read as AtLeast, as before. The rule is carried through the reader's and the HTML converter's row-span reconstruction. The HTML serializer does not write row heights today, so the rule is not serialized either. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ README.md | 2 +- docs/de/README.md | 2 +- docs/ru/README.md | 2 +- docs/zh/README.md | 2 +- src/Build/TableRowBuilder.php | 21 +++++++++++++++- src/Element/TableRow.php | 4 ++++ src/Html/Converter.php | 3 ++- src/Reader/TableReader.php | 13 +++++++--- src/Style/RowHeightRule.php | 17 +++++++++++++ src/Writer/BodyXmlBuilder.php | 2 +- tests/Build/TableBuilderTest.php | 18 ++++++++++++++ tests/Reader/TableReaderTest.php | 37 +++++++++++++++++++++++++++++ tests/Writer/BodyXmlBuilderTest.php | 15 ++++++++++++ 14 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 src/Style/RowHeightRule.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2bd9e..85aac26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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. ### Fixed - A tab or a newline in run text was written as is inside ``, where diff --git a/README.md b/README.md index 2757d46..d692cab 100644 --- a/README.md +++ b/README.md @@ -652,7 +652,7 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace. | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker elements | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, 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 }` | diff --git a/docs/de/README.md b/docs/de/README.md index a14e1da..41eab5a 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -575,7 +575,7 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`. | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | beides | Marker-Elemente | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, 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 }` | diff --git a/docs/ru/README.md b/docs/ru/README.md index 850cc91..2a4c36a 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -568,7 +568,7 @@ foreach ($variables as $v) { | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker-элементы | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, 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 }` | diff --git a/docs/zh/README.md b/docs/zh/README.md index ced5a7d..545aba0 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -559,7 +559,7 @@ foreach ($variables as $v) { | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`、`PageBreak`、`HorizontalRule` | 两者 | 标记元素 | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | -| `TableRow` | element | `{ cells: TableCell[], isHeader, heightTwips, 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 }` | 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/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/Reader/TableReader.php b/src/Reader/TableReader.php index ed29c03..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; @@ -164,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; @@ -173,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; + } } } @@ -181,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 @@ -343,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; @@ -392,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/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/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index d6323de..63de314 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -656,7 +656,7 @@ private function renderTableRow(TableRow $row): string $trPr .= ''; } if ($row->heightTwips !== null) { - $trPr .= ''; + $trPr .= ''; } $trPrXml = $trPr === '' ? '' : ''.$trPr.''; diff --git a/tests/Build/TableBuilderTest.php b/tests/Build/TableBuilderTest.php index 89409d5..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; @@ -223,6 +224,23 @@ public function table_indent(): void 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/Reader/TableReaderTest.php b/tests/Reader/TableReaderTest.php index f8128a6..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; @@ -192,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 0db3950..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; @@ -374,6 +375,20 @@ public function it_omits_table_indent_by_default(): void 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 { From 075dc26afc0ab7722434b6e12038b8669cc8c147 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:28:15 +0300 Subject: [PATCH 11/14] fix: patch ZIP entry times in linear time, with bounds checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZipTimestamp::apply() called substr_replace() on the whole archive twice per entry, so a package with many media parts took time proportional to entries × size: 2000 entries of 8 KB took about 4 s, 2000 entries in a 60 MB package about 35 s. It now collects the offsets of the time fields while walking the central directory and writes the archive out in one pass. A central directory record has to fit before the end record and a local file header inside the string; otherwise the archive is returned unchanged instead of reading past the end. The class is marked @internal: it is a helper of Word2007Writer. Tests: a 2000-entry archive within a time budget, a truncated central directory, and identical bytes under TZ=UTC, Asia/Tokyo and America/New_York (in child processes, since libzip reads TZ at start). Co-Authored-By: Claude Opus 5.5 --- src/Writer/ZipTimestamp.php | 32 +++++++--- tests/Writer/DeterministicOutputTest.php | 77 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/src/Writer/ZipTimestamp.php b/src/Writer/ZipTimestamp.php index e899a01..1704ace 100644 --- a/src/Writer/ZipTimestamp.php +++ b/src/Writer/ZipTimestamp.php @@ -18,6 +18,8 @@ * every local file header and every central directory record. libzip writes no * extended-timestamp extra fields, so these two fields are the only place the * time is kept, and the CRC does not cover them. + * + * @internal Used by Word2007Writer; not part of the public API. */ final class ZipTimestamp { @@ -49,27 +51,43 @@ public static function apply(string $zip, ?\DateTimeInterface $at = null): strin return $zip; } - $stamp = pack('vv', $time, $date); - $patched = $zip; + // First collect the offset of every time field, then write the archive + // out once: patching the string entry by entry would copy all of it per + // entry, which is quadratic for a package with many images. + $offsets = []; + $length = strlen($zip); $pos = $end['offset']; for ($i = 0; $i < $end['entries']; $i++) { - if (substr($zip, $pos, 4) !== self::CENTRAL_HEADER) { + if ($pos + 46 > $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 (substr($zip, $local, 4) !== self::LOCAL_HEADER) { + if ($local + 30 > $length || substr($zip, $local, 4) !== self::LOCAL_HEADER) { return $zip; } - $patched = substr_replace($patched, $stamp, $pos + 12, 4); - $patched = substr_replace($patched, $stamp, $local + 10, 4); + $offsets[] = $pos + 12; + $offsets[] = $local + 10; $pos += 46 + $entry['nameLength'] + $entry['extraLength'] + $entry['commentLength']; } - return $patched; + // 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); } /** diff --git a/tests/Writer/DeterministicOutputTest.php b/tests/Writer/DeterministicOutputTest.php index 2add527..fc4441c 100644 --- a/tests/Writer/DeterministicOutputTest.php +++ b/tests/Writer/DeterministicOutputTest.php @@ -102,6 +102,83 @@ 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. From a2ff6218bd292a2318597cc7c8644c16683583b4 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:28:42 +0300 Subject: [PATCH 12/14] fix: write \v and \f in run text as a line break and a page break XmlEscape drops characters outside the XML 1.0 Char production, so a vertical tab or a form feed in run text disappeared and glued the words on either side together. Word's object model and clipboard use exactly these two for a manual line break and a page break, so text taken from Word lost its breaks. renderRunContent() now splits on them too: "\v" becomes and "\f" . Other non-XML characters are still dropped. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++--- src/Writer/BodyXmlBuilder.php | 10 ++++++++-- tests/Writer/RunTextTest.php | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85aac26..f67f2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,9 +49,11 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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`, a vertical tab pasted from Word, …) - went into the XML as is, and Word refused to open the file. `XmlEscape` - now removes every character XML 1.0 does not allow. +- 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 diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index 63de314..d60eea4 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -375,10 +375,15 @@ private function renderRun(Run $r): string * 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)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + $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).''; @@ -388,7 +393,8 @@ private static function renderRunContent(string $text): string foreach ($parts as $part) { $xml .= match ($part) { "\t" => '', - "\r\n", "\r", "\n" => '', + "\r\n", "\r", "\n", "\x0B" => '', + "\x0C" => '', // xml:space="preserve" keeps the leading and trailing spaces. default => ''.XmlEscape::text($part).'', }; diff --git a/tests/Writer/RunTextTest.php b/tests/Writer/RunTextTest.php index 77d3910..ea9b151 100644 --- a/tests/Writer/RunTextTest.php +++ b/tests/Writer/RunTextTest.php @@ -57,6 +57,24 @@ public function every_newline_form_becomes_w_br(): void 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 { From 09da60ad65a361429bc1cdad0d1872bb437bd462 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:28:55 +0300 Subject: [PATCH 13/14] docs: list the ZIP entry time and table margin-left changes Both change the output for existing callers, so they belong under Changed and not only under Added/Fixed: entries are stamped with CoreProperties::$modified or 1980-01-01 instead of the write time, and an HTML table with a length margin-left now gets . Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f67f2ab..1fa95cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,15 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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 From 10f0c24d0ab65249bb960846995a0dcb813ac2d2 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:29:25 +0300 Subject: [PATCH 14/14] docs: note that styleId() needs a defined style The README example writes styleId('Quote'), but Quote is not in the default registry, and Word shows a paragraph with an unknown style id as Normal. One line in all four READMEs says which ids exist by default and that others have to be registered with StyleRegistry::paragraph(). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++++ docs/de/README.md | 4 ++++ docs/ru/README.md | 4 ++++ docs/zh/README.md | 3 +++ 4 files changed, 15 insertions(+) diff --git a/README.md b/README.md index d692cab..99d4357 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,10 @@ Paragraph-level styling: ) ``` +`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 diff --git a/docs/de/README.md b/docs/de/README.md index 41eab5a..bbf6e25 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -306,6 +306,10 @@ Absatz-Stil: ) ``` +`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 diff --git a/docs/ru/README.md b/docs/ru/README.md index 2a4c36a..b5f918d 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -302,6 +302,10 @@ $count = DocumentBuilder::new()->paragraph('Привет')->toFile('out.docx'); ) ``` +`styleId()` действует только для стиля, который есть в документе: `Heading1..6`, +`ListParagraph`, `Caption` или зарегистрированный через `StyleRegistry::paragraph()`. +Абзац с неизвестным id Word показывает как Normal. + ### TableBuilder ```php diff --git a/docs/zh/README.md b/docs/zh/README.md index 545aba0..4103c09 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -297,6 +297,9 @@ $count = DocumentBuilder::new()->paragraph('你好')->toFile('out.docx'); ) ``` +`styleId()` 仅对文档中已定义的样式生效:`Heading1..6`、`ListParagraph`、`Caption`, +或通过 `StyleRegistry::paragraph()` 注册的样式。id 未知的段落在 Word 中按 Normal 显示。 + ### TableBuilder ```php