Skip to content

Commit 9b61478

Browse files
authored
feat(api): DocumentPageNumbering — page-number offset/restart/style (#230)
* feat(api): DocumentPageNumbering — page-number offset/restart/style 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. * docs(api): fix DocumentPageNumbering javadoc links 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. * docs(changelog): correct DocumentPageNumbering scope wording 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.
1 parent c45623c commit 9b61478

18 files changed

Lines changed: 646 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`DocumentPageNumbering` / `DocumentPageNumberStyle`** (`@since 1.9.0`). Header
16+
and footer `{page}` / `{pages}` tokens can now offset, restart, restyle, and
17+
suppress-on-first-page numbering per zone via
18+
`DocumentHeaderFooter.builder().numbering(...)`. `DocumentPageNumbering` carries
19+
`startAt` (printed value on the first counted page), `countFrom` (physical page
20+
where counting begins), `showOnFirstPage`, and a `DocumentPageNumberStyle`
21+
(`DECIMAL`, `LOWER_ROMAN`, `UPPER_ROMAN`, `LOWER_ALPHA`, `UPPER_ALPHA`) — e.g.
22+
lower-roman or alphabetic numbering, an uncounted cover, or an offset/restarted
23+
count (one style per zone; switching style mid-document — roman front matter then
24+
arabic body — is a per-section concern). Under
25+
an offset, `{pages}` expands to the counted total
26+
(`startAt + (totalPages - countFrom)`), not the physical page count. The default
27+
(`DocumentPageNumbering.DEFAULT`) is decimal, no offset, shown on every page, so
28+
existing header/footer output is byte-identical.
29+
1530
- **`LineBuilder.lineCap(DocumentLineCap)`** (`@since 1.9.0`). Lines gain the
1631
round / square end-caps `PathBuilder` already exposed. Pairing `ROUND` with a
1732
short dash draws a dotted line — `line.dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)`
2.49 KB
Binary file not shown.

examples/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ are with the canonical DSL, then jump to its detailed section below.
9898
| [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) |
9999
| [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) |
100100
| [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) |
101+
| [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) |
101102
| [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) |
102103
| [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) |
103104
| [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)
641642
[📄 View PDF](../assets/readme/examples/pdf-chrome.pdf) ·
642643
[📜 Full source](src/main/java/com/demcha/examples/features/chrome/PdfChromeExample.java)
643644

645+
### Page numbering
646+
647+
`DocumentHeaderFooter.builder().numbering(...)` controls how the `{page}` /
648+
`{pages}` footer tokens count: an offset (`startAt`), a restart point
649+
(`countFrom`), a style (`DECIMAL` / `LOWER_ROMAN` / `UPPER_ROMAN` / `LOWER_ALPHA`
650+
/ `UPPER_ALPHA`), and whether the number shows on the first page
651+
(`showOnFirstPage`). Under an offset, `{pages}` reports the counted total, not the
652+
physical page count. Here a cover is left uncounted and the body is lower-roman.
653+
654+
```java
655+
session.chrome().footer(DocumentHeaderFooter.builder()
656+
.centerText("{page} / {pages}")
657+
.numbering(DocumentPageNumbering.builder()
658+
.style(DocumentPageNumberStyle.LOWER_ROMAN)
659+
.countFrom(2) // physical page 1 (the cover) is uncounted
660+
.build())
661+
.build());
662+
```
663+
664+
[📄 View PDF](../assets/readme/examples/page-numbering.pdf) ·
665+
[📜 Full source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java)
666+
644667
---
645668

646669
## Production patterns

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.demcha.examples.features.canvas.CanvasLayerExample;
66
import com.demcha.examples.features.debug.DebugOverlayExample;
77
import com.demcha.examples.features.docx.WordExportExample;
8+
import com.demcha.examples.features.chrome.PageNumberingExample;
89
import com.demcha.examples.features.chrome.PdfChromeExample;
910
import com.demcha.examples.features.layout.BleedExample;
1011
import com.demcha.examples.features.layout.BlockAlignExample;
@@ -167,6 +168,7 @@ public static void main(String[] args) throws Exception {
167168
// Theming + chrome
168169
System.out.println("Generated: " + CustomBusinessThemeExample.generate());
169170
System.out.println("Generated: " + PdfChromeExample.generate());
171+
System.out.println("Generated: " + PageNumberingExample.generate());
170172

171173
// DOCX export
172174
System.out.println("Generated: " + WordExportExample.generate());
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.demcha.examples.features.chrome;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.output.DocumentHeaderFooter;
6+
import com.demcha.compose.document.output.DocumentPageNumberStyle;
7+
import com.demcha.compose.document.output.DocumentPageNumbering;
8+
import com.demcha.compose.document.style.DocumentColor;
9+
import com.demcha.compose.document.style.DocumentInsets;
10+
import com.demcha.compose.document.style.DocumentTextStyle;
11+
import com.demcha.examples.support.ExampleOutputPaths;
12+
13+
import java.nio.file.Path;
14+
15+
/**
16+
* Runnable showcase for v1.9 page numbering: a footer whose {@code {page}} /
17+
* {@code {pages}} tokens offset, restart, restyle, and suppress-on-first-page via
18+
* {@code DocumentHeaderFooter.builder().numbering(...)}. Here a cover page is left
19+
* uncounted ({@code countFrom = 2}) and the body is numbered in lower-roman, so
20+
* {@code {pages}} reports the counted total rather than the physical page count.
21+
*
22+
* <pre>{@code
23+
* session.chrome().footer(DocumentHeaderFooter.builder()
24+
* .centerText("{page} / {pages}")
25+
* .numbering(DocumentPageNumbering.builder()
26+
* .style(DocumentPageNumberStyle.LOWER_ROMAN)
27+
* .countFrom(2) // physical page 1 (the cover) is uncounted
28+
* .build())
29+
* .build());
30+
* }</pre>
31+
*
32+
* @author Artem Demchyshyn
33+
*/
34+
public final class PageNumberingExample {
35+
36+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
37+
private static final DocumentColor MUTED = DocumentColor.rgb(96, 102, 112);
38+
39+
private PageNumberingExample() {
40+
}
41+
42+
/**
43+
* Renders a 4-page booklet: an uncounted cover, then three lower-roman body
44+
* pages whose footer reads {@code i / iii}, {@code ii / iii}, {@code iii / iii}.
45+
*
46+
* @return path to the generated PDF
47+
* @throws Exception if rendering or file IO fails
48+
*/
49+
public static Path generate() throws Exception {
50+
Path pdfFile = ExampleOutputPaths.prepare("features/chrome", "page-numbering.pdf");
51+
52+
DocumentTextStyle title = DocumentTextStyle.DEFAULT.withSize(22).withColor(INK);
53+
DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK);
54+
DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(9.5).withColor(MUTED);
55+
56+
try (DocumentSession session = GraphCompose.document(pdfFile)
57+
.pageSize(300, 230)
58+
.margin(DocumentInsets.of(30))
59+
.create()) {
60+
61+
session.chrome().footer(DocumentHeaderFooter.builder()
62+
.centerText("{page} / {pages}")
63+
.numbering(DocumentPageNumbering.builder()
64+
.style(DocumentPageNumberStyle.LOWER_ROMAN)
65+
.countFrom(2) // the cover (physical page 1) is uncounted
66+
.startAt(1)
67+
.build())
68+
.build());
69+
70+
session.pageFlow(page -> {
71+
// Cover — uncounted, no footer number.
72+
page.addParagraph(p -> p.text("Field Notes").textStyle(title));
73+
page.addParagraph(p -> p.text("countFrom(2) leaves this cover uncounted; "
74+
+ "the body restarts at i and {pages} reports the counted total.")
75+
.textStyle(caption).padding(DocumentInsets.top(8)));
76+
page.addPageBreak(b -> b.name("toBody"));
77+
78+
page.addParagraph(p -> p.text("Chapter I").textStyle(title));
79+
page.addParagraph(p -> p.text("Footer reads i / iii.").textStyle(body)
80+
.padding(DocumentInsets.top(6)));
81+
page.addPageBreak(b -> b.name("toII"));
82+
83+
page.addParagraph(p -> p.text("Chapter II").textStyle(title));
84+
page.addParagraph(p -> p.text("Footer reads ii / iii.").textStyle(body)
85+
.padding(DocumentInsets.top(6)));
86+
page.addPageBreak(b -> b.name("toIII"));
87+
88+
page.addParagraph(p -> p.text("Chapter III").textStyle(title));
89+
page.addParagraph(p -> p.text("Footer reads iii / iii.").textStyle(body)
90+
.padding(DocumentInsets.top(6)));
91+
});
92+
93+
session.buildPdf();
94+
}
95+
96+
return pdfFile;
97+
}
98+
99+
public static void main(String[] args) throws Exception {
100+
System.out.println("Generated: " + generate());
101+
}
102+
}

src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOptionsAdapter.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import com.demcha.compose.document.backend.fixed.pdf.options.*;
44
import com.demcha.compose.document.node.DocumentBarcodeOptions;
55
import com.demcha.compose.document.node.DocumentBarcodeType;
6+
import com.demcha.compose.document.output.DocumentPageNumberStyle;
7+
import com.demcha.compose.document.output.DocumentPageNumbering;
68
import com.demcha.compose.engine.components.content.barcode.BarcodeData;
79
import com.demcha.compose.engine.components.content.barcode.BarcodeType;
810
import com.demcha.compose.engine.components.content.header_footer.HeaderFooterConfig;
911
import com.demcha.compose.engine.components.content.header_footer.HeaderFooterZone;
12+
import com.demcha.compose.engine.components.content.header_footer.PageNumberStyle;
1013
import com.demcha.compose.engine.components.content.metadata.DocumentMetadata;
1114
import com.demcha.compose.engine.components.content.protection.PdfProtectionConfig;
1215
import com.demcha.compose.engine.components.content.watermark.WatermarkConfig;
@@ -74,6 +77,9 @@ static HeaderFooterConfig toEngine(PdfHeaderFooterOptions options) {
7477
if (options == null) {
7578
return null;
7679
}
80+
DocumentPageNumbering numbering = options.getNumbering() == null
81+
? DocumentPageNumbering.DEFAULT
82+
: options.getNumbering();
7783
return HeaderFooterConfig.builder()
7884
.zone(map(options.getZone()))
7985
.height(options.getHeight())
@@ -85,6 +91,10 @@ static HeaderFooterConfig toEngine(PdfHeaderFooterOptions options) {
8591
.showSeparator(options.isShowSeparator())
8692
.separatorColor(options.getSeparatorColor())
8793
.separatorThickness(options.getSeparatorThickness())
94+
.numberStartAt(numbering.getStartAt())
95+
.numberCountFrom(numbering.getCountFrom())
96+
.numberShowOnFirstPage(numbering.isShowOnFirstPage())
97+
.numberStyle(map(numbering.getStyle()))
8898
.build();
8999
}
90100

@@ -122,6 +132,19 @@ private static HeaderFooterZone map(PdfHeaderFooterZone zone) {
122132
return zone == PdfHeaderFooterZone.FOOTER ? HeaderFooterZone.FOOTER : HeaderFooterZone.HEADER;
123133
}
124134

135+
private static PageNumberStyle map(DocumentPageNumberStyle style) {
136+
if (style == null) {
137+
return PageNumberStyle.DECIMAL;
138+
}
139+
return switch (style) {
140+
case DECIMAL -> PageNumberStyle.DECIMAL;
141+
case LOWER_ROMAN -> PageNumberStyle.LOWER_ROMAN;
142+
case UPPER_ROMAN -> PageNumberStyle.UPPER_ROMAN;
143+
case LOWER_ALPHA -> PageNumberStyle.LOWER_ALPHA;
144+
case UPPER_ALPHA -> PageNumberStyle.UPPER_ALPHA;
145+
};
146+
}
147+
125148
private static BarcodeType map(DocumentBarcodeType type) {
126149
return switch (type) {
127150
case CODE_128 -> BarcodeType.CODE_128;

src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfOutputOptionsTranslator.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ public static PdfHeaderFooterOptions toPdf(DocumentHeaderFooter entry) {
106106
.showSeparator(entry.isShowSeparator())
107107
.separatorColor(entry.getSeparatorColor() == null ? null : entry.getSeparatorColor().color())
108108
.separatorThickness(entry.getSeparatorThickness())
109+
.numbering(entry.getNumbering())
109110
.build();
110111
}
111112

src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfHeaderFooterOptions.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.demcha.compose.document.backend.fixed.pdf.options;
22

3+
import com.demcha.compose.document.output.DocumentPageNumbering;
34
import lombok.AccessLevel;
45
import lombok.AllArgsConstructor;
56
import lombok.Builder;
@@ -42,6 +43,9 @@ public final class PdfHeaderFooterOptions {
4243
@Builder.Default
4344
private final float separatorThickness = 0.5f;
4445

46+
@Builder.Default
47+
private final DocumentPageNumbering numbering = DocumentPageNumbering.DEFAULT;
48+
4549
private PdfHeaderFooterOptions() {
4650
this.zone = PdfHeaderFooterZone.HEADER;
4751
this.height = 30f;
@@ -53,6 +57,7 @@ private PdfHeaderFooterOptions() {
5357
this.showSeparator = false;
5458
this.separatorColor = new Color(200, 200, 200);
5559
this.separatorThickness = 0.5f;
60+
this.numbering = DocumentPageNumbering.DEFAULT;
5661
}
5762

5863
/**

src/main/java/com/demcha/compose/document/output/DocumentHeaderFooter.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ public final class DocumentHeaderFooter {
4444
@Builder.Default
4545
private final float separatorThickness = 0.5f;
4646

47+
@Builder.Default
48+
private final DocumentPageNumbering numbering = DocumentPageNumbering.DEFAULT;
49+
4750
private DocumentHeaderFooter() {
4851
this.zone = DocumentHeaderFooterZone.HEADER;
4952
this.height = 30f;
@@ -55,6 +58,7 @@ private DocumentHeaderFooter() {
5558
this.showSeparator = false;
5659
this.separatorColor = DocumentColor.LIGHT_GRAY;
5760
this.separatorThickness = 0.5f;
61+
this.numbering = DocumentPageNumbering.DEFAULT;
5862
}
5963

6064
/**
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.demcha.compose.document.output;
2+
3+
/**
4+
* How a page number is rendered in a header / footer {@code {page}} or
5+
* {@code {pages}} token. A backend-neutral selector; the engine performs the
6+
* actual numeral formatting.
7+
*
8+
* @author Artem Demchyshyn
9+
* @see DocumentPageNumbering
10+
* @since 1.9.0
11+
*/
12+
public enum DocumentPageNumberStyle {
13+
/** Arabic numerals: {@code 1, 2, 3} (the default). */
14+
DECIMAL,
15+
/** Lowercase Roman numerals: {@code i, ii, iii} — common for front matter. */
16+
LOWER_ROMAN,
17+
/** Uppercase Roman numerals: {@code I, II, III}. */
18+
UPPER_ROMAN,
19+
/** Lowercase letters: {@code a, b, c, … z, aa}. */
20+
LOWER_ALPHA,
21+
/** Uppercase letters: {@code A, B, C, … Z, AA}. */
22+
UPPER_ALPHA
23+
}

0 commit comments

Comments
 (0)