From 071c08aad1561a5e361aa23dcf10fe9d300ea990 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Tue, 27 Oct 2026 21:35:06 -0700 Subject: [PATCH 1/4] feat: several sections per document, and custom page sizes A Document takes further sections after the first (followingSections), each with its own page setup, start (SectionStart: next page, continuous, even or odd page) and header/footer parts. Every section but the last ends with its w:sectPr in the pPr of its last paragraph, or of a paragraph added when it ends in a table. A section without a header or footer of a kind shows the previous section's, as in Word. DocumentBuilder::newSection() starts one. PageSetup takes a custom width and height in twips, so a section's page can be any size; the writer writes it as given and the reader keeps a size no PaperSize matches instead of turning it into A4. DocxReader reads every section: body, page setup, start, headers and footers. Code that reads only $document->section now sees the first section of a multi-section file. The HTML serializer writes every section's body, with a page break before each that starts on a new page. The conformance reference document gains a landscape section and a custom-size one; it validates against the ECMA-376 Transitional schemas. README in all four languages and the changelog describe it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 24 +++++ README.md | 47 ++++++++- docs/de/README.md | 50 ++++++++- docs/ru/README.md | 49 ++++++++- docs/zh/README.md | 42 +++++++- scripts/conformance/generate.php | 15 ++- src/Build/DocumentBuilder.php | 54 ++++++++-- src/Document.php | 22 +++- src/Html/Serializer.php | 13 ++- src/Reader/DocxReader.php | 151 +++++++++++++++++---------- src/Reader/SectionReader.php | 33 ++++-- src/Section.php | 10 +- src/Style/PageSetup.php | 44 +++++++- src/Style/SectionStart.php | 19 ++++ src/Writer/BodyXmlBuilder.php | 22 +++- src/Writer/Word2007Writer.php | 153 ++++++++++++++++------------ tests/Build/SectionsBuilderTest.php | 61 +++++++++++ tests/Html/SerializerTest.php | 19 ++++ tests/Reader/SectionsReaderTest.php | 82 +++++++++++++++ tests/Writer/SectionsTest.php | 153 ++++++++++++++++++++++++++++ 20 files changed, 903 insertions(+), 160 deletions(-) create mode 100644 src/Style/SectionStart.php create mode 100644 tests/Build/SectionsBuilderTest.php create mode 100644 tests/Reader/SectionsReaderTest.php create mode 100644 tests/Writer/SectionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4485a74..ab8e460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Sections: `Document::$followingSections` (after the first section) and + `Document::sections()`, `Section::$start` (`SectionStart`: `NextPage`, + `Continuous`, `EvenPage`, `OddPage`), and `DocumentBuilder::newSection()`. + Each section has its own page setup and header/footer parts; every section + but the last ends with its `` in the pPr of its last paragraph + (one is added when it ends in a table). A section without a header or + footer of a kind shows the previous section's, as in Word. +- `PageSetup::$widthTwips` / `$heightTwips`: any page size, not only a + `PaperSize` (landscape when wider than tall), with `pageWidthTwips()`, + `pageHeightTwips()` and `effectiveOrientation()`. + +### Changed +- `DocxReader` reads every section — body, page setup, start, headers and + footers — instead of the last section's page setup and the first section's + headers for the whole document. **Code that reads only `$document->section` + now sees the first section of a multi-section file;** use + `$document->sections()` for all of it. A page size no `PaperSize` matches is + kept as a custom size instead of becoming A4. +- The HTML serializer writes every section's body, with a page break before + each section that starts on a new page. + ## [1.17.0] — 2026-10-27 ### Added diff --git a/README.md b/README.md index 99d4357..2da6c36 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ external dependencies beyond standard PHP extensions. - [Programmatic builder API](#programmatic-builder-api) - [DOCX → HTML (Reader)](#docx--html-reader) - [Headers, footers & watermarks](#headers-footers--watermarks) +- [Sections](#sections) - [Variable detection](#variable-detection) - [Length helpers](#length-helpers) - [AST overview](#ast-overview) @@ -65,6 +66,9 @@ external dependencies beyond standard PHP extensions. text patterns (`{{x}}`, `${x}`, `%x%`). - **Multi-header/footer** — default / first-page / even-pages variants with automatic `` and `` plumbing. +- **Sections** — several per document, each with its own page setup + (a preset paper size or any custom size), start (new page, continuous, + even or odd page) and headers/footers; read back section by section. - **Round-trip safe** — read DOCX → AST → write DOCX produces a valid document; bytes-level differences are limited to whitespace/ordering. - **PHP 8.2+** — `readonly` value-objects, named arguments, @@ -594,6 +598,45 @@ Renders as a 45°-rotated VML text shape on every page. --- +## Sections + +A document is one section unless the page setup or the headers change part +way through. `newSection()` ends the current section and starts the next: +the content, headers and footers added after it belong to the new one. + +```php +use Dskripchenko\PhpDocx\Build\DocumentBuilder; +use Dskripchenko\PhpDocx\Style\Orientation; +use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; + +DocumentBuilder::new() + ->header(fn ($h) => $h->paragraph('Report')) + ->paragraph('Portrait pages') + ->newSection(new PageSetup(orientation: Orientation::Landscape)) + ->header(fn ($h) => $h->paragraph('Appendix')) + ->paragraph('A wide table on landscape pages') + ->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage) + ->paragraph('A custom page size (A5 here), starting on an odd page') + ->toFile('sections.docx'); +``` + +- A new section keeps the page setup unless given one, and starts on a new + page unless given a `SectionStart` (`Continuous`, `EvenPage`, `OddPage`). +- A section shows the previous section's headers and footers until it sets + its own (Word's rule): an empty header list means "inherit", not "blank". +- `PageSetup(widthTwips:, heightTwips:)` sets any page size; the page is + landscape when it is wider than it is tall. +- Without the builder: `new Document($first, followingSections: [$second, …])`; + `Document::sections()` lists them all. +- Every section but the last ends with its `` in the properties + of its last paragraph (a paragraph is added when the section ends in a + table). `DocxReader` reads each section back with its own body, page + setup, start and headers; the HTML serializer puts the bodies one after + another, with a page break before each section that starts on a new page. + +--- + ## Variable detection Scans an imported DOCX for three kinds of variables: @@ -646,8 +689,8 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace. | Element | Type | Notes | |---|---|---| -| `Document` | root | `{ section: Section, watermarkText: ?string }` | -| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` | +| `Document` | root | `{ section: Section, watermarkText: ?string, coreProperties: ?CoreProperties, followingSections: Section[] }` | +| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter, start: SectionStart }` | | `Paragraph` | BlockElement | `{ children: InlineElement[], style: ParagraphStyle, headingLevel: ?int, styleId: ?string }` | | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children: InlineElement[] }` | diff --git a/docs/de/README.md b/docs/de/README.md index bbf6e25..9371661 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -34,6 +34,7 @@ Abhängigkeiten ausser Standard-PHP-Erweiterungen. - [Builder-API](#builder-api) - [DOCX → HTML (Reader)](#docx--html-reader) - [Kopf-/Fußzeilen & Wasserzeichen](#kopf-fußzeilen--wasserzeichen) +- [Abschnitte](#abschnitte) - [Variablenerkennung](#variablenerkennung) - [Längen-Helper](#längen-helper) - [AST-Übersicht](#ast-übersicht) @@ -61,6 +62,10 @@ Abhängigkeiten ausser Standard-PHP-Erweiterungen. konfigurierbare Textmuster (`{{x}}`, `${x}`, `%x%`). - **Multi-Header/Footer** — default / Titelseite / gerade Seiten, inklusive automatischem `` und ``. +- **Abschnitte** — mehrere pro Dokument, jeder mit eigener Seiteneinrichtung + (Papierformat oder beliebige Größe), eigenem Beginn (neue Seite, fortlaufend, + gerade oder ungerade Seite) und eigenen Kopf-/Fußzeilen; werden Abschnitt für + Abschnitt zurückgelesen. - **Round-Trip-sicher** — DOCX lesen → AST → DOCX schreiben liefert ein valides Dokument; byteweise Unterschiede beschränken sich auf Whitespace und Reihenfolge. @@ -516,6 +521,47 @@ Wird als um 45° gedrehte VML-Textform auf jeder Seite gerendert. --- +## Abschnitte + +Ein Dokument besteht aus einem Abschnitt, solange sich Seiteneinrichtung oder +Kopfzeilen nicht ändern. `newSection()` beendet den aktuellen Abschnitt und +beginnt den nächsten: Inhalte, Kopf- und Fußzeilen danach gehören zum neuen. + +```php +use Dskripchenko\PhpDocx\Build\DocumentBuilder; +use Dskripchenko\PhpDocx\Style\Orientation; +use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; + +DocumentBuilder::new() + ->header(fn ($h) => $h->paragraph('Report')) + ->paragraph('Portrait pages') + ->newSection(new PageSetup(orientation: Orientation::Landscape)) + ->header(fn ($h) => $h->paragraph('Appendix')) + ->paragraph('A wide table on landscape pages') + ->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage) + ->paragraph('A custom page size (A5 here), starting on an odd page') + ->toFile('sections.docx'); +``` + +- Ein neuer Abschnitt behält die Seiteneinrichtung, wenn keine angegeben ist, + und beginnt auf einer neuen Seite, wenn kein `SectionStart` angegeben ist + (`Continuous`, `EvenPage`, `OddPage`). +- Ein Abschnitt zeigt die Kopf- und Fußzeilen des vorherigen, bis er eigene + setzt (Regel von Word): eine leere Liste heißt „übernehmen“, nicht „leer“. +- `PageSetup(widthTwips:, heightTwips:)` setzt jede Seitengröße; die Seite ist + quer, wenn sie breiter als hoch ist. +- Ohne Builder: `new Document($first, followingSections: [$second, …])`; + `Document::sections()` liefert alle. +- Jeder Abschnitt außer dem letzten endet mit seinem `` in den + Eigenschaften seines letzten Absatzes (endet er mit einer Tabelle, wird ein + Absatz angefügt). `DocxReader` liest jeden Abschnitt mit eigenem Inhalt, + eigener Seiteneinrichtung, eigenem Beginn und eigenen Kopfzeilen zurück; der + HTML-Serializer setzt die Inhalte hintereinander, mit einem Seitenumbruch vor + jedem Abschnitt, der auf einer neuen Seite beginnt. + +--- + ## Variablenerkennung Durchsucht ein importiertes DOCX nach drei Arten von Variablen: @@ -569,8 +615,8 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`. | Element | Typ | Hinweise | |---|---|---| -| `Document` | root | `{ section: Section, watermarkText: ?string }` | -| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` | +| `Document` | root | `{ section: Section, watermarkText: ?string, coreProperties: ?CoreProperties, followingSections: Section[] }` | +| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter, start: SectionStart }` | | `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` | | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` | diff --git a/docs/ru/README.md b/docs/ru/README.md index b5f918d..d65e41f 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -34,6 +34,7 @@ PHP-расширения. - [Программный builder API](#программный-builder-api) - [DOCX → HTML (Reader)](#docx--html-reader) - [Headers, footers и watermark'и](#headers-footers-и-watermarki) +- [Разделы](#разделы) - [Детекция переменных](#детекция-переменных) - [Length-хелперы](#length-хелперы) - [AST overview](#ast-overview) @@ -60,6 +61,9 @@ PHP-расширения. настраиваемые текстовые паттерны (`{{x}}`, `${x}`, `%x%`). - **Multi-header/footer** — default / first-page / even-pages варианты с автоматическим `` и ``. +- **Разделы** — несколько в документе, у каждого свои параметры страницы + (стандартный формат или любой размер), начало (новая страница, без разрыва, + чётная или нечётная страница) и колонтитулы; читаются обратно по разделам. - **Round-trip safe** — DOCX → AST → DOCX даёт валидный документ; байтовые различия ограничены пробелами/порядком атрибутов. - **PHP 8.2+** — `readonly` value-objects, named аргументы, @@ -510,6 +514,47 @@ DocumentBuilder::new() --- +## Разделы + +Документ — это один раздел, пока по ходу не меняются параметры страницы или +колонтитулы. `newSection()` завершает текущий раздел и начинает следующий: +содержимое и колонтитулы, добавленные после него, относятся к новому. + +```php +use Dskripchenko\PhpDocx\Build\DocumentBuilder; +use Dskripchenko\PhpDocx\Style\Orientation; +use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; + +DocumentBuilder::new() + ->header(fn ($h) => $h->paragraph('Report')) + ->paragraph('Portrait pages') + ->newSection(new PageSetup(orientation: Orientation::Landscape)) + ->header(fn ($h) => $h->paragraph('Appendix')) + ->paragraph('A wide table on landscape pages') + ->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage) + ->paragraph('A custom page size (A5 here), starting on an odd page') + ->toFile('sections.docx'); +``` + +- Новый раздел сохраняет параметры страницы, если не заданы новые, и + начинается с новой страницы, если не задан `SectionStart` (`Continuous`, + `EvenPage`, `OddPage`). +- Раздел показывает колонтитулы предыдущего, пока не задаст свои (правило + Word): пустой список означает «унаследовать», а не «пусто». +- `PageSetup(widthTwips:, heightTwips:)` задаёт любой размер страницы; + страница альбомная, если она шире, чем выше. +- Без builder'а: `new Document($first, followingSections: [$second, …])`; + `Document::sections()` возвращает все разделы. +- Каждый раздел, кроме последнего, заканчивается своим `` в + свойствах последнего абзаца (если раздел заканчивается таблицей, + добавляется абзац). `DocxReader` читает каждый раздел с его содержимым, + параметрами страницы, началом и колонтитулами; HTML-сериализатор выводит + содержимое разделов подряд, с разрывом страницы перед каждым разделом, + который начинается с новой страницы. + +--- + ## Детекция переменных Сканирует DOCX и находит переменные трёх типов: @@ -562,8 +607,8 @@ foreach ($variables as $v) { | Элемент | Тип | Заметки | |---|---|---| -| `Document` | root | `{ section: Section, watermarkText: ?string }` | -| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` | +| `Document` | root | `{ section: Section, watermarkText: ?string, coreProperties: ?CoreProperties, followingSections: Section[] }` | +| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter, start: SectionStart }` | | `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` | | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` | diff --git a/docs/zh/README.md b/docs/zh/README.md index 4103c09..5987169 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -33,6 +33,7 @@ - [编程构造器 API](#编程构造器-api) - [DOCX → HTML(Reader)](#docx--html-reader) - [页眉、页脚与水印](#页眉页脚与水印) +- [节](#节) - [变量检测](#变量检测) - [长度辅助函数](#长度辅助函数) - [AST 概览](#ast-概览) @@ -57,6 +58,8 @@ (`{{x}}`、`${x}`、`%x%`)。 - **多页眉/页脚** — default / 首页 / 偶数页变体,自动处理 `` 与 ``。 +- **节** — 一个文档可有多个节,每节有自己的页面设置(标准纸张或任意尺寸)、 + 起始方式(新页、连续、偶数页或奇数页)和页眉/页脚;读取时逐节还原。 - **往返安全** — 读 DOCX → AST → 写 DOCX 得到有效文档;字节级别的差异 仅限于空白和顺序。 - **PHP 8.2+** — `readonly` 值对象、命名参数、构造函数提升、enum。 @@ -502,6 +505,41 @@ DocumentBuilder::new() --- +## 节 + +只要页面设置或页眉不在中途改变,文档就只有一个节。`newSection()` 结束当前节并开始 +下一节:之后添加的内容、页眉和页脚都属于新节。 + +```php +use Dskripchenko\PhpDocx\Build\DocumentBuilder; +use Dskripchenko\PhpDocx\Style\Orientation; +use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; + +DocumentBuilder::new() + ->header(fn ($h) => $h->paragraph('Report')) + ->paragraph('Portrait pages') + ->newSection(new PageSetup(orientation: Orientation::Landscape)) + ->header(fn ($h) => $h->paragraph('Appendix')) + ->paragraph('A wide table on landscape pages') + ->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage) + ->paragraph('A custom page size (A5 here), starting on an odd page') + ->toFile('sections.docx'); +``` + +- 新节在未指定时沿用页面设置,在未指定 `SectionStart`(`Continuous`、`EvenPage`、 + `OddPage`)时从新页开始。 +- 节在设置自己的页眉页脚之前显示上一节的(Word 的规则):空列表表示“继承”, + 而不是“空白”。 +- `PageSetup(widthTwips:, heightTwips:)` 可设置任意页面尺寸;宽大于高时为横向。 +- 不用构造器:`new Document($first, followingSections: [$second, …])`; + `Document::sections()` 返回所有节。 +- 除最后一节外,每节以其 `` 结束,写在该节最后一个段落的属性中(若该节以 + 表格结束,则添加一个段落)。`DocxReader` 逐节读回内容、页面设置、起始方式和页眉; + HTML 序列化器依次输出各节内容,并在每个从新页开始的节之前加分页符。 + +--- + ## 变量检测 扫描已导入的 DOCX,查找三种变量: @@ -552,8 +590,8 @@ foreach ($variables as $v) { | 元素 | 类型 | 备注 | |---|---|---| -| `Document` | root | `{ section: Section, watermarkText: ?string }` | -| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter }` | +| `Document` | root | `{ section: Section, watermarkText: ?string, coreProperties: ?CoreProperties, followingSections: Section[] }` | +| `Section` | container | `{ body, header, footer, pageSetup, firstHeader, firstFooter, evenHeader, evenFooter, start: SectionStart }` | | `Paragraph` | BlockElement | `{ children: InlineElement[], style, headingLevel: ?int, styleId: ?string }` | | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` | diff --git a/scripts/conformance/generate.php b/scripts/conformance/generate.php index 8728631..90b5f8d 100644 --- a/scripts/conformance/generate.php +++ b/scripts/conformance/generate.php @@ -6,8 +6,9 @@ * Generate the conformance reference DOCX — a document exercising every * writer feature at once: headings, justified paragraphs with spacing and * indents, tables with header rows, nested ordered lists, multi-part - * headers/footers with page-number fields, bookmarks, hyperlinks, and a - * text watermark. Validated by: + * headers/footers with page-number fields, bookmarks, hyperlinks, a text + * watermark, and a second section (landscape, its own header, a custom page + * size) whose first section ends in a table. Validated by: * * scripts/conformance/xsd-check.sh — ECMA-376 Transitional XSD * scripts/conformance/consumer-smoke.sh — LibreOffice + python-docx @@ -19,6 +20,9 @@ use Dskripchenko\PhpDocx\Build\DocumentBuilder; use Dskripchenko\PhpDocx\Element\ListFormat; use Dskripchenko\PhpDocx\Style\Alignment; +use Dskripchenko\PhpDocx\Style\Orientation; +use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; require dirname(__DIR__, 2).'/vendor/autoload.php'; @@ -56,6 +60,13 @@ ->paragraph(fn ($p) => $p ->text('Link: ') ->link('https://github.com/dskripchenko/php-docx', 'repository')) + ->table(fn ($t) => $t->row(['A section that ends in a table', 'w:sectPr in an added paragraph'])) + ->newSection(new PageSetup(orientation: Orientation::Landscape)) + ->header(fn ($h) => $h->paragraph('Landscape section header')) + ->heading(2, 'Sections') + ->paragraph('A landscape section with a header of its own.') + ->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::OddPage) + ->paragraph('A custom page size, starting on an odd page; it inherits the header.') ->toFile($outDir.'/reference.docx'); printf("wrote %s/reference.docx (%d bytes)\n", $outDir, $written); diff --git a/src/Build/DocumentBuilder.php b/src/Build/DocumentBuilder.php index c61c402..5d5781b 100644 --- a/src/Build/DocumentBuilder.php +++ b/src/Build/DocumentBuilder.php @@ -8,6 +8,7 @@ use Dskripchenko\PhpDocx\Section; use Dskripchenko\PhpDocx\Style\CoreProperties; use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; use Dskripchenko\PhpDocx\Style\StyleRegistry; use Dskripchenko\PhpDocx\Writer\Word2007Writer; @@ -66,6 +67,11 @@ final class DocumentBuilder /** @var list<\Dskripchenko\PhpDocx\Element\BlockElement> */ private array $evenFooterBlocks = []; + private SectionStart $start = SectionStart::NextPage; + + /** @var list
sections closed by newSection(), in order */ + private array $closedSections = []; + private function __construct() { $this->pageSetup = new PageSetup; @@ -199,21 +205,49 @@ public function evenFooter(callable $builderCallback): self return $this; } + /** + * Ends the current section and starts a new one: the content, headers and + * footers added from here on belong to it. It keeps the page setup unless + * given one, and shows the previous section's headers and footers until it + * sets its own (Word's rule). + */ + public function newSection(?PageSetup $pageSetup = null, SectionStart $start = SectionStart::NextPage): self + { + $this->closedSections[] = $this->currentSection(); + $this->blocks = []; + $this->headerBlocks = $this->footerBlocks = []; + $this->firstHeaderBlocks = $this->firstFooterBlocks = []; + $this->evenHeaderBlocks = $this->evenFooterBlocks = []; + $this->pageSetup = $pageSetup ?? $this->pageSetup; + $this->start = $start; + + return $this; + } + public function build(): Document { + $sections = [...$this->closedSections, $this->currentSection()]; + return new Document( - section: new Section( - body: $this->buildBlocks(), - header: $this->headerBlocks, - footer: $this->footerBlocks, - pageSetup: $this->pageSetup, - firstHeader: $this->firstHeaderBlocks, - firstFooter: $this->firstFooterBlocks, - evenHeader: $this->evenHeaderBlocks, - evenFooter: $this->evenFooterBlocks, - ), + section: $sections[0], watermarkText: $this->watermarkText, coreProperties: $this->coreProperties, + followingSections: array_slice($sections, 1), + ); + } + + private function currentSection(): Section + { + return new Section( + body: $this->buildBlocks(), + header: $this->headerBlocks, + footer: $this->footerBlocks, + pageSetup: $this->pageSetup, + firstHeader: $this->firstHeaderBlocks, + firstFooter: $this->firstFooterBlocks, + evenHeader: $this->evenHeaderBlocks, + evenFooter: $this->evenFooterBlocks, + start: $this->start, ); } diff --git a/src/Document.php b/src/Document.php index 2dd8c2f..8d31821 100644 --- a/src/Document.php +++ b/src/Document.php @@ -7,7 +7,8 @@ use Dskripchenko\PhpDocx\Style\CoreProperties; /** - * The root model of a DOCX document. In v1 there is one Section per document. + * The root model of a DOCX document: one Section, or several when the page + * setup or the headers change part way through (`$followingSections`). * * The watermark is plain text, rendered in the centre of the header (rotated * -45°, semi-transparent grey). Null means none is added. @@ -23,5 +24,24 @@ public function __construct( * empty properties part is worse than none, it just adds noise. */ public ?CoreProperties $coreProperties = null, + /** + * Sections after the first, in order. Each starts as its `start` says + * (on a new page by default) and has its own page setup; a section + * with no header or footer of a kind shows the previous section's, as + * in Word. + * + * @var list
+ */ + public array $followingSections = [], ) {} + + /** + * Every section, the first one included. + * + * @return non-empty-list
+ */ + public function sections(): array + { + return [$this->section, ...$this->followingSections]; + } } diff --git a/src/Html/Serializer.php b/src/Html/Serializer.php index 4baf81b..f3fee5f 100644 --- a/src/Html/Serializer.php +++ b/src/Html/Serializer.php @@ -31,6 +31,7 @@ use Dskripchenko\PhpDocx\Style\CellStyle; use Dskripchenko\PhpDocx\Style\ParagraphStyle; use Dskripchenko\PhpDocx\Style\RunStyle; +use Dskripchenko\PhpDocx\Style\SectionStart; use Dskripchenko\PhpDocx\Style\TableStyle; use Dskripchenko\PhpDocx\Style\VerticalAlign; @@ -84,7 +85,17 @@ public function serialize(Document $document, array $variables = []): ImportedDo $this->mediaCounter = 0; $this->markerRules = []; - $bodyHtml = $this->renderBlocks($document->section->body); + // Every section's body, one after another: a section that starts on a + // new page is preceded by a page break, as Word lays it out. The + // headers, footers and page settings are the first section's. + $body = []; + foreach ($document->sections() as $i => $section) { + if ($i > 0 && $section->start !== SectionStart::Continuous) { + $body[] = new PageBreak; + } + array_push($body, ...$section->body); + } + $bodyHtml = $this->renderBlocks($body); $headerHtml = $document->section->header !== [] ? $this->renderBlocks($document->section->header) : null; diff --git a/src/Reader/DocxReader.php b/src/Reader/DocxReader.php index 0f94e99..a01fa6a 100644 --- a/src/Reader/DocxReader.php +++ b/src/Reader/DocxReader.php @@ -51,77 +51,126 @@ public function readPackage(DocxPackage $pkg): Document $pkg->documentPartPath, $footnotes, ); - $body = $bodyReader->read($bodyEl); - $pageSetup = (new SectionReader)->readPageSetup($bodyEl); - - // 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'); + // The body splits into sections at each in a paragraph's + // pPr; the last section's sectPr is the body's own (or, when Word put + // it there, the last paragraph's). + $sectionReader = new SectionReader; + // Word shows the even-page parts only with in + // the settings, for every section. $usesEven = $pkg->settingsXml?->documentElement instanceof \DOMElement && self::onFlag($pkg->settingsXml->documentElement, 'evenAndOddHeaders'); - - $headersBy = [ - 'default' => [], - 'first' => [], - 'even' => [], - ]; - $footersBy = [ - 'default' => [], - 'first' => [], - 'even' => [], - ]; + /** @var array> $partBlocks header/footer part path → blocks, read once */ + $partBlocks = []; $watermarkText = null; - - foreach ($pkg->headers as $partPath => $headerDoc) { - $type = $headerTypes[$partPath] ?? null; + foreach ($pkg->headers as $headerDoc) { // 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 ($type === null || ! $headerRoot instanceof \DOMElement) { - continue; - } - $hImg = new ImageReader($pkg, $partPath); - $hReader = new BodyReader($styles, $numbering, $hImg, $pkg, $partPath); - $headersBy[$type] = $this->ensureBlocks($hReader->read($headerRoot)); } - foreach ($pkg->footers as $partPath => $footerDoc) { - $type = $footerTypes[$partPath] ?? null; - $footerRoot = $footerDoc->documentElement; - if ($type === null || ! $footerRoot instanceof \DOMElement) { - continue; + $sections = []; + $groups = $this->splitSections($bodyEl); + foreach ($groups as [$nodes, $sectPr]) { + $container = $bodyEl->ownerDocument?->createElementNS(OoxmlNs::W, 'w:body'); + if (! $container instanceof \DOMElement) { + throw new DocxException('Не удалось разобрать разделы word/document.xml.'); } - $fImg = new ImageReader($pkg, $partPath); - $fReader = new BodyReader($styles, $numbering, $fImg, $pkg, $partPath); - $footersBy[$type] = $this->ensureBlocks($fReader->read($footerRoot)); - } - - return new Document( - section: new Section( + foreach ($nodes as $node) { + $container->appendChild($node->cloneNode(true)); + } + $body = $bodyReader->read($container); + // Without a sectPr at all the references are looked for in the + // whole body, as before sections were read one by one. + $scope = $sectPr ?? (count($groups) === 1 ? $bodyEl : null); + $headerTypes = $scope === null ? [] : $this->resolveHeaderTypes($scope, $pkg, isFooter: false); + $footerTypes = $scope === null ? [] : $this->resolveHeaderTypes($scope, $pkg, isFooter: true); + // Word shows the first-page parts only with . + $usesFirst = $sectPr instanceof \DOMElement && self::onFlag($sectPr, 'titlePg'); + $headersBy = $this->partsByType($headerTypes, $pkg->headers, $pkg, $styles, $numbering, $partBlocks); + $footersBy = $this->partsByType($footerTypes, $pkg->footers, $pkg, $styles, $numbering, $partBlocks); + + $sections[] = new Section( body: $this->ensureBlocks($body), header: $headersBy['default'], footer: $footersBy['default'], - pageSetup: $pageSetup, + pageSetup: $sectionReader->fromSectPr($sectPr), firstHeader: $usesFirst ? $headersBy['first'] : [], firstFooter: $usesFirst ? $footersBy['first'] : [], evenHeader: $usesEven ? $headersBy['even'] : [], evenFooter: $usesEven ? $footersBy['even'] : [], - ), + start: $sectionReader->start($sectPr), + ); + } + + return new Document( + section: $sections[0], watermarkText: $watermarkText, + followingSections: array_slice($sections, 1), ); } + /** + * The body's children cut into sections, each with the sectPr that ends it + * (null when the document has none). + * + * @return non-empty-list, ?\DOMElement}> + */ + private function splitSections(\DOMElement $body): array + { + $groups = []; + $nodes = []; + $bodySectPr = null; + foreach ($body->childNodes as $child) { + if ($child instanceof \DOMElement && $child->namespaceURI === OoxmlNs::W && $child->localName === 'sectPr') { + $bodySectPr = $child; + continue; + } + $nodes[] = $child; + $pPr = $child instanceof \DOMElement && $child->namespaceURI === OoxmlNs::W && $child->localName === 'p' + ? OoxmlNs::firstChild($child, OoxmlNs::W, 'pPr') + : null; + $sectPr = $pPr === null ? null : OoxmlNs::firstChild($pPr, OoxmlNs::W, 'sectPr'); + if ($sectPr !== null) { + $groups[] = [$nodes, $sectPr]; + $nodes = []; + } + } + // What follows the last paragraph-level sectPr is the final section; + // when nothing does and the body has no sectPr of its own, Word put + // the final section's sectPr on the last paragraph. + $rest = array_filter($nodes, static fn (\DOMNode $n): bool => $n instanceof \DOMElement); + if ($groups === [] || $rest !== [] || $bodySectPr !== null) { + $groups[] = [$nodes, $bodySectPr]; + } + + return $groups; + } + + /** + * A section's header or footer blocks by type, from the parts its sectPr + * references. + * + * @param array $types part path → type + * @param array $docs part path → DOM + * @param array> $cache part path → blocks + * @return array{default: list, first: list, even: list} + */ + private function partsByType(array $types, array $docs, DocxPackage $pkg, StylesResolver $styles, NumberingDefinitions $numbering, array &$cache): array + { + $by = ['default' => [], 'first' => [], 'even' => []]; + foreach ($docs as $partPath => $partDoc) { + $type = $types[$partPath] ?? null; + $root = $partDoc->documentElement; + if ($type === null || ! $root instanceof \DOMElement || ! array_key_exists($type, $by)) { + continue; + } + $cache[$partPath] ??= $this->ensureBlocks((new BodyReader($styles, $numbering, new ImageReader($pkg, $partPath), $pkg, $partPath))->read($root)); + $by[$type] = $cache[$partPath]; + } + + return $by; + } + /** * Parses ``/`` in sectPr and returns * a map. diff --git a/src/Reader/SectionReader.php b/src/Reader/SectionReader.php index 9198298..318b9a1 100644 --- a/src/Reader/SectionReader.php +++ b/src/Reader/SectionReader.php @@ -7,20 +7,25 @@ use Dskripchenko\PhpDocx\Style\Orientation; use Dskripchenko\PhpDocx\Style\PageSetup; use Dskripchenko\PhpDocx\Style\PaperSize; +use Dskripchenko\PhpDocx\Style\SectionStart; /** * Phase 8 — SectionReader: `` → PageSetup. * - * A Word document may have several `` (one per section); we take the - * LAST one in the body (it defines the default for every page up to the next - * sectPr above). Multi-section documents with differing orientation or margins - * are out of scope (ADR-015) — warn upstream. + * A Word document has one `` per section: DocxReader reads each with + * fromSectPr(). readPageSetup() takes the LAST one in the body, the final + * section's. */ final class SectionReader { public function readPageSetup(\DOMElement $body): PageSetup { - $sectPr = $this->findLastSectPr($body); + return $this->fromSectPr($this->findLastSectPr($body)); + } + + /** One section's `` → PageSetup (the defaults when null). */ + public function fromSectPr(?\DOMElement $sectPr): PageSetup + { if ($sectPr === null) { return new PageSetup; } @@ -70,9 +75,11 @@ public function readPageSetup(\DOMElement $body): PageSetup } $paperSize = $this->detectPaperSize($widthTwips, $heightTwips, $orientation); + // A size no PaperSize matches is kept as it is. + $custom = $paperSize === null && $widthTwips !== null && $heightTwips !== null && $widthTwips > 0 && $heightTwips > 0; return new PageSetup( - paperSize: $paperSize, + paperSize: $paperSize ?? PaperSize::A4, orientation: $orientation, marginTopTwips: $marginTop, marginRightTwips: $marginRight, @@ -80,9 +87,19 @@ public function readPageSetup(\DOMElement $body): PageSetup marginLeftTwips: $marginLeft, headerOffsetTwips: $headerOffset, footerOffsetTwips: $footerOffset, + widthTwips: $custom ? $widthTwips : null, + heightTwips: $custom ? $heightTwips : null, ); } + /** Where the section starts (``); a new page when absent or unknown. */ + public function start(?\DOMElement $sectPr): SectionStart + { + $type = $sectPr === null ? null : OoxmlNs::firstChild($sectPr, OoxmlNs::W, 'type'); + + return $type === null ? SectionStart::NextPage : (SectionStart::tryFrom($type->getAttributeNS(OoxmlNs::W, 'val')) ?? SectionStart::NextPage); + } + private function findLastSectPr(\DOMElement $body): ?\DOMElement { $last = null; @@ -114,7 +131,7 @@ private function findLastSectPr(\DOMElement $body): ?\DOMElement return $last; } - private function detectPaperSize(?int $w, ?int $h, Orientation $orientation): PaperSize + private function detectPaperSize(?int $w, ?int $h, Orientation $orientation): ?PaperSize { if ($w === null || $h === null) { return PaperSize::A4; @@ -132,6 +149,6 @@ private function detectPaperSize(?int $w, ?int $h, Orientation $orientation): Pa } } - return PaperSize::A4; + return null; } } diff --git a/src/Section.php b/src/Section.php index 12b83f8..3bbb306 100644 --- a/src/Section.php +++ b/src/Section.php @@ -6,6 +6,7 @@ use Dskripchenko\PhpDocx\Element\BlockElement; use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\SectionStart; /** * Section is a logical section of the document: a set of block elements plus @@ -23,9 +24,10 @@ * `` in `word/settings.xml` (without it Word ignores * the even headers). * - * In the simple case the whole document is a single section. Multi-section - * documents for a mid-document change of orientation or margins are out of - * scope for v1. + * In the simple case the whole document is a single section; see + * `Document::$followingSections` for more. A section with no header or footer + * of a kind shows the previous section's (Word's rule), so an empty list here + * means "inherit", not "blank". */ final readonly class Section { @@ -52,6 +54,8 @@ public function __construct( public array $firstFooter = [], public array $evenHeader = [], public array $evenFooter = [], + /** Where this section begins; see SectionStart. */ + public SectionStart $start = SectionStart::NextPage, ) {} /** diff --git a/src/Style/PageSetup.php b/src/Style/PageSetup.php index 18f03fd..20c4e02 100644 --- a/src/Style/PageSetup.php +++ b/src/Style/PageSetup.php @@ -21,7 +21,21 @@ public function __construct( public int $marginLeftTwips = 850, // ~15mm public int $headerOffsetTwips = 283, // ~5mm от верха страницы public int $footerOffsetTwips = 283, // ~5mm от низа страницы - ) {} + /** + * A page size of its own, in twips, instead of `$paperSize`: both or + * neither. The page is landscape when it is wider than it is tall; + * `$orientation` is not applied to a custom size. + */ + public ?int $widthTwips = null, + public ?int $heightTwips = null, + ) { + if (($widthTwips === null) !== ($heightTwips === null)) { + throw new \InvalidArgumentException('A custom page size needs both a width and a height.'); + } + if ($widthTwips !== null && ($widthTwips <= 0 || $heightTwips <= 0)) { + throw new \InvalidArgumentException('A custom page size must be positive.'); + } + } public static function fromMm( PaperSize $paperSize = PaperSize::A4, @@ -48,10 +62,30 @@ public static function fromMm( */ public function contentWidthTwips(): int { - [$pageWidth, ] = $this->orientation === Orientation::Portrait - ? [$this->paperSize->widthTwips(), $this->paperSize->heightTwips()] - : [$this->paperSize->heightTwips(), $this->paperSize->widthTwips()]; + return $this->pageWidthTwips() - $this->marginLeftTwips - $this->marginRightTwips; + } + + /** The page's width as laid out: the custom size, else the paper turned by the orientation. */ + public function pageWidthTwips(): int + { + return $this->widthTwips ?? ($this->orientation === Orientation::Portrait + ? $this->paperSize->widthTwips() + : $this->paperSize->heightTwips()); + } - return $pageWidth - $this->marginLeftTwips - $this->marginRightTwips; + /** The page's height as laid out; see pageWidthTwips(). */ + public function pageHeightTwips(): int + { + return $this->heightTwips ?? ($this->orientation === Orientation::Portrait + ? $this->paperSize->heightTwips() + : $this->paperSize->widthTwips()); + } + + /** What `w:orient` says: landscape when the page is wider than it is tall. */ + public function effectiveOrientation(): Orientation + { + return $this->widthTwips === null + ? $this->orientation + : ($this->widthTwips > (int) $this->heightTwips ? Orientation::Landscape : Orientation::Portrait); } } diff --git a/src/Style/SectionStart.php b/src/Style/SectionStart.php new file mode 100644 index 0000000..11f5363 --- /dev/null +++ b/src/Style/SectionStart.php @@ -0,0 +1,19 @@ +` in its ``): on a new page (the + * default, written as no `w:type` at all), straight after the previous + * section on the same page, or on the next even or odd page. The first + * section's start has no effect. + */ +enum SectionStart: string +{ + case NextPage = 'nextPage'; + case Continuous = 'continuous'; + case EvenPage = 'evenPage'; + case OddPage = 'oddPage'; +} diff --git a/src/Writer/BodyXmlBuilder.php b/src/Writer/BodyXmlBuilder.php index d60eea4..7ab478a 100644 --- a/src/Writer/BodyXmlBuilder.php +++ b/src/Writer/BodyXmlBuilder.php @@ -66,12 +66,21 @@ public function numbering(): NumberingXmlBuilder /** * @param list $blocks + * @param string $sectPr A section's `` to end these blocks with: it + * goes into the pPr of the last paragraph, or of an + * empty paragraph added when the blocks end otherwise. */ - public function render(array $blocks): string + public function render(array $blocks, string $sectPr = ''): string { + $last = $sectPr !== '' ? array_key_last($blocks) : null; $xml = ''; - foreach ($blocks as $b) { - $xml .= $this->renderBlock($b); + foreach ($blocks as $i => $b) { + $xml .= $i === $last && $b instanceof Paragraph + ? $this->renderParagraph($b, $sectPr) + : $this->renderBlock($b); + } + if ($sectPr !== '' && ($last === null || ! $blocks[$last] instanceof Paragraph)) { + $xml .= ''.$sectPr.''; } return $xml; @@ -210,9 +219,9 @@ private function renderListItem(ListItem $item, int $numId, int $level): string // ──────────────────────────── Paragraph + Run ──────────────────────────── - private function renderParagraph(Paragraph $p): string + private function renderParagraph(Paragraph $p, string $sectPr = ''): string { - $pPr = $this->renderParagraphProperties($p->style, $p->headingLevel, styleId: $p->styleId); + $pPr = $this->renderParagraphProperties($p->style, $p->headingLevel, styleId: $p->styleId, sectPr: $sectPr); $children = ''; foreach ($p->children as $child) { $children .= $this->renderInline($child); @@ -232,6 +241,7 @@ private function renderParagraphProperties( ?int $headingLevel, string $numPr = '', ?string $styleId = null, + string $sectPr = '', ): string { $pPr = ''; @@ -309,6 +319,8 @@ private function renderParagraphProperties( if ($style->alignment !== Alignment::Start) { $pPr .= ''; } + // A section's properties close its last paragraph's (after w:rPr in CT_PPr). + $pPr .= $sectPr; if ($pPr === '') { return ''; diff --git a/src/Writer/Word2007Writer.php b/src/Writer/Word2007Writer.php index a5eeb10..fae6c80 100644 --- a/src/Writer/Word2007Writer.php +++ b/src/Writer/Word2007Writer.php @@ -6,8 +6,8 @@ use Dskripchenko\PhpDocx\Document; use Dskripchenko\PhpDocx\Exception\DocxException; -use Dskripchenko\PhpDocx\Style\Orientation; -use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Section; +use Dskripchenko\PhpDocx\Style\SectionStart; use Dskripchenko\PhpDocx\Style\StyleRegistry; /** @@ -36,62 +36,81 @@ public function __construct( public function write(Document $document): string { - $section = $document->section; + $sections = $document->sections(); + $last = count($sections) - 1; // When the caller passed an empty registry we use the defaults // (Heading1..6 plus ListParagraph) so that the DOCX is self-contained. $styles = $this->styles->isEmpty() ? StyleRegistry::defaults() : $this->styles; $rels = new RelationshipManager; $numbering = new NumberingXmlBuilder; $builder = new BodyXmlBuilder($rels, $numbering); - $bodyXml = $builder->render($section->body); + // Every section but the last ends with its in the pPr of + // its last paragraph. The bodies are rendered first, as for a single + // section, so the relationship ids come out in the same order; each + // sectPr is filled in once the header and footer parts have their ids. + $bodyXml = ''; + foreach ($sections as $i => $section) { + $bodyXml .= $builder->render($section->body, $i < $last ? self::sectPrSlot($i) : ''); + } // Multiple headers and footers: we gather every kind - // (default/first/even). The watermark goes ONLY onto the default header - // (when there is no default one we create an empty one with the - // watermark prefix). + // (default/first/even) of every section. The watermark goes ONLY onto + // default headers (when the first section has no default one we create + // an empty one with the watermark prefix; later sections inherit it). $watermarkXml = ($document->watermarkText !== null && $document->watermarkText !== '') ? $this->watermarkXml($document->watermarkText) : ''; - $headers = $section->allHeaders(); // type → blocks - $footers = $section->allFooters(); - - if ($watermarkXml !== '' && ! isset($headers['default'])) { - // The watermark needs a default header — we create an empty one. - $headers['default'] = []; - } - /** @var array $headerParts */ $headerParts = []; - $headerCounter = 0; - foreach ($headers as $type => $blocks) { - $headerCounter++; - $partName = 'header'.$headerCounter.'.xml'; - $prefix = $type === 'default' ? $watermarkXml : ''; - $xml = $rels->forPart( - $partName, - fn (): string => $this->renderHeaderFooterXml($blocks, $rels, $numbering, isHeader: true, prefixXml: $prefix), - ); - $rId = $rels->registerHeaderFooter($partName, isHeader: true); - $headerParts[$type] = ['rId' => $rId, 'xml' => $xml, 'partName' => $partName]; - } - /** @var array $footerParts */ $footerParts = []; - $footerCounter = 0; - foreach ($footers as $type => $blocks) { - $footerCounter++; - $partName = 'footer'.$footerCounter.'.xml'; - $xml = $rels->forPart( - $partName, - fn (): string => $this->renderHeaderFooterXml($blocks, $rels, $numbering, isHeader: false), - ); - $rId = $rels->registerHeaderFooter($partName, isHeader: false); - $footerParts[$type] = ['rId' => $rId, 'xml' => $xml, 'partName' => $partName]; + /** @var list> $headerRefs per section: type → rId */ + $headerRefs = []; + /** @var list> $footerRefs per section: type → rId */ + $footerRefs = []; + foreach ($sections as $i => $section) { + $headers = $section->allHeaders(); // type → blocks + if ($i === 0 && $watermarkXml !== '' && ! isset($headers['default'])) { + // The watermark needs a default header — we create an empty one. + $headers['default'] = []; + } + $headerRefs[$i] = []; + foreach ($headers as $type => $blocks) { + $partName = 'header'.(count($headerParts) + 1).'.xml'; + $prefix = $type === 'default' ? $watermarkXml : ''; + $xml = $rels->forPart( + $partName, + fn (): string => $this->renderHeaderFooterXml($blocks, $rels, $numbering, isHeader: true, prefixXml: $prefix), + ); + $rId = $rels->registerHeaderFooter($partName, isHeader: true); + $headerParts[$partName] = ['rId' => $rId, 'xml' => $xml, 'partName' => $partName]; + $headerRefs[$i][$type] = $rId; + } + } + foreach ($sections as $i => $section) { + $footerRefs[$i] = []; + foreach ($section->allFooters() as $type => $blocks) { + $partName = 'footer'.(count($footerParts) + 1).'.xml'; + $xml = $rels->forPart( + $partName, + fn (): string => $this->renderHeaderFooterXml($blocks, $rels, $numbering, isHeader: false), + ); + $rId = $rels->registerHeaderFooter($partName, isHeader: false); + $footerParts[$partName] = ['rId' => $rId, 'xml' => $xml, 'partName' => $partName]; + $footerRefs[$i][$type] = $rId; + } } - $hasFirstPage = $section->hasFirstPageHeaderOrFooter(); - $hasEvenPage = $section->hasEvenPageHeaderOrFooter(); + $hasEvenPage = false; + $sectPrs = []; + foreach ($sections as $i => $section) { + $hasEvenPage = $hasEvenPage || $section->hasEvenPageHeaderOrFooter(); + $sectPrs[$i] = $this->renderSectPr($section, $headerRefs[$i], $footerRefs[$i], isFirst: $i === 0); + } + $finalSectPr = $sectPrs[$last]; + unset($sectPrs[$last]); + $bodyXml = strtr($bodyXml, array_combine(array_map(self::sectPrSlot(...), array_keys($sectPrs)), $sectPrs)); // Numbering part (Phase 5b) $numberingXml = null; @@ -143,13 +162,7 @@ public function write(Document $document): string if ($coreXml !== null) { $zip->addFromString('docProps/core.xml', $coreXml); } - $zip->addFromString('word/document.xml', $this->renderDocumentXml( - $section->pageSetup, - $bodyXml, - headerRefs: array_map(fn ($info) => $info['rId'], $headerParts), - footerRefs: array_map(fn ($info) => $info['rId'], $footerParts), - hasFirstPage: $hasFirstPage, - )); + $zip->addFromString('word/document.xml', $this->renderDocumentXml($bodyXml, $finalSectPr)); $zip->addFromString('word/_rels/document.xml.rels', $this->renderDocumentRels($rels)); foreach ($headerParts as $info) { @@ -350,40 +363,48 @@ private function renderRelsFile(array $relationships): string return $xml; } + /** A placeholder for a section's sectPr in the body XML, filled in by write(). */ + private static function sectPrSlot(int $section): string + { + return ''; + } + /** + * One section's ``: its header/footer references, where it + * starts, the page size and margins, and the first-page flag. + * * @param array $headerRefs type → rId * @param array $footerRefs type → rId */ - private function renderDocumentXml( - PageSetup $ps, - string $bodyXml, - array $headerRefs = [], - array $footerRefs = [], - bool $hasFirstPage = false, - ): string { - [$w, $h] = $ps->orientation === Orientation::Portrait - ? [$ps->paperSize->widthTwips(), $ps->paperSize->heightTwips()] - : [$ps->paperSize->heightTwips(), $ps->paperSize->widthTwips()]; - $orient = $ps->orientation->value; - - $sectPrInner = ''; + private function renderSectPr(Section $section, array $headerRefs, array $footerRefs, bool $isFirst): string + { + $ps = $section->pageSetup; + $inner = ''; foreach ($headerRefs as $type => $rId) { - $sectPrInner .= ''; + $inner .= ''; } foreach ($footerRefs as $type => $rId) { - $sectPrInner .= ''; + $inner .= ''; + } + // A new page is what a section does without a w:type; the first + // section's start means nothing. + if (! $isFirst && $section->start !== SectionStart::NextPage) { + $inner .= ''; } - $sectPrInner .= '' + $inner .= '' .''; // Word requires a when a first-page header or footer is // set — otherwise the type="first" reference is ignored. - if ($hasFirstPage) { - $sectPrInner .= ''; + if ($section->hasFirstPageHeaderOrFooter()) { + $inner .= ''; } - $sectPr = ''.$sectPrInner.''; + return ''.$inner.''; + } + private function renderDocumentXml(string $bodyXml, string $sectPr): string + { return '' .'children[0]->text; + } + + #[Test] + public function new_section_closes_the_current_one(): void + { + $landscape = new PageSetup(orientation: Orientation::Landscape); + $doc = DocumentBuilder::new() + ->header(fn ($h) => $h->paragraph('Header A')) + ->paragraph('one') + ->newSection($landscape, SectionStart::OddPage) + ->footer(fn ($f) => $f->paragraph('Footer B')) + ->paragraph('two') + ->build(); + + [$a, $b] = $doc->sections(); + self::assertSame('one', self::text($a->body[0])); + self::assertSame('Header A', self::text($a->header[0])); + self::assertSame([], $a->footer); + self::assertSame('two', self::text($b->body[0])); + self::assertSame([], $b->header, 'a new section inherits the header unless it sets one'); + self::assertSame('Footer B', self::text($b->footer[0])); + self::assertSame($landscape, $b->pageSetup); + self::assertSame(SectionStart::OddPage, $b->start); + } + + #[Test] + public function a_new_section_keeps_the_page_setup_unless_given_one(): void + { + $setup = new PageSetup(widthTwips: 9000, heightTwips: 9000); + $doc = DocumentBuilder::new()->pageSetup($setup)->paragraph('one')->newSection()->paragraph('two')->build(); + self::assertSame($setup, $doc->sections()[1]->pageSetup); + self::assertSame(SectionStart::NextPage, $doc->sections()[1]->start); + } + + #[Test] + public function without_new_section_there_is_one(): void + { + self::assertCount(1, DocumentBuilder::new()->paragraph('x')->build()->sections()); + } +} diff --git a/tests/Html/SerializerTest.php b/tests/Html/SerializerTest.php index 044e24a..51ad29f 100644 --- a/tests/Html/SerializerTest.php +++ b/tests/Html/SerializerTest.php @@ -303,4 +303,23 @@ public function header_and_footer_serialize_separately(): void self::assertSame('

H

', $imported->headerHtml); self::assertSame('

F

', $imported->footerHtml); } + + #[Test] + public function every_section_body_is_serialized_with_a_page_break_between(): void + { + $p = static fn (string $t): Paragraph => new Paragraph([new Run($t)]); + $serializer = new \Dskripchenko\PhpDocx\Html\Serializer; + $breakHtml = $serializer->serialize(new Document(new Section([new \Dskripchenko\PhpDocx\Element\PageBreak])))->bodyHtml; + + $html = $serializer->serialize(new Document( + new Section([$p('one')]), + followingSections: [ + new Section([$p('two')]), + new Section([$p('three')], start: \Dskripchenko\PhpDocx\Style\SectionStart::Continuous), + ], + ))->bodyHtml; + + self::assertSame($serializer->serialize(new Document(new Section([$p('one'), new \Dskripchenko\PhpDocx\Element\PageBreak, $p('two'), $p('three')])))->bodyHtml, $html); + self::assertNotSame('', $breakHtml); + } } diff --git a/tests/Reader/SectionsReaderTest.php b/tests/Reader/SectionsReaderTest.php new file mode 100644 index 0000000..ab7657a --- /dev/null +++ b/tests/Reader/SectionsReaderTest.php @@ -0,0 +1,82 @@ + $blocks */ + private static function texts(array $blocks): array + { + return array_map(static fn ($b): string => $b instanceof Paragraph ? implode('', array_map(static fn ($c) => $c->text ?? '', $b->children)) : get_class($b), $blocks); + } + + #[Test] + public function sections_survive_a_round_trip(): void + { + $bytes = DocumentBuilder::new() + ->header(fn ($h) => $h->paragraph('Header A')) + ->paragraph('one') + ->paragraph('two') + ->newSection(new PageSetup(orientation: Orientation::Landscape), SectionStart::Continuous) + ->header(fn ($h) => $h->paragraph('Header B')) + ->firstHeader(fn ($h) => $h->paragraph('Title B')) + ->paragraph('three') + ->newSection(new PageSetup(widthTwips: 9000, heightTwips: 7000)) + ->paragraph('four') + ->toBytes(); + + $doc = (new DocxReader)->read($bytes); + $sections = $doc->sections(); + self::assertCount(3, $sections); + [$a, $b, $c] = $sections; + + self::assertSame(['one', 'two'], self::texts($a->body)); + self::assertSame(['three'], self::texts($b->body)); + self::assertSame(['four'], self::texts($c->body)); + + self::assertSame(['Header A'], self::texts($a->header)); + self::assertSame(['Header B'], self::texts($b->header)); + self::assertSame(['Title B'], self::texts($b->firstHeader)); + self::assertSame([], $c->header, 'no references of its own: it inherits'); + + self::assertSame(PaperSize::A4, $a->pageSetup->paperSize); + self::assertSame(Orientation::Portrait, $a->pageSetup->orientation); + self::assertSame(Orientation::Landscape, $b->pageSetup->orientation); + self::assertSame([9000, 7000], [$c->pageSetup->pageWidthTwips(), $c->pageSetup->pageHeightTwips()]); + + self::assertSame(SectionStart::Continuous, $b->start); + self::assertSame(SectionStart::NextPage, $c->start); + } + + #[Test] + public function writing_what_was_read_gives_the_same_sections(): void + { + $bytes = DocumentBuilder::new()->paragraph('one')->newSection(new PageSetup(orientation: Orientation::Landscape))->paragraph('two')->toBytes(); + $again = (new DocxReader)->read((new Word2007Writer)->write((new DocxReader)->read($bytes))); + self::assertCount(2, $again->sections()); + self::assertSame(['two'], self::texts($again->sections()[1]->body)); + self::assertSame(Orientation::Landscape, $again->sections()[1]->pageSetup->orientation); + } + + #[Test] + public function a_one_section_document_reads_as_one(): void + { + $doc = (new DocxReader)->read(DocumentBuilder::new()->paragraph('x')->toBytes()); + self::assertCount(1, $doc->sections()); + self::assertSame(['x'], self::texts($doc->section->body)); + } +} diff --git a/tests/Writer/SectionsTest.php b/tests/Writer/SectionsTest.php new file mode 100644 index 0000000..0e0fe59 --- /dev/null +++ b/tests/Writer/SectionsTest.php @@ -0,0 +1,153 @@ +` (in + * the pPr of its last paragraph, the last section's directly in the body), + * with its own page setup, start type and header/footer references. + */ +final class SectionsTest extends TestCase +{ + /** @return array entry name → contents */ + private static function parts(string $bytes): array + { + $tmp = (string) tempnam(sys_get_temp_dir(), 'docx-sect-'); + file_put_contents($tmp, $bytes); + $zip = new \ZipArchive; + $zip->open($tmp); + $out = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = (string) $zip->getNameIndex($i); + $out[$name] = (string) $zip->getFromIndex($i); + } + $zip->close(); + unlink($tmp); + + return $out; + } + + private static function xpath(string $xml): \DOMXPath + { + $dom = new \DOMDocument; + $dom->loadXML($xml); + $xp = new \DOMXPath($dom); + $xp->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'); + $xp->registerNamespace('r', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'); + + return $xp; + } + + private static function p(string $text): Paragraph + { + return new Paragraph([new Run($text)]); + } + + #[Test] + public function a_custom_page_size_is_written_as_given(): void + { + $setup = new PageSetup(widthTwips: 8000, heightTwips: 12000); + self::assertSame(8000 - 850 - 850, $setup->contentWidthTwips()); + + $xp = self::xpath(self::parts((new Word2007Writer)->write(new Document(new Section([self::p('x')], pageSetup: $setup))))['word/document.xml']); + self::assertSame('8000', $xp->evaluate('string(//w:body/w:sectPr/w:pgSz/@w:w)')); + self::assertSame('12000', $xp->evaluate('string(//w:body/w:sectPr/w:pgSz/@w:h)')); + self::assertSame('portrait', $xp->evaluate('string(//w:body/w:sectPr/w:pgSz/@w:orient)')); + } + + #[Test] + public function a_wider_custom_page_is_landscape(): void + { + $xp = self::xpath(self::parts((new Word2007Writer)->write(new Document(new Section([self::p('x')], pageSetup: new PageSetup(widthTwips: 12000, heightTwips: 8000)))))['word/document.xml']); + self::assertSame('landscape', $xp->evaluate('string(//w:body/w:sectPr/w:pgSz/@w:orient)')); + self::assertSame('12000', $xp->evaluate('string(//w:body/w:sectPr/w:pgSz/@w:w)')); + } + + #[Test] + public function each_section_ends_with_its_own_settings(): void + { + $doc = new Document( + new Section([self::p('one'), self::p('two')], header: [self::p('Header A')]), + followingSections: [ + new Section([self::p('three')], header: [self::p('Header B')], pageSetup: new PageSetup(orientation: Orientation::Landscape), start: SectionStart::Continuous), + ], + ); + self::assertCount(2, $doc->sections()); + $parts = self::parts((new Word2007Writer)->write($doc)); + $xp = self::xpath($parts['word/document.xml']); + + // The first section's sectPr rides on its last paragraph: no extra paragraph. + self::assertSame(3.0, $xp->evaluate('count(//w:body/w:p)')); + self::assertSame('two', $xp->evaluate('string(//w:body/w:p[w:pPr/w:sectPr]//w:t)')); + self::assertSame('portrait', $xp->evaluate('string(//w:p/w:pPr/w:sectPr/w:pgSz/@w:orient)')); + self::assertSame('landscape', $xp->evaluate('string(//w:body/w:sectPr/w:pgSz/@w:orient)')); + self::assertSame('continuous', $xp->evaluate('string(//w:body/w:sectPr/w:type/@w:val)')); + self::assertSame(0.0, $xp->evaluate('count(//w:p/w:pPr/w:sectPr/w:type)'), 'next page is the default and needs no w:type'); + + // Each section points at its own header part. + $first = $xp->evaluate('string(//w:p/w:pPr/w:sectPr/w:headerReference/@r:id)'); + $last = $xp->evaluate('string(//w:body/w:sectPr/w:headerReference/@r:id)'); + self::assertNotSame('', $first); + self::assertNotSame($first, $last); + self::assertStringContainsString('Header A', $parts['word/header1.xml']); + self::assertStringContainsString('Header B', $parts['word/header2.xml']); + } + + #[Test] + public function a_section_without_headers_writes_no_references_so_it_inherits(): void + { + $doc = new Document( + new Section([self::p('one')], header: [self::p('Header A')]), + followingSections: [new Section([self::p('two')])], + ); + $xp = self::xpath(self::parts((new Word2007Writer)->write($doc))['word/document.xml']); + self::assertSame(1.0, $xp->evaluate('count(//w:p/w:pPr/w:sectPr/w:headerReference)')); + self::assertSame(0.0, $xp->evaluate('count(//w:body/w:sectPr/w:headerReference)')); + } + + #[Test] + public function a_section_ending_in_a_table_gets_a_paragraph_for_its_settings(): void + { + $table = new Table([new TableRow([new TableCell([self::p('cell')])])]); + $doc = new Document(new Section([self::p('one'), $table]), followingSections: [new Section([self::p('two')])]); + $xp = self::xpath(self::parts((new Word2007Writer)->write($doc))['word/document.xml']); + self::assertSame(1.0, $xp->evaluate('count(//w:body/w:tbl/following-sibling::w:p[1]/w:pPr/w:sectPr)')); + } + + #[Test] + public function first_page_headers_are_per_section(): void + { + $doc = new Document( + new Section([self::p('one')]), + followingSections: [new Section([self::p('two')], firstHeader: [self::p('Title')])], + ); + $xp = self::xpath(self::parts((new Word2007Writer)->write($doc))['word/document.xml']); + self::assertSame(0.0, $xp->evaluate('count(//w:p/w:pPr/w:sectPr/w:titlePg)')); + self::assertSame(1.0, $xp->evaluate('count(//w:body/w:sectPr/w:titlePg)')); + } + + #[Test] + public function a_one_section_document_is_unchanged(): void + { + $doc = new Document(new Section([self::p('x')])); + self::assertSame([$doc->section], $doc->sections()); + $xp = self::xpath(self::parts((new Word2007Writer)->write($doc))['word/document.xml']); + self::assertSame(1.0, $xp->evaluate('count(//w:sectPr)')); + } +} From 58d4452eda3e61a4652a3f49e266c276e08f6c3d Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Tue, 27 Oct 2026 21:39:10 -0700 Subject: [PATCH 2/4] feat: floating pictures (wp:anchor) Image::$position (ImagePosition) writes a picture as : on each axis an offset in EMU from the page, margin, column or character (horizontally) or the page, margin, paragraph or line (vertically), or an alignment there; in front of or behind the text; wrapped none, square or top and bottom; stacked by zOrder. Placed on the page from a header or footer, a picture repeats on every page: letterheads, page backgrounds, stamps. An offsetYEmu without a position now floats the picture that far below its paragraph, as the property always described; the writer ignored it before. DocxReader reads the anchor's position back (and still fills offsetYEmu), so a read document keeps its floating pictures when written again. Image::fromPx() and the builder's image helpers take a position. The conformance reference document gains a page-positioned picture behind the text in the header and a square-wrapped one in the body; both validate against the ECMA-376 Transitional schemas. README in all four languages and the changelog describe it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 +++ README.md | 46 ++++++- docs/de/README.md | 44 ++++++- docs/ru/README.md | 44 ++++++- docs/zh/README.md | 41 +++++- scripts/conformance/generate.php | 28 +++- src/Build/AddsInlineContent.php | 13 +- src/Element/Image.php | 23 +++- src/Reader/ImageReader.php | 64 +++++++++- src/Style/HorizontalAnchor.php | 14 ++ src/Style/ImagePosition.php | 42 ++++++ src/Style/ImageWrap.php | 16 +++ src/Style/PositionAlign.php | 21 +++ src/Style/VerticalAnchor.php | 14 ++ src/Writer/BodyXmlBuilder.php | 41 +++++- tests/Writer/FloatingImageTest.php | 198 +++++++++++++++++++++++++++++ 16 files changed, 648 insertions(+), 17 deletions(-) create mode 100644 src/Style/HorizontalAnchor.php create mode 100644 src/Style/ImagePosition.php create mode 100644 src/Style/ImageWrap.php create mode 100644 src/Style/PositionAlign.php create mode 100644 src/Style/VerticalAnchor.php create mode 100644 tests/Writer/FloatingImageTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ab8e460..dc19b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `PageSetup::$widthTwips` / `$heightTwips`: any page size, not only a `PaperSize` (landscape when wider than tall), with `pageWidthTwips()`, `pageHeightTwips()` and `effectiveOrientation()`. +- Floating pictures: `Image::$position` (`ImagePosition`) writes the picture + as `` instead of ``. On each axis it sits at an + offset in EMU from what it is measured from (`HorizontalAnchor`: page, + margin, column, character; `VerticalAnchor`: page, margin, paragraph, + line) or is aligned there (`PositionAlign`); `behindText`, `wrap` + (`ImageWrap`: none, square, top and bottom) and `zOrder` set how it sits + with the text. A picture positioned on the page from a header or footer + repeats on every page. `Image::fromPx()` and the builder's + `imageFromBytes()`, `imageFromDataUrl()` and `imageFromFile()` take a + `position`. ### Changed - `DocxReader` reads every section — body, page setup, start, headers and @@ -28,6 +38,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). kept as a custom size instead of becoming A4. - The HTML serializer writes every section's body, with a page break before each section that starts on a new page. +- An `Image` with a non-zero `offsetYEmu` and no `position` is now written + floating that far below the top of its paragraph, as the property always + described; the writer used to ignore it and write the picture inline. +- `DocxReader` reads an anchored picture's position into `Image::$position` + (it still fills `offsetYEmu`, which the HTML serializer uses), so writing a + read document keeps its floating pictures where they were. ## [1.17.0] — 2026-10-27 diff --git a/README.md b/README.md index 2da6c36..8a6eebf 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ external dependencies beyond standard PHP extensions. - **Sections** — several per document, each with its own page setup (a preset paper size or any custom size), start (new page, continuous, even or odd page) and headers/footers; read back section by section. +- **Floating pictures** — positioned on the page, margin or paragraph, in + front of or behind the text, with wrapping; from a header they repeat on + every page. - **Round-trip safe** — read DOCX → AST → write DOCX produces a valid document; bytes-level differences are limited to whitespace/ordering. - **PHP 8.2+** — `readonly` value-objects, named arguments, @@ -596,6 +599,47 @@ DocumentBuilder::new() Renders as a 45°-rotated VML text shape on every page. +### Floating pictures + +An `Image` with a `position` (an `ImagePosition`) floats (``) +instead of sitting in the line of text. On each axis it is placed at an +offset in EMU from the page, the margin, the column or character +(horizontally), or the paragraph or line (vertically), or aligned there with +a `PositionAlign`. It is drawn in front of the text, or behind it with +`behindText`, and the text flows around it as `wrap` says (`None`, `Square`, +`TopAndBottom`); `zOrder` stacks floating pictures. The builder's +`imageFromBytes()`, `imageFromDataUrl()` and `imageFromFile()` take a +`position`. + +```php +use Dskripchenko\PhpDocx\Element\ImageFormat; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\ImageWrap; +use Dskripchenko\PhpDocx\Style\PositionAlign; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; + +DocumentBuilder::new() + // A logo in the top right corner of every page, behind the text. + ->header(fn ($h) => $h->paragraph(fn ($p) => $p->imageFromFile('logo.png', 120, 40, 'Logo', new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + horizontalAlign: PositionAlign::Right, + verticalFrom: VerticalAnchor::Page, + yEmu: 360000, // 1 cm from the top + behindText: true, + )))) + // A photo the text wraps around. + ->paragraph(fn ($p) => $p + ->imageFromFile('photo.jpg', position: new ImagePosition(wrap: ImageWrap::Square)) + ->text('Text flows around the photo.')) + ->toFile('floating.docx'); +``` + +A picture placed on the page from a header or footer is drawn on every page +that header governs: letterheads, page backgrounds, stamps. An `offsetYEmu` +without a `position` floats the picture that far below the top of its +paragraph. `DocxReader` reads the position back. + --- ## Sections @@ -695,7 +739,7 @@ All elements live under `Dskripchenko\PhpDocx\Element` namespace. | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children: InlineElement[] }` | | `Bookmark` | InlineElement | `{ name: string, children: InlineElement[] }` | -| `Image` | both | `{ binary, format, widthEmu, heightEmu, altText }` | +| `Image` | both | `{ binary, format, widthEmu, heightEmu, altText, offsetYEmu, position: ?ImagePosition }` | | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker elements | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | diff --git a/docs/de/README.md b/docs/de/README.md index 9371661..496a043 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -66,6 +66,8 @@ Abhängigkeiten ausser Standard-PHP-Erweiterungen. (Papierformat oder beliebige Größe), eigenem Beginn (neue Seite, fortlaufend, gerade oder ungerade Seite) und eigenen Kopf-/Fußzeilen; werden Abschnitt für Abschnitt zurückgelesen. +- **Schwebende Bilder** — auf Seite, Rand oder Absatz positioniert, vor oder + hinter dem Text, mit Umbruch; aus einer Kopfzeile auf jeder Seite. - **Round-Trip-sicher** — DOCX lesen → AST → DOCX schreiben liefert ein valides Dokument; byteweise Unterschiede beschränken sich auf Whitespace und Reihenfolge. @@ -519,6 +521,46 @@ DocumentBuilder::new() Wird als um 45° gedrehte VML-Textform auf jeder Seite gerendert. +### Schwebende Bilder + +Ein `Image` mit `position` (eine `ImagePosition`) schwebt (``), +statt in der Textzeile zu stehen. Auf jeder Achse liegt es in einem Abstand +in EMU von Seite, Rand, Spalte oder Zeichen (waagerecht) bzw. Absatz oder +Zeile (senkrecht), oder es wird dort mit einer `PositionAlign` ausgerichtet. +Es liegt vor dem Text, mit `behindText` dahinter, und der Text fließt +darum, wie `wrap` es angibt (`None`, `Square`, `TopAndBottom`); `zOrder` +stapelt schwebende Bilder. `imageFromBytes()`, `imageFromDataUrl()` und +`imageFromFile()` des Builders nehmen eine `position`. + +```php +use Dskripchenko\PhpDocx\Element\ImageFormat; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\ImageWrap; +use Dskripchenko\PhpDocx\Style\PositionAlign; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; + +DocumentBuilder::new() + // A logo in the top right corner of every page, behind the text. + ->header(fn ($h) => $h->paragraph(fn ($p) => $p->imageFromFile('logo.png', 120, 40, 'Logo', new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + horizontalAlign: PositionAlign::Right, + verticalFrom: VerticalAnchor::Page, + yEmu: 360000, // 1 cm from the top + behindText: true, + )))) + // A photo the text wraps around. + ->paragraph(fn ($p) => $p + ->imageFromFile('photo.jpg', position: new ImagePosition(wrap: ImageWrap::Square)) + ->text('Text flows around the photo.')) + ->toFile('floating.docx'); +``` + +Ein auf der Seite platziertes Bild aus einer Kopf- oder Fußzeile erscheint +auf jeder Seite, für die sie gilt: Briefköpfe, Seitenhintergründe, Stempel. +Ein `offsetYEmu` ohne `position` lässt das Bild so weit unter dem Anfang +seines Absatzes schweben. `DocxReader` liest die Position zurück. + --- ## Abschnitte @@ -621,7 +663,7 @@ Alle Elemente leben unter `Dskripchenko\PhpDocx\Element`. | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` | | `Bookmark` | InlineElement | `{ name: string, children }` | -| `Image` | beides | `{ binary, format, widthEmu, heightEmu, altText }` | +| `Image` | beides | `{ binary, format, widthEmu, heightEmu, altText, offsetYEmu, position: ?ImagePosition }` | | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | beides | Marker-Elemente | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | diff --git a/docs/ru/README.md b/docs/ru/README.md index d65e41f..a4f1143 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -64,6 +64,8 @@ PHP-расширения. - **Разделы** — несколько в документе, у каждого свои параметры страницы (стандартный формат или любой размер), начало (новая страница, без разрыва, чётная или нечётная страница) и колонтитулы; читаются обратно по разделам. +- **Плавающие изображения** — позиция от страницы, поля или абзаца, перед + текстом или за ним, с обтеканием; из колонтитула — на каждой странице. - **Round-trip safe** — DOCX → AST → DOCX даёт валидный документ; байтовые различия ограничены пробелами/порядком атрибутов. - **PHP 8.2+** — `readonly` value-objects, named аргументы, @@ -512,6 +514,46 @@ DocumentBuilder::new() Рендерится как VML text-shape с rotation 45° на каждой странице. +### Плавающие изображения + +`Image` с `position` (объект `ImagePosition`) плавает (``), а не +стоит в строке текста. По каждой оси оно размещается со смещением в EMU от +страницы, поля, колонки или символа (по горизонтали) либо абзаца или строки +(по вертикали), или выравнивается там через `PositionAlign`. Изображение +рисуется перед текстом, с `behindText` — за ним, а текст обтекает его так, +как указано в `wrap` (`None`, `Square`, `TopAndBottom`); `zOrder` задаёт +порядок наложения. Методы builder'а `imageFromBytes()`, `imageFromDataUrl()` +и `imageFromFile()` принимают `position`. + +```php +use Dskripchenko\PhpDocx\Element\ImageFormat; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\ImageWrap; +use Dskripchenko\PhpDocx\Style\PositionAlign; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; + +DocumentBuilder::new() + // A logo in the top right corner of every page, behind the text. + ->header(fn ($h) => $h->paragraph(fn ($p) => $p->imageFromFile('logo.png', 120, 40, 'Logo', new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + horizontalAlign: PositionAlign::Right, + verticalFrom: VerticalAnchor::Page, + yEmu: 360000, // 1 cm from the top + behindText: true, + )))) + // A photo the text wraps around. + ->paragraph(fn ($p) => $p + ->imageFromFile('photo.jpg', position: new ImagePosition(wrap: ImageWrap::Square)) + ->text('Text flows around the photo.')) + ->toFile('floating.docx'); +``` + +Изображение, размещённое на странице из колонтитула, рисуется на каждой +странице, к которой относится колонтитул: бланки, фон страницы, печати. +`offsetYEmu` без `position` смещает изображение на столько вниз от начала +абзаца. `DocxReader` читает позицию обратно. + --- ## Разделы @@ -613,7 +655,7 @@ foreach ($variables as $v) { | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` | | `Bookmark` | InlineElement | `{ name: string, children }` | -| `Image` | both | `{ binary, format, widthEmu, heightEmu, altText }` | +| `Image` | both | `{ binary, format, widthEmu, heightEmu, altText, offsetYEmu, position: ?ImagePosition }` | | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`, `PageBreak`, `HorizontalRule` | both | marker-элементы | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | diff --git a/docs/zh/README.md b/docs/zh/README.md index 5987169..ffab753 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -60,6 +60,8 @@ `` 与 ``。 - **节** — 一个文档可有多个节,每节有自己的页面设置(标准纸张或任意尺寸)、 起始方式(新页、连续、偶数页或奇数页)和页眉/页脚;读取时逐节还原。 +- **浮动图片** — 相对于页面、页边距或段落定位,位于文字前或后,可设置环绕; + 放在页眉中则每页重复。 - **往返安全** — 读 DOCX → AST → 写 DOCX 得到有效文档;字节级别的差异 仅限于空白和顺序。 - **PHP 8.2+** — `readonly` 值对象、命名参数、构造函数提升、enum。 @@ -503,6 +505,43 @@ DocumentBuilder::new() 呈现为旋转 45° 的 VML 文本形状,出现在每一页。 +### 浮动图片 + +带有 `position`(`ImagePosition`)的 `Image` 会浮动(``),而不是位于文本行中。 +每个方向上,它都可以相对于页面、页边距、栏或字符(水平方向),或段落、行(垂直方向) +以 EMU 偏移放置,或用 `PositionAlign` 对齐。图片绘制在文字前面,设置 `behindText` +则在文字后面;文字按 `wrap`(`None`、`Square`、`TopAndBottom`)环绕;`zOrder` 决定 +浮动图片的叠放顺序。构造器的 `imageFromBytes()`、`imageFromDataUrl()` 和 +`imageFromFile()` 都接受 `position`。 + +```php +use Dskripchenko\PhpDocx\Element\ImageFormat; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\ImageWrap; +use Dskripchenko\PhpDocx\Style\PositionAlign; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; + +DocumentBuilder::new() + // A logo in the top right corner of every page, behind the text. + ->header(fn ($h) => $h->paragraph(fn ($p) => $p->imageFromFile('logo.png', 120, 40, 'Logo', new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + horizontalAlign: PositionAlign::Right, + verticalFrom: VerticalAnchor::Page, + yEmu: 360000, // 1 cm from the top + behindText: true, + )))) + // A photo the text wraps around. + ->paragraph(fn ($p) => $p + ->imageFromFile('photo.jpg', position: new ImagePosition(wrap: ImageWrap::Square)) + ->text('Text flows around the photo.')) + ->toFile('floating.docx'); +``` + +从页眉或页脚放置在页面上的图片会出现在该页眉所管辖的每一页上:信笺抬头、页面背景、 +印章。没有 `position` 的 `offsetYEmu` 会让图片浮动在其段落顶部下方相应距离处。 +`DocxReader` 会读回位置。 + --- ## 节 @@ -596,7 +635,7 @@ foreach ($variables as $v) { | `Run` | InlineElement | `{ text: string, style: RunStyle }` | | `Hyperlink` | InlineElement | `{ href: ?string, anchor: ?string, children }` | | `Bookmark` | InlineElement | `{ name: string, children }` | -| `Image` | 两者 | `{ binary, format, widthEmu, heightEmu, altText }` | +| `Image` | 两者 | `{ binary, format, widthEmu, heightEmu, altText, offsetYEmu, position: ?ImagePosition }` | | `Field` | InlineElement | `{ instruction: string, style: RunStyle }` | | `LineBreak`、`PageBreak`、`HorizontalRule` | 两者 | 标记元素 | | `Table` | BlockElement | `{ rows: TableRow[], style, caption, gridColumnsTwips }` | diff --git a/scripts/conformance/generate.php b/scripts/conformance/generate.php index 90b5f8d..81ba4a0 100644 --- a/scripts/conformance/generate.php +++ b/scripts/conformance/generate.php @@ -7,8 +7,10 @@ * writer feature at once: headings, justified paragraphs with spacing and * indents, tables with header rows, nested ordered lists, multi-part * headers/footers with page-number fields, bookmarks, hyperlinks, a text - * watermark, and a second section (landscape, its own header, a custom page - * size) whose first section ends in a table. Validated by: + * watermark, a second section (landscape, its own header, a custom page + * size) whose first section ends in a table, and floating pictures (one + * behind the text on every page from the header, one in the body with text + * wrapped around it). Validated by: * * scripts/conformance/xsd-check.sh — ECMA-376 Transitional XSD * scripts/conformance/consumer-smoke.sh — LibreOffice + python-docx @@ -18,11 +20,17 @@ */ use Dskripchenko\PhpDocx\Build\DocumentBuilder; +use Dskripchenko\PhpDocx\Element\ImageFormat; use Dskripchenko\PhpDocx\Element\ListFormat; use Dskripchenko\PhpDocx\Style\Alignment; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\ImageWrap; use Dskripchenko\PhpDocx\Style\Orientation; use Dskripchenko\PhpDocx\Style\PageSetup; +use Dskripchenko\PhpDocx\Style\PositionAlign; use Dskripchenko\PhpDocx\Style\SectionStart; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; require dirname(__DIR__, 2).'/vendor/autoload.php'; @@ -32,9 +40,20 @@ exit(1); } +// A 2×2 PNG for the floating pictures. +$png = (string) base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDK+mmyAAAAABJRU5ErkJggg==', true); + $written = DocumentBuilder::new() ->watermark('CONFORMANCE') - ->header(fn ($h) => $h->paragraph('ACME Inc. — conformance reference')) + ->header(fn ($h) => $h + ->paragraph('ACME Inc. — conformance reference') + ->paragraph(fn ($p) => $p->imageFromBytes($png, ImageFormat::Png, 96, 96, 'page mark', new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + horizontalAlign: PositionAlign::Right, + verticalFrom: VerticalAnchor::Page, + yEmu: 360000, + behindText: true, + )))) ->footer(fn ($f) => $f->paragraph(fn ($p) => $p ->text('Page ')->pageNumber()->text(' of ')->totalPages())) ->heading(1, 'php-docx conformance reference') @@ -57,6 +76,9 @@ ->item('schema validation') ->item('LibreOffice conversion') ->item('python-docx extraction')) + ->paragraph(fn ($p) => $p + ->imageFromBytes($png, ImageFormat::Png, 48, 48, 'wrapped', new ImagePosition(wrap: ImageWrap::Square, zOrder: 1)) + ->text('A floating picture with the text wrapped around it.')) ->paragraph(fn ($p) => $p ->text('Link: ') ->link('https://github.com/dskripchenko/php-docx', 'repository')) diff --git a/src/Build/AddsInlineContent.php b/src/Build/AddsInlineContent.php index d0f984e..291c69f 100644 --- a/src/Build/AddsInlineContent.php +++ b/src/Build/AddsInlineContent.php @@ -12,6 +12,7 @@ use Dskripchenko\PhpDocx\Element\InlineElement; use Dskripchenko\PhpDocx\Element\LineBreak; use Dskripchenko\PhpDocx\Element\Run; +use Dskripchenko\PhpDocx\Style\ImagePosition; use Dskripchenko\PhpDocx\Style\RunStyle; /** @@ -209,8 +210,9 @@ public function imageFromBytes( int $widthPx, int $heightPx, ?string $altText = null, + ?ImagePosition $position = null, ): self { - $this->children[] = Image::fromPx($binary, $format, $widthPx, $heightPx, $altText); + $this->children[] = Image::fromPx($binary, $format, $widthPx, $heightPx, $altText, $position); return $this; } @@ -223,6 +225,7 @@ public function imageFromDataUrl( ?int $widthPx = null, ?int $heightPx = null, ?string $altText = null, + ?ImagePosition $position = null, ): self { if (preg_match('/^data:image\/(\w+);base64,(.+)$/s', $dataUrl, $m) !== 1) { return $this; // silently skip @@ -242,7 +245,7 @@ public function imageFromDataUrl( return $this; } - return $this->ingestImage($binary, $format, $widthPx, $heightPx, $altText); + return $this->ingestImage($binary, $format, $widthPx, $heightPx, $altText, $position); } /** @@ -254,6 +257,7 @@ public function imageFromFile( ?int $widthPx = null, ?int $heightPx = null, ?string $altText = null, + ?ImagePosition $position = null, ): self { if (! is_readable($path)) { throw new \InvalidArgumentException('Image file not readable: '.$path); @@ -264,7 +268,7 @@ public function imageFromFile( throw new \InvalidArgumentException('Unsupported image format: '.$path); } - return $this->ingestImage($binary, $format, $widthPx, $heightPx, $altText); + return $this->ingestImage($binary, $format, $widthPx, $heightPx, $altText, $position); } /** @@ -276,6 +280,7 @@ private function ingestImage( ?int $widthPx, ?int $heightPx, ?string $altText, + ?ImagePosition $position = null, ): self { if ($widthPx === null || $heightPx === null) { $info = @getimagesizefromstring($binary); @@ -286,7 +291,7 @@ private function ingestImage( } $widthPx = max(1, $widthPx ?? 100); $heightPx = max(1, $heightPx ?? 100); - $this->children[] = Image::fromPx($binary, $format, $widthPx, $heightPx, $altText); + $this->children[] = Image::fromPx($binary, $format, $widthPx, $heightPx, $altText, $position); return $this; } diff --git a/src/Element/Image.php b/src/Element/Image.php index 8ce8695..c0b095c 100644 --- a/src/Element/Image.php +++ b/src/Element/Image.php @@ -4,6 +4,9 @@ namespace Dskripchenko\PhpDocx\Element; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; + /** * An image. It can be inline (inside a run) or a block (its own paragraph). * @@ -27,17 +30,34 @@ public function __construct( * EMU. A negative value means the object is drawn ABOVE its paragraph * and overlaps the preceding text: that is how Word places stamps and * signatures over a finished block. Zero is an ordinary image in the - * flow. + * flow. Without a `$position`, a non-zero offset floats the picture + * that far below the top of its paragraph. */ public int $offsetYEmu = 0, + /** + * Where the picture floats; null keeps it in the line of text (or + * floats it by `$offsetYEmu` alone). See ImagePosition. + */ + public ?ImagePosition $position = null, ) {} + /** The position the writer uses: `$position`, else one from `$offsetYEmu`, else null (inline). */ + public function effectivePosition(): ?ImagePosition + { + if ($this->position !== null || $this->offsetYEmu === 0) { + return $this->position; + } + + return new ImagePosition(verticalFrom: VerticalAnchor::Paragraph, yEmu: $this->offsetYEmu); + } + public static function fromPx( string $binary, ImageFormat $format, int $widthPx, int $heightPx, ?string $altText = null, + ?ImagePosition $position = null, ): self { return new self( binary: $binary, @@ -45,6 +65,7 @@ public static function fromPx( widthEmu: $widthPx * 9525, heightEmu: $heightPx * 9525, altText: $altText, + position: $position, ); } } diff --git a/src/Reader/ImageReader.php b/src/Reader/ImageReader.php index b84aae2..f308a6c 100644 --- a/src/Reader/ImageReader.php +++ b/src/Reader/ImageReader.php @@ -6,6 +6,11 @@ use Dskripchenko\PhpDocx\Element\Image; use Dskripchenko\PhpDocx\Element\ImageFormat; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImagePosition; +use Dskripchenko\PhpDocx\Style\ImageWrap; +use Dskripchenko\PhpDocx\Style\PositionAlign; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; /** * Phase 6 — ImageReader. @@ -20,8 +25,9 @@ * - the rel does not resolve * - the binary is missing or its format is unsupported * - * An anchored picture (``) is treated as inline — a reader in an - * HTML context has no notion of a float position. + * An anchored picture (``) keeps its position (Image::$position) + * and, as before, its vertical offset in `offsetYEmu`, which is all the HTML + * serializer uses of it. */ final class ImageReader { @@ -63,6 +69,7 @@ public function read(\DOMElement $drawing): ?Image } } + $position = $container->localName === 'anchor' ? self::position($container) : null; $docPr = OoxmlNs::firstChild($container, OoxmlNs::WP, 'docPr'); $altText = null; if ($docPr !== null) { @@ -133,6 +140,59 @@ public function read(\DOMElement $drawing): ?Image heightEmu: $heightEmu, altText: $altText, offsetYEmu: $offsetYEmu, + position: $position, + ); + } + + /** + * A ``'s position. What the model has no word for is read as + * the nearest it has: a `*Margin` origin as the margin, a tight or + * through wrap as square. + */ + private static function position(\DOMElement $anchor): ImagePosition + { + $axis = static function (string $tag) use ($anchor): array { + $el = OoxmlNs::firstChild($anchor, OoxmlNs::WP, $tag); + if ($el === null) { + return ['', null, 0]; + } + $align = OoxmlNs::firstChild($el, OoxmlNs::WP, 'align'); + $offset = OoxmlNs::firstChild($el, OoxmlNs::WP, 'posOffset'); + $value = $offset === null ? '' : trim($offset->textContent); + + return [ + $el->getAttribute('relativeFrom'), + $align === null ? null : PositionAlign::tryFrom(trim($align->textContent)), + preg_match('/^-?\d+$/', $value) === 1 ? (int) $value : 0, + ]; + }; + [$hFrom, $hAlign, $x] = $axis('positionH'); + [$vFrom, $vAlign, $y] = $axis('positionV'); + $margin = static fn (string $from): bool => str_ends_with($from, 'Margin'); + + $wrap = ImageWrap::None; + foreach ($anchor->childNodes as $child) { + if ($child instanceof \DOMElement && $child->namespaceURI === OoxmlNs::WP) { + $wrap = match ($child->localName) { + 'wrapSquare', 'wrapTight', 'wrapThrough' => ImageWrap::Square, + 'wrapTopAndBottom' => ImageWrap::TopAndBottom, + default => $wrap, + }; + } + } + $height = $anchor->getAttribute('relativeHeight'); + $behind = strtolower($anchor->getAttribute('behindDoc')); + + return new ImagePosition( + horizontalFrom: HorizontalAnchor::tryFrom($hFrom) ?? ($margin($hFrom) ? HorizontalAnchor::Margin : HorizontalAnchor::Column), + xEmu: $x, + verticalFrom: VerticalAnchor::tryFrom($vFrom) ?? ($margin($vFrom) ? VerticalAnchor::Margin : VerticalAnchor::Paragraph), + yEmu: $y, + horizontalAlign: $hAlign === PositionAlign::Top || $hAlign === PositionAlign::Bottom ? null : $hAlign, + verticalAlign: $vAlign === PositionAlign::Left || $vAlign === PositionAlign::Right ? null : $vAlign, + behindText: $behind === '1' || $behind === 'true' || $behind === 'on', + wrap: $wrap, + zOrder: ctype_digit($height) ? (int) $height : 0, ); } diff --git a/src/Style/HorizontalAnchor.php b/src/Style/HorizontalAnchor.php new file mode 100644 index 0000000..7f86376 --- /dev/null +++ b/src/Style/HorizontalAnchor.php @@ -0,0 +1,14 @@ +`): on each axis, an offset in + * EMU from what it is measured from, or an alignment within it instead. A + * picture with a position is drawn in front of the text (or behind it, with + * `$behindText`), and text flows around it as `$wrap` says. + * + * A picture positioned on the page inside a header or footer is drawn on + * every page the header governs: a letterhead, a page background, a stamp. + * `$zOrder` stacks floating pictures (`relativeHeight`): higher is in front. + */ +final readonly class ImagePosition +{ + public function __construct( + public HorizontalAnchor $horizontalFrom = HorizontalAnchor::Column, + public int $xEmu = 0, + public VerticalAnchor $verticalFrom = VerticalAnchor::Paragraph, + public int $yEmu = 0, + /** Aligns the picture horizontally instead of placing it at `$xEmu`. */ + public ?PositionAlign $horizontalAlign = null, + /** Aligns the picture vertically instead of placing it at `$yEmu`. */ + public ?PositionAlign $verticalAlign = null, + public bool $behindText = false, + public ImageWrap $wrap = ImageWrap::None, + public int $zOrder = 0, + ) { + if ($horizontalAlign === PositionAlign::Top || $horizontalAlign === PositionAlign::Bottom) { + throw new \InvalidArgumentException('A horizontal alignment is left, right, center, inside or outside.'); + } + if ($verticalAlign === PositionAlign::Left || $verticalAlign === PositionAlign::Right) { + throw new \InvalidArgumentException('A vertical alignment is top, bottom, center, inside or outside.'); + } + if ($zOrder < 0) { + throw new \InvalidArgumentException('The z-order of a picture cannot be negative.'); + } + } +} diff --git a/src/Style/ImageWrap.php b/src/Style/ImageWrap.php new file mode 100644 index 0000000..5d7b8ff --- /dev/null +++ b/src/Style/ImageWrap.php @@ -0,0 +1,16 @@ +rels->nextDrawingId(); + $position = $img->effectivePosition(); + if ($position === null) { + $open = ''; + $place = ''; + $wrap = ''; + $close = ''; + } else { + // CT_Anchor: its attributes are all required but the distances; + // its children are a sequence (simplePos, positionH, positionV, + // extent, effectExtent, the wrap, docPr, ...). + $open = ''; + $place = '' + .self::renderPosition('positionH', $position->horizontalFrom->value, $position->horizontalAlign, $position->xEmu) + .self::renderPosition('positionV', $position->verticalFrom->value, $position->verticalAlign, $position->yEmu); + $wrap = match ($position->wrap) { + ImageWrap::None => '', + ImageWrap::Square => '', + ImageWrap::TopAndBottom => '', + }; + $close = ''; + } return << - + {$open}{$place} - + {$wrap} @@ -844,8 +869,18 @@ private function renderInlineImage(Image $img): string - + {$close} XML; } + + /** `` or ``: an alignment, or else an offset in EMU. */ + private static function renderPosition(string $tag, string $from, ?PositionAlign $align, int $offsetEmu): string + { + $inner = $align !== null + ? ''.$align->value.'' + : ''.$offsetEmu.''; + + return ''.$inner.''; + } } diff --git a/tests/Writer/FloatingImageTest.php b/tests/Writer/FloatingImageTest.php new file mode 100644 index 0000000..4a8a32a --- /dev/null +++ b/tests/Writer/FloatingImageTest.php @@ -0,0 +1,198 @@ +`) instead of sitting in the line. */ +final class FloatingImageTest extends TestCase +{ + // 1×1 transparent PNG + private const string PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + private static function image(?ImagePosition $position = null, int $offsetYEmu = 0): Image + { + return new Image((string) base64_decode(self::PNG, true), ImageFormat::Png, 914400, 457200, 'logo', $offsetYEmu, $position); + } + + /** @return array */ + private static function parts(Document $doc): array + { + $tmp = (string) tempnam(sys_get_temp_dir(), 'docx-float-'); + file_put_contents($tmp, (new Word2007Writer)->write($doc)); + $zip = new \ZipArchive; + $zip->open($tmp); + $out = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $out[(string) $zip->getNameIndex($i)] = (string) $zip->getFromIndex($i); + } + $zip->close(); + unlink($tmp); + + return $out; + } + + private static function xpath(string $xml): \DOMXPath + { + $dom = new \DOMDocument; + $dom->loadXML($xml); + $xp = new \DOMXPath($dom); + $xp->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'); + $xp->registerNamespace('wp', 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'); + $xp->registerNamespace('a', 'http://schemas.openxmlformats.org/drawingml/2006/main'); + + return $xp; + } + + private static function body(Image $img): \DOMXPath + { + return self::xpath(self::parts(new Document(new Section([$img, new Paragraph([new Run('text')])])))['word/document.xml']); + } + + #[Test] + public function without_a_position_a_picture_is_inline(): void + { + $xp = self::body(self::image()); + self::assertSame(1.0, $xp->evaluate('count(//wp:inline)')); + self::assertSame(0.0, $xp->evaluate('count(//wp:anchor)')); + } + + #[Test] + public function a_picture_behind_the_text_at_a_page_offset(): void + { + $xp = self::body(self::image(new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + xEmu: 720000, + verticalFrom: VerticalAnchor::Page, + yEmu: 360000, + behindText: true, + zOrder: 3, + ))); + self::assertSame(0.0, $xp->evaluate('count(//wp:inline)')); + self::assertSame('1', $xp->evaluate('string(//wp:anchor/@behindDoc)')); + self::assertSame('3', $xp->evaluate('string(//wp:anchor/@relativeHeight)')); + self::assertSame('page', $xp->evaluate('string(//wp:anchor/wp:positionH/@relativeFrom)')); + self::assertSame('720000', $xp->evaluate('string(//wp:anchor/wp:positionH/wp:posOffset)')); + self::assertSame('page', $xp->evaluate('string(//wp:anchor/wp:positionV/@relativeFrom)')); + self::assertSame('360000', $xp->evaluate('string(//wp:anchor/wp:positionV/wp:posOffset)')); + self::assertSame(1.0, $xp->evaluate('count(//wp:anchor/wp:wrapNone)')); + self::assertSame('914400', $xp->evaluate('string(//wp:anchor/wp:extent/@cx)')); + self::assertSame('logo', $xp->evaluate('string(//wp:anchor/wp:docPr/@descr)')); + self::assertSame(1.0, $xp->evaluate('count(//wp:anchor/a:graphic)')); + // CT_Anchor is a sequence: positions, then extent, then wrap, then docPr. + self::assertSame( + ['simplePos', 'positionH', 'positionV', 'extent', 'effectExtent', 'wrapNone', 'docPr', 'cNvGraphicFramePr', 'graphic'], + array_map(static fn (\DOMElement $e): string => $e->localName, iterator_to_array($xp->query('//wp:anchor/*'))), + ); + } + + #[Test] + public function alignment_instead_of_offsets_and_wrapping(): void + { + $xp = self::body(self::image(new ImagePosition( + horizontalFrom: HorizontalAnchor::Margin, + horizontalAlign: PositionAlign::Center, + verticalFrom: VerticalAnchor::Margin, + verticalAlign: PositionAlign::Top, + wrap: ImageWrap::Square, + ))); + self::assertSame('center', $xp->evaluate('string(//wp:positionH/wp:align)')); + self::assertSame('top', $xp->evaluate('string(//wp:positionV/wp:align)')); + self::assertSame(0.0, $xp->evaluate('count(//wp:posOffset)')); + self::assertSame('bothSides', $xp->evaluate('string(//wp:wrapSquare/@wrapText)')); + self::assertSame('0', $xp->evaluate('string(//wp:anchor/@behindDoc)')); + + $xp = self::body(self::image(new ImagePosition(wrap: ImageWrap::TopAndBottom))); + self::assertSame(1.0, $xp->evaluate('count(//wp:wrapTopAndBottom)')); + } + + #[Test] + public function an_offset_y_alone_floats_from_its_paragraph(): void + { + $xp = self::body(self::image(offsetYEmu: -254000)); + self::assertSame('paragraph', $xp->evaluate('string(//wp:positionV/@relativeFrom)')); + self::assertSame('-254000', $xp->evaluate('string(//wp:positionV/wp:posOffset)')); + self::assertSame('column', $xp->evaluate('string(//wp:positionH/@relativeFrom)')); + } + + #[Test] + public function a_picture_in_the_header_positioned_on_the_page_repeats_on_every_page(): void + { + $parts = self::parts(new Document(new Section( + [new Paragraph([new Run('body')])], + header: [new Paragraph([self::image(new ImagePosition(horizontalFrom: HorizontalAnchor::Page, verticalFrom: VerticalAnchor::Page, behindText: true))])], + ))); + $xp = self::xpath($parts['word/header1.xml']); + self::assertSame(1.0, $xp->evaluate('count(//wp:anchor)')); + self::assertStringContainsString('image', $parts['word/_rels/header1.xml.rels']); + } + + #[Test] + public function an_alignment_on_the_wrong_axis_is_refused(): void + { + $this->expectException(\InvalidArgumentException::class); + new ImagePosition(horizontalAlign: PositionAlign::Top); + } + + #[Test] + public function the_position_is_read_back(): void + { + $position = new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + xEmu: 720000, + verticalFrom: VerticalAnchor::Margin, + verticalAlign: PositionAlign::Bottom, + behindText: true, + wrap: ImageWrap::TopAndBottom, + zOrder: 7, + ); + $bytes = (new Word2007Writer)->write(new Document(new Section([new Paragraph([self::image($position)])]))); + $read = (new DocxReader)->read($bytes)->section->body[0]; + self::assertInstanceOf(Paragraph::class, $read); + $img = $read->children[0]; + self::assertInstanceOf(Image::class, $img); + self::assertEquals($position, $img->position); + } + + #[Test] + public function the_builder_takes_a_position(): void + { + $position = new ImagePosition(horizontalFrom: HorizontalAnchor::Page, verticalFrom: VerticalAnchor::Page, behindText: true); + $png = (string) base64_decode(self::PNG, true); + $tmp = (string) tempnam(sys_get_temp_dir(), 'png'); + file_put_contents($tmp, $png); + try { + $doc = \Dskripchenko\PhpDocx\Build\DocumentBuilder::new() + ->paragraph(fn ($p) => $p + ->imageFromBytes($png, ImageFormat::Png, 10, 10, position: $position) + ->imageFromDataUrl('data:image/png;base64,'.self::PNG, position: $position) + ->imageFromFile($tmp, position: $position)) + ->build(); + } finally { + unlink($tmp); + } + $p = $doc->section->body[0]; + self::assertInstanceOf(Paragraph::class, $p); + self::assertCount(3, $p->children); + foreach ($p->children as $img) { + self::assertInstanceOf(Image::class, $img); + self::assertSame($position, $img->position); + } + } +} From b586179fc863de610175843868c35d154b453b49 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Tue, 27 Oct 2026 21:53:28 -0700 Subject: [PATCH 3/4] test: no typed class constant, for PHP 8.2 Typed class constants arrived in PHP 8.3; the library supports 8.2. Co-Authored-By: Claude Opus 5.5 --- tests/Writer/FloatingImageTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Writer/FloatingImageTest.php b/tests/Writer/FloatingImageTest.php index 4a8a32a..f2bbd9b 100644 --- a/tests/Writer/FloatingImageTest.php +++ b/tests/Writer/FloatingImageTest.php @@ -24,7 +24,7 @@ final class FloatingImageTest extends TestCase { // 1×1 transparent PNG - private const string PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + private const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; private static function image(?ImagePosition $position = null, int $offsetYEmu = 0): Image { From 1ad5d4671acc09bcbfe8acc30a6247947e6b3170 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Tue, 27 Oct 2026 21:55:29 -0700 Subject: [PATCH 4/4] test: consumer smoke checks the reference document's sections The reference document now ends its first section in a second table, so python-docx finds two tables. The smoke check also asserts the three sections python-docx reads: portrait, landscape, and the custom width. Co-Authored-By: Claude Opus 5.5 --- scripts/conformance/consumer-smoke.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/conformance/consumer-smoke.sh b/scripts/conformance/consumer-smoke.sh index 5502c57..4fa1699 100755 --- a/scripts/conformance/consumer-smoke.sh +++ b/scripts/conformance/consumer-smoke.sh @@ -2,7 +2,8 @@ # Consumer smoke: the reference DOCX must survive two independent # real-world consumers — # * LibreOffice headless converts it to PDF without errors; -# * python-docx opens it and extracts the expected content markers. +# * python-docx opens it and extracts the expected content markers +# and sections. # # Usage: scripts/conformance/consumer-smoke.sh [docx-file] # default: build/conformance/reference.docx @@ -55,10 +56,14 @@ text = "\n".join(p.text for p in d.paragraphs) assert "php-docx conformance reference" in text, "heading missing" assert "Justified paragraph" in text, "body paragraph missing" assert "кириллица" in text, "cyrillic text missing" -assert len(d.tables) == 1, f"expected 1 table, got {len(d.tables)}" +assert len(d.tables) == 2, f"expected 2 tables, got {len(d.tables)}" assert d.tables[0].rows[0].cells[0].text == "Feature", "table header cell mismatch" +# Three sections: portrait A4, landscape A4, then a custom size. +assert len(d.sections) == 3, f"expected 3 sections, got {len(d.sections)}" +assert d.sections[1].page_width > d.sections[1].page_height, "second section is not landscape" +assert d.sections[2].page_width == 8391 * 635, "custom page width lost" print(f"PASS python-docx {docx.__version__ if hasattr(docx, '__version__') else ''}: " - f"{len(d.paragraphs)} paragraphs, {len(d.tables)} table") + f"{len(d.paragraphs)} paragraphs, {len(d.tables)} tables, {len(d.sections)} sections") PY then echo "| python-docx | open + content markers | ✅ |" >> "$summary"