Skip to content

fix(docx): hold a row's auto column's editor point in the gap after it - #948

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-row-editor-slack
Oct 23, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-row-editor-slack

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Why

A row column sized to its content (DocumentRowColumn.auto()) is given a point of slack (EDITOR_COLUMN_SLACK_POINTS). An editor setting its text in its own face would otherwise wrap it: a table of contents' label broke mid-word in LibreOffice without it. withRowEditorSlack always took that point out of the row's weight columns. The auto column was written a point wider, and whatever stood right of it moved a point right. Nothing named it.

A rule in a placed row's column also counted its left margin twice. The column already starts past the child's margin (leftMarginInCell), and writeRule added it again as the rule's indent.

Measured on Windows, in Word:

Stroke Before
ReceiptModern's 8 leaders start +0.95 to +1.04pt
EditorialProposal's 3 signature lines start +0.88 to +1.02pt
VioletGrid's credential heading rules start +12.3pt (their 11.3pt margin again, and the point)
CharcoalGold's heading rules start +9.25pt (their 9pt margin again)

What changed

  • withRowEditorSlack counts the room an auto column already has (roomInItsColumn):
    • The column runs to the next child's start, past that child's left margin, so its text box holds that margin. CharcoalGold's heading words have the 9pt their rule stands in by, and take no point at all.
    • A paragraph's own right side counts as far as it does not write it as an indent. In a cell, a left-to-right paragraph not set right writes its right side 2pt short (EDITOR_SLACK_POINTS). A side a margin below zero pulls out stands at the cell's text, so it counts as nothing. The arithmetic is now one helper, writtenSides, shared with writeParagraph.
  • A paragraph set left takes what that leaves from the gap after its column, the column's right margin. The column keeps the page's width, and what stands right of it stays where the page sets it.
    • The row's padding after its last column is not a gap: a painted row's panel takes it out of its columns (withoutTheRowsPadding).
    • A paragraph set otherwise, or a child that is not a paragraph, takes none from a gap. Set right, a paragraph in an auto column claims its row's whole width on the page.
  • What neither holds still comes out of the weight columns, in proportion to their text boxes as before, and the row is named (right end). The note is skipped where the weight columns giving it hold only spacers, or the remainder is under a tenth of a point. Twenty corpus rows are named that nothing named before:
    • ReceiptModern's amounts at the row's end, 0.84 to 1.1pt left in Word and 0.86 to 1.02pt in LibreOffice;
    • VioletGrid's section heading rules, a point right;
    • SerifHeadline's section headings, Orange Ops' contact strip and Subscription's masthead.
  • writeRule counts a rule's left margin once in a placed row's column.
  • Docs:
    • the recipe's "Row columns" row and its rules paragraph;
    • the CHANGELOG (Public API);
    • the node and value ledgers' row columns entries (ROW_AUTO_ROOM).

Verification

  • DocxEditorSlackTest (+10 new, 3 updated):
    • New:
      • a neighbour's 9pt margin needs no point;
      • with no gap, the point comes from the weight column, named;
      • a 0.4pt gap gives what it has;
      • a section takes none from the gap;
      • a centred paragraph takes none from the gap;
      • a paragraph's own right side gives the room it does not indent: 3pt needs no point, 0.4pt leaves 0.6pt to the weight column;
      • a right side a margin below zero pulls out gives no room, beside a neighbour standing in by as much;
      • the row's padding is no gap;
      • a spacer's column is not named;
      • a 0.05pt remainder is not named.
    • Updated:
      • the table of contents' entry: the label's point comes out of the 6pt gap, and the page number's out of the leader, named;
      • the fixed-column test now runs with no gap;
      • the proportion test now puts its auto column at the row's end.
  • DocxRuleTest (+1): a rule with a 9pt margin in a row's column starts and ends at Word's reach in from its cell's text.
  • Fail-closed: 12 sabotages, each run alone, all caught. A thirteenth cannot change the output: taking a paragraph's whole right side as room, where a side wider than 2pt already gives 2pt, more than the point. The caught ones:
    • room not counted;
    • gap not used;
    • padding taken as a gap;
    • any child, or any paragraph, taking from the gap;
    • a paragraph's spared right side not counted;
    • a side pulled out taken as room;
    • a spacer's column named;
    • under a tenth named;
    • nothing named;
    • the rule's margin counted again;
    • weight columns giving by width.
  • Word and LibreOffice on Windows, the changed documents against the export before:
    • In Word, the leaders start within 0.06pt of the page's and end within 0.06pt. The signature lines are within 0.18pt, and VioletGrid's credential rules within 0.07pt.
    • CharcoalGold's heading rules stand with their words, which stand 0.7pt left in both editors.
    • LibreOffice sets each end of a rule 1.4 and 1.5pt in, as before.
    • No word changes or moves down in either editor. EditorialProposal's "Date:" stands 0.17pt right of the page's in Word, where it stood 0.19pt left.
  • Corpus: 4 of 62 DOCX change (cv-charcoal_gold, cv-violet_grid, proposal-editorial, receipt-modern). Notes go from 1365 to 1385.
  • 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 2083, 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. The committed word-export-companion.docx does not change.

Notes for review:

  • A row composed in a table cell has no placed children, so its auto columns count no room. They take the point from the gap or the weight columns, as a placed row's would with no neighbour margin.
  • An auto column holding no text, such as a spacer or a picture, is still given the point, as before. Where its weight column gives it, the note speaks of "their text".

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

- A row column sized to its content is given a point for an editor's
  face. It came out of the row's weight columns and moved what stood
  right of it a point right: ReceiptModern's leaders and
  EditorialProposal's signature lines started 0.9 to 1pt right of the
  page's in Word.
- The column already holds the next child's left margin, and a
  paragraph's own right side gives what the paragraph does not write as
  an indent (writtenSides, now shared with writeParagraph). A paragraph
  set left takes what that leaves from the gap after its column, which
  then stands where the page sets it.
- What neither holds still comes out of the weight columns, and the row
  is named (right end) unless they hold only spacers or it is under a
  tenth of a point: ReceiptModern's amounts at the row's end and
  VioletGrid's section heading rules, among twenty rows nothing named
  before.
- A rule in a row's column counts its left margin once. Counted again,
  CharcoalGold's heading rules started 9.25pt right of the page's in
  Word and VioletGrid's credential ones 12.3pt.
- Measured on Windows, in Word: leaders within 0.06pt, signature lines
  within 0.18pt, credential rules within 0.07pt; no word moves in Word
  or LibreOffice.
- Tests: the gap holding the point, the last column taking it from the
  weight column, a neighbour's margin and a paragraph's own right side
  as room, a thin gap, a section and a centred paragraph, the row's
  padding, a spacer, a tenth of a point, and a rule's margin in a row's
  column.
@DemchaAV
DemchaAV marked this pull request as draft October 23, 2026 10:06
- A row cell holds a paragraph's side at its text, so a right margin
  below zero gives an auto column no room: counted, a label pulled 2pt
  into a neighbour's 2pt margin got no point.
- writtenSides names its paragraph and says a cell holds a side below
  zero at its text.
- The rule test checks the right end of a rule in a row's column too.
- The CHANGELOG, the recipe and the javadoc give ReceiptModern's amounts
  as measured on all eight rows, 0.84 to 1.1pt left in Word and 0.86 to
  1.02pt in LibreOffice, and the leaders and signature lines 0.88 to
  1.04pt right before. The CHANGELOG says no line breaks differently or
  moves down, and no word moves across by more than 0.4pt.
@DemchaAV
DemchaAV marked this pull request as ready for review October 23, 2026 10:17
@DemchaAV
DemchaAV merged commit 5c2e62b into 2.5-dev Oct 23, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-row-editor-slack branch October 23, 2026 10:49
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