Skip to content

Commit d6bcf58

Browse files
authored
Load watermark font once per document (#7820)
## Description of Changes `addTextWatermark` is called once per page, and every call ran `PDType0Font.load(document, tempFile)`. That call embeds a **new font object into the document each time**, so a watermark job held one full copy of the TTF per page for the life of the request. For a 1000-page document with the default Latin face (`NotoSans-Regular.ttf`, 582 KB) that is ~570 MB of raw font bytes before PDFBox's parsed glyph tables and subsetting structures — which is what pushes a large watermark past the heap and OOM-kills containers under 8 GB. The CJK faces make it much worse: `NotoSansKR-Regular.ttf` is 10.4 MB, so the same document needs an order of magnitude more again. Each call also wrote the font to a temp file and deleted it, so a 1000-page job did 1000 create/write/delete cycles. ### What changed - `loadWatermarkFont` extracted, called **once** before the page loop; the loaded `PDFont` is passed into `addTextWatermark`. - Loads via `PDType0Font.load(PDDocument, InputStream)` straight from the classpath resource, so the temp file is gone entirely. - `addTextWatermark` takes the `PDFont` instead of the `alphabet` string, and no longer needs the `PDDocument`. - Dropped the dead `PDType1Font` initialiser that was overwritten before use, plus the imports that went with it. The page loop itself was already correct — it streams one page at a time. The allocation was purely inside it. ## Verification Added `testAddTextWatermark_EmbedsFontOnce`, which watermarks a 12-page document and counts the distinct embedded `FontFile2` streams reachable from page resources. Confirmed it catches the regression — reintroducing the per-page load fails it: ``` expected: <1> but was: <12> ``` All 23 existing watermark tests pass unchanged. ## Checklist - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes
1 parent 6ebbbc2 commit d6bcf58

2 files changed

Lines changed: 95 additions & 32 deletions

File tree

app/core/src/main/java/stirling/software/SPDF/controller/api/security/WatermarkController.java

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,16 @@
33
import java.awt.*;
44
import java.awt.image.BufferedImage;
55
import java.beans.PropertyEditorSupport;
6-
import java.io.File;
7-
import java.io.FileOutputStream;
86
import java.io.IOException;
97
import java.io.InputStream;
10-
import java.nio.file.Files;
118

129
import javax.imageio.ImageIO;
1310

14-
import org.apache.commons.io.IOUtils;
1511
import org.apache.pdfbox.pdmodel.PDDocument;
1612
import org.apache.pdfbox.pdmodel.PDPage;
1713
import org.apache.pdfbox.pdmodel.PDPageContentStream;
1814
import org.apache.pdfbox.pdmodel.font.PDFont;
1915
import org.apache.pdfbox.pdmodel.font.PDType0Font;
20-
import org.apache.pdfbox.pdmodel.font.PDType1Font;
21-
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
2216
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
2317
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
2418
import org.apache.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState;
@@ -113,6 +107,11 @@ public ResponseEntity<Resource> addWatermark(@Valid @ModelAttribute AddWatermark
113107
// Load the input PDF with proper resource management
114108
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
115109

110+
PDFont font =
111+
"text".equalsIgnoreCase(watermarkType)
112+
? loadWatermarkFont(document, alphabet)
113+
: null;
114+
116115
// Create a page in the document
117116
for (PDPage page : document.getPages()) {
118117
// Get the page's content stream
@@ -133,13 +132,12 @@ public ResponseEntity<Resource> addWatermark(@Valid @ModelAttribute AddWatermark
133132
addTextWatermark(
134133
contentStream,
135134
watermarkText,
136-
document,
137135
page,
138136
rotation,
139137
widthSpacer,
140138
heightSpacer,
141139
fontSize,
142-
alphabet,
140+
font,
143141
customColor);
144142
} else if ("image".equalsIgnoreCase(watermarkType)) {
145143
addImageWatermark(
@@ -175,21 +173,13 @@ public ResponseEntity<Resource> addWatermark(@Valid @ModelAttribute AddWatermark
175173
}
176174
}
177175

178-
private void addTextWatermark(
179-
PDPageContentStream contentStream,
180-
String watermarkText,
181-
PDDocument document,
182-
PDPage page,
183-
float rotation,
184-
int widthSpacer,
185-
int heightSpacer,
186-
float fontSize,
187-
String alphabet,
188-
String colorString)
189-
throws IOException {
190-
String resourceDir = "";
191-
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
192-
resourceDir =
176+
/**
177+
* Load the watermark font once for the whole document. {@link PDType0Font#load} embeds a font
178+
* object into {@code document} on every call, so calling this per page holds one full copy of
179+
* the TTF per page in memory (10 MB each for the CJK faces).
180+
*/
181+
private PDFont loadWatermarkFont(PDDocument document, String alphabet) throws IOException {
182+
String resourceDir =
193183
switch (alphabet) {
194184
case "arabic" -> "static/fonts/NotoSansArabic-Regular.ttf";
195185
case "japanese" -> "static/fonts/NotoSansJP-Regular.ttf";
@@ -199,17 +189,22 @@ private void addTextWatermark(
199189
default -> "static/fonts/NotoSans-Regular.ttf";
200190
};
201191

202-
ClassPathResource classPathResource = new ClassPathResource(resourceDir);
203-
String fileExtension = resourceDir.substring(resourceDir.lastIndexOf('.'));
204-
File tempFile = Files.createTempFile("NotoSansFont", fileExtension).toFile();
205-
try (InputStream is = classPathResource.getInputStream();
206-
FileOutputStream os = new FileOutputStream(tempFile)) {
207-
IOUtils.copy(is, os);
208-
font = PDType0Font.load(document, tempFile);
209-
} finally {
210-
Files.deleteIfExists(tempFile.toPath());
192+
try (InputStream is = new ClassPathResource(resourceDir).getInputStream()) {
193+
return PDType0Font.load(document, is);
211194
}
195+
}
212196

197+
private void addTextWatermark(
198+
PDPageContentStream contentStream,
199+
String watermarkText,
200+
PDPage page,
201+
float rotation,
202+
int widthSpacer,
203+
int heightSpacer,
204+
float fontSize,
205+
PDFont font,
206+
String colorString)
207+
throws IOException {
213208
contentStream.setFont(font, fontSize);
214209

215210
Color redactColor;

app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@
1010
import java.io.ByteArrayOutputStream;
1111
import java.io.File;
1212
import java.nio.file.Files;
13+
import java.util.Collections;
14+
import java.util.IdentityHashMap;
15+
import java.util.Set;
1316

1417
import org.apache.pdfbox.Loader;
18+
import org.apache.pdfbox.cos.COSBase;
19+
import org.apache.pdfbox.cos.COSName;
1520
import org.apache.pdfbox.pdmodel.PDDocument;
1621
import org.apache.pdfbox.pdmodel.PDPage;
1722
import org.apache.pdfbox.pdmodel.PDPageContentStream;
23+
import org.apache.pdfbox.pdmodel.PDResources;
1824
import org.apache.pdfbox.pdmodel.common.PDRectangle;
25+
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
1926
import org.apache.pdfbox.pdmodel.font.PDType1Font;
2027
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
2128
import org.junit.jupiter.api.BeforeEach;
@@ -387,6 +394,67 @@ void testAddTextWatermark_MultiPage() throws Exception {
387394
assertNotNull(response.getBody());
388395
assertTrue(drainBody(response).length > 0);
389396
}
397+
398+
@Test
399+
@DisplayName("Should embed the watermark font once, not once per page")
400+
void testAddTextWatermark_EmbedsFontOnce() throws Exception {
401+
int pageCount = 12;
402+
byte[] multiPagePdf;
403+
try (PDDocument doc = new PDDocument()) {
404+
for (int i = 0; i < pageCount; i++) {
405+
doc.addPage(new PDPage(PDRectangle.A4));
406+
}
407+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
408+
doc.save(baos);
409+
multiPagePdf = baos.toByteArray();
410+
}
411+
412+
AddWatermarkRequest request = new AddWatermarkRequest();
413+
request.setFileInput(
414+
new MockMultipartFile(
415+
"fileInput",
416+
"multi.pdf",
417+
MediaType.APPLICATION_PDF_VALUE,
418+
multiPagePdf));
419+
request.setWatermarkType("text");
420+
request.setWatermarkText("WATERMARK");
421+
request.setAlphabet("roman");
422+
request.setFontSize(30);
423+
request.setRotation(45);
424+
request.setOpacity(0.5f);
425+
request.setWidthSpacer(50);
426+
request.setHeightSpacer(50);
427+
request.setCustomColor("#d3d3d3");
428+
request.setConvertPDFToImage(false);
429+
430+
when(pdfDocumentFactory.load(any(MultipartFile.class)))
431+
.thenAnswer(inv -> Loader.loadPDF(multiPagePdf));
432+
433+
byte[] watermarked = drainBody(watermarkController.addWatermark(request));
434+
435+
Set<COSBase> fontPrograms =
436+
Collections.newSetFromMap(new IdentityHashMap<COSBase, Boolean>());
437+
try (PDDocument out = Loader.loadPDF(watermarked)) {
438+
assertEquals(pageCount, out.getNumberOfPages());
439+
for (PDPage page : out.getPages()) {
440+
PDResources resources = page.getResources();
441+
for (COSName fontName : resources.getFontNames()) {
442+
PDFontDescriptor descriptor =
443+
resources.getFont(fontName).getFontDescriptor();
444+
if (descriptor != null && descriptor.getFontFile2() != null) {
445+
fontPrograms.add(descriptor.getFontFile2().getCOSObject());
446+
}
447+
}
448+
}
449+
}
450+
451+
// One embedded font program shared by every page. Loading it inside the page
452+
// loop embeds a fresh copy per page, which is what OOMs large documents.
453+
assertEquals(
454+
1,
455+
fontPrograms.size(),
456+
"watermark font should be embedded once for the whole document");
457+
}
390458
}
391459

392460
@Nested

0 commit comments

Comments
 (0)