Skip to content

Commit c52e58e

Browse files
committed
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.
1 parent 5d17ab3 commit c52e58e

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ Entries land here as they merge.
9898

9999
### Bug fixes
100100

101+
- **`BEHIND_CONTENT` watermarks no longer wash out the page.** The PDF
102+
watermark renderer set its low-opacity graphics state in a *prepended*
103+
content stream without a save/restore pair; PDFBox's `resetContext` only
104+
isolates appended streams, so the watermark alpha leaked into the entire
105+
page and every element rendered nearly invisible. The watermark now wraps
106+
its drawing in `q`/`Q`, keeping page content at full strength. This
107+
affected every document using the default `DocumentWatermark` layer.
101108
- **DOCX export no longer drops lists.** `DocxSemanticBackend` had no branch
102109
for `ListNode`, so `addList(...)` content silently vanished from Word
103110
exports. Lists now map to marker-prefixed paragraphs in the list's text

src/main/java/com/demcha/compose/engine/render/pdf/helpers/PdfWatermarkRenderer.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ public static void apply(PDDocument doc, WatermarkConfig config) throws IOExcept
5454
};
5555

5656
try (PDPageContentStream cs = new PDPageContentStream(doc, page, mode, true, true)) {
57+
// PDFBox's resetContext only isolates APPEND streams; a
58+
// PREPEND stream shares its graphics state with the page
59+
// content that follows, so without this q/Q pair the
60+
// watermark opacity bleeds into the entire page.
61+
cs.saveGraphicsState();
62+
5763
// Set opacity
5864
PDExtendedGraphicsState gState = new PDExtendedGraphicsState();
5965
gState.setNonStrokingAlphaConstant(config.getOpacity());
@@ -65,6 +71,8 @@ public static void apply(PDDocument doc, WatermarkConfig config) throws IOExcept
6571
} else if (config.isImageBased()) {
6672
renderImageWatermark(cs, doc, config, mediaBox);
6773
}
74+
75+
cs.restoreGraphicsState();
6876
}
6977
}
7078
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package com.demcha.compose.document.backend.fixed.pdf;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.output.DocumentWatermark;
6+
import com.demcha.compose.document.output.DocumentWatermarkLayer;
7+
import com.demcha.compose.document.style.DocumentColor;
8+
import com.demcha.compose.document.style.DocumentInsets;
9+
import org.apache.pdfbox.Loader;
10+
import org.apache.pdfbox.cos.COSName;
11+
import org.apache.pdfbox.pdmodel.PDDocument;
12+
import org.apache.pdfbox.pdmodel.PDResources;
13+
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
14+
import org.apache.pdfbox.rendering.PDFRenderer;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.io.TempDir;
17+
18+
import java.awt.Color;
19+
import java.awt.image.BufferedImage;
20+
import java.nio.file.Path;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
24+
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.assertj.core.api.Assertions.within;
26+
27+
/**
28+
* A {@code BEHIND_CONTENT} watermark renders through a PREPEND content
29+
* stream, and PDFBox's {@code resetContext} flag only isolates APPEND
30+
* streams — so the watermark must save/restore the graphics state itself.
31+
* Without that {@code q}/{@code Q} pair the watermark's low alpha constant
32+
* bled into the original page stream and washed out the entire page.
33+
*/
34+
class PdfWatermarkStateIsolationTest {
35+
36+
private static final DocumentColor NAVY = DocumentColor.rgb(20, 40, 90);
37+
38+
@TempDir
39+
Path tempDir;
40+
41+
@Test
42+
void behindContentWatermarkOpacityDoesNotBleedIntoPageContent() throws Exception {
43+
Path out = tempDir.resolve("watermark-isolation.pdf");
44+
try (DocumentSession document = GraphCompose.document(out)
45+
.pageSize(200, 150)
46+
.margin(DocumentInsets.of(20))
47+
.create()) {
48+
document.watermark(DocumentWatermark.builder()
49+
.text("WM")
50+
.opacity(0.05f)
51+
.layer(DocumentWatermarkLayer.BEHIND_CONTENT)
52+
.build());
53+
document.pageFlow().name("Flow")
54+
.addShape(100, 50, NAVY)
55+
.build();
56+
document.buildPdf();
57+
}
58+
59+
try (PDDocument doc = Loader.loadPDF(out.toFile())) {
60+
BufferedImage image = new PDFRenderer(doc).renderImageWithDPI(0, 96);
61+
// Centre of the 100x50 shape placed at the top-left margin.
62+
float scale = 96f / 72f;
63+
int x = Math.round((20 + 50) * scale);
64+
int y = Math.round((20 + 25) * scale);
65+
Color sampled = new Color(image.getRGB(x, y));
66+
67+
// With the alpha leak the navy fill blends 5% over white and
68+
// samples near (243, 244, 247); the fix keeps it solid navy.
69+
assertThat(sampled.getRed()).as("red at shape centre").isCloseTo(20, within(30));
70+
assertThat(sampled.getGreen()).as("green at shape centre").isCloseTo(40, within(30));
71+
assertThat(sampled.getBlue()).as("blue at shape centre").isCloseTo(90, within(30));
72+
73+
// The watermark itself must still carry its low-alpha state.
74+
List<PDExtendedGraphicsState> states = extGStates(doc);
75+
assertThat(states)
76+
.as("watermark extended graphics state")
77+
.anySatisfy(state -> assertThat(state.getNonStrokingAlphaConstant())
78+
.isCloseTo(0.05f, within(0.005f)));
79+
}
80+
}
81+
82+
private static List<PDExtendedGraphicsState> extGStates(PDDocument doc) throws Exception {
83+
PDResources resources = doc.getPage(0).getResources();
84+
List<PDExtendedGraphicsState> states = new ArrayList<>();
85+
for (COSName name : resources.getExtGStateNames()) {
86+
states.add(resources.getExtGState(name));
87+
}
88+
return states;
89+
}
90+
}

0 commit comments

Comments
 (0)