Skip to content

feat(docx): write horizontal lines and dividers as Word rules - #737

Merged
DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-rules
Sep 23, 2026
Merged

DemchaAV merged 2 commits into
2.5-devfrom
feature/docx-rules

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Why

The DOCX export dropped every LineNode and every standalone ShapeNode. So the rules a template draws under a heading or between entries were missing from the Word file. Across templates and examples there are 147 addLine( calls, 96 of them horizontal, plus 12 addDivider(, and together they are among the most common drawing in the templates.

Word has a horizontal rule of its own: the bottom border of a paragraph. It is what Word itself makes when a reader types three hyphens and presses Enter. As a paragraph it flows with the text, and a reader moves or deletes it as a line of the document.

What changed

  • DocxRules.of(node) decides what is a rule. It returns the rule, or null for anything else. Two kinds qualify:
    • a LineNode whose ends sit at one height, with a visible stroke and no transform;
    • a ShapeNode that is only a fill (no stroke, radius, gradient or transform) and no taller than Word's thickest border, 12pt. That is what addDivider draws.
  • writeRule writes it as an empty paragraph whose bottom border is the stroke.
    • Border: the stroke's colour, and its thickness as w:sz in eighths of a point, clamped to Word's 2..96.
    • Ends: the line's two ends are the paragraph's left and right indents, measured against the column it sits in. A fill() line runs to the right edge; the width comes from the layout when it placed the node.
    • Vertical position: the page measures the stroke from the bottom of its box, so its distance from the top is height − startY. The space above the stroke is the paragraph's exact height, and never less than the 0.1pt the table separator uses. The space below it is owed to whatever follows.
    • Margin and padding: the space around the paragraph, as for any block.
  • A dash becomes Word's border dash. A pattern whose dashes are no longer than the stroke becomes dotted, any other becomes dashed. Word's dash lengths are its own, so the report notes a dash pattern as APPROXIMATED. The line cap is not carried.
  • Anchors. An anchor on a rule is a bookmark on its paragraph (blockAnchorOf). A dropped drawing registers none, so no page reference can point at a missing bookmark.
  • Only a rule in the flow is written. A line laid over another in a LayerStackNode, a CanvasLayerNode or a ShapeContainerNode is not written as a rule (overlayDepth). A skill meter's track and the fill over it would otherwise come out as two bars, one under the other. Such a line is dropped and reported, and registers no bookmark, so no page reference can point at a missing one.
  • Colour and room.
    • A translucent rule is flattened against what lies under it, as a chip is. One not drawn at all keeps its place and draws nothing.
    • A stroke thicker than its box spills out of the box on the page and takes no room there. horizontal() sizes the box from the stroke set before it, so a 2pt stroke set afterwards sits in a 1pt box. The extra room it takes in Word comes off the space owed below it.
  • Edge cases.
    • In a cell of unwritten width, the right end is not placed against the page.
    • A bar filled with a solid DocumentPaint is a rule too.
    • A start past the end is held at the end.
    • A link on a rule is reported as not carried.
  • Still dropped and reported: vertical and slanted lines, ellipses, other shapes, and a rule in a page zone.
  • Committed preview. word-export-companion.docx gains its gold divider: one paragraph, w:bottom single sz=16 C4994C, right indent 1580 twips. Nothing else in its document.xml changes, and the package has the same parts.
  • Two report tests. DocxExportReportTest used a horizontal line as its example of a dropped node; it now uses a vertical one.
  • Docs. The capability matrix Line and Rectangle rows, the drawing-nodes paragraph, the recipe's "What is skipped", troubleshooting.md and the CHANGELOG are updated.

Verification

In an editor. Five rules around text:

  • a full-width 2pt rule;
  • a 200pt, 3pt divider with a 6pt margin;
  • a dashed 300pt line;
  • a 1pt line in a 12pt box;
  • a rule inside a filled card.

Each was rendered by the engine and converted with LibreOffice, then measured by pixel runs:

rule engine y, x-span LibreOffice y, x-span
full width 80–82, 65–728 82–83, 65–729
divider 108–111, 64–329 110–112, 65–331
dashed 137, 65–461 139, 65–463
12pt box 171, 65–396 172, 65–396
in card 222, 81–712 224–225, 81–712

Every rule is 1–3px lower in LibreOffice, and for these rules the offset does not grow down the page. Each rule's length is within 2px of the engine's. Word has not been measured.

Tests. ./mvnw -B -ntp test -pl :graph-compose-render-docx → 382 run, 0 failures. New DocxRuleTest (17):

  • a fill line → border single, sz 16, its colour, indents 0/0;
  • a 200pt line in a 360pt column → right indent 160pt;
  • a divider → sz 24, its colour, 6pt before, right indent;
  • a stroke centred in a 12pt box → 5.5pt exact line, 5.5pt before the next paragraph;
  • dashed → dashed, and short dashes → dotted, each reported;
  • a 20pt stroke → sz 96;
  • a rule in a card → in the card's cell;
  • an anchor → a bookmark;
  • a vertical line, a 30pt box and a rounded bar → still DROPPED, three notes;
  • the stroke's distance from the top for an off-centre startY;
  • lines overlaid in a layer stack → no borders, two DROPPED, and no PAGEREF to the dropped line's anchor;
  • half-opaque black → 7F7F7F;
  • a transparent stroke → no border, and its place is kept;
  • a 3pt stroke in a 1pt box with a 10pt margin → 7.9pt before the next paragraph;
  • a rule in a padded section → 20pt indents on both sides;
  • a solid-paint bar → a rule;
  • a link → reported.

Each of these was sabotaged and fails its test:

  • measuring the stroke from the top;
  • dropping the right indent (3 tests);
  • always solid;
  • no height cap on shapes;
  • no w:sz clamp;
  • ignoring the overlay;
  • registering an overlaid line's anchor;
  • no flattening;
  • no excess taken off;
  • drawing an alpha-0 stroke;
  • no solid paint.

Gate. Full reactor gate (clean verify over core, render-pdf/docx/pptx, templates, testing, qa, coverage) → BUILD SUCCESS (qa 1788 green). Examples after install: 93 green, with word-export-companion.docx regenerated by WordExportExample: its document.xml gains the divider paragraph and nothing else.

Notes

  • Adjacent identical rules are drawn as one. Word groups consecutive paragraphs with identical borders and indents and draws their bottom border once, whatever the space between them. Two identical rules with no other paragraph between them therefore show as one line. A double rule with a margin between is one such case. I found no way to break that grouping that could be verified in LibreOffice, so it is stated in the recipe rather than worked around.
  • Page zones. A rule in a page zone is still dropped, as zones take paragraphs, fields and spacers only.

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

The export dropped every LineNode and standalone ShapeNode, so the rules
a template draws under a heading or between entries were missing from
the Word file. A horizontal line with no transform, and a filled bar no
taller than Word's thickest border with no stroke, radius, gradient or
transform (what addDivider draws), are now Word's own rule: an empty
paragraph whose bottom border is the stroke, in its colour and thickness,
with its ends as the paragraph's indents and the space above and below
the stroke in its box kept. A dash becomes Word's dashed or dotted
border and is reported APPROXIMATED. Vertical and slanted lines and
other shapes are still dropped and reported.

The committed word-export-companion.docx gains its divider and nothing
else. Two report tests that used a horizontal line as their example of a
dropped node now use a vertical one.
…page

A line laid over another in a layer stack, canvas or shape container was
written as a rule in the flow: a skill meter's track and the fill over it
came out as two bars one under the other, unreported. Only a rule in the
flow is written now; one in an overlay is dropped and reported as before,
and registers no bookmark a page reference could point at.

A translucent rule is flattened against what lies under it, and one not
drawn at all keeps its place and draws nothing. A stroke thicker than its
box spills out of it on the page and takes no room, so the room it takes
in Word comes off the space owed below. In a cell of unwritten width the
right end is not placed against the page. A bar filled with a solid paint
is a rule too, a link on a rule is reported as not carried, and a start
past the end is held at the end.

The recipe, matrix and CHANGELOG state the limits: overlaid lines and
rules in page zones are dropped, Word draws identical adjacent rules as
one, and the placement was measured in LibreOffice only.
@DemchaAV
DemchaAV merged commit e9b979e into 2.5-dev Sep 23, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the feature/docx-rules branch September 23, 2026 22:00
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