mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
refactor(redact): replace PDFBox-based text redaction with JPDFium (#7364)
# Description of Changes Refactors automatic text redaction to use JPDFium-based redaction/text removal instead of PDFBox Changes: * The `RedactController` now uses the JPDFium native redaction engine (`PdfRedactor.redact`) as the primary method for PDF redaction, with automatic fallback to the manual redaction service if JPDFium fails or throws an exception. This improves reliability and leverages more robust native features when available. * Regex patterns provided by the user are now validated before redaction begins, ensuring invalid patterns are rejected early with clear error messages. * The code now trims and filters out empty or excessively long redaction terms, preventing unnecessary processing and potential errors. <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
+114
-101
@@ -1,9 +1,14 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
@@ -43,6 +48,10 @@ import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.common.util.propertyeditor.JsonListPropertyEditor;
|
||||
import stirling.software.common.util.propertyeditor.JsonObjectPropertyEditor;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.jpdfium.redact.PdfRedactor;
|
||||
import stirling.software.jpdfium.redact.RedactOptions;
|
||||
import stirling.software.jpdfium.redact.RedactResult;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
|
||||
@@ -140,134 +149,138 @@ public class RedactController {
|
||||
+ " patterns. Users can provide text patterns to redact, with options for regex"
|
||||
+ " and whole word matching.")
|
||||
public ResponseEntity<Resource> redactPdf(@ModelAttribute RedactPdfRequest request) {
|
||||
String rawListOfText = request.getListOfText();
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
if (request.getFileInput() == null || request.getFileInput().isEmpty()) {
|
||||
log.error("File input is null or empty");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
|
||||
String rawListOfText = request.getListOfText();
|
||||
if (rawListOfText == null || rawListOfText.trim().isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
String[] listOfText = rawListOfText.split("\n");
|
||||
if (listOfText.length == 1 && listOfText[0].trim().isEmpty()) {
|
||||
List<String> terms =
|
||||
Arrays.stream(rawListOfText.split("\n"))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty() && s.length() <= 4096)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (terms.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "No text patterns provided for redaction");
|
||||
}
|
||||
|
||||
PDDocument document = null;
|
||||
PDDocument fallbackDocument = null;
|
||||
boolean useRegex = Boolean.TRUE.equals(request.getUseRegex());
|
||||
boolean wholeWordSearchBool = Boolean.TRUE.equals(request.getWholeWordSearch());
|
||||
|
||||
try {
|
||||
if (request.getFileInput() == null) {
|
||||
log.error("File input is null");
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
if (useRegex) {
|
||||
for (String term : terms) {
|
||||
try {
|
||||
Pattern.compile(term);
|
||||
} catch (PatternSyntaxException e) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.redaction.no.patterns", "Invalid regex pattern: " + term);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document = pdfDocumentFactory.load(request.getFileInput());
|
||||
String filename =
|
||||
removeFileExtension(
|
||||
Objects.requireNonNull(
|
||||
Filenames.toSimpleFileName(
|
||||
request.getFileInput().getOriginalFilename())))
|
||||
+ "_redacted.pdf";
|
||||
|
||||
Color redactColor = ManualRedactionService.decodeOrDefault(request.getRedactColor());
|
||||
int boxColorInt = redactColor.getRGB();
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(request.getFileInput())) {
|
||||
if (document == null) {
|
||||
log.error("Failed to load PDF document");
|
||||
throw ExceptionUtils.createPdfCorruptedException(
|
||||
"during redaction", new IOException("Failed to load PDF document"));
|
||||
}
|
||||
|
||||
Map<Integer, List<PDFText>> allFoundTextsByPage =
|
||||
textRedactionService.findTextToRedact(
|
||||
document, listOfText, useRegex, wholeWordSearchBool);
|
||||
try (TempFile tempInput = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
try {
|
||||
request.getFileInput().transferTo(tempInput.getFile());
|
||||
} catch (Exception e) {
|
||||
document.save(tempInput.getFile());
|
||||
}
|
||||
|
||||
int totalMatches = allFoundTextsByPage.values().stream().mapToInt(List::size).sum();
|
||||
log.info(
|
||||
"Redaction scan: {} occurrences across {} pages (patterns={}, regex={}, wholeWord={})",
|
||||
totalMatches,
|
||||
allFoundTextsByPage.size(),
|
||||
listOfText.length,
|
||||
useRegex,
|
||||
wholeWordSearchBool);
|
||||
RedactOptions options =
|
||||
RedactOptions.builder()
|
||||
.addWords(terms)
|
||||
.useRegex(useRegex)
|
||||
.wholeWord(wholeWordSearchBool)
|
||||
.boxColor(boxColorInt)
|
||||
.padding(request.getCustomPadding())
|
||||
.removeContent(true)
|
||||
.convertToImage(Boolean.TRUE.equals(request.getConvertPDFToImage()))
|
||||
.normalizeFonts(false)
|
||||
.fixToUnicode(false)
|
||||
.glyphAware(true)
|
||||
.redactMetadata(true)
|
||||
.build();
|
||||
|
||||
String filename =
|
||||
removeFileExtension(
|
||||
Objects.requireNonNull(
|
||||
Filenames.toSimpleFileName(
|
||||
request.getFileInput().getOriginalFilename())))
|
||||
+ "_redacted.pdf";
|
||||
TempFile tempOutput = tempFileManager.createManagedTempFile(".pdf");
|
||||
try {
|
||||
try (PdfDocument checkDoc = PdfDocument.open(tempInput.getFile().toPath())) {
|
||||
if (checkDoc.pageCount() <= 0) {
|
||||
throw new IOException("Invalid or empty PDF document");
|
||||
}
|
||||
}
|
||||
|
||||
if (allFoundTextsByPage.isEmpty()) {
|
||||
log.info("No text found matching redaction patterns");
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, filename, tempFileManager);
|
||||
log.debug(
|
||||
"Calling JPDFium PdfRedactor.redact in RedactController (terms={})",
|
||||
terms);
|
||||
RedactResult result = PdfRedactor.redact(tempInput.getFile().toPath(), options);
|
||||
log.debug(
|
||||
"JPDFium auto-redact complete (matches={})",
|
||||
result != null ? result.totalMatches() : -1);
|
||||
if (result == null) {
|
||||
throw new IOException("JPDFium auto-redact returned null result");
|
||||
}
|
||||
try {
|
||||
result.save(tempOutput.getFile().toPath());
|
||||
log.info(
|
||||
"JPDFium auto-redact: {} matches processed into {}",
|
||||
result.totalMatches(),
|
||||
filename);
|
||||
return WebResponseUtils.pdfFileToWebResponse(tempOutput, filename);
|
||||
} finally {
|
||||
if (result.document() != null) {
|
||||
result.document().close();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
tempOutput.close();
|
||||
log.warn(
|
||||
"JPDFium native redaction fell back to manual redaction service: {}",
|
||||
e.getMessage());
|
||||
Map<Integer, List<PDFText>> foundTexts =
|
||||
textRedactionService.findTextToRedact(
|
||||
document,
|
||||
terms.toArray(new String[0]),
|
||||
useRegex,
|
||||
wholeWordSearchBool);
|
||||
TempFile finalized =
|
||||
manualRedactionService.finalizeRedaction(
|
||||
document,
|
||||
foundTexts,
|
||||
request.getRedactColor(),
|
||||
request.getCustomPadding(),
|
||||
request.getConvertPDFToImage(),
|
||||
false);
|
||||
return WebResponseUtils.pdfFileToWebResponse(finalized, filename);
|
||||
}
|
||||
}
|
||||
|
||||
boolean fallbackToBoxOnlyMode;
|
||||
try {
|
||||
fallbackToBoxOnlyMode =
|
||||
textRedactionService.performTextReplacement(
|
||||
document,
|
||||
allFoundTextsByPage,
|
||||
listOfText,
|
||||
useRegex,
|
||||
wholeWordSearchBool);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Text replacement redaction failed, falling back to box-only mode: {}",
|
||||
e.getMessage());
|
||||
fallbackToBoxOnlyMode = true;
|
||||
}
|
||||
|
||||
if (fallbackToBoxOnlyMode) {
|
||||
log.warn(
|
||||
"Font compatibility issues detected. Using box-only redaction mode for better reliability.");
|
||||
|
||||
fallbackDocument = pdfDocumentFactory.load(request.getFileInput());
|
||||
|
||||
allFoundTextsByPage =
|
||||
textRedactionService.findTextToRedact(
|
||||
fallbackDocument, listOfText, useRegex, wholeWordSearchBool);
|
||||
|
||||
TempFile finalized =
|
||||
manualRedactionService.finalizeRedaction(
|
||||
fallbackDocument,
|
||||
allFoundTextsByPage,
|
||||
request.getRedactColor(),
|
||||
request.getCustomPadding(),
|
||||
request.getConvertPDFToImage(),
|
||||
false);
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(finalized, filename);
|
||||
}
|
||||
|
||||
TempFile finalized =
|
||||
manualRedactionService.finalizeRedaction(
|
||||
document,
|
||||
allFoundTextsByPage,
|
||||
request.getRedactColor(),
|
||||
request.getCustomPadding(),
|
||||
request.getConvertPDFToImage(),
|
||||
true);
|
||||
|
||||
return WebResponseUtils.pdfFileToWebResponse(finalized, filename);
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Redaction operation failed: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("Failed to perform PDF redaction: " + e.getMessage(), e);
|
||||
|
||||
} finally {
|
||||
if (document != null) {
|
||||
try {
|
||||
if (fallbackDocument == null) {
|
||||
document.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to close main document: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackDocument != null) {
|
||||
try {
|
||||
fallbackDocument.close();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to close fallback document: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-1104
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -45,5 +45,5 @@ public class RedactPdfRequest extends PDFFile {
|
||||
description = "Convert the redacted PDF to an image",
|
||||
defaultValue = "false",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean convertPDFToImage;
|
||||
private Boolean convertPDFToImage = Boolean.FALSE;
|
||||
}
|
||||
|
||||
+14
-2
@@ -24,6 +24,8 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -120,12 +122,22 @@ class RedactControllerMoreTest {
|
||||
.thenAnswer(inv -> Loader.loadPDF(pdfBytes));
|
||||
}
|
||||
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private byte[] singlePageTextPdf(String... lines) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
@@ -145,7 +157,7 @@ class RedactControllerMoreTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showText(line);
|
||||
|
||||
+54
-361
@@ -14,19 +14,25 @@ import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSDocument;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSStream;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -49,6 +55,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest;
|
||||
import stirling.software.SPDF.model.api.security.RedactPdfRequest;
|
||||
import stirling.software.common.model.api.security.RedactionArea;
|
||||
@@ -64,9 +71,9 @@ class RedactControllerTest {
|
||||
return ResponseEntity.ok(new ByteArrayResource(bytes));
|
||||
}
|
||||
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws java.io.IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream __in = response.getBody().getInputStream()) {
|
||||
private static byte[] drainBody(ResponseEntity<Resource> response) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream __in = response.getBody().getInputStream()) {
|
||||
__in.transferTo(baos);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
@@ -90,13 +97,24 @@ class RedactControllerTest {
|
||||
private PDDocument realDocument;
|
||||
private PDPage realPage;
|
||||
|
||||
private static PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
RedactControllerTest.class.getResourceAsStream(
|
||||
"/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private static byte[] createSimplePdfContent() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.A4);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(doc, page)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(doc), 12);
|
||||
contentStream.newLineAtOffset(100, 700);
|
||||
contentStream.showText("This is a simple PDF.");
|
||||
contentStream.endText();
|
||||
@@ -156,8 +174,7 @@ class RedactControllerTest {
|
||||
mockDocument = mock(PDDocument.class);
|
||||
mockPages = mock(PDPageTree.class);
|
||||
mockPage = mock(PDPage.class);
|
||||
org.apache.pdfbox.pdmodel.PDDocumentCatalog mockCatalog =
|
||||
mock(org.apache.pdfbox.pdmodel.PDDocumentCatalog.class);
|
||||
PDDocumentCatalog mockCatalog = mock(PDDocumentCatalog.class);
|
||||
|
||||
// Setup document structure properly
|
||||
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDocument);
|
||||
@@ -182,9 +199,8 @@ class RedactControllerTest {
|
||||
|
||||
when(mockPage.hasContents()).thenReturn(true);
|
||||
|
||||
org.apache.pdfbox.cos.COSDocument mockCOSDocument =
|
||||
mock(org.apache.pdfbox.cos.COSDocument.class);
|
||||
org.apache.pdfbox.cos.COSStream mockCOSStream = mock(org.apache.pdfbox.cos.COSStream.class);
|
||||
COSDocument mockCOSDocument = mock(COSDocument.class);
|
||||
COSStream mockCOSStream = mock(COSStream.class);
|
||||
when(mockDocument.getDocument()).thenReturn(mockCOSDocument);
|
||||
when(mockCOSDocument.createCOSStream()).thenReturn(mockCOSStream);
|
||||
|
||||
@@ -338,17 +354,13 @@ class RedactControllerTest {
|
||||
|
||||
when(mockPages.get(0)).thenReturn(mockPage);
|
||||
|
||||
org.apache.pdfbox.pdmodel.PDDocumentInformation mockInfo =
|
||||
mock(org.apache.pdfbox.pdmodel.PDDocumentInformation.class);
|
||||
PDDocumentInformation mockInfo = mock(PDDocumentInformation.class);
|
||||
when(mockDocument.getDocumentInformation()).thenReturn(mockInfo);
|
||||
|
||||
ResponseEntity<Resource> response = redactController.redactPdf(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
|
||||
verify(mockDocument).save(any(File.class));
|
||||
verify(mockDocument).close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,8 +765,6 @@ class RedactControllerTest {
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(drainBody(response).length > 0);
|
||||
verify(mockDocument, times(1)).save(any(File.class));
|
||||
verify(mockDocument, times(1)).close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (expectSuccess) {
|
||||
@@ -788,11 +798,14 @@ class RedactControllerTest {
|
||||
realPage = new PDPage(PDRectangle.A4);
|
||||
realDocument.addPage(realPage);
|
||||
|
||||
// Set up basic page resources
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(
|
||||
COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.setResources(resources);
|
||||
// Set up basic page resources with embedded font
|
||||
try {
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
realPage.setResources(resources);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods for real PDF content creation
|
||||
@@ -803,8 +816,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
@@ -822,8 +834,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
@@ -846,8 +857,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.setLineWidth(2);
|
||||
@@ -874,8 +884,7 @@ class RedactControllerTest {
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
realPage.getResources().put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
|
||||
try (PDPageContentStream contentStream = new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
@@ -1005,22 +1014,6 @@ class RedactControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> getOriginalTokens() throws Exception {
|
||||
// Create a new page to avoid side effects from other tests
|
||||
PDPage pageForTokenExtraction = new PDPage(PDRectangle.A4);
|
||||
pageForTokenExtraction.setResources(realPage.getResources());
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(realDocument, pageForTokenExtraction)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(realPage.getResources().getFont(COSName.getPDFName("F1")), 12);
|
||||
contentStream.newLineAtOffset(50, 750);
|
||||
contentStream.showText("Original content");
|
||||
contentStream.endText();
|
||||
}
|
||||
return textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, pageForTokenExtraction, Collections.emptySet(), false, false);
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Color Decoding Utility Tests")
|
||||
class ColorDecodingTests {
|
||||
@@ -1099,318 +1092,19 @@ class RedactControllerTest {
|
||||
class ContentStreamUnitTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should remove simple text tokens")
|
||||
void shouldRemoveSimpleTextTokens() throws Exception {
|
||||
createRealPageWithSimpleText("This document contains confidential information.");
|
||||
@DisplayName("performTextReplacement should process document text replacement")
|
||||
void shouldPerformTextReplacement() throws Exception {
|
||||
createRealPageWithSimpleText("This document contains sensitive information.");
|
||||
String[] targetWords = new String[] {"sensitive"};
|
||||
|
||||
Set<String> targetWords = Set.of("confidential");
|
||||
Map<Integer, List<PDFText>> found =
|
||||
textRedactionService.findTextToRedact(realDocument, targetWords, false, false);
|
||||
assertFalse(found.isEmpty(), "Should find target text to redact");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertFalse(tokens.isEmpty());
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(
|
||||
reconstructedText.contains("confidential"),
|
||||
"Target text should be replaced with placeholder");
|
||||
assertTrue(reconstructedText.contains("document"), "Non-target text should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should handle TJ operator arrays")
|
||||
void shouldHandleTJOperatorArrays() throws Exception {
|
||||
createRealPageWithTJArrayText();
|
||||
|
||||
Set<String> targetWords = Set.of("secret");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
|
||||
boolean foundModifiedTJArray = false;
|
||||
for (Object token : tokens) {
|
||||
if (token instanceof COSArray array) {
|
||||
for (int i = 0; i < array.size(); i++) {
|
||||
if (array.getObject(i) instanceof COSString cosString) {
|
||||
String text = cosString.getString();
|
||||
if (text.contains("secret")) {
|
||||
fail(
|
||||
"Target text 'secret' should have been redacted from TJ"
|
||||
+ " array");
|
||||
}
|
||||
foundModifiedTJArray = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(foundModifiedTJArray, "Should find at least one TJ array");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should preserve non-text tokens")
|
||||
void shouldPreserveNonTextTokens() throws Exception {
|
||||
createRealPageWithMixedContent();
|
||||
|
||||
Set<String> targetWords = Set.of("redact");
|
||||
|
||||
List<Object> originalTokens = getOriginalTokens();
|
||||
List<Object> filteredTokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
long originalNonTextCount =
|
||||
originalTokens.stream()
|
||||
.filter(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& !textRedactionService.isTextShowingOperator(
|
||||
op.getName()))
|
||||
.count();
|
||||
|
||||
long filteredNonTextCount =
|
||||
filteredTokens.stream()
|
||||
.filter(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& !textRedactionService.isTextShowingOperator(
|
||||
op.getName()))
|
||||
.count();
|
||||
|
||||
assertTrue(filteredNonTextCount > 0, "Non-text operators should be preserved");
|
||||
|
||||
assertTrue(
|
||||
filteredNonTextCount >= originalNonTextCount / 2,
|
||||
"A reasonable number of non-text operators should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should handle regex patterns")
|
||||
void shouldHandleRegexPatterns() throws Exception {
|
||||
createRealPageWithSimpleText("Phone: 123-456-7890 and SSN: 111-22-3333");
|
||||
|
||||
Set<String> targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, true, false);
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(reconstructedText.contains("111-22-3333"), "SSN should be redacted");
|
||||
assertTrue(reconstructedText.contains("123-456-7890"), "Phone should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createTokensWithoutTargetText should handle whole word search")
|
||||
void shouldHandleWholeWordSearch() throws Exception {
|
||||
createRealPageWithSimpleText("This test testing tested document");
|
||||
|
||||
Set<String> targetWords = Set.of("test");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, true);
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertTrue(reconstructedText.contains("testing"), "Partial matches should remain");
|
||||
assertTrue(reconstructedText.contains("tested"), "Partial matches should remain");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"Tj", "TJ", "'", "\""})
|
||||
@DisplayName("createTokensWithoutTargetText should handle all text operators")
|
||||
void shouldHandleAllTextOperators(String operatorName) throws Exception {
|
||||
createRealPageWithSpecificOperator(operatorName);
|
||||
|
||||
Set<String> targetWords = Set.of("sensitive");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(
|
||||
reconstructedText.contains("sensitive"),
|
||||
"Text should be redacted regardless of operator type");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeFilteredContentStream should write tokens to new stream")
|
||||
void shouldWriteTokensToNewContentStream() throws Exception {
|
||||
List<Object> tokens = createSampleTokenList();
|
||||
|
||||
textRedactionService.writeFilteredContentStream(realDocument, realPage, tokens);
|
||||
|
||||
assertNotNull(realPage.getContents(), "Page should have content stream");
|
||||
|
||||
// Verify the content can be read back
|
||||
try (InputStream inputStream = realPage.getContents()) {
|
||||
byte[] content = readAllBytes(inputStream);
|
||||
assertTrue(content.length > 0, "Content stream should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeFilteredContentStream should handle empty token list")
|
||||
void shouldHandleEmptyTokenList() throws Exception {
|
||||
List<Object> emptyTokens = Collections.emptyList();
|
||||
|
||||
assertDoesNotThrow(
|
||||
() ->
|
||||
textRedactionService.writeFilteredContentStream(
|
||||
realDocument, realPage, emptyTokens));
|
||||
|
||||
assertNotNull(realPage.getContents(), "Page should still have content stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeFilteredContentStream should replace existing content")
|
||||
void shouldReplaceExistingContentStream() throws Exception {
|
||||
createRealPageWithSimpleText("Original content");
|
||||
String originalContent = extractTextFromModifiedPage(realPage);
|
||||
|
||||
List<Object> newTokens = createSampleTokenList();
|
||||
textRedactionService.writeFilteredContentStream(realDocument, realPage, newTokens);
|
||||
|
||||
String newContent = extractTextFromModifiedPage(realPage);
|
||||
assertNotEquals(originalContent, newContent, "Content stream should be replaced");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Placeholder creation should maintain text width")
|
||||
void shouldCreateWidthMatchingPlaceholder() {
|
||||
String originalText = "confidential";
|
||||
String placeholder =
|
||||
textRedactionService.createPlaceholderWithFont(
|
||||
originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
|
||||
assertEquals(
|
||||
originalText.length(),
|
||||
placeholder.length(),
|
||||
"Placeholder should maintain character count for width preservation");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Placeholder should handle special characters")
|
||||
void shouldHandleSpecialCharactersInPlaceholder() {
|
||||
String originalText = "café naïve";
|
||||
String placeholder =
|
||||
textRedactionService.createPlaceholderWithFont(
|
||||
originalText, new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
|
||||
assertEquals(originalText.length(), placeholder.length());
|
||||
assertFalse(
|
||||
placeholder.contains("café"), "Placeholder should not contain original text");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Integration test: createTokens and writeStream")
|
||||
void shouldIntegrateTokenCreationAndWriting() throws Exception {
|
||||
createRealPageWithSimpleText("This document contains secret information.");
|
||||
|
||||
Set<String> targetWords = Set.of("secret");
|
||||
|
||||
List<Object> filteredTokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
textRedactionService.writeFilteredContentStream(realDocument, realPage, filteredTokens);
|
||||
assertNotNull(realPage.getContents());
|
||||
|
||||
String finalText = extractTextFromModifiedPage(realPage);
|
||||
assertFalse(finalText.contains("secret"), "Target text should be completely removed");
|
||||
assertTrue(finalText.contains("document"), "Other text should remain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve text positioning operators")
|
||||
void shouldPreserveTextPositioning() throws Exception {
|
||||
createRealPageWithPositionedText();
|
||||
|
||||
Set<String> targetWords = Set.of("confidential");
|
||||
|
||||
List<Object> filteredTokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
long filteredPositioning =
|
||||
filteredTokens.stream()
|
||||
.filter(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& ("Td".equals(op.getName())
|
||||
|| "TD".equals(op.getName())
|
||||
|| "Tm".equals(op.getName())))
|
||||
.count();
|
||||
|
||||
assertTrue(filteredPositioning > 0, "Positioning operators should be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle complex content streams with multiple operators")
|
||||
void shouldHandleComplexContentStreams() throws Exception {
|
||||
realPage = new PDPage(PDRectangle.A4);
|
||||
while (realDocument.getNumberOfPages() > 0) {
|
||||
realDocument.removePage(0);
|
||||
}
|
||||
realDocument.addPage(realPage);
|
||||
realPage.setResources(new PDResources());
|
||||
realPage.getResources()
|
||||
.put(
|
||||
COSName.getPDFName("F1"),
|
||||
new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.setLineWidth(2);
|
||||
contentStream.moveTo(100, 100);
|
||||
contentStream.lineTo(200, 200);
|
||||
contentStream.stroke();
|
||||
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(
|
||||
realPage.getResources().getFont(COSName.getPDFName("F1")), 12);
|
||||
contentStream.newLineAtOffset(50, 750);
|
||||
contentStream.showText("This is a complex document with ");
|
||||
contentStream.setTextRise(5);
|
||||
contentStream.showText("confidential");
|
||||
contentStream.setTextRise(0);
|
||||
contentStream.showText(" information.");
|
||||
contentStream.endText();
|
||||
|
||||
contentStream.addRect(300, 300, 100, 100);
|
||||
contentStream.fill();
|
||||
}
|
||||
|
||||
Set<String> targetWords = Set.of("confidential");
|
||||
|
||||
List<Object> tokens =
|
||||
textRedactionService.createTokensWithoutTargetText(
|
||||
realDocument, realPage, targetWords, false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertFalse(tokens.isEmpty());
|
||||
|
||||
String reconstructedText = extractTextFromTokens(tokens);
|
||||
assertFalse(
|
||||
reconstructedText.contains("confidential"), "Target text should be redacted");
|
||||
|
||||
boolean hasGraphicsOperators =
|
||||
tokens.stream()
|
||||
.anyMatch(
|
||||
token ->
|
||||
token instanceof Operator op
|
||||
&& ("re".equals(op.getName())
|
||||
|| "f".equals(op.getName())
|
||||
|| "m".equals(op.getName())
|
||||
|| "l".equals(op.getName())
|
||||
|| "S".equals(op.getName())));
|
||||
|
||||
assertTrue(hasGraphicsOperators, "Graphics operators should be preserved");
|
||||
boolean fallback =
|
||||
textRedactionService.performTextReplacement(
|
||||
realDocument, found, targetWords, false, false);
|
||||
assertFalse(fallback, "JPDFium text replacement should complete without fallback");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1423,14 +1117,13 @@ class RedactControllerTest {
|
||||
realDocument.addPage(realPage);
|
||||
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(
|
||||
COSName.getPDFName("F1"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
|
||||
resources.put(COSName.getPDFName("F1"), helvetica(realDocument));
|
||||
realPage.setResources(resources);
|
||||
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(realDocument, realPage)) {
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(realDocument), 12);
|
||||
contentStream.newLineAtOffset(50, 750);
|
||||
contentStream.showText("This is the first text block");
|
||||
contentStream.endText();
|
||||
@@ -1441,7 +1134,7 @@ class RedactControllerTest {
|
||||
contentStream.stroke();
|
||||
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(realDocument), 12);
|
||||
contentStream.newLineAtOffset(50, 650);
|
||||
contentStream.showText("This block contains confidential information");
|
||||
contentStream.endText();
|
||||
@@ -1450,7 +1143,7 @@ class RedactControllerTest {
|
||||
contentStream.fill();
|
||||
|
||||
contentStream.beginText();
|
||||
contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
|
||||
contentStream.setFont(helvetica(realDocument), 12);
|
||||
contentStream.newLineAtOffset(50, 550);
|
||||
contentStream.showText("This is the third text block");
|
||||
contentStream.endText();
|
||||
|
||||
+17
-5
@@ -10,6 +10,7 @@ import static org.mockito.Mockito.mock;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
@@ -22,6 +23,8 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
@@ -31,6 +34,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
@@ -118,18 +122,26 @@ class RedactExecuteServiceMoreTest {
|
||||
|
||||
private RedactExecuteRequest requestFor(byte[] pdfBytes) {
|
||||
RedactExecuteRequest req = new RedactExecuteRequest();
|
||||
req.setFileInput(
|
||||
new org.springframework.mock.web.MockMultipartFile(
|
||||
"fileInput", "in.pdf", "application/pdf", pdfBytes));
|
||||
req.setFileInput(new MockMultipartFile("fileInput", "in.pdf", "application/pdf", pdfBytes));
|
||||
return req;
|
||||
}
|
||||
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private byte[] singlePageTextPdf(String... lines) throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * LINE_H);
|
||||
@@ -149,7 +161,7 @@ class RedactExecuteServiceMoreTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showText("page " + p + " has SECRET content here");
|
||||
|
||||
+18
-5
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -11,6 +12,8 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -219,12 +222,22 @@ class RedactExecuteServiceTest {
|
||||
* anchor) 1: line one 2: line two 3: line three 4: STOP-HERE (end anchor) 5: line five (must
|
||||
* NOT be redacted)
|
||||
*/
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private PDDocument buildSingleColumnDoc() throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
String[] lines = {
|
||||
"START-HERE", "line one", "line two", "line three", "STOP-HERE", "line five"
|
||||
};
|
||||
@@ -247,7 +260,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
// Body lines are padded to make each column genuinely wide enough that column
|
||||
// detection (which ignores narrow lines) treats both sides as real columns.
|
||||
String fill = " " + "x".repeat(26);
|
||||
@@ -294,7 +307,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
String[] lines = {
|
||||
"#1 Auto layout",
|
||||
"Body about auto layout.",
|
||||
@@ -331,7 +344,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
// Header — full width, lines 0..1.
|
||||
for (int i = 0; i < 2; i++) {
|
||||
cs.beginText();
|
||||
@@ -383,7 +396,7 @@ class RedactExecuteServiceTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
|
||||
float dateX = PAGE_WIDTH - 144f; // right-aligned dates near the right margin
|
||||
|
||||
|
||||
+10
-504
@@ -3,44 +3,24 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
|
||||
/**
|
||||
* Further gap-coverage tests for {@link TextRedactionService}, complementing {@code
|
||||
* TextRedactionServiceTest} and {@code TextRedactionServiceMoreTest}. These target branches the
|
||||
* other two suites leave untouched: case-sensitive vs regex find, multi-term and multi-match within
|
||||
* one segment, the kerning ({@code adjustment != 0}) path that rewrites a {@code Tj} into a {@code
|
||||
* TJ} array, nested Form XObject traversal, pages with no resources, and the private width helpers
|
||||
* exercised directly via reflection.
|
||||
*/
|
||||
@DisplayName("TextRedactionService extra coverage")
|
||||
class TextRedactionServiceExtraTest {
|
||||
|
||||
@@ -50,43 +30,22 @@ class TextRedactionServiceExtraTest {
|
||||
|
||||
private final TextRedactionService service = new TextRedactionService();
|
||||
|
||||
private PDFont helvetica() {
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object t;
|
||||
while ((t = parser.parseNextToken()) != null) {
|
||||
tokens.add(t);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private String tokensText(List<Object> tokens) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Object token : tokens) {
|
||||
if (token instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
} else if (token instanceof COSArray arr) {
|
||||
for (COSBase el : arr) {
|
||||
if (el instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** Single page, one Tj line per supplied text line, Helvetica 12. */
|
||||
private PDDocument buildDoc(String... lines) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
@@ -97,25 +56,6 @@ class TextRedactionServiceExtraTest {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Page whose content stream is exactly {@code rawContent}, font F1=Helvetica. */
|
||||
private PDDocument docWithRawContent(String rawContent) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica());
|
||||
page.setResources(resources);
|
||||
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(rawContent.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── findTextToRedact: matching modes ─────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findTextToRedact matching modes")
|
||||
class FindModes {
|
||||
@@ -126,7 +66,6 @@ class TextRedactionServiceExtraTest {
|
||||
try (PDDocument doc = buildDoc("Secret and secret and SECRET")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"secret"}, false, false);
|
||||
// Patterns are compiled CASE_INSENSITIVE, so all three occurrences match.
|
||||
assertThat(result.get(0)).hasSize(3);
|
||||
}
|
||||
}
|
||||
@@ -148,7 +87,6 @@ class TextRedactionServiceExtraTest {
|
||||
try (PDDocument doc = buildDoc("abcde")) {
|
||||
Map<Integer, List<PDFText>> result =
|
||||
service.findTextToRedact(doc, new String[] {"[ae]"}, true, false);
|
||||
// 'a' and 'e' both match -> two single-character hits.
|
||||
assertThat(result.get(0)).hasSize(2);
|
||||
}
|
||||
}
|
||||
@@ -163,436 +101,4 @@ class TextRedactionServiceExtraTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── createTokensWithoutTargetText structural branches ────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createTokensWithoutTargetText structural branches")
|
||||
class TokenStructural {
|
||||
|
||||
@Test
|
||||
@DisplayName("page with null resources still parses and redacts the matched Tj text")
|
||||
void nullResourcesStillRedacts() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
// No resources set; the content stream references no real font.
|
||||
String raw = "BT 72 700 Td (SECRET) Tj ET";
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(raw.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokensText(tokens)).doesNotContain("SECRET");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a match inside a Tj segment is redacted and surrounding text survives")
|
||||
void multipleMatchesOneSegment() throws IOException {
|
||||
try (PDDocument doc = docWithRawContent("BT /F1 12 Tf 72 700 Td (xAAxAAx) Tj ET")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("AA"), false, false);
|
||||
String redacted = tokensText(tokens);
|
||||
// The segment was rewritten away from the original literal.
|
||||
assertThat(redacted).isNotEqualTo("xAAxAAx");
|
||||
// Redaction replaces matched runs with whitespace, so at least one "AA" is gone
|
||||
// (the leading occurrence) and the surrounding x characters survive.
|
||||
assertThat(redacted.split("AA", -1).length - 1).isLessThan(2);
|
||||
assertThat(redacted).startsWith("x ");
|
||||
assertThat(redacted).contains("x");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a second Tf operator updates the active font for later segments")
|
||||
void secondTfUpdatesFont() throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica());
|
||||
resources.put(
|
||||
COSName.getPDFName("F2"),
|
||||
new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN));
|
||||
page.setResources(resources);
|
||||
String raw = "BT /F1 12 Tf 72 700 Td (first) Tj /F2 18 Tf 0 -20 Td (SECRET) Tj ET";
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(raw.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
try (doc) {
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokensText(tokens)).doesNotContain("SECRET");
|
||||
assertThat(tokensText(tokens)).contains("first");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── nested Form XObject traversal ────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("nested Form XObject traversal")
|
||||
class NestedXObjects {
|
||||
|
||||
@Test
|
||||
@DisplayName("a match in a form nested two levels deep is reached and rewritten")
|
||||
void nestedTwoLevelsDeep() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
// Inner form shows SECRET.
|
||||
PDFormXObject inner = new PDFormXObject(doc);
|
||||
inner.setResources(new PDResources());
|
||||
inner.getResources().put(COSName.getPDFName("F1"), helvetica());
|
||||
inner.setBBox(new PDRectangle(0, 0, 200, 50));
|
||||
try (var out = inner.getStream().createOutputStream()) {
|
||||
out.write(
|
||||
"BT /F1 12 Tf 0 10 Td (SECRET) Tj ET"
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
// Outer form references inner via Do.
|
||||
PDFormXObject outer = new PDFormXObject(doc);
|
||||
PDResources outerRes = new PDResources();
|
||||
COSName innerName = outerRes.add(inner);
|
||||
outer.setResources(outerRes);
|
||||
outer.setBBox(new PDRectangle(0, 0, 200, 50));
|
||||
try (var out = outer.getStream().createOutputStream()) {
|
||||
out.write(
|
||||
("/" + innerName.getName() + " Do")
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
PDResources pageRes = new PDResources();
|
||||
COSName outerName = pageRes.add(outer);
|
||||
page.setResources(pageRes);
|
||||
PDStream pageStream = new PDStream(doc);
|
||||
try (var out = pageStream.createOutputStream()) {
|
||||
out.write(
|
||||
("/" + outerName.getName() + " Do")
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(pageStream);
|
||||
|
||||
service.createTokensWithoutTargetText(doc, page, Set.of("SECRET"), false, false);
|
||||
|
||||
// The deep traversal must have rewritten the inner form's content stream.
|
||||
assertThat(inner.getCOSObject().containsKey(COSName.CONTENTS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a form XObject with no resources is skipped without error")
|
||||
void formWithoutResourcesSkipped() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setBBox(new PDRectangle(0, 0, 100, 50));
|
||||
// Intentionally no resources on the form.
|
||||
try (var out = form.getStream().createOutputStream()) {
|
||||
out.write("q Q".getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
PDResources pageRes = new PDResources();
|
||||
COSName formName = pageRes.add(form);
|
||||
page.setResources(pageRes);
|
||||
PDStream pageStream = new PDStream(doc);
|
||||
try (var out = pageStream.createOutputStream()) {
|
||||
out.write(
|
||||
("/" + formName.getName() + " Do")
|
||||
.getBytes(StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(pageStream);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokens).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── kerning / adjustment path in modifyTokenForRedaction ─────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("modifyTokenForRedaction adjustment branches")
|
||||
class ModifyTokenAdjustment {
|
||||
|
||||
@Test
|
||||
@DisplayName("a non-zero width adjustment rewrites a Tj into a TJ array with kerning")
|
||||
void adjustmentRewritesToTjArray() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("KEEP"));
|
||||
tokens.add(Operator.getOperator("Tj"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "KEEP", 0, 4, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
// A clearly non-zero adjustment forces the COSArray + kerning branch.
|
||||
m.invoke(service, tokens, segment, "AB", 5.0f, List.of());
|
||||
|
||||
assertThat(tokens.get(0)).isInstanceOf(COSArray.class);
|
||||
COSArray arr = (COSArray) tokens.get(0);
|
||||
boolean hasKern = false;
|
||||
for (COSBase el : arr) {
|
||||
if (el instanceof COSFloat) {
|
||||
hasKern = true;
|
||||
}
|
||||
}
|
||||
assertThat(hasKern).as("kerning float should be appended to the TJ array").isTrue();
|
||||
// The trailing Tj operator should have been switched to TJ.
|
||||
assertThat(tokens.get(1)).isInstanceOf(Operator.class);
|
||||
assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty replacement text with ~zero adjustment sets the shared empty COSString")
|
||||
void emptyReplacementZeroAdjustment() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("SECRET"));
|
||||
tokens.add(Operator.getOperator("Tj"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "SECRET", 0, 6, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, tokens, segment, "", 0f, List.of());
|
||||
|
||||
assertThat(tokens.get(0)).isInstanceOf(COSString.class);
|
||||
assertThat(((COSString) tokens.get(0)).getString()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the ' operator with a non-zero adjustment is also rewritten to a TJ array")
|
||||
void apostropheAdjustmentRewrites() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("WORD"));
|
||||
tokens.add(Operator.getOperator("'"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "'", "WORD", 0, 4, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, tokens, segment, "X", 4.0f, List.of());
|
||||
|
||||
assertThat(tokens.get(0)).isInstanceOf(COSArray.class);
|
||||
assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createRedactedTJArray edge branches ──────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createRedactedTJArray edge branches")
|
||||
class RedactedTjArray {
|
||||
|
||||
@Test
|
||||
@DisplayName("non-COSString elements (kerning numbers) are preserved in order")
|
||||
void preservesNumberElements() throws Exception {
|
||||
COSArray original = new COSArray();
|
||||
original.add(new COSString("AA"));
|
||||
original.add(new COSFloat(-25f));
|
||||
original.add(new COSString("BB"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "TJ", "AABB", 0, 4, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(0, 2)); // "AA"
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createRedactedTJArray",
|
||||
COSArray.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
COSArray result = (COSArray) m.invoke(service, original, segment, matches);
|
||||
|
||||
boolean sawFloat = false;
|
||||
for (COSBase el : result) {
|
||||
if (el instanceof COSFloat) {
|
||||
sawFloat = true;
|
||||
}
|
||||
}
|
||||
assertThat(sawFloat).as("original kerning number must be retained").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a TJ array with no overlapping match is returned essentially unchanged")
|
||||
void noMatchLeavesTextIntact() throws Exception {
|
||||
COSArray original = new COSArray();
|
||||
original.add(new COSString("hello"));
|
||||
original.add(new COSString("world"));
|
||||
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "TJ", "helloworld", 0, 10, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(50, 60)); // out of range
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createRedactedTJArray",
|
||||
COSArray.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
COSArray result = (COSArray) m.invoke(service, original, segment, matches);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (COSBase el : result) {
|
||||
if (el instanceof COSString cs) sb.append(cs.getString());
|
||||
}
|
||||
assertThat(sb.toString()).isEqualTo("helloworld");
|
||||
}
|
||||
}
|
||||
|
||||
// ── private width helpers via reflection ─────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("private width helpers via reflection")
|
||||
class WidthHelpers {
|
||||
|
||||
private float invokeFloat(String name, Object... args) throws Exception {
|
||||
Class<?>[] types = new Class<?>[] {PDFont.class, String.class};
|
||||
Method m = TextRedactionService.class.getDeclaredMethod(name, types);
|
||||
m.setAccessible(true);
|
||||
return (float) m.invoke(service, args);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateConservativeWidth scales linearly at 500 units per character")
|
||||
void conservativeWidthLinear() throws Exception {
|
||||
float w = invokeFloat("calculateConservativeWidth", helvetica(), "abcd");
|
||||
assertThat(w).isEqualTo(4 * 500f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateCharacterBasedWidth returns a positive width for normal text")
|
||||
void characterBasedWidthPositive() throws Exception {
|
||||
float w = invokeFloat("calculateCharacterBasedWidth", helvetica(), "Hello");
|
||||
assertThat(w).isGreaterThan(0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateFallbackWidth returns a positive width using font metrics")
|
||||
void fallbackWidthPositive() throws Exception {
|
||||
float w = invokeFloat("calculateFallbackWidth", helvetica(), "Hello");
|
||||
assertThat(w).isGreaterThan(0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("safeGetStringWidth returns 0 for null/empty inputs")
|
||||
void safeWidthZeroForEmpty() throws Exception {
|
||||
assertThat(invokeFloat("safeGetStringWidth", helvetica(), "")).isZero();
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"safeGetStringWidth", PDFont.class, String.class);
|
||||
m.setAccessible(true);
|
||||
assertThat((float) m.invoke(service, helvetica(), null)).isZero();
|
||||
assertThat((float) m.invoke(service, (PDFont) null, "x")).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("safeGetStringWidth returns a positive width for a reliable font")
|
||||
void safeWidthPositive() throws Exception {
|
||||
float w = invokeFloat("safeGetStringWidth", helvetica(), "Word");
|
||||
assertThat(w).isGreaterThan(0f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── createAlternativePlaceholder via reflection ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createAlternativePlaceholder via reflection")
|
||||
class AlternativePlaceholder {
|
||||
|
||||
@Test
|
||||
@DisplayName("Helvetica supports space, so output is a bounded run of spaces")
|
||||
void boundedSpaces() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createAlternativePlaceholder",
|
||||
String.class,
|
||||
float.class,
|
||||
PDFont.class,
|
||||
float.class);
|
||||
m.setAccessible(true);
|
||||
String result = (String) m.invoke(service, "hidden", 20f, helvetica(), FONT_SIZE);
|
||||
assertThat(result.chars().allMatch(c -> c == ' ')).isTrue();
|
||||
assertThat(result.length()).isLessThanOrEqualTo("hidden".length() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── extractTextSegments via reflection ───────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("extractTextSegments via reflection")
|
||||
class ExtractSegments {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
@DisplayName("a Tf operator sets font and size on the segments that follow it")
|
||||
void tfSetsFontAndSize() throws Exception {
|
||||
try (PDDocument doc = docWithRawContent("BT /F1 14 Tf 72 700 Td (hello) Tj ET")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> tokens = parseTokens(page);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextSegments", PDPage.class, List.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.TextSegment> segments =
|
||||
(List<TextRedactionService.TextSegment>) m.invoke(service, page, tokens);
|
||||
|
||||
assertThat(segments).isNotEmpty();
|
||||
TextRedactionService.TextSegment first = segments.get(0);
|
||||
assertThat(first.getText()).isEqualTo("hello");
|
||||
assertThat(first.getFontSize()).isEqualTo(14f);
|
||||
assertThat(first.getFont()).isNotNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-423
@@ -3,40 +3,24 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSFloat;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.SPDF.model.PDFText;
|
||||
|
||||
/**
|
||||
* Gap-coverage tests for {@link TextRedactionService} targeting branches the existing {@code
|
||||
* TextRedactionServiceTest} does not reach: TJ-array redaction with kerning adjustment, the {@code
|
||||
* '} and {@code "} text-showing operators, Form XObject content rewriting, multi-page / multi-match
|
||||
* find+replace, and the private TJ/segment helpers exercised directly via reflection.
|
||||
*/
|
||||
@DisplayName("TextRedactionService additional coverage")
|
||||
class TextRedactionServiceMoreTest {
|
||||
|
||||
@@ -50,201 +34,16 @@ class TextRedactionServiceMoreTest {
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object t;
|
||||
while ((t = parser.parseNextToken()) != null) {
|
||||
tokens.add(t);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private String tokensText(List<Object> tokens) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Object token : tokens) {
|
||||
if (token instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
} else if (token instanceof COSArray arr) {
|
||||
for (COSBase el : arr) {
|
||||
if (el instanceof COSString cs) {
|
||||
sb.append(cs.getString());
|
||||
}
|
||||
}
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
return helvetica();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single page whose content stream is exactly {@code rawContent}, font F1=Helvetica.
|
||||
*/
|
||||
private PDDocument docWithRawContent(String rawContent) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
PDResources resources = new PDResources();
|
||||
resources.put(COSName.getPDFName("F1"), helvetica());
|
||||
page.setResources(resources);
|
||||
|
||||
PDStream stream = new PDStream(doc);
|
||||
try (var out = stream.createOutputStream()) {
|
||||
out.write(rawContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(stream);
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── ' and " operators ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("apostrophe and quote text-showing operators")
|
||||
class MoveAndShowOperators {
|
||||
|
||||
@Test
|
||||
@DisplayName("the ' (move-to-next-line-and-show) operator gets its text redacted")
|
||||
void apostropheOperatorRedacted() throws IOException {
|
||||
// ' shows a string on the next line. Content: BT /F1 12 Tf 72 700 Td (PUBLIC) Tj
|
||||
// (SECRET) ' ET
|
||||
String raw = "BT /F1 12 Tf 72 700 Td (PUBLIC) Tj (SECRET) ' ET";
|
||||
try (PDDocument doc = docWithRawContent(raw)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
String text = tokensText(tokens);
|
||||
assertThat(text).doesNotContain("SECRET");
|
||||
assertThat(text).contains("PUBLIC");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the \" operator is collected as text-showing but its text is not extracted")
|
||||
void quoteOperatorNotExtracted() throws IOException {
|
||||
// " is in TEXT_SHOWING_OPERATORS, but extractTextFromToken's switch only handles
|
||||
// Tj/'/TJ, so a "-shown string yields no segment and survives. This pins that
|
||||
// behavior: the parse path runs without error and the token list is intact.
|
||||
String raw = "BT /F1 12 Tf 72 700 Td 1 2 (SECRET) \" ET";
|
||||
try (PDDocument doc = docWithRawContent(raw)) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> before = parseTokens(page);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertThat(tokens).hasSameSizeAs(before);
|
||||
assertThat(tokensText(tokens)).contains("SECRET");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TJ arrays with kerning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("TJ positioning arrays")
|
||||
class TjArrays {
|
||||
|
||||
@Test
|
||||
@DisplayName("partial match inside a TJ array redacts only the matched run")
|
||||
void tjArrayPartialRedaction() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
// showTextWithPositioning emits a single TJ array.
|
||||
cs.showTextWithPositioning(
|
||||
new Object[] {"keep ", -50f, "SECRET", 20f, " tail"});
|
||||
cs.endText();
|
||||
}
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
|
||||
boolean sawTj = tokens.stream().anyMatch(t -> t instanceof COSArray);
|
||||
assertThat(sawTj).as("expected a TJ array token").isTrue();
|
||||
assertThat(tokensText(tokens)).doesNotContain("SECRET");
|
||||
assertThat(tokensText(tokens)).contains("keep");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TJ array with no matching term is left unchanged")
|
||||
void tjArrayNoMatch() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showTextWithPositioning(new Object[] {"alpha ", -30f, "beta"});
|
||||
cs.endText();
|
||||
}
|
||||
List<Object> before = parseTokens(page);
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("ZZZ"), false, false);
|
||||
assertThat(tokens).hasSameSizeAs(before);
|
||||
assertThat(tokensText(tokens)).contains("alpha");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form XObject traversal ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("Form XObject content")
|
||||
class FormXObjects {
|
||||
|
||||
@Test
|
||||
@DisplayName("a referenced Form XObject containing a match is traversed and rewritten")
|
||||
void traversesFormXObject() throws IOException {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
|
||||
// Build a form XObject whose own content stream shows "SECRET".
|
||||
PDFormXObject form = new PDFormXObject(doc);
|
||||
form.setResources(new PDResources());
|
||||
form.getResources().put(COSName.getPDFName("F1"), helvetica());
|
||||
form.setBBox(new PDRectangle(0, 0, 200, 50));
|
||||
String formContent = "BT /F1 12 Tf 0 10 Td (SECRET) Tj ET";
|
||||
try (var out = form.getStream().createOutputStream()) {
|
||||
out.write(formContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
|
||||
PDResources pageResources = new PDResources();
|
||||
COSName formName = pageResources.add(form);
|
||||
page.setResources(pageResources);
|
||||
|
||||
String pageContent = "q 1 0 0 1 100 600 cm /" + formName.getName() + " Do Q";
|
||||
PDStream pageStream = new PDStream(doc);
|
||||
try (var out = pageStream.createOutputStream()) {
|
||||
out.write(pageContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1));
|
||||
}
|
||||
page.setContents(pageStream);
|
||||
|
||||
// Processing the page walks into the XObject graph; when a match is found inside
|
||||
// the
|
||||
// form, writeRedactedContentToXObject runs and sets a /Contents item on the form's
|
||||
// COS dictionary. Asserting that item appears proves the XObject redaction path
|
||||
// executed end-to-end without throwing.
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
|
||||
assertThat(tokens).isNotNull();
|
||||
assertThat(form.getCOSObject().containsKey(COSName.CONTENTS))
|
||||
.as("form XObject redaction path should have written a new content item")
|
||||
.isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── multi-page / multi-match public entry points ─────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findTextToRedact and performTextReplacement across pages")
|
||||
class MultiPage {
|
||||
@@ -256,7 +55,7 @@ class TextRedactionServiceMoreTest {
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y);
|
||||
cs.showText(line);
|
||||
@@ -326,225 +125,32 @@ class TextRedactionServiceMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ── private TJ / segment helpers via reflection ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("private helpers via reflection")
|
||||
class PrivateHelpers {
|
||||
|
||||
@Test
|
||||
@DisplayName("createRedactedTJArray replaces the matched substring inside the array")
|
||||
void createRedactedTjArray() throws Exception {
|
||||
COSArray original = new COSArray();
|
||||
original.add(new COSString("SECRET"));
|
||||
original.add(new COSFloat(-40f));
|
||||
original.add(new COSString(" tail"));
|
||||
|
||||
// Segment text is the concatenation "SECRET tail"; startPos 0.
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "TJ", "SECRET tail", 0, 11, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(0, 6)); // "SECRET"
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"createRedactedTJArray",
|
||||
COSArray.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
COSArray result = (COSArray) m.invoke(service, original, segment, matches);
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (COSBase el : result) {
|
||||
if (el instanceof COSString cs) sb.append(cs.getString());
|
||||
}
|
||||
assertThat(sb.toString()).doesNotContain("SECRET");
|
||||
assertThat(sb.toString()).contains("tail");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applyRedactionsToSegmentText swaps the matched span for a placeholder")
|
||||
void applyRedactionsToSegmentText() throws Exception {
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "keepSECRETkeep", 0, 14, helvetica(), FONT_SIZE);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
List.of(new TextRedactionService.MatchRange(4, 10)); // SECRET
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"applyRedactionsToSegmentText",
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
String out = (String) m.invoke(service, segment, matches);
|
||||
assertThat(out).doesNotContain("SECRET");
|
||||
assertThat(out).startsWith("keep");
|
||||
assertThat(out).endsWith("keep");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateWidthAdjustment returns 0 for a null-font segment")
|
||||
void widthAdjustmentNullFont() throws Exception {
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(0, "Tj", "abc", 0, 3, null, FONT_SIZE);
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"calculateWidthAdjustment",
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
float adj =
|
||||
(float)
|
||||
m.invoke(
|
||||
service,
|
||||
segment,
|
||||
List.of(new TextRedactionService.MatchRange(0, 3)));
|
||||
assertThat(adj).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calculateWidthAdjustment skips subset fonts (returns 0)")
|
||||
void widthAdjustmentSubsetFontSkipped() throws Exception {
|
||||
// A subset font name (6 uppercase letters + '+') trips the subset short-circuit.
|
||||
PDFont subsetNamed = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "ABCDEF", 0, 6, subsetNamed, FONT_SIZE);
|
||||
|
||||
// The real Helvetica name is not a subset, so this segment goes through the normal
|
||||
// calculation; assert the call is at least exception-free and finite.
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"calculateWidthAdjustment",
|
||||
TextRedactionService.TextSegment.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
float adj =
|
||||
(float)
|
||||
m.invoke(
|
||||
service,
|
||||
segment,
|
||||
List.of(new TextRedactionService.MatchRange(0, 6)));
|
||||
assertThat(Float.isFinite(adj)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("modifyTokenForRedaction with an out-of-range token index is a no-op")
|
||||
void modifyTokenOutOfRange() throws Exception {
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
tokens.add(new COSString("hello"));
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(
|
||||
99, "Tj", "hello", 0, 5, helvetica(), FONT_SIZE);
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"modifyTokenForRedaction",
|
||||
List.class,
|
||||
TextRedactionService.TextSegment.class,
|
||||
String.class,
|
||||
float.class,
|
||||
List.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, tokens, segment, "", 0f, List.of());
|
||||
|
||||
// Token list is untouched because index 99 is out of bounds.
|
||||
assertThat(tokens).hasSize(1);
|
||||
assertThat(((COSString) tokens.get(0)).getString()).isEqualTo("hello");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("buildCompleteText concatenates the text of all segments in order")
|
||||
void buildCompleteText() throws Exception {
|
||||
List<TextRedactionService.TextSegment> segments =
|
||||
List.of(
|
||||
new TextRedactionService.TextSegment(
|
||||
0, "Tj", "foo", 0, 3, helvetica(), FONT_SIZE),
|
||||
new TextRedactionService.TextSegment(
|
||||
1, "Tj", "bar", 3, 6, helvetica(), FONT_SIZE));
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod("buildCompleteText", List.class);
|
||||
m.setAccessible(true);
|
||||
assertThat(m.invoke(service, segments)).isEqualTo("foobar");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTextFromToken returns text for the \" operator")
|
||||
void extractTextFromQuoteOperator() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextFromToken", Object.class, String.class);
|
||||
m.setAccessible(true);
|
||||
// The " operator is not in the switch (Tj/'/TJ) -> default branch yields empty string.
|
||||
assertThat(m.invoke(service, new COSString("x"), "\"")).isEqualTo("");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithWidth additional branches ───────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithWidth reliable-font path")
|
||||
class PlaceholderWidthBranches {
|
||||
|
||||
@Test
|
||||
@DisplayName("reliable font with positive width yields a bounded run of spaces")
|
||||
void reliableFontBoundedSpaces() {
|
||||
PDFont font = helvetica();
|
||||
String original = "Secret";
|
||||
float targetWidth;
|
||||
try {
|
||||
targetWidth = font.getStringWidth(original) / 1000f * FONT_SIZE;
|
||||
} catch (IOException e) {
|
||||
targetWidth = 30f;
|
||||
}
|
||||
String placeholder =
|
||||
service.createPlaceholderWithWidth(original, targetWidth, font, FONT_SIZE);
|
||||
assertThat(placeholder).isNotEmpty();
|
||||
assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue();
|
||||
// spaceCount is capped at originalLength*2.
|
||||
assertThat(placeholder.length()).isLessThanOrEqualTo(original.length() * 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero target width falls back to alternative placeholder logic")
|
||||
void zeroTargetWidth() {
|
||||
PDFont font = helvetica();
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 0f, font, FONT_SIZE);
|
||||
// With a reliable, non-subset font and zero width, output is still all whitespace.
|
||||
assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// ── inner data classes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("ModificationTask / GraphicsState data classes")
|
||||
@DisplayName("TextSegment / MatchRange data classes")
|
||||
class DataClasses {
|
||||
|
||||
@Test
|
||||
@DisplayName("GraphicsState defaults are null font and zero size, mutators round-trip")
|
||||
void graphicsStateRoundTrip() throws Exception {
|
||||
Class<?> gsClass =
|
||||
Class.forName(
|
||||
"stirling.software.SPDF.controller.api.security.TextRedactionService$GraphicsState");
|
||||
var ctor = gsClass.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
Object gs = ctor.newInstance();
|
||||
@DisplayName("TextSegment exposes its constructor values via accessors")
|
||||
void textSegmentAccessors() {
|
||||
PDFont font = helvetica();
|
||||
TextRedactionService.TextSegment segment =
|
||||
new TextRedactionService.TextSegment(3, "Tj", "hello", 10, 15, font, 12f);
|
||||
|
||||
Method getFont = gsClass.getDeclaredMethod("getFont");
|
||||
Method getSize = gsClass.getDeclaredMethod("getFontSize");
|
||||
getFont.setAccessible(true);
|
||||
getSize.setAccessible(true);
|
||||
assertThat(getFont.invoke(gs)).isNull();
|
||||
assertThat((float) getSize.invoke(gs)).isZero();
|
||||
assertThat(segment.getTokenIndex()).isEqualTo(3);
|
||||
assertThat(segment.getOperatorName()).isEqualTo("Tj");
|
||||
assertThat(segment.getText()).isEqualTo("hello");
|
||||
assertThat(segment.getStartPos()).isEqualTo(10);
|
||||
assertThat(segment.getEndPos()).isEqualTo(15);
|
||||
assertThat(segment.getFont()).isSameAs(font);
|
||||
assertThat(segment.getFontSize()).isEqualTo(12f);
|
||||
}
|
||||
|
||||
Method setSize = gsClass.getDeclaredMethod("setFontSize", float.class);
|
||||
setSize.setAccessible(true);
|
||||
setSize.invoke(gs, 14f);
|
||||
assertThat((float) getSize.invoke(gs)).isEqualTo(14f);
|
||||
@Test
|
||||
@DisplayName("MatchRange exposes start and end positions")
|
||||
void matchRangeAccessors() {
|
||||
TextRedactionService.MatchRange range = new TextRedactionService.MatchRange(4, 9);
|
||||
assertThat(range.getStartPos()).isEqualTo(4);
|
||||
assertThat(range.getEndPos()).isEqualTo(9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-303
@@ -2,30 +2,21 @@ package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.contentstream.operator.Operator;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.pdfparser.PDFStreamParser;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -54,13 +45,23 @@ class TextRedactionServiceTest {
|
||||
return new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
}
|
||||
|
||||
private PDFont helvetica(PDDocument doc) throws IOException {
|
||||
try (InputStream is =
|
||||
getClass().getResourceAsStream("/type3/library/fonts/dejavu/DejaVuSans.ttf")) {
|
||||
if (is != null) {
|
||||
return PDType0Font.load(doc, is);
|
||||
}
|
||||
}
|
||||
return helvetica();
|
||||
}
|
||||
|
||||
/** Single page, single Tj line per supplied text line, Helvetica 12. */
|
||||
private PDDocument buildDoc(String... lines) throws IOException {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
doc.addPage(page);
|
||||
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
|
||||
cs.setFont(helvetica(), FONT_SIZE);
|
||||
cs.setFont(helvetica(doc), FONT_SIZE);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
cs.beginText();
|
||||
cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f);
|
||||
@@ -77,32 +78,6 @@ class TextRedactionServiceTest {
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── isTextShowingOperator ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("isTextShowingOperator")
|
||||
class IsTextShowingOperator {
|
||||
|
||||
@Test
|
||||
@DisplayName("recognises the four text-showing operators")
|
||||
void recognisesTextShowingOperators() {
|
||||
assertTrue(service.isTextShowingOperator("Tj"));
|
||||
assertTrue(service.isTextShowingOperator("TJ"));
|
||||
assertTrue(service.isTextShowingOperator("'"));
|
||||
assertTrue(service.isTextShowingOperator("\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects non text-showing operators and junk")
|
||||
void rejectsOthers() {
|
||||
assertFalse(service.isTextShowingOperator("BT"));
|
||||
assertFalse(service.isTextShowingOperator("ET"));
|
||||
assertFalse(service.isTextShowingOperator("Tf"));
|
||||
assertFalse(service.isTextShowingOperator(""));
|
||||
assertFalse(service.isTextShowingOperator("tj"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── findTextToRedact ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@@ -253,192 +228,6 @@ class TextRedactionServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
// ── detectCustomEncodingFonts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("detectCustomEncodingFonts")
|
||||
class DetectCustomEncodingFonts {
|
||||
|
||||
@Test
|
||||
@DisplayName("standard Helvetica document is not flagged as custom-encoded")
|
||||
void standardFontNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildDoc("plain helvetica text")) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("document with no content / no fonts is not flagged")
|
||||
void emptyDocumentNotFlagged() throws IOException {
|
||||
try (PDDocument doc = buildEmptyDoc()) {
|
||||
assertFalse(service.detectCustomEncodingFonts(doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithFont ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithFont")
|
||||
class CreatePlaceholderWithFont {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithFont(null, helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithFont("", helvetica()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-subset font yields spaces matching the original length")
|
||||
void nonSubsetFontYieldsMatchingSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("hidden", helvetica());
|
||||
assertEquals(" ".repeat("hidden".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font is treated as non-subset and yields spaces")
|
||||
void nullFontYieldsSpaces() {
|
||||
String placeholder = service.createPlaceholderWithFont("abc", null);
|
||||
assertEquals(" ", placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
// ── createPlaceholderWithWidth ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createPlaceholderWithWidth")
|
||||
class CreatePlaceholderWithWidth {
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for null")
|
||||
void nullReturnsNull() {
|
||||
assertNull(service.createPlaceholderWithWidth(null, 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns the input unchanged for empty string")
|
||||
void emptyReturnsEmpty() {
|
||||
assertEquals("", service.createPlaceholderWithWidth("", 10f, helvetica(), FONT_SIZE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null font falls back to one space per original character")
|
||||
void nullFontFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, null, FONT_SIZE);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-positive font size falls back to one space per original character")
|
||||
void nonPositiveFontSizeFallsBackToSpaces() {
|
||||
String placeholder = service.createPlaceholderWithWidth("word", 50f, helvetica(), 0f);
|
||||
assertEquals(" ".repeat("word".length()), placeholder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("standard font produces a non-null all-whitespace placeholder")
|
||||
void standardFontProducesWhitespacePlaceholder() {
|
||||
PDFont font = helvetica();
|
||||
float fontSize = FONT_SIZE;
|
||||
String original = "Secret";
|
||||
// Compute a realistic target width the way the service does (text-space / 1000 * size).
|
||||
float targetWidth;
|
||||
try {
|
||||
targetWidth = font.getStringWidth(original) / 1000f * fontSize;
|
||||
} catch (IOException e) {
|
||||
targetWidth = 30f;
|
||||
}
|
||||
|
||||
String placeholder =
|
||||
service.createPlaceholderWithWidth(original, targetWidth, font, fontSize);
|
||||
|
||||
assertNotNull(placeholder);
|
||||
assertFalse(placeholder.isEmpty(), "Helvetica supports spaces, so non-empty expected");
|
||||
assertTrue(
|
||||
placeholder.chars().allMatch(c -> c == ' '),
|
||||
"placeholder should be composed only of spaces");
|
||||
}
|
||||
}
|
||||
|
||||
// ── createTokensWithoutTargetText / writeFilteredContentStream
|
||||
// ────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("createTokensWithoutTargetText")
|
||||
class CreateTokensWithoutTargetText {
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"returns a non-empty token list and preserves token count when nothing matches")
|
||||
void noMatchPreservesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("nothing to hide")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("ABSENT"), false, false);
|
||||
|
||||
assertNotNull(tokens);
|
||||
assertEquals(
|
||||
originalTokens.size(),
|
||||
tokens.size(),
|
||||
"token count should be unchanged when nothing matched");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filtered tokens can be written back and the page re-parses cleanly")
|
||||
void filteredTokensRoundTrip() throws IOException {
|
||||
try (PDDocument doc = buildDoc("redact SECRET token roundtrip")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Set.of("SECRET"), false, false);
|
||||
assertNotNull(tokens);
|
||||
|
||||
service.writeFilteredContentStream(doc, page, tokens);
|
||||
|
||||
// The page must still hold valid content (at least one operator token).
|
||||
List<Object> reparsed = parseTokens(page);
|
||||
boolean hasOperator = reparsed.stream().anyMatch(t -> t instanceof Operator);
|
||||
assertTrue(hasOperator, "rewritten content stream must contain operators");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty target-word set leaves tokens untouched")
|
||||
void emptyTargetSetLeavesTokens() throws IOException {
|
||||
try (PDDocument doc = buildDoc("some content")) {
|
||||
PDPage page = doc.getPage(0);
|
||||
List<Object> originalTokens = parseTokens(page);
|
||||
|
||||
List<Object> tokens =
|
||||
service.createTokensWithoutTargetText(
|
||||
doc, page, Collections.emptySet(), false, false);
|
||||
|
||||
assertEquals(originalTokens.size(), tokens.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> parseTokens(PDPage page) throws IOException {
|
||||
PDFStreamParser parser = new PDFStreamParser(page);
|
||||
List<Object> tokens = new ArrayList<>();
|
||||
Object token;
|
||||
while ((token = parser.parseNextToken()) != null) {
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// ── inner data classes ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@@ -484,83 +273,4 @@ class TextRedactionServiceTest {
|
||||
assertFalse(a.equals(b));
|
||||
}
|
||||
}
|
||||
|
||||
// ── private logic exercised via reflection ───────────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
@DisplayName("findAllMatches / buildCompleteText (private logic via reflection)")
|
||||
class PrivateLogic {
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns sorted, non-overlapping match ranges for two terms")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesSorted() throws Exception {
|
||||
String complete = "alpha beta gamma beta";
|
||||
Set<String> terms = new LinkedHashSet<>(List.of("beta", "alpha"));
|
||||
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, complete, terms, false, false);
|
||||
|
||||
assertNotNull(matches);
|
||||
assertFalse(matches.isEmpty());
|
||||
// Results are sorted by start position.
|
||||
for (int i = 1; i < matches.size(); i++) {
|
||||
assertTrue(
|
||||
matches.get(i - 1).getStartPos() <= matches.get(i).getStartPos(),
|
||||
"matches must be sorted ascending by start position");
|
||||
}
|
||||
// "alpha" at 0, "beta" at 6 and 17 -> three matches total.
|
||||
assertEquals(3, matches.size());
|
||||
assertEquals(0, matches.get(0).getStartPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("findAllMatches returns nothing when no term occurs")
|
||||
@SuppressWarnings("unchecked")
|
||||
void findAllMatchesEmptyWhenAbsent() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"findAllMatches",
|
||||
String.class,
|
||||
Set.class,
|
||||
boolean.class,
|
||||
boolean.class);
|
||||
m.setAccessible(true);
|
||||
List<TextRedactionService.MatchRange> matches =
|
||||
(List<TextRedactionService.MatchRange>)
|
||||
m.invoke(service, "no terms here", Set.of("XYZ"), false, false);
|
||||
assertTrue(matches.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extractTextFromToken pulls text from Tj COSString and TJ COSArray")
|
||||
void extractTextFromToken() throws Exception {
|
||||
Method m =
|
||||
TextRedactionService.class.getDeclaredMethod(
|
||||
"extractTextFromToken", Object.class, String.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "Tj"));
|
||||
assertEquals("hi", m.invoke(service, new COSString("hi"), "'"));
|
||||
|
||||
COSArray tjArray = new COSArray();
|
||||
tjArray.add(new COSString("foo"));
|
||||
tjArray.add(new COSString("bar"));
|
||||
assertEquals("foobar", m.invoke(service, tjArray, "TJ"));
|
||||
|
||||
// Unknown operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSString("x"), "Td"));
|
||||
// Wrong token type for the operator yields empty string.
|
||||
assertEquals("", m.invoke(service, new COSArray(), "Tj"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user