From 8aab9d0e996c188494d1100cf7a102cc34117a1c Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 19 Oct 2026 11:01:12 +0100
Subject: [PATCH] fix(docx): name what a text header or footer shows otherwise
in Word
A text band is written as one exact line of a Word header or footer,
its slots set by a centre and a right tab stop. Measured in Word and
LibreOffice on Windows, four things showed otherwise than on the page,
with nothing said:
- A band too shallow for its line, which is written from the page's
edge: 9pt text in a header 11pt tall stood 2.14pt lower in Word than
on the page (2.10 in LibreOffice), in a footer 10pt tall 1.04pt
higher (1.21), that footer's separator 0.92pt higher (1.06).
- Page numbers in letters: Word letters a number past 26 by repeating
one letter, page 28 bb and a total of 56 ddd, where the page and
LibreOffice read ab and bd.
- Slots that overlap where the page sets them, which it draws over one
another: both editors set a slot that would overlap the text before
it after that text, Word on the same line as far as past the right
margin, LibreOffice on it or on a line below.
- A slot wider than the line, which the page runs past the margins and
both editors wrap onto a line below.
The report now names each (page header / page footer): the shallow
band with about how far its text and separator move, a lettered band
where the layout runs to 28 pages or more, or where it would with no
layout, and the slots with on how many of the band's pages, each page
read as the page reads it. The DOCX is written as before.
---
CHANGELOG.md | 24 ++
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 21 ++
.../semantic/docx/DocxSemanticBackend.java | 168 +++++++++++-
.../backend/semantic/docx/DocxTextBands.java | 65 ++++-
.../semantic/docx/DocxBandTextReportTest.java | 249 ++++++++++++++++++
.../docx/DocxNodeFieldLedgerTest.java | 5 +-
.../semantic/docx/DocxTextBandsTest.java | 51 ++++
8 files changed, 577 insertions(+), 8 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandTextReportTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47819e3fc..1930dbd10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,30 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export names what a text header or footer shows otherwise in Word.** Measured in
+ Word and LibreOffice on Windows, four things went unsaid:
+ - **A band too shallow for its line.** Its line is written from the page's edge.
+ - 9pt text in a header 11pt tall stood 2.14pt lower in Word than on the page (2.10 in
+ LibreOffice), and in a footer 10pt tall 1.04pt higher (1.21).
+ - That footer's separator, ruled over the line, stood 0.92pt higher (1.06).
+ - **Page numbers in letters past 26.** Word letters them by repeating one letter: page 28
+ read `bb` and the total of 56 pages `ddd`, where the page and LibreOffice read `ab` and `bd`.
+ - **Slots that overlap where the page sets them.** The page draws them over one another, and
+ both editors set a slot that would overlap the text before it after that text instead:
+ - a left slot reaching past the middle had the centre and right slots set after it in both,
+ the right one past the right margin;
+ - a centre slot reaching the right one had it set after it, past the margin, in Word, and
+ on a line below in LibreOffice.
+ - **A slot wider than the line.** The page runs it past the margins; both editors wrap it
+ onto a line below.
+
+ The report now names each one:
+ - a shallow band with about how far its text, and its separator, move;
+ - a lettered band where the layout runs to 28 pages or more, or where it would with no layout
+ behind the export;
+ - overlapping or overwide slots with on how many of the band's pages.
+
+ The DOCX is written as before.
- **A DOCX export names the blank page Word adds before a section.** Every section of a
multi-section document numbers its pages from 1. Once the document states different even and
odd pages, as it does for a page zone on some of them, Word starts every section on an odd
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index bc860d822..47fb4a152 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -117,7 +117,7 @@ honour an option ignores it (documented contract).
| Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ⚠️ `applyMetadata` (OPC core properties — title, author as creator, subject, keywords — and the creating application as the extended `Application`; OPC has no producer field here either, so that value is not representable, and the report names one the caller set other than the application) |
| Page backgrounds (`DocumentSession.pageBackgrounds`, `PageBackgroundFill` — full page, columns, bands) | ✅ `DocumentPageBackgrounds` adds each fill as a shape fragment under every page's content, drawn by the ordinary shape handler | ✅ the same fragments, drawn as shapes on every slide | ⚠️ `DocxPageBackgrounds` — each fill is a rectangle anchored to the page, behind the text. A section of more than one page, or with a header or footer, carries them in every header part it has (default, first page, even pages), so they are drawn on every page; a section without a header gets an empty one against the page edge to carry them, and a later section without fills gets an empty header of its own rather than inheriting them. On a page with no top margin LibreOffice still sets the first line about 3pt lower under that header. A section of one page with neither draws them from the body instead — in the first cell's paragraph when the page opens with a table — and its lines stand where the page sets them in both editors (the seven sidebar CVs stood 2.6 to 3.3pt low in LibreOffice); they are on that page alone, so a page an editor's text runs onto has none. A fill's alpha is carried as the shape's. A two-column layout still flows its columns one after the other, so a column fill can stand beside text that is not its column's |
| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ (not written; reported `DROPPED`, `watermark`) |
-| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border, a translucent one flattened against white and reported (`translucency`), one wider or thinner than Word draws held to its range, one of no width drawn at Word's thinnest and one below zero not written, each reported (`border width`); the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported |
+| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` — the zone's `fontName` is resolved through the document's own `FontLibrary`, so a zone draws in the family the author named; unnamed means standard-14 Helvetica, and a code point that family cannot encode is substituted with `?` exactly as body text is | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules). The named family reaches the slide run through `PptxFontMapping.familyFor`, and the same family measures the slots — a run measured against one face and typeset in another lands off-centre | ✅ `DocxSemanticBackend.writeBand` (`DocxTextBands`) — one line of a Word header or footer part: the left slot, the centre slot at a centre tab and the right slot at a right tab against the margins; `{page}` / `{pages}` as `PAGE` / `NUMPAGES` (`SECTIONPAGES` per section) fields with the roman or alphabetic switch; `{date}` as the date of the export; the separator as the paragraph's border, a translucent one flattened against white and reported (`translucency`), one wider or thinner than Word draws held to its range, one of no width drawn at Word's thinnest and one below zero not written, each reported (`border width`); the header or footer distance from the band's geometry, baseline within 0.1pt in LibreOffice; a band sharing its kind with another band or a page zone stands in a frame (`w:framePr`) at its own height; `showOnFirstPage(false)` or counting from page 2 → an empty first-page part. A band starting after page 2, numbers not counting from 1 on page 1, and a band alone of its kind reaching past the page margin (written as a negative margin, so that Word holds the body at it as the page does; LibreOffice moves the body clear of it) are reported; so are a band too shallow for its line, whose text and separator Word stands further in from the edge (9pt in a header 11pt tall: 2.14pt lower), page numbers in letters past 26, which Word letters by repeating one letter (page 28 `bb`, the page `ab`), slots that overlap where the page sets them, which the page draws over one another and both editors set apart, and a slot wider than the line, which both editors wrap |
| Page zones (node subtree in the band) | ✅ Spliced into the layout graph by `DocumentPageZones`, so the ordinary fragment handlers draw it — no zone-specific code in the backend | ✅ Same splice, same reason: `PptxFixedLayoutBackend.renderGraph` draws every fragment of the graph | ✅ Written into a real `w:ftr` / `w:hdr` part. The band's children become runs on one Word line: a paragraph contributes its runs, an `ImageNode` an inline picture at the size the page draws it — contained or cropped to cover its box as the page draws it, its link its run's — a flex spacer becomes the right tab stop, and `PageContext.pageNumber()` / `pageTotal()` become live `PAGE` / `NUMPAGES` fields. Other node kinds are skipped, logged once a kind and reported once each (`DROPPED`, `page zone content`); a zone row's own fill, outline and side borders are reported as `row paint`. Word sets the line's parts one after another from the left margin and, after the first spacer, against the right margin, on one baseline, its tallest part's, in a zone of one line a one-line part the page sets on a baseline of its own raised or lowered to it by `w:position`, in whole half points — lowered only as far as the line holds below its baseline, a fifth of it or as far as its tallest part reaches; LibreOffice raises a run about a seventh further than Word, does not raise a page field and stands every picture on the baseline —; the page sets each by the zone's padding, a row's columns and gap, a paragraph's alignment and a part's own sides (a page field's alignment moves nothing: its box is a point wider than its number). The `page zone` note counts the parts the page sets elsewhere — read from the layout's zone fragments: a part stands where the page sets it when its line starts, or against the right margin ends, within a point and a half of Word's, on the baseline Word sets it on, in one line and with no prefix before it on the left side; past a part whose width Word does not keep, or a zone whose nodes the page names or nests otherwise, where a part stands is said to be not measured — and names a zone paragraph's right-to-left direction, prefix letters, fitted size where it is not measured, markdown marks written as letters, a markdown heading taller than its line, outline entry, anchor (no bookmark) and a picture set off the baseline, which the line does not carry, and a picture part's transform, outline entry and anchor. The line is an exact line as tall as the tallest part's line on the page — taller for a picture in it, a part written at a larger size than the page's or a part raised above it — standing as far from its edge as puts that part's baseline — a picture's foot — where the page has it (a lone or tallest part within 0.1pt in Word and LibreOffice, measured on 8pt and 18pt Lato headers and 8pt Lato footers), a lone part's padding and margin above and below included; a tallest part of more lines than one is as many exact lines, a footer's standing as many lines further from its edge; lines reaching past the page margin by more than half a point are held there with a negative margin and named; a zone the layout measures that shares its kind with another page zone stands in a frame (`w:framePr`) at its own height, at least its lines tall; a zone the page draws otherwise on the page it is measured on than it is written — other text, face, size or pictures — is not measured, its line Word's, and a zone whose content is none for every page Word writes it on is not written and named, as is one building none for the first page or the pages between, where the page draws it. Because Word paginates, `PageContext.number()` refuses here rather than baking a number that would be wrong on every page but one. The content is built for the first page and for the pages after it (`PageContext.unpaginated(first, last)`): built otherwise for the first, it becomes the title page's part (`w:titlePg`) and the ordinary one, a measured one framed where the two cannot share one distance; built otherwise for the last page, it is written there as built for the pages between and reported. A zone's `appliesTo` predicate is asked over sample pages (`DocxPageClasses`) — a layout of fewer than six pages also over its own, each as one of as many as it ran to, which decide its kinds of page — and, when it follows Word's first / even / other pages, becomes the matching part — `w:titlePg` for the first page, `w:evenAndOddHeaders` for even pages — with an empty part on the pages it skips; a predicate that picks pages within a kind (the last page) is written on every page and reported |
| 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`) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index fecc6245d..2434591ff 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1219,6 +1219,27 @@ kind share Word's one header or footer. A `{date}` token keeps a
deterministic export byte-identical only within one day unless
`-Dgraphcompose.renderDate` pins it, as for the PDF.
+The report names four things a band shows otherwise in Word, each measured in Word and
+LibreOffice on Windows:
+
+- **A band too shallow for its line.** Its line is written from the page's edge, the top for a
+ header and the foot for a footer. So in a band shallower than its line needs — a header under
+ about one and a half times its type size, a footer under about one and a quarter — the text
+ stands further in. With 9pt text in a header 11pt tall, it stood 2.14pt lower in Word than on
+ the page, 2.10pt in LibreOffice. A separator ruled past a line taller than its band moves too:
+ in a footer 10pt tall it stood 0.92pt higher in Word, 1.06pt in LibreOffice. The report names
+ the band with about how far each moves.
+- **Page numbers in letters past 26.** Word letters them by repeating one letter: page 28 reads
+ `bb`, where the page and LibreOffice read `ab`. The report names such a band where the layout
+ runs to 28 pages or more, and with no layout behind the export, says where it would.
+- **Slots that overlap where the page sets them.** A left slot that reaches past where the
+ centre one starts is one example. The page draws such slots over one another. Both editors set
+ a slot that would overlap the text before it after that text instead: Word on the same line, as
+ far as past the right margin, LibreOffice on it or on a line below. The report names the band
+ with on how many of its pages.
+- **A slot wider than the line.** The page runs it past the margins, and both editors wrap it
+ onto a line below. The report names the band.
+
A page zone (`session.chrome().zone(...)`) exports as a real
Word header or footer part, with the page number as a live field, and its text stands on the
baseline the page sets it on. The zone's line is an exact line as tall as its tallest part's
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 b36fb75fa..f5b6d6980 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
@@ -1860,7 +1860,10 @@ private java.util.Set applyPageZones(
|| sectionZones.stream().anyMatch(zone -> zone.getZone() == band.getZone()
&& zone.getContent() != null);
writers.add(new ZoneWriter(band.getZone(), bandPageClasses(band),
- part -> writeBand(part, band, framed), () -> placeBand(document, band, framed), false));
+ part -> writeBand(part, band, framed), () -> {
+ placeBand(document, band, framed);
+ reportBandText(band);
+ }, false));
}
for (int index = 0; index < sectionZones.size(); index++) {
DocumentPageZone zone = sectionZones.get(index);
@@ -2357,6 +2360,169 @@ private void placeBand(XWPFDocument document, DocumentHeaderFooter band, boolean
}
}
+ /**
+ * Names what a text band's text reads or where it stands otherwise in Word than on the page,
+ * each measured in Word and LibreOffice on Windows: a line its band is too shallow for, written
+ * from the page's edge, so its baseline stands further in ({@link DocxTextBands#baselineMovedIn})
+ * and its separator, ruled past the line, too ({@link DocxTextBands#separatorMovedIn}); page
+ * numbers in letters past 26, which Word letters by repeating one letter — page 28 reads
+ * {@code bb} in Word and {@code ab} on the page and in LibreOffice; slots that overlap where the
+ * page sets them ({@link DocxTextBands#slotsOverlap}), which the page draws over one another and
+ * both editors set apart; and a slot wider than the line, which the page runs past the margins and
+ * both editors wrap. Said once for a band, which is written into every kind of header it is given.
+ */
+ private void reportBandText(DocumentHeaderFooter band) {
+ String section = sectioned ? "section " + (sectionIndex + 1) : null;
+ String subject = "page " + zoneName(band);
+ boolean header = band.getZone() == DocumentHeaderFooterZone.HEADER;
+ double moved = DocxTextBands.baselineMovedIn(band);
+ if (moved >= 0.1) {
+ double separator = separatorWritten(band)
+ ? DocxTextBands.separatorMovedIn(band, DocxBorderWidths.eighths(band.getSeparatorThickness()) / 8.0)
+ : 0;
+ report.add(DocxExportReport.Severity.APPROXIMATED, subject, section,
+ "its text stands about " + tenths(moved) + "pt " + (header ? "lower" : "higher")
+ + " than the page sets it: the page sets its baseline " + tenths(DocxTextBands.baselineFromEdge(band))
+ + "pt from the " + (header ? "top" : "foot") + ", too near for its "
+ + tenths(DocxTextBands.lineHeight(band)) + "pt line, which is written "
+ + (header ? "from the page's top edge" : "up from the page's foot")
+ + (separator >= 0.1
+ ? "; its separator, ruled " + (header ? "under" : "over") + " that line, stands about "
+ + tenths(separator) + "pt " + (header ? "lower" : "higher")
+ : ""));
+ }
+ com.demcha.compose.document.output.DocumentPageNumberStyle numbers =
+ band.getNumbering() == null ? null : band.getNumbering().getStyle();
+ boolean lettered = numbers == com.demcha.compose.document.output.DocumentPageNumberStyle.LOWER_ALPHA
+ || numbers == com.demcha.compose.document.output.DocumentPageNumberStyle.UPPER_ALPHA;
+ if (lettered && numbersPages(band) && (layout.isEmpty() || layout.pageCount() >= 28)) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, subject, section,
+ "its page numbers are letters, and Word letters a number past 26 by repeating one letter "
+ + "— 28 reads bb where the page reads ab — so a page number or a page total of 28 or more "
+ + "reads otherwise than on the page" + (layout.isEmpty() ? ", where the document runs that far" : "")
+ + "; LibreOffice letters them as the page does");
+ }
+ SlotsSet slots = slotsSetOn(band);
+ if (slots != null && slots.overlapping() > 0) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, subject, section,
+ "its slots' text overlaps where the page sets it, drawn over one another, "
+ + slots.onPages(slots.overlapping()) + "; Word and LibreOffice set a slot that would overlap "
+ + "the text before it after that text instead — Word on the same line, as far as past the "
+ + "right margin, LibreOffice on it or on a line below");
+ }
+ if (slots != null && slots.pastTheMargins() > 0) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, subject, section,
+ "its text runs past the margins where the page sets it, " + slots.onPages(slots.pastTheMargins())
+ + "; Word and LibreOffice wrap it onto a line below");
+ }
+ }
+
+ /** A length in points to a tenth, as a note gives it. */
+ private static double tenths(double points) {
+ return Math.round(points * 10) / 10.0;
+ }
+
+ /** Whether any of a text band's slots sets a page number or the page total. */
+ private static boolean numbersPages(DocumentHeaderFooter band) {
+ for (String slot : java.util.Arrays.asList(band.getLeftText(), band.getCenterText(), band.getRightText())) {
+ for (DocxTextBands.Segment segment : DocxTextBands.segments(slot)) {
+ if (segment.kind() == DocxTextBands.Kind.PAGE || segment.kind() == DocxTextBands.Kind.PAGES) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * On how many of the pages a text band is drawn on its slots overlap, and a slot runs past the
+ * margins, where the page sets them.
+ *
+ * @param laidOut whether the layout counts the pages, else the band is read on the first page it
+ * is drawn on
+ */
+ private record SlotsSet(int drawn, int overlapping, int pastTheMargins, boolean laidOut) {
+
+ /** On how many of its pages, as a note's clause. */
+ String onPages(int pages) {
+ if (!laidOut) {
+ return "with the page numbers of the first page it is drawn on";
+ }
+ return pages == drawn ? "on every page it is drawn on" : "on " + pages + " of the " + drawn + " pages it is drawn on";
+ }
+ }
+
+ /**
+ * Where a text band's slots stand on the pages it is drawn on, or {@code null} where the export
+ * has no page width to set them across or the band is drawn on no page. Each page's slots are
+ * read as the page reads them — its tokens resolved for that page, the pages it is drawn on as
+ * its numbering keeps it off some — and measured in the band's face, or in Helvetica, which the
+ * page draws a face its fonts do not hold in; with no layout behind the export, on the first
+ * page it is drawn on, as many pages long. A slot with no page token reads alike on every page.
+ */
+ private SlotsSet slotsSetOn(DocumentHeaderFooter band) {
+ if (contentWidth == Double.MAX_VALUE) {
+ // No page to set them across: an export with no canvas behind it.
+ return null;
+ }
+ com.demcha.compose.document.output.DocumentPageNumbering numbering = band.getNumbering() == null
+ ? com.demcha.compose.document.output.DocumentPageNumbering.DEFAULT : band.getNumbering();
+ int first = Math.max(1, Math.max(numbering.getCountFrom(), numbering.isShowOnFirstPage() ? 1 : 2));
+ boolean laidOut = !layout.isEmpty();
+ int pages = laidOut ? layout.pageCount() : first;
+ if (pages < first) {
+ return null;
+ }
+ for (DocumentTextStyle face : List.of(bandStyleOf(band), new DocumentTextStyle(FontName.HELVETICA,
+ band.getFontSize(), DocumentTextDecoration.DEFAULT, DocumentColor.GRAY))) {
+ try {
+ return slotsSetOn(band, numbering, face, first, pages, laidOut);
+ } catch (RuntimeException faceNotHeld) {
+ // The page draws a face its fonts do not hold in Helvetica, read next.
+ }
+ }
+ return null;
+ }
+
+ private SlotsSet slotsSetOn(DocumentHeaderFooter band, com.demcha.compose.document.output.DocumentPageNumbering numbering,
+ DocumentTextStyle face, int first, int pages, boolean laidOut) {
+ com.demcha.compose.engine.components.content.header_footer.PageNumberStyle numerals =
+ com.demcha.compose.engine.components.content.header_footer.PageNumberStyle.valueOf(
+ (numbering.getStyle() == null
+ ? com.demcha.compose.document.output.DocumentPageNumberStyle.DECIMAL
+ : numbering.getStyle()).name());
+ String date = com.demcha.compose.engine.components.content.header_footer.HeaderFooterConfig
+ .resolvePlaceholders("{date}", 1, 1);
+ String total = numerals.format(numbering.getStartAt() + (pages - numbering.getCountFrom()));
+ int drawn = pages - first + 1;
+ // Read alike on every page, a band with no page token is read once.
+ int last = numbersPages(band) ? pages : first;
+ int overlapping = 0;
+ int pastTheMargins = 0;
+ for (int page = first; page <= last; page++) {
+ String number = numerals.format(numbering.getStartAt() + (page - numbering.getCountFrom()));
+ double left = slotWidth(band.getLeftText(), number, total, date, face);
+ double centre = slotWidth(band.getCenterText(), number, total, date, face);
+ double right = slotWidth(band.getRightText(), number, total, date, face);
+ int times = last == pages ? 1 : drawn;
+ if (DocxTextBands.slotsOverlap(contentWidth, left, centre, right)) {
+ overlapping += times;
+ }
+ if (Math.max(left, Math.max(centre, right)) > contentWidth) {
+ pastTheMargins += times;
+ }
+ }
+ return new SlotsSet(drawn, overlapping, pastTheMargins, laidOut);
+ }
+
+ /** How wide the page sets a slot's text, its tokens resolved, in a face; 0 for none. */
+ private double slotWidth(String text, String number, String total, String date, DocumentTextStyle face) {
+ if (text == null || text.isEmpty()) {
+ return 0;
+ }
+ return styleMetrics().width(face, text.replace("{page}", number).replace("{pages}", total).replace("{date}", date));
+ }
+
/**
* How far a text band reaches from its page edge, its separator included as Word draws it, in
* points.
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java
index 204ddcc93..dbe419888 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBands.java
@@ -106,11 +106,8 @@ static double lineHeight(DocumentHeaderFooter band) {
* page, and a footer's {@code height - fontSize} above its foot.
*/
static double distanceFromEdge(DocumentHeaderFooter band) {
- double line = lineHeight(band);
- if (band.getZone() == DocumentHeaderFooterZone.HEADER) {
- return distanceFromEdge(true, band.getHeight() - band.getFontSize() / 2.0, line);
- }
- return distanceFromEdge(false, band.getHeight() - band.getFontSize(), line);
+ return distanceFromEdge(band.getZone() == DocumentHeaderFooterZone.HEADER, baselineFromEdge(band),
+ lineHeight(band));
}
/**
@@ -128,6 +125,64 @@ static double distanceFromEdge(boolean header, double baselineFromEdge, double l
return Math.max(0, baselineFromEdge - line * (header ? BASELINE_SHARE : 1 - BASELINE_SHARE));
}
+ /**
+ * How much further in from its page edge Word stands the band's baseline than the page does,
+ * in points: none where its line fits between the edge and the page's baseline, else as much
+ * as the line stopped at the edge ({@link #distanceFromEdge(boolean, double, double)}) holds
+ * its baseline further in — lower for a header, higher for a footer. Measured in Word on
+ * Windows, 9pt text in a header 11pt tall stood 2.14pt lower than on the page.
+ */
+ static double baselineMovedIn(DocumentHeaderFooter band) {
+ double line = lineHeight(band);
+ if (band.getZone() == DocumentHeaderFooterZone.HEADER) {
+ return Math.max(0, line * BASELINE_SHARE - baselineFromEdge(band));
+ }
+ return Math.max(0, line * (1 - BASELINE_SHARE) - baselineFromEdge(band));
+ }
+
+ /**
+ * How much further in from its page edge Word rules the band's separator than the page strokes
+ * it, in points, where the band is shallower than its line: the separator is the line's border,
+ * ruled past the line, which starts at the edge, where the page strokes it the band's height
+ * from the edge. Measured on Windows, a 0.5pt separator of a footer 10pt tall over 9pt text
+ * stood 0.92pt higher in Word than on the page, 1.06pt in LibreOffice.
+ *
+ * @param width the separator's width as written
+ */
+ static double separatorMovedIn(DocumentHeaderFooter band, double width) {
+ double line = lineHeight(band);
+ return band.getHeight() < line ? line + width / 2 - band.getHeight() : 0;
+ }
+
+ /**
+ * How far from its page edge the page sets the band's baseline, in points: down from the
+ * page's top for a header, up from its foot for a footer.
+ */
+ static double baselineFromEdge(DocumentHeaderFooter band) {
+ return band.getZone() == DocumentHeaderFooterZone.HEADER
+ ? band.getHeight() - band.getFontSize() / 2.0
+ : band.getHeight() - band.getFontSize();
+ }
+
+ /**
+ * Whether a band's slots overlap where the page sets them: the left one at the left margin,
+ * the centre one in the middle, the right one against the right margin, each as wide as its
+ * text. The page draws them over one another there; measured on Windows, Word and LibreOffice
+ * set a slot that would overlap the text before it after that text instead.
+ *
+ * @param width the width between the margins
+ * @param left the left slot's width, 0 where it is empty
+ * @param centre the centre slot's width, 0 where it is empty
+ * @param right the right slot's width, 0 where it is empty
+ */
+ static boolean slotsOverlap(double width, double left, double centre, double right) {
+ if (centre > 0) {
+ return left > 0 && left > (width - centre) / 2
+ || right > 0 && (width + centre) / 2 > width - right;
+ }
+ return left > 0 && right > 0 && left > width - right;
+ }
+
/**
* The space between the band's line and its separator, in points: the PDF strokes the
* separator {@code height} from the page edge, below a header's text and above a footer's.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandTextReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandTextReportTest.java
new file mode 100644
index 000000000..b6a30119b
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxBandTextReportTest.java
@@ -0,0 +1,249 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.semantic.SemanticBackend;
+import com.demcha.compose.document.backend.semantic.SemanticExportContext;
+import com.demcha.compose.document.layout.DocumentGraph;
+import com.demcha.compose.document.output.DocumentHeaderFooter;
+import com.demcha.compose.document.output.DocumentHeaderFooterZone;
+import com.demcha.compose.document.output.DocumentPageNumberStyle;
+import com.demcha.compose.document.output.DocumentPageNumbering;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What a text header or footer reads or where it stands otherwise in Word than on the page is
+ * named.
+ *
+ * Measured in Word and LibreOffice on Windows: 9pt text in a header 11pt tall stood 2.14pt
+ * lower in Word (2.10 in LibreOffice) than on the page, and in a footer 10pt tall 1.04pt higher
+ * (1.21), its separator 0.92pt higher (1.06); page 28 numbered in letters read {@code bb} in Word
+ * and {@code ab} on the page and in LibreOffice; a left slot reaching past the middle drawn under the
+ * centre one on the page set the centre and right slots after it in both editors, the right one
+ * past the right margin; a slot wider than the line both editors wrapped onto a line below.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxBandTextReportTest {
+
+ @Test
+ void aBandTooShallowForItsLineIsNamedWithHowFarItsTextMoves() throws Exception {
+ List header = bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(11).fontSize(9).leftText("Head").build()));
+ List footer = bandNotes(2, session -> session.footer(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(10).fontSize(9).leftText("Foot").build()));
+
+ assertThat(header).extracting(DocxExportReport.Note::detail).containsExactly(
+ "its text stands about 2.1pt lower than the page sets it: the page sets its baseline 6.5pt from "
+ + "the top, too near for its 10.8pt line, which is written from the page's top edge");
+ assertThat(header).allSatisfy(note -> assertThat(note.subject()).isEqualTo("page header"));
+ assertThat(footer).extracting(DocxExportReport.Note::detail).containsExactly(
+ "its text stands about 1.2pt higher than the page sets it: the page sets its baseline 1.0pt from "
+ + "the foot, too near for its 10.8pt line, which is written up from the page's foot");
+ // 0.14pt, measured 0.14 in Word: past the tenth of a point Word sets lines to.
+ assertThat(details(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(13).fontSize(9).leftText("Head").build()))))
+ .singleElement().asString().startsWith("its text stands about 0.1pt lower");
+ }
+
+ @Test
+ void aSeparatorRuledPastALineTallerThanItsBandIsNamedWithIt() throws Exception {
+ // A 0.5pt separator over a 10.8pt line in a footer 10pt tall: ruled 10.8pt up and half its
+ // width more, where the page strokes it 10pt up. Measured: 0.92pt higher in Word, 1.06 in
+ // LibreOffice; in a header 11pt tall, over a line that fits, where the page strokes it.
+ List footer = details(bandNotes(2, session -> session.footer(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(10).fontSize(9).leftText("Foot").showSeparator(true)
+ .build())));
+ List header = details(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(11).fontSize(9).leftText("Head").showSeparator(true)
+ .build())));
+
+ assertThat(footer).singleElement().asString()
+ .endsWith("which is written up from the page's foot; its separator, ruled over that line, stands "
+ + "about 1.0pt higher");
+ assertThat(header).singleElement().asString().endsWith("which is written from the page's top edge");
+ }
+
+ @Test
+ void aBandDeepEnoughForItsLineIsNotNamed() throws Exception {
+ assertThat(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(14).fontSize(9).leftText("Head").build()))).isEmpty();
+ assertThat(bandNotes(2, session -> session.footer(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(12).fontSize(9).leftText("Foot").build()))).isEmpty();
+ }
+
+ @Test
+ void pageNumbersInLettersAreNamedWhereTheLayoutRunsPastTheTwentySeventhPage() throws Exception {
+ assertThat(details(bandNotes(28, lettered("Page {page}", DocumentPageNumberStyle.LOWER_ALPHA))))
+ .singleElement().asString()
+ .isEqualTo("its page numbers are letters, and Word letters a number past 26 by repeating one "
+ + "letter — 28 reads bb where the page reads ab — so a page number or a page total of "
+ + "28 or more reads otherwise than on the page; LibreOffice letters them as the page does");
+ assertThat(details(bandNotes(28, lettered("of {pages}", DocumentPageNumberStyle.UPPER_ALPHA)))).hasSize(1);
+ assertThat(bandNotes(27, lettered("Page {page}", DocumentPageNumberStyle.LOWER_ALPHA)))
+ .as("aa, the 27th, reads alike").isEmpty();
+ assertThat(bandNotes(28, lettered("Page {page}", DocumentPageNumberStyle.LOWER_ROMAN))).isEmpty();
+ assertThat(bandNotes(28, lettered("Lettered", DocumentPageNumberStyle.LOWER_ALPHA)))
+ .as("no number in it").isEmpty();
+ }
+
+ @Test
+ void pageNumbersInLettersWithNoLayoutAreNamedWhereTheDocumentRunsThatFar() throws Exception {
+ assertThat(details(bandNotesWithoutLayout(lettered("Page {page}", DocumentPageNumberStyle.LOWER_ALPHA))))
+ .singleElement().asString()
+ .contains("reads otherwise than on the page, where the document runs that far; LibreOffice");
+ }
+
+ @Test
+ void slotsOverlappingWhereThePageSetsThemAreNamed() throws Exception {
+ List notes = details(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(9)
+ .leftText("A rather long left slot that reaches past the middle")
+ .centerText("CENTRE").rightText("RIGHT").build())));
+
+ assertThat(notes).singleElement().asString()
+ .isEqualTo("its slots' text overlaps where the page sets it, drawn over one another, on every page "
+ + "it is drawn on; Word and LibreOffice set a slot that would overlap the text before it "
+ + "after that text instead — Word on the same line, as far as past the right margin, "
+ + "LibreOffice on it or on a line below");
+ assertThat(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(9)
+ .leftText("Left").centerText("CENTRE").rightText("RIGHT").build()))).isEmpty();
+ StringBuilder reaching = new StringBuilder("A left slot reaching the right one ");
+ while (helvetica9(reaching.toString()) < 240 - helvetica9("RIGHT SLOT") + 4) {
+ reaching.append('i');
+ }
+ String left = reaching.toString();
+ assertThat(helvetica9(left)).isLessThan(240);
+ assertThat(details(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(9)
+ .leftText(left).rightText("RIGHT SLOT").build()))))
+ .as("no centre slot, the left one reaching the right one")
+ .singleElement().asString().startsWith("its slots' text overlaps where the page sets it");
+ }
+
+ @Test
+ void aSlotWiderThanTheLineIsNamed() throws Exception {
+ // 240pt between the margins; measured on a wider page, both editors wrapped such a slot.
+ List notes = details(bandNotes(2, session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(9)
+ .leftText("A left slot far wider than the line between the margins of this narrow page").build())));
+
+ assertThat(notes).singleElement().asString()
+ .isEqualTo("its text runs past the margins where the page sets it, on every page it is drawn on; "
+ + "Word and LibreOffice wrap it onto a line below");
+ }
+
+ @Test
+ void slotsWithNoLayoutAreReadOnTheFirstPageTheBandIsDrawnOn() throws Exception {
+ List notes = details(bandNotesWithoutLayout(session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).fontSize(9)
+ .leftText("A rather long left slot that reaches past the middle").centerText("CENTRE")
+ .numbering(DocumentPageNumbering.builder().showOnFirstPage(false).build()).build())));
+
+ assertThat(notes).singleElement().asString()
+ .contains("drawn over one another, with the page numbers of the first page it is drawn on;");
+ }
+
+ @Test
+ void slotsOverlappingOnSomePagesOnlyAreNamedWithHowMany() throws Exception {
+ // The right slot grows a digit on the tenth page: a centre slot that ends between where a
+ // one-digit and a two-digit right slot start overlaps it on pages 10 to 12 only.
+ double width = 240;
+ double nine = helvetica9("Page 9");
+ double ten = helvetica9("Page 10");
+ StringBuilder centre = new StringBuilder();
+ while (helvetica9(centre.toString()) < width - nine - ten) {
+ centre.append('i');
+ }
+ String centred = centre.toString();
+ assertThat(helvetica9(centred)).isGreaterThan(width - 2 * ten + 1).isLessThan(width - 2 * nine - 1);
+
+ List notes = details(bandNotes(12, session -> session.footer(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(30).fontSize(9)
+ .centerText(centred).rightText("Page {page}").build())));
+ List offTheFirst = details(bandNotes(12, session -> session.footer(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(30).fontSize(9)
+ .centerText(centred).rightText("Page {page}")
+ .numbering(DocumentPageNumbering.builder().showOnFirstPage(false).build()).build())));
+
+ assertThat(notes).singleElement().asString().contains("drawn over one another, on 3 of the 12 pages it is drawn on;");
+ assertThat(offTheFirst).singleElement().asString()
+ .as("counted over the pages it is drawn on, the first not among them")
+ .contains("on 3 of the 11 pages it is drawn on;");
+ }
+
+ private static Consumer lettered(String text, DocumentPageNumberStyle style) {
+ return session -> session.header(DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(30).leftText(text)
+ .numbering(DocumentPageNumbering.builder().style(style).build()).build());
+ }
+
+ private static double helvetica9(String text) throws Exception {
+ return new PDType1Font(Standard14Fonts.FontName.HELVETICA).getStringWidth(text) / 1000 * 9;
+ }
+
+ private static List details(List notes) {
+ return notes.stream().map(DocxExportReport.Note::detail).toList();
+ }
+
+ /** The band notes of a document so many pages long, laid out, with the layout behind it. */
+ private static List bandNotes(int pages, Consumer chrome) throws Exception {
+ AtomicReference report = new AtomicReference<>();
+ try (DocumentSession session = session(pages, chrome)) {
+ session.export(new DocxSemanticBackend(report::set));
+ }
+ assertThat(report.get().notes()).noneMatch(note -> note.subject().equals("measured geometry"));
+ return report.get().notes().stream().filter(note -> note.subject().startsWith("page ")).toList();
+ }
+
+ /** The band notes of a one-page document exported with no layout behind it. */
+ private static List bandNotesWithoutLayout(Consumer chrome) throws Exception {
+ AtomicReference graph = new AtomicReference<>();
+ AtomicReference context = new AtomicReference<>();
+ try (DocumentSession session = session(1, chrome)) {
+ session.export(new SemanticBackend() {
+ @Override
+ public String name() {
+ return "capture";
+ }
+
+ @Override
+ public byte[] export(DocumentGraph documentGraph, SemanticExportContext exportContext) {
+ graph.set(documentGraph);
+ context.set(exportContext);
+ return new byte[0];
+ }
+ });
+ }
+ SemanticExportContext laidOut = context.get();
+ AtomicReference report = new AtomicReference<>();
+ new DocxSemanticBackend(report::set).export(graph.get(), new SemanticExportContext(laidOut.canvas(),
+ laidOut.customFontFamilies(), laidOut.outputFile(), laidOut.outputOptions(), null));
+ assertThat(report.get().notes()).anyMatch(note -> note.subject().equals("measured geometry"));
+ return report.get().notes().stream().filter(note -> note.subject().startsWith("page ")).toList();
+ }
+
+ private static DocumentSession session(int pages, Consumer chrome) {
+ DocumentSession session = GraphCompose.document().pageSize(300, 200).margin(DocumentInsets.of(30)).create();
+ chrome.accept(session);
+ session.pageFlow(page -> {
+ page.addParagraph(p -> p.text("Page 1"));
+ for (int next = 2; next <= pages; next++) {
+ int number = next;
+ page.addPageBreak(pageBreak -> { });
+ page.addParagraph(p -> p.text("Page " + number));
+ }
+ });
+ return session;
+ }
+}
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 7755e82d7..fadfe891c 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
@@ -139,7 +139,10 @@ private record Entry(Fate fate, String note) {
"viewerPreferences:REPORTED",
"headersAndFooters:REPORTED:a band's translucent separator, flattened against white; of a separator, "
+ BORDER_WIDTH + ", one of no width at Word's thinnest, and one below zero or not a number, not "
- + "written; any other is written, or reported where Word's parts cannot hold it",
+ + "written; a band too shallow for its line, whose text and separator Word stands further in from "
+ + "the edge; page numbers in letters from 28 on, which Word letters otherwise; slots that overlap "
+ + "where the page sets them, and a slot wider than the line; 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, 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 "
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandsTest.java
index 6c81cda06..aebf40ebb 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextBandsTest.java
@@ -83,6 +83,57 @@ void aBandTooLowForItsLineStartsAtTheEdge() {
assertThat(DocxTextBands.separatorSpace(header)).isZero();
}
+ @Test
+ void aBandTooShallowForItsLineHasItsBaselineMovedIn() {
+ // 9pt text, a 10.8pt line: Word stands its baseline 8.64pt below the top and 2.16pt above
+ // the foot where the line stops at the edge. Measured in Word on Windows: 8.64 and 2.04.
+ DocumentHeaderFooter header = DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(11).fontSize(9).build();
+ DocumentHeaderFooter footer = DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(10).fontSize(9).build();
+
+ assertThat(DocxTextBands.baselineFromEdge(header)).isCloseTo(6.5, within(1e-9));
+ assertThat(DocxTextBands.baselineMovedIn(header)).isCloseTo(2.14, within(1e-6));
+ assertThat(DocxTextBands.baselineFromEdge(footer)).isCloseTo(1.0, within(1e-9));
+ assertThat(DocxTextBands.baselineMovedIn(footer)).isCloseTo(1.16, within(1e-6));
+ }
+
+ @Test
+ void aSeparatorIsRuledPastALineTallerThanItsBand() {
+ // A 10.8pt line: a separator half a point wide ruled past it stands 11.05pt from the edge.
+ DocumentHeaderFooter shallow = DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(10).fontSize(9).build();
+ DocumentHeaderFooter fits = DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(11).fontSize(9).build();
+
+ assertThat(DocxTextBands.separatorMovedIn(shallow, 0.5)).isCloseTo(1.05, within(1e-6));
+ assertThat(DocxTextBands.separatorMovedIn(fits, 0.5)).isZero();
+ }
+
+ @Test
+ void aBandDeepEnoughForItsLineHasItsBaselineWhereThePageSetsIt() {
+ DocumentHeaderFooter header = DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.HEADER).height(14).fontSize(9).build();
+ DocumentHeaderFooter footer = DocumentHeaderFooter.builder()
+ .zone(DocumentHeaderFooterZone.FOOTER).height(12).fontSize(9).build();
+
+ assertThat(DocxTextBands.baselineMovedIn(header)).isZero();
+ assertThat(DocxTextBands.baselineMovedIn(footer)).isZero();
+ }
+
+ @Test
+ void slotsOverlapWhereOneReachesPastWhereTheNextStarts() {
+ // Across 256pt: the centre slot starts at (256 - its width) / 2, the right one at 256 less its width.
+ assertThat(DocxTextBands.slotsOverlap(256, 40, 40, 40)).isFalse();
+ assertThat(DocxTextBands.slotsOverlap(256, 109, 40, 40)).as("past the centre's start").isTrue();
+ assertThat(DocxTextBands.slotsOverlap(256, 107, 40, 40)).isFalse();
+ assertThat(DocxTextBands.slotsOverlap(256, 20, 200, 40)).as("the centre past the right's start").isTrue();
+ assertThat(DocxTextBands.slotsOverlap(256, 150, 0, 110)).as("no centre, the left past the right").isTrue();
+ assertThat(DocxTextBands.slotsOverlap(256, 150, 0, 100)).isFalse();
+ assertThat(DocxTextBands.slotsOverlap(256, 0, 250, 0)).as("one slot overlaps nothing").isFalse();
+ assertThat(DocxTextBands.slotsOverlap(256, 300, 0, 0)).isFalse();
+ }
+
@Test
void eachNumberStyleHasWordsSwitchAndDecimalNone() {
assertThat(DocxTextBands.numberFormat(DocumentPageNumberStyle.DECIMAL)).isEmpty();