From f4f9e6cce4d699c5b8d35800cc5e31bd90c9d53b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 00:59:57 +0100 Subject: [PATCH 1/2] fix(docx): end a rule where the page ends it in Word - A line, a divider or a thin bar is written as an empty paragraph's bottom border, its ends the paragraph's indents. Word draws a paragraph's border past both indents, so every rule stood about 1.4pt longer at its left and 1.5pt at its right. - Each indent is taken in by Word's reach (wordsRuleReach): 1.42pt at the left, 1.51pt at the right, measured on nine rules 0.5 to 4pt thick in the body, a row's column and a table cell (1.36 to 1.49, 1.44 to 1.59). A rule running past its width stays held at the width's edge, less only what Word draws further than the page ends it. A rule shorter than both keeps a tenth of a point of its paragraph. LibreOffice, which draws the border between the indents, sets each rule that much short at each end. - Re-render the Word export example's DOCX preview: its divider's indents. - Measured on the corpus: in Word 280 strokes nearer the page's, 13 further, rules whose column already starts right of the page's; in LibreOffice 281 rules moved in; no word moves. - Tests: indents with the reach, its two values, a short rule, a rule past its width, a rule in a cell whose width is Word's, with and without a padded container. --- CHANGELOG.md | 26 +++++ .../examples/word-export-companion.docx | Bin 10248 -> 10247 bytes .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 19 ++++ .../semantic/docx/DocxSemanticBackend.java | 56 +++++++++- .../semantic/docx/DocxCellRuleTest.java | 10 +- .../semantic/docx/DocxHangingLeftTest.java | 4 +- .../backend/semantic/docx/DocxRuleTest.java | 99 ++++++++++++++++-- 8 files changed, 199 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b163fb8b..fd102666f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export ends a rule where the page ends it in Word.** + - A horizontal line, a divider or a thin bar is written as an empty paragraph's bottom border, + its ends the paragraph's indents. Word draws a paragraph's border past both indents, so every + rule stood about 1.4pt longer at its left and 1.5pt at its right. Subscription Invoice's + four-coloured strip had its segments run that far into each other. + - Each indent is now taken in by Word's reach: 1.42pt at the left, 1.51pt at the right. A rule + running past the width it is written in is held at that width's edge as before, and only what + Word draws further than the page ends it is taken in. A rule shorter than both keeps a tenth of + a point of its paragraph: rules of 1, 2 and 3pt stood 3.0, 2.88 and 3.12pt long in Word, each + centred where the page centres it. + - LibreOffice draws the border between the indents, so it now sets each rule that much short at + each end. + - Measured on Windows: + - Nine rules, half a point to 4pt thick, in the body, a row's column and a table cell: Word drew + them 1.36 to 1.49pt past the left indent and 1.44 to 1.59pt past the right. Neither a side + border written as none nor the size of the paragraph's mark changed that. + - On the corpus, in Word, 280 strokes stand nearer the page's and 13 further. The 13 are rules + whose column the export already starts right of the page's, which Word's reach used to cover + in part: Receipt Modern's leaders and Violet Grid's separators, after a row's auto column + given an editor's point, now start 1pt right instead of 0.45pt left. + - In LibreOffice, 281 rules move in: 249 by 1.4pt at the left and 1.5pt at the right, 21 at the + left only, being held at their width's edge, the rest by less at the right. + - No word moves in either editor. + - A header's or footer's separator is its text's own border and keeps its indents: Word still + draws it past the margins. + - 43 of the corpus's 62 documents change. No note changes. - **A DOCX export stands a table's rules where the page draws them beside a column sized to its content.** - An auto column's text is given a point of slack past the page's width, so an editor setting it diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 84873b8eb177a6c911656f31bae12e3c9ef652ea..808a3ef6ffbd321c946a580a758636f778c774c9 100644 GIT binary patch delta 2775 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 */ @@ -14584,6 +14586,14 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule // known: a cell whose grid this export did not write has none, and measuring against the // page there put the rule's end past the cell's. boolean widthKnown = currentCell != null ? Double.isFinite(currentCellWidth) : contentWidth < Double.MAX_VALUE; + // Word draws a paragraph's border past both its indents, and LibreOffice between them: the + // indents are taken in by Word's reach, so the rule ends where the page ends it in Word. + // A rule running past the width it is written in is held at that width's edge, as before, + // and only what Word draws further than the page ends it is taken in. + double reachLeft = wordsRuleReach(drawnAs != null ? drawnAs.end() - drawnAs.start() : to - from, + WORD_RULE_REACH_LEFT_POINTS); + double reachRight = wordsRuleReach(drawnAs != null ? drawnAs.end() - drawnAs.start() : to - from, + WORD_RULE_REACH_RIGHT_POINTS); if (drawnAs != null) { // Written in the cell itself, it runs where that drawing does, between the cell's // margins: a composed cell's content has no place of its own, and the alignment or the @@ -14591,14 +14601,19 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule // the cell's left. double textLeft = drawnAs.box().left() + cellMarginOf(currentCell, true); double textRight = drawnAs.box().right() - cellMarginOf(currentCell, false); - indent.setLeft(BigInteger.valueOf(leftIndentTwips(drawnAs.start() - textLeft))); - indent.setRight(BigInteger.valueOf(toTwips(Math.max(0, textRight - drawnAs.end())))); + indent.setLeft(BigInteger.valueOf(leftIndentTwips(drawnAs.start() - textLeft + reachLeft))); + indent.setRight(BigInteger.valueOf(toTwips(Math.max(0, textRight - drawnAs.end() + reachRight)))); } else { // A rule in a container hanging left starts out past the margin with its text. - indent.setLeft(BigInteger.valueOf(leftIndentTwips(insetLeft + sideLeft + from))); + indent.setLeft(BigInteger.valueOf(leftIndentTwips(insetLeft + sideLeft + from + reachLeft))); if (widthKnown) { double rightGap = availableWidth() - sideLeft - to; - indent.setRight(BigInteger.valueOf(toTwips(insetRight + Math.max(0, rightGap)))); + indent.setRight(BigInteger.valueOf(toTwips(insetRight + Math.max(0, rightGap + reachRight)))); + } else { + // It runs to the edge Word gives it, held in from it by the containers round it + // (applyInset), and that much less past it. + long held = twipsOf(indent.isSetRight() ? indent.getRight() : null); + indent.setRight(BigInteger.valueOf(held + Math.round(reachRight * POINT_TO_TWIP))); } } @@ -14660,6 +14675,37 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule reportWrittenWithout(node, "written as a paragraph border"); } + /** + * How far one of a rule's indents is taken in for what Word draws its border past it, in + * points: Word's reach, but no further than leaves the rule's paragraph a tenth of a point. + * + *

Word draws a paragraph's bottom border past both its indents and LibreOffice between + * them. Measured on nine rules, half a point to 4pt thick, indented from nothing to 61.7pt: + * Word drew them 1.36 to 1.49pt past the left indent and 1.44 to 1.59pt past the right, + * whatever their weight, the size of the paragraph's mark, or a left and right border written + * as none; in a row's column and in a table cell alike. {@code SubscriptionInvoice}'s + * separators stood 1.32pt left of the page's and 1.57pt right, and its four-coloured strip's + * segments ran that far into each other. A rule shorter than Word's reach at both ends keeps a + * tenth of a point of its paragraph, which Word still draws the reach past: measured, rules of + * 1, 2 and 3pt stood 3.0, 2.88 and 3.12pt long in Word, each centred within 0.05pt of where + * the page centres it.

+ * + * @param length the rule's length on the page, in points + * @param reach how far past that indent Word draws it, in points + */ + private static double wordsRuleReach(double length, double reach) { + return Math.min(reach, Math.max(0, (length - SHORTEST_RULE_PARAGRAPH_POINTS) / 2)); + } + + /** How far past its left indent Word draws a paragraph's border, in points: measured, 1.36 to 1.49. */ + static final double WORD_RULE_REACH_LEFT_POINTS = 1.42; + + /** How far past its right indent Word draws a paragraph's border, in points: measured, 1.44 to 1.59. */ + static final double WORD_RULE_REACH_RIGHT_POINTS = 1.51; + + /** What a rule shorter than Word's reach at both ends keeps of its paragraph's width, in points. */ + private static final double SHORTEST_RULE_PARAGRAPH_POINTS = 0.1; + /** Word's border for a rule's dash: dots where the dashes are no longer than the stroke. */ private static STBorder.Enum dashOf(DocxRules.Rule rule) { if (rule.dashed() == null) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleTest.java index 9b9c0f447..8fc68f13e 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellRuleTest.java @@ -61,9 +61,12 @@ void aLineCentredInItsCellIsWrittenWhereThePageCentresIt() throws Exception { // The cell's text starts 4pt in from its edge at 20, as its margin in the file does. double start = line.x() + ((LineFragmentPayload) line.payload()).startX() - 24; + // Each indent taken in by what Word draws its border past it (wordsRuleReach). CTInd indent = export.rule().getCTP().getPPr().getInd(); - assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(Math.round(start * 20)); - assertThat(DocxTwips.of(indent.getRight())).as("as far from the right").isEqualTo(Math.round(start * 20)); + assertThat(DocxTwips.of(indent.getLeft())) + .isEqualTo(Math.round((start + DocxSemanticBackend.WORD_RULE_REACH_LEFT_POINTS) * 20)); + assertThat(DocxTwips.of(indent.getRight())).as("as far from the right") + .isEqualTo(Math.round((start + DocxSemanticBackend.WORD_RULE_REACH_RIGHT_POINTS) * 20)); assertThat(export.body()).doesNotContain(LINE); } @@ -86,7 +89,8 @@ void aRuleTakesItsOwnDrawingNotAnotherLineBeforeIt() throws Exception { assertThat(export.body().split(LINE, -1)).as("one line drawn").hasSize(2); assertThat(export.body()).as("the vertical one").contains(" * + *

Word draws a paragraph's border past both its indents, so each indent is taken in by as + * much: the rule ends where the page ends it in Word ({@code wordsRuleReach}).

+ * * @author Artem Demchyshyn */ class DocxRuleTest { @@ -35,6 +41,9 @@ class DocxRuleTest { private static final DocumentColor ACCENT = DocumentColor.rgb(26, 86, 148); /** What the default text's line under a rule is raised into the space above it. */ private static final long RAISE = DocxExports.DEFAULT_LINE_RAISE; + /** How far past its left and right indents Word draws a rule, in twips. */ + private static final long REACH_LEFT = Math.round(DocxSemanticBackend.WORD_RULE_REACH_LEFT_POINTS * 20); + private static final long REACH_RIGHT = Math.round(DocxSemanticBackend.WORD_RULE_REACH_RIGHT_POINTS * 20); @Test void aFillLineIsAParagraphBorderAcrossTheWidth() throws Exception { @@ -49,10 +58,11 @@ void aFillLineIsAParagraphBorderAcrossTheWidth() throws Exception { // w:sz counts eighths of a point. assertThat(bottom.getSz().intValue()).isEqualTo(16); assertThat(hex(bottom.getColor())).isEqualTo("1A5694"); - assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getLeft())).isZero(); + assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getLeft())) + .as("from the left margin, Word's reach in").isEqualTo(REACH_LEFT); assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getRight())) - .as("a fill line runs to the right margin") - .isZero(); + .as("a fill line runs to the right margin, Word's reach in") + .isEqualTo(REACH_RIGHT); } } @@ -63,7 +73,82 @@ void aLineShorterThanTheColumnEndsWhereItEnds() throws Exception { .addLine(l -> l.horizontal(200).stroke(DocumentStroke.of(ACCENT, 1))))) { CTPPr properties = document.getParagraphs().get(0).getCTP().getPPr(); - assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(160 * 20L); + assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(160 * 20L + REACH_RIGHT); + } + } + + @Test + void wordsReachIsAPointAndFourTenthsAtTheLeftAndAPointAndAHalfAtTheRight() { + // Measured on nine rules: 1.36 to 1.49pt past the left indent, 1.44 to 1.59pt past the right. + assertThat(REACH_LEFT).isEqualTo(28L); + assertThat(REACH_RIGHT).isEqualTo(30L); + } + + @Test + void aRuleRunningPastItsWidthIsHeldAtItsEdgeAndTakenInOnlyByWhatWordDrawsFurther() throws Exception { + // A 355pt rule set 6pt in, in 360pt: the page ends it a point past the right margin. Held + // at the margin, Word's 1.51pt reach would end it half a point further than the page, so + // only that half point is taken in. + try (XWPFDocument document = export(page -> page + .addLine(l -> l.horizontal(355).stroke(DocumentStroke.of(ACCENT, 1)) + .margin(new DocumentInsets(0, 0, 0, 6))))) { + CTPPr properties = document.getParagraphs().get(0).getCTP().getPPr(); + + assertThat(DocxTwips.of(properties.getInd().getLeft())).isEqualTo(6 * 20L + REACH_LEFT); + assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(REACH_RIGHT - 20); + } + } + + @Test + void aRuleInACellWhoseWidthIsWordsEndsWordsReachShortOfTheCellsText() throws Exception { + // No layout, an auto column: the table's columns are Word's, and the rule runs to the cell's + // text edge, which it is held in from by Word's reach past it. + try (XWPFDocument document = DocxExports.withoutLayout(400, 400, 20, page -> page.addTable(t -> t + .autoColumns(1) + .rowCells(DocumentTableCell.node(new LineBuilder().horizontal(100) + .stroke(DocumentStroke.of(ACCENT, 1)).fill().build()))))) { + assertThat(DocxTwips.of(ruleIn(document).getCTP().getPPr().getInd().getRight())).isEqualTo(REACH_RIGHT); + } + } + + @Test + void aRuleInAPaddedContainerOfACellWhoseWidthIsWordsKeepsThePaddingAndTheReach() throws Exception { + try (XWPFDocument document = DocxExports.withoutLayout(400, 400, 20, page -> page.addTable(t -> t + .autoColumns(1) + .rowCells(DocumentTableCell.node(new SectionBuilder().padding(DocumentInsets.of(8)) + .addLine(l -> l.horizontal(100).stroke(DocumentStroke.of(ACCENT, 1)).fill()) + .build()))))) { + assertThat(DocxTwips.of(ruleIn(document).getCTP().getPPr().getInd().getRight())) + .as("the section's padding and Word's reach").isEqualTo(8 * 20L + REACH_RIGHT); + } + } + + /** The rule's paragraph in the first cell of the first table, at any depth. */ + private static XWPFParagraph ruleIn(XWPFDocument document) { + java.util.ArrayDeque cells = new java.util.ArrayDeque<>(); + cells.add(document.getTables().get(0).getRow(0).getCell(0)); + while (!cells.isEmpty()) { + org.apache.poi.xwpf.usermodel.XWPFTableCell cell = cells.poll(); + for (XWPFParagraph paragraph : cell.getParagraphs()) { + if (paragraph.getCTP().getPPr() != null && paragraph.getCTP().getPPr().isSetPBdr()) { + return paragraph; + } + } + cell.getTables().forEach(table -> table.getRows().forEach(row -> cells.addAll(row.getTableCells()))); + } + throw new AssertionError("no rule"); + } + + @Test + void aRuleShorterThanWordsReachAtBothEndsKeepsATenthOfAPointOfItsParagraph() throws Exception { + // A 2pt rule: each indent taken in by 0.95pt, the paragraph 0.1pt wide, which Word draws + // its reach past at both ends. + try (XWPFDocument document = export(page -> page + .addLine(l -> l.horizontal(2).stroke(DocumentStroke.of(ACCENT, 1))))) { + CTPPr properties = document.getParagraphs().get(0).getCTP().getPPr(); + + assertThat(DocxTwips.of(properties.getInd().getLeft())).isEqualTo(19L); + assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(358 * 20L + 19); } } @@ -77,7 +162,7 @@ void aDividerIsARuleOfItsThicknessAndColour() throws Exception { assertThat(properties.getPBdr().getBottom().getSz().intValue()).isEqualTo(24); assertThat(hex(properties.getPBdr().getBottom().getColor())).isEqualTo("1A5694"); assertThat(DocxTwips.of(properties.getSpacing().getBefore())).isEqualTo(6 * 20L); - assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(160 * 20L); + assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(160 * 20L + REACH_RIGHT); } } @@ -371,8 +456,8 @@ void aRuleInAPaddedSectionStartsAndEndsInsideIt() throws Exception { .addLine(l -> l.horizontal(100).stroke(DocumentStroke.of(ACCENT, 1)).fill())))) { CTPPr properties = document.getParagraphs().get(0).getCTP().getPPr(); - assertThat(DocxTwips.of(properties.getInd().getLeft())).isEqualTo(20 * 20L); - assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(20 * 20L); + assertThat(DocxTwips.of(properties.getInd().getLeft())).isEqualTo(20 * 20L + REACH_LEFT); + assertThat(DocxTwips.of(properties.getInd().getRight())).isEqualTo(20 * 20L + REACH_RIGHT); } } From 2566a88048406e71bc7a2579d1d2f4d13cdd0e4e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 01:42:31 +0100 Subject: [PATCH 2/2] fix(docx): say how far a rule's ends move in LibreOffice - LibreOffice now sets each end of a rule further in by Word's reach, not short of the page's by it: in the body it drew a rule's right end short of its indent already. - The reach is stated as measured: nine rules in the body, half a point to 4pt thick; rules of 1 and 3pt in a row's column and a table cell in the same range; a dashed and a dotted rule reaching as far as a solid one. - Tests: a rule hung 1pt left of its row column's text takes the reach in before the cell holds it at its text; the hung dash's left indent is the reach. --- CHANGELOG.md | 11 ++++++----- docs/architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 10 ++++++---- .../semantic/docx/DocxSemanticBackend.java | 13 +++++++------ .../semantic/docx/DocxHangingLeftTest.java | 4 ++++ .../backend/semantic/docx/DocxRuleTest.java | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd102666f..07561e7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,12 +18,13 @@ follow semantic versioning; release dates are ISO 8601. Word draws further than the page ends it is taken in. A rule shorter than both keeps a tenth of a point of its paragraph: rules of 1, 2 and 3pt stood 3.0, 2.88 and 3.12pt long in Word, each centred where the page centres it. - - LibreOffice draws the border between the indents, so it now sets each rule that much short at - each end. + - LibreOffice draws the border between the indents, so it now sets each end of a rule that much + further in. - Measured on Windows: - - Nine rules, half a point to 4pt thick, in the body, a row's column and a table cell: Word drew - them 1.36 to 1.49pt past the left indent and 1.44 to 1.59pt past the right. Neither a side - border written as none nor the size of the paragraph's mark changed that. + - Nine rules in the body, half a point to 4pt thick: Word drew them 1.36 to 1.49pt past the left + indent and 1.44 to 1.59pt past the right. Neither a side border written as none nor the size + of the paragraph's mark changed that. Rules of 1 and 3pt in a row's column and in a table + cell fell in the same range, and a dashed and a dotted one reached as far as a solid one. - On the corpus, in Word, 280 strokes stand nearer the page's and 13 further. The 13 are rules whose column the export already starts right of the page's, which Word's reach used to cover in part: Receipt Modern's leaders and Violet Grid's separators, after a row's auto column diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 11874eb60..264809160 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -75,7 +75,7 @@ Payload records live in `core` under | Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches | | Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what runs past the cell's text as Word starts it, half that border in — what more it gives up there than it would held in at the text coming off its right margin, and no more than its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half —, since a nested table running past its cell widens the cell, fixed layout or not; one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the paragraph whose text it stands beside, placed down from its top, so it moves with that text when the text above is edited; beside no text, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line stays on the page —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row | -| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets it that much short at each end, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | +| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is | | Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom` through the same move/line/cubic/close segments, an unfilled path left open; the fill and stroke colours are carried, and the outline's cap and join as the page's — butt, round or square; mitred up to ten times the stroke's width, round or bevelled — which Word draws as the page does, and LibreOffice too but for a mitred corner sharper than 30 degrees, which it bevels — neither reads the miter limit, Word's own being the page's (written with neither cap nor join, both end an outline flat and round its corners); a gradient paint and the dash pattern are not carried, and a path's report note names which of them it loses, composed in a table cell the table's note naming them; the SVG icons of a block (`addSvgIcon`) are drawn this way, one shape per layer, unclipped | | Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 5023db71b..5dd20d273 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1175,10 +1175,12 @@ whose bottom border is the stroke, in its colour and thickness, from where the line starts to where it ends, with the space above and below the stroke kept. Word draws a paragraph's border past both its indents, and LibreOffice between them, so each indent is taken in by Word's reach: 1.42pt at the left and 1.51pt at -the right (measured on nine rules half a point to 4pt thick, in the body, a row's -column and a table cell alike: 1.36 to 1.49pt past the left, 1.44 to 1.59pt past the -right, whatever the paragraph's mark or a side border written as none). Word sets the -rule where the page ends it; LibreOffice sets it that much short at each end. A rule +the right (measured on nine rules in the body, half a point to 4pt thick: 1.36 to +1.49pt past the left, 1.44 to 1.59pt past the right, whatever the paragraph's mark or a +side border written as none; rules of 1 and 3pt in a row's column and in a table cell +fell in the same range, and a dashed and a dotted one reached as far as a solid one). +Word sets the rule where the page ends it; LibreOffice sets each end that much further +in. A rule running past the width it is written in is held at that width's edge, and only what Word draws further than the page ends it is taken in. A rule whose column the export starts right of the page's — the weight column after a row's auto column given an diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 08e72a1a0..884c58dc5 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -14560,8 +14560,8 @@ private void writeBarcode(XWPFDocument document, com.demcha.compose.document.nod * paragraph's height, held to a tenth of a point when there is none, the space below is * owed to what follows, what Word's paragraph stands taller than the box hangs below it, * and the rule's two ends are the paragraph's indents, each taken in by what Word draws a - * border past it ({@link #wordsRuleReach}): LibreOffice, which draws it between them, sets the - * rule that much short at each end. Word draws + * border past it ({@link #wordsRuleReach}): LibreOffice, which draws it between them, sets each + * end that much further in. Word draws * a border in its own dash lengths, so a dashed rule keeps its dash and not the pattern's * lengths, and the report says so.

*/ @@ -14680,10 +14680,11 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule * points: Word's reach, but no further than leaves the rule's paragraph a tenth of a point. * *

Word draws a paragraph's bottom border past both its indents and LibreOffice between - * them. Measured on nine rules, half a point to 4pt thick, indented from nothing to 61.7pt: - * Word drew them 1.36 to 1.49pt past the left indent and 1.44 to 1.59pt past the right, - * whatever their weight, the size of the paragraph's mark, or a left and right border written - * as none; in a row's column and in a table cell alike. {@code SubscriptionInvoice}'s + * them. Measured on nine rules in the body, half a point to 4pt thick, indented from nothing + * to 61.7pt: Word drew them 1.36 to 1.49pt past the left indent and 1.44 to 1.59pt past the + * right, whatever the size of the paragraph's mark or a left and right border written as none; + * rules of 1 and 3pt in a row's column and in a table cell fell in the same range, and a + * dashed and a dotted one reached as far as a solid one. {@code SubscriptionInvoice}'s * separators stood 1.32pt left of the page's and 1.57pt right, and its four-coloured strip's * segments ran that far into each other. A rule shorter than Word's reach at both ends keeps a * tenth of a point of its paragraph, which Word still draws the reach past: measured, rules of diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingLeftTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingLeftTest.java index 56680fe86..2b9a52684 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingLeftTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingLeftTest.java @@ -164,6 +164,10 @@ void aFirstColumnWrittenAsARuleKeepsItsWidth() throws Exception { assertThat(dash == null || !dash.isSetRight() || DocxTwips.of(dash.getRight()) <= reach + 10) .as("and the dash across all of it: the hang its cells take moves text, not widths") .isTrue(); + // It starts at its cell's text, the hang being the cell's: its left indent is Word's reach. + assertThat(dash == null || !dash.isSetLeft() ? 0 : DocxTwips.of(dash.getLeft())) + .as("from its cell's text, Word's reach in") + .isEqualTo(Math.round(DocxSemanticBackend.WORD_RULE_REACH_LEFT_POINTS * 20)); } } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java index af9e2b33a..baa6022e3 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRuleTest.java @@ -99,6 +99,22 @@ void aRuleRunningPastItsWidthIsHeldAtItsEdgeAndTakenInOnlyByWhatWordDrawsFurther } } + @Test + void aRuleHungLessThanWordsReachLeftOfItsCellsTextIsTakenInByWhatIsLeft() throws Exception { + // A point left of its row column's text: Word's 1.42pt reach less that point, 0.42pt in. + try (XWPFDocument document = export(page -> page.addRow(row -> row.spacing(12).weights(1, 1) + .addSection(s -> s.addLine(l -> l.horizontal(100).stroke(DocumentStroke.of(ACCENT, 1)) + .margin(new DocumentInsets(0, 0, 0, -1)))) + .addSection(s -> s.addParagraph("Beside"))))) { + XWPFParagraph rule = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream() + .filter(paragraph -> paragraph.getCTP().getPPr() != null + && paragraph.getCTP().getPPr().isSetPBdr()) + .findFirst().orElseThrow(); + + assertThat(DocxTwips.of(rule.getCTP().getPPr().getInd().getLeft())).isEqualTo(REACH_LEFT - 20); + } + } + @Test void aRuleInACellWhoseWidthIsWordsEndsWordsReachShortOfTheCellsText() throws Exception { // No layout, an auto column: the table's columns are Word's, and the rule runs to the cell's