From 9398d46662f046bafa5fba3a0cf2aae72bedf84d Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 14:38:11 +0100 Subject: [PATCH 1/2] fix(docx): name timeline rails once and correct the ledger's fates - Every rail the layout draws shares one path, so each timeline, and each page a rail runs onto, named it again, word for word: cv-terracotta_rail carried the note twice. The rail is named once a path a section; what no paragraph carries is reckoned for all of them together, as before. - The field ledger stated WRITTEN, or a narrower case, for fields a path loses and the report names: the geometry and paint of a drawing the export drops (no layout, a gradient alone, nothing painted); a paragraph's line spacing and a section's or a line's keeps with no layout; keeps inside a panel the layout runs on; a page field's kind, style, padding and margin in the flow. - A page field's alignment moves its number up to a point in its box; the ledger, the capability matrix and the DOCX recipe said it moved nothing. - Two losses no note names are gaps now: a spacer level with another layer's content in a band or a stack written as columns, where that content is not written in its place; and a paragraph composed in a table cell whose text no laid-out line carries, written with no line gap. - A v2.5.0 CHANGELOG bullet still said a band opening with a list or a row loses the space above it, which a later change fixed. --- CHANGELOG.md | 30 +++++- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 7 +- .../docx/DocxFlowContainerReportTest.java | 29 ++++++ .../docx/DocxNodeFieldLedgerTest.java | 96 +++++++++++++------ .../docx/DocxValueFieldLedgerTest.java | 8 +- 7 files changed, 133 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557fe3bce..0a3736dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export names a document's timeline rails once.** + - Every rail the layout draws shares one path, so each timeline named its rail again under + it, word for word, and a rail running onto another page named it again there: + `cv-terracotta_rail`, with two timelines, carried the note twice. It is named once a + section, and what no paragraph carries is reckoned for all of them together, as before. + - The file is unchanged: none of the corpus's 62 DOCX changes, and it carries one note fewer. + - **A DOCX export sets a panel set in from its table cell's text where the page sets its text.** - In a cell, Word and LibreOffice set a nested table's left border with its outer edge at the cell's text, or as far in as its indent sets it, and its text that border's centre and the @@ -318,8 +325,9 @@ follow semantic versioning; release dates are ISO 8601. 14pt margin stood 14pt high, as did one with a backdrop laid first and one nested in a stack of one layer with that margin; one in a section with a 10pt margin stood 10pt high. Held, each stands where the page sets it, within 1.2pt, and the backdrop where the page draws it. - - A band whose first block is a list or a row still loses that space, measured; nor is it held - for a table, a panel or a line pair written first. + - A band whose first block is a list, a row, a table, a panel or a line pair holds that space + too, as the entry above on the space above a list, a line pair, a table, a row or a panel + says. - A later layer whose own margin, or a picture's room in its box, reaches above where the layers before it end stands lower than the page sets it by as much as it reaches above them, 13pt in the measured case. Such a band near the foot of a page, which the page keeps there, now moves @@ -3863,6 +3871,24 @@ follow semantic versioning; release dates are ISO 8601. ### Tests +- **The DOCX field ledger states where a field it gave as written, or as moving nothing, is + lost.** + - A drawing the export drops — with no layout, painted only with a gradient, or painting + nothing — loses its geometry and paint, which its own note names; a line or a shape written + as a rule keeps them. With no layout, a paragraph's line spacing and a section's or a + line's keeps are lost, as the section's note says; inside a panel the layout runs on to the + next page, a keep is named as one Word may break. A page field in the flow is not written, + which its note names. + - A page field's alignment was given as moving nothing, and `TextAlign`'s centre and right + as not applied to one. The page sets the field in a box a point wider than its number, so + its alignment moves the number up to that point. In a page zone, where the part stands is + the zone's note, as for any part. The capability matrix and the DOCX recipe said the same + and are corrected. + - Two losses no note names are now gaps: a spacer of a layer, level with another layer's + content in a band or a stack written as columns, where that content is not written in its + place; and a paragraph composed in a table cell whose text no line the layout set carries, + which is written with no line gap. + - **The DOCX field ledger reaches the values a node holds, and names the losses it knows of and its report does not.** `DocxNodeFieldLedgerTest` gives every field of every node a fate, and stopped at the field: a part added to a value one holds — a style's colour, a run's diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 5710a7520..534607780 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -118,7 +118,7 @@ honour an option ignores it (documented contract). | Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text. A section of more than one page, or with a header or footer, carries them in every header part it has (default, first page, even pages), so they are drawn on every page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. On a page with no top margin LibreOffice still sets the first line about 3pt lower under that header. A section of one page with neither draws them from the body instead — in the first cell's paragraph when the page opens with a table — and its lines stand where the page sets them in both editors (the seven sidebar CVs stood 2.6 to 3.3pt low in LibreOffice); they are on that page alone, so a page an editor's text runs onto has none. A fill's alpha is carried as the shape's. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's | | Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ (not written; reported `DROPPED`, `watermark`) | | Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border, a translucent one flattened against white and reported (`translucency`), one wider or thinner than Word draws held to its range, one of no width drawn at Word's thinnest and one below zero not written, each reported (`border width`); the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported; so are a band too shallow for its line, whose text and separator Word stands further in from the edge (9pt in a header 11pt tall: 2.14pt lower), page numbers in letters past 26, which Word letters by repeating one letter (page 28 `bb`, the page `ab`), slots that overlap where the page sets them, which the page draws over one another and both editors set apart, and a slot wider than the line, which both editors wrap | -| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, an `ImageNode` an inline picture at the size the page draws it — contained or cropped to cover its box as the page draws it, its link its run's — a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Word sets the line's parts one after another from the left margin and, after the first spacer, against the right margin, on one baseline, its tallest part's, in a zone of one line a one-line part the page sets on a baseline of its own raised or lowered to it by `w:position`, in whole half points — lowered only as far as the line holds below its baseline, a fifth of it or as far as its tallest part reaches; LibreOffice raises a run about a seventh further than Word, does not raise a page field and stands every picture on the baseline —; the page sets each by the zone's padding, a row's columns and gap, a paragraph's alignment and a part's own sides (a page field's alignment moves nothing: its box is a point wider than its number). The `page zone` note counts the parts the page sets elsewhere — read from the layout's zone fragments: a part stands where the page sets it when its line starts, or against the right margin ends, within a point and a half of Word's, on the baseline Word sets it on, in one line and with no prefix before it on the left side; past a part whose width Word does not keep, or a zone whose nodes the page names or nests otherwise, where a part stands is said to be not measured — and names a zone paragraph's right-to-left direction, prefix letters, fitted size where it is not measured, markdown marks written as letters, a markdown heading taller than its line, outline entry, anchor (no bookmark) and a picture set off the baseline, which the line does not carry, and a picture part's transform, outline entry and anchor. The line is an exact line as tall as the tallest part's line on the page — taller for a picture in it, a part written at a larger size than the page's or a part raised above it — standing as far from its edge as puts that part's baseline — a picture's foot — where the page has it (a lone or tallest part within 0.1pt in Word and LibreOffice, measured on 8pt and 18pt Lato headers and 8pt Lato footers), a lone part's padding and margin above and below included; a tallest part of more lines than one is as many exact lines, a footer's standing as many lines further from its edge; lines reaching past the page margin by more than half a point are held there with a negative margin and named; a zone the layout measures that shares its kind with another page zone stands in a frame (`w:framePr`) at its own height, at least its lines tall; a zone the page draws otherwise on the page it is measured on than it is written — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for every page Word writes it on is not written and named, as is one building none for the first page or the pages between, where the page draws it. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. The content is built for the first page and for the pages after it (`PageContext.unpaginated(first, last)`): built otherwise for the first, it becomes the title page's part (`w:titlePg`) and the ordinary one, a measured one framed where the two cannot share one distance; built otherwise for the last page, it is written there as built for the pages between and reported. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) — a layout of fewer than six pages also over its own, each as one of as many as it ran to, which decide its kinds of page — and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported | +| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, an `ImageNode` an inline picture at the size the page draws it — contained or cropped to cover its box as the page draws it, its link its run's — a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Word sets the line's parts one after another from the left margin and, after the first spacer, against the right margin, on one baseline, its tallest part's, in a zone of one line a one-line part the page sets on a baseline of its own raised or lowered to it by `w:position`, in whole half points — lowered only as far as the line holds below its baseline, a fifth of it or as far as its tallest part reaches; LibreOffice raises a run about a seventh further than Word, does not raise a page field and stands every picture on the baseline —; the page sets each by the zone's padding, a row's columns and gap, a paragraph's alignment and a part's own sides (a page field's alignment moves its number up to a point, within a box a point wider than it). The `page zone` note counts the parts the page sets elsewhere — read from the layout's zone fragments: a part stands where the page sets it when its line starts, or against the right margin ends, within a point and a half of Word's, on the baseline Word sets it on, in one line and with no prefix before it on the left side; past a part whose width Word does not keep, or a zone whose nodes the page names or nests otherwise, where a part stands is said to be not measured — and names a zone paragraph's right-to-left direction, prefix letters, fitted size where it is not measured, markdown marks written as letters, a markdown heading taller than its line, outline entry, anchor (no bookmark) and a picture set off the baseline, which the line does not carry, and a picture part's transform, outline entry and anchor. The line is an exact line as tall as the tallest part's line on the page — taller for a picture in it, a part written at a larger size than the page's or a part raised above it — standing as far from its edge as puts that part's baseline — a picture's foot — where the page has it (a lone or tallest part within 0.1pt in Word and LibreOffice, measured on 8pt and 18pt Lato headers and 8pt Lato footers), a lone part's padding and margin above and below included; a tallest part of more lines than one is as many exact lines, a footer's standing as many lines further from its edge; lines reaching past the page margin by more than half a point are held there with a negative margin and named; a zone the layout measures that shares its kind with another page zone stands in a frame (`w:framePr`) at its own height, at least its lines tall; a zone the page draws otherwise on the page it is measured on than it is written — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for every page Word writes it on is not written and named, as is one building none for the first page or the pages between, where the page draws it. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. The content is built for the first page and for the pages after it (`PageContext.unpaginated(first, last)`): built otherwise for the first, it becomes the title page's part (`w:titlePg`) and the ordinary one, a measured one framed where the two cannot share one distance; built otherwise for the last page, it is written there as built for the pages between and reported. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) — a layout of fewer than six pages also over its own, each as one of as many as it ran to, which decide its kinds of page — and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported | | Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ (not written, so the file opens unprotected; reported `DROPPED`, `protection`) | | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) | | Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index a1c927424..4f1ec820f 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1488,7 +1488,7 @@ elsewhere — by the zone's padding, a row's columns and gap, a paragraph's alig own sides — lower than the line holds, past the page's edge, over more than one line or after a prefix, the export report counts it (`page zone`): "1 of its 3 parts stands off where the page sets them". A page field's alignment -moves nothing: the page sets it in a box a point wider than its number. Past a part Word sets +moves its number up to a point: the page sets it in a box a point wider than it. Past a part Word sets at another width — after a prefix, auto-sized to a size its lines do not tell, over more lines than one — or in a zone whose nodes the page names or nests otherwise than the file, it says where a part stands is not measured. It names what a paragraph in the zone loses of its diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java index 7e63c70cc..ab4e808f8 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java @@ -1259,10 +1259,13 @@ private byte[] write(List sections, Path outputFile) throws Exc earlierZones.add(DocumentHeaderFooterZone.HEADER); } // Drawing the layout paints in a pass of its own — a timeline's rail — belongs to - // no node, so it waits for the first paragraph on its page. + // no node, so it waits for the first paragraph on its page. Every rail, and every + // page of one, shares the pass's path: it is named once, as what nothing carries + // is reckoned for that path together (DocxDrawingAnchors.Owner). + java.util.Set railsNamed = new java.util.HashSet<>(); for (com.demcha.compose.document.layout.PlacedFragment pass : layout.passFragments()) { drawingFor = new DocxDrawingAnchors.Owner("timeline rail", pass.path()); - if (queueDrawing(pass, drawsInFront())) { + if (queueDrawing(pass, drawsInFront()) && railsNamed.add(pass.path())) { report.add(DocxExportReport.Severity.APPROXIMATED, "timeline rail", pass.path(), "drawn as a line where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java index 57648c1d4..f8e630b62 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java @@ -97,6 +97,35 @@ void aTimelinesMarkersAreNotNamed() throws Exception { assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); } + @Test + void twoTimelinesRailsAreNamedOnce() throws Exception { + // Every rail the layout draws shares the pass's path, so a second timeline named its + // rail again under it, word for word: TerracottaRail's report carried the note twice. + Consumer one = page -> page + .addTimeline(timeline -> timeline.connector(INK, 1.5) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("Senior Engineer").meta("2023 - Present")) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("Engineer").meta("2021 - 2023"))); + Consumer two = page -> { + one.accept(page); + page.addParagraph("Education") + .addTimeline(timeline -> timeline.connector(INK, 1.5) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("MSc").meta("2019 - 2021")) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("BSc").meta("2016 - 2019"))); + }; + + assertThat(detailOf(reportOf(two), "timeline rail")).startsWith("drawn as a line where the layout puts it"); + assertThat(anchoredDrawings(bodyOf(two))).as("both rails drawn, with their markers") + .isEqualTo(2 * anchoredDrawings(bodyOf(one))); + } + + private static int anchoredDrawings(String body) { + return body.split(" bled = page -> page.addSection(band -> band.fillColor(SURFACE) diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index 68d9a81a3..ba0ff5107 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -123,10 +123,15 @@ record Entry(Fate fate, String note) { private static final String PULL = "below zero below it, the pull the space under it and the next block's own " + "top edge do not give back, on the next block (space above); any other is " + "written"; + // A drawing is drawn where the layout puts it, and only where a shape can show it + // (droppedBecause): what the export drops, it names. + private static final String DRAWING_DROPPED = "where the export drops what is drawn, which its note names: with " + + "no layout, painted only with a gradient or painting nothing; any " + + "other is written"; private static final String RULE_TRANSLUCENCY = "its translucency, as a rule's, flattened against the colour " - + "under it; drawn, its alpha is written; any other is written"; + + "under it; drawn, its alpha is written; " + DRAWING_DROPPED; // A rule is a paragraph's border, no wider than 12pt (DocxRules): only a hairline is held. - private static final String RULE_WIDTH = "as a rule, " + BORDER_WIDTH + "; any other is written"; + private static final String RULE_WIDTH = "as a rule, " + BORDER_WIDTH + "; " + DRAWING_DROPPED; private static final String CELL_TRANSLUCENCY = "the translucency of a cell's fill, flattened against the colour " + "under the cell, and of its rules, against its fill where it has " + "one; of its rules, " + BORDER_WIDTH + ", and the room Word " @@ -213,6 +218,17 @@ record Entry(Fate fate, String note) { static final String LAYER_UNLAID = "with no layout, a stack's one layer written where the stack starts, not named"; static final String PANEL_WIDER_THAN_ITS_CELL = "as a panel in a table cell narrower than it, held to the cell " + "where the page draws it past, not named"; + static final String STAND_IN_LEFT_EMPTY = "of a layer, level with another layer's content in a band or a stack " + + "written as columns, where that content is not written in its place, " + + "not written, not named"; + static final String LINE_GAP_UNREAD = "composed in a table cell, where no line the layout set carries its text, " + + "no line gap written, not named"; + // A keep is written only for a block the layout keeps on one page. + private static final String KEEP = "with no layout, in the section's note; inside a panel the layout runs on to " + + "the next page, named as one Word may break; any other is written"; + // A page field is a zone's: in the flow it is dropped with a note. + private static final String FIELD_IN_THE_FLOW = "in the flow, not written, which its note names; any other is " + + "written"; // A row whose children the layout does not place has columns Word sizes to their text. private static final String ROW_COLUMNS = "its columns Word's, sized to their text, where the layout does not " + "show where its children start: composed in a table cell with a " @@ -222,7 +238,8 @@ record Entry(Fate fate, String note) { /** The known cases: a gap's note opens with one of them, or the ledger fails. */ private static final List GAPS = List.of(TOP_PULL, BOTTOM_PULL, PADDING_BELOW_ZERO, SPACE_BELOW_AT_THE_FOOT, PULL_UNRECKONED, CELL_PADDING, LIST_PICTURE, SEAT_UNREAD, ROW_COLUMNS_UNLAID, DRAWING_OVER_TEXT, - COMPOSED_STACK_LAYER, LAYER_PAST_THE_EDGE, LAYER_UNLAID, PANEL_WIDER_THAN_ITS_CELL); + COMPOSED_STACK_LAYER, LAYER_PAST_THE_EDGE, LAYER_UNLAID, PANEL_WIDER_THAN_ITS_CELL, STAND_IN_LEFT_EMPTY, + LINE_GAP_UNREAD); private static final Map, Map> NODES = new LinkedHashMap<>(); private static final Map OUTPUT_OPTIONS = fields( @@ -285,9 +302,10 @@ record Entry(Fate fate, String note) { "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, " + "which has no bookmark; otherwise, in the flow, it is bookmarked", "bookmarkOptions:REPORTED", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); - node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", - "stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", - "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", + node(EllipseNode.class, "name:INERT", "width:REPORTED:" + DRAWING_DROPPED, "height:REPORTED:" + DRAWING_DROPPED, + "fillColor:REPORTED:" + DRAWING_DROPPED, "stroke:REPORTED:" + DRAWING_DROPPED, "linkTarget:REPORTED", + "bookmarkOptions:REPORTED", "padding:REPORTED:" + DRAWING_DROPPED, "margin:REPORTED:" + DRAWING_DROPPED, + "transform:REPORTED", "anchor:REPORTED"); node(ImageNode.class, "name:INERT", "imageData:WRITTEN", "width:WRITTEN", "height:WRITTEN", "scale:WRITTEN", "fitMode:WRITTEN", "linkTarget:REPORTED", @@ -311,16 +329,22 @@ record Entry(Fate fate, String note) { "margin:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + SPACE_BELOW_AT_THE_FOOT + "; any other is written", "clipToBounds:REPORTED:where it cuts what its layers paint; composed in a table cell, on its table"); - node(LineNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "startX:WRITTEN", "startY:WRITTEN", - "endX:WRITTEN", "endY:WRITTEN", + node(LineNode.class, "name:INERT", "width:REPORTED:" + DRAWING_DROPPED, "height:REPORTED:" + DRAWING_DROPPED, + "startX:REPORTED:" + DRAWING_DROPPED, "startY:REPORTED:" + DRAWING_DROPPED, + "endX:REPORTED:" + DRAWING_DROPPED, "endY:REPORTED:" + DRAWING_DROPPED, "stroke:REPORTED:its translucency, as a rule's, flattened against the colour under it, and as a " - + "rule, " + BORDER_WIDTH + "; drawn, its alpha is written; any other is written", + + "rule, " + BORDER_WIDTH + "; drawn, its alpha is written; " + DRAWING_DROPPED, "linkTarget:REPORTED", - "bookmarkOptions:REPORTED", "padding:GAP:" + PULL_UNRECKONED + "; as a rule, " + PULLED_UP + "; any other is written", - "margin:GAP:" + PULL_UNRECKONED + "; as a rule, " + PULLED_UP + "; any other is written", "transform:REPORTED", + "bookmarkOptions:REPORTED", + "padding:GAP:" + PULL_UNRECKONED + "; as a rule, " + PULLED_UP + "; " + DRAWING_DROPPED, + "margin:GAP:" + PULL_UNRECKONED + "; as a rule, " + PULLED_UP + "; " + DRAWING_DROPPED, + "transform:REPORTED", "dashPattern:REPORTED", - "anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a rule's other than butt, a border ending flat; drawn, it is written", - "fillWidth:WRITTEN", "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in"); + "anchor:REPORTED:drawn; a rule in the flow is bookmarked", + "lineCap:REPORTED:a rule's other than butt, a border ending flat; drawn, it is written; " + + DRAWING_DROPPED, + "fillWidth:WRITTEN", + "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in; " + KEEP); node(ListNode.class, "name:INERT", "items:GAP:" + LIST_PICTURE + "; a blank one a hangingIndent list draws as its marker alone, the " + "marks of one " @@ -344,15 +368,17 @@ record Entry(Fate fate, String note) { "markerGap:REPORTED:where an item stands at a stated column, a space past its marker or " + "two spaces a level in"); node(PageBreakNode.class, "name:INERT", "margin:INERT"); - node(PageFieldNode.class, "name:INERT", "kind:WRITTEN", "textStyle:WRITTEN", - "align:INERT:the page sets a field in a box a point wider than its number, which its " - + "alignment moves it no further within", + node(PageFieldNode.class, "name:INERT", "kind:REPORTED:" + FIELD_IN_THE_FLOW, + "textStyle:REPORTED:" + FIELD_IN_THE_FLOW, + "align:REPORTED:the page sets a field in a box a point wider than its number, which its " + + "alignment moves it up to that point within; in a page zone, where its part stands is the " + + "zone's note; " + FIELD_IN_THE_FLOW, "padding:REPORTED:at its sides, and above and below beside other parts, where they stand the " + "field off the place Word sets it on the zone's line; a lone or tallest field's above and " - + "below are written, where its line stands", + + "below are written, where its line stands; " + FIELD_IN_THE_FLOW, "margin:REPORTED:at its sides, and above and below beside other parts, where they stand the " + "field off the place Word sets it on the zone's line; a lone or tallest field's above and " - + "below are written, where its line stands"); + + "below are written, where its line stands; " + FIELD_IN_THE_FLOW); node(PageReferenceNode.class, "name:INERT", "anchor:REPORTED:where the anchor has no bookmark, its number is written as text", "textStyle:WRITTEN", "align:WRITTEN", "placeholderText:WRITTEN", @@ -365,7 +391,8 @@ record Entry(Fate fate, String note) { + "page's parser drops a mark; any other is written", "inlineRuns:REPORTED:a chip's translucent fill, flattened against the colour under it, with what " + "else of its shape Word cannot hold; a run's translucent colour is written as Word's text fill", - "textStyle:WRITTEN", "align:WRITTEN", "lineSpacing:WRITTEN", + "textStyle:WRITTEN", "align:WRITTEN", + "lineSpacing:GAP:" + LINE_GAP_UNREAD + "; with no layout, in the section's note; any other is written", "bulletOffset:REPORTED:its letters before the first line; over the flow, as a side of an " + "overlay's pair or as a badge's text, the room it sets lines in by where that moves one", "indentStrategy:REPORTED:as its bulletOffset's: a prefix's letters before the first line, and over " @@ -383,11 +410,15 @@ record Entry(Fate fate, String note) { "verticalAlign:GAP:" + SEAT_UNREAD + "; its first text line's seat for all its lines, a line in " + "another size or face named from half a point; " + BADGE_SEAT + "; any other is written", "anchor:WRITTEN", "direction:WRITTEN"); - node(PathNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "segments:WRITTEN", - "fillColor:WRITTEN", "fillPaint:REPORTED", "stroke:WRITTEN", "strokePaint:REPORTED", - "padding:WRITTEN", "margin:WRITTEN", "dashPattern:REPORTED", "lineCap:WRITTEN", "lineJoin:WRITTEN"); - node(PolygonNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "points:WRITTEN", - "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN"); + node(PathNode.class, "name:INERT", "width:REPORTED:" + DRAWING_DROPPED, "height:REPORTED:" + DRAWING_DROPPED, + "segments:REPORTED:" + DRAWING_DROPPED, "fillColor:REPORTED:" + DRAWING_DROPPED, "fillPaint:REPORTED", + "stroke:REPORTED:" + DRAWING_DROPPED, "strokePaint:REPORTED", "padding:REPORTED:" + DRAWING_DROPPED, + "margin:REPORTED:" + DRAWING_DROPPED, "dashPattern:REPORTED", "lineCap:REPORTED:" + DRAWING_DROPPED, + "lineJoin:REPORTED:" + DRAWING_DROPPED); + node(PolygonNode.class, "name:INERT", "width:REPORTED:" + DRAWING_DROPPED, "height:REPORTED:" + DRAWING_DROPPED, + "points:REPORTED:" + DRAWING_DROPPED, "fillColor:REPORTED:" + DRAWING_DROPPED, + "stroke:REPORTED:" + DRAWING_DROPPED, "padding:REPORTED:" + DRAWING_DROPPED, + "margin:REPORTED:" + DRAWING_DROPPED); node(RowNode.class, "name:INERT", "children:WRITTEN", "weights:WRITTEN", "gap:WRITTEN", "padding:GAP:" + TOP_PULL + "; " + BOTTOM_PULL + "; " + SPACE_BELOW_AT_THE_FOOT + "; " + PADDING_BELOW_ZERO + "; any other is written", @@ -415,11 +446,11 @@ record Entry(Fate fate, String note) { "fillColor:REPORTED:" + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_TRANSLUCENCY, "stroke:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_BORDERS, "cornerRadius:REPORTED", "borders:REPORTED:" + RIGHT_BORDER + "; " + RIGHT_END + "; " + LEFT_EDGE + "; " + PANEL_BORDERS, - "keepTogether:WRITTEN", + "keepTogether:REPORTED:" + KEEP, "anchor:REPORTED:as a layer stack's column, or where it writes no paragraph, as of drawings alone, " + "which has no bookmark; otherwise, in the flow, it is bookmarked", "bleed:REPORTED:of a panel the page bleeds, in the flow it pages", - "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN", + "bookmarkOptions:REPORTED", "keepWithNext:REPORTED:" + KEEP, "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); node(ShapeContainerNode.class, "name:INERT", "outline:GAP:" + PANEL_WIDER_THAN_ITS_CELL + "; drawn as a shape, corners whose radii differ once " @@ -455,14 +486,17 @@ record Entry(Fate fate, String note) { + "and the cell sets what it holds at its middle or foot, as far as Word does not count it " + "there; any other is written", "transform:REPORTED"); - node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:REPORTED:" + RULE_WIDTH, + node(ShapeNode.class, "name:INERT", "width:REPORTED:" + DRAWING_DROPPED, "height:REPORTED:" + RULE_WIDTH, "fillColor:REPORTED:" + RULE_TRANSLUCENCY, - "stroke:WRITTEN", "cornerRadius:REPORTED:corners unequal once held to half its shorter side, drawn at the largest radius", + "stroke:REPORTED:" + DRAWING_DROPPED, + "cornerRadius:REPORTED:corners unequal once held to half its shorter side, drawn at the largest " + + "radius; " + DRAWING_DROPPED, "linkTarget:REPORTED", "bookmarkOptions:REPORTED", - "padding:GAP:" + TOP_PULL + "; any other is written", - "margin:GAP:" + TOP_PULL + "; any other is written", "transform:REPORTED", "fillPaint:REPORTED", + "padding:GAP:" + TOP_PULL + "; " + DRAWING_DROPPED, + "margin:GAP:" + TOP_PULL + "; " + DRAWING_DROPPED, "transform:REPORTED", "fillPaint:REPORTED", "anchor:REPORTED:drawn; a rule in the flow is bookmarked"); - node(SpacerNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", + node(SpacerNode.class, "name:INERT", "width:WRITTEN", + "height:GAP:" + STAND_IN_LEFT_EMPTY + "; any other is written", "padding:REPORTED:above and below; below a block a band or a column measures from, written", "margin:REPORTED:above and below; below a block a band or a column measures from, written", "grow:WRITTEN"); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java index 28f293a81..661a99678 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java @@ -113,12 +113,12 @@ class DocxValueFieldLedgerTest { + "to, where its lines tell it, as its autoSize says"); value(com.demcha.compose.document.node.TextAlign.class, "CENTER:REPORTED:a list's items, written flush left, named; in a page zone, where its part stands " - + "is the zone's note; a page field's the page does not apply; any other is written as Word's " - + "alignment, turned to the flow in a right-to-left paragraph", + + "is the zone's note; a page field's in the flow, not written, which its node's note names; any " + + "other is written as Word's alignment, turned to the flow in a right-to-left paragraph", "LEFT:WRITTEN:turned to the flow in a right-to-left paragraph", "RIGHT:REPORTED:a list's items, written flush left, named; in a page zone, where its part stands " - + "is the zone's note; a page field's the page does not apply; any other is written as Word's " - + "alignment, turned to the flow in a right-to-left paragraph"); + + "is the zone's note; a page field's in the flow, not written, which its node's note names; any " + + "other is written as Word's alignment, turned to the flow in a right-to-left paragraph"); value(com.demcha.compose.document.node.PageFieldKind.class, "NUMBER:WRITTEN:as Word's page field on a page zone's line, each section counting from 1 as on the " + "page; a field in the body is not written, which its node's note names", From bead7027c83ffe9f4fbd249ecc65e39d02085601 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 23 Oct 2026 15:03:05 +0100 Subject: [PATCH 2/2] test(docx): name every path the ledger's dropped drawings and page fields take - A drawing the export drops is named by a note for every reason it has, a page zone and a page no paragraph is written on among them, not only with no layout, a gradient alone or nothing painted. - A page field is dropped anywhere but on a page zone's line, a table cell or a layer's column as much as the body. - A paragraph's line spacing in a page zone is where the zone's note places its part; a keep composed in a table cell is the row's. - The CHANGELOG says a line or a shape written as a rule is written as a rule, not that it keeps its geometry and paint. --- CHANGELOG.md | 13 +++---- .../docx/DocxNodeFieldLedgerTest.java | 34 +++++++++++-------- .../docx/DocxValueFieldLedgerTest.java | 10 +++--- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3736dcc..238756805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3873,12 +3873,13 @@ follow semantic versioning; release dates are ISO 8601. - **The DOCX field ledger states where a field it gave as written, or as moving nothing, is lost.** - - A drawing the export drops — with no layout, painted only with a gradient, or painting - nothing — loses its geometry and paint, which its own note names; a line or a shape written - as a rule keeps them. With no layout, a paragraph's line spacing and a section's or a - line's keeps are lost, as the section's note says; inside a panel the layout runs on to the - next page, a keep is named as one Word may break. A page field in the flow is not written, - which its note names. + - A drawing the export drops — with no layout, in a page zone, painted only with a gradient + or nothing, or on a page no paragraph is written on — loses its geometry and paint, which a + note names; a line or a shape written as a rule is written as a rule. With no layout, a + paragraph's line spacing and a section's or a line's keeps are lost, as the section's note + says. Inside a panel the layout runs on to the next page, a keep is named as one Word may + break; composed in a table cell, the table row is what is kept. A page field anywhere but + on a page zone's line is not written, which its note names. - A page field's alignment was given as moving nothing, and `TextAlign`'s centre and right as not applied to one. The page sets the field in a box a point wider than its number, so its alignment moves the number up to that point. In a page zone, where the part stands is diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index ba0ff5107..4c0dbdee9 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -123,11 +123,12 @@ record Entry(Fate fate, String note) { private static final String PULL = "below zero below it, the pull the space under it and the next block's own " + "top edge do not give back, on the next block (space above); any other is " + "written"; - // A drawing is drawn where the layout puts it, and only where a shape can show it - // (droppedBecause): what the export drops, it names. - private static final String DRAWING_DROPPED = "where the export drops what is drawn, which its note names: with " - + "no layout, painted only with a gradient or painting nothing; any " - + "other is written"; + // A drawing is drawn where the layout puts it, and only where a shape can show it: what the + // export drops, a note names, whatever the reason. + private static final String DRAWING_DROPPED = "where the export drops what is drawn, which a note names — with no " + + "layout, in a page zone, painted only with a gradient or nothing, on " + + "a page no paragraph is written on, among its reasons; any other is " + + "written"; private static final String RULE_TRANSLUCENCY = "its translucency, as a rule's, flattened against the colour " + "under it; drawn, its alpha is written; " + DRAWING_DROPPED; // A rule is a paragraph's border, no wider than 12pt (DocxRules): only a hairline is held. @@ -225,10 +226,12 @@ record Entry(Fate fate, String note) { + "no line gap written, not named"; // A keep is written only for a block the layout keeps on one page. private static final String KEEP = "with no layout, in the section's note; inside a panel the layout runs on to " - + "the next page, named as one Word may break; any other is written"; - // A page field is a zone's: in the flow it is dropped with a note. - private static final String FIELD_IN_THE_FLOW = "in the flow, not written, which its note names; any other is " - + "written"; + + "the next page, named as one Word may break; composed in a table cell, " + + "where the table row it stands in is what is kept, not written; any other " + + "is written"; + // A page field is a zone line's part: anywhere else it is dropped with a note. + private static final String FIELD_OUTSIDE_A_ZONE = "outside a page zone's line, not written, which its note " + + "names; any other is written"; // A row whose children the layout does not place has columns Word sizes to their text. private static final String ROW_COLUMNS = "its columns Word's, sized to their text, where the layout does not " + "show where its children start: composed in a table cell with a " @@ -368,17 +371,17 @@ record Entry(Fate fate, String note) { "markerGap:REPORTED:where an item stands at a stated column, a space past its marker or " + "two spaces a level in"); node(PageBreakNode.class, "name:INERT", "margin:INERT"); - node(PageFieldNode.class, "name:INERT", "kind:REPORTED:" + FIELD_IN_THE_FLOW, - "textStyle:REPORTED:" + FIELD_IN_THE_FLOW, + node(PageFieldNode.class, "name:INERT", "kind:REPORTED:" + FIELD_OUTSIDE_A_ZONE, + "textStyle:REPORTED:" + FIELD_OUTSIDE_A_ZONE, "align:REPORTED:the page sets a field in a box a point wider than its number, which its " + "alignment moves it up to that point within; in a page zone, where its part stands is the " - + "zone's note; " + FIELD_IN_THE_FLOW, + + "zone's note; " + FIELD_OUTSIDE_A_ZONE, "padding:REPORTED:at its sides, and above and below beside other parts, where they stand the " + "field off the place Word sets it on the zone's line; a lone or tallest field's above and " - + "below are written, where its line stands; " + FIELD_IN_THE_FLOW, + + "below are written, where its line stands; " + FIELD_OUTSIDE_A_ZONE, "margin:REPORTED:at its sides, and above and below beside other parts, where they stand the " + "field off the place Word sets it on the zone's line; a lone or tallest field's above and " - + "below are written, where its line stands; " + FIELD_IN_THE_FLOW); + + "below are written, where its line stands; " + FIELD_OUTSIDE_A_ZONE); node(PageReferenceNode.class, "name:INERT", "anchor:REPORTED:where the anchor has no bookmark, its number is written as text", "textStyle:WRITTEN", "align:WRITTEN", "placeholderText:WRITTEN", @@ -392,7 +395,8 @@ record Entry(Fate fate, String note) { "inlineRuns:REPORTED:a chip's translucent fill, flattened against the colour under it, with what " + "else of its shape Word cannot hold; a run's translucent colour is written as Word's text fill", "textStyle:WRITTEN", "align:WRITTEN", - "lineSpacing:GAP:" + LINE_GAP_UNREAD + "; with no layout, in the section's note; any other is written", + "lineSpacing:GAP:" + LINE_GAP_UNREAD + "; with no layout, in the section's note; in a page zone, " + + "where its part stands is the zone's note; any other is written", "bulletOffset:REPORTED:its letters before the first line; over the flow, as a side of an " + "overlay's pair or as a badge's text, the room it sets lines in by where that moves one", "indentStrategy:REPORTED:as its bulletOffset's: a prefix's letters before the first line, and over " diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java index 661a99678..6bb4ab290 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxValueFieldLedgerTest.java @@ -113,12 +113,14 @@ class DocxValueFieldLedgerTest { + "to, where its lines tell it, as its autoSize says"); value(com.demcha.compose.document.node.TextAlign.class, "CENTER:REPORTED:a list's items, written flush left, named; in a page zone, where its part stands " - + "is the zone's note; a page field's in the flow, not written, which its node's note names; any " - + "other is written as Word's alignment, turned to the flow in a right-to-left paragraph", + + "is the zone's note; a page field's outside a page zone's line, not written, which its node's " + + "note names; any other is written as Word's alignment, turned to the flow in a right-to-left " + + "paragraph", "LEFT:WRITTEN:turned to the flow in a right-to-left paragraph", "RIGHT:REPORTED:a list's items, written flush left, named; in a page zone, where its part stands " - + "is the zone's note; a page field's in the flow, not written, which its node's note names; any " - + "other is written as Word's alignment, turned to the flow in a right-to-left paragraph"); + + "is the zone's note; a page field's outside a page zone's line, not written, which its node's " + + "note names; any other is written as Word's alignment, turned to the flow in a right-to-left " + + "paragraph"); value(com.demcha.compose.document.node.PageFieldKind.class, "NUMBER:WRITTEN:as Word's page field on a page zone's line, each section counting from 1 as on the " + "page; a field in the body is not written, which its node's note names",