Skip to content

fix(docx): stand a page zone's text on the page's baseline in Word - #867

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-zone-line
Oct 7, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-zone-line

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Why

A page zone (session.chrome().zone(...)) is written as one line of a Word header or footer. That line was Word's single line for its face. Its top sat at the zone content's top in a header, and its foot at the content's foot in a footer (DocxLayoutMetrics.zoneDistanceFromEdge). So three things were left to Word, and nothing named them:

  • the line's height;
  • where the text's baseline lands;
  • the room a lone part's padding holds above and below its text. That padding is inside the content's edges, so a header's text stood that much higher than on the page.

Measured against the engine's PDF, LibreOffice stood a header's text up to 0.97pt higher than the page does.

Two zones of one kind, such as a cover's header on the first page and the running header on the rest, shared Word's one distance from the edge, and the last zone's distance won. The cover's header stood 15.9pt high in Word and 16.4pt high in LibreOffice.

A zone paragraph's anchor had no bookmark, and nothing named it. A zone whose content is none for no page in particular was not written, and nothing named that either.

DocxNodeFieldLedgerTest carried these as the zones output option's GAP, the last gap in the ledger.

What changed

  • zonePlacement works out where a zone's line stands in Word (ZonePlacement), from the zone's text in the layout.
    • The line is an exact line, as tall as the tallest part's line on the page, as a body paragraph's exact line is. It is taller in two cases:
      • for a picture in a zone paragraph (DocxZoneParts.tallestPicture). Word stands such a picture on the baseline, and an exact line cuts whatever passes its top. So the line holds the picture under the four fifths above its baseline.
      • for a part the page fits smaller (autoSize). Word writes that part at its style's size, so the line is the style's.
    • The distance from the edge is the one that puts the tallest part's baseline where the page has it. Both editors stand an exact line's baseline four fifths of the way down it (DocxTextBands.BASELINE_SHARE, measured on exact lines). So a lone part's padding and margin above and below are in that distance. A line the page sets against the edge stops at the edge. The arithmetic is the text band's, now one DocxTextBands.distanceFromEdge(header, baselineFromEdge, line) both call.
    • A part the page sets in more lines than one makes the paragraph as many exact lines. Word grows a header's paragraph down from its distance and a footer's up from it, so a footer's stands the lines below its first further from its edge, its first line on the page's first baseline. The count is the most lines any part takes: the page sets the zone's parts side by side, Word sets them one after another, so the paragraph is as tall as its longest part at least.
    • The baseline Word then sets the first line on goes to reportZoneLine, which counts a part off that baseline. The old model of a line Word placed itself (wordsBaseline) is gone. Parts after a part of more lines than one stand on a later line in Word, so where they stand is said to be not measured.
    • Content built otherwise for its first page is not measured. DocxZoneParts.readAlike compares two versions of the content: as written, for no page in particular, and as built for the first page the zone is drawn on (DocxLayoutMetrics.zoneFirstPage). It compares what sets the line: each part's text, face, size and weight, each run's, a picture's size and place, a page field's kind. Colour is left out, since it sets nothing of the line and a colour built afresh is not equal to itself. On a difference Word keeps its own line, and the note says the zone is not measured.
  • writeZoneLine writes the exact line. placeZone writes the distance as w:pgMar/@w:header or @w:footer.
    • Lines may reach past a positive page margin by more than half a point (ZONE_REACH_CLEARANCE), counted line by line. The margin is then written negative, as placeBand already does for a text band, so Word holds the body at the margin, and the report names it. Text set against the margin reaches up to half a point past it in the default face under about 22pt, and Word moves the body down by that much, unnamed.
    • Where the layout shows no text of the zone, both keep what they did: Word's line, and the distance from the content's edges.
    • holdTheBodyAtTheMargin writes the negative margin for bands and zones alike.
  • A zone the layout measures that shares its kind with another page zone of its section stands in a frame (w:framePr) at its own height, as a text band sharing its kind already does.
    • Word holds one distance from the edge for a kind, and lines stacked in one part keep the order the zones were added in.
    • The frame is at least its lines tall (AT_LEAST), so a part Word sets in more lines goes on below them rather than hiding.
    • Two frames at one height are kept apart by a hairline paragraph, as bands' frames are, through one separateFromAnEqualFrame.
    • A framed zone leaves a distance already written untouched, whether by a zone of its kind in the flow or by a band.
    • Beside a text band of its kind, a zone stays in the flow and the band is framed.
    • The frame code moves out of writeBand into frameAcrossTheMargins. It makes the same calls in the same order, with the band's EXACT rule, so bands write the same bytes.
  • zoneParagraphLost names two more things:
    • a zone paragraph's anchor, with "a paragraph's anchor has no bookmark in the Word file: a link to it points at none". A zone is written into a part each kind of page repeats, so there is no one place in the document a bookmark could mark. On the page, a link to it lands on the last page that draws it, and a page reference does not find it;
    • a picture the page sets anywhere but on the baseline, with "a paragraph's picture stands on the line's baseline, not where the page sets it" (DocxZoneParts.setOffTheBaseline).
  • A zone whose content is none for no page in particular is named as not written (DROPPED), unless the layout shows it drawn on no page either. Its content function must return a node on every page; a zone absent from some pages says so through appliesTo.
  • DocxZoneParts (new, package-private) holds what a zone's parts are and how they compare, with its own unit test.
  • Ledger:
    • zones moves from GAP to REPORTED;
    • a lone or tallest page field's padding and margin above and below are written;
    • no entry is a gap any more, so GAP is removed as a fate, along with everyGapSaysWhatIsLost. A field added later is written, reported or inert in the change that adds it.
  • Docs:
    • the page zone section of the DOCX recipe;
    • the page zone row of the capability matrix;
    • the CHANGELOG, including the earlier entries' lines this supersedes.

Verification

  • ./mvnw -B -ntp install -pl :graph-compose-render-docx → BUILD SUCCESS, 1175 tests, 0 failures, 1 skipped (the property-gated fidelity probe).

  • DocxZoneLineTest (new, 25 tests) reads each zone's baseline from the PDF the engine draws. Word's baseline it reads from the file: the distance from the edge or the frame's place, plus four fifths of the exact line.

    • The baseline:
      • a header and a footer stand their text on the page's baseline;
      • so does a lone page field, in a header and in a footer;
      • a page number padded 6pt down, or given a 6pt margin, stands 6pt further from the edge;
      • a header's and a footer's lines of parts stand on their 18pt part's baseline, the line as tall as that part's.
    • More lines than one:
      • a footer of two lines stands both on the page's baselines;
      • a footer's row beside a part of two lines stands its first line on the page's baseline, and only the part of two lines is counted off;
      • where the parts after a part of two lines stand is said to be not measured;
      • three 18pt lines of a header the body runs under reach past a 36pt margin, where one would not. The margin is written negative and the reach named.
    • The edge: a line the page sets against the edge stops at it, and is not named. At 80pt, where the text stands more than a point and a half lower, it is named.
    • Pictures and sizes:
      • a 24pt picture gets four fifths of the line above the baseline, with its text still on the page's baseline, and its centred place is named;
      • a part fitted smaller gets the line its 18pt style has unfitted.
    • The margin: a 30pt picture at the foot of a 36pt zone reaches past the 36pt margin. The margin is written negative, and the reach is the one note: the picture on the baseline is not named.
    • Frames:
      • a cover's header and a running header (the first page and the rest) each stand in a frame at their own height;
      • so do two headers, and two footers, on the same pages in one part, each frame at least its line tall and that tall;
      • a framed footer of two lines stands its first line on the page's baseline, in a frame of both;
      • two zones at one height are two frames with a hairline between them;
      • a framed zone leaves the distance of an unmeasured zone of its kind in the flow.
    • Not measured, not written:
      • content written "End" that reads "Continued" on page 1 keeps Word's line, and the note says it is not measured;
      • so does content written at 18pt that page 1 sets at 8pt;
      • with no layout, no exact line is written;
      • a zone whose content is none for no page in particular is named as not written.
    • Anchors: a zone paragraph's anchor is named.
  • DocxZonePartsTest (new, 4 tests): content built afresh, its colours new objects, reads alike; other text, size, weight, page field, count of parts, a run's face, or a picture's size or place does not; the tallest picture and a picture off the baseline.

  • DocxTextBandsTest: the shared distance puts a header's and a footer's baseline where asked and stops at either edge.

  • DocxPageZonePositionTest: a header's distance follows its padding, 10pt for 10pt, and is under the 9pt to the content's top. The old test pinned the distance at the content's top, which this change moves by design.

  • DocxZoneReportTest: a smaller part set lower than an 18pt one is the one part off. The 18pt part keeps its place, as Word's line now stands on its baseline. Before, both counted as off.

  • The tests fail without the code they cover. 31 sabotages were run on the final code, each breaking one thing, and each made the tests that cover it fail:

    • the line: no exact line; the line placed by its top rather than its baseline; the first part taken for the tallest; no room for a picture; a fitted part keeping the page's line;
    • more lines: a footer placed by its first line; its baseline missing the lines below; the lines of the tallest part only; the reach of one line; a frame one line tall; a footer frame's top of one line; parts after a part of more lines still measured;
    • the distance: the distance from the content's edges; a line past the edge not stopped at it; the shared distance's share inverted, which bands use too; a line past the margin not held; no clearance at the margin;
    • frames: zones of one kind not framed; a footer's frame placed at its distance; a zone frame exactly the line tall; a framed zone overriding the distance; equal frames not parted;
    • the report and the comparison: reading no baseline; content that reads otherwise still measured; the face not compared; runs not compared; a picture's place not compared; an anchor not named; a picture off the baseline not named; every picture taken as off it; a zone built as nothing not named.
  • Measured in Word 16.0.20430 and LibreOffice 26.8.0.3. Probe documents were exported before and after the change and converted by both editors. Each zone's baseline was compared with the engine's PDF, before → after:

    Zone Word LibreOffice
    header, 8pt Lato, 9pt down +0.02 → +0.02 −0.40 → −0.05
    footer, 8pt Lato, 6pt padding +0.18 → +0.06 0.00 → −0.05
    header line, its 18pt part −0.05 → +0.07 −0.97 → −0.02
    cover header, first page of two zones −15.94 → −0.10 −16.40 → −0.05
    running header, the other pages +0.06 → −0.06 −0.40 → −0.05
    header with a 24pt logo on the baseline, its text (after only) +0.02 0.00
    • The logo stands where the engine draws it, whole: 4.3–28.3pt from the top in Word and 4.25–28.25pt in LibreOffice.
    • The 8pt part beside the 18pt one stays about 9.9pt off in both editors, since Word sets one baseline for a line. The report names it.
    • A zone of more lines than one was not measured in an editor; the tests hold its placement against the engine's PDF.
  • Corpus bytes: the 62 corpus documents, exported deterministically (DocxFidelityCorpusTest -Dgraphcompose.docxFidelity=export), are byte-identical to the export before the change. No corpus document has a page zone, and the text bands written through frameAcrossTheMargins, separateFromAnEqualFrame and holdTheBodyAtTheMargin are unchanged.

  • The report across the corpus is unchanged, to the note: 955 notes.

  • Documentation guards:

    • core -Dtest='com.demcha.documentation.**' → 166 tests, 0 failures;
    • qa documentation guards plus DocxPageZoneTest, DocxTransparentWrapperTest, TimelineRailAcrossBackendsTest and RtlAcrossBackendsTest → 50 tests, 0 failures.
  • The full reactor gate was not run, since no public signature, POM or workflow file changed.

Known limits

  • Word sets one baseline for a line. A part the page sets on a baseline of its own moves onto the tallest part's in both editors, and the note counts it.
  • A part of more lines than one is written with a line break where the page breaks it or Word wraps it. Word sets the parts after it on a later line, which the page sets beside it; the note counts the part and says where the rest stand is not measured. The page's line gap between a paragraph's lines is not written into the exact lines.
  • A zone's picture stands on the baseline in Word. A centred one stands higher than on the page, and the note names it. Its line is tall enough that it is not cut.
  • Text the page sets right against the edge stands off the page's baseline, as the line stops at the edge: lower in a header, by what its ascent falls short of four fifths of its line, and higher in a footer, by what its descent falls short of a fifth. In the default face only a header's is, about 0.4pt at 18pt. Past a point and a half the note names it.
  • A zone the layout measures that shares its kind with another page zone is framed. A frame is less natural to edit than a paragraph in the flow, as for text bands. Two unmeasured zones of one kind stay in the flow: they share Word's one distance, the last one's, and stand one under the other, each named as not measured.
  • The exact line is the tallest part's line on the page, so a face whose descent is more than a fifth of its line may lose a sliver of its descenders on Word's screen, as body paragraphs' exact lines do. Word's PDF export draws them whole.
  • A zone whose content is built otherwise for its first page keeps Word's own line, and the note names it as not measured. The export calls the zone's content function once for no page in particular, as before, and once more for the first page it is drawn on, with the context the layout gave that page.

Lane: render-docx backend, plus tests and docs.

A page zone's line is written as an exact line, as tall as its tallest
part's line on the page, or taller for a zone picture or a part the page
fits smaller. Its distance from the edge puts that part's baseline where
the page has it, so a lone part's padding and margin are held. The report
counts parts off that baseline, and names a zone that reads otherwise on
its first page as not measured.

A zone sharing its kind with another page zone stands in a frame at its
own height. A line reaching past a positive margin gets the margin
written negative, and is named. A zone paragraph's anchor, and a picture
the page sets off the baseline, are named.

The zones output option moves from GAP to REPORTED in the field ledger.
No gap is left, so GAP is removed as a fate.
…e a zone built as nothing

A zone's paragraph is as many exact lines as its part of the most lines
takes on the page. Word grows a footer up from its distance, so a footer
of two lines stands a line further from the edge, its first line on the
page's baseline; its frame is as tall as its lines, and its reach past
the margin is counted line by line. Parts after a part of more lines
than one stand on a later line in Word, so where they stand is said to
be not measured.

Content built for the first page is compared with the content written
by text, face, size and weight, runs and pictures, not by text alone,
so a zone set at another size on its first page keeps Word's line
rather than an exact line its letters overflow.

A zone whose content is none for no page in particular is named as not
written.

DocxZoneParts holds the parts' comparison and pictures, with a unit
test. Bands and zones share DocxTextBands.distanceFromEdge, the
negative margin and the hairline between equal frames.
@DemchaAV
DemchaAV merged commit 815e582 into 2.5-dev Oct 7, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-zone-line branch October 7, 2026 12:50
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.

1 participant