diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27218be1e..e941e21d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **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 chip takes the room its padding takes on the page.** A chip's fill is written as
run shading, which covers the letters and nothing more, so its horizontal padding was not
in the file. `ModernReceipt`'s status chip, right-aligned with 9pt of padding on each side,
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index f77e1cd00..c5dfc3143 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) |
+| 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 1f881dfde..fef20ce12 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -259,8 +259,15 @@ end of a section none is written: the page ends there, and space below the last
only push that line onto a page of its own. The same holds for the last line of each cell
of a table the section ends with, and of the tables such a cell ends with, unless the cell
is painted or has its bottom edge drawn. A document that ends with a table ends with a
-paragraph a point tall after it: Word writes one there in any case, and its own is a line of
-text tall.
+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, 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 db984e6c8..f50ca1f8d 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
@@ -768,7 +768,9 @@ private byte[] write(List sections, Path outputFile) throws Exc
raiseRows();
// 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));
+ reportDrawingsLeftOver(document,
+ dropTheSpaceAtTheEnd(document, roomBelowTheFlow(context.layoutGraph()),
+ index == sections.size() - 1));
}
hideTheClosingMark(document);
hideTheCellClosingMarks(document.getTables());
@@ -842,8 +844,10 @@ private void beginSection(SemanticSection section, int index) {
* section that wrote nothing into the body at all — an empty session, or one of shapes
* this export drops — whose last paragraph is still the one closing the section before
* it. Handing that paragraph these properties would overwrite the earlier section's,
- * folding two sections into one. The added paragraph is one invisible point tall, so it
- * cannot push a full page onto a page of its own.
+ * folding two sections into one. A section ending in a table already ends with the paragraph
+ * {@link #dropTheSpaceAtTheEnd} writes after it, which carries them; one that wrote nothing
+ * gets an added paragraph a point tall, so it cannot push a full page onto a page of its
+ * own.
*/
private void endSection(XWPFDocument document) {
CTBody body = document.getDocument().getBody();
@@ -932,7 +936,8 @@ private static boolean distinguishesEvenPages(List 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
@@ -944,12 +949,26 @@ private static boolean distinguishesEvenPages(List sections) {
private static void hideTheClosingMark(XWPFDocument document) {
List body = document.getBodyElements();
if (body.size() < 2 || !(body.get(body.size() - 1) instanceof XWPFParagraph last)
- || !(body.get(body.size() - 2) instanceof XWPFTable) || !structureAlone(last)) {
+ || !(body.get(body.size() - 2) instanceof XWPFTable) || !structureAlone(last) || !hairline(last, CLOSING_LINE_POINTS)) {
return;
}
hide(last);
}
+ /**
+ * Whether a paragraph is a hairline {@code points} tall holding no space above or below it:
+ * one written only to close a table ({@link #collapsed}, a cell's separator). An ordinary
+ * closing paragraph, where the page has the room for one, is left where a reader can type.
+ */
+ private static boolean hairline(XWPFParagraph paragraph, double points) {
+ CTSpacing spacing = paragraph.getCTP().getPPr() != null && paragraph.getCTP().getPPr().isSetSpacing()
+ ? paragraph.getCTP().getPPr().getSpacing() : null;
+ return spacing != null && spacing.isSetLineRule()
+ && spacing.getLineRule() == STLineSpacingRule.EXACT
+ && twipsOf(spacing.getLine()) == Math.round(points * POINT_TO_TWIP)
+ && twipsOf(spacing.getBefore()) == 0 && twipsOf(spacing.getAfter()) == 0;
+ }
+
/**
* Hides the mark of the empty paragraph a table cell ending in a nested table must end with,
* in every table of the document, nested ones included.
@@ -972,15 +991,9 @@ private static void hideTheCellClosingMarks(List tables) {
|| !(elements.get(elements.size() - 2) instanceof XWPFTable) || !structureAlone(last)) {
continue;
}
- CTSpacing spacing = last.getCTP().getPPr() != null && last.getCTP().getPPr().isSetSpacing()
- ? last.getCTP().getPPr().getSpacing() : null;
- boolean hairline = spacing != null && spacing.isSetLineRule()
- && spacing.getLineRule() == STLineSpacingRule.EXACT
- && twipsOf(spacing.getLine()) == Math.round(SEPARATOR_POINTS * POINT_TO_TWIP);
- if (!hairline || twipsOf(spacing.getBefore()) != 0 || twipsOf(spacing.getAfter()) != 0) {
- continue;
+ if (hairline(last, SEPARATOR_POINTS)) {
+ hide(last);
}
- hide(last);
}
}
}
@@ -1012,6 +1025,9 @@ private static void hide(XWPFParagraph paragraph) {
}
}
+ /** How tall a paragraph that exists only for Word's structure is written, in points. */
+ private static final double CLOSING_LINE_POINTS = 1;
+
/** Makes a paragraph that exists only for Word's structure take a single point. */
private static XWPFParagraph collapsed(XWPFParagraph paragraph) {
CTPPr properties = paragraph.getCTP().isSetPPr()
@@ -1021,7 +1037,7 @@ private static XWPFParagraph collapsed(XWPFParagraph paragraph) {
spacing.setBefore(BigInteger.ZERO);
spacing.setAfter(BigInteger.ZERO);
spacing.setLineRule(STLineSpacingRule.EXACT);
- spacing.setLine(BigInteger.valueOf(Math.round(POINT_TO_TWIP)));
+ spacing.setLine(BigInteger.valueOf(Math.round(CLOSING_LINE_POINTS * POINT_TO_TWIP)));
return paragraph;
}
@@ -4557,23 +4573,93 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) {
* tables such a cell ends with in turn — unless the cell is painted or has a bottom edge
* drawn, where that space is part of the box the reader sees.
*
+ * Word cannot end a section with a table: a paragraph follows it, a line of the document's
+ * 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 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.
+ *
+ * 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) {
+ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document, double room, boolean lastSection) {
pendingSpacingAfter = 0;
carriedSpacingBefore = 0;
pullBelow = 0;
List body = document.getBodyElements();
if (!body.isEmpty() && body.get(body.size() - 1) instanceof XWPFTable table) {
dropTheSpaceBelow(table);
- // Word cannot end a section with a table: it puts a paragraph of its own after it,
- // a line of the document's text tall, and one that finds no room under a table
- // reaching the page's foot opens a blank page. This one is a point tall.
- return collapsed(document.createParagraph());
+ XWPFParagraph closing = document.createParagraph();
+ if (room >= roomForAClosingLine()) {
+ return closing;
+ }
+ 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;
}
+ /**
+ * The room an ordinary closing paragraph needs below the last table, in points: a line of the
+ * document's text, as tall as an editor sets one of its size, and as much again to spare for
+ * where an editor sets the content above it lower than the page does.
+ */
+ private double roomForAClosingLine() {
+ double size = documentDefaultStyle != null && documentDefaultStyle.size() > 0
+ ? documentDefaultStyle.size() : DEFAULT_CLOSING_TEXT_POINTS;
+ return 2 * size * EDITOR_LINE_SHARE;
+ }
+
+ /** 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 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 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 Double.NaN;
+ }
+ int lastPage = graph.totalPages() - 1;
+ double lowest = Double.NaN;
+ for (com.demcha.compose.document.layout.PlacedFragment fragment : graph.fragments()) {
+ if (fragment.pageIndex() != lastPage || fragment.path() == null || fragment.path().startsWith("@")) {
+ continue;
+ }
+ // A fragment's y is its bottom edge, measured up from the page's foot.
+ lowest = Double.isNaN(lowest) ? fragment.y() : Math.min(lowest, fragment.y());
+ }
+ return Double.isNaN(lowest) ? 0 : lowest - graph.canvas().margin().bottom();
+ }
+
/** Leaves out the space below the last line of each cell of a table's last row that shows none of it. */
private static void dropTheSpaceBelow(XWPFTable table) {
if (table.getRows().isEmpty() || drawn(tableBottom(table))) {
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 8a9bf189a..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
@@ -158,13 +158,16 @@ void aLinkReachesAnAnchorInAnotherSection() throws Exception {
@Test
void aSectionEndingInATableIsClosedByAParagraphOfItsOwn() throws Exception {
DocumentSession tableOnly = session(300, 400, 24);
- tableOnly.pageFlow(page -> page.addTable(t -> t.autoColumns(2).row("Net", "100")));
+ // 352pt inside the margins: the table ends a few points above the foot.
+ tableOnly.pageFlow(page -> page.spacer(1, 330).addTable(t -> t.autoColumns(2).row("Net", "100")));
try (XWPFDocument document = export(tableOnly, landscapeBody())) {
assertThat(sectionsOf(document)).hasSize(2);
- XWPFParagraph carrier = document.getParagraphArray(0);
- assertThat(document.getBodyElements().get(0))
- .as("the table comes first, and the section ends after it")
- .isSameAs(document.getTables().get(0));
+ var body = document.getBodyElements();
+ int table = body.indexOf(document.getTables().get(0));
+ XWPFParagraph carrier = (XWPFParagraph) body.get(table + 1);
+ assertThat(carrier.getCTP().getPPr())
+ .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()))
.as("a point tall, so a table filling its page does not spill the carrier onto another")
@@ -172,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
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java
index b18bccbae..18876b9a5 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelHeightTest.java
@@ -132,8 +132,9 @@ void theParagraphClosingACellKeepsItsMarkWhenItHoldsTheSpaceBelowTheTable() thro
}
@Test
- void theEmptyParagraphClosingADocumentThatEndsInATableIsNotLaidOut() throws Exception {
- try (XWPFDocument document = export(page -> page.addTable(t -> t
+ void theEmptyParagraphClosingADocumentThatEndsInATableAtThePagesFootIsNotLaidOut() throws Exception {
+ // 360pt inside the margins: the table ends a few points above the foot.
+ try (XWPFDocument document = export(page -> page.spacer(1, 320).addTable(t -> t
.columns(DocumentTableColumn.fixed(120)).row("Last")))) {
List body = document.getBodyElements();
XWPFParagraph closing = (XWPFParagraph) body.get(body.size() - 1);
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 814245648..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
@@ -112,10 +112,12 @@ void theSpaceBelowTheLastLineOfEachCellOfAClosingTableIsLeftOut() throws Excepti
}
@Test
- void aDocumentThatEndsWithATableEndsWithAParagraphAPointTall() throws Exception {
+ void aDocumentWhoseTableEndsAtThePagesFootEndsWithAParagraphAPointTall() throws Exception {
// Word puts a paragraph after a closing table whatever the file says: left to it, that
- // paragraph is a line of body text tall and opens a blank page under a full one.
+ // paragraph is a line of body text tall and opens a blank page under a full one. 560pt
+ // inside the margins, so the row ends a few points above the foot.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .spacer(1, 540)
.addRow(row -> row.addParagraph(p -> p.text("Left")).addParagraph(p -> p.text("Right"))))) {
var body = document.getBodyElements();
assertThat(body.get(body.size() - 1)).isInstanceOf(XWPFParagraph.class);
@@ -124,6 +126,63 @@ void aDocumentThatEndsWithATableEndsWithAParagraphAPointTall() throws Exception
assertThat(spacing.getLineRule())
.isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule.EXACT);
assertThat(DocxTwips.of(spacing.getLine())).as("a point").isEqualTo(20L);
+ assertThat(last.getCTP().getPPr().getRPr().sizeOfVanishArray()).as("its mark hidden").isEqualTo(1);
+ }
+ }
+
+ @Test
+ void aClosingParagraphLeftAPointTallIsReported() throws Exception {
+ java.util.function.Function reportOf = spacer -> {
+ java.util.concurrent.atomic.AtomicReference captured =
+ new java.util.concurrent.atomic.AtomicReference<>();
+ 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.pageFlow(page -> page.spacer(1, spacer)
+ .addRow(row -> row.addParagraph(p -> p.text("Left")).addParagraph(p -> p.text("Right"))));
+ session.export(new DocxSemanticBackend(captured::set));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ return captured.get();
+ };
+ assertThat(reportOf.apply(540.0).bySubject()).as("no room below the table").containsKey("closing paragraph");
+ 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.
+ // A point tall with its mark hidden, what Word's editing protocol typed there was hidden
+ // too; where the page has the room, it is an ordinary paragraph.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
+ .addRow(row -> row.addParagraph(p -> p.text("Left")).addParagraph(p -> p.text("Right"))))) {
+ var body = document.getBodyElements();
+ XWPFParagraph last = (XWPFParagraph) body.get(body.size() - 1);
+ var properties = last.getCTP().getPPr();
+ assertThat(body.get(body.size() - 2)).isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class);
+ assertThat(properties == null || !properties.isSetSpacing() || !properties.getSpacing().isSetLineRule())
+ .as("no exact line").isTrue();
+ assertThat(properties == null || !properties.isSetRPr() || properties.getRPr().sizeOfVanishArray() == 0)
+ .as("no hidden mark").isTrue();
}
}