From 7eb2215b22542996c9ebb193d6632d6de59fab07 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Sun, 18 Oct 2026 23:16:27 +0300 Subject: [PATCH 1/6] fix(reader): headers and footers of the first section, titlePg and even/odd as Word shows them Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 10 +++ src/Reader/DocxReader.php | 45 +++++++++---- tests/Reader/SectionHeadersTest.php | 97 +++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 tests/Reader/SectionHeadersTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f375f28..dd5d797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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 +- Reading headers and footers took the references of every section, and a + later section's part won: a seven-page form came out with the footer of + its last section and the first-page footer of its first. The first + section's parts are read now — they govern the document's first pages — + and first-page parts only with ``, even-page ones only with + ``, as Word shows them. + ## [1.14.1] — 2026-08-17 ### Changed diff --git a/src/Reader/DocxReader.php b/src/Reader/DocxReader.php index 1f40246..0f94e99 100644 --- a/src/Reader/DocxReader.php +++ b/src/Reader/DocxReader.php @@ -54,9 +54,20 @@ public function readPackage(DocxPackage $pkg): Document $body = $bodyReader->read($bodyEl); $pageSetup = (new SectionReader)->readPageSetup($bodyEl); - // Map path → type through the sectPr references. - $headerTypes = $this->resolveHeaderTypes($bodyEl, $pkg, isFooter: false); - $footerTypes = $this->resolveHeaderTypes($bodyEl, $pkg, isFooter: true); + // Map path → type through the references of the FIRST section: the + // document is read as one section, and its first pages are what the + // first section's parts govern. Taking the references of every + // section let a later section's parts win, and a first-page footer + // showed up where Word shows none. + $sectPr = $bodyEl->getElementsByTagNameNS(OoxmlNs::W, 'sectPr')->item(0); + $scope = $sectPr instanceof \DOMElement ? $sectPr : $bodyEl; + $headerTypes = $this->resolveHeaderTypes($scope, $pkg, isFooter: false); + $footerTypes = $this->resolveHeaderTypes($scope, $pkg, isFooter: true); + // Word shows the first-page parts only with , the + // even-page ones only with in the settings. + $usesFirst = $sectPr instanceof \DOMElement && self::onFlag($sectPr, 'titlePg'); + $usesEven = $pkg->settingsXml?->documentElement instanceof \DOMElement + && self::onFlag($pkg->settingsXml->documentElement, 'evenAndOddHeaders'); $headersBy = [ 'default' => [], @@ -71,13 +82,13 @@ public function readPackage(DocxPackage $pkg): Document $watermarkText = null; foreach ($pkg->headers as $partPath => $headerDoc) { - $type = $headerTypes[$partPath] ?? 'default'; + $type = $headerTypes[$partPath] ?? null; // A watermark usually sits in the default header; extract it from any // one (the first found wins) so that HTML does not get duplicates. $watermarkText ??= (new WatermarkExtractor)->extract($headerDoc); $headerRoot = $headerDoc->documentElement; - if (! $headerRoot instanceof \DOMElement) { + if ($type === null || ! $headerRoot instanceof \DOMElement) { continue; } $hImg = new ImageReader($pkg, $partPath); @@ -86,9 +97,9 @@ public function readPackage(DocxPackage $pkg): Document } foreach ($pkg->footers as $partPath => $footerDoc) { - $type = $footerTypes[$partPath] ?? 'default'; + $type = $footerTypes[$partPath] ?? null; $footerRoot = $footerDoc->documentElement; - if (! $footerRoot instanceof \DOMElement) { + if ($type === null || ! $footerRoot instanceof \DOMElement) { continue; } $fImg = new ImageReader($pkg, $partPath); @@ -102,10 +113,10 @@ public function readPackage(DocxPackage $pkg): Document header: $headersBy['default'], footer: $footersBy['default'], pageSetup: $pageSetup, - firstHeader: $headersBy['first'], - firstFooter: $footersBy['first'], - evenHeader: $headersBy['even'], - evenFooter: $footersBy['even'], + firstHeader: $usesFirst ? $headersBy['first'] : [], + firstFooter: $usesFirst ? $footersBy['first'] : [], + evenHeader: $usesEven ? $headersBy['even'] : [], + evenFooter: $usesEven ? $footersBy['even'] : [], ), watermarkText: $watermarkText, ); @@ -146,6 +157,18 @@ private function resolveHeaderTypes(\DOMElement $body, DocxPackage $pkg, bool $i return $out; } + /** An on/off element among the children: present and not `w:val="0|false|off"`. */ + private static function onFlag(\DOMElement $parent, string $name): bool + { + foreach ($parent->childNodes as $child) { + if ($child instanceof \DOMElement && $child->namespaceURI === OoxmlNs::W && $child->localName === $name) { + return ! in_array(strtolower($child->getAttributeNS(OoxmlNs::W, 'val')), ['0', 'false', 'off'], true); + } + } + + return false; + } + /** * @param list $items * @return list diff --git a/tests/Reader/SectionHeadersTest.php b/tests/Reader/SectionHeadersTest.php new file mode 100644 index 0000000..98e7f03 --- /dev/null +++ b/tests/Reader/SectionHeadersTest.php @@ -0,0 +1,97 @@ +`, even-page ones ``. + */ +final class SectionHeadersTest extends TestCase +{ + private static function docx(): string + { + return (new Word2007Writer)->write(DocumentBuilder::new() + ->footer(fn ($f) => $f->paragraph('Default footer')) + ->firstFooter(fn ($f) => $f->paragraph('First footer')) + ->paragraph('Body') + ->build()); + } + + /** @param \Closure(string): string $edit */ + private static function editDocumentXml(string $docx, \Closure $edit): string + { + $path = tempnam(sys_get_temp_dir(), 'docx'); + file_put_contents($path, $docx); + $zip = new \ZipArchive; + $zip->open($path); + $zip->addFromString('word/document.xml', $edit((string) $zip->getFromName('word/document.xml'))); + $zip->close(); + $bytes = (string) file_get_contents($path); + @unlink($path); + + return $bytes; + } + + /** @param list<\Dskripchenko\PhpDocx\Element\BlockElement> $blocks */ + private static function text(array $blocks): string + { + $p = $blocks[0] ?? null; + + return $p instanceof Paragraph ? implode('', array_map(static fn ($r) => $r->text ?? '', $p->children)) : ''; + } + + #[Test] + public function first_page_parts_need_title_pg(): void + { + $with = (new DocxReader)->read(self::docx()); + self::assertSame('First footer', self::text($with->section->firstFooter)); + + $without = (new DocxReader)->read(self::editDocumentXml(self::docx(), static fn (string $xml): string => str_replace('', '', $xml))); + self::assertSame([], $without->section->firstFooter); + self::assertSame('Default footer', self::text($without->section->footer)); + } + + #[Test] + public function the_first_sections_parts_win_over_later_sections(): void + { + // A section break before the body's own section: the first section + // shows the first-page footer part as its default footer, without + // titlePg. + $bytes = self::editDocumentXml(self::docx(), static function (string $xml): string { + preg_match('//', $xml, $m); + + return (string) preg_replace( + '//', + '', + $xml, + 1, + ); + }); + + $doc = (new DocxReader)->read($bytes); + self::assertSame('First footer', self::text($doc->section->footer)); + self::assertSame([], $doc->section->firstFooter); + } + + #[Test] + public function even_parts_need_even_and_odd_headers(): void + { + $bytes = (new Word2007Writer)->write(DocumentBuilder::new() + ->footer(fn ($f) => $f->paragraph('Odd')) + ->evenFooter(fn ($f) => $f->paragraph('Even')) + ->paragraph('Body') + ->build()); + + self::assertSame('Even', self::text((new DocxReader)->read($bytes)->section->evenFooter)); + } +} From 4befbfbd1ef92dbf95ebeaaa1f3e92af3644ede5 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Mon, 19 Oct 2026 07:27:01 +0300 Subject: [PATCH 2/6] fix(reader): lists inside table cells keep their markers Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ src/Reader/TableReader.php | 18 ++++-------------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5d797..3359b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). section's parts are read now — they govern the document's first pages — and first-page parts only with ``, even-page ones only with ``, as Word shows them. +- A table cell was read paragraph by paragraph, and its numbered and + bulleted items came out as plain paragraphs without markers. A cell is now + read like a body: lists, nested tables and all. ## [1.14.1] — 2026-08-17 diff --git a/src/Reader/TableReader.php b/src/Reader/TableReader.php index 48e78b9..f512514 100644 --- a/src/Reader/TableReader.php +++ b/src/Reader/TableReader.php @@ -177,20 +177,10 @@ private function readCell(\DOMElement $tc): TableCell $tcPr = OoxmlNs::firstChild($tc, OoxmlNs::W, 'tcPr'); $style = $this->readCellStyle($tcPr); - $children = []; - foreach ($tc->childNodes as $child) { - if (! $child instanceof \DOMElement || $child->namespaceURI !== OoxmlNs::W) { - continue; - } - if ($child->localName === 'p') { - foreach ($this->body->readParagraph($child) as $b) { - $children[] = $b; - } - } elseif ($child->localName === 'tbl') { - $children[] = $this->read($child); - } - // tcPr — skip - } + // A cell holds what a body holds: paragraphs, lists, tables. Read + // paragraph by paragraph, its numbered and bulleted items lost their + // markers. + $children = $this->body->read($tc); if ($children === []) { $children = [new Paragraph([])]; } From 22866ccbbb8452fb56a3a1f047d8b45ddab949f5 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Mon, 19 Oct 2026 07:32:52 +0300 Subject: [PATCH 3/6] =?UTF-8?q?feat(html):=20Word=20multilevel=20numbering?= =?UTF-8?q?=20through=20HTML=20=E2=80=94=20continued=20lists,=20parent=20l?= =?UTF-8?q?abels,=20::marker=20level=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 13 +++ README.md | 12 ++- docs/de/README.md | 13 ++- docs/ru/README.md | 12 ++- docs/zh/README.md | 11 +- phpstan-baseline.neon | 6 -- src/Element/ListItem.php | 7 ++ src/Element/ListNode.php | 9 ++ src/Html/Converter.php | 124 ++++++++++++++++++++-- src/Html/ImportedDocument.php | 6 ++ src/Html/Serializer.php | 86 ++++++++++++++- src/Reader/BodyReader.php | 79 ++++++++++++-- src/Reader/NumberingDefinitions.php | 26 ++++- src/Reader/NumberingReader.php | 15 ++- src/Writer/BodyXmlBuilder.php | 73 +++++++++++++ src/Writer/NumberingXmlBuilder.php | 56 ++++++++++ tests/Html/WordNumberingRoundTripTest.php | 77 ++++++++++++++ 17 files changed, 577 insertions(+), 48 deletions(-) create mode 100644 tests/Html/WordNumberingRoundTripTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3359b40..541ee0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Word's multilevel numbering through HTML. The reader counts lists on the + way Word does — a list broken off by a paragraph continues (`
    `), + a level below hangs under its parent's number through a label-less parent + item (`ListItem::$markerHidden`) — and keeps each level's label pattern + (`ListNode::$levelText`: `%1.%2.`, `4.%1.`). The serializer writes the + labels as `::marker` rules on per-level classes into + `ImportedDocument::$css`; the converter reads them back from `'.$imported->bodyHtml)`; +an application that inlines CSS first keeps the labels with +`Converter::markerStyleOf($html)`. --- diff --git a/docs/de/README.md b/docs/de/README.md index 4e7f83c..ca091a8 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -187,7 +187,7 @@ Das Eingabe-HTML darf **nur Inline-Styles** verwenden (keine | Links | `` extern, `` intern, `` Lesezeichen | | Bilder | `` | | Tabellen | ``, `/`, ``, `/`, `
    /`, `
    `, `colspan`, `rowspan` | -| Listen | `