diff --git a/CHANGELOG.md b/CHANGELOG.md index c67bdb26c..fbafe0580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,21 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`DocumentPageNumbering` / `DocumentPageNumberStyle`** (`@since 1.9.0`). Header + and footer `{page}` / `{pages}` tokens can now offset, restart, restyle, and + suppress-on-first-page numbering per zone via + `DocumentHeaderFooter.builder().numbering(...)`. `DocumentPageNumbering` carries + `startAt` (printed value on the first counted page), `countFrom` (physical page + where counting begins), `showOnFirstPage`, and a `DocumentPageNumberStyle` + (`DECIMAL`, `LOWER_ROMAN`, `UPPER_ROMAN`, `LOWER_ALPHA`, `UPPER_ALPHA`) — e.g. + lower-roman or alphabetic numbering, an uncounted cover, or an offset/restarted + count (one style per zone; switching style mid-document — roman front matter then + arabic body — is a per-section concern). Under + an offset, `{pages}` expands to the counted total + (`startAt + (totalPages - countFrom)`), not the physical page count. The default + (`DocumentPageNumbering.DEFAULT`) is decimal, no offset, shown on every page, so + existing header/footer output is byte-identical. + - **`LineBuilder.lineCap(DocumentLineCap)`** (`@since 1.9.0`). Lines gain the round / square end-caps `PathBuilder` already exposed. Pairing `ROUND` with a short dash draws a dotted line — `line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)` diff --git a/assets/readme/examples/page-numbering.pdf b/assets/readme/examples/page-numbering.pdf new file mode 100644 index 000000000..3eeac42f7 Binary files /dev/null and b/assets/readme/examples/page-numbering.pdf differ diff --git a/examples/README.md b/examples/README.md index 2b7054cdc..103cb344a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -98,6 +98,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Barcodes](#barcodes) | QR, Code 128, Code 39, EAN-13, EAN-8, branded QR with theme colours | [PDF](../assets/readme/examples/barcode-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/barcodes/BarcodeShowcaseExample.java) | | [Charts](#charts) | Native vector bar, line, and pie/donut charts — data/spec/style layers, axis & grid toggles, point markers, value labels, legend | [PDF](../assets/readme/examples/chart-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java) | | [PDF chrome](#pdf-chrome) | `DocumentMetadata`, `DocumentWatermark`, `DocumentHeaderFooter`, `DocumentBookmarkOptions` | [PDF](../assets/readme/examples/pdf-chrome.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/PdfChromeExample.java) | +| [Page numbering](#page-numbering) | `DocumentPageNumbering` — offset / restart / roman / suppress-on-first-page for `{page}` / `{pages}` footer tokens | [PDF](../assets/readme/examples/page-numbering.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java) | | [HTTP streaming](#http-streaming) | `writePdf(OutputStream)` for Servlet / S3 / GCS — caller's stream is not closed | [PDF](../assets/readme/examples/invoice-http-stream.pdf) · [Source](src/main/java/com/demcha/examples/features/streaming/HttpStreamingExample.java) | | [Word export (DOCX)](#word-export-docx) | `DocxSemanticBackend` — the same session renders a fixed-layout PDF and an editable Word file; paragraphs / lists / tables / images map 1:1, charts fall back to their data table | [PDF](../assets/readme/examples/word-export-companion.pdf) · [DOCX](../assets/readme/examples/word-export-companion.docx) · [Source](src/main/java/com/demcha/examples/features/docx/WordExportExample.java) | | [Layout snapshot regression](#layout-snapshot-regression) | Deterministic `layoutSnapshot()` workflow with baseline + drift report — production regression-testing pattern | [PDF](../assets/readme/examples/invoice-snapshot-regression.pdf) · [Source](src/main/java/com/demcha/examples/features/snapshots/LayoutSnapshotRegressionExample.java) | @@ -641,6 +642,28 @@ GraphCompose.document(outputFile) [📄 View PDF](../assets/readme/examples/pdf-chrome.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/chrome/PdfChromeExample.java) +### Page numbering + +`DocumentHeaderFooter.builder().numbering(...)` controls how the `{page}` / +`{pages}` footer tokens count: an offset (`startAt`), a restart point +(`countFrom`), a style (`DECIMAL` / `LOWER_ROMAN` / `UPPER_ROMAN` / `LOWER_ALPHA` +/ `UPPER_ALPHA`), and whether the number shows on the first page +(`showOnFirstPage`). Under an offset, `{pages}` reports the counted total, not the +physical page count. Here a cover is left uncounted and the body is lower-roman. + +```java +session.chrome().footer(DocumentHeaderFooter.builder() + .centerText("{page} / {pages}") + .numbering(DocumentPageNumbering.builder() + .style(DocumentPageNumberStyle.LOWER_ROMAN) + .countFrom(2) // physical page 1 (the cover) is uncounted + .build()) + .build()); +``` + +[📄 View PDF](../assets/readme/examples/page-numbering.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java) + --- ## Production patterns diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 36835c1fb..2b7190142 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -5,6 +5,7 @@ import com.demcha.examples.features.canvas.CanvasLayerExample; import com.demcha.examples.features.debug.DebugOverlayExample; import com.demcha.examples.features.docx.WordExportExample; +import com.demcha.examples.features.chrome.PageNumberingExample; import com.demcha.examples.features.chrome.PdfChromeExample; import com.demcha.examples.features.layout.BleedExample; import com.demcha.examples.features.layout.BlockAlignExample; @@ -167,6 +168,7 @@ public static void main(String[] args) throws Exception { // Theming + chrome System.out.println("Generated: " + CustomBusinessThemeExample.generate()); System.out.println("Generated: " + PdfChromeExample.generate()); + System.out.println("Generated: " + PageNumberingExample.generate()); // DOCX export System.out.println("Generated: " + WordExportExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java b/examples/src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java new file mode 100644 index 000000000..6c7e328ae --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java @@ -0,0 +1,102 @@ +package com.demcha.examples.features.chrome; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentPageNumberStyle; +import com.demcha.compose.document.output.DocumentPageNumbering; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 page numbering: a footer whose {@code {page}} / + * {@code {pages}} tokens offset, restart, restyle, and suppress-on-first-page via + * {@code DocumentHeaderFooter.builder().numbering(...)}. Here a cover page is left + * uncounted ({@code countFrom = 2}) and the body is numbered in lower-roman, so + * {@code {pages}} reports the counted total rather than the physical page count. + * + *
{@code
+ * session.chrome().footer(DocumentHeaderFooter.builder()
+ *     .centerText("{page} / {pages}")
+ *     .numbering(DocumentPageNumbering.builder()
+ *         .style(DocumentPageNumberStyle.LOWER_ROMAN)
+ *         .countFrom(2)         // physical page 1 (the cover) is uncounted
+ *         .build())
+ *     .build());
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class PageNumberingExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(96, 102, 112); + + private PageNumberingExample() { + } + + /** + * Renders a 4-page booklet: an uncounted cover, then three lower-roman body + * pages whose footer reads {@code i / iii}, {@code ii / iii}, {@code iii / iii}. + * + * @return path to the generated PDF + * @throws Exception if rendering or file IO fails + */ + public static Path generate() throws Exception { + Path pdfFile = ExampleOutputPaths.prepare("features/chrome", "page-numbering.pdf"); + + DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(22).withColor(INK); + DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK); + DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(9.5).withColor(MUTED); + + try (DocumentSession session = GraphCompose.document(pdfFile) + .pageSize(300, 230) + .margin(DocumentInsets.of(30)) + .create()) { + + session.chrome().footer(DocumentHeaderFooter.builder() + .centerText("{page} / {pages}") + .numbering(DocumentPageNumbering.builder() + .style(DocumentPageNumberStyle.LOWER_ROMAN) + .countFrom(2) // the cover (physical page 1) is uncounted + .startAt(1) + .build()) + .build()); + + session.pageFlow(page -> { + // Cover — uncounted, no footer number. + page.addParagraph(p -> p.text("Field Notes").textStyle(title)); + page.addParagraph(p -> p.text("countFrom(2) leaves this cover uncounted; " + + "the body restarts at i and {pages} reports the counted total.") + .textStyle(caption).padding(DocumentInsets.top(8))); + page.addPageBreak(b -> b.name("toBody")); + + page.addParagraph(p -> p.text("Chapter I").textStyle(title)); + page.addParagraph(p -> p.text("Footer reads i / iii.").textStyle(body) + .padding(DocumentInsets.top(6))); + page.addPageBreak(b -> b.name("toII")); + + page.addParagraph(p -> p.text("Chapter II").textStyle(title)); + page.addParagraph(p -> p.text("Footer reads ii / iii.").textStyle(body) + .padding(DocumentInsets.top(6))); + page.addPageBreak(b -> b.name("toIII")); + + page.addParagraph(p -> p.text("Chapter III").textStyle(title)); + page.addParagraph(p -> p.text("Footer reads iii / iii.").textStyle(body) + .padding(DocumentInsets.top(6))); + }); + + session.buildPdf(); + } + + return pdfFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOptionsAdapter.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOptionsAdapter.java index 53cd6452b..41b3dce42 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOptionsAdapter.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOptionsAdapter.java @@ -3,10 +3,13 @@ import com.demcha.compose.document.backend.fixed.pdf.options.*; import com.demcha.compose.document.node.DocumentBarcodeOptions; import com.demcha.compose.document.node.DocumentBarcodeType; +import com.demcha.compose.document.output.DocumentPageNumberStyle; +import com.demcha.compose.document.output.DocumentPageNumbering; import com.demcha.compose.engine.components.content.barcode.BarcodeData; import com.demcha.compose.engine.components.content.barcode.BarcodeType; import com.demcha.compose.engine.components.content.header_footer.HeaderFooterConfig; import com.demcha.compose.engine.components.content.header_footer.HeaderFooterZone; +import com.demcha.compose.engine.components.content.header_footer.PageNumberStyle; import com.demcha.compose.engine.components.content.metadata.DocumentMetadata; import com.demcha.compose.engine.components.content.protection.PdfProtectionConfig; import com.demcha.compose.engine.components.content.watermark.WatermarkConfig; @@ -74,6 +77,9 @@ static HeaderFooterConfig toEngine(PdfHeaderFooterOptions options) { if (options == null) { return null; } + DocumentPageNumbering numbering = options.getNumbering() == null + ? DocumentPageNumbering.DEFAULT + : options.getNumbering(); return HeaderFooterConfig.builder() .zone(map(options.getZone())) .height(options.getHeight()) @@ -85,6 +91,10 @@ static HeaderFooterConfig toEngine(PdfHeaderFooterOptions options) { .showSeparator(options.isShowSeparator()) .separatorColor(options.getSeparatorColor()) .separatorThickness(options.getSeparatorThickness()) + .numberStartAt(numbering.getStartAt()) + .numberCountFrom(numbering.getCountFrom()) + .numberShowOnFirstPage(numbering.isShowOnFirstPage()) + .numberStyle(map(numbering.getStyle())) .build(); } @@ -122,6 +132,19 @@ private static HeaderFooterZone map(PdfHeaderFooterZone zone) { return zone == PdfHeaderFooterZone.FOOTER ? HeaderFooterZone.FOOTER : HeaderFooterZone.HEADER; } + private static PageNumberStyle map(DocumentPageNumberStyle style) { + if (style == null) { + return PageNumberStyle.DECIMAL; + } + return switch (style) { + case DECIMAL -> PageNumberStyle.DECIMAL; + case LOWER_ROMAN -> PageNumberStyle.LOWER_ROMAN; + case UPPER_ROMAN -> PageNumberStyle.UPPER_ROMAN; + case LOWER_ALPHA -> PageNumberStyle.LOWER_ALPHA; + case UPPER_ALPHA -> PageNumberStyle.UPPER_ALPHA; + }; + } + private static BarcodeType map(DocumentBarcodeType type) { return switch (type) { case CODE_128 -> BarcodeType.CODE_128; diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOutputOptionsTranslator.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOutputOptionsTranslator.java index 365acc73a..a0e05fec3 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOutputOptionsTranslator.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOutputOptionsTranslator.java @@ -106,6 +106,7 @@ public static PdfHeaderFooterOptions toPdf(DocumentHeaderFooter entry) { .showSeparator(entry.isShowSeparator()) .separatorColor(entry.getSeparatorColor() == null ? null : entry.getSeparatorColor().color()) .separatorThickness(entry.getSeparatorThickness()) + .numbering(entry.getNumbering()) .build(); } diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfHeaderFooterOptions.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfHeaderFooterOptions.java index 7a1625d23..6a91e5965 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfHeaderFooterOptions.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfHeaderFooterOptions.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.backend.fixed.pdf.options; +import com.demcha.compose.document.output.DocumentPageNumbering; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -42,6 +43,9 @@ public final class PdfHeaderFooterOptions { @Builder.Default private final float separatorThickness = 0.5f; + @Builder.Default + private final DocumentPageNumbering numbering = DocumentPageNumbering.DEFAULT; + private PdfHeaderFooterOptions() { this.zone = PdfHeaderFooterZone.HEADER; this.height = 30f; @@ -53,6 +57,7 @@ private PdfHeaderFooterOptions() { this.showSeparator = false; this.separatorColor = new Color(200, 200, 200); this.separatorThickness = 0.5f; + this.numbering = DocumentPageNumbering.DEFAULT; } /** diff --git a/src/main/java/com/demcha/compose/document/output/DocumentHeaderFooter.java b/src/main/java/com/demcha/compose/document/output/DocumentHeaderFooter.java index 1b0cfdbb7..4bcb24320 100644 --- a/src/main/java/com/demcha/compose/document/output/DocumentHeaderFooter.java +++ b/src/main/java/com/demcha/compose/document/output/DocumentHeaderFooter.java @@ -44,6 +44,9 @@ public final class DocumentHeaderFooter { @Builder.Default private final float separatorThickness = 0.5f; + @Builder.Default + private final DocumentPageNumbering numbering = DocumentPageNumbering.DEFAULT; + private DocumentHeaderFooter() { this.zone = DocumentHeaderFooterZone.HEADER; this.height = 30f; @@ -55,6 +58,7 @@ private DocumentHeaderFooter() { this.showSeparator = false; this.separatorColor = DocumentColor.LIGHT_GRAY; this.separatorThickness = 0.5f; + this.numbering = DocumentPageNumbering.DEFAULT; } /** diff --git a/src/main/java/com/demcha/compose/document/output/DocumentPageNumberStyle.java b/src/main/java/com/demcha/compose/document/output/DocumentPageNumberStyle.java new file mode 100644 index 000000000..e70909927 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/output/DocumentPageNumberStyle.java @@ -0,0 +1,23 @@ +package com.demcha.compose.document.output; + +/** + * How a page number is rendered in a header / footer {@code {page}} or + * {@code {pages}} token. A backend-neutral selector; the engine performs the + * actual numeral formatting. + * + * @author Artem Demchyshyn + * @see DocumentPageNumbering + * @since 1.9.0 + */ +public enum DocumentPageNumberStyle { + /** Arabic numerals: {@code 1, 2, 3} (the default). */ + DECIMAL, + /** Lowercase Roman numerals: {@code i, ii, iii} — common for front matter. */ + LOWER_ROMAN, + /** Uppercase Roman numerals: {@code I, II, III}. */ + UPPER_ROMAN, + /** Lowercase letters: {@code a, b, c, … z, aa}. */ + LOWER_ALPHA, + /** Uppercase letters: {@code A, B, C, … Z, AA}. */ + UPPER_ALPHA +} diff --git a/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java b/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java new file mode 100644 index 000000000..041a48e19 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java @@ -0,0 +1,64 @@ +package com.demcha.compose.document.output; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * Page-numbering policy for the {@code {page}} / {@code {pages}} tokens in a + * header or footer zone: an offset, a restart point, a style, and whether the + * number shows on the first page. Attaches to a {@link DocumentHeaderFooter} via + * {@code builder().numbering(...)}. + * + *

{@code startAt} is the value printed on the first counted page; + * {@code countFrom} is the physical (1-based) page where counting begins — + * pages before it are not counted. So {@code startAt=1, countFrom=3} prints the + * first two pages without numbering and starts the body at {@code 1}. Under an + * offset, {@code {pages}} expands to the counted total + * ({@code startAt + (totalPages - countFrom)}), not the physical page count.

+ * + *

Suppression is whole-zone: on a page where the number is not shown + * ({@code showOnFirstPage=false}, or a physical page before {@code countFrom}), + * the entire header/footer zone is skipped, not just the number — which is what + * a numbered cover / uncounted front matter wants. Put branding that must always + * appear in a separate, always-on zone.

+ * + *

The {@link #DEFAULT} (decimal, no offset, shown on every page) reproduces + * the pre-1.9 behaviour exactly. Instances are immutable and thread-safe.

+ * + * @author Artem Demchyshyn + * @see DocumentPageNumberStyle + * @since 1.9.0 + */ +@Getter +@Builder(toBuilder = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public final class DocumentPageNumbering { + + /** Decimal, no offset, shown on every page — the pre-1.9 default. */ + public static final DocumentPageNumbering DEFAULT = builder().build(); + + /** Value printed on the first counted page. */ + @Builder.Default + private final int startAt = 1; + + /** Physical 1-based page where counting begins; earlier pages are uncounted. */ + @Builder.Default + private final int countFrom = 1; + + /** Whether the numbered zone is rendered on the first physical page. */ + @Builder.Default + private final boolean showOnFirstPage = true; + + /** Numeral style for the rendered number. */ + @Builder.Default + private final DocumentPageNumberStyle style = DocumentPageNumberStyle.DECIMAL; + + private DocumentPageNumbering() { + this.startAt = 1; + this.countFrom = 1; + this.showOnFirstPage = true; + this.style = DocumentPageNumberStyle.DECIMAL; + } +} diff --git a/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java b/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java index 0349de0cf..657523a2a 100644 --- a/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java +++ b/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java @@ -63,8 +63,62 @@ public final class HeaderFooterConfig implements Component { @Builder.Default private final float separatorThickness = 0.5f; + /** Printed value on the first counted page. */ + @Builder.Default + private final int numberStartAt = 1; + + /** Physical 1-based page where counting begins; earlier pages are uncounted. */ + @Builder.Default + private final int numberCountFrom = 1; + + /** Whether the number is shown on the first physical page. */ + @Builder.Default + private final boolean numberShowOnFirstPage = true; + + /** Numeral style for the rendered page number. */ + @Builder.Default + private final PageNumberStyle numberStyle = PageNumberStyle.DECIMAL; + + /** + * Resolves placeholder tokens in the given text for a specific page, honouring + * this zone's page-numbering offset/restart/style. {@code {page}} and + * {@code {pages}} use the counted value ({@code startAt + (physicalPage - + * countFrom)}); {@code {pages}} reports the counted total, not the physical + * page count. With the default numbering this yields exactly the same output + * as the static {@link #resolvePlaceholders(String, int, int)}. + * + * @param text raw text with placeholders + * @param physicalPage 1-based physical page number + * @param totalPages total number of physical pages + * @return resolved text + */ + public String resolveTokens(String text, int physicalPage, int totalPages) { + if (text == null || text.isEmpty()) return text; + int counted = numberStartAt + (physicalPage - numberCountFrom); + int countedTotal = numberStartAt + (totalPages - numberCountFrom); + return text + .replace("{page}", numberStyle.format(counted)) + .replace("{pages}", numberStyle.format(countedTotal)) + .replace("{date}", java.time.LocalDate.now().toString()); + } + + /** + * Whether this zone's number should be rendered on the given physical page — + * false before {@code countFrom}, and false on page 1 when + * {@code showOnFirstPage} is off. + * + * @param physicalPage 1-based physical page number + * @return {@code true} if the zone renders on this page + */ + public boolean appliesTo(int physicalPage) { + return physicalPage >= numberCountFrom && (numberShowOnFirstPage || physicalPage != 1); + } + /** - * Resolves placeholder tokens in the given text for a specific page. + * Resolves placeholder tokens with decimal, no-offset numbering. Retained for + * binary compatibility (public since v1.7.0); production rendering uses the + * numbering-aware instance method {@link #resolveTokens(String, int, int)}, + * which reproduces this output for the default numbering. * * @param text raw text with placeholders * @param currentPage 1-based page number diff --git a/src/main/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyle.java b/src/main/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyle.java new file mode 100644 index 000000000..6182350be --- /dev/null +++ b/src/main/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyle.java @@ -0,0 +1,71 @@ +package com.demcha.compose.engine.components.content.header_footer; + +import java.util.Locale; + +/** + * Engine-local page-number style. Owns the numeral formatting for header/footer + * {@code {page}} / {@code {pages}} tokens; the canonical + * {@code DocumentPageNumberStyle} is a thin selector mapped onto this at the + * options-translation boundary (mirroring how the zone enums are mapped). + * + * @author Artem Demchyshyn + */ +public enum PageNumberStyle { + /** Arabic numerals: 1, 2, 3. */ + DECIMAL, + /** Lowercase Roman numerals: i, ii, iii. */ + LOWER_ROMAN, + /** Uppercase Roman numerals: I, II, III. */ + UPPER_ROMAN, + /** Lowercase letters: a, b, c. */ + LOWER_ALPHA, + /** Uppercase letters: A, B, C. */ + UPPER_ALPHA; + + private static final int[] ROMAN_VALUES = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}; + private static final String[] ROMAN_SYMBOLS = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}; + + /** + * Formats a counted page number in this style. Values outside a style's + * representable range (Roman 1–3999, alphabetic n ≥ 1) fall back to the + * decimal string, so rendering never throws. + * + * @param n the counted page number + * @return the formatted numeral + */ + public String format(int n) { + return switch (this) { + case DECIMAL -> String.valueOf(n); + case LOWER_ROMAN -> inRomanRange(n) ? roman(n).toLowerCase(Locale.ROOT) : String.valueOf(n); + case UPPER_ROMAN -> inRomanRange(n) ? roman(n) : String.valueOf(n); + case LOWER_ALPHA -> n >= 1 ? alpha(n).toLowerCase(Locale.ROOT) : String.valueOf(n); + case UPPER_ALPHA -> n >= 1 ? alpha(n) : String.valueOf(n); + }; + } + + private static boolean inRomanRange(int n) { + return n >= 1 && n <= 3999; + } + + private static String roman(int n) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ROMAN_VALUES.length; i++) { + while (n >= ROMAN_VALUES[i]) { + sb.append(ROMAN_SYMBOLS[i]); + n -= ROMAN_VALUES[i]; + } + } + return sb.toString(); + } + + /** Bijective base-26: 1 -> A, 26 -> Z, 27 -> AA. */ + private static String alpha(int n) { + StringBuilder sb = new StringBuilder(); + while (n > 0) { + int rem = (n - 1) % 26; + sb.append((char) ('A' + rem)); + n = (n - 1) / 26; + } + return sb.reverse().toString(); + } +} diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java index 40aeef8d4..c26063077 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfHeaderFooterRenderer.java @@ -56,6 +56,9 @@ public static void apply(PDDocument doc, doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { for (HeaderFooterConfig config : configs) { + if (!config.appliesTo(i + 1)) { + continue; + } renderZone(cs, config, mediaBox, i + 1, totalPages, marginLeft, marginRight); } } @@ -90,7 +93,7 @@ private static void renderZone(PDPageContentStream cs, // unsupported code points become '?' instead of crashing the // whole document render. String leftText = GlyphFallbackLogger.sanitize(font, - HeaderFooterConfig.resolvePlaceholders(config.getLeftText(), currentPage, totalPages)); + config.resolveTokens(config.getLeftText(), currentPage, totalPages)); if (!leftText.isEmpty()) { cs.beginText(); cs.setFont(font, fontSize); @@ -100,7 +103,7 @@ private static void renderZone(PDPageContentStream cs, } String centerText = GlyphFallbackLogger.sanitize(font, - HeaderFooterConfig.resolvePlaceholders(config.getCenterText(), currentPage, totalPages)); + config.resolveTokens(config.getCenterText(), currentPage, totalPages)); if (!centerText.isEmpty()) { float textWidth = font.getStringWidth(centerText) / 1000f * fontSize; float centerX = marginLeft + (usableWidth - textWidth) / 2f; @@ -112,7 +115,7 @@ private static void renderZone(PDPageContentStream cs, } String rightText = GlyphFallbackLogger.sanitize(font, - HeaderFooterConfig.resolvePlaceholders(config.getRightText(), currentPage, totalPages)); + config.resolveTokens(config.getRightText(), currentPage, totalPages)); if (!rightText.isEmpty()) { float textWidth = font.getStringWidth(rightText) / 1000f * fontSize; float rightX = pageWidth - marginRight - textWidth; diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPageNumberingTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPageNumberingTest.java new file mode 100644 index 000000000..429e34354 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPageNumberingTest.java @@ -0,0 +1,92 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentPageNumberStyle; +import com.demcha.compose.document.output.DocumentPageNumbering; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end proof that {@link DocumentPageNumbering} on a public + * {@code chrome().footer(...)} reaches the rendered PDF: an offset/restart roman + * footer and a suppress-on-first-page footer, read back per page with + * {@link PDFTextStripper}. + */ +class PdfPageNumberingTest { + + @TempDir + Path tempDir; + + private Path renderFourPages(String name, DocumentPageNumbering numbering) throws Exception { + Path out = tempDir.resolve(name + ".pdf"); + try (DocumentSession session = GraphCompose.document(out) + .pageSize(220, 200) + .margin(DocumentInsets.of(24)) + .create()) { + session.chrome().footer(DocumentHeaderFooter.builder() + .centerText("{page} / {pages}") + .numbering(numbering) + .build()); + session.pageFlow(page -> { + page.addParagraph("Alpha"); + page.addPageBreak(b -> b.name("b1")); + page.addParagraph("Beta"); + page.addPageBreak(b -> b.name("b2")); + page.addParagraph("Gamma"); + page.addPageBreak(b -> b.name("b3")); + page.addParagraph("Delta"); + }); + session.buildPdf(); + } + return out; + } + + private static String pageText(PDDocument doc, int oneBasedPage) throws Exception { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(oneBasedPage); + stripper.setEndPage(oneBasedPage); + return stripper.getText(doc); + } + + @Test + void romanFrontMatterOffsetReachesTheRenderedFooter() throws Exception { + // countFrom=2: physical page 1 is uncounted (no footer); the body restarts at i. + Path pdf = renderFourPages("roman", DocumentPageNumbering.builder() + .style(DocumentPageNumberStyle.LOWER_ROMAN) + .countFrom(2) + .startAt(1) + .build()); + + try (PDDocument doc = Loader.loadPDF(pdf.toFile())) { + assertThat(doc.getNumberOfPages()).isEqualTo(4); + assertThat(pageText(doc, 1)).doesNotContain("/"); // uncounted: no footer number + assertThat(pageText(doc, 2)).contains("i / iii"); // counted total = 1+(4-2) = iii + assertThat(pageText(doc, 3)).contains("ii / iii"); + assertThat(pageText(doc, 4)).contains("iii / iii"); + } + } + + @Test + void showOnFirstPageFalseSuppressesOnlyTheFirstNumber() throws Exception { + Path pdf = renderFourPages("suppress", DocumentPageNumbering.builder() + .showOnFirstPage(false) + .build()); + + try (PDDocument doc = Loader.loadPDF(pdf.toFile())) { + assertThat(pageText(doc, 1)).doesNotContain("/"); // first-page number hidden + assertThat(pageText(doc, 2)).contains("2 / 4"); // decimal, no offset + assertThat(pageText(doc, 4)).contains("4 / 4"); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/output/DocumentPageNumberingTest.java b/src/test/java/com/demcha/compose/document/output/DocumentPageNumberingTest.java new file mode 100644 index 000000000..8a2a4b21b --- /dev/null +++ b/src/test/java/com/demcha/compose/document/output/DocumentPageNumberingTest.java @@ -0,0 +1,47 @@ +package com.demcha.compose.document.output; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers the {@link DocumentPageNumbering} value object and its default, and that + * a {@link DocumentHeaderFooter} carries numbering through its builder while + * defaulting to the pre-1.9 behaviour. + */ +class DocumentPageNumberingTest { + + @Test + void defaultIsDecimalNoOffsetShownEverywhere() { + DocumentPageNumbering def = DocumentPageNumbering.DEFAULT; + assertThat(def.getStartAt()).isEqualTo(1); + assertThat(def.getCountFrom()).isEqualTo(1); + assertThat(def.isShowOnFirstPage()).isTrue(); + assertThat(def.getStyle()).isEqualTo(DocumentPageNumberStyle.DECIMAL); + } + + @Test + void builderRoundTrips() { + DocumentPageNumbering numbering = DocumentPageNumbering.builder() + .startAt(1) + .countFrom(3) + .showOnFirstPage(false) + .style(DocumentPageNumberStyle.LOWER_ROMAN) + .build(); + assertThat(numbering.getCountFrom()).isEqualTo(3); + assertThat(numbering.isShowOnFirstPage()).isFalse(); + assertThat(numbering.getStyle()).isEqualTo(DocumentPageNumberStyle.LOWER_ROMAN); + assertThat(numbering.toBuilder().build().getStyle()).isEqualTo(DocumentPageNumberStyle.LOWER_ROMAN); + } + + @Test + void headerFooterDefaultsToDefaultNumberingAndCarriesOverrides() { + assertThat(DocumentHeaderFooter.builder().centerText("{page}").build().getNumbering()) + .isSameAs(DocumentPageNumbering.DEFAULT); + + DocumentPageNumbering roman = DocumentPageNumbering.builder() + .style(DocumentPageNumberStyle.UPPER_ROMAN).build(); + assertThat(DocumentHeaderFooter.builder().numbering(roman).build().getNumbering()) + .isSameAs(roman); + } +} diff --git a/src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfigNumberingTest.java b/src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfigNumberingTest.java new file mode 100644 index 000000000..9e2b2686f --- /dev/null +++ b/src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfigNumberingTest.java @@ -0,0 +1,70 @@ +package com.demcha.compose.engine.components.content.header_footer; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Engine semantics of page numbering on {@link HeaderFooterConfig}: the instance + * {@code resolveTokens} offset/restart/style math, the {@code appliesTo} + * predicate, and that the static {@code resolvePlaceholders} shim stays + * byte-identical for the legacy decimal path. + */ +class HeaderFooterConfigNumberingTest { + + @Test + void defaultNumberingMatchesTheLegacyStaticOutput() { + HeaderFooterConfig config = HeaderFooterConfig.builder().build(); + // counted == physical, counted total == physical total → identical to the static shim. + assertThat(config.resolveTokens("Page {page} of {pages}", 2, 5)) + .isEqualTo(HeaderFooterConfig.resolvePlaceholders("Page {page} of {pages}", 2, 5)) + .isEqualTo("Page 2 of 5"); + } + + @Test + void styleAndOffsetApplyToCountedValue() { + HeaderFooterConfig config = HeaderFooterConfig.builder() + .numberStyle(PageNumberStyle.UPPER_ROMAN) + .build(); + assertThat(config.resolveTokens("{page}", 3, 6)).isEqualTo("III"); + } + + @Test + void countFromRestartsAndPagesReportsCountedTotal() { + // Front matter: first two physical pages are uncounted; body starts at 1. + HeaderFooterConfig config = HeaderFooterConfig.builder() + .numberCountFrom(3) + .numberStartAt(1) + .build(); + // physical page 3 -> counted 1; physical page 4 -> counted 2. + assertThat(config.resolveTokens("{page}", 3, 6)).isEqualTo("1"); + assertThat(config.resolveTokens("{page}", 4, 6)).isEqualTo("2"); + // {pages} on a 6-page doc = counted total = 1 + (6 - 3) = 4 (not physical 6). + assertThat(config.resolveTokens("{page} of {pages}", 4, 6)).isEqualTo("2 of 4"); + } + + @Test + void appliesToHonoursCountFromAndShowOnFirstPage() { + HeaderFooterConfig suppressed = HeaderFooterConfig.builder() + .numberShowOnFirstPage(false) + .build(); + assertThat(suppressed.appliesTo(1)).isFalse(); + assertThat(suppressed.appliesTo(2)).isTrue(); + + HeaderFooterConfig offset = HeaderFooterConfig.builder() + .numberCountFrom(3) + .build(); + assertThat(offset.appliesTo(2)).isFalse(); + assertThat(offset.appliesTo(3)).isTrue(); + + // Default config renders on every page. + assertThat(HeaderFooterConfig.builder().build().appliesTo(1)).isTrue(); + } + + @Test + void staticShimIsUnchanged() { + assertThat(HeaderFooterConfig.resolvePlaceholders("p{page}/{pages}", 4, 9)).isEqualTo("p4/9"); + assertThat(HeaderFooterConfig.resolvePlaceholders(null, 1, 1)).isNull(); + assertThat(HeaderFooterConfig.resolvePlaceholders("", 1, 1)).isEmpty(); + } +} diff --git a/src/test/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyleTest.java b/src/test/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyleTest.java new file mode 100644 index 000000000..3ff32b1b7 --- /dev/null +++ b/src/test/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyleTest.java @@ -0,0 +1,43 @@ +package com.demcha.compose.engine.components.content.header_footer; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers the numeral formatting owned by {@link PageNumberStyle}: decimal, Roman, + * and alphabetic styles, plus graceful fallback for out-of-range values. + */ +class PageNumberStyleTest { + + @Test + void decimalIsPlainArabic() { + assertThat(PageNumberStyle.DECIMAL.format(7)).isEqualTo("7"); + assertThat(PageNumberStyle.DECIMAL.format(2026)).isEqualTo("2026"); + } + + @Test + void romanCoversBothCases() { + assertThat(PageNumberStyle.LOWER_ROMAN.format(4)).isEqualTo("iv"); + assertThat(PageNumberStyle.UPPER_ROMAN.format(9)).isEqualTo("IX"); + assertThat(PageNumberStyle.UPPER_ROMAN.format(2024)).isEqualTo("MMXXIV"); + assertThat(PageNumberStyle.LOWER_ROMAN.format(1)).isEqualTo("i"); + } + + @Test + void alphaIsBijectiveBase26() { + assertThat(PageNumberStyle.LOWER_ALPHA.format(1)).isEqualTo("a"); + assertThat(PageNumberStyle.LOWER_ALPHA.format(26)).isEqualTo("z"); + assertThat(PageNumberStyle.LOWER_ALPHA.format(27)).isEqualTo("aa"); + assertThat(PageNumberStyle.UPPER_ALPHA.format(1)).isEqualTo("A"); + assertThat(PageNumberStyle.UPPER_ALPHA.format(28)).isEqualTo("AB"); + } + + @Test + void outOfRangeFallsBackToDecimalWithoutThrowing() { + assertThat(PageNumberStyle.UPPER_ROMAN.format(0)).isEqualTo("0"); + assertThat(PageNumberStyle.LOWER_ROMAN.format(4000)).isEqualTo("4000"); + assertThat(PageNumberStyle.LOWER_ALPHA.format(0)).isEqualTo("0"); + assertThat(PageNumberStyle.UPPER_ALPHA.format(-3)).isEqualTo("-3"); + } +}