From 76f5c62ca180f7c2db9f1d008da8ebbc4c7932ed Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 8 Oct 2026 01:22:54 +0100
Subject: [PATCH 1/3] fix(docx): write a page zone's picture in its line, where
the page draws it
A page zone is written as one line of a Word header or footer, and an
ImageNode in it was not written: DROPPED, page zone content. It is now an
inline picture in the line, at the size the page draws it - contained in
its box or cropped to cover it - its link its run's.
Word stands an inline picture on its line's baseline, so a picture that is
the line's tallest part places the line by its foot, read off the layout's
zone fragment, and the line is tall enough to hold it.
Each one-line part the page sets on a baseline of its own - the text beside
a logo, set from the logo's top, a smaller text beside a larger one, a page
field - is raised or lowered to it by its runs' w:position, through the run
loop seatInTheLine already used, now shared. The line grows to hold what is
raised; a part is lowered only as far as the fifth of the line below the
baseline holds, and one set lower stays counted. A line stopped at the
page's edge raises its tallest part back onto the page's baseline.
The report reads a picture as a part of the line and names what it loses:
its transform, its outline entry and its anchor's bookmark. readAlike
compares a picture's box, and the picture itself where a side is its own.
---
CHANGELOG.md | 44 +-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 40 +-
render-docx/README.md | 7 +-
.../backend/semantic/docx/DocxClipInk.java | 2 +-
.../semantic/docx/DocxLayoutMetrics.java | 27 +-
.../semantic/docx/DocxSemanticBackend.java | 279 +++++++++--
.../backend/semantic/docx/DocxZoneParts.java | 29 ++
.../docx/DocxNodeFieldLedgerTest.java | 16 +-
.../semantic/docx/DocxReportedLossesTest.java | 40 +-
.../semantic/docx/DocxZoneLineTest.java | 55 ++-
.../semantic/docx/DocxZonePictureTest.java | 465 ++++++++++++++++++
.../semantic/docx/DocxZoneReportTest.java | 22 +-
13 files changed, 928 insertions(+), 100 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd91acc1b..0afb6072e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,40 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX page zone writes its picture — a logo — in its line, where the page draws it.** A
+ page zone is written as one line of a Word header or footer. An `ImageNode` in it was not
+ written, and was named `DROPPED`, `page zone content`.
+ - **The picture is an inline picture in the line**, at the size the page draws it: fitted
+ inside its box where it is contained, the box's size and cropped where it covers it. Its link
+ is its run's.
+ - **Word stands it on the line's baseline.** Where it is the line's tallest part, the line is
+ placed by its foot, which then stands where the page draws it, and is tall enough to hold it:
+ a 24pt logo makes a 30pt exact line, its baseline four fifths down.
+ - **A part the page sets on a baseline of its own is raised or lowered to it** (`w:position`),
+ and the exact line grows to hold what is raised. The text beside a logo, set from the logo's
+ top or its middle, would otherwise stand on the logo's foot, 9 to 18pt low. A smaller text
+ beside a larger one is raised too, where it stood on the larger one's baseline. A part is
+ lowered only as far as the fifth of the line below Word's baseline holds; one set lower
+ stands on Word's baseline, and the note counts it. A picture that is not the line's tallest
+ part is raised as a text is. A line that stops at the page's edge raises its tallest part back
+ onto the page's baseline too, where it stood up to the edge's distance off it: an 80pt title
+ against the top edge stood 1.8pt low, named. Only text the page sets past the edge itself is
+ left off its baseline, and named.
+ - **What a picture loses on the line is named** in the `page zone` note: its transform, its
+ outline entry and its anchor's bookmark. A picture the zone builds otherwise for the first
+ page it is drawn on than as written is written, and where it stands is named not measured.
+ - **Measured** in Word 16 and LibreOffice on a 48 by 24pt logo in a header and in a footer:
+ alone, beside 8pt text set from its top and from its middle, after 18pt text, and beside a
+ page number. Each logo stood where the page draws it, to a tenth of a point, in both editors.
+ In Word each text's baseline stood within a quarter point of the page's, the half point
+ `w:position` counts in. LibreOffice raises a run about a seventh further than `w:position`
+ says, and does not raise a page field, which stands on the logo's foot there.
+ - Anything else in a page zone — a shape, a barcode, a container — is still `DROPPED`, `page
+ zone content`.
+ - No document of the DOCX fidelity corpus has a page zone; its bytes are unchanged. In
+ `DocxNodeFieldLedgerTest` the `zones` entry writes each part's own baseline and the zone's
+ pictures, and names a part set lower than the line holds.
+
- **A DOCX export writes a row's fill, outline and side borders, as a panel holding its
columns.** A row paints its box as a container does — `RowBuilder.fillColor`, `stroke`,
`borders` — and the export wrote its columns as a table with no shading or borders, naming the
@@ -206,11 +240,14 @@ follow semantic versioning; release dates are ISO 8601.
- **Measured** in Word 16.0.20430 and LibreOffice 26.8 on 8pt and 18pt Lato headers and 8pt
Lato footers. A lone part's text, and a line's tallest part's, stood within 0.1pt of the
page's baseline in each editor, the cover's header included. A smaller part beside a taller
- one stays on Word's one baseline, and the note counts it.
+ one stays on Word's one baseline, and the note counts it (since raised to its own: see "A
+ DOCX page zone writes its picture — a logo — in its line, where the page draws it").
- **Text the page sets right against the edge** stands off the page's baseline, as the line
stops at the edge: lower in a header, by what its ascent falls short of four fifths of its
line, and higher in a footer, by what its descent falls short of a fifth. In the default face
- only a header's is, about 0.4pt at 18pt. Past a point and a half, the note names it.
+ only a header's is, about 0.4pt at 18pt. Past a point and a half, the note names it (since
+ raised back onto it: see "A DOCX page zone writes its picture — a logo — in its line, where
+ the page draws it").
- **The `page zone` note:**
- counts a part off the baseline Word sets the line on, and says where the parts after a
part of more lines than one stand is not measured, since Word sets them on a later line;
@@ -574,7 +611,8 @@ follow semantic versioning; release dates are ISO 8601.
- anything in a page zone but paragraphs, page fields and spacers, such as a logo: now
`DROPPED`, `page zone content`, once for each, though a zone is written into each kind of
header or footer it is given — two logos of no name are two notes, and in a file of several
- sections each names its section;
+ sections each names its section (a picture since written: see "A DOCX page zone writes its
+ picture — a logo — in its line, where the page draws it");
- a watermark, a protection and viewer preferences: now `DROPPED`, `watermark` (once per
section that sets one), `protection` and `viewer preferences` (once for the file) — a file
asked to be protected opens and edits without a password, and the report says so;
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index cb0231a56..098331a87 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, a translucent one flattened against white and reported (`translucency`); 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`. 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, 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. The line is an exact line as tall as the tallest part's line on the page — taller for a picture in it or a part written at a larger size than the page's — standing as far from its edge as puts that part's baseline 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 whose content is built otherwise for its first page — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for no page in particular is not written and 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 |
+| 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, a part the page sets on a baseline of its own raised or lowered to it by `w:position` — lowered only as far as the fifth of the exact line below the baseline holds; LibreOffice raises a run about a seventh further than Word and does not raise a page field —; 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 whose content is built otherwise for its first page — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for no page in particular is not written and 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 06261a5d2..e0a90bbec 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1083,11 +1083,12 @@ in more lines than one is as many exact lines; Word grows a footer up from its d
footer of two lines stands a line further from the edge, its first line on the page's first
baseline. Measured in Word 16.0.20430 and LibreOffice 26.8 on 8pt and 18pt Lato headers and 8pt
Lato footers, a lone part's text, and a line's tallest part's, stood within 0.1pt of the page's
-baseline; a smaller part beside it stays on Word's one baseline, and is counted. Text the page
-sets right against the edge stands off its baseline, the line stopping at the edge: lower in a
-header, by what its ascent falls short of four fifths of its line, and higher in a footer, by
-what its descent falls short of a fifth — in the default face only a header's is, about 0.4pt at
-18pt; past a point and a half, the report names it. Lines reaching past the page margin, into the
+baseline; a smaller part beside it is raised to its own (see below). Where the page sets text
+right against the edge, the line stops at the edge, its baseline lower in a header by what the
+text's ascent falls short of four fifths of its line, and higher in a footer by what its descent
+falls short of a fifth — about 0.4pt at 18pt in the default face, 1.8pt at 80pt — and the text is
+raised or lowered back onto the page's baseline, as a part on a baseline of its own is. Only text
+the page sets past the edge itself stands off its baseline, and the report names it. Lines reaching past the page margin, into the
body, by more than half a point are held there as a text band is: the margin is written
negative, and the report names it. Within half a point, as text under about 22pt set against the
margin reaches in the default face, Word moves the body down by as much, unnamed; a framed zone
@@ -1103,15 +1104,26 @@ pictures — the line is Word's, the distance is read from where its content lan
report says where its text stands is not measured. Such a zone is not framed: two of one kind
share Word's one distance, the last one's, and stand one under the other. A zone whose content
is none for no page in particular is not written, and the report names it. A zone is written
-from its paragraphs, page fields and spacers: anything else in it — a logo, a panel, a table —
-is not written, and the export report names it once (`page zone content`).
+from its paragraphs, page fields, spacers and pictures. A picture — a logo — is an inline
+picture in the line, at the size the page draws it: fitted inside its box where it is
+contained, the box's size and cropped where it covers it, its link its run's. Word stands it on
+the line's baseline, so where it is the line's tallest part the line is placed by its foot,
+which then stands where the page draws it. Anything else in a zone — a shape, a panel, a table
+— is not written, and the export report names it once (`page zone content`).
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, 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
+the first spacer against its right margin, at the right tab the line holds, on one
+baseline, its tallest part's. A part the page sets on a baseline of its own — the text beside
+a logo, set from the logo's top, or a smaller text beside a larger one — is raised or lowered to
+it (`w:position`), and the exact line grows to hold what is raised; it is lowered only as far as
+the fifth of the line below Word's baseline holds. Measured on a 48 by 24pt logo beside 8pt text
+and beside a page number, each text's baseline stood within a quarter point of the page's in
+Word, the half point `w:position` counts in. LibreOffice raises a run about a seventh further
+than `w:position` says, and does not raise a page field, which stands on Word's baseline there.
+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 — lower than the line holds, 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 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
@@ -1119,7 +1131,9 @@ says where a part stands is not measured. It names what a paragraph in the zone
own too: its right-to-left direction, a prefix's letters, the size an auto-sized one is fitted
to where its lines do not tell it, the markdown marks the page reads where they are written as letters, a markdown heading Word cuts on screen, its outline entry, its anchor, which has no bookmark — a zone is written into a part each
kind of page repeats, no one place a bookmark could mark, and a link to it points at none — and
-a picture the page sets anywhere but on the baseline, where Word stands it.
+a picture the page sets anywhere but on the baseline, where Word stands it. Of a picture part
+it names its transform, which an inline picture does not carry, its outline entry and its
+anchor.
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,
diff --git a/render-docx/README.md b/render-docx/README.md
index 08c5e7245..446bb708d 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -97,7 +97,8 @@ What maps:
- **Charts** are a table of their data.
- **Pages.** Page size, margins and orientation; page zones (`session.chrome().zone(...)`)
and text headers and footers (`session.header(...)` / `footer(...)`) as real Word headers
- and footers with live page-number fields; document metadata (title, author, subject,
+ and footers with live page-number fields, a zone's logo an inline picture where the page
+ draws it, and each part of a zone's line raised to the baseline the page sets it on; document metadata (title, author, subject,
keywords).
- **Byte-identical output** with `DocxSemanticBackend.builder().deterministic(true)`.
@@ -123,8 +124,8 @@ What is not written — each one is named in the export report
cell the export shaded, or what the page paints there — and a text header's separator against
white; each is named in the export report (`translucency`, a chip's on its `inline chip` note).
Text, drawings and pictures keep their alpha.
-- **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`.
+- **In a page zone, anything but paragraphs, page fields, spacers and pictures** — a barcode,
+ a rule, a panel — 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:
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java
index 22895633a..b5ac8a595 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxClipInk.java
@@ -322,7 +322,7 @@ private static void side(List ink, Stroke stroke, double x1, double y1
* centred there where it is contained, as the file writes it at that size too; across its box
* otherwise, covering it or stretched over it.
*/
- private static double[] drawn(PlacedFragment fragment, ImageFragmentPayload image) {
+ static double[] drawn(PlacedFragment fragment, ImageFragmentPayload image) {
double[] box = {fragment.x(), fragment.y(), fragment.width(), fragment.height()};
ImageData data = image.imageData();
if (image.fitMode() != DocumentImageFitMode.CONTAIN || data == null || data.getMetadata() == null) {
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 0999093c7..6c2ec4812 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
@@ -4,6 +4,7 @@
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.layout.PlacedNode;
+import com.demcha.compose.document.layout.payloads.ImageFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphLineGeometry;
@@ -554,24 +555,42 @@ OptionalDouble zoneDistanceFromEdge(int zoneIndex, boolean header, double pageHe
* @return the fragments by path, empty when the layout carries no such zone
*/
Map zoneText(int zoneIndex) {
+ return zoneFragments(zoneIndex, fragment -> fragment.payload() instanceof ParagraphFragmentPayload paragraph
+ && !paragraph.lines().isEmpty());
+ }
+
+ /**
+ * The pictures a page zone's content laid out, on the first page the zone is drawn on: each
+ * node's first picture fragment, the box the page draws it in, by its path within the content
+ * (see {@link #zoneText}).
+ *
+ * @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
+ */
+ Map zonePictures(int zoneIndex) {
+ return zoneFragments(zoneIndex, fragment -> fragment.payload() instanceof ImageFragmentPayload);
+ }
+
+ /** Each node's first fragment a page zone laid out that is of a kind, by its path within the content. */
+ private Map zoneFragments(int zoneIndex, java.util.function.Predicate kind) {
int first = zoneFirstPage(zoneIndex);
if (first < 0) {
return Map.of();
}
String prefix = "@page-zone[" + first + "][" + zoneIndex + "]";
- Map text = new HashMap<>();
+ Map found = new HashMap<>();
for (Map.Entry> entry : fragments.entrySet()) {
if (!entry.getKey().startsWith(prefix)) {
continue;
}
for (PlacedFragment fragment : entry.getValue()) {
- if (fragment.payload() instanceof ParagraphFragmentPayload paragraph && !paragraph.lines().isEmpty()) {
- text.putIfAbsent(entry.getKey().substring(prefix.length()), fragment);
+ if (kind.test(fragment)) {
+ found.putIfAbsent(entry.getKey().substring(prefix.length()), fragment);
break;
}
}
}
- return text;
+ return found;
}
/**
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 61684c74c..573cbd848 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
@@ -210,7 +210,7 @@ public final class DocxSemanticBackend implements SemanticBackend {
private final java.util.Set warnedNodeKinds =
java.util.concurrent.ConcurrentHashMap.newKeySet();
// The page zone content already reported this export. By identity: a zone's content is built
- // once a section and written into each kind of header Word is given, and two logos of one
+ // once a section and written into each kind of header Word is given, and two shapes of one
// kind and no name are two losses.
private final java.util.Set zonePartsReported =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
@@ -1822,9 +1822,10 @@ private void separateFromAnEqualFrame(XWPFHeaderFooter part, long frameTop) {
* Writes one zone's subtree as a single line in the header/footer part.
*
* A zone is one band deep, so its children belong on one Word line rather
- * than stacked paragraphs: a row's children become runs in document order, and
- * a flex spacer becomes the tab that carries the rest to the right margin —
- * which is how a Word footer is built by hand anyway.
+ * than stacked paragraphs: a row's children become runs in document order — a
+ * picture an inline picture in its run — and a flex spacer becomes the tab that
+ * carries the rest to the right margin — which is how a Word footer is built by
+ * hand anyway.
*
* Where the layout shows its text, the line is exact and as tall as {@link #zonePlacement}
* says, which with the distance {@link #placeZone} writes stands it on the tallest part's
@@ -1876,8 +1877,13 @@ private void writeZoneLine(XWPFHeaderFooter target, int zoneIndex, DocumentNode
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
+ java.util.Map pictures = layout.zonePictures(zoneIndex);
for (DocumentNode part : parts) {
- appendZonePart(para, part, zoneLinesOf(part, paths, laid, placement));
+ int runsBefore = runsIn(para).size();
+ appendZonePart(para, part, zoneLinesOf(part, paths, laid, placement),
+ placement.laidOtherwise().contains(part) ? null : pictures.get(paths.get(part)));
+ raiseRunsFrom(para, runsBefore,
+ Math.round(placement.raised().getOrDefault(part, 0.0) * HALF_POINTS_PER_POINT));
}
}
@@ -1912,6 +1918,17 @@ private static List z
* a footer. A line placed by its parts' edges instead, its top at the highest part's top in
* a header, stands a header's text its padding above it high.
*
+ * A picture of the line — a logo — is one of its parts: Word stands it on the line's
+ * baseline, its foot there, so where it is the tallest part the line is placed by its foot,
+ * where the page draws it, and is as tall as it needs.
+ *
+ * Each one-line part Word sets as the page does — the tallest one too, where the line stops
+ * at the page's edge — stands on the baseline the page sets it on, raised or lowered from the
+ * line's by its runs' position ({@link ZonePlacement#raised}). The line grows to hold what a
+ * part raised reaches above it; below, it holds a fifth of itself, five times what it would
+ * grow by, so a part is lowered only as far as that, or as its tallest part reaches. One the
+ * line does not hold stands on Word's baseline, and the report counts it.
+ *
* A part the page sets in more lines than one makes the zone's paragraph as many exact
* lines, which Word grows down from a header's distance and up from a footer's: a footer's
* stands the lines below its first further from its edge, so that the first is the one on
@@ -1931,16 +1948,19 @@ private static List z
* @param laidOtherwise the parts the page set on the first page it draws the zone otherwise
* than they are written ({@link DocxZoneParts#partsReadOtherwise}), whose
* lines there are not their own
+ * @param raised how far each part the page sets on a baseline of its own is raised
+ * from the line's to it, in points, lowered where below zero
*/
private record ZonePlacement(double line, int lines, double distance, double baseline,
- java.util.Set laidOtherwise) {
+ java.util.Set laidOtherwise,
+ java.util.Map raised) {
private static final ZonePlacement UNMEASURED =
- new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, java.util.Set.of());
+ new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, java.util.Set.of(), java.util.Map.of());
/** Not measured, its parts named laid otherwise among them. */
static ZonePlacement laidOtherwise(java.util.Set parts) {
- return new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, parts);
+ return new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, parts, java.util.Map.of());
}
/** Whether the layout laid out the text the line is placed by. */
@@ -1969,11 +1989,29 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
}
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
+ java.util.Map pictures = layout.zonePictures(zoneIndex);
ZoneLine tallest = null;
int mostLines = 1;
double picture = 0;
double styled = 0;
+ // The parts Word sets as the page does, one line each, where the page sets them: each
+ // can stand on its own baseline, raised or lowered from the line's.
+ java.util.Map standing = new java.util.IdentityHashMap<>();
for (DocumentNode part : DocxZoneParts.of(content)) {
+ if (part instanceof ImageNode) {
+ // A picture of the line is a part of it, standing on its foot as on Word's
+ // baseline: the tallest part, it places the line by its foot.
+ com.demcha.compose.document.layout.PlacedFragment box = pictures.get(paths.get(part));
+ if (box != null) {
+ ZoneLine drawnAt = pictureOnThePage(box);
+ if (tallest == null || drawnAt.above() > tallest.above() + tallest.below()) {
+ tallest = drawnAt;
+ }
+ picture = Math.max(picture, drawnAt.above());
+ standing.put(part, drawnAt);
+ }
+ continue;
+ }
com.demcha.compose.document.layout.PlacedFragment fragment =
part instanceof ParagraphNode || part instanceof PageFieldNode ? laid.get(paths.get(part)) : null;
if (fragment == null) {
@@ -1984,6 +2022,15 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
tallest = first;
}
mostLines = Math.max(mostLines, first.lines());
+ if (widthKept(part, fragment) && !(part instanceof ParagraphNode prefixed
+ && setsAPrefixBeforeTheFirstLine(prefixed))) {
+ // Its pictures stand on its baseline as on the line's, and reach as high.
+ double pictured = part instanceof ParagraphNode paragraph ? DocxZoneParts.tallestPicture(paragraph) : 0;
+ standing.put(part, pictured > first.above()
+ ? new ZoneLine(first.start(), first.end(), first.baseline(), first.top(), first.bottom(),
+ pictured, first.below(), first.lines())
+ : first);
+ }
if (part instanceof ParagraphNode paragraph) {
List lines =
((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) fragment.payload()).lines();
@@ -2004,15 +2051,43 @@ private ZonePlacement zonePlacement(int zoneIndex, boolean header, DocumentPageZ
if (!(line > 0)) {
return ZonePlacement.UNMEASURED;
}
+ // Past a part of more lines than one Word sets the rest on later lines: none is raised.
+ if (mostLines > 1) {
+ standing.clear();
+ }
+ // And for each other part raised to its own baseline, as far as it reaches above the
+ // four fifths of the line above its baseline, or past its tallest part: an exact line cuts
+ // what passes its edges. Below, the line holds a fifth of itself, five times what it would
+ // grow by: a part lowered past that is not lowered.
+ for (ZoneLine part : standing.values()) {
+ double raise = part.baseline() - tallest.baseline();
+ if (raise + part.above() > Math.max(DocxTextBands.BASELINE_SHARE * line, tallest.above())) {
+ line = (raise + part.above()) / DocxTextBands.BASELINE_SHARE;
+ }
+ }
double above = DocxTextBands.BASELINE_SHARE * line;
// A footer's paragraph grows up from its distance: its last line's baseline is the one
// that distance places, the lines above it each an exact line higher.
double distance = DocxTextBands.distanceFromEdge(header,
header ? canvasHeight - tallest.baseline() : tallest.baseline() - (mostLines - 1) * line, line);
double baseline = header ? canvasHeight - distance - above : distance + (mostLines - 1) * line + (line - above);
- return new ZonePlacement(line, mostLines, distance, baseline, java.util.Set.of());
+ // Raised or lowered from the line's baseline as Word sets it — a line stopping at the
+ // page's edge stands it further in — where the line holds it.
+ java.util.Map raised = new java.util.IdentityHashMap<>();
+ for (java.util.Map.Entry part : standing.entrySet()) {
+ double raise = part.getValue().baseline() - baseline;
+ if (Math.round(raise * HALF_POINTS_PER_POINT) != 0
+ && raise + part.getValue().above() <= Math.max(above, tallest.above()) + ZONE_LINE_HOLDS
+ && part.getValue().below() - raise <= Math.max(line - above, tallest.below()) + ZONE_LINE_HOLDS) {
+ raised.put(part.getKey(), raise);
+ }
+ }
+ return new ZonePlacement(line, mostLines, distance, baseline, java.util.Set.of(), raised);
}
+ /** How far past an exact line's edge a part may reach and still be held: a hundredth of a point. */
+ private static final double ZONE_LINE_HOLDS = 0.01;
+
/**
* Frames a header's or footer's paragraph across the margins at a height on the page, where
* the part's flow does not move it and it moves nothing in the body.
@@ -2067,7 +2142,9 @@ private double zoneRightTab() {
* the layout does not show, where it stands across the line is not measured; past a part of
* more lines than one — Word sets the parts after it on a later line — where they stand is
* not measured at all. A zone whose nodes the page names or nests otherwise than the file,
- * built for no page in particular, shows none of its parts.
+ * built for no page in particular, shows none of its parts. A picture is a part as wide as
+ * the page draws it, standing on its foot, which is where it is read against Word's
+ * baseline.
*
* A paragraph's own losses are named too ({@link #zoneParagraphLost}): its right-to-left
* text is written left to right, its prefix's letters are not written, its text is written
@@ -2075,7 +2152,7 @@ private double zoneRightTab() {
* markdown marks the page reads as letters, a markdown heading
* stands taller than its line, its outline entry is not
* written, its anchor has no bookmark, and a picture the page sets off the baseline stands
- * on it.
+ * on it. So are a picture part's ({@link #zonePictureLost}).
*
* @param zoneIndex the zone's position in the section's zone list
* @param header whether the zone is a header
@@ -2086,19 +2163,29 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
List parts = DocxZoneParts.of(content);
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
+ java.util.Map pictures = layout.zonePictures(zoneIndex);
// Read where the line is placed by what the page set: not where the page set other text.
- boolean measured = placement.measured() && !laid.isEmpty() && !Double.isNaN(canvasLeftMargin);
- // Each text part's side of the line: 0 from the left margin, 1 against the right, 2
+ boolean measured = placement.measured() && !(laid.isEmpty() && pictures.isEmpty())
+ && !Double.isNaN(canvasLeftMargin);
+ // Each placed part's side of the line: 0 from the left margin, 1 against the right, 2
// after a second spacer, where the line holds no tab for it.
java.util.Map side = new java.util.IdentityHashMap<>();
+ // Its box on the page: a text's laid-out lines, a picture's box.
+ java.util.Map laidOut =
+ new java.util.IdentityHashMap<>();
List texts = new ArrayList<>();
int spacers = 0;
for (DocumentNode part : parts) {
if (part instanceof SpacerNode) {
spacers++;
- } else if (part instanceof ParagraphNode || part instanceof PageFieldNode) {
+ } else if (part instanceof ParagraphNode || part instanceof PageFieldNode || part instanceof ImageNode) {
texts.add(part);
side.put(part, Math.min(spacers, 2));
+ com.demcha.compose.document.layout.PlacedFragment fragment =
+ (part instanceof ImageNode ? pictures : laid).get(paths.get(part));
+ if (fragment != null) {
+ laidOut.put(part, fragment);
+ }
}
}
// Where Word starts each part from the left margin, and ends each one against the right,
@@ -2107,7 +2194,7 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
java.util.Map wordsEnd = new java.util.IdentityHashMap<>();
double x = canvasLeftMargin;
for (DocumentNode part : texts) {
- com.demcha.compose.document.layout.PlacedFragment fragment = laid.get(paths.get(part));
+ com.demcha.compose.document.layout.PlacedFragment fragment = laidOut.get(part);
if (side.get(part) != 0 || fragment == null) {
break;
}
@@ -2115,13 +2202,13 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
if (!widthKept(part, fragment)) {
break;
}
- x += wordsWidthOf(fragment);
+ x += wordsWidthOf(part, fragment);
}
double end = canvasLeftMargin + zoneRightTab();
List againstTheRight = texts.stream().filter(part -> side.get(part) == 1).toList();
for (int index = againstTheRight.size() - 1; index >= 0; index--) {
DocumentNode part = againstTheRight.get(index);
- com.demcha.compose.document.layout.PlacedFragment fragment = laid.get(paths.get(part));
+ com.demcha.compose.document.layout.PlacedFragment fragment = laidOut.get(part);
if (fragment == null) {
break;
}
@@ -2129,14 +2216,14 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
if (!widthKept(part, fragment)) {
break;
}
- end -= wordsWidthOf(fragment);
+ end -= wordsWidthOf(part, fragment);
}
- // Where the page sets each part's first line.
+ // Where the page sets each part's first line, and draws each picture.
java.util.Map onThePage = new java.util.IdentityHashMap<>();
for (DocumentNode part : texts) {
- com.demcha.compose.document.layout.PlacedFragment fragment = laid.get(paths.get(part));
+ com.demcha.compose.document.layout.PlacedFragment fragment = laidOut.get(part);
if (measured && fragment != null) {
- onThePage.put(part, firstLineOnThePage(fragment));
+ onThePage.put(part, part instanceof ImageNode ? pictureOnThePage(fragment) : firstLineOnThePage(fragment));
}
}
// Read from the same text the line is placed by: where none is, no part is read either.
@@ -2153,10 +2240,13 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
&& setsAPrefixBeforeTheFirstLine(paragraph);
boolean afterABreak = broken;
broken |= line != null && !line.oneLine();
+ // Word stands a part raised or lowered from the line's baseline by whole half points.
+ double wordsBaseline = baseline + Math.round(placement.raised().getOrDefault(part, 0.0)
+ * HALF_POINTS_PER_POINT) / HALF_POINTS_PER_POINT;
if (line == null || afterABreak) {
unread++;
} else if (side.get(part) == 2 || !line.oneLine() || prefixed
- || Math.abs(line.baseline() - baseline) > ZONE_PLACE_CLEARANCE) {
+ || Math.abs(line.baseline() - wordsBaseline) > ZONE_PLACE_CLEARANCE) {
off++;
} else if (wordStart == null && wordEnd == null) {
unread++;
@@ -2166,11 +2256,16 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
}
}
java.util.Set lost = new java.util.LinkedHashSet<>();
+ // What the line's placed parts are, to a reader: its text, its picture, or its parts.
+ boolean pictured = texts.stream().anyMatch(part -> part instanceof ImageNode);
+ boolean several = pictured && texts.size() > 1;
+ String what = !pictured ? "its text" : several ? "its parts" : "its picture";
if (!texts.isEmpty() && unread == texts.size()) {
- lost.add("whether its text stands where the page sets it is not measured");
+ lost.add("whether " + what + (several ? " stand where the page sets them" : " stands where the page sets it")
+ + " is not measured");
} else {
if (off > 0) {
- lost.add(texts.size() == 1 ? "its text stands off where the page sets it"
+ lost.add(texts.size() == 1 ? what + " stands off where the page sets it"
: partsOf(off, texts.size()) + " off where the page sets them");
}
if (unread > 0) {
@@ -2180,6 +2275,8 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
for (DocumentNode part : texts) {
if (part instanceof ParagraphNode paragraph) {
lost.addAll(zoneParagraphLost(paragraph, zoneLinesOf(part, paths, laid, placement)));
+ } else if (part instanceof ImageNode image) {
+ lost.addAll(zonePictureLost(image));
}
}
if (!lost.isEmpty()) {
@@ -2235,13 +2332,38 @@ private List zoneParagraphLost(ParagraphNode node, List zonePictureLost(ImageNode image) {
+ List lost = new ArrayList<>(3);
+ if (image.transform() != null && !image.transform().isIdentity()) {
+ lost.add("a picture's transform is not carried, so it is drawn upright at its size");
+ }
+ if (image.bookmarkOptions() != null) {
+ lost.add("a picture's outline entry is not written");
+ }
+ if (image.anchor() != null) {
+ lost.add("a picture's anchor has no bookmark in the Word file: a link to it points at none");
+ }
+ return lost;
+ }
+
/** How many of a zone's parts a phrase is about, with its verb: "1 of its 3 parts stands". */
private static String partsOf(int some, int all) {
return some + " of its " + all + " parts " + (some == 1 ? "stands" : "stand");
}
- /** The width Word sets a part of a zone's line at: its laid-out line at Word's size. */
- private static double wordsWidthOf(com.demcha.compose.document.layout.PlacedFragment fragment) {
+ /**
+ * The width Word sets a part of a zone's line at: its laid-out line at Word's size, or a
+ * picture as wide as the page draws it.
+ */
+ private static double wordsWidthOf(DocumentNode part, com.demcha.compose.document.layout.PlacedFragment fragment) {
+ if (part instanceof ImageNode) {
+ return pictureOnThePage(fragment).end() - pictureOnThePage(fragment).start();
+ }
return widthAtWordsSize(((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) fragment.payload())
.lines().get(0));
}
@@ -2249,9 +2371,12 @@ private static double wordsWidthOf(com.demcha.compose.document.layout.PlacedFrag
/**
* Whether Word sets a part of a zone's line as wide as the page sets it, give or take its
* half-point size: one line, with no prefix it does not write and at the size the page fits
- * its text to.
+ * its text to; a picture is written as wide as the page draws it.
*/
private boolean widthKept(DocumentNode part, com.demcha.compose.document.layout.PlacedFragment fragment) {
+ if (part instanceof ImageNode) {
+ return true;
+ }
List lines =
((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) fragment.payload()).lines();
return lines.size() == 1 && !(part instanceof ParagraphNode paragraph
@@ -2307,21 +2432,92 @@ private ZoneLine firstLineOnThePage(com.demcha.compose.document.layout.PlacedFra
/**
* Writes one part of a zone's line into its paragraph.
*
- * @param lines the lines the page laid the part out in, empty where they are not read
+ * @param lines the lines the page laid the part out in, empty where they are not read
+ * @param picture the box the page draws a picture part in, {@code null} where it is not read
*/
private void appendZonePart(XWPFParagraph para, DocumentNode part,
- List lines) {
+ List lines,
+ com.demcha.compose.document.layout.PlacedFragment picture) {
if (part instanceof ParagraphNode paragraph) {
writeParagraphRuns(para, paragraph, false, 0, false, lines);
} else if (part instanceof PageFieldNode field) {
appendPageField(para, field);
} else if (part instanceof SpacerNode) {
para.createRun().addTab();
+ } else if (part instanceof ImageNode image) {
+ writeZonePicture(para, image, picture);
} else {
warnUnsupportedZoneNode(part);
}
}
+ /**
+ * A picture of a page zone's line, as it is written.
+ *
+ * @param data the picture, resolved
+ * @param box the box the page draws it in: the layout's, or the one the picture states for
+ * itself where the layout shows none
+ * @param width how wide it is written: fitted inside the box where it is contained, the box's
+ * width otherwise, a picture that covers its box cropped to it
+ * @param height how tall it is written, likewise
+ */
+ private record ZonePicture(ImageData data, NodeDefinitionSupport.ImageDimensions box, double width,
+ double height) {
+ }
+
+ /** A zone's picture at the size the page draws it ({@link ZonePicture}). */
+ private ZonePicture zonePicture(ImageNode image, com.demcha.compose.document.layout.PlacedFragment fragment) {
+ ImageData resolved = NodeDefinitionSupport.toImageData(image.imageData());
+ NodeDefinitionSupport.ImageDimensions box = fragment != null && fragment.width() > 0 && fragment.height() > 0
+ ? new NodeDefinitionSupport.ImageDimensions(fragment.width(), fragment.height())
+ : NodeDefinitionSupport.resolveImageDimensions(image, zoneRightTab(), resolved);
+ if (image.fitMode() != DocumentImageFitMode.CONTAIN) {
+ return new ZonePicture(resolved, box, box.width(), box.height());
+ }
+ double sourceWidth = Math.max(1, resolved.getMetadata().width());
+ double sourceHeight = Math.max(1, resolved.getMetadata().height());
+ double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight);
+ return new ZonePicture(resolved, box, sourceWidth * scale, sourceHeight * scale);
+ }
+
+ /**
+ * Writes a picture of a page zone's line as an inline picture in it, at the size the page
+ * draws it. Word stands it on the line's baseline, which {@link #zonePlacement} puts where
+ * the page sets the picture's foot where it is the line's tallest part. Its link is its run's.
+ *
+ * @param fragment the box the page draws it in, {@code null} where the layout does not show it
+ */
+ private void writeZonePicture(XWPFParagraph para, ImageNode image,
+ com.demcha.compose.document.layout.PlacedFragment fragment) {
+ ZonePicture picture = zonePicture(image, fragment);
+ byte[] bytes = picture.data().getBytes();
+ XWPFRun run = newRun(para, image.linkTarget());
+ try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) {
+ XWPFPicture written = run.addPicture(stream, pictureType(bytes), "image",
+ Units.toEMU(picture.width()), Units.toEMU(picture.height()));
+ if (image.fitMode() == DocumentImageFitMode.COVER) {
+ applyCoverCrop(written, Math.max(1, picture.data().getMetadata().width()),
+ Math.max(1, picture.data().getMetadata().height()), picture.box());
+ }
+ // POI describes a picture by the file name it is handed, which a screen reader then
+ // reads out.
+ run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr("");
+ written.getCTPicture().getNvPicPr().getCNvPr().setDescr("");
+ } catch (Exception failure) {
+ throw new IllegalStateException("could not write a page zone's picture", failure);
+ }
+ }
+
+ /**
+ * Where the page draws a zone's picture as a part of its line (see {@link ZoneLine}): it
+ * stands on its foot, as Word stands it on the line's baseline, and is one line.
+ */
+ private static ZoneLine pictureOnThePage(com.demcha.compose.document.layout.PlacedFragment fragment) {
+ double[] drawn = DocxClipInk.drawn(fragment,
+ (com.demcha.compose.document.layout.payloads.ImageFragmentPayload) fragment.payload());
+ return new ZoneLine(drawn[0], drawn[0] + drawn[2], drawn[1], drawn[1] + drawn[3], drawn[1], drawn[3], 0, 1);
+ }
+
/**
* Emits Word's own field rather than a number, so it stays correct when the
* reader adds a page or edits the document.
@@ -2381,12 +2577,12 @@ private String fieldPlaceholder(PageFieldKind kind) {
private void warnUnsupportedZoneNode(DocumentNode node) {
if (warnedNodeKinds.add("zone:" + node.nodeKind())) {
- LOG.warn("docx.zone.unsupportedNode kind={} — a page zone maps paragraphs, page fields"
- + " and spacers onto a Word header/footer; other nodes are skipped",
+ LOG.warn("docx.zone.unsupportedNode kind={} — a page zone maps paragraphs, page fields,"
+ + " spacers and pictures onto a Word header/footer; other nodes are skipped",
node.nodeKind());
}
// A zone's content is written into each kind of header Word is given — the first page's,
- // the even pages', the rest —, so the same logo would be reported once per kind: once is
+ // the even pages', the rest —, so the same shape would be reported once per kind: once is
// what the caller needs.
if (zonePartsReported.add(node)) {
report.add(DocxExportReport.Severity.DROPPED, "page zone content",
@@ -2394,7 +2590,7 @@ private void warnUnsupportedZoneNode(DocumentNode node) {
"a page zone's " + node.nodeKind()
+ (node.name().isEmpty() ? "" : " '" + node.name() + "'")
+ " is not written: a Word header or footer is written from the zone's "
- + "paragraphs, page fields and spacers only");
+ + "paragraphs, page fields, spacers and pictures only");
}
}
@@ -8965,7 +9161,8 @@ private boolean holdsText(ParagraphNode node) {
* Only this paragraph's runs move — a line pair writes another's in the same Word
* paragraph — and a picture's own raise is added to, as the page moves a picture with its
* line's seated baseline. The room made for a picture in the line is its unseated reach. A
- * page zone's paragraph has no laid-out lines here, and is written on its baseline.
+ * page zone's paragraph has no laid-out lines here: it is raised as a part of its zone's line
+ * ({@link #zonePlacement}).
*
* The baseline the page seats off is not where Word puts it either (see
* {@link #shiftToThePagesBaseline}), and the two moves are one position.
@@ -8978,8 +9175,18 @@ private boolean holdsText(ParagraphNode node) {
*/
private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore, double lineTopAbove,
boolean heldExact) {
- long halfPoints = Math.round((seatShift(node) + shiftToThePagesBaseline(para, node, lineTopAbove, heldExact))
- * HALF_POINTS_PER_POINT);
+ raiseRunsFrom(para, runsBefore, Math.round(
+ (seatShift(node) + shiftToThePagesBaseline(para, node, lineTopAbove, heldExact)) * HALF_POINTS_PER_POINT));
+ }
+
+ /**
+ * Raises a paragraph's runs from one on, lowered where the half points are below zero, by
+ * their {@code w:position}: added to the raise a run holds already, a picture's own.
+ *
+ * @param runsBefore how many of the paragraph's runs ({@link #runsIn}) come before the first
+ * one raised
+ */
+ private static void raiseRunsFrom(XWPFParagraph para, int runsBefore, long halfPoints) {
if (halfPoints == 0) {
return;
}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
index 15e4f7e65..960967ef7 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
@@ -1,6 +1,8 @@
package com.demcha.compose.document.backend.semantic.docx;
+import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.ImageNode;
import com.demcha.compose.document.node.InlineHighlightRun;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineImageRun;
@@ -93,9 +95,36 @@ && sameFace(paragraph.textStyle(), drawn.textStyle())
return other instanceof PageFieldNode drawn && field.kind() == drawn.kind()
&& sameFace(field.textStyle(), drawn.textStyle());
}
+ if (part instanceof ImageNode picture) {
+ return other instanceof ImageNode drawn && samePictureBox(picture, drawn);
+ }
return part.getClass() == other.getClass();
}
+ /**
+ * Whether two pictures are laid out in one box: the sizes, fit and insets they state, and,
+ * where they leave a side to the picture's own proportions, the same picture.
+ */
+ private static boolean samePictureBox(ImageNode picture, ImageNode other) {
+ boolean sized = picture.width() != null && picture.height() != null;
+ return Objects.equals(picture.width(), other.width()) && Objects.equals(picture.height(), other.height())
+ && Objects.equals(picture.scale(), other.scale()) && picture.fitMode() == other.fitMode()
+ && picture.padding().equals(other.padding()) && picture.margin().equals(other.margin())
+ && (sized || samePicture(picture.imageData(), other.imageData()));
+ }
+
+ /** Whether two pictures' data are the same file or the same bytes. */
+ private static boolean samePicture(DocumentImageData picture, DocumentImageData other) {
+ if (picture == other) {
+ return true;
+ }
+ if (picture.path().isPresent() || other.path().isPresent()) {
+ return picture.path().equals(other.path());
+ }
+ return picture.bytes().isPresent() && other.bytes().isPresent()
+ && java.util.Arrays.equals(picture.bytes().get(), other.bytes().get());
+ }
+
private static boolean runsAlike(List runs, List others) {
if (runs.size() != others.size()) {
return false;
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 dc88cae7b..13a4fbeee 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
@@ -118,14 +118,16 @@ private record Entry(Fate fate, String note) {
"headersAndFooters:REPORTED:a band's translucent separator, flattened against white; any other is "
+ "written, or reported where Word's parts cannot hold it",
// The node entries below are the body's. A zone is written as one line of its
- // paragraphs' runs, page fields and tabs, and has its own entry here.
- "zones:REPORTED:where its parts stand across the line and off the baseline Word sets it on, what "
- + "its paragraphs lose of their own — an anchor, a picture's place among them — what else a zone "
- + "holds, lines reaching past the page margin, a line the layout does not measure, and a zone "
+ // paragraphs' runs, page fields, pictures and tabs, and has its own entry here.
+ "zones:REPORTED:where its parts stand across the line, a part the page sets lower than the line "
+ + "holds below Word's baseline, what its paragraphs and pictures lose of their own — an anchor, an "
+ + "outline entry, a picture's transform, a picture's place among a paragraph's runs — what else a "
+ + "zone holds, lines reaching past the page margin, a line the layout does not measure, and a zone "
+ "built as nothing for no page in particular; its line's height, its lines, its tallest part's "
- + "baseline and the room that part holds above and below its text are written, as exact lines "
- + "placed from the edge, in a frame at its height where a measured zone shares its kind with "
- + "another page zone");
+ + "baseline, each other part's own, raised or lowered to it, and the room that part holds above and "
+ + "below its text are written, as exact lines placed from the edge, in a frame at its height where "
+ + "a measured zone shares its kind with another page zone; its pictures are written in the line at "
+ + "the size the page draws them");
static {
node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
index 18a1b4ef9..8810336ac 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
@@ -73,12 +73,12 @@ void aRowThatPaintsNothingReportsNothing() throws Exception {
}
@Test
- void aPictureInAPageZoneIsReportedOnceThoughWrittenIntoTwoHeaders() throws Exception {
+ void aShapeInAPageZoneIsReportedOnceThoughWrittenIntoTwoHeaders() throws Exception {
// A second zone on the first page alone gives the section a first-page header, so the
- // logo's zone is written into it and into the ordinary header: told once all the same.
+ // mark's zone is written into it and into the ordinary header: told once all the same.
DocxExportReport report = reportOf(session -> {
session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder()
- .addImage(image -> image.name("Logo").source(DocumentImageData.fromBytes(png())).size(24, 24))
+ .addShape(shape -> shape.name("Mark").size(24, 24).fillColor(SURFACE))
.addParagraph("Quarterly report")
.build()));
session.chrome().zone(onTheFirstPage(DocumentPageZone.footer(20,
@@ -87,22 +87,36 @@ void aPictureInAPageZoneIsReportedOnceThoughWrittenIntoTwoHeaders() throws Excep
});
List notes = report.bySubject().get("page zone content");
- assertThat(notes).as("the logo, once").hasSize(1);
+ assertThat(notes).as("the mark, once").hasSize(1);
assertThat(notes.get(0).severity()).isEqualTo(DocxExportReport.Severity.DROPPED);
- assertThat(notes.get(0).detail()).contains("ImageNode 'Logo' is not written");
+ assertThat(notes.get(0).detail()).contains("ShapeNode 'Mark' is not written")
+ .contains("written from the zone's paragraphs, page fields, spacers and pictures only");
}
@Test
- void twoPicturesOfNoNameInAPageZoneAreTwoNotes() throws Exception {
+ void twoShapesOfNoNameInAPageZoneAreTwoNotes() throws Exception {
DocxExportReport report = reportOf(session -> {
session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder()
- .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(24, 24))
- .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(24, 24))
+ .addShape(shape -> shape.size(24, 24).fillColor(SURFACE))
+ .addShape(shape -> shape.size(24, 24).fillColor(SURFACE))
.build()));
session.pageFlow(page -> page.addParagraph("Body"));
});
- assertThat(report.bySubject().get("page zone content")).as("each picture is a loss of its own").hasSize(2);
+ assertThat(report.bySubject().get("page zone content")).as("each shape is a loss of its own").hasSize(2);
+ }
+
+ @Test
+ void aPictureInAPageZoneIsWrittenNotReported() throws Exception {
+ DocxExportReport report = reportOf(session -> {
+ session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder()
+ .addImage(image -> image.name("Logo").source(DocumentImageData.fromBytes(png())).size(24, 24))
+ .addParagraph("Quarterly report")
+ .build()));
+ session.pageFlow(page -> page.addParagraph("Body"));
+ });
+
+ assertThat(report.bySubject()).doesNotContainKey("page zone content");
}
@Test
@@ -172,8 +186,8 @@ void aFilledRowRoundNoTextInATableCellIsApproximated() throws Exception {
void aZonesLossesInASectionedFileNameTheirSection() throws Exception {
AtomicReference captured = new AtomicReference<>();
try (com.demcha.compose.document.api.MultiSectionDocument document = GraphCompose.documents()
- .section(withALogoHeader("Cover"))
- .section(withALogoHeader("Body"))
+ .section(withAMarkHeader("Cover"))
+ .section(withAMarkHeader("Body"))
.create()) {
document.export(new DocxSemanticBackend(captured::set));
}
@@ -358,10 +372,10 @@ private static void longBody(DocumentSession session) {
});
}
- private static DocumentSession withALogoHeader(String text) {
+ private static DocumentSession withAMarkHeader(String text) {
DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create();
session.chrome().zone(DocumentPageZone.header(30, page -> new RowBuilder()
- .addImage(image -> image.source(DocumentImageData.fromBytes(png())).size(24, 24))
+ .addShape(shape -> shape.size(24, 24).fillColor(SURFACE))
.addParagraph(text).build()));
session.pageFlow(page -> page.addParagraph(text));
return session;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
index ee35db467..684838c50 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
@@ -145,11 +145,33 @@ void aFootersRowBesideAPartOfTwoLinesStandsItsFirstLineOnThePagesBaseline() thro
assertThat(PAGE_HEIGHT - fromTheTop(exported.margin().getFooter()) - (lineOf(line) - share(line))
- lineOf(line)).as("the first line's baseline").isCloseTo(exported.baseline("Acme"), within(0.1));
+ assertThat(line.getRuns()).as("on Word's own lines, none raised off them")
+ .allSatisfy(run -> assertThat(run.getCTR().isSetRPr() && run.getCTR().getRPr().sizeOfPositionArray() > 0)
+ .isFalse());
assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
.containsExactly("a footer written as one line of Word's footer; 1 of its 2 parts stands off where "
+ "the page sets them");
}
+ @Test
+ void noPartIsRaisedInAZoneOfMoreLinesThanOne() throws Exception {
+ // Word sets "v2.4" on the second line, after the break; raised off the first line's
+ // baseline to the page's, it would stand a line and more off it.
+ Exported exported = export(DocumentPageZone.builder().zone(DocumentHeaderFooterZone.FOOTER).height(40)
+ .padding(new DocumentInsets(4, 0, 0, 0))
+ .content(page -> new RowBuilder().name("Line")
+ .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18)))
+ .addParagraph(p -> p.text("First\nSecond").textStyle(CHROME))
+ .flexSpacer()
+ .addParagraph(p -> p.text("v2.4").textStyle(CHROME))
+ .build())
+ .build());
+
+ assertThat(exported.footerLine().getRuns()).as("on Word's own lines, none raised off them")
+ .allSatisfy(run -> assertThat(run.getCTR().isSetRPr() && run.getCTR().getRPr().sizeOfPositionArray() > 0)
+ .isFalse());
+ }
+
@Test
void wherePartsAfterAPartOfTwoLinesStandIsNotMeasured() throws Exception {
// Word sets what follows the break on its second line, whatever the page sets it on.
@@ -496,24 +518,45 @@ void aZoneParagraphsAnchorIsNamed() throws Exception {
@Test
void aLineThePageSetsAtTheEdgeStandsAtIt() throws Exception {
// An exact line's baseline is four fifths down it; text set against the page's top edge
- // stands a little higher than that, and the line stops at the edge.
+ // stands a little higher than that, and the line stops at the edge. The text is raised
+ // back onto the page's baseline, to the half point, as far as the line holds it.
Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, 40, DocumentInsets.zero(), "Acme",
DocumentTextStyle.DEFAULT.withSize(18)));
+ XWPFRun text = exported.headerLine().getRuns().get(0);
assertThat(fromTheTop(exported.margin().getHeader())).isZero();
- assertThat(share(exported.headerLine()) - exported.baseline("Acme")).as("lower than the page's, by a hair")
+ assertThat(share(exported.headerLine()) - exported.baseline("Acme")).as("the line's, lower than the page's")
.isBetween(0.0, 1.0);
+ assertThat(share(exported.headerLine())
+ - ((Number) text.getCTR().getRPr().getPositionArray(0).getVal()).doubleValue() / 2)
+ .as("the text's, raised").isCloseTo(exported.baseline("Acme"), within(0.3));
assertThat(exported.report().bySubject()).as("within the place a part keeps").doesNotContainKey("page zone");
}
@Test
- void aLineStoppedAtTheEdgeFurtherThanAPartKeepsItsPlaceIsNamed() throws Exception {
- // At 80pt the line's top would stand 1.8pt past the page's edge: stopped there, the text
- // stands that much lower than the page sets it.
+ void aLineStoppedAtTheEdgeFurtherThanAPartKeepsItsPlaceRaisesItBack() throws Exception {
+ // At 80pt the line's top would stand 1.8pt past the page's edge: stopped there, its baseline
+ // stands that much lower than the page sets the text, which is raised back onto the page's,
+ // its letters within the page as the line is.
Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, 120, DocumentInsets.zero(), "Acme",
DocumentTextStyle.DEFAULT.withSize(80)));
+ XWPFRun text = exported.headerLine().getRuns().get(0);
+ double raise = ((Number) text.getCTR().getRPr().getPositionArray(0).getVal()).doubleValue() / 2;
+
+ assertThat(share(exported.headerLine()) - exported.baseline("Acme")).as("the line's").isGreaterThan(1.5);
+ assertThat(share(exported.headerLine()) - raise).as("the text's").isCloseTo(exported.baseline("Acme"),
+ within(0.3));
+ assertThat(exported.report().bySubject()).doesNotContainKey("page zone");
+ }
+
+ @Test
+ void textThePageSetsPastTheEdgeIsNamed() throws Exception {
+ // Pulled 10pt up past the page's top, its letters would rise out of the line raised back.
+ Exported exported = export(DocumentPageZone.builder().zone(DocumentHeaderFooterZone.HEADER).height(40)
+ .content(page -> new ParagraphBuilder().text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18))
+ .margin(new DocumentInsets(-10, 0, 0, 0)).build())
+ .build());
- assertThat(share(exported.headerLine()) - exported.baseline("Acme")).isGreaterThan(1.5);
assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
.containsExactly("a header written as one line of Word's header; its text stands off where the page "
+ "sets it");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java
new file mode 100644
index 000000000..70facd2a6
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java
@@ -0,0 +1,465 @@
+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.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.RowBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.ImageFragmentPayload;
+import com.demcha.compose.document.node.DocumentBookmarkOptions;
+import com.demcha.compose.document.image.DocumentImageFitMode;
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.RowVerticalAlign;
+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.DocumentTextStyle;
+import com.demcha.compose.document.style.DocumentTransform;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFHeaderFooter;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTInline;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPageMar;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * A picture of a page zone — a logo — is written in the zone's line, where the page draws it.
+ *
+ * Word stands an inline picture on its line's baseline, four fifths down an exact line
+ * ({@link DocxTextBands#BASELINE_SHARE}). Where the picture is the line's tallest part, the line
+ * is placed by its foot, which then stands where the page draws it, and is tall enough to hold
+ * it. A part the page sets on a baseline of its own — the text beside a logo, set from the
+ * logo's top — is raised to it by {@code w:position}.
+ *
+ * The page's place for a picture is read from the layout; for a text, from the PDF the engine
+ * draws. Word's from the file: the distance from the edge, the exact line and the positions.
+ */
+class DocxZonePictureTest {
+
+ private static final double PAGE_HEIGHT = 600;
+ private static final DocumentTextStyle CHROME = DocumentTextStyle.DEFAULT.withSize(8);
+ private static final byte[] LOGO = png(48, 24);
+
+ @Test
+ void aHeadersLogoStandsWhereThePageDrawsIt() throws Exception {
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> logo().build()));
+ XWPFParagraph line = exported.headerLine();
+ PlacedFragment drawn = exported.picture();
+
+ assertThat(sizeOf(line.getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(fromTheTop(exported.margin().getHeader()) + share(line)).as("its foot, from the page's top")
+ .isCloseTo(PAGE_HEIGHT - drawn.y(), within(0.1));
+ assertThat(share(line)).as("the line holds it above its baseline").isGreaterThanOrEqualTo(24 - 0.05);
+ assertThat(line.getRuns().get(0).getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().getDescr())
+ .as("no file name for a screen reader to read out").isEmpty();
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void aContainedLogoStandsWhereThePageDrawsItInItsBox() throws Exception {
+ // A 2:1 picture contained in a 48 by 40 box is drawn 48 by 24 in its middle, 8pt above the
+ // box's foot: that is the foot Word stands on the baseline.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, 60,
+ page -> logo().size(48, 40).fitMode(DocumentImageFitMode.CONTAIN).build()));
+ XWPFParagraph line = exported.headerLine();
+
+ assertThat(sizeOf(line.getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(fromTheTop(exported.margin().getHeader()) + share(line)).as("its drawn foot, from the page's top")
+ .isCloseTo(PAGE_HEIGHT - (exported.picture().y() + 8), within(0.1));
+ }
+
+ @Test
+ void textSetAboveItsLogoGrowsTheLineToHoldIt() throws Exception {
+ // The logo, 6pt down the row, is the tallest part; the text, at the row's top, reaches 6pt
+ // above it, past the four fifths of the logo's 30pt line above its baseline.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24)
+ .margin(new DocumentInsets(6, 0, 0, 0)))
+ .flexSpacer()
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME))
+ .build()));
+ XWPFParagraph line = exported.headerLine();
+ double baseline = fromTheTop(exported.margin().getHeader()) + share(line);
+ XWPFRun text = line.getRuns().get(line.getRuns().size() - 1);
+
+ assertThat(lineOf(line)).as("30pt above the logo's foot, four fifths of it").isCloseTo(37.5, within(0.1));
+ assertThat(baseline).as("the logo's foot").isCloseTo(PAGE_HEIGHT - exported.picture().y(), within(0.1));
+ assertThat(baseline - positionOf(text)).isCloseTo(exported.baseline("Quarterly"), within(0.3));
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void aFootersLogoStandsWhereThePageDrawsIt() throws Exception {
+ Exported exported = export(zone(DocumentHeaderFooterZone.FOOTER, page -> logo().build()));
+ XWPFParagraph line = exported.footerLine();
+
+ assertThat(fromTheTop(exported.margin().getFooter()) + (lineOf(line) - share(line)))
+ .as("its foot, from the page's foot").isCloseTo(exported.picture().y(), within(0.1));
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void textBesideALogoIsRaisedToTheBaselineThePageSetsItOn() throws Exception {
+ for (RowVerticalAlign align : List.of(RowVerticalAlign.TOP, RowVerticalAlign.CENTER)) {
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .verticalAlign(align)
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24))
+ .flexSpacer()
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME))
+ .build()));
+ XWPFParagraph line = exported.headerLine();
+ double baseline = fromTheTop(exported.margin().getHeader()) + share(line);
+
+ assertThat(baseline).as("the logo's foot, " + align).isCloseTo(PAGE_HEIGHT - exported.picture().y(),
+ within(0.1));
+ XWPFRun text = line.getRuns().get(line.getRuns().size() - 1);
+ assertThat(text.text()).isEqualTo("Quarterly");
+ assertThat(baseline - positionOf(text)).as("raised onto its own baseline, to the half point, " + align)
+ .isCloseTo(exported.baseline("Quarterly"), within(0.3));
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+ }
+
+ @Test
+ void textRightAfterALogoStartsWhereWordSetsIt() throws Exception {
+ // Word sets the text after the picture's width, as the page sets it in the column after the
+ // logo's, as wide as the logo; an even split would set it at the row's middle, named.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .columns(com.demcha.compose.document.style.DocumentRowColumn.fixed(48),
+ com.demcha.compose.document.style.DocumentRowColumn.weight(1))
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24))
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME))
+ .build()));
+ Exported split = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24))
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME))
+ .build()));
+
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ assertThat(split.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a header written as one line of Word's header; 1 of its 2 parts stands off where "
+ + "the page sets them");
+ }
+
+ @Test
+ void aPageNumberBesideAFootersLogoIsRaisedEveryRunOfItsField() throws Exception {
+ Exported exported = export(zone(DocumentHeaderFooterZone.FOOTER, page -> new RowBuilder().name("Line")
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24))
+ .flexSpacer()
+ .add(page.pageNumber(CHROME))
+ .build()));
+ XWPFParagraph line = exported.footerLine();
+ List field = line.getRuns().subList(2, line.getRuns().size());
+ double raise = positionOf(field.get(0));
+
+ assertThat(field).as("begin, instruction, separator, result, end").hasSize(5)
+ .allSatisfy(run -> assertThat(positionOf(run)).isEqualTo(raise));
+ double baseline = PAGE_HEIGHT - fromTheTop(exported.margin().getFooter()) - (lineOf(line) - share(line));
+ assertThat(baseline - raise).isCloseTo(exported.baseline("1"), within(0.3));
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void aLogoShorterThanTheTextBesideItIsRaisedToWhereThePageDrawsIt() throws Exception {
+ // The tallest part, the text places the line; the logo, drawn from the row's top, is
+ // raised off its baseline as an author's picture is.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(30)))
+ .flexSpacer()
+ .addImage(image -> image.name("Logo").source(LOGO).size(16, 8))
+ .build()));
+ XWPFParagraph line = exported.headerLine();
+ XWPFRun picture = line.getRuns().get(line.getRuns().size() - 1);
+ double baseline = fromTheTop(exported.margin().getHeader()) + share(line);
+
+ assertThat(sizeOf(picture)).containsExactly(16.0, 8.0);
+ assertThat(baseline).as("the text's").isCloseTo(exported.baseline("Acme"), within(0.1));
+ assertThat(baseline - positionOf(picture)).as("its foot, raised").isCloseTo(PAGE_HEIGHT - exported.picture().y(),
+ within(0.3));
+ }
+
+ @Test
+ void aLogoIsWrittenAtTheSizeThePageDrawsIt() throws Exception {
+ // A 2:1 picture contained in a 60 by 24 box is drawn 48 by 24, in its middle; one that
+ // covers it is drawn the box's size, cropped.
+ Exported contained = export(zone(DocumentHeaderFooterZone.HEADER,
+ page -> logo().size(60, 24).fitMode(DocumentImageFitMode.CONTAIN).build()));
+ Exported covered = export(zone(DocumentHeaderFooterZone.HEADER,
+ page -> logo().size(60, 24).fitMode(DocumentImageFitMode.COVER).build()));
+
+ assertThat(sizeOf(contained.headerLine().getRuns().get(0))).containsExactly(48.0, 24.0);
+ XWPFRun cover = covered.headerLine().getRuns().get(0);
+ assertThat(sizeOf(cover)).containsExactly(60.0, 24.0);
+ assertThat(cover.getEmbeddedPictures().get(0).getCTPicture().getBlipFill().isSetSrcRect())
+ .as("cropped to its box").isTrue();
+ }
+
+ @Test
+ void aLinkedLogoIsWrittenInItsLink() throws Exception {
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER,
+ page -> logo().link(new DocumentLinkOptions("https://example.com")).build()));
+ var link = exported.headerLine().getCTP().getHyperlinkArray(0);
+
+ assertThat(link.getRArray(0).sizeOfDrawingArray()).as("the picture in the link").isEqualTo(1);
+ XWPFHeaderFooter header = exported.document().getHeaderList().get(0);
+ assertThat(header.getPackagePart().getRelationship(link.getId()).getTargetURI())
+ .hasToString("https://example.com");
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void whatALogoLosesOnTheLineIsNamed() throws Exception {
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> logo()
+ .transform(DocumentTransform.rotate(15)).anchor("logo")
+ .bookmark(new DocumentBookmarkOptions("Logo")).build()));
+
+ assertThat(exported.headerLine().getRuns().get(0).getEmbeddedPictures()).as("written all the same").hasSize(1);
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .singleElement().asString()
+ .contains("a picture's transform is not carried, so it is drawn upright at its size")
+ .contains("a picture's outline entry is not written")
+ .contains("a picture's anchor has no bookmark in the Word file: a link to it points at none");
+ assertThat(exported.report().bySubject()).doesNotContainKey("page zone content");
+ }
+
+ @Test
+ void aLogoInTwoKindsOfHeaderIsWrittenIntoEach() throws Exception {
+ // A second zone on the first page alone gives the section a first-page header: the logo's
+ // zone is written into it and into the ordinary one.
+ Exported exported = export(session -> {
+ session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER, page -> logo().build()));
+ session.chrome().zone(DocumentPageZone.builder().zone(DocumentHeaderFooterZone.FOOTER).height(20)
+ .appliesTo(page -> page.isFirst())
+ .content(page -> new ParagraphBuilder().text("Cover").build()).build());
+ }, true);
+
+ assertThat(exported.document().getHeaderList()).hasSize(2)
+ .allSatisfy(header -> assertThat(header.getAllPictures()).as("the logo").hasSize(1));
+ assertThat(exported.report().bySubject()).doesNotContainKey("page zone content");
+ }
+
+ @Test
+ void aLogoThePageDrawsOtherwiseOnItsFirstPageIsWrittenAndNotMeasured() throws Exception {
+ // Written 48 wide for no page in particular, it is drawn 24 wide on page 1: a line measured
+ // by that is not the one written.
+ Exported exported = export(session -> session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER,
+ page -> logo().size(page.isLast() ? 48 : 24, 24).build())), true);
+
+ assertThat(sizeOf(exported.headerLine().getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(exported.headerLine().getCTP().getPPr().getSpacing().isSetLineRule())
+ .as("Word's own line").isFalse();
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a header written as one line of Word's header; whether its picture stands where "
+ + "the page sets it is not measured");
+ }
+
+ @Test
+ void aLogoOfAnotherPictureOnItsFirstPageIsNotMeasuredWhereItsHeightIsThePicturesOwn() throws Exception {
+ // 48 wide, as tall as its picture makes it: a square on page 1, two to one as written.
+ Exported exported = export(session -> session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER, 60,
+ page -> new ImageBuilder().name("Logo")
+ .source(DocumentImageData.fromBytes(page.isLast() ? LOGO : png(48, 48))).width(48).build())),
+ true);
+
+ assertThat(sizeOf(exported.headerLine().getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a header written as one line of Word's header; whether its picture stands where "
+ + "the page sets it is not measured");
+ }
+
+ @Test
+ void withoutALayoutALogoIsWrittenAtTheSizeItStates() throws Exception {
+ byte[] docx;
+ try (DocumentSession session = GraphCompose.document().pageSize(400, PAGE_HEIGHT)
+ .margin(DocumentInsets.of(72)).create()) {
+ session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER, page -> logo().build()));
+ session.pageFlow(page -> page.addParagraph("Body"));
+ CapturingBackend captured = new CapturingBackend();
+ session.export(captured);
+ docx = new DocxSemanticBackend().export(captured.graph,
+ new com.demcha.compose.document.backend.semantic.SemanticExportContext(captured.canvas,
+ List.of(), null, captured.options));
+ }
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
+ XWPFParagraph line = document.getHeaderList().get(0).getParagraphs().get(0);
+ assertThat(sizeOf(line.getRuns().get(0))).containsExactly(48.0, 24.0);
+ }
+ }
+
+ private static ImageBuilder logo() {
+ return new ImageBuilder().name("Logo").source(DocumentImageData.fromBytes(LOGO)).size(48, 24);
+ }
+
+ private static DocumentPageZone zone(DocumentHeaderFooterZone kind,
+ Function content) {
+ return zone(kind, 48, content);
+ }
+
+ /** A zone of a height, its content 10pt in from its top. */
+ private static DocumentPageZone zone(DocumentHeaderFooterZone kind, double height,
+ Function content) {
+ return DocumentPageZone.builder().zone(kind).height(height).padding(new DocumentInsets(10, 0, 0, 0))
+ .content(content::apply).build();
+ }
+
+ /** The width and the height a picture's run writes it at, in points. */
+ private static List sizeOf(XWPFRun run) {
+ CTInline inline = run.getCTR().getDrawingArray(0).getInlineArray(0);
+ return List.of(inline.getExtent().getCx() / 12700.0, inline.getExtent().getCy() / 12700.0);
+ }
+
+ /** How far a run is raised off its line's baseline, in points: its {@code w:position}. */
+ private static double positionOf(XWPFRun run) {
+ var properties = run.getCTR().getRPr();
+ assertThat(properties).as("a raised run's properties").isNotNull();
+ assertThat(properties.sizeOfPositionArray()).as("a raised run's position").isEqualTo(1);
+ return ((Number) properties.getPositionArray(0).getVal()).doubleValue() / 2;
+ }
+
+ private static double share(XWPFParagraph paragraph) {
+ return 0.8 * lineOf(paragraph);
+ }
+
+ private static double lineOf(XWPFParagraph paragraph) {
+ CTSpacing spacing = paragraph.getCTP().getPPr().getSpacing();
+ return DocxTwips.of(spacing.getLine()) / 20.0;
+ }
+
+ private static double fromTheTop(Object twips) {
+ return DocxTwips.of(twips) / 20.0;
+ }
+
+ private static byte[] png(int width, int height) {
+ java.awt.image.BufferedImage image = new java.awt.image.BufferedImage(width, height,
+ java.awt.image.BufferedImage.TYPE_INT_RGB);
+ try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
+ javax.imageio.ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ } catch (IOException failure) {
+ throw new java.io.UncheckedIOException(failure);
+ }
+ }
+
+ /** Takes the graph, the canvas and the options a session hands any backend. */
+ private static final class CapturingBackend
+ implements com.demcha.compose.document.backend.semantic.SemanticBackend {
+
+ private com.demcha.compose.document.layout.DocumentGraph graph;
+ private com.demcha.compose.document.layout.LayoutCanvas canvas;
+ private com.demcha.compose.document.output.DocumentOutputOptions options;
+
+ @Override
+ public String name() {
+ return "capture";
+ }
+
+ @Override
+ public byte[] export(com.demcha.compose.document.layout.DocumentGraph documentGraph,
+ com.demcha.compose.document.backend.semantic.SemanticExportContext context) {
+ this.graph = documentGraph;
+ this.canvas = context.canvas();
+ this.options = context.outputOptions();
+ return new byte[0];
+ }
+ }
+
+ private record Exported(XWPFDocument document, DocxExportReport report, List text,
+ List pictures) {
+
+ CTPageMar margin() {
+ return document.getDocument().getBody().getSectPr().getPgMar();
+ }
+
+ XWPFParagraph headerLine() {
+ return document.getHeaderList().get(0).getParagraphs().get(0);
+ }
+
+ XWPFParagraph footerLine() {
+ return document.getFooterList().get(0).getParagraphs().get(0);
+ }
+
+ /** The box the page draws the zone's one picture in, on the first page. */
+ PlacedFragment picture() {
+ return pictures.get(0);
+ }
+
+ /** Where the page sets a word's baseline, from its top, on the first page it draws it. */
+ double baseline(String word) {
+ StringBuilder letters = new StringBuilder();
+ for (int start = 0; start < text.size(); start++) {
+ letters.setLength(0);
+ for (int index = start; index < text.size() && letters.length() < word.length(); index++) {
+ letters.append(text.get(index).getUnicode());
+ }
+ if (letters.toString().equals(word)) {
+ return text.get(start).getYDirAdj();
+ }
+ }
+ throw new AssertionError("the page draws no " + word);
+ }
+ }
+
+ private static Exported export(DocumentPageZone zone) throws Exception {
+ return export(session -> session.chrome().zone(zone), false);
+ }
+
+ private static Exported export(Consumer chrome, boolean twoPages) throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, PAGE_HEIGHT)
+ .margin(DocumentInsets.of(72))
+ .create()) {
+ chrome.accept(session);
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("Body"));
+ if (twoPages) {
+ page.addPageBreak(pageBreak -> { });
+ page.addParagraph(p -> p.text("More"));
+ }
+ });
+ List pictures = session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.path().startsWith("@page-zone") && fragment.pageIndex() == 0
+ && fragment.payload() instanceof ImageFragmentPayload)
+ .toList();
+ List text = textOf(session.toPdfBytes());
+ byte[] docx = session.export(new DocxSemanticBackend(report::set));
+ return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get(), text, pictures);
+ }
+ }
+
+ /** Every letter the page draws, in the order it draws them, page by page. */
+ private static List textOf(byte[] pdf) throws IOException {
+ List letters = new ArrayList<>();
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ PDFTextStripper stripper = new PDFTextStripper() {
+ @Override
+ protected void processTextPosition(TextPosition text) {
+ letters.add(text);
+ }
+ };
+ stripper.getText(document);
+ }
+ return letters;
+ }
+}
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
index 5bc4c8d66..18a2b3dd4 100644
--- 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
@@ -134,40 +134,36 @@ void partsSetOneAfterAnotherStandWhereWordSetsThem() throws Exception {
}
@Test
- void aPartOnABaselineOfItsOwnOrOfMoreLinesThanOneIsCounted() throws Exception {
+ void aPartOnABaselineOfItsOwnIsRaisedToItAndOneOfMoreLinesThanOneIsCounted() 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.
+ // the top, the smaller ones higher, and each is raised to its own.
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");
+ .build()))).isEmpty();
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");
+ .build()))).as("a page field as a paragraph").isEmpty();
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's line stands on its tallest part's baseline, where the page sets that part: a
- // smaller part the page sets lower moves up onto it, and the larger one stays.
+ .build()))).as("in a header too").isEmpty();
+ // A smaller part the page sets lower than the fifth of the line below Word's baseline holds
+ // is not lowered: it moves up onto that baseline, and is counted.
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 + "1 of its 2 parts stands off where the page sets them");
- // A part the page seats off its baseline is set on Word's.
+ // A part the page seats below its baseline, past what the line holds below Word's, is set
+ // on Word's, and counted.
assertThat(zoneNotes(DocumentPageZone.footer(40, page -> new RowBuilder().name("Line")
.addParagraph(p -> p.text("Confidential").textStyle(DocumentTextStyle.DEFAULT.withSize(18)))
.flexSpacer()
From 9554be7a5064f6eca1767c51650e32f40332df6a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 8 Oct 2026 05:58:13 +0100
Subject: [PATCH 2/3] refactor(docx): share a picture's fitted size, crop and
description
The body's writeImage and a page zone's picture fit a picture in its box, crop a covering one to it, and add it to a run the same way: addFittedPicture does it once for both. A zone's picture and an inline picture clear the file name POI describes a picture by through one describe. The body writes what it wrote before.
---
.../semantic/docx/DocxSemanticBackend.java | 133 ++++++++----------
1 file changed, 62 insertions(+), 71 deletions(-)
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 573cbd848..b342c7429 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
@@ -2452,60 +2452,27 @@ private void appendZonePart(XWPFParagraph para, DocumentNode part,
}
/**
- * A picture of a page zone's line, as it is written.
+ * Writes a picture of a page zone's line as an inline picture in it, at the size the page
+ * draws it ({@link #addFittedPicture}). Word stands it on the line's baseline, which
+ * {@link #zonePlacement} puts where the page sets the picture's foot where it is the line's
+ * tallest part. Its link is its run's.
*
- * @param data the picture, resolved
- * @param box the box the page draws it in: the layout's, or the one the picture states for
- * itself where the layout shows none
- * @param width how wide it is written: fitted inside the box where it is contained, the box's
- * width otherwise, a picture that covers its box cropped to it
- * @param height how tall it is written, likewise
+ * @param fragment the box the page draws it in, {@code null} where the layout does not show
+ * it: then the box the picture states for itself
*/
- private record ZonePicture(ImageData data, NodeDefinitionSupport.ImageDimensions box, double width,
- double height) {
- }
-
- /** A zone's picture at the size the page draws it ({@link ZonePicture}). */
- private ZonePicture zonePicture(ImageNode image, com.demcha.compose.document.layout.PlacedFragment fragment) {
+ private void writeZonePicture(XWPFParagraph para, ImageNode image,
+ com.demcha.compose.document.layout.PlacedFragment fragment) {
ImageData resolved = NodeDefinitionSupport.toImageData(image.imageData());
NodeDefinitionSupport.ImageDimensions box = fragment != null && fragment.width() > 0 && fragment.height() > 0
? new NodeDefinitionSupport.ImageDimensions(fragment.width(), fragment.height())
: NodeDefinitionSupport.resolveImageDimensions(image, zoneRightTab(), resolved);
- if (image.fitMode() != DocumentImageFitMode.CONTAIN) {
- return new ZonePicture(resolved, box, box.width(), box.height());
- }
- double sourceWidth = Math.max(1, resolved.getMetadata().width());
- double sourceHeight = Math.max(1, resolved.getMetadata().height());
- double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight);
- return new ZonePicture(resolved, box, sourceWidth * scale, sourceHeight * scale);
- }
-
- /**
- * Writes a picture of a page zone's line as an inline picture in it, at the size the page
- * draws it. Word stands it on the line's baseline, which {@link #zonePlacement} puts where
- * the page sets the picture's foot where it is the line's tallest part. Its link is its run's.
- *
- * @param fragment the box the page draws it in, {@code null} where the layout does not show it
- */
- private void writeZonePicture(XWPFParagraph para, ImageNode image,
- com.demcha.compose.document.layout.PlacedFragment fragment) {
- ZonePicture picture = zonePicture(image, fragment);
- byte[] bytes = picture.data().getBytes();
XWPFRun run = newRun(para, image.linkTarget());
- try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) {
- XWPFPicture written = run.addPicture(stream, pictureType(bytes), "image",
- Units.toEMU(picture.width()), Units.toEMU(picture.height()));
- if (image.fitMode() == DocumentImageFitMode.COVER) {
- applyCoverCrop(written, Math.max(1, picture.data().getMetadata().width()),
- Math.max(1, picture.data().getMetadata().height()), picture.box());
- }
- // POI describes a picture by the file name it is handed, which a screen reader then
- // reads out.
- run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr("");
- written.getCTPicture().getNvPicPr().getCNvPr().setDescr("");
+ try {
+ addFittedPicture(run, resolved.getBytes(), resolved, image.fitMode(), box);
} catch (Exception failure) {
throw new IllegalStateException("could not write a page zone's picture", failure);
}
+ describe(run, "");
}
/**
@@ -9681,11 +9648,8 @@ private PictureReach writeInlinePicture(XWPFParagraph para, InlineRun run, Docum
} catch (Exception failure) {
throw new IllegalStateException("could not write an inline picture", failure);
}
- // POI describes a picture by the file name it is handed, which a screen reader then
- // reads out; the description is the text the icon stands for, or nothing.
- String alt = description == null ? "" : description;
- picture.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr(alt);
- picture.getEmbeddedPictures().get(0).getCTPicture().getNvPicPr().getCNvPr().setDescr(alt);
+ // The description is the text the icon stands for, or nothing.
+ describe(picture, description == null ? "" : description);
if (description != null && !description.isBlank()) {
report.add(DocxExportReport.Severity.APPROXIMATED, "inline icon", path,
"drawn as a picture, as on the page; the text it stands for (" + description
@@ -10154,13 +10118,6 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception
box = new NodeDefinitionSupport.ImageDimensions(placedWidth, placedHeight);
}
}
- double drawWidth = box.width();
- double drawHeight = box.height();
- if (fitMode == DocumentImageFitMode.CONTAIN) {
- double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight);
- drawWidth = sourceWidth * scale;
- drawHeight = sourceHeight * scale;
- }
if (drawnOverItsBadge(node, clipContainer)) {
drawWhereThePagePutsIt(document, node, bytes, sourceWidth, sourceHeight,
"drawn over the badge holding it");
@@ -10178,25 +10135,59 @@ private void writeImage(XWPFDocument document, ImageNode node) throws Exception
// probe's image ran straight into the heading under it, 24pt short of the page.
applyVerticalSpacing(para, node);
XWPFRun run = para.createRun();
- try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) {
- XWPFPicture picture = run.addPicture(stream,
- pictureType(bytes),
- "image",
- Units.toEMU(drawWidth),
- Units.toEMU(drawHeight));
- if (fitMode == DocumentImageFitMode.COVER) {
- applyCoverCrop(picture, sourceWidth, sourceHeight, box);
- }
- // A portrait clipped to a circle: the picture takes the circle's shape, which both
- // editors crop it to, instead of standing square over the ring drawn round it.
- if (fillsItsEllipse(node, clipContainer) && picture.getCTPicture().getSpPr().isSetPrstGeom()) {
- picture.getCTPicture().getSpPr().getPrstGeom()
- .setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE);
- }
+ XWPFPicture picture = addFittedPicture(run, bytes, resolved, fitMode, box);
+ // A portrait clipped to a circle: the picture takes the circle's shape, which both
+ // editors crop it to, instead of standing square over the ring drawn round it.
+ if (fillsItsEllipse(node, clipContainer) && picture.getCTPicture().getSpPr().isSetPrstGeom()) {
+ picture.getCTPicture().getSpPr().getPrstGeom()
+ .setPrst(org.openxmlformats.schemas.drawingml.x2006.main.STShapeType.ELLIPSE);
}
reportWrittenWithout(node, "written as an inline picture", sidesLost(node, true, false));
}
+ /**
+ * Adds a picture to a run at the size the page draws it in its box: fitted inside the box
+ * where it is contained, across it otherwise, and cropped to it where it covers it
+ * ({@link #applyCoverCrop}).
+ *
+ * @param resolved the picture, resolved: its own size
+ * @param box the box the page draws it in
+ * @return the picture added
+ */
+ private XWPFPicture addFittedPicture(XWPFRun run, byte[] bytes, ImageData resolved,
+ DocumentImageFitMode fitMode,
+ NodeDefinitionSupport.ImageDimensions box) throws Exception {
+ double sourceWidth = Math.max(1, resolved.getMetadata().width());
+ double sourceHeight = Math.max(1, resolved.getMetadata().height());
+ double drawWidth = box.width();
+ double drawHeight = box.height();
+ if (fitMode == DocumentImageFitMode.CONTAIN) {
+ double scale = Math.min(box.width() / sourceWidth, box.height() / sourceHeight);
+ drawWidth = sourceWidth * scale;
+ drawHeight = sourceHeight * scale;
+ }
+ XWPFPicture picture;
+ try (InputStream stream = new java.io.ByteArrayInputStream(bytes)) {
+ picture = run.addPicture(stream, pictureType(bytes), "image",
+ Units.toEMU(drawWidth), Units.toEMU(drawHeight));
+ }
+ if (fitMode == DocumentImageFitMode.COVER) {
+ applyCoverCrop(picture, sourceWidth, sourceHeight, box);
+ }
+ return picture;
+ }
+
+ /**
+ * Describes a run's picture as a screen reader reads it out: POI describes a picture by the
+ * file name it is handed.
+ *
+ * @param description the text the picture stands for, or nothing
+ */
+ private static void describe(XWPFRun run, String description) {
+ run.getCTR().getDrawingArray(0).getInlineArray(0).getDocPr().setDescr(description);
+ run.getEmbeddedPictures().get(0).getCTPicture().getNvPicPr().getCNvPr().setDescr(description);
+ }
+
/**
* Whether a picture is what a shape container clips to an ellipse, filling it: the ellipse
* inscribed in the picture's box is then the one the page clips it to. A logo smaller than
From 359217f4566361529d1e4afad6afd5d8de5287a9 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 8 Oct 2026 06:02:28 +0100
Subject: [PATCH 3/3] fix(docx): read a contained zone picture by its own
proportions, and keep zone parts off the body's lines
A picture contained in a stated box is drawn as its own proportions fit
it there, so a zone whose first page draws another picture in that box
was read as alike, its line placed by the other picture's foot, and
nothing named. readAlike now needs the same picture for a contained one,
as for one whose side is left to its proportions.
A zone's paragraph no longer reads the lines the body lays the same node
out in: it is not seated by them, its pictures are not placed by them,
and it is raised as the zone's line places it alone. A node used in the
body and in a zone was raised twice, its note gone.
A part's raise is written in whole half points - the nearest, or the
next one towards the line's baseline where the nearest would pass the
line's edge - and checked against what is written. A picture is lowered
only as far as the line holds below its baseline: its foot is ink.
---
CHANGELOG.md | 40 +--
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 35 +--
render-docx/README.md | 9 +-
.../semantic/docx/DocxSemanticBackend.java | 106 +++++---
.../backend/semantic/docx/DocxZoneParts.java | 14 +-
.../docx/DocxNodeFieldLedgerTest.java | 16 +-
.../semantic/docx/DocxReportedLossesTest.java | 4 +-
.../semantic/docx/DocxZoneLineTest.java | 34 ++-
.../semantic/docx/DocxZonePictureTest.java | 227 ++++++++++++++++--
10 files changed, 375 insertions(+), 112 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0afb6072e..d359173f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,30 +17,35 @@ follow semantic versioning; release dates are ISO 8601.
- **Word stands it on the line's baseline.** Where it is the line's tallest part, the line is
placed by its foot, which then stands where the page draws it, and is tall enough to hold it:
a 24pt logo makes a 30pt exact line, its baseline four fifths down.
- - **A part the page sets on a baseline of its own is raised or lowered to it** (`w:position`),
- and the exact line grows to hold what is raised. The text beside a logo, set from the logo's
- top or its middle, would otherwise stand on the logo's foot, 9 to 18pt low. A smaller text
- beside a larger one is raised too, where it stood on the larger one's baseline. A part is
- lowered only as far as the fifth of the line below Word's baseline holds; one set lower
- stands on Word's baseline, and the note counts it. A picture that is not the line's tallest
- part is raised as a text is. A line that stops at the page's edge raises its tallest part back
- onto the page's baseline too, where it stood up to the edge's distance off it: an 80pt title
- against the top edge stood 1.8pt low, named. Only text the page sets past the edge itself is
- left off its baseline, and named.
+ - **A one-line part the page sets on a baseline of its own is raised or lowered to it**
+ (`w:position`, in half points), in a zone of one line, and the exact line grows to hold
+ what is raised. The text beside a logo, set from the logo's top or its middle, would
+ otherwise stand on the logo's foot, 9 to 18pt low. A smaller text beside a larger one is
+ raised too, where it stood on the larger one's baseline. A part is lowered only as far as
+ the line holds below Word's baseline — a fifth of it, or as far as its tallest part reaches;
+ one set lower stands on Word's baseline, and the note counts it. A picture that is not the
+ line's tallest part is raised as a text is. A line that stops at the page's edge raises its
+ tallest part back onto the page's baseline too: an 80pt title against the top edge stood
+ 1.8pt low before, and was named. Text the page sets past the edge itself is left off its
+ baseline, and named.
- **What a picture loses on the line is named** in the `page zone` note: its transform, its
- outline entry and its anchor's bookmark. A picture the zone builds otherwise for the first
- page it is drawn on than as written is written, and where it stands is named not measured.
+ outline entry and its anchor's bookmark. A picture the zone lays out on the first page it
+ is drawn on in another box — another size, fit or insets — or, where its own proportions
+ set what is drawn (a side left unstated, or a picture contained in its box), another
+ picture, is written, and where it stands is named not measured.
- **Measured** in Word 16 and LibreOffice on a 48 by 24pt logo in a header and in a footer:
alone, beside 8pt text set from its top and from its middle, after 18pt text, and beside a
page number. Each logo stood where the page draws it, to a tenth of a point, in both editors.
- In Word each text's baseline stood within a quarter point of the page's, the half point
- `w:position` counts in. LibreOffice raises a run about a seventh further than `w:position`
- says, and does not raise a page field, which stands on the logo's foot there.
+ In Word each text's baseline stood within half a point of the page's: a run is raised by
+ whole half points, and to the next one towards the line's baseline where the nearest would
+ pass the line's edge. LibreOffice raises a run about a seventh further than `w:position`
+ says, does not raise a page field, which stands on the logo's foot there, and stands every
+ picture on the line's baseline, a raised one included.
- Anything else in a page zone — a shape, a barcode, a container — is still `DROPPED`, `page
zone content`.
- No document of the DOCX fidelity corpus has a page zone; its bytes are unchanged. In
`DocxNodeFieldLedgerTest` the `zones` entry writes each part's own baseline and the zone's
- pictures, and names a part set lower than the line holds.
+ pictures, and names a part set lower than the line holds or past the page's edge.
- **A DOCX export writes a row's fill, outline and side borders, as a panel holding its
columns.** A row paints its box as a container does — `RowBuilder.fillColor`, `stroke`,
@@ -399,7 +404,8 @@ follow semantic versioning; release dates are ISO 8601.
there;
- it sits on Word's baseline: its tallest part's, with the line standing at the zone's edge
(since placed by that part's baseline: see "A DOCX page zone's text stands on the page's
- baseline");
+ baseline"; and each part since raised to its own: see "A DOCX page zone writes its picture
+ — a logo — in its line, where the page draws it");
- it is one line;
- no prefix stands before it, on the line's left side.
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 098331a87..f74a368ac 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, a translucent one flattened against white and reported (`translucency`); 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, 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, a part the page sets on a baseline of its own raised or lowered to it by `w:position` — lowered only as far as the fifth of the exact line below the baseline holds; LibreOffice raises a run about a seventh further than Word and does not raise a page field —; 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 whose content is built otherwise for its first page — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for no page in particular is not written and 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 |
+| 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 whose content is built otherwise for its first page — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for no page in particular is not written and 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 e0a90bbec..5e41c9932 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -933,8 +933,8 @@ report names it (see "Translucent colours"). Three limits:
the flow, and is drawn as a shape like other drawing. A layer stack of one layer lays
nothing over anything, and a line among the text in it is a rule; its other shapes, and
a line in a stack that holds nothing but drawing, stay drawing.
-- A rule in a page zone is not written, as a zone takes paragraphs, fields and
- spacers.
+- A rule in a page zone is not written, as a zone takes paragraphs, fields, spacers
+ and pictures.
- Word draws one border for consecutive paragraphs whose borders and indents are
the same, whatever the space between them, so two identical rules with no other
paragraph between them show as one.
@@ -1098,10 +1098,10 @@ A zone the layout measures that shares its kind with another page zone — a cov
the first page, the running header on the rest — stands in a frame (`w:framePr`) at its own
height, at least its lines tall, since Word holds one distance from the edge for a kind; written
in the flow, the cover's header stood 16pt high. Beside a text band of its kind, the band is
-framed. Where the layout shows no text of the zone, or the zone's content is built otherwise for
-the first page it is drawn on than it is written — other text, another face or size, other
-pictures — the line is Word's, the distance is read from where its content landed, and the
-report says where its text stands is not measured. Such a zone is not framed: two of one kind
+framed. Where the layout shows none of the zone's text or pictures, or the zone's content is
+built otherwise for the first page it is drawn on than it is written — other text, another face
+or size, another picture or picture box — the line is Word's, the distance is read from where its
+content landed, and the report says where its text or picture stands is not measured. Such a zone is not framed: two of one kind
share Word's one distance, the last one's, and stand one under the other. A zone whose content
is none for no page in particular is not written, and the report names it. A zone is written
from its paragraphs, page fields, spacers and pictures. A picture — a logo — is an inline
@@ -1113,16 +1113,19 @@ which then stands where the page draws it. Anything else in a zone — a shape,
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, at the right tab the line holds, on one
-baseline, its tallest part's. A part the page sets on a baseline of its own — the text beside
-a logo, set from the logo's top, or a smaller text beside a larger one — is raised or lowered to
-it (`w:position`), and the exact line grows to hold what is raised; it is lowered only as far as
-the fifth of the line below Word's baseline holds. Measured on a 48 by 24pt logo beside 8pt text
-and beside a page number, each text's baseline stood within a quarter point of the page's in
-Word, the half point `w:position` counts in. LibreOffice raises a run about a seventh further
-than `w:position` says, and does not raise a page field, which stands on Word's baseline there.
-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 — lower than the line holds, over more than one line
-or after a prefix, the export report counts it (`page zone`): "1 of its 3 parts stands off where
+baseline, its tallest part's. In a zone of one line, a one-line part the page sets on a
+baseline of its own — the text beside a logo, set from the logo's top, or a smaller text beside
+a larger one — is raised or lowered to it (`w:position`), and the exact line grows to hold what
+is raised; it is lowered only as far as the line holds below Word's baseline, a fifth of it or
+as far as its tallest part reaches. A run is raised by whole half points, to the next one
+towards the line's baseline where the nearest would pass the line's edge. Measured on a 48 by
+24pt logo beside 8pt text and beside a page number, each text's baseline stood within half a
+point of the page's in Word. LibreOffice raises a run about a seventh further than
+`w:position` says, does not raise a page field, which stands on Word's baseline there, and
+stands every picture on the line's baseline, a raised one included. 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 — 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
at another width — after a prefix, auto-sized to a size its lines do not tell, over more
diff --git a/render-docx/README.md b/render-docx/README.md
index 446bb708d..cdd33d419 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -98,8 +98,9 @@ What maps:
- **Pages.** Page size, margins and orientation; page zones (`session.chrome().zone(...)`)
and text headers and footers (`session.header(...)` / `footer(...)`) as real Word headers
and footers with live page-number fields, a zone's logo an inline picture where the page
- draws it, and each part of a zone's line raised to the baseline the page sets it on; document metadata (title, author, subject,
- keywords).
+ draws it, and, in a zone of one line, each one-line part Word sets as the page does raised
+ or lowered to the baseline the page sets it on, as far as the line holds it; document
+ metadata (title, author, subject, keywords).
- **Byte-identical output** with `DocxSemanticBackend.builder().deterministic(true)`.
What is not written — each one is named in the export report
@@ -126,8 +127,8 @@ What is not written — each one is named in the export report
Text, drawings and pictures keep their alpha.
- **In a page zone, anything but paragraphs, page fields, spacers and pictures** — a barcode,
a rule, a panel — 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`.
+- **Where a page zone's parts stand on Word's line, and what a paragraph or a picture 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;
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 b342c7429..4cb7b9ae3 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
@@ -214,6 +214,10 @@ public final class DocxSemanticBackend implements SemanticBackend {
// kind and no name are two losses.
private final java.util.Set zonePartsReported =
java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
+ // Whether a page zone's line is being written: its parts stand on its baseline as the line
+ // places them (zonePlacement), not by the lines the body lays a node out in, which the same
+ // node also placed in the body would otherwise lend them.
+ private boolean writingAZoneLine;
// The text bands whose translucent separator is already reported this export: a band is
// written into each kind of header or footer Word is given.
private final java.util.Set separatorsReported =
@@ -1827,10 +1831,12 @@ private void separateFromAnEqualFrame(XWPFHeaderFooter part, long frameTop) {
* carries the rest to the right margin — which is how a Word footer is built by
* hand anyway.
*
- * Where the layout shows its text, the line is exact and as tall as {@link #zonePlacement}
- * says, which with the distance {@link #placeZone} writes stands it on the tallest part's
- * baseline; framed, where the zone shares its kind with another page zone, the frame stands
- * at the line's own height on the page, at least the line tall.
+ * Where the layout shows its text or pictures, the line is exact and as tall as
+ * {@link #zonePlacement} says, which with the distance {@link #placeZone} writes stands it on
+ * the tallest part's baseline, each other one-line part raised or lowered to its own as far
+ * as the line holds it ({@link ZonePlacement#raised}); framed, where the zone shares its kind
+ * with another page zone, the frame stands at the line's own height on the page, at least
+ * the line tall.
*
* @param zoneIndex the zone's position in the section's zone list
*/
@@ -1878,12 +1884,17 @@ private void writeZoneLine(XWPFHeaderFooter target, int zoneIndex, DocumentNode
java.util.Map paths = DocxLayoutMetrics.pathsWithin(content);
java.util.Map laid = layout.zoneText(zoneIndex);
java.util.Map pictures = layout.zonePictures(zoneIndex);
- for (DocumentNode part : parts) {
- int runsBefore = runsIn(para).size();
- appendZonePart(para, part, zoneLinesOf(part, paths, laid, placement),
- placement.laidOtherwise().contains(part) ? null : pictures.get(paths.get(part)));
- raiseRunsFrom(para, runsBefore,
- Math.round(placement.raised().getOrDefault(part, 0.0) * HALF_POINTS_PER_POINT));
+ writingAZoneLine = true;
+ try {
+ for (DocumentNode part : parts) {
+ int runsBefore = runsIn(para).size();
+ appendZonePart(para, part, zoneLinesOf(part, paths, laid, placement),
+ placement.laidOtherwise().contains(part) ? null : pictures.get(paths.get(part)));
+ raiseRunsFrom(para, runsBefore,
+ Math.round(placement.raised().getOrDefault(part, 0.0) * HALF_POINTS_PER_POINT));
+ }
+ } finally {
+ writingAZoneLine = false;
}
}
@@ -1940,7 +1951,8 @@ private static List z
* of another's, which tell nothing of it: not the size an auto-sized one is fitted to, nor the
* pieces of its markdown. A part it set there as written keeps them.
*
- * @param line the exact line's height in points, NaN where no part's text is laid out
+ * @param line the exact line's height in points, NaN where the layout shows none of
+ * the zone's text or pictures
* @param lines how many lines the part of the most takes on the page
* @param distance from the page's top edge to the paragraph's top in a header, from its
* foot to the paragraph's foot in a footer, in points
@@ -1949,7 +1961,8 @@ private static List z
* than they are written ({@link DocxZoneParts#partsReadOtherwise}), whose
* lines there are not their own
* @param raised how far each part the page sets on a baseline of its own is raised
- * from the line's to it, in points, lowered where below zero
+ * from the line's to it, in points, lowered where below zero: in the
+ * half points Word counts a run's position in
*/
private record ZonePlacement(double line, int lines, double distance, double baseline,
java.util.Set laidOtherwise,
@@ -1963,7 +1976,7 @@ static ZonePlacement laidOtherwise(java.util.Set parts) {
return new ZonePlacement(Double.NaN, 0, Double.NaN, Double.NaN, parts, java.util.Map.of());
}
- /** Whether the layout laid out the text the line is placed by. */
+ /** Whether the layout laid out the text or picture the line is placed by. */
boolean measured() {
return !Double.isNaN(line);
}
@@ -2072,13 +2085,23 @@ && setsAPrefixBeforeTheFirstLine(prefixed))) {
header ? canvasHeight - tallest.baseline() : tallest.baseline() - (mostLines - 1) * line, line);
double baseline = header ? canvasHeight - distance - above : distance + (mostLines - 1) * line + (line - above);
// Raised or lowered from the line's baseline as Word sets it — a line stopping at the
- // page's edge stands it further in — where the line holds it.
+ // page's edge stands it further in — by the half points Word counts in, where the line
+ // holds it so: to the nearest, or to the next one towards the line's baseline where the
+ // nearest passes the line's edge, half a point off at most.
java.util.Map raised = new java.util.IdentityHashMap<>();
+ double holdsAbove = Math.max(above, tallest.above()) + ZONE_LINE_HOLDS;
for (java.util.Map.Entry part : standing.entrySet()) {
- double raise = part.getValue().baseline() - baseline;
- if (Math.round(raise * HALF_POINTS_PER_POINT) != 0
- && raise + part.getValue().above() <= Math.max(above, tallest.above()) + ZONE_LINE_HOLDS
- && part.getValue().below() - raise <= Math.max(line - above, tallest.below()) + ZONE_LINE_HOLDS) {
+ ZoneLine at = part.getValue();
+ // Below, a text may reach as far as the tallest part's letters do; a picture's foot is
+ // ink, and stays within the line.
+ double holdsBelow = (part.getKey() instanceof ImageNode
+ ? line - above : Math.max(line - above, tallest.below())) + ZONE_LINE_HOLDS;
+ double exact = (at.baseline() - baseline) * HALF_POINTS_PER_POINT;
+ double raise = Math.round(exact) / HALF_POINTS_PER_POINT;
+ if (raise + at.above() > holdsAbove || at.below() - raise > holdsBelow) {
+ raise = (exact > 0 ? Math.floor(exact) : Math.ceil(exact)) / HALF_POINTS_PER_POINT;
+ }
+ if (raise != 0 && raise + at.above() <= holdsAbove && at.below() - raise <= holdsBelow) {
raised.put(part.getKey(), raise);
}
}
@@ -2133,7 +2156,8 @@ private double zoneRightTab() {
* ({@link #zonePlacement}): its tallest part's, where the page sets that part, or off it
* where the line stops at the page's edge. 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
+ * of what holds a body paragraph's breaks where the page sets them — on the baseline Word
+ * sets it on, the line's raised or lowered by its own position ({@link ZonePlacement#raised}), 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
@@ -2173,13 +2197,13 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
// Its box on the page: a text's laid-out lines, a picture's box.
java.util.Map laidOut =
new java.util.IdentityHashMap<>();
- List texts = new ArrayList<>();
+ List placed = new ArrayList<>();
int spacers = 0;
for (DocumentNode part : parts) {
if (part instanceof SpacerNode) {
spacers++;
} else if (part instanceof ParagraphNode || part instanceof PageFieldNode || part instanceof ImageNode) {
- texts.add(part);
+ placed.add(part);
side.put(part, Math.min(spacers, 2));
com.demcha.compose.document.layout.PlacedFragment fragment =
(part instanceof ImageNode ? pictures : laid).get(paths.get(part));
@@ -2193,7 +2217,7 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
java.util.Map wordsStart = new java.util.IdentityHashMap<>();
java.util.Map wordsEnd = new java.util.IdentityHashMap<>();
double x = canvasLeftMargin;
- for (DocumentNode part : texts) {
+ for (DocumentNode part : placed) {
com.demcha.compose.document.layout.PlacedFragment fragment = laidOut.get(part);
if (side.get(part) != 0 || fragment == null) {
break;
@@ -2205,7 +2229,7 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
x += wordsWidthOf(part, fragment);
}
double end = canvasLeftMargin + zoneRightTab();
- List againstTheRight = texts.stream().filter(part -> side.get(part) == 1).toList();
+ List againstTheRight = placed.stream().filter(part -> side.get(part) == 1).toList();
for (int index = againstTheRight.size() - 1; index >= 0; index--) {
DocumentNode part = againstTheRight.get(index);
com.demcha.compose.document.layout.PlacedFragment fragment = laidOut.get(part);
@@ -2220,19 +2244,19 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
}
// Where the page sets each part's first line, and draws each picture.
java.util.Map onThePage = new java.util.IdentityHashMap<>();
- for (DocumentNode part : texts) {
+ for (DocumentNode part : placed) {
com.demcha.compose.document.layout.PlacedFragment fragment = laidOut.get(part);
if (measured && fragment != null) {
onThePage.put(part, part instanceof ImageNode ? pictureOnThePage(fragment) : firstLineOnThePage(fragment));
}
}
- // Read from the same text the line is placed by: where none is, no part is read either.
+ // Read from the same parts the line is placed by: where none is, no part is read either.
double baseline = placement.baseline();
int off = 0;
int unread = 0;
// Past a part of more lines than one, Word sets the rest on a later line of its own.
boolean broken = false;
- for (DocumentNode part : texts) {
+ for (DocumentNode part : placed) {
ZoneLine line = onThePage.get(part);
Double wordStart = wordsStart.get(part);
Double wordEnd = wordsEnd.get(part);
@@ -2240,9 +2264,8 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
&& setsAPrefixBeforeTheFirstLine(paragraph);
boolean afterABreak = broken;
broken |= line != null && !line.oneLine();
- // Word stands a part raised or lowered from the line's baseline by whole half points.
- double wordsBaseline = baseline + Math.round(placement.raised().getOrDefault(part, 0.0)
- * HALF_POINTS_PER_POINT) / HALF_POINTS_PER_POINT;
+ // Word stands a part raised or lowered from the line's baseline as written.
+ double wordsBaseline = baseline + placement.raised().getOrDefault(part, 0.0);
if (line == null || afterABreak) {
unread++;
} else if (side.get(part) == 2 || !line.oneLine() || prefixed
@@ -2257,22 +2280,22 @@ private void reportZoneLine(int zoneIndex, boolean header, DocumentNode content,
}
java.util.Set lost = new java.util.LinkedHashSet<>();
// What the line's placed parts are, to a reader: its text, its picture, or its parts.
- boolean pictured = texts.stream().anyMatch(part -> part instanceof ImageNode);
- boolean several = pictured && texts.size() > 1;
+ boolean pictured = placed.stream().anyMatch(part -> part instanceof ImageNode);
+ boolean several = pictured && placed.size() > 1;
String what = !pictured ? "its text" : several ? "its parts" : "its picture";
- if (!texts.isEmpty() && unread == texts.size()) {
+ if (!placed.isEmpty() && unread == placed.size()) {
lost.add("whether " + what + (several ? " stand where the page sets them" : " stands where the page sets it")
+ " is not measured");
} else {
if (off > 0) {
- lost.add(texts.size() == 1 ? what + " stands off where the page sets it"
- : partsOf(off, texts.size()) + " off where the page sets them");
+ lost.add(placed.size() == 1 ? what + " stands off where the page sets it"
+ : partsOf(off, placed.size()) + " off where the page sets them");
}
if (unread > 0) {
- lost.add("where " + partsOf(unread, texts.size()) + " is not measured");
+ lost.add("where " + partsOf(unread, placed.size()) + " is not measured");
}
}
- for (DocumentNode part : texts) {
+ for (DocumentNode part : placed) {
if (part instanceof ParagraphNode paragraph) {
lost.addAll(zoneParagraphLost(paragraph, zoneLinesOf(part, paths, laid, placement)));
} else if (part instanceof ImageNode image) {
@@ -8895,7 +8918,9 @@ private void writeParagraphRuns(XWPFParagraph para, ParagraphNode node, boolean
? 0 : runsIn(para).size();
warnDroppedInlineRuns(node);
String path = layout.pathOf(node);
- java.util.Optional line = layout.firstLine(node);
+ // A zone's part has no line of the body's, though the node may be laid out there too.
+ java.util.Optional line =
+ writingAZoneLine ? java.util.Optional.empty() : layout.firstLine(node);
boolean wroteARun = false;
PictureReach pictures = PictureReach.NONE;
DocumentTextStyle fitted = fittedStyle(node, lines);
@@ -9128,8 +9153,8 @@ private boolean holdsText(ParagraphNode node) {
* Only this paragraph's runs move — a line pair writes another's in the same Word
* paragraph — and a picture's own raise is added to, as the page moves a picture with its
* line's seated baseline. The room made for a picture in the line is its unseated reach. A
- * page zone's paragraph has no laid-out lines here: it is raised as a part of its zone's line
- * ({@link #zonePlacement}).
+ * page zone's paragraph is not seated here, by lines the body may lay the same node out in: it
+ * is raised as a part of its zone's line ({@link #zonePlacement}).
*
* The baseline the page seats off is not where Word puts it either (see
* {@link #shiftToThePagesBaseline}), and the two moves are one position.
@@ -9142,6 +9167,9 @@ private boolean holdsText(ParagraphNode node) {
*/
private void seatInTheLine(XWPFParagraph para, ParagraphNode node, int runsBefore, double lineTopAbove,
boolean heldExact) {
+ if (writingAZoneLine) {
+ return;
+ }
raiseRunsFrom(para, runsBefore, Math.round(
(seatShift(node) + shiftToThePagesBaseline(para, node, lineTopAbove, heldExact)) * HALF_POINTS_PER_POINT));
}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
index 960967ef7..37345d71d 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneParts.java
@@ -1,6 +1,7 @@
package com.demcha.compose.document.backend.semantic.docx;
import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.image.DocumentImageFitMode;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.ImageNode;
import com.demcha.compose.document.node.InlineHighlightRun;
@@ -38,7 +39,8 @@ static List of(DocumentNode content) {
/**
* Whether content built for a page reads as the content written, as far as a zone's line is
* set by it: its parts one by one, each paragraph's text and face and each of its runs' — the
- * letters, their face, and a picture's size and place — and each page field's kind and face.
+ * letters, their face, and a picture's size and place — each page field's kind and face, and
+ * each picture's box ({@link #samePictureBox}).
* Colour is left out: it sets nothing of the line, and a colour built afresh is not equal to
* itself.
*
@@ -102,15 +104,17 @@ && sameFace(paragraph.textStyle(), drawn.textStyle())
}
/**
- * Whether two pictures are laid out in one box: the sizes, fit and insets they state, and,
- * where they leave a side to the picture's own proportions, the same picture.
+ * Whether two pictures are drawn alike: in one box — the sizes, fit and insets they state —
+ * and, where their own proportions set what is drawn — a side they leave unstated, or a
+ * picture contained in its box — the same picture.
*/
private static boolean samePictureBox(ImageNode picture, ImageNode other) {
- boolean sized = picture.width() != null && picture.height() != null;
+ boolean boxed = picture.width() != null && picture.height() != null
+ && picture.fitMode() != DocumentImageFitMode.CONTAIN;
return Objects.equals(picture.width(), other.width()) && Objects.equals(picture.height(), other.height())
&& Objects.equals(picture.scale(), other.scale()) && picture.fitMode() == other.fitMode()
&& picture.padding().equals(other.padding()) && picture.margin().equals(other.margin())
- && (sized || samePicture(picture.imageData(), other.imageData()));
+ && (boxed || samePicture(picture.imageData(), other.imageData()));
}
/** Whether two pictures' data are the same file or the same bytes. */
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 13a4fbeee..14555d6de 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
@@ -120,14 +120,14 @@ private record Entry(Fate fate, String note) {
// The node entries below are the body's. A zone is written as one line of its
// paragraphs' runs, page fields, pictures and tabs, and has its own entry here.
"zones:REPORTED:where its parts stand across the line, a part the page sets lower than the line "
- + "holds below Word's baseline, what its paragraphs and pictures lose of their own — an anchor, an "
- + "outline entry, a picture's transform, a picture's place among a paragraph's runs — what else a "
- + "zone holds, lines reaching past the page margin, a line the layout does not measure, and a zone "
- + "built as nothing for no page in particular; its line's height, its lines, its tallest part's "
- + "baseline, each other part's own, raised or lowered to it, and the room that part holds above and "
- + "below its text are written, as exact lines placed from the edge, in a frame at its height where "
- + "a measured zone shares its kind with another page zone; its pictures are written in the line at "
- + "the size the page draws them");
+ + "holds below Word's baseline or past the page's edge, what its paragraphs and pictures lose of "
+ + "their own — an anchor, an outline entry, a picture's transform, a picture's place among a "
+ + "paragraph's runs — what else a zone holds, lines reaching past the page margin, a line the "
+ + "layout does not measure, and a zone built as nothing for no page in particular; its line's "
+ + "height, its lines, its tallest part's baseline, each other one-line part's own, raised or "
+ + "lowered to it, and the room that part holds above and below its text are written, as exact "
+ + "lines placed from the edge, in a frame at its height where a measured zone shares its kind "
+ + "with another page zone; its pictures are written in the line at the size the page draws them");
static {
node(AlignNode.class, "name:INERT", "child:WRITTEN", "align:WRITTEN", "margin:WRITTEN");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
index 8810336ac..c81f0d792 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxReportedLossesTest.java
@@ -42,11 +42,11 @@
* What a document asks for that the Word file is not given is in the report, not only in the
* file's absence.
*
- * A row's fill in a page zone or a table cell, a logo in a page header, a watermark, a
+ *
A row's fill in a page zone or a table cell, a shape in a page header, a watermark, a
* protection and a node kind the export does not know were each left out of the Word file with no
* more than a log line, or nothing: a caller reading the report was told the document lost nothing.
* Each is now a note naming what the file does not carry. A row's paint in the flow is written
- * ({@link DocxRowPaintTest}).
+ * ({@link DocxRowPaintTest}), and so is a page zone's picture ({@link DocxZonePictureTest}).
*/
class DocxReportedLossesTest {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
index 684838c50..c9d35a9f6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZoneLineTest.java
@@ -153,6 +153,22 @@ void aFootersRowBesideAPartOfTwoLinesStandsItsFirstLineOnThePagesBaseline() thro
+ "the page sets them");
}
+ @Test
+ void aSmallerPartBesideALargerOneIsRaisedToThePagesBaseline() throws Exception {
+ // The page sets the parts from the top, the smaller one higher; Word's line stands on the
+ // larger one's baseline, and the smaller one is raised off it to its own.
+ Exported exported = export(footerRow(p -> p.text("Confidential").textStyle(CHROME),
+ p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18))));
+ XWPFParagraph line = exported.footerLine();
+ double baseline = PAGE_HEIGHT - fromTheTop(exported.margin().getFooter()) - (lineOf(line) - share(line));
+ XWPFRun smaller = line.getRuns().get(0);
+
+ assertThat(smaller.text()).isEqualTo("Confidential");
+ assertThat(baseline).as("the larger one's").isCloseTo(exported.baseline("Acme"), within(0.1));
+ assertThat(baseline - ((Number) smaller.getCTR().getRPr().getPositionArray(0).getVal()).doubleValue() / 2)
+ .as("the smaller one's, raised").isCloseTo(exported.baseline("Confidential"), within(0.5));
+ }
+
@Test
void noPartIsRaisedInAZoneOfMoreLinesThanOne() throws Exception {
// Word sets "v2.4" on the second line, after the break; raised off the first line's
@@ -519,7 +535,7 @@ void aZoneParagraphsAnchorIsNamed() throws Exception {
void aLineThePageSetsAtTheEdgeStandsAtIt() throws Exception {
// An exact line's baseline is four fifths down it; text set against the page's top edge
// stands a little higher than that, and the line stops at the edge. The text is raised
- // back onto the page's baseline, to the half point, as far as the line holds it.
+ // back onto the page's baseline, within half a point, as far as the line holds it.
Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, 40, DocumentInsets.zero(), "Acme",
DocumentTextStyle.DEFAULT.withSize(18)));
XWPFRun text = exported.headerLine().getRuns().get(0);
@@ -527,9 +543,8 @@ void aLineThePageSetsAtTheEdgeStandsAtIt() throws Exception {
assertThat(fromTheTop(exported.margin().getHeader())).isZero();
assertThat(share(exported.headerLine()) - exported.baseline("Acme")).as("the line's, lower than the page's")
.isBetween(0.0, 1.0);
- assertThat(share(exported.headerLine())
- - ((Number) text.getCTR().getRPr().getPositionArray(0).getVal()).doubleValue() / 2)
- .as("the text's, raised").isCloseTo(exported.baseline("Acme"), within(0.3));
+ assertThat(share(exported.headerLine()) - raiseOf(text))
+ .as("the text's, raised").isCloseTo(exported.baseline("Acme"), within(0.5));
assertThat(exported.report().bySubject()).as("within the place a part keeps").doesNotContainKey("page zone");
}
@@ -541,11 +556,11 @@ void aLineStoppedAtTheEdgeFurtherThanAPartKeepsItsPlaceRaisesItBack() throws Exc
Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, 120, DocumentInsets.zero(), "Acme",
DocumentTextStyle.DEFAULT.withSize(80)));
XWPFRun text = exported.headerLine().getRuns().get(0);
- double raise = ((Number) text.getCTR().getRPr().getPositionArray(0).getVal()).doubleValue() / 2;
+ double raise = raiseOf(text);
assertThat(share(exported.headerLine()) - exported.baseline("Acme")).as("the line's").isGreaterThan(1.5);
assertThat(share(exported.headerLine()) - raise).as("the text's").isCloseTo(exported.baseline("Acme"),
- within(0.3));
+ within(0.5));
assertThat(exported.report().bySubject()).doesNotContainKey("page zone");
}
@@ -660,6 +675,13 @@ private static double fromTheTop(Object twips) {
return DocxTwips.of(twips) / 20.0;
}
+ /** How far a run is raised off its line's baseline, in points: its {@code w:position}, or none. */
+ private static double raiseOf(XWPFRun run) {
+ var properties = run.getCTR().getRPr();
+ return properties == null || properties.sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getPositionArray(0).getVal()).doubleValue() / 2;
+ }
+
/** A header's or a footer's paragraph reading the text. */
private static XWPFParagraph headerParagraph(XWPFDocument document, String text) {
List parts = new ArrayList<>(document.getHeaderList());
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java
index 70facd2a6..897d9813f 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxZonePictureTest.java
@@ -103,7 +103,7 @@ void textSetAboveItsLogoGrowsTheLineToHoldIt() throws Exception {
assertThat(lineOf(line)).as("30pt above the logo's foot, four fifths of it").isCloseTo(37.5, within(0.1));
assertThat(baseline).as("the logo's foot").isCloseTo(PAGE_HEIGHT - exported.picture().y(), within(0.1));
- assertThat(baseline - positionOf(text)).isCloseTo(exported.baseline("Quarterly"), within(0.3));
+ assertThat(baseline - positionOf(text)).isCloseTo(exported.baseline("Quarterly"), within(0.5));
assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
}
@@ -133,8 +133,8 @@ void textBesideALogoIsRaisedToTheBaselineThePageSetsItOn() throws Exception {
within(0.1));
XWPFRun text = line.getRuns().get(line.getRuns().size() - 1);
assertThat(text.text()).isEqualTo("Quarterly");
- assertThat(baseline - positionOf(text)).as("raised onto its own baseline, to the half point, " + align)
- .isCloseTo(exported.baseline("Quarterly"), within(0.3));
+ assertThat(baseline - positionOf(text)).as("raised onto its own baseline, within half a point, " + align)
+ .isCloseTo(exported.baseline("Quarterly"), within(0.5));
assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
}
}
@@ -174,7 +174,7 @@ void aPageNumberBesideAFootersLogoIsRaisedEveryRunOfItsField() throws Exception
assertThat(field).as("begin, instruction, separator, result, end").hasSize(5)
.allSatisfy(run -> assertThat(positionOf(run)).isEqualTo(raise));
double baseline = PAGE_HEIGHT - fromTheTop(exported.margin().getFooter()) - (lineOf(line) - share(line));
- assertThat(baseline - raise).isCloseTo(exported.baseline("1"), within(0.3));
+ assertThat(baseline - raise).isCloseTo(exported.baseline("1"), within(0.5));
assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
}
@@ -194,7 +194,7 @@ void aLogoShorterThanTheTextBesideItIsRaisedToWhereThePageDrawsIt() throws Excep
assertThat(sizeOf(picture)).containsExactly(16.0, 8.0);
assertThat(baseline).as("the text's").isCloseTo(exported.baseline("Acme"), within(0.1));
assertThat(baseline - positionOf(picture)).as("its foot, raised").isCloseTo(PAGE_HEIGHT - exported.picture().y(),
- within(0.3));
+ within(0.5));
}
@Test
@@ -255,6 +255,104 @@ void aLogoInTwoKindsOfHeaderIsWrittenIntoEach() throws Exception {
assertThat(exported.document().getHeaderList()).hasSize(2)
.allSatisfy(header -> assertThat(header.getAllPictures()).as("the logo").hasSize(1));
assertThat(exported.report().bySubject()).doesNotContainKey("page zone content");
+
+ // The text beside it raised alike in each.
+ Exported beside = export(session -> {
+ session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24)).flexSpacer()
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME)).build()));
+ session.chrome().zone(DocumentPageZone.builder().zone(DocumentHeaderFooterZone.FOOTER).height(20)
+ .appliesTo(page -> page.isFirst())
+ .content(page -> new ParagraphBuilder().text("Cover").build()).build());
+ }, true);
+ List raises = new ArrayList<>();
+ for (XWPFHeaderFooter header : beside.document().getHeaderList()) {
+ List runs = header.getParagraphs().get(0).getRuns();
+ raises.add(positionOf(runs.get(runs.size() - 1)));
+ }
+ assertThat(raises).hasSize(2).allSatisfy(raise -> assertThat(raise).isEqualTo(raises.get(0)).isPositive());
+ }
+
+ @Test
+ void aParagraphInTheBodyAndAZoneTooIsRaisedAsTheZonePlacesIt() throws Exception {
+ // The same node laid out in the body as well: its body lines are not the zone's, and lend
+ // its runs nothing — neither its text's seat nor its picture's place.
+ java.util.function.Supplier confidential = () ->
+ new ParagraphBuilder().name("Shared").textStyle(CHROME)
+ .inlineImage(DocumentImageData.fromBytes(png(8, 8)), 6, 6,
+ com.demcha.compose.document.node.InlineImageAlignment.CENTER)
+ .inlineText(" Confidential", CHROME).build();
+ com.demcha.compose.document.node.ParagraphNode shared = confidential.get();
+ Function footer = part ->
+ DocumentPageZone.builder().zone(DocumentHeaderFooterZone.FOOTER).height(48)
+ .padding(new DocumentInsets(10, 0, 0, 0))
+ .content(page -> new RowBuilder().name("Line").add(part).flexSpacer()
+ .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(18)))
+ .build())
+ .build();
+ Exported apart = export(session -> session.chrome().zone(footer.apply(confidential.get())),
+ page -> page.add(confidential.get()));
+ Exported both = export(session -> session.chrome().zone(footer.apply(shared)), page -> page.add(shared));
+
+ List alone = apart.footerLine().getRuns().subList(0, 2).stream().map(DocxZonePictureTest::raiseOf)
+ .toList();
+ assertThat(alone.get(1)).as("its text raised to its own baseline").isPositive();
+ assertThat(both.footerLine().getRuns().subList(0, 2)).extracting(DocxZonePictureTest::raiseOf)
+ .as("its picture and its text as far as apart").containsExactlyElementsOf(alone);
+ }
+
+ @Test
+ void aPartThePageSetsLowerIsLoweredToTheHalfPointTheLineHolds() throws Exception {
+ // 4.3pt below the logo's foot: the nearest half point, 4.5, passes the fifth of the 30pt line
+ // below the baseline with the text's own 1.7pt below its baseline; 4 does not.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24))
+ .flexSpacer()
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME)
+ .margin(new DocumentInsets(22.556, 0, 0, 0)))
+ .build()));
+ XWPFParagraph line = exported.headerLine();
+ XWPFRun text = line.getRuns().get(line.getRuns().size() - 1);
+ double baseline = fromTheTop(exported.margin().getHeader()) + share(line);
+
+ assertThat(positionOf(text)).as("lowered, to the half point towards the baseline").isEqualTo(-4.0);
+ assertThat(baseline - positionOf(text)).isCloseTo(exported.baseline("Quarterly"), within(0.5));
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void aPictureThePageSetsBelowWhatTheLineHoldsIsNotLowered() throws Exception {
+ // Set at the 30pt text's foot, the logo would hang past the line's: it stands on the
+ // baseline, and is counted.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER, page -> new RowBuilder().name("Line")
+ .verticalAlign(RowVerticalAlign.BOTTOM)
+ .addParagraph(p -> p.text("Acme").textStyle(DocumentTextStyle.DEFAULT.withSize(30)))
+ .flexSpacer()
+ .addImage(image -> image.name("Logo").source(LOGO).size(16, 8))
+ .build()));
+ XWPFRun picture = exported.headerLine().getRuns().get(exported.headerLine().getRuns().size() - 1);
+
+ assertThat(picture.getCTR().isSetRPr() && picture.getCTR().getRPr().sizeOfPositionArray() > 0)
+ .as("not lowered").isFalse();
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a header written as one line of Word's header; 1 of its 2 parts stands off where "
+ + "the page sets them");
+ }
+
+ @Test
+ void aPaddedLogoStandsWhereThePageDrawsItsPicture() throws Exception {
+ // The layout draws the picture inside its padding: 4pt down and 4pt in from its box.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER,
+ page -> logo().padding(DocumentInsets.of(4)).build()));
+ XWPFParagraph line = exported.headerLine();
+
+ assertThat(sizeOf(line.getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(fromTheTop(exported.margin().getHeader()) + share(line)).as("its foot")
+ .isCloseTo(PAGE_HEIGHT - exported.picture().y(), within(0.1));
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .as("Word starts it at the margin, the page 4pt in")
+ .containsExactly("a header written as one line of Word's header; its picture stands off where the "
+ + "page sets it");
}
@Test
@@ -286,6 +384,93 @@ void aLogoOfAnotherPictureOnItsFirstPageIsNotMeasuredWhereItsHeightIsThePictures
+ "the page sets it is not measured");
}
+ @Test
+ void aContainedLogoOfAnotherPictureOnItsFirstPageIsNotMeasured() throws Exception {
+ // Its box stated, a contained picture is drawn as its own proportions fit it there: a
+ // square 40 by 40 on page 1, two to one as written, 48 by 24.
+ Exported exported = export(session -> session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER, 60,
+ page -> new ImageBuilder().name("Logo").size(48, 40).fitMode(DocumentImageFitMode.CONTAIN)
+ .source(DocumentImageData.fromBytes(page.isLast() ? LOGO : png(40, 40))).build())),
+ true);
+
+ assertThat(sizeOf(exported.headerLine().getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a header written as one line of Word's header; whether its picture stands where "
+ + "the page sets it is not measured");
+ }
+
+ @Test
+ void aLogoOfTheSameBytesOnEveryPageIsMeasuredWhereItsHeightIsThePicturesOwn() throws Exception {
+ // 48 wide, as tall as its picture makes it, built afresh for each page from the same bytes.
+ Exported exported = export(session -> session.chrome().zone(zone(DocumentHeaderFooterZone.HEADER,
+ page -> new ImageBuilder().name("Logo").source(DocumentImageData.fromBytes(LOGO.clone())).width(48)
+ .build())), true);
+ XWPFParagraph line = exported.headerLine();
+
+ assertThat(fromTheTop(exported.margin().getHeader()) + share(line))
+ .isCloseTo(PAGE_HEIGHT - exported.picture().y(), within(0.1));
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void aLogoBeforeAPageNumberAgainstTheRightMarginEndsWhereWordSetsIt() throws Exception {
+ // Against the right margin Word ends the number there and the logo before it, as wide as
+ // the page draws it.
+ Exported exported = export(zone(DocumentHeaderFooterZone.FOOTER, page -> new RowBuilder().name("Line")
+ .addParagraph(p -> p.text("Quarterly").textStyle(CHROME))
+ .flexSpacer()
+ .addImage(image -> image.name("Logo").source(LOGO).size(48, 24))
+ .add(page.pageNumber(CHROME))
+ .build()));
+
+ assertThat(exported.report().isEmpty()).as(String.valueOf(exported.report().notes())).isTrue();
+ }
+
+ @Test
+ void aLogoThePageSetsInFromTheMarginStandsOffIt() throws Exception {
+ // Word starts the line at the left margin; the page draws the logo 20pt in.
+ Exported exported = export(zone(DocumentHeaderFooterZone.HEADER,
+ page -> logo().margin(new DocumentInsets(0, 0, 0, 20)).build()));
+
+ assertThat(exported.report().bySubject().get("page zone")).extracting(DocxExportReport.Note::detail)
+ .containsExactly("a header written as one line of Word's header; its picture stands off where the "
+ + "page sets it");
+ }
+
+ @Test
+ void aFramedZonesLogoStandsWhereThePageDrawsIt() throws Exception {
+ // A cover's header on the first page and the running one on the rest share Word's one
+ // distance for a kind: each stands in a frame at its own height. So do two footers.
+ for (DocumentHeaderFooterZone kind : List.of(DocumentHeaderFooterZone.HEADER, DocumentHeaderFooterZone.FOOTER)) {
+ Exported exported = export(session -> {
+ session.chrome().zone(DocumentPageZone.builder().zone(kind).height(60)
+ .padding(new DocumentInsets(24, 0, 0, 0)).appliesTo(page -> page.isFirst())
+ .content(page -> logo().build()).build());
+ session.chrome().zone(DocumentPageZone.builder().zone(kind).height(60)
+ .padding(new DocumentInsets(8, 0, 0, 0)).appliesTo(page -> !page.isFirst())
+ .content(page -> logo().build()).build());
+ }, true);
+ List pageFeet = exported.pictures().stream().map(box -> PAGE_HEIGHT - box.y()).toList();
+
+ List wordsFeet = new ArrayList<>();
+ List parts = new ArrayList<>(exported.document().getHeaderList());
+ parts.addAll(exported.document().getFooterList());
+ for (XWPFHeaderFooter part : parts) {
+ for (XWPFParagraph paragraph : part.getParagraphs()) {
+ if (paragraph.getCTP().getPPr() != null && paragraph.getCTP().getPPr().isSetFramePr()) {
+ wordsFeet.add(fromTheTop(paragraph.getCTP().getPPr().getFramePr().getY()) + share(paragraph));
+ }
+ }
+ }
+ assertThat(pageFeet).as("the cover's logo 24pt in, the running one's 8pt, " + kind).hasSize(2);
+ assertThat(wordsFeet).as(kind.toString()).hasSize(2);
+ for (double foot : pageFeet) {
+ assertThat(wordsFeet).as(kind.toString())
+ .anySatisfy(word -> assertThat(word).isCloseTo(foot, within(0.1)));
+ }
+ }
+ }
+
@Test
void withoutALayoutALogoIsWrittenAtTheSizeItStates() throws Exception {
byte[] docx;
@@ -302,6 +487,8 @@ void withoutALayoutALogoIsWrittenAtTheSizeItStates() throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) {
XWPFParagraph line = document.getHeaderList().get(0).getParagraphs().get(0);
assertThat(sizeOf(line.getRuns().get(0))).containsExactly(48.0, 24.0);
+ assertThat(line.getCTP().getPPr().getSpacing().isSetLineRule())
+ .as("Word's own line, with nothing to measure it by").isFalse();
}
}
@@ -329,6 +516,13 @@ private static List sizeOf(XWPFRun run) {
return List.of(inline.getExtent().getCx() / 12700.0, inline.getExtent().getCy() / 12700.0);
}
+ /** How far a run is raised off its line's baseline, in points: its {@code w:position}, or none. */
+ private static double raiseOf(XWPFRun run) {
+ var properties = run.getCTR().getRPr();
+ return properties == null || properties.sizeOfPositionArray() == 0 ? 0
+ : ((Number) properties.getPositionArray(0).getVal()).doubleValue() / 2;
+ }
+
/** How far a run is raised off its line's baseline, in points: its {@code w:position}. */
private static double positionOf(XWPFRun run) {
var properties = run.getCTR().getRPr();
@@ -401,7 +595,7 @@ XWPFParagraph footerLine() {
/** The box the page draws the zone's one picture in, on the first page. */
PlacedFragment picture() {
- return pictures.get(0);
+ return pictures.stream().filter(box -> box.pageIndex() == 0).findFirst().orElseThrow();
}
/** Where the page sets a word's baseline, from its top, on the first page it draws it. */
@@ -425,21 +619,26 @@ private static Exported export(DocumentPageZone zone) throws Exception {
}
private static Exported export(Consumer chrome, boolean twoPages) throws Exception {
+ return export(chrome, page -> {
+ page.addParagraph(p -> p.text("Body"));
+ if (twoPages) {
+ page.addPageBreak(pageBreak -> { });
+ page.addParagraph(p -> p.text("More"));
+ }
+ });
+ }
+
+ private static Exported export(Consumer chrome,
+ Consumer body) throws Exception {
AtomicReference report = new AtomicReference<>();
try (DocumentSession session = GraphCompose.document()
.pageSize(400, PAGE_HEIGHT)
.margin(DocumentInsets.of(72))
.create()) {
chrome.accept(session);
- session.pageFlow(page -> {
- page.addParagraph(p -> p.text("Body"));
- if (twoPages) {
- page.addPageBreak(pageBreak -> { });
- page.addParagraph(p -> p.text("More"));
- }
- });
+ session.pageFlow(body::accept);
List pictures = session.layoutGraph().fragments().stream()
- .filter(fragment -> fragment.path().startsWith("@page-zone") && fragment.pageIndex() == 0
+ .filter(fragment -> fragment.path().startsWith("@page-zone")
&& fragment.payload() instanceof ImageFragmentPayload)
.toList();
List text = textOf(session.toPdfBytes());