diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfe8302e8..662836254 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4444,6 +4444,47 @@ 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 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 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
+ 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..c4d5dd41e
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingAnchorGuardTest.java
@@ -0,0 +1,468 @@
+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: 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, 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
+ * 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.
+ *
+ *
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, 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.
+ * 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 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
+ * ({@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, List drawings) {
+
+ OnThePage(String reason, String... drawings) {
+ this(reason, List.of(drawings));
+ }
+
+ List named() {
+ return drawings;
+ }
+ }
+
+ /**
+ * 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 = 750;
+
+ /**
+ * 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()));
+ // 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);
+ }
+
+ @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;
+ 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);
+
+ 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 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.carrier());
+ }
+ }
+ }
+
+ 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,
+ // 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 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",
+ 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 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",
+ body(paragraph("" + ACROSS
+ + ""
+ + "somewhere"
+ + "")))));
+
+ assertThat(placements.problems()).singleElement()
+ .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. */
+ 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..87c5d58c0
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxDrawingPlacements.java
@@ -0,0 +1,346 @@
+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}, 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 {
+
+ 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 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
+ * 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()) {
+ // 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");
+ }
+ }
+ case "positionH" -> {
+ position = "H";
+ if (!being.isEmpty()) {
+ being.peek().placedAcross = xml.getAttributeValue(null, "relativeFrom");
+ }
+ }
+ case "posOffset" -> {
+ readingOffset = true;
+ offset.setLength(0);
+ if ("V".equals(position) && !being.isEmpty()) {
+ being.peek().saidHowFarDown = true;
+ }
+ }
+ 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.floating || read.saidHowFarDown ? read.down : Long.MIN_VALUE,
+ read.floating, read.simplePos, read.placedDownTwice);
+ }
+ }
+ 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;
+ /**
+ * 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;
+ /** 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;
+ }
+ }
+
+ 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, boolean placedDownTwice) {
+ 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");
+ }
+ // 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.
+ // 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");
+ }
+ 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
new file mode 100644
index 000000000..e5259170a
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxFixtures.java
@@ -0,0 +1,78 @@
+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.
+ * 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 {
+
+ /** 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..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
@@ -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,14 +21,17 @@
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.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;
@@ -177,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"),
@@ -194,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");
@@ -279,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"),
@@ -298,14 +298,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 +342,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 +354,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..3053bad4a
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/FlowOwnerDrawingDocuments.java
@@ -0,0 +1,215 @@
+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.LayerStackBuilder;
+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.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 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 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
+ * 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 what must carry its drawings.
+ *
+ * @param name its name, which a failure is reported under
+ * @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 carrier, int drawings, Consumer compose) {
+ }
+
+ /** 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,
+ 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, 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 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
+ * 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 LayerStackBuilder()
+ .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")))
+ .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();
+ }
+}