Skip to content

Commit b02c82f

Browse files
authored
Merge pull request #159 from DemchaAV/fix/watermark-alpha-leak
fix(pdf): BEHIND_CONTENT watermark opacity no longer bleeds into page content
2 parents 5d17ab3 + c52e58e commit b02c82f

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)