Skip to content

Sections and floating pictures - #10

Merged
dskripchenko merged 4 commits into
dskripchenko:mainfrom
serpentblade:feat/sections-and-floating-images
Oct 28, 2026
Merged

dskripchenko merged 4 commits into
dskripchenko:mainfrom
serpentblade:feat/sections-and-floating-images

Conversation

@serpentblade

@serpentblade serpentblade commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for merging #7 and releasing 1.17.0. This PR adds the two writer features we couldn't work around in our exporter: several sections per document, and floating pictures. It's two commits, one per feature, plus two small test follow-ups: the consumer smoke check belongs with the first (the reference document gains a second table and three sections), and a PHP 8.2 fix belongs with the second. Each feature passes the full suite and composer stan on its own, so either can be taken alone. As you asked, it comes from my personal fork, so you can edit it before merging.

Feature Commit
Sections and custom page sizes feat: several sections per document, and custom page sizes
test: consumer smoke checks the reference document's sections
Floating pictures feat: floating pictures (wp:anchor)
test: no typed class constant, for PHP 8.2

New constructor parameters are appended at the end, so positional callers are unaffected. The CHANGELOG has one [Unreleased] section, and the README is updated in all four languages. The conformance reference document exercises both features, and it validates against the ECMA-376 Transitional schemas with scripts/conformance/xsd-check.sh.

1. Sections

A Document had exactly one Section, so one page setup and one set of headers covered the whole document. A landscape appendix, or a cover page with a different header, needed a second document.

Model. Document gains $followingSections (the sections after the first) and sections(), which lists them all. Section gains $start (SectionStart: NextPage, Continuous, EvenPage, OddPage). DocumentBuilder::newSection(?PageSetup, SectionStart) ends the current section and starts the next. The content, headers and footers added after it belong to the new section, and it keeps the page setup unless given one.

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, starting on an odd page')
    ->toFile('sections.docx');

Writer. Each section gets its own header and footer parts. Every section but the last ends with its <w:sectPr> in the pPr of its last paragraph, as Word writes it:

<w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rId3"/><w:pgSz .../><w:pgMar .../></w:sectPr></w:pPr><w:r>...</w:r></w:p>
<w:p>...</w:p>
<w:sectPr><w:headerReference w:type="default" r:id="rId4"/><w:type w:val="continuous"/><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/>...</w:sectPr>

A section that ends in a table gets an added paragraph to carry its sectPr. w:type is written only when the start isn't a new page, which is what a section does without one. titlePg is per section. A section without a header or footer of a kind writes no reference, so it shows the previous section's, which is Word's rule. That means an empty header list means "inherit", not "blank", and the Section docblock says so. A single-section document writes the same bytes as before: the bodies are rendered first, as they were, and each sectPr is filled in once the header parts have their ids.

Custom page sizes. Sections with different pages need sizes beyond PaperSize. PageSetup gains $widthTwips and $heightTwips (both or neither), with pageWidthTwips(), pageHeightTwips() and effectiveOrientation(). A custom page is landscape when it's wider than it is tall. contentWidthTwips() uses the custom size.

Reader. DocxReader splits the body at each paragraph-level sectPr and reads each section's body, page setup, start, header and footer references and titlePg. Before, it read the last section's page setup and the first section's headers and applied them to the whole body. SectionReader gains fromSectPr() and start(), and keeps a page size that no PaperSize matches as a custom size instead of turning it into A4. A document without any sectPr reads as before.

One behaviour change, called out in the CHANGELOG: code that reads only $document->section now gets the first section of a multi-section file, not the whole body. sections() returns all of it. The HTML serializer now writes every section's body, with a page break before each section that starts on a new page. It still takes headers, footers and page settings from the first section.

2. Floating pictures

The writer only wrote <wp:inline>. Image::$offsetYEmu was documented as the offset of a floating object, and the reader and the HTML serializer used it, but the writer ignored it.

Model. Image gains $position (ImagePosition). On each axis, the picture sits either at an offset in EMU from what it's measured from or at an alignment there. It's measured from the page, margin, column or character horizontally (HorizontalAnchor), or the page, margin, paragraph or line vertically (VerticalAnchor). The alignment is a PositionAlign, and the constructor refuses one on the wrong axis. behindText, wrap (ImageWrap: None, Square, TopAndBottom) and zOrder (relativeHeight) set how it sits with the text. Image::fromPx() and the builder's imageFromBytes(), imageFromDataUrl() and imageFromFile() take a position.

// 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,
    behindText: true,
))))

Writer. A picture with a position is written as <wp:anchor>, with the children in the order CT_Anchor requires: simplePos, positionH, positionV, extent, effectExtent, the wrap, docPr, cNvGraphicFramePr, graphic. Every required attribute is written. Without a position, the picture stays <wp:inline>, byte for byte as before. A picture placed on the page from a header or footer repeats on every page that header covers, which gives letterheads, page backgrounds and stamps.

offsetYEmu. 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. effectivePosition() makes that one rule in one place.

Reader. ImageReader reads an anchor's position into $position and still fills offsetYEmu, so a document that is read and written back keeps its floating pictures where they were. Origins the model has no case for are read as the nearest one: a *Margin origin becomes the margin, and a tight or through wrap becomes square.

Tests

composer install && vendor/bin/phpunit: OK (494 tests, 7368 assertions). Before: OK (472 tests, 7271 assertions). composer stan: no errors. The first commit alone passes with 486 tests.

New tests:

  • tests/Writer/SectionsTest.php: where each sectPr goes, header parts per section, inheritance, a section ending in a table, titlePg per section, custom sizes, and that a single section is unchanged.
  • tests/Build/SectionsBuilderTest.php covers newSection().
  • tests/Reader/SectionsReaderTest.php: a three-section round trip (bodies, headers, first-page header, page setups including a custom size, start types), and write → read → write.
  • tests/Writer/FloatingImageTest.php: the anchor's attributes and child order, alignments, wraps, offsetYEmu alone, a header picture on the page, the wrong-axis guard, the builder helpers, and a read-back of the position.
  • SerializerTest gains one test for the section bodies.

scripts/conformance/consumer-smoke.sh now also checks that python-docx reads three sections with the right page sizes. LibreOffice 26.8 renders the conformance reference as three pages: A4 portrait, A4 landscape with its own header, and a custom size that inherits it. The header's page-positioned picture sits behind the text, and the body picture has text wrapped around it.

🤖 Generated with Claude Code

serpentblade and others added 4 commits October 27, 2026 21:35
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 <noreply@anthropic.com>
Image::$position (ImagePosition) writes a picture as <wp:anchor>: 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 <noreply@anthropic.com>
Typed class constants arrived in PHP 8.3; the library supports 8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@dskripchenko
dskripchenko merged commit bd02d86 into dskripchenko:main Oct 28, 2026
8 checks passed
@dskripchenko dskripchenko mentioned this pull request Oct 28, 2026
@dskripchenko

Copy link
Copy Markdown
Owner

Thank you, @serpentblade — this is a great contribution, and it closes the two writer gaps that the library had listed as out of scope since v1.

I reviewed both features in depth before merging: the per-section sectPr placement and header/footer parts, the "empty means inherit" rule, custom page sizes, the reader's split by section, the CT_Anchor child order and attributes, and the offsetYEmu rule. Single-section documents and inline pictures write the same bytes as before, which made the review easy. The full suite and composer stan pass locally, and CI is green across the board. It needed no edits on my side.

Released in v1.18.0, now on Packagist. The behaviour change for multi-section documents ($document->section is the first section, sections() is all of them) is in the changelog as you wrote it. Our PHPWord bridge reads $document->section too; I'm updating it to map sections one to one.

Thanks again for splitting it into self-contained commits and for opening it from your personal fork — that kept the whole round smooth.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants