Skip to content

fix(docx): draw a cell's rule once, a thick line at its thickness, a shape before a break - #905

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-drawings-thick-line-cell-align
Oct 15, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-drawings-thick-line-cell-align

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Why

Three drawings the DOCX export lost or doubled, while its report said nothing or said otherwise.

  • A rule in a table's cell came out twice. A table draws what its composed cells (DocumentTableCell.node(...)) paint as shapes (drawCellDrawing). A cell's line or bar is also written as a rule: a paragraph whose bottom border is the stroke.
    • Every such line was in the file twice. Word drew the border and a line over it, 0.2pt above the border's middle and 0.7pt to its left. In the corpus that is 14 lines, in invoice-obsidian, invoice-platform and proposal-indigo.
    • A line centred in its cell by addAligned(...) was drawn centred, and written again at the cell's left.
  • A horizontal line thicker than 12pt was written as a 12pt border. That is Word's thickest. In Word a 20pt bar came out 12pt, and what followed stood over it.
  • A shape alone on a page before a page break was dropped. Its note said it was drawn where the layout puts it. DocxDrawingAnchors.endSection found no paragraph on that page to anchor it in, and named only "1 shape(s) on page 1".

What changed

  • DocxRules.ofLine refuses a line stroked thicker than MAX_RULE_POINTS, as ofShape already refused a bar.
    • Such a line is drawn as a shape at its thickness. It holds its room where a rule would: in the flow, and among the text of a stack of one layer (inTheFlow).
    • Composed in a table cell it holds none. Its note there says so (DocxRules.thickerThanABorder).
  • writeRule in a composed cell takes its drawing (takeTheDrawingOf) from the table's waiting drawings.
    • A candidate is a fragment of the rule's kind and thickness that reaches into the cell: a level line as long as the node's (any, for a rule running to the end of its width), or a bar. One reaching past the cell counts too.
    • It takes the first candidate only where nothing else can be it. As many candidates must wait in the cell as rules drawn alike are left to write there (collectRules in writing order, drawnAlike matching drawingsOf). This rule must be the next one. And it must be written in the composed cell itself.
    • A repeated header's copies on later pages are taken with it.
    • Otherwise both stay, and the rule's note says it stands twice. Otherwise a drawing of the same kind and size laid over something earlier in the cell would be taken for it and lost, and a rule inside a panel or a row in the cell is not placed from its drawing.
    • A taken rule's indents are read off the fragment, between the cell's margins (cellMarginOf), so a centred line is written centred.
    • The table's cell drawing note is written after its rows, and only where something its rules did not take is drawn.
  • A page break's paragraph carries the page it ends, where nothing else written there does: no body paragraph, opening line, closing paragraph or cell paragraph (DocxDrawingAnchors.breakOn). A break's paragraph after a table that fills its page would flow to the next page, so the cell paragraph comes first.
  • A drawing nothing carries is named on its node.
    • DocxDrawingAnchors keeps, for each queued shape, the note it is drawn for (Owner). The owners are drawn nodes, a drawn picture, a text box over the flow, a timeline's rail, and a table's cell drawings.
    • endSection returns, per owner, whether all its shapes were anchored nowhere.
    • The backend then puts a DROPPED note in the place of each note that said the drawing was there, within the section (DocxExportReport.Builder.replace), or adds one naming the part where only some of its shapes went.
  • Docs:
    • docs/recipes/docx-export.md and backend-capability-matrix.md: the thickest rule, the rule composed in a cell (and when it stands twice), the break's paragraph and the uncarried drawing.
    • CHANGELOG.md: an entry for the three fixes.

Verification

  • Word. Probes and the corpus, converted through Word and measured with PyMuPDF:
    • a 20pt line is drawn at its place (centre at 43pt from the top), and the text after it stands on its own line;
    • a line centred in a cell is drawn once, at 88.6–151.5pt against the page's 90–150pt;
    • the shape before a break is on page 1, its text on page 2;
    • invoice-obsidian keeps its 5 composed-cell borders and loses the 5 duplicate lines drawn over them.
  • DocxCellRuleTest, 12 tests. It covers:
    • a line and a bar in a cell, each written once;
    • two rules in one cell, alike or a short one then a full-width one, each taking its own drawing;
    • a rule reaching past its cell, not taken for an underline as thick laid over a mark — both stay, named;
    • a centred line's indents read off the layout;
    • a rule taking its own drawing, not a vertical line before it — also before a full-width rule;
    • a level underline in an overlay before a rule, parted by length alone and by thickness alone;
    • an underline drawn alike, where both stay and the rule is named as standing twice;
    • a rule in a panel inside its cell, named the same;
    • a too-thick line in a cell, drawn and named as taking no room;
    • a repeated header's rule, written in its header cell and not drawn on any page;
    • no cell drawing note where only rules are composed.
  • DocxDrawingCarrierTest, 2 tests. A shape before a break is anchored in the break's paragraph. A shape on a page no paragraph is written on has one DROPPED note on itself, and no page note.
  • DocxDrawingAnchorsTest, 2 new tests. It covers an owner carried in part against one dropped whole, and a break's paragraph used only where no cell paragraph stands on its page.
  • DocxExportReportTest, 1 new test. replace reaches only the section's notes, and every one of them.
  • DocxRuleTest.
    • aThickStrokeStopsAtWordsThickestBorder pinned the 12pt border. It is now aStrokeThickerThanWordsThickestBorderIsDrawnAtItsThickness: a 12pt stroke is still the thickest border, and a 20pt one is drawn.
    • A new test checks that the thick line holds the same room in a stack of one layer — last in it too — as in the flow.
  • Sabotage, 22 of 22 caught:
    • R1: no thickness cap;
    • R2: the rule takes nothing;
    • R3: no place from the drawing;
    • R4: header copies drawn;
    • R5: any line matches;
    • R6: any thickness matches;
    • R11: any length matches;
    • R7: the drawn note always written;
    • R8: no break carrier;
    • R9: no owner;
    • R10: the drawn note kept;
    • R12: no ambiguity guard;
    • R13: no nesting guard;
    • R14: no room in a one-layer stack;
    • R15: no cell room clause;
    • R16: the break before the cell;
    • R17: replace not scoped;
    • R18: only drawings the cell holds counted;
    • R19: no rules counted as written;
    • R20: every rule counted as left;
    • R21: drawnAlike out of step with drawingsOf;
    • R22: room in a stack of one layer only with a sibling after it.
  • Corpus. 59 of 62 DOCX are byte-identical to 2.5-dev. The other 3 differ only by the 14 duplicate line drawings removed; their rule paragraphs are the same. Report notes go from 1246 to 1244: two cell drawing notes on tables whose cells draw only rules.
  • Build. ./mvnw -B -ntp clean verify → BUILD SUCCESS (render-docx 1617, qa 1820).

Lane: render-docx (DocxSemanticBackend, DocxRules, DocxDrawingAnchors, DocxExportReport).

…shape before a break

A rule composed in a table cell was written as a paragraph border and
drawn again by its table, which draws what its composed cells paint:
every line in such a cell stood twice in Word, and one centred by
addAligned was drawn centred and written again at the cell's left.
writeRule now takes its drawing from the table's waiting drawings and
is written where it places it. It takes one only where nothing else can
be it - as many drawings like it wait in the cell as rules like it are
left to write, and the rule stands in the cell itself; otherwise both
stay and the report says the rule stands twice. The table's cell
drawing note is written only where something its rules did not take is
drawn.

A horizontal line thicker than Word's 12pt border was written as a 12pt
border; it is drawn as a shape at its thickness, holding its room in the
flow and in a stack of one layer, and named as holding none in a cell.

A shape alone on a page before a page break was dropped under a note
saying it was drawn. The break's paragraph now carries its page where
nothing else written there does. A drawing nothing carries is named on
its own note, DROPPED, which DocxDrawingAnchors learns from the owner
each queued shape is drawn for.
… as it is matched

A rule's drawing reaching past its cell was not counted among the drawings
like it, so an underline as thick laid over a mark could be taken for the
rule's and lost, the rule's own drawing left standing twice unnamed. The
drawings like a rule are now those reaching into its cell at all. Rules
are counted alike as their drawings are matched, so a short rule before
a full-width one of the same stroke each take their own; a refused rule's
note no longer names a reason it may not have.

A line too thick to be a rule holds its room among the text of a stack
of one layer whether or not something follows it there, as a rule does,
and only a line in the flow of its cell is named as taking no room
there. The recipe ranks a page break's paragraph last among a page's
carriers, as the export does.
@DemchaAV
DemchaAV merged commit 27979a9 into 2.5-dev Oct 15, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-drawings-thick-line-cell-align branch October 15, 2026 16:22
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