A direct run property beats a style, so an export where every run carries its own
+ * size and font accepts "change the Normal style" and then changes nothing — measured in
+ * Word before this landed. Writing a styles part is only half of it; the other half is
+ * leaving the runs that agree with it silent.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxDocumentStyleTest {
+
+ private static final DocumentTextStyle BODY = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA).size(10.5).color(DocumentColor.rgb(24, 28, 38)).build();
+ private static final DocumentTextStyle HEADING = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA_BOLD).size(18).color(DocumentColor.rgb(24, 28, 38)).build();
+
+ private static final String LONG_BODY =
+ "A body paragraph long enough that its characters outweigh the headings around "
+ + "it, which is how the document default is chosen.";
+
+ @Test
+ void documentShouldCarryAStylesPartNamingItsOwnBodyText() throws Exception {
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("Heading").textStyle(HEADING));
+ page.addParagraph(p -> p.text(LONG_BODY).textStyle(BODY));
+ })) {
+ assertThat(document.getStyles())
+ .as("a styles part exists at all")
+ .isNotNull();
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getRFontsArray(0).getAscii()).isEqualTo("Helvetica");
+ // w:sz counts half-points, so 10.5pt is 21.
+ assertThat(defaults.getSzArray(0).getVal().toString()).isEqualTo("21");
+ }
+ }
+
+ @Test
+ void aRunThatOnlyRestatesTheStyleShouldSayNothing() throws Exception {
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("Heading").textStyle(HEADING));
+ page.addParagraph(p -> p.text(LONG_BODY).textStyle(BODY));
+ })) {
+ CTRPr body = runProperties(document, LONG_BODY);
+ assertThat(body == null || body.sizeOfSzArray() == 0)
+ .as("the body run leaves its size to Normal")
+ .isTrue();
+ assertThat(body == null || body.sizeOfRFontsArray() == 0)
+ .as("and its font too")
+ .isTrue();
+ }
+ }
+
+ @Test
+ void aRunThatDiffersShouldKeepSayingSo() throws Exception {
+ try (XWPFDocument document = exported(page -> {
+ page.addParagraph(p -> p.text("Heading").textStyle(HEADING));
+ page.addParagraph(p -> p.text(LONG_BODY).textStyle(BODY));
+ })) {
+ CTRPr heading = runProperties(document, "Heading");
+ assertThat(heading).isNotNull();
+ // 18pt in half-points. A heading must not be swallowed by the body style.
+ assertThat(heading.getSzArray(0).getVal().toString()).isEqualTo("36");
+ assertThat(heading.getRFontsArray(0).getAscii()).isEqualTo("Helvetica-Bold");
+ }
+ }
+
+ @Test
+ void theDefaultShouldBeChosenByCharactersNotByParagraphCount() throws Exception {
+ // Four short headings against one long body paragraph. Counting paragraphs elects
+ // the heading style and leaves every body run carrying a direct size; counting
+ // characters elects the body, which is what a reader means by "the body style".
+ try (XWPFDocument document = exported(page -> {
+ for (int i = 0; i < 4; i++) {
+ page.addParagraph(p -> p.text("H").textStyle(HEADING));
+ }
+ page.addParagraph(p -> p.text(LONG_BODY).textStyle(BODY));
+ })) {
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getSzArray(0).getVal().toString())
+ .as("the long body paragraph outweighs four short headings")
+ .isEqualTo("21");
+ }
+ }
+
+ @Test
+ void stylesThatOnlyDifferByColourIdentityShouldWeighAsOne() throws Exception {
+ // DocumentColor has no value equality and DocumentTextStyle is a record, so two
+ // styles built the same way with separately-constructed colours are unequal.
+ // Building the style inline per paragraph is ordinary authoring, and if each one
+ // becomes its own weight the body's characters never add up: here six body
+ // paragraphs of ten characters would weigh ten each, losing to three headings
+ // sharing one instance, and the document default would come out the heading's.
+ try (XWPFDocument document = exported(page -> {
+ for (int i = 0; i < 3; i++) {
+ page.addParagraph(p -> p.text("Heading of twenty ch").textStyle(HEADING));
+ }
+ for (int i = 0; i < 6; i++) {
+ page.addParagraph(p -> p.text("Body line.").textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(10.5)
+ .color(DocumentColor.rgb(24, 28, 38))
+ .build()));
+ }
+ })) {
+ CTRPr defaults = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(defaults.getSzArray(0).getVal().toString())
+ .as("sixty characters of body outweigh sixty of heading only if the "
+ + "body's styles weigh as one")
+ .isEqualTo("21");
+ }
+ }
+
+ @Test
+ void theStyleShouldNameTheFamilyForEveryCharacterRangeNotJustAscii() throws Exception {
+ // A run suppressed in favour of Normal carries no rFonts at all, and w:ascii only
+ // covers ASCII: High-ANSI letters read w:hAnsi, Hebrew and Arabic read w:cs, CJK
+ // reads w:eastAsia. Naming one slot would send every accented letter and every
+ // complex script to Word's theme font while the rest of the line stayed correct.
+ try (XWPFDocument document = exported(page ->
+ page.addParagraph(p -> p.text(LONG_BODY).textStyle(BODY)))) {
+
+ var fonts = document.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr().getRFontsArray(0);
+ assertThat(fonts.getAscii()).isEqualTo("Helvetica");
+ assertThat(fonts.getHAnsi()).as("High-ANSI, e.g. é").isEqualTo("Helvetica");
+ assertThat(fonts.getCs()).as("complex script, e.g. Hebrew").isEqualTo("Helvetica");
+ assertThat(fonts.getEastAsia()).as("CJK").isEqualTo("Helvetica");
+ }
+ }
+
+ @Test
+ void aDocumentWithoutTextShouldNotInventAStyle() throws Exception {
+ try (XWPFDocument document = exported(page -> page.spacer(10, 10))) {
+ assertThat(document.getStyles())
+ .as("nothing to take a default from, so no styles part is written")
+ .isNull();
+ }
+ }
+
+ private static CTRPr runProperties(XWPFDocument document, String text) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ if (paragraph.getText().contains(text)) {
+ XWPFRun run = paragraph.getRuns().get(0);
+ return run.getCTR().getRPr();
+ }
+ }
+ throw new AssertionError("no paragraph containing: " + text);
+ }
+
+ private static XWPFDocument exported(
+ Consumer content) throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 400)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(content::accept);
+ docx = session.export(new DocxSemanticBackend());
+ }
+ return new XWPFDocument(new ByteArrayInputStream(docx));
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java
index 8019f0c48..2f606716b 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxHangingIndentIsIgnoredTest.java
@@ -4,7 +4,6 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.ListBuilder;
import com.demcha.compose.document.dsl.PageFlowBuilder;
-import com.demcha.compose.document.node.ListMarker;
import com.demcha.compose.document.style.DocumentInsets;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
@@ -12,38 +11,40 @@
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
import java.util.List;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
/**
- * {@code hangingIndent} is fixed-layout geometry, and the semantic DOCX export
- * does not lay text out — so it exports a list exactly the same way whether the
- * flag is set or not.
+ * {@code hangingIndent} is fixed-layout geometry, and the semantic DOCX export does not
+ * lay text out — so it exports a list exactly the same way whether the flag is set or
+ * not.
*
- * That is a decision rather than an omission, and it was made against
- * measurements. Word places content at absolute indents and has no way to be told
- * "start the text one marker width plus a gap from here"; every mechanism that
- * looks like it would (a hanging indent, a hanging indent with a tab stop, real
- * Word numbering) positions content absolutely, so the distance left beside the
- * marker is always the column minus the marker's own width — a number only Word
- * knows. Reproducing the geometry would mean measuring the marker, and the
- * semantic backend has no font runtime to measure with, by design: its only
- * dependencies are the core model and POI.
+ * That is a decision rather than an omission, and it was made against measurements.
+ * Word places content at absolute indents and has no way to be told "start the text one
+ * marker width plus a gap from here"; every mechanism that looks like it would (a hanging
+ * indent, a hanging indent with a tab stop, real Word numbering) positions content
+ * absolutely, so the distance left beside the marker is always the column minus the
+ * marker's own width — a number only Word knows. Reproducing the geometry would mean
+ * measuring the marker, and the semantic backend has no font runtime to measure with, by
+ * design: its only dependencies are the core model and POI.
*
- * The approximations were built and rendered through Word before being
- * rejected. A reserved-column approximation renders a gap that is not the
- * configured one — a 0pt gap came out as 5.72pt, a 4pt gap as 9.68pt — and a
- * marker wider than the column misaligns outright, its tab overshooting to Word's
- * default half-inch grid while the wrapped lines stay at the paragraph indent.
- * Shipping that would mean {@code markerGap(8)} rendering as something other
+ *
The approximations were built and rendered through Word before being rejected. A
+ * reserved-column approximation renders a gap that is not the configured one — a 0pt gap
+ * came out as 5.72pt, a 4pt gap as 9.68pt — and a marker wider than the column misaligns
+ * outright. Shipping that would mean {@code markerGap(8)} rendering as something other
* than 8.
*
- * So what this test pins is that the DOCX output is unchanged, and
- * that nothing is lost: same paragraphs, same text, same nesting, no stray
- * indentation properties. If native DOCX list geometry is built later, this test
- * is the one that has to be deliberately rewritten.
+ * A list is real Word numbering now, which the old wording of this test read
+ * as ruling out. It does not: numbering was rejected as a way to honour {@code markerGap},
+ * and it still does not honour it. What it buys is behaviour — Enter continues the list —
+ * at the price of a marker column that is a stated constant rather than the configured
+ * gap. So the decision this test pins is unchanged and its subject is narrower than it
+ * was: not "no numbering", but "the flag and the gap change nothing about the output".
+ *
+ * @author Artem Demchyshyn
*/
class DocxHangingIndentIsIgnoredTest {
@@ -54,29 +55,37 @@ void aFlatListExportsIdenticallyWithAndWithoutHangingIndent() throws Exception {
assertThat(listTexts(shape.andThen(l -> l.hangingIndent(true).markerGap(16))))
.isEqualTo(listTexts(shape))
- .containsExactly("• Alpha", "• Beta", "• Gamma");
+ .containsExactly("Alpha", "Beta", "Gamma");
}
@Test
- void aMultiCharacterMarkerExportsIdenticallyAndKeepsItsMarkerInTheText() throws Exception {
+ void aMultiCharacterMarkerExportsIdenticallyAndBecomesTheLevelText() throws Exception {
Consumer shape = list -> list.name("Wide").marker("=>").items("Alpha");
assertThat(listTexts(shape.andThen(l -> l.hangingIndent(true).markerGap(8))))
.isEqualTo(listTexts(shape))
- .containsExactly("=> Alpha");
+ .containsExactly("Alpha");
+ assertThat(markerPerDepth(shape.andThen(l -> l.hangingIndent(true).markerGap(8))))
+ .as("a wide marker is the level's text, whatever the flag says")
+ .containsExactly("=>");
}
@Test
void theGapIsNotRepresentedAtAllSoEveryValueExportsTheSame() throws Exception {
- // If the gap ever leaked into DOCX as spaces or indentation, these would
- // stop agreeing — which is the failure this test exists to catch.
- List zero = listTexts(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha"));
+ // If the gap ever leaked into DOCX — as spaces, as indentation, or as the
+ // level's own indent — these would stop agreeing, which is the failure this
+ // test exists to catch.
+ List zeroText = listTexts(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha"));
+ List zeroIndent = levelIndents(l -> l.bullet().hangingIndent(true).markerGap(0).items("Alpha"));
for (double gap : List.of(4.0, 8.0, 16.0)) {
assertThat(listTexts(l -> l.bullet().hangingIndent(true).markerGap(gap).items("Alpha")))
- .as("gap %s", gap)
- .isEqualTo(zero);
+ .as("text at gap %s", gap)
+ .isEqualTo(zeroText);
+ assertThat(levelIndents(l -> l.bullet().hangingIndent(true).markerGap(gap).items("Alpha")))
+ .as("level indent at gap %s", gap)
+ .isEqualTo(zeroIndent);
}
- assertThat(zero).containsExactly("• Alpha");
+ assertThat(zeroText).containsExactly("Alpha");
}
@Test
@@ -86,59 +95,81 @@ void aLongItemStaysOneParagraphWithEveryCharacterOfItsText() throws Exception {
List texts = listTexts(l -> l.bullet().hangingIndent(true).items(text));
assertThat(texts).hasSize(1);
- assertThat(texts.get(0)).isEqualTo("• " + text);
+ assertThat(texts.get(0)).isEqualTo(text);
}
@Test
- void nestedListsKeepTheirCascadeAndTheirTwoAsciiSpacesPerLevel() throws Exception {
+ void nestedListsKeepTheirCascadeWhicheverWayTheFlagIsSet() throws Exception {
Consumer shape = list -> list.name("Outline")
.addItem("alpha", l1 -> l1.addItem("beta", l2 -> l2.addItem("gamma")));
assertThat(listTexts(shape.andThen(l -> l.hangingIndent(true).markerGap(12))))
.isEqualTo(listTexts(shape))
- .containsExactly("• alpha", " ◦ beta", " ▪ gamma");
+ .containsExactly("alpha", "beta", "gamma");
+ assertThat(markerPerDepth(shape.andThen(l -> l.hangingIndent(true).markerGap(12))))
+ .isEqualTo(markerPerDepth(shape))
+ .containsExactly("•", "◦", "▪");
}
@Test
void aMarkerlessListAndAMarkerOnlyRowBothSurviveUnchanged() throws Exception {
+ // A list that asked for no marker gains nothing from numbering and would gain an
+ // indent it did not ask for, so it stays plain paragraphs — with or without the
+ // flag.
assertThat(listTexts(l -> l.noMarker().hangingIndent(true).markerGap(16).items("Alpha")))
.containsExactly("Alpha");
+ try (XWPFDocument document = export(flow -> flow.addList(
+ l -> l.noMarker().hangingIndent(true).markerGap(16).items("Alpha")))) {
+ assertThat(document.getNumbering())
+ .as("opting into the geometry does not turn a markerless list into a "
+ + "numbered one, which would add the very indent it declined")
+ .isNull();
+ }
// The flat path drops a blank item whatever its marker — that is the
// legacy rule, and opting in does not change the DOCX side of it.
assertThat(listTexts(l -> l.bullet().hangingIndent(true).items("Alpha", " ", "Beta")))
- .containsExactly("• Alpha", "• Beta");
-
- // A nested parent with an empty label keeps its marker row and children.
- assertThat(listTexts(l -> l.hangingIndent(true)
- .addItem("", c -> c.addItem("Child"))))
- .containsExactly("• ", " ◦ Child");
+ .containsExactly("Alpha", "Beta");
+
+ // A nested parent with an empty label still gets its own row: the marker is the
+ // level's now, so the row carries no text and is found by its w:numPr instead.
+ try (XWPFDocument document = export(flow -> flow.addList(l -> l.hangingIndent(true)
+ .addItem("", c -> c.addItem("Child"))))) {
+ List items = document.getParagraphs().stream()
+ .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetNumPr())
+ .toList();
+ assertThat(items).hasSize(2);
+ assertThat(items.get(0).getText()).isEmpty();
+ assertThat(items.get(0).getNumIlvl()).isEqualTo(BigInteger.ZERO);
+ assertThat(items.get(1).getText()).isEqualTo("Child");
+ assertThat(items.get(1).getNumIlvl()).isEqualTo(BigInteger.ONE);
+ }
}
@Test
- void noIndentationOrNumberingPropertyIsWrittenEitherWay() throws Exception {
- try (XWPFDocument document = export(flow -> flow.addList(list -> list
+ void theSameNumberingIsWrittenEitherWayAndNothingIsApproximatedOnTheParagraph() throws Exception {
+ try (XWPFDocument withFlag = export(flow -> flow.addList(list -> list
.name("Flat").bullet().hangingIndent(true).markerGap(16)
- .items("Alpha", "Beta")))) {
+ .items("Alpha", "Beta")));
+ XWPFDocument without = export(flow -> flow.addList(list -> list
+ .name("Flat").bullet()
+ .items("Alpha", "Beta")))) {
- List paragraphs = document.getParagraphs().stream()
- .filter(p -> !p.getText().isBlank())
- .toList();
+ List paragraphs = items(withFlag);
assertThat(paragraphs).hasSize(2);
for (XWPFParagraph paragraph : paragraphs) {
CTPPr properties = paragraph.getCTP().getPPr();
- assertThat(properties == null || !properties.isSetInd())
- .as("no w:ind — the geometry is not approximated here")
- .isTrue();
- assertThat(properties == null || !properties.isSetNumPr())
- .as("no w:numPr")
- .isTrue();
- assertThat(properties == null || !properties.isSetTabs())
- .as("no tab stops")
- .isTrue();
+ assertThat(properties.isSetNumPr()).as("the item belongs to a list").isTrue();
+ assertThat(properties.isSetInd())
+ .as("no w:ind on the paragraph — the geometry is the level's, "
+ + "and it is not approximated from the gap")
+ .isFalse();
+ assertThat(properties.isSetTabs()).as("no tab stops").isFalse();
assertThat(paragraph.getRuns()).as("one run, as before").hasSize(1);
}
- assertThat(document.getNumbering()).as("no numbering.xml").isNull();
+ assertThat(indentsOf(withFlag))
+ .as("the flag changes no part of the list definition")
+ .isEqualTo(indentsOf(without));
}
}
@@ -148,8 +179,8 @@ void aMarkerGapNeverBecomesSpacesInTheRunText() throws Exception {
// still "look right" in a viewer and be wrong in the file.
List texts = listTexts(l -> l.bullet().hangingIndent(true).markerGap(16).items("Alpha"));
assertThat(texts.get(0))
- .isEqualTo("• Alpha")
.as("no padding run, no non-breaking spaces, no tab")
+ .isEqualTo("Alpha")
.doesNotContain(" ")
.doesNotContain(" ")
.doesNotContain("\t");
@@ -157,6 +188,12 @@ void aMarkerGapNeverBecomesSpacesInTheRunText() throws Exception {
// ------------------------------------------------------------------
+ private static List items(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(p -> !p.getText().isBlank())
+ .toList();
+ }
+
private static List listTexts(Consumer spec) throws Exception {
try (XWPFDocument document = export(flow -> flow.addList(spec))) {
return document.getParagraphs().stream()
@@ -166,6 +203,40 @@ private static List listTexts(Consumer spec) throws Excepti
}
}
+ private static List markerPerDepth(Consumer spec) throws Exception {
+ try (XWPFDocument document = export(flow -> flow.addList(spec))) {
+ return document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlList().stream()
+ .map(level -> level.getLvlText().getVal())
+ .toList();
+ }
+ }
+
+ private static List levelIndents(Consumer spec) throws Exception {
+ try (XWPFDocument document = export(flow -> flow.addList(spec))) {
+ return indentsOf(document);
+ }
+ }
+
+ /** Left and hanging of every level, flattened, so two definitions compare as one list. */
+ private static List indentsOf(XWPFDocument document) {
+ return document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlList().stream()
+ .flatMap(level -> List.of(twips(level.getPPr().getInd().getLeft()),
+ twips(level.getPPr().getInd().getHanging())).stream())
+ .toList();
+ }
+
+ /**
+ * Reads a twip measure back as a number.
+ *
+ * {@code ST_SignedTwipsMeasure} is an xmlbeans union, so the accessor is typed
+ * {@code Object} and hands back whichever member matched.
+ */
+ private static int twips(Object measure) {
+ return Integer.parseInt(String.valueOf(measure));
+ }
+
private static XWPFDocument export(Consumer author) throws Exception {
byte[] docxBytes;
try (DocumentSession session = GraphCompose.document()
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListGeometryFreezeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListGeometryFreezeTest.java
new file mode 100644
index 000000000..7a9da0b03
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListGeometryFreezeTest.java
@@ -0,0 +1,161 @@
+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.style.DocumentInsets;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
+
+import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Freezes the geometry of the semantic DOCX export of a list.
+ *
+ * The DOCX list path shares no geometry code with the PDF one — it walks the authored
+ * node tree directly and never touches the layout engine — so its contract has to be
+ * pinned separately.
+ *
+ * What is pinned changed when a list became a list Word owns. The nesting indent is no
+ * longer padding characters in the run text but a level in the list definition, so the
+ * hazard this test used to name — the two backends indenting with different codepoints
+ * that look alike — is gone: the DOCX side writes no indent characters at all. In its
+ * place are two indent constants that Word reads and nobody measures, worth pinning for
+ * the same reason the codepoints were.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxListGeometryFreezeTest {
+
+ /** The marker column the export writes, in twips. A convention, not a measurement. */
+ private static final int HANGING_TWIPS = 180;
+
+ /** Added per nesting level, approximating the two spaces the old text path used. */
+ private static final int NESTING_STEP_TWIPS = 120;
+
+ private static final Consumer OUTLINE =
+ flow -> flow.addList(list -> list
+ .name("Outline")
+ .addItem("alpha", l1 -> l1
+ .addItem("beta", l2 -> l2
+ .addItem("gamma"))));
+
+ @Test
+ void nestingIsAListLevelWithAPinnedIndentAndNoPaddingCharacters() throws Exception {
+ // No indent characters of any kind reach the text — neither the ASCII spaces the
+ // DOCX path used to write nor the non-breaking ones the PDF flatten path uses.
+ List texts = exportTexts(OUTLINE);
+ assertThat(texts).contains("alpha", "beta", "gamma");
+ assertThat(texts.stream().anyMatch(DocxListGeometryFreezeTest::startsWithPadding))
+ .as("nesting is a list level, so nothing pads the run text")
+ .isFalse();
+
+ try (XWPFDocument document = export(OUTLINE)) {
+ var levels = document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlList();
+ assertThat(levels).hasSize(3);
+ for (int depth = 0; depth < levels.size(); depth++) {
+ var indent = levels.get(depth).getPPr().getInd();
+ assertThat(twips(indent.getHanging()))
+ .as("the marker column at depth %d", depth)
+ .isEqualTo(HANGING_TWIPS);
+ assertThat(twips(indent.getLeft()))
+ .as("the content origin at depth %d", depth)
+ .isEqualTo(HANGING_TWIPS + NESTING_STEP_TWIPS * depth);
+ }
+ }
+ }
+
+ @Test
+ void theMarkerIsWordNumberingAndNotRunText() throws Exception {
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Flat").bullet().items("Java", "SQL")))) {
+
+ List paragraphs = document.getParagraphs().stream()
+ .filter(p -> !p.getText().isBlank())
+ .toList();
+ assertThat(paragraphs).hasSize(2);
+ // The marker belongs to the list definition, so the text is the item alone.
+ assertThat(paragraphs.get(0).getText()).isEqualTo("Java");
+ assertThat(paragraphs.get(1).getText()).isEqualTo("SQL");
+
+ for (XWPFParagraph paragraph : paragraphs) {
+ CTPPr properties = paragraph.getCTP().getPPr();
+ assertThat(properties).isNotNull();
+ assertThat(properties.isSetNumPr())
+ .as("w:numPr — these are list items, not paragraphs that look like some")
+ .isTrue();
+ assertThat(properties.isSetInd())
+ .as("the indent is the level's, not repeated on every paragraph")
+ .isFalse();
+ }
+ assertThat(document.getNumbering()).as("numbering.xml is written").isNotNull();
+ }
+ }
+
+ @Test
+ void oneParagraphPerItemRegardlessOfLengthBecauseDocxDoesNotWrap() throws Exception {
+ // The DOCX path never measures or wraps: a long item is one w:p and Word
+ // does its own line breaking, so there is no continuation line to indent
+ // and continuationIndent() has no effect here.
+ List texts = exportTexts(flow -> flow
+ .addList(list -> list
+ .name("Long")
+ .bullet()
+ .continuationIndent(" ")
+ .items("Long item text that would wrap across several visual lines in the PDF "
+ + "backend but stays a single Word paragraph here.")));
+
+ List items = texts.stream().filter(t -> !t.isBlank()).toList();
+ assertThat(items).hasSize(1);
+ assertThat(items.get(0)).startsWith("Long item text");
+ assertThat(items.get(0)).doesNotContain(" ");
+ }
+
+ // ------------------------------------------------------------------
+
+ /**
+ * Reads a twip measure back as a number.
+ *
+ * {@code ST_SignedTwipsMeasure} is an xmlbeans union, so the accessor is typed
+ * {@code Object} and hands back whichever member matched.
+ */
+ private static int twips(Object measure) {
+ return Integer.parseInt(String.valueOf(measure));
+ }
+
+ /** True when a line begins with an ASCII space or carries a non-breaking one. */
+ private static boolean startsWithPadding(String text) {
+ return text.startsWith(" ") || text.indexOf(' ') >= 0;
+ }
+
+ private static List exportTexts(
+ Consumer author) throws Exception {
+ try (XWPFDocument document = export(author)) {
+ return document.getParagraphs().stream()
+ .map(XWPFParagraph::getText)
+ .toList();
+ }
+ }
+
+ private static XWPFDocument export(
+ Consumer author) throws Exception {
+ byte[] docxBytes;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ var flow = session.dsl().pageFlow().name("Flow");
+ author.accept(flow);
+ flow.build();
+ docxBytes = session.export(new DocxSemanticBackend());
+ }
+ return new XWPFDocument(new ByteArrayInputStream(docxBytes));
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java
deleted file mode 100644
index e8b0d1ce9..000000000
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java
+++ /dev/null
@@ -1,132 +0,0 @@
-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.style.DocumentInsets;
-import org.apache.poi.xwpf.usermodel.XWPFDocument;
-import org.apache.poi.xwpf.usermodel.XWPFParagraph;
-import org.junit.jupiter.api.Test;
-import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
-
-import java.io.ByteArrayInputStream;
-import java.util.List;
-import java.util.function.Consumer;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Freezes what the semantic DOCX export of a list is today, before any
- * hanging-indent or marker-gap work exists.
- *
- * The DOCX list path shares no geometry code with the PDF one — it walks the
- * authored node tree directly and never touches the layout engine — so its
- * contract has to be pinned separately, and pinned as codepoints. In particular
- * the two backends indent nesting with different characters that happen
- * to look the same, which is the kind of agreement that breaks silently the
- * first time someone "unifies" them.
- */
-class DocxListLegacyGeometryFreezeTest {
-
- /** What the DOCX writer uses per nesting level: two ASCII spaces. */
- private static final String DOCX_INDENT_UNIT = " ";
-
- /** What the PDF flatten path uses per nesting level: two non-breaking spaces. */
- private static final String PDF_INDENT_UNIT = " ";
-
- @Test
- void nestingIndentsWithTwoAsciiSpacesPerLevelAndNotWithTheNonBreakingSpacesThePdfPathUses() throws Exception {
- List texts = exportTexts(flow -> flow
- .addList(list -> list
- .name("Outline")
- .addItem("alpha", l1 -> l1
- .addItem("beta", l2 -> l2
- .addItem("gamma")))));
-
- assertThat(texts).contains(
- "• alpha",
- DOCX_INDENT_UNIT + "◦ beta",
- DOCX_INDENT_UNIT.repeat(2) + "▪ gamma");
-
- // Stated as the divergence it is: same visual width, different codepoints,
- // held together by convention rather than by shared code.
- assertThat(DOCX_INDENT_UNIT).isNotEqualTo(PDF_INDENT_UNIT);
- assertThat(texts.stream().anyMatch(t -> t.contains(" ")))
- .as("no non-breaking space reaches the DOCX run text")
- .isFalse();
- }
-
- @Test
- void theMarkerIsRunTextWithATrailingSpaceAndNotWordNumbering() throws Exception {
- try (XWPFDocument document = export(flow -> flow
- .addList(list -> list.name("Flat").bullet().items("Java", "SQL")))) {
-
- List paragraphs = document.getParagraphs().stream()
- .filter(p -> !p.getText().isBlank())
- .toList();
- assertThat(paragraphs).hasSize(2);
- assertThat(paragraphs.get(0).getText()).isEqualTo("• Java");
- assertThat(paragraphs.get(1).getText()).isEqualTo("• SQL");
-
- for (XWPFParagraph paragraph : paragraphs) {
- CTPPr properties = paragraph.getCTP().getPPr();
- boolean numbered = properties != null && properties.isSetNumPr();
- boolean indented = properties != null && properties.isSetInd();
- assertThat(numbered).as("no w:numPr — these are plain paragraphs").isFalse();
- assertThat(indented).as("no w:ind — hanging indent has no DOCX representation today").isFalse();
- }
- }
- assertThat(hasNumberingPart()).as("no numbering.xml is written").isFalse();
- }
-
- @Test
- void oneParagraphPerItemRegardlessOfLengthBecauseDocxDoesNotWrap() throws Exception {
- // The DOCX path never measures or wraps: a long item is one w:p and Word
- // does its own line breaking, so there is no continuation line to indent
- // and continuationIndent() has no effect here.
- List texts = exportTexts(flow -> flow
- .addList(list -> list
- .name("Long")
- .bullet()
- .continuationIndent(" ")
- .items("Long item text that would wrap across several visual lines in the PDF "
- + "backend but stays a single Word paragraph here.")));
-
- List items = texts.stream().filter(t -> !t.isBlank()).toList();
- assertThat(items).hasSize(1);
- assertThat(items.get(0)).startsWith("• Long item text");
- assertThat(items.get(0)).doesNotContain(" ");
- }
-
- // ------------------------------------------------------------------
-
- private static boolean hasNumberingPart() throws Exception {
- try (XWPFDocument document = export(flow -> flow
- .addList(list -> list.name("Flat").bullet().items("Java")))) {
- return document.getNumbering() != null;
- }
- }
-
- private static List exportTexts(
- Consumer author) throws Exception {
- try (XWPFDocument document = export(author)) {
- return document.getParagraphs().stream()
- .map(XWPFParagraph::getText)
- .toList();
- }
- }
-
- private static XWPFDocument export(
- Consumer author) throws Exception {
- byte[] docxBytes;
- try (DocumentSession session = GraphCompose.document()
- .pageSize(595, 842)
- .margin(DocumentInsets.of(36))
- .create()) {
- var flow = session.dsl().pageFlow().name("Flow");
- author.accept(flow);
- flow.build();
- docxBytes = session.export(new DocxSemanticBackend());
- }
- return new XWPFDocument(new ByteArrayInputStream(docxBytes));
- }
-}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListNumberingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListNumberingTest.java
new file mode 100644
index 000000000..1b2590c41
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListNumberingTest.java
@@ -0,0 +1,285 @@
+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.node.ListItem;
+import com.demcha.compose.document.node.ListMarker;
+import com.demcha.compose.document.node.ListNode;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A list exports as a list Word owns, not as a marker typed into the text.
+ *
+ * The difference is invisible until somebody edits the file: a marker written into
+ * the run text looks right and then produces a blank paragraph on Enter instead of the
+ * next item. Measured in Word 16.0 against the old export, {@code ListFormat.ListType}
+ * came back as "no numbering" — the reader had bulleted-looking paragraphs, not a
+ * list.
+ *
+ * Numbering is only attached where Word can express what was authored, which is why
+ * several kinds of list deliberately keep the older text form.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxListNumberingTest {
+
+ @Test
+ void aBulletListShouldBecomeAWordListWithTheAuthoredMarker() throws Exception {
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Flat").bullet().items("Java", "SQL")))) {
+
+ List items = items(document);
+ assertThat(items).hasSize(2);
+ for (XWPFParagraph item : items) {
+ assertThat(item.getNumID()).as("the paragraph belongs to a list").isNotNull();
+ assertThat(item.getNumIlvl()).isEqualTo(BigInteger.ZERO);
+ }
+ // Word draws the marker now, so it must not also be in the text.
+ assertThat(items.get(0).getText()).isEqualTo("Java");
+ assertThat(items.get(1).getText()).isEqualTo("SQL");
+ assertThat(document.getNumbering()).as("a numbering part exists").isNotNull();
+ }
+ }
+
+ @Test
+ void nestingShouldUseLevelsRatherThanSpaces() throws Exception {
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Outline")
+ .addItem("alpha", l1 -> l1
+ .addItem("beta", l2 -> l2
+ .addItem("gamma")))))) {
+
+ List items = items(document);
+ assertThat(items).hasSize(3);
+ assertThat(items.get(0).getNumIlvl()).isEqualTo(BigInteger.ZERO);
+ assertThat(items.get(1).getNumIlvl()).isEqualTo(BigInteger.ONE);
+ assertThat(items.get(2).getNumIlvl()).isEqualTo(BigInteger.TWO);
+ // The indent is a level, so no padding characters reach the text at all.
+ assertThat(items.get(1).getText()).isEqualTo("beta");
+ assertThat(items.get(2).getText()).isEqualTo("gamma");
+ }
+ }
+
+ @Test
+ void everyDepthShouldKeepTheMarkerItWasAuthoredWith() throws Exception {
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Outline")
+ .addItem("alpha", l1 -> l1.addItem("beta"))))) {
+
+ var levels = document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlList();
+ assertThat(levels).hasSize(2);
+ assertThat(levels.get(0).getLvlText().getVal()).isEqualTo("\u2022");
+ assertThat(levels.get(1).getLvlText().getVal())
+ .as("the depth cascade the export already used, now as list levels")
+ .isEqualTo("\u25E6");
+ }
+ }
+
+ @Test
+ void aCustomMarkerShouldBecomeTheLevelText() throws Exception {
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Ticks").marker(ListMarker.custom("\u2713"))
+ .items("done")))) {
+
+ var level = document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlArray(0);
+ assertThat(level.getLvlText().getVal()).isEqualTo("\u2713");
+ assertThat(items(document).get(0).getText()).isEqualTo("done");
+ }
+ }
+
+ @Test
+ void aMarkerlessListShouldStayPlainParagraphs() throws Exception {
+ // Numbering always draws something and indents; a list that asked for no marker
+ // would gain both. Nothing is gained by making it a list, so it is not one.
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Bare").noMarker().items("alpha", "beta")))) {
+
+ assertThat(document.getNumbering()).isNull();
+ assertThat(items(document).get(0).getNumID()).isNull();
+ }
+ }
+
+ @Test
+ void aPerDepthMarkerShouldBecomeThatLevelsText() throws Exception {
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Outline")
+ .markerFor(1, ListMarker.custom("\u2013"))
+ .addItem("alpha", l1 -> l1.addItem("beta"))))) {
+
+ var levels = document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlList();
+ assertThat(levels.get(1).getLvlText().getVal())
+ .as("markerFor(depth) chooses that level's text")
+ .isEqualTo("\u2013");
+ }
+ }
+
+ @Test
+ void aListWhoseSiblingsDisagreeOnTheMarkerShouldStayText() throws Exception {
+ // A Word list definition names one marker per level. Two different markers at the
+ // same depth cannot both be it, so the whole list keeps writing its markers as
+ // text rather than silently having one of them replaced by the other. The list
+ // DSL assigns markers per depth and cannot author this; ListNode is public API,
+ // so a caller can hand the exporter exactly this shape.
+ ListNode mixed = new ListNode("Mixed", List.of(),
+ List.of(new ListItem("alpha", ListMarker.custom("\u2713"), List.of()),
+ new ListItem("beta", ListMarker.custom("\u2717"), List.of())),
+ ListMarker.bullet(), DocumentTextStyle.DEFAULT, TextAlign.LEFT,
+ 0, 0, "", true, DocumentInsets.zero(), DocumentInsets.zero());
+
+ try (XWPFDocument document = export(flow -> flow.add(mixed))) {
+ assertThat(document.getNumbering()).isNull();
+ List items = items(document);
+ assertThat(items.get(0).getText()).isEqualTo("\u2713 alpha");
+ assertThat(items.get(1).getText()).isEqualTo("\u2717 beta");
+ }
+ }
+
+ @Test
+ void aDrawnMarkerShouldStayOnTheRunPathItAlreadyUsed() throws Exception {
+ // A marker made of runs has no Word list analogue; the rich path writes what it
+ // can and warns about what it cannot, and numbering must not hide that.
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Drawn")
+ .marker(ListMarker.ofRuns(List.of()))
+ .items("alpha")))) {
+
+ assertThat(document.getNumbering()).isNull();
+ }
+ }
+
+ @Test
+ void theDefinitionAnItemPointsAtShouldBeTheOneCarryingItsMarker() throws Exception {
+ // Every other assertion here reads abstract definition zero directly. That would
+ // still pass if the item's w:numPr pointed at a different definition, or at one
+ // that does not exist — the marker would be right in the file and wrong on the
+ // page. This resolves the item's own numId through w:num to its abstract id.
+ try (XWPFDocument document = export(flow -> flow
+ .addList(list -> list.name("Ticks").marker(ListMarker.custom("✓"))
+ .items("done")))) {
+
+ BigInteger numId = items(document).get(0).getNumID();
+ assertThat(numId).isNotNull();
+ var num = document.getNumbering().getNum(numId);
+ assertThat(num).as("the item points at a definition that exists").isNotNull();
+ BigInteger abstractId = num.getCTNum().getAbstractNumId().getVal();
+ var levels = document.getNumbering()
+ .getAbstractNum(abstractId).getAbstractNum().getLvlArray(0);
+ assertThat(levels.getLvlText().getVal()).isEqualTo("✓");
+ }
+ }
+
+ @Test
+ void twoListsShouldGetTwoDefinitionsAndEachItemShouldPointAtItsOwn() throws Exception {
+ // One definition per list, so the second list must not inherit the first's
+ // marker — the failure a single shared abstract id would produce.
+ try (XWPFDocument document = export(flow -> {
+ flow.addList(list -> list.name("Ticks").marker(ListMarker.custom("✓"))
+ .items("first"));
+ flow.addList(list -> list.name("Crosses").marker(ListMarker.custom("✗"))
+ .items("second"));
+ })) {
+ List items = items(document);
+ assertThat(items).hasSize(2);
+ assertThat(markerOf(document, items.get(0))).isEqualTo("✓");
+ assertThat(markerOf(document, items.get(1))).isEqualTo("✗");
+ assertThat(items.get(0).getNumID()).isNotEqualTo(items.get(1).getNumID());
+ }
+ }
+
+ @Test
+ void aListNestedPastWordsNineLevelsShouldStayText() throws Exception {
+ // CT_AbstractNum/lvl is maxOccurs="9". POI saves a tenth without complaint and
+ // Word then refuses to open the file, so a list this deep keeps the text form
+ // rather than shipping a document nobody can open.
+ try (XWPFDocument document = export(flow -> flow.add(nested(10)))) {
+ assertThat(document.getNumbering())
+ .as("ten levels cannot be a Word list definition")
+ .isNull();
+ assertThat(items(document)).hasSize(10);
+ }
+ }
+
+ @Test
+ void aListNestedToWordsNinthLevelShouldStillBeAList() throws Exception {
+ try (XWPFDocument document = export(flow -> flow.add(nested(9)))) {
+ assertThat(document.getNumbering()).isNotNull();
+ assertThat(document.getNumbering()
+ .getAbstractNum(BigInteger.ZERO).getAbstractNum().getLvlList())
+ .as("nine is the deepest Word holds")
+ .hasSize(9);
+ }
+ }
+
+ @Test
+ void aListWhoseFlatItemsAreAllBlankShouldNotClaimTheFirstLevel() throws Exception {
+ // Blank flat items write no paragraph, so seeding depth zero from their marker
+ // would reject a perfectly uniform nested list whose own depth zero differs.
+ ListNode list = new ListNode("Mixed", List.of("", " "),
+ List.of(new ListItem("alpha", ListMarker.bullet(), List.of())),
+ ListMarker.dash(), DocumentTextStyle.DEFAULT, TextAlign.LEFT,
+ 0, 0, "", true, DocumentInsets.zero(), DocumentInsets.zero());
+
+ try (XWPFDocument document = export(flow -> flow.add(list))) {
+ assertThat(document.getNumbering())
+ .as("the surviving item's own marker defines level zero")
+ .isNotNull();
+ assertThat(items(document)).hasSize(1);
+ assertThat(markerOf(document, items(document).get(0))).isEqualTo("•");
+ }
+ }
+
+ /** A chain of single children {@code depth} levels deep, markers left to the cascade. */
+ private static ListNode nested(int depth) {
+ ListItem item = new ListItem("level " + (depth - 1), null, List.of());
+ for (int level = depth - 2; level >= 0; level--) {
+ item = new ListItem("level " + level, null, List.of(item));
+ }
+ return new ListNode("Deep", List.of(), List.of(item),
+ ListMarker.bullet(), DocumentTextStyle.DEFAULT, TextAlign.LEFT,
+ 0, 0, "", true, DocumentInsets.zero(), DocumentInsets.zero());
+ }
+
+ /** The marker Word will draw beside this item, resolved through its own numbering id. */
+ private static String markerOf(XWPFDocument document, XWPFParagraph item) {
+ BigInteger abstractId = document.getNumbering().getNum(item.getNumID())
+ .getCTNum().getAbstractNumId().getVal();
+ return document.getNumbering().getAbstractNum(abstractId).getAbstractNum()
+ .getLvlArray(item.getNumIlvl().intValue()).getLvlText().getVal();
+ }
+
+ private static List items(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(p -> !p.getText().isBlank())
+ .toList();
+ }
+
+ private static XWPFDocument export(
+ Consumer author) throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(36))
+ .create()) {
+ var flow = session.dsl().pageFlow().name("Flow");
+ author.accept(flow);
+ flow.build();
+ docx = session.export(new DocxSemanticBackend());
+ }
+ return new XWPFDocument(new ByteArrayInputStream(docx));
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListParityTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListParityTest.java
index b5010aaad..4135d4f11 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListParityTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListParityTest.java
@@ -32,19 +32,27 @@ void nestedFallbackFollowsTheDepthCascade() throws Exception {
.addItem("beta", l2 -> l2
.addItem("gamma")))));
- assertThat(texts).contains("• alpha", " ◦ beta", " ▪ gamma");
+ assertThat(texts).contains("alpha", "beta", "gamma");
+ assertThat(markerPerDepth(flow -> flow
+ .addList(list -> list
+ .name("Outline")
+ .addItem("alpha", l1 -> l1
+ .addItem("beta", l2 -> l2
+ .addItem("gamma"))))))
+ .containsExactly("•", "◦", "▪");
}
@Test
void explicitMarkersStillBeatTheCascade() throws Exception {
- List texts = exportTexts(flow -> flow
+ List markers = markerPerDepth(flow -> flow
.addList(list -> list
.name("Outline")
.markerFor(1, ListMarker.custom("→"))
.addItem("alpha", l1 -> l1.addItem("beta"))));
- assertThat(texts).contains(" → beta");
- assertThat(texts).doesNotContain(" ◦ beta");
+ assertThat(markers.get(1))
+ .as("the override wins over the cascade's ◦ at this depth")
+ .isEqualTo("→");
}
@Test
@@ -52,8 +60,10 @@ void flatItemsStripAuthorTypedMarkers() throws Exception {
List texts = exportTexts(flow -> flow
.addList("- dashed", "• bulleted", "* starred", "+ plussed"));
- assertThat(texts).contains("• dashed", "• bulleted", "• starred", "• plussed");
- assertThat(texts).noneMatch(t -> t.startsWith("• - ") || t.startsWith("• • "));
+ // Word draws the marker, so the item's text is the item: an author-typed marker
+ // that survived normalization would show up here as a leading "- " or "• ".
+ assertThat(texts).contains("dashed", "bulleted", "starred", "plussed");
+ assertThat(texts).noneMatch(t -> t.startsWith("- ") || t.startsWith("• "));
}
@Test
@@ -61,7 +71,7 @@ void boldLeadIsNotMistakenForAMarker() throws Exception {
List texts = exportTexts(flow -> flow
.addList("**bold** lead stays intact"));
- assertThat(texts).contains("• **bold** lead stays intact");
+ assertThat(texts).contains("**bold** lead stays intact");
}
@Test
@@ -69,9 +79,13 @@ void blankFlatItemsAreDropped() throws Exception {
List texts = exportTexts(flow -> flow
.addList("kept", "", " "));
- assertThat(texts).contains("• kept");
- // No marker-only paragraphs for the blank items.
- assertThat(texts).noneMatch(t -> t.trim().equals("•"));
+ assertThat(texts).contains("kept");
+ // Counted as list items, not as text: a numbered empty paragraph draws a marker
+ // with nothing beside it — the same defect the marker-only paragraph used to be —
+ // and filtering blank text before asserting would step right over it.
+ assertThat(listItemCount(flow -> flow.addList("kept", "", " ")))
+ .as("one item survives; the blank ones leave no marker behind")
+ .isEqualTo(1);
}
@Test
@@ -82,11 +96,47 @@ void normalizeMarkersFalsePreservesRawItems() throws Exception {
.normalizeMarkers(false)
.items("- raw dash survives")));
- assertThat(texts).contains("• - raw dash survives");
+ assertThat(texts).contains("- raw dash survives");
}
private static List exportTexts(
Consumer author) throws Exception {
+ try (XWPFDocument document = export(author)) {
+ return document.getParagraphs().stream()
+ .map(XWPFParagraph::getText)
+ .toList();
+ }
+ }
+
+ /**
+ * The marker each nesting depth resolved to.
+ *
+ * Markers are the list definition's business now rather than the run text's, so
+ * the cascade is read where it lives.
+ */
+ private static List markerPerDepth(
+ Consumer author) throws Exception {
+ try (XWPFDocument document = export(author)) {
+ return document.getNumbering()
+ .getAbstractNum(java.math.BigInteger.ZERO).getAbstractNum().getLvlList()
+ .stream()
+ .map(level -> level.getLvlText().getVal())
+ .toList();
+ }
+ }
+
+ /** How many paragraphs Word will draw a marker beside, whatever their text says. */
+ private static long listItemCount(
+ Consumer author) throws Exception {
+ try (XWPFDocument document = export(author)) {
+ return document.getParagraphs().stream()
+ .filter(p -> p.getCTP().getPPr() != null && p.getCTP().getPPr().isSetNumPr())
+ .count();
+ }
+ }
+
+ private static XWPFDocument export(
+ Consumer author) throws Exception {
byte[] docxBytes;
try (DocumentSession session = GraphCompose.document()
.pageSize(595, 842)
@@ -97,10 +147,6 @@ private static List exportTexts(
flow.build();
docxBytes = session.export(new DocxSemanticBackend());
}
- try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
- return document.getParagraphs().stream()
- .map(XWPFParagraph::getText)
- .toList();
- }
+ return new XWPFDocument(new ByteArrayInputStream(docxBytes));
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRightToLeftTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRightToLeftTest.java
index f216c2cf0..b18c81b02 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRightToLeftTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRightToLeftTest.java
@@ -112,7 +112,10 @@ void aLeftToRightCellParagraphKeepsTheAlignmentItAskedFor() throws Exception {
@Test
void theRequestedSizeReachesTheComplexScriptCharacters() throws Exception {
- XWPFParagraph paragraph = onlyParagraph(HEBREW, TextDirection.RTL, null);
+ // The Hebrew run is deliberately not the document's dominant style here: a run
+ // that differs from Normal keeps its own size, and this is the case that says
+ // w:szCs is written beside w:sz rather than forgotten.
+ XWPFParagraph paragraph = hebrewBesideALongerLatinBody();
var properties = paragraph.getRuns().get(0).getCTR().getRPr();
assertThat(halfPoints(properties.getSzArray(0).getVal()))
@@ -124,6 +127,74 @@ void theRequestedSizeReachesTheComplexScriptCharacters() throws Exception {
.isEqualTo(30);
}
+ @Test
+ void complexScriptSizeShouldTravelWithTheStyleWhenTheRunInheritsIt() throws Exception {
+ // When the Hebrew run *is* the document's body style, its size moves to Normal so
+ // a reader can restyle the document. The guarantee is unchanged — Hebrew still has
+ // a w:szCs to read — but it now lives on the style, and this asserts it is there
+ // rather than lost on the way.
+ byte[] docx = exportOneParagraph(HEBREW, 15);
+ try (XWPFDocument word = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ var runProperties = word.getParagraphs().get(0).getRuns().get(0).getCTR().getRPr();
+ assertThat(runProperties == null || runProperties.sizeOfSzCsArray() == 0)
+ .describedAs("the run leaves its size to Normal")
+ .isTrue();
+
+ var styleDefaults = word.getStyles().getCtStyles().getDocDefaults()
+ .getRPrDefault().getRPr();
+ assertThat(halfPoints(styleDefaults.getSzCsArray(0).getVal()))
+ .describedAs("and Normal carries the complex-script size Hebrew reads")
+ .isEqualTo(30);
+ }
+ }
+
+ /**
+ * A document whose body is a long Latin paragraph, with a shorter Hebrew one beside
+ * it, so the Hebrew run differs from the document default and keeps direct formatting.
+ *
+ * @return the Hebrew paragraph
+ */
+ private static XWPFParagraph hebrewBesideALongerLatinBody() throws Exception {
+ DocumentTextStyle hebrew = DocumentTextStyle.builder()
+ .fontName(FontName.DAVID_LIBRE).size(15).build();
+ DocumentTextStyle latin = DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA).size(11).build();
+ String body = "A Latin body paragraph long enough to outweigh the Hebrew line and "
+ + "become the document's own default text style.";
+
+ byte[] docx;
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(400, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ document.pageFlow(page -> {
+ page.addParagraph(p -> p.text(body).textStyle(latin));
+ page.addParagraph(p -> p.text(HEBREW)
+ .direction(TextDirection.RTL).textStyle(hebrew));
+ });
+ docx = document.export(new DocxSemanticBackend());
+ }
+ try (XWPFDocument word = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ return word.getParagraphs().stream()
+ .filter(p -> p.getText().contains(HEBREW))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("the Hebrew paragraph is missing"));
+ }
+ }
+
+ private static byte[] exportOneParagraph(String text, double size) throws Exception {
+ DocumentTextStyle style = DocumentTextStyle.builder()
+ .fontName(FontName.DAVID_LIBRE).size(size).build();
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(400, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ document.pageFlow(page -> page.addParagraph(p -> p.text(text)
+ .direction(TextDirection.RTL).textStyle(style)));
+ return document.export(new DocxSemanticBackend());
+ }
+ }
+
@Test
void theRequestedWeightReachesTheComplexScriptCharacters() throws Exception {
XWPFParagraph paragraph = onlyParagraph(HEBREW, TextDirection.RTL,
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
new file mode 100644
index 000000000..0994f47a7
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
@@ -0,0 +1,205 @@
+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.dsl.PageFlowBuilder;
+import com.demcha.compose.document.node.RowArrangement;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentRowColumn;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The one-row table a {@code row(...)} is carried as.
+ *
+ * Two things are pinned here. That the carrier's own grid is turned off once:
+ * POI's {@code createTable} already writes a full set of single-line borders, and adding a
+ * second element per edge on top of them leaves a {@code w:tblBorders} that
+ * {@code CT_TblBorders} does not allow — Word reads the last one and draws nothing, so the
+ * render looked correct while the part was invalid.
+ *
+ * And that the columns land where the fixed-layout render puts them. A row's slots are
+ * arithmetic on the width it is offered, not a measurement, for every distribution but
+ * two: weights, an even split and fixed columns are all shares of what is left after the
+ * gaps, while an {@code auto} column and the flex path ask what a child's content
+ * naturally measures. The first three are written; the other two stay Word's.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxRowLayoutTest {
+
+ /** A5-ish page with a 20pt margin: 360pt of content width. */
+ private static final double CONTENT_WIDTH = 360;
+ private static final double TWIPS_PER_POINT = 20.0;
+
+ @Test
+ void theCarriersGridIsTurnedOffWithOneElementPerEdge() throws Exception {
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .addParagraph(p -> p.text("Left"))
+ .addParagraph(p -> p.text("Right"))));
+
+ String borders = table.getCTTbl().getTblPr().getTblBorders().xmlText();
+ for (String edge : new String[] {"top", "bottom", "left", "right", "insideH", "insideV"}) {
+ assertThat(occurrences(borders, " page.addRow(r -> r
+ .addParagraph(p -> p.text("Left"))
+ .addParagraph(p -> p.text("A much longer right-hand column of text"))));
+
+ assertThat(gridTwips(table)).containsExactly(3600L, 3600L);
+ assertThat(layoutType(table))
+ .as("without this Word re-fits the columns to their content and the grid "
+ + "is only a suggestion")
+ .isEqualTo("fixed");
+ }
+
+ @Test
+ void weightsAndTheGapDecideTheColumnsAndTheCellMargins() throws Exception {
+ // 360 of content, less a 20pt gap, is 340 to share 3:2 — so 204 and 136. The gap
+ // is not a column and Word has nowhere to put it, so it rides in the first
+ // column's width and comes back out as that cell's right margin: the text box is
+ // the slot, and the second column starts exactly where the second slot does.
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .gap(20)
+ .weights(3, 2)
+ .addParagraph(p -> p.text("Scope"))
+ .addParagraph(p -> p.text("Period"))));
+
+ assertThat(gridTwips(table)).containsExactly(4480L, 2720L);
+ assertThat(sum(gridTwips(table)))
+ .as("the columns still add up to the table")
+ .isEqualTo(Math.round(CONTENT_WIDTH * TWIPS_PER_POINT));
+ assertThat(marginTwips(table, 0)).containsExactly(0L, 400L);
+ assertThat(marginTwips(table, 1))
+ .as("written even though they are zero — Word's own default is not")
+ .containsExactly(0L, 0L);
+ }
+
+ @Test
+ void aFixedColumnTakesItsWidthAndAWeightTakesTheRest() throws Exception {
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .columns(DocumentRowColumn.fixed(100), DocumentRowColumn.weight(1))
+ .addParagraph(p -> p.text("Label"))
+ .addParagraph(p -> p.text("Value"))));
+
+ assertThat(gridTwips(table)).containsExactly(2000L, 5200L);
+ }
+
+ @Test
+ void theRowsPaddingRidesInTheOuterColumnsAndComesBackOutAsAMargin() throws Exception {
+ // 12pt each side leaves 336 to halve. The padding is part of the table, which
+ // spans the content width, so it lives in the first and last columns and is taken
+ // off again by their margins.
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .padding(DocumentInsets.symmetric(0, 12))
+ .addParagraph(p -> p.text("Left"))
+ .addParagraph(p -> p.text("Right"))));
+
+ assertThat(gridTwips(table)).containsExactly(3600L, 3600L);
+ assertThat(marginTwips(table, 0)).containsExactly(240L, 0L);
+ assertThat(marginTwips(table, 1)).containsExactly(0L, 240L);
+ }
+
+ @Test
+ void anAutoColumnLeavesTheWholeSplitToWord() throws Exception {
+ // An auto column is as wide as its content needs, and needing content widths is
+ // exactly what this backend has no font runtime for.
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .columns(DocumentRowColumn.fixed(100), DocumentRowColumn.auto())
+ .addParagraph(p -> p.text("Label"))
+ .addParagraph(p -> p.text("Value"))));
+
+ assertThat(gridTwips(table)).as("no grid at all").isEmpty();
+ assertThat(layoutType(table)).as("and Word keeps fitting the columns").isNull();
+ }
+
+ @Test
+ void anArrangementThatJustifiesTheChildrenLeavesTheSplitToWord() throws Exception {
+ // The flex path gives every child without a grow factor its natural width and
+ // spreads the leftover — both halves of that are measurements.
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .arrangement(RowArrangement.SPACE_BETWEEN)
+ .addParagraph(p -> p.text("Left"))
+ .addParagraph(p -> p.text("Right"))));
+
+ assertThat(gridTwips(table)).isEmpty();
+ assertThat(layoutType(table)).isNull();
+ }
+
+ /** Grid column widths in twips, empty when the export wrote no grid. */
+ private static List gridTwips(XWPFTable table) {
+ CTTblGrid grid = table.getCTTbl().getTblGrid();
+ if (grid == null) {
+ return List.of();
+ }
+ return grid.getGridColList().stream().map(column -> twips(column.getW())).toList();
+ }
+
+ /** The left and right margin of one cell, in twips. */
+ private static List marginTwips(XWPFTable table, int index) {
+ CTTcMar margins = table.getRow(0).getCell(index).getCTTc().getTcPr().getTcMar();
+ return List.of(twips(margins.getLeft().getW()), twips(margins.getRight().getW()));
+ }
+
+ private static String layoutType(XWPFTable table) {
+ CTTblPr properties = table.getCTTbl().getTblPr();
+ return properties == null || !properties.isSetTblLayout()
+ ? null
+ : properties.getTblLayout().getType().toString();
+ }
+
+ private static long sum(List values) {
+ return values.stream().mapToLong(Long::longValue).sum();
+ }
+
+ /** {@code ST_TwipsMeasure} is an xmlbeans union, so the accessor is typed Object. */
+ private static long twips(Object measure) {
+ return Long.parseLong(String.valueOf(measure));
+ }
+
+ private static int occurrences(String haystack, String needle) {
+ int count = 0;
+ for (int at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + 1)) {
+ count++;
+ }
+ return count;
+ }
+
+ private static XWPFTable onlyTable(Consumer content) throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 600)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(content::accept);
+ docx = session.export(new DocxSemanticBackend());
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ assertThat(document.getTables()).hasSize(1);
+ return document.getTables().get(0);
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
index df0a93a0b..4365f9ad1 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRunStyleTest.java
@@ -106,7 +106,13 @@ void aCodeChipCarriesItsOwnGlyphStyleRatherThanTheParagraphs() throws Exception
.inlineCode("run()")));
assertThat(runs).hasSize(2);
- assertThat(runs.get(0).getFontFamily()).isEqualTo("Helvetica");
+ // The surrounding text is the document's own body style, so its face comes from
+ // Normal and the run says nothing — that is what lets a reader restyle the
+ // document. The claim this test makes is about the chip, and it is unchanged: the
+ // chip's face differs, so it is still written on the run itself.
+ assertThat(runs.get(0).getFontFamily())
+ .describedAs("plain text takes its face from the Normal style")
+ .isNull();
assertThat(runs.get(1).getText(0)).isEqualTo("run()");
assertThat(runs.get(1).getFontFamily()).isEqualTo("Courier");
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
index c27b6bac8..e505f130e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackendTest.java
@@ -64,7 +64,7 @@ void chartExportsAsItsDataTable() throws Exception {
}
@Test
- void listsExportAsMarkerPrefixedParagraphs() throws Exception {
+ void listsExportAsWordListItems() throws Exception {
byte[] docxBytes;
try (DocumentSession session = GraphCompose.document()
.pageSize(595, 842)
@@ -79,13 +79,15 @@ void listsExportAsMarkerPrefixedParagraphs() throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
List texts = document.getParagraphs().stream()
.map(XWPFParagraph::getText).toList();
- assertThat(texts).anyMatch(t -> t.endsWith("First") && t.length() > "First".length());
- assertThat(texts).anyMatch(t -> t.endsWith("Second"));
+ // Word owns the marker now, so the item's text is the item and nothing more;
+ // that the paragraph is a list item is asserted through w:numPr, in
+ // DocxListNumberingTest.
+ assertThat(texts).contains("First", "Second");
}
}
@Test
- void nestedListItemsIndentTwoSpacesPerDepth() throws Exception {
+ void nestedListItemsUseListLevelsRatherThanIndentCharacters() throws Exception {
byte[] docxBytes;
try (DocumentSession session = GraphCompose.document()
.pageSize(595, 842)
@@ -104,13 +106,11 @@ void nestedListItemsIndentTwoSpacesPerDepth() throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
List texts = document.getParagraphs().stream()
.map(XWPFParagraph::getText).toList();
- // Two spaces of indent per depth; without per-item markers the
- // semantic export falls back to the same depth cascade the
- // fixed-layout pipeline uses (• ◦ ▪), so PDF and DOCX agree.
- assertThat(texts).contains(
- "• Level zero",
- " ◦ Level one",
- " ▪ Level two");
+ // Nesting is a list level rather than padding characters, so no indent
+ // reaches the text. Without per-item markers the export still falls back to
+ // the same depth cascade the fixed-layout pipeline uses (• ◦ ▪) — that it
+ // does is asserted on the level definitions in DocxListNumberingTest.
+ assertThat(texts).contains("Level zero", "Level one", "Level two");
}
}
@@ -133,13 +133,17 @@ void nestedListItemsKeepTheirCustomMarkers() throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docxBytes))) {
List texts = document.getParagraphs().stream()
.map(XWPFParagraph::getText).toList();
- // The per-depth override survives; the flat-list marker("→") does
- // not leak into nested fallbacks — depth 0 takes the cascade
- // bullet exactly as fixed-layout rendering does (markerFor(0, ...)
- // is the way to control depth 0).
- assertThat(texts).contains(
- "• Root",
- " ‣ Child");
+ assertThat(texts).contains("Root", "Child");
+
+ // The claim is about which marker each depth gets, and the markers now live
+ // in the list definition rather than in the text. The per-depth override
+ // survives; the flat-list marker("→") does not leak into nested fallbacks —
+ // depth 0 takes the cascade bullet exactly as fixed-layout rendering does
+ // (markerFor(0, ...) is the way to control depth 0).
+ var levels = document.getNumbering()
+ .getAbstractNum(java.math.BigInteger.ZERO).getAbstractNum().getLvlList();
+ assertThat(levels.get(0).getLvlText().getVal()).isEqualTo("•");
+ assertThat(levels.get(1).getLvlText().getVal()).isEqualTo("‣");
}
}
@@ -266,7 +270,7 @@ void listInsideASectionIsExported() throws Exception {
.map(XWPFParagraph::getText).toList();
// writeNode recurses through section/container wrappers, so the
// nested list is not dropped.
- assertThat(texts).contains("• Inside section");
+ assertThat(texts).contains("Inside section");
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableWidthTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableWidthTest.java
new file mode 100644
index 000000000..7d7582805
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableWidthTest.java
@@ -0,0 +1,172 @@
+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.style.DocumentInsets;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A table is as wide as the document says — in the cases where the document says.
+ *
+ * Nothing used to write a width at all, so Word sized every table to its own content
+ * and a table of short values came out far narrower than the reference draws it. But the
+ * fix is not "as wide as the page": with no stated width the engine gives a table the sum
+ * of its natural column widths, and an {@code auto} column's natural width is its widest
+ * unwrapped cell. That is a measurement, and this backend has no font runtime to make it,
+ * so the content width would be a guess that happens to be right for a table whose text
+ * fills the line and wrong for one with three short values in it.
+ *
+ * What is knowable without measuring is written: a width the author stated, and the
+ * column widths when every column is fixed. What is not stays Word's until resolved
+ * layout can supply the measured widths.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxTableWidthTest {
+
+ /** A5-ish page with a 20pt margin: 360pt of content width. */
+ private static final double PAGE_WIDTH = 400;
+ private static final double MARGIN = 20;
+ private static final double CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
+
+ /** Word counts table and column widths in twentieths of a point. */
+ private static final double TWIPS_PER_POINT = 20.0;
+
+ @Test
+ void anAutoTableWithNoStatedWidthKeepsWordsOwnSizing() throws Exception {
+ // The honest answer here is no answer: the width is the columns' natural widths,
+ // which nothing in this backend can measure. Writing the content width instead
+ // would be wrong in exactly the way the old shrink-to-fit was, in the other
+ // direction.
+ XWPFTable table = onlyTable(page -> page.addTable(t -> t
+ .autoColumns(3)
+ .headerRow("Item", "Qty", "Amount")
+ .row("A", "1", "2")));
+
+ assertThat(widthType(table)).as("POI's own size-to-content default, untouched").isEqualTo("auto");
+ assertThat(widthTwips(table)).isZero();
+ assertThat(table.getCTTbl().getTblGrid() == null
+ || table.getCTTbl().getTblGrid().sizeOfGridColArray() == 0)
+ .as("and no grid either")
+ .isTrue();
+ }
+
+ @Test
+ void anAuthoredWidthIsWrittenEvenWhenTheColumnsAreAuto() throws Exception {
+ // The total is stated, so it needs no measuring. How it divides still does.
+ XWPFTable table = onlyTable(page -> page.addTable(t -> t
+ .autoColumns(2)
+ .width(200)
+ .row("A", "B")));
+
+ assertThat(widthTwips(table)).isEqualTo(Math.round(200 * TWIPS_PER_POINT));
+ assertThat(widthType(table)).isEqualTo("dxa");
+ assertThat(table.getCTTbl().getTblGrid() == null
+ || table.getCTTbl().getTblGrid().sizeOfGridColArray() == 0)
+ .as("the split is Word's")
+ .isTrue();
+ }
+
+ @Test
+ void columnsThatAllStateAWidthAreWrittenExactlyAndSumToTheTable() throws Exception {
+ XWPFTable table = onlyTable(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(60))
+ .row("A", "B")));
+
+ assertThat(widthTwips(table))
+ .as("160pt of columns, not 360pt of page: the engine gives an unstated "
+ + "width the columns' natural sum")
+ .isEqualTo(3200);
+ var grid = table.getCTTbl().getTblGrid();
+ assertThat(grid).isNotNull();
+ assertThat(grid.sizeOfGridColArray()).isEqualTo(2);
+ assertThat(twips(grid.getGridColArray(0).getW())).isEqualTo(2000);
+ assertThat(twips(grid.getGridColArray(1).getW())).isEqualTo(1200);
+ }
+
+ @Test
+ void aStatedWidthWiderThanFixedColumnsGoesToTheLastOne() throws Exception {
+ // Mirrors resolveFinalColumnWidths: with no auto column to absorb the surplus,
+ // the engine hands all of it to the last column. Spreading it evenly instead
+ // would move every column edge but the first away from where the PDF draws it.
+ XWPFTable table = onlyTable(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.fixed(60))
+ .width(200)
+ .row("A", "B")));
+
+ assertThat(widthTwips(table)).isEqualTo(4000);
+ var grid = table.getCTTbl().getTblGrid();
+ assertThat(twips(grid.getGridColArray(0).getW())).as("untouched").isEqualTo(2000);
+ assertThat(twips(grid.getGridColArray(1).getW()))
+ .as("60pt plus the whole 40pt surplus")
+ .isEqualTo(2000);
+ }
+
+ @Test
+ void oneAutoColumnLeavesTheWholeTableToWord() throws Exception {
+ // Mixing a measured column with stated ones cannot be written honestly: the auto
+ // column's width is its content's, so neither the split nor the total is known.
+ XWPFTable table = onlyTable(page -> page.addTable(t -> t
+ .columns(DocumentTableColumn.fixed(100), DocumentTableColumn.auto())
+ .row("A", "B")));
+
+ assertThat(widthType(table)).isEqualTo("auto");
+ assertThat(table.getCTTbl().getTblGrid() == null
+ || table.getCTTbl().getTblGrid().sizeOfGridColArray() == 0)
+ .isTrue();
+ }
+
+ @Test
+ void aRowCarriedAsATableSpansTheContentWidth() throws Exception {
+ // A row takes the whole width it is offered whatever its children measure —
+ // measureRow returns the available width unconditionally — so this one needs no
+ // measuring either. POI's createTable writes w:tblW as w=0 type=auto, which
+ // collapsed the pair around its text and moved both columns away from where the
+ // fixed-layout render puts them.
+ XWPFTable table = onlyTable(page -> page.addRow(r -> r
+ .addParagraph(p -> p.text("Left"))
+ .addParagraph(p -> p.text("Right"))));
+
+ assertThat(widthType(table))
+ .as("stated, not POI's size-to-content default")
+ .isEqualTo("dxa");
+ assertThat(widthTwips(table)).isEqualTo(Math.round(CONTENT_WIDTH * TWIPS_PER_POINT));
+ }
+
+ private static long widthTwips(XWPFTable table) {
+ return twips(table.getCTTbl().getTblPr().getTblW().getW());
+ }
+
+ private static String widthType(XWPFTable table) {
+ return table.getCTTbl().getTblPr().getTblW().getType().toString();
+ }
+
+ /** {@code ST_TwipsMeasure} is an xmlbeans union, so the accessor is typed Object. */
+ private static long twips(Object measure) {
+ return Long.parseLong(String.valueOf(measure));
+ }
+
+ private static XWPFTable onlyTable(
+ Consumer content) throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(PAGE_WIDTH, 600)
+ .margin(DocumentInsets.of(MARGIN))
+ .create()) {
+ session.pageFlow(content::accept);
+ docx = session.export(new DocxSemanticBackend());
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ assertThat(document.getTables()).hasSize(1);
+ return document.getTables().get(0);
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFidelityTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFidelityTest.java
new file mode 100644
index 000000000..808c54217
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFidelityTest.java
@@ -0,0 +1,122 @@
+package com.demcha.compose.document.backend.semantic.docx.probe;
+
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Measures how close a DOCX export lands to its reference PDF, once a desktop editor
+ * has rendered the DOCX back to PDF.
+ *
+ * The editor half cannot run inside the build: Word and LibreOffice are not build
+ * dependencies, and rendering the DOCX with GraphCompose instead would compare the
+ * engine against itself. So this reads what
+ * {@code scripts/docx-visual/convert-with-word.ps1} left behind and skips when it is not
+ * there. A skip is an honest "not measured here" — it is not a pass, and the manifest
+ * says so.
+ *
+ * This records, it does not gate. The exporter it measures is the
+ * semantic one, which the capability matrix already documents as dropping fills,
+ * outlines, borders and every other piece of fixed-layout geometry. Asserting that no
+ * region of content is lost would be asserting something already known to be false, and
+ * a red build is not a measurement. So the loss is counted and written down, and the
+ * numbers become the baseline a later exporter has to beat. The gate arrives with the
+ * exporter that claims the capability.
+ *
+ * Two things are asserted, because they are settled today: the editor must produce
+ * the same number of pages, at the same physical size. Those held on first measurement,
+ * and a regression in them is a defect rather than a known gap.
+ *
+ * @author Artem Demchyshyn
+ */
+class EditableExportFidelityTest {
+
+ private static final Path PROBE = Path.of("target", "docx-probe");
+ private static final String[] FIXTURES = {"mixed-two-pager", "boundary-cases"};
+
+ /** Suffixes of the exports compared against one reference: the shipped one, and the probe's. */
+ private static final String[] VARIANTS = {"", "-prototype"};
+
+ @Test
+ void editorRenderShouldKeepPaginationAndRecordEveryRegionItLoses() throws Exception {
+ Path wordDir = PROBE.resolve("word");
+ Assumptions.assumeTrue(Files.isDirectory(wordDir),
+ "no editor render present — run scripts/docx-visual/convert-with-word.ps1 first");
+
+ List entries = new ArrayList<>();
+ for (String fixture : FIXTURES) {
+ Path reference = PROBE.resolve(fixture + ".pdf");
+ if (!Files.exists(reference)) {
+ continue;
+ }
+ // Both exports of the same document are measured against the one reference,
+ // so the two numbers are comparable by construction. A prototype that scored
+ // well against its own render would be scoring nothing.
+ for (String variant : VARIANTS) {
+ String id = fixture + variant;
+ Path candidate = wordDir.resolve(id + ".pdf");
+ if (!Files.exists(candidate)) {
+ continue;
+ }
+
+ PdfRegionDiff.Report report = PdfRegionDiff.compare(
+ reference, candidate, PROBE.resolve("diff").resolve(id));
+
+ assertThat(report.pageCountMatches())
+ .as("%s: reference has %d pages, the editor's render has %d",
+ id, report.referencePages(), report.candidatePages())
+ .isTrue();
+ assertThat(report.sizeMismatches())
+ .as("%s: page sizes must survive the round trip", id)
+ .isEmpty();
+
+ entries.add("""
+ {
+ "id": "%s",
+ "export": "%s",
+ "pages": %d,
+ "worstCellDifferingFraction": %.4f,
+ "cellsOver10pct": %d,
+ "cellsOver25pct": %d,
+ "lostRegions": %d,
+ "lostRegionCells": [%s],
+ "verdict": "MEASURED_NO_BUDGET_AGREED"
+ }"""
+ .formatted(fixture, variant.isEmpty() ? "semantic-backend" : "prototype",
+ report.referencePages(), report.worstCell(),
+ report.over(0.10).size(), report.over(0.25).size(),
+ report.lostContent().size(), describe(report.lostContent()))
+ .indent(2).stripTrailing());
+ }
+ }
+
+ Files.writeString(PROBE.resolve("fidelity.json"),
+ ("""
+ {
+ "note": "Baseline of the semantic exporter, measured through a desktop editor. \
+ Lost regions are counted, not tolerated: they are the number a later exporter has to reduce.",
+ "grid": %d,
+ "dpi": %d,
+ "fixtures": [
+ %s
+ ]
+ }
+ """).formatted(PdfRegionDiff.GRID, PdfRegionDiff.DPI, String.join(",\n", entries)),
+ StandardCharsets.UTF_8);
+ }
+
+ /** Renders lost cells as {@code "p1 c7 r4"} tokens so a reader can find them on the page. */
+ private static String describe(List cells) {
+ return cells.stream()
+ .map(c -> "\"p%d c%d r%d\"".formatted(c.page() + 1, c.column(), c.row()))
+ .reduce((a, b) -> a + ", " + b)
+ .orElse("");
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFixtures.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFixtures.java
new file mode 100644
index 000000000..bf766442f
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFixtures.java
@@ -0,0 +1,271 @@
+package com.demcha.compose.document.backend.semantic.docx.probe;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.RowBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.output.DocumentPageZone;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.UncheckedIOException;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static com.demcha.compose.document.style.DocumentRowColumn.weight;
+
+/**
+ * Source documents for the editable-DOCX probe corpus.
+ *
+ * Each fixture is authored once and rendered twice — through the PDF backend for the
+ * reference look, and through a DOCX exporter for the editing behaviour — so the two
+ * outputs can only differ because of the exporter, never because of the input. Nothing
+ * here is hand-placed: every block is an ordinary flow node, which is the point. A
+ * fixture that pinned coordinates would prove that a drawing can be copied, not that a
+ * Word document can be produced.
+ *
+ * The fixtures deliberately mix the constructs whose Word representations disagree:
+ * flowing paragraphs and a real table stay native in any design, while a panel, a
+ * clipped outline and a non-installed font family are where a semantic exporter has to
+ * choose. Keeping them in one document makes the choice visible in a single render.
+ *
+ * @author Artem Demchyshyn
+ */
+public final class EditableExportFixtures {
+
+ /** A4 width in points — the corpus renders at a realistic page size, not a stub. */
+ public static final double PAGE_WIDTH = 595;
+
+ /** A4 height in points. */
+ public static final double PAGE_HEIGHT = 842;
+
+ /** Page margin in points, shared by every fixture so content widths are comparable. */
+ public static final double PAGE_MARGIN = 42;
+
+ private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
+ private static final DocumentColor MUTED = DocumentColor.rgb(108, 116, 128);
+ private static final DocumentColor ACCENT = DocumentColor.rgb(26, 86, 148);
+ private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
+ private static final DocumentColor CHIP_FILL = DocumentColor.rgb(219, 233, 246);
+
+ private static final DocumentTextStyle TITLE =
+ DocumentTextStyle.builder().fontName(FontName.HELVETICA_BOLD).size(21).color(INK).build();
+ private static final DocumentTextStyle HEADING =
+ DocumentTextStyle.builder().fontName(FontName.HELVETICA_BOLD).size(13).color(INK).build();
+ private static final DocumentTextStyle BODY =
+ DocumentTextStyle.builder().fontName(FontName.HELVETICA).size(10.5).color(INK).build();
+ private static final DocumentTextStyle BODY_BOLD =
+ DocumentTextStyle.builder().fontName(FontName.HELVETICA_BOLD).size(10.5).color(INK).build();
+ private static final DocumentTextStyle BODY_ITALIC =
+ DocumentTextStyle.builder().fontName(FontName.HELVETICA_OBLIQUE).size(10.5).color(ACCENT).build();
+ private static final DocumentTextStyle CHROME =
+ DocumentTextStyle.builder().fontName(FontName.HELVETICA).size(8.5).color(MUTED).build();
+
+ /**
+ * A family bundled with GraphCompose and absent from a stock Windows or Linux
+ * install, so a paragraph set in it can only look right if the exporter carried the
+ * face into the file.
+ */
+ private static final DocumentTextStyle UNINSTALLED_FAMILY =
+ DocumentTextStyle.builder().fontName(FontName.LATO).size(10.5).color(INK).build();
+
+ private EditableExportFixtures() {
+ }
+
+ /**
+ * The two-page baseline: heading, mixed runs, a two-column row, a panel that has to
+ * grow, a real table, an image, a list and a page-number footer.
+ *
+ * The caller owns the returned session and closes it.
+ *
+ * @param pdfFile default output file for {@code buildPdf()}
+ * @return an open session carrying the whole fixture
+ */
+ public static DocumentSession mixedTwoPager(Path pdfFile) {
+ DocumentSession session = GraphCompose.document(pdfFile)
+ .pageSize(PAGE_WIDTH, PAGE_HEIGHT)
+ .margin(DocumentInsets.of(PAGE_MARGIN))
+ .create();
+
+ session.chrome().zone(DocumentPageZone.footer(30, page -> new RowBuilder()
+ .name("FooterZone")
+ .gap(8)
+ .addParagraph(p -> p.text("Editable export probe").textStyle(CHROME))
+ .flexSpacer()
+ .add(page.pageNumber(CHROME))
+ .build()));
+
+ session.pageFlow(page -> {
+ page.name("Body");
+
+ page.addParagraph(p -> p.text("Quarterly service report").textStyle(TITLE));
+
+ // Mixed runs in one paragraph. In Word this must stay one w:p whose runs
+ // carry their own styles — not one frame per rendered line.
+ page.addParagraph(p -> p.textStyle(BODY)
+ .padding(DocumentInsets.top(8))
+ .inlineText("This paragraph mixes ")
+ .inlineText("bold", BODY_BOLD)
+ .inlineText(", ")
+ .inlineText("italic accent", BODY_ITALIC)
+ .inlineText(" and inline ")
+ .inlineCode("code()")
+ .inlineText(" in one block, followed by a chip ")
+ .inlineChip("v2", ACCENT, CHIP_FILL)
+ .inlineText(" so the editing probe can lengthen a sentence that already "
+ + "carries several run styles and watch whether the wrap, the "
+ + "styles and the block below it all behave."));
+
+ // Two columns of unequal length: the row must keep them side by side and
+ // must not lose the longer column's tail at a page boundary.
+ page.addRow("TwoColumns", r -> r.gap(18).columns(weight(3), weight(2))
+ .padding(DocumentInsets.symmetric(14, 0))
+ .addSection(left -> left
+ .addParagraph(p -> p.text("Scope").textStyle(HEADING))
+ .addParagraph(p -> p.textStyle(BODY).padding(DocumentInsets.top(4))
+ .text("The left column is the longer of the two. It exists so "
+ + "an edit can make one column outgrow the other and "
+ + "the probe can record what the row does about it: "
+ + "whether both columns keep flowing, whether the "
+ + "shorter one stays put, and whether anything is "
+ + "clipped when the pair no longer fits.")))
+ .addSection(right -> right
+ .addParagraph(p -> p.text("Period").textStyle(HEADING))
+ .addParagraph(p -> p.textStyle(BODY).padding(DocumentInsets.top(4))
+ .text("Q3, closing 30 September. Shorter on purpose."))));
+
+ // The growing card: the acceptance example from the editing contract. Adding
+ // a sentence here must move the fill and the border with the text.
+ page.addSection("GrowingCard", card -> card
+ .softPanel(SURFACE, 8, 14)
+ .accentLeft(ACCENT, 3)
+ .margin(DocumentInsets.symmetric(6, 0))
+ .addParagraph(p -> p.text("Notice").textStyle(HEADING))
+ .addParagraph(p -> p.textStyle(BODY).padding(DocumentInsets.top(5))
+ .text("Lengthen this sentence in Word and the panel behind it has to "
+ + "grow with it. A panel that keeps its old height and clips "
+ + "the new text is the failure this corpus exists to catch.")));
+
+ page.addParagraph(p -> p.text("Billing").textStyle(HEADING)
+ .padding(DocumentInsets.top(16)));
+
+ page.addTable(t -> t.name("Billing")
+ .autoColumns(3)
+ .headerRow("Item", "Qty", "Amount")
+ .repeatHeader()
+ .row("Platform subscription", "12", "1 440.00")
+ .row("Priority support", "12", "720.00")
+ .row("Onboarding workshop", "1", "350.00")
+ .row("Additional storage, billed monthly in arrears", "9", "216.00")
+ .totalRow("Total", "", "2 726.00")
+ .margin(DocumentInsets.top(6)));
+
+ page.addPageBreak(b -> b.name("toSecond"));
+
+ page.addParagraph(p -> p.text("Attachments and notes").textStyle(TITLE));
+
+ page.addImage(image -> image
+ .source(DocumentImageData.fromBytes(sampleImagePng()))
+ .width(180)
+ .height(101)
+ .margin(DocumentInsets.symmetric(12, 0)));
+
+ page.addParagraph(p -> p.text("Checklist").textStyle(HEADING));
+
+ page.addList(list -> list
+ .name("Checklist")
+ .textStyle(BODY)
+ .itemSpacing(3)
+ .padding(DocumentInsets.top(4))
+ .items("Usage reconciled against the metering export",
+ "Support response times inside the agreed window",
+ "Storage growth reviewed with the account team",
+ "Next review scheduled for the first week of the quarter"));
+
+ page.addParagraph(p -> p.textStyle(UNINSTALLED_FAMILY)
+ .padding(DocumentInsets.top(14))
+ .text("This line is set in Lato, a family GraphCompose bundles and a stock "
+ + "desktop does not install. If it renders in something else, the "
+ + "export did not carry the face."));
+ });
+
+ return session;
+ }
+
+ /**
+ * The small boundary fixture: a rounded panel and a clipped outline, kept out of the
+ * baseline so a known-hard case cannot quietly degrade the main result.
+ *
+ * The caller owns the returned session and closes it.
+ *
+ * @param pdfFile default output file for {@code buildPdf()}
+ * @return an open session carrying the boundary cases
+ */
+ public static DocumentSession boundaryCases(Path pdfFile) {
+ DocumentSession session = GraphCompose.document(pdfFile)
+ .pageSize(360, 460)
+ .margin(DocumentInsets.of(28))
+ .create();
+
+ session.pageFlow(page -> {
+ page.name("Boundaries");
+
+ page.addParagraph(p -> p.text("Boundary cases").textStyle(HEADING));
+
+ // Rounded panel: Word can shade and border a paragraph, but not round it,
+ // so this is the first construct that has to pick a representation.
+ page.addSection("RoundedCard", card -> card
+ .softPanel(SURFACE, 14, 12)
+ .margin(DocumentInsets.top(10))
+ .addParagraph(p -> p.textStyle(BODY)
+ .text("A 14pt corner radius on a panel that also has to grow with "
+ + "its text. Word shading is rectangular.")));
+
+ // Clipped outline: the label is longer than the circle, so CLIP_PATH has ink
+ // to cut. This is the case the export must not answer by rasterising text.
+ page.addCircle(120, ACCENT, circle -> circle
+ .name("ClippedCircle")
+ .center(new com.demcha.compose.document.dsl.ParagraphBuilder()
+ .text("A label far longer than the circle that holds it")
+ .textStyle(DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA)
+ .size(9)
+ .color(DocumentColor.WHITE)
+ .build())
+ .build()));
+ });
+
+ return session;
+ }
+
+ /**
+ * A deterministic PNG — a plain gradient, generated rather than committed so the
+ * corpus carries no binary fixture.
+ *
+ * @return PNG bytes, 320x180
+ */
+ public static byte[] sampleImagePng() {
+ int width = 320;
+ int height = 180;
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ for (int x = 0; x < width; x++) {
+ for (int y = 0; y < height; y++) {
+ int r = 26 + (x * 160) / width;
+ int g = 86 + (y * 120) / height;
+ int b = 148 + (x * 60) / width;
+ image.setRGB(x, y, (r << 16) | (g << 8) | b);
+ }
+ }
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new UncheckedIOException("sample PNG could not be encoded", e);
+ }
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportProbeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportProbeTest.java
new file mode 100644
index 000000000..090c006f5
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportProbeTest.java
@@ -0,0 +1,269 @@
+package com.demcha.compose.document.backend.semantic.docx.probe;
+
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.semantic.SemanticBackend;
+import com.demcha.compose.document.backend.semantic.SemanticExportContext;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.layout.DocumentGraph;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Renders the probe corpus to PDF and DOCX and records what each export actually
+ * contains.
+ *
+ * This is a measuring harness, not a parity gate. It asserts only what is settled
+ * today — that both renders are produced, that the reference PDF paginates as the
+ * corpus was designed to, and that the DOCX is a readable OOXML package whose body is
+ * made of real paragraphs and a real table. Everything the editable-export work still
+ * has to decide is written to a manifest as an observation, so a later run can be
+ * compared against this one instead of against a recollection.
+ *
+ * The counts are deliberately the ones that decide how a Word document behaves
+ * rather than how it looks on open. A missing styles part means every run carries
+ * direct formatting and a global restyle does nothing; an absent numbering part means
+ * the list markers are characters in the text; an embedded font count of zero means a
+ * family the reader does not have will be substituted. None of that shows up in a
+ * screenshot, and all of it decides whether the file is editable.
+ *
+ * Artifacts land under {@code target/docx-probe}. They are inputs to the editor
+ * protocol in {@code scripts/docx-visual}, which no build can perform for itself.
+ *
+ * @author Artem Demchyshyn
+ */
+class EditableExportProbeTest {
+
+ private static final Path OUTPUT = Path.of("target", "docx-probe");
+ private static final Pattern ASCII_FONT = Pattern.compile("w:ascii=\"([^\"]+)\"");
+
+ @Test
+ void corpusShouldRenderToBothBackendsAndRecordWhatTheDocxContains() throws Exception {
+ Files.createDirectories(OUTPUT);
+
+ List entries = new ArrayList<>();
+ entries.add(probe("mixed-two-pager", 2, EditableExportFixtures::mixedTwoPager));
+ entries.add(probe("boundary-cases", 1, EditableExportFixtures::boundaryCases));
+
+ Files.writeString(OUTPUT.resolve("manifest.json"),
+ "{\n \"fixtures\": [\n" + String.join(",\n", entries) + "\n ],\n"
+ + " \"editorProtocol\": {\n"
+ + " \"wordDesktop\": \"see word/conversion.json and edit/edit-protocol.json\",\n"
+ + " \"libreOfficeWriter\": \"NOT_RUN\"\n"
+ + " },\n"
+ + " \"tolerances\": \"NOT_MEASURED\"\n}\n",
+ StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Renders one fixture both ways, writes the artifacts and returns its manifest entry.
+ */
+ private String probe(String id, int expectedPdfPages, Function fixture)
+ throws Exception {
+ Path pdfFile = OUTPUT.resolve(id + ".pdf");
+ Path docxFile = OUTPUT.resolve(id + ".docx");
+
+ byte[] docx;
+ byte[] prototype;
+ EditableExportPrototype.Applied[] applied = new EditableExportPrototype.Applied[1];
+ try (DocumentSession session = fixture.apply(pdfFile)) {
+ session.buildPdf();
+ docx = session.export(new DocxSemanticBackend(), docxFile);
+ // The same document, not a re-authored one: the prototype reads the tree the
+ // export just walked, so a difference between the two files can only come
+ // from the constructs the prototype adds. Null output: this writes nothing.
+ DocumentGraph graph = session.export(new GraphCapture(), null);
+ prototype = EditableExportPrototype.augment(docx, graph, applied);
+ }
+ Path prototypeFile = OUTPUT.resolve(id + "-prototype.docx");
+ Files.write(prototypeFile, prototype);
+ DocxShape prototypeShape = shapeOf(prototype);
+
+ int pdfPages;
+ try (PDDocument pdf = Loader.loadPDF(pdfFile.toFile())) {
+ pdfPages = pdf.getNumberOfPages();
+ }
+ // The corpus is designed around a known pagination; a change here means the
+ // fixture moved, and every measurement taken against the old one is stale.
+ assertThat(pdfPages)
+ .as("reference PDF pagination for %s", id)
+ .isEqualTo(expectedPdfPages);
+
+ DocxShape shape = shapeOf(docx);
+ assertThat(shape.paragraphs())
+ .as("%s must export real Word paragraphs", id)
+ .isPositive();
+ // Nothing in this corpus is a floating box today, and a body turned into boxes
+ // is the specific regression the editing contract forbids.
+ assertThat(shape.textBoxes())
+ .as("%s: body content must flow, not sit in text boxes", id)
+ .isZero();
+
+ // Not the package hash: a DOCX carries creation timestamps and a PDF carries a
+ // time-seeded /ID, so both change on every run and comparing them would only
+ // ever say "this is a different run". document.xml has no such field, so its
+ // hash does answer the question a reader actually has — did the body change.
+ return """
+ {
+ "id": "%s",
+ "pdf": { "file": "%s.pdf", "pages": %d, "bytes": %d },
+ "docx": { "file": "%s.docx", "documentXmlSha256": "%s",
+ "bodyParagraphs": %d, "tables": %d, "tableRows": %d,
+ "pictures": %d, "textBoxes": %d, "footerParts": %d,
+ "hasStylesPart": %b, "hasNumberingPart": %b,
+ "numberedParagraphs": %d, "paragraphShading": %d,
+ "paragraphBorders": %d, "embeddedFontFaces": %d,
+ "declaredFonts": [%s] },
+ "prototype": { "file": "%s-prototype.docx",
+ "hasStylesPart": %b, "hasNumberingPart": %b,
+ "numberedParagraphs": %d, "paragraphShading": %d,
+ "paragraphBorders": %d, "textBoxes": %d,
+ "runsFreedToFollowTheStyle": %d,
+ "paragraphsNumbered": %d, "paragraphsShaded": %d },
+ "initialFidelity": "see fidelity.json",
+ "editability": "see edit/edit-protocol.json"
+ }"""
+ .formatted(id,
+ id, pdfPages, Files.size(pdfFile),
+ id, sha256(documentXml(docx)),
+ shape.paragraphs(), shape.tables(), shape.tableRows(),
+ shape.pictures(), shape.textBoxes(), shape.footerParts(),
+ shape.hasStylesPart(), shape.hasNumberingPart(),
+ shape.numberedParagraphs(), shape.paragraphShading(),
+ shape.paragraphBorders(), shape.embeddedFontFaces(),
+ quoted(shape.declaredFonts()),
+ id, prototypeShape.hasStylesPart(), prototypeShape.hasNumberingPart(),
+ prototypeShape.numberedParagraphs(), prototypeShape.paragraphShading(),
+ prototypeShape.paragraphBorders(), prototypeShape.textBoxes(),
+ applied[0].styledRuns(), applied[0].numberedParagraphs(),
+ applied[0].shadedParagraphs())
+ .indent(2).stripTrailing();
+ }
+
+ /**
+ * What the exported package is made of, read back from the file rather than from the
+ * exporter's own view of what it wrote.
+ *
+ * @param paragraphs top-level body paragraphs
+ * @param tables top-level body tables
+ * @param tableRows rows across those tables
+ * @param pictures embedded pictures across all body paragraphs
+ * @param textBoxes {@code w:txbxContent} elements — above zero means some body
+ * content is a floating box rather than flowing text
+ * @param footerParts real {@code w:ftr} parts in the package
+ * @param hasStylesPart whether a styles part exists at all; without one every run
+ * carries direct formatting and a global restyle is inert
+ * @param hasNumberingPart whether a numbering part exists; without one a list is
+ * marker characters in ordinary paragraphs
+ * @param numberedParagraphs paragraphs carrying {@code w:numPr}
+ * @param paragraphShading {@code w:shd} elements — the only fill Word paragraphs and
+ * table cells can carry
+ * @param paragraphBorders {@code w:pBdr} elements — a panel outline, if there is one
+ * @param embeddedFontFaces {@code w:embedRegular} and friends; zero means a family
+ * the reader lacks will be substituted
+ * @param declaredFonts distinct {@code w:ascii} families named in the body
+ */
+ private record DocxShape(int paragraphs, int tables, int tableRows, int pictures,
+ int textBoxes, int footerParts, boolean hasStylesPart,
+ boolean hasNumberingPart, int numberedParagraphs,
+ int paragraphShading, int paragraphBorders,
+ int embeddedFontFaces, Set declaredFonts) {
+ }
+
+ private static DocxShape shapeOf(byte[] docx) throws IOException {
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ int rows = document.getTables().stream().mapToInt(t -> t.getRows().size()).sum();
+ int pictures = document.getParagraphs().stream()
+ .flatMap(p -> p.getRuns().stream())
+ .mapToInt(r -> r.getEmbeddedPictures().size())
+ .sum();
+ // Counted on the raw XML: POI has no accessor for several of these, and an
+ // absence is exactly what the editing contract turns on.
+ String xml = document.getDocument().toString();
+ Set fonts = new TreeSet<>();
+ Matcher matcher = ASCII_FONT.matcher(xml);
+ while (matcher.find()) {
+ fonts.add(matcher.group(1));
+ }
+ return new DocxShape(document.getParagraphs().size(), document.getTables().size(),
+ rows, pictures,
+ count(xml, "txbxContent"),
+ document.getFooterList().size(),
+ document.getStyles() != null,
+ document.getNumbering() != null,
+ count(xml, "= 0; at = haystack.indexOf(needle, at + needle.length())) {
+ count++;
+ }
+ return count;
+ }
+
+ /**
+ * Hands back the very tree the export was given.
+ *
+ * There is no public way to ask a session for its node tree, and the prototype has
+ * to read the same one the exporter walked — re-authoring the fixture would compare
+ * two documents rather than two exports of one.
+ */
+ private static final class GraphCapture implements SemanticBackend {
+ @Override
+ public String name() {
+ return "graph-capture";
+ }
+
+ @Override
+ public DocumentGraph export(DocumentGraph graph, SemanticExportContext context) {
+ return graph;
+ }
+ }
+
+ private static String quoted(Set values) {
+ return values.stream().map(v -> "\"" + v + "\"").reduce((a, b) -> a + ", " + b).orElse("");
+ }
+
+ private static String sha256(byte[] bytes) throws Exception {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
+ }
+
+ /** Reads {@code word/document.xml} out of the package — the body, without the timestamps. */
+ private static byte[] documentXml(byte[] docx) throws IOException {
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(docx))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ if ("word/document.xml".equals(entry.getName())) {
+ return zip.readAllBytes();
+ }
+ }
+ }
+ throw new IOException("the export contains no word/document.xml");
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportPrototype.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportPrototype.java
new file mode 100644
index 000000000..cea705357
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportPrototype.java
@@ -0,0 +1,343 @@
+package com.demcha.compose.document.backend.semantic.docx.probe;
+
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.node.ContainerNode;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.ListNode;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.node.SectionNode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+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.XWPFStyles;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPBdr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyles;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A throwaway exporter prototype: takes the DOCX the semantic backend produced and adds
+ * the three Word constructs the measured baseline does not emit at all — a styles part,
+ * a numbering part, and paragraph shading with a border for a filled container.
+ *
+ * It exists to answer one question that no amount of reading the OOXML specification
+ * settles: does Word's own paragraph machinery carry this design and keep
+ * behaving when somebody edits it? A panel drawn as paragraph shading has to grow with
+ * its text; a list attached to real numbering has to continue on Enter; a body whose runs
+ * no longer carry a direct size has to follow the Normal style. Those are claims about
+ * Word, and only Word can answer them.
+ *
+ * This is not the design. It matches paragraphs to source nodes by
+ * their text, which the architecture explicitly rules out — text is not identity, and two
+ * paragraphs that read the same are not the same paragraph. Real provenance is a later
+ * problem, and solving it here would mean guessing at the answer before the question is
+ * asked. Matching by text is sound for exactly one thing: a corpus whose paragraph texts
+ * are known to be distinct, which is how the probe fixtures are written. Nothing in this
+ * class may be promoted; it is a measuring instrument that gets thrown away.
+ *
+ * Superseded. Word answered yes to all three, and
+ * {@code DocxSemanticBackend} now writes the styles part, the numbering part and the
+ * container's paint itself. Augmenting an export that already has them is a no-op for the
+ * panel and the styles, and adds a second, redundant list definition. It is kept only so
+ * the measurement that justified those three can be reproduced against an older build;
+ * once nothing needs that comparison, delete it rather than maintaining it.
+ *
+ * @author Artem Demchyshyn
+ */
+public final class EditableExportPrototype {
+
+ /** Word measures font size in half-points and border width in eighths of a point. */
+ private static final int HALF_POINTS = 2;
+ private static final BigInteger BULLET_NUM_ID = BigInteger.ONE;
+
+ private EditableExportPrototype() {
+ }
+
+ /**
+ * What the prototype changed, so a measurement can say which construct did the work.
+ *
+ * @param styledRuns runs whose direct size and font were removed in favour of the style
+ * @param numberedParagraphs paragraphs attached to the numbering part
+ * @param shadedParagraphs paragraphs given a fill, a border, or both
+ */
+ public record Applied(int styledRuns, int numberedParagraphs, int shadedParagraphs) {
+ }
+
+ /** A filled container found in the source, and the paragraph texts it wraps. */
+ private record Panel(DocumentColor fill, DocumentStroke accentLeft, Set texts) {
+ }
+
+ /**
+ * Augments an exported package in place and returns the new bytes.
+ *
+ * @param docx bytes the semantic backend produced
+ * @param graph the same document's node tree, walked for fills, lists and the body style
+ * @param applied receives what was changed; may be {@code null}
+ * @return the augmented package
+ * @throws Exception if the package cannot be read or rewritten
+ */
+ public static byte[] augment(byte[] docx, DocumentGraph graph, Applied[] applied) throws Exception {
+ List panels = new ArrayList<>();
+ Set listItems = new LinkedHashSet<>();
+ Map styleWeights = new HashMap<>();
+ for (DocumentNode root : graph.roots()) {
+ collect(root, null, panels, listItems, styleWeights);
+ }
+ DocumentTextStyle body = styleWeights.entrySet().stream()
+ .max(Map.Entry.comparingByValue())
+ .map(Map.Entry::getKey)
+ .orElse(DocumentTextStyle.DEFAULT);
+
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ writeStylesPart(document, body);
+ writeBulletNumbering(document);
+
+ int styledRuns = 0;
+ int numbered = 0;
+ int shaded = 0;
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ String text = normalize(paragraph.getText());
+ if (text.isEmpty()) {
+ continue;
+ }
+ styledRuns += dropDirectFormattingThatMatchesTheStyle(paragraph, body);
+ if (attachNumbering(paragraph, text, listItems)) {
+ numbered++;
+ }
+ for (Panel panel : panels) {
+ if (panel.texts().contains(text)) {
+ paintPanel(paragraph, panel);
+ shaded++;
+ break;
+ }
+ }
+ }
+ if (applied != null && applied.length > 0) {
+ applied[0] = new Applied(styledRuns, numbered, shaded);
+ }
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ document.write(out);
+ return out.toByteArray();
+ }
+ }
+
+ /**
+ * Walks the tree, carrying the nearest filled ancestor down so a paragraph inside a
+ * panel is recorded against it.
+ */
+ private static void collect(DocumentNode node,
+ Panel enclosing,
+ List panels,
+ Set listItems,
+ Map styleWeights) {
+ Panel current = enclosing;
+ DocumentColor fill = fillOf(node);
+ if (fill != null) {
+ current = new Panel(fill, leftBorderOf(node), new LinkedHashSet<>());
+ panels.add(current);
+ }
+ if (node instanceof ParagraphNode paragraph) {
+ String text = normalize(paragraph.text());
+ if (!text.isEmpty() && current != null) {
+ current.texts().add(text);
+ }
+ if (paragraph.textStyle() != null) {
+ // Weighted by characters, not by paragraph. Headings are short and
+ // numerous; body text is long. Counting paragraphs picks the heading
+ // style as the document default, which is how this probe first got it
+ // wrong — only three runs matched and the restyle stayed inert.
+ styleWeights.merge(paragraph.textStyle(), Math.max(1, text.length()), Integer::sum);
+ }
+ } else if (node instanceof ListNode list) {
+ listItems.addAll(list.items().stream().map(EditableExportPrototype::normalize).toList());
+ }
+ for (DocumentNode child : node.children()) {
+ collect(child, current, panels, listItems, styleWeights);
+ }
+ }
+
+ private static DocumentColor fillOf(DocumentNode node) {
+ if (node instanceof SectionNode section) {
+ return section.fillColor();
+ }
+ if (node instanceof ContainerNode container) {
+ return container.fillColor();
+ }
+ return null;
+ }
+
+ private static DocumentStroke leftBorderOf(DocumentNode node) {
+ if (node instanceof SectionNode section && section.borders() != null) {
+ return section.borders().left();
+ }
+ if (node instanceof ContainerNode container && container.borders() != null) {
+ return container.borders().left();
+ }
+ return null;
+ }
+
+ /**
+ * Gives the package a styles part with the body font and size as the document default.
+ *
+ * Without one, Word invents a latent Normal that no run refers to, which is why the
+ * baseline accepts a restyle and ignores it.
+ */
+ private static void writeStylesPart(XWPFDocument document, DocumentTextStyle body) {
+ XWPFStyles styles = document.createStyles();
+ CTStyles ctStyles = CTStyles.Factory.newInstance();
+ CTRPr defaults = ctStyles.addNewDocDefaults().addNewRPrDefault().addNewRPr();
+ defaults.addNewRFonts().setAscii(body.fontName().name());
+ BigInteger halfPoints = BigInteger.valueOf(Math.round(body.size() * HALF_POINTS));
+ defaults.addNewSz().setVal(halfPoints);
+ defaults.addNewSzCs().setVal(halfPoints);
+
+ CTStyle normal = ctStyles.addNewStyle();
+ normal.setType(STStyleType.PARAGRAPH);
+ normal.setStyleId("Normal");
+ normal.setDefault(true);
+ normal.addNewName().setVal("Normal");
+ CTRPr normalRun = normal.addNewRPr();
+ normalRun.addNewRFonts().setAscii(body.fontName().name());
+ normalRun.addNewSz().setVal(halfPoints);
+ normalRun.addNewSzCs().setVal(halfPoints);
+
+ styles.setStyles(ctStyles);
+ }
+
+ /** Adds one bullet list definition; the corpus needs no more than one. */
+ private static void writeBulletNumbering(XWPFDocument document) {
+ CTAbstractNum abstractNum = CTAbstractNum.Factory.newInstance();
+ abstractNum.setAbstractNumId(BigInteger.ZERO);
+ CTLvl level = abstractNum.addNewLvl();
+ level.setIlvl(BigInteger.ZERO);
+ level.addNewStart().setVal(BigInteger.ONE);
+ level.addNewNumFmt().setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat.BULLET);
+ level.addNewLvlText().setVal("\u2022");
+ level.addNewLvlJc().setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STJc.LEFT);
+
+ org.apache.poi.xwpf.usermodel.XWPFNumbering numbering = document.createNumbering();
+ BigInteger abstractId = numbering.addAbstractNum(
+ new org.apache.poi.xwpf.usermodel.XWPFAbstractNum(abstractNum));
+ numbering.addNum(abstractId);
+ }
+
+ /**
+ * Removes a run's direct font and size when they only restate the document default.
+ *
+ * @return how many runs were freed to follow the style
+ */
+ private static int dropDirectFormattingThatMatchesTheStyle(XWPFParagraph paragraph,
+ DocumentTextStyle body) {
+ int freed = 0;
+ int bodyHalfPoints = (int) Math.round(body.size() * HALF_POINTS);
+ for (XWPFRun run : paragraph.getRuns()) {
+ CTRPr properties = run.getCTR().getRPr();
+ if (properties == null) {
+ continue;
+ }
+ // w:rPr children bind as arrays here, not as singletons: xmlbeans generates
+ // sizeOf/get/removeXArray for them and no isSet/unset pair, so this reads and
+ // clears through the array API rather than the more familiar one.
+ boolean sameSize = properties.sizeOfSzArray() > 0
+ && new BigInteger(properties.getSzArray(0).getVal().toString())
+ .intValue() == bodyHalfPoints;
+ boolean sameFont = properties.sizeOfRFontsArray() > 0
+ && body.fontName().name().equals(properties.getRFontsArray(0).getAscii());
+ // A run that says something the style does not must keep saying it; only a
+ // run that merely restates the default is freed.
+ if (sameSize && sameFont) {
+ properties.removeSz(0);
+ if (properties.sizeOfSzCsArray() > 0) {
+ properties.removeSzCs(0);
+ }
+ properties.removeRFonts(0);
+ freed++;
+ }
+ }
+ return freed;
+ }
+
+ /**
+ * Turns a marker-prefixed paragraph into a real numbered one, dropping the marker
+ * character now that Word draws it.
+ *
+ * @return whether this paragraph was a list item
+ */
+ private static boolean attachNumbering(XWPFParagraph paragraph, String text, Set listItems) {
+ String withoutMarker = stripMarker(text);
+ if (!listItems.contains(withoutMarker)) {
+ return false;
+ }
+ for (XWPFRun run : paragraph.getRuns()) {
+ String runText = run.getText(0);
+ if (runText != null && !runText.equals(stripMarker(runText))) {
+ run.setText(stripMarker(runText), 0);
+ }
+ }
+ paragraph.setNumID(BULLET_NUM_ID);
+ return true;
+ }
+
+ private static String stripMarker(String text) {
+ String trimmed = text.stripLeading();
+ if (trimmed.startsWith("\u2022") || trimmed.startsWith("-")) {
+ return trimmed.substring(1).stripLeading();
+ }
+ return text.strip();
+ }
+
+ /** Shades the paragraph and, when the source had one, draws its accent on the left. */
+ private static void paintPanel(XWPFParagraph paragraph, Panel panel) {
+ CTPPr properties = paragraph.getCTP().isSetPPr()
+ ? paragraph.getCTP().getPPr()
+ : paragraph.getCTP().addNewPPr();
+
+ CTShd shading = properties.isSetShd() ? properties.getShd() : properties.addNewShd();
+ shading.setVal(STShd.CLEAR);
+ shading.setColor("auto");
+ shading.setFill(hex(panel.fill()));
+
+ if (panel.accentLeft() != null && panel.accentLeft().width() > 0) {
+ CTPBdr borders = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr();
+ var left = borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft();
+ left.setVal(STBorder.SINGLE);
+ // w:sz counts eighths of a point, as the table painter already does, and
+ // rounds to at least one so a hairline stays a line.
+ left.setSz(BigInteger.valueOf(Math.max(1, Math.round(panel.accentLeft().width() * 8.0))));
+ left.setSpace(BigInteger.valueOf(4));
+ left.setColor(hex(panel.accentLeft().color()));
+ }
+ }
+
+ private static String hex(DocumentColor color) {
+ java.awt.Color awt = color.color();
+ return String.format(Locale.ROOT, "%02X%02X%02X",
+ awt.getRed(), awt.getGreen(), awt.getBlue());
+ }
+
+ private static String normalize(String text) {
+ return text == null ? "" : text.replaceAll("\\s+", " ").strip();
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/PdfRegionDiff.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/PdfRegionDiff.java
new file mode 100644
index 000000000..a3958d63f
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/PdfRegionDiff.java
@@ -0,0 +1,239 @@
+package com.demcha.compose.document.backend.semantic.docx.probe;
+
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.rendering.ImageType;
+import org.apache.pdfbox.rendering.PDFRenderer;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Rasterises two PDFs and compares them region by region.
+ *
+ * A single similarity percentage over a whole page is the wrong instrument here. An
+ * A4 page is mostly margin, so a paragraph that vanished entirely still scores well
+ * above ninety percent, and a result that cannot tell "identical" from "the body text is
+ * gone" is not a measurement. This splits each page into a grid and reports every cell
+ * separately, plus the one comparison that needs no tolerance at all: a cell carrying ink
+ * in the reference and none in the candidate means content was lost, whatever the
+ * percentages say.
+ *
+ * Nothing here decides what an acceptable difference is. It reports what the
+ * difference is; the budgets belong to whichever corpus is being measured, and are not
+ * verified until something measures them.
+ *
+ * @author Artem Demchyshyn
+ */
+public final class PdfRegionDiff {
+
+ /** Rasterisation resolution. Fixed so two runs are comparable. */
+ public static final int DPI = 144;
+
+ /** Grid resolution used when a caller does not choose one. */
+ public static final int GRID = 12;
+
+ /**
+ * Per-channel difference below which two pixels count as equal. Covers
+ * antialiasing only; it is deliberately far too small to hide a moved glyph.
+ */
+ private static final int PIXEL_TOLERANCE = 12;
+
+ /** Fraction of differing pixels above which a cell counts as carrying ink. */
+ private static final double INK_THRESHOLD = 0.002;
+
+ private PdfRegionDiff() {
+ }
+
+ /**
+ * The outcome of comparing two rendered documents.
+ *
+ * @param pageCountMatches whether both PDFs have the same number of pages
+ * @param referencePages page count of the reference
+ * @param candidatePages page count of the candidate
+ * @param sizeMismatches human-readable page-size differences, empty when none
+ * @param cells one entry per compared grid cell
+ */
+ public record Report(boolean pageCountMatches,
+ int referencePages,
+ int candidatePages,
+ List sizeMismatches,
+ List cells) {
+
+ /** @return cells whose reference had ink and whose candidate is blank */
+ public List lostContent() {
+ return cells.stream().filter(Cell::contentLost).toList();
+ }
+
+ /**
+ * @param budget maximum tolerated fraction of differing pixels in one cell
+ * @return cells exceeding that budget, worst first
+ */
+ public List over(double budget) {
+ return cells.stream()
+ .filter(c -> c.differingFraction() > budget)
+ .sorted((a, b) -> Double.compare(b.differingFraction(), a.differingFraction()))
+ .toList();
+ }
+
+ /** @return the worst differing fraction across every cell, or 0 when there are none */
+ public double worstCell() {
+ return cells.stream().mapToDouble(Cell::differingFraction).max().orElse(0);
+ }
+ }
+
+ /**
+ * One grid cell of one page.
+ *
+ * @param page zero-based page index
+ * @param column zero-based grid column
+ * @param row zero-based grid row
+ * @param differingFraction fraction of pixels differing beyond the antialiasing tolerance
+ * @param referenceHasInk whether the reference cell carries any non-background pixels
+ * @param candidateHasInk whether the candidate cell carries any non-background pixels
+ */
+ public record Cell(int page,
+ int column,
+ int row,
+ double differingFraction,
+ boolean referenceHasInk,
+ boolean candidateHasInk) {
+
+ /** @return true when the reference drew something here and the candidate drew nothing */
+ public boolean contentLost() {
+ return referenceHasInk && !candidateHasInk;
+ }
+ }
+
+ /**
+ * Compares two PDFs at {@link #DPI} on a {@link #GRID}-square grid.
+ *
+ * @param reference the PDF that defines the expected look
+ * @param candidate the PDF produced from the export under test
+ * @param diffOutput directory to receive, per page, the reference render, the editor's
+ * render and the overlay of the two; {@code null} writes nothing
+ * @return the comparison report
+ * @throws IOException if either file cannot be read or a diff image cannot be written
+ */
+ public static Report compare(Path reference, Path candidate, Path diffOutput) throws IOException {
+ try (PDDocument ref = Loader.loadPDF(reference.toFile());
+ PDDocument cand = Loader.loadPDF(candidate.toFile())) {
+
+ List sizeMismatches = new ArrayList<>();
+ int comparable = Math.min(ref.getNumberOfPages(), cand.getNumberOfPages());
+ for (int page = 0; page < comparable; page++) {
+ float refWidth = ref.getPage(page).getMediaBox().getWidth();
+ float refHeight = ref.getPage(page).getMediaBox().getHeight();
+ float candWidth = cand.getPage(page).getMediaBox().getWidth();
+ float candHeight = cand.getPage(page).getMediaBox().getHeight();
+ // Half a point: below that the two are the same box written differently.
+ if (Math.abs(refWidth - candWidth) > 0.5f || Math.abs(refHeight - candHeight) > 0.5f) {
+ sizeMismatches.add("page %d: reference %.1fx%.1f, candidate %.1fx%.1f"
+ .formatted(page + 1, refWidth, refHeight, candWidth, candHeight));
+ }
+ }
+
+ PDFRenderer refRenderer = new PDFRenderer(ref);
+ PDFRenderer candRenderer = new PDFRenderer(cand);
+ List cells = new ArrayList<>();
+ if (diffOutput != null) {
+ Files.createDirectories(diffOutput);
+ }
+
+ for (int page = 0; page < comparable; page++) {
+ BufferedImage a = refRenderer.renderImageWithDPI(page, DPI, ImageType.RGB);
+ BufferedImage b = candRenderer.renderImageWithDPI(page, DPI, ImageType.RGB);
+ BufferedImage diff = diffOutput == null ? null
+ : new BufferedImage(Math.min(a.getWidth(), b.getWidth()),
+ Math.min(a.getHeight(), b.getHeight()), BufferedImage.TYPE_INT_RGB);
+ cells.addAll(comparePage(page, a, b, diff));
+ if (diff != null) {
+ // The overlay shows where the two disagree; the two page images
+ // show what each side actually drew. A reader needs all three:
+ // an overlay alone cannot say which of the two is wrong.
+ ImageIO.write(diff, "png",
+ diffOutput.resolve("page-%d-diff.png".formatted(page + 1)).toFile());
+ ImageIO.write(a, "png",
+ diffOutput.resolve("page-%d-reference.png".formatted(page + 1)).toFile());
+ ImageIO.write(b, "png",
+ diffOutput.resolve("page-%d-editor.png".formatted(page + 1)).toFile());
+ }
+ }
+
+ return new Report(ref.getNumberOfPages() == cand.getNumberOfPages(),
+ ref.getNumberOfPages(), cand.getNumberOfPages(), sizeMismatches, cells);
+ }
+ }
+
+ private static List comparePage(int page, BufferedImage a, BufferedImage b, BufferedImage diff) {
+ int width = Math.min(a.getWidth(), b.getWidth());
+ int height = Math.min(a.getHeight(), b.getHeight());
+ int[] differing = new int[GRID * GRID];
+ int[] total = new int[GRID * GRID];
+ int[] refInk = new int[GRID * GRID];
+ int[] candInk = new int[GRID * GRID];
+
+ for (int y = 0; y < height; y++) {
+ int row = Math.min(GRID - 1, y * GRID / height);
+ for (int x = 0; x < width; x++) {
+ int cell = Math.min(GRID - 1, x * GRID / width) + row * GRID;
+ int pa = a.getRGB(x, y);
+ int pb = b.getRGB(x, y);
+ total[cell]++;
+ if (isInk(pa)) {
+ refInk[cell]++;
+ }
+ if (isInk(pb)) {
+ candInk[cell]++;
+ }
+ boolean differs = channelDelta(pa, pb) > PIXEL_TOLERANCE;
+ if (differs) {
+ differing[cell]++;
+ }
+ if (diff != null) {
+ // Differences in red over a faded reference, so a reader can see
+ // both what changed and where on the page it sits.
+ diff.setRGB(x, y, differs ? 0xFFE0263C : fade(pa));
+ }
+ }
+ }
+
+ List| cells = new ArrayList<>(GRID * GRID);
+ for (int index = 0; index < GRID * GRID; index++) {
+ if (total[index] == 0) {
+ continue;
+ }
+ cells.add(new Cell(page, index % GRID, index / GRID,
+ (double) differing[index] / total[index],
+ (double) refInk[index] / total[index] > INK_THRESHOLD,
+ (double) candInk[index] / total[index] > INK_THRESHOLD));
+ }
+ return cells;
+ }
+
+ /** A pixel counts as ink when it is meaningfully darker than paper white. */
+ private static boolean isInk(int rgb) {
+ int r = (rgb >> 16) & 0xFF;
+ int g = (rgb >> 8) & 0xFF;
+ int b = rgb & 0xFF;
+ return (r + g + b) / 3 < 232;
+ }
+
+ private static int channelDelta(int first, int second) {
+ return Math.max(Math.abs(((first >> 16) & 0xFF) - ((second >> 16) & 0xFF)),
+ Math.max(Math.abs(((first >> 8) & 0xFF) - ((second >> 8) & 0xFF)),
+ Math.abs((first & 0xFF) - (second & 0xFF))));
+ }
+
+ private static int fade(int rgb) {
+ int r = 255 - (255 - ((rgb >> 16) & 0xFF)) / 4;
+ int g = 255 - (255 - ((rgb >> 8) & 0xFF)) / 4;
+ int b = 255 - (255 - (rgb & 0xFF)) / 4;
+ return (r << 16) | (g << 8) | b;
+ }
+}
diff --git a/scripts/docx-visual/convert-with-word.ps1 b/scripts/docx-visual/convert-with-word.ps1
new file mode 100644
index 000000000..da0f5b529
--- /dev/null
+++ b/scripts/docx-visual/convert-with-word.ps1
@@ -0,0 +1,146 @@
+<#
+.SYNOPSIS
+ Converts DOCX files to PDF through an installed desktop editor, for the
+ editable-export visual comparison.
+
+.DESCRIPTION
+ The DOCX side of the comparison has to be rendered by the program people
+ actually open the file in. Exporting the GraphCompose PDF a second time and
+ calling it the DOCX render would compare the engine against itself and pass
+ no matter what the exporter wrote.
+
+ Microsoft Word is driven through COM. LibreOffice Writer is driven through
+ `soffice --convert-to pdf`. Neither is installed by the build, so a missing
+ editor is reported as NOT_RUN and the script exits non-zero: an absent editor
+ changes how a result is obtained, never whether it is required.
+
+ Every run writes a sidecar JSON next to the PDFs recording which editor and
+ version produced them, because a rendering difference between two Word builds
+ is a real finding and an unlabelled PDF cannot carry it.
+
+.PARAMETER Path
+ A .docx file, or a directory that is searched (non-recursively) for .docx files.
+
+.PARAMETER OutputDir
+ Where the PDFs and the sidecar are written. Defaults to a `word` or
+ `libreoffice` subdirectory beside the input.
+
+.PARAMETER Editor
+ `word` (default) or `libreoffice`.
+
+.EXAMPLE
+ ./scripts/docx-visual/convert-with-word.ps1 -Path render-docx/target/docx-probe
+
+.EXAMPLE
+ ./scripts/docx-visual/convert-with-word.ps1 -Path out.docx -Editor libreoffice
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [string]$OutputDir,
+ [ValidateSet('word', 'libreoffice')][string]$Editor = 'word'
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Resolve-Inputs {
+ param([string]$Target)
+ if (Test-Path -PathType Leaf $Target) { return @((Get-Item $Target)) }
+ if (Test-Path -PathType Container $Target) {
+ return @(Get-ChildItem -Path $Target -Filter *.docx -File)
+ }
+ throw "Input not found: $Target"
+}
+
+# wdExportFormatPDF; the numeric constant is used rather than the named enum so
+# the script does not depend on the Word type library being registered for the
+# PowerShell host.
+$WD_EXPORT_FORMAT_PDF = 17
+
+function Convert-WithWord {
+ param([System.IO.FileInfo[]]$Files, [string]$Destination)
+
+ try {
+ $word = New-Object -ComObject Word.Application
+ } catch {
+ return @{ status = 'NOT_RUN'; reason = 'Microsoft Word is not available through COM on this machine'; results = @() }
+ }
+
+ $version = $word.Version
+ $build = $word.Build
+ $word.Visible = $false
+ $word.DisplayAlerts = 0
+ $results = @()
+ try {
+ foreach ($file in $Files) {
+ $out = Join-Path $Destination ($file.BaseName + '.pdf')
+ $doc = $word.Documents.Open($file.FullName, [ref]$false, [ref]$true)
+ try {
+ $doc.ExportAsFixedFormat($out, $WD_EXPORT_FORMAT_PDF)
+ $results += @{ source = $file.Name; pdf = (Split-Path $out -Leaf); pages = $doc.ComputeStatistics(2) }
+ } finally {
+ $doc.Close([ref]$false)
+ [Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
+ }
+ }
+ } finally {
+ $word.Quit()
+ [Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
+ [GC]::Collect(); [GC]::WaitForPendingFinalizers()
+ }
+ return @{ status = 'OK'; editor = 'Microsoft Word'; version = "$version ($build)"; results = $results }
+}
+
+function Convert-WithLibreOffice {
+ param([System.IO.FileInfo[]]$Files, [string]$Destination)
+
+ $candidates = @(
+ 'C:\Program Files\LibreOffice\program\soffice.exe',
+ 'C:\Program Files (x86)\LibreOffice\program\soffice.exe'
+ )
+ $exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
+ if (-not $exe) { $exe = (Get-Command soffice -ErrorAction SilentlyContinue).Source }
+ if (-not $exe) {
+ return @{ status = 'NOT_RUN'; reason = 'LibreOffice (soffice) was not found on this machine'; results = @() }
+ }
+
+ $version = (& $exe --version 2>&1 | Select-Object -First 1)
+ $results = @()
+ foreach ($file in $Files) {
+ & $exe --headless --convert-to pdf --outdir $Destination $file.FullName | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "soffice failed on $($file.Name) with exit code $LASTEXITCODE" }
+ $results += @{ source = $file.Name; pdf = ($file.BaseName + '.pdf'); pages = $null }
+ }
+ return @{ status = 'OK'; editor = 'LibreOffice Writer'; version = "$version"; results = $results }
+}
+
+$files = Resolve-Inputs -Target $Path
+if ($files.Count -eq 0) { throw "No .docx files under $Path" }
+
+if (-not $OutputDir) {
+ $base = if (Test-Path -PathType Container $Path) { $Path } else { Split-Path $Path -Parent }
+ $OutputDir = Join-Path $base $Editor
+}
+New-Item -ItemType Directory -Force $OutputDir | Out-Null
+$OutputDir = (Resolve-Path $OutputDir).Path
+
+$report = if ($Editor -eq 'word') {
+ Convert-WithWord -Files $files -Destination $OutputDir
+} else {
+ Convert-WithLibreOffice -Files $files -Destination $OutputDir
+}
+
+$report['os'] = [System.Environment]::OSVersion.VersionString
+$report['convertedAt'] = (Get-Date).ToString('o')
+$report['requestedEditor'] = $Editor
+$sidecar = Join-Path $OutputDir 'conversion.json'
+$report | ConvertTo-Json -Depth 5 | Set-Content -Path $sidecar -Encoding utf8
+
+if ($report.status -eq 'NOT_RUN') {
+ Write-Warning "NOT_RUN: $($report.reason)"
+ Write-Host "Recorded in $sidecar"
+ exit 2
+}
+
+Write-Host "Converted $($report.results.Count) file(s) with $($report.editor) $($report.version)"
+Write-Host "Output: $OutputDir"
diff --git a/scripts/docx-visual/edit-protocol-word.ps1 b/scripts/docx-visual/edit-protocol-word.ps1
new file mode 100644
index 000000000..95c37f6c5
--- /dev/null
+++ b/scripts/docx-visual/edit-protocol-word.ps1
@@ -0,0 +1,280 @@
+<#
+.SYNOPSIS
+ Runs the editing protocol against an exported DOCX through Microsoft Word.
+
+.DESCRIPTION
+ Looking identical on open is half the contract; the other half is that the file
+ behaves like a Word document once somebody types in it. This drives Word through
+ COM over a copy of the export, performs the edits the contract names, saves,
+ reopens, and records what survived.
+
+ What it can decide by itself is whether text was lost, whether structure held and
+ whether the file still opens after a save — all of which are objective. What it
+ cannot decide is whether the result still looks right; that stays a human reading
+ of the rendered PDFs, and the protocol says so rather than scoring itself.
+
+ Word is not installed by the build. When it is absent every scenario is recorded
+ NOT_RUN and the script exits non-zero; an absent editor never becomes a pass.
+
+.PARAMETER Docx
+ The exported .docx to exercise. It is copied first and never modified in place.
+
+.PARAMETER OutputDir
+ Where the edited copies and the protocol JSON are written.
+ Defaults to an `edit` directory beside the input.
+
+.EXAMPLE
+ ./scripts/docx-visual/edit-protocol-word.ps1 -Docx render-docx/target/docx-probe/mixed-two-pager.docx
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$Docx,
+ [string]$OutputDir
+)
+
+$ErrorActionPreference = 'Stop'
+
+$source = Get-Item $Docx
+if (-not $OutputDir) { $OutputDir = Join-Path $source.DirectoryName 'edit' }
+New-Item -ItemType Directory -Force $OutputDir | Out-Null
+$OutputDir = (Resolve-Path $OutputDir).Path
+
+# wdStatisticPages; numeric so the script does not need the Word type library.
+$WD_STATISTIC_PAGES = 2
+
+$scenarios = @()
+
+function New-Result {
+ param([string]$Name, [string]$Status, [string]$Detail, $Observed = $null)
+ return @{ scenario = $Name; status = $Status; detail = $Detail; observed = $Observed }
+}
+
+try {
+ $word = New-Object -ComObject Word.Application
+} catch {
+ $report = @{
+ status = 'NOT_RUN'
+ reason = 'Microsoft Word is not available through COM on this machine'
+ source = $source.Name
+ scenarios = @()
+ }
+ $report | ConvertTo-Json -Depth 6 | Set-Content -Path (Join-Path $OutputDir 'edit-protocol.json') -Encoding utf8
+ Write-Warning 'NOT_RUN: Microsoft Word is not available'
+ exit 2
+}
+
+$wordVersion = "$($word.Version) ($($word.Build))"
+$word.Visible = $false
+$word.DisplayAlerts = 0
+
+function Invoke-Scenario {
+ param([string]$Name, [string]$Suffix, [scriptblock]$Edit, [scriptblock]$Verify)
+
+ $copy = Join-Path $OutputDir ($source.BaseName + '-' + $Suffix + '.docx')
+ Copy-Item $source.FullName $copy -Force
+
+ $doc = $word.Documents.Open($copy, [ref]$false, [ref]$false)
+ try {
+ $before = @{
+ pages = $doc.ComputeStatistics($WD_STATISTIC_PAGES)
+ paragraphs = $doc.Paragraphs.Count
+ tables = $doc.Tables.Count
+ characters = $doc.Content.Text.Length
+ }
+ & $Edit $doc
+ $doc.Save()
+ } finally {
+ $doc.Close([ref]$true)
+ [Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
+ }
+
+ # Reopen from disk: an edit that only holds while the document is in memory has
+ # not survived anything. This is also where a package Word had to repair shows up.
+ $reopened = $word.Documents.Open($copy, [ref]$false, [ref]$true)
+ try {
+ $after = @{
+ pages = $reopened.ComputeStatistics($WD_STATISTIC_PAGES)
+ paragraphs = $reopened.Paragraphs.Count
+ tables = $reopened.Tables.Count
+ characters = $reopened.Content.Text.Length
+ }
+ $result = & $Verify $reopened $before $after
+ } finally {
+ $reopened.Close([ref]$false)
+ [Runtime.InteropServices.Marshal]::ReleaseComObject($reopened) | Out-Null
+ }
+ $result['observed'] = @{ before = $before; after = $after; file = (Split-Path $copy -Leaf) }
+ return $result
+}
+
+try {
+ # 1. Lengthen a sentence roughly twofold. The paragraph must rewrap, nothing may
+ # be lost, and the blocks below it must move rather than be overwritten.
+ $added = ' This sentence was appended by the editing protocol to make the paragraph roughly twice as long as it was, so the block has to rewrap and everything below it has to move down the page.'
+ $scenarios += Invoke-Scenario -Name 'lengthen-a-sentence' -Suffix 'lengthened' -Edit {
+ param($doc)
+ $target = $doc.Paragraphs | Where-Object { $_.Range.Text -like '*Lengthen this sentence*' } | Select-Object -First 1
+ if (-not $target) { throw 'the card paragraph was not found in the export' }
+ $target.Range.InsertAfter($added)
+ } -Verify {
+ param($doc, $before, $after)
+ $text = $doc.Content.Text
+ $keptOriginal = $text -like '*Lengthen this sentence in Word*'
+ $keptAddition = $text -like '*roughly twice as long*'
+ if ($keptOriginal -and $keptAddition -and $after.characters -gt $before.characters) {
+ New-Result 'lengthen-a-sentence' 'PASS' 'the paragraph absorbed the new text and both the original and the addition survived the save'
+ } else {
+ New-Result 'lengthen-a-sentence' 'FAIL' "original kept=$keptOriginal, addition kept=$keptAddition"
+ }
+ }
+
+ # 2. Insert a whole new paragraph into the flow.
+ $scenarios += Invoke-Scenario -Name 'insert-a-paragraph' -Suffix 'inserted' -Edit {
+ param($doc)
+ $anchor = $doc.Paragraphs | Where-Object { $_.Range.Text -like '*Lengthen this sentence*' } | Select-Object -First 1
+ if (-not $anchor) { throw 'the card paragraph was not found in the export' }
+ $range = $anchor.Range
+ $range.InsertParagraphAfter()
+ $range.InsertAfter('A paragraph the protocol inserted, to see whether it joins the flow.')
+ } -Verify {
+ param($doc, $before, $after)
+ $present = $doc.Content.Text -like '*a paragraph the protocol inserted*'
+ if ($present -and $after.paragraphs -gt $before.paragraphs) {
+ New-Result 'insert-a-paragraph' 'PASS' 'the new paragraph joined the body flow and survived the save'
+ } else {
+ New-Result 'insert-a-paragraph' 'FAIL' "present=$present, paragraphs $($before.paragraphs) -> $($after.paragraphs)"
+ }
+ }
+
+ # 3. Change the body size globally, through the Normal style — the way a person
+ # restyles a document. The question is not whether the style object accepts the
+ # new value; it is whether the body text actually changes. Runs carrying a direct
+ # size override the style and swallow the edit silently, which looks like a
+ # working document until somebody tries to restyle it.
+ $scenarios += Invoke-Scenario -Name 'restyle-body-through-normal' -Suffix 'resized' -Edit {
+ param($doc)
+ $sample = $doc.Paragraphs | Where-Object { $_.Range.Text -like '*Lengthen this sentence*' } | Select-Object -First 1
+ if (-not $sample) { throw 'the card paragraph was not found in the export' }
+ $script:bodySizeBefore = $sample.Range.Font.Size
+ $doc.Styles('Normal').Font.Size = 14
+ } -Verify {
+ param($doc, $before, $after)
+ $styleSize = $doc.Styles('Normal').Font.Size
+ $sample = $doc.Paragraphs | Where-Object { $_.Range.Text -like '*Lengthen this sentence*' } | Select-Object -First 1
+ $bodySizeAfter = if ($sample) { $sample.Range.Font.Size } else { $null }
+ $kept = $doc.Content.Text -like '*Quarterly service report*'
+ if (-not $kept) {
+ return New-Result 'restyle-body-through-normal' 'FAIL' 'text was lost when the Normal style changed'
+ }
+ if ($bodySizeAfter -eq 14) {
+ New-Result 'restyle-body-through-normal' 'PASS' "the Normal style reached the body text: ${script:bodySizeBefore}pt became ${bodySizeAfter}pt, and pagination went from $($before.pages) to $($after.pages) pages"
+ } else {
+ New-Result 'restyle-body-through-normal' 'FAIL' "Normal was set to ${styleSize}pt but the body text stayed at ${bodySizeAfter}pt — the runs carry a direct size that overrides the style, so a global restyle does nothing"
+ }
+ }
+
+ # 4. Insert a table row, through Word's own table editing. The grid has to be a
+ # grid, not a picture of one.
+ $scenarios += Invoke-Scenario -Name 'insert-a-table-row' -Suffix 'row-added' -Edit {
+ param($doc)
+ if ($doc.Tables.Count -lt 1) { throw 'the export contains no Word table' }
+ $table = $doc.Tables($doc.Tables.Count)
+ $row = $table.Rows.Add()
+ $row.Cells(1).Range.Text = 'Protocol row'
+ } -Verify {
+ param($doc, $before, $after)
+ $present = $doc.Content.Text -like '*Protocol row*'
+ $stillTables = $after.tables -eq $before.tables
+ if ($present -and $stillTables) {
+ New-Result 'insert-a-table-row' 'PASS' 'a row was added through Word table editing and survived the save with the table count unchanged'
+ } else {
+ New-Result 'insert-a-table-row' 'FAIL' "row present=$present, tables $($before.tables) -> $($after.tables)"
+ }
+ }
+
+ # 5. Delete a row again. Added and removed are different operations against a
+ # merged grid, so a pass on one is not a pass on the other.
+ $scenarios += Invoke-Scenario -Name 'delete-a-table-row' -Suffix 'row-deleted' -Edit {
+ param($doc)
+ if ($doc.Tables.Count -lt 1) { throw 'the export contains no Word table' }
+ $table = $doc.Tables($doc.Tables.Count)
+ $script:rowsBeforeDelete = $table.Rows.Count
+ $script:deletedText = $table.Rows($table.Rows.Count).Range.Text
+ $table.Rows($table.Rows.Count).Delete()
+ } -Verify {
+ param($doc, $before, $after)
+ $table = $doc.Tables($doc.Tables.Count)
+ $rowsNow = $table.Rows.Count
+ $stillTables = $after.tables -eq $before.tables
+ if ($rowsNow -eq ($script:rowsBeforeDelete - 1) -and $stillTables) {
+ New-Result 'delete-a-table-row' 'PASS' "the last row was removed through Word table editing: $($script:rowsBeforeDelete) rows became $rowsNow, and the grid survived the save"
+ } else {
+ New-Result 'delete-a-table-row' 'FAIL' "rows $($script:rowsBeforeDelete) -> $rowsNow, tables $($before.tables) -> $($after.tables)"
+ }
+ }
+
+ # 6. Continue a list with Enter. This is a question about the mechanism, not the
+ # look: marker characters typed into ordinary paragraphs look exactly like a
+ # list until somebody presses Enter and gets a blank line instead of an item.
+ # wdListNoNumbering is 0, so anything else means Word sees a real list.
+ $scenarios += Invoke-Scenario -Name 'continue-a-list' -Suffix 'list-continued' -Edit {
+ param($doc)
+ $item = $doc.Paragraphs | Where-Object { $_.Range.Text -like '*Storage growth reviewed*' } | Select-Object -First 1
+ if (-not $item) { throw 'the checklist item was not found in the export' }
+ $script:listTypeBefore = $item.Range.ListFormat.ListType
+ $range = $item.Range
+ $range.InsertParagraphAfter()
+ $range.InsertAfter('An item the protocol added by continuing the list.')
+ } -Verify {
+ param($doc, $before, $after)
+ $added = $doc.Paragraphs | Where-Object { $_.Range.Text -like '*continuing the list*' } | Select-Object -First 1
+ if (-not $added) {
+ return New-Result 'continue-a-list' 'FAIL' 'the new item did not survive the save'
+ }
+ $listTypeAfter = $added.Range.ListFormat.ListType
+ if ($script:listTypeBefore -ne 0 -and $listTypeAfter -ne 0) {
+ New-Result 'continue-a-list' 'PASS' "Word reads the block as a real list (ListType $($script:listTypeBefore)) and the new paragraph joined it as an item (ListType $listTypeAfter)"
+ } elseif ($script:listTypeBefore -eq 0) {
+ New-Result 'continue-a-list' 'FAIL' 'Word sees no list here: the markers are characters in ordinary paragraphs, so Enter produces a plain paragraph rather than the next item'
+ } else {
+ New-Result 'continue-a-list' 'FAIL' "the block is a list (ListType $($script:listTypeBefore)) but the new paragraph did not join it (ListType $listTypeAfter)"
+ }
+ }
+
+ # 7. Save and reopen with no edit at all. A package that needs repairing fails
+ # here before any editing question is asked.
+ $scenarios += Invoke-Scenario -Name 'round-trip-without-editing' -Suffix 'roundtrip' -Edit {
+ param($doc)
+ } -Verify {
+ param($doc, $before, $after)
+ $same = $after.paragraphs -eq $before.paragraphs -and $after.tables -eq $before.tables `
+ -and $after.characters -eq $before.characters
+ if ($same) {
+ New-Result 'round-trip-without-editing' 'PASS' 'saving and reopening changed neither the text nor the structure'
+ } else {
+ New-Result 'round-trip-without-editing' 'FAIL' 'the save/reopen cycle altered the document'
+ }
+ }
+} finally {
+ $word.Quit()
+ [Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
+ [GC]::Collect(); [GC]::WaitForPendingFinalizers()
+}
+
+$report = @{
+ status = 'OK'
+ editor = 'Microsoft Word'
+ version = $wordVersion
+ os = [System.Environment]::OSVersion.VersionString
+ ranAt = (Get-Date).ToString('o')
+ source = $source.Name
+ visualJudgement = 'NOT_RUN — whether the edited document still looks right is read from the PDFs by a person, not scored here'
+ scenarios = $scenarios
+}
+$protocol = Join-Path $OutputDir 'edit-protocol.json'
+$report | ConvertTo-Json -Depth 6 | Set-Content -Path $protocol -Encoding utf8
+
+$failed = @($scenarios | Where-Object { $_.status -ne 'PASS' })
+foreach ($s in $scenarios) { Write-Host ("{0,-32} {1}" -f $s.scenario, $s.status) }
+Write-Host "Protocol: $protocol"
+if ($failed.Count -gt 0) { exit 1 }
| | | | | |