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 DOCX export names the blank page Word adds before a section.** Every section of a
multi-section document numbers its pages from 1. Once the document states different even and
odd pages, as it does for a page zone on some of them, Word starts every section on an odd
page. It adds a blank page before a section whose section before it runs to an odd number of
pages, with nothing said. Measured in Word and LibreOffice on Windows, sections of one, one and
one page came out five pages in Word and three in LibreOffice. Sections of two, one and one
page came out five in Word, and of one, three and two pages eight; LibreOffice adds none. In
both editors a footer on even pages stood on each section's second page, where the page draws
it: they file a section's even and odd pages by its own numbers. The report now names each
section Word adds a page before (`page zone`), and with no layout behind the section before it,
says where it would.
- **A DOCX export writes a page zone whose predicate reads the page total on the pages the page
draws it on.** Word paginates for itself, so a zone's `appliesTo` predicate is asked over
sample pages, the document's own and more up to the sixth, and sorted into Word's first, even
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ honour an option ignores it (documented contract).
| Render to bytes / stream / file (`FixedLayoutRenderer`) | ✅ `PdfFixedLayoutBackend` | ✅ `PptxFixedLayoutBackend` | ✅ `DocxSemanticBackend` (`SemanticBackend<byte[]>`) |
| 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 |
| 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) |
| 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
7 changes: 7 additions & 0 deletions docs/recipes/docx-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ would behave differently left to itself, the export tells it what the PDF does:
otherwise repeat the previous section's;
- the metadata is the first section's that states any.

Word starts every section on an odd page once the document states different even and odd
pages, as it does for a page zone on some of them. So it adds a blank page before a section
whose section before it runs to an odd number of pages. Measured in Word on Windows, three
sections of one page each came out five pages; LibreOffice adds none. The report names each
section Word adds a page before (`page zone`), from the page count the layout gives the section
before it.

Styles, fonts and bookmark names are shared across the document, so a link in the cover
reaches an anchor in the body. `export(backend)` takes any semantic backend; one that
cannot combine sections refuses more than one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,9 @@ private byte[] write(List<SemanticSection> sections, Path outputFile) throws Exc
endSection(document);
}
beginSection(section, index);
if (evenAndOdd && index > 0) {
reportTheBlankPageBefore(sections.get(index - 1));
}
sectionFirstElement = document.getBodyElements().size();
backgroundsInBody = List.of();
applyPageGeometry(document, context.canvas());
Expand Down Expand Up @@ -1269,6 +1272,33 @@ private byte[] write(List<SemanticSection> sections, Path outputFile) throws Exc
}
}

/**
* Names the blank page Word adds before the section being written, where the document states
* different even and odd pages. Every section numbers its pages from 1, and Word then starts
* every section on an odd page, adding a blank one where the section before it runs to an odd
* number of pages. Measured in Word on Windows, three sections of a page each ran to five
* pages; LibreOffice adds none. Where the layout does not count the section before, the note
* says where Word would.
*
* @param before the section before the one being written
*/
private void reportTheBlankPageBefore(SemanticSection before) {
com.demcha.compose.document.layout.LayoutGraph graph = before.context().layoutGraph();
int pages = graph == null ? 0 : graph.totalPages();
if (pages > 0 && pages % 2 == 0) {
return;
}
report.add(DocxExportReport.Severity.APPROXIMATED, "page zone", "section " + (sectionIndex + 1),
(pages > 0
? "Word adds a blank page before this section: the section before it runs to "
+ (pages == 1 ? "one page" : pages + " pages") + ", and"
: "Word adds a blank page before this section where the section before it runs to an "
+ "odd number of pages:")
+ " Word starts every section on an odd page where the document states different even and odd "
+ "pages, as it does for a page zone on some of them, each section numbering its pages from 1; "
+ "LibreOffice adds none");
}

/**
* Resets what a section starts from: its own measurements, page width and height, and no
* spacing carried in from the section before it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.demcha.compose.document.backend.semantic.docx;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.api.MultiSectionDocument;
import com.demcha.compose.document.backend.semantic.SemanticBackend;
import com.demcha.compose.document.backend.semantic.SemanticExportContext;
import com.demcha.compose.document.backend.semantic.SemanticSection;
import com.demcha.compose.document.dsl.ParagraphBuilder;
import com.demcha.compose.document.layout.DocumentGraph;
import com.demcha.compose.document.output.DocumentPageZone;
import com.demcha.compose.document.style.DocumentInsets;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

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

/**
* A section Word starts after a blank page of its own is named.
*
* <p>Every section numbers its pages from 1, and where the document states different even and
* odd pages — for a page zone on some of them — Word starts every section on an odd page, adding
* a blank one before a section whose section before it runs to an odd number of pages. Measured
* in Word on Windows: sections of one, one and one page ran to five pages, of two, one and one to
* five, of one, two and one to five, of one, three and two to eight; LibreOffice adds none.</p>
*
* @author Artem Demchyshyn
*/
class DocxBlankPageBeforeSectionTest {

private static final String BLANK_PAGE = "Word adds a blank page before this section";

@Test
void aSectionAfterAnOddNumberOfPagesIsNamed() throws Exception {
List<DocxExportReport.Note> notes = notes(1, 1, 1);

assertThat(blankPagesBefore(notes)).containsExactly("section 2", "section 3");
assertThat(notes).anySatisfy(note -> assertThat(note.detail())
.startsWith(BLANK_PAGE + ": the section before it runs to one page, and")
.contains("different even and odd pages")
.endsWith("LibreOffice adds none"));
}

@Test
void aSectionAfterAnyOddNumberOfPagesIsNamedWithIt() throws Exception {
List<DocxExportReport.Note> notes = notes(1, 3, 2);

assertThat(blankPagesBefore(notes)).containsExactly("section 2", "section 3");
assertThat(notes).anySatisfy(note -> assertThat(note.detail())
.startsWith(BLANK_PAGE + ": the section before it runs to 3 pages, and"));
}

@Test
void aSectionAfterAnEvenNumberOfPagesIsNot() throws Exception {
assertThat(blankPagesBefore(notes(2, 1, 1))).containsExactly("section 3");
assertThat(blankPagesBefore(notes(1, 2, 1))).containsExactly("section 2");
assertThat(blankPagesBefore(notes(2, 2))).isEmpty();
}

@Test
void aDocumentWithoutEvenAndOddPagesAddsNone() throws Exception {
List<DocxExportReport.Note> notes = new ArrayList<>();
try (MultiSectionDocument document = GraphCompose.documents()
.section(section(1, false))
.section(section(1, false))
.create()) {
document.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build());
}

assertThat(blankPagesBefore(notes)).isEmpty();
}

@Test
void aSectionAfterOneTheLayoutDoesNotCountIsNamedWhereItRunsOdd() throws Exception {
Captured captured = new Captured();
try (MultiSectionDocument document = GraphCompose.documents()
.section(section(2, true))
.section(section(1, false))
.create()) {
document.export(captured);
}
SemanticSection first = captured.sections.get(0);
SemanticExportContext laidOut = first.context();
List<SemanticSection> sections = List.of(
new SemanticSection(first.graph(), new SemanticExportContext(laidOut.canvas(),
laidOut.customFontFamilies(), laidOut.outputFile(), laidOut.outputOptions(), null)),
captured.sections.get(1));
List<DocxExportReport.Note> notes = new ArrayList<>();
DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build()
.exportSections(sections);

assertThat(blankPagesBefore(notes)).containsExactly("section 2");
assertThat(notes).anySatisfy(note -> assertThat(note.detail())
.startsWith(BLANK_PAGE + " where the section before it runs to an odd number of pages:"));
}

private static List<DocxExportReport.Note> notes(int... pages) throws Exception {
List<DocxExportReport.Note> notes = new ArrayList<>();
var documents = GraphCompose.documents();
for (int index = 0; index < pages.length; index++) {
documents = documents.section(section(pages[index], index == 0));
}
try (MultiSectionDocument document = documents.create()) {
document.export(DocxSemanticBackend.builder().reportSink(report -> notes.addAll(report.notes())).build());
}
return notes;
}

/** The sections the report names a blank page before, in order. */
private static List<String> blankPagesBefore(List<DocxExportReport.Note> notes) {
return notes.stream()
.filter(note -> note.detail().startsWith(BLANK_PAGE))
.map(note -> {
assertThat(note.severity()).isEqualTo(DocxExportReport.Severity.APPROXIMATED);
assertThat(note.subject()).isEqualTo("page zone");
return note.path();
})
.toList();
}

/** A section of so many pages, with a footer on its even pages only where asked. */
private static DocumentSession section(int pages, boolean evenFooter) {
DocumentSession session = GraphCompose.document()
.pageSize(300, 200)
.margin(DocumentInsets.of(30))
.create();
if (evenFooter) {
session.chrome().zone(DocumentPageZone.footer(20, page -> new ParagraphBuilder().text("Verso").build())
.toBuilder().appliesTo(page -> page.number() % 2 == 0).build());
}
session.pageFlow(page -> {
page.addParagraph(p -> p.text("Page 1"));
for (int next = 2; next <= pages; next++) {
int number = next;
page.addPageBreak(pageBreak -> { });
page.addParagraph(p -> p.text("Page " + number));
}
});
return session;
}

/** Takes the sections a multi-section document hands a backend. */
private static final class Captured implements SemanticBackend<byte[]> {

private List<SemanticSection> sections;

@Override
public String name() {
return "capture";
}

@Override
public byte[] export(DocumentGraph documentGraph, SemanticExportContext context) {
return new byte[0];
}

@Override
public byte[] exportSections(List<SemanticSection> sections) {
this.sections = List.copyOf(sections);
return new byte[0];
}
}
}
Loading