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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ follow semantic versioning; release dates are ISO 8601.

### Public API

- **A DOCX export writes top and bottom margins that change from page to page as Word
sections.**
- `DocumentSession.pageMargins(...)` reached the layout only. The DOCX wrote the canvas's
margins on every page and said nothing: a page with a larger bottom margin ran 7 lines of
33 further in Word and LibreOffice, past its foot, and a rule for page 1 alone was lost too.
- The internal layout graph carries the margins each page is laid out against
(`LayoutGraph.pageGeometry`), and the export writes the first page's top and bottom margins
as the section's. A later page whose top and bottom margins differ from those Word sets it
with starts a Word section with them, where it starts between blocks of the body. An
explicit page break there becomes the section's break, the hairline between two tables
carries its end, and the title-page header and footer stay with the first section, as, in a
document of several sections, does its page count from 1. Measured in Word and LibreOffice
on Windows — on pages of paragraphs, on a page after a page break, after a table, between
two tables and under a card's held margin — every page stood
where the layout sets it, line for line. The report names each such section (`page
margin`): its page break is fixed when the text above it is edited. The room for the line
closing a section, and the space above a block the layout opens a page with, are reckoned
against that page's margins.
- Side margins stay the document's, which the body is written against — a rule's length and a
panel's room among them — and from which the page sets its header and footer. A page whose
side margins differ is named.
- The report names, and the export starts no section at, a page that starts inside a block —
the rows of a table, the lines of a paragraph, a panel — or after a page whose content
reaches its foot under a table, and a page of a document of several sections whose header or
footer counts its section's pages, which Word would count apart. The four invoice presets
that set another bottom margin from page 2 start their second page inside their line items,
so their later pages are named, not written.
- The corpus is unchanged: none of its documents has margins that change from page to page.

- **A DOCX export names a document's timeline rails once.**
- Every rail the layout draws shares one path, so each timeline named its rail again under
it, word for word, and a rail running onto another page named it again there:
Expand Down Expand Up @@ -3871,6 +3900,11 @@ follow semantic versioning; release dates are ISO 8601.

### Tests

- **The DOCX field ledger names what a session hands an export beside the tree.** Every part
of `SemanticExportContext` and of the `LayoutGraph` it carries has a fate, and a part with
none fails the ledger. A session's per-page margins reached the layout, no export read them,
and no entry said so.

- **The DOCX field ledger states where a field it gave as written, or as moving nothing, is
lost.**
- A drawing the export drops — with no layout, in a page zone, painted only with a gradient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ static LayoutGraph apply(LayoutGraph base, List<PageBackgroundFill> fills) {
}
}
combined.addAll(base.fragments());
return new LayoutGraph(base.canvas(), base.totalPages(),
base.nodes(), combined);
return base.withFragments(combined);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ static LayoutGraph apply(LayoutGraph base,
compiler, registry, resources, markdown));
}
}
return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined);
return base.withFragments(combined);
}

private static List<PlacedFragment> placeZone(DocumentPageZone zone,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ static LayoutGraph apply(LayoutGraph base, List<ResolvedLayoutPass> passes) {
combined.addAll(under.getOrDefault(body.size(), List.of()));
combined.addAll(over);
// Nodes pass through untouched: a pass contributes drawing, never structure.
return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined);
return base.withFragments(combined);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ public LayoutGraph compile(DocumentGraph graph, PrepareContext prepareContext, F
LayoutCanvas canvas = prepareContext.canvas();
LAYOUT_LOG.debug("layout.compile.start roots={} canvas={}x{}", graph.roots().size(), Math.round(canvas.width()), Math.round(canvas.height()));
PAGINATION_LOG.debug("pagination.compile.start roots={} innerHeight={}", graph.roots().size(), Math.round(canvas.innerHeight()));
CompilerState state = new CompilerState(canvas, prepareContext.pageGeometry());
PageGeometry pageGeometry = prepareContext.pageGeometry();
CompilerState state = new CompilerState(canvas, pageGeometry);
List<PlacedNode> nodes = new ArrayList<>();
List<PlacedFragment> fragments = new ArrayList<>();

Expand Down Expand Up @@ -107,7 +108,7 @@ public LayoutGraph compile(DocumentGraph graph, PrepareContext prepareContext, F
int totalPages = nodes.isEmpty() && fragments.isEmpty()
? 0
: state.maxTouchedPage + 1;
LayoutGraph layoutGraph = new LayoutGraph(canvas, totalPages, nodes, fragments);
LayoutGraph layoutGraph = new LayoutGraph(canvas, totalPages, nodes, fragments, pageGeometry);
PAGINATION_LOG.debug(
"pagination.compile.end roots={} pages={} nodes={} fragments={} durationMs={}",
graph.roots().size(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,53 @@
/**
* Canonical resolved v2 layout graph.
*
* @param canvas physical page canvas used for the graph
* @param totalPages number of pages required by the graph
* @param nodes placed semantic nodes in deterministic order
* @param fragments placed render fragments in deterministic order
* @param canvas physical page canvas used for the graph
* @param totalPages number of pages required by the graph
* @param nodes placed semantic nodes in deterministic order
* @param fragments placed render fragments in deterministic order
* @param pageGeometry the margin each page was laid out against — the canvas's on every page
* unless the document sets per-page margins
* @since 2.5.0 carries {@code pageGeometry}
*/
public record LayoutGraph(
LayoutCanvas canvas,
int totalPages,
List<PlacedNode> nodes,
List<PlacedFragment> fragments
List<PlacedFragment> fragments,
PageGeometry pageGeometry
) {
/**
* Validates required graph fields and freezes ordered node/fragment lists.
* Validates required graph fields and freezes ordered node/fragment lists. A graph given no
* page geometry has the canvas's margin on every page.
*/
public LayoutGraph {
Objects.requireNonNull(canvas, "canvas");
nodes = List.copyOf(nodes);
fragments = List.copyOf(fragments);
pageGeometry = pageGeometry == null ? PageGeometry.of(canvas, List.of()) : pageGeometry;
}
}

/**
* A graph laid out against the canvas's margin on every page.
*
* @param canvas physical page canvas used for the graph
* @param totalPages number of pages required by the graph
* @param nodes placed semantic nodes in deterministic order
* @param fragments placed render fragments in deterministic order
*/
public LayoutGraph(LayoutCanvas canvas, int totalPages, List<PlacedNode> nodes, List<PlacedFragment> fragments) {
this(canvas, totalPages, nodes, fragments, null);
}

/**
* This layout with other fragments: what a pass that draws over a laid-out document gives
* back, its pages, nodes and page geometry the same.
*
* @param fragments the fragments in their paint order
* @return the layout with them
* @since 2.5.0
*/
public LayoutGraph withFragments(List<PlacedFragment> fragments) {
return new LayoutGraph(canvas, totalPages, nodes, fragments, pageGeometry);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,24 @@ public double innerWidthForPage(int pageIndex) {
public double innerHeightForPage(int pageIndex) {
return Math.max(0.0, canvas.height() - marginForPage(pageIndex).vertical());
}

/**
* Equal to another resolver of the same canvas and overrides, as the layout graph that
* carries it is equal to one laid out alike.
*/
@Override
public boolean equals(Object other) {
return other instanceof PageGeometry geometry
&& canvas.equals(geometry.canvas) && overrides.equals(geometry.overrides);
}

@Override
public int hashCode() {
return Objects.hash(canvas, overrides);
}

@Override
public String toString() {
return "PageGeometry[canvas=" + canvas + ", overrides=" + overrides + "]";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.demcha.compose.document.layout;

import com.demcha.compose.engine.components.style.Margin;
import org.junit.jupiter.api.Test;

import java.util.List;

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

/**
* A page geometry is a value: a layout graph carries it, and two layouts laid out alike are equal
* and print alike.
*/
class PageGeometryTest {

private static final LayoutCanvas CANVAS = LayoutCanvas.from(400, 600, Margin.of(36));

@Test
void twoResolversOfTheSameCanvasAndOverridesAreEqual() {
PageGeometry one = PageGeometry.of(CANVAS, List.of(new PageMarginOverride(1, Integer.MAX_VALUE,
new Margin(36, 36, 136, 36))));
PageGeometry other = PageGeometry.of(CANVAS, List.of(new PageMarginOverride(1, Integer.MAX_VALUE,
new Margin(36, 36, 136, 36))));

assertThat(one).isEqualTo(other).hasSameHashCodeAs(other);
assertThat(one.toString()).isEqualTo(other.toString());
assertThat(one).isNotEqualTo(PageGeometry.of(CANVAS, List.of()));
}

@Test
void aLayoutGraphGivenNoGeometryHasTheCanvasMarginOnEveryPage() {
LayoutGraph graph = new LayoutGraph(CANVAS, 2, List.of(), List.of());

assertThat(graph.pageGeometry()).isEqualTo(PageGeometry.of(CANVAS, List.of()));
assertThat(graph.pageGeometry().marginForPage(1)).isEqualTo(CANVAS.margin());
}
}
1 change: 1 addition & 0 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ honour an option ignores it (documented contract).
| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | ❌ (throws with a pointer to the PDF backend — POI's slide rasterizer cannot honour embedded fonts, and the PDF raster of the same graph is the canonical image output) | n/a (a `FixedLayoutRenderer` surface; the semantic SPI has no raster output) |
| Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | n/a (a fixed-layout backend builder option) |
| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | ⚠️ `renderSections` in `PptxFixedLayoutBackend` (a deck carries one slide size, so every section must share the same page canvas — differing sizes throw) | ✅ `DocxSemanticBackend.exportSections`, reached through `MultiSectionDocument.export` / `toDocxBytes` — a Word section per section, each with its own page size, orientation, margins and page-zone header and footer (the text header and footer slots are written per section, their page total as `SECTIONPAGES`). Page numbers restart at 1 in every section and a zone's total is `SECTIONPAGES`, as the PDF counts; a section with no zone of its own gets an empty header or footer rather than Word's inherited one; styles, fonts and bookmark names are shared, so links cross sections. With different even and odd pages stated, Word starts every section on an odd page and adds a blank page before a section whose section before it runs to an odd number of pages, which the report names (LibreOffice adds none) |
| Per-page margins (`DocumentSession.pageMargins`) | ✅ laid out page by page (`PageGeometry`) | ✅ drawn from the layout | ⚠️ `DocxPageMarginSections` — the first page's top and bottom margins are the section's; a page set against other top and bottom margins than Word sets it with starts a Word section with them where it starts between blocks of the body, an explicit page break there becoming the section's break and a hairline between two tables carrying its end, the title-page part kept to the first section, as is the page count from 1 in a document of several sections — measured in Word and LibreOffice on Windows, on pages of paragraphs, after a page break, after a table, between two tables and under a card's held margin, every page where the layout sets it, line for line, where one set of margins ran a page with a larger bottom margin on past its foot. Each section is named (`page margin`): its page break is fixed when the text above it is edited. Side margins stay the document's, which the body is written against and the page sets its header and footer from; a page whose side margins differ is named. A page that starts inside a block (a table's rows, a paragraph's lines, a panel), after a page whose content reaches its foot under a table, or in a document of sections whose header or footer counts its section's pages starts none and is named, Word setting it with the margins in force |
| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ✅ `DocxDeterminism` (`DocxSemanticBackend.builder().deterministic(true)` or `deterministic(Instant)`: pinned OPC created/modified + zip entry-time normalization; an embedded font's obfuscation key is derived from the font, so it needs no pinning) |
| ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) |
| `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | ✅ `buildPptx` / `writePptx` / `toPptxBytes` (resolved via `BackendProviders.fixedLayout("pptx")`; session chrome applies; fails with `MissingBackendException` naming `graph-compose-render-pptx` when the backend is absent) | ✅ `session.export(new DocxSemanticBackend(...), target)` — the semantic backend is named directly rather than discovered |
Expand Down
26 changes: 26 additions & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ Styles, fonts and bookmark names are shared across the document, so a link in th
reaches an anchor in the body. `export(backend)` takes any semantic backend; one that
cannot combine sections refuses more than one.

### Margins that change from page to page

A Word section holds one set of margins. A page that `session.pageMargins(...)` sets against
other top and bottom margins than Word sets it with starts a section of its own with them,
where it starts between blocks of the body: the paragraph that ends the page before carries the
section's end, an explicit page break there becomes the section's break, a hairline between two
tables carries it there, and the first page's title-page header and footer stay with the first
section, as, in a document of several sections, does its page count from 1. Measured in Word
and LibreOffice on Windows — on pages of paragraphs, on a page after a page break, after a
table, between two tables and under a card's held margin — a page break into
a section with the later page's margins set every page where the page sets it, line for line;
one set of margins for the whole document ran a page with a larger bottom margin on past its
foot, 7 lines of 33 further. The report names the section (`page margin`): its page break is
fixed, so text an edit adds above it no longer runs onto that page.

Side margins stay the document's. The body is written against its width — a rule's length, a
panel's room — and the page sets its header and footer there, so a page whose side margins
differ is named and Word sets its text between the document's.

A section ends in a paragraph of the body, so a page that starts inside a block — the rows of a
table, the lines of a paragraph, a panel — does not start one, nor does a page after one whose
content reaches its foot under a table, where the line that would end the section has no room.
Neither does a page of a document of several sections whose header or footer counts its
section's pages: Word would count a section of its own apart. The report names each (`page
margin`), on the block where there is one, and Word sets that page with the margins in force.

## Finding out what the export could not carry

The export says what it drops — but it says it to the log, which a service generating
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,54 @@ void perPageMarginShiftsTheContentBoxHorizontallyAndVertically() throws Exceptio
}
}

@Test
void theLayoutGraphCarriesTheMarginEachPageIsLaidOutAgainst() throws Exception {
// A semantic backend reads it to lay its own pages out alike. A page background, a
// zone and a timeline's rail each add their fragments to a new graph, which keeps it.
try (DocumentSession session = GraphCompose.document()
.pageSize(240, 300)
.margin(DocumentInsets.of(20))
.create()) {
session.pageMargins(List.of(
PageMarginRule.page(1, DocumentInsets.symmetric(15, 10)),
PageMarginRule.from(2, DocumentInsets.symmetric(40, 70))));
session.pageBackground(com.demcha.compose.document.style.DocumentColor.rgb(250, 250, 250));
session.chrome().zone(com.demcha.compose.document.output.DocumentPageZone.header(18,
page -> new com.demcha.compose.document.dsl.ParagraphBuilder().text("Header").build()));
com.demcha.compose.document.style.DocumentColor ink = com.demcha.compose.document.style.DocumentColor.rgb(26, 86, 148);
session.pageFlow()
.addTimeline(timeline -> timeline.connector(ink, 1)
.entry(com.demcha.compose.document.dsl.TimelineMarker.dot(6, ink), entry -> entry.title("One"))
.entry(com.demcha.compose.document.dsl.TimelineMarker.dot(6, ink), entry -> entry.title("Two")))
.addPageBreak(b -> { })
.addParagraph(p -> p.text("Body"))
.build();

LayoutGraph graph = session.layoutGraph();

assertThat(graph.fragments()).as("the passes ran")
.anyMatch(fragment -> fragment.path() != null && fragment.path().startsWith("@timeline-rail"));
assertThat(graph.pageGeometry().marginForPage(0).left()).isCloseTo(10.0, within(0.01));
assertThat(graph.pageGeometry().marginForPage(1).left()).isCloseTo(70.0, within(0.01));
assertThat(graph.pageGeometry().marginForPage(4).left()).isCloseTo(70.0, within(0.01));
}
}

@Test
void aLayoutWithNoPageMarginsHasTheCanvasMarginOnEveryPage() throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(240, 300)
.margin(new DocumentInsets(11, 12, 13, 14))
.create()) {
session.pageFlow().addParagraph(p -> p.text("Body")).build();

LayoutGraph graph = session.layoutGraph();

assertThat(graph.pageGeometry().marginForPage(0)).isEqualTo(graph.canvas().margin());
assertThat(graph.pageGeometry().marginForPage(3)).isEqualTo(graph.canvas().margin());
}
}

@Test
void naturalOverflowResolvesEachBlockAtItsStartPageWidth() throws Exception {
// Same vertical margin on both pages (so pagination is width-independent),
Expand Down
Loading
Loading