From fb66fd0618ead5116e577f4bd336967d6f3a550b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 30 Oct 2026 00:45:54 +0000
Subject: [PATCH 1/4] test(docx): hold every drawing of a block that writes
text to a paragraph of that text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A drawing placed from its paragraph moves with that paragraph when a reader edits the text above
it; one placed from the page keeps its place on the sheet while the text it belongs to is edited
away from under it. Four changes taught the export to carry such drawings, bringing the template
corpus from 465 drawings placed from the page down to 32, and nothing held what they won: a
change putting one back on the sheet would have left every suite green, no test reading where a
drawing is placed from at all.
DocxDrawingPlacements reads every drawing of a DOCX from every XML part — the body, its headers
and footers, and the tables and text boxes nested in them, a drawing inside another's text box
included — with what each is placed from and across, whether it floats, and the name the export
writes it under, which tells a drawing a node paints from a page's own fill. It reads simplePos
as well, because at simplePos="1" the editors take the place from wp:simplePos and read neither
positionH nor positionV, so a drawing nailed to the sheet that way still says
relativeFrom="paragraph" in the file. An anchor it cannot classify is a problem it reports rather
than a silence.
DocxDrawingAnchorGuardTest holds the rule two ways. Over the template corpus, where 728 of the
760 floating drawings a node paints are carried by a paragraph, the 32 still placed from the page
must equal, document for document and drawing for drawing, a ledger naming the reason each is
there — work still to do, not a set of cases where the page is right. Over six documents of its
own, one for each writer of a paragraph that carries a drawing today — a paragraph of the body, a
panel's, a column of a row, an item of a list, the one line two halves of a band share, and a
table cell a drawing is all of — every drawing must be placed down from a paragraph, since one
written into the text instead would satisfy a rule that only kept it off the page.
DocxFixtures holds what this guard and DocxPageReferenceGuardTest both need to write a DOCX by
hand, which were about to exist twice; the page-reference guard now uses it.
No engine code changes, so the corpus is unmoved and no baseline is retaken. The recipe now says
what being placed from the page costs a drawing.
---
CHANGELOG.md | 37 ++
docs/recipes/docx-export.md | 3 +
.../fidelity/DocxDrawingAnchorGuardTest.java | 388 ++++++++++++++++++
.../fidelity/DocxDrawingPlacements.java | 315 ++++++++++++++
.../templates/fidelity/DocxFixtures.java | 77 ++++
.../fidelity/DocxPageReferenceGuardTest.java | 39 +-
.../fidelity/FlowOwnerDrawingDocuments.java | 191 +++++++++
7 files changed, 1016 insertions(+), 34 deletions(-)
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfe8302e8..8f361cf8a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4444,6 +4444,43 @@ follow semantic versioning; release dates are ISO 8601.
### Tests
+- **A test holds every drawing the DOCX export writes for a block that writes text to a paragraph
+ of that text, so none of them is left placed from the page.**
+ - Word and LibreOffice move a drawing placed from its paragraph with that paragraph when the
+ text above it is edited; one placed from the page keeps its place on the sheet while the text
+ it belongs to is edited away from under it. Four changes in a row taught the export to carry
+ such drawings, bringing the template corpus from 465 drawings placed from the page down to
+ 32, and nothing held what they won: a change putting one back on the sheet would have left
+ every suite green.
+ - Held over the template corpus, where 728 of the 760 floating drawings a node paints are
+ carried by a paragraph — its 381 inline ones stand in the text itself and are placed from
+ nothing — and over six documents of its own, one for each writer of a paragraph that carries
+ a drawing today: a paragraph of the body, a panel's, a column of a row, an item of a list,
+ the one line two halves of a band share, and a table cell a drawing is all of. The corpus
+ fixes what the export does today; the six hold the rule, and every drawing in them must be
+ placed down from a paragraph, not merely kept off the page.
+ - The 32 the corpus still places from the page are named one by one with the reason each is
+ there: 25 of a table's composed cell, where the walk from the cell holding the drawing
+ crosses a cell set to the middle of a nested row whose room the layout does not state; 6
+ whose owner's own text is written as the drawing itself — a brand lockup's badges and a
+ closing banner's, laid over the flow — so no paragraph of the blocks holding it stands on its
+ page at all; and one standing 3.43pt above the nearest paragraph's top with four paragraphs
+ of its own block on its page that none of them was placed from. The list is a ledger of work
+ still to do and not a set of cases where the page is right: when the export learns to carry
+ those drawings, the test fails until the ledger is brought down to match.
+ - It fails closed on a reader that stops reading. Every page fill must come back placed from
+ the page, which a reader that lost the ability to read what a drawing is placed from cannot
+ show; the corpus must come back with the hundreds of carried drawings it holds; and an anchor
+ the reader cannot classify — one with nothing to be placed down from, no name of its own, a
+ name that is neither a drawing nor a page fill, an offset that is no number, or a place this
+ reader was not written to judge — is a failure rather than a silence.
+ - `simplePos` is read as well as `positionV`, because at `simplePos="1"` Word and LibreOffice
+ take the place from `wp:simplePos` and read neither `positionH` nor `positionV`: a drawing
+ nailed to the sheet that way still says `relativeFrom="paragraph"` in the file, and a test
+ reading only what a drawing says it is placed from would call it carried. Every floating
+ drawing of the corpus must carry an explicit `simplePos="0"`, and a truthy value is a
+ failure; left out, the attribute means what `"0"` means.
+
- **The DOCX fidelity gate holds each document where its baseline puts it, whichever way it
moved, instead of only catching lines set further from the page.**
- A document failed when a line stood more than 0.5pt further from the engine's PDF than its
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index f9ace63fb..e684025d2 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1513,6 +1513,9 @@ next paragraph able to place it, or else to the next block up that writes one. A
header row carry none either. A shape no node draws — a timeline's rail — and one none of
whose blocks writes such a paragraph on its page — a badge beside a table holding its stack's
text — is anchored in the paragraph nearest it on its page that can place it, however far off.
+A shape the export can place from no paragraph at all stays placed from the page: it keeps its
+place on the sheet while the text it belongs to is edited away from under it, and the note naming
+how it is anchored says that this may be where it ends up.
The paragraph's top is where Word places from: the space written above its first line
included, and the line where Word stands it — an exact line's baseline four fifths of the way
down it, raised by the run's position — measured in Word 16.0.20430 and LibreOffice to a tenth
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
new file mode 100644
index 000000000..86abea92f
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
@@ -0,0 +1,388 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.semantic.docx.DocxExportReport;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import com.demcha.compose.document.templates.fidelity.FlowOwnerDrawingDocuments.FlowOwnerDocument;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.assertLaidOut;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.body;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.docx;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.footer;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.header;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.paragraph;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A drawing painted for a block that writes text in the flow is carried by a paragraph, not nailed
+ * to the sheet: {@code wp:positionV relativeFrom="paragraph"} and never {@code "page"}. Word and
+ * LibreOffice move a drawing carried by a paragraph with that paragraph when the text above it is
+ * edited; one placed from the page stays where it was written while the text it belongs to walks
+ * away from under it.
+ *
+ *
Held two ways. Over the template corpus, which fixes what the export does today: every
+ * floating drawing a node paints is carried by a paragraph but for the ones this test names one by
+ * one, each with the reason it is still on the sheet ({@link #STILL_ON_THE_PAGE}). And over
+ * documents of its own, one for each writer of a paragraph that may carry a drawing
+ * ({@link FlowOwnerDrawingDocuments}), which hold the rule rather than today's state: every
+ * drawing in them must be placed down from a paragraph, and none may be on the sheet.
+ *
+ *
The list is a ledger of work still to do, not a set of cases where the sheet is right: the
+ * export has no drawing it is content to leave there. It names each drawing, not just how many, so
+ * that a change fixing one and breaking another in the same document cannot pass on a tally that
+ * happens to come out the same. An entry shrinks or goes when the export learns to carry those
+ * drawings, and this test then fails until the ledger is brought down to match — which is what
+ * makes it a ledger and not a licence.
+ *
+ *
It fails closed. The reader is held to finding the drawings it is to judge: every page fill
+ * must come back placed from the page, which no reader that lost the ability to read
+ * {@code relativeFrom} can show, every floating drawing must come back saying the editors read
+ * where it is placed from at all ({@code simplePos="0"}), the corpus must come back with the
+ * hundreds of carried drawings it holds, and an anchor the reader cannot classify is a failure
+ * rather than a silence ({@link DocxDrawingPlacements#problems()}).
+ */
+class DocxDrawingAnchorGuardTest {
+
+ /**
+ * Drawings of a table's composed cell, between which and the cell the drawing stands in there
+ * is a cell set to the middle of a nested row, whose room the layout does not state: the walk
+ * from the holding cell stops there rather than guess, and the drawing stays on the sheet.
+ */
+ private static final String COMPOSED_CELL_WALK_STOPS =
+ "a composed cell's drawing, the walk to it crossing a cell set to the middle of a nested row";
+
+ /**
+ * Drawings painted for a block whose own text is written as the drawing itself — a lockup or a
+ * banner laid over the flow, set in a text box where the page sets it — so no paragraph of the
+ * blocks holding it is written on its page at all, and none of the page's other paragraphs
+ * could be placed from either.
+ */
+ private static final String OWNER_WRITES_NO_PARAGRAPH =
+ "its owner's own text is written as the drawing, so no paragraph of its blocks stands on its page";
+
+ /**
+ * A drawing whose holding block does write paragraphs on its page — four of them stand within
+ * its path — and which none of them was placed from even so. It rises 3.43pt above the nearest
+ * paragraph's top, within the 24pt a drawing may rise, and that top is known, so the refusal is
+ * inside the walk through the row; which step of the walk refuses has not been measured.
+ */
+ private static final String HOLDER_PARAGRAPH_REFUSED =
+ "paragraphs of its block stand on its page and none of them placed it";
+
+ /**
+ * The drawings of the corpus still placed from the page: which they are, and why each is there.
+ * Measured over the corpus of 71 documents, which holds 760 floating drawings painted by a
+ * node, of which 728 are carried by a paragraph.
+ *
+ *
Twenty-five are a composed cell's ({@link #COMPOSED_CELL_WALK_STOPS}): thirteen in
+ * {@code invoice-payments} under {@code LineItemsBox/LineItems}, eight in
+ * {@code invoice-workspace} and four in {@code invoice-merchant}, each with ninety or more
+ * paragraphs on its page to choose from, so it is not for want of one. Seven are painted for a
+ * block of a sidebar or a closing banner: six in {@code invoice-luma_studio_long} — the brand
+ * lockup's four text badges on page 1 and the closing banner's two on page 3
+ * ({@link #OWNER_WRITES_NO_PARAGRAPH}) — and one in {@code invoice-obsidian_long}, the closing
+ * band's disc on page 5 ({@link #HOLDER_PARAGRAPH_REFUSED}).
+ */
+ private static final Map STILL_ON_THE_PAGE = Map.of(
+ "invoice-payments", new OnThePage(COMPOSED_CELL_WALK_STOPS,
+ "Drawing 8", "Drawing 9", "Drawing 10", "Drawing 11", "Drawing 12", "Drawing 13", "Drawing 14",
+ "Drawing 15", "Drawing 16", "Drawing 17", "Drawing 18", "Drawing 19", "Drawing 20"),
+ "invoice-workspace", new OnThePage(COMPOSED_CELL_WALK_STOPS,
+ "Drawing 5", "Drawing 6", "Drawing 7", "Drawing 8", "Drawing 9", "Drawing 10", "Drawing 11",
+ "Drawing 12"),
+ "invoice-merchant", new OnThePage(COMPOSED_CELL_WALK_STOPS,
+ "Drawing 7", "Drawing 8", "Drawing 9", "Drawing 10"),
+ "invoice-luma_studio_long", new OnThePage(OWNER_WRITES_NO_PARAGRAPH,
+ "Drawing 5", "Drawing 6", "Drawing 8", "Drawing 9", "Drawing 27", "Drawing 28"),
+ "invoice-obsidian_long", new OnThePage(HOLDER_PARAGRAPH_REFUSED, "Drawing 68"));
+
+ /**
+ * The drawings of a document still placed from the page, and why they are.
+ *
+ * @param reason why they are there, which is a reason they are not carried yet and never a
+ * reason the sheet is right for them
+ * @param drawings their names in the file, as the export writes them
+ */
+ private record OnThePage(String reason, String... drawings) {
+
+ List named() {
+ return List.of(drawings);
+ }
+ }
+
+ /**
+ * How many floating drawings the corpus has a node paint, as a floor: a reader that stopped
+ * finding them would otherwise report every document clean. Measured at 760 over 71 documents,
+ * beside 381 inline ones, which are placed from nothing and so are not this guard's business.
+ */
+ private static final int AT_LEAST_THIS_MANY_DRAWINGS = 700;
+
+ /**
+ * How many page fills the corpus holds, as a floor, each of which must come back placed from
+ * the page: this is the reader's own proof that it can read a drawing placed from the page at
+ * all. Measured at 32.
+ */
+ private static final int AT_LEAST_THIS_MANY_BACKGROUNDS = 30;
+
+ @Test
+ void everyDrawingOfTheCorpusIsCarriedByAParagraphBarTheOnesLedgered() throws Exception {
+ Map> onThePage = new TreeMap<>();
+ List problems = new ArrayList<>();
+ List stems = new ArrayList<>();
+ int floating = 0;
+ int backgrounds = 0;
+ for (DocxCorpusDocument document : DocxFidelityCorpusTest.corpus()) {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (DocumentSession session = DocxFidelityCorpusTest.composed(document)) {
+ docx = session.export(DocxSemanticBackend.builder().deterministic(true).reportSink(report::set).build());
+ }
+ assertLaidOut(document.stem(), report.get());
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx);
+ assertThat(placements.parts()).as("%s: its body read", document.stem()).contains("word/document.xml");
+ placements.problems().forEach(problem -> problems.add(document.stem() + ": " + problem));
+ // Every page fill belongs to the sheet: this is the reader showing it reads what a
+ // drawing is placed from, and not a constant.
+ assertThat(placements.backgrounds()).as("%s: its page fills placed from the page", document.stem())
+ .allMatch(DocxDrawingPlacements.Placement::anchoredToThePage);
+ // And every floating drawing says the editors read where it is placed from at all.
+ assertThat(placements.floatingPainted()).as("%s: its drawings placed from what they name",
+ document.stem())
+ .allMatch(drawing -> drawing.simplePos().equals("0"));
+ stems.add(document.stem());
+ floating += placements.floatingPainted().size();
+ backgrounds += placements.backgrounds().size();
+ List left = placements.paintedOnThePage().stream()
+ .map(DocxDrawingPlacements.Placement::name).sorted().toList();
+ if (!left.isEmpty()) {
+ onThePage.put(document.stem(), left);
+ }
+ }
+
+ assertThat(problems).as("every drawing of the corpus classified").isEmpty();
+ assertThat(floating).as("the floating drawings the corpus paints, read")
+ .isGreaterThanOrEqualTo(AT_LEAST_THIS_MANY_DRAWINGS);
+ assertThat(backgrounds).as("the page fills the corpus holds, read")
+ .isGreaterThanOrEqualTo(AT_LEAST_THIS_MANY_BACKGROUNDS);
+ assertThat(STILL_ON_THE_PAGE.keySet()).as("the ledger names documents of the corpus").isSubsetOf(stems);
+ Map> ledgered = new TreeMap<>();
+ STILL_ON_THE_PAGE.forEach((stem, still) ->
+ ledgered.put(stem, still.named().stream().sorted().toList()));
+ assertThat(onThePage).as("the drawings still placed from the page, each ledgered with its reason")
+ .isEqualTo(ledgered);
+ }
+
+ @Test
+ void aDrawingWhoseOwnerWritesTextIsCarriedByAParagraph() throws Exception {
+ for (FlowOwnerDocument document : FlowOwnerDrawingDocuments.documents()) {
+ AtomicReference report = new AtomicReference<>();
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().create()) {
+ document.compose().accept(session);
+ docx = session.export(DocxSemanticBackend.builder().deterministic(true).reportSink(report::set).build());
+ }
+ assertLaidOut(document.name(), report.get());
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx);
+
+ assertThat(placements.problems()).as("%s", document.name()).isEmpty();
+ assertThat(placements.floatingPainted()).as("%s: the drawings it floats", document.name())
+ .hasSize(document.drawings());
+ assertThat(placements.paintedOnThePage())
+ .as("%s: nothing %s paints is left on the sheet", document.name(), document.writer())
+ .isEmpty();
+ // Not merely "not on the sheet": written as what moves with the text. An export that
+ // put these in the text instead would satisfy every assertion above.
+ assertThat(placements.floatingPainted())
+ .as("%s: each drawing placed down from a paragraph of %s", document.name(), document.writer())
+ .allSatisfy(drawing -> assertThat(drawing.placedFrom()).isEqualTo("paragraph"));
+ }
+ }
+
+ // The reader, on files written by hand: it must find what it is to judge, wherever it stands,
+ // and say so when it cannot.
+
+ @Test
+ void aDrawingPlacedFromItsParagraphIsCarriedAndOneFromThePageIsNot() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph(anchor("Drawing 1", "paragraph", 1270))
+ + paragraph(anchor("Drawing 2", "page", 254000))))));
+
+ assertThat(placements.problems()).isEmpty();
+ assertThat(placements.floatingPainted()).hasSize(2);
+ assertThat(placements.paintedOnThePage()).singleElement()
+ .satisfies(drawing -> assertThat(drawing.name()).isEqualTo("Drawing 2"));
+ assertThat(placements.placements().get(0).carriedByItsParagraph()).isTrue();
+ assertThat(placements.placements().get(0).down()).isEqualTo(1270);
+ }
+
+ @Test
+ void aPageFillIsToldFromADrawingANodePaints() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/header1.xml",
+ header(paragraph(anchor("Page background 1", "page", 0))))));
+
+ assertThat(placements.problems()).isEmpty();
+ assertThat(placements.backgrounds()).singleElement()
+ .satisfies(fill -> assertThat(fill.anchoredToThePage()).isTrue());
+ assertThat(placements.painted()).isEmpty();
+ assertThat(placements.paintedOnThePage()).isEmpty();
+ }
+
+ @Test
+ void aDrawingIsFoundInAHeaderANestedTableAndATextBox() throws Exception {
+ String nested = ""
+ + paragraph(anchor("Drawing 1", "page", 0)) + "";
+ String textBox = paragraph(""
+ + ""
+ + "0"
+ + "0"
+ + ""
+ + ""
+ + paragraph(anchor("Drawing 3", "page", 0))
+ + ""
+ + "");
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of(
+ "word/document.xml", body(nested + textBox),
+ "word/footer1.xml", footer(paragraph(anchor("Drawing 4", "page", 0))))));
+
+ assertThat(placements.problems()).isEmpty();
+ assertThat(placements.painted()).extracting(DocxDrawingPlacements.Placement::name)
+ .containsExactlyInAnyOrder("Drawing 1", "Drawing 2", "Drawing 3", "Drawing 4");
+ assertThat(placements.paintedOnThePage()).extracting(DocxDrawingPlacements.Placement::name)
+ .containsExactlyInAnyOrder("Drawing 1", "Drawing 3", "Drawing 4");
+ }
+
+ @Test
+ void anInlineDrawingStandsInTheTextAndIsNotFloating() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph(""
+ + "")))));
+
+ assertThat(placements.problems()).isEmpty();
+ // Counted as a drawing a node paints, but not as a floating one: it is placed from nothing.
+ assertThat(placements.painted()).hasSize(1);
+ assertThat(placements.floatingPainted()).isEmpty();
+ assertThat(placements.placements()).singleElement().satisfies(drawing -> {
+ assertThat(drawing.floating()).isFalse();
+ assertThat(drawing.carriedByItsParagraph()).isTrue();
+ assertThat(drawing.anchoredToThePage()).isFalse();
+ });
+ }
+
+ @Test
+ void aDrawingSentToAnAbsolutePointIsAProblemWhateverItIsPlacedFrom() throws Exception {
+ // simplePos="1" takes the editors to wp:simplePos and leaves positionV unread, so a
+ // drawing nailed to the sheet this way still says relativeFrom="paragraph".
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph(""
+ + "" + ACROSS
+ + "0"
+ + ""
+ + "")))));
+
+ assertThat(placements.problems()).singleElement()
+ .satisfies(problem -> assertThat(problem).contains("\"Drawing 1\"")
+ .contains("simplePos=\"1\"").contains("neither positionH nor positionV"));
+ assertThat(placements.placements()).singleElement().satisfies(drawing -> {
+ assertThat(drawing.placedFrom()).as("what it says it is placed from").isEqualTo("paragraph");
+ assertThat(drawing.carriedByItsParagraph()).as("but no paragraph carries it").isFalse();
+ });
+ }
+
+ @Test
+ void aDrawingWithNoSimplePosIsPlacedFromWhatItNames() throws Exception {
+ // The attribute defaults to false, so one left out is not a drawing sent to a point.
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph("" + ACROSS
+ + "0"
+ + ""
+ + "")))));
+
+ assertThat(placements.problems()).isEmpty();
+ assertThat(placements.placements()).singleElement()
+ .satisfies(drawing -> assertThat(drawing.carriedByItsParagraph()).isTrue());
+ }
+
+ @Test
+ void anAnchorWithNothingToBePlacedFromIsAProblem() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph("" + ACROSS
+ + "")))));
+
+ assertThat(placements.problems()).singleElement()
+ .satisfies(problem -> assertThat(problem).contains("\"Drawing 1\"")
+ .contains("names nothing to be placed down from"));
+ }
+
+ @Test
+ void anAnchorPlacedFromSomethingElseIsAProblem() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph(anchor("Drawing 1", "margin", 0))))));
+
+ assertThat(placements.problems()).singleElement()
+ .satisfies(problem -> assertThat(problem).contains("placed down from \"margin\""));
+ assertThat(placements.paintedOnThePage()).as("not read as the sheet, and not passed over either").isEmpty();
+ }
+
+ @Test
+ void anAnchorPlacedAcrossFromSomethingElseIsAProblem() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph(""
+ + "0"
+ + ""
+ + "0"
+ + ""
+ + "")))));
+
+ assertThat(placements.problems()).singleElement()
+ .satisfies(problem -> assertThat(problem).contains("placed across from \"margin\""));
+ }
+
+ @Test
+ void anAnchorWithNoNameOrANameOfItsOwnIsAProblem() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph("" + ACROSS
+ + ""
+ + "0")
+ + paragraph(anchor("Chart 7", "page", 0))))));
+
+ assertThat(placements.problems()).satisfiesExactly(
+ problem -> assertThat(problem).contains("an unnamed drawing").contains("carries no wp:docPr name"),
+ problem -> assertThat(problem).contains("\"Chart 7\"")
+ .contains("named neither a drawing nor a page background"));
+ }
+
+ @Test
+ void anOffsetThatIsNoNumberIsAProblem() throws Exception {
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph("" + ACROSS
+ + ""
+ + "somewhere"
+ + "")))));
+
+ assertThat(placements.problems()).singleElement()
+ .satisfies(problem -> assertThat(problem).contains("no offset down this reader can read"));
+ }
+
+ /** Placed across from the page's edge, so a fixture may be about what it is placed down from. */
+ private static final String ACROSS =
+ "0";
+
+ /** A floating drawing, placed across from the page and down from what it is given. */
+ private static String anchor(String name, String placedFrom, long down) {
+ return ""
+ + "127000"
+ + "" + down
+ + ""
+ + ""
+ + ""
+ + "";
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
new file mode 100644
index 000000000..aa9336f7a
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
@@ -0,0 +1,315 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+/**
+ * Every drawing of a DOCX and what it is placed from, read from every XML part of the file: the
+ * body, its headers and footers, and the tables and text boxes nested in them.
+ *
+ *
A floating drawing is a {@code wp:anchor}, placed from what its {@code wp:positionV} names:
+ * {@code paragraph} moves it with the paragraph carrying it when the text above is edited, and
+ * {@code page} nails it to the sheet. An {@code wp:inline} drawing stands in the text itself and
+ * names nothing to be placed from. The export writes a drawing a node paints under the name
+ * "Drawing N" and a page's fill under "Page background N", so the two are told apart in the file
+ * ({@link Placement#paints} and {@link Placement#isPageBackground()}).
+ *
+ *
{@link #problems()} names every anchor this reader cannot classify — one with no
+ * {@code wp:positionV}, one whose {@code wp:docPr} carries no name, one named neither a drawing
+ * nor a page background, and one placed from something neither {@code paragraph} nor {@code page}.
+ * A reader that fell silent would otherwise let a broken export pass as a clean one.
+ */
+final class DocxDrawingPlacements {
+
+ private static final String WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
+
+ /** The name the export writes a drawing a node paints under. */
+ private static final String DRAWING = "Drawing ";
+
+ /** The name the export writes a page's own fill under. */
+ private static final String PAGE_BACKGROUND = "Page background ";
+
+ private static final XMLInputFactory XML = XMLInputFactory.newFactory();
+
+ static {
+ XML.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+ XML.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ XML.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, true);
+ }
+
+ private final List parts = new ArrayList<>();
+ private final List placements = new ArrayList<>();
+ private final List problems = new ArrayList<>();
+
+ private DocxDrawingPlacements() {
+ }
+
+ /**
+ * A drawing and what it is placed from.
+ *
+ * @param part the part it stands in
+ * @param name its {@code wp:docPr} name, or an empty string where it carries none
+ * @param placedFrom what its {@code wp:positionV} names — {@code paragraph} or {@code page}
+ * — or an empty string for an inline drawing, which names nothing
+ * @param placedAcross what its {@code wp:positionH} names, or an empty string for an inline one
+ * @param down its {@code wp:positionV} offset in EMU, 0 for an inline one
+ * @param floating whether it floats ({@code wp:anchor}) rather than standing in the text
+ * @param simplePos what a floating drawing's {@code simplePos} attribute says, or an empty
+ * string for an inline one. At {@code 1} Word and LibreOffice place the
+ * drawing at the absolute page point its {@code wp:simplePos} gives and
+ * ignore {@code positionH} and {@code positionV} altogether, so what the
+ * rest of this record says about it would no longer be what the editors do
+ */
+ record Placement(String part, String name, String placedFrom, String placedAcross, long down,
+ boolean floating, String simplePos) {
+
+ /** Whether a node paints it, rather than it being a page's own fill. */
+ boolean paints() {
+ return name.startsWith(DRAWING);
+ }
+
+ /** Whether it is a page's own fill, which belongs to the sheet and not to any text. */
+ boolean isPageBackground() {
+ return name.startsWith(PAGE_BACKGROUND);
+ }
+
+ /** Whether it is nailed to the sheet rather than carried by a paragraph. */
+ boolean anchoredToThePage() {
+ return floating && placedFrom.equals("page");
+ }
+
+ /**
+ * Whether a paragraph carries it, so it moves with that paragraph when the text above is
+ * edited: placed down from a paragraph, or standing in the text itself. A drawing the
+ * editors place at an absolute point is carried by nothing, whatever it is placed from.
+ */
+ boolean carriedByItsParagraph() {
+ return !placedAtAnAbsolutePoint() && (!floating || placedFrom.equals("paragraph"));
+ }
+
+ /**
+ * Whether {@code simplePos} sends the editors to {@code wp:simplePos} for the place, which
+ * leaves {@code positionH} and {@code positionV} unread. The attribute defaults to false,
+ * so one left out is not this.
+ */
+ boolean placedAtAnAbsolutePoint() {
+ return simplePos.equals("1") || simplePos.equals("true") || simplePos.equals("on");
+ }
+
+ @Override
+ public String toString() {
+ return name + " in " + part + (floating ? " from " + placedFrom : " inline");
+ }
+ }
+
+ /** Reads the drawings of a DOCX, from every XML part it holds. */
+ static DocxDrawingPlacements read(byte[] docx) throws IOException, XMLStreamException {
+ DocxDrawingPlacements read = new DocxDrawingPlacements();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(docx))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ if (entry.isDirectory() || !entry.getName().endsWith(".xml")) {
+ continue;
+ }
+ read.parts.add(entry.getName());
+ read.readPart(entry.getName(), zip.readAllBytes());
+ }
+ }
+ return read;
+ }
+
+ /** The parts read, by name. */
+ List parts() {
+ return List.copyOf(parts);
+ }
+
+ /** Every drawing the file holds, in the order the parts were read. */
+ List placements() {
+ return List.copyOf(placements);
+ }
+
+ /**
+ * The drawings a node paints, floating and inline together. POI names an inline picture
+ * "Drawing N" as the export names a floating one, and the corpus holds 381 of them against
+ * 760 floating, so a count of these is not a count of what this guard is about:
+ * {@link #floatingPainted()} is.
+ */
+ List painted() {
+ return placements.stream().filter(Placement::paints).toList();
+ }
+
+ /**
+ * The floating drawings a node paints — the ones that are placed from something, and so the
+ * only ones that can be placed from the page.
+ */
+ List floatingPainted() {
+ return placements.stream().filter(placement -> placement.paints() && placement.floating()).toList();
+ }
+
+ /** The page fills, which belong to the sheet. */
+ List backgrounds() {
+ return placements.stream().filter(Placement::isPageBackground).toList();
+ }
+
+ /** The drawings a node paints that are nailed to the sheet. */
+ List paintedOnThePage() {
+ return placements.stream().filter(placement -> placement.paints() && placement.anchoredToThePage()).toList();
+ }
+
+ /** What this reader could not classify: each one is a failure to read, never a pass. */
+ List problems() {
+ return List.copyOf(problems);
+ }
+
+ private void readPart(String part, byte[] bytes) throws XMLStreamException {
+ XMLStreamReader xml = XML.createXMLStreamReader(new ByteArrayInputStream(bytes));
+ try {
+ // A drawing's text box may hold a drawing of its own: each is read in its own frame,
+ // and what stands inside the inner one belongs to it, not to the one around it.
+ Deque being = new ArrayDeque<>();
+ // Which of the two position elements the reader stands in: a posOffset belongs to it.
+ String position = null;
+ boolean readingOffset = false;
+ StringBuilder offset = new StringBuilder();
+ while (xml.hasNext()) {
+ int event = xml.next();
+ if (event == XMLStreamConstants.CHARACTERS && readingOffset) {
+ offset.append(xml.getText());
+ continue;
+ }
+ if (event != XMLStreamConstants.START_ELEMENT && event != XMLStreamConstants.END_ELEMENT) {
+ continue;
+ }
+ if (!WP.equals(xml.getNamespaceURI())) {
+ continue;
+ }
+ String element = xml.getLocalName();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ switch (element) {
+ case "anchor", "inline" -> {
+ Being opened = new Being(element.equals("anchor"));
+ if (opened.floating) {
+ opened.simplePos = xml.getAttributeValue(null, "simplePos");
+ }
+ being.push(opened);
+ }
+ case "positionV" -> {
+ position = "V";
+ if (!being.isEmpty()) {
+ being.peek().placedFrom = xml.getAttributeValue(null, "relativeFrom");
+ }
+ }
+ case "positionH" -> {
+ position = "H";
+ if (!being.isEmpty()) {
+ being.peek().placedAcross = xml.getAttributeValue(null, "relativeFrom");
+ }
+ }
+ case "posOffset" -> {
+ readingOffset = true;
+ offset.setLength(0);
+ }
+ case "docPr" -> {
+ if (!being.isEmpty()) {
+ being.peek().name = xml.getAttributeValue(null, "name");
+ }
+ }
+ default -> {
+ }
+ }
+ } else {
+ switch (element) {
+ case "posOffset" -> {
+ if ("V".equals(position) && !being.isEmpty()) {
+ being.peek().down = parse(offset.toString());
+ }
+ readingOffset = false;
+ offset.setLength(0);
+ }
+ case "positionV", "positionH" -> position = null;
+ case "anchor", "inline" -> {
+ if (!being.isEmpty()) {
+ Being read = being.pop();
+ add(part, read.name, read.placedFrom, read.placedAcross, read.down, read.floating,
+ read.simplePos);
+ }
+ }
+ default -> {
+ }
+ }
+ }
+ }
+ } finally {
+ xml.close();
+ }
+ }
+
+ /** A drawing the reader is in the middle of, held while what stands inside it is read. */
+ private static final class Being {
+
+ private final boolean floating;
+ private String placedFrom;
+ private String placedAcross;
+ private String name;
+ private long down;
+ private String simplePos;
+
+ private Being(boolean floating) {
+ this.floating = floating;
+ }
+ }
+
+ private static long parse(String text) {
+ try {
+ return Long.parseLong(text.trim());
+ } catch (NumberFormatException notANumber) {
+ return Long.MIN_VALUE;
+ }
+ }
+
+ private void add(String part, String name, String placedFrom, String placedAcross, long down, boolean floating,
+ String simplePos) {
+ Placement placement = new Placement(part, name == null ? "" : name,
+ placedFrom == null ? "" : placedFrom, placedAcross == null ? "" : placedAcross, down, floating,
+ simplePos == null ? "" : simplePos);
+ placements.add(placement);
+ String where = (placement.name().isEmpty() ? "an unnamed drawing" : "\"" + placement.name() + "\"")
+ + " in " + part;
+ if (placement.name().isEmpty()) {
+ problems.add(where + " carries no wp:docPr name, so what paints it cannot be told");
+ } else if (!placement.paints() && !placement.isPageBackground()) {
+ problems.add(where + " is named neither a drawing nor a page background");
+ }
+ if (floating && placement.placedFrom().isEmpty()) {
+ problems.add(where + " floats and names nothing to be placed down from");
+ } else if (floating && !placement.placedFrom().equals("paragraph") && !placement.placedFrom().equals("page")) {
+ problems.add(where + " is placed down from \"" + placement.placedFrom() + "\", which this reader does not know");
+ }
+ if (floating && down == Long.MIN_VALUE) {
+ problems.add(where + " has no offset down this reader can read");
+ }
+ // The export places a drawing across from its cell's text column, or from the page's edge
+ // where no cell holds it. Anything else is a drawing this reader was not written to judge.
+ // At simplePos="1" the editors place the drawing at wp:simplePos and read neither
+ // positionH nor positionV, so every other reading of this drawing would be beside the
+ // point: it is nailed to the sheet whatever relativeFrom says.
+ if (placement.placedAtAnAbsolutePoint()) {
+ problems.add(where + " is written simplePos=\"" + placement.simplePos()
+ + "\", so the editors place it at an absolute point on the page and read"
+ + " neither positionH nor positionV");
+ }
+ if (floating && !placement.placedAcross().equals("column") && !placement.placedAcross().equals("page")) {
+ problems.add(where + " is placed across from \"" + placement.placedAcross()
+ + "\", which this reader does not know");
+ }
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
new file mode 100644
index 000000000..b17a66d2b
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
@@ -0,0 +1,77 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import com.demcha.compose.document.backend.semantic.docx.DocxExportReport;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What the fidelity guards that read a DOCX back need in common: a package of XML parts written
+ * by hand, so a reader can be held to finding what it is to judge in a file put together on
+ * purpose, and the check that an export was made from its document's layout.
+ *
+ *
A guard's own fixtures stay with the guard — a page reference's fields, a drawing's anchor —
+ * and only what every one of them needs is here.
+ */
+final class DocxFixtures {
+
+ /** The namespaces a hand-written part declares, enough for the parts written here. */
+ static final String NAMESPACES =
+ "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" "
+ + "xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" "
+ + "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" "
+ + "xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\"";
+
+ private DocxFixtures() {
+ }
+
+ /** A body part holding the given content. */
+ static String body(String content) {
+ return "" + content + "";
+ }
+
+ /** A header part holding the given content. */
+ static String header(String content) {
+ return "" + content + "";
+ }
+
+ /** A footer part holding the given content. */
+ static String footer(String content) {
+ return "" + content + "";
+ }
+
+ /** A paragraph holding the given content. */
+ static String paragraph(String content) {
+ return "" + content + "";
+ }
+
+ /** A package of the given parts, enough for a reader that reads XML parts alone. */
+ static byte[] docx(Map parts) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
+ for (Map.Entry part : new TreeMap<>(parts).entrySet()) {
+ zip.putNextEntry(new ZipEntry(part.getKey()));
+ zip.write(part.getValue().getBytes(StandardCharsets.UTF_8));
+ zip.closeEntry();
+ }
+ }
+ return bytes.toByteArray();
+ }
+
+ /**
+ * The export was made from the document's layout: where the layout fails, the export writes
+ * the document without it and says so under this subject, and everything the layout would
+ * have given — where a line sits, where a drawing is placed from — is the editor's guess
+ * instead. A guard reading such a file would be reading the fallback, not the export.
+ */
+ static void assertLaidOut(String document, DocxExportReport report) {
+ assertThat(report.bySubject()).as("%s: exported from its layout", document)
+ .doesNotContainKey("measured geometry");
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
index af1c9b644..e690139b1 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
@@ -31,6 +31,11 @@
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.NAMESPACES;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.assertLaidOut;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.body;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.docx;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.paragraph;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@@ -298,14 +303,6 @@ private static String reads(Reference reference, Map pages) {
return page == null ? "" : String.valueOf(page);
}
- /**
- * The export was made from the document's layout: where the layout fails, the export writes the
- * document without it, saying so under this subject, and a page reference reads no page.
- */
- private static void assertLaidOut(String document, DocxExportReport report) {
- assertThat(report.bySubject()).as("%s: exported from its layout", document).doesNotContainKey("measured geometry");
- }
-
private static long notedAsText(DocxExportReport report) {
return report.notes().stream()
.filter(note -> note.subject().equals("PageReferenceNode") && note.detail().contains(NO_BOOKMARK))
@@ -350,20 +347,6 @@ private static Set nodeKindsDeclaringAnAnchor() throws Exception {
return kinds;
}
- private static final String NAMESPACES =
- "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" "
- + "xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" "
- + "xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" "
- + "xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\"";
-
- private static String body(String content) {
- return "" + content + "";
- }
-
- private static String paragraph(String content) {
- return "" + content + "";
- }
-
private static String bookmark(String id, String name) {
return "";
}
@@ -376,16 +359,4 @@ private static String field(String instruction, String result) {
+ "";
}
- /** A package of the given parts, enough for the reader, which reads XML parts alone. */
- private static byte[] docx(Map parts) throws Exception {
- ByteArrayOutputStream bytes = new ByteArrayOutputStream();
- try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
- for (Map.Entry part : new TreeMap<>(parts).entrySet()) {
- zip.putNextEntry(new ZipEntry(part.getKey()));
- zip.write(part.getValue().getBytes(StandardCharsets.UTF_8));
- zip.closeEntry();
- }
- }
- return bytes.toByteArray();
- }
}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
new file mode 100644
index 000000000..47ba973ac
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
@@ -0,0 +1,191 @@
+package com.demcha.compose.document.templates.fidelity;
+
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.EllipseBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.svg.SvgIcon;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import com.demcha.compose.font.FontName;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Small documents whose every drawing is painted for a block that writes text in the flow: one for
+ * each writer of a paragraph that carries a drawing today — a paragraph of the body, a panel's, a
+ * column of a row, an item of a list, the one line two halves of a band share, and a table cell a
+ * drawing is all of.
+ *
+ *
The template corpus proves what the export does today over whole presets, where a drawing's
+ * owner is buried in a preset's tree. These documents hold the rule itself: a drawing whose owner
+ * writes text must be carried by a paragraph, never nailed to the sheet, and each document is
+ * small enough that a failure names the writer that stopped offering its paragraph
+ * ({@link DocxDrawingAnchorGuardTest}).
+ *
+ *
Each is laid out on a small page, its drawing standing beside its owner's text so that there
+ * is a paragraph to carry it: a drawing further from text than the export reaches is another case,
+ * and not this one.
+ */
+final class FlowOwnerDrawingDocuments {
+
+ private static final DocumentInsets MARGIN = DocumentInsets.of(24);
+ private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148);
+ private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249);
+ private static final DocumentTextStyle TEXT = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
+
+ /** The cells' text, in the same face: the export embeds it, so the editors set the same lines. */
+ private static final DocumentTableStyle CELLS = DocumentTableStyle.builder().textStyle(TEXT).build();
+
+ /** A disc the export writes as a drawing, small enough to stand beside a line of text. */
+ private static final SvgIcon ICON = SvgIcon.parse("");
+
+ private FlowOwnerDrawingDocuments() {
+ }
+
+ /**
+ * A document and the writer of text whose paragraph is to carry its drawings.
+ *
+ * @param name its name, which a failure is reported under
+ * @param writer the writer of text the drawings' owners hold, named for the report
+ * @param drawings how many drawings a node paints in it
+ * @param compose how it is composed
+ */
+ record FlowOwnerDocument(String name, String writer, int drawings, Consumer compose) {
+ }
+
+ /** One document for each writer of a paragraph that may carry a drawing. */
+ static List documents() {
+ return List.of(
+ new FlowOwnerDocument("disc_beside_body_text", "a paragraph of the body", 1,
+ FlowOwnerDrawingDocuments::discBesideBodyText),
+ new FlowOwnerDocument("icon_over_panel_text", "a panel's paragraph", 1,
+ FlowOwnerDrawingDocuments::iconOverPanelText),
+ new FlowOwnerDocument("icon_in_row_column", "a column of a row", 1,
+ FlowOwnerDrawingDocuments::iconInRowColumn),
+ new FlowOwnerDocument("icon_over_list_card", "an item of a list", 1,
+ FlowOwnerDrawingDocuments::iconOverListCard),
+ new FlowOwnerDocument("disc_alone_in_a_cell", "a table cell a drawing is all of", 1,
+ FlowOwnerDrawingDocuments::discAloneInACell),
+ new FlowOwnerDocument("disc_beside_a_bands_line", "the one line two halves of a band share", 1,
+ FlowOwnerDrawingDocuments::discBesideABandsLine));
+ }
+
+ /**
+ * A disc between two paragraphs of the body: the page flow holds both the disc and the text,
+ * so a paragraph of the flow carries it.
+ */
+ private static void discBesideBodyText(DocumentSession session) {
+ session.pageSize(300, 320).margin(MARGIN);
+ session.pageFlow(page -> page.spacing(6)
+ .addParagraph(p -> text(p, "Above the disc: this paragraph grows a line when it is edited, "
+ + "and the disc under it has to move down with it."))
+ .addEllipse(18, 18, INK)
+ .addParagraph(p -> text(p, "Under the disc, on the same page.")));
+ }
+
+ /**
+ * An icon laid over the top right corner of a panel whose own paragraph is the only text
+ * holding it: that paragraph carries the icon.
+ */
+ private static void iconOverPanelText(DocumentSession session) {
+ session.pageSize(300, 320).margin(MARGIN);
+ session.pageFlow(page -> page.spacing(6)
+ .addParagraph(p -> text(p, "Above the panel."))
+ .addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, 12)
+ .addParagraph(p -> text(p, "A panel under an icon laid over its corner, its "
+ + "text held in by its padding."))
+ .build())
+ .layer(ICON.node(16), LayerAlign.TOP_RIGHT))
+ .addParagraph(p -> text(p, "Under the panel, on the same page.")));
+ }
+
+ /** An icon in one column of a row and text in the other: the row's columns write both. */
+ private static void iconInRowColumn(DocumentSession session) {
+ session.pageSize(300, 320).margin(MARGIN);
+ session.pageFlow(page -> page.spacing(6)
+ .addParagraph(p -> text(p, "Above the row."))
+ .addRow(row -> row.gap(8).weights(1, 4)
+ .add(ICON.node(16))
+ .addParagraph(p -> text(p, "Beside the icon, in the other column of the row: the icon "
+ + "stands against this text and moves with it.")))
+ .addParagraph(p -> text(p, "Under the row, on the same page.")));
+ }
+
+ /**
+ * An icon laid over a card whose only text is a list's items: an item of the list carries it,
+ * no paragraph of the card's own being written.
+ */
+ private static void iconOverListCard(DocumentSession session) {
+ session.pageSize(300, 320).margin(MARGIN);
+ session.pageFlow(page -> page.spacing(6)
+ .addParagraph(p -> text(p, "Above the card."))
+ .addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, 12)
+ .addList(list -> list.bullet().textStyle(TEXT)
+ .items("The first item of the card's list.",
+ "The second, which the icon stands beside.",
+ "The third and last item."))
+ .build())
+ .layer(ICON.node(16), LayerAlign.TOP_RIGHT))
+ .addParagraph(p -> text(p, "Under the card, on the same page.")));
+ }
+
+ /**
+ * A table a cell of which holds nothing but a disc, its neighbour holding text. A drawing that
+ * is all its cell holds is the export's own exception: it is anchored in that cell's paragraph
+ * directly, without the walk from the cell whose box holds it
+ * ({@code DocxDrawingAnchors#anchorInCell}), so this document holds that path and not the walk.
+ *
+ *
The walk itself has no document here. A composed cell holding text as well as a drawing
+ * leaves the drawing on the page today, as 25 of the corpus's do, so there is nothing for a
+ * document of the rule to assert: the corpus ledger carries that case until the export does.
+ */
+ private static void discAloneInACell(DocumentSession session) {
+ session.pageSize(300, 320).margin(MARGIN);
+ session.pageFlow(page -> page.spacing(6)
+ .addParagraph(p -> text(p, "Above the table."))
+ .addTable(table -> table
+ .columns(DocumentTableColumn.fixed(80), DocumentTableColumn.fixed(170))
+ .defaultCellStyle(CELLS)
+ .rowCells(DocumentTableCell.node(new EllipseBuilder().circle(18).fillColor(INK)
+ .stroke(DocumentStroke.of(INK, 1)).build()),
+ DocumentTableCell.text("Beside the disc, in the cell next to it."))
+ .rowCells(DocumentTableCell.text("Second row"), DocumentTableCell.text("Under the disc")))
+ .addParagraph(p -> text(p, "Under the table, on the same page.")));
+ }
+
+ /**
+ * A band whose title stands at the left of a line and whose dates stand at the right of the
+ * same line, with a disc laid over it: the export writes the two halves as one Word line, and
+ * that line is the only text the band writes, so it carries the disc.
+ */
+ private static void discBesideABandsLine(DocumentSession session) {
+ session.pageSize(300, 320).margin(MARGIN);
+ session.pageFlow(page -> page.spacing(6)
+ .addParagraph(p -> text(p, "Above the band."))
+ .addLayerStack(stack -> stack
+ .layer(paragraph("Senior engineer"), LayerAlign.TOP_LEFT)
+ .layer(paragraph("2019-2024"), LayerAlign.TOP_RIGHT)
+ .layer(new EllipseBuilder().circle(8).fillColor(INK).build(), LayerAlign.CENTER_LEFT))
+ .addParagraph(p -> text(p, "Under the band, on the same page.")));
+ }
+
+ private static ParagraphBuilder text(ParagraphBuilder paragraph, String text) {
+ return paragraph.text(text).textStyle(TEXT);
+ }
+
+ private static ParagraphNode paragraph(String text) {
+ return text(new ParagraphBuilder(), text).build();
+ }
+}
From 702ad0502fcdf37746fddec3afaca17508e8b09a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 30 Oct 2026 01:17:49 +0000
Subject: [PATCH 2/4] test(docx): hold the cell exception by the shape it
needs, and read a keyword where an offset belongs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The document for a drawing that is all a table cell holds put a bare EllipseNode in the cell,
which is not what the export's exception asks for: it fires only for a layer stack or a shape
container that only draws, so the disc was anchored by the ordinary walk instead and the
exception had no document at all. The disc is now a layer stack of one layer, which meets every
condition the exception states.
A wp:positionV may say wp:align — a keyword, "bottom" — where the export says a wp:posOffset.
Read as a missing offset that came out as nought, a drawing sitting exactly on its paragraph's
top: a reading, and a wrong one. The reader now tracks whether an offset was given at all and
reports an anchor that gave none, which leaves the missing-offset problem to anchors that do name
something to be placed down from, an anchor with no wp:positionV being that one problem and not
two.
The ledger's reasons reach the failure: they were recorded against each document and read
nowhere, so a red named the drawings and not what the ledger claims about them. The floor on the
corpus's floating drawings is held at 750 against the 760 measured rather than 700, since the
slack is what a drawing could go missing in.
DocxPageReferenceGuardTest drops the four imports left dead by moving its hand-written-file
helpers out.
---
CHANGELOG.md | 12 ++--
.../fidelity/DocxDrawingAnchorGuardTest.java | 57 ++++++++++++++++---
.../fidelity/DocxDrawingPlacements.java | 19 ++++++-
.../fidelity/DocxPageReferenceGuardTest.java | 4 --
.../fidelity/FlowOwnerDrawingDocuments.java | 28 ++++++---
5 files changed, 91 insertions(+), 29 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f361cf8a..b89b6299f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4460,12 +4460,12 @@ follow semantic versioning; release dates are ISO 8601.
fixes what the export does today; the six hold the rule, and every drawing in them must be
placed down from a paragraph, not merely kept off the page.
- The 32 the corpus still places from the page are named one by one with the reason each is
- there: 25 of a table's composed cell, where the walk from the cell holding the drawing
- crosses a cell set to the middle of a nested row whose room the layout does not state; 6
- whose owner's own text is written as the drawing itself — a brand lockup's badges and a
- closing banner's, laid over the flow — so no paragraph of the blocks holding it stands on its
- page at all; and one standing 3.43pt above the nearest paragraph's top with four paragraphs
- of its own block on its page that none of them was placed from. The list is a ledger of work
+ there: 25 of a table's composed cell, where the walk from the cell whose box holds the
+ drawing finds no paragraph of that cell it can be placed from; 6 whose owner's own text is
+ written as the drawing itself — a brand lockup's badges and a closing banner's, laid over the
+ flow — so no paragraph of the blocks holding it stands on its page at all; and one standing
+ 3.43pt above the nearest paragraph's top, with four paragraphs of its own block on its page
+ that none of them was placed from. The list is a ledger of work
still to do and not a set of cases where the page is right: when the export learns to carry
those drawings, the test fails until the ledger is brought down to match.
- It fails closed on a reader that stops reading. Every page fill must come back placed from
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
index 86abea92f..3a5d268f0 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
@@ -52,12 +52,17 @@
class DocxDrawingAnchorGuardTest {
/**
- * Drawings of a table's composed cell, between which and the cell the drawing stands in there
- * is a cell set to the middle of a nested row, whose room the layout does not state: the walk
- * from the holding cell stops there rather than guess, and the drawing stays on the sheet.
+ * Drawings of a table's composed cell: the walk from the cell whose box holds the drawing to a
+ * paragraph of that cell finds none it can be placed from, and the drawing stays on the sheet.
+ *
+ *
Which step of the walk gives up has not been pinned down. A cell set to the middle of a
+ * nested row stands in the way in these three invoices, and was taken for the cause, but a
+ * composed cell holding text and a drawing leaves the drawing on the sheet with no nested row
+ * anywhere near it, so that is at most one way in. The reason here is therefore the outcome,
+ * not a mechanism.
*/
private static final String COMPOSED_CELL_WALK_STOPS =
- "a composed cell's drawing, the walk to it crossing a cell set to the middle of a nested row";
+ "a composed cell's drawing, which the walk from its holding cell found no paragraph for";
/**
* Drawings painted for a block whose own text is written as the drawing itself — a lockup or a
@@ -84,7 +89,7 @@ class DocxDrawingAnchorGuardTest {
*
*
Twenty-five are a composed cell's ({@link #COMPOSED_CELL_WALK_STOPS}): thirteen in
* {@code invoice-payments} under {@code LineItemsBox/LineItems}, eight in
- * {@code invoice-workspace} and four in {@code invoice-merchant}, each with ninety or more
+ * {@code invoice-workspace} and four in {@code invoice-merchant}, each with 92 or more
* paragraphs on its page to choose from, so it is not for want of one. Seven are painted for a
* block of a sidebar or a closing banner: six in {@code invoice-luma_studio_long} — the brand
* lockup's four text badges on page 1 and the closing banner's two on page 3
@@ -118,12 +123,28 @@ List named() {
}
}
+ /**
+ * The ledger as a failure reads it: each document, how many drawings it is allowed to leave on
+ * the page, and the reason recorded for them. A change that moves a drawing onto the sheet, or
+ * carries one at last, fails against this.
+ */
+ private static String ledgerAsText() {
+ StringBuilder text = new StringBuilder();
+ new TreeMap<>(STILL_ON_THE_PAGE).forEach((stem, still) -> text.append(" ").append(stem).append(": ")
+ .append(still.named().size()).append(" — ").append(still.reason()).append(System.lineSeparator()));
+ return text.toString();
+ }
+
/**
* How many floating drawings the corpus has a node paint, as a floor: a reader that stopped
* finding them would otherwise report every document clean. Measured at 760 over 71 documents,
* beside 381 inline ones, which are placed from nothing and so are not this guard's business.
+ *
+ *
Held close to the measurement rather than loosely, since the slack is what a drawing could
+ * go missing in: a document that stops painting a drawing altogether is not this guard's
+ * subject, but a reader that stops seeing them is exactly what the floor is here to catch.
*/
- private static final int AT_LEAST_THIS_MANY_DRAWINGS = 700;
+ private static final int AT_LEAST_THIS_MANY_DRAWINGS = 750;
/**
* How many page fills the corpus holds, as a floor, each of which must come back placed from
@@ -176,7 +197,9 @@ void everyDrawingOfTheCorpusIsCarriedByAParagraphBarTheOnesLedgered() throws Exc
Map> ledgered = new TreeMap<>();
STILL_ON_THE_PAGE.forEach((stem, still) ->
ledgered.put(stem, still.named().stream().sorted().toList()));
- assertThat(onThePage).as("the drawings still placed from the page, each ledgered with its reason")
+ // The reasons go into the message, so that a failure says what the ledger claims about each
+ // document and the reader can see at once which claim the change has broken.
+ assertThat(onThePage).as("the drawings still placed from the page, against the ledger:%n%s", ledgerAsText())
.isEqualTo(ledgered);
}
@@ -359,6 +382,24 @@ void anAnchorWithNoNameOrANameOfItsOwnIsAProblem() throws Exception {
.contains("named neither a drawing nor a page background"));
}
+ @Test
+ void aKeywordWhereTheOffsetShouldBeIsAProblemAndNotNought() throws Exception {
+ // wp:align in place of wp:posOffset is schema-legal and says a keyword — "bottom" — where
+ // the export says a number. Read as a missing offset it would come out as nought, which is
+ // a drawing sitting exactly on its paragraph's top: a reading, and a wrong one.
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph("" + ACROSS
+ + "bottom"
+ + ""
+ + "")))));
+
+ assertThat(placements.problems()).singleElement()
+ .satisfies(problem -> assertThat(problem).contains("\"Drawing 1\"")
+ .contains("says how far down in nothing this reader can read"));
+ assertThat(placements.placements()).singleElement()
+ .satisfies(drawing -> assertThat(drawing.down()).as("not read as nought").isEqualTo(Long.MIN_VALUE));
+ }
+
@Test
void anOffsetThatIsNoNumberIsAProblem() throws Exception {
DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
@@ -368,7 +409,7 @@ void anOffsetThatIsNoNumberIsAProblem() throws Exception {
+ "")))));
assertThat(placements.problems()).singleElement()
- .satisfies(problem -> assertThat(problem).contains("no offset down this reader can read"));
+ .satisfies(problem -> assertThat(problem).contains("says how far down in nothing this reader can read"));
}
/** Placed across from the page's edge, so a fixture may be about what it is placed down from. */
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
index aa9336f7a..ce0957a3c 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
@@ -217,6 +217,9 @@ private void readPart(String part, byte[] bytes) throws XMLStreamException {
case "posOffset" -> {
readingOffset = true;
offset.setLength(0);
+ if ("V".equals(position) && !being.isEmpty()) {
+ being.peek().saidHowFarDown = true;
+ }
}
case "docPr" -> {
if (!being.isEmpty()) {
@@ -239,7 +242,8 @@ private void readPart(String part, byte[] bytes) throws XMLStreamException {
case "anchor", "inline" -> {
if (!being.isEmpty()) {
Being read = being.pop();
- add(part, read.name, read.placedFrom, read.placedAcross, read.down, read.floating,
+ add(part, read.name, read.placedFrom, read.placedAcross,
+ read.saidHowFarDown ? read.down : Long.MIN_VALUE, read.floating,
read.simplePos);
}
}
@@ -262,6 +266,12 @@ private static final class Being {
private String name;
private long down;
private String simplePos;
+ /**
+ * Whether its {@code wp:positionV} gave an offset at all. A schema-legal
+ * {@code bottom} gives a keyword instead, which would otherwise leave
+ * the offset at nought and read as a drawing sitting on its paragraph's top.
+ */
+ private boolean saidHowFarDown;
private Being(boolean floating) {
this.floating = floating;
@@ -294,8 +304,11 @@ private void add(String part, String name, String placedFrom, String placedAcros
} else if (floating && !placement.placedFrom().equals("paragraph") && !placement.placedFrom().equals("page")) {
problems.add(where + " is placed down from \"" + placement.placedFrom() + "\", which this reader does not know");
}
- if (floating && down == Long.MIN_VALUE) {
- problems.add(where + " has no offset down this reader can read");
+ // Only where it does name something to be placed down from: an anchor with no
+ // wp:positionV at all is that one problem, not two.
+ if (floating && !placement.placedFrom().isEmpty() && down == Long.MIN_VALUE) {
+ problems.add(where + " says how far down in nothing this reader can read: a wp:posOffset that is"
+ + " no number, or a wp:align keyword in its place");
}
// The export places a drawing across from its cell's text column, or from the page's edge
// where no cell holds it. Anything else is a drawing this reader was not written to judge.
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
index e690139b1..b82d0bf3c 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
@@ -12,9 +12,7 @@
import org.assertj.core.groups.Tuple;
import org.junit.jupiter.api.Test;
-import java.io.ByteArrayOutputStream;
import java.lang.reflect.Modifier;
-import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -23,13 +21,11 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
-import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicReference;
import java.util.jar.JarFile;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
-import java.util.zip.ZipOutputStream;
import static com.demcha.compose.document.templates.fidelity.DocxFixtures.NAMESPACES;
import static com.demcha.compose.document.templates.fidelity.DocxFixtures.assertLaidOut;
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
index 47ba973ac..6cab468ef 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
@@ -2,6 +2,7 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.EllipseBuilder;
+import com.demcha.compose.document.dsl.LayerStackBuilder;
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.dsl.SectionBuilder;
import com.demcha.compose.document.node.LayerAlign;
@@ -57,7 +58,8 @@ private FlowOwnerDrawingDocuments() {
*
* @param name its name, which a failure is reported under
* @param writer the writer of text the drawings' owners hold, named for the report
- * @param drawings how many drawings a node paints in it
+ * @param drawings how many floating drawings a node paints in it — the ones placed from
+ * something, which are the only ones that can be placed from the page
* @param compose how it is composed
*/
record FlowOwnerDocument(String name, String writer, int drawings, Consumer compose) {
@@ -144,12 +146,20 @@ private static void iconOverListCard(DocumentSession session) {
/**
* A table a cell of which holds nothing but a disc, its neighbour holding text. A drawing that
* is all its cell holds is the export's own exception: it is anchored in that cell's paragraph
- * directly, without the walk from the cell whose box holds it
- * ({@code DocxDrawingAnchors#anchorInCell}), so this document holds that path and not the walk.
+ * directly, placed from the cell's text column, without the walk from the cell whose box holds
+ * it — the exception a sidebar strip's band icons needed, each alone in the first column of its
+ * row.
*
- *
The walk itself has no document here. A composed cell holding text as well as a drawing
- * leaves the drawing on the page today, as 25 of the corpus's do, so there is nothing for a
- * document of the rule to assert: the corpus ledger carries that case until the export does.
+ *
The cell is composed of a layer stack of one layer, which is what the exception asks for:
+ * it fires for a layer stack or a shape container that only draws, holds no rule, states no
+ * margin, is laid out on one page and paints inside its box. A bare {@code EllipseNode} in the
+ * cell is none of those things and would be anchored by the ordinary walk instead, which is
+ * what the paragraph documents above already hold.
+ *
+ *
The walk from a holding cell has no document here. A composed cell holding text as well as
+ * a drawing leaves the drawing on the page today, as 25 of the corpus's do, so there is nothing
+ * for a document of the rule to assert: the corpus ledger carries that case until the export
+ * does.
*/
private static void discAloneInACell(DocumentSession session) {
session.pageSize(300, 320).margin(MARGIN);
@@ -158,8 +168,10 @@ private static void discAloneInACell(DocumentSession session) {
.addTable(table -> table
.columns(DocumentTableColumn.fixed(80), DocumentTableColumn.fixed(170))
.defaultCellStyle(CELLS)
- .rowCells(DocumentTableCell.node(new EllipseBuilder().circle(18).fillColor(INK)
- .stroke(DocumentStroke.of(INK, 1)).build()),
+ .rowCells(DocumentTableCell.node(new LayerStackBuilder()
+ .layer(new EllipseBuilder().circle(18).fillColor(INK)
+ .stroke(DocumentStroke.of(INK, 1)).build())
+ .build()),
DocumentTableCell.text("Beside the disc, in the cell next to it."))
.rowCells(DocumentTableCell.text("Second row"), DocumentTableCell.text("Under the disc")))
.addParagraph(p -> text(p, "Under the table, on the same page.")));
From cd67d32c74fa6ab792a775d5009ce9155e7009d9 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 30 Oct 2026 01:47:15 +0000
Subject: [PATCH 3/4] test(docx): name the gate each fixture really passes, and
read an anchor placed down twice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The document for a drawing that is all a table cell holds named the wrong gate. It said
drawingCellFor, and that a shape container would do as well as a layer stack. Neither is so:
drawingCellFor wants a placement of its own and content composed in a cell has none, so the gate
that fires is anchorComposedDrawing, and the shapes are collected by DocxCellDrawings.shapesOf,
whose parameter is a layer stack. A shape container in that cell would stop the exception firing
while the ordinary walk carried the drawing anyway — green, and covering nothing. The document
was already a layer stack of one layer; now the Javadoc says which gate that satisfies and that a
shape container is not one of them.
The reason recorded for the one drawing whose own block does write paragraphs on its page said
how far it rises, within what a drawing is allowed. The rise is not what refuses it, and not
because it is within the allowance: risesNoFurther is reached only from Seat.place, which a
composed cell's drawing takes by way of placeBeside, while a drawing a node paints is placed by
Seat.placeHeld, which never asks. The reason now names what placeHeld does refuse on, with the
one of those that is measured out.
An anchor carrying two wp:positionV left this reader saying what one of them names and how far
down the other says, silently; it is now a problem. An inline drawing's offset reads 0 as its
contract says, rather than the value a floating anchor gets when its offset cannot be read.
The ledger's record holds its drawings as a list, not an array, so its equality is its drawings.
The documents of the rule are all read before the test fails, since a document for each writer of
text is there to name the writer that stopped offering its paragraph.
---
.../fidelity/DocxDrawingAnchorGuardTest.java | 67 ++++++++++++++-----
.../fidelity/DocxDrawingPlacements.java | 33 ++++++---
.../templates/fidelity/DocxFixtures.java | 5 +-
.../fidelity/FlowOwnerDrawingDocuments.java | 21 +++---
4 files changed, 93 insertions(+), 33 deletions(-)
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
index 3a5d268f0..b59eb7b46 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
@@ -75,9 +75,19 @@ class DocxDrawingAnchorGuardTest {
/**
* A drawing whose holding block does write paragraphs on its page — four of them stand within
- * its path — and which none of them was placed from even so. It rises 3.43pt above the nearest
- * paragraph's top, within the 24pt a drawing may rise, and that top is known, so the refusal is
- * inside the walk through the row; which step of the walk refuses has not been measured.
+ * its path — and which none of them was placed from even so.
+ *
+ *
How far it rises is not what refuses it, and not because the rise is within what is
+ * allowed: the rise is not on this path at all. {@code DocxDrawingAnchors.Seat.risesNoFurther}
+ * is consulted only from {@code Seat.place}, which a composed cell's drawing reaches by way of
+ * {@code placeBeside}. A drawing a node paints is placed by {@code Seat.placeHeld}, which never
+ * asks how far it rises.
+ *
+ *
What {@code placeHeld} does refuse on is a short list, and one of them is measured out: a
+ * paragraph whose top is not known, which this one's is (52.80pt, not NaN). The rest stand:
+ * a repeated header row, a paragraph not set from its cell's left, a cell whose width or left
+ * edge is not known in twips, and a cell not set from its top whose row hands the drawing to
+ * no neighbour. Which of those it is has not been measured.
*/
private static final String HOLDER_PARAGRAPH_REFUSED =
"paragraphs of its block stand on its page and none of them placed it";
@@ -116,10 +126,14 @@ class DocxDrawingAnchorGuardTest {
* reason the sheet is right for them
* @param drawings their names in the file, as the export writes them
*/
- private record OnThePage(String reason, String... drawings) {
+ private record OnThePage(String reason, List drawings) {
+
+ OnThePage(String reason, String... drawings) {
+ this(reason, List.of(drawings));
+ }
List named() {
- return List.of(drawings);
+ return drawings;
}
}
@@ -205,6 +219,10 @@ void everyDrawingOfTheCorpusIsCarriedByAParagraphBarTheOnesLedgered() throws Exc
@Test
void aDrawingWhoseOwnerWritesTextIsCarriedByAParagraph() throws Exception {
+ // Every document is read before anything fails: the point of a document per writer of text
+ // is to name which writer stopped offering its paragraph, and the first failure would
+ // otherwise hide the other five.
+ List wrong = new ArrayList<>();
for (FlowOwnerDocument document : FlowOwnerDrawingDocuments.documents()) {
AtomicReference report = new AtomicReference<>();
byte[] docx;
@@ -215,18 +233,23 @@ void aDrawingWhoseOwnerWritesTextIsCarriedByAParagraph() throws Exception {
assertLaidOut(document.name(), report.get());
DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx);
- assertThat(placements.problems()).as("%s", document.name()).isEmpty();
- assertThat(placements.floatingPainted()).as("%s: the drawings it floats", document.name())
- .hasSize(document.drawings());
- assertThat(placements.paintedOnThePage())
- .as("%s: nothing %s paints is left on the sheet", document.name(), document.writer())
- .isEmpty();
+ placements.problems().forEach(problem -> wrong.add(document.name() + ": " + problem));
+ if (placements.floatingPainted().size() != document.drawings()) {
+ wrong.add(document.name() + ": floats " + placements.floatingPainted().size()
+ + " drawings, not the " + document.drawings() + " it paints");
+ }
// Not merely "not on the sheet": written as what moves with the text. An export that
- // put these in the text instead would satisfy every assertion above.
- assertThat(placements.floatingPainted())
- .as("%s: each drawing placed down from a paragraph of %s", document.name(), document.writer())
- .allSatisfy(drawing -> assertThat(drawing.placedFrom()).isEqualTo("paragraph"));
+ // put these in the text instead would leave nothing on the sheet either.
+ for (DocxDrawingPlacements.Placement drawing : placements.floatingPainted()) {
+ if (!drawing.placedFrom().equals("paragraph")) {
+ wrong.add(document.name() + ": " + drawing.name() + " is placed from \""
+ + drawing.placedFrom() + "\" and not down from a paragraph of "
+ + document.writer());
+ }
+ }
}
+
+ assertThat(wrong).as("every drawing carried by the writer of text its owner holds").isEmpty();
}
// The reader, on files written by hand: it must find what it is to judge, wherever it stands,
@@ -344,6 +367,20 @@ void anAnchorWithNothingToBePlacedFromIsAProblem() throws Exception {
.contains("names nothing to be placed down from"));
}
+ @Test
+ void anAnchorPlacedDownTwiceIsAProblem() throws Exception {
+ // What it is placed from would come from one of them and how far down from the other.
+ DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
+ body(paragraph("" + ACROSS
+ + "1270"
+ + ""
+ + "top"
+ + "")))));
+
+ assertThat(placements.problems()).anySatisfy(problem -> assertThat(problem)
+ .contains("\"Drawing 1\"").contains("carries more than one wp:positionV"));
+ }
+
@Test
void anAnchorPlacedFromSomethingElseIsAProblem() throws Exception {
DocxDrawingPlacements placements = DocxDrawingPlacements.read(docx(Map.of("word/document.xml",
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
index ce0957a3c..1951ea2eb 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
@@ -24,10 +24,14 @@
* "Drawing N" and a page's fill under "Page background N", so the two are told apart in the file
* ({@link Placement#paints} and {@link Placement#isPageBackground()}).
*
- *
{@link #problems()} names every anchor this reader cannot classify — one with no
- * {@code wp:positionV}, one whose {@code wp:docPr} carries no name, one named neither a drawing
- * nor a page background, and one placed from something neither {@code paragraph} nor {@code page}.
- * A reader that fell silent would otherwise let a broken export pass as a clean one.
+ *
{@link #problems()} names every anchor this reader cannot classify: one with no
+ * {@code wp:positionV}, or more than one; one whose {@code wp:docPr} carries no name; one named
+ * neither a drawing nor a page background; one placed down from something neither
+ * {@code paragraph} nor {@code page}, or across from something neither {@code column} nor
+ * {@code page}; one saying how far down in something that is not a number, a {@code wp:align}
+ * keyword included; and one sent to an absolute point by {@code simplePos}, which leaves what it
+ * says it is placed from unread by the editors. A reader that fell silent would otherwise let a
+ * broken export pass as a clean one.
*/
final class DocxDrawingPlacements {
@@ -62,7 +66,9 @@ private DocxDrawingPlacements() {
* @param placedFrom what its {@code wp:positionV} names — {@code paragraph} or {@code page}
* — or an empty string for an inline drawing, which names nothing
* @param placedAcross what its {@code wp:positionH} names, or an empty string for an inline one
- * @param down its {@code wp:positionV} offset in EMU, 0 for an inline one
+ * @param down its {@code wp:positionV} offset in EMU; 0 for an inline drawing, which
+ * is placed from nothing, and {@link Long#MIN_VALUE} for a floating one
+ * whose offset this reader could not read
* @param floating whether it floats ({@code wp:anchor}) rather than standing in the text
* @param simplePos what a floating drawing's {@code simplePos} attribute says, or an empty
* string for an inline one. At {@code 1} Word and LibreOffice place the
@@ -205,6 +211,11 @@ private void readPart(String part, byte[] bytes) throws XMLStreamException {
case "positionV" -> {
position = "V";
if (!being.isEmpty()) {
+ if (being.peek().placedFrom != null) {
+ // A second one would leave this reader saying what one of them
+ // names and how far down the other says.
+ being.peek().placedDownTwice = true;
+ }
being.peek().placedFrom = xml.getAttributeValue(null, "relativeFrom");
}
}
@@ -243,8 +254,8 @@ private void readPart(String part, byte[] bytes) throws XMLStreamException {
if (!being.isEmpty()) {
Being read = being.pop();
add(part, read.name, read.placedFrom, read.placedAcross,
- read.saidHowFarDown ? read.down : Long.MIN_VALUE, read.floating,
- read.simplePos);
+ !read.floating || read.saidHowFarDown ? read.down : Long.MIN_VALUE,
+ read.floating, read.simplePos, read.placedDownTwice);
}
}
default -> {
@@ -272,6 +283,8 @@ private static final class Being {
* the offset at nought and read as a drawing sitting on its paragraph's top.
*/
private boolean saidHowFarDown;
+ /** Whether it carried more than one {@code wp:positionV}, which no reading of it can trust. */
+ private boolean placedDownTwice;
private Being(boolean floating) {
this.floating = floating;
@@ -287,7 +300,7 @@ private static long parse(String text) {
}
private void add(String part, String name, String placedFrom, String placedAcross, long down, boolean floating,
- String simplePos) {
+ String simplePos, boolean placedDownTwice) {
Placement placement = new Placement(part, name == null ? "" : name,
placedFrom == null ? "" : placedFrom, placedAcross == null ? "" : placedAcross, down, floating,
simplePos == null ? "" : simplePos);
@@ -324,5 +337,9 @@ private void add(String part, String name, String placedFrom, String placedAcros
problems.add(where + " is placed across from \"" + placement.placedAcross()
+ "\", which this reader does not know");
}
+ if (placedDownTwice) {
+ problems.add(where + " carries more than one wp:positionV, so what it is placed down from and how"
+ + " far down cannot be read from the same one");
+ }
}
}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
index b17a66d2b..e5259170a 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
@@ -16,8 +16,9 @@
* by hand, so a reader can be held to finding what it is to judge in a file put together on
* purpose, and the check that an export was made from its document's layout.
*
- *
A guard's own fixtures stay with the guard — a page reference's fields, a drawing's anchor —
- * and only what every one of them needs is here.
+ *
A guard's own fixtures stay with the guard — a page reference's fields, a drawing's anchor.
+ * Here are the parts of a DOCX itself, which carry nothing of either guard's subject: the
+ * namespaces every hand-written part declares, the three part kinds, a paragraph, and the zip.
*/
final class DocxFixtures {
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
index 6cab468ef..16a375510 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
@@ -9,7 +9,6 @@
import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
-import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.svg.SvgIcon;
import com.demcha.compose.document.table.DocumentTableCell;
@@ -150,11 +149,18 @@ private static void iconOverListCard(DocumentSession session) {
* it — the exception a sidebar strip's band icons needed, each alone in the first column of its
* row.
*
- *
The cell is composed of a layer stack of one layer, which is what the exception asks for:
- * it fires for a layer stack or a shape container that only draws, holds no rule, states no
- * margin, is laid out on one page and paints inside its box. A bare {@code EllipseNode} in the
- * cell is none of those things and would be anchored by the ordinary walk instead, which is
- * what the paragraph documents above already hold.
+ *
The cell is composed of a layer stack of one layer, which is what the exception asks for.
+ * It is reached by {@code DocxSemanticBackend#anchorComposedDrawing}, and only for a
+ * {@code LayerStackNode}: the shapes are collected by {@code DocxCellDrawings#shapesOf}, whose
+ * parameter is a layer stack, so a {@code ShapeContainerNode} is not one of them and a
+ * bare {@code EllipseNode} is not either — both would be anchored by the ordinary walk instead,
+ * which is what the paragraph documents above already hold. The other conditions are that the
+ * node be composed in the cell (laid out with no place of its own), that the cell hold nothing
+ * yet, that the stack hold no rule, and that the drawing be the one still waiting in the
+ * cell's box.
+ *
+ *
Not {@code drawingCellFor}, which gates the same exception for a node written in the body:
+ * it wants a placement of its own, and a node composed in a cell has none by definition.
*
*
The walk from a holding cell has no document here. A composed cell holding text as well as
* a drawing leaves the drawing on the page today, as 25 of the corpus's do, so there is nothing
@@ -169,8 +175,7 @@ private static void discAloneInACell(DocumentSession session) {
.columns(DocumentTableColumn.fixed(80), DocumentTableColumn.fixed(170))
.defaultCellStyle(CELLS)
.rowCells(DocumentTableCell.node(new LayerStackBuilder()
- .layer(new EllipseBuilder().circle(18).fillColor(INK)
- .stroke(DocumentStroke.of(INK, 1)).build())
+ .layer(new EllipseBuilder().circle(18).fillColor(INK).build())
.build()),
DocumentTableCell.text("Beside the disc, in the cell next to it."))
.rowCells(DocumentTableCell.text("Second row"), DocumentTableCell.text("Under the disc")))
From 2f8d8344e40ca8842561bd9f790aa7f78b116bc9 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 30 Oct 2026 02:19:54 +0000
Subject: [PATCH 4/4] test(docx): say what the six documents really vary, and
count a drawing's places down
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The documents were described as one for each writer of a paragraph that carries a drawing. They
are not: offerASeat is keyed on a paragraph node, so the body's paragraph, a panel's and a column
of a row are the same writer, and what those three vary is the holder whose paragraph the walk
must find. The six do cover all three writers that offer a paragraph — a paragraph node, a list's
item, the line two halves of a band share — and the exception for a drawing that is all a cell
holds, which offers none, and they are now described that way. The record's component is named
for what carries the drawing rather than for a writer.
Both ledger reasons named only the first leg of the walk. A drawing is left on the page only once
the fall back to the text nearest it has refused it as well, so both now say so.
A second wp:positionV is counted rather than inferred from whether the last one named anything to
be placed from: a first element without relativeFrom would otherwise pass unnoticed and leave its
offset read against the second's name.
The page-reference guard takes its header parts from the shared fixtures too, which is what moving
them out was for.
---
CHANGELOG.md | 24 ++++++++++--------
.../fidelity/DocxDrawingAnchorGuardTest.java | 8 +++---
.../fidelity/DocxDrawingPlacements.java | 11 ++++----
.../fidelity/DocxPageReferenceGuardTest.java | 11 ++++----
.../fidelity/FlowOwnerDrawingDocuments.java | 25 ++++++++++++-------
5 files changed, 46 insertions(+), 33 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b89b6299f..662836254 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4454,18 +4454,22 @@ follow semantic versioning; release dates are ISO 8601.
every suite green.
- Held over the template corpus, where 728 of the 760 floating drawings a node paints are
carried by a paragraph — its 381 inline ones stand in the text itself and are placed from
- nothing — and over six documents of its own, one for each writer of a paragraph that carries
- a drawing today: a paragraph of the body, a panel's, a column of a row, an item of a list,
- the one line two halves of a band share, and a table cell a drawing is all of. The corpus
- fixes what the export does today; the six hold the rule, and every drawing in them must be
- placed down from a paragraph, not merely kept off the page.
+ nothing — and over six documents of its own, one for each paragraph that carries a drawing
+ today: a paragraph of the body, a panel's, a column of a row, an item of a list, the one line
+ two halves of a band share, and a table cell a drawing is all of. Between them they cover all
+ three writers of text that offer a paragraph — a paragraph node, a list's item, a band's
+ shared line — and the exception for a drawing that is all a cell holds, which offers none.
+ The corpus fixes what the export does today; the six hold the rule, and every drawing in them
+ must be placed down from a paragraph, not merely kept off the page.
- The 32 the corpus still places from the page are named one by one with the reason each is
there: 25 of a table's composed cell, where the walk from the cell whose box holds the
- drawing finds no paragraph of that cell it can be placed from; 6 whose owner's own text is
- written as the drawing itself — a brand lockup's badges and a closing banner's, laid over the
- flow — so no paragraph of the blocks holding it stands on its page at all; and one standing
- 3.43pt above the nearest paragraph's top, with four paragraphs of its own block on its page
- that none of them was placed from. The list is a ledger of work
+ drawing finds no paragraph of that cell it can be placed from and the fallback to the nearest
+ text refuses it too; 6 whose owner's own text is written as the drawing itself — a brand
+ lockup's badges and a closing banner's, laid over the flow — so no paragraph of the blocks
+ holding it stands on its page at all; and one with four paragraphs of its own block on its
+ page that none of them was placed from, nor any other paragraph of that page. How far that
+ one rises is not what refuses it: the rise is read only on the path a composed cell's drawing
+ takes, and a drawing a node paints is placed by a walk that never asks. The list is a ledger of work
still to do and not a set of cases where the page is right: when the export learns to carry
those drawings, the test fails until the ledger is brought down to match.
- It fails closed on a reader that stops reading. Every page fill must come back placed from
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
index b59eb7b46..c4d5dd41e 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
@@ -62,7 +62,8 @@ class DocxDrawingAnchorGuardTest {
* not a mechanism.
*/
private static final String COMPOSED_CELL_WALK_STOPS =
- "a composed cell's drawing, which the walk from its holding cell found no paragraph for";
+ "a composed cell's drawing, which the walk from its holding cell found no paragraph for,"
+ + " and which the fall back to the text nearest it did not place either";
/**
* Drawings painted for a block whose own text is written as the drawing itself — a lockup or a
@@ -90,7 +91,8 @@ class DocxDrawingAnchorGuardTest {
* no neighbour. Which of those it is has not been measured.
*/
private static final String HOLDER_PARAGRAPH_REFUSED =
- "paragraphs of its block stand on its page and none of them placed it";
+ "paragraphs of its block stand on its page and none of them placed it, nor did any other"
+ + " paragraph of that page once the nearest were tried in turn";
/**
* The drawings of the corpus still placed from the page: which they are, and why each is there.
@@ -244,7 +246,7 @@ void aDrawingWhoseOwnerWritesTextIsCarriedByAParagraph() throws Exception {
if (!drawing.placedFrom().equals("paragraph")) {
wrong.add(document.name() + ": " + drawing.name() + " is placed from \""
+ drawing.placedFrom() + "\" and not down from a paragraph of "
- + document.writer());
+ + document.carrier());
}
}
}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
index 1951ea2eb..87c5d58c0 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
@@ -211,11 +211,10 @@ private void readPart(String part, byte[] bytes) throws XMLStreamException {
case "positionV" -> {
position = "V";
if (!being.isEmpty()) {
- if (being.peek().placedFrom != null) {
- // A second one would leave this reader saying what one of them
- // names and how far down the other says.
- being.peek().placedDownTwice = true;
- }
+ // Counted, not inferred from what the last one said: a first
+ // wp:positionV with no relativeFrom of its own would otherwise go
+ // unnoticed and leave its offset read against the second's name.
+ being.peek().placedDownTwice = ++being.peek().positionVs > 1;
being.peek().placedFrom = xml.getAttributeValue(null, "relativeFrom");
}
}
@@ -285,6 +284,8 @@ private static final class Being {
private boolean saidHowFarDown;
/** Whether it carried more than one {@code wp:positionV}, which no reading of it can trust. */
private boolean placedDownTwice;
+ /** How many {@code wp:positionV} it carried, whatever each of them named. */
+ private int positionVs;
private Being(boolean floating) {
this.floating = floating;
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
index b82d0bf3c..394bb762d 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxPageReferenceGuardTest.java
@@ -27,10 +27,10 @@
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
-import static com.demcha.compose.document.templates.fidelity.DocxFixtures.NAMESPACES;
import static com.demcha.compose.document.templates.fidelity.DocxFixtures.assertLaidOut;
import static com.demcha.compose.document.templates.fidelity.DocxFixtures.body;
import static com.demcha.compose.document.templates.fidelity.DocxFixtures.docx;
+import static com.demcha.compose.document.templates.fidelity.DocxFixtures.header;
import static com.demcha.compose.document.templates.fidelity.DocxFixtures.paragraph;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@@ -178,7 +178,7 @@ void aFieldOnABookmarkTheBodyLacksIsAProblem() throws Exception {
DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
"word/document.xml", body(paragraph(bookmark("1", "intro") + field(" PAGEREF terms \\h ", "3")
+ field(" PAGEREF head \\h ", "3"))),
- "word/header1.xml", "" + paragraph(bookmark("2", "head")) + "")));
+ "word/header1.xml", header(paragraph(bookmark("2", "head"))))));
assertThat(fields.problems()).satisfiesExactly(
problem -> assertThat(problem).contains("PAGEREF \"terms\"").contains("names a bookmark the file does not hold"),
@@ -195,8 +195,7 @@ void aFieldIsFoundInAHeaderANestedTableAndATextBox() throws Exception {
+ "");
DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
"word/document.xml", body(nested + textBox),
- "word/header1.xml", "" + paragraph(field(" PAGEREF inHeader \\h ", "1"))
- + "")));
+ "word/header1.xml", header(paragraph(field(" PAGEREF inHeader \\h ", "1"))))));
assertThat(fields.pageReferences()).extracting(DocxPageReferenceFields.Field::bookmark)
.containsExactlyInAnyOrder("inTable", "inBox", "inHeader");
@@ -280,8 +279,8 @@ void aStrayInstructionASimpleFieldClosedOverAnotherOrAnIdOpenedTwiceIsAProblem()
+ "";
DocxPageReferenceFields fields = DocxPageReferenceFields.read(docx(Map.of(
"word/document.xml", body(paragraph(stray)),
- "word/header1.xml", "" + paragraph(over) + "",
- "word/header2.xml", "" + paragraph(twice) + "")));
+ "word/header1.xml", header(paragraph(over)),
+ "word/header2.xml", header(paragraph(twice)))));
assertThat(fields.problems()).satisfiesExactly(
problem -> assertThat(problem).isEqualTo("an instruction where no field's code is open, in word/document.xml"),
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
index 16a375510..3053bad4a 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
@@ -20,15 +20,22 @@
import java.util.function.Consumer;
/**
- * Small documents whose every drawing is painted for a block that writes text in the flow: one for
- * each writer of a paragraph that carries a drawing today — a paragraph of the body, a panel's, a
- * column of a row, an item of a list, the one line two halves of a band share, and a table cell a
- * drawing is all of.
+ * Small documents whose every drawing is painted for a block that writes text in the flow, one for
+ * each paragraph that carries a drawing today: a paragraph of the body, a panel's, a column of a
+ * row, an item of a list, the one line two halves of a band share, and a table cell a drawing is
+ * all of.
+ *
+ *
Between them they cover all three writers of text that offer a paragraph to the drawings
+ * standing beside it — a paragraph node, an item of a list, and the line two halves of a band
+ * share — and the exception for a drawing that is all a table cell holds, which offers no
+ * paragraph and anchors the drawing in the cell itself. The first three documents share the
+ * paragraph node as their writer and differ in the holder whose paragraph the walk must find: the
+ * body's flow, a panel, a column of a row.
*
*
The template corpus proves what the export does today over whole presets, where a drawing's
* owner is buried in a preset's tree. These documents hold the rule itself: a drawing whose owner
* writes text must be carried by a paragraph, never nailed to the sheet, and each document is
- * small enough that a failure names the writer that stopped offering its paragraph
+ * small enough that a failure names what stopped carrying the drawing
* ({@link DocxDrawingAnchorGuardTest}).
*
*
Each is laid out on a small page, its drawing standing beside its owner's text so that there
@@ -53,18 +60,18 @@ private FlowOwnerDrawingDocuments() {
}
/**
- * A document and the writer of text whose paragraph is to carry its drawings.
+ * A document and what must carry its drawings.
*
* @param name its name, which a failure is reported under
- * @param writer the writer of text the drawings' owners hold, named for the report
+ * @param carrier what must carry the drawings — the paragraph, or the cell, named for the report
* @param drawings how many floating drawings a node paints in it — the ones placed from
* something, which are the only ones that can be placed from the page
* @param compose how it is composed
*/
- record FlowOwnerDocument(String name, String writer, int drawings, Consumer compose) {
+ record FlowOwnerDocument(String name, String carrier, int drawings, Consumer compose) {
}
- /** One document for each writer of a paragraph that may carry a drawing. */
+ /** One document for each paragraph, and the one cell, that carries a drawing today. */
static List documents() {
return List.of(
new FlowOwnerDocument("disc_beside_body_text", "a paragraph of the body", 1,