Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A block kept together in the layout is kept together in Word.** `keepTogether()` moves a
block to the next page whole, and `keepWithNext()` moves a block down with the first line
of the one after it. The DOCX export said neither, and Word re-paginates on its own, so a
card split across Word's page break and a heading could be left at the foot of the page
above its body. A block the layout placed on one page now gives each of its paragraphs
`w:keepLines` and every one but the last `w:keepNext` — the last as well when it is kept
with the next — and a table inside it takes part row by row. A block taller than a page,
which the layout lets flow, is left to flow. Converted in LibreOffice, a six-line card that
the page moves whole split 4 + 2 across the break before and lands whole on the next page
after.

- **A landscape page exports as landscape.** Word draws a page from its width and height but
reads the orientation from `w:orient` — for Page Setup, for printing, for the paper tray —
and the DOCX export wrote portrait for every page. A landscape document opened the right
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ honour an option ignores it (documented contract).
| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ |
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it |

## Output surface and lifecycle

Expand Down
2 changes: 1 addition & 1 deletion docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ creation date is real metadata.
| Images | Embedded pictures at the node's declared size |
| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin (cell content limited to atomic children). The row is kept whole across a page break, as the layout keeps it |
| Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below |
| Sections / containers | Children written in order; a fill, per-side borders or a uniform stroke travel to each paragraph inside as `w:shd` and `w:pBdr`, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`) |
| Spacers | Empty paragraphs carrying the vertical gap as spacing-after |
| Page breaks | Explicit Word page breaks |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,17 @@ boolean placed(DocumentNode node) {
return placedFor(node) != null;
}

/**
* Whether the layout placed a node, and placed all of it on one page.
*
* @param node any authored node
* @return true when the node starts and ends on the same page
*/
boolean onOnePage(DocumentNode node) {
PlacedNode placedNode = placedFor(node);
return placedNode != null && placedNode.startPage() == placedNode.endPage();
}

/**
* How far a page zone's content sits from the page edge it belongs to, as laid out on
* the first page.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import com.demcha.compose.font.FontName;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.BreakType;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.IRunBody;
import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
import com.demcha.compose.document.node.PageFieldKind;
Expand Down Expand Up @@ -627,6 +628,71 @@ private void warnUnsupportedZoneNode(DocumentNode node) {
}

private void writeNode(XWPFDocument document, DocumentNode node) throws Exception {
boolean keepTogether = node.keepTogether() && layout.onOnePage(node);
boolean keepWithNext = node.keepWithNext() && layout.onOnePage(node);
if (!keepTogether && !keepWithNext) {
writeNodeContent(document, node);
return;
}
int first = document.getBodyElements().size();
writeNodeContent(document, node);
keepOnOnePage(document.getBodyElements().subList(first, document.getBodyElements().size()),
keepWithNext);
}

/**
* Tells Word to keep together what the layout kept together.
*
* <p>{@code keepTogether()} moves a block to the next page whole rather than letting it
* run over the break, and {@code keepWithNext()} does the same for a block and the first
* line of the one after it. Word re-paginates on its own and was told neither, so a card
* the page held together split across Word's break, and a heading the page moved down
* with its body was left at the foot of the page above it.</p>
*
* <p>Word says both with two paragraph properties: {@code w:keepLines} keeps a paragraph's
* own lines on one page, and {@code w:keepNext} keeps it on the page of whatever follows.
* Every paragraph the block wrote gets the first and every one but the last the second,
* which chains the block into one unit; a block kept with the next gives its last
* paragraph {@code w:keepNext} as well. A table inside the block takes part row by row,
* because Word reads keep-with-next on a row's paragraphs as keeping the row with the
* next one.</p>
*
* <p>Only a block the layout placed on one page is kept. The layout keeps a block together
* only when a page can hold it and lets a taller one flow, and a block that ran over a
* page break is exactly that; without a layout there is nothing to say either way.</p>
*/
private static void keepOnOnePage(List<IBodyElement> written, boolean withNext) {
List<List<XWPFParagraph>> units = new ArrayList<>();
for (IBodyElement element : written) {
if (element instanceof XWPFParagraph paragraph) {
units.add(List.of(paragraph));
} else if (element instanceof XWPFTable table) {
for (XWPFTableRow row : table.getRows()) {
List<XWPFParagraph> paragraphs = new ArrayList<>();
for (XWPFTableCell cell : row.getTableCells()) {
paragraphs.addAll(cell.getParagraphs());
}
units.add(paragraphs);
}
}
}
for (int index = 0; index < units.size(); index++) {
boolean last = index == units.size() - 1;
for (XWPFParagraph paragraph : units.get(index)) {
CTPPr properties = paragraph.getCTP().isSetPPr()
? paragraph.getCTP().getPPr()
: paragraph.getCTP().addNewPPr();
if (!properties.isSetKeepLines()) {
properties.addNewKeepLines();
}
if ((!last || withNext) && !properties.isSetKeepNext()) {
properties.addNewKeepNext();
}
}
}
}

private void writeNodeContent(XWPFDocument document, DocumentNode node) throws Exception {
if (node instanceof ParagraphNode paragraph) {
writeParagraph(document, paragraph);
} else if (node instanceof ImageNode image) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.demcha.compose.document.backend.semantic.docx;

import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* What the layout keeps on one page, Word keeps on one page.
*
* <p>{@code keepTogether()} moves a block to the next page whole, and {@code keepWithNext()}
* moves a block down with the first line of the one after it. Word re-paginates on its own,
* so it has to be told the same thing — {@code w:keepLines} and {@code w:keepNext} — or a
* card splits across its break and a heading is left behind at the foot of a page.</p>
*
* @author Artem Demchyshyn
*/
class DocxKeepTogetherTest {

@Test
void aBlockKeptTogetherIsChainedIntoOneUnit() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.keepTogether()
.addParagraph(p -> p.text("Invoice"))
.addParagraph(p -> p.text("Due in 30 days"))
.addParagraph(p -> p.text("Thank you")))
.addParagraph(p -> p.text("After")))) {
XWPFParagraph first = paragraph(document, "Invoice");
XWPFParagraph middle = paragraph(document, "Due in 30 days");
XWPFParagraph last = paragraph(document, "Thank you");

assertThat(keepsNext(first)).isTrue();
assertThat(keepsNext(middle)).isTrue();
assertThat(keepsNext(last))
.as("the block ends here, and is not tied to what follows it")
.isFalse();
assertThat(keepsLines(first) && keepsLines(middle) && keepsLines(last)).isTrue();
assertThat(keepsNext(paragraph(document, "After")) || keepsLines(paragraph(document, "After")))
.isFalse();
}
}

@Test
void aBlockKeptWithTheNextHoldsOntoIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.keepWithNext().addParagraph(p -> p.text("Experience")))
.addParagraph(p -> p.text("Senior engineer, 2019 to now")))) {
assertThat(keepsNext(paragraph(document, "Experience"))).isTrue();
assertThat(keepsNext(paragraph(document, "Senior engineer, 2019 to now"))).isFalse();
}
}

@Test
void aBlockThatAsksForNothingIsFreeToBreak() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s
.addParagraph(p -> p.text("One"))
.addParagraph(p -> p.text("Two"))))) {
for (XWPFParagraph paragraph : document.getParagraphs()) {
assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse();
}
}
}

@Test
void aBlockTallerThanAPageIsLeftToFlowAsTheLayoutLetsIt() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(300, 200, 20, page -> page
.addSection(s -> {
s.keepTogether();
for (int index = 0; index < 20; index++) {
int line = index;
s.addParagraph(p -> p.text("Line " + line));
}
}))) {
for (XWPFParagraph paragraph : document.getParagraphs()) {
assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse();
}
}
}

@Test
void aTableInsideAKeptBlockIsChainedRowByRow() throws Exception {
try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page
.addSection(s -> s.keepTogether()
.addParagraph(p -> p.text("Totals"))
.addTable(t -> t.autoColumns(2).row("Net", "100").row("Tax", "20"))))) {
XWPFTable table = document.getTables().get(0);

assertThat(keepsNext(paragraph(document, "Totals"))).isTrue();
assertThat(rowKeepsNext(table.getRow(0))).isTrue();
assertThat(rowKeepsNext(table.getRow(1)))
.as("the table's last row ends the block")
.isFalse();
}
}

@Test
void withoutALayoutNothingIsKnownToBeOnOnePage() throws Exception {
try (XWPFDocument document = DocxExports.withoutLayout(595, 842, 36, page -> page
.addSection(s -> s.keepTogether()
.addParagraph(p -> p.text("One"))
.addParagraph(p -> p.text("Two"))))) {
for (XWPFParagraph paragraph : document.getParagraphs()) {
assertThat(keepsNext(paragraph) || keepsLines(paragraph)).isFalse();
}
}
}

private static XWPFParagraph paragraph(XWPFDocument document, String text) {
return document.getParagraphs().stream()
.filter(paragraph -> paragraph.getText().equals(text))
.findFirst()
.orElseThrow(() -> new AssertionError("no paragraph reads " + text));
}

private static boolean keepsNext(XWPFParagraph paragraph) {
return paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetKeepNext();
}

private static boolean keepsLines(XWPFParagraph paragraph) {
return paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetKeepLines();
}

private static boolean rowKeepsNext(XWPFTableRow row) {
for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph paragraph : cell.getParagraphs()) {
if (!keepsNext(paragraph)) {
return false;
}
}
}
return true;
}
}
Loading