Skip to content

fix(docx): hold an auto column's editor slack in its cells' margins - #944

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-table-column-slack
Oct 22, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-table-column-slack

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Why

A table column sized to its content (DocumentTableColumn.auto()) is exactly as wide as its widest unwrapped cell. An editor setting that cell in its own face can wrap it, so the export gives each auto column's text a point of slack (withEditorSlack). The slack widened the column. Every rule and fill right of it, the text of the columns after it, and the table's right end stood a point right of the page's. The report did not say so.

Measured against the page on Windows:

  • In Word, the rules right of the auto column in Classic and Modern Invoice (both lengths) and Modern Proposal stood 0.94 to 1.06pt right of the page's.
  • In LibreOffice, 0.66 to 1.06pt.

What changed

  • Slack in the cells' margins (slackTheMarginsHold, givingTheSlack):
    • A text cell gives its column's slack from the side its text does not lean on: the right where it is set left, the left where it is set right, and half from each where it is centred (leanOf, the cell's anchor read with its direction). Its text keeps the room the slack gave it, so the line breaks are the same. The column keeps the page's width.
    • A cell spanning more than one column given slack shares its margin among them.
    • A composed cell gives none. A paragraph in it may run past its text into the margin (measureAtWordsSize). With the margin taken, it would run past the cell's edge, where Word cuts it off.
    • A side gives only what it has past the inner half of the cell's rule, as wide as Word draws it (ruleTwips). Taken further, a line an editor's face sets wider would run onto the rule: a cell padded 1pt beside a 1pt rule gives half a point.
    • A column holds the least any of its cells gives. The rest widens it, as before.
  • right end note (applyTableWidth): a table whose auto columns are widened by a tenth of a point or more, in all, is named. Its right end, and what stands right of them, is up to that much further right. It is named whether or not the table paints there, since the text of the columns after a widened one moves with it. In the corpus, these are Orange Ops's aside headings: one-column tables with no padding, a composed heading over a filled accent rule. Word draws their rules 0.98 to 1.05pt longer than the page.
  • applyTableWidth returns what each column's cells' margins hold, and the row loop writes each cell's padding less its share.
  • An indented table (indentTable) is indented by its first cell's written left margin, as before, now less what that cell gives. Word and LibreOffice measure the indent to that cell's text. Measured in a section padded 20pt, with the first auto column set right, centred and left: the left rule stood 0.06pt right of the page's in Word and on it in LibreOffice, in all three.
  • Example preview: assets/readme/examples/word-export-companion.docx is re-rendered. WordExportExample's quarterly table has three auto columns. Its grid and its cells' right margins are a point narrower each, and nothing else in the document changes.
  • Docs:
    • the recipe's Tables row;
    • the capability matrix's Table rows row;
    • the CHANGELOG (Public API).
  • Ledgers:
    • DocumentTableColumn.type, DocumentTableColumn.Type.AUTO and TableNode.columns name the margins and the right end note;
    • DocumentTableStyle.padding says a text cell's padding in an auto column is written less the slack.

Verification

  • DocxEditorSlackTest (+12 table tests, two rewritten):
    • Set left, the right margin gives the point: the grid is the layout's width, and the text room is the layout's plus the point. No note.
    • Set right, the left margin gives it. Centred, half from each side. A centred cell with 0.5pt sides holds it whole.
    • A right-to-left cell with no anchor gives it from its left margin, and one anchored left from its right.
    • Padding thinner than the point gives what it has, and the column widens by the rest and is named. This is tested on the right of a left-set cell and on the left of a right-set one.
    • A side padded 1pt beside a 1pt rule gives half a point, and the column widens by the other half, named. The tests of thin padding are unruled.
    • A header padded thinner than the body holds the whole column to what it gives.
    • A cell spanning two rows gives the point in the merge's covered position too.
    • A composed cell keeps its margins. Its column takes the point and is named, while the other column holds its own.
    • A cell spanning two auto columns shares its 1pt margin: half of each column's point is held, half widens it.
    • An indented table's w:tblInd is the inset and the margin its set-right first cell keeps.
    • A fixed column, and a table with a stated width, keep their margins.
  • DocxCellPaddingTest:
    • 11 tests on auto-column tables expect the right margin less the point (SLACK). Their vertical margins are unchanged.
    • In one of them, a cell padded 1pt under a 2pt rule keeps its right margin: the rule's half is the whole of it.
  • Example preview: CommittedAssetDriftTest (examples) passes with the re-rendered DOCX, and failed without it.
  • Fail-closed: 14 sabotages, each run alone, all caught. They cover:
    • no slack held, or the grid widened all the same;
    • the wrong side for a cell set right, centred or right to left;
    • a composed cell giving;
    • a span's margin not shared, or given from its first column alone;
    • a centred cell holding its thinner side once;
    • the rule's inner half given too;
    • the note missing, or written where the margins hold it all.
  • Word and LibreOffice on Windows, the 5 changed documents against the export before:
    • Word: the rules right of the auto column were 0.94 to 1.06pt right of the page's and are now within 0.12pt. LibreOffice: 0.66 to 1.06pt, and now with the tables' other rules (Modern Invoice's stand 0.3pt left in LibreOffice before and after).
    • No line breaks and no word moves down the page in either editor. The words right of the auto column (7 to 216 per document) move 1.0 to 1.1pt left, back to where the page sets them.
  • Corpus: 5 of 62 DOCX change (Classic and Modern Invoice, both lengths, and Modern Proposal). Notes go from 1359 to 1363, the 4 Orange Ops headings' right end.
  • Gate: ./mvnw -B -ntp clean verify -pl <core, render-pdf, render-docx, render-pptx, templates, testing, qa, coverage> -am → BUILD SUCCESS: core 821, render-pdf 339, render-docx 2062, testing 5, render-pptx 142, templates 127, qa 1828. The JDK 17 knowledge checks pass, and core javadoc has 0 warnings. ./mvnw -f examples/pom.xml test → BUILD SUCCESS, 93 tests.

Lane: shared-engine (render-docx): no public API change.

- A table column sized to its content gives its text a point of slack
  so an editor's face keeps the page's line breaks. It widened the
  column, moving every rule and fill right of it, and the table's right
  end, a point right of the page's. It now comes out of the column's
  text cells' margins, on the side their text does not lean on, and the
  column keeps the page's width.
- A cell spanning more than one such column shares its margin among
  them. A composed cell keeps its margins; what a column's cells do not
  hold widens it and is named (right end).
- An indented table is indented by the margin its first cell keeps,
  which both editors measure the indent to.
- Measured on Windows: Word stood the rules right of the auto column
  0.94 to 1.06pt right of the page's, now within 0.12pt; LibreOffice
  0.66 to 1.06pt, now with the tables' other rules. No line breaks
  change in either editor.
- Tests: margins by alignment, direction, span, header and thin
  padding, a composed cell widening its column with the note, an
  indented table's tblInd; cell padding tests expect the right margin
  less the slack.
@DemchaAV
DemchaAV marked this pull request as draft October 22, 2026 20:22
…slack

- A side of a text cell gives an auto column's slack only past the
  inner half of the cell's rule, as wide as Word draws it: taken
  further, a line an editor's face sets wider would run onto the rule.
  What it cannot give widens the column, named (right end).
- Re-render the Word export example's DOCX preview: its quarterly
  table's three auto columns are a point narrower each, the point in
  their cells' right margins.
- Tests: a 1pt padding beside a 1pt rule gives half a point; a cell
  spanning two rows gives the point in the merge's covered position;
  the thin-padding tests are unruled.
@DemchaAV
DemchaAV marked this pull request as ready for review October 22, 2026 20:39
@DemchaAV
DemchaAV merged commit 2defe85 into 2.5-dev Oct 22, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-table-column-slack branch October 22, 2026 21:30
DemchaAV added a commit that referenced this pull request Oct 28, 2026
A cold pass over the note's numbers against the measurements found three
wrong:

- 75 commits between #899 and #974; `git rev-list --count` says 74, which is
  also what the per-commit attribution sums to.
- "229 of them standing a point nearer the page": 229 lines moved across, but
  one of them stands further (`proposal-editorial`'s "Signature:", -0.01 ->
  +0.11pt), and only the 220 of #944 moved by about a point - the other nine
  moved 0.02 to 0.76pt.
- "Word 16.0.20430 and 16.0.20527 measure this corpus identically": the A/B
  that shows it was run on the corpus as 6683be9 exported it, 62 documents.
  Today's corpus is 71 and carries least line heights, per-page margin
  sections and panel cuts that did not exist then, none of which the older
  build was ever measured on. The sentence now says which corpus it is about.
DemchaAV added a commit that referenced this pull request Oct 28, 2026
…975)

* test(qa): rewrite the Word fidelity baseline on Word 16.0.20527

The baseline was shot on Word 16.0.20430, and the Word the gate is run with
is 16.0.20527. It sets 1388 of the corpus's 5165 lines at another height and
229 at another place across, 1609 rows in all, reaching 0.48pt further from
the page against the 0.51pt at which the gate fails a line: the Word gate
passed with 0.03pt to spare on cv-serif_headline, cv-panel and
invoice-obsidian. Nine documents already carried rows shot on 16.0.20527, so
the file held two builds at once.

word-windows.tsv and word-windows-lines.tsv now hold what Word 16.0.20527
measures across all 71 documents, under one header. CONTRIBUTING.md says,
where it tells a contributor to run the Word gate, that the baseline's header
records the build it was shot on and that a failure on another build is
re-shot in a change of its own. Nothing else changes: no Java, and no
resource the export reads, so the corpus exports byte for byte as before
(71 documents, 1499 report notes).

The file holds its own control group: the nine documents whose rows were
already shot on 16.0.20527 move 0 lines of 883, while the other 62 move 1609
of 4282. The move is Word's 1/600in grid - 1385 of the 1388 lines whose
drift down changes move by an exact multiple of 0.12pt, and 227 of the 229
that move across do too. The other five move a whole number of steps plus
0.02 to 0.03pt. All 5165 lines are found in both measurements, none lost and
none new; past 0.05pt, 632 stand nearer the page and 553 further, and across
all but one of the 229 move nearer, four invoices' figures by about a point.
Of the 47 rows that change, 21 stand nearer, 21 further and 5 the same, no
median by more than 0.13pt. Acceptance stays at 63 of 71, the same eight
documents short of it.

Three full runs - export, a fresh Word conversion of all 71 documents,
measure - wrote byte-identical measurements, the baseline is that
measurement byte for byte, and the third holds a fresh Word run to the
rewritten files.

* docs(contributing): say why a stale fidelity baseline goes unnoticed

The note added with the baseline rewrite said a Word build sets lines a
little differently on its own, and that a gate failure on another build is
re-shot in a change of its own. Measurement says otherwise: the corpus
exported from 6683be9, the commit whose run last took all 62 rows on Word
16.0.20430, and converted by Word 16.0.20527 measures byte-identical to that
commit's baseline - 62 document rows and 4282 line rows, 0 changes. The two
builds read this corpus the same, so the note pointed a contributor at the
wrong cause.

What actually leaves a baseline stale is the gate's own shape: it fails a
line only once it stands more than 0.5pt further from the page, so a change
that moves documents nearer, or less than that further, passes and the rows
stay as they were. The note now says that, with what it cost between #899
and #974, and keeps the build in one clause as the thing it rarely is.

* docs(contributing): correct three figures in the fidelity gate note

A cold pass over the note's numbers against the measurements found three
wrong:

- 75 commits between #899 and #974; `git rev-list --count` says 74, which is
  also what the per-commit attribution sums to.
- "229 of them standing a point nearer the page": 229 lines moved across, but
  one of them stands further (`proposal-editorial`'s "Signature:", -0.01 ->
  +0.11pt), and only the 220 of #944 moved by about a point - the other nine
  moved 0.02 to 0.76pt.
- "Word 16.0.20430 and 16.0.20527 measure this corpus identically": the A/B
  that shows it was run on the corpus as 6683be9 exported it, 62 documents.
  Today's corpus is 71 and carries least line heights, per-page margin
  sections and panel cuts that did not exist then, none of which the older
  build was ever measured on. The sentence now says which corpus it is about.
DemchaAV added a commit that referenced this pull request Oct 29, 2026
… a line moved (#976)

* test(qa): hold the DOCX fidelity corpus to its baseline whichever way a line moved

The fidelity gate failed a document only when it moved away from the page: a
line more than 0.5pt further than its baseline held it, a line no longer found,
or a page count further from the engine's. Measured on the gate as it stood,
everything else passed in silence — a line 1.10pt nearer the page, one 5pt
nearer, a page count arriving on the engine's own, a line newly found, a line
more drawn on the page, and `+3.00 -> -3.00pt`, which is six points of movement
that a rule comparing which of two drifts is further from the page cannot see at
all, since both stand three points away.

Silence there is not tidiness. A move nearer the page that nothing writes down
leaves the further value in the baseline, so a later change may walk the line
back to it and spend the 0.5pt on top; held that way, the Word baseline went
stale in 1609 of its 5165 rows over 74 commits without one red run, 227 of them
standing as much as a point nearer the page across after #944 and #948, until
three documents stood 0.48pt further and the next change to touch them would
have failed for drift it did not cause.

So each measurement is now held as a step from where the baseline puts it:
`FidelityBaseline.divergences` fails a line whose drift moves more than 0.5pt,
down or across, nearer the page or further; a document the editor sets on another
number of pages, or that the engine draws on another number of pages or lines;
a document whose set of found lines is not the set the baseline holds; and a line
the baseline holds a place across for that is measured without one. Each failure
names the step and its difference, so its direction is in the message, and the
remedy is in the assertion: a change that moves documents, nearer the page
included, rewrites the baseline in the same commit, and that diff is the record.
The slack stays one number for both directions, because it is rounding and
rounding is the same size whichever way a line rounds; the row aggregates are
deliberately held to nothing, since they follow from the lines, which are held
one by one.

The export does not change — the 71-document corpus exports byte for byte as
before — and the Word baseline needs no rewrite: Word 16.0.20527 measures all 71
documents and 5165 line rows exactly as word-windows.tsv holds them, not a row
differing. DocxFidelityGateTest covers each rule in both directions, 32 tests
from 27, including that a step of exactly half a point is rounding for every
drift from 0 to 5pt either way. Thirteen sabotages — every rule removed, and
every rule that has a direction made one-sided again — were each caught by those
tests.

libreoffice-windows.tsv is left as it stands and named as stale instead. Neither
LibreOffice baseline has been shot whole since #898, and the local run is red
already under the one-sided rule: 2 documents, 8 lines no longer found, 2 page
counts and 65 lines more than half a point further, 8 of them proposal-indigo's,
10.25pt lower than it holds them on the LibreOffice build the baseline names.
Rewriting it here would bury a 10pt export move in a baseline diff; it wants
attributing to the commit that moved it first, as the Word baseline's drift was
attributed before #975 rewrote it.

* test(qa): shoot the Linux LibreOffice baseline whole, and close the gate's own NaN hole

The first run of the two-sided gate went red on the Linux LibreOffice baseline:
8 documents, 230 lines. Measured against the old predicate the same run is green,
and of the 230 steps past the slack every one stands nearer the engine's PDF and
not one further, with no line lost or gained and no page or line count moved, on
the build the file names. Not shot whole since #898 and patched since only where
the one-sided gate went red, the baseline was stale on 1778 of its 5149 rows
purely in improvements that rule had swallowed. It is re-shot from that run, and
its note records the run and the reckoning; the note explaining where
cv-teal_pulse's email line stands is carried over, and three notes that only
marked which run a document's rows came from are dropped, since a whole re-shoot
makes them untrue.

libreoffice-windows.tsv is the opposite case and is left alone, marked stale in
the file rather than only in CONTRIBUTING: nothing in CI runs it, and it fails
even one-sidedly on 2 documents — 8 lines no longer found, 2 page counts, 65
lines more than half a point further, 8 of them proposal-indigo's at 10.25pt on
the build it names. A move further from the page wants attributing to the commit
that made it before the file is shot again.

Three holes the gate had, found by reading it against its own thesis:

- A baseline row whose drift or place across was not a number was read, and held
  its line to nothing: every comparison against NaN is false, so a line 500pt
  from where the baseline put it produced no divergence. These files are patched
  by hand, so one mistyped cell was enough. Such a row is now refused, which is
  the mirror of the rule this change added for a measurement carrying no place
  across.
- A rewrite dropped every hand-written note the file carried, and a rewrite is
  now the ordinary answer to a document moving. FidelityBaseline.write keeps them
  under the new header, for the author to prune.
- The page's own line count was not held, so a line added to the page with the
  same ones found passed in silence. It is held beside the pages the engine draws.

The drift figures the javadoc, the changelog and CONTRIBUTING quote were wrong
and are recounted from the committed rows: of the Word baseline's 1609 stale
rows, 1388 moved down the page — 717 nearer it, 635 further, 36 crossing it to
stand the same distance on the other side — and 229 moved across, 228 of them
nearer by as much as 1.10pt. 228 rows had moved more than the slack, 226 across
and 2 down, and no row's distance from the page had grown by more than 0.41pt,
which is why a rule reading distances stayed quiet. CONTRIBUTING also gains the
two engine-side rules it did not list, and both it and the javadoc now say that
the slack is read afresh against each rewrite and so bounds no total.

34 tests from 27, and 17 sabotages — every rule removed, every rule that has a
direction made one-sided again, the non-finite refusals removed and note-keeping
dropped — each caught by DocxFidelityGateTest.

* test(qa): count the drift the gate's own reckoning quotes, and fail closed on both axes

The figure the javadoc, the changelog and the pull request quoted for how far
the stale rows had grown — 0.41pt — was the largest growth among the rows whose
step passed the slack, not among the rows that moved, and it contradicted the
next clause of its own sentence. Recounted from the committed rows: no row's
distance from the page grew by more than 0.48pt, 21 rows of cv-serif_headline,
cv-panel and invoice-obsidian by that much and the rest by 0.36pt or less, and
those three documents are the ones that ended 0.48pt further. The argument is
the same and now agrees with itself: 0.48 against half a point of slack is why a
rule reading distances stayed quiet.

Three things the gate still let through:

- A measurement carrying no number for its drift down passed every rule, since
  Math.round(NaN) is 0 and the step against it compares false. The across axis
  had that guard; the down axis did not, so the mirror the last change claimed
  was one-sided. Both axes fail closed now.
- A baseline row's median and p90 were read unchecked, and a median that is not
  a number reads as passing every limit it is put to. They are refused as the
  drift already is.
- Keeping a file's notes did not reach the path the contributor is told to take:
  a measured run writes its own file, which carries no notes, and a run on a
  machine the baseline cannot be shot on is copied over the baseline from there.
  write takes the notes from the baseline, so the copy carries them.

Where a file's header stands is now its shape rather than a guess. The header
cannot be told from a note by its text — both are a # row of prose — so a note
written above it could not be kept without dropping the header in its place and
leaving the file naming a build it was never shot on. notesOf takes the first
line to be the header and refuses a file that does not open with one.

CONTRIBUTING no longer says neither LibreOffice baseline has been shot whole
since #898, which the Linux one now has, and it, the changelog and the job
comment name the engine's two rules in their lists.

37 tests from 27, and 20 sabotages — each rule removed, each rule that has a
direction made one-sided, both NaN guards reopened, every non-finite refusal
removed, note-keeping dropped from either file and the header's place made a
guess — every one caught by DocxFidelityGateTest.
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