From 32472055dbca125af691601892565d838b24f3b0 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 13:24:06 +0100 Subject: [PATCH 01/11] test(render-docx): measure what a DOCX export loses and what it lets you edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DOCX column of the capability matrix is a list of crosses, but a cross says a payload is unsupported, not what a reader actually gets when they open the file and start typing. Nothing in the repository measured that, so the gap between "editable Word document" and "what we ship" was an impression rather than a number. Adds a corpus and the harness that measures it. EditableExportFixtures builds one two-page document out of the constructs whose Word representations disagree — mixed runs, a two-column row, a panel that has to grow, a real table, an image, a list, a page-number footer — plus a small boundary fixture for a rounded panel and a clipped outline. EditableExportProbeTest renders it through both backends and reads the package back from disk, counting the things that decide how a Word file behaves rather than how it looks: whether a styles part exists at all, whether there is any numbering, how many fonts are embedded, and whether any body content ended up in a text box. It hashes word/document.xml rather than the package, because a DOCX carries creation timestamps and a PDF a time-seeded /ID, so a package hash only ever says "different run". PdfRegionDiff compares the reference PDF against a PDF an editor rendered from the DOCX, on a grid rather than as one page-wide percentage: an A4 page is mostly margin, so a paragraph that vanished still scores above ninety percent. It reports the one comparison that needs no tolerance — a region with ink in the reference and none in the candidate — separately from the pixel fractions. Two scripts drive the half no build can do. convert-with-word.ps1 renders the DOCX through Word COM or LibreOffice and records which version did it; edit-protocol-word.ps1 performs the editing scenarios and checks them after a save and reopen, since an edit that only holds in memory has survived nothing. A missing editor is recorded NOT_RUN and exits non-zero; it never becomes a pass. The measurements record a baseline; they do not gate. The exporter under measurement is documented to drop fixed-layout geometry, so asserting that nothing is lost would assert a known falsehood. Only pagination, page size and the absence of text boxes are asserted, because those held on first measurement and a regression in them would be a defect rather than a known gap. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0. Against Word 16.0 (16.0.20326) on Windows 11: both fixtures paginate identically to the reference (2 and 1 pages) at matching page size; the export carries real paragraphs, two tables, a real footer part and zero text boxes; 14 and 16 regions of content are lost, worst grid cell 78.9% differing. Five of six editing scenarios pass — lengthening a sentence, inserting a paragraph, adding and deleting a table row, and a clean save/reopen. Restyling through the Normal style fails, measured rather than assumed: Normal accepts 14pt and the body text stays at 10.5pt, because every run carries a direct size. LibreOffice is not installed on this machine, so its whole column is NOT_RUN. --- .../probe/EditableExportFidelityTest.java | 108 +++++++ .../docx/probe/EditableExportFixtures.java | 271 ++++++++++++++++++ .../docx/probe/EditableExportProbeTest.java | 226 +++++++++++++++ .../semantic/docx/probe/PdfRegionDiff.java | 239 +++++++++++++++ scripts/docx-visual/convert-with-word.ps1 | 146 ++++++++++ scripts/docx-visual/edit-protocol-word.ps1 | 252 ++++++++++++++++ 6 files changed, 1242 insertions(+) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFidelityTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFixtures.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportProbeTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/PdfRegionDiff.java create mode 100644 scripts/docx-visual/convert-with-word.ps1 create mode 100644 scripts/docx-visual/edit-protocol-word.ps1 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..5e6c55185 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportFidelityTest.java @@ -0,0 +1,108 @@ +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"}; + + @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"); + Path candidate = wordDir.resolve(fixture + ".pdf"); + if (!Files.exists(reference) || !Files.exists(candidate)) { + continue; + } + + PdfRegionDiff.Report report = PdfRegionDiff.compare( + reference, candidate, PROBE.resolve("diff").resolve(fixture)); + + assertThat(report.pageCountMatches()) + .as("%s: reference has %d pages, the editor's render has %d", + fixture, report.referencePages(), report.candidatePages()) + .isTrue(); + assertThat(report.sizeMismatches()) + .as("%s: page sizes must survive the round trip", fixture) + .isEmpty(); + + entries.add(""" + { + "id": "%s", + "pages": %d, + "worstCellDifferingFraction": %.4f, + "cellsOver10pct": %d, + "cellsOver25pct": %d, + "baselineLostRegions": %d, + "lostRegionCells": [%s], + "verdict": "BASELINE_RECORDED" + }""" + .formatted(fixture, 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..2a505eca7 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportProbeTest.java @@ -0,0 +1,226 @@ +package com.demcha.compose.document.backend.semantic.docx.probe; + +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend; +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; + try (DocumentSession session = fixture.apply(pdfFile)) { + session.buildPdf(); + docx = session.export(new DocxSemanticBackend(), docxFile); + } + + 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] }, + "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())) + .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; + } + + 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/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..6d4517e04 --- /dev/null +++ b/scripts/docx-visual/edit-protocol-word.ps1 @@ -0,0 +1,252 @@ +<# +.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. 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 } From 2f6d6b6c825555735f0a206608c4216654ef71e7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 13:40:40 +0100 Subject: [PATCH 02/11] test(render-docx): prove three Word constructs close the editing gaps the export leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe established that the DOCX export is already fully flowing text and already survives most editing, and that two contract items fail: restyling through the Normal style does nothing, and pressing Enter in a list produces a plain paragraph. Both were measured, neither was explained. Reading the package back said why — there is no styles part and no numbering part at all — but "the construct is missing" is not the same claim as "adding the construct fixes it in Word", and only Word can settle the second. EditableExportPrototype settles it. It takes the package the semantic backend produced, walks the same document tree, and adds three things: a styles part carrying the body font and size as the document default with the direct size and font stripped from every run that only restated it; a numbering part with the list paragraphs attached to it and the marker character removed now that Word draws it; and paragraph shading plus a left border on the paragraphs inside a filled container, coloured from the node. Against Word 16.0 on the same document, the baseline passes five of seven editing scenarios and the prototype passes seven: Normal set to 14pt now moves the body from 10.5pt to 14pt, and Word reads the checklist as a real list (ListType 4) that a new paragraph joins as an item. Initial fidelity moves much less — worst grid cell 78.9% to 53.7%, cells over 25% from 45 to 40, lost regions 14 to 13. That gap between the two is the useful result. The lost regions are not the panel, which the prototype does draw: they are the table's Qty and Amount columns, which Word sizes to its own auto-width while the reference spans the text width. A column width is a measurement, not a construct, so no amount of Word vocabulary reaches it. The prototype also costs geometry where it wins behaviour: a real Word list indents its marker about a quarter inch, wider than the reference's marker gap. The prototype matches paragraphs to nodes by their text, which the architecture rules out and which is sound only because these fixtures have distinct paragraph texts. It is a measuring instrument, not a design, and its Javadoc says so. Provenance belongs to the work that gives the export real access to resolved layout. Also records the font result properly: Lato is declared in the package, embedded in neither export, and is not installed on the measuring machine, so Word substituted. Checked rather than assumed. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0. Both editing protocols and the fidelity comparison rerun from a clean target; the numbers above reproduce exactly. LibreOffice is not installed here, so its column stays NOT_RUN and no gate is claimed across the matrix. --- .../probe/EditableExportFidelityTest.java | 66 ++-- .../docx/probe/EditableExportProbeTest.java | 45 ++- .../docx/probe/EditableExportPrototype.java | 336 ++++++++++++++++++ scripts/docx-visual/edit-protocol-word.ps1 | 30 +- 4 files changed, 449 insertions(+), 28 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportPrototype.java 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 index 5e6c55185..808c54217 100644 --- 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 @@ -41,6 +41,9 @@ 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"); @@ -50,37 +53,48 @@ void editorRenderShouldKeepPaginationAndRecordEveryRegionItLoses() throws Except List entries = new ArrayList<>(); for (String fixture : FIXTURES) { Path reference = PROBE.resolve(fixture + ".pdf"); - Path candidate = wordDir.resolve(fixture + ".pdf"); - if (!Files.exists(reference) || !Files.exists(candidate)) { + 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(fixture)); + 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", - fixture, report.referencePages(), report.candidatePages()) - .isTrue(); - assertThat(report.sizeMismatches()) - .as("%s: page sizes must survive the round trip", fixture) - .isEmpty(); + 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", - "pages": %d, - "worstCellDifferingFraction": %.4f, - "cellsOver10pct": %d, - "cellsOver25pct": %d, - "baselineLostRegions": %d, - "lostRegionCells": [%s], - "verdict": "BASELINE_RECORDED" - }""" - .formatted(fixture, report.referencePages(), report.worstCell(), - report.over(0.10).size(), report.over(0.25).size(), - report.lostContent().size(), describe(report.lostContent())) - .indent(2).stripTrailing()); + 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"), 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 index 2a505eca7..090c006f5 100644 --- 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 @@ -1,7 +1,10 @@ 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; @@ -81,10 +84,20 @@ private String probe(String id, int expectedPdfPages, FunctionThere 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(""); } 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..e4fff0d75 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportPrototype.java @@ -0,0 +1,336 @@ +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.

+ * + * @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/scripts/docx-visual/edit-protocol-word.ps1 b/scripts/docx-visual/edit-protocol-word.ps1 index 6d4517e04..95c37f6c5 100644 --- a/scripts/docx-visual/edit-protocol-word.ps1 +++ b/scripts/docx-visual/edit-protocol-word.ps1 @@ -213,7 +213,35 @@ try { } } - # 6. Save and reopen with no edit at all. A package that needs repairing fails + # 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) From 9ff7e4c4edc5389a4aa0f1692a572690d0f89c53 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 14:11:09 +0100 Subject: [PATCH 03/11] feat(render-docx): carry a container's paint and name the body style as Normal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the export never wrote, both of them ordinary Word paragraph machinery, both measured as broken in Word 16.0 before this. A SectionNode or ContainerNode was treated as a transparent wrapper: its children were written and its fillColor, borders and stroke were dropped with nothing in the log to say so. A card exported as bare text, and the reader had no way to know a panel was missing. Word has no element that wraps a run of paragraphs, but it shades and borders each one, and consecutive paragraphs sharing a fill render as a single band — close enough to a panel to be worth having. Each paragraph a painted container wraps now carries the fill as w:shd and the borders as w:pBdr, a uniform stroke standing in for all four sides when per-side borders are absent. Nested containers resolve innermost-first and the paint stops where the container does. Every body paragraph is created through one helper so no writer can forget it; a page break is deliberately excluded, since a shaded break draws a band across the page. Three parts of a container's design are not representable and are documented rather than approximated: the corner radius, because Word paragraph shading is rectangular — dropped with one warning per export; the padding, because a paragraph's shading hugs its own text; and a table inside a painted container, which keeps its own cell paint. The package also carried no styles part at all, so Word invented a latent Normal that no run referred to while every run spelled out its own font and size. A direct run property beats a style, so the ordinary way a person restyles a Word document was accepted and did nothing: setting Normal to 14pt left the body at 10.5pt. The export now writes document defaults and a Normal carrying the document's dominant text style, and a run that only restates it writes no w:rFonts, w:sz, w:szCs or w:color. The dominant style is chosen by how many characters are set in it, not by how many nodes use it — headings are numerous and short while body text is long, so counting nodes elects the heading style and leaves every body run carrying a direct size, which is the bug this measure avoids. A document with no text writes no styles part. Two existing tests asserted the old location of properties that moved. They were not relaxed. DocxRunStyleTest's claim is about a code chip keeping its own face while the surrounding text does not, and that claim is unchanged — only the surrounding run now inherits rather than repeats. DocxRightToLeftTest's claim is that Hebrew has a w:szCs to read, and it now holds in two places, so it is asserted in two tests: the direct case, on a run that differs from the body style, and the inherited case, where the size moved to Normal. One Normal, and no generated heading styles: a heading still carries direct formatting rather than a named Heading 1, so restyling the body works and restyling all headings at once does not. Named heading styles are separate work. Verification: ./mvnw -B -ntp test -Dtest='Docx*Test' -pl :graph-compose-render-docx -am, 105 tests, exit 0. Capability matrix, DOCX recipe and CHANGELOG updated in this change, as the matrix's own rule requires. --- CHANGELOG.md | 29 ++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 42 ++- .../semantic/docx/DocxSemanticBackend.java | 286 +++++++++++++++++- .../semantic/docx/DocxContainerPaintTest.java | 171 +++++++++++ .../semantic/docx/DocxDocumentStyleTest.java | 139 +++++++++ .../semantic/docx/DocxRightToLeftTest.java | 73 ++++- .../semantic/docx/DocxRunStyleTest.java | 8 +- 8 files changed, 735 insertions(+), 15 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b6fedf0c..4c0857418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ follow semantic versioning; release dates are ISO 8601. ## v2.4.1 — Planned +### Public API + +- **A container's fill and borders now reach the DOCX export.** A `SectionNode` or + `ContainerNode` carrying a `fillColor`, per-side `borders` or a uniform `stroke` was + treated as a transparent wrapper: its children were written and its paint was dropped + with nothing in the log to say so, which is why a card exported as bare text. Each + paragraph the container wraps now carries the fill as `w:shd` and the borders as + `w:pBdr`; consecutive shaded paragraphs render in Word as one band, nested containers + resolve innermost-first, and the paint stops where the container does. Three things are + still not representable and are documented rather than approximated: the corner radius, + since Word paragraph shading is rectangular — dropped with one warning per export; the + container's `padding`, so the band hugs its text; and a table inside a painted + container, which keeps its own cell paint. A container with no paint exports exactly as + before. + +- **A DOCX export now names its own body text as Word's Normal style.** The package + carried no styles part at all, so Word invented a latent `Normal` that no run referred + to, and every run spelled out its own font and size. A direct run property beats a + style, so "change the Normal style" — the ordinary way a person restyles a Word + document — was accepted and then did nothing; measured in Word 16.0, setting Normal to + 14pt left the body at 10.5pt. The export now writes a styles part whose document + defaults and `Normal` carry the document's dominant text style, chosen by how many + characters are set in it rather than by how many nodes use it, since headings are + numerous and short while body text is long. A run that only restates that style writes + no `w:rFonts`, `w:sz`, `w:szCs` or `w:color`, so the style reaches it; a run that + differs still says so. Complex-script sizing is unchanged in effect — `w:szCs` moves to + the style along with `w:sz`, so Hebrew and Arabic still read a size rather than falling + back to Word's default. A document with no text writes no styles part. + ### Performance - **A barcode is drawn as vector shapes, not as an image, in PDF and PPTX.** diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index ab832886e..75eb3138a 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -60,7 +60,7 @@ Payload records live in `core` under | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ❌ | | Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback; gradient paints use their primary colour) | ❌ | -| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ❌ | +| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke passes them to each paragraph it wraps as `w:shd` and `w:pBdr`, so consecutive shaded paragraphs render as one band. Nested containers resolve innermost-first and the paint stops where the container does. Three deviations: the corner radius is dropped (Word paragraph shading is rectangular) with one warning per export; the container's `padding` is not representable, so the band hugs the text rather than insetting it; and a table inside a painted container keeps its own cell paint rather than inheriting the band. A standalone `ShapeNode` is still dropped — this row's DOCX cell is about a container's own paint, not about shapes as content | | Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ❌ | | Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ❌ | | Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 9c5b61c0b..98330a58c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -46,13 +46,53 @@ PDF never pull POI. | Tables | Word tables, one cell per cell | | Images | Embedded pictures at the node's declared size | | Rows | A one-row table, so editors keep the side-by-side layout (cell content limited to atomic children) | -| Sections / containers | Children written in order | +| Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below | | Spacers | Empty paragraphs carrying the vertical gap as spacing-after | | Page breaks | Explicit Word page breaks | Page geometry (size and margins) and session metadata (title, author, subject, keywords) carry into the Word document as well. +## Named styles, so the document can be restyled + +The export writes a styles part whose `Normal` carries the document's own body text — +the style the most characters are set in, not the one the most nodes use. Runs that only +restate it stay silent, so changing `Normal` in Word changes the body the way a reader +expects. A run whose font, size or colour differs keeps saying so, so headings, chips and +accents are unaffected. + +There is one `Normal` and no generated heading styles yet: a heading still carries its +own direct formatting rather than a named `Heading 1`. Restyling the body works; restyling +"all headings" in one go does not. + +## What a panel keeps and loses + +Word has no element that wraps a run of paragraphs, but it shades and borders each one, +and consecutive paragraphs sharing a fill render as a single band. So a container's paint +travels with the paragraphs inside it: + +```java +page.addSection("Notice", card -> card + .softPanel(surface, 8, 14) // fill lands; radius and padding do not + .accentLeft(accent, 3) // lands as a left w:pBdr + .addParagraph(p -> p.text("The band grows with this text when it is edited."))); +``` + +Kept: the fill, per-side borders, and a uniform stroke standing in for all four sides. +Nested containers resolve innermost-first, and the paint stops where the container does. +The band is a property of the paragraphs, so it grows and reflows as the text is edited — +which is the point of exporting DOCX rather than PDF. + +Not representable, and left undone rather than approximated: + +- **The corner radius.** Word paragraph shading is rectangular. The panel renders with + square corners and the export logs one warning per document. +- **The container's padding.** A paragraph's shading hugs its own text, so the band does + not inset its content the way the PDF does. Add spacing inside the container if the + breathing room matters in Word. +- **A table inside a painted container.** The table keeps its own cell fills and borders + rather than inheriting the band. + ## What falls back - **Charts → data table.** The semantic export has no layout pass, so a diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 0c4a49101..bb9647774 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -31,6 +31,7 @@ import com.demcha.compose.document.node.SpacerNode; import com.demcha.compose.document.node.TableNode; import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentBorders; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; @@ -60,6 +61,11 @@ import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPBdr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyles; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; @@ -102,6 +108,8 @@ */ public final class DocxSemanticBackend implements SemanticBackend { + /** Word''s built-in default paragraph style; the name is fixed by the format. */ + private static final String NORMAL_STYLE_ID = "Normal"; /** Word measures tab stops in twentieths of a point. */ private static final double TWIPS_PER_POINT = 20.0; private static final double POINT_TO_TWIP = 20.0; @@ -117,6 +125,27 @@ public final class DocxSemanticBackend implements SemanticBackend { // Geometry-only node kinds already warned about this export pass. private final java.util.Set warnedNodeKinds = java.util.concurrent.ConcurrentHashMap.newKeySet(); + private final AtomicBoolean containerRadiusWarned = new AtomicBoolean(false); + // Fills and borders of the containers currently being written into, innermost first. + // A paragraph carries the innermost one, because that is the panel it sits in. + private final java.util.Deque containerPaint = new java.util.ArrayDeque<>(); + // The text style the document is mostly written in, promoted to Word's Normal style. + // Null until an export computes it, and when the graph carries no text at all. + private DocumentTextStyle documentDefaultStyle; + + /** + * A container's paint, reduced to what a Word paragraph can carry. + * + * @param fill background, written as {@code w:shd} + * @param borders per-side strokes, written as {@code w:pBdr} + */ + private record ContainerPaint(DocumentColor fill, DocumentBorders borders) { + + /** @return true when there is nothing for a paragraph to carry */ + boolean isEmpty() { + return fill == null && borders == null; + } + } /** * Creates a DOCX semantic backend. @@ -133,10 +162,14 @@ public String name() { public byte[] export(DocumentGraph graph, SemanticExportContext context) throws Exception { shapeContainerWarned.set(false); chartWarned.set(false); + containerRadiusWarned.set(false); warnedNodeKinds.clear(); + containerPaint.clear(); + documentDefaultStyle = dominantTextStyle(graph); contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth(); try (XWPFDocument document = new XWPFDocument()) { applyPageGeometry(document, context.canvas()); + writeStylesPart(document); applyOutputOptions(document, context.outputOptions()); for (DocumentNode root : graph.roots()) { writeNode(document, root); @@ -308,9 +341,9 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio // Overlay/positioned wrappers have no DOCX analogue for their // geometry, but their children can be semantic (text, images) — // render them sequentially rather than dropping the subtree. - for (DocumentNode child : node.children()) { - writeNode(document, child); - } + // A fill or a border is the exception: Word paragraphs carry both, so a + // panel travels with the paragraphs inside it instead of disappearing. + writeContainerChildren(document, node); } else { // Geometry-only node kinds (line, ellipse, shape, path, polygon, // barcode) have no semantic Word analogue. Warn once per kind so a @@ -412,7 +445,7 @@ private void writeNestedItem(XWPFDocument document, private void writeListLine(XWPFDocument document, DocumentTextStyle style, String text, int depth) { - XWPFParagraph para = document.createParagraph(); + XWPFParagraph para = newBodyParagraph(document); XWPFRun run = para.createRun(); applyStyle(run, style); run.setText(" ".repeat(depth) + text); @@ -443,7 +476,7 @@ private void writeRichListLine(XWPFDocument document, DocumentTextStyle style, int depth) { warnDroppedInlineRuns(marker.runs()); warnDroppedInlineRuns(item.runs()); - XWPFParagraph para = document.createParagraph(); + XWPFParagraph para = newBodyParagraph(document); XWPFRun leading = para.createRun(); applyStyle(leading, style); leading.setText(" ".repeat(depth) + (marker.isRich() ? "" : marker.prefix())); @@ -519,6 +552,220 @@ private void writeChartFallback(XWPFDocument document, ChartNode node) throws Ex writeTable(document, table.build()); } + /** + * Writes a wrapper's children, carrying its fill and borders down to each paragraph. + * + *

Word has no box to put around a run of paragraphs, but it does shade and border + * each one, and consecutive paragraphs sharing a fill render as a single band. That is + * close enough to a panel to be worth having, and much better than what this exporter + * used to do, which was to drop the paint without saying so.

+ * + *

What does not survive: the corner radius, because Word paragraph shading is + * rectangular, and the container's padding, because a paragraph's shading hugs its own + * text. The radius is warned about once per export rather than pretended away.

+ */ + private void writeContainerChildren(XWPFDocument document, DocumentNode node) throws Exception { + ContainerPaint paint = paintOf(node); + if (paint.isEmpty()) { + for (DocumentNode child : node.children()) { + writeNode(document, child); + } + return; + } + warnContainerRadiusDropped(node); + containerPaint.push(paint); + try { + for (DocumentNode child : node.children()) { + writeNode(document, child); + } + } finally { + containerPaint.pop(); + } + } + + private static boolean hasRadius(com.demcha.compose.document.style.DocumentCornerRadius radius) { + return radius != null && !radius.isZero(); + } + + /** + * Gives the package a styles part naming the document''s own body text as Normal. + * + *

Without one Word invents a latent Normal that no run refers to, so a reader who + * restyles the document changes nothing: every run carries its own size and font, and a + * direct property beats a style. Writing the part and leaving those runs silent is what + * makes "change the Normal style" behave the way a Word user expects.

+ * + *

Nothing is written when the graph carries no text to take a default from.

+ */ + private void writeStylesPart(XWPFDocument document) { + DocumentTextStyle defaults = documentDefaultStyle; + if (defaults == null) { + return; + } + CTStyles styles = CTStyles.Factory.newInstance(); + applyDefaultRunProperties(styles.addNewDocDefaults().addNewRPrDefault().addNewRPr(), defaults); + + CTStyle normal = styles.addNewStyle(); + normal.setType(STStyleType.PARAGRAPH); + normal.setStyleId(NORMAL_STYLE_ID); + normal.setDefault(true); + normal.addNewName().setVal(NORMAL_STYLE_ID); + applyDefaultRunProperties(normal.addNewRPr(), defaults); + + document.createStyles().setStyles(styles); + } + + private static void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults) { + if (defaults.fontName() != null) { + properties.addNewRFonts().setAscii(defaults.fontName().name()); + } + if (defaults.size() > 0) { + // w:sz counts half-points, and w:szCs carries the same for complex scripts. + BigInteger halfPoints = BigInteger.valueOf(Math.round(defaults.size() * 2)); + properties.addNewSz().setVal(halfPoints); + properties.addNewSzCs().setVal(halfPoints); + } + if (defaults.color() != null) { + properties.addNewColor().setVal(toHexColor(defaults.color().color())); + } + } + + /** + * The text style the document is mostly written in. + * + *

Weighted by characters rather than by how many nodes use a style: headings are + * numerous and short while body text is long, so counting nodes elects the heading + * style as the document default and leaves every body run carrying a direct size.

+ * + * @param graph the document being exported + * @return the dominant style, or {@code null} when the graph carries no text + */ + private static DocumentTextStyle dominantTextStyle(DocumentGraph graph) { + java.util.Map weights = new java.util.HashMap<>(); + for (DocumentNode root : graph.roots()) { + weighTextStyles(root, weights); + } + return weights.entrySet().stream() + .max(java.util.Map.Entry.comparingByValue()) + .map(java.util.Map.Entry::getKey) + .orElse(null); + } + + private static void weighTextStyles(DocumentNode node, + java.util.Map weights) { + if (node instanceof ParagraphNode paragraph && paragraph.textStyle() != null) { + weights.merge(paragraph.textStyle(), textWeight(paragraph.text()), Long::sum); + } else if (node instanceof com.demcha.compose.document.node.ListNode list + && list.textStyle() != null) { + long weight = list.items().stream().mapToLong(DocxSemanticBackend::textWeight).sum(); + weights.merge(list.textStyle(), weight, Long::sum); + } + for (DocumentNode child : node.children()) { + weighTextStyles(child, weights); + } + } + + /** At least one, so a style used only by empty text still counts as used. */ + private static long textWeight(String text) { + return text == null ? 1L : Math.max(1L, text.length()); + } + + /** Reads the fill and borders off whichever wrapper kind this is, or an empty paint. */ + private static ContainerPaint paintOf(DocumentNode node) { + if (node instanceof SectionNode section) { + return new ContainerPaint(section.fillColor(), + bordersOf(section.borders(), section.stroke())); + } + if (node instanceof ContainerNode container) { + return new ContainerPaint(container.fillColor(), + bordersOf(container.borders(), container.stroke())); + } + return new ContainerPaint(null, null); + } + + /** + * Per-side borders win; a uniform stroke stands in for all four when they are absent, + * which is how the node model says "one outline round the whole box". + */ + private static DocumentBorders bordersOf(DocumentBorders borders, DocumentStroke stroke) { + if (borders != null && !DocumentBorders.NONE.equals(borders)) { + return borders; + } + if (stroke != null && stroke.width() > 0) { + return DocumentBorders.all(stroke); + } + return null; + } + + /** One warning per export for the part of a container's design Word cannot hold. */ + private void warnContainerRadiusDropped(DocumentNode node) { + boolean rounded = node instanceof SectionNode section + ? hasRadius(section.cornerRadius()) + : node instanceof ContainerNode container && hasRadius(container.cornerRadius()); + if (rounded && containerRadiusWarned.compareAndSet(false, true)) { + LOG.warn("docx.export.container-radius-dropped node='{}' — Word paragraph shading " + + "is rectangular, so the panel renders with square corners. " + + "(One warning per export; use the PDF backend for the rounded form.)", + node.nodeKind()); + } + } + + /** + * Creates a body paragraph already wearing the panel it sits in. + * + *

Every body paragraph goes through here, so a container's paint cannot be + * forgotten by a writer that creates its paragraph directly.

+ */ + private XWPFParagraph newBodyParagraph(XWPFDocument document) { + XWPFParagraph para = document.createParagraph(); + ContainerPaint paint = containerPaint.peek(); + if (paint != null) { + applyContainerPaint(para, paint); + } + return para; + } + + private static void applyContainerPaint(XWPFParagraph para, ContainerPaint paint) { + CTPPr properties = para.getCTP().isSetPPr() + ? para.getCTP().getPPr() + : para.getCTP().addNewPPr(); + if (paint.fill() != null) { + CTShd shading = properties.isSetShd() ? properties.getShd() : properties.addNewShd(); + shading.setVal(STShd.CLEAR); + shading.setColor("auto"); + shading.setFill(toHexColor(paint.fill().color())); + } + DocumentBorders borders = paint.borders(); + if (borders == null) { + return; + } + CTPBdr edges = properties.isSetPBdr() ? properties.getPBdr() : properties.addNewPBdr(); + paintParagraphEdge(borders.top(), edges::isSetTop, edges::getTop, edges::addNewTop); + paintParagraphEdge(borders.bottom(), edges::isSetBottom, edges::getBottom, edges::addNewBottom); + paintParagraphEdge(borders.left(), edges::isSetLeft, edges::getLeft, edges::addNewLeft); + paintParagraphEdge(borders.right(), edges::isSetRight, edges::getRight, edges::addNewRight); + } + + /** + * Writes one paragraph border edge, reusing whichever edge element is already there. + * + *

A stroke of no width is how this codebase says "no border", the same predicate the + * table painter reads, so such a side is left unwritten rather than drawn hairline.

+ */ + private static void paintParagraphEdge(DocumentStroke stroke, + java.util.function.BooleanSupplier isSet, + java.util.function.Supplier get, + java.util.function.Supplier add) { + if (stroke == null || stroke.width() <= 0) { + return; + } + // w:sz counts eighths of a point, rounded to at least one so a hairline the author + // asked for stays a line rather than vanishing. + BigInteger eighths = BigInteger.valueOf(Math.max(1, Math.round(stroke.width() * 8.0))); + paintEdge(isSet.getAsBoolean() ? get.get() : add.get(), + STBorder.SINGLE, eighths, toHexColor(stroke.color().color())); + } + private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node) throws Exception { // POI/DOCX has no portable equivalent of a graphics-state path clip. // The fallback rule (recorded in docs/canonical-legacy-parity.md) is @@ -540,7 +787,7 @@ private void writeShapeContainer(XWPFDocument document, ShapeContainerNode node) } private void writeParagraph(XWPFDocument document, ParagraphNode node) { - XWPFParagraph para = document.createParagraph(); + XWPFParagraph para = newBodyParagraph(document); boolean rightToLeft = applyParagraphProperties(para, node); writeParagraphRuns(para, node, rightToLeft); } @@ -684,7 +931,7 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception drawHeight = sourceHeight * scale; } - XWPFParagraph para = document.createParagraph(); + XWPFParagraph para = newBodyParagraph(document); XWPFRun run = para.createRun(); try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) { XWPFPicture picture = run.addPicture(stream, @@ -1099,7 +1346,7 @@ private void writeCellNode(XWPFTableCell cell, DocumentNode child) throws Except } private void writeSpacer(XWPFDocument document, SpacerNode node) { - XWPFParagraph para = document.createParagraph(); + XWPFParagraph para = newBodyParagraph(document); para.createRun().setText(""); if (node.height() > 0) { para.setSpacingAfter((int) Math.round(node.height() * POINT_TO_TWIP)); @@ -1192,10 +1439,19 @@ private void applyStyle(XWPFRun run, DocumentTextStyle style) { if (style == null) { return; } - if (style.fontName() != null) { + // A run that only restates the Normal style is left saying nothing, so Word's own + // "change the Normal style" reaches it. Written out, the direct property wins over + // the style and a global restyle silently does nothing — which is what this + // exporter used to produce for every run in every document. + DocumentTextStyle defaults = documentDefaultStyle; + if (style.fontName() != null + && (defaults == null || !style.fontName().equals(defaults.fontName()))) { run.setFontFamily(style.fontName().name()); } - if (style.size() > 0) { + // Complex-script size rides along with the ordinary one, so it is skipped for the + // same reason when the style already carries it. + boolean sizeComesFromTheStyle = defaults != null && style.size() == defaults.size(); + if (style.size() > 0 && !sizeComesFromTheStyle) { // Passed as a double, because w:sz counts half-points and rounding to whole // points first throws away a precision the format has: the timeline's 8.5pt // label was being written as 9pt. @@ -1206,7 +1462,15 @@ private void applyStyle(XWPFRun run, DocumentTextStyle style) { run.setComplexScriptFontSize(style.size()); } applyLetterSpacing(run, style); - if (style.color() != null) { + applyRunColourAndDecoration(run, style, defaults); + } + + /** Colour and face, with the colour skipped when the Normal style already says it. */ + private void applyRunColourAndDecoration(XWPFRun run, + DocumentTextStyle style, + DocumentTextStyle defaults) { + if (style.color() != null + && (defaults == null || !style.color().equals(defaults.color()))) { run.setColor(toHexColor(style.color().color())); } if (style.decoration() != null) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java new file mode 100644 index 000000000..a9f377d32 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java @@ -0,0 +1,171 @@ +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.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +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; + +/** + * A container's fill and borders reach the paragraphs inside it. + * + *

Word has no element that wraps a run of paragraphs, but it shades and borders each + * one, and consecutive paragraphs sharing a fill render as a single band. Until this + * landed the exporter treated a section as a transparent wrapper and dropped its paint + * silently, so a card exported as bare text with no sign that anything was missing.

+ * + * @author Artem Demchyshyn + */ +class DocxContainerPaintTest { + + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + private static final DocumentColor ACCENT = DocumentColor.rgb(26, 86, 148); + + @Test + void filledSectionShouldShadeEveryParagraphInsideIt() throws Exception { + List paragraphs = bodyOf(page -> page.addSection("Card", card -> card + .fillColor(SURFACE) + .addParagraph(p -> p.text("First")) + .addParagraph(p -> p.text("Second")))); + + assertThat(paragraphs).hasSize(2); + for (XWPFParagraph paragraph : paragraphs) { + assertThat(shadingFill(paragraph)) + .as("every paragraph in the panel carries its fill") + .isEqualTo("EEF3F9"); + } + } + + @Test + void sectionBorderShouldReachTheParagraphAsAParagraphBorder() throws Exception { + List paragraphs = bodyOf(page -> page.addSection("Card", card -> card + .fillColor(SURFACE) + .accentLeft(ACCENT, 3) + .addParagraph(p -> p.text("Bordered")))); + + CTPPr properties = paragraphs.get(0).getCTP().getPPr(); + assertThat(properties.isSetPBdr()).as("the accent reaches w:pBdr").isTrue(); + assertThat(properties.getPBdr().isSetLeft()).as("on the side it was asked for").isTrue(); + assertThat(hex(properties.getPBdr().getLeft().getColor())).isEqualTo("1A5694"); + // w:sz counts eighths of a point, so a 3pt accent is 24. + assertThat(properties.getPBdr().getLeft().getSz().intValue()).isEqualTo(24); + assertThat(properties.getPBdr().isSetRight()) + .as("a side the author did not ask for stays unwritten") + .isFalse(); + } + + @Test + void uniformStrokeShouldStandInForAllFourSides() throws Exception { + List paragraphs = bodyOf(page -> page.addSection("Card", card -> card + .stroke(DocumentStroke.of(ACCENT, 1)) + .addParagraph(p -> p.text("Outlined")))); + + var edges = paragraphs.get(0).getCTP().getPPr().getPBdr(); + assertThat(edges.isSetTop()).isTrue(); + assertThat(edges.isSetBottom()).isTrue(); + assertThat(edges.isSetLeft()).isTrue(); + assertThat(edges.isSetRight()).isTrue(); + } + + @Test + void unpaintedSectionShouldLeaveItsParagraphsAlone() throws Exception { + List paragraphs = bodyOf(page -> page.addSection("Plain", plain -> plain + .padding(DocumentInsets.of(8)) + .addParagraph(p -> p.text("Nothing to paint")))); + + CTPPr properties = paragraphs.get(0).getCTP().getPPr(); + // A wrapper with no paint must not start writing empty shading or borders: the + // export of a plain section is what it always was. + assertThat(properties == null || !properties.isSetShd()).isTrue(); + assertThat(properties == null || !properties.isSetPBdr()).isTrue(); + } + + @Test + void innerPanelShouldWinOverTheOneAroundIt() throws Exception { + DocumentColor inner = DocumentColor.rgb(255, 240, 200); + List paragraphs = bodyOf(page -> page.addSection("Outer", outer -> outer + .fillColor(SURFACE) + .addParagraph(p -> p.text("Outer text")) + .addSection("Inner", in -> in + .fillColor(inner) + .addParagraph(p -> p.text("Inner text"))))); + + assertThat(shadingFill(paragraphs.get(0))).isEqualTo("EEF3F9"); + assertThat(shadingFill(paragraphs.get(1))) + .as("a paragraph wears the panel it actually sits in") + .isEqualTo("FFF0C8"); + } + + @Test + void paintShouldNotLeakToParagraphsAfterTheContainer() throws Exception { + List paragraphs = bodyOf(page -> { + page.addSection("Card", card -> card + .fillColor(SURFACE) + .addParagraph(p -> p.text("Inside"))); + page.addParagraph(p -> p.text("After")); + }); + + assertThat(shadingFill(paragraphs.get(0))).isEqualTo("EEF3F9"); + CTPPr after = paragraphs.get(1).getCTP().getPPr(); + assertThat(after == null || !after.isSetShd()) + .as("the panel ends where the container ends") + .isTrue(); + } + + private static String shadingFill(XWPFParagraph paragraph) { + CTPPr properties = paragraph.getCTP().getPPr(); + if (properties == null || !properties.isSetShd()) { + return null; + } + return hex(properties.getShd().getFill()); + } + + /** + * Reads an {@code ST_HexColor} back as the six hex digits it was written as. + * + *

The type is an xmlbeans union, so a concrete colour comes back as the three + * bytes rather than as the string the writer passed in; {@code String.valueOf} on it + * yields an array identity, which is how this test first "failed" against a value + * that was correct.

+ */ + private static String hex(Object value) { + if (value == null) { + return null; + } + if (value instanceof byte[] bytes) { + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + out.append(String.format("%02X", b & 0xFF)); + } + return out.toString(); + } + return String.valueOf(value); + } + + private static List bodyOf( + 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()); + } + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + return document.getParagraphs().stream() + .filter(p -> !p.getText().isBlank()) + .toList(); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java new file mode 100644 index 000000000..5a0e4bf99 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java @@ -0,0 +1,139 @@ +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.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; + +import java.io.ByteArrayInputStream; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The export names the document's own body text as Word's Normal style, and stops every + * run from restating it. + * + *

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 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/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/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"); } From 7662c60b51da58bab219c7a4214d76d5112ea8ad Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 14:53:21 +0100 Subject: [PATCH 04/11] feat(render-docx): export a list as a list Word owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marker written into the run text looks like a list and is not one. Measured in Word 16.0 against the previous export, ListFormat.ListType came back as "no numbering": the reader had bulleted-looking paragraphs, so pressing Enter produced a blank paragraph instead of the next item, and demoting with Tab did nothing. That is the contract item this repairs. A list now gets a numbering.xml definition, w:numPr on each item and the authored marker as the level's text, with nesting as a list level rather than two spaces per depth. The ListMarker.defaultForDepth cascade becomes the levels' markers and markerFor(depth, ...) still chooses a level's own, so which marker each depth gets is unchanged — only where it is written. Four kinds of list keep the plain-paragraph form, because Word cannot hold them without changing what was asked for: a markerless list, which would gain a marker and an indent it declined; a drawn marker, which has no Word list analogue; a list whose siblings at one depth carry different markers, since a definition names one marker per level and replacing one with the other silently would be worse than writing both as text; and rich items, whose runs the numbered path does not write. This does not make markerGap work and does not claim to. Real Word numbering was measured against that requirement and rejected for it, and it is still rejected: Word places content at an absolute indent and cannot be told "one marker width plus a gap from here". The level's marker column is a stated constant — 180 twips, plus 120 per nesting level, chosen near the single space the text form left — and the Javadoc says it is a convention rather than a measurement, for the same reason hangingIndent is unrepresentable here. What this buys is behaviour; what it costs is that the marker column is not the configured gap. Twenty-one assertions across four suites encoded the old form. None were relaxed to pass. DocxListParityTest's subject is marker normalization, which is unchanged — its markers are now read from the level definitions, where they live. DocxHangingIndentIsIgnoredTest still pins that the flag and the gap change nothing, and pins it harder: it now compares the whole list definition with and without the flag, not just the text. Its old claim that no numbering is written was about rejecting numbering as a way to honour the gap, and the Javadoc now separates the two. The freeze test named itself "legacy" and said in its own words that native list geometry would have to rewrite it; it is renamed DocxListGeometryFreezeTest and pins the new geometry — including that no indent characters of either backend's kind reach the run text, which is the divergence it used to guard and which no longer exists. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0, 116 tests in the module. Eight new tests in DocxListNumberingTest cover the numbered path and all four fallbacks. Capability matrix, the DOCX and lists recipes and CHANGELOG updated in this change. --- CHANGELOG.md | 22 +++ .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 39 +++- docs/recipes/lists.md | 4 +- .../semantic/docx/DocxSemanticBackend.java | 179 ++++++++++++++++- .../docx/DocxHangingIndentIsIgnoredTest.java | 180 +++++++++++------ .../docx/DocxListGeometryFreezeTest.java | 161 +++++++++++++++ .../DocxListLegacyGeometryFreezeTest.java | 132 ------------- .../semantic/docx/DocxListNumberingTest.java | 185 ++++++++++++++++++ .../semantic/docx/DocxListParityTest.java | 65 ++++-- .../docx/DocxSemanticBackendTest.java | 42 ++-- .../docx/probe/EditableExportPrototype.java | 7 + 12 files changed, 778 insertions(+), 240 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListGeometryFreezeTest.java delete mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListLegacyGeometryFreezeTest.java create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListNumberingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0857418..3e8069846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,28 @@ follow semantic versioning; release dates are ISO 8601. container, which keeps its own cell paint. A container with no paint exports exactly as before. +- **A DOCX list is now a real Word list.** The export wrote the marker into the item's + run text and indented nesting with two spaces per level, which looks like a list and is + not one: measured in Word 16.0, `ListFormat.ListType` came back as "no numbering", so + pressing Enter produced a plain paragraph instead of the next item. A list now gets a + `numbering.xml` definition, `w:numPr` on each item, and the authored marker as the + level's text, with nesting as a level rather than padding characters. The + `ListMarker.defaultForDepth` cascade becomes the levels' markers and + `markerFor(depth, ...)` still chooses a level's own. +

+ Four kinds of list deliberately keep the plain-paragraph form, because Word cannot hold + them without changing what was asked for: a markerless list, which would gain a marker + and an indent it declined; a drawn marker, which has no Word list analogue; a list whose + siblings at one depth carry different markers, since a definition names one marker per + level; and rich items, whose runs the numbered path does not write. +

+ This does not make `markerGap` work and does not claim to — real Word numbering was + measured against that requirement and rejected for it, and it is still rejected. The + level's marker column is a stated constant, 180 twips plus 120 per nesting level, + chosen near the single space the text form used. What changes is behaviour: the list + continues, renumbers and demotes. What it costs is that the marker column is a + convention rather than the configured gap. + - **A DOCX export now names its own body text as Word's Normal style.** The package carried no styles part at all, so Word invented a latent `Normal` that no run referred to, and every run spelled out its own font and size. A direct run property beats a diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 75eb3138a..06152e654 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -55,7 +55,7 @@ Payload records live in `core` under | Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| | Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; `linkTarget` is still dropped | -| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` writes one paragraph per item with the marker in its text, identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker — which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column | +| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used | | Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | | Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ❌ | | Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ❌ | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 98330a58c..0b2080f07 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -42,7 +42,7 @@ PDF never pull POI. | Document node | DOCX output | |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved | -| Lists | Marker-prefixed paragraphs in the list's text style; nested items indent per depth and keep their own markers | +| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell | | Images | Embedded pictures at the node's declared size | | Rows | A one-row table, so editors keep the side-by-side layout (cell content limited to atomic children) | @@ -65,6 +65,34 @@ There is one `Normal` and no generated heading styles yet: a heading still carri own direct formatting rather than a named `Heading 1`. Restyling the body works; restyling "all headings" in one go does not. +## What a list becomes + +A list exports as a list Word owns: a `numbering.xml` definition, `w:numPr` on each item, +and the authored marker as the level's text. That is what makes Enter continue the list +and Tab demote an item, instead of producing a plain paragraph beside a bullet character +that only looked like one. + +Nesting is a level rather than padding, so no indent characters reach the text, and the +`ListMarker.defaultForDepth` cascade the PDF path uses becomes the levels' markers — +`markerFor(depth, ...)` still chooses a level's own. + +Four kinds of list stay plain paragraphs, because Word could not express them without +changing what was asked for: + +- **A markerless list.** Numbering always draws something and indents; a list that asked + for neither would gain both. +- **A drawn marker** — one made of runs, an icon or a disc. It has no Word list analogue, + so the item keeps the run path it already used. +- **A list whose siblings at one depth carry different markers.** A Word list definition + names one marker per level, and silently replacing one of them with the other would be + worse than writing both as text. +- **Rich items**, whose runs the numbered path does not write. + +The marker column is a stated constant — 180 twips, plus 120 for each nesting level — +chosen near the single space the old text form left. It is a convention, not a +measurement: measuring the marker needs a font runtime this backend does not have, which +is the same reason `markerGap` is unrepresentable here. + ## What a panel keeps and loses Word has no element that wraps a run of paragraphs, but it shades and borders each one, @@ -105,11 +133,10 @@ Not representable, and left undone rather than approximated: inline, in source order, without the outline frame and without clipping — again with one warning per export. - **`hangingIndent(true)` → the ordinary list form.** A list that opts - into marker/content geometry exports exactly as one that did not: one - paragraph per item, the marker in the item's text, two spaces per - nesting depth. Nothing is lost — same paragraphs, same text, same - nesting — but wrapped lines align the way Word aligns them rather than - the way the PDF does, and `markerGap` has no effect here. + into marker/content geometry exports exactly as one that did not: the + same Word list, the same levels, the same markers. Nothing is lost — + same items, same text, same nesting — but the marker column is the + level's own and `markerGap` has no effect here. This is a decision rather than an omission. Word places content at absolute indents and has no way to be told "start the text one marker diff --git a/docs/recipes/lists.md b/docs/recipes/lists.md index 1decccada..7702fb565 100644 --- a/docs/recipes/lists.md +++ b/docs/recipes/lists.md @@ -94,7 +94,9 @@ inside the content column and leave the marker where it is. It is off by default and stays off; existing lists are untouched. -> **Fixed-layout only.** PDF and PPTX honour this. The semantic DOCX +> **Fixed-layout only.** PDF and PPTX honour this. A DOCX list is a real +> Word list either way — same levels, same markers — but the marker column +> is the level's own rather than the gap configured here. The semantic DOCX > export writes one Word paragraph per item and lets Word lay it out, so > it keeps the marker in the text and exports the same either way — same > paragraphs, same text, same nesting. See diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index bb9647774..bfb8eb605 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -61,6 +61,9 @@ import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTBorder; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTAbstractNum; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTLvl; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPBdr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; @@ -75,6 +78,8 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STJc; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STNumberFormat; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STPageOrientation; import org.slf4j.Logger; @@ -132,6 +137,10 @@ public final class DocxSemanticBackend implements SemanticBackend { // The text style the document is mostly written in, promoted to Word's Normal style. // Null until an export computes it, and when the graph carries no text at all. private DocumentTextStyle documentDefaultStyle; + // One Word list definition per ListNode that can be one, keyed by identity because + // two lists reading the same are still two lists. + private final java.util.Map + listNumbering = new java.util.IdentityHashMap<>(); /** * A container's paint, reduced to what a Word paragraph can carry. @@ -165,6 +174,7 @@ public byte[] export(DocumentGraph graph, SemanticExportContext context) throws containerRadiusWarned.set(false); warnedNodeKinds.clear(); containerPaint.clear(); + listNumbering.clear(); documentDefaultStyle = dominantTextStyle(graph); contentWidth = context.canvas() == null ? Double.MAX_VALUE : context.canvas().innerWidth(); try (XWPFDocument document = new XWPFDocument()) { @@ -388,6 +398,143 @@ private void warnDroppedInlineRuns(List runs) { } } + /** + * Word''s marker column, in twips. Chosen to sit close to the single space the text + * path used rather than to Word''s much wider default, and stated as the convention it + * is: measuring the marker would need a font runtime, which is the same thing + * {@code hangingIndent} is missing and the reason its gap is unrepresentable here. + */ + private static final int LIST_HANGING_TWIPS = 180; + + /** Added per nesting level, approximating the two spaces the text path indented by. */ + private static final int LIST_NESTING_STEP_TWIPS = 120; + + /** + * Gives a list a real Word list definition, when it is one Word can express. + * + *

A marker written into the run text looks like a list and is not one: pressing + * Enter yields a plain paragraph rather than the next item, which is the contract + * failure this repairs. Attaching {@code w:numPr} makes Word own the marker, so the + * list continues, renumbers and demotes the way a reader expects.

+ * + *

This does not fix {@code markerGap}, and does not claim to. Word places content + * at an absolute indent and cannot be told "one marker width plus a gap from here"; + * real numbering was measured against that requirement and rejected for it, and it is + * still rejected. What it buys is behaviour, and it costs geometry: the marker column + * is a stated constant rather than the measured gap.

+ * + * @return the list definition to attach, or {@code null} when the list has to stay + * marker-prefixed text + */ + private BigInteger numberingFor(XWPFDocument document, + com.demcha.compose.document.node.ListNode list) { + BigInteger existing = listNumbering.get(list); + if (existing != null) { + return existing; + } + List levels = markerPerDepth(list); + if (levels == null) { + return null; + } + CTAbstractNum abstractNum = CTAbstractNum.Factory.newInstance(); + abstractNum.setAbstractNumId(BigInteger.valueOf(listNumbering.size())); + for (int depth = 0; depth < levels.size(); depth++) { + CTLvl level = abstractNum.addNewLvl(); + level.setIlvl(BigInteger.valueOf(depth)); + level.addNewStart().setVal(BigInteger.ONE); + // Every marker this export can carry is a literal, so the format is BULLET + // even when the literal is a digit: Word must draw the marker the author + // wrote, not one it derives from the item''s position. + level.addNewNumFmt().setVal(STNumberFormat.BULLET); + level.addNewLvlText().setVal(levels.get(depth)); + level.addNewLvlJc().setVal(STJc.LEFT); + CTInd indent = level.addNewPPr().addNewInd(); + indent.setLeft(BigInteger.valueOf( + (long) LIST_HANGING_TWIPS + (long) LIST_NESTING_STEP_TWIPS * depth)); + indent.setHanging(BigInteger.valueOf(LIST_HANGING_TWIPS)); + } + BigInteger abstractId = document.createNumbering() + .addAbstractNum(new org.apache.poi.xwpf.usermodel.XWPFAbstractNum(abstractNum)); + BigInteger numId = document.getNumbering().addNum(abstractId); + listNumbering.put(list, numId); + return numId; + } + + /** + * The one marker each nesting depth uses, or {@code null} when the list cannot be a + * Word list. + * + *

A Word list definition names one marker per level, so a list whose items at the + * same depth carry different markers has no definition to be given and keeps writing + * its markers as text. So does a list with a drawn marker, which has no Word analogue + * at all, one with no marker, where numbering would add an indent the author did not + * ask for, and one with rich items, whose runs the numbered path does not write.

+ */ + private static List markerPerDepth(com.demcha.compose.document.node.ListNode list) { + java.util.Map perDepth = new java.util.TreeMap<>(); + if (!list.items().isEmpty()) { + if (!isPlainVisible(list.marker())) { + return null; + } + perDepth.put(0, levelText(list.marker())); + } + for (com.demcha.compose.document.node.ListItem item : list.nestedItems()) { + if (!collectMarkers(item, 0, perDepth)) { + return null; + } + } + if (perDepth.isEmpty()) { + return null; + } + // Depths must be contiguous from zero; a definition cannot skip a level. + for (int depth = 0; depth < perDepth.size(); depth++) { + if (!perDepth.containsKey(depth)) { + return null; + } + } + return List.copyOf(perDepth.values()); + } + + private static boolean collectMarkers(com.demcha.compose.document.node.ListItem item, + int depth, + java.util.Map perDepth) { + if (item.isRich()) { + return false; + } + com.demcha.compose.document.node.ListMarker marker = + item.marker() != null + ? item.marker() + : com.demcha.compose.document.node.ListMarker.defaultForDepth(depth); + if (!isPlainVisible(marker)) { + return false; + } + String existing = perDepth.putIfAbsent(depth, levelText(marker)); + if (existing != null && !existing.equals(levelText(marker))) { + return false; + } + for (com.demcha.compose.document.node.ListItem child : item.children()) { + if (!collectMarkers(child, depth + 1, perDepth)) { + return false; + } + } + return true; + } + + private static boolean isPlainVisible(com.demcha.compose.document.node.ListMarker marker) { + return !marker.isRich() && marker.isVisible() && !marker.value().isBlank(); + } + + /** + * The marker as Word's {@code w:lvlText} wants it: the glyph alone. + * + *

A marker's own value carries the separating space the text path needed, because + * there it was concatenated straight onto the item. Word puts the gap there itself + * from the level's indent, so the space would be drawn twice.

+ */ + private static String levelText(com.demcha.compose.document.node.ListMarker marker) { + return marker.value().strip(); + } + /** * Semantic list mapping: each item becomes a marker-prefixed paragraph in * the list's text style. Flat items run through the same @@ -398,6 +545,7 @@ private void warnDroppedInlineRuns(List runs) { */ private void writeList(XWPFDocument document, com.demcha.compose.document.node.ListNode list) { + BigInteger numId = numberingFor(document, list); for (String item : list.items()) { // Same normalization as the fixed-layout pipeline: strip an // author-typed leading marker and skip items with no content. @@ -411,20 +559,24 @@ private void writeList(XWPFDocument document, // any row with runs in it is; its item is still just a label. writeRichListLine(document, list.textStyle(), list.marker(), com.demcha.compose.document.node.ListItem.of(normalized), 0); + } else if (numId != null) { + // Word draws the marker, so the text is the item and nothing else. + writeListLine(document, list.textStyle(), normalized, 0, numId); } else { writeListLine(document, list.textStyle(), - list.marker().prefix() + normalized, 0); + list.marker().prefix() + normalized, 0, null); } } for (com.demcha.compose.document.node.ListItem item : list.nestedItems()) { - writeNestedItem(document, list, item, 0); + writeNestedItem(document, list, item, 0, numId); } } private void writeNestedItem(XWPFDocument document, com.demcha.compose.document.node.ListNode list, com.demcha.compose.document.node.ListItem item, - int depth) { + int depth, + BigInteger numId) { // prefix() carries its own trailing space (and is empty for // markerless lists). Items without an explicit (or markerFor-baked) // marker fall back to the same depth cascade the fixed-layout @@ -435,20 +587,33 @@ private void writeNestedItem(XWPFDocument document, : com.demcha.compose.document.node.ListMarker.defaultForDepth(depth); if (item.isRich() || marker.isRich()) { writeRichListLine(document, list.textStyle(), marker, item, depth); + } else if (numId != null) { + writeListLine(document, list.textStyle(), item.label(), depth, numId); } else { - writeListLine(document, list.textStyle(), marker.prefix() + item.label(), depth); + writeListLine(document, list.textStyle(), marker.prefix() + item.label(), depth, null); } for (com.demcha.compose.document.node.ListItem child : item.children()) { - writeNestedItem(document, list, child, depth + 1); + writeNestedItem(document, list, child, depth + 1, numId); } } + /** + * Writes one item, either as a real Word list paragraph or as the marker-prefixed + * text the export used before Word numbering existed here. + * + * @param numId the list definition to attach, or {@code null} to write the marker + * and the nesting indent as characters + */ private void writeListLine(XWPFDocument document, DocumentTextStyle style, - String text, int depth) { + String text, int depth, BigInteger numId) { XWPFParagraph para = newBodyParagraph(document); + if (numId != null) { + para.setNumID(numId); + para.setNumILvl(BigInteger.valueOf(depth)); + } XWPFRun run = para.createRun(); applyStyle(run, style); - run.setText(" ".repeat(depth) + text); + run.setText(numId != null ? text : " ".repeat(depth) + text); } /** 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..73d930c51 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,74 @@ 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"); // 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 +172,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 +181,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 +196,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..35314b35b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListNumberingTest.java @@ -0,0 +1,185 @@ +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(); + } + } + + 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..a027544ab 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,26 @@ 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)).isEqualTo("→"); + assertThat(markers.get(1)).isNotEqualTo("◦"); } @Test @@ -52,8 +59,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 +70,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 +78,11 @@ 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"); + // No empty list items for the blank ones: a numbered empty paragraph would draw + // a marker with nothing beside it, the same defect the old marker-only paragraph + // was. + assertThat(texts.stream().filter(t -> !t.isBlank()).toList()).containsExactly("kept"); } @Test @@ -82,11 +93,37 @@ 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(); + } + } + + private static XWPFDocument export( + Consumer author) throws Exception { byte[] docxBytes; try (DocumentSession session = GraphCompose.document() .pageSize(595, 842) @@ -97,10 +134,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/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/probe/EditableExportPrototype.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/probe/EditableExportPrototype.java index e4fff0d75..cea705357 100644 --- 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 @@ -56,6 +56,13 @@ * 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 { From 40b65488411b68da48da9fc5aa0d9c290df74b5c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 15:15:47 +0100 Subject: [PATCH 05/11] fix(render-docx): close five defects the review found in the new export paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from the pre-PR review, four of them in code this branch just added and one older defect the render made obvious. The styles part named the font in w:ascii only. POI's XWPFRun.setFontFamily writes ascii, hAnsi, cs and eastAsia, and the export now suppresses a run's own rFonts when it matches the document default — so naming one slot sent every High-ANSI character, every complex script and all CJK to Word's theme font while the rest of the line kept the asked-for family. A Hebrew body paragraph in the dominant style lost its w:cs entirely, which is a regression against the export this branch started from. All four slots are written. A list nested past nine levels produced a document Word refuses to open. CT_AbstractNum/lvl is maxOccurs="9" and POI saves a tenth without complaint, so a guard-free path turned a readable document into an unopenable one. Deeper than nine keeps the text form. The dominant text style was keyed on DocumentTextStyle. That record's equality is its components', and DocumentColor defines no equals, so styles built inline per paragraph — ordinary authoring — each weighed alone and the body's characters never added up. Measured: six body paragraphs against three headings sharing one instance elected the heading, so restyling Normal moved the headings instead of the body. Styles are now weighed by what the styles part writes: family, half-points, packed RGB. Size and colour are compared the same way where a run decides whether to restate the style. A row inside a painted container broke the band into stripes. Its paragraphs live in table cells and cannot carry the paint, so the cells take it instead. The newBodyParagraph Javadoc claimed every body paragraph went through it, which was not true of four writers; it now names the three that stay out and why. A list whose flat items are all blank claimed level zero for a marker no paragraph is written for, either refusing numbering to a uniform nested list or minting a definition nothing references. It seeds level zero only from an item that survives normalization. Also two defects the Word render showed. A RowNode is carried as a one-row table so editors keep the side-by-side layout, but POI ships Word's default single-line grid and nothing turned it off, so every two-column block exported ruled where the PDF draws nothing. And the header/footer writer added its right tab stop through addNewPPr() after the spacing calls had already created the paragraph properties — a second w:pPr that Word ignores in favour of the first, so the page number sat on Word's default half-inch grid instead of the right margin. Both confirmed fixed in a Word render of the probe corpus. Verification: ./mvnw -B -ntp verify -pl :graph-compose-render-docx -am, exit 0, BUILD SUCCESS, 324 core + 124 module tests. Ten new tests, one per finding plus the resolution of an item's numId through w:num to the definition carrying its marker — every marker assertion read abstract definition zero directly, so a correct marker on an unreferenced definition would have passed. Measured through Word 16.0 on the probe corpus: worst grid cell 78.9% to 53.9%, cells over 25% from 45 to 38, regions of lost content from 14 to 12. --- CHANGELOG.md | 18 +- .../semantic/docx/DocxSemanticBackend.java | 155 ++++++++++++++++-- .../semantic/docx/DocxContainerPaintTest.java | 34 ++++ .../semantic/docx/DocxDocumentStyleTest.java | 47 ++++++ .../docx/DocxHangingIndentIsIgnoredTest.java | 7 + .../semantic/docx/DocxListNumberingTest.java | 100 +++++++++++ .../semantic/docx/DocxListParityTest.java | 25 ++- 7 files changed, 364 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8069846..019adc2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,16 @@ follow semantic versioning; release dates are ISO 8601. container, which keeps its own cell paint. A container with no paint exports exactly as before. +- **A row no longer exports with visible table rules, and a page-number footer sits at the + right margin again.** Two defects the render made obvious. A `RowNode` is carried as a + one-row table so editors keep the side-by-side layout, but POI ships Word's default + single-line grid and nothing turned it off, so every two-column block exported ruled + where the PDF draws nothing. And the header/footer writer added its right tab stop + through `addNewPPr()` after the spacing calls had already created the paragraph + properties — a second `w:pPr` that Word ignores in favour of the first, so the tab fell + back to Word's default half-inch grid and the page number sat near the left margin. + Both are fixed; a table an author asked for keeps its own borders. + - **A DOCX list is now a real Word list.** The export wrote the marker into the item's run text and indented nesting with two spaces per level, which looks like a list and is not one: measured in Word 16.0, `ListFormat.ListType` came back as "no numbering", so @@ -50,7 +60,13 @@ follow semantic versioning; release dates are ISO 8601. 14pt left the body at 10.5pt. The export now writes a styles part whose document defaults and `Normal` carry the document's dominant text style, chosen by how many characters are set in it rather than by how many nodes use it, since headings are - numerous and short while body text is long. A run that only restates that style writes + numerous and short while body text is long. Styles are weighed by what the styles part + writes — family, half-points, packed RGB — rather than by `DocumentTextStyle` equality: + that record's equality is its components', `DocumentColor` defines no `equals`, and + styles built inline per paragraph would each weigh alone, electing whichever style + happened to be reused. Measured before the fix: six body paragraphs against three + headings elected the heading, and restyling Normal moved the headings instead of the + body. A run that only restates that style writes no `w:rFonts`, `w:sz`, `w:szCs` or `w:color`, so the style reaches it; a run that differs still says so. Complex-script sizing is unchanged in effect — `w:szCs` moves to the style along with `w:sz`, so Hebrew and Arabic still read a size rather than falling diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index bfb8eb605..a9e42578d 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -37,6 +37,7 @@ import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.table.DocumentTableCell; import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.font.FontName; import org.apache.poi.util.Units; import org.apache.poi.xwpf.usermodel.BreakType; import org.apache.poi.xwpf.usermodel.ParagraphAlignment; @@ -69,7 +70,10 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyles; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; @@ -117,6 +121,8 @@ public final class DocxSemanticBackend implements SemanticBackend { private static final String NORMAL_STYLE_ID = "Normal"; /** Word measures tab stops in twentieths of a point. */ private static final double TWIPS_PER_POINT = 20.0; + /** {@code w:sz} and {@code w:szCs} count half-points. */ + private static final double HALF_POINTS_PER_POINT = 2.0; private static final double POINT_TO_TWIP = 20.0; private static final Logger LOG = LoggerFactory.getLogger(DocxSemanticBackend.class); // The page's content width, so an image is held to the same bound layout holds it to. @@ -278,7 +284,14 @@ private void writeZoneLine(XWPFHeaderFooter target, DocumentNode content) { XWPFParagraph para = target.createParagraph(); para.setSpacingBefore(0); para.setSpacingAfter(0); - CTTabStop tab = para.getCTP().addNewPPr().addNewTabs().addNewTab(); + // Reuse the properties the spacing calls above already created. addNewPPr() would + // append a second w:pPr, and Word reads the first — the tab stop would be in the + // file and ignored, so the page number fell back to Word's default half-inch grid + // instead of sitting at the right margin. + CTPPr properties = para.getCTP().isSetPPr() + ? para.getCTP().getPPr() + : para.getCTP().addNewPPr(); + CTTabStop tab = properties.addNewTabs().addNewTab(); tab.setVal(STTabJc.RIGHT); tab.setPos(java.math.BigInteger.valueOf(Math.round(contentWidth * TWIPS_PER_POINT))); @@ -409,6 +422,16 @@ private void warnDroppedInlineRuns(List runs) { /** Added per nesting level, approximating the two spaces the text path indented by. */ private static final int LIST_NESTING_STEP_TWIPS = 120; + /** + * Levels one Word list definition may hold. + * + *

{@code CT_AbstractNum/lvl} is {@code maxOccurs="9"} — Word has nine list levels + * and {@code w:ilvl} runs 0..8. Writing a tenth produces a part that POI saves without + * complaint and Word refuses to open, so a list nested deeper keeps its markers as + * text rather than shipping a document that cannot be opened at all.

+ */ + private static final int MAX_LIST_LEVELS = 9; + /** * Gives a list a real Word list definition, when it is one Word can express. * @@ -472,7 +495,12 @@ private BigInteger numberingFor(XWPFDocument document, */ private static List markerPerDepth(com.demcha.compose.document.node.ListNode list) { java.util.Map perDepth = new java.util.TreeMap<>(); - if (!list.items().isEmpty()) { + // Seeded from the flat items only when one of them survives normalization. A list + // whose flat items are all blank writes no paragraph for them, so claiming depth + // zero for their marker would either reject a uniform nested list whose own depth + // zero differs, or mint a definition nothing references. + if (list.items().stream().anyMatch(item -> !com.demcha.compose.document.node.ListMarker + .normalizeItemText(item, list.normalizeMarkers()).isBlank())) { if (!isPlainVisible(list.marker())) { return null; } @@ -483,7 +511,7 @@ private static List markerPerDepth(com.demcha.compose.document.node.List return null; } } - if (perDepth.isEmpty()) { + if (perDepth.isEmpty() || perDepth.size() > MAX_LIST_LEVELS) { return null; } // Depths must be contiguous from zero; a definition cannot skip a level. @@ -782,7 +810,17 @@ private void writeStylesPart(XWPFDocument document) { private static void applyDefaultRunProperties(CTRPr properties, DocumentTextStyle defaults) { if (defaults.fontName() != null) { - properties.addNewRFonts().setAscii(defaults.fontName().name()); + // All four slots, exactly as XWPFRun.setFontFamily writes them on a run. + // w:ascii alone covers only ASCII: High-ANSI characters read w:hAnsi, Hebrew + // and Arabic read w:cs, CJK reads w:eastAsia. Naming one and suppressing the + // run's own rFonts would send every accented letter and every complex script + // to Word's theme font while the rest of the line kept the asked-for family. + String family = defaults.fontName().name(); + CTFonts fonts = properties.addNewRFonts(); + fonts.setAscii(family); + fonts.setHAnsi(family); + fonts.setCs(family); + fonts.setEastAsia(family); } if (defaults.size() > 0) { // w:sz counts half-points, and w:szCs carries the same for complex scripts. @@ -806,27 +844,65 @@ private static void applyDefaultRunProperties(CTRPr properties, DocumentTextStyl * @return the dominant style, or {@code null} when the graph carries no text */ private static DocumentTextStyle dominantTextStyle(DocumentGraph graph) { - java.util.Map weights = new java.util.HashMap<>(); + java.util.Map weights = new java.util.HashMap<>(); + java.util.Map byKey = new java.util.HashMap<>(); for (DocumentNode root : graph.roots()) { - weighTextStyles(root, weights); + weighTextStyles(root, weights, byKey); } return weights.entrySet().stream() .max(java.util.Map.Entry.comparingByValue()) - .map(java.util.Map.Entry::getKey) + .map(entry -> byKey.get(entry.getKey())) .orElse(null); } private static void weighTextStyles(DocumentNode node, - java.util.Map weights) { + java.util.Map weights, + java.util.Map byKey) { if (node instanceof ParagraphNode paragraph && paragraph.textStyle() != null) { - weights.merge(paragraph.textStyle(), textWeight(paragraph.text()), Long::sum); + weigh(paragraph.textStyle(), textWeight(paragraph.text()), weights, byKey); } else if (node instanceof com.demcha.compose.document.node.ListNode list && list.textStyle() != null) { long weight = list.items().stream().mapToLong(DocxSemanticBackend::textWeight).sum(); - weights.merge(list.textStyle(), weight, Long::sum); + weigh(list.textStyle(), weight, weights, byKey); } for (DocumentNode child : node.children()) { - weighTextStyles(child, weights); + weighTextStyles(child, weights, byKey); + } + } + + private static void weigh(DocumentTextStyle style, + long weight, + java.util.Map weights, + java.util.Map byKey) { + StyleKey key = StyleKey.of(style); + weights.merge(key, weight, Long::sum); + byKey.putIfAbsent(key, style); + } + + /** + * What makes two text styles the same for the purpose of electing a document default. + * + *

{@code DocumentTextStyle} cannot be the key. It is a record, so its equality is + * its components', and {@code DocumentColor} defines no {@code equals} — two colours + * built from the same channels are unequal unless they are the same instance. Styles + * built inline per paragraph, which is ordinary authoring, would each weigh alone and + * the body's characters would never add up, electing whichever style happened to be + * reused instead.

+ * + *

The components are the three the styles part actually writes, compared as they + * are written: the family by name, the size in half-points, the colour as packed + * RGB.

+ * + * @param fontName the family, or {@code null} when the style names none + * @param halfPoints the size as {@code w:sz} counts it + * @param colour packed RGB, or {@code null} when the style names no colour + */ + private record StyleKey(FontName fontName, long halfPoints, Integer colour) { + + static StyleKey of(DocumentTextStyle style) { + return new StyleKey(style.fontName(), + Math.round(style.size() * HALF_POINTS_PER_POINT), + style.color() == null ? null : style.color().color().getRGB()); } } @@ -878,8 +954,12 @@ private void warnContainerRadiusDropped(DocumentNode node) { /** * Creates a body paragraph already wearing the panel it sits in. * - *

Every body paragraph goes through here, so a container's paint cannot be - * forgotten by a writer that creates its paragraph directly.

+ *

Every paragraph written straight into the body goes through here, so a + * container's paint cannot be forgotten by a writer that creates its own. Three + * writers create paragraphs elsewhere on purpose: a page break, which would draw a + * band across the page; a table cell, which carries the author's own cell paint; and + * a row's cells, which take the paint on the cell instead, since a paragraph inside a + * table cannot reach the band the container is drawing.

*/ private XWPFParagraph newBodyParagraph(XWPFDocument document) { XWPFParagraph para = document.createParagraph(); @@ -1427,10 +1507,25 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception { return; } XWPFTable table = document.createTable(1, node.children().size()); + // A row is a layout device, not a table anybody asked to see. POI's createTable + // ships Word's default single-line grid, so without this every two-column block — + // a header pair, a label beside a value — exported with visible rules the PDF + // never draws. + hideTableGrid(table); XWPFTableRow row = table.getRow(0); + // A row inside a panel is still inside it. Its paragraphs live in table cells and + // so cannot carry the paint themselves; without shading the cells the band breaks + // into stripes wherever a two-column block sits in a filled container. + ContainerPaint paint = containerPaint.peek(); for (int i = 0; i < node.children().size(); i++) { XWPFTableCell cell = row.getCell(i); cell.removeParagraph(0); + if (paint != null && paint.fill() != null) { + CTShd shading = cellProperties(cell).addNewShd(); + shading.setVal(STShd.CLEAR); + shading.setColor("auto"); + shading.setFill(toHexColor(paint.fill().color())); + } DocumentNode child = node.children().get(i); writeRowCellChild(cell, child); } @@ -1440,6 +1535,27 @@ private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) throws Ex writeCellBody(cell, child); } + /** + * Turns off a table's own grid, leaving each cell free to state its borders. + * + *

Used where the table is a carrier for a side-by-side layout rather than + * something the author asked to see ruled.

+ */ + private static void hideTableGrid(XWPFTable table) { + CTTblPr properties = table.getCTTbl().getTblPr() != null + ? table.getCTTbl().getTblPr() + : table.getCTTbl().addNewTblPr(); + CTTblBorders borders = properties.isSetTblBorders() + ? properties.getTblBorders() + : properties.addNewTblBorders(); + paintEdge(borders.addNewTop(), STBorder.NONE, null, null); + paintEdge(borders.addNewBottom(), STBorder.NONE, null, null); + paintEdge(borders.addNewLeft(), STBorder.NONE, null, null); + paintEdge(borders.addNewRight(), STBorder.NONE, null, null); + paintEdge(borders.addNewInsideH(), STBorder.NONE, null, null); + paintEdge(borders.addNewInsideV(), STBorder.NONE, null, null); + } + /** * Writes {@code child} into an emptied cell and leaves a paragraph behind either way. * @@ -1615,7 +1731,11 @@ private void applyStyle(XWPFRun run, DocumentTextStyle style) { } // Complex-script size rides along with the ordinary one, so it is skipped for the // same reason when the style already carries it. - boolean sizeComesFromTheStyle = defaults != null && style.size() == defaults.size(); + // Compared as they are written, in half-points, rather than as raw doubles: two + // sizes Word cannot tell apart must not produce a redundant direct w:sz. + boolean sizeComesFromTheStyle = defaults != null + && Math.round(style.size() * HALF_POINTS_PER_POINT) + == Math.round(defaults.size() * HALF_POINTS_PER_POINT); if (style.size() > 0 && !sizeComesFromTheStyle) { // Passed as a double, because w:sz counts half-points and rounding to whole // points first throws away a precision the format has: the timeline's 8.5pt @@ -1635,7 +1755,12 @@ private void applyRunColourAndDecoration(XWPFRun run, DocumentTextStyle style, DocumentTextStyle defaults) { if (style.color() != null - && (defaults == null || !style.color().equals(defaults.color()))) { + && (defaults == null || defaults.color() == null + // By channel, not by instance: DocumentColor defines no equals, so two + // colours built from the same channels are unequal unless they are the + // same object, and a style built inline per paragraph would keep writing + // a colour the Normal style already says. + || style.color().color().getRGB() != defaults.color().color().getRGB())) { run.setColor(toHexColor(style.color().color())); } if (style.decoration() != null) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java index a9f377d32..46387265f 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxContainerPaintTest.java @@ -122,6 +122,29 @@ void paintShouldNotLeakToParagraphsAfterTheContainer() throws Exception { .isTrue(); } + @Test + void aRowInsideAPanelShouldNotBreakTheBandIntoStripes() throws Exception { + // A row is carried as a one-row table, so its paragraphs live in cells and cannot + // carry the paint themselves. Without shading the cells the panel renders as a + // band, a white stripe where the row sits, and a band again. + byte[] docx = exportBytes(page -> page.addSection("Card", card -> card + .fillColor(SURFACE) + .addParagraph(p -> p.text("Above")) + .addRow(r -> r + .addParagraph(p -> p.text("Left")) + .addParagraph(p -> p.text("Right"))) + .addParagraph(p -> p.text("Below")))); + + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + assertThat(document.getTables()).hasSize(1); + for (var cell : document.getTables().get(0).getRow(0).getTableCells()) { + assertThat(hex(cell.getCTTc().getTcPr().getShd().getFill())) + .as("the row's cells continue the panel") + .isEqualTo("EEF3F9"); + } + } + } + private static String shadingFill(XWPFParagraph paragraph) { CTPPr properties = paragraph.getCTP().getPPr(); if (properties == null || !properties.isSetShd()) { @@ -152,6 +175,17 @@ private static String hex(Object value) { return String.valueOf(value); } + private static byte[] exportBytes( + Consumer content) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 400) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(content::accept); + return session.export(new DocxSemanticBackend()); + } + } + private static List bodyOf( Consumer content) throws Exception { byte[] docx; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java index 5a0e4bf99..2d9127388 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDocumentStyleTest.java @@ -105,6 +105,53 @@ void theDefaultShouldBeChosenByCharactersNotByParagraphCount() throws Exception } } + @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))) { 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 73d930c51..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 @@ -118,6 +118,13 @@ void aMarkerlessListAndAMarkerOnlyRowBothSurviveUnchanged() throws Exception { // 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. 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 index 35314b35b..1b2590c41 100644 --- 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 @@ -162,6 +162,106 @@ void aDrawnMarkerShouldStayOnTheRunPathItAlreadyUsed() throws Exception { } } + @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()) 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 a027544ab..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 @@ -50,8 +50,9 @@ void explicitMarkersStillBeatTheCascade() throws Exception { .markerFor(1, ListMarker.custom("→")) .addItem("alpha", l1 -> l1.addItem("beta")))); - assertThat(markers.get(1)).isEqualTo("→"); - assertThat(markers.get(1)).isNotEqualTo("◦"); + assertThat(markers.get(1)) + .as("the override wins over the cascade's ◦ at this depth") + .isEqualTo("→"); } @Test @@ -79,10 +80,12 @@ void blankFlatItemsAreDropped() throws Exception { .addList("kept", "", " ")); assertThat(texts).contains("kept"); - // No empty list items for the blank ones: a numbered empty paragraph would draw - // a marker with nothing beside it, the same defect the old marker-only paragraph - // was. - assertThat(texts.stream().filter(t -> !t.isBlank()).toList()).containsExactly("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 @@ -122,6 +125,16 @@ private static List markerPerDepth( } } + /** 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; From a123cb0d7f968fe573af52680f2030c4d3bb4200 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 17:41:11 +0100 Subject: [PATCH 06/11] chore(examples): re-render the committed Word preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CommittedAssetDriftTest held the committed previews against what the catalogue renders, and the Word one had moved: this branch gives a container's fill and borders, a styles part and real list numbering to the DOCX export, so the same example produces a different document. The guard names the fix — re-render the file, or revert what moved it — and the change is intended, so the file is re-rendered. Only the DOCX. The PDF beside it differs byte-for-byte on every run through its time-seeded /ID, which AssetContent drops, so the guard did not flag it and committing it would be churn. Verification: ./mvnw -B -ntp test -Dtest=CommittedAssetDriftTest -f examples/pom.xml, 3 tests, BUILD SUCCESS. Regenerated through WordExportExample after installing core, render-pdf, render-docx, render-pptx and templates, since the examples module resolves them from the local repository rather than the reactor. --- .../examples/word-export-companion.docx | Bin 8218 -> 9063 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 9d5f99b534f2423fa4046a7d4410fe78b2b74e12..2d340cb23c6c00161de6fa493b8c346ae0d458ec 100644 GIT binary patch delta 3956 zcmZu!2Q*yK)*ih_FHvTgAc*KKNOWNa(IR^EI(i!=E>V+61~WkrH6qcXMIR(OQ4^gI zJ%TV=5InN}|D~+=-F4SFcdfI}IrrP&K4-dz>%8xk zMMg^fFr6~Gq!%{&&Uj{I&i2IgMl8N&VD8~;Hj}HO=<~ScZ=}YaZ#qnURk!qDpEzWq zZpY4=I4gTUtkEitg8{FZ7N(ti0b##Q!VFlqc&pt{%EzWVhVpKE+bgEV$;X4$IY^VO zy=dW49O?q2t;buaRCk^<3Cki=Hf(?zyoP9Hlln<+#n=y%L41(h$8O)|b)CKIlG)b-io(FL+T@(=Bg?$S}~9C0Lm zF{g_CxnzEKoiV>iVXo7^w%Q!sB0P1O+cMK(tmjUYBIn%j);b#lvE18r9eW`JDREJR8e{QM|oqSNBXcK zxitpuOV;z7ZBaU2Ayj6-*QcKguXdYc2;0RJ#o6$nMYePG(x0lMV5h7TN>-JvOmdZ4 z`yWA~oI`Z57C8zb)P#%PP~4Ohfrz~h{fzEyn^!#Luctc6^Z z8BTQk!=2G@W_ta&acGZlg-MwZ)8fza2EG$RTxJMeMux>JfOruIxH1}eGo{ljelHN! z@tH_vS4!_tRl8Z{UJ9r!DT$2=xhdqy(?mQ(w{!TDS#nf*D-LD#Txpggbg2Y*lQDZO zgT$2JIZh&+dALd4R?>g^Df(hz28-O+xD9#=Gf< z9Or&|Y9OcUher&B1hr@7hsn>s zESZb$Ap>8Tm*902@fbS;;hrb+MyaA?_+yKW%;wE+EN{)l#-$9I zb5p$uF~pI=@o8}{W;R2y8|)cDZ_;LfeHU|THIX(~ zayempkaF9n=G@gI+id06s?C+ekzNaTV0oz!gAC+F)>^U*-N7yP`)ZYL(ka%4X`BpL zFtxFqeV6rZ!5re&EE029p86#fKHc+@1qOO$)e`+bgjpnKHg!wP@C%S!rG;l-Jiv|4EYNGL zgzaErA84?2`Nk-=hs)LlESGo(CD#{ugx4n?Pv@}v8I2|q7k$xelQ6inB>9HK91TXX zSksq0=5?T)D4-{QPR|5Z_Z5ha7WCW>+i|;c+~dDoreShLKp9PQLP}Aa4OqmFRYNg* z{lznxIsyo2l$fBekQH&96Kf06rD%;Ooq=>-n-r2-5(l_t1r(oTsF_Ph)Tf8Fc)jZK zGDs_^*>FXLi|ZwPL3RpA>V5Cs8sDi}l$W0xp%H|TW@zW*#fz*@zy-7r(f9O2j1HbV zEVNZdiowNUd$2tHV7*!tATNx-V6C+hQwrFOFO1c^M|+}QBv9Ip&JOZ*6MeO$uwAw; z+1$bMH=DL!A&R?WBzB|UkYwoEm*}=sO0KImG_hg*n3m;kMhE{P$1EYOwM2o5j)hy? z21(;F^M$zE2ZzlP?XI#C1ld;C0u*j^-|FkwHKt(`P*lQxiYW3{26%fx^Mo#&p;Nar z((6Zb*)(36#CPUE4wK-u{o*GU^Fw4~qQ0;-F&JbFOoPp8^gS(pi$^oQV#uCmXPn^Aqz5T!k>&iWG3*gIVHUjx1wO(r50s3ym&rak6fDP6f*9qpLh1jeOR!b^2BkR&1PSu6$((phF0zJMcNf9-i3EAAt z9Gu!alLD&Fi2qKST)y%8y`!paXpEOwLakETlCTQ-qrn`^e7|Zf^U4xF*5*juj6V}>|GjDz#i;?nd zLSv8IzJ&_!MZdL#y**~NCg8tqq~ZKMv~A$LMks7~Hb1NeH{{HoT+X=^Jv#GYn+-Rl^6(&_fCDc;pBZ27caFOed zgxX}p2o!0gd7A6DpJR31W*)c^qAEesG-Fy4`h_{5GMcT6J4LF&dy!j#hI5N^I1egc zNlM46BCm-Op>6H-1#P8W5++sr<`1AqzHadbp|Yd5c&Sg~g4sW8Vd zas343zJFIY_eU~tNg?QOS4R(TehM`9OC3}frKc=?;5TvwNsyDlw#lsb1)cPyRr6LG zk{JtrW-OHd^@gCDim9{O38lG!+hRHZKGKxWWC$#OwvYa-#uY)hY9wAYEG`^Lu8aaNWm{%k@F^qMD z`JuSmD4f#F&e;K(K0LTR_ATA)>WtWG&Won?8^e9UqLUjigjwdyYkjO+um9xgqK;n! z;C97ya>U=iz#DOdZCX$M4(zkh(1XQYQiYIQLEwknsfim5%+w8;^45IR2c$L$dTssckQy@55JLO3| z#XrCHG#A9~w-SRucB22>dKRERz<$;bKwqb12NL^TT0|&g?zXKo$~bqhEnjWr32l@9W(%>CLj>U?i~` z!q&BeVX!%pFl~30<7Q8}azy`&NZPLE=JdOjywX;shIw&%ycPoQ$}xGP<8|0|J>k_u zO&}RgY%V@rC8=BGM&RNIM6;&Ss;OnM%+bgjK+1BUUV5^4zp=QqMt9b^ z^?*p{Q}9-gPl$ZvEx|e2>UWSI8LjgsP^)jndrEy{h9*W1J2H9z`_3>>-1IAH3(vin zGk5mSFaOO2Dy~sMX@Ik&?Js)h>>+wi9;ubF;Dcc6N6ZLb@T|8kD(5h|o-5*@eXK zw<;D2D2F2wCbC!KL&NY$;@8B>sqEJ7R0hXfI4%m+q^6_McwL~{lxM| z&tIBgAXlF2wV3nNFf>(UN*wJCs^TnM0rnim33ahpAqT7ZBNr zEz>eiouZWeGlrk#YgTcc>La1;|Q33a0A-$ z;+=KU&OSwXF*Xyst8<+vP4$7JW9!T7+ptOP;vnYI!!5spVYz+E!V{vRMj+E=_p`#b zLsmT_%S`8j=i%3Tax&2E)1*M#iyZg9t}veq(IYtG1K(MmO!{x0{1?--zl7+?fdT#~ zR{k16{r|r3@eM@S`$YS1@`R&JPx1;Iyk^1oEbDAXpFFE2fntgvM8xNlW^xvA#;&-WnW(SKy&Q&hl z@GI+~Q9b&jt4zD9HaU_gj9taH6vV76c+cdw??@ Jhd9r_{sRs1*@gfB delta 3076 zcmZ9Oc{mi>|Ho&>Hkh$1>)2%n zBg;h28Fav@glL0B{<*sf*1$2=ZyNhE!}m-PCx_`IeL;3>tG>4@Ek10@`!(EXsBlwP z%X0H`eV{81GHwB#xf*cHPba*Z!IgO5eiP6aJ0%s#T<=~pU|;=UxeQn3u*haFkm&o2 zen@;-?m%9GjE})YhR4CfcPLSXCc`k#DfO$BQxxnLCnte(bQ|(oKU~@1*E{A9M;cG{ zOFVh1?R)izyFS)s>b6xZ5Fr`|rDZ5iLD2qyDlwb-W0P z!CGolPF7Y{N0o2RReCpv8%n(P60LB7E-u(O=QIXd8M-&UFIX=FR%KBj0ANXRzW!Us#!Mgw zkm9_c*yo_p-}VlLFv`qgMV2Y$YYj!Al2>L(n?)A4d#{9&DkG^+jGb%KHF$UboslVerw z#o`lx8C~}7i`Dvqt{ON!ciQuQuJHwuZv0>AC1q#SDMLnERh;;a?Mm`cXP-fGY)5-E z$FOgP*;%dC`$;R8uLY8KdONr0kq4uL_t(?;= zi|(FA`h#%GcvR!yeJ% zV6X_CL$Vv07*P9MuF<-{^;OjC+_0^W} zS0n=^4I~{PmHqqdrMr`9k4Uoo$l4~1)d(JD%hN3+s=*AsKFa9OPDZYIpKR2*n&uaJ zm0=81I@67mFG&w5qeO#W*7|5N;x#dHnTzw<&oNwIo|mX)4X5RJIS24_PzTpo`?U(N zXJNBal}lE{ujC1ZJPK4M=2_VmFyWH!y0;blg|oC%FQ#@M?|48UQRkk#D_HUkgkPl7 ziudt)$3rS!rHpmwX(4yN-4=P&G>6Ta*pZqveUQ7KOM?uiM3ph5A6I6%@*rb85^RFZ z3B~(8GTBpnjUw+QXN(Nt59K(Ga-jAbge{MlvLfiP7;o#-ClI3Q#R{^)#Ps%{Y;Hcb zz%5>c4{)j>Zz(Sc6X$I3*gXzqAxD4Pz*Qays6r-oc}^D z@}on?xEDVNWqxFP!@bK?dm#T__ag4j3GnGqmOx~}N9a_`WL{eEO&X%Jq`R&V<4@Yd zdnIw0eFk}Ajo7P&@c559N*_kDEs%J~A&Z4itodc=Qlgx`0nOi1bd;^iis;M4<8QOU z!%kAePSu!jIm_a&cir#2>Oob;-?H8g>@9dY(9Y{wi*nMc=J~#b9E{^k*!P6!66QH0 zTI6nAw$`vl%6-<;dj`)y)gz6K%K*6sKq-P#&2DuBpR`I#>X71^94-E3O4|apB6GB) zP3sv9?F3{a6kLc!qz@U+cHZJ1v3jCI0R_{EBlhGPU*gJ>{jQti-MVSes z1FRgrHuil6^$N^88FtomipnPCn4`LaYj?b5v+XUb%L zveD2%661~?D|mFUB(INOMAk4*iZb{$$OT0?G+mh9=)Gia)``8(^u09n!Hn3)L^Tr# zxmz6cY;m_`(T~%M(3B3gmq)!ME@_$VyZKB{T2p-}v#rY!t1-mC1eL!#MZc}Ht%h1& zQJ1p%*m?TUWPf(*v@i!vfR!8M2?Zut2T_@nAF~&@wcA!7i1FH);FM%v_dD$)lS`1`|1s)ipGt44Wf2$c<}vL|L#GwiW+c45xMs3_jAMaigx+yeP3KV z5X>2k%%HX8NY0YE;^nfVoylH|coC?o3$ahXq|iE1yDw3~euV4rWq8wwVNmV7@O?LS z>=_L!%ok`UTiQ2J$~zHlSY)57t3X}QysB0>{&_ftKhWiKjYISX&-DnpA}7>(gZQn` zPo3Qq-9FNlPHMxF{+?1v?Us5`x!QVHL;U@K5;|ySg$EX(=q}nOKSm@KBF$!9I6br~>qW^eJY95fFmQt7M^+VHW)Ou7f;E3}DI z@K{~kc~{&?)i|IG`TPJjvDXY!$aamWt)o>((3Yhzma{m|n&u$Wq%xW;G~s4H=yRQM zML~Ctjut7JgEI4*Q5v_S$9H?0xp!xSjkRusMa%{b2PtpDn!*YHh>kYTa;~P4H4-n1 zGJUFY{1FU#80}`zmHy55aeAtUZGTh_)4D0{C?86)gWSd8QV{$XfQ$GK*alwRpaJ-~U_#3&X zNv&;d@(JEpBG7}&K(@EzUhhQr$ol#gEWK)FajM&ENf&gVyp?BP8K<9X+jpBfEm@zS z$&YpuQ{yxd*}@W=Hk%$vdxrm<)bD<)65K%c@#s_}@gyZjKv*AR1G3&HV*FdOOBC)L zTCc-Xy$i9G1Ir8Y4fEjsn=*?*$!{}tD=<72pDZ4y4DV_wc2pHHL>A{};?ea?V11eaK59# zeiPv5|0Vlx;5>iafkCi35E!E+;*Vgl9)jFJDQuje@NfBx{6pNWb6M>9`tu1ZUMx_E VosH_e_W%I+e1gtX0l|Kq{s-H0T=D<_ From 4df1ccd2f243b2d0af930f80b008f4e311d916d3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 19:51:13 +0100 Subject: [PATCH 07/11] feat(render-docx): state a table's width where the document knows it POI writes w:tblW as w=0 type=auto, which is Word's instruction to shrink a table around its own content. Nothing overrode it, so every exported table sized itself to its text while the reference render spans much more, and a row carried as a one-row table collapsed around a label. A row takes the whole width it is offered whatever its children measure, so its table gets the content width. A table's own width is written only where it needs no measuring: the width the author stated, and the column grid when every column is fixed, with a stated width's surplus going to the last column as the layout gives it. A table with an auto column and no stated width keeps Word's sizing, because an auto column is as wide as its content needs and that is a measurement this backend cannot make. Tests: six cases over the exported package -- an auto table left alone, a stated width honoured through auto columns, a fixed grid written exactly, the surplus rule, a mixed table left alone, and a row spanning the content width. --- .../semantic/docx/DocxSemanticBackend.java | 112 ++++++++++++ .../semantic/docx/DocxTableWidthTest.java | 172 ++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableWidthTest.java diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index a9e42578d..35d2df7c8 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -73,7 +73,10 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTFonts; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcBorders; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; @@ -1304,6 +1307,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception // cell is one cell carrying a span, not several, so a row's physical count is not // the column count. XWPFTable table = document.createTable(rowCount, 1); + applyTableWidth(table, node, columnCount); for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { XWPFTableRow row = table.getRow(rowIdx); List physical = new ArrayList<>(); @@ -1512,6 +1516,14 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception { // a header pair, a label beside a value — exported with visible rules the PDF // never draws. hideTableGrid(table); + // A row occupies the whole width it is offered — NodeDefinitionSupport.measureRow + // returns the available width unconditionally, whatever its children measure — so + // the table carrying it has to as well. Left at POI's size-to-content default the + // pair collapses around its text and both columns stop sitting where the PDF puts + // them. How the width divides between them is still Word's to decide here. + if (Double.isFinite(contentWidth) && contentWidth > 0) { + setTableWidth(table, contentWidth); + } XWPFTableRow row = table.getRow(0); // A row inside a panel is still inside it. Its paragraphs live in table cells and // so cannot carry the paint themselves; without shading the cells the band breaks @@ -1535,6 +1547,106 @@ private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) throws Ex writeCellBody(cell, child); } + /** + * Gives a table the width the fixed-layout render gives it, in the cases where that + * width can be known without measuring anything. + * + *

Nothing used to write a width at all, so Word sized every table to its own + * content while the reference spans much more — the single largest visual difference + * between the two renders. But "as wide as the page" is not the rule the engine + * follows either. A table with no stated width comes out as wide as its columns + * naturally need ({@code TableLayoutSupport.resolveFinalColumnWidths}), which for an + * {@code auto} column is the width of its widest unwrapped cell — a measurement, and + * measuring is what this backend has no font runtime for. Writing the content width + * there would be right for a table whose text fills the line and wrong for a table of + * short values, in the same way the old shrink-to-fit was wrong in the other + * direction.

+ * + *

So a width is written when it is knowable and not otherwise: the width the + * author stated, or the sum of the columns when every one of them is fixed. Both are + * numbers the document already carries. A table with an {@code auto} column and no + * stated width keeps Word's own sizing until resolved layout can supply the measured + * widths.

+ */ + private void applyTableWidth(XWPFTable table, TableNode node, int columnCount) { + Double authored = node.width() != null && node.width() > 0 ? node.width() : null; + List fixedColumns = fixedColumnWidths(node, columnCount); + + if (fixedColumns == null) { + // One of the columns is as wide as its content needs. The split is Word's, + // and so is the total unless the author stated one. + if (authored != null) { + setTableWidth(table, authored); + } + return; + } + + double natural = fixedColumns.stream().mapToDouble(Double::doubleValue).sum(); + double width = authored != null ? Math.max(authored, natural) : natural; + setTableWidth(table, width); + + CTTblGrid grid = table.getCTTbl().getTblGrid() != null + ? table.getCTTbl().getTblGrid() + : table.getCTTbl().addNewTblGrid(); + while (grid.sizeOfGridColArray() > 0) { + grid.removeGridCol(0); + } + for (int index = 0; index < fixedColumns.size(); index++) { + // With no auto column to absorb it, the engine hands a stated width's surplus + // to the last column. Splitting it evenly instead would put every column edge + // but the first in a different place than the PDF draws it. + double column = fixedColumns.get(index); + if (index == fixedColumns.size() - 1) { + column += width - natural; + } + grid.addNewGridCol().setW(BigInteger.valueOf(Math.round(column * POINT_TO_TWIP))); + } + } + + /** + * States a table's width in points, replacing the size-to-content default. + * + *

POI's {@code createTable} writes {@code w:tblW} as {@code w=0, type=auto}, which + * is Word's instruction to shrink the table around whatever it holds. That is why an + * exported table of short values came out narrow while the reference spans the text + * column, and it applies equally to a row carried as a one-row table.

+ */ + private static void setTableWidth(XWPFTable table, double points) { + CTTblPr properties = table.getCTTbl().getTblPr() != null + ? table.getCTTbl().getTblPr() + : table.getCTTbl().addNewTblPr(); + CTTblWidth width = properties.isSetTblW() + ? properties.getTblW() + : properties.addNewTblW(); + width.setType(STTblWidth.DXA); + width.setW(BigInteger.valueOf(Math.round(points * POINT_TO_TWIP))); + } + + /** + * Every column's width in points, or {@code null} when one of them is not fixed. + * + * @param node the table being written + * @param columnCount positions the resolved grid actually has + * @return the widths, or null when the split needs measuring + */ + private static List fixedColumnWidths(TableNode node, int columnCount) { + List columns = node.columns(); + // A grid position with no declared column has no width to write, so a table whose + // spans reach past its column list is one of the cases Word has to divide itself. + if (columns.size() != columnCount) { + return null; + } + List widths = new ArrayList<>(columnCount); + for (var column : columns) { + if (column.type() != com.demcha.compose.document.table.DocumentTableColumn.Type.FIXED + || column.fixedWidth() == null) { + return null; + } + widths.add(column.fixedWidth()); + } + return widths; + } + /** * Turns off a table's own grid, leaving each cell free to state its borders. * 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); + } + } +} From 9ccac1370ee3ce0ad42200ad1e0d482eb99079fd Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 19:53:48 +0100 Subject: [PATCH 08/11] fix(render-docx): write each border of a row carrier once POI's createTable ships a full set of single-line table borders, and turning them off added a second element per edge instead of replacing the first. CT_TblBorders permits one child per edge, so every exported row carried a w:tblBorders the schema does not allow. Word reads the last element and draws nothing, which is why the render looked right while the part was invalid. Tests: the carrier has exactly one element per edge and no single-line border survives. --- .../semantic/docx/DocxSemanticBackend.java | 24 +++++-- .../semantic/docx/DocxRowLayoutTest.java | 69 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 35d2df7c8..c49029d8f 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -1652,6 +1652,12 @@ private static List fixedColumnWidths(TableNode node, int columnCount) { * *

Used where the table is a carrier for a side-by-side layout rather than * something the author asked to see ruled.

+ * + *

Each edge is replaced rather than appended to. POI's {@code createTable} already + * writes a full set of single-line borders, and {@code addNew*} on top of them leaves + * two elements per edge where {@code CT_TblBorders} permits one. Word reads the last + * and draws nothing, which is why the output looked right, but the part is invalid + * against the schema either way.

*/ private static void hideTableGrid(XWPFTable table) { CTTblPr properties = table.getCTTbl().getTblPr() != null @@ -1660,12 +1666,18 @@ private static void hideTableGrid(XWPFTable table) { CTTblBorders borders = properties.isSetTblBorders() ? properties.getTblBorders() : properties.addNewTblBorders(); - paintEdge(borders.addNewTop(), STBorder.NONE, null, null); - paintEdge(borders.addNewBottom(), STBorder.NONE, null, null); - paintEdge(borders.addNewLeft(), STBorder.NONE, null, null); - paintEdge(borders.addNewRight(), STBorder.NONE, null, null); - paintEdge(borders.addNewInsideH(), STBorder.NONE, null, null); - paintEdge(borders.addNewInsideV(), STBorder.NONE, null, null); + paintEdge(borders.isSetTop() ? borders.getTop() : borders.addNewTop(), + STBorder.NONE, null, null); + paintEdge(borders.isSetBottom() ? borders.getBottom() : borders.addNewBottom(), + STBorder.NONE, null, null); + paintEdge(borders.isSetLeft() ? borders.getLeft() : borders.addNewLeft(), + STBorder.NONE, null, null); + paintEdge(borders.isSetRight() ? borders.getRight() : borders.addNewRight(), + STBorder.NONE, null, null); + paintEdge(borders.isSetInsideH() ? borders.getInsideH() : borders.addNewInsideH(), + STBorder.NONE, null, null); + paintEdge(borders.isSetInsideV() ? borders.getInsideV() : borders.addNewInsideV(), + STBorder.NONE, null, null); } /** 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..a55aa8aab --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java @@ -0,0 +1,69 @@ +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.style.DocumentInsets; +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; + +/** + * The one-row table a {@code row(...)} is carried as. + * + *

A row is a layout device rather than something the author asked to see ruled, so the + * carrier's own grid is turned off. What this pins is that it 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.

+ * + * @author Artem Demchyshyn + */ +class DocxRowLayoutTest { + + @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, "= 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); + } + } +} From 56473a20b712597e160a34844ee21fe60aad8062 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 21:03:55 +0100 Subject: [PATCH 09/11] feat(render-docx): give a row's cells the slots the layout gives them A row's slots are arithmetic on the width it is offered for three of the four ways it can divide: weights, an even split and fixed columns are all shares of what is left after the gaps. Only an auto column and the flex path ask what a child's content naturally measures. Word was left to guess all four, and its autofit sizes columns to their text, so a short label beside a long one took a fraction of the width the layout gives it. The three that are arithmetic are now written as the grid. Word has no inter-column gap, so the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin: the text box is exactly the slot and each column starts exactly where its slot does. Cell margins are written even when zero, because Word's own default is not, and the table is marked fixed-layout, without which the grid is only a starting suggestion that Word re-fits to the content. This moves the columns onto the layout's and uncovers a drift that was partly cancelling it: Word starts the body 12.8pt lower than the reference and sets each body line 13.9pt against 9.7pt, so a row that was one line too short had been pulling the page back up. The line height is measured from the font, so closing it needs the resolved layout rather than arithmetic. Tests: seven over the exported package -- an even split, weights with a gap, fixed plus weight, padding in the outer columns, and the two cases that stay Word's. --- .../semantic/docx/DocxSemanticBackend.java | 170 +++++++++++++++++- .../semantic/docx/DocxRowLayoutTest.java | 142 ++++++++++++++- 2 files changed, 301 insertions(+), 11 deletions(-) diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index c49029d8f..012dfbac3 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -24,6 +24,7 @@ import com.demcha.compose.document.node.InlineTextRun; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.TextDirection; +import com.demcha.compose.document.node.RowArrangement; import com.demcha.compose.document.node.RowNode; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import com.demcha.compose.document.node.SectionNode; @@ -33,6 +34,7 @@ import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.DocumentBorders; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentRowColumn; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.table.DocumentTableCell; @@ -74,6 +76,7 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblBorders; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblGrid; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth; @@ -82,7 +85,9 @@ import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageSz; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSectPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblLayoutType; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder; import org.openxmlformats.schemas.wordprocessingml.x2006.main.STJc; @@ -1516,14 +1521,7 @@ private void writeRow(XWPFDocument document, RowNode node) throws Exception { // a header pair, a label beside a value — exported with visible rules the PDF // never draws. hideTableGrid(table); - // A row occupies the whole width it is offered — NodeDefinitionSupport.measureRow - // returns the available width unconditionally, whatever its children measure — so - // the table carrying it has to as well. Left at POI's size-to-content default the - // pair collapses around its text and both columns stop sitting where the PDF puts - // them. How the width divides between them is still Word's to decide here. - if (Double.isFinite(contentWidth) && contentWidth > 0) { - setTableWidth(table, contentWidth); - } + applyRowGeometry(table, node); XWPFTableRow row = table.getRow(0); // A row inside a panel is still inside it. Its paragraphs live in table cells and // so cannot carry the paint themselves; without shading the cells the band breaks @@ -1547,6 +1545,162 @@ private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) throws Ex writeCellBody(cell, child); } + /** + * Gives the carrier the row's width, and its cells the row's slots. + * + *

A row occupies the whole width it is offered — {@code measureRow} returns the + * available width unconditionally, whatever its children measure — so the carrier + * gets the content width. Left at POI's size-to-content default the pair collapses + * around its text instead.

+ * + *

How that width divides is arithmetic the document already carries, for every + * distribution except one. Weights, an even split and fixed columns are shares of the + * width left after the gaps; only an {@code auto} column and the flex path ask what a + * child's content naturally measures, which is the question this backend cannot + * answer. So the grid is written for the first three and left to Word for the + * others.

+ * + *

The gap and the row's padding are not columns, and Word has nowhere to put them: + * a table has no inter-column gap. They are folded into the neighbouring column's + * width and taken back out as that cell's margin, so each cell's text box is exactly + * its slot and starts exactly where the slot starts. The margins are written even + * when they are zero, because Word's own default is not.

+ * + *

The width used is the page's, not the row's parent's. A row inside a padded panel + * is offered less than the page in the fixed-layout render — but the panel's padding + * is not exported either, so in the file being written the row really does have the + * whole width. The slots match the document this backend produces rather than the one + * it was given.

+ */ + private void applyRowGeometry(XWPFTable table, RowNode node) { + if (!Double.isFinite(contentWidth) || contentWidth <= 0) { + return; + } + setTableWidth(table, contentWidth); + + double[] slots = resolveRowSlots(node, contentWidth); + if (slots == null) { + return; + } + + CTTblGrid grid = table.getCTTbl().getTblGrid() != null + ? table.getCTTbl().getTblGrid() + : table.getCTTbl().addNewTblGrid(); + while (grid.sizeOfGridColArray() > 0) { + grid.removeGridCol(0); + } + for (int index = 0; index < slots.length; index++) { + double leading = index == 0 ? node.padding().left() : 0.0; + double trailing = index == slots.length - 1 ? node.padding().right() : node.gap(); + double column = slots[index] + leading + trailing; + grid.addNewGridCol().setW(BigInteger.valueOf(Math.round(column * POINT_TO_TWIP))); + + CTTcPr properties = cellProperties(table.getRow(0).getCell(index)); + CTTblWidth cellWidth = properties.isSetTcW() ? properties.getTcW() : properties.addNewTcW(); + cellWidth.setType(STTblWidth.DXA); + cellWidth.setW(BigInteger.valueOf(Math.round(column * POINT_TO_TWIP))); + CTTcMar margins = properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar(); + setCellMargin(margins.isSetLeft() ? margins.getLeft() : margins.addNewLeft(), leading); + setCellMargin(margins.isSetRight() ? margins.getRight() : margins.addNewRight(), trailing); + } + + // Without this Word treats the grid as a starting suggestion and re-fits the + // columns to their content, which is the behaviour being replaced. + CTTblPr properties = table.getCTTbl().getTblPr(); + CTTblLayoutType layout = properties.isSetTblLayout() + ? properties.getTblLayout() + : properties.addNewTblLayout(); + layout.setType(STTblLayoutType.FIXED); + } + + /** + * The width of each of a row's slots, or {@code null} when one of them is content's. + * + *

Mirrors {@code NodeDefinitionSupport.measureRow}: the gaps and the row's padding + * come off the top, and what is left is split by columns, by weights, or evenly. The + * two branches that measure — a non-START arrangement or a grow spacer, and an + * {@code auto} column — return nothing instead.

+ * + * @param node the row being carried + * @param outerWidth the width the row is laid out in + * @return one width per child, or null when the split needs measuring + */ + private static double[] resolveRowSlots(RowNode node, double outerWidth) { + int count = node.children().size(); + if (count == 0) { + return null; + } + if (node.arrangement() != RowArrangement.START || hasGrowChild(node)) { + // The flex path gives every child without a grow factor its natural width. + return null; + } + double inner = Math.max(0.0, outerWidth - node.padding().horizontal()); + double slotsTotal = Math.max(0.0, inner - node.gap() * Math.max(0, count - 1)); + double[] slots = new double[count]; + + List columns = node.columns(); + if (!columns.isEmpty()) { + double used = 0.0; + double totalWeight = 0.0; + for (int index = 0; index < count; index++) { + DocumentRowColumn column = columns.get(index); + switch (column.type()) { + case FIXED -> { + slots[index] = column.value(); + used += slots[index]; + } + case WEIGHT -> totalWeight += column.value(); + case AUTO -> { + return null; + } + default -> { + return null; + } + } + } + double remaining = Math.max(0.0, slotsTotal - used); + if (totalWeight > 0.0) { + for (int index = 0; index < count; index++) { + if (columns.get(index).type() == DocumentRowColumn.Type.WEIGHT) { + slots[index] = remaining * (columns.get(index).value() / totalWeight); + } + } + } + return slots; + } + + List weights = node.weights(); + if (weights.isEmpty()) { + for (int index = 0; index < count; index++) { + slots[index] = slotsTotal / count; + } + return slots; + } + double total = 0.0; + for (Double weight : weights) { + total += weight; + } + for (int index = 0; index < count; index++) { + slots[index] = total > 0.0 ? slotsTotal * (weights.get(index) / total) : slotsTotal / count; + } + return slots; + } + + private static boolean hasGrowChild(RowNode node) { + for (DocumentNode child : node.children()) { + if (child instanceof SpacerNode spacer && spacer.grow() > 0.0) { + return true; + } + } + return false; + } + + /** States one cell margin in points, so Word's own default does not apply instead. */ + private static void setCellMargin(CTTblWidth margin, double points) { + margin.setType(STTblWidth.DXA); + margin.setW(BigInteger.valueOf(Math.round(Math.max(0.0, points) * POINT_TO_TWIP))); + } + /** * Gives a table the width the fixed-layout render gives it, in the cases where that * width can be known without measuring anything. 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 index a55aa8aab..0994f47a7 100644 --- 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 @@ -3,12 +3,18 @@ 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; @@ -16,17 +22,26 @@ /** * The one-row table a {@code row(...)} is carried as. * - *

A row is a layout device rather than something the author asked to see ruled, so the - * carrier's own grid is turned off. What this pins is that it is turned off once: + *

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 + * {@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 @@ -44,6 +59,127 @@ void theCarriersGridIsTurnedOffWithOneElementPerEdge() throws Exception { .doesNotContain("single"); } + @Test + void twoChildrenWithNothingStatedSplitTheWidthEvenly() throws Exception { + // measureRow gives each child slotsTotal / n when there are neither columns nor + // weights, whatever the children contain. Word's autofit instead sizes them to + // their text, which is how a short label beside a long one ended up with a third + // of the width the PDF gives it. + XWPFTable table = onlyTable(page -> 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)) { From 5abfd57ab65842e1cc6801105e9ce7d3daaf9246 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 21 Sep 2026 21:09:08 +0100 Subject: [PATCH 10/11] docs(render-docx): record what a table and a row width become The capability table said a table is a Word table and a row is a one-row table without saying how wide either comes out, which was the part that differed most from the reference render. Says what is written, what is left to Word and why, and what to state to pin it. --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ docs/recipes/docx-export.md | 13 +++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 019adc2db..533934dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,42 @@ follow semantic versioning; release dates are ISO 8601. the style along with `w:sz`, so Hebrew and Arabic still read a size rather than falling back to Word's default. A document with no text writes no styles part. +- **A DOCX table and row now state the width the layout gives them, where that width can + be known.** POI's `createTable` writes `w:tblW` as `w=0, type=auto` — Word's instruction + to shrink a table around its own content — and nothing overrode it, so every exported + table sized itself to its text and a row carried as a one-row table collapsed around a + label. A row takes the whole width it is offered whatever its children measure, so its + carrier gets the content width. A table's own width is written only where it needs no + measuring: the width the author stated, and the column grid when every column is fixed, + with a stated width's surplus going to the last column as the layout gives it. A table + with an `auto` column and no stated width keeps Word's sizing — the layout would give it + the sum of its natural column widths, and an `auto` column's natural width is its widest + unwrapped cell, which is a measurement this backend has no font runtime to make. Writing + the content width there would be right for a table whose text fills the line and wrong + for one holding three short values. +

+ A row's slots are arithmetic for three of the four ways it can divide: weights, an even + split and fixed columns are shares of what is left after the gaps, and those are now + written as the grid. Word has no inter-column gap, so the gap and the row's padding ride + in the neighbouring column's width and come back out as that cell's margin — the text + box is exactly the slot and each column starts exactly where its slot does. Cell margins + are written even when they are zero, because Word's own default is not, and the carrier + is marked fixed-layout, without which the grid is a starting suggestion Word re-fits to + the content. An `auto` column and the flex path — a non-START arrangement or a grow + spacer — ask what a child's content naturally measures, and stay Word's. +

+ A row carrier also wrote each of its six borders twice, because POI ships a full + single-line set and turning them off added to it rather than replacing it, leaving a + `w:tblBorders` that `CT_TblBorders` does not allow. Word read the last element and drew + nothing, so the render looked right while the part was invalid. +

+ Measured through Word 16.0 and LibreOffice Writer against the reference render, this + moves the columns onto the layout's and uncovers a larger drift that had been partly + cancelling it: Word starts the body 12.8pt lower than the reference and sets each body + line at 13.9pt against 9.7pt (LibreOffice: 12.1pt), so a row that came out one line too + short had been pulling the page back up. Line height is measured from the font, so + closing that needs resolved layout rather than arithmetic. + ### Performance - **A barcode is drawn as vector shapes, not as an image, in PDF and PPTX.** diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 0b2080f07..6c330fb78 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -43,9 +43,9 @@ PDF never pull POI. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell | +| Tables | Word tables, one cell per cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" | | Images | Embedded pictures at the node's declared size | -| Rows | A one-row table, so editors keep the side-by-side layout (cell content limited to atomic children) | +| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children) | | Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below | | Spacers | Empty paragraphs carrying the vertical gap as spacing-after | | Page breaks | Explicit Word page breaks | @@ -123,6 +123,15 @@ Not representable, and left undone rather than approximated: ## What falls back +- **An `auto` column's width → Word's own sizing.** A table with no stated width is as + wide as its columns naturally need, and an `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 such a table is left to Word's autofit rather than given a guessed width — writing + the content width instead would be right for a table whose text fills the line and + wrong for one holding three short values. A row divides the same way: an `auto` column, + a non-`START` arrangement or a grow spacer all ask what a child's content measures, so + those rows keep Word's split too. State a width, or fixed columns, to pin either. + - **Charts → data table.** The semantic export has no layout pass, so a chart's compiled vector geometry does not exist here. Its *semantic* content is its data, so the backend writes a categories-by-series table From 04cd520a0904abd7e7710db09ccfe15960aedb1b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 22 Sep 2026 10:07:49 +0100 Subject: [PATCH 11/11] chore(examples): re-render the committed Word preview again CommittedAssetDriftTest went red on word-export-companion.docx: three commits landed after the last re-render -- a table stating its width, a row carrier writing each border once, and a row's cells taking the slots the layout gives them -- and each of them changes the bytes of the document the example produces. The reactor gate does not cover this. The examples module is outside it and resolves the engine from the local repository, so a change to the export is invisible there until CI runs the examples job. Only the DOCX. The PDF beside it differs on every run through its time-seeded /ID, which the comparison drops, so committing it would be churn. Verification: ./mvnw -B -ntp test -f examples/pom.xml, 93 tests, BUILD SUCCESS, after installing core, render-pdf, render-docx, render-pptx and templates and regenerating through WordExportExample. --- .../examples/word-export-companion.docx | Bin 9063 -> 9137 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 2d340cb23c6c00161de6fa493b8c346ae0d458ec..409d1c01b26973a178b08eb23869f6b66de28db3 100644 GIT binary patch delta 2587 zcmZ8jcQhMn8&Axr5VN*4W{TGK*`!5l&)%v{t0`(WR4LIbN^OFwy;8FT_o|?_+M~5g z?IN^BtXj>7p7Y&vzxSN?JkRrc)_czT$M3hIQ+bQQM4yt14FCdx01YMzl?-Sg<(aLH z19DzC*b<6?a{wDiDTMAfT9;x9vv*$dH7`VHGvORfgB5JNg(Ee;N2~pn`^x><%k=#p z6`3hpf_}QoD%r-5?{d_ahoL!(y!~Jghiw2>KMd}V({JPJ1_r2@#N8~`C9b#g>)MPY z@8kS{WSSLeuTZK__lrk@VG4bXNr}5OD%!)hClsIi=9=@`w^^YBaPM7Xs=9W}Ct>3G zmfb>qCy&@};XdVmZVLBf;=ESb!FUauVTdGQoxyyk+?*nUg#3pEM~&J0qLdsw{Y%5y zQ^&c_$0l|+xwUQiMsbWqoD5os?m+1KrTZlRssJwN-P3!v-l51&R1aE2$65H4HD}_pPd@PJ40ZX^xTe*g zGxjqkPlDJ7{UKvqR5NqP4U)$pmE^YS5cg_QAzedB4Q`%CQ3c2mQWH~{I}v`pm@t32 z$v3>ScBArA!P}mXZt_%+t)b21j$$&Ui9RjJ;xO#c7sv_#gi-(i|N1bjB()&gTK~zE z0#oDDw`5?OzGThSt5pKx2{JKkxGZn;ac`_0f+f)eOGO)Er?fW$9U@BhNfNJXnnBDx z3;oPoJ_<#2`W&>WH&dUGMujeQN9cBFPosK}!r}M{4q)7|;mZ4|4VSOaMvs2gzh2b2 za^l$TWayw(#=ZDfc1YQ!ww#2fmB?E&5R!OVlNHZAx&=SpuPQ>ms&Q%r0h_z&)hvUi&$OB8TBm{4ZH3#kPH1yUE(q+h$e zdavs#Pd3(tQl_P`n3Kz9ByGRSi2Z=1=YR+gr%{G7|}ujirY4BM1R@%9|VcHd!FV zdJxnmEkITHW{2rbr3FD9e*@KATBLnz+t?-l3id6#Vhh(nG4%1=Gv|iyeD~>I6*Yu@ z3)vP|Wvrp&-j=GO=8tXVy?t!;s2)tBGoDRnoPhh|G`D)JT2{i=ZYYY{RNqd3VYE>c zu&y_HKmXPXOUT^iGT@-u$LM|OoHJ$-iNQle?|#!rUgtAktst5}Fqm)C!9Gb)+t{hf zDBWcrD0~@f>(V)b`saoEs$hWk{ORtG?r0_sRHU-=ijL8|4>@X-2Q^ z=@hdr0%C4a&DT=tFN)jb?ziryB<{4vZIwt{u}|;Gy}$zlu1N?Eop8$m=46cpx+hva z-^5QEMHrf@wR5&NVO*dY_frRnO^o<1CtDD(=h`tGdVurm6c7S&2QaRl!m z0=^8*H2Qld09VGN!bHZ=;^Hknv9{~bJ6^JSqdm;H zet7aYItL}!#rUT2K%_o0V9DE>EPpobvGbsqJ-A@1}ISl96I7b5=&=-?NWPq32;D2bVgo}T*V2+>E;sr0UhHZbur!THXSuUc- zE-in*6MW*x5NV&yQy={(`^DpgaGv|hUn4-NP%}FDNVHf_$bicJ?lc=4s~78X*UjeS zMtN&i%erp;ik;8wDSb=|(oDy!NqKf{G2RQt=|%*Ss>WRWTnpY!*WUruE=S?47x#d< zpeqTo9g#1g7^2V@EPOD$XoZT~v0p|5CV7h)N0Y$9v!NEPL260hB1Cb*(qjbC!F78z zji}9)x?0;WJLi6rZ2H$`Z97@-b*XUoW*HJvQa$`VWpEo>k7VG(oCa%8N?}lNJZ-LPW4Xu0 zHTG`5aqq(vVU(O`CizZz|I1Sqe@~Zj+Z5Xa9>$)A&9u-Nd7eNMJD+jz*K$HhXq7_! zvZUW>BH6FWMS4#9S|RaIj)s-uh_kHe{*cu~p*xoEYFH&COec7?dTC8ZKP%ZBoGEsm~xs+DSH44p%E`5cPr?)ea<^JyT zodpsjL+CH_2=Z!j3l8jet&e{~T`6_CwAMs^KxsyJZYqOt0erLi;M&8v=42@Xdzt9t zZvSR?zBeWnFp9eF`c|uYVr#&B$@o_h~u0+kKgf`GSUJ7 zax(u%p4BqotH0%r9fJVVh3*OEbN;P+B&jcBL4sf`12p;~kpaE&J28Jn{X!54z!Wa5 zHB1F`CXj5;IU1V<`w!!J@Xt!@B241nUEiqtz$R82SRy8dbR*nyU;LNz=A6&>IWNxhoEP8k^RtU){1*riP7x3k3I%;l(R|Ji za6o?c8Zw9Y!9Xbm_x%zmQ4lQf=Q*aTV8SKuxf`}8luEN?`4hoWPPKRu(be8@a2FNM6_bf)-9rU-OOh5@nKuo5^K%g} zRZVg7gc@QS^{wy@#3#T6Ae4#PBlg>R)j=mHCsVu*EEBR`R$|q{!kP@QMe=ufQft;fEf<#iYzam_-0iW7^Q#g2y zPt#K4JA2W9EUOtq^ox!kzSqFfYo@0|az_(KYHIxaFRIfTMTt$6bpsjDQwgBbJuEYFN|BC- z$Ln8Fu2X(Xhg}|<8I*`ICm;=rCfBt>G#|544wR#(2)z1R`L`~ zitVSiE%u0B2_Nz)RJL5nke}?DJCC)^nn+}pvF*QaGc-E=Pia6NyPu3O(C<0l*Y(v= z5GikXhCY0!Jj4h%-V0)K`mM#hx2NSdjb1?FpE#v;7ooPZ2o3$}d#=nO-f^{1ixr#& zY8>Q?L92F&k|Zt(AF|T62$E~Ow#n7fN4=|1*K2;3IL02@YcnRlls{>Vzs z$-7%Ex11<>hpi%R-E1Js`7wDxI;u7LQ(cqs9{S;)UIf6BsP~^ll+Ps(mmb2EN1OlZl2ABzT${8xVu=}`E8{1=8Y-d zw5rB-?P-HNdh=SH4(^Vlv!IK3PVXh+iRe}s@Oxn_$w&6iL{)Wtaq_CeT#F$;r{cYp z!zflcU&)5EDhI4hXV8>*t8TW35)$k^l3yc~)IQ_DIa(hUrlQ$UN3S@YwpXkrK3CEmT;-SkIiSD%9Q`3R=&@s6Y)rhXZ7urS;>Hv z^@CWUXB<}BD`%CQ+$Ct%#)w2Q=^udZmoH{6;Up1htpK=vUBbxP@J+|C+(9!nIIJo; zY*c{+DWW0Q5Y`_lS*h*j=Q~IUy3|8Q1Q^ll>J?J{$l%8K=X|2lG;X=*x7Jntk-jLk zcPxtlV!`xN8+u@G_`Bu#b79Rupn~(e(eUunkboU}>uUBF;2Tm9E~W05t4xxPl)8t_ z&0vWj1ey!*x;9l*;7Fywe%6^hHr3;NcAl6R`ryRmaACNKz3H~5OAz)Ak7>Ge~kSWO+ LH22W4U)TQwn80Te