From 2a3c11a4c181b9160729f94c3f2dac0dbc9b50bb Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 10:41:05 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20chrome().viewerPreferences()?= =?UTF-8?q?=20=E2=80=94=20how=20a=20PDF=20opens=20(page=20mode=20/=20layou?= =?UTF-8?q?t=20/=20window)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document could not say how a reader should present it on open — open the bookmark panel, use a two-column layout, show the title in the window — so a bookmarked document still opened to a bare page with the outline hidden. chrome().viewerPreferences(DocumentViewerPreferences) writes page mode, page layout, and the window-chrome flags (displayDocTitle, hideToolbar, hideMenubar, fitWindow, centerWindow) to the PDF document catalog. DocumentPageMode.USE_OUTLINES opens the bookmark panel — it pairs with bookmark(...) on sections, and DocumentViewerPreferences.openOutline() is a one-line preset. Threaded through the existing chrome path (SessionChromeApi -> DocumentChromeOptions -> DocumentOutputOptions -> translator -> backend -> post-processor) as a third document-global option alongside metadata and protection; the multi-section combined document takes it from the first section that sets it. Viewer preferences are advisory and PDF-only — other backends ignore them, and a document that sets none does not touch the catalog, so existing output is byte-for-byte unchanged. Tests: ViewerPreferencesTest asserts the catalog dictionary (not pixels) — USE_OUTLINES page mode, two-column layout, the window flags incl. an explicit false still writing the /ViewerPreferences dict, page-mode-only writing no dict, no-preferences leaving the catalog at its defaults, and the multi-section first-section-wins rule. Example: ViewerPreferencesExample (a guide that opens with its bookmark panel). --- CHANGELOG.md | 9 ++ assets/readme/examples/viewer-preferences.pdf | Bin 0 -> 1377 bytes examples/README.md | 19 +++ .../demcha/examples/GenerateAllExamples.java | 2 + .../chrome/ViewerPreferencesExample.java | 103 +++++++++++++ .../document/api/DocumentChromeOptions.java | 12 +- .../compose/document/api/DocumentSession.java | 16 ++ .../document/api/SessionChromeApi.java | 17 +++ .../fixed/pdf/PdfDocumentPostProcessor.java | 80 ++++++++++ .../fixed/pdf/PdfFixedLayoutBackend.java | 30 +++- .../fixed/pdf/PdfOutputOptionsTranslator.java | 21 +++ .../options/PdfViewerPreferencesOptions.java | 30 ++++ .../output/DocumentOutputOptions.java | 22 ++- .../output/DocumentViewerPreferences.java | 58 +++++++ .../document/style/DocumentPageLayout.java | 23 +++ .../document/style/DocumentPageMode.java | 21 +++ .../fixed/pdf/ViewerPreferencesTest.java | 144 ++++++++++++++++++ 17 files changed, 599 insertions(+), 8 deletions(-) create mode 100644 assets/readme/examples/viewer-preferences.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/chrome/ViewerPreferencesExample.java create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfViewerPreferencesOptions.java create mode 100644 src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentPageLayout.java create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentPageMode.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/ViewerPreferencesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index dd74d9e58..ae4222209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`chrome().viewerPreferences(...)` + `DocumentViewerPreferences` / + `DocumentPageMode` / `DocumentPageLayout`** (`@since 1.9.0`). Controls how a PDF + reader presents the document on open — the page mode (`USE_OUTLINES` opens the + bookmark panel, pairing with `bookmark(...)`), the page layout (single / one-column + / two-column / two-page), and the window flags (`displayDocTitle`, `hideToolbar`, + `hideMenubar`, `fitWindow`, `centerWindow`). Written to the PDF document catalog; + `DocumentViewerPreferences.openOutline()` is a one-line preset. PDF-only — other + backends ignore it. A document that sets none is unchanged. + - **Container `bookmark(...)`** (`@since 1.9.0`). `bookmark(DocumentBookmarkOptions)` on any container flow builder (`Section` / `Container` / page flow) — previously only the seven leaf builders carried a bookmark — adds a PDF outline entry pointing diff --git a/assets/readme/examples/viewer-preferences.pdf b/assets/readme/examples/viewer-preferences.pdf new file mode 100644 index 0000000000000000000000000000000000000000..105e573e996e5ba7fb8ab7851f2d8f2cc3f88aec GIT binary patch literal 1377 zcmY!laBZ^4=fsl4ocwey{jk)c;>`R! z1$~fe{eZ;u)M5o=pz z;*z4 UskiaB#9p3XaDAmIA_Usq&Ph~#Y*m1(Q=1lD_n#mYSM;8+3D05eD6@%s8-89=+`C`A=Al@6 z$-cv9<}B3RF=g?#%(aQZYu<`i?z^`tDK`6ENZahJ=YeegSDBX{Qx<#As<-igj+=4* z*=2{{Z;$-)>5tW%bGLcZ)Y@2@-<7*a?3UJT2C+$PO@{yY)3z`DHr0x=;9$ee z#5ETWxJ}QRjt53~+cd5RCr?=EKl{4q-s(@|ZRQLOZJ$B#) zMF=Et;ZXyMTUgS7MU9y`ESdNxWd)bya_Rdi7#d=aE?^uM6_+TOnW08p?WEIwhk+4S z|5Pur`LWF`msJxqmWDhE6;+8#N}MDZ!BzOwBJ}t7p4mZBTsk?vY3uyo^~fqYw3s;Z zXa=iH$n20ynWyYJBkhEMs%6m;Pc}(~&YcoUFE`pbO=5lV{pj2>(T533R%#XAdo_tq z(N9-1U5D@7+F5(&&+4C)V&aH)bcViQ5|7x0bI{ z^Y#Dk_T%b&hg=rrtv>a8riQmq-|k!_?`eeuIGw_ogEHt(G3cW2xByss}O3neX(dZm@T>e-W$g$n0h9h$PMu6f7x zuby{b_%=1pUpVpPRBZ7I%7bX}Y66Vbp#1z21yFVb=5Nouw0s34DBDFL+Q!ky&B@Ho z#K^_i+1b+6$iURaz{$wi*wx6u&B)cl#n9Bm)y>$_&C=1$#n{Bf&DGSz!py|n$;sTr z(Z$Hc#nDcIawB44NiQNOHH}L@II}8M!4Ozfc;=;~RwzUpC>WXom4quq8v>nf2xJm0 zLqt#QWMoj_IbxvxN5p{r^d_Siu2QCX8w3Oy$+^_Z%5cSm8H&*#JJu@X$1umojs YNn%k+1+aMIGBhwU^a}09(x)?EnA( literal 0 HcmV?d00001 diff --git a/examples/README.md b/examples/README.md index 68d89bd9c..d2f004097 100644 --- a/examples/README.md +++ b/examples/README.md @@ -103,6 +103,7 @@ are with the canonical DSL, then jump to its detailed section below. | [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) | +| [Viewer preferences](#viewer-preferences) | `chrome().viewerPreferences(...)` — open with the bookmark panel (`USE_OUTLINES`), set page layout, or show the doc title in the window | [PDF](../assets/readme/examples/viewer-preferences.pdf) · [Source](src/main/java/com/demcha/examples/features/chrome/ViewerPreferencesExample.java) | | [Page references](#page-references) | `addPageReference(anchor)` — print the page an `anchor(...)` lands on (a native "see page N" cross-reference), resolved in one authoring pass | [PDF](../assets/readme/examples/page-reference.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/PageReferenceExample.java) | | [Table of contents](#table-of-contents) | `addTableOfContents(toc -> toc.entry(label, anchor))` — a native clickable TOC with dot leaders and auto-resolved page numbers | [PDF](../assets/readme/examples/table-of-contents.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/TocExample.java) | | [Container bookmarks](#container-bookmarks) | `section.bookmark(new DocumentBookmarkOptions(title))` — make a section / container a PDF outline (bookmark-panel) target | [PDF](../assets/readme/examples/container-bookmark.pdf) · [Source](src/main/java/com/demcha/examples/features/navigation/ContainerBookmarkExample.java) | @@ -742,6 +743,24 @@ session.chrome().footer(DocumentHeaderFooter.builder() [📄 View PDF](../assets/readme/examples/page-numbering.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/chrome/PageNumberingExample.java) +### Viewer preferences + +`chrome().viewerPreferences(...)` controls how a reader presents the document when +it opens — the page mode (`USE_OUTLINES` opens the bookmark panel, pairing with +`bookmark(...)` on sections), the page layout (e.g. two-column), and window-chrome +flags (`displayDocTitle`, `hideToolbar`, `fitWindow`, …). Written to the PDF +catalog; readers honour the subset they support. PDF-only — other backends ignore it. + +```java +document.chrome().viewerPreferences(DocumentViewerPreferences.builder() + .pageMode(DocumentPageMode.USE_OUTLINES) // open with the bookmark panel + .displayDocTitle(true) + .build()); +``` + +[📄 View PDF](../assets/readme/examples/viewer-preferences.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/chrome/ViewerPreferencesExample.java) + ### Page references `addPageReference(anchor)` prints the page a declared `anchor(...)` lands on — a diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index b059c5ee1..6d00df999 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -7,6 +7,7 @@ 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.chrome.ViewerPreferencesExample; import com.demcha.examples.features.layout.BleedExample; import com.demcha.examples.features.layout.RowColumnsExample; import com.demcha.examples.features.layout.RowFlexExample; @@ -185,6 +186,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + CustomBusinessThemeExample.generate()); System.out.println("Generated: " + PdfChromeExample.generate()); System.out.println("Generated: " + PageNumberingExample.generate()); + System.out.println("Generated: " + ViewerPreferencesExample.generate()); // DOCX export System.out.println("Generated: " + WordExportExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/chrome/ViewerPreferencesExample.java b/examples/src/main/java/com/demcha/examples/features/chrome/ViewerPreferencesExample.java new file mode 100644 index 000000000..20188e2a8 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/chrome/ViewerPreferencesExample.java @@ -0,0 +1,103 @@ +package com.demcha.examples.features.chrome; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.document.output.DocumentViewerPreferences; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPageMode; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 {@code chrome().viewerPreferences(...)}: how a PDF + * reader presents the document when it opens. Here the page mode is + * {@code USE_OUTLINES} so the reader opens with the bookmark panel showing — it + * pairs with {@code bookmark(...)} on the sections — and {@code displayDocTitle} + * shows the document title in the window title bar. Viewer preferences are + * advisory; readers honour the ones they support. + * + *
{@code
+ * document.chrome().viewerPreferences(DocumentViewerPreferences.builder()
+ *     .pageMode(DocumentPageMode.USE_OUTLINES)  // open the bookmark panel
+ *     .displayDocTitle(true)
+ *     .build());
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class ViewerPreferencesExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135); + + private static final String[][] CHAPTERS = { + {"Habitat", "Where the species lives."}, + {"Diet", "What it eats through the seasons."}, + {"Behaviour", "How it moves, nests, and migrates."}, + }; + + private ViewerPreferencesExample() { + } + + /** + * Renders a short guide that opens with the bookmark panel showing. + * + * @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", "viewer-preferences.pdf"); + + DocumentTextStyle heading = DocumentTextStyle.DEFAULT.withSize(13).withColor(INK); + DocumentTextStyle body = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(360, 320) + .margin(DocumentInsets.of(34)) + .create()) { + document.metadata(DocumentMetadata.builder().title("Field Guide").build()); + document.chrome().viewerPreferences(DocumentViewerPreferences.builder() + .pageMode(DocumentPageMode.USE_OUTLINES) + .displayDocTitle(true) + .build()); + + document.pageFlow(page -> { + page.addParagraph(p -> p.text("Field Guide") + .textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK))); + page.addParagraph(p -> p.text("opens with the bookmark panel — viewerPreferences(USE_OUTLINES)") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED)) + .padding(DocumentInsets.bottom(10))); + + for (String[] chapter : CHAPTERS) { + chapterSection(page, heading, body, chapter[0], chapter[1]); + } + }); + + document.buildPdf(); + } + + return pdfFile; + } + + private static void chapterSection(PageFlowBuilder page, + DocumentTextStyle heading, + DocumentTextStyle body, + String title, + String summary) { + page.addSection(s -> s.bookmark(new DocumentBookmarkOptions(title)) + .spacing(2) + .addParagraph(p -> p.text(title).textStyle(heading)) + .addParagraph(p -> p.text(summary).textStyle(body))); + page.addSpacer(s -> s.height(8)); + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java b/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java index af483d660..412d638eb 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java @@ -36,6 +36,7 @@ final class DocumentChromeOptions { private DocumentMetadata metadata; private DocumentWatermark watermark; private DocumentProtection protection; + private DocumentViewerPreferences viewerPreferences; DocumentChromeOptions() { } @@ -44,6 +45,10 @@ void setMetadata(DocumentMetadata metadata) { this.metadata = metadata; } + void setViewerPreferences(DocumentViewerPreferences viewerPreferences) { + this.viewerPreferences = viewerPreferences; + } + void setWatermark(DocumentWatermark watermark) { this.watermark = watermark; } @@ -76,6 +81,7 @@ boolean isEmpty() { return metadata == null && watermark == null && protection == null + && viewerPreferences == null && headersAndFooters.isEmpty(); } @@ -89,7 +95,8 @@ DocumentOutputOptions snapshot() { if (isEmpty()) { return DocumentOutputOptions.EMPTY; } - return new DocumentOutputOptions(metadata, watermark, protection, List.copyOf(headersAndFooters)); + return new DocumentOutputOptions(metadata, watermark, protection, viewerPreferences, + List.copyOf(headersAndFooters)); } /** @@ -110,7 +117,8 @@ PdfFixedLayoutBackend toConveniencePdfBackend(DocumentDebugOptions debug) { .debug(debug) .metadata(PdfOutputOptionsTranslator.toPdf(metadata)) .watermark(PdfOutputOptionsTranslator.toPdf(watermark)) - .protect(PdfOutputOptionsTranslator.toPdf(protection)); + .protect(PdfOutputOptionsTranslator.toPdf(protection)) + .viewerPreferences(PdfOutputOptionsTranslator.toPdf(viewerPreferences)); for (DocumentHeaderFooter entry : headersAndFooters) { PdfHeaderFooterOptions translated = PdfOutputOptionsTranslator.toPdf(entry); if (entry.getZone() == DocumentHeaderFooterZone.FOOTER) { diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java index 0103acc86..2df6151ac 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -492,6 +492,22 @@ public DocumentSession metadata(DocumentMetadata metadata) { return this; } + /** + * Sets the PDF viewer preferences — how a reader presents the document when it + * opens (page mode, page layout, window flags). PDF-only; other backends + * ignore it. + * + * @param viewerPreferences viewer preferences, or {@code null} to clear + * @return this session + * @throws IllegalStateException if this session has already been closed + * @since 1.9.0 + */ + public DocumentSession viewerPreferences(DocumentViewerPreferences viewerPreferences) { + ensureOpen(); + chromeOptions.setViewerPreferences(viewerPreferences); + return this; + } + /** * @param options legacy PDF metadata options, or {@code null} to clear * @return this session diff --git a/src/main/java/com/demcha/compose/document/api/SessionChromeApi.java b/src/main/java/com/demcha/compose/document/api/SessionChromeApi.java index be0e7058a..f2b873c95 100644 --- a/src/main/java/com/demcha/compose/document/api/SessionChromeApi.java +++ b/src/main/java/com/demcha/compose/document/api/SessionChromeApi.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.output.DocumentHeaderFooter; import com.demcha.compose.document.output.DocumentMetadata; import com.demcha.compose.document.output.DocumentProtection; +import com.demcha.compose.document.output.DocumentViewerPreferences; import com.demcha.compose.document.output.DocumentWatermark; import java.util.Objects; @@ -67,6 +68,22 @@ public SessionChromeApi metadata(DocumentMetadata metadata) { return this; } + /** + * Sets the PDF viewer preferences — how a reader presents the document when it + * opens (page mode, page layout, window flags). PDF-only; other backends + * ignore it. + * + * @param viewerPreferences viewer preferences, or {@code null} to clear + * @return this facade for chaining + * @throws IllegalStateException if the owning session has already been closed + * @since 1.9.0 + */ + public SessionChromeApi viewerPreferences(DocumentViewerPreferences viewerPreferences) { + ensureOpen(); + chromeOptions.setViewerPreferences(viewerPreferences); + return this; + } + /** * Configures a backend-neutral document-wide watermark. Pass * {@code null} to clear. diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java index f1705dc50..1b5cbe744 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDocumentPostProcessor.java @@ -3,16 +3,24 @@ import com.demcha.compose.document.backend.fixed.pdf.options.PdfHeaderFooterOptions; import com.demcha.compose.document.backend.fixed.pdf.options.PdfMetadataOptions; import com.demcha.compose.document.backend.fixed.pdf.options.PdfProtectionOptions; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfViewerPreferencesOptions; import com.demcha.compose.document.backend.fixed.pdf.options.PdfWatermarkOptions; import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.style.DocumentPageLayout; +import com.demcha.compose.document.style.DocumentPageMode; import com.demcha.compose.engine.components.style.Margin; import com.demcha.compose.engine.render.pdf.helpers.PdfHeaderFooterRenderer; import com.demcha.compose.engine.render.pdf.helpers.PdfWatermarkRenderer; import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PageLayout; +import org.apache.pdfbox.pdmodel.PageMode; import org.apache.pdfbox.pdmodel.encryption.AccessPermission; import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -132,6 +140,78 @@ public static void applyDocumentMetadataAndProtection(PDDocument document, } } + /** + * Writes document-global viewer preferences to the PDF catalog: page mode, + * page layout, and the window-chrome flags. Each unset field leaves the + * reader's default in place. A no-op when {@code options} is {@code null}. + * + * @param document target PDFBox document + * @param options viewer-preference options, or {@code null} + * @since 1.9.0 + */ + public static void applyViewerPreferences(PDDocument document, PdfViewerPreferencesOptions options) { + if (options == null) { + return; + } + PDDocumentCatalog catalog = document.getDocumentCatalog(); + if (options.getPageMode() != null) { + catalog.setPageMode(toPageMode(options.getPageMode())); + } + if (options.getPageLayout() != null) { + catalog.setPageLayout(toPageLayout(options.getPageLayout())); + } + + PDViewerPreferences prefs = catalog.getViewerPreferences(); + if (prefs == null) { + prefs = new PDViewerPreferences(new COSDictionary()); + } + boolean anyFlag = false; + if (options.getDisplayDocTitle() != null) { + prefs.setDisplayDocTitle(options.getDisplayDocTitle()); + anyFlag = true; + } + if (options.getHideToolbar() != null) { + prefs.setHideToolbar(options.getHideToolbar()); + anyFlag = true; + } + if (options.getHideMenubar() != null) { + prefs.setHideMenubar(options.getHideMenubar()); + anyFlag = true; + } + if (options.getFitWindow() != null) { + prefs.setFitWindow(options.getFitWindow()); + anyFlag = true; + } + if (options.getCenterWindow() != null) { + prefs.setCenterWindow(options.getCenterWindow()); + anyFlag = true; + } + if (anyFlag) { + catalog.setViewerPreferences(prefs); + } + } + + private static PageMode toPageMode(DocumentPageMode mode) { + return switch (mode) { + case USE_NONE -> PageMode.USE_NONE; + case USE_OUTLINES -> PageMode.USE_OUTLINES; + case USE_THUMBNAILS -> PageMode.USE_THUMBS; + case FULL_SCREEN -> PageMode.FULL_SCREEN; + case USE_ATTACHMENTS -> PageMode.USE_ATTACHMENTS; + }; + } + + private static PageLayout toPageLayout(DocumentPageLayout layout) { + return switch (layout) { + case SINGLE_PAGE -> PageLayout.SINGLE_PAGE; + case ONE_COLUMN -> PageLayout.ONE_COLUMN; + case TWO_COLUMN_LEFT -> PageLayout.TWO_COLUMN_LEFT; + case TWO_COLUMN_RIGHT -> PageLayout.TWO_COLUMN_RIGHT; + case TWO_PAGE_LEFT -> PageLayout.TWO_PAGE_LEFT; + case TWO_PAGE_RIGHT -> PageLayout.TWO_PAGE_RIGHT; + }; + } + private static void applyMetadata(PDDocument document, PdfMetadataOptions metadataOptions) { PDDocumentInformation info = document.getDocumentInformation(); if (metadataOptions.getTitle() != null) { diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index f0f3ad04d..26a36191a 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -56,17 +56,18 @@ public final class PdfFixedLayoutBackend implements FixedLayoutBackend { private final PdfMetadataOptions metadataOptions; private final PdfWatermarkOptions watermarkOptions; private final PdfProtectionOptions protectionOptions; + private final PdfViewerPreferencesOptions viewerPreferencesOptions; private final List headerFooterOptions; /** * Creates a backend with the built-in paragraph, shape, image, and table handlers. */ public PdfFixedLayoutBackend() { - this(defaultHandlers(), DocumentDebugOptions.none(), null, null, null, List.of()); + this(defaultHandlers(), DocumentDebugOptions.none(), null, null, null, null, List.of()); } PdfFixedLayoutBackend(Collection> handlers) { - this(handlers, DocumentDebugOptions.none(), null, null, null, List.of()); + this(handlers, DocumentDebugOptions.none(), null, null, null, null, List.of()); } private PdfFixedLayoutBackend(Collection> handlers, @@ -74,6 +75,7 @@ private PdfFixedLayoutBackend(Collection> PdfMetadataOptions metadataOptions, PdfWatermarkOptions watermarkOptions, PdfProtectionOptions protectionOptions, + PdfViewerPreferencesOptions viewerPreferencesOptions, Collection headerFooterOptions) { Map, PdfFragmentRenderHandler> registry = new LinkedHashMap<>(); for (PdfFragmentRenderHandler handler : handlers) { @@ -87,6 +89,7 @@ private PdfFixedLayoutBackend(Collection> this.metadataOptions = metadataOptions; this.watermarkOptions = watermarkOptions; this.protectionOptions = protectionOptions; + this.viewerPreferencesOptions = viewerPreferencesOptions; this.headerFooterOptions = List.copyOf(headerFooterOptions); } @@ -373,6 +376,7 @@ private PDDocument buildDocument(LayoutGraph graph, FixedLayoutRenderContext con watermarkOptions, protectionOptions, headerFooterOptions); + PdfDocumentPostProcessor.applyViewerPreferences(document, viewerPreferencesOptions); return document; } catch (Exception ex) { @@ -502,10 +506,11 @@ private PDDocument buildSectionsDocument(List
sections) throws Exceptio private static void applyDocumentMetadataAndProtection(PDDocument document, List
sections) throws IOException { - // Metadata and protection are document-global in PDF; the first section - // that declares each wins for the combined document. + // Metadata, protection, and viewer preferences are document-global in PDF; + // the first section that declares each wins for the combined document. PdfMetadataOptions metadata = null; PdfProtectionOptions protection = null; + PdfViewerPreferencesOptions viewerPreferences = null; for (Section section : sections) { if (metadata == null) { metadata = section.chrome().metadataOptions; @@ -513,8 +518,12 @@ private static void applyDocumentMetadataAndProtection(PDDocument document, List if (protection == null) { protection = section.chrome().protectionOptions; } + if (viewerPreferences == null) { + viewerPreferences = section.chrome().viewerPreferencesOptions; + } } PdfDocumentPostProcessor.applyDocumentMetadataAndProtection(document, metadata, protection); + PdfDocumentPostProcessor.applyViewerPreferences(document, viewerPreferences); } private static List unionCustomFonts(List
sections) { @@ -745,6 +754,7 @@ public static final class Builder { private PdfMetadataOptions metadataOptions; private PdfWatermarkOptions watermarkOptions; private PdfProtectionOptions protectionOptions; + private PdfViewerPreferencesOptions viewerPreferencesOptions; private Builder() { } @@ -814,6 +824,17 @@ public Builder debug(DocumentDebugOptions options) { return this; } + /** + * Configures PDF viewer preferences (page mode / layout / window flags). + * + * @param options viewer-preference options, or {@code null} to clear + * @return this builder + */ + public Builder viewerPreferences(PdfViewerPreferencesOptions options) { + this.viewerPreferencesOptions = options; + return this; + } + /** * Configures PDF metadata. * @@ -889,6 +910,7 @@ public PdfFixedLayoutBackend build() { metadataOptions, watermarkOptions, protectionOptions, + viewerPreferencesOptions, headerFooterOptions); } } 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 a0e05fec3..5987ef1b0 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 @@ -38,6 +38,27 @@ public static PdfMetadataOptions toPdf(DocumentMetadata metadata) { .build(); } + /** + * Translates canonical viewer preferences into the PDF backend value. + * + * @param viewerPreferences canonical viewer preferences, may be {@code null} + * @return PDF viewer-preference options or {@code null} when input is {@code null} + */ + public static PdfViewerPreferencesOptions toPdf(DocumentViewerPreferences viewerPreferences) { + if (viewerPreferences == null) { + return null; + } + return PdfViewerPreferencesOptions.builder() + .pageMode(viewerPreferences.getPageMode()) + .pageLayout(viewerPreferences.getPageLayout()) + .displayDocTitle(viewerPreferences.getDisplayDocTitle()) + .hideToolbar(viewerPreferences.getHideToolbar()) + .hideMenubar(viewerPreferences.getHideMenubar()) + .fitWindow(viewerPreferences.getFitWindow()) + .centerWindow(viewerPreferences.getCenterWindow()) + .build(); + } + /** * Translates a canonical watermark into the PDF backend value. * diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfViewerPreferencesOptions.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfViewerPreferencesOptions.java new file mode 100644 index 000000000..471ebd03f --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/options/PdfViewerPreferencesOptions.java @@ -0,0 +1,30 @@ +package com.demcha.compose.document.backend.fixed.pdf.options; + +import com.demcha.compose.document.style.DocumentPageLayout; +import com.demcha.compose.document.style.DocumentPageMode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * PDF-flavoured viewer preferences applied to the document catalog (page mode, + * page layout, and window-chrome flags). The PDF-vocabulary mapping to PDFBox is + * done where these are applied; every field is optional ({@code null} leaves the + * reader default). + * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +@Getter +@Builder(toBuilder = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public final class PdfViewerPreferencesOptions { + private final DocumentPageMode pageMode; + private final DocumentPageLayout pageLayout; + private final Boolean displayDocTitle; + private final Boolean hideToolbar; + private final Boolean hideMenubar; + private final Boolean fitWindow; + private final Boolean centerWindow; +} diff --git a/src/main/java/com/demcha/compose/document/output/DocumentOutputOptions.java b/src/main/java/com/demcha/compose/document/output/DocumentOutputOptions.java index 36143b295..3a23f87bd 100644 --- a/src/main/java/com/demcha/compose/document/output/DocumentOutputOptions.java +++ b/src/main/java/com/demcha/compose/document/output/DocumentOutputOptions.java @@ -15,6 +15,7 @@ * @param metadata document information (title, author, ...) * @param watermark optional document-wide watermark * @param protection optional access protection + * @param viewerPreferences optional PDF viewer preferences (page mode / layout / window flags) * @param headersAndFooters repeating header/footer entries * @author Artem Demchyshyn */ @@ -22,12 +23,13 @@ public record DocumentOutputOptions( DocumentMetadata metadata, DocumentWatermark watermark, DocumentProtection protection, + DocumentViewerPreferences viewerPreferences, List headersAndFooters ) { /** * All-empty defaults. */ - public static final DocumentOutputOptions EMPTY = new DocumentOutputOptions(null, null, null, List.of()); + public static final DocumentOutputOptions EMPTY = new DocumentOutputOptions(null, null, null, null, List.of()); /** * Normalizes the headers-and-footers collection to an immutable snapshot. @@ -39,12 +41,28 @@ public record DocumentOutputOptions( } } + /** + * Backwards-compatible constructor without viewer preferences (defaults to none). + * + * @param metadata document information + * @param watermark optional document-wide watermark + * @param protection optional access protection + * @param headersAndFooters repeating header/footer entries + */ + public DocumentOutputOptions(DocumentMetadata metadata, + DocumentWatermark watermark, + DocumentProtection protection, + List headersAndFooters) { + this(metadata, watermark, protection, null, headersAndFooters); + } + /** * Returns {@code true} when at least one option is configured. * * @return {@code true} for non-empty option bundles */ public boolean hasAny() { - return metadata != null || watermark != null || protection != null || !headersAndFooters.isEmpty(); + return metadata != null || watermark != null || protection != null + || viewerPreferences != null || !headersAndFooters.isEmpty(); } } diff --git a/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java b/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java new file mode 100644 index 000000000..c457c595b --- /dev/null +++ b/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java @@ -0,0 +1,58 @@ +package com.demcha.compose.document.output; + +import com.demcha.compose.document.style.DocumentPageLayout; +import com.demcha.compose.document.style.DocumentPageMode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * Backend-neutral PDF viewer preferences — how a reader should present the + * document when it opens (page mode, page layout, and window-chrome flags), + * written to the PDF document catalog. + * + *

Every field is optional: a {@code null} leaves the reader's own default in + * place. Non-PDF backends ignore viewer preferences (a documented part of the + * {@code DocumentOutputOptions} contract). Set via + * {@code chrome().viewerPreferences(...)}.

+ * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +@Getter +@Builder(toBuilder = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public final class DocumentViewerPreferences { + + /** Page mode (e.g. open the bookmark panel), or {@code null} for the reader default. */ + private final DocumentPageMode pageMode; + + /** Page layout (e.g. two-column), or {@code null} for the reader default. */ + private final DocumentPageLayout pageLayout; + + /** Show the document title in the window title bar instead of the file name. */ + private final Boolean displayDocTitle; + + /** Hide the reader's toolbar. */ + private final Boolean hideToolbar; + + /** Hide the reader's menu bar. */ + private final Boolean hideMenubar; + + /** Resize the window to fit the first page. */ + private final Boolean fitWindow; + + /** Centre the window on screen. */ + private final Boolean centerWindow; + + /** + * Preset that opens the reader with the bookmark (outline) panel visible — + * pairs with {@code bookmark(...)} on sections. + * + * @return viewer preferences with {@link DocumentPageMode#USE_OUTLINES} + */ + public static DocumentViewerPreferences openOutline() { + return builder().pageMode(DocumentPageMode.USE_OUTLINES).build(); + } +} diff --git a/src/main/java/com/demcha/compose/document/style/DocumentPageLayout.java b/src/main/java/com/demcha/compose/document/style/DocumentPageLayout.java new file mode 100644 index 000000000..ecc790034 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentPageLayout.java @@ -0,0 +1,23 @@ +package com.demcha.compose.document.style; + +/** + * How a PDF reader should lay out pages — the page layout written to the document + * catalog. + * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public enum DocumentPageLayout { + /** One page at a time (the default). */ + SINGLE_PAGE, + /** A single continuously scrolling column. */ + ONE_COLUMN, + /** Two continuous columns, odd-numbered pages on the left. */ + TWO_COLUMN_LEFT, + /** Two continuous columns, odd-numbered pages on the right. */ + TWO_COLUMN_RIGHT, + /** Two pages side by side, odd-numbered pages on the left. */ + TWO_PAGE_LEFT, + /** Two pages side by side, odd-numbered pages on the right. */ + TWO_PAGE_RIGHT +} diff --git a/src/main/java/com/demcha/compose/document/style/DocumentPageMode.java b/src/main/java/com/demcha/compose/document/style/DocumentPageMode.java new file mode 100644 index 000000000..34c77ccc7 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentPageMode.java @@ -0,0 +1,21 @@ +package com.demcha.compose.document.style; + +/** + * How a PDF reader should present the document when it first opens — the page + * mode written to the document catalog. + * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public enum DocumentPageMode { + /** Neither the outline nor thumbnails panel is shown (the default). */ + USE_NONE, + /** Open with the bookmark (outline) panel visible. */ + USE_OUTLINES, + /** Open with the page-thumbnail panel visible. */ + USE_THUMBNAILS, + /** Open in full-screen mode, with no menu bar, window controls, or panels. */ + FULL_SCREEN, + /** Open with the attachments panel visible. */ + USE_ATTACHMENTS +} diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/ViewerPreferencesTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/ViewerPreferencesTest.java new file mode 100644 index 000000000..70b1ab1de --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/ViewerPreferencesTest.java @@ -0,0 +1,144 @@ +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.api.MultiSectionDocument; +import com.demcha.compose.document.output.DocumentViewerPreferences; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPageLayout; +import com.demcha.compose.document.style.DocumentPageMode; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PageLayout; +import org.apache.pdfbox.pdmodel.PageMode; +import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences; +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; + +/** + * {@code chrome().viewerPreferences(...)} writes page mode, page layout, and the + * window-chrome flags to the PDF document catalog. Asserted against the catalog + * dictionary (PDFBox), not pixels — viewer preferences are advisory and many + * readers honour only a subset. + */ +class ViewerPreferencesTest { + + @TempDir + Path tempDir; + + private PDDocument render(String name, Consumer chrome) throws Exception { + Path out = tempDir.resolve(name + ".pdf"); + try (DocumentSession session = GraphCompose.document(out) + .pageSize(240, 200).margin(DocumentInsets.of(24)).create()) { + chrome.accept(session); + session.pageFlow(page -> page.addParagraph("content")); + session.buildPdf(); + } + return Loader.loadPDF(out.toFile()); + } + + @Test + void useOutlinesPageModeIsWrittenToTheCatalog() throws Exception { + try (PDDocument doc = render("outline", + s -> s.chrome().viewerPreferences(DocumentViewerPreferences.openOutline()))) { + assertThat(doc.getDocumentCatalog().getPageMode()).isEqualTo(PageMode.USE_OUTLINES); + } + } + + @Test + void pageLayoutAndWindowFlagsAreWrittenToTheCatalog() throws Exception { + try (PDDocument doc = render("layout", s -> s.chrome().viewerPreferences( + DocumentViewerPreferences.builder() + .pageLayout(DocumentPageLayout.TWO_COLUMN_LEFT) + .displayDocTitle(true) + .hideToolbar(true) + .fitWindow(true) + .build()))) { + PDDocumentCatalog catalog = doc.getDocumentCatalog(); + assertThat(catalog.getPageLayout()).isEqualTo(PageLayout.TWO_COLUMN_LEFT); + PDViewerPreferences prefs = catalog.getViewerPreferences(); + assertThat(prefs).isNotNull(); + assertThat(prefs.displayDocTitle()).isTrue(); + assertThat(prefs.hideToolbar()).isTrue(); + assertThat(prefs.fitWindow()).isTrue(); + // unset flags stay at the reader default (false) + assertThat(prefs.centerWindow()).isFalse(); + } + } + + @Test + void noViewerPreferencesLeavesTheCatalogUntouched() throws Exception { + try (PDDocument doc = render("none", s -> { })) { + // A document that sets no viewer preferences gets no /ViewerPreferences + // dictionary and the default page mode — unchanged from before the feature. + assertThat(doc.getDocumentCatalog().getViewerPreferences()).isNull(); + assertThat(doc.getDocumentCatalog().getPageMode()).isEqualTo(PageMode.USE_NONE); + } + } + + @Test + void hideMenubarAndCenterWindowFlagsRoundTrip() throws Exception { + try (PDDocument doc = render("morewindowflags", s -> s.chrome().viewerPreferences( + DocumentViewerPreferences.builder() + .hideMenubar(true) + .centerWindow(true) + .build()))) { + PDViewerPreferences prefs = doc.getDocumentCatalog().getViewerPreferences(); + assertThat(prefs).isNotNull(); + assertThat(prefs.hideMenubar()).isTrue(); + assertThat(prefs.centerWindow()).isTrue(); + } + } + + @Test + void anExplicitlyFalseFlagStillWritesTheDictionary() throws Exception { + // A flag set to false is an explicit choice, not "unset", so the dictionary + // is written (the value would read false either way; the dict's presence is + // what proves the flag was honoured rather than skipped). + try (PDDocument doc = render("falseflag", s -> s.chrome().viewerPreferences( + DocumentViewerPreferences.builder().hideToolbar(false).build()))) { + PDViewerPreferences prefs = doc.getDocumentCatalog().getViewerPreferences(); + assertThat(prefs).isNotNull(); + assertThat(prefs.hideToolbar()).isFalse(); + } + } + + @Test + void multiSectionTakesViewerPreferencesFromTheFirstSectionThatSetsThem() throws Exception { + Path out = tempDir.resolve("multi.pdf"); + DocumentSession cover = GraphCompose.document().pageSize(240, 200).margin(DocumentInsets.of(24)).create(); + cover.chrome().viewerPreferences(DocumentViewerPreferences.openOutline()); + cover.pageFlow(page -> page.addParagraph("cover")); + + DocumentSession body = GraphCompose.document().pageSize(240, 200).margin(DocumentInsets.of(24)).create(); + // A different page mode on a later section must lose to the first. + body.chrome().viewerPreferences(DocumentViewerPreferences.builder() + .pageMode(DocumentPageMode.USE_THUMBNAILS).build()); + body.pageFlow(page -> page.addParagraph("body")); + + try (MultiSectionDocument doc = GraphCompose.documents(out).section(cover).section(body).create()) { + doc.buildPdf(); + } + + try (PDDocument pdf = Loader.loadPDF(out.toFile())) { + assertThat(pdf.getDocumentCatalog().getPageMode()).isEqualTo(PageMode.USE_OUTLINES); + } + } + + @Test + void pageModeWithoutWindowFlagsWritesNoViewerPreferencesDictionary() throws Exception { + try (PDDocument doc = render("modeonly", + s -> s.chrome().viewerPreferences(DocumentViewerPreferences.openOutline()))) { + // Page mode lives directly on the catalog; with no window flags there is + // no /ViewerPreferences dict to write. + assertThat(doc.getDocumentCatalog().getPageMode()).isEqualTo(PageMode.USE_OUTLINES); + assertThat(doc.getDocumentCatalog().getViewerPreferences()).isNull(); + } + } +} From c2f1d1c364e9e050a471fc60d574dec3e6efc435 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 10:52:20 +0100 Subject: [PATCH 2/2] docs(api): note openOutline() pairs with container bookmarks too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bookmark() that USE_OUTLINES opens the panel for now exists on container flow builders (sections and containers), not only sections — widen the openOutline() Javadoc accordingly. --- .../compose/document/output/DocumentViewerPreferences.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java b/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java index c457c595b..7b0befba9 100644 --- a/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java +++ b/src/main/java/com/demcha/compose/document/output/DocumentViewerPreferences.java @@ -48,7 +48,7 @@ public final class DocumentViewerPreferences { /** * Preset that opens the reader with the bookmark (outline) panel visible — - * pairs with {@code bookmark(...)} on sections. + * pairs with {@code bookmark(...)} on sections and containers. * * @return viewer preferences with {@link DocumentPageMode#USE_OUTLINES} */