diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a094d1f..676fc8653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,38 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's report names where a page zone's parts stand, and what its paragraphs + lose.** A page zone is written as one Word line. Word sets its parts one after another from + the page's left margin, and those after the first spacer against its right margin, on one + baseline, its tallest part's. The page sets each part by the zone's padding, a row's columns + and gap and a paragraph's alignment, and each part's sides, on a baseline of its own. So + these moved in Word without a note: + - a centred line, a padded zone, a row's column or gap; + - a page field's sides; + - a smaller part beside a larger one, which Word sets on the larger one's baseline. + + A zone paragraph's right-to-left direction, prefix letters, fitted size and outline entry + went unnamed too. A `page zone` note now: + - counts the parts that stand off where the page sets them. Each part is read from the + layout's zone fragments on the first page the zone is drawn on. A part stands where the + page sets it when it meets all of these: + - its line starts within a point and a half of Word's, or, against the right margin, ends + there; + - it sits on Word's baseline: its tallest part's, with the line standing at the zone's edge; + - it is one line; + - no prefix stands before it, on the line's left side. + + Past a part Word sets at another width, in a zone whose nodes the page names or nests + otherwise than the file, and with no layout, the note says where the parts stand is not + measured; + - names a zone paragraph's right-to-left text written left to right, its prefix's letters, + the size its text is fitted to, and its outline entry. + + None of this changes what is written, and no document of the DOCX fidelity corpus has a page + zone. In `DocxNodeFieldLedgerTest` a page field's `padding` and `margin` move from a gap to + `REPORTED`, and its `align` to `INERT`: the page sets a field in a box a point wider than its + number. The `zones` option keeps a gap for the line's height and the room its parts hold above + and below, and a zone paragraph's anchor. 1 node-field gap remains, `CanvasLayerNode.height`. - **A DOCX export's report names what a paragraph's own fields lose.** Several of a paragraph's own fields were lost without a note: - an auto-sized paragraph's text was written at its style's size, not the one the page fits it @@ -34,7 +66,7 @@ follow semantic versioning; release dates are ISO 8601. side holds the line's level. A paragraph set in a text box over the flow carries these on the note it already had. A page - zone's paragraphs are not named yet: that stays a gap of the `zones` option. Across + zone's paragraphs are named on the zone's own note (`page zone`). Across the DOCX fidelity corpus the report names no paragraph: `EngineeringResume`'s auto-sized name fits at its own size. None of this changes what is written: the 62 documents of the corpus export to the same bytes. In `DocxNodeFieldLedgerTest` a paragraph's `autoSize`, diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 549d6cc2b..633e20b7e 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -115,7 +115,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; 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 | -| 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, 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`. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) 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, 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; 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 Word's baseline with the line at the zone's edge, 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 and outline entry, which the line does not carry; the line's height and the room its parts hold above and below are Word's, and a zone paragraph's anchor has no bookmark, none of these yet named. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) 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 769002cf7..ac3c48b0f 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -67,7 +67,7 @@ creation date is real metadata. | Document node | DOCX output | |---|---| -| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; outside a header or footer, the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and outside a header or footer the report names both sizes where Word, to its half point, holds them apart. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | +| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, as a badge's initials, or in a header or footer; the report names a prefix's letters, and the room a prefix sets lines in by where a path that writes none leaves it out and it moves a line — on the paragraph, or in a header or footer on the zone's note. An auto-sized paragraph (`autoSize`) is written at its style's size, not the one the page fits its text to, and the report names both sizes where Word, to its half point, holds them apart, on the paragraph or, in a header or footer, on the zone's note. A body paragraph the layout moves to a new page keeps the space the layout leaves above its text there — its own top edge, the edges of the containers opening with it, and the gap before it where the gap did not fit at the foot of the page above — as an empty line that tall before it, kept with it, since Word drops a paragraph's space above at the top of a page and keeps a line's height; the rest of the space owed stays above that line, so Word breaks the page where it did, and the gap between the paragraph's lines comes off the line as it would off its space above. A paragraph in a table cell, an overlay or a list is left to its container | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | | Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A row held at the page's height is written less its margins and those rules too — a rule and a half in the first row and the last, two in a table of one row — since both editors read a row's written height as its cells' content (measured: held less one rule, a table ruled at 0.75pt stood 0.46pt taller in its first row and 0.36pt in its last). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one. A table or a row the layout moves to a new page keeps its own top edge there, as the page does — written as a line that tall, kept with it, since Word drops a paragraph's space above at the top of a page — while the gap between it and the block before stays at the foot of the page above, where it fits there; a gap the layout carries onto the new page, because it did not fit at the foot of the page above, is not yet held above a table (body paragraphs and spacers: see their rows). A table's margin is its indent and the space round it, and its padding on the sides holds its rows in as the page draws them: its left side is in the indent too, and both are out of the room its columns are given | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in, less its own margins and padding — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | @@ -1007,6 +1007,20 @@ A zone is written from its paragraphs, page fields and spacers: anything else in logo, a panel, a table — is not written, and the export report names it once (`page zone content`). +The zone's line is Word's: its parts one after another from the page's left margin, and +those after the first spacer against its right margin, at the right tab the line holds, all +on one baseline, its tallest part's. Where the page sets a part elsewhere — by the zone's +padding, a row's columns and gap, a paragraph's alignment or a part's own sides — off that +baseline, 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 +at another width — after a prefix, auto-sized to a size the file does not hold, 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 +own too: its right-to-left direction, a prefix's letters, the size an auto-sized one is fitted +to, its outline entry. The line's height and the room its parts hold above and below are +Word's, and an anchor in a zone has no bookmark; none of these is named yet. + A zone drawn on some pages only (`appliesTo(...)`) lands on the same pages when Word can say so. Word has a header and footer for the first page, for even pages and for the rest, so the predicate is asked over sample pages and sorted into those kinds: diff --git a/render-docx/README.md b/render-docx/README.md index d8d7fbad5..c357a7b8e 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -113,6 +113,8 @@ What is not written — each one is named in the export report - **A row's own fill, outline and side borders**, named in the export report as `row paint`. - **In a page zone, anything but paragraphs, page fields and spacers** — a logo, a barcode, a rule — named in the export report as `page zone content`. +- **Where a page zone's parts stand on Word's line, and what a paragraph in it loses of its + own**, named in the export report as `page zone`. - **A list's own geometry where Word cannot hold it**, named in the export report on the list: - a centred or right-aligned list's alignment; - its `lineSpacing` where the layout's items are not its own; @@ -121,7 +123,7 @@ What is not written — each one is named in the export report too narrow for its marker — while a flat hanging-indent list keeps the page's column; - a row the page draws as a marker alone, for a blank item. - **What a paragraph's own fields set where Word cannot hold it**, named in the export report on - the paragraph outside a header or footer (a page zone's paragraphs are not named yet): + the paragraph outside a header or footer (a page zone's are named on the zone): - the size an auto-sized paragraph's text is fitted to, where Word, to its half point, holds it apart from its style's; - the letters of a `bulletOffset` prefix; and, where it moves a line, the room a prefix sets diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index e0e3648fc..c340422fd 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -496,6 +496,62 @@ OptionalDouble zoneDistanceFromEdge(int zoneIndex, boolean header, double pageHe return OptionalDouble.of(header ? pageHeight - highest : lowest); } + /** + * The text a page zone's content laid out, on the first page the zone is drawn on: each + * node's first fragment of paragraph lines, by its path within the content. + * + *
A zone's content is compiled on its own, its paths as a document's of that one root, + * and spliced in under {@code @page-zone[page][index]} (see {@link #zoneDistanceFromEdge}). + * The export builds the content again, so it finds its nodes here by those paths + * ({@link #pathsWithin}).
+ * + * @param zoneIndex the zone's position in the section's zone list + * @return the fragments by path, empty when the layout carries no such zone + */ + MapWord sets the line's parts one after another from the page's left margin, and those + * after the first spacer against its right margin, at the right tab the line holds; a part + * after a second spacer goes to no tab the line holds. The page sets each part where the + * zone's padding, a row's columns and gap and the part's own alignment and sides put it, on + * a baseline of its own. Word's line has one baseline, its tallest part's, and stands at the + * zone's edge — the foot of a footer, the head of a header ({@link #placeZone}). A part + * stands where the page sets it when it is one line — the zone's line is written with none + * of what holds a body paragraph's breaks where the page sets them — on Word's baseline, and + * its line starts within {@link #ZONE_PLACE_CLEARANCE} of Word's start, or, against the + * right margin, ends within it of Word's end; a part a prefix stands before starts off it, + * the prefix being unwritten. Word's place for a part follows from the widths of those + * before it on its side of the line: past a part whose width Word does not keep — of more + * lines than one, after a prefix, or auto-sized to a size the file does not hold — or one + * the layout does not show, where it stands across the line is not measured. A zone whose + * nodes the page names or nests otherwise than the file, built for no page in particular, + * shows none of its parts.
+ * + *A paragraph's own losses are named too: its right-to-left text is written left to + * right, its prefix's letters are not written, its text is written at its style's size, + * and its outline entry is not written.
+ * + * @param zoneIndex the zone's position in the section's zone list + * @param header whether the zone is a header + * @param content the zone's content, as written + */ + private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content) { + ListIts text is written at its style's size, not the one the page fits it to. A prefix's
* letters are never written. A path that does not write a prefix as a distance
@@ -6749,7 +7019,7 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
*/
private List A node's entries are about the body. A page zone is written as one line of its
* paragraphs' runs, page fields and tabs, which loses more of a paragraph and a row than the
- * body does; that is recorded once, as the {@code zones} output option's gap.
The entries are claims about the export, not proofs of it: a {@code WRITTEN} field is * proved by the export's own tests, a {@code REPORTED} one by a report test, and a {@code GAP} @@ -95,9 +96,11 @@ private record Entry(Fate fate, String note) { "viewerPreferences:REPORTED", "headersAndFooters:WRITTEN", // The node entries below are the body's. A zone is written as one line of its - // paragraphs' runs, page fields and tabs, which is a gap of its own. - "zones:GAP:in a page zone, a paragraph's alignment, spacing, direction, prefix, fitted size and " - + "outline entry and a row's columns, gap and padding; whatever else a zone holds is reported"); + // paragraphs' runs, page fields and tabs; what of that the report does not name is + // a gap of its own. + "zones:GAP:in a page zone, its line's height and the room its parts hold above and below their " + + "text, which Word sets its own way, and a paragraph's anchor; where its parts stand across the " + + "line and on its baseline, and what else a zone holds, is reported"); static { node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN"); @@ -154,8 +157,14 @@ private record Entry(Fate fate, String note) { + "two spaces a level in"); node(PageBreakNode.class, "name:INERT", "margin:INERT"); node(PageFieldNode.class, "name:INERT", "kind:WRITTEN", "textStyle:WRITTEN", - "align:GAP:its alignment in a page zone", "padding:GAP:its sides in a page zone", - "margin:GAP:its sides in a page zone"); + "align:INERT:the page sets a field in a box a point wider than its number, which its " + + "alignment moves it no further within", + "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 field's above and below, in the " + + "zones option's gap", + "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 field's above and below, in the " + + "zones option's gap"); 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", diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java new file mode 100644 index 000000000..01b89987b --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneReportTest.java @@ -0,0 +1,313 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.semantic.SemanticBackend; +import com.demcha.compose.document.backend.semantic.SemanticExportContext; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.layout.DocumentGraph; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.PageFieldKind; +import com.demcha.compose.document.node.PageFieldNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.node.TextDirection; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; +import com.demcha.compose.document.output.DocumentPageZone; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextIndent; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What a page zone loses as the one line of a Word header or footer it is written as is in the + * report: where its parts stand, and what a paragraph among them loses of its own. + * + *
Word sets the line's parts one after another from the page's left margin, and those after + * the first spacer against its right margin. The page sets each where the zone's padding, a row's + * columns and gap and the part's own alignment and sides put it. A zone whose parts stand where + * Word sets them, and whose paragraphs lose nothing of their own, is not named.
+ */ +class DocxZoneReportTest { + + private static final DocumentTextStyle CHROME = DocumentTextStyle.DEFAULT.withSize(8); + private static final String FOOTER = "a footer written as one line of Word's footer; "; + private static final String OFF = "its text stands off where the page sets it"; + + @Test + void aLineSetFromTheLeftMarginIsNotNamed() throws Exception { + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential").build()))).isEmpty(); + // Its parts after a spacer end at the right margin, where Word's right tab holds them. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .flexSpacer() + .add(page.pageNumber(CHROME)) + .build()))).isEmpty(); + } + + @Test + void aPartThePageSetsElsewhereThanWordIsNamed() throws Exception { + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential").align(TextAlign.CENTER).build()))) + .containsExactly(FOOTER + OFF); + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential").build()).toBuilder() + .padding(new DocumentInsets(0, 0, 0, 20)).build())) + .as("the zone's own padding").containsExactly(FOOTER + OFF); + assertThat(zoneNotes(DocumentPageZone.header(30, page -> text("Confidential").margin(new DocumentInsets(0, 0, 0, 12)) + .build()))) + .as("a paragraph's own side, in a header").containsExactly("a header written as one line of Word's header; " + OFF); + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> page.pageNumber(CHROME)))) + .as("a page number set from the left").isEmpty(); + // The page sets a field in a box a point wider than its number, which its alignment moves + // it within; its own side moves it as a paragraph's does. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new PageFieldNode("Number", PageFieldKind.NUMBER, + CHROME, TextAlign.RIGHT, DocumentInsets.zero(), DocumentInsets.zero())))).as("aligned right").isEmpty(); + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new PageFieldNode("Number", PageFieldKind.NUMBER, + CHROME, TextAlign.LEFT, DocumentInsets.zero(), new DocumentInsets(0, 0, 0, 12))))) + .as("set in by its margin").containsExactly(FOOTER + OFF); + // A row of a spacer and a page number sets the number against the right margin, as Word does. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .flexSpacer() + .add(page.pageNumber(CHROME)) + .build()))).isEmpty(); + } + + @Test + void theLineTheReportReadsIsTheOneWritten() throws Exception { + // One right tab, at the right margin, which the report's line holds its right side at. + try (DocumentSession session = session(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .flexSpacer() + .add(page.pageNumber(CHROME)) + .build()))) { + try (org.apache.poi.xwpf.usermodel.XWPFDocument document = new org.apache.poi.xwpf.usermodel.XWPFDocument( + new java.io.ByteArrayInputStream(session.export(new DocxSemanticBackend())))) { + var tabs = document.getFooterArray(0).getParagraphs().get(0).getCTP().getPPr().getTabs(); + assertThat(tabs.sizeOfTabArray()).isEqualTo(1); + assertThat(tabs.getTabArray(0).getVal().toString()).isEqualTo("right"); + assertThat(DocxTwips.of(tabs.getTabArray(0).getPos())).isEqualTo(Math.round((300 - 2 * 36) * 20.0)); + } + } + } + + @Test + void aRowsColumnsAndGapAreCountedWherePartsStandOffWordsLine() throws Exception { + // Without a spacer, Word sets the second right after the first; the page sets it at its column. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .addParagraph(p -> p.text("Acme").textStyle(CHROME)) + .build()))) + .containsExactly(FOOTER + "1 of its 2 parts stands off where the page sets them"); + // After a spacer, the page keeps its gap between the parts; Word sets them against each other. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line").spacing(8) + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME)) + .add(page.pageNumber(CHROME)) + .build()))) + .containsExactly(FOOTER + "1 of its 3 parts stands off where the page sets them"); + // A part after a second spacer goes to no tab the line holds. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME)) + .flexSpacer() + .add(page.pageNumber(CHROME)) + .build()))) + .containsExactly(FOOTER + "2 of its 3 parts stand off where the page sets them"); + } + + @Test + void partsSetOneAfterAnotherStandWhereWordSetsThem() throws Exception { + // Packed at either end, as Word sets them from the left margin and against the right. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .addParagraph(p -> p.text("Acme").textStyle(CHROME)) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME)) + .add(page.pageNumber(CHROME)) + .build()))).isEmpty(); + } + + @Test + void aPartOnABaselineOfItsOwnOrOfMoreLinesThanOneIsCounted() throws Exception { + // Word sets a line's parts on one baseline, its tallest part's; the page sets the parts from + // the top, the smaller ones higher. + assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .flexSpacer() + .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18))) + .build()))) + .containsExactly(FOOTER + "1 of its 2 parts stands off where the page sets them"); + assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18))) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME)) + .add(page.pageNumber(CHROME)) + .build()))) + .as("both smaller parts move onto the tallest one's baseline") + .containsExactly(FOOTER + "2 of its 3 parts stand off where the page sets them"); + assertThat(zoneNotes(DocumentPageZone.header(40, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18))) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME)) + .add(page.pageNumber(CHROME)) + .build()))) + .as("in a header too").containsExactly("a header written as one line of Word's header; 2 of its 3 " + + "parts stand off where the page sets them"); + // Word stands the line at the footer's foot, which a smaller part set lower holds: the + // larger part moves down onto it, and the smaller one onto the larger one's baseline. + assertThat(zoneNotes(DocumentPageZone.footer(60, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18))) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME).margin(new DocumentInsets(30, 0, 0, 0))) + .build()))) + .containsExactly(FOOTER + "2 of its 2 parts stand off where the page sets them"); + // A part the page seats off its baseline is set on Word's. + assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(DocumentTextStyle.DEFAULT.withSize(18))) + .flexSpacer() + .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18)) + .verticalAlign(com.demcha.compose.document.node.TextVerticalAlign.CENTER)) + .build()))) + .containsExactly(FOOTER + "1 of its 2 parts stands off where the page sets them"); + // The zone's line holds none of what keeps a body paragraph's breaks where the page sets them. + assertThat(zoneNotes(DocumentPageZone.footer(40, page -> text( + "Confidential and proprietary: not for distribution outside the company").build()))) + .containsExactly(FOOTER + OFF); + } + + @Test + void aZoneParagraphsOwnLossesAreNamed() throws Exception { + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential") + .direction(TextDirection.RTL).align(TextAlign.LEFT).build()))) + .singleElement().asString().contains("a paragraph's right-to-left text is written left to right"); + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential").bulletOffset("• ") + .indentStrategy(DocumentTextIndent.FIRST_LINE).build()))) + .as("a prefix Word does not write stands the text off, and its letters are lost") + .containsExactly(FOOTER + OFF + "; a paragraph's bulletOffset letters, \"•\", are not written before " + + "its first line"); + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Hi").autoSize(14).build()))) + .containsExactly(FOOTER + "a paragraph's text is written at 8pt, where the page fits it to 14pt"); + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> text("Confidential") + .bookmark(new DocumentBookmarkOptions("Confidential", 0)).build()))) + .containsExactly(FOOTER + "a paragraph's outline entry is not written"); + } + + @Test + void wherePartsStandPastOneWordSetsAtAnotherWidthIsNotMeasured() throws Exception { + // Word writes no prefix, so the part after it starts where the layout does not tell. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME).bulletOffset("• ") + .indentStrategy(DocumentTextIndent.FIRST_LINE)) + .addParagraph(p -> p.text("Acme").textStyle(CHROME)) + .flexSpacer() + .add(page.pageNumber(CHROME)) + .build()))) + .containsExactly(FOOTER + "1 of its 3 parts stands off where the page sets them; where 1 of its 3 " + + "parts stands is not measured; a paragraph's bulletOffset letters, \"•\", are not " + + "written before its first line"); + // Nor after a part auto-sized to a size the file does not hold; set lower than Word's + // baseline, the part after it stands off all the same. + assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Hi").textStyle(CHROME).autoSize(14)) + .addParagraph(p -> p.text("Acme").textStyle(CHROME)) + .addParagraph(p -> p.text("Co").textStyle(CHROME)) + .build()))) + .containsExactly(FOOTER + "2 of its 3 parts stand off where the page sets them; a paragraph's text is " + + "written at 8pt, where the page fits it to 14pt"); + // Against the right margin, the part a prefix stands before ends where Word ends it; the + // part before it, Word sets the prefix's width off. + assertThat(zoneNotes(DocumentPageZone.footer(30, page -> new RowBuilder().name("Line") + .addParagraph(p -> p.text("Confidential").textStyle(CHROME)) + .flexSpacer() + .addParagraph(p -> p.text("v2.4").textStyle(CHROME)) + .addParagraph(p -> p.text("Acme").textStyle(CHROME).bulletOffset(" ") + .indentStrategy(DocumentTextIndent.FIRST_LINE)) + .add(page.pageNumber(CHROME)) + .build()))) + .containsExactly(FOOTER + "where 1 of its 4 parts stands is not measured"); + } + + @Test + void aZoneWhoseNodesThePageNestsOtherwiseIsNotMeasured() throws Exception { + // Written once for every page, the zone is built as for no page in particular: the last, + // as far as it can tell, where the page's first is not, and its nodes are another tree. + AtomicReference