Skip to content

fix(docx): put a header or footer as far from its page edge as the page does - #714

Merged
DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-footer-position
Sep 23, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-footer-position

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Why

The export never wrote how far a header or footer sits from its page edge, so Word used its
own distance — 36pt. On the probe corpus that put the footer 14.5pt higher than the page
draws it, on both pages:

818.1 → 803.7  −14.5  Editable export probe   (page 1)
818.1 → 803.7  −14.5  Editable export probe   (page 2)

What changed

The distance is read from the resolved layout, not rebuilt from the zone's parts. A zone
is a band of a given height against its edge, and its content is laid out inside the band
from the top — so for a footer, neither the band's height nor its padding is the distance to
the text. DocxLayoutMetrics.zoneDistanceFromEdge finds the zone's fragments by the
@page-zone[0][index] prefix they are spliced in under and takes the lowest edge of a
footer's, or the highest of a header's; placeZone writes that as w:pgMar/@w:footer or
@w:header.

Without a layout, the zone's own padding on that edge stands in — exact for a header,
whose content starts at its padding, and the nearer estimate for a footer. It is reached
only when the document could not be laid out at all.

Written twips are read back as numbers, not parsed. placeZone first parsed the page
height back out of the XML it had just written, which CodeQL flagged as an uncaught
NumberFormatException. It never needed the XML — the page geometry is written from the
canvas, so the height now comes from the canvas. The same parse-it-back pattern sat in three
older places (the spacing accumulator, a cell's margin reader, a nested table's usable width),
each flagged the same way when it landed; XmlBeans hands a measure back as the schema's union,
which may legally be a unit string or a percentage, and this export only ever writes plain
twips, which come back as a Number. So a written value is read as a Number and anything
else is reported as unknown. No parsing is left on these paths.

The recipe said headers and footers are ignored. The text slots are; a page zone has
exported as a real Word header or footer part since it shipped, and the recipe now says so.

Verification

./mvnw -B -ntp clean verify over the eight-module gate → BUILD SUCCESS (core 815,
render-pdf 337, render-docx 230, testing 5, render-pptx 140, templates 127, qa 1784).
./mvnw -B -ntp test -f examples/pom.xml → 93 tests, BUILD SUCCESS; no committed preview
drifted.

Same corpus, same editor, after the change: the footer lands at 817.5 against the page's
818.1 on both pages — −0.6pt. The written value is w:footer="443" (22.2pt). The worst
line anywhere in the document is now the billing table's Total row, 2.0pt low.

render-docx goes from 227 to 230 tests. DocxPageZonePositionTest asserts relations that
hold whatever the font measures: a band 30pt taller puts its footer exactly 600 twips further
from the edge, and inside the band rather than at Word's 720; a header's distance is its top
padding; and without a layout a footer falls back to its bottom padding.

Lane: canonical — document.backend.semantic.docx only. No public API change.

…ge does

Nothing was written for the distance, so Word used its own 36pt and the
probe corpus's footer sat 14.5pt higher than the page draws it, on every
page.

The engine does not state the distance either: a zone is a band of a
given height against the edge, with its content laid out inside it from
the top. So the distance is read from where the zone's content landed in
the resolved layout — the lowest edge of a footer's fragments, the
highest of a header's — and written as w:pgMar/@w:footer or @w:header.
Without a layout, the zone's own padding on that edge stands in.

Measured through LibreOffice, the footer now lands within 0.6pt of the
page. The recipe also stopped claiming that headers and footers are
ignored: the text slots are, a page zone has not been since it shipped.
placeZone parsed the page height back out of the XML it had just written,
which CodeQL flags as a NumberFormatException nothing catches. It never
needed the XML: the page geometry was written from the canvas, so the
height now comes from the canvas.

The same parse-it-back pattern sat in three older places — the spacing
accumulator, a cell's margin reader, and a nested table's usable width —
each flagged the same way when it landed. XmlBeans hands a measure back
as the schema's union, which may legally be a unit string or a
percentage; this export only ever writes plain twips, and those come back
as a Number. So a written value is read as a Number and anything else is
reported as unknown, with no parsing left in these paths.
@DemchaAV
DemchaAV merged commit b1bcca8 into 2.5-dev Sep 23, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-footer-position branch September 23, 2026 00:27
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