From 0b75eb1164fbe166581774636ffc481748f40a88 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Wed, 28 Oct 2026 23:15:50 +0300 Subject: [PATCH] feat: sections, headers of every kind and floating pictures both ways A php-docx section per PhpWord section with its page setup and start; default, first-page and even-page headers and footers; floating pictures through PhpWord's image style. Pictures from php-docx now reach PhpWord as bytes: a data: URL was rejected as an invalid image. Requires php-docx ^1.18. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 +++++ README.md | 6 +- composer.json | 2 +- docs/de/README.md | 6 +- docs/ru/README.md | 6 +- docs/zh/README.md | 6 +- src/Internal/FromPhpWordMapper.php | 122 ++++++++++++++++++-- src/Internal/ToPhpWordMapper.php | 110 +++++++++++++++--- src/PhpWordBridge.php | 6 +- tests/SectionsAndFloatingTest.php | 176 +++++++++++++++++++++++++++++ 10 files changed, 426 insertions(+), 41 deletions(-) create mode 100644 tests/SectionsAndFloatingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 629c98b..c00409c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to `dskripchenko/php-docx-phpword` are documented in this 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] + +Requires `dskripchenko/php-docx` ^1.18. + +### Added +- Sections both ways: a php-docx section per PhpWord section, with its page + size and orientation (a preset paper size, else the size as it is), + margins, header and footer offsets and start (`breakType`: new page, + continuous, even or odd page). Before, every section was folded into one. +- Headers and footers of every kind both ways: default, first-page and + even-page (`Header::FIRST`, `Header::EVEN`; even pages turn PhpWord's + `evenAndOddHeaders` on). Before, only the default ones were carried, and + PhpWord's headers of every kind were merged into one. +- Floating pictures both ways: php-docx's `Image::$position` ↔ PhpWord's + image style (`positioning`, `posHorizontal`/`posVertical`, their + `…Rel`, `marginLeft`/`marginTop`, `wrappingStyle`). Origins php-docx has + no case for are read as the nearest one. + +### Fixed +- Pictures from a php-docx document reach PhpWord: they were handed over + as a `data:` URL, which PhpWord 1.x rejects as an invalid image, so any + document with a picture failed in `fromDocument()`. They go as bytes now. + +### Changed +- `fromDocument()` sets each PhpWord section's page from the document's + page setup; before, PhpWord's default page was used. + ## [1.0.0] — 2026-07-18 Initial release. diff --git a/README.md b/README.md index 9d32b34..31fe86d 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,9 @@ The bridge maps what **both** models can express: | headings 1–6 | charts, TOC, checkboxes, OLE | | tables incl. `gridSpan` / `vMerge` | tracked changes, comments | | bullet / numbered lists (nested) | footnotes/endnotes, OMML math | -| hyperlinks, images, line/page breaks | form fields | -| default headers/footers | first/even header variants, watermarks | -| paragraph alignment, spacing, indents | section/page geometry details | +| hyperlinks, images (in the line of text or floating: position, wrap, in front of or behind the text), line/page breaks | form fields | +| sections (page size and orientation, margins, start) with default, first-page and even-page headers/footers | watermarks | +| paragraph alignment, spacing, indents | section columns, page numbering, page borders | If a document leans on the right-hand column, the bridge will not pretend otherwise — content outside the set is dropped silently by diff --git a/composer.json b/composer.json index 370d13c..7d40821 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": "^8.2", - "dskripchenko/php-docx": "^1.1", + "dskripchenko/php-docx": "^1.18", "phpoffice/phpword": "^1.3" }, "require-dev": { diff --git a/docs/de/README.md b/docs/de/README.md index f2f32fa..6bfc5a0 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -76,9 +76,9 @@ Die Brücke überträgt, was **beide** Modelle ausdrücken können: | Überschriften 1–6 | Diagramme, Inhaltsverzeichnis, Kontrollkästchen, OLE | | Tabellen inkl. `gridSpan` / `vMerge` | Änderungsverfolgung, Kommentare | | Aufzählungen und nummerierte Listen (auch verschachtelt) | Fuß- und Endnoten, OMML-Formeln | -| Hyperlinks, Bilder, Zeilen- und Seitenumbrüche | Formularfelder | -| Standard-Kopf-/Fußzeilen | eigene Varianten für erste und gerade Seiten, Wasserzeichen | -| Absatzausrichtung, Abstände, Einzüge | Feinheiten der Abschnitts- und Seitengeometrie | +| Hyperlinks, Bilder (im Text oder frei positioniert: Position, Umbruch, vor oder hinter dem Text), Zeilen- und Seitenumbrüche | Formularfelder | +| Abschnitte (Seitengröße und -ausrichtung, Ränder, Abschnittsbeginn) mit Standard-Kopf-/Fußzeilen sowie Varianten für erste und gerade Seiten | Wasserzeichen | +| Absatzausrichtung, Abstände, Einzüge | Spalten im Abschnitt, Seitennummerierung, Seitenrahmen | Stützt sich ein Dokument auf die rechte Spalte, tut die Brücke nicht so, als wäre alles in Ordnung: Inhalte außerhalb der gemeinsamen Menge werden bewusst diff --git a/docs/ru/README.md b/docs/ru/README.md index b4fdfba..ba35aa0 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -76,9 +76,9 @@ php-docx. | заголовки 1–6 | диаграммы, оглавление, чекбоксы, OLE | | таблицы, включая `gridSpan` и `vMerge` | правки, комментарии | | маркированные и нумерованные списки, в том числе вложенные | сноски и концевые сноски, формулы OMML | -| ссылки, изображения, разрывы строки и страницы | поля форм | -| колонтитулы по умолчанию | отдельные колонтитулы первой и чётных страниц, подложки | -| выравнивание, интервалы, отступы абзаца | тонкости геометрии разделов и страниц | +| ссылки, изображения (в строке и плавающие: позиция, обтекание, перед текстом или за ним), разрывы строки и страницы | поля форм | +| разделы (размер и ориентация страницы, поля, начало раздела) с колонтитулами по умолчанию, первой и чётных страниц | подложки | +| выравнивание, интервалы, отступы абзаца | колонки раздела, нумерация страниц, границы страницы | Если документ держится на правом столбце, мост не станет делать вид, что всё в порядке: содержимое вне общего множества сознательно отбрасывается молча. Для diff --git a/docs/zh/README.md b/docs/zh/README.md index adbb569..4fa358f 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -72,9 +72,9 @@ $variables = PhpWordBridge::detectVariables($phpWordOrDocxBytes); | 1–6 级标题 | 图表、目录、复选框、OLE | | 表格,含 `gridSpan` / `vMerge` | 修订记录、批注 | | 项目符号与编号列表(含嵌套) | 脚注/尾注、OMML 公式 | -| 超链接、图片、换行与分页符 | 表单域 | -| 默认页眉页脚 | 首页/偶数页的独立页眉、水印 | -| 段落对齐、间距、缩进 | 节与页面几何的细节 | +| 超链接、图片(行内或浮动:位置、环绕、置于文字上方或下方)、换行与分页符 | 表单域 | +| 节(页面尺寸与方向、页边距、起始方式),含默认、首页与偶数页页眉页脚 | 水印 | +| 段落对齐、间距、缩进 | 节内分栏、页码、页面边框 | 如果一份文档主要依赖右栏的那些特性,桥不会假装没事:落在共同元素集之外的内容会按设计被静默丢弃。 需要完全保真的 DOCX 往返,请直接使用 diff --git a/src/Internal/FromPhpWordMapper.php b/src/Internal/FromPhpWordMapper.php index 45a23d7..232a866 100644 --- a/src/Internal/FromPhpWordMapper.php +++ b/src/Internal/FromPhpWordMapper.php @@ -23,7 +23,16 @@ use Dskripchenko\PhpDocx\Style\Alignment; use Dskripchenko\PhpDocx\Style\CellStyle; use Dskripchenko\PhpDocx\Style\ParagraphStyle; +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\PaperSize; +use Dskripchenko\PhpDocx\Style\PositionAlign; use Dskripchenko\PhpDocx\Style\RunStyle; +use Dskripchenko\PhpDocx\Style\SectionStart; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; use PhpOffice\PhpWord\Element\AbstractElement; use PhpOffice\PhpWord\Element\Cell; use PhpOffice\PhpWord\Element\Link; @@ -41,7 +50,9 @@ use PhpOffice\PhpWord\SimpleType\Jc; use PhpOffice\PhpWord\Style\Font; use PhpOffice\PhpWord\Style\ListItem as ListItemStyle; +use PhpOffice\PhpWord\Style\Image as WordImageStyle; use PhpOffice\PhpWord\Style\Paragraph as WordParagraphStyle; +use PhpOffice\PhpWord\Style\Section as WordSectionStyle; /** * PhpWord object model → php-docx AST, on the shared element set (see @@ -52,21 +63,64 @@ final class FromPhpWordMapper { public function map(PhpWord $phpWord): Document { - $body = []; - $header = []; - $footer = []; - + $sections = []; foreach ($phpWord->getSections() as $section) { + // Headers and footers by kind: default, first, even (PhpWord's names too). + $headers = ['default' => [], 'first' => [], 'even' => []]; foreach ($section->getHeaders() as $h) { - $header = array_merge($header, $this->mapElements($h->getElements())); + $type = array_key_exists($h->getType(), $headers) ? $h->getType() : 'default'; + $headers[$type] = array_merge($headers[$type], $this->mapElements($h->getElements())); } + $footers = ['default' => [], 'first' => [], 'even' => []]; foreach ($section->getFooters() as $f) { - $footer = array_merge($footer, $this->mapElements($f->getElements())); + $type = array_key_exists($f->getType(), $footers) ? $f->getType() : 'default'; + $footers[$type] = array_merge($footers[$type], $this->mapElements($f->getElements())); } - $body = array_merge($body, $this->mapElements($section->getElements())); + $style = $section->getStyle() ?? new WordSectionStyle; + $sections[] = new Section( + body: $this->mapElements($section->getElements()), + header: $headers['default'], + footer: $footers['default'], + pageSetup: $this->pageSetup($style), + firstHeader: $headers['first'], + firstFooter: $footers['first'], + evenHeader: $headers['even'], + evenFooter: $footers['even'], + start: SectionStart::tryFrom((string) $style->getBreakType()) ?? SectionStart::NextPage, + ); + } + if ($sections === []) { + $sections[] = new Section(body: []); } - return new Document(new Section(body: $body, header: $header, footer: $footer)); + return new Document($sections[0], followingSections: array_slice($sections, 1)); + } + + /** A PhpWord section's page (twips): a preset paper size when one matches, else the size as it is. */ + private function pageSetup(WordSectionStyle $style): PageSetup + { + $width = (int) round((float) $style->getPageSizeW()); + $height = (int) round((float) $style->getPageSizeH()); + $paper = null; + foreach (PaperSize::cases() as $size) { + if (abs($size->widthTwips() - min($width, $height)) <= 2 && abs($size->heightTwips() - max($width, $height)) <= 2) { + $paper = $size; + break; + } + } + + return new PageSetup( + paperSize: $paper ?? PaperSize::A4, + orientation: $style->getOrientation() === WordSectionStyle::ORIENTATION_LANDSCAPE ? Orientation::Landscape : Orientation::Portrait, + marginTopTwips: (int) round((float) $style->getMarginTop()), + marginRightTwips: (int) round((float) $style->getMarginRight()), + marginBottomTwips: (int) round((float) $style->getMarginBottom()), + marginLeftTwips: (int) round((float) $style->getMarginLeft()), + headerOffsetTwips: (int) round((float) $style->getHeaderHeight()), + footerOffsetTwips: (int) round((float) $style->getFooterHeight()), + widthTwips: $paper === null && $width > 0 && $height > 0 ? $width : null, + heightTwips: $paper === null && $width > 0 && $height > 0 ? $height : null, + ); } /** @@ -207,7 +261,57 @@ private function mapImage(WordImage $el): ?Image $wPt = (float) ($style?->getWidth() ?? 96.0); $hPt = (float) ($style?->getHeight() ?? 96.0); - return new Image($binary, $format, (int) round($wPt * 12700), (int) round($hPt * 12700)); + return new Image( + $binary, + $format, + (int) round($wPt * 12700), + (int) round($hPt * 12700), + position: $style === null ? null : $this->floating($style), + ); + } + + /** + * A floating picture's place (PhpWord writes a picture floating unless + * its wrap is inline); null for a picture in the line of text. Origins + * php-docx has no case for are read as the nearest one: the margin areas + * as the margin, the text as the paragraph. + */ + private function floating(WordImageStyle $style): ?ImagePosition + { + // PhpWord leaves these null until set, its docblocks say string. + $wrap = (string) $style->getWrappingStyle(); + if ($wrap === '' || $wrap === WordImageStyle::WRAPPING_STYLE_INLINE) { + return null; + } + $horizontal = PositionAlign::tryFrom((string) $style->getPosHorizontal()); + $vertical = PositionAlign::tryFrom((string) $style->getPosVertical()); + + return new ImagePosition( + horizontalFrom: match ((string) $style->getPosHorizontalRel()) { + WordImageStyle::POSITION_RELATIVE_TO_PAGE => HorizontalAnchor::Page, + WordImageStyle::POSITION_RELATIVE_TO_COLUMN, '' => HorizontalAnchor::Column, + WordImageStyle::POSITION_RELATIVE_TO_CHAR => HorizontalAnchor::Character, + default => HorizontalAnchor::Margin, + }, + xEmu: (int) round((float) $style->getMarginLeft() * 12700), + verticalFrom: match ((string) $style->getPosVerticalRel()) { + WordImageStyle::POSITION_RELATIVE_TO_PAGE => VerticalAnchor::Page, + WordImageStyle::POSITION_RELATIVE_TO_LINE => VerticalAnchor::Line, + WordImageStyle::POSITION_RELATIVE_TO_TEXT, '' => VerticalAnchor::Paragraph, + default => VerticalAnchor::Margin, + }, + yEmu: (int) round((float) $style->getMarginTop() * 12700), + // Only the alignments of their own axis: PhpWord takes left/right + // for vertical ones too, php-docx refuses them. + horizontalAlign: in_array($horizontal, [PositionAlign::Top, PositionAlign::Bottom], true) ? null : $horizontal, + verticalAlign: in_array($vertical, [PositionAlign::Left, PositionAlign::Right], true) ? null : $vertical, + behindText: $wrap === WordImageStyle::WRAPPING_STYLE_BEHIND, + wrap: match ($wrap) { + WordImageStyle::WRAPPING_STYLE_SQUARE, WordImageStyle::WRAPPING_STYLE_TIGHT, 'through' => ImageWrap::Square, + 'topAndBottom' => ImageWrap::TopAndBottom, + default => ImageWrap::None, + }, + ); } private function mapTable(WordTable $el): AstTable diff --git a/src/Internal/ToPhpWordMapper.php b/src/Internal/ToPhpWordMapper.php index 8056a11..bd5abb5 100644 --- a/src/Internal/ToPhpWordMapper.php +++ b/src/Internal/ToPhpWordMapper.php @@ -17,16 +17,23 @@ use Dskripchenko\PhpDocx\Style\Alignment; use Dskripchenko\PhpDocx\Style\CellStyle; use Dskripchenko\PhpDocx\Style\ParagraphStyle; +use Dskripchenko\PhpDocx\Section; +use Dskripchenko\PhpDocx\Style\HorizontalAnchor; +use Dskripchenko\PhpDocx\Style\ImageWrap; use Dskripchenko\PhpDocx\Style\RunStyle; +use Dskripchenko\PhpDocx\Style\SectionStart; +use Dskripchenko\PhpDocx\Style\VerticalAnchor; use PhpOffice\PhpWord\Element\AbstractContainer; use PhpOffice\PhpWord\PhpWord; use PhpOffice\PhpWord\SimpleType\Jc; +use PhpOffice\PhpWord\Style\Image as ImageStyle; use PhpOffice\PhpWord\Style\ListItem as ListItemStyle; /** - * php-docx AST → PhpWord object model, on the shared element set. - * AST features outside PHPWord's model (text watermarks, first/even - * header variants) are skipped, never guessed. + * php-docx AST → PhpWord object model, on the shared element set: a PhpWord + * section per php-docx section, with its page setup, start and headers and + * footers of every kind. AST features outside PHPWord's model (text + * watermarks) are skipped, never guessed. */ final class ToPhpWordMapper { @@ -37,21 +44,52 @@ public function map(Document $document): PhpWord for ($level = 1; $level <= 6; $level++) { $phpWord->addTitleStyle($level, ['bold' => true, 'size' => 20 - $level * 2]); } - $section = $phpWord->addSection(); - - if ($document->section->header !== []) { - $header = $section->addHeader(); - $this->mapBlocks($header, $document->section->header); - } - if ($document->section->footer !== []) { - $footer = $section->addFooter(); - $this->mapBlocks($footer, $document->section->footer); + foreach ($document->sections() as $i => $astSection) { + $section = $phpWord->addSection($this->sectionStyle($astSection, isFirst: $i === 0)); + // The kinds share their names: default, first, even. A section + // without one shows the previous section's, in both models. + foreach ($astSection->allHeaders() as $type => $blocks) { + $this->mapBlocks($section->addHeader($type), $blocks); + } + foreach ($astSection->allFooters() as $type => $blocks) { + $this->mapBlocks($section->addFooter($type), $blocks); + } + if ($astSection->hasEvenPageHeaderOrFooter()) { + $phpWord->getSettings()->setEvenAndOddHeaders(true); + } + $this->mapBlocks($section, $astSection->body); } - $this->mapBlocks($section, $document->section->body); return $phpWord; } + /** + * A section's page and start as PhpWord's section style (twips). + * + * @return array + */ + private function sectionStyle(Section $section, bool $isFirst): array + { + $page = $section->pageSetup; + $style = [ + 'pageSizeW' => $page->pageWidthTwips(), + 'pageSizeH' => $page->pageHeightTwips(), + 'orientation' => $page->effectiveOrientation()->value, + 'marginTop' => $page->marginTopTwips, + 'marginRight' => $page->marginRightTwips, + 'marginBottom' => $page->marginBottomTwips, + 'marginLeft' => $page->marginLeftTwips, + 'headerHeight' => $page->headerOffsetTwips, + 'footerHeight' => $page->footerOffsetTwips, + ]; + // A new page is what a section does without a break type. + if (! $isFirst && $section->start !== SectionStart::NextPage) { + $style['breakType'] = $section->start->value; + } + + return $style; + } + /** * @param list $blocks */ @@ -97,10 +135,12 @@ private function mapParagraph(AbstractContainer $container, Paragraph $p): void } elseif ($child instanceof LineBreak) { $textRun->addTextBreak(); } elseif ($child instanceof Image) { - $textRun->addImage($this->dataUri($child), [ + // The bytes themselves: PhpWord takes a string that is not a + // path for image data, and a data: URL for neither. + $textRun->addImage($child->binary, [ 'width' => (int) round($child->widthEmu / 12700), 'height' => (int) round($child->heightEmu / 12700), - ]); + ] + $this->floating($child)); } } } @@ -241,8 +281,44 @@ private function paragraphStyle(ParagraphStyle $style): array return $out; } - private function dataUri(Image $image): string + /** + * A floating picture's position as PhpWord's image style (points, as its + * sizes); nothing for a picture in the line of text. + * + * @return array + */ + private function floating(Image $image): array { - return 'data:image/'.$image->format->value.';base64,'.base64_encode($image->binary); + $position = $image->effectivePosition(); + if ($position === null) { + return []; + } + + return [ + 'positioning' => ImageStyle::POSITION_RELATIVE, + 'posHorizontalRel' => match ($position->horizontalFrom) { + HorizontalAnchor::Page => ImageStyle::POSITION_RELATIVE_TO_PAGE, + HorizontalAnchor::Margin => ImageStyle::POSITION_RELATIVE_TO_MARGIN, + HorizontalAnchor::Column => ImageStyle::POSITION_RELATIVE_TO_COLUMN, + HorizontalAnchor::Character => ImageStyle::POSITION_RELATIVE_TO_CHAR, + }, + 'posVerticalRel' => match ($position->verticalFrom) { + VerticalAnchor::Page => ImageStyle::POSITION_RELATIVE_TO_PAGE, + VerticalAnchor::Margin => ImageStyle::POSITION_RELATIVE_TO_MARGIN, + VerticalAnchor::Paragraph => ImageStyle::POSITION_RELATIVE_TO_TEXT, + VerticalAnchor::Line => ImageStyle::POSITION_RELATIVE_TO_LINE, + }, + // The alignments share their names; without one, the offset. + 'posHorizontal' => $position->horizontalAlign->value ?? ImageStyle::POSITION_ABSOLUTE, + 'posVertical' => $position->verticalAlign->value ?? ImageStyle::POSITION_ABSOLUTE, + 'marginLeft' => round($position->xEmu / 12700, 2), + 'marginTop' => round($position->yEmu / 12700, 2), + 'wrappingStyle' => match ($position->wrap) { + ImageWrap::None => $position->behindText ? ImageStyle::WRAPPING_STYLE_BEHIND : ImageStyle::WRAPPING_STYLE_INFRONT, + ImageWrap::Square => ImageStyle::WRAPPING_STYLE_SQUARE, + ImageWrap::TopAndBottom => 'topAndBottom', + }, + ]; } + } diff --git a/src/PhpWordBridge.php b/src/PhpWordBridge.php index 1c47b54..560f66e 100644 --- a/src/PhpWordBridge.php +++ b/src/PhpWordBridge.php @@ -22,8 +22,10 @@ * Works on the SHARED element set: paragraphs and runs (bold, italic, * underline, strikethrough, super/subscript, colour, size, font name), * headings 1–6, tables (gridSpan / vMerge), bullet and numbered lists - * (nested), hyperlinks, images, line and page breaks, default - * headers/footers, paragraph alignment/spacing/indent. Features outside + * (nested), hyperlinks, images (inline or floating), line and page + * breaks, sections with their page setup and start, default, first-page + * and even-page headers/footers, paragraph alignment/spacing/indent. + * Features outside * the set (charts, TOC, tracked changes, comments, footnotes, form * fields, named styles) are skipped, never guessed. */ diff --git a/tests/SectionsAndFloatingTest.php b/tests/SectionsAndFloatingTest.php new file mode 100644 index 0000000..9454729 --- /dev/null +++ b/tests/SectionsAndFloatingTest.php @@ -0,0 +1,176 @@ +header(fn ($h) => $h->paragraph('Report')) + ->firstHeader(fn ($h) => $h->paragraph('Cover')) + ->paragraph('Portrait pages') + ->newSection(new PageSetup(orientation: Orientation::Landscape)) + ->header(fn ($h) => $h->paragraph('Appendix')) + ->paragraph('Landscape pages') + ->newSection(new PageSetup(widthTwips: 8391, heightTwips: 11906), SectionStart::Continuous) + ->paragraph('A custom size, on the same page') + ->build(); + } + + #[Test] + public function every_section_becomes_a_phpword_section(): void + { + $word = PhpWordBridge::fromDocument($this->threeSections()); + $sections = $word->getSections(); + + self::assertCount(3, $sections); + self::assertSame('portrait', $sections[0]->getStyle()->getOrientation()); + self::assertSame('landscape', $sections[1]->getStyle()->getOrientation()); + self::assertSame(PaperSize::A4->heightTwips(), (int) $sections[1]->getStyle()->getPageSizeW()); + self::assertSame(8391, (int) $sections[2]->getStyle()->getPageSizeW()); + self::assertSame(11906, (int) $sections[2]->getStyle()->getPageSizeH()); + self::assertNull($sections[1]->getStyle()->getBreakType()); + self::assertSame('continuous', $sections[2]->getStyle()->getBreakType()); + + $types = array_map(fn (Header $h) => $h->getType(), $sections[0]->getHeaders()); + self::assertEqualsCanonicalizing([Header::AUTO, Header::FIRST], array_values($types)); + // The third section sets no header: it shows the second's, in both models. + self::assertSame([], $sections[2]->getHeaders()); + } + + #[Test] + public function sections_survive_phpword_s_own_writer(): void + { + $word = PhpWordBridge::fromDocument($this->threeSections()); + $path = tempnam(sys_get_temp_dir(), 'bridge').'.docx'; + IOFactory::createWriter($word, 'Word2007')->save($path); + $read = (new DocxReader)->read((string) file_get_contents($path)); + unlink($path); + + $sections = $read->sections(); + self::assertCount(3, $sections); + self::assertSame(Orientation::Landscape, $sections[1]->pageSetup->effectiveOrientation()); + self::assertSame(SectionStart::Continuous, $sections[2]->start); + self::assertSame('Landscape pages', $this->text($sections[1])); + self::assertSame('Cover', $this->text($sections[0], 'firstHeader')); + } + + #[Test] + public function phpword_sections_become_document_sections(): void + { + $word = new PhpWord; + $first = $word->addSection(); + $first->addHeader()->addText('Default'); + $first->addHeader(Header::FIRST)->addText('First'); + $first->addText('One'); + $second = $word->addSection(['orientation' => 'landscape', 'breakType' => 'oddPage', 'marginTop' => 720]); + $second->addText('Two'); + + $document = PhpWordBridge::toDocument($word); + $sections = $document->sections(); + + self::assertCount(2, $sections); + self::assertSame('One', $this->text($sections[0])); + self::assertSame('Default', $this->text($sections[0], 'header')); + self::assertSame('First', $this->text($sections[0], 'firstHeader')); + self::assertSame('Two', $this->text($sections[1])); + self::assertSame(Orientation::Landscape, $sections[1]->pageSetup->orientation); + self::assertSame(PaperSize::A4, $sections[1]->pageSetup->paperSize); + self::assertSame(720, $sections[1]->pageSetup->marginTopTwips); + self::assertSame(SectionStart::OddPage, $sections[1]->start); + } + + #[Test] + public function a_floating_picture_keeps_its_place_both_ways(): void + { + $position = new ImagePosition( + horizontalFrom: HorizontalAnchor::Page, + verticalFrom: VerticalAnchor::Page, + yEmu: 254000, + horizontalAlign: PositionAlign::Right, + behindText: true, + ); + $image = new Image(self::png(), ImageFormat::Png, 1270000, 635000, position: $position); + $word = PhpWordBridge::fromDocument(new Document(new Section(body: [new Paragraph([$image])]))); + + $run = $word->getSections()[0]->getElements()[0]; + self::assertInstanceOf(TextRun::class, $run); + $wordImage = $run->getElements()[0]; + self::assertInstanceOf(WordImage::class, $wordImage); + $style = $wordImage->getStyle(); + self::assertSame('page', $style->getPosHorizontalRel()); + self::assertSame('right', $style->getPosHorizontal()); + self::assertSame('absolute', $style->getPosVertical()); + self::assertEquals(20, $style->getMarginTop()); + self::assertSame('behind', $style->getWrappingStyle()); + + $back = PhpWordBridge::toDocument($word)->section->body[0]; + self::assertInstanceOf(Paragraph::class, $back); + $backImage = $back->children[0]; + self::assertInstanceOf(Image::class, $backImage); + self::assertEquals($position, $backImage->position); + } + + #[Test] + public function a_picture_in_the_line_of_text_stays_there(): void + { + $image = new Image(self::png(), ImageFormat::Png, 1270000, 635000); + $word = PhpWordBridge::fromDocument(new Document(new Section(body: [new Paragraph([$image])]))); + $back = PhpWordBridge::toDocument($word)->section->body[0]; + + self::assertInstanceOf(Paragraph::class, $back); + self::assertInstanceOf(Image::class, $back->children[0]); + self::assertNull($back->children[0]->position); + } + + private function text(Section $section, string $part = 'body'): string + { + $out = ''; + foreach ($section->{$part} as $block) { + if ($block instanceof Paragraph) { + foreach ($block->children as $child) { + $out .= $child instanceof \Dskripchenko\PhpDocx\Element\Run ? $child->text : ''; + } + } + } + + return $out; + } +}