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; + } +}