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(); + } +}