flattened = new ArrayList<>(2);
+ if (fill) {
+ flattened.add(fillName);
+ }
+ if (strokes) {
+ flattened.add(strokeName);
+ }
+ if (!flattened.isEmpty()) {
+ // "its fill" is one; "its borders", "its cells' fills" and two of them are more.
+ boolean plural = flattened.size() > 1 || flattened.get(0).endsWith("s");
+ report.add(DocxExportReport.Severity.APPROXIMATED, TRANSLUCENCY, layout.pathOf(node),
+ String.join(" and ", flattened) + (plural ? " are" : " is") + " flattened against the colour "
+ + "under " + (plural ? "them" : "it") + ", because a Word cell's shading and borders are opaque");
+ }
+ }
+
/**
* Writes a panel's borders on its cell, side by side; a side the panel does not draw is
* stated as none, so the cell carries no border the page does not show.
@@ -5171,6 +5237,18 @@ private static void writeHeadingStyle(CTStyles styles, int level) {
}
private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults) {
+ applyDefaultRunProperties(properties, defaults, false);
+ }
+
+ /**
+ * Writes a style's font, size and colour on run properties: the document's defaults, the
+ * Normal style's, or a list level's for its marker.
+ *
+ * @param overATranslucentStyle whether these properties sit over a Normal style whose colour is
+ * translucent, which an opaque colour here must override in full
+ */
+ private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults,
+ boolean overATranslucentStyle) {
if (defaults.fontName() != null) {
// All four slots, exactly as XWPFRun.setFontFamily writes them on a run.
// w:ascii alone covers only ASCII: High-ANSI characters read w:hAnsi, Hebrew
@@ -5192,9 +5270,18 @@ private void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defau
}
if (defaults.color() != null) {
properties.addNewColor().setVal(toHexColor(defaults.color().color()));
+ // Both editors take a text fill's transparency from the style a run follows.
+ textFillsWritten |= DocxTranslucency.writeTextAlpha(properties, defaults.color().color(),
+ overATranslucentStyle);
}
}
+ /** Whether the Normal style's colour is translucent, so its text fill is what a run follows. */
+ private boolean normalIsTranslucent() {
+ return documentDefaultStyle != null && documentDefaultStyle.color() != null
+ && documentDefaultStyle.color().color().getAlpha() < 255;
+ }
+
/**
* The family name to write for a style's font, as Word understands families.
*
@@ -7669,6 +7756,9 @@ private void styleTheMark(XWPFParagraph target, DocumentTextStyle style) {
mark.setSzArray(text.getSzArray());
mark.setSzCsArray(text.getSzCsArray());
mark.setUArray(text.getUArray());
+ // A list's marker is drawn in the mark's style where its level states none — a nested
+ // level, a list the layout did not measure — so the mark takes the text's fill too.
+ DocxTranslucency.copyTextFill(text, mark);
}
/** Whether a paragraph's lines are written at an exact height, which no mark changes. */
@@ -8890,10 +8980,10 @@ private static InlineBackground backgroundOf(InlineRun run) {
* A {@code w:shd} fill is opaque, and the chip this sugar reaches for most —
* {@code code(...)} — is a fifth-opacity grey. Written at full strength it is a solid
* slab where the page has a tint, so a translucent fill is flattened first against what
- * Word paints underneath it: the paragraph's own shading, the cell's, or the page. The
- * chip then agrees with the file it is in — including where that file already differs
- * from the page, since a translucent container fill lands opaque too. What it
- * stops being is translucent: recoloured underneath in Word, the chip no longer
+ * Word paints underneath it: the paragraph's own shading, the cell's, or else what the page
+ * paints under the paragraph ({@link #colourUnder(XWPFRun, String)}). The chip then agrees
+ * with the file it is in, a translucent container fill under it flattened too. What
+ * it stops being is translucent: recoloured underneath in Word, the chip no longer
* follows.
*
* What Word cannot express is the chip's shape. Shading covers the glyph
@@ -8917,7 +9007,8 @@ private void applyInlineBackground(XWPFRun run, List runs, int index,
: properties.addNewShd();
shading.setVal(STShd.CLEAR);
shading.setColor("auto");
- shading.setFill(toHexColor(flatten(background.fill().color(), colourUnder(run))));
+ java.awt.Color fill = background.fill().color();
+ shading.setFill(toHexColor(fill.getAlpha() < 255 ? DocxTranslucency.flatten(fill, colourUnder(run, path)) : fill));
String lost = chipLost(background, leftPaddingAs(runs, index, rightToLeft),
!rightToLeft && takesSpaceAfter(textOf(runs.get(index)).text()));
if (lost != null) {
@@ -8967,15 +9058,17 @@ private static String chipLost(InlineBackground background, String leftAs, boole
/**
* The colour Word will paint under {@code run} — the shading this export itself wrote
- * on the run's paragraph or on the cell holding it, then the fill of the panel around an
- * unshaded cell, and otherwise the page's white.
+ * on the run's paragraph or on the cell holding it; then, where neither is shaded, what the
+ * page paints under the paragraph ({@link #colourUnder(DocumentNode)}).
*
* Read back from the file being written rather than tracked in a field, so it is
* whatever was actually written and cannot drift from it. Read, and only read:
* {@code cellProperties} would create the {@code w:tcPr} it cannot find, so an
* unstyled cell holding a chip would come away carrying an empty one.
+ *
+ * @param path the paragraph's path, or {@code null} where it has none
*/
- private java.awt.Color colourUnder(XWPFRun run) {
+ private java.awt.Color colourUnder(XWPFRun run, String path) {
XWPFParagraph para = run.getParent() instanceof XWPFParagraph parent ? parent : null;
CTPPr paragraphProperties = para == null || !para.getCTP().isSetPPr()
? null
@@ -8994,6 +9087,20 @@ private java.awt.Color colourUnder(XWPFRun run) {
if (cellFill != null) {
return cellFill;
}
+ return layout.colourUnder(path).orElseGet(this::surfaceColour);
+ }
+
+ /**
+ * The colour the page paints under a node, at its centre, where the layout tells it
+ * ({@link DocxLayoutMetrics#colourUnder(DocumentNode)}); otherwise the surface this export
+ * set the node on — the flattened fill of the panel or cell around it — or the page's white.
+ */
+ private java.awt.Color colourUnder(DocumentNode node) {
+ return layout.colourUnder(node).orElseGet(this::surfaceColour);
+ }
+
+ /** The fill of the panel or cell being written into, as written; the page's white outside one. */
+ private java.awt.Color surfaceColour() {
// A cell with no shading of its own — a row's, inside a card — shows the panel's.
return surfaceBehind != null ? surfaceBehind.color() : java.awt.Color.WHITE;
}
@@ -9015,26 +9122,6 @@ private static java.awt.Color shadingFillOf(CTShd shading) {
return new java.awt.Color(rgb[0] & 0xFF, rgb[1] & 0xFF, rgb[2] & 0xFF);
}
- /**
- * Composites a colour over what sits beneath it, so a translucent fill survives a
- * format that has no alpha. An opaque colour is returned untouched.
- */
- private static java.awt.Color flatten(java.awt.Color colour, java.awt.Color under) {
- int alpha = colour.getAlpha();
- if (alpha >= 255) {
- return colour;
- }
- double weight = alpha / 255.0;
- return new java.awt.Color(
- blend(colour.getRed(), under.getRed(), weight),
- blend(colour.getGreen(), under.getGreen(), weight),
- blend(colour.getBlue(), under.getBlue(), weight));
- }
-
- private static int blend(int over, int under, double weight) {
- return (int) Math.round(over * weight + under * (1 - weight));
- }
-
/**
* A run, inside a hyperlink when the thing being written is one.
*
@@ -9559,17 +9646,20 @@ private void writeRule(XWPFDocument document, DocumentNode node, DocxRules.Rule
pendingSpacingAfter = Math.max(0, pendingSpacingAfter - excess);
}
- // A border is opaque, so a translucent rule is flattened against what lies under it, as a
- // chip is; one that is not drawn at all keeps its place and draws nothing.
+ // A border is opaque, so a translucent rule is flattened against what the page paints under
+ // it, as a chip is; one that is not drawn at all keeps its place and draws nothing.
java.awt.Color colour = rule.colour().color();
if (colour.getAlpha() > 0) {
CTPBdr borders = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr();
CTBorder bottom = borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom();
- java.awt.Color under = surfaceBehind != null ? surfaceBehind.color() : java.awt.Color.WHITE;
paintEdge(bottom, dashOf(rule), BigInteger.valueOf(ruleEighths(rule.thickness())),
- toHexColor(flatten(colour, under)));
+ toHexColor(colour.getAlpha() < 255 ? DocxTranslucency.flatten(colour, colourUnder(node)) : colour));
bottom.setSpace(BigInteger.ZERO);
}
+ if (DocxTranslucency.translucent(colour)) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, TRANSLUCENCY, layout.pathOf(node),
+ "the rule is flattened against the colour under it, because a paragraph border is opaque");
+ }
if (node instanceof com.demcha.compose.document.node.LineNode lineNode && lineNode.linkTarget() != null) {
report.add(DocxExportReport.Severity.APPROXIMATED, "rule link", layout.pathOf(node),
"the rule is written as a paragraph border, which carries no link");
@@ -10415,6 +10505,8 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
// the column count.
XWPFTable table = newTable(document, rowCount, 1);
applyTableWidth(table, node, columnCount);
+ boolean translucentFills = false;
+ boolean translucentRules = false;
for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) {
XWPFTableRow row = table.getRow(rowIdx);
List physical = new ArrayList<>();
@@ -10432,10 +10524,21 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
XWPFTableCell cell = row.getCell(i);
applySpans(cell, placement, rowIdx);
// The covered positions of a merge take the paint too, so a merged
- // region reads as one cell rather than as a striped run of them.
- DocumentColor fill = resolveCellFill(node, placement);
+ // region reads as one cell rather than as a striped run of them. A translucent
+ // fill or rule is flattened against what the page paints under the cell, as Word's
+ // shading and borders are opaque.
+ DocumentColor authoredFill = resolveCellFill(node, placement);
DocumentStroke stroke = resolveCellStroke(node, placement);
- applyCellPaint(cell, fill, stroke);
+ boolean translucentFill = DocxTranslucency.flattensFill(authoredFill);
+ boolean translucentRule = DocxTranslucency.flattensStroke(stroke);
+ translucentFills |= translucentFill;
+ translucentRules |= translucentRule;
+ java.awt.Color under = translucentFill || translucentRule
+ ? layout.colourUnderCell(node, placement.row(), placement.column()).orElseGet(this::surfaceColour)
+ : java.awt.Color.WHITE;
+ DocumentColor fill = DocxTranslucency.flattenedFill(authoredFill, under);
+ // The page draws the rules over the cells' fills, in a pass after them.
+ applyCellPaint(cell, fill, DocxTranslucency.flattenedStroke(stroke, fill != null ? fill.color() : under));
int next = placement.row() + placement.rowSpan();
DocumentStroke underneath = next < rowCount ? resolveCellStroke(node, cover[next][placement.column()]) : null;
applyCellPadding(cell, clearOfTheRules(resolveCellPadding(node, placement), stroke,
@@ -10473,6 +10576,7 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
breakRowsWhereTheLayoutDoes(table, node);
indentTable(table);
carryDrawingsInRows(table, node);
+ reportFlattenedPaint(node, translucentFills, translucentRules, "its cells' fills", "its cells' rules");
}
/**
@@ -10565,9 +10669,10 @@ private void applySpans(XWPFTableCell cell, TableGrid.Placement placement, int r
* {@code w:shd} for the fill and {@code w:tcBorders} for the four edges — so this is
* mapping rather than approximation.
*
- * What does not survive is transparency. A {@code w:shd} fill is opaque, so a colour
- * carrying an opacity below 1 lands at full strength; the alternative would be blending it
- * against a background this backend does not resolve, Word owning the flow.
+ * What does not survive is transparency: {@code w:shd} and {@code w:tcBorders} are
+ * opaque, so the caller hands over a translucent fill or stroke already flattened against
+ * what the page paints under it ({@link DocxTranslucency#flatten}) and names it in the
+ * report.
*/
private void applyCellPaint(XWPFTableCell cell, DocumentColor fill, DocumentStroke stroke) {
if (fill == null && stroke == null) {
@@ -13297,8 +13402,13 @@ private void applyRunColourAndDecoration(XWPFRun run,
// colours built from the same channels are unequal unless they are the
// same object, and a style built inline per paragraph would keep writing
// a colour the Normal style already says.
+ // getRGB carries the alpha, so a run whose colour differs from Normal's only in
+ // strength writes its own.
|| style.color().color().getRGB() != defaults.color().color().getRGB())) {
run.setColor(toHexColor(style.color().color()));
+ textFillsWritten |= DocxTranslucency.writeTextAlpha(
+ run.getCTR().isSetRPr() ? run.getCTR().getRPr() : run.getCTR().addNewRPr(),
+ style.color().color(), normalIsTranslucent());
}
if (style.decoration() != null) {
switch (style.decoration()) {
@@ -13354,7 +13464,7 @@ private static String toHexColor(java.awt.Color color) {
if (color == null) {
return "000000";
}
- return String.format("%02X%02X%02X", color.getRed(), color.getGreen(), color.getBlue());
+ return DocxTranslucency.hex(color);
}
/**
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java
new file mode 100644
index 000000000..8d2ca1f32
--- /dev/null
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java
@@ -0,0 +1,298 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.style.DocumentBorders;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter;
+import org.apache.xmlbeans.XmlCursor;
+import org.apache.xmlbeans.XmlObject;
+
+import javax.xml.namespace.QName;
+import java.awt.Color;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A colour's alpha, where Word holds it and where it does not.
+ *
+ * Text holds it. Word 2010 added a text fill to a run's properties, {@code w14:textFill},
+ * whose colour carries a transparency — Word's own Font, Text Effects, Transparency — and both
+ * editors draw it (measured in Word 16.0.20430 and LibreOffice: red at three quarters
+ * transparency comes out {@code (255, 193, 193)} over white in each). The two read it apart:
+ * Word takes the colour from the text fill, LibreOffice takes it from {@code w:color} and the
+ * transparency from the text fill, so {@code w:color} keeps the colour as authored — flattened,
+ * LibreOffice would lighten it twice. Word sets such text as drawing when it saves a PDF, so
+ * its PDF of the file holds no text layer for it; the file itself holds the text.
+ *
+ * A cell's shading, a border and a run's shading do not: they take an RGB and nothing else.
+ * A translucent fill or rule written there is flattened first against the colour under it
+ * ({@link #flatten}), so it shows the colour the page shows, and is no longer translucent —
+ * recoloured underneath in Word, it stays the colour it was flattened to. The backend names
+ * each one in its report.
+ */
+final class DocxTranslucency {
+
+ /** Word 2010's namespace, which the text fill is in. */
+ private static final String W14 = "http://schemas.microsoft.com/office/word/2010/wordml";
+ /** Markup compatibility, which says what a reader that does not know a namespace may skip. */
+ private static final String MC = "http://schemas.openxmlformats.org/markup-compatibility/2006";
+
+ private static final QName TEXT_FILL = new QName(W14, "textFill", "w14");
+ private static final QName IGNORABLE = new QName(MC, "Ignorable", "mc");
+
+ private DocxTranslucency() {
+ }
+
+ /**
+ * Composites a colour over what sits beneath it, so a translucent fill survives a format
+ * that has no alpha. An opaque colour is returned untouched.
+ *
+ * @param colour the colour laid on top, alpha included
+ * @param under the opaque colour beneath it
+ * @return the opaque colour the two make
+ */
+ static Color flatten(Color colour, Color under) {
+ int alpha = colour.getAlpha();
+ if (alpha >= 255) {
+ return colour;
+ }
+ double weight = alpha / 255.0;
+ return new Color(
+ blend(colour.getRed(), under.getRed(), weight),
+ blend(colour.getGreen(), under.getGreen(), weight),
+ blend(colour.getBlue(), under.getBlue(), weight));
+ }
+
+ private static int blend(int over, int under, double weight) {
+ return (int) Math.round(over * weight + under * (1 - weight));
+ }
+
+ /**
+ * Whether a colour is translucent: drawn, and not at full strength.
+ *
+ * @param colour a colour, or {@code null}
+ * @return true for an alpha between 1 and 254
+ */
+ static boolean translucent(Color colour) {
+ return colour != null && colour.getAlpha() > 0 && colour.getAlpha() < 255;
+ }
+
+ /**
+ * Whether a fill is one a cell's shading holds only by flattening: translucent.
+ *
+ * @param fill a fill, or {@code null}
+ * @return true for a translucent one
+ */
+ static boolean flattensFill(DocumentColor fill) {
+ return fill != null && translucent(fill.color());
+ }
+
+ /**
+ * Whether a stroke is one a border holds only by flattening: one with a width, in a colour not
+ * at full strength. A wholly transparent one is flattened too — into the colour under it — so
+ * the border keeps the room the page's rule holds in Word's row and cell geometry.
+ *
+ * @param stroke a stroke, or {@code null}
+ * @return true where the border is written flattened
+ */
+ static boolean flattensStroke(DocumentStroke stroke) {
+ return stroke != null && stroke.width() > 0 && stroke.color() != null
+ && stroke.color().color().getAlpha() < 255;
+ }
+
+ /**
+ * Whether any of a block's sides is a stroke a border holds only flattened.
+ *
+ * @param borders the sides, or {@code null}
+ * @return true where one is
+ */
+ static boolean flattensSides(DocumentBorders borders) {
+ return borders != null && (flattensStroke(borders.top()) || flattensStroke(borders.right())
+ || flattensStroke(borders.bottom()) || flattensStroke(borders.left()));
+ }
+
+ /**
+ * A fill as a cell's shading holds it: flattened against the colour under it where it is
+ * translucent, and none where it is wholly transparent, as the page draws nothing.
+ *
+ * @param fill the authored fill, or {@code null}
+ * @param under the opaque colour under the block
+ * @return the fill to write, or {@code null} for none
+ */
+ static DocumentColor flattenedFill(DocumentColor fill, Color under) {
+ if (fill == null || fill.color().getAlpha() == 0) {
+ return null;
+ }
+ return fill.color().getAlpha() >= 255 ? fill : DocumentColor.of(flatten(fill.color(), under));
+ }
+
+ /**
+ * A stroke as a border holds it: a colour not at full strength flattened against the colour
+ * the page draws it over.
+ *
+ * @param stroke the authored stroke, or {@code null}
+ * @param under the opaque colour under the stroke
+ * @return the stroke to write
+ */
+ static DocumentStroke flattenedStroke(DocumentStroke stroke, Color under) {
+ if (!flattensStroke(stroke)) {
+ return stroke;
+ }
+ return new DocumentStroke(DocumentColor.of(flatten(stroke.color().color(), under)), stroke.width());
+ }
+
+ /**
+ * A block's sides as borders hold them, each flattened as {@link #flattenedStroke} does.
+ *
+ * @param borders the authored sides, or {@code null}
+ * @param under the opaque colour under the sides
+ * @return the sides to write
+ */
+ static DocumentBorders flattenedBorders(DocumentBorders borders, Color under) {
+ return borders == null ? null : new DocumentBorders(flattenedStroke(borders.top(), under),
+ flattenedStroke(borders.right(), under), flattenedStroke(borders.bottom(), under),
+ flattenedStroke(borders.left(), under));
+ }
+
+ /**
+ * A colour as Word writes one: six hex digits, its alpha dropped.
+ *
+ * @param colour a colour
+ * @return {@code RRGGBB}
+ */
+ static String hex(Color colour) {
+ return String.format("%02X%02X%02X", colour.getRed(), colour.getGreen(), colour.getBlue());
+ }
+
+ /**
+ * Writes a run's transparency as its text fill, or takes away a text fill an opaque colour
+ * no longer wants.
+ *
+ * Word reads the transparency — not the opacity — as hundred-thousandths: three quarters
+ * transparent is {@code 75000}. The fill declares its own namespace, so a part with no
+ * translucent text is not touched; {@link #settle} puts it last among the run's properties,
+ * where Word writes it, once nothing more is written on them.
+ *
+ * A run follows its style's text fill where it writes none, and Word draws the fill's
+ * colour over the run's own: an opaque run under a translucent default would come out in the
+ * default's colour. Such a run writes an opaque fill of its own.
+ *
+ * @param properties a run's properties — a run's, a style's or a numbering level's —
+ * its {@code w:color} already written
+ * @param colour the run's colour, alpha included
+ * @param styleIsTranslucent whether the style the run follows carries a text fill
+ * @return whether a text fill was written
+ */
+ static boolean writeTextAlpha(XmlObject properties, Color colour, boolean styleIsTranslucent) {
+ removeTextFill(properties);
+ if (colour.getAlpha() >= 255 && !styleIsTranslucent) {
+ return false;
+ }
+ try (XmlCursor cursor = properties.newCursor()) {
+ cursor.toEndToken();
+ cursor.beginElement(TEXT_FILL);
+ cursor.beginElement(new QName(W14, "solidFill", "w14"));
+ cursor.beginElement(new QName(W14, "srgbClr", "w14"));
+ cursor.insertAttributeWithValue(new QName(W14, "val", "w14"), hex(colour));
+ if (colour.getAlpha() < 255) {
+ long transparency = Math.round((255 - colour.getAlpha()) * 100000.0 / 255.0);
+ cursor.beginElement(new QName(W14, "alpha", "w14"));
+ cursor.insertAttributeWithValue(new QName(W14, "val", "w14"), Long.toString(transparency));
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Copies a run's text fill onto other properties — a paragraph's mark, in whose style a
+ * list's marker is drawn where its level states none — replacing any they held.
+ *
+ * @param from the properties a text fill is read from
+ * @param to the properties it is written on
+ */
+ static void copyTextFill(XmlObject from, XmlObject to) {
+ removeTextFill(to);
+ for (XmlObject fill : from.selectChildren(TEXT_FILL)) {
+ try (XmlCursor source = fill.newCursor(); XmlCursor target = to.newCursor()) {
+ target.toEndToken();
+ source.copyXml(target);
+ }
+ }
+ }
+
+ private static void removeTextFill(XmlObject properties) {
+ for (XmlObject fill : properties.selectChildren(TEXT_FILL)) {
+ try (XmlCursor cursor = fill.newCursor()) {
+ cursor.removeXml();
+ }
+ }
+ }
+
+ /**
+ * Settles the text fills of a finished document. Each is moved last among its properties,
+ * after what was written on them since — a decoration, a chip's shading, a direction —
+ * where Word writes it. Each part holding one marks Word 2010's namespace on its root as one a
+ * reader that does not know it may skip ({@code mc:Ignorable}). A part holding none is not
+ * touched.
+ *
+ * @param document the finished document
+ */
+ static void settle(XWPFDocument document) {
+ List roots = new ArrayList<>();
+ roots.add(document.getDocument());
+ // The styles and numbering parts' roots as the document holds them, read from one of
+ // their children: XWPFDocument.getStyle() parses a copy of the part.
+ if (document.getStyles() != null && !document.getStyles().getStyles().isEmpty()) {
+ roots.add(parentOf(document.getStyles().getStyles().get(0).getCTStyle()));
+ }
+ if (document.getNumbering() != null && !document.getNumbering().getAbstractNums().isEmpty()) {
+ roots.add(parentOf(document.getNumbering().getAbstractNums().get(0).getCTAbstractNum()));
+ }
+ for (XWPFHeaderFooter part : document.getHeaderList()) {
+ roots.add(part._getHdrFtr());
+ }
+ for (XWPFHeaderFooter part : document.getFooterList()) {
+ roots.add(part._getHdrFtr());
+ }
+ for (XmlObject root : roots) {
+ if (root != null) {
+ settlePart(root);
+ }
+ }
+ }
+
+ private static XmlObject parentOf(XmlObject child) {
+ try (XmlCursor cursor = child.newCursor()) {
+ cursor.toParent();
+ return cursor.getObject();
+ }
+ }
+
+ private static void settlePart(XmlObject root) {
+ XmlObject[] fills = root.selectPath("declare namespace w14='" + W14 + "' .//w14:textFill");
+ if (fills.length == 0) {
+ return;
+ }
+ for (XmlObject fill : fills) {
+ try (XmlCursor source = fill.newCursor(); XmlCursor target = fill.newCursor()) {
+ target.toParent();
+ target.toEndToken();
+ source.moveXml(target);
+ }
+ }
+ try (XmlCursor cursor = root.newCursor()) {
+ String ignorable = cursor.getAttributeText(IGNORABLE);
+ if (ignorable == null) {
+ cursor.toNextToken();
+ cursor.insertNamespace("w14", W14);
+ cursor.insertNamespace("mc", MC);
+ cursor.insertAttributeWithValue(IGNORABLE, "w14");
+ } else if (!(" " + ignorable + " ").contains(" w14 ")) {
+ cursor.setAttributeText(IGNORABLE, ignorable + " w14");
+ cursor.toNextToken();
+ cursor.insertNamespace("w14", W14);
+ }
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
index 444799e62..15f01eb01 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java
@@ -88,13 +88,25 @@ private enum Fate {
private record Entry(Fate fate, String note) {
}
+ // A text colour's alpha is written, as Word's text fill, and a drawing's in DrawingML; what a
+ // cell's shading or a border holds is opaque, so a translucent one is flattened and reported.
+ private static final String PANEL_TRANSLUCENCY = "its translucency, as a panel's, flattened against the colour "
+ + "under it, a border's against the panel's fill where it has "
+ + "one; any other is written";
+ private static final String RULE_TRANSLUCENCY = "its translucency, as a rule's, flattened against the colour "
+ + "under it; drawn, its alpha is written; any other is written";
+ private static final String CELL_TRANSLUCENCY = "the translucency of a cell's fill, flattened against the colour "
+ + "under the cell, and of its rules, against its fill where it has "
+ + "one; any other is written";
+
private static final Map, Map> NODES = new LinkedHashMap<>();
private static final Map OUTPUT_OPTIONS = fields(
"metadata:WRITTEN",
"watermark:REPORTED",
"protection:REPORTED",
"viewerPreferences:REPORTED",
- "headersAndFooters:WRITTEN",
+ "headersAndFooters:REPORTED:a band's translucent separator, flattened against white; any other is "
+ + "written, or reported where Word's parts cannot hold it",
// The node entries below are the body's. A zone is written as one line of its
// paragraphs' runs, page fields and tabs; what of that the report does not name is
// a gap of its own.
@@ -119,7 +131,8 @@ private record Entry(Fate fate, String note) {
"margin:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written",
"padding:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written");
node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN",
- "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN",
+ "margin:WRITTEN", "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + PANEL_TRANSLUCENCY,
+ "cornerRadius:REPORTED", "borders:REPORTED:" + PANEL_TRANSLUCENCY,
"anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked",
"bookmarkOptions:REPORTED", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column");
node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN",
@@ -134,16 +147,18 @@ private record Entry(Fate fate, String note) {
node(LayerStackNode.class, "name:INERT", "layers:WRITTEN", "padding:WRITTEN", "margin:WRITTEN",
"clipToBounds:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table");
node(LineNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "startX:WRITTEN", "startY:WRITTEN",
- "endX:WRITTEN", "endY:WRITTEN", "stroke:WRITTEN",
+ "endX:WRITTEN", "endY:WRITTEN", "stroke:REPORTED:" + RULE_TRANSLUCENCY,
"linkTarget:REPORTED",
"bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED",
"dashPattern:REPORTED",
"anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a cap other than butt; whether an editor ends a drawn butt line flat is not measured",
"fillWidth:WRITTEN", "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in");
node(ListNode.class, "name:INERT",
- "items:REPORTED:a blank one a hangingIndent list draws as its marker alone, and the marks of one "
- + "the page reads as markdown; any other is written",
- "nestedItems:REPORTED:the marks of one the page reads as markdown; any other is written",
+ "items:REPORTED:a blank one a hangingIndent list draws as its marker alone, the marks of one "
+ + "the page reads as markdown, and a chip's translucent fill, flattened against the colour under "
+ + "it; any other is written",
+ "nestedItems:REPORTED:the marks of one the page reads as markdown, and a chip's translucent fill, "
+ + "flattened against the colour under it; any other is written",
"marker:WRITTEN",
"textStyle:WRITTEN", "align:REPORTED",
"lineSpacing:REPORTED:where the layout's items are not its own and one wraps, an item run "
@@ -175,8 +190,9 @@ private record Entry(Fate fate, String note) {
node(ParagraphNode.class, "name:INERT",
"text:REPORTED:where the page reads it as markdown, its marks written as letters; where its "
+ "lines are not read and the page's parser drops a mark, not measured; any other is written",
- "inlineRuns:WRITTEN", "textStyle:WRITTEN",
- "align:WRITTEN", "lineSpacing:WRITTEN",
+ "inlineRuns:REPORTED:a chip's translucent fill, flattened against the colour under it, with what "
+ + "else of its shape Word cannot hold; a run's translucent colour is written as Word's text fill",
+ "textStyle:WRITTEN", "align:WRITTEN", "lineSpacing:WRITTEN",
"bulletOffset:REPORTED:its letters before the first line; over the flow, as a side of an "
+ "overlay's pair or as a badge's text, the room it sets lines in by where that moves one",
"indentStrategy:WRITTEN", "linkTarget:WRITTEN",
@@ -204,7 +220,8 @@ private record Entry(Fate fate, String note) {
node(com.demcha.compose.document.layout.HorizontalBandContentNode.class, "name:INERT", "key:INERT",
"slot:WRITTEN", "child:WRITTEN");
node(SectionNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN",
- "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN",
+ "margin:WRITTEN", "fillColor:REPORTED:" + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + PANEL_TRANSLUCENCY,
+ "cornerRadius:REPORTED", "borders:REPORTED:" + PANEL_TRANSLUCENCY,
"keepTogether:WRITTEN",
"anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked",
"bleed:REPORTED:of a panel the page bleeds, in the flow it pages",
@@ -212,9 +229,13 @@ private record Entry(Fate fate, String note) {
"flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column");
node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN",
"clipPolicy:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table",
- "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN",
+ "fillColor:REPORTED:its translucency, as a panel's, flattened against the colour under it; "
+ + "drawn, its alpha is written; any other is written",
+ "stroke:REPORTED:its translucency, as a panel's border, flattened against the panel's fill or "
+ + "the colour under it; drawn, its alpha is written; any other is written",
+ "padding:WRITTEN", "margin:WRITTEN",
"transform:REPORTED");
- node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN",
+ node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:REPORTED:" + RULE_TRANSLUCENCY,
"stroke:WRITTEN", "cornerRadius:REPORTED:unequal corners, drawn at the largest radius",
"linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN",
"margin:WRITTEN", "transform:REPORTED", "fillPaint:REPORTED",
@@ -223,8 +244,9 @@ private record Entry(Fate fate, String note) {
"padding:REPORTED:above and below; below a block a band or a column measures from, written",
"margin:REPORTED:above and below; below a block a band or a column measures from, written",
"grow:WRITTEN");
- node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:WRITTEN", "defaultCellStyle:WRITTEN",
- "rowStyles:WRITTEN", "columnStyles:WRITTEN", "width:WRITTEN", "linkTarget:REPORTED",
+ node(TableNode.class, "name:INERT", "columns:WRITTEN", "rows:REPORTED:" + CELL_TRANSLUCENCY,
+ "defaultCellStyle:REPORTED:" + CELL_TRANSLUCENCY, "rowStyles:REPORTED:" + CELL_TRANSLUCENCY,
+ "columnStyles:REPORTED:" + CELL_TRANSLUCENCY, "width:WRITTEN", "linkTarget:REPORTED",
"bookmarkOptions:REPORTED",
"padding:WRITTEN",
"margin:WRITTEN", "repeatedHeaderRowCount:WRITTEN", "anchor:WRITTEN");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
new file mode 100644
index 000000000..c0d32789f
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
@@ -0,0 +1,500 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.PageBackgroundFill;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.output.DocumentHeaderFooter;
+import com.demcha.compose.document.output.DocumentHeaderFooterZone;
+import com.demcha.compose.document.style.DocumentBorders;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.apache.xmlbeans.XmlObject;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
+
+import javax.xml.namespace.QName;
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A translucent colour in the Word file: text carries its transparency as Word's text fill, and
+ * a cell's shading, a border and a rule — which hold only an opaque colour — are flattened
+ * against the colour the page paints under them and named in the report.
+ *
+ * The colour under is the layout's, so a fill over a page's background composites over that
+ * background, not over white. The expected values are worked out by hand: a channel is
+ * {@code round(over × a + under × (1 − a))}, {@code a} the alpha over 255.
+ */
+class DocxTranslucencyTest {
+
+ private static final String W14 = "http://schemas.microsoft.com/office/word/2010/wordml";
+ private static final DocumentColor NAVY = DocumentColor.rgb(28, 39, 64);
+ private static final DocumentColor HALF_BLUE = DocumentColor.rgba(0, 90, 200, 128);
+ private static final String FILL_NOTE = "its fill is flattened against the colour under it, because a Word "
+ + "cell's shading and borders are opaque";
+
+ @Test
+ void translucentTextIsWrittenWithItsTransparency() throws Exception {
+ try (Exported exported = export(null, page -> page
+ .addParagraph(p -> p.text("Faint")
+ .textStyle(DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(200, 0, 0, 64))))
+ .addParagraph(p -> p.text("Solid words, long enough to be the body of this page")
+ .textStyle(DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgb(0, 0, 200)))))) {
+ CTRPr faint = run(exported.document(), "Faint").getCTR().getRPr();
+ // LibreOffice takes the colour from w:color and the transparency from the text fill, so
+ // w:color keeps the colour as authored.
+ assertThat(hex(faint.getColorArray(0).getVal())).isEqualTo("C80000");
+ // Transparency, not opacity: (255 - 64) / 255 = 74.902%.
+ assertThat(textFill(faint)).isEqualTo("C80000@74902");
+ assertThat(textFill(run(exported.document(), "Solid").getCTR().getRPr()))
+ .as("an opaque colour writes no text fill").isNull();
+ assertThat(exported.report().bySubject()).as("written, not reported")
+ .doesNotContainKey("translucency");
+ }
+ }
+
+ @Test
+ void aTranslucentBodyColourIsTheStylesAndAnOpaqueRunOverridesIt() throws Exception {
+ DocumentTextStyle faint = DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(0, 0, 0, 153));
+ try (Exported exported = export(null, page -> page
+ .addParagraph(p -> p.text("Heading").textStyle(DocumentTextStyle.DEFAULT.withSize(16)))
+ .addParagraph(p -> p.text("The body of the page is set in a faint black, and there is "
+ + "more of it than of the heading, so it is the style's.").textStyle(faint))
+ .addParagraph(p -> p.text("A second paragraph in the same faint black.").textStyle(faint))
+ .addList(list -> list.name("Points").textStyle(DocumentTextStyle.DEFAULT
+ .withColor(DocumentColor.rgb(0, 0, 0))).items("One", "Two")))) {
+ CTRPr marker = exported.document().getNumbering().getAbstractNums().get(0).getCTAbstractNum()
+ .getLvlArray(0).getRPr();
+ assertThat(textFill(marker)).as("an opaque list's marker over the translucent style").isEqualTo("000000@");
+ CTRPr defaults = exported.document().getStyles().getStyle("Normal").getCTStyle().getRPr();
+ assertThat(textFill(defaults)).as("the Normal style's text fill").isEqualTo("000000@40000");
+ assertThat(textFill(run(exported.document(), "The body").getCTR().getRPr()))
+ .as("a run in the style's colour follows the style").isNull();
+ // A run follows the style's text fill where it writes none, and Word draws the fill's
+ // colour over the run's: the opaque heading would come out faint.
+ assertThat(textFill(run(exported.document(), "Heading").getCTR().getRPr()))
+ .as("an opaque run under a translucent style writes an opaque fill").isEqualTo("000000@");
+ }
+ }
+
+ @Test
+ void aPanelsTranslucentFillIsFlattenedAgainstThePagesBackground() throws Exception {
+ try (Exported exported = export(navyPage(), page -> page
+ .addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside")))) {
+ // 0/90/200 at 128/255 over 28/39/64.
+ assertThat(shading(onlyCell(exported.document()))).isEqualTo("0E4184");
+ assertThat(notes(exported)).containsExactly(FILL_NOTE);
+ }
+ try (Exported exported = export(null, page -> page
+ .addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside")))) {
+ assertThat(shading(onlyCell(exported.document()))).as("over the page's white").isEqualTo("7FACE3");
+ }
+ }
+
+ @Test
+ void aChipOnAPanelThatWasFlattenedCompositesOverWhatWasWritten() throws Exception {
+ try (Exported exported = export(navyPage(), page -> page
+ .addSection("Card", card -> card.fillColor(HALF_BLUE)
+ .addParagraph(p -> p.inlineText("Call ").inlineCode("render()"))))) {
+ String card = shading(onlyCell(exported.document()));
+ assertThat(card).isEqualTo("0E4184");
+ // 175/184/193 at 51/255 over the card as written, 14/65/132: what Word paints under it.
+ // The chip's last letter is a run of its own, which carries the space after it.
+ assertThat(runShading(run(exported.document(), "render("))).isEqualTo("2E5990");
+ }
+ }
+
+ @Test
+ void aChipOnAPagesBackgroundCompositesOverThatBackground() throws Exception {
+ try (Exported exported = export(navyPage(), page -> page
+ .addParagraph(p -> p.inlineText("Call ").inlineCode("render()")))) {
+ // 175/184/193 at 51/255 over 28/39/64 — over white it was 239/241/243.
+ assertThat(runShading(run(exported.document(), "render("))).isEqualTo("39445A");
+ }
+ }
+
+ @Test
+ void aPanelsTranslucentBordersAreFlattenedAndNamed() throws Exception {
+ try (Exported exported = export(null, page -> page
+ .addSection("Card", card -> card.borders(DocumentBorders.all(
+ DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 100), 2)))
+ .addParagraph("Inside")))) {
+ CTBorder top = onlyCell(exported.document()).getCTTc().getTcPr().getTcBorders().getTop();
+ // 200/0/0 at 100/255 over white.
+ assertThat(hex(top.getColor())).isEqualTo("E99B9B");
+ assertThat(notes(exported)).containsExactly("its borders are flattened against the colour under them, "
+ + "because a Word cell's shading and borders are opaque");
+ }
+ }
+
+ @Test
+ void aWhollyTransparentFillIsNoShadingAndARuleKeepsItsRoomInTheColourUnder() throws Exception {
+ try (Exported exported = export(navyPage(), page -> page
+ .addSection("Card", card -> card.fillColor(DocumentColor.rgba(0, 90, 200, 0))
+ .borders(DocumentBorders.all(DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 0), 2)))
+ .addParagraph("Inside")))) {
+ XWPFTableCell cell = onlyCell(exported.document());
+ assertThat(shading(cell)).as("the page draws no fill").isNull();
+ assertThat(hex(cell.getCTTc().getTcPr().getTcBorders().getTop().getColor()))
+ .as("the border holds its room, in the navy under it").isEqualTo("1C2740");
+ assertThat(notes(exported)).containsExactly("its borders are flattened against the colour under them, "
+ + "because a Word cell's shading and borders are opaque");
+ }
+ }
+
+ @Test
+ void aTablesTranslucentCellsAreFlattenedAndNamedOnce() throws Exception {
+ DocumentTableStyle tint = DocumentTableStyle.builder().fillColor(HALF_BLUE)
+ .stroke(DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 100), 1)).build();
+ try (Exported exported = export(navyPage(), page -> page.add(new com.demcha.compose.document.dsl.TableBuilder()
+ .name("Rota").columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.text("A").withStyle(tint), DocumentTableCell.text("B").withStyle(tint))
+ .rowCells(DocumentTableCell.text("C").withStyle(tint), DocumentTableCell.text("D").withStyle(tint))
+ .build()))) {
+ XWPFTableCell cell = exported.document().getTables().get(0).getRow(1).getCell(1);
+ assertThat(shading(cell)).isEqualTo("0E4184");
+ // The page draws the rules over the cells' fills: 200/0/0 at 100/255 over 14/65/132.
+ assertThat(hex(cell.getCTTc().getTcPr().getTcBorders().getTop().getColor())).isEqualTo("572850");
+ assertThat(notes(exported)).containsExactly("its cells' fills and its cells' rules are flattened against "
+ + "the colour under them, because a Word cell's shading and "
+ + "borders are opaque");
+ }
+ }
+
+ @Test
+ void aTranslucentRuleIsFlattenedAgainstThePagesBackground() throws Exception {
+ try (Exported exported = export(navyPage(), page -> page
+ .addDivider(d -> d.width(200).thickness(1).color(DocumentColor.rgba(255, 255, 255, 115))))) {
+ CTBorder bottom = ruleParagraph(exported.document()).getCTP().getPPr().getPBdr().getBottom();
+ // White at 115/255 over 28/39/64: a pale navy, as the page shows it, and not white.
+ assertThat(hex(bottom.getColor())).isEqualTo("828896");
+ assertThat(notes(exported)).containsExactly(
+ "the rule is flattened against the colour under it, because a paragraph border is opaque");
+ }
+ }
+
+ @Test
+ void aTranslucentSeparatorIsFlattenedAgainstWhite() throws Exception {
+ try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header")
+ .showSeparator(true).separatorColor(DocumentColor.rgba(200, 0, 0, 100)).separatorThickness(1)
+ .build()), page -> page.addParagraph("Body"))) {
+ XWPFParagraph band = exported.document().getHeaderList().get(0).getParagraphs().get(0);
+ assertThat(hex(band.getCTP().getPPr().getPBdr().getBottom().getColor())).isEqualTo("E99B9B");
+ assertThat(notes(exported)).containsExactly("a page header's separator is flattened against white, "
+ + "because a paragraph border is opaque");
+ }
+ }
+
+ @Test
+ void aPanelOverATableCellIsFlattenedAgainstTheCell() throws Exception {
+ // The table is the stack's back layer and the panel stands over its cell, which the page
+ // paints first: what is under the panel is the cell's red, not the page.
+ DocumentTableStyle red = DocumentTableStyle.builder().fillColor(DocumentColor.rgb(200, 40, 40)).build();
+ try (Exported exported = export(null, page -> page.addLayerStack(stack -> stack.name("Stack")
+ .back(new com.demcha.compose.document.dsl.TableBuilder().name("Under")
+ .columns(DocumentTableColumn.fixed(200))
+ .rowCells(DocumentTableCell.text("Under the panel").withStyle(red)).build())
+ .center(new com.demcha.compose.document.dsl.SectionBuilder().name("Over").fillColor(HALF_BLUE)
+ .addParagraph("Over").build())))) {
+ // 0/90/200 at 128/255 over 200/40/40.
+ assertThat(allCells(exported.document())).extracting(DocxTranslucencyTest::shading).contains("644178");
+ }
+ }
+
+ @Test
+ void withNoLayoutAChipInARowOnATranslucentPanelCompositesOverThePanelAsWritten() throws Exception {
+ // The row's cell has no shading, and nothing tells the colour under the paragraph but the
+ // panel the export set it on — the panel as written, not its translucent colour.
+ try (XWPFDocument document = DocxExports.withoutLayout(400, 400, 20, page -> page
+ .addSection("Card", card -> card.fillColor(HALF_BLUE)
+ .addRow(row -> row.addParagraph(p -> p.inlineText("Call ").inlineCode("render()"))
+ .addParagraph(p -> p.text("Beside")))))) {
+ assertThat(shading(document.getTables().get(0).getRow(0).getCell(0))).isEqualTo("7FACE3");
+ // 175/184/193 at 51/255 over 127/172/227.
+ assertThat(runShading(run(document, "render("))).isEqualTo("89AEDC");
+ }
+ }
+
+ @Test
+ void aPanelsBordersAreFlattenedAgainstItsOwnFill() throws Exception {
+ // The page draws the borders over the panel's fill, not over what is under the panel.
+ try (Exported exported = export(null, page -> page
+ .addSection("Card", card -> card.fillColor(NAVY).borders(DocumentBorders.all(
+ DocumentStroke.of(DocumentColor.rgba(255, 255, 255, 51), 2)))
+ .addParagraph("Inside")))) {
+ CTBorder top = onlyCell(exported.document()).getCTTc().getTcPr().getTcBorders().getTop();
+ // White at 51/255 over 28/39/64; over the white page it would be white.
+ assertThat(hex(top.getColor())).isEqualTo("495266");
+ }
+ }
+
+ @Test
+ void aRowsOwnFillIsNotWhatIsUnderItsChip() throws Exception {
+ // The export does not write a row's own fill — the report names it as row paint — so what
+ // Word shows under the chip is the page's white, and the chip is flattened against that.
+ try (Exported exported = export(null, page -> page.addRow(row -> row.fillColor(NAVY)
+ .addParagraph(p -> p.inlineText("Call ").inlineCode("render()")).addParagraph("Beside")))) {
+ assertThat(exported.report().bySubject()).as("the row's fill is not written").containsKey("row paint");
+ assertThat(runShading(run(exported.document(), "render("))).isEqualTo("EFF1F3");
+ }
+ }
+
+ @Test
+ void aSeparatorWrittenIntoSeveralPartsIsNamedOnce() throws Exception {
+ // A footer kept off the first page gives the section a first page of its own, so the header
+ // band is written into the first page's part and the default one.
+ try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header")
+ .showSeparator(true).separatorColor(DocumentColor.rgba(200, 0, 0, 100))
+ .separatorThickness(1).build())
+ .footer(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.FOOTER).height(20)
+ .fontSize(8).rightText("{page}").numbering(com.demcha.compose.document.output
+ .DocumentPageNumbering.builder().showOnFirstPage(false).build()).build()),
+ page -> page.addParagraph("Body"))) {
+ assertThat(exported.document().getHeaderList()).as("written into more than one part").hasSizeGreaterThan(1);
+ assertThat(notes(exported)).containsExactly("a page header's separator is flattened against white, "
+ + "because a paragraph border is opaque");
+ }
+ }
+
+ @Test
+ void aTextFillIsTheLastOfARunsPropertiesAndItsPartSaysItMayBeSkipped() throws Exception {
+ DocumentTextStyle faint = new DocumentTextStyle(DocumentTextStyle.DEFAULT.fontName(), 10,
+ com.demcha.compose.document.style.DocumentTextDecoration.UNDERLINE, DocumentColor.rgba(200, 0, 0, 64));
+ try (Exported exported = export(null, page -> page
+ .addParagraph(p -> p.text("Underlined").textStyle(faint))
+ .addParagraph(p -> p.text("The body of the page, in an opaque colour and longer than the rest")))) {
+ CTRPr properties = run(exported.document(), "Underlined").getCTR().getRPr();
+ assertThat(properties.sizeOfUArray()).as("the underline is written").isEqualTo(1);
+ // Word writes its 2010 run properties after the ones Word 2007 had.
+ try (org.apache.xmlbeans.XmlCursor cursor = properties.newCursor()) {
+ cursor.toLastChild();
+ assertThat(cursor.getName()).isEqualTo(new QName(W14, "textFill"));
+ }
+ QName ignorable = new QName("http://schemas.openxmlformats.org/markup-compatibility/2006", "Ignorable");
+ try (org.apache.xmlbeans.XmlCursor root = exported.document().getDocument().newCursor()) {
+ assertThat(root.getAttributeText(ignorable)).as("the body part").isEqualTo("w14");
+ }
+ try (org.apache.xmlbeans.XmlCursor root = exported.document().getStyle().newCursor()) {
+ assertThat(root.getAttributeText(ignorable)).as("the styles part holds no text fill").isNull();
+ }
+ }
+ }
+
+ @Test
+ void anOpaqueDocumentWritesNothingOfWord2010() throws Exception {
+ try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header").build()),
+ page -> page.addParagraph("Body").addList(list -> list.name("Points").items("One", "Two"))
+ .addSection("Card", card -> card.fillColor(NAVY).addParagraph("Inside")))) {
+ try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream(
+ new ByteArrayInputStream(exported.bytes()))) {
+ for (java.util.zip.ZipEntry entry; (entry = zip.getNextEntry()) != null; ) {
+ if (entry.getName().endsWith(".xml")) {
+ assertThat(new String(zip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8))
+ .as(entry.getName()).doesNotContain("wordprocessingml/2010").doesNotContain("Ignorable");
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ void aNestedItemsMarkTakesTheTextFillItsMarkerIsDrawnIn() throws Exception {
+ DocumentTextStyle faint = DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(0, 0, 0, 153));
+ try (Exported exported = export(null, page -> page
+ .addParagraph(p -> p.text("The body of the page is set in a faint black, and there is more of it "
+ + "than of the list, so it is the style's.").textStyle(faint))
+ .addList(list -> list.name("Points").textStyle(DocumentTextStyle.DEFAULT
+ .withColor(DocumentColor.rgb(0, 0, 0)))
+ .addItem("Languages", child -> child.addItem("Java").addItem("Kotlin"))))) {
+ XWPFParagraph nested = run(exported.document(), "Java").getParent() instanceof XWPFParagraph paragraph
+ ? paragraph : null;
+ assertThat(nested).isNotNull();
+ // A nested level states no style of its own, so its marker is drawn in the mark's.
+ assertThat(textFill(nested.getCTP().getPPr().getRPr())).as("an opaque fill over the faint style")
+ .isEqualTo("000000@");
+ }
+ }
+
+ @Test
+ void aContainersTranslucentFillAndALinesStrokeAreFlattenedAndNamed() throws Exception {
+ try (Exported exported = export(null, page -> page
+ .add(new com.demcha.compose.document.node.ContainerNode("Box",
+ List.of(new com.demcha.compose.document.dsl.ParagraphBuilder().name("Inside").text("Inside")
+ .build()), 0, DocumentInsets.of(4), DocumentInsets.zero(), HALF_BLUE, null))
+ .addLine(line -> line.horizontal(200).stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1))))) {
+ assertThat(shading(onlyCell(exported.document()))).isEqualTo("7FACE3");
+ // Black at 128/255 over white.
+ assertThat(hex(ruleParagraph(exported.document()).getCTP().getPPr().getPBdr().getBottom().getColor()))
+ .isEqualTo("7F7F7F");
+ assertThat(notes(exported)).containsExactly(FILL_NOTE,
+ "the rule is flattened against the colour under it, because a paragraph border is opaque");
+ }
+ }
+
+ @Test
+ void underAPictureTheSurfaceStandsInAndTheFillIsStillNamed() throws Exception {
+ // The layout carries no colour for a picture's pixels: the panel over it is flattened against
+ // the surface it is written on — the page's white — and named all the same.
+ try (Exported exported = export(navyPage(), page -> page.addLayerStack(stack -> stack.name("Stack")
+ .back(new com.demcha.compose.document.dsl.ImageBuilder().name("Photo")
+ .source(com.demcha.compose.document.image.DocumentImageData.fromBytes(png())).size(200, 80)
+ .build())
+ .center(new com.demcha.compose.document.dsl.SectionBuilder().name("Over").fillColor(HALF_BLUE)
+ .addParagraph("Over").build())))) {
+ assertThat(allCells(exported.document())).extracting(DocxTranslucencyTest::shading).contains("7FACE3");
+ assertThat(notes(exported)).contains(FILL_NOTE);
+ }
+ }
+
+ @Test
+ void withNoLayoutAFillIsFlattenedAgainstTheSurfaceItIsWrittenOn() throws Exception {
+ DocxExportReport report = DocxExports.reportWithoutLayout(400, 400, 20, page -> page
+ .addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside")));
+ assertThat(report.bySubject().get("translucency")).extracting(DocxExportReport.Note::detail)
+ .containsExactly(FILL_NOTE);
+ }
+
+ private static Consumer navyPage() {
+ return session -> session.pageBackgrounds(List.of(PageBackgroundFill.fullPage(NAVY)));
+ }
+
+ private record Exported(XWPFDocument document, DocxExportReport report, byte[] bytes) implements AutoCloseable {
+ @Override
+ public void close() throws Exception {
+ document.close();
+ }
+ }
+
+ private static byte[] png() {
+ try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
+ javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(40, 40,
+ java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out);
+ return out.toByteArray();
+ } catch (Exception failure) {
+ throw new IllegalStateException(failure);
+ }
+ }
+
+ private static Exported export(Consumer setup, Consumer content) throws Exception {
+ AtomicReference captured = new AtomicReference<>();
+ byte[] bytes;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 400).margin(DocumentInsets.of(20))
+ .create()) {
+ if (setup != null) {
+ setup.accept(session);
+ }
+ session.pageFlow(content::accept);
+ bytes = session.export(new DocxSemanticBackend(captured::set));
+ }
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(bytes)), captured.get(), bytes);
+ }
+
+ private static List notes(Exported exported) {
+ return exported.report().bySubject().getOrDefault("translucency", List.of()).stream()
+ .map(DocxExportReport.Note::detail).toList();
+ }
+
+ private static XWPFRun run(XWPFDocument document, String startsWith) {
+ for (XWPFParagraph paragraph : allParagraphs(document)) {
+ for (XWPFRun run : paragraph.getRuns()) {
+ if (run.text().startsWith(startsWith)) {
+ return run;
+ }
+ }
+ }
+ throw new AssertionError("no run starts with " + startsWith);
+ }
+
+ private static List allParagraphs(XWPFDocument document) {
+ List paragraphs = new java.util.ArrayList<>(document.getParagraphs());
+ allCells(document).forEach(cell -> paragraphs.addAll(cell.getParagraphs()));
+ return paragraphs;
+ }
+
+ /** Every cell, nested tables' included: a row in a panel is a table in the panel's cell. */
+ private static List allCells(XWPFDocument document) {
+ List cells = new java.util.ArrayList<>();
+ document.getTables().forEach(table -> collectCells(table, cells));
+ return cells;
+ }
+
+ private static void collectCells(org.apache.poi.xwpf.usermodel.XWPFTable table, List cells) {
+ table.getRows().forEach(row -> row.getTableCells().forEach(cell -> {
+ cells.add(cell);
+ cell.getTables().forEach(nested -> collectCells(nested, cells));
+ }));
+ }
+
+ private static XWPFParagraph ruleParagraph(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr())
+ .findFirst().orElseThrow();
+ }
+
+ private static XWPFTableCell onlyCell(XWPFDocument document) {
+ assertThat(document.getTables()).hasSize(1);
+ return document.getTables().get(0).getRow(0).getCell(0);
+ }
+
+ private static String shading(XWPFTableCell cell) {
+ var properties = cell.getCTTc().getTcPr();
+ return properties == null || !properties.isSetShd() ? null : hex(properties.getShd().getFill());
+ }
+
+ private static String runShading(XWPFRun run) {
+ CTRPr properties = run.getCTR().getRPr();
+ return properties.sizeOfShdArray() == 0 ? null : hex(properties.getShdArray(0).getFill());
+ }
+
+ /** A text fill as {@code RRGGBB@transparency}, the transparency empty for an opaque one; null for none. */
+ private static String textFill(XmlObject properties) {
+ if (properties == null) {
+ return null;
+ }
+ XmlObject[] fills = properties.selectChildren(new QName(W14, "textFill"));
+ if (fills.length == 0) {
+ return null;
+ }
+ assertThat(fills).as("one text fill").hasSize(1);
+ XmlObject colour = fills[0].selectChildren(new QName(W14, "solidFill"))[0]
+ .selectChildren(new QName(W14, "srgbClr"))[0];
+ XmlObject[] alpha = colour.selectChildren(new QName(W14, "alpha"));
+ return attribute(colour) + "@" + (alpha.length == 0 ? "" : attribute(alpha[0]));
+ }
+
+ private static String attribute(XmlObject element) {
+ try (org.apache.xmlbeans.XmlCursor cursor = element.selectAttribute(new QName(W14, "val")).newCursor()) {
+ return cursor.getTextValue();
+ }
+ }
+
+ /** XmlBeans hands an ST_HexColor back as bytes, so read it as the colour it encodes. */
+ private static String hex(Object value) {
+ if (value instanceof byte[] bytes) {
+ StringBuilder text = new StringBuilder();
+ for (byte part : bytes) {
+ text.append(String.format("%02X", part & 0xFF));
+ }
+ return text.toString();
+ }
+ return String.valueOf(value);
+ }
+}
From 01097ee242f5cb7521b2aabeb34bb84268666070 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 7 Oct 2026 00:03:12 +0100
Subject: [PATCH 2/2] fix(docx): settle a header's and a footer's text fills,
and set content on the panel as written
The header and footer parts made in an export are found through the
document's relations: POI lists in getHeaderList() and getFooterList() only
the parts it read, so their text fills were not moved last and their roots
not marked mc:Ignorable.
Inside a panel or cell the export shaded, a chip, a rule, a panel and a
table cell flatten against that shading as written, so a panel flattened at
its centre is one colour wherever its content stands; the layout is read on
the page. The empty index with no layout is not asked for a cell.
---
CHANGELOG.md | 20 +--
.../architecture/backend-capability-matrix.md | 4 +-
docs/recipes/docx-export.md | 43 +++---
docs/recipes/translucency.md | 4 +-
render-docx/README.md | 7 +-
.../semantic/docx/DocxLayoutMetrics.java | 4 +
.../semantic/docx/DocxSemanticBackend.java | 24 +--
.../semantic/docx/DocxTranslucency.java | 36 +++--
.../semantic/docx/DocxTranslucencyTest.java | 138 +++++++++++++++++-
9 files changed, 213 insertions(+), 67 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d5cb697b..caedcc4fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,13 +18,14 @@ follow semantic versioning; release dates are ISO 8601.
each with the fill's transparency, so both draw the page's tint.
- A translucent body colour is the Normal style's. A run, or a list's marker, of an opaque
colour writes an opaque fill of its own, so it does not take the style's.
- - The fill is the last of a run's properties, and a part holding one marks its namespace
- `mc:Ignorable`.
+ - The fill is the last of a run's properties, and each part holding one — the body, the
+ styles, the numbering, a header or footer — marks its namespace `mc:Ignorable`.
- Word saves such text into a PDF as drawing, without a text layer.
- **Where Word holds an opaque colour only** — a cell's shading, a border, a rule — the colour
- is flattened against what the page paints under it, read from the layout: the fills drawn
- before the block at its centre, a page background included, a row's own fill (which is not
- written) left out. Each is named in the report as `translucency`:
+ is flattened against what Word paints under it. Inside a panel or cell the export shaded, that
+ is the shading as written; on the page, it is read from the layout: the fills drawn before the
+ block at its centre, a page background included, a row's own fill (which is not written) left
+ out. Each is named in the report as `translucency`:
- a panel's fill and borders, once per panel;
- a table cell's fill and rules, once per table;
- a rule drawn as a paragraph border;
@@ -34,11 +35,10 @@ follow semantic versioning; release dates are ISO 8601.
page draws them over.
- A wholly transparent fill writes no shading; a wholly transparent border is drawn in the
colour under it, so it keeps its room in the row.
- - A chip's shading is flattened against what the page paints under its paragraph where no
- shading of the file's is under it, a page background included.
- - Where a picture, a barcode, a gradient or a fill under a transform is under the block, or it
- is composed in a table cell, the surface it is written on stands in: the flattened panel or
- cell around it, or white.
+ - A chip's shading is flattened the same way where its paragraph has no shading of its own,
+ and named on its `inline chip` note.
+ - On the page, where a picture, a barcode, a gradient or a fill under a transform is under the
+ block, or it is composed in a table cell, white stands in.
- Drawings, page backgrounds and pictures already kept their alpha.
Across the DOCX fidelity corpus one document's bytes change: `NavySidebar`'s four sidebar
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 84f4be16a..a49219db7 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -70,7 +70,7 @@ Payload records live in `core` under
| 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`) |
| 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: 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). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — 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. 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 the colour the page paints under the panel (`DocxLayoutMetrics.colourUnder`: the fills the layout draws before it, a page background included and a row's own fill, which is not written, left out, at its centre), and a translucent border 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, 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; 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` |
+| 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: 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). A panel with less space above it than its top border — such as one opening a table cell — takes what of the border neither that space nor its padding holds from the space above its first line, where that line has some, and where its row holds the page's height that height is less the borders Word draws outside it, since Word starts a cell's content below its top border or its top margin, whichever is wider (LibreOffice, where `MerchantInvoice`'s panel's content sets its height, draws it that border's width shorter). The table takes the width the layout placed the container at, plus a point of editor slack; in a cell, a panel whose left border hangs half its width left of the cell's text gives up what runs past the cell's edge as Word starts it, half that border in — 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. 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, which is not written, left out). 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, 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; 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 is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, clamped to Word's 12pt), its ends as the paragraph's indents and the space above and below the stroke in its box as the paragraph's height and the space owed below it; 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). 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 — the dash pattern, cap 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, anchored as a rectangle is |
@@ -86,7 +86,7 @@ Payload records live in `core` under
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning; a turned shape container is written upright, and the report names its transform — in its drawn outline's note, or on its own where the outline draws nothing |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ✅ `DocxSemanticBackend` — an anchor becomes a `w:bookmarkStart` / `w:bookmarkEnd` pair wrapping the paragraph's text, named as Word requires (letters, digits and underscores, starting with a letter, 40 characters); two anchors that clean to one name stay two bookmarks |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ✅ `DocxSemanticBackend` — the stated outline level becomes Word's own `HeadingN` style, so the Navigation Pane, the outline view and a generated table of contents all see the document's structure. The style carries the outline level and no formatting, so the paragraph keeps the look its author gave it; only the levels the document uses are defined, and one past Word's nine is clamped. The role is never inferred from type size |
-| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against the colour the page paints under it — from the layout, a page background included and a row's unwritten fill left out; a border or rule against its block's own fill; the surface it is written on where a picture, barcode, gradient or transformed fill is under it, or it is composed in a table cell; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer |
+| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState` on every surface — shape fills/strokes, text runs, lines, side borders, table paint) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ⚠️ `DocxTranslucency` — text keeps its alpha as Word's text fill (`w14:textFill` with a transparency, the last of the run's properties, its namespace marked `mc:Ignorable` on the part; `w:color` keeps the colour as authored, which LibreOffice reads with the fill's transparency; an opaque run or list marker under a translucent Normal style writes an opaque fill of its own), and a drawing's fill and outline, a page background and a picture keep theirs; a cell's shading, a border, a rule, a run's shading (a chip) and a text header's separator hold an opaque colour only, so a translucent one is flattened against what Word paints under it — inside a panel or cell the export shaded, that shading as written; on the page, the colour the layout paints there, a page background included and a row's unwritten fill left out, or white where a picture, barcode, gradient or transformed fill is under it; a panel's border or a cell's rule against the block's own fill; white under a separator — and named in the report as `translucency` (a chip's on its `inline chip` note). Word saves translucent text into a PDF as drawing, with no text layer |
| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ✅ `PdfTextDecorations` (em-proportional marks: underline −0.10 em, strikethrough +0.28 em, thickness 0.05 em) | ✅ `PptxTextFrames.applyStyle` (PowerPoint draws its own marks — sub-point placement differences vs the PDF's constants) | ✅ `DocxSemanticBackend.applyStyle` (underline maps to Word's single underline, strikethrough to `w:strike`) |
| Writing direction — right-to-left paragraphs (`ParagraphBuilder.direction`, `TextDirection`) | ✅ `ParagraphWrapping` resolves the line with the Unicode Bidirectional Algorithm and `PdfParagraphFragmentRenderHandler` draws it reordered — the page is painted, so the engine owns the order | ⚠️ `PptxParagraphFragmentRenderHandler` — a right-to-left line goes through **per-span absolute frames** rather than one flowing frame, each pinned where the layout put it, because a shared frame lets PowerPoint re-flow the runs and undo the resolved order. Every frame this handler emits — plain span and chip text alike — declares its direction (`rtl`), which is what puts a neutral on the correct side. A table cell declares it too, through the overload of `PptxTextFrames.singleRunBox` that takes a direction. A header/footer and a watermark still take the overload that declares nothing, so right-to-left text there shows the original defect. The deviation is that the line is not one editable paragraph, and that the text a reader copies out carries mirrored punctuation (see the mirroring row) | ✅ `DocxSemanticBackend.applyParagraphProperties` writes `w:bidi` (resolving `AUTO` through the same `ParagraphDirection` the page used) and hands Word logical text for its own bidi engine, which orders and joins it. Every run of that paragraph also carries `w:rtl`: `w:bidi` settles which edge the line starts from, `w:rtl` settles how Word resolves the characters inside a run, and a run without it is handled as Latin — measured in Word, `(2026)` closing an Arabic line was drawn as `)2026(` with `w:bidi` alone. Hebrew was unaffected, so the defect needed Arabic, where digits after a letter resolve as an Arabic number. Alignment is mapped through the direction, because Word reads `w:jc`'s left/right as start/end **relative to the paragraph** — written physically, a flush-right right-to-left paragraph came out flush left. The indents that carry a container's margin and padding, and a rail timeline body's column, are mapped the same way (`applyDirection` swaps `w:ind` `left` and `right`): Word and LibreOffice both read them as start/end in a `w:bidi` paragraph, and `w:start`/`w:end` read the same — measured, a Hebrew paragraph in a section padded on the left ended short of the right margin by the padding in both. Size and weight are written to the complex-script twins (`w:szCs`, `w:bCs`, `w:iCs`) as well as the Latin ones, since Word takes Hebrew and Arabic from those. Column order in a right-to-left table is not mirrored: `w:tblPr/w:bidiVisual` is unwritten |
| Arabic contextual shaping — joined letter forms (`ArabicShaper`) | ✅ shaped into Presentation Forms-B before measurement, because `showText` walks the font `cmap` and never runs `GSUB`; a font carrying the letters but not the forms degrades to unjoined base letters rather than `?` | ✅ base letters restored (`PptxParagraphFragmentRenderHandler` → `ArabicShaper.toBaseLetters`, joining controls kept) — PowerPoint shapes Arabic itself, and frozen forms would land in a file users search and copy from | ✅ never shaped — Word receives the letters and shapes them itself |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index ef7f353a3..1a682d046 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -67,7 +67,7 @@ creation date is real metadata.
| Document node | DOCX output |
|---|---|
-| Paragraphs | Word paragraphs with alignment, font, size, colour — a translucent one with its transparency, as Word's text fill (see "Translucent colours") —, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, and drops its marks; the export writes the text as authored, marks and all, which it does not yet write as the page sets it, and the report names it — and, where the paragraph's lines are not read, composed in a table cell whose text the page set otherwise or with no layout, says whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where Word, to its half point, holds them apart, on the paragraph or, in a header or footer, on the zone's note. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
+| Paragraphs | Word paragraphs with alignment, font, size, colour (a translucent one with its transparency, as Word's text fill — see "Translucent colours"), bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. A session reads markdown unless told not to (`markdown(false)`): it sets a paragraph of plain text's emphasis marks as the style of the text they mark, and drops its marks; the export writes the text as authored, marks and all, which it does not yet write as the page sets it, and the report names it — and, where the paragraph's lines are not read, composed in a table cell whose text the page set otherwise or with no layout, says whether the page reads its marks is not measured, where the page's parser drops one. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where Word, to its half point, holds them apart, on the paragraph or, in a header or footer, on the zone's note. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
@@ -664,31 +664,34 @@ wherever Word holds an alpha, and is flattened where it does not:
Word 16.0 and LibreOffice). A body colour that is translucent is the Normal style's, which
both editors take the fill from; a run of another, opaque colour writes an opaque text fill
of its own, as does a list's marker, so neither takes the style's. The fill is the last of
- a run's properties, as Word writes it, and a part holding one marks Word 2010's namespace
- as one a reader that does not know it may skip (`mc:Ignorable`). Word sets such text as drawing when it saves the file as a PDF, so that PDF holds no
- text layer for it; the Word file holds the text.
+ a run's properties, as Word writes it, and each part holding one — the body, the styles,
+ the numbering, a header or footer — marks Word 2010's namespace as one a reader that does
+ not know it may skip (`mc:Ignorable`). Word sets such text as drawing when it saves the
+ file as a PDF, so that PDF holds no text layer for it; the Word file holds the text.
- **Drawings and pictures** keep it: a shape's fill and outline carry their alpha in
DrawingML, and a page background's too; an inline shape, an icon and a barcode are
pictures with an alpha channel.
- **A cell's shading, a border and a rule** hold an opaque colour only. A translucent panel
- fill, a table cell's fill and a rule drawn as a paragraph border are flattened against the
- colour the page paints under them — the fills the layout draws before them, composited at
- the block's centre — so the file shows on first opening the colour the PDF shows: a white
- rule at half strength over a navy sidebar is a pale navy, not white. A page background
- counts; a row's own fill does not, as the export does not write it (`row paint`). A panel's
- borders and a cell's rules are flattened against the block's own fill where it has one,
- since the page draws them over it. A wholly transparent fill is no shading; a wholly
- transparent border is drawn in the colour under it, so it keeps its room in the row.
-- **Where the layout cannot tell what is under** — a picture, a barcode or a gradient there,
- a fill drawn under a transform, content composed in a table cell, or no layout at all — the
- surface the block is written on stands in for it: the flattened panel or cell around it, or
- white. A text header's or footer's separator runs across the page, over whatever it
- crosses, and is flattened against white.
+ fill, a table cell's fill and a rule drawn as a paragraph border are flattened against
+ what Word paints under them, so the file shows on first opening the colour the PDF shows.
+ Inside a panel or a cell the export shaded, that is the shading as written: a panel
+ flattened at its centre is one colour wherever its content stands. On the page, it is the
+ colour the page paints there — the fills the layout draws before the block, composited at
+ its centre: a white rule at half strength over a navy sidebar is a pale navy, not white. A
+ page background counts; a row's own fill does not, as the export does not write it (`row
+ paint`). A panel's borders and a cell's rules are flattened against the block's own fill
+ where it has one, since the page draws them over it. A wholly transparent fill is no
+ shading; a wholly transparent border is drawn in the colour under it, so it keeps its room
+ in the row.
+- **Where the page's colour is not known** — a picture, a barcode or a gradient there, a fill
+ drawn under a transform, content composed in a table cell, or no layout at all — white
+ stands in for it. A text header's or footer's separator runs across the page, over
+ whatever it crosses, and is flattened against white.
Each flattened colour is named in the export report as `translucency` — once for a panel,
-once for a table, for each rule and each separator; a chip's on its `inline chip` note. What
-it stops being is translucent: recolour what is under it in Word and it keeps the colour it
-was flattened to.
+once for a table, for each rule, and once for each separator, however many headers it is
+written into; a chip's on its `inline chip` note. What it stops being is translucent:
+recolour what is under it in Word and it keeps the colour it was flattened to.
## What falls back
diff --git a/docs/recipes/translucency.md b/docs/recipes/translucency.md
index fae02d41c..188e51421 100644
--- a/docs/recipes/translucency.md
+++ b/docs/recipes/translucency.md
@@ -34,8 +34,8 @@ In the PDF backend, alpha applies on every surface:
The semantic DOCX export keeps the alpha where Word can hold it — text, as
Word's text fill, and drawings and pictures — and flattens it where Word
holds an opaque colour only: a table cell's shading, a border, a rule. A
-flattened colour is composited against what the page paints under it, so it
-looks as the PDF does on first opening, and the export report names it. See
+flattened colour is composited against what Word paints under it, so it looks
+as the PDF does on first opening, and the export report names it. See
[Translucent colours](docx-export.md#translucent-colours) in the DOCX recipe.
## Opaque colours stay byte-identical
diff --git a/render-docx/README.md b/render-docx/README.md
index 10d625329..f50ad716c 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -112,9 +112,10 @@ What is not written — each one is named in the export report
- **Watermarks, protection and viewer preferences**, each named in the export report.
- **A row's own fill, outline and side borders**, named in the export report as `row paint`.
- **Translucency where Word holds an opaque colour only** — a cell's shading, a border, a rule,
- a text header's separator, a chip's run shading: the colour is flattened against what the page
- paints under it and named in the export report (`translucency`, a chip's on its `inline chip`
- note). Text, drawings and pictures keep their alpha.
+ a chip's run shading: the colour is flattened against what Word paints under it — the panel or
+ cell the export shaded, or what the page paints there — and a text header's separator against
+ white; each is named in the export report (`translucency`, a chip's on its `inline chip` note).
+ Text, drawings and pictures keep their alpha.
- **In a page zone, anything but paragraphs, page fields and spacers** — a logo, a barcode,
a rule — named in the export report as `page zone content`.
- **Where a page zone's parts stand on Word's line, and what a paragraph in it loses of its
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 785b9adb7..077302ee7 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -1253,6 +1253,10 @@ java.util.Optional colourUnder(String path) {
* @return that colour, or empty where nothing tells, as {@link #colourUnder(DocumentNode)}
*/
java.util.Optional colourUnderCell(DocumentNode table, int row, int column) {
+ if (isEmpty()) {
+ // Nothing to tell, and the index with no layout is shared: its caches stay empty.
+ return java.util.Optional.empty();
+ }
List boxes = cellBoxes(table, row, column);
if (boxes.isEmpty()) {
return java.util.Optional.empty();
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 1caf92a3e..041c611a3 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
@@ -1480,7 +1480,8 @@ private void writeBand(XWPFHeaderFooter part, DocumentHeaderFooter band, boolean
? (borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom())
: (borders.isSetTop() ? borders.getTop() : borders.addNewTop());
// The separator runs the width of the page, over whatever fills the page draws across it,
- // so there is no one colour under it to flatten a translucent one against but the page's.
+ // so there is no one colour under it to flatten a translucent one against but the page's
+ // white.
java.awt.Color colour = band.getSeparatorColor().color();
paintEdge(edge, STBorder.SINGLE, BigInteger.valueOf(ruleEighths(band.getSeparatorThickness())),
toHexColor(DocxTranslucency.flatten(colour, java.awt.Color.WHITE)));
@@ -9058,8 +9059,8 @@ private static String chipLost(InlineBackground background, String leftAs, boole
/**
* The colour Word will paint under {@code run} — the shading this export itself wrote
- * on the run's paragraph or on the cell holding it; then, where neither is shaded, what the
- * page paints under the paragraph ({@link #colourUnder(DocumentNode)}).
+ * on the run's paragraph, on the cell holding it, or on the panel or cell around an unshaded
+ * one; on the page, what the page paints under the paragraph ({@link #colourUnder(DocumentNode)}).
*
* Read back from the file being written rather than tracked in a field, so it is
* whatever was actually written and cannot drift from it. Read, and only read:
@@ -9087,16 +9088,17 @@ private java.awt.Color colourUnder(XWPFRun run, String path) {
if (cellFill != null) {
return cellFill;
}
- return layout.colourUnder(path).orElseGet(this::surfaceColour);
+ return surfaceBehind != null ? surfaceBehind.color() : layout.colourUnder(path).orElse(java.awt.Color.WHITE);
}
/**
- * The colour the page paints under a node, at its centre, where the layout tells it
- * ({@link DocxLayoutMetrics#colourUnder(DocumentNode)}); otherwise the surface this export
- * set the node on — the flattened fill of the panel or cell around it — or the page's white.
+ * The colour Word paints under a node: inside a panel or cell this export shaded, that
+ * shading as written — a panel flattened at its own centre is one colour wherever its
+ * content stands —; on the page, what the page paints under the node at its centre, where
+ * the layout tells it ({@link DocxLayoutMetrics#colourUnder(DocumentNode)}), or white.
*/
private java.awt.Color colourUnder(DocumentNode node) {
- return layout.colourUnder(node).orElseGet(this::surfaceColour);
+ return surfaceBehind != null ? surfaceBehind.color() : layout.colourUnder(node).orElse(java.awt.Color.WHITE);
}
/** The fill of the panel or cell being written into, as written; the page's white outside one. */
@@ -10533,9 +10535,9 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except
boolean translucentRule = DocxTranslucency.flattensStroke(stroke);
translucentFills |= translucentFill;
translucentRules |= translucentRule;
- java.awt.Color under = translucentFill || translucentRule
- ? layout.colourUnderCell(node, placement.row(), placement.column()).orElseGet(this::surfaceColour)
- : java.awt.Color.WHITE;
+ java.awt.Color under = !(translucentFill || translucentRule) ? java.awt.Color.WHITE
+ : surfaceBehind != null ? surfaceBehind.color()
+ : layout.colourUnderCell(node, placement.row(), placement.column()).orElse(java.awt.Color.WHITE);
DocumentColor fill = DocxTranslucency.flattenedFill(authoredFill, under);
// The page draws the rules over the cells' fills, in a pass after them.
applyCellPaint(cell, fill, DocxTranslucency.flattenedStroke(stroke, fill != null ? fill.color() : under));
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java
index 8d2ca1f32..b23fe4c19 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucency.java
@@ -171,7 +171,7 @@ static String hex(Color colour) {
*
*
Word reads the transparency — not the opacity — as hundred-thousandths: three quarters
* transparent is {@code 75000}. The fill declares its own namespace, so a part with no
- * translucent text is not touched; {@link #settle} puts it last among the run's properties,
+ * text fill is not touched; {@link #settle} puts it last among the run's properties,
* where Word writes it, once nothing more is written on them.
*
* A run follows its style's text fill where it writes none, and Word draws the fill's
@@ -249,11 +249,12 @@ static void settle(XWPFDocument document) {
if (document.getNumbering() != null && !document.getNumbering().getAbstractNums().isEmpty()) {
roots.add(parentOf(document.getNumbering().getAbstractNums().get(0).getCTAbstractNum()));
}
- for (XWPFHeaderFooter part : document.getHeaderList()) {
- roots.add(part._getHdrFtr());
- }
- for (XWPFHeaderFooter part : document.getFooterList()) {
- roots.add(part._getHdrFtr());
+ // From the document's relations: a header or footer made in this export is related to the
+ // document, but POI lists in getHeaderList() and getFooterList() only the parts it read.
+ for (org.apache.poi.ooxml.POIXMLDocumentPart part : document.getRelations()) {
+ if (part instanceof XWPFHeaderFooter headerOrFooter) {
+ roots.add(headerOrFooter._getHdrFtr());
+ }
}
for (XmlObject root : roots) {
if (root != null) {
@@ -281,18 +282,25 @@ private static void settlePart(XmlObject root) {
source.moveXml(target);
}
}
+ String ignorable;
try (XmlCursor cursor = root.newCursor()) {
- String ignorable = cursor.getAttributeText(IGNORABLE);
- if (ignorable == null) {
- cursor.toNextToken();
+ ignorable = cursor.getAttributeText(IGNORABLE);
+ if (ignorable != null && (" " + ignorable + " ").contains(" w14 ")) {
+ return;
+ }
+ // Declared first, each once, so the attribute below takes the prefixes they name.
+ boolean declaresW14 = W14.equals(cursor.namespaceForPrefix("w14"));
+ boolean declaresMc = MC.equals(cursor.namespaceForPrefix("mc"));
+ cursor.toNextToken();
+ if (!declaresW14) {
cursor.insertNamespace("w14", W14);
+ }
+ if (!declaresMc) {
cursor.insertNamespace("mc", MC);
- cursor.insertAttributeWithValue(IGNORABLE, "w14");
- } else if (!(" " + ignorable + " ").contains(" w14 ")) {
- cursor.setAttributeText(IGNORABLE, ignorable + " w14");
- cursor.toNextToken();
- cursor.insertNamespace("w14", W14);
}
}
+ try (XmlCursor cursor = root.newCursor()) {
+ cursor.setAttributeText(IGNORABLE, ignorable == null ? "w14" : ignorable + " w14");
+ }
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
index c0d32789f..8916ba6c6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTranslucencyTest.java
@@ -143,7 +143,7 @@ void aPanelsTranslucentBordersAreFlattenedAndNamed() throws Exception {
}
@Test
- void aWhollyTransparentFillIsNoShadingAndARuleKeepsItsRoomInTheColourUnder() throws Exception {
+ void aWhollyTransparentFillIsNoShadingAndABorderKeepsItsRoomInTheColourUnder() throws Exception {
try (Exported exported = export(navyPage(), page -> page
.addSection("Card", card -> card.fillColor(DocumentColor.rgba(0, 90, 200, 0))
.borders(DocumentBorders.all(DocumentStroke.of(DocumentColor.rgba(200, 0, 0, 0), 2)))
@@ -324,9 +324,7 @@ void aNestedItemsMarkTakesTheTextFillItsMarkerIsDrawnIn() throws Exception {
.addList(list -> list.name("Points").textStyle(DocumentTextStyle.DEFAULT
.withColor(DocumentColor.rgb(0, 0, 0)))
.addItem("Languages", child -> child.addItem("Java").addItem("Kotlin"))))) {
- XWPFParagraph nested = run(exported.document(), "Java").getParent() instanceof XWPFParagraph paragraph
- ? paragraph : null;
- assertThat(nested).isNotNull();
+ XWPFParagraph nested = (XWPFParagraph) run(exported.document(), "Java").getParent();
// A nested level states no style of its own, so its marker is drawn in the mark's.
assertThat(textFill(nested.getCTP().getPPr().getRPr())).as("an opaque fill over the faint style")
.isEqualTo("000000@");
@@ -365,7 +363,116 @@ void underAPictureTheSurfaceStandsInAndTheFillIsStillNamed() throws Exception {
}
@Test
- void withNoLayoutAFillIsFlattenedAgainstTheSurfaceItIsWrittenOn() throws Exception {
+ void everyPartHoldingATextFillEndsItsPropertiesWithItAndMarksItsNamespaceIgnorable() throws Exception {
+ // A translucent body colour is the Normal style's, so every opaque run anywhere — a header's
+ // and a footer's text, a page number, a table cell's — writes an opaque fill of its own.
+ DocumentTextStyle faint = DocumentTextStyle.DEFAULT.withColor(DocumentColor.rgba(0, 0, 0, 153));
+ DocumentTableStyle tinted = DocumentTableStyle.builder().textStyle(new DocumentTextStyle(
+ DocumentTextStyle.DEFAULT.fontName(), 10,
+ com.demcha.compose.document.style.DocumentTextDecoration.UNDERLINE, DocumentColor.rgba(200, 0, 0, 64)))
+ .build();
+ try (Exported exported = export(session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(10).leftText("Header").build())
+ .footer(DocumentHeaderFooter.builder().zone(DocumentHeaderFooterZone.FOOTER).height(20).fontSize(8)
+ .rightText("Page {page}").build()),
+ page -> page.addParagraph(p -> p.text("The body of the page is set in a faint black, and there is "
+ + "more of it than of anything else.").textStyle(faint))
+ .add(new com.demcha.compose.document.dsl.TableBuilder().name("Rota")
+ .columns(DocumentTableColumn.fixed(200))
+ .rowCells(DocumentTableCell.text("Underlined in the cell").withStyle(tinted)).build()))) {
+ assertThat(textFill(run(exported.document(), "Underlined in the cell").getCTR().getRPr()))
+ .as("a table cell's run").isEqualTo("C80000@74902");
+ java.util.Map parts = xmlParts(exported.bytes());
+ assertThat(parts.keySet()).as("the header and the footer hold fills")
+ .anyMatch(name -> name.startsWith("word/header") && parts.get(name).contains("w14:textFill"))
+ .anyMatch(name -> name.startsWith("word/footer") && parts.get(name).contains("w14:textFill"));
+ parts.forEach((name, xml) -> {
+ int fills = count(xml, " 0) {
+ String root = xml.substring(xml.indexOf('<', xml.indexOf("?>") + 2), xml.indexOf('>', xml.indexOf("?>") + 2));
+ assertThat(root).as(name + "'s root").contains("mc:Ignorable=\"w14\"");
+ assertThat(count(xml, "")).as(name + ": each fill last of its properties")
+ .isEqualTo(fills);
+ }
+ });
+ }
+ }
+
+ @Test
+ void aPanelSplitByAPageBreakIsOneColourAndNamedOnce() throws Exception {
+ try (Exported exported = export(navyPage(), page -> page.addSection("Card", card -> card.fillColor(HALF_BLUE)
+ .addParagraph("First").addPageBreak(pageBreak -> { }).addParagraph("Second")))) {
+ assertThat(exported.document().getTables()).as("a table a piece").hasSize(2)
+ .allSatisfy(table -> assertThat(shading(table.getRow(0).getCell(0))).isEqualTo("0E4184"));
+ assertThat(notes(exported)).containsExactly(FILL_NOTE);
+ }
+ }
+
+ @Test
+ void aMergedTranslucentCellIsFlattenedAsOne() throws Exception {
+ DocumentTableStyle tint = DocumentTableStyle.builder().fillColor(HALF_BLUE).build();
+ try (Exported exported = export(navyPage(), page -> page.add(new com.demcha.compose.document.dsl.TableBuilder()
+ .name("Rota").columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(100))
+ .rowCells(DocumentTableCell.text("A").withStyle(tint).rowSpan(2), DocumentTableCell.text("B"))
+ .rowCells(DocumentTableCell.text("C"))
+ .build()))) {
+ var table = exported.document().getTables().get(0);
+ assertThat(shading(table.getRow(0).getCell(0))).isEqualTo("0E4184");
+ assertThat(shading(table.getRow(1).getCell(0))).as("the merge's covered row").isEqualTo("0E4184");
+ assertThat(notes(exported)).containsExactly("its cells' fills are flattened against the colour under "
+ + "them, because a Word cell's shading and borders are opaque");
+ }
+ }
+
+ @Test
+ void aRuleInAFilledRowIsFlattenedAgainstWhatWordShows() throws Exception {
+ // The export does not write a row's own fill, so what Word shows under the rule is the page.
+ try (Exported exported = export(null, page -> page.addRow(row -> row.fillColor(NAVY)
+ .addLine(line -> line.horizontal(100).stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1)))
+ .addParagraph("Beside")))) {
+ CTBorder bottom = allParagraphs(exported.document()).stream()
+ .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr())
+ .findFirst().orElseThrow().getCTP().getPPr().getPBdr().getBottom();
+ // Black at 128/255 over white, not over the row's navy.
+ assertThat(hex(bottom.getColor())).isEqualTo("7F7F7F");
+ }
+ }
+
+ @Test
+ void contentInAFlattenedPanelIsSetOnThePanelAsWritten() throws Exception {
+ // The panel stands over the page's navy column and its white side, and is flattened at its
+ // centre, over the navy: a chip in its right half composites over the panel Word paints there,
+ // not over the white the page has under the chip.
+ try (Exported exported = export(session -> session.pageBackgrounds(List.of(
+ PageBackgroundFill.leftColumn(0.6, NAVY))),
+ page -> page.addSection("Card", card -> card.fillColor(HALF_BLUE)
+ .addRow(row -> row.addParagraph("Left")
+ .addParagraph(p -> p.inlineText("Call ").inlineCode("render()")))
+ .addRow(row -> row.addParagraph("Left").addLine(line -> line.horizontal(100)
+ .stroke(DocumentStroke.of(DocumentColor.rgba(0, 0, 0, 128), 1))))
+ // A table in the panel's right half, over the page's white side.
+ .add(new com.demcha.compose.document.dsl.TableBuilder().name("Tint")
+ .columns(DocumentTableColumn.fixed(80)).margin(new DocumentInsets(0, 0, 0, 260))
+ .rowCells(DocumentTableCell.text("Cell").withStyle(DocumentTableStyle.builder()
+ .fillColor(HALF_BLUE).build())).build())))) {
+ XWPFTableCell tinted = allCells(exported.document()).stream()
+ .filter(cell -> cell.getText().contains("Cell") && cell.getTables().isEmpty())
+ .findFirst().orElseThrow();
+ // 0/90/200 at 128/255 over 14/65/132.
+ assertThat(shading(tinted)).as("a translucent cell in the panel").isEqualTo("074EA6");
+ assertThat(shading(allCells(exported.document()).get(0))).isEqualTo("0E4184");
+ // 175/184/193 at 51/255 over 14/65/132.
+ assertThat(runShading(run(exported.document(), "render("))).isEqualTo("2E5990");
+ CTBorder rule = allParagraphs(exported.document()).stream()
+ .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetPBdr())
+ .findFirst().orElseThrow().getCTP().getPPr().getPBdr().getBottom();
+ // Black at 128/255 over 14/65/132, not over the panel-on-white the page has under the rule.
+ assertThat(hex(rule.getColor())).isEqualTo("072042");
+ }
+ }
+
+ @Test
+ void withNoLayoutATranslucentFillIsStillNamed() throws Exception {
DocxExportReport report = DocxExports.reportWithoutLayout(400, 400, 20, page -> page
.addSection("Card", card -> card.fillColor(HALF_BLUE).addParagraph("Inside")));
assertThat(report.bySubject().get("translucency")).extracting(DocxExportReport.Note::detail)
@@ -383,6 +490,27 @@ public void close() throws Exception {
}
}
+ /** The package's XML parts, by name. */
+ private static java.util.Map xmlParts(byte[] docx) throws Exception {
+ java.util.Map parts = new java.util.TreeMap<>();
+ try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream(new ByteArrayInputStream(docx))) {
+ for (java.util.zip.ZipEntry entry; (entry = zip.getNextEntry()) != null; ) {
+ if (entry.getName().endsWith(".xml")) {
+ parts.put(entry.getName(), new String(zip.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
+ }
+ }
+ }
+ return parts;
+ }
+
+ private static int count(String text, String of) {
+ int count = 0;
+ for (int at = text.indexOf(of); at >= 0; at = text.indexOf(of, at + of.length())) {
+ count++;
+ }
+ return count;
+ }
+
private static byte[] png() {
try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(40, 40,