From b16ac62154e476fb864b620d6ab1542e137ba4ac Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 25 Jun 2026 09:49:59 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(api):=20DocumentPageNumbering=20?= =?UTF-8?q?=E2=80=94=20page-number=20offset/restart/style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header and footer {page} / {pages} tokens can now offset, restart, restyle, and suppress-on-first-page 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+upper roman / lower+upper alpha) — e.g. lower-roman front matter then arabic body, or no number on a cover page. Under an offset {pages} expands to the counted total, not the physical page count. DocumentPageNumberStyle is a backend-neutral marker; the engine owns the numeral formatting in an engine-local PageNumberStyle (mapped at the options boundary, keeping the engine free of document.* deps). HeaderFooterConfig gains numbering fields plus an instance resolveTokens and an appliesTo predicate; the static resolvePlaceholders is retained for binary compatibility. The default numbering is decimal, no offset, shown on every page, so existing header/footer output is byte-identical. Verified: ./mvnw test -pl . — 1518 tests, 0 baselines changed. PdfPageNumberingTest reads back per-page footers (roman front-matter offset, suppress-on-first-page) with PDFTextStripper; a runnable PageNumberingExample ships with a committed preview. --- CHANGELOG.md | 13 +++ assets/readme/examples/page-numbering.pdf | Bin 0 -> 2549 bytes examples/README.md | 23 ++++ .../demcha/examples/GenerateAllExamples.java | 2 + .../features/chrome/PageNumberingExample.java | 102 ++++++++++++++++++ .../backend/fixed/pdf/PdfOptionsAdapter.java | 23 ++++ .../fixed/pdf/PdfOutputOptionsTranslator.java | 1 + .../pdf/options/PdfHeaderFooterOptions.java | 5 + .../document/output/DocumentHeaderFooter.java | 4 + .../output/DocumentPageNumberStyle.java | 23 ++++ .../output/DocumentPageNumbering.java | 64 +++++++++++ .../header_footer/HeaderFooterConfig.java | 56 +++++++++- .../header_footer/PageNumberStyle.java | 71 ++++++++++++ .../pdf/helpers/PdfHeaderFooterRenderer.java | 9 +- .../fixed/pdf/PdfPageNumberingTest.java | 92 ++++++++++++++++ .../output/DocumentPageNumberingTest.java | 47 ++++++++ .../HeaderFooterConfigNumberingTest.java | 70 ++++++++++++ .../header_footer/PageNumberStyleTest.java | 43 ++++++++ 18 files changed, 644 insertions(+), 4 deletions(-) create mode 100644 assets/readme/examples/page-numbering.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java create mode 100644 src/main/java/com/demcha/compose/document/output/DocumentPageNumberStyle.java create mode 100644 src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java create mode 100644 src/main/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyle.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPageNumberingTest.java create mode 100644 src/test/java/com/demcha/compose/document/output/DocumentPageNumberingTest.java create mode 100644 src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfigNumberingTest.java create mode 100644 src/test/java/com/demcha/compose/engine/components/content/header_footer/PageNumberStyleTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c67bdb26c..dab5abda5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ 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 front matter then arabic body, or no number on a cover page. 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 0000000000000000000000000000000000000000..3eeac42f751bae65bf60d106c8c1a6d76107908a GIT binary patch literal 2549 zcmbtWdo+}37)NdKnJLAn?fSWOy3CmGVlFafo7=aB2~9#I4JM74VN8ffmeffp2^-y+ zZc-|xRl1BeQf#_NWkZBYDVMf3+Htlz?fTAX_UwP(<$0d>_x^s*?|ohycRogeZRvnb zf9r?G01QH)A|M1{u>h*a>ToGYD*) zmj)_=q+b_{$qTE!?5cqXqnhG&xBL*5O%~oR8h6&{I?*WeiLC|A-D4-EYPLm1*{Mrv zKc;t9=<`$Y%Y3Ut^c!SF{4m0cOZP4$i%(EP<6WvdIy)1L_vAEPS#cOFHB-+KPTseb zb@cr$p;6n=H_ANm@%66w)H)O>Nak*y70JV!HWRb@b9f2v6@!CS~@G< zs*atBacV4;mGYMQ>;%2ZWRH<}6xj`@2!?cE$hg%&Pp?RC30_V5@R~Y^#TcFiBTOBj z?6?V!Pl8Oa<^;Fb*$=GCT-@vSw|GBmH4tqG3^i$qNk~m9J`>ycc1FmEMFd&if}-<3 zPOOYFSd{G05cs||N8WB;Y%;mu)hskQ{#3y;aS zY!3O&gh!j7wA37)4j^P@%~QrZgZm#TgLnimhi`?c8>w~CLQ1vBm8gssJJ~)(enV-{ zX}g8<4sP0hE{|K<+n8oXCUNUZ3G>r>(?moffs{rdamA%}2&tEZpeT*RJz2VhSE?7U zFfGABhCsuSeOnsjYxjO~AB@=RBu(*^*+Y2z5^|myy9QR?t8i91q)uvB1@xd zz4C6I@l77R%fN4iPnVypzo6)3rN0v0V$8ZGuXHl%cc}WXlaSI4221m#rAL z>Z}c+FHN-B?6WfHzoe1RGrfQZ49AH&NJASgD2(dTYoA<`qIFGi0ln(w_{`IXpjoSw z`ZFplw9>FHZci#!uU`B1!goGnNfr#uXwkj>(>c*;mK}Tc9)F$7UDHchPsp#FWNv*m zD0h6I5E{)~qHZ48IA?DAmJa{$p72zu#;s&-{mEN5$~xq`H=X}gawA8~_46E1B;?ys z1Mn!oIXIZG4sG^dw9)!zGnw+mH|UT&iTP@-lX><^!wGtYk!IOA&`=x;ps?NNVdfSP zvQQNUP+dWofsa=^bCi)P5QdoloHT72jm{%rlNA3p*19Uu&Y%&36IU%afQ!2K_XdRV z7H{?IlnWmJF0`zh9#`)`jmpYH(Z{W+`y@?v7^sk3=4d4O6c*WZwzsLtvSXuV-a^YH z;;5hk+eCxBh-IBEg!>xd=%OY^A?lGOu}gv{rr+(Uk4ug%WE7|VqPXX1j?Kt1W(#r; zR7V)mx}v(kdwqQp${pSkMqmD)619_HbrNp6}t+FUlIDY~1~mW%XlP zjCVT&JKz<)nsV^uTuv5I{Bz2YYAX$#vC6ZR-IGpDw~oI;4z6kwH%x_sGqR?M>-tG? z`;!AxZkBU9s|HFuQPVe%7dgapf3cXh&IFv^o6yTAz1tP%{vR}x{#RYZpORS?{;I&l)dXl zPQ8E0aFXE|h?fL9?NvmWGboAamEjYKS2t#Z3o^{a&H^$>-+FLh*K3<7k?Tw v&`27QkWF)PmOpomtwNy2KlHcx8Y4JlN2(-BRg6-L9f9d6U}Gb2!+^g46M4fv literal 0 HcmV?d00001 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..a427d1a07 --- /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(...)}. + * + *

{@link #getStartAt()} is the value printed on the first counted page; + * {@link #getCountFrom()} 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"); + } +} From 17d6dd451eb180216d73ef5f57e748ed1e2b7eaa Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 25 Jun 2026 09:54:43 +0100 Subject: [PATCH 2/3] docs(api): fix DocumentPageNumbering javadoc links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class javadoc linked the Lombok-generated getStartAt()/getCountFrom() getters, which the javadoc tool cannot resolve from source — a hard "reference not found" error under the JDK 17 doclint (newer JDKs were lenient, so it passed locally and on JDK 21/25 but failed the JDK 17 CI javadoc gate). Reference the fields as {@code startAt} / {@code countFrom} instead. --- .../demcha/compose/document/output/DocumentPageNumbering.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java b/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java index a427d1a07..041a48e19 100644 --- a/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java +++ b/src/main/java/com/demcha/compose/document/output/DocumentPageNumbering.java @@ -11,8 +11,8 @@ * number shows on the first page. Attaches to a {@link DocumentHeaderFooter} via * {@code builder().numbering(...)}. * - *

{@link #getStartAt()} is the value printed on the first counted page; - * {@link #getCountFrom()} is the physical (1-based) page where counting begins — + *

{@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 From a5469fc74aa1f2bafe03316eb3e823deab253b03 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 25 Jun 2026 10:09:40 +0100 Subject: [PATCH 3/3] docs(changelog): correct DocumentPageNumbering scope wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single numbering policy applies one style per zone, so "lower-roman front matter then arabic body" in one document is not achievable here — switching numbering style mid-document is a per-section (multi-section) concern. Reword the example to what one policy actually does: roman/alpha instead of decimal, an uncounted cover, an offset/restarted count. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dab5abda5..fbafe0580 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ PDF `GoTo` actions. External links are unchanged. `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 front matter then arabic body, or no number on a cover page. Under + 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