From 45a0cd1d6713770efa6397733e37aa52920c6a3c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 10 Jun 2026 10:42:10 +0100 Subject: [PATCH] fix(docx): lists no longer silently dropped from the semantic export DocxSemanticBackend.writeNode had no ListNode branch, so addList(...) content vanished from Word exports without a warning. Lists now map to marker-prefixed paragraphs in the list's text style; nested items indent two spaces per depth and keep their own markers. Surfaced by the senior review's hostile fact-check of the docx-export recipe - its 'what maps' table could not honestly be completed without the fix. Recipe table and CHANGELOG updated; regression test added. --- CHANGELOG.md | 9 +++++ docs/recipes/docx-export.md | 1 + .../backend/semantic/DocxSemanticBackend.java | 37 +++++++++++++++++++ .../semantic/DocxSemanticBackendTest.java | 21 +++++++++++ 4 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 651c31ea..eec50539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,15 @@ Entries land here as they merge. of orphaning its heading from the content below. Blocks taller than a page still flow. Default off — existing layouts are byte-identical. +### Bug fixes + +- **DOCX export no longer drops lists.** `DocxSemanticBackend` had no branch + for `ListNode`, so `addList(...)` content silently vanished from Word + exports. Lists now map to marker-prefixed paragraphs in the list's text + style, with nested items indented per depth and keeping their own markers. + (Found by the recipe fact-check: the docx-export recipe's "what is skipped" + list could not honestly be written without it.) + ### Documentation - **Recipe coverage is complete.** Nine new cookbook pages close every gap the diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 598dfc45..701bfd2a 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -42,6 +42,7 @@ render PDF carry no POI footprint. | 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 | | 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) | diff --git a/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java b/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java index f6ba7e65..e459c384 100644 --- a/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackend.java @@ -145,6 +145,8 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio writeShapeContainer(document, shapeContainer); } else if (node instanceof ChartNode chart) { writeChartFallback(document, chart); + } else if (node instanceof com.demcha.compose.document.node.ListNode list) { + writeList(document, list); } else if (node instanceof ContainerNode || node instanceof SectionNode) { for (DocumentNode child : node.children()) { writeNode(document, child); @@ -155,6 +157,41 @@ private void writeNode(XWPFDocument document, DocumentNode node) throws Exceptio // should use the PDF fixed-layout backend. } + /** + * Semantic list mapping: each item becomes a marker-prefixed paragraph in + * the list's text style; nested items indent two spaces per depth and use + * their own marker when one is set. + */ + private void writeList(XWPFDocument document, + com.demcha.compose.document.node.ListNode list) { + for (String item : list.items()) { + writeListLine(document, list.textStyle(), + list.marker().value() + " " + item, 0); + } + for (com.demcha.compose.document.node.ListItem item : list.nestedItems()) { + writeNestedItem(document, list, item, 0); + } + } + + private void writeNestedItem(XWPFDocument document, + com.demcha.compose.document.node.ListNode list, + com.demcha.compose.document.node.ListItem item, + int depth) { + String marker = item.marker() != null ? item.marker().value() : list.marker().value(); + writeListLine(document, list.textStyle(), marker + " " + item.label(), depth); + for (com.demcha.compose.document.node.ListItem child : item.children()) { + writeNestedItem(document, list, child, depth + 1); + } + } + + private void writeListLine(XWPFDocument document, DocumentTextStyle style, + String text, int depth) { + XWPFParagraph para = document.createParagraph(); + XWPFRun run = para.createRun(); + applyStyle(run, style); + run.setText(" ".repeat(depth) + text); + } + /** * Semantic chart fallback: the semantic export has no layout pass, so the * chart's compiled vector geometry is unavailable here. The chart's diff --git a/src/test/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackendTest.java b/src/test/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackendTest.java index eb036ef8..83d8c562 100644 --- a/src/test/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackendTest.java +++ b/src/test/java/com/demcha/compose/document/backend/semantic/DocxSemanticBackendTest.java @@ -68,6 +68,27 @@ void chartExportsAsItsDataTable() throws Exception { } } + @Test + void listsExportAsMarkerPrefixedParagraphs() throws Exception { + byte[] docxBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(595, 842) + .margin(DocumentInsets.of(36)) + .create()) { + session.dsl().pageFlow().name("Flow") + .addList("First", "Second") + .build(); + docxBytes = session.export(new DocxSemanticBackend()); + } + + 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")); + } + } + @Test void exportProducesDocxWithParagraphAndTableContent() throws Exception { byte[] docxBytes;