Skip to content

fix(docx): measure a band's space to the box of the panel holding its text - #983

Merged
DemchaAV merged 4 commits into
2.5-devfrom
fix/docx-icon-over-card-flow
Oct 31, 2026
Merged

DemchaAV merged 4 commits into
2.5-devfrom
fix/docx-icon-over-card-flow

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Why

A layer stack of two or more layers is written as one band: layer after layer, holding the page's
distance from the stack's edges to the first block it writes and to its lowest. Where a panel held
that block, the distance ran through the panel's padding — and the panel writes that padding again
as its cell's margins, once above its text and once below it. The space stood in the file twice.

Measured on a card of 14pt padding under an icon laid over its top right corner
(synthetic-icon_over_card in the DOCX fidelity corpus), the paragraph above it and one under it:

card's text paragraph under the card
Word 16.0.20527, run here +13.99 → −0.05pt +28.02 → +0.06pt
LibreOffice on Windows (build bf6ef2662401), run here +13.98 → −0.02pt +27.98 → −0.02pt
LibreOffice 24.2 on Linux, from this PR's own CI run 41189036705 +13.98 → −0.02pt +27.98 → −0.02pt

Below where the page sets them in every editor, and the file says why: the paragraph above the card
carried w:after="440" — 22pt, the page's 8pt gap and the card's 14pt padding — and the cell
w:tcMar of 280 twips on top of that. The card's summary row goes from 13.98/27.98 with three
lines past two points to 0.02/0.02 with none.

It is the measurement and not the padding that moved them. The same card with no second layer,
which is no band, stood where the page sets it to 0.06pt; a card of 20pt padding stood 19.99 and
40.02pt out in Word, a card whose own margin is 10pt stood 13.95 and 28.06pt, and a card holding a
further panel of 6pt padding 13.99 and 34.02pt — the outer panel's padding and the inner one's,
each over again. The icon itself needed nothing: it is carried by the card's text
(wp:positionV relativeFrom="paragraph"), stands on the card's corner as each editor sets the
card, and moves with it when the text above is edited.

What changed

render-docx — DocxLayerColumns

  • The space a band owes above its first written block and below its lowest is measured to the
    box of the panel holding that block
    , where a panel holds it, and to the block itself where
    none does, as before. A panel writes its padding itself, as its cell's margins
    (writePanelPiece), so the band's share ends at the panel's box.
  • The panel is threaded down the walk that finds the block — one walk, edgeIn, serving
    firstOf and lowestOf, written next to the endOf/lowestEdge pair it is modelled on, and
    returning an Edge of the block, its placement and its panel. That brings the guard those
    carry: a panel's box is the block's edge only while both are on one page, since placement
    hands back the box a panel broken over pages has on its first page only. An earlier draft walked
    the tree twice more to find the panel and had no such guard; lowestLeaf, which only that walk
    used, is gone with it.
  • The lowest leaf is still chosen by the foot of its box, as it always was, and only then
    measured. An attempt to reuse endOf for the foot changed that key to the measured edge, which
    picks a different leaf wherever a band's lowest box and lowest text belong to different ones:
    cv-violet_grid then owed 4.65pt more under one of its bands and Word set it on two pages
    where the page has one
    . The two writings of the corpus were byte-identical everywhere else, so
    the key is what did it, not the panel rule. endOf keeps its own question — the edge the next
    layer of a stack's column has to clear, where a panel's box stands whatever hangs past it —
    which is why the band has a walk of its own rather than sharing that one.
  • The outermost panel holding the block is the one measured to, since every panel between the
    band and the block writes its own padding inside the cell above it. Which of the two is taken
    shows wherever the measured block sits inside an inner panel: below the band whenever its lowest
    block does, whether or not the outer panel has text of its own; above it only where the outer
    panel has none, since a block it holds directly is the first one either way. The fixture that
    discriminates on both sides is therefore a card whose only text is in a panel of its own.
    It was added because a sabotage taking the innermost panel went green against the shape written
    first — a card with text of its own and a panel inside it — whose lowest block the card itself
    already ends at. Against the walk as it stands now that sabotage reddens both fixtures; the
    discriminating one is the card with no text of its own, and it is the one the sabotage set
    requires.
  • The panel has to hold that block. It is threaded down the path from each layer, so a panel
    in another layer than the block cannot be picked up; a card in one layer writes nothing above or
    below the text of a plain layer standing under it.
  • A painted block of its own is measured to its own box. A card with no children — a coloured
    bar — is the band's block and still writes its padding as its cell's margins. Measured to the
    bar instead, the band form stood 10pt short above it and 8pt long below; it now stands where a
    stack of one layer, which is no band, stands it. Pre-existing, and the same rule closes it.
  • The set of blocks recorded as closing the band is left exactly as it was. A draft here made
    that membership conditional on whether the foot came from the block's own text, which reads
    better — measured to a panel's box, the block is arguably not what the space below was measured
    from. It was dropped for two reasons. It asked the question by comparing two doubles for
    equality, which answers wrongly where a placement holds no number (both NaN, equal to nothing)
    and so dropped a block out of a set it has always been in. And of the two readers of that set,
    one cannot see the difference and the other was not shown to: keptFromItsPanel is shadowed
    inside a band by bandDepth, and the note naming what a block written as its height alone loses
    below it (insetSidesLost) consults the set only for a block whose own margin or padding below
    it is not nought. Probed over nine shapes of a band closed by a spacer in a painted panel
    (padding 12, 4 and none, with one, two and three paragraphs above it), not one produced that
    note at all — so those shapes do not tell the two behaviours apart either, and the probe is
    evidence that the shapes are unreachable rather than that the set is unread. The block stays in
    as before, what is and is not known is written at the line, and the nicety is a named follow-up
    rather than a difference no test here can hold.
  • A panel's own margin is left outside its table, where the space the band owes holds it: it is
    not taken off that space. A table carries no space of its own, and the margin a panel owes above
    itself is replaced by the space a band resumes with (resumeHere), so that margin is in the
    band's measurement and nowhere else — a card with a 10pt margin and 14pt padding keeps the 10pt
    in the space above its table and the 14pt in its cell.

How much of this the corpus holds. Instrumented over all 71 documents, exactly one band has
a first or lowest block that a panel holds — synthetic-icon_over_card, the fixture this change is
about. Every other band in the corpus takes the panel-less path, where the measurement is the one
it always was. So the corpus is proof that nothing else moved; it is not proof that the rule is
right in its other shapes, and those are held by the unit fixtures below instead. It also means
the panel-less path had to come out byte-identical, which took three tries: a first attempt skipped
a leaf whose placement carries no number and went on to the next, where the measurement used to
return that leaf and let the NaN through — and NaN is what tells writeOverlayBand there is no
resume to pay. A second left closing comparing a foot against a top. Both showed up as
cv-violet_grid owing 4.65pt more, and both are why the leaf is now chosen structurally, exactly
as before, and only then measured.

Each of the two cases is written out, rather than reconciled by a Math.max. A draft took the
higher of the panel's box and the block's own box above, and the lower below, so that text hanging
past its panel's box would still be cleared. That read as a safety and worked as a hiding place: it
picked the panel's box for this card either way, so it went on passing all nine fixtures while the
block's own top margin was being added to the space above instead of subtracted from it — the
margin is folded into that edge, and a sign went the wrong way. cv-violet_grid, whose band has no
panel at all, came out 2 × 2.3134 = 4.6268pt low because of it, and only a band-by-band comparison
of an instrumented origin against an instrumented build of this change found it, after several
wrong guesses. Neither branch was earning anything: writing the corpus with and without them gave
byte-identical output in all 71 documents. So the panel's box is taken where a panel holds the
block and the block's own box where none does, with nothing in between, and text that hangs past
its panel's box is named below as a case this does not cover.

What this does not change, and the two cases it does not cover:

  • A band's resume to a later layer is still measured to that layer's text, a panel round it
    included — band hands the flow node -> false there, as it did. In the document the
    cross-layer fixture builds, the text of the layer under the card stands 14pt below where the
    page sets it, the card's padding over again. That is the same defect one layer down, left as it
    stands and now written down in the test's own javadoc, in the recipe and in the CHANGELOG rather
    than left for the next reader to rediscover.
  • Text hanging past its own panel's box is measured to the box all the same, so such a hang is
    not cleared. No corpus document and no fixture has that shape, and nothing here measures what
    Word does with it; the case is named rather than guessed at.
  • A panel whose box the layout breaks over pages is guarded against — its box is taken as the
    block's edge only while both stand on one page, as the sibling walk has always done — but no
    fixture here reaches that guard: every fixture is a one-page document, and the corpus holds
    exactly one band with a panel at all. The guard is carried rather than proven, and a fixture for
    it is noted as follow-up rather than claimed.
  • A panel with a negative top margin would put its box above the stack's, making the space
    above negative, which Math.max(0, …) clamps to nothing. No corpus document or template has
    that shape, so it is named here rather than handled.
  • A row and a shape container are not what paintsAPanel answers for, so they are untouched, and
    a stack of one layer lays nothing over anything and is no band.

CHANGELOG.md carries it under Public API, and docs/recipes/docx-export.md had the old
behaviour written down as a limitation under "Overlapping layers → one band" — that paragraph now
states the rule, the numbers both ways and the resume that is still measured to text. The
capability is unchanged (a stack is still written layer after layer), so the backend capability
matrix is as it was, and no public API moved, so the knowledge pack is unchanged.

What each commit holds, since the third one's message overstates it

  • 50a5d9424 — the measurement itself, and already in its final shape: Edge.text() adding the
    block's own top margin, the plain onePage() ternaries with no max/min between the two
    boxes, and nine fixtures including the one whose first block carries a top margin. The sign
    error and the max/min draft were found and undone before this commit, and its own
    message says so.
  • f03701464 — the LibreOffice 24.2 Linux baseline, from this PR's own CI run.
  • 3e4d268ea — 31 lines: closing.add made unconditional again, the corpus javadoc pointer, a
    stray blank line, and the assertion pinning the cross-layer 14pt. Its message also retells the
    sign error and the max/min draft as though they were fixed in it, which they were not
    —
    that reads as a second occurrence of a defect that was never pushed. Correcting it would mean
    rewriting a pushed commit message, which this branch does not do; the record is set straight
    here instead.

Verification

  • DocxBandPanelSpacingTest, 9 tests, new — the card's space outside its table is the page's
    and its padding is in its cell, for 14pt and 20pt padding; a card's own 10pt margin stays
    outside; a panel inside the card adds nothing; a card whose only text is in a panel of its own
    is measured to the card's box; a painted bar that is itself the block stands where no band
    stands it; and three that hold the rest of the rule rather than the fix — a card with no drawn
    layer beside it, which is no band, a band whose first block no panel holds, and a panel in
    another layer than the lowest block, the last two checked against what the layout says.
  • Six sabotages, six caught, each by the fixture that claims the rule it breaks, read off
    surefire's own failure lines with 9 tests run every time: the space above measured to the text,
    the space below measured to the text, the innermost panel taken instead of the outermost, a
    panel carried across to a layer it does not hold, the panel's own margin taken off the space
    outside its table, and a painted block of its own measured to the block rather than to its box.
    The files came back byte-identical to the copies taken at the start of the run.
  • The template corpus, 71 documents, exported before and after: one document changed —
    synthetic-icon_over_card — and in it two paragraphs, w:after and w:before from 440 to 160
    twips. The export report's notes are unchanged, 1497 before and after, none added or removed.
  • Word 16.0.20527 over the corpus: the card's text and the paragraph under it stand where the
    page sets them, −0.05 and +0.06pt, from +13.99 and +28.02; the gate is green on the rewritten
    baseline, 71 documents. LibreOffice on Windows: −0.02 and −0.02pt, from +13.98 and +27.98,
    its over2 count 3 → 0. Measured again on five further cards built for this change — 20pt
    padding, a 10pt margin, a panel inside the card, one layer only, no stack at all — every one
    within 0.06pt of the page in Word, from 13.95 to 40.02pt out before.
  • graph-compose-render-docx 2372 tests, graph-compose-qa 1871 tests, no failures, and the
    reactor's own clean verify green over 823 / 339 / 2372 / 5 / 142 / 127 / 1871 tests, with the
    four knowledge checks clean, core javadoc at 0 warnings and the examples suite's
    CommittedAssetDriftTest green, so no committed asset owes a regeneration.
  • The three baselines are rewritten the way CONTRIBUTING.md asks for each, and the Linux and
    Word files are treated oppositely on purpose: the Linux one can only be shot by a CI run, whole,
    so another run's re-measurement comes with it and is recorded instead of being hand-trimmed —
    trimming it would leave the file a mixture of two runs, which is how its own header records 1778
    rows going stale. The Word one is shot here and can be shot again, so the same noise is simply
    not written. That distinction is worth settling in CONTRIBUTING.md rather than re-derived per
    change, and is noted as follow-up.
    • libreoffice-linux*.tsv is shot on CI, and a change that moves documents "takes that
      artifact's files as the Linux baseline", so this PR's own run 41189036705 replaces them
      whole. Six rows came with that run which this change does not move — five of
      invoice-luma_studio by 0.10 to 0.35pt and one of invoice-metered_long by 0.10pt, none
      past the half point the gate reads as rounding, no page count among them — and those two
      documents' DOCX is byte-identical before and after, so that is CI's LibreOffice measuring the
      same files again. A note under each file's header records the run, the card's reckoning and
      those rows.
    • word-windows*.tsv was shot here, so only this document's rows are written. -Update also
      rewrote 15 rows of invoice-luma_studio and invoice-metered_long by 0.12 to 0.36pt, on
      byte-identical DOCX — Word measuring the same file twice — and those are left as they were.
    • libreoffice-windows*.tsv is the known-stale baseline CONTRIBUTING.md says not to rewrite
      as part of an unrelated change, so only this document's rows are written there too. The eleven
      documents that fail it on a clean tree fail it the same way after this change, and this card
      is not among them: 13.98/27.98 → 0.02/0.02, its count of lines past two points 3 → 0.

… text

A layer stack of two or more layers is written as one band, which holds the
page's distance from the stack's edges to the first block it writes and to its
lowest. Where a panel held that block the distance ran through the panel's
padding, which the panel writes again as its cell's margins: the space stood in
the file twice. A card of 14pt padding under an icon laid over its corner set
its text 13.99pt below where the page sets it in Word 16.0.20527 and 13.98pt in
LibreOffice, and the paragraph under the card 28.02 and 27.98pt; its w:after was
440 twips, the page's 8pt gap and the padding, with the cell's w:tcMar of 280 on
top of it. The same card with no second layer, which is no band, already stood
where the page sets it: the measurement moved them, not the padding.

The panel is now threaded down the walk that finds the block, which carries the
guard the sibling walk has: a panel's box is the block's edge only while both are
on one page. The block itself is still chosen as it was, and only then measured,
so the path where no panel holds it comes out unchanged. A panel's own margin
stays in the distance the band owes, since the margin a table owes above itself
is replaced by the space a band resumes with. A painted block with no children of
its own is measured to its own box, where it stood 10pt short above and 8pt long
below.

Each of the two cases is written out rather than reconciled by taking the higher
or lower of them. That draft read as a safety and worked as a hiding place: it
chose the panel's box for this card either way, so it went on passing every
fixture while the block's own top margin was being added to the space above
instead of taken off it. cv-violet_grid, whose band holds no panel at all, came
out 2 x 2.3134 = 4.6268pt low because of it.
…sets it

The Linux baseline is shot on CI, so a change that moves documents takes that
job's artifact as the baseline (CONTRIBUTING.md). On run 41189036705 the card's
two lines and the paragraph under it stood 13.98, 13.98 and 27.98pt below where
the page sets them, and now stand -0.02pt: the space round the band is measured
to the card's box rather than through the padding the card writes again as its
cell's margins. Its summary row goes from 13.98/27.98 with three lines past two
points to 0.02/0.02 with none.

Six rows came with that run that this change does not move: five of
invoice-luma_studio by 0.10 to 0.35pt and one of invoice-metered_long by 0.10pt,
none past the half point the gate reads as rounding and no page count among them.
Their DOCX is byte-identical before and after, so that is LibreOffice on CI
measuring the same files again. A note under each file's header records the run,
the card's reckoning and those rows, since the whole artifact is what replaces
the file.
Review of the change found two things in it.

The block's own top margin was folded into the edge the space above is measured
to, and folded in with the wrong sign, so that space came out two margins too
large wherever a band's first block carries one: cv-violet_grid, whose band holds
no panel at all, stood 2 x 2.3134 = 4.6268pt low. The draft that hid it took the
higher of the block's box and its panel's, which chose the panel's box for a card
either way and kept every fixture green. Each case is written out instead - the
panel's box where a panel holds the block, the block's own box where none does -
and a fixture whose first block carries a top margin of its own holds the sign.

The set of blocks recorded as closing a band was made conditional on whether the
foot came from the block's own text, which asked that by comparing two doubles
for equality: where a placement holds no number both are NaN, equal to nothing,
and the block fell out of a set it has always been in. The membership is
unconditional again, as before this change. Asking it properly would buy nothing
that can be shown: the one reader that would tell the difference, the note naming
what a block written as its height alone loses below it, is never reached for a
block inside a panel - probed over nine shapes of a band closed by a spacer in a
painted panel, none produced that note - and the other is shadowed inside a band
by bandDepth. The reckoning is written at the line.

The corpus is byte-identical to the export before these two, its notes included,
and still one document and two paragraphs from the base. A corpus javadoc pointed
at a member this change had already renamed away.

The gap a later layer resumes with is measured to its text through a card's
padding, and the 14pt that costs was stated in the CHANGELOG, the recipe and the
test's javadoc while nothing held it. It now has an assertion of its own, so the
three cannot drift apart in silence.
@DemchaAV
DemchaAV marked this pull request as ready for review October 31, 2026 10:18
The CHANGELOG and the export recipe both said that which of the two panels is
measured to shows only where the outer one has no text of its own, a block it
holds directly ending at its box either way. That is true of the space above the
band and false of the space below it: a sabotage taking the innermost panel
reddens the fixture for a card with text of its own and a panel inside it as well
as the one for a card with no text, because the lowest block is in the inner
panel either way. An author reading it would conclude the choice cannot reach a
card like theirs, and be wrong by the outer card's bottom padding - 14pt in that
fixture, which is the defect this fixes.

Two notes beside the code claimed more than was shown. The band's edges are the
block's own numbers, and so are none where its placement holds none, only where
no panel holds it; measured to a panel's box they are that box's numbers whatever
the block's are. And the reckoning above the set of blocks closing a band said
the note that reads it is never reached for a block inside a panel: what the nine
probed shapes show is that none of them produced that note at all, and none of
them varies the block's own insets below it, which is what the note turns on. Both
now say what was established and what was not.
@DemchaAV
DemchaAV merged commit 539b110 into 2.5-dev Oct 31, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-icon-over-card-flow branch October 31, 2026 10:56
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