From 8260340837ee02c91e8f145b4fb7af05b99f7d62 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 18 Oct 2026 19:01:53 +0100 Subject: [PATCH 1/2] fix(docx): write a chart in a table cell as its data table, and draw nothing of it over that table A chart composed in a table cell was written as its data table, as a chart is anywhere, and drawn as well: its fragments are among the table's own, which the cell drawings draw. Measured in Word and LibreOffice on Windows, its bars and grid lines stood over the data table and hid its numbers ("Sales" read "Sa", 2.5 read "2."), without its axis labels, while the report said it was exported as its data table. Where the chart is all its cell draws, alone or with text, lists, spacers and wrappers that paint nothing beside it, every drawing the layout places in the cell's box is the chart's, and none is drawn: the chart is its data table there too. A chart beside other drawings in its cell, whose drawings the export cannot tell from its own, or in a table composed in a cell, whose cells the layout gives no box, is still drawn, and its note now says so. The export's log line, the chart's Javadoc, the charts recipe and the Word export example said the export has no layout pass, or draws no vector geometry; what it does not write is a Word chart. The example's committed DOCX and PDF are re-rendered for its caption. --- CHANGELOG.md | 11 + .../examples/word-export-companion.docx | Bin 10244 -> 10243 bytes .../readme/examples/word-export-companion.pdf | Bin 12969 -> 12964 bytes .../layout/definitions/ChartDefinition.java | 2 +- .../compose/document/node/ChartNode.java | 5 +- docs/recipes/charts.md | 6 +- docs/recipes/docx-export.md | 12 +- .../features/docx/WordExportExample.java | 5 +- .../semantic/docx/DocxSemanticBackend.java | 100 ++++++++- .../semantic/docx/DocxChartInACellTest.java | 207 ++++++++++++++++++ 10 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxChartInACellTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index edfb181ae..289fed0c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export writes a chart in a table cell as its data table, and draws nothing of it over + that table.** Composed in a cell, a chart was written as its data table and drawn as well: its + bars and grid lines stood over the table in Word and LibreOffice on Windows and hid its numbers + — "Sales" read "Sa" — without its axis labels, while the report said only that it was exported + as its data table. Where the chart is all its cell draws — alone, or with text, lists and + wrappers that paint nothing beside it — nothing of its drawing is drawn now. One beside other + drawings in its cell, which the export cannot tell its own from, or in a table composed in a + cell, is still drawn, and its note now says so. The export's log line, the chart's Javadoc, the + charts recipe and the Word export example no longer say the export has no layout pass or draws + no vector geometry: it writes no Word chart. The example's committed DOCX and PDF are + re-rendered for its caption. - **A DOCX export names a table whose columns it leaves to the editor.** A table with an auto column whose widths the layout does not show — one composed in another table's cell, which the layout lays out among that table's own rows, or one with a column edge no row of it shows — diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx index 56723b6e03b169a472e256f0e8cde63ebabd58db..146b309d62321e2fb7cb2e6442311dcaa07407d2 100644 GIT binary patch delta 3516 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 delta 3513 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 Y9Tq1kbjIGEG7xrBQiKL2{?D@i0C%N=YybcN diff --git a/assets/readme/examples/word-export-companion.pdf b/assets/readme/examples/word-export-companion.pdf index 8b8ce609ded0df683d8ee66504a7075333bc19b1..7d48382a8e29a5315afe1e9bd31176d2a1c4260d 100644 GIT binary patch delta 1135 zcmZ3Px+HajIvb%Y~Stk5lXWWO&xMdim7M_%P93F%82OO5Alyy?88-mZ3;)w)v=<|hSgk1mS69-`8> zf7jIYuXio^@whN{`rcp!DH9c2KNR>VKFM!4J!IU>uKYG{Upse*GLJXY zH=mH7a%e{JQL4)79l2+%M0iTz!8at2g0Pr6Sw4yMHEJ%{n6e`jt=EZ@x8t z7SHB58or1u|Lj~JrY?}b-S>O==3l{U{#M_qn6K4x*@Gj*Xt8tE*|wjO{zmpQ_CptX?q+MCjeqThl+AyH{&TuanKWH);Ehd^OtkwQZK!eUp{f zcf_n0lb)5oqQ|#%zty&tO^4p9*SpVNpPzB+)k-IG=bx+IL`|-YyZ1OhX3hL|xAs+` z-&rhb+bCT0}52hYI=k`!6Br9Rx3!Z-oDN>=`+U;tON=+$;yam^{l21*8d$mJi27>PP*>VRHQD(D`O3 zm{eJW*>1P_Ocjn~cC9I|tlVqeVSdwp?cR4~yj+{FaF(kxnoRaFl&?2*G;*?ZGB$HF zbu%$FH8eLivNSR@cQ!F_H8D1Fbv7|Ka5T3xws3Q`a5A(sbTlwAGjVaVbaAsVHv?i< zS33n8N{nCunKAjDVW`bP-UbB`mWz=ABBq8L1(jMa{J-i|vcJ82CSzRTq#aEl++O+&LU_+ zV~SMFHC`(blIXKY>ZV%OC%P(C=3MtT?Dy~c{Pt~#ZHjHRQU4yoaB?3xzn7xy{|leJV98F+qQb_Mz9kSx;T81Q_Iy1Sx0K=>%Uy3 zw5W0UxHMXhD^L69cEz5=GqAmKmAWOYJMVdy z1#l8iycpK^5Y}#Q8*O$SIQSQTw||F=w&MYgEmVSuS=@CUsiL$`xWyp=on_cqq7bc^yU zDT|05sLrvUzb}tB>MgwWs{4so%%$=fT?@JJEVOa1DVNLRZsn!lD*Ab1lk!#ma=pA21i!lQ zhWk)HHd!!`{5g|&cP8{&=*a}b#T#+P8xRF;0^&gGxg%#=zH5&hJ?|P3WIDZaFM}MS z_D~i`O(g%(OSj_=X-DWRiQAI`*cllGpWx4H6uNq0;sZU3r{1jJ<@e$idxi99NB^^I zrsaVv{co7%sV)wCC3@b{;uch!QyUz=Oq*LcbDBmyQ&w?a{D}22IG=Ve+bsYa#lHV?Aro_zugOdnZo z0SrD7vQ#ioamx;2|Cq%t-7))xw~_71G9aEm8j2`_;08Ib#mZTq-r)na=Q>A}p^P;r zL*AlRtj)JGeO*pakzgPU=>tcCA>L>x#s`B&fIu*qk2eSjgu&5ZCBecause the output is ordinary geometry + text, every existing snapshot and * visual-parity mechanism covers charts unchanged, any fixed-layout backend * renders them with no chart-specific code, and determinism is structural, not - * promised. (Semantic exports bypass layout entirely — the DOCX backend falls + * promised. (The DOCX export writes no Word chart of this geometry and falls * back to the chart's data table.)

* * @author Artem Demchyshyn diff --git a/core/src/main/java/com/demcha/compose/document/node/ChartNode.java b/core/src/main/java/com/demcha/compose/document/node/ChartNode.java index 6e03b880d..175e914ce 100644 --- a/core/src/main/java/com/demcha/compose/document/node/ChartNode.java +++ b/core/src/main/java/com/demcha/compose/document/node/ChartNode.java @@ -15,9 +15,8 @@ * primitive nodes. No fixed-layout render handler ever sees a chart — that is * what keeps charts deterministic and snapshot-testable for free, and what * gives every fixed-layout backend (PDF today, others later) vector charts - * with no chart-specific code. Semantic exports have no layout pass, so they - * fall back to the chart's data: the DOCX backend writes a - * categories-by-series table. + * with no chart-specific code. The DOCX export writes no Word chart of that + * geometry and falls back to the chart's data: a categories-by-series table. * *

A chart is {@link com.demcha.compose.document.layout.PaginationPolicy#ATOMIC}: * it places whole on a page or moves to the next, exactly like an inline image diff --git a/docs/recipes/charts.md b/docs/recipes/charts.md index bd37e28cb..384975f9e 100644 --- a/docs/recipes/charts.md +++ b/docs/recipes/charts.md @@ -376,8 +376,10 @@ The run's minimum maps to the bottom of the box and its maximum to the top; Any fixed-layout backend (PDF today) renders charts for free — they are ordinary primitives by the time rendering starts. The semantic DOCX export -does not draw vector geometry, so it writes the chart's **data table** -(categories × series) with a one-time capability warning. +writes no Word chart, so it writes the chart's **data table** +(categories × series) with a one-time capability warning — in a table cell +too, where a chart drawing alone in its cell is not drawn over that table +(see the [DOCX export recipe](docx-export.md)). Unsupported combinations fail fast with `UnsupportedOperationException` rather than rendering silently wrong; in the current release that is only diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 64bdb883f..2d24ad852 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -788,12 +788,18 @@ recolour what is under it in Word and it keeps the colour it was flattened to. where the layout puts it, has no place and is dropped, each with a note of its own saying so; a rule is written as one still. -- **Charts → data table.** A chart compiles to vector geometry, which this - export does not draw. Its *semantic* +- **Charts → data table.** A chart compiles to vector geometry, of which this + export writes no Word chart. Its *semantic* content is its data, so the backend writes a categories-by-series table (values formatted with the chart's own axis format) and logs **one capability warning per export**. The chart's margin and padding above, below - and on its left are not written round the table; its report note says so. See [charts.md](charts.md). + and on its left are not written round the table; its report note says so. A chart that is + all a table cell draws — alone, or with text, lists and wrappers that paint nothing beside + it — is its data table there too, and nothing of its drawing is drawn: drawn, its bars stood + over the table and hid its numbers, without its labels (measured in Word and LibreOffice on + Windows). One beside other drawings in its cell, whose drawings the export cannot tell its + own from, or in a table composed in a cell, is drawn as well, over its table, and its note + says so. See [charts.md](charts.md). - **A canvas → its contents.** A canvas's drawings stand where it places them; what it writes — text, pictures, tables — is written one block after another inside its margin and padding. The places it gives them and the width its text wraps at are not carried, and the diff --git a/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java b/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java index f97bfe870..c3eabd1fd 100644 --- a/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java +++ b/examples/src/main/java/com/demcha/examples/features/docx/WordExportExample.java @@ -167,8 +167,9 @@ public static Path generate() throws Exception { .addPageBreak(pb -> pb.name("ToPage2")) .addParagraph("Charts fall back to their data", heading) - .addParagraph("The PDF renders the vector chart; Word has no layout pass, " - + "so the same node exports as a categories-by-series table.", caption) + .addParagraph("The PDF renders the vector chart; the Word export writes no " + + "chart, so the same node exports as a categories-by-series " + + "table.", caption) .addSection("Chart", s -> s.chart(ChartSpec.bar().data(quarters).build())) .addParagraph("Geometry stays PDF-only by design", heading) 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 b415a3f66..944cb6fe8 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 @@ -226,6 +226,10 @@ public final class DocxSemanticBackend implements SemanticBackend { private final AtomicBoolean chartWarned = new AtomicBoolean(false); // Whether the table being written is a chart's data table, which the page does not draw. private boolean writingAChartsTable; + // The charts a table's cell is built from, whose drawings in that cell are not drawn: the + // chart is its data table there, as anywhere else (drawCellDrawing). + private final java.util.Set chartsNotDrawn = + java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // Geometry-only node kinds already warned about this export pass. private final java.util.Set warnedNodeKinds = java.util.concurrent.ConcurrentHashMap.newKeySet(); @@ -1071,6 +1075,7 @@ private byte[] write(List sections, Path outputFile) throws Exc zonePartsReported.clear(); zoneLinesReported.clear(); separatorsReported.clear(); + chartsNotDrawn.clear(); textFillsWritten = false; closingBlocks.clear(); keepsUnheld.clear(); @@ -6312,25 +6317,34 @@ private PictureReach writeInlineTextRuns(XWPFParagraph para, DocumentTextStyle s } /** - * Semantic chart fallback: the semantic export has no layout pass, so the - * chart's compiled vector geometry is unavailable here. The chart's - * semantic content is its data, so the fallback writes a - * categories-by-series table (values formatted with the chart's own axis - * format). Authors who need the rendered chart must use the PDF - * fixed-layout backend, where charts compile into ordinary primitives. + * Semantic chart fallback: the chart compiles into the layout's geometry — bars, lines and + * labels placed on the page — and this export writes no Word chart (DrawingML's + * {@code c:chart}) of it. The chart's semantic content is its data, so the fallback + * writes a categories-by-series table (values formatted with the chart's own axis format). + * Authors who need the rendered chart must use the PDF fixed-layout backend, where charts + * compile into ordinary primitives. */ private void writeChartFallback(XWPFDocument document, ChartNode node) throws Exception { // The table is written as it is, set from the left with nothing owed round it for the // chart's own insets, but where the page measures the space below it (insetSidesLost). List sides = insetSidesLost(node, true); + // A chart a cell's drawing is all of is not drawn there (drawCellDrawing); one the export + // does not find so — beside other drawings in its cell, which it cannot tell its own + // from, or in a table composed in a cell — is drawn among the table's drawings. + boolean drawnToo = composedInACell(node) && !chartsNotDrawn.contains(node); report.add(DocxExportReport.Severity.APPROXIMATED, "chart", layout.pathOf(node), "exported as its data table — a categories-by-series table in the chart's own " + "value format — because the drawn chart is layout geometry" + + (drawnToo ? "; composed in a table cell where the export does not find it drawing alone — " + + "beside other drawings, or in a table composed in a cell — its bars, lines and " + + "slices are drawn as well, where the page draws them, over the table and " + + "without its text" + : "") + (sides.isEmpty() ? "" : "; its margin and padding " + listed(sides) + " are not written round the table")); if (chartWarned.compareAndSet(false, true)) { - LOG.warn("docx.export.chart-fallback kind={} — the semantic DOCX export has no " - + "layout pass, so charts are exported as their data table. " + LOG.warn("docx.export.chart-fallback kind={} — a chart is exported as its data table: the " + + "export writes no Word chart. " + "(One warning per export; use the PDF backend for the rendered chart.)", node.spec().getClass().getSimpleName()); } @@ -13575,7 +13589,11 @@ private CellDrawing drawCellDrawing(TableNode table) { boolean drew = false; List skipped = new ArrayList<>(); List pending = new ArrayList<>(); + List charts = cellsBuiltFromAChart(table); for (com.demcha.compose.document.layout.PlacedFragment fragment : fragments) { + if (charts.stream().anyMatch(cell -> cell.holds(fragment))) { + continue; + } boolean frames = framesText(fragment, content); if (frames && fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload) { skipped.add(fragment); @@ -13603,6 +13621,72 @@ private CellDrawing drawCellDrawing(TableNode table) { return new CellDrawing(drew, skipped, strayed, pending); } + /** + * The boxes of a table's cells whose drawing is a chart's alone, which are not drawn: the chart + * is written there as its data table, as it is anywhere else. + * + *

Drawn too, its bars and grid lines stood over the table in both editors and hid its + * numbers — "Sales" read "Sa", 2.5 read "2." — and its axis labels, the page's text, were not + * drawn at all (measured in Word and LibreOffice on Windows). A cell's drawing is a chart's + * alone where the chart is the only thing in the cell that draws ({@link #chartDrawnAlone}): + * then every drawing the layout places in the cell's box is the chart's. One beside other + * drawings, which the export cannot tell its own from, or in a table composed in a cell, whose + * cells the layout gives no box, is drawn, and its note says so ({@link #writeChartFallback}). + * A drawing another cell pushes wholly into a chart's box, by a layer's offset or a margin + * below zero, is taken for the chart's.

+ */ + private List cellsBuiltFromAChart(TableNode table) { + List boxes = new ArrayList<>(); + for (List row : TableGrid.resolve(table)) { + for (TableGrid.Placement placement : row) { + ChartNode chart = placement.cell() == null ? null : chartDrawnAlone(placement.cell().content()); + if (chart != null) { + List cell = layout.cellBoxes(table, placement.row(), placement.column()); + if (!cell.isEmpty()) { + boxes.addAll(cell); + chartsNotDrawn.add(chart); + } + } + } + } + return boxes; + } + + /** The kinds that draw nothing in a cell beside a chart: text, and space between it. */ + private static final java.util.Set> DRAWING_NOTHING = java.util.Set.of( + ParagraphNode.class, com.demcha.compose.document.node.ListNode.class, SpacerNode.class, + PageBreakNode.class, com.demcha.compose.document.node.AlignNode.class); + + /** + * The chart a cell's content draws alone, or {@code null}: one chart, and beside it nothing + * that draws — text, a list, a spacer, a wrapper that paints nothing — so every drawing the + * layout places in the cell is the chart's. + * + * @param content what the cell is built from, or {@code null} + */ + private static ChartNode chartDrawnAlone(DocumentNode content) { + ChartNode[] chart = new ChartNode[1]; + return content != null && drawsAChartAlone(content, chart) ? chart[0] : null; + } + + private static boolean drawsAChartAlone(DocumentNode node, ChartNode[] chart) { + if (node instanceof ChartNode found) { + boolean first = chart[0] == null; + chart[0] = found; + return first; + } + boolean wrapper = (node instanceof SectionNode || node instanceof ContainerNode) && paintOf(node).isEmpty(); + if (!wrapper && !DRAWING_NOTHING.contains(node.getClass())) { + return false; + } + for (DocumentNode child : DocxWrittenTree.under(node)) { + if (!drawsAChartAlone(child, chart)) { + return false; + } + } + return true; + } + /** * A drawing a table's composed cell paints, waiting to be anchored. * diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxChartInACellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxChartInACellTest.java new file mode 100644 index 000000000..1a0a8a27f --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxChartInACellTest.java @@ -0,0 +1,207 @@ +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.chart.ChartData; +import com.demcha.compose.document.chart.ChartSpec; +import com.demcha.compose.document.dsl.EllipseBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.node.ChartNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A chart that is all a table cell draws is written there as its data table, as anywhere else, and + * none of its drawing is drawn over that table; one beside other drawings in its cell, or in a + * table composed in a cell, is drawn among the cell's drawings, and its note says so. + */ +class DocxChartInACellTest { + + private static final ChartSpec BARS = ChartSpec.bar().data(ChartData.builder().categories("Q1", "Q2", "Q3") + .series("Sales", 3.0, 5.0, 4.0).series("Costs", 2.0, 2.5, 3.5).build()).build(); + private static final String TABLE = "exported as its data table — a categories-by-series table in the chart's " + + "own value format — because the drawn chart is layout geometry"; + private static final String DRAWN_TOO = TABLE + "; composed in a table cell where the export does not find it " + + "drawing alone — beside other drawings, or in a table composed in a " + + "cell — its bars, lines and slices are drawn as well, where the page " + + "draws them, over the table and without its text"; + + @Test + void aChartACellIsBuiltFromIsItsDataTableThereAndNothingIsDrawn() throws Exception { + Consumer content = beside(new ChartNode(BARS)); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, content)) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(1); + assertThat(cell.getTables()).as("its data table").hasSize(1); + assertThat(cell.getTables().get(0).getRow(0).getCell(1).getText()).isEqualTo("Sales"); + assertThat(drawings(document)).as("no bar or grid line drawn over it").isZero(); + } + DocxExportReport report = laidOut(content); + assertThat(details(report, "chart")).containsExactly(TABLE); + assertThat(report.bySubject()).as("nothing drawn to name").doesNotContainKey("cell drawing"); + } + + @Test + void aChartWithOnlyTextBesideItOrInAWrapperThatPaintsNothingIsNotDrawn() throws Exception { + for (DocumentNode built : List.of( + new SectionBuilder().add(new ChartNode(BARS)).build(), + new SectionBuilder().addParagraph("By quarter").add(new ChartNode(BARS)).build())) { + Consumer content = beside(built); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, content)) { + assertThat(drawings(document)).as("%s", built).isZero(); + } + assertThat(details(laidOut(content), "chart")).containsExactly(TABLE); + } + } + + @Test + void aChartBesideAnotherDrawingInItsCellIsDrawnTooAndItsNoteSaysSo() throws Exception { + Consumer content = beside(new SectionBuilder() + .add(new EllipseBuilder().circle(12).fillColor(DocumentColor.BLACK).build()) + .add(new ChartNode(BARS)).build()); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, content)) { + assertThat(drawings(document)).as("the disc, and its bars and grid lines").isGreaterThan(1); + } + assertThat(details(laidOut(content), "chart")).containsExactly(DRAWN_TOO); + } + + @Test + void aCellsOwnDrawingBesideAChartsCellIsStillDrawnThoughItReachesIntoIt() throws Exception { + // A tile in its own cell, and one its margin below zero widens from 20pt to 52pt in a 30pt + // column, reaching into the chart's cell, which does not hold it. + for (double right : new double[]{0, -30}) { + Consumer content = page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(30), DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.node(new com.demcha.compose.document.dsl.ShapeBuilder().size(20, 20) + .fillColor(DocumentColor.BLACK).margin(new DocumentInsets(0, right, 0, 0)).build()), + DocumentTableCell.node(new ChartNode(BARS)))); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, content)) { + assertThat(drawings(document)).as("the tile with a right margin of %spt, and nothing of the chart", + right).isEqualTo(1); + } + assertThat(details(laidOut(content), "chart")).containsExactly(TABLE); + } + } + + @Test + void twoChartsInACellOrAChartInAPaintedWrapperAreDrawnAndNamed() throws Exception { + for (DocumentNode built : List.of( + new SectionBuilder().add(new ChartNode(BARS)).add(new ChartNode(BARS)).build(), + new SectionBuilder().fillColor(DocumentColor.rgb(240, 240, 240)).add(new ChartNode(BARS)).build())) { + Consumer content = beside(built); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, content)) { + assertThat(drawings(document)).as("%s", built).isPositive(); + } + assertThat(details(laidOut(content), "chart")).as("%s", built).isNotEmpty().allMatch(DRAWN_TOO::equals); + } + } + + @Test + void aChartInARepeatedHeaderOrASpanningCellIsNotDrawnOnAnyPage() throws Exception { + Consumer header = page -> page.addTable(t -> { + t.columns(DocumentTableColumn.fixed(300)).repeatHeader(1) + .rowCells(DocumentTableCell.node(new ChartNode(BARS))); + for (int row = 0; row < 40; row++) { + t.row("Row " + row); + } + }); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, header)) { + assertThat(document.getTables().get(0).getRows()).as("a table over pages").hasSizeGreaterThan(20); + assertThat(drawings(document)).as("nothing of the chart on any page it repeats on").isZero(); + } + assertThat(laidOut(header).bySubject()).doesNotContainKey("cell drawing"); + Consumer spanning = page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(150), DocumentTableColumn.fixed(150)) + .rowCells(DocumentTableCell.node(new ChartNode(BARS)).colSpan(2)) + .row("Left", "Right")); + try (XWPFDocument document = DocxExports.withLayout(500, 400, 30, spanning)) { + assertThat(drawings(document)).as("a chart across two columns").isZero(); + } + assertThat(details(laidOut(spanning), "chart")).containsExactly(TABLE); + } + + @Test + void aChartInATableComposedInACellIsDrawnTooAndItsNoteSaysSo() throws Exception { + assertThat(details(laidOut(page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(420)) + .rowCells(DocumentTableCell.node(new TableBuilder() + .columns(DocumentTableColumn.fixed(400)) + .rowCells(DocumentTableCell.node(new ChartNode(BARS))).build())))), "chart")) + .containsExactly(DRAWN_TOO); + } + + @Test + void aChartInTheFlowOrWithNoLayoutIsItsDataTableAlone() throws Exception { + assertThat(details(laidOut(page -> page.add(new ChartNode(BARS))), "chart")).containsExactly(TABLE); + DocxExportReport withoutLayout = DocxExports.reportWithoutLayout(500, 400, 30, beside(new SectionBuilder() + .add(new EllipseBuilder().circle(12).fillColor(DocumentColor.BLACK).build()) + .add(new ChartNode(BARS)).build())); + assertThat(details(withoutLayout, "chart")).as("nothing is drawn without a layout").containsExactly(TABLE); + } + + @Test + void aChartNotDrawnInOneExportIsNotTakenForOneInTheNext() throws Exception { + // One backend, two exports of one chart node: alone in a cell, then beside a disc. + ChartNode chart = new ChartNode(BARS); + AtomicReference report = new AtomicReference<>(); + DocxSemanticBackend backend = new DocxSemanticBackend(report::set); + export(backend, beside(chart)); + assertThat(details(report.get(), "chart")).containsExactly(TABLE); + export(backend, beside(new SectionBuilder() + .add(new EllipseBuilder().circle(12).fillColor(DocumentColor.BLACK).build()).add(chart).build())); + assertThat(details(report.get(), "chart")).containsExactly(DRAWN_TOO); + } + + /** A table whose second cell is built from {@code content}, beside a cell of text. */ + private static Consumer beside(DocumentNode content) { + return page -> page.addTable(t -> t + .columns(DocumentTableColumn.fixed(120), DocumentTableColumn.fixed(300)) + .rowCells(DocumentTableCell.text("Quarterly"), DocumentTableCell.node(content))); + } + + private static void export(DocxSemanticBackend backend, Consumer content) throws Exception { + try (DocumentSession session = GraphCompose.document().pageSize(500, 400).margin(DocumentInsets.of(30)) + .create()) { + session.pageFlow(content::accept); + session.export(backend); + } + } + + /** The report of an export the layout stands behind. */ + private static DocxExportReport laidOut(Consumer content) throws Exception { + DocxExportReport report = DocxExports.report(500, 400, 30, content); + assertThat(report.bySubject()).as("laid out").doesNotContainKey("measured geometry"); + return report; + } + + private static List details(DocxExportReport report, String subject) { + return report.bySubject().getOrDefault(subject, List.of()).stream().map(DocxExportReport.Note::detail).toList(); + } + + /** The drawings in the document's body, its tables' cells included, floating or in a line. */ + private static int drawings(XWPFDocument document) { + String xml = document.getDocument().xmlText(); + return count(xml, "= 0; at = text.indexOf(of, at + 1)) { + count++; + } + return count; + } +} From fae4d1660fdd46be5cfe179dc53c25ea595a73e5 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 18 Oct 2026 20:00:47 +0100 Subject: [PATCH 2/2] docs(docx): say what the charts left undrawn are, and which kinds draw nothing beside one The set of charts not drawn holds the charts that are all their cell draws, which its comment now says, with what keying it by identity does to a chart node placed twice. The kinds that draw nothing beside a chart are named as the set holds them. --- .../backend/semantic/docx/DocxSemanticBackend.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 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 944cb6fe8..40326cbd7 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 @@ -226,8 +226,10 @@ public final class DocxSemanticBackend implements SemanticBackend { private final AtomicBoolean chartWarned = new AtomicBoolean(false); // Whether the table being written is a chart's data table, which the page does not draw. private boolean writingAChartsTable; - // The charts a table's cell is built from, whose drawings in that cell are not drawn: the - // chart is its data table there, as anywhere else (drawCellDrawing). + // The charts that are all their table cell draws, whose drawings in that cell are not drawn: + // the chart is its data table there, as anywhere else (drawCellDrawing). By identity: one chart + // node placed twice, alone in one cell and beside other drawings in another, has the note of + // one not drawn in both. private final java.util.Set chartsNotDrawn = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // Geometry-only node kinds already warned about this export pass. @@ -13652,7 +13654,10 @@ private List cellsBuiltFromAChart(TableNode table) { return boxes; } - /** The kinds that draw nothing in a cell beside a chart: text, and space between it. */ + /** + * The kinds that draw nothing in a cell beside a chart: a paragraph and a list, whose pictures + * stand in their lines, a spacer, a page break, and an alignment, which draws what it holds. + */ private static final java.util.Set> DRAWING_NOTHING = java.util.Set.of( ParagraphNode.class, com.demcha.compose.document.node.ListNode.class, SpacerNode.class, PageBreakNode.class, com.demcha.compose.document.node.AlignNode.class);