Skip to content

fix(docx): one bookmark per anchor, no control characters, and margins below zero written - #911

Merged
DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-text-and-bookmarks
Oct 16, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
fix/docx-text-and-bookmarks

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Why

Four DOCX text and bookmark losses, none of them reported:

  • Bookmark names. DocxBookmarkNames gave "Section-1" the name Section_1_2 after "Section 1", and then gave the same Section_1_2 to the anchor "Section 1 2", which cleans to it: two links went to one place. An anchor two nodes declare was a bookmark on each, one name twice — Word goes to the first, the page (PageIndex, its links) to the last.
  • Control characters. The layout removes Unicode category C, but the direction marks and joiners, from the text it sets; the export wrote it, XMLBeans turning the controls into a question mark: "Total\u0007 due" read "Total? due" in Word, a tab stood as a gap the page does not leave, and a soft hyphen or a zero-width space let Word break a word the page sets whole.
  • Margins below zero. writeParagraph and writeList held a side's margin at Math.max(0, …): a paragraph the page sets 12pt into the left margin (x = 8) stood at the margin in Word (x = 20), its lines broken elsewhere.
  • Corner-radius note. A rounded panel with only a border was said to keep "its fill".

What changed

  • DocxBookmarkNames.mint takes a name only once it is free (case-insensitive, as Word reads it): "Section 1 2" now gets Section_1_2_2. Names that do not collide are as before.
  • keepTheLastBookmarkOfEachAnchor, after the body and before the page references are settled, keeps each name's last bookmark in the file — whose order is the page's: a block's bookmark opens at its first paragraph, before those inside it — and takes the earlier starts and ends out by id. It names the anchor (APPROXIMATED, anchor) wherever the layout registers it on two node paths or more — DocxLayoutMetrics.anchorsDeclared, distinct non-zone paths of AnchorMarkerPayload (a repeated table header's re-drawn marker carries one path: measured, five markers, one path; a table's composed cells carry their table's) — which also covers a last declaration the file writes no bookmark for (a drawn shape's).
  • DocxTextControls (new): at the end of the export, every w:t of the body, headers and footers loses what the page leaves out — category C but the formatting controls (TextControlSanitizer.isFormattingControl) and \n / \r — with xml:space="preserve" where the kept text starts or ends with a space. Headers and footers are read off the document's relations: a part a header-footer policy creates is in no list of the document's until the file is read back. Text written as XML — a badge's initials, a list level's lvlText, a picture's and a barcode's descr — is cleaned where it is written (kept), since XMLBeans writes the question mark at serialisation.
  • Margins below zero on a paragraph or a list, and on the right of a block round them (its left was written already), are written as an indent below zero outside table cells (applyInset writes a negative right side there). In a table cell — a panel's included — the paragraph or list holds its running side, its own with the blocks', no further out than the cell's text: 12pt taken back from a block's 20pt of padding is written as 8; what is held back is named ("a margin below zero, its own or a block's round it, is not written past its table cell's text"), unless a line of one unbroken word stands out by letTheLineStandOut. A placed row's first child the page sets out past the row's left edge starts at the edge, named (reportTheFirstChildPulledOut), unless it is only drawn — an icon, drawn where the page draws it.
  • keptOf names what a rounded panel keeps — fill, border (a side whose stroke has a width, as paintCellSide paints it), both, or neither.

Verification

  • Probe on 2.5-dev first, then the fix, in Word (COM export) and LibreOffice (Windows):
    • margin(0,0,0,-12): page x 8 → Word 20 before, 8 after; LO 8.1; lines break where the page breaks them (before: a word sooner). Right -12: Word line ends at 290.8, LO 290.9, page 286.9 (Word's face).
    • Control characters: "Total? due", "Nul?l and ?end" before; "Total due", "Tabhere", "Null and end" after, as the page.
    • The page's PDF link to the twice-declared anchor goes to page 2, the last; after the fix Word's does too.
  • New: DocxTextControlsTest (7), DocxNegativeMarginTest (13: each side in the flow, a block's right, a list, in a panel its own and a block's, a block's padding taken back, a list in a panel, a standing word, a row's first child and its first icon, a picture in a panel's block), DocxLinkTest +6 (suffix collision in written order, last of two kept, a block's bookmark taken out whole, a block and a paragraph inside sharing an anchor, a shape's last declaration named, a split panel marked where it starts), DocxExportReportTest +1 (border only, both, zero-width sides).
  • Changed: DocxSessionMarkdownTest expected "a_b\tc" to keep its tab; the page draws no tab (TextControlSanitizer via TextFlowSupport / ParagraphWrapping), so it now expects "a_b cd".
  • 30 sabotages, each undoing one part (the free-name loop, the keep-the-last pass and its order, the layout count, the text pass, the tab, the line breaks, category C, the formatting controls, the edge space, the footers, the badge, marker and barcode text, each side's clamp, the running-side clamp in a cell, the notes and the standing-word exemption, the list's note, the row's first child and its drawn exemption, the cell guard in applyInset, keptOf's fill, border and zero width): all caught.
  • Corpus: 62/62 DOCX byte-identical; notes 1244 → 1244, 51 of the 163 corner-radius notes now read "keeps its fill and border" — each in a document with as many bordered cells or more. PaymentsInvoice's footer icons are a row's first children set 3.5–3.7pt out past it, drawn where the page draws them, and not named.
  • ./mvnw -B -ntp clean verify → BUILD SUCCESS (render-docx 1706, qa 1820).

Lane: shared semantic backend (render-docx), report text and docs.

Notes for review: measured on a panel (padding 12) by hand-written indents, Word and LibreOffice set a negative indent in a table cell as the page does, past the cell's side too (−6pt → x 26 as the page; −18pt → x 14 against the side at 20); writing it there means letting a cell's indent go below zero, which leftIndentTwips holds at zero for a row's hang, and is left to its own change — here it is named.

Not changed here: an anchor in a repeated table header row is one bookmark, which Word reads on the header's first page, where the page sends a link to its last repetition; and a table's composed cells share their table's path in the layout, so one anchor in two of them counts as one node.

…s below zero written

A bookmark name a suffix made for one anchor, "Section_1_2" for
"Section-1", was given again to the anchor that cleans to it, "Section 1
2", so two links went to one place: a name is now taken only once it is
free. An anchor two nodes declare was a bookmark on each, one name
twice, where Word goes to the first and the page to the last: the file
keeps the last it writes, and the report names the anchor, counted on
the nodes the layout registers it on.

A control character in text, a bell or a tab, which the page leaves
out, was written, most of them as a question mark ("Total? due"). It is
taken out of every run of the body, headers and footers, with a space
it leaves at an edge kept, and out of a badge's initials, a list
level's marker and a picture's description where they are written.

A margin below zero on a paragraph, a list or a block round them was
written as none. It is an indent below zero in the flow, as far as the
page sets the text; in a table cell, a panel's included, the side is
held where the cell's text stands and named.

The corner-radius note said a panel kept its fill where it had only a
border; it names what the panel paints.

A markdown test's tab expectation follows the page, which draws no tab.
@DemchaAV
DemchaAV marked this pull request as draft October 16, 2026 18:29
… take out every character the page leaves out

In a table cell the paragraph's or list's own margin below zero was
held at zero, not the side it made with the blocks round it: 12pt taken
back from a block's 20pt of padding stood at 20, named as held at the
cell's text, and a block's own margin below zero there was dropped with
no note. The running side is held at the cell's text now, and the note
names what that holds back. A row's first child the page sets out past
the row's left edge starts at it, and is named; one only drawn, an
icon, is drawn where the page draws it and is not.

The last of a name's bookmarks is the last in the file, whose order is
the page's: a block's opens before the nodes inside it, though written
after them, so a block and a paragraph inside it sharing an anchor keep
the paragraph's, where the page goes.

The text pass took out only the controls; the page leaves out every
character of Unicode's category C but the direction marks and joiners,
so a soft hyphen, a zero-width space, a noncharacter and a private-use
one go too, and a barcode's description is cleaned where it is written.
@DemchaAV
DemchaAV marked this pull request as ready for review October 16, 2026 19:02
@DemchaAV
DemchaAV merged commit c512678 into 2.5-dev Oct 16, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-text-and-bookmarks branch October 16, 2026 19:10
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