sections) {
/**
* Hides the mark of the empty paragraph a document ending in a table must end with.
*
- * Word ends a document on a paragraph, so one follows a closing table, a point tall.
+ *
Word ends a document on a paragraph, so one follows a closing table, a point tall where
+ * the last page has no room for an ordinary one ({@link #dropTheSpaceAtTheEnd}).
* Where the table ends a point from the page's foot, that point does not fit, and the
* paragraph opened a page of its own: {@code ModernReceipt}'s QR code ends 0.5pt above the
* margin, and once its panels held the page's height the receipt ran to a blank second page
@@ -4579,10 +4582,12 @@ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room) {
if (room >= roomForAClosingLine()) {
return closing;
}
- report.add(DocxExportReport.Severity.APPROXIMATED, "closing paragraph", null,
+ report.add(DocxExportReport.Severity.APPROXIMATED, "closing paragraph",
+ sectioned ? "section " + (sectionIndex + 1) : null,
"the last page has no room for a line below the closing table, so the paragraph "
- + "after it is a point tall with its mark hidden: text typed at the end of the "
- + "document takes that formatting, and is hidden until it is given a style");
+ + "after it is a point tall — at the end of the document, its mark hidden where it "
+ + "holds nothing else: text typed there takes that formatting, on point-tall lines "
+ + "or hidden");
return collapsed(closing);
}
return null;
@@ -4607,9 +4612,9 @@ private double roomForAClosingLine() {
/**
* How far above its bottom margin a section's content ends on its last page, in points; 0
- * where there is no layout to say. Only the content counts: a page's backgrounds, zones and
- * fields — fragments under a path of the layout's own, starting {@code @} — are drawn
- * elsewhere and take no room in the flow.
+ * where there is no layout to say. Only the content counts: a page's backgrounds, its zones
+ * with the fields in them and a timeline's rail — fragments under a path of the layout's
+ * own, starting {@code @} — are drawn elsewhere and take no room in the flow.
*/
private static double roomBelowTheFlow(com.demcha.compose.document.layout.LayoutGraph graph) {
if (graph == null || graph.totalPages() <= 0) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java
index c3db4643c..a012099c5 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java
@@ -166,7 +166,7 @@ void aSectionEndingInATableIsClosedByAParagraphOfItsOwn() throws Exception {
int table = body.indexOf(document.getTables().get(0));
XWPFParagraph carrier = (XWPFParagraph) body.get(table + 1);
assertThat(carrier.getCTP().getPPr())
- .as("the section ends straight after the table")
+ .as("the paragraph after the table has properties to carry")
.isNotNull();
assertThat(carrier.getCTP().getPPr().isSetSectPr()).isTrue();
assertThat(DocxTwips.of(carrier.getCTP().getPPr().getSpacing().getLine()))
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java
index 6e564aa3b..b26f6f69a 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxVerticalSpacingTest.java
@@ -149,6 +149,25 @@ void aClosingParagraphLeftAPointTallIsReported() throws Exception {
assertThat(reportOf.apply(1.0).bySubject()).as("room below the table").doesNotContainKey("closing paragraph");
}
+ @Test
+ void aPageBackgroundTakesNoRoomFromTheClosingParagraph() throws Exception {
+ // A background fills the page to its foot, under the content: it is the layout's own
+ // fragment, not the flow's, so the room below the table is still the page's.
+ try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document()
+ .pageSize(400, 600).margin(com.demcha.compose.document.style.DocumentInsets.of(20)).create()) {
+ session.pageBackground(com.demcha.compose.document.style.DocumentColor.rgb(240, 244, 248));
+ session.pageFlow(page -> page
+ .addRow(row -> row.addParagraph(p -> p.text("Left")).addParagraph(p -> p.text("Right"))));
+ try (XWPFDocument document = new XWPFDocument(
+ new java.io.ByteArrayInputStream(session.export(new DocxSemanticBackend())))) {
+ var body = document.getBodyElements();
+ var properties = ((XWPFParagraph) body.get(body.size() - 1)).getCTP().getPPr();
+ assertThat(properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetLineRule())
+ .as("an ordinary closing paragraph").isTrue();
+ }
+ }
+ }
+
@Test
void aDocumentWhoseTableLeavesRoomBelowEndsWithAnOrdinaryParagraph() throws Exception {
// The paragraph after the closing table is where a reader types to add to the document.
From 15f0792189af42747ab05893f0d54f45f375a338 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 4 Oct 2026 20:27:44 +0100
Subject: [PATCH 3/4] docs(docx): say where text typed at a document's end goes
when its closing paragraph is a hairline
---
CHANGELOG.md | 23 +++++++++++--------
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 7 +++---
.../semantic/docx/DocxSemanticBackend.java | 11 +++++----
4 files changed, 25 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d9bf5644..2667e12c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,15 +8,20 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
-- **Text typed at the end of a DOCX shows.** A document that ends with a table ends with the
- paragraph Word requires after it. That paragraph was always a point tall with its mark
- hidden, so that it could not open a blank page, and whatever a reader typed at the end of
- the document took that formatting: 70 lines typed in Word at the end of `CompactMono`
- showed none of them. Where the last page has room for two lines of the
- document's text below the table, the paragraph is now an ordinary one. That is 17 of the 62
- corpus documents. The other 16 that end in a table fill their last page to the foot, keep the
- hairline, and the export report says so (`closing paragraph`, `APPROXIMATED`). Neither Word
- nor LibreOffice moves a line or adds a page anywhere in the corpus.
+- **A reader can type below a DOCX's closing table.** A document that ends with a table ends
+ with the paragraph Word requires after it. That paragraph was always a point tall with its
+ mark hidden, so that it could not open a blank page, and no one could type below the table.
+ Measured in Word on `CompactMono`:
+ - typed at the caret Word gives the document's end, 70 lines ran into the table's last
+ cell, a narrow column, over five more pages;
+ - inserted at the very end, they went into the hidden paragraph and none of them showed.
+
+ Where the last page has room for two lines of the document's text below the table, the
+ paragraph is now an ordinary one. That is 17 of the 62 corpus documents, and in all 17 the
+ same 70 lines now run onto a new page under the table, visible, with the footer. The other
+ 16 that end in a table fill their last page to the foot and keep the hairline; the export
+ report says so (`closing paragraph`, `APPROXIMATED`). Neither Word nor LibreOffice moves a
+ line or adds a page anywhere in the corpus.
- **A DOCX panel in the body starts its text the whole padding in.** A panel's table took half
its left border off its cell's left margin and its indent, for an editor that keeps the
border inside the cell; for a panel in the body, Word 16 and LibreOffice on Windows centre
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index cd3e70b5c..2f93fba74 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -79,7 +79,7 @@ Payload records live in `core` under
| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link or a transform on it as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) |
-| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so typing at the end of the document reads, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed there is hidden) |
+| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; the paragraph Word requires after a nested table is hidden where it ends its cell holding nothing and no space; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way; the paragraph Word requires after a document's closing table is an ordinary one where the last page has room for two lines below it, so a reader can type below the table, and otherwise a point tall with its mark hidden, so it opens no blank page, reported `APPROXIMATED` since text typed at the end then goes into the table's last cell) |
| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a picture anchored to the page over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported, its row held at least the outline's height less the borders both editors draw outside it where its padding does not hold its top border, and a one-line label the shape centres top to bottom on a line taller than the room Word leaves its content cut alike on both sides to that room, no closer to its letters than three quarters of a point, and seated where the page sets it; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing`, anchored to the page where the layout puts it |
| Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape anchored to the page, and the markers as the shapes they are; they stay where the layout put them when the entries' text is edited |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 279df0c02..14ef62da9 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -262,9 +262,10 @@ is painted or has its bottom edge drawn. A document that ends with a table ends
paragraph after it: Word writes one there in any case, a line of text tall. Where the last
page has room for two such lines below the table, that paragraph is an ordinary one, which
is where a reader types to add to the document. Where it has not — a page filled to its
-foot — it is a point tall with its mark hidden, so it cannot open a blank page. Then text
-typed at the very end of the document takes that formatting and is hidden. The export report
-says so (`closing paragraph`, `APPROXIMATED`).
+foot — it is a point tall with its mark hidden, so it cannot open a blank page. Then no one
+can type below the table. In Word, text typed at the end of the document goes into the table's
+last cell, and text inserted at the very end goes into the hidden paragraph and does not show.
+The export report says so (`closing paragraph`, `APPROXIMATED`).
The horizontal half is carried as an indent: outside any panel, every paragraph by each enclosing container's
margin and padding, a row or a table by the same amount as `w:tblInd`, its own left margin
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 249d018c2..50ccbd2ad 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
@@ -4564,9 +4564,10 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) {
* text tall, and one that finds no room under a table reaching the page's foot opens a blank
* page. Where the last page has the room, that paragraph is an ordinary one, which is where
* a reader types to add to the document. Where it has not, it is a point tall and its mark is
- * hidden ({@link #hideTheClosingMark}), and what is typed there is hidden too: Word gave the
- * 70 lines its editing protocol typed at the end of {@code CompactMono} to that paragraph,
- * none of them shown and the CV still one page.
+ * hidden ({@link #hideTheClosingMark}), and no one can type below the table. Measured in Word
+ * on {@code CompactMono}, typed at the caret Word gives the document's end, 70 lines ran
+ * into the table's last cell, a narrow column, over five more pages. Inserted at the very
+ * end, they went into the hidden paragraph and none of them showed.
*
* @param room how far above its bottom margin the last page's content ends, in points
* @return the paragraph written to close a section that ends with a table, or null
@@ -4586,8 +4587,8 @@ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room) {
sectioned ? "section " + (sectionIndex + 1) : null,
"the last page has no room for a line below the closing table, so the paragraph "
+ "after it is a point tall — at the end of the document, its mark hidden where it "
- + "holds nothing else: text typed there takes that formatting, on point-tall lines "
- + "or hidden");
+ + "holds nothing else: text typed at the end of the document goes into the table's "
+ + "last cell, or into that paragraph, on point-tall lines or hidden");
return collapsed(closing);
}
return null;
From bd4a847a281574fc1405caf8959548af6809796c Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 4 Oct 2026 20:45:12 +0100
Subject: [PATCH 4/4] fix(docx): report a hairline closing paragraph only where
the room was measured, in words for the section it closes
---
docs/recipes/docx-export.md | 10 +++--
.../semantic/docx/DocxSemanticBackend.java | 39 +++++++++++++------
.../semantic/docx/DocxMultiSectionTest.java | 17 ++++++++
3 files changed, 50 insertions(+), 16 deletions(-)
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 14ef62da9..ac38d745d 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -262,10 +262,12 @@ is painted or has its bottom edge drawn. A document that ends with a table ends
paragraph after it: Word writes one there in any case, a line of text tall. Where the last
page has room for two such lines below the table, that paragraph is an ordinary one, which
is where a reader types to add to the document. Where it has not — a page filled to its
-foot — it is a point tall with its mark hidden, so it cannot open a blank page. Then no one
-can type below the table. In Word, text typed at the end of the document goes into the table's
-last cell, and text inserted at the very end goes into the hidden paragraph and does not show.
-The export report says so (`closing paragraph`, `APPROXIMATED`).
+foot — it is a point tall, so it cannot open a blank page. At the document's end its mark is
+also hidden, where it holds nothing else. Then no one can type below the table. In Word, text
+typed at the end of the document goes into the table's last cell, and text inserted at the
+very end goes into the hidden paragraph and does not show. The export report says so
+(`closing paragraph`, `APPROXIMATED`). An export with no layout behind it has no room to
+measure, and keeps the point-tall paragraph.
The horizontal half is carried as an indent: outside any panel, every paragraph by each enclosing container's
margin and padding, a row or a table by the same amount as `w:tblInd`, its own left margin
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 50ccbd2ad..ed53c1338 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
@@ -769,7 +769,8 @@ private byte[] write(List sections, Path outputFile) throws Exc
// The paragraph closing a section that ends with a table is also the one that
// carries the drawings nothing else on the last page carried.
reportDrawingsLeftOver(document,
- dropTheSpaceAtTheEnd(document, roomBelowTheFlow(context.layoutGraph())));
+ dropTheSpaceAtTheEnd(document, roomBelowTheFlow(context.layoutGraph()),
+ index == sections.size() - 1));
}
hideTheClosingMark(document);
hideTheCellClosingMarks(document.getTables());
@@ -4569,10 +4570,16 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) {
* into the table's last cell, a narrow column, over five more pages. Inserted at the very
* end, they went into the hidden paragraph and none of them showed.
*
- * @param room how far above its bottom margin the last page's content ends, in points
+ * Where there is no layout to measure the room in, the paragraph is the hairline, and the
+ * report says nothing more than that the section was not laid out.
+ *
+ * @param room how far above its bottom margin the last page's content ends, in points,
+ * or NaN where there is no layout to say
+ * @param lastSection whether the section is the document's last, whose closing paragraph is
+ * the document's end
* @return the paragraph written to close a section that ends with a table, or null
*/
- private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room) {
+ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room, boolean lastSection) {
pendingSpacingAfter = 0;
carriedSpacingBefore = 0;
pullBelow = 0;
@@ -4583,12 +4590,15 @@ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room) {
if (room >= roomForAClosingLine()) {
return closing;
}
- report.add(DocxExportReport.Severity.APPROXIMATED, "closing paragraph",
- sectioned ? "section " + (sectionIndex + 1) : null,
- "the last page has no room for a line below the closing table, so the paragraph "
- + "after it is a point tall — at the end of the document, its mark hidden where it "
- + "holds nothing else: text typed at the end of the document goes into the table's "
- + "last cell, or into that paragraph, on point-tall lines or hidden");
+ if (!Double.isNaN(room)) {
+ report.add(DocxExportReport.Severity.APPROXIMATED, "closing paragraph",
+ sectioned ? "section " + (sectionIndex + 1) : null,
+ "the last page has no room for a line below the closing table, so the paragraph "
+ + "after it is a point tall: " + (lastSection
+ ? "text typed at the end of the document goes into the table's last cell, "
+ + "or into that paragraph, whose mark is hidden where it holds nothing else"
+ : "text typed after the table is set on point-tall lines"));
+ }
return collapsed(closing);
}
return null;
@@ -4608,18 +4618,23 @@ private double roomForAClosingLine() {
/** The size a closing line is reckoned at where the document's text has none. */
private static final double DEFAULT_CLOSING_TEXT_POINTS = 12;
- /** How tall an editor sets a line of text against its size: Word's Lato and Calibri are 1.2. */
+ /**
+ * How tall an editor sets a line of text against its size: Word sets Lato and Calibri at 1.2,
+ * and this is that with a little to spare. A face set taller — Poppins about 1.5 — leaves
+ * one line of room to spare rather than more.
+ */
private static final double EDITOR_LINE_SHARE = 1.25;
/**
* How far above its bottom margin a section's content ends on its last page, in points; 0
- * where there is no layout to say. Only the content counts: a page's backgrounds, its zones
+ * where its last page holds no content, and NaN where there is no layout to say. Only the
+ * content counts: a page's backgrounds, its zones
* with the fields in them and a timeline's rail — fragments under a path of the layout's
* own, starting {@code @} — are drawn elsewhere and take no room in the flow.
*/
private static double roomBelowTheFlow(com.demcha.compose.document.layout.LayoutGraph graph) {
if (graph == null || graph.totalPages() <= 0) {
- return 0;
+ return Double.NaN;
}
int lastPage = graph.totalPages() - 1;
double lowest = Double.NaN;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java
index a012099c5..cc8f65da8 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxMultiSectionTest.java
@@ -175,6 +175,23 @@ void aSectionEndingInATableIsClosedByAParagraphOfItsOwn() throws Exception {
}
}
+ @Test
+ void aSectionEndingInATableWithRoomBelowIsClosedByAnOrdinaryParagraphCarryingItsSection() throws Exception {
+ // The page has room below the table, so the paragraph closing the section is one a
+ // reader can type in, and it is the one that carries the section's properties.
+ DocumentSession tableOnly = session(300, 400, 24);
+ tableOnly.pageFlow(page -> page.addTable(t -> t.autoColumns(2).row("Net", "100")));
+ try (XWPFDocument document = export(tableOnly, landscapeBody())) {
+ var body = document.getBodyElements();
+ int table = body.indexOf(document.getTables().get(0));
+ XWPFParagraph carrier = (XWPFParagraph) body.get(table + 1);
+ var properties = carrier.getCTP().getPPr();
+ assertThat(properties.isSetSectPr()).isTrue();
+ assertThat(properties.isSetSpacing() && properties.getSpacing().isSetLineRule())
+ .as("no exact line").isFalse();
+ }
+ }
+
@Test
void aSectionThatWritesNothingStillEndsAsItsOwnSection() throws Exception {
// An empty session writes no body element, so the last paragraph is still the one