From c52e58e0ca699df2fdc916a07a1bec14d988f762 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 11 Jun 2026 09:47:40 +0100 Subject: [PATCH] fix(pdf): BEHIND_CONTENT watermark opacity no longer bleeds into page content The watermark renderer set its low-alpha graphics state inside a prepended content stream without a save/restore pair. PDFBox's resetContext flag only isolates APPEND streams, so the alpha constant leaked into the original page stream and washed out every element on the page. Wrap the watermark drawing in q/Q. Regression test renders a solid shape under a BEHIND_CONTENT watermark and asserts the fill samples at full strength while the watermark keeps its own low-alpha ExtGState. --- CHANGELOG.md | 7 ++ .../pdf/helpers/PdfWatermarkRenderer.java | 8 ++ .../pdf/PdfWatermarkStateIsolationTest.java | 90 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfWatermarkStateIsolationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a899ba7a..ee432490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,13 @@ Entries land here as they merge. ### Bug fixes +- **`BEHIND_CONTENT` watermarks no longer wash out the page.** The PDF + watermark renderer set its low-opacity graphics state in a *prepended* + content stream without a save/restore pair; PDFBox's `resetContext` only + isolates appended streams, so the watermark alpha leaked into the entire + page and every element rendered nearly invisible. The watermark now wraps + its drawing in `q`/`Q`, keeping page content at full strength. This + affected every document using the default `DocumentWatermark` layer. - **DOCX export no longer drops lists.** `DocxSemanticBackend` had no branch for `ListNode`, so `addList(...)` content silently vanished from Word exports. Lists now map to marker-prefixed paragraphs in the list's text diff --git a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java index a3143456..5a288e9c 100644 --- a/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java +++ b/src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java @@ -54,6 +54,12 @@ public static void apply(PDDocument doc, WatermarkConfig config) throws IOExcept }; try (PDPageContentStream cs = new PDPageContentStream(doc, page, mode, true, true)) { + // PDFBox's resetContext only isolates APPEND streams; a + // PREPEND stream shares its graphics state with the page + // content that follows, so without this q/Q pair the + // watermark opacity bleeds into the entire page. + cs.saveGraphicsState(); + // Set opacity PDExtendedGraphicsState gState = new PDExtendedGraphicsState(); gState.setNonStrokingAlphaConstant(config.getOpacity()); @@ -65,6 +71,8 @@ public static void apply(PDDocument doc, WatermarkConfig config) throws IOExcept } else if (config.isImageBased()) { renderImageWatermark(cs, doc, config, mediaBox); } + + cs.restoreGraphicsState(); } } } diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfWatermarkStateIsolationTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfWatermarkStateIsolationTest.java new file mode 100644 index 00000000..110ead57 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfWatermarkStateIsolationTest.java @@ -0,0 +1,90 @@ +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.DocumentWatermark; +import com.demcha.compose.document.output.DocumentWatermarkLayer; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A {@code BEHIND_CONTENT} watermark renders through a PREPEND content + * stream, and PDFBox's {@code resetContext} flag only isolates APPEND + * streams — so the watermark must save/restore the graphics state itself. + * Without that {@code q}/{@code Q} pair the watermark's low alpha constant + * bled into the original page stream and washed out the entire page. + */ +class PdfWatermarkStateIsolationTest { + + private static final DocumentColor NAVY = DocumentColor.rgb(20, 40, 90); + + @TempDir + Path tempDir; + + @Test + void behindContentWatermarkOpacityDoesNotBleedIntoPageContent() throws Exception { + Path out = tempDir.resolve("watermark-isolation.pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(200, 150) + .margin(DocumentInsets.of(20)) + .create()) { + document.watermark(DocumentWatermark.builder() + .text("WM") + .opacity(0.05f) + .layer(DocumentWatermarkLayer.BEHIND_CONTENT) + .build()); + document.pageFlow().name("Flow") + .addShape(100, 50, NAVY) + .build(); + document.buildPdf(); + } + + try (PDDocument doc = Loader.loadPDF(out.toFile())) { + BufferedImage image = new PDFRenderer(doc).renderImageWithDPI(0, 96); + // Centre of the 100x50 shape placed at the top-left margin. + float scale = 96f / 72f; + int x = Math.round((20 + 50) * scale); + int y = Math.round((20 + 25) * scale); + Color sampled = new Color(image.getRGB(x, y)); + + // With the alpha leak the navy fill blends 5% over white and + // samples near (243, 244, 247); the fix keeps it solid navy. + assertThat(sampled.getRed()).as("red at shape centre").isCloseTo(20, within(30)); + assertThat(sampled.getGreen()).as("green at shape centre").isCloseTo(40, within(30)); + assertThat(sampled.getBlue()).as("blue at shape centre").isCloseTo(90, within(30)); + + // The watermark itself must still carry its low-alpha state. + List states = extGStates(doc); + assertThat(states) + .as("watermark extended graphics state") + .anySatisfy(state -> assertThat(state.getNonStrokingAlphaConstant()) + .isCloseTo(0.05f, within(0.005f))); + } + } + + private static List extGStates(PDDocument doc) throws Exception { + PDResources resources = doc.getPage(0).getResources(); + List states = new ArrayList<>(); + for (COSName name : resources.getExtGStateNames()) { + states.add(resources.getExtGState(name)); + } + return states; + } +}