diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d7053f23..27218be1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,27 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX chip takes the room its padding takes on the page.** A chip's fill is written as
+ run shading, which covers the letters and nothing more, so its horizontal padding was not
+ in the file. `ModernReceipt`'s status chip, right-aligned with 9pt of padding on each side,
+ stood 9.16pt right of the page's in Word; it stands 0.16pt right now. The padding is
+ written as character spacing (`w:spacing`) in two places. After the chip's last letter it
+ is shaded with the chip; after the letter before the chip it is not, unless that letter
+ ends another chip, whose fill it then takes. Each of those letters, with its accents, goes
+ in a run of its own where its run holds anything else, inside the same link where it is in
+ one. A centred or right-aligned line spaced to the page's width adds its
+ spacing to the padding's and reckons the padding at its own width, not grown to Word's type
+ size. It is the only chip in the corpus with padding beside its letters, and no other line
+ moves, in Word or in LibreOffice. LibreOffice sets no spacing after a line's last letter, so
+ there the status chip stays where it was (9.21 → 9.26pt); mid-line it sets it as Word does.
+ Not written, and so reported:
+ - the left padding of a chip that opens its line or follows a picture;
+ - any space after right-to-left letters: in a right-to-left paragraph, and for Hebrew or
+ Arabic in a left-to-right one;
+ - any space after a symbol or an emoji, which Java 17 does not keep whole.
+
+ The export report says, chip by chip, how each side was written, in place of the old "its
+ padding is not in the file".
- **Enter in a DOCX continues the text it follows.** Word gives a new paragraph its mark's
formatting, and the export left the mark of most paragraphs — every one written at an exact
line height — in the document's own size and face. Pressing Enter at the end of a paragraph
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index f46022498..f77e1cd00 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -64,7 +64,7 @@ Payload records live in `core` under
|---|---|---|---|
| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a centred or right-aligned left-to-right line of its own, of text alone and untracked, that Word sets a point or more wider or narrower at its half-point size has its letters spaced by the difference (`w:spacing`) and its room reckoned from the page's width; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ⚠️ the top level only. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — or, with rich items or a drawn marker, as paragraphs; content and nesting are unaffected. With the flag, the top level's marker column is the layout's — the marker's width and `markerGap`, the text and its wrapped lines where the page sets them — where the gap covers what Word may set the marker wider: a picture at its written size, its edges included, or text in the page's face (embedded, or a standard one Word sets in the same widths) grown to its half-point size, half a point clear. A Word list's level then indents and hangs by that column; a list of paragraphs writes the marker, a tab to a stop there, and hangs the item there. Word places content at absolute indents and has no relative-advance primitive, so without the layout's measure the gap could not be honoured; a Word list without the flag that the layout placed and that does not nest takes the page's column too, the spaces the page sets its wrapped lines after, its marker followed by a space (`w:suff`) and an item that wraps measured at Word's half-point size; a list that nests items, a list built as a tree of items (laid out flattened), and a marker the gap does not clear keep the stated column (180 twips, plus 120 per nesting level) — except, in a list of paragraphs, a nested rich item with no marker, which stands where the layout set its text, its measure weighed at Word's half-point sizes, where the layout's items are matched to the list's; a list that nests only such items sets its top level at the page's column too |
-| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
+| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding above and below the letters are not in the file, and the export records them. The padding beside the letters is written as the room it takes (`spaceAfterTheLastLetter`): character spacing after the chip's last letter, shaded with it, and after the letter before the chip, unshaded. A chip opening its line or following a picture has no letter before it, so its left padding is not in the file; no space is written after right-to-left letters or after a symbol or emoji. The export records, chip by chip, how each side was written. LibreOffice sets no spacing after a line's last letter, so it does not apply the right padding of a chip that ends a line. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
| Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space) |
| Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character, reported `APPROXIMATED`) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index eeeafb2db..1f881dfde 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -589,11 +589,29 @@ page.addParagraph(p -> p
What Word has no way to say is the chip's *shape*. Shading covers the glyph box, so:
- **The corner radius** is square in Word.
-- **The padding** that widens the run on the page is not in the file, so the fill hugs
- the glyphs and the line is fractionally shorter than the PDF's.
-
-Both are recorded as `APPROXIMATED` in the export report, per chip, so a caller can see
-which phrase lost what.
+- **The padding beside the letters** is written as the room it takes. It becomes character
+ spacing after the chip's last letter, which Word shades with the chip, and after the letter
+ before the chip, which it does not — or, where that letter ends another chip, does in that
+ chip's fill. The text after the chip then stands where the page puts it, and a
+ right-aligned or centred line holding one starts where the page starts it. The chip's fill
+ shows its padding on the right only. There are exceptions:
+ - A chip that opens its line, or that follows a picture, has no letter before it, so its
+ left padding is not in the file.
+ - No space is written after right-to-left letters, so a right-to-left paragraph has
+ neither side in the file, and Hebrew or Arabic in a left-to-right one neither. In run
+ order such a letter stands at the word's left, and an Arabic one in a run of its own would
+ lose its join.
+ - No space is written after a symbol or an emoji. Java 17 does not keep a flag or a joined
+ emoji sequence whole, and a run boundary inside one draws it as its parts.
+ - LibreOffice sets no spacing after the last letter of a line, so a chip ending a
+ right-aligned line has its letters its right padding further right there than in Word.
+ One ending a centred line should be off by half that, though only the right-aligned case
+ is measured.
+- **The padding above and below** the letters is not in the file.
+
+What is lost is recorded as `APPROXIMATED` in the export report, per chip, so a caller can
+see which phrase lost what — down to whether its left padding is unshaded space, in another
+chip's fill, or not in the file.
A `w:shd` fill is opaque, so a translucent chip — `inlineCode(...)` is a fifth-opacity
grey — is flattened first against what the export wrote underneath it: the paragraph's own
@@ -602,7 +620,7 @@ a solid slab where the page has a tint; flattened, it is the colour the PDF show
chip agrees with the file it is in rather than with the page the PDF drew — a translucent
*container* fill lands opaque too, and a chip on it composites over that. And the chip
stops being translucent: shade that paragraph another colour in Word and it keeps the
-tint it was flattened to. Recorded, like the other two.
+tint it was flattened to. That is recorded with the rest.
## What falls back
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
index 1d05dd45d..fa10fc93c 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -3974,7 +3974,7 @@ receipt-modern 0:465691680b99 0.03 0.10 £66.62
receipt-modern 0:465691680b99#1 0.29 -0.90 £66.62
receipt-modern 0:465691680b99#2 0.29 -0.90 £66.62
receipt-modern 0:4260e5ae365d 0.20 0.10 directdebitcollectedbyharbourfin
-receipt-modern 0:4ddb3e96801a 0.18 9.21 completed
+receipt-modern 0:4ddb3e96801a 0.18 9.26 completed
receipt-modern 0:a22e6dacae05 0.24 0.19 valuedate07jul2026
receipt-modern 0:53becbb168d1 0.24 0.18 operationdate07jul2026
receipt-modern 0:b311cb4017a4 0.24 0.25 schemebacsdirectdebit
diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
index 600da53a7..4a986179e 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -4078,7 +4078,7 @@ receipt-modern 0:465691680b99 0.17 0.04 £66.62
receipt-modern 0:465691680b99#1 0.35 -0.92 £66.62
receipt-modern 0:465691680b99#2 0.34 -0.92 £66.62
receipt-modern 0:4260e5ae365d 0.24 0.04 directdebitcollectedbyharbourfin
-receipt-modern 0:4ddb3e96801a 0.21 9.16 completed
+receipt-modern 0:4ddb3e96801a 0.21 0.16 completed
receipt-modern 0:a22e6dacae05 0.29 0.05 valuedate07jul2026
receipt-modern 0:53becbb168d1 0.32 0.12 operationdate07jul2026
receipt-modern 0:b311cb4017a4 0.35 0.03 schemebacsdirectdebit
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 748a5e5ba..db984e6c8 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
@@ -3436,7 +3436,8 @@ private PictureReach writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle s
List runs, String path,
java.util.Optional line) {
PictureReach pictures = PictureReach.NONE;
- for (InlineRun run : runs) {
+ for (int index = 0; index < runs.size(); index++) {
+ InlineRun run = runs.get(index);
InlineTextRun text = textOf(run);
if (text == null) {
pictures = pictures.max(writeInlinePicture(para, run, null, path, line));
@@ -3447,8 +3448,9 @@ private PictureReach writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle s
// a link in a sentence and dead text in a bullet.
XWPFRun docRun = newRun(para, text.linkTarget());
applyStyle(docRun, text.textStyle() == null ? style : text.textStyle());
- applyInlineBackground(docRun, backgroundOf(run), path);
+ applyInlineBackground(docRun, runs, index, path, false);
setTextBrokenAtLines(docRun, text.text());
+ spaceAfterTheLastLetter(para, docRun, text.text(), roomAfter(runs, index));
}
return pictures;
}
@@ -5272,7 +5274,10 @@ private static double widthAtWordsSize(com.demcha.compose.document.layout.payloa
if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run
&& run.textStyle() != null && run.textStyle().size() > 0) {
double tracking = run.textStyle().letterSpacing() * run.text().codePointCount(0, run.text().length());
- set += (span.width() - tracking) * wordsSize(run.textStyle().size()) / run.textStyle().size() + tracking;
+ // A chip's padding is written as a distance (spaceAfterTheLastLetter), not grown.
+ double padding = run.background() == null ? 0 : run.background().padding().horizontal();
+ set += (span.width() - tracking - padding) * wordsSize(run.textStyle().size()) / run.textStyle().size()
+ + tracking + padding;
} else {
set += span.width();
}
@@ -5892,7 +5897,8 @@ private void setTheLineAsWideAsThePage(XWPFParagraph para, ParagraphNode node) {
for (XWPFRun run : runs) {
String text = run.text();
if (text != null && !text.isEmpty()) {
- run.setCharacterSpacing(twentieths);
+ // Added to a chip's padding where its last letter carries it.
+ run.setCharacterSpacing(run.getCharacterSpacing() + twentieths);
}
}
}
@@ -6445,7 +6451,9 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
XWPFRun textBefore = null;
java.util.Map givenBack = new java.util.LinkedHashMap<>();
java.util.Map lettersOf = new java.util.HashMap<>();
- for (InlineRun run : node.inlineRuns()) {
+ List inlineRuns = node.inlineRuns();
+ for (int index = 0; index < inlineRuns.size(); index++) {
+ InlineRun run = inlineRuns.get(index);
InlineTextRun text = textOf(run);
if (text == null) {
PictureReach reach = writeInlinePicture(para, run, node.linkTarget(), path, line);
@@ -6473,14 +6481,17 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
markStyle = backgroundOf(run) != null ? node.textStyle() : runStyle;
applyStyle(docRun, runStyle);
applyRunDirection(docRun, rightToLeft);
- applyInlineBackground(docRun, backgroundOf(run), path);
+ applyInlineBackground(docRun, inlineRuns, index, path, rightToLeft);
setTextBrokenAtLines(docRun, text.text());
if (widthOwed > 0) {
givenBack.merge(docRun, widthOwed, Double::sum);
}
widthOwed = 0;
- textBefore = docRun;
- lettersOf.put(docRun, text.text());
+ XWPFRun lastLetter = spaceAfterTheLastLetter(para, docRun, text.text(),
+ rightToLeft ? 0 : roomAfter(inlineRuns, index));
+ lettersOf.put(docRun, docRun.text());
+ lettersOf.put(lastLetter, lastLetter.text());
+ textBefore = lastLetter;
wroteARun = true;
}
givenBack.forEach((textRun, room) -> giveBackWidth(textRun, lettersOf.get(textRun), room));
@@ -6800,6 +6811,142 @@ private static void giveBackWidth(XWPFRun run, String text, double points) {
}
}
+ /**
+ * The room the page leaves after a text run's letters for a chip's padding, in points: the
+ * run's own right padding where it is a chip, and the left padding of a chip written next.
+ */
+ private static double roomAfter(List runs, int index) {
+ double room = 0;
+ InlineBackground own = backgroundOf(runs.get(index));
+ if (own != null) {
+ room += Math.max(0, own.padding().right());
+ }
+ if (index + 1 < runs.size() && textOf(runs.get(index + 1)) != null) {
+ InlineBackground next = backgroundOf(runs.get(index + 1));
+ if (next != null) {
+ room += Math.max(0, next.padding().left());
+ }
+ }
+ return room;
+ }
+
+ /**
+ * How a chip's left padding is written ({@link #spaceAfterTheLastLetter}), for the export
+ * report: as unshaded space after the text before it, as space in the fill of a chip right
+ * before it, or — null — not at all, where no text the space can follow comes before it.
+ */
+ private static String leftPaddingAs(List runs, int index, boolean rightToLeft) {
+ InlineTextRun before = rightToLeft || index == 0 ? null : textOf(runs.get(index - 1));
+ if (before == null || !takesSpaceAfter(before.text())) {
+ return null;
+ }
+ return backgroundOf(runs.get(index - 1)) != null
+ ? "space after the chip before it, in that chip's fill"
+ : "unshaded space after the text before it";
+ }
+
+ /** Whether a run's text takes a space after its last letter ({@link #lastSpaceableLetter}). */
+ private static boolean takesSpaceAfter(String text) {
+ String[] lines = LINE_BREAK.split(text == null ? "" : text, -1);
+ return lastSpaceableLetter(lines[lines.length - 1]) >= 0;
+ }
+
+ /**
+ * Where a line's last letter starts, its marks with it, when a space after it can be written;
+ * -1 when it cannot. Not after an empty line. Not in a line holding right-to-left letters:
+ * in a left-to-right paragraph the last of them in the run's order stands at their left, and
+ * an Arabic one set in a run of its own would lose its join. Not after a symbol or an emoji:
+ * the JDK 17 baseline's {@link java.text.BreakIterator} does not keep a flag or a joined
+ * emoji sequence whole, and a run boundary inside one draws it as its parts.
+ */
+ private static int lastSpaceableLetter(String line) {
+ if (line.isEmpty() || line.codePoints().anyMatch(DocxSemanticBackend::isRightToLeftLetter)) {
+ return -1;
+ }
+ java.text.BreakIterator characters = java.text.BreakIterator.getCharacterInstance();
+ characters.setText(line);
+ int last = Math.max(0, characters.preceding(line.length()));
+ if (last > 0 && isPartOfAnEmoji(line.codePointBefore(last))
+ || line.substring(last).codePoints().anyMatch(DocxSemanticBackend::isPartOfAnEmoji)) {
+ return -1;
+ }
+ return last;
+ }
+
+ private static boolean isRightToLeftLetter(int codePoint) {
+ byte direction = Character.getDirectionality(codePoint);
+ return direction == Character.DIRECTIONALITY_RIGHT_TO_LEFT
+ || direction == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC;
+ }
+
+ /** A symbol, or a joiner, selector, skin tone or flag letter an emoji sequence is built of. */
+ private static boolean isPartOfAnEmoji(int codePoint) {
+ return Character.getType(codePoint) == Character.OTHER_SYMBOL
+ || codePoint == 0x200D
+ || codePoint >= 0xFE00 && codePoint <= 0xFE0F
+ || codePoint >= 0x1F3FB && codePoint <= 0x1F3FF
+ || codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF;
+ }
+
+ /**
+ * Spaces a run's last letter from what follows it by the room a chip's padding takes.
+ *
+ * A chip's horizontal padding widens its run on the page, and Word's run shading covers
+ * the letters alone, so the padding was not in the file. {@code ModernReceipt}'s status
+ * chip, right-aligned with 9pt of padding on each side, stood 9.16pt right of the page's in
+ * Word. The room is written where Word can hold it: as character spacing
+ * ({@code w:spacing}) after the last letter, which Word sets after every letter of a run
+ * and shades with it — so a chip's right padding is shaded as on the page — and after the
+ * last letter of the text before a chip, for its left padding, which is unshaded, or in the
+ * fill of a chip right before it. The last letter — the last character as a reader sees one,
+ * its accents with it — goes in a run of its own with the same properties, inside the same
+ * link, so the letters before it keep their spacing and a linked phrase stays one link; in
+ * text broken over lines, the last letter of its last line. No space is written where there
+ * is no such letter ({@link #lastSpaceableLetter}): a chip opening its paragraph or its
+ * line, or following a picture, keeps its left padding out of the file, and so does a
+ * right-to-left paragraph both sides, its letters spaced in the run's order rather than
+ * toward the side the padding stands on. {@link #chipLost} says which. LibreOffice sets no
+ * spacing after the last letter of a line, so there the status chip, which ends its line,
+ * stands where it did; mid-line it sets it as Word does.
+ *
+ * @param points the room after the run's letters, in points
+ * @return the run holding the last letter: {@code run} itself unless it was split
+ */
+ private static XWPFRun spaceAfterTheLastLetter(XWPFParagraph para, XWPFRun run, String text, double points) {
+ int twentieths = (int) Math.round(points * 20);
+ // The run's last line, which setTextBrokenAtLines wrote as its last w:t.
+ String[] lines = LINE_BREAK.split(text == null ? "" : text, -1);
+ String line = lines[lines.length - 1];
+ int last = lastSpaceableLetter(line);
+ if (twentieths <= 0 || last < 0) {
+ return run;
+ }
+ XWPFRun lastLetter = run;
+ if (last > 0 || lines.length > 1) {
+ run.setText(line.substring(0, last), lines.length - 1);
+ lastLetter = runAfter(para, run);
+ if (run.getCTR().isSetRPr()) {
+ lastLetter.getCTR().setRPr(run.getCTR().getRPr());
+ }
+ lastLetter.setText(line.substring(last), 0);
+ }
+ lastLetter.setCharacterSpacing(lastLetter.getCharacterSpacing() + twentieths);
+ return lastLetter;
+ }
+
+ /**
+ * A new run straight after the last one written, inside the same link where that is in
+ * one: a second {@code w:hyperlink} would make one linked phrase two links.
+ */
+ private static XWPFRun runAfter(XWPFParagraph para, XWPFRun run) {
+ try (org.apache.xmlbeans.XmlCursor cursor = run.getCTR().newCursor()) {
+ if (cursor.toParent() && cursor.getObject() instanceof CTHyperlink link) {
+ return new XWPFRun(link.addNewR(), (IRunBody) para);
+ }
+ }
+ return para.createRun();
+ }
+
/** A line break in text, as the page breaks lines at it (see {@code ParagraphWrapping}). */
private static final java.util.regex.Pattern LINE_BREAK = java.util.regex.Pattern.compile("\r\n|\r|\n");
@@ -7215,10 +7362,14 @@ private static InlineBackground backgroundOf(InlineRun run) {
* follows.
*
* What Word cannot express is the chip's shape. Shading covers the glyph
- * box, so the rounded corners and the padding that widens the run on the page are not
- * in the file. All three are recorded rather than quietly approximated.
- */
- private void applyInlineBackground(XWPFRun run, InlineBackground background, String path) {
+ * box, so the rounded corners are not in the file, nor is the padding above and below the
+ * letters. The room the padding takes beside them is written as space after a letter
+ * ({@link #spaceAfterTheLastLetter}), shaded on the chip's right only. What is lost is
+ * recorded rather than quietly approximated.
+ */
+ private void applyInlineBackground(XWPFRun run, List runs, int index, String path,
+ boolean rightToLeft) {
+ InlineBackground background = backgroundOf(runs.get(index));
if (background == null) {
return;
}
@@ -7232,7 +7383,8 @@ private void applyInlineBackground(XWPFRun run, InlineBackground background, Str
shading.setVal(STShd.CLEAR);
shading.setColor("auto");
shading.setFill(toHexColor(flatten(background.fill().color(), colourUnder(run))));
- String lost = chipLost(background);
+ String lost = chipLost(background, leftPaddingAs(runs, index, rightToLeft),
+ !rightToLeft && takesSpaceAfter(textOf(runs.get(index)).text()));
if (lost != null) {
if (warnedNodeKinds.add("inline-background")) {
LOG.warn("DocxSemanticBackend: an inline chip keeps its fill as run shading, "
@@ -7243,14 +7395,30 @@ private void applyInlineBackground(XWPFRun run, InlineBackground background, Str
}
}
- /** What a chip loses on the way to run shading, or null when the mapping is exact. */
- private static String chipLost(InlineBackground background) {
+ /**
+ * What a chip loses on the way to run shading, or null when the mapping is exact.
+ *
+ * Its padding beside its letters is written as space after a letter
+ * ({@link #spaceAfterTheLastLetter}) where one can take it, said chip by chip.
+ *
+ * @param leftAs how its left padding is written, or null where it is not
+ * @param rightWritten whether its right padding is written
+ */
+ private static String chipLost(InlineBackground background, String leftAs, boolean rightWritten) {
List lost = new ArrayList<>(3);
if (background.cornerRadius() > 0) {
lost.add("its rounded corners are square");
}
- if (background.padding().horizontal() > 0 || background.padding().vertical() > 0) {
- lost.add("its padding is not in the file");
+ if (background.padding().left() > 0) {
+ lost.add(leftAs == null
+ ? "its left padding is not in the file"
+ : "its left padding is " + leftAs);
+ }
+ if (background.padding().right() > 0 && !rightWritten) {
+ lost.add("its right padding is not in the file");
+ }
+ if (background.padding().vertical() > 0) {
+ lost.add("its padding above and below its letters is not in the file");
}
if (background.fill().color().getAlpha() < 255) {
// The colour on the page is right. What is gone is the translucency itself:
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java
index 868d9837d..bb8a0e57c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxInlineBackgroundTest.java
@@ -36,9 +36,10 @@
* part of itself that was doing the talking.
*
* Word shades a run with {@code w:shd}, which takes any RGB. What it cannot express is
- * the chip's shape: shading covers the glyph box, so the rounded corners and the padding go
- * and the export says so. A {@code w:shd} fill is also opaque, so a translucent chip is
- * flattened against what Word paints beneath it first.
+ * the chip's shape: shading covers the glyph box, so the rounded corners go and the export
+ * says so. The room its padding takes beside its letters is written as character spacing
+ * after a letter, shaded on the chip's right only. A {@code w:shd} fill is also opaque, so a
+ * translucent chip is flattened against what Word paints beneath it first.
*
* @author Artem Demchyshyn
*/
@@ -157,6 +158,269 @@ void aChipKeepsItsTextAndItsStyle() throws Exception {
}
}
+ @Test
+ void aChipsRightPaddingIsSpaceAfterItsLastLetterInsideItsShading() throws Exception {
+ // ModernReceipt's status chip, right-aligned with 9pt of padding on each side, stood
+ // 9.2pt right of the page's in Word: shading covers the letters and nothing more.
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineHighlight("Completed", DocumentTextStyle.DEFAULT, BADGE,
+ 9, new DocumentInsets(3.5, 9, 3.5, 9))
+ .align(com.demcha.compose.document.node.TextAlign.RIGHT)))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("Complete", "d");
+ assertThat(runs.get(1).getCharacterSpacing() - runs.get(0).getCharacterSpacing())
+ .as("the last letter is 9pt further from what follows, in twentieths")
+ .isEqualTo(180);
+ assertThat(runs).extracting(DocxInlineBackgroundTest::fillOf)
+ .as("both shaded, so the room is inside the chip")
+ .containsExactly("D63838", "D63838");
+ }
+ }
+
+ @Test
+ void aChipsLeftPaddingIsSpaceAfterTheLetterBeforeIt() throws Exception {
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineText("Status:")
+ .inlineHighlight("due", DocumentTextStyle.DEFAULT, BADGE,
+ 0, new DocumentInsets(0, 0, 0, 6))
+ .inlineText(" today")))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("Status", ":", "due", " today");
+ assertThat(runs.get(1).getCharacterSpacing()).as("6pt after the colon").isEqualTo(120);
+ assertThat(fillOf(runs.get(1))).as("outside the chip, so not shaded").isNull();
+ assertThat(runs.get(2).getCharacterSpacing()).as("no right padding, nothing after it").isZero();
+ assertThat(document.getParagraphs().get(0).getText()).isEqualTo("Status:due today");
+ }
+ }
+
+ @Test
+ void twoChipsSideBySideAreSpacedByBothTheirPaddings() throws Exception {
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineHighlight("one", DocumentTextStyle.DEFAULT, BADGE,
+ 0, new DocumentInsets(0, 2, 0, 0))
+ .inlineHighlight("two", DocumentTextStyle.DEFAULT, BADGE,
+ 0, new DocumentInsets(0, 0, 0, 3))))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("on", "e", "two");
+ assertThat(runs.get(1).getCharacterSpacing()).as("2pt + 3pt").isEqualTo(100);
+ }
+ }
+
+ @Test
+ void aChipInAListItemIsSpacedByItsPadding() throws Exception {
+ try (XWPFDocument document = exported(page -> page
+ .addList(list -> list
+ .bullet()
+ .hangingIndent(true)
+ .addItem(rich -> rich.plain("Invoice ").highlight("overdue",
+ DocumentTextStyle.DEFAULT, BADGE, 0, new DocumentInsets(0, 4, 0, 0)))))) {
+
+ XWPFRun head = runReading(document, "overdue");
+ assertThat(head.text()).isEqualTo("overdu");
+ XWPFParagraph item = (XWPFParagraph) head.getParent();
+ XWPFRun last = item.getRuns().get(item.getRuns().indexOf(head) + 1);
+ assertThat(last.getCharacterSpacing()).isEqualTo(80);
+ }
+ }
+
+ @Test
+ void aCentredLinesLettersAreSpacedOnTopOfAChipsPadding() throws Exception {
+ // 8.3pt is set at 8.5 in Word, so a centred line of it is spaced back to the page's
+ // width; that spacing goes on every letter, the padding's on the last one too.
+ DocumentTextStyle small = DocumentTextStyle.builder().size(8.3).build();
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineHighlight("Settled by the beneficiary bank today", small, BADGE,
+ 0, new DocumentInsets(0, 5, 0, 0))
+ .align(com.demcha.compose.document.node.TextAlign.CENTER)))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).hasSize(2);
+ assertThat(runs.get(0).getCharacterSpacing()).as("the line is spaced to the page's").isNotZero();
+ assertThat(runs.get(1).getCharacterSpacing() - runs.get(0).getCharacterSpacing())
+ .isEqualTo(100);
+ }
+ }
+
+ @Test
+ void aChipWithNoPaddingStaysOneRun() throws Exception {
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineText("Status: ")
+ .inlineHighlight("due", DocumentTextStyle.DEFAULT, BADGE, 0, DocumentInsets.zero())
+ .inlineText(" today")))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("Status: ", "due", " today");
+ assertThat(runs).extracting(XWPFRun::getCharacterSpacing).containsOnly(0);
+ }
+ }
+
+ @Test
+ void aLinkedPaddedChipIsOneLink() throws Exception {
+ // The last letter's run goes in the link its letters are in: a second w:hyperlink would
+ // make the phrase two links to a reader and to Word's Edit Hyperlink.
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p.inlineHighlight("Docs", DocumentTextStyle.DEFAULT,
+ BADGE, 0, new DocumentInsets(0, 4, 0, 0),
+ new DocumentLinkOptions("https://graphcompose.dev"))))) {
+
+ var links = document.getParagraphs().get(0).getCTP().getHyperlinkList();
+ assertThat(links).hasSize(1);
+ assertThat(links.get(0).sizeOfRArray()).isEqualTo(2);
+ assertThat(document.getParagraphs().get(0).getText()).isEqualTo("Docs");
+ }
+ }
+
+ @Test
+ void theLastLetterKeepsItsAccent() throws Exception {
+ // A decomposed é is two code points, one letter: split between them, the accent would
+ // be shaped in a run of its own.
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p.inlineHighlight("Resumé", DocumentTextStyle.DEFAULT,
+ BADGE, 0, new DocumentInsets(0, 4, 0, 0))))) {
+
+ assertThat(document.getParagraphs().get(0).getRuns())
+ .extracting(XWPFRun::text).containsExactly("Resum", "é");
+ }
+ }
+
+ @Test
+ void aChipsPaddingIsNotGrownToWordsSize() throws Exception {
+ // 8.3pt letters are set at 8.5 in Word, and the line is spaced back to the page's
+ // width when it grows a point or more. The padding is written in points, not grown:
+ // reckoned at Word's size, 80pt of it alone would have grown the line 1.9pt.
+ DocumentTextStyle small = DocumentTextStyle.builder().size(8.3).build();
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineHighlight("Paid", small, BADGE, 0, new DocumentInsets(0, 40, 0, 40))
+ .align(com.demcha.compose.document.node.TextAlign.CENTER)))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("Pai", "d");
+ assertThat(runs.get(0).getCharacterSpacing()).as("four letters grow under a point").isZero();
+ assertThat(runs.get(1).getCharacterSpacing()).isEqualTo(800);
+ }
+ }
+
+ @Test
+ void aChipInAListItemTakesItsLeftPaddingAfterTheTextBeforeIt() throws Exception {
+ try (XWPFDocument document = exported(page -> page
+ .addList(list -> list
+ .bullet()
+ .hangingIndent(true)
+ .addItem(rich -> rich.plain("Invoice:").highlight("overdue",
+ DocumentTextStyle.DEFAULT, BADGE, 0, new DocumentInsets(0, 0, 0, 6)))))) {
+
+ XWPFRun colon = runReading(document, ":");
+ assertThat(colon.getCharacterSpacing()).isEqualTo(120);
+ }
+ }
+
+ @Test
+ void whatAChipsPaddingLosesIsSaidChipByChip() throws Exception {
+ DocumentInsets sides = new DocumentInsets(0, 4, 0, 4);
+ assertThat(chipNotes(page -> page.addParagraph(p -> p
+ .inlineText("Status:")
+ .inlineHighlight("due", DocumentTextStyle.DEFAULT, BADGE, 0, sides))))
+ .as("after text: the left side is unshaded space, the right is written")
+ .containsExactly("the fill is written as run shading; its left padding is unshaded "
+ + "space after the text before it");
+ assertThat(chipNotes(page -> page.addParagraph(p -> p
+ .inlineHighlight("due", DocumentTextStyle.DEFAULT, BADGE, 0, sides))))
+ .as("opening its line, nothing comes before it to take its left padding")
+ .containsExactly("the fill is written as run shading; its left padding is not in the file");
+ assertThat(chipNotes(page -> page.addParagraph(p -> p
+ .inlineHighlight("Java", DocumentTextStyle.DEFAULT, BADGE, 0, DocumentInsets.zero())
+ .inlineHighlight("Kotlin", DocumentTextStyle.DEFAULT, SURFACE, 0,
+ new DocumentInsets(0, 0, 0, 4)))))
+ .as("after a chip, its left padding takes that chip's fill")
+ .containsExactly("the fill is written as run shading; its left padding is space "
+ + "after the chip before it, in that chip's fill");
+ assertThat(chipNotes(page -> page.addParagraph(p -> p
+ .direction(TextDirection.RTL)
+ .inlineText("סטטוס:")
+ .inlineHighlight("דחוף", DocumentTextStyle.DEFAULT, BADGE, 0, sides))))
+ .containsExactly("the fill is written as run shading; its left padding is not in "
+ + "the file, its right padding is not in the file");
+ assertThat(chipNotes(page -> page.addParagraph(p -> p
+ .inlineHighlight("due", DocumentTextStyle.DEFAULT, BADGE, 0,
+ new DocumentInsets(0, 4, 0, 0)))))
+ .as("a right padding written is no loss")
+ .isEmpty();
+ }
+
+ @Test
+ void rightToLeftLettersInALeftToRightLineAreNotSpaced() throws Exception {
+ // In run order the last Hebrew letter stands at the left of the word, and an Arabic one
+ // set in a run of its own would lose its join: no space is written after either.
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineText("שלום")
+ .inlineHighlight("דחוף", DocumentTextStyle.DEFAULT, BADGE, 0,
+ new DocumentInsets(0, 4, 0, 4))
+ .inlineText(" then")))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("שלום", "דחוף", " then");
+ assertThat(runs).extracting(XWPFRun::getCharacterSpacing).containsOnly(0);
+ }
+ }
+
+ @Test
+ void aFlagEndingAChipIsNotCutInTwo() throws Exception {
+ // Java 17 splits a flag's two letters; a run boundary between them draws two letters.
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p.inlineHighlight("Ship 🇬🇧",
+ DocumentTextStyle.DEFAULT, BADGE, 0, new DocumentInsets(0, 4, 0, 0))))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).hasSize(1);
+ assertThat(runs.get(0).getCharacterSpacing()).isZero();
+ }
+ }
+
+ private static List chipNotes(Consumer content) throws Exception {
+ List notes = reportOf(content).bySubject().get("inline chip");
+ return notes == null ? List.of() : notes.stream().map(DocxExportReport.Note::detail).toList();
+ }
+
+ @Test
+ void textBrokenOverLinesBeforeAChipSpacesTheLastLetterOfItsLastLine() throws Exception {
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .inlineText("Invoice 42\nStatus:")
+ .inlineHighlight("due", DocumentTextStyle.DEFAULT, BADGE,
+ 0, new DocumentInsets(0, 0, 0, 6))))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("Invoice 42\nStatus", ":", "due");
+ assertThat(runs.get(1).getCharacterSpacing()).isEqualTo(120);
+ }
+ }
+
+ @Test
+ void aChipInARightToLeftParagraphIsNotSpaced() throws Exception {
+ // Spacing goes after a letter in the run's order, which is not the side the
+ // padding stands on in a right-to-left line.
+ try (XWPFDocument document = exported(page -> page
+ .addParagraph(p -> p
+ .direction(TextDirection.RTL)
+ .inlineHighlight("דחוף", DocumentTextStyle.DEFAULT, BADGE,
+ 0, new DocumentInsets(0, 4, 0, 4))))) {
+
+ List runs = document.getParagraphs().get(0).getRuns();
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("דחוף");
+ assertThat(runs.get(0).getCharacterSpacing()).isZero();
+ }
+ }
+
@Test
void aLinkedChipIsStillALink() throws Exception {
// The chip and the link are written by different parts of the run path, and the
@@ -203,7 +467,8 @@ void theShapeAChipLosesIsRecorded() throws Exception {
assertThat(note.detail())
.as("the fill is written; what goes is the shape around it")
.contains("rounded corners")
- .contains("padding");
+ .contains("left padding")
+ .contains("above and below");
}
@Test
@@ -280,11 +545,18 @@ private static String fillOf(XWPFRun run) {
: null;
}
+ /**
+ * The run reading {@code text}, or the first of two that do: a padded chip's last letter is
+ * a run of its own.
+ */
private static XWPFRun runReading(XWPFDocument document, String text) {
for (XWPFParagraph para : everyParagraph(document)) {
- for (XWPFRun run : para.getRuns()) {
- if (text.equals(run.text())) {
- return run;
+ List runs = para.getRuns();
+ for (int index = 0; index < runs.size(); index++) {
+ String read = runs.get(index).text();
+ if (text.equals(read)
+ || index + 1 < runs.size() && text.equals(read + runs.get(index + 1).text())) {
+ return runs.get(index);
}
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java
index 397e7b67e..8cc595ccd 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java
@@ -256,7 +256,8 @@ void aChipOverAStripeInsideACardIsFlattenedAgainstTheStripe() throws Exception {
XWPFTableCell striped = card(document).getTables().get(0).getRow(0).getCell(0);
XWPFRun chip = striped.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream()
.flatMap(p -> p.getRuns().stream())
- .filter(run -> "render()".equals(run.text()))
+ // The chip's last letter is a run of its own, spaced by its padding.
+ .filter(run -> "render(".equals(run.text()))
.findFirst()
.orElseThrow();
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
index 4365f9ad1..c18e98955 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
@@ -105,7 +105,9 @@ void aCodeChipCarriesItsOwnGlyphStyleRatherThanTheParagraphs() throws Exception
.inlineText("call ")
.inlineCode("run()")));
- assertThat(runs).hasSize(2);
+ // The chip's padding is space after the letter before it and after its own last
+ // letter, each of those letters a run of its own.
+ assertThat(runs).extracting(XWPFRun::text).containsExactly("call", " ", "run(", ")");
// The surrounding text is the document's own body style, so its face comes from
// Normal and the run says nothing — that is what lets a reader restyle the
// document. The claim this test makes is about the chip, and it is unchanged: the
@@ -113,8 +115,8 @@ void aCodeChipCarriesItsOwnGlyphStyleRatherThanTheParagraphs() throws Exception
assertThat(runs.get(0).getFontFamily())
.describedAs("plain text takes its face from the Normal style")
.isNull();
- assertThat(runs.get(1).getText(0)).isEqualTo("run()");
- assertThat(runs.get(1).getFontFamily()).isEqualTo("Courier");
+ assertThat(runs.get(2).getFontFamily()).isEqualTo("Courier");
+ assertThat(runs.get(3).getFontFamily()).isEqualTo("Courier");
}
@Test