diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java index c280264838..7a186235cd 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactController.java @@ -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 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 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> 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> 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()); - } - } } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java index a9633ffa39..9cf4e6c700 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TextRedactionService.java @@ -1,33 +1,20 @@ package stirling.software.SPDF.controller.api.security; +import java.io.File; import java.io.IOException; -import java.util.ArrayList; +import java.nio.file.Files; import java.util.Arrays; -import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; import java.util.stream.Collectors; -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.COSNumber; -import org.apache.pdfbox.cos.COSString; -import org.apache.pdfbox.pdfparser.PDFStreamParser; -import org.apache.pdfbox.pdfwriter.ContentStreamWriter; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.multipdf.PDFMergerUtility; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDResources; -import org.apache.pdfbox.pdmodel.common.PDStream; import org.apache.pdfbox.pdmodel.font.PDFont; -import org.apache.pdfbox.pdmodel.graphics.PDXObject; -import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.springframework.stereotype.Service; import lombok.AllArgsConstructor; @@ -35,31 +22,23 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.PDFText; -import stirling.software.SPDF.utils.text.TextEncodingHelper; import stirling.software.SPDF.utils.text.TextFinderUtils; -import stirling.software.SPDF.utils.text.WidthCalculator; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.redact.PdfRedactor; +import stirling.software.jpdfium.redact.RedactOptions; +import stirling.software.jpdfium.redact.RedactResult; @Service @Slf4j class TextRedactionService { - private static final int MAX_XOBJECT_DEPTH = 10; - private static final float PRECISION_THRESHOLD = 1e-3f; - private static final int FONT_SCALE_FACTOR = 1000; - private static final Set TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\""); - private static final COSString EMPTY_COS_STRING = new COSString(""); - - // ----------------------------------------------------------------------- - // Public API - // ----------------------------------------------------------------------- - Map> findTextToRedact( PDDocument document, String[] listOfText, boolean useRegex, boolean wholeWordSearch) { Set terms = Arrays.stream(listOfText) .map(String::trim) - .filter(s -> !s.isEmpty()) + .filter(s -> !s.isEmpty() && s.length() <= 4096) .collect(Collectors.toSet()); if (terms.isEmpty()) { @@ -98,1086 +77,99 @@ class TextRedactionService { String[] listOfText, boolean useRegex, boolean wholeWordSearchBool) { - if (allFoundTextsByPage.isEmpty()) { + if (allFoundTextsByPage == null || allFoundTextsByPage.isEmpty()) { return false; } - if (detectCustomEncodingFonts(document)) { + List terms = + Arrays.stream(listOfText) + .map(String::trim) + .filter(s -> !s.isEmpty() && s.length() <= 4096) + .toList(); + + if (terms.isEmpty()) { + return false; + } + + File tempIn = null; + File tempOut = null; + try { + tempIn = File.createTempFile("jpdfium_redact_in_", ".pdf"); + tempOut = File.createTempFile("jpdfium_redact_out_", ".pdf"); + + document.save(tempIn); + + RedactOptions options = + RedactOptions.builder() + .addWords(terms) + .useRegex(useRegex) + .wholeWord(wholeWordSearchBool) + .boxColor(0) + .removeContent(true) + .normalizeFonts(false) + .fixToUnicode(false) + .repairWidths(false) + .glyphAware(true) + .build(); + + try (PdfDocument checkDoc = PdfDocument.open(tempIn.toPath())) { + if (checkDoc.pageCount() <= 0) { + return true; + } + } + + log.debug("Calling JPDFium PdfRedactor.redact (terms={})", terms); + RedactResult result = PdfRedactor.redact(tempIn.toPath(), options); + log.debug( + "JPDFium PdfRedactor.redact complete (matches={})", + result != null ? result.totalMatches() : -1); + if (result == null) { + log.warn( + "JPDFium PdfRedactor.redact returned null result, falling back to box-only redaction mode"); + return true; + } + + try { + result.save(tempOut.toPath()); + } finally { + if (result.document() != null) { + result.document().close(); + } + } + + try (PDDocument redactedDoc = Loader.loadPDF(tempOut)) { + while (document.getNumberOfPages() > 0) { + document.removePage(0); + } + PDFMergerUtility merger = new PDFMergerUtility(); + merger.appendDocument(document, redactedDoc); + } + + log.info("JPDFium text replacement complete: {} total matches", result.totalMatches()); + return false; + } catch (Exception e) { log.warn( - "Custom encoded fonts detected (non-standard encodings / DictionaryEncoding / damaged fonts). " - + "Text replacement is unreliable for these fonts. Falling back to box-only redaction mode."); - return true; - } - - try { - Set allSearchTerms = - Arrays.stream(listOfText) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - int pageCount = 0; - for (PDPage page : document.getPages()) { - pageCount++; - List filteredTokens = - createTokensWithoutTargetText( - document, page, allSearchTerms, useRegex, wholeWordSearchBool); - writeFilteredContentStream(document, page, filteredTokens); - } - log.info("Successfully performed text replacement redaction on {} pages.", pageCount); - return false; - } catch (Exception e) { - log.error( - "Text replacement redaction failed due to font or encoding issues. " - + "Will fall back to box-only redaction mode. Error: {}", + "JPDFium native text replacement failed, falling back to box-only redaction mode: {}", e.getMessage()); return true; - } - } - - // ----------------------------------------------------------------------- - // Content stream manipulation - // ----------------------------------------------------------------------- - - List createTokensWithoutTargetText( - PDDocument document, - PDPage page, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch) - throws IOException { - - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object token; - while ((token = parser.parseNextToken()) != null) { - tokens.add(token); - } - - PDResources resources = page.getResources(); - if (resources != null) { - processPageXObjects(document, resources, targetWords, useRegex, wholeWordSearch); - } - - List textSegments = extractTextSegments(page, tokens); - String completeText = buildCompleteText(textSegments); - List matches = - findAllMatches(completeText, targetWords, useRegex, wholeWordSearch); - - return applyRedactionsToTokens(tokens, textSegments, matches); - } - - void writeFilteredContentStream(PDDocument document, PDPage page, List tokens) - throws IOException { - - PDStream newStream = new PDStream(document); - - try { - try (var out = newStream.createOutputStream()) { - ContentStreamWriter writer = new ContentStreamWriter(out); - writer.writeTokens(tokens); - } - page.setContents(newStream); - } catch (IOException e) { - throw new IOException("Failed to write filtered content stream to page", e); - } - } - - boolean isTextShowingOperator(String opName) { - return TEXT_SHOWING_OPERATORS.contains(opName); - } - - boolean detectCustomEncodingFonts(PDDocument document) { - try { - var documentCatalog = document.getDocumentCatalog(); - if (documentCatalog == null) { - return false; - } - - int totalFonts = 0; - int customEncodedFonts = 0; - int subsetFonts = 0; - int unreliableFonts = 0; - - for (PDPage page : document.getPages()) { - if (TextFinderUtils.hasProblematicFonts(page)) { - log.debug("Page contains fonts flagged as problematic by TextFinderUtils"); - } - - PDResources resources = page.getResources(); - if (resources == null) { - continue; - } - - for (COSName fontName : resources.getFontNames()) { - try { - PDFont font = resources.getFont(fontName); - if (font != null) { - totalFonts++; - - boolean isSubset = TextEncodingHelper.isFontSubset(font.getName()); - boolean hasCustomEncoding = TextEncodingHelper.hasCustomEncoding(font); - boolean isReliable = WidthCalculator.isWidthCalculationReliable(font); - boolean canCalculateWidths = - TextEncodingHelper.canCalculateBasicWidths(font); - - if (isSubset) { - subsetFonts++; - } - if (hasCustomEncoding) { - customEncodedFonts++; - log.debug("Font {} has custom encoding", font.getName()); - } - if (!isReliable || !canCalculateWidths) { - unreliableFonts++; - log.debug( - "Font {} flagged as unreliable: reliable={}, canCalculateWidths={}", - font.getName(), - isReliable, - canCalculateWidths); - } - if (!TextFinderUtils.validateFontReliability(font)) { - log.debug( - "Font {} failed comprehensive reliability check", - font.getName()); - } - } - } catch (Exception e) { - log.debug( - "Font loading/analysis failed for {}: {}", - fontName.getName(), - e.getMessage()); - customEncodedFonts++; - unreliableFonts++; - totalFonts++; - } - } - } - - log.info( - "Enhanced font analysis: {}/{} custom encoding, {}/{} subset, {}/{} unreliable fonts", - customEncodedFonts, - totalFonts, - subsetFonts, - totalFonts, - unreliableFonts, - totalFonts); - - return customEncodedFonts > 0 || unreliableFonts > 0; - - } catch (Exception e) { - log.warn("Enhanced font detection analysis failed: {}", e.getMessage()); - return true; - } - } - - // ----------------------------------------------------------------------- - // Placeholder creation - // ----------------------------------------------------------------------- - - String createPlaceholderWithFont(String originalWord, PDFont font) { - if (originalWord == null || originalWord.isEmpty()) { - return originalWord; - } - - if (font != null && TextEncodingHelper.isFontSubset(font.getName())) { - try { - float originalWidth = safeGetStringWidth(font, originalWord) / FONT_SCALE_FACTOR; - return createAlternativePlaceholder(originalWord, originalWidth, font, 1.0f); - } catch (Exception e) { - log.debug( - "Subset font placeholder creation failed for {}: {}", - font.getName(), - e.getMessage()); - return ""; - } - } - - return " ".repeat(originalWord.length()); - } - - String createPlaceholderWithWidth( - String originalWord, float targetWidth, PDFont font, float fontSize) { - if (originalWord == null || originalWord.isEmpty()) { - return originalWord; - } - - if (font == null || fontSize <= 0) { - return " ".repeat(originalWord.length()); - } - - try { - if (!WidthCalculator.isWidthCalculationReliable(font)) { - log.debug( - "Font {} unreliable for width calculation, using simple placeholder", - font.getName()); - return " ".repeat(originalWord.length()); - } - - if (TextEncodingHelper.isFontSubset(font.getName())) { - return createSubsetFontPlaceholder(originalWord, targetWidth, font, fontSize); - } - - float spaceWidth = WidthCalculator.calculateAccurateWidth(font, " ", fontSize); - - if (spaceWidth <= 0) { - return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); - } - - int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth)); - int maxSpaces = - Math.max( - originalWord.length() * 2, Math.round(targetWidth / spaceWidth * 1.5f)); - spaceCount = Math.min(spaceCount, maxSpaces); - - return " ".repeat(spaceCount); - - } catch (Exception e) { - log.debug("Enhanced placeholder creation failed: {}", e.getMessage()); - return createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); - } - } - - private String createSubsetFontPlaceholder( - String originalWord, float targetWidth, PDFont font, float fontSize) { - try { - log.debug("Subset font {} - trying to find replacement characters", font.getName()); - String result = createAlternativePlaceholder(originalWord, targetWidth, font, fontSize); - - if (result.isEmpty()) { - log.debug( - "Subset font {} has no suitable replacement characters, using empty string", - font.getName()); - } - - return result; - - } catch (Exception e) { - log.debug("Subset font placeholder creation failed: {}", e.getMessage()); - return ""; - } - } - - private String createAlternativePlaceholder( - String originalWord, float targetWidth, PDFont font, float fontSize) { - try { - String[] alternatives = {" ", ".", "-", "_", "~", "°", "·"}; - - if (TextEncodingHelper.fontSupportsCharacter(font, " ")) { - float spaceWidth = safeGetStringWidth(font, " ") / FONT_SCALE_FACTOR * fontSize; - if (spaceWidth > 0) { - int spaceCount = Math.max(1, Math.round(targetWidth / spaceWidth)); - int maxSpaces = originalWord.length() * 2; - spaceCount = Math.min(spaceCount, maxSpaces); - log.debug("Using spaces for font {}", font.getName()); - return " ".repeat(spaceCount); - } - } - - for (String altChar : alternatives) { - if (" ".equals(altChar)) continue; - - try { - if (!TextEncodingHelper.fontSupportsCharacter(font, altChar)) { - continue; - } - - float charWidth = - safeGetStringWidth(font, altChar) / FONT_SCALE_FACTOR * fontSize; - if (charWidth > 0) { - int charCount = Math.max(1, Math.round(targetWidth / charWidth)); - int maxChars = originalWord.length() * 2; - charCount = Math.min(charCount, maxChars); - log.debug( - "Using character '{}' for width calculation but spaces for placeholder in font {}", - altChar, - font.getName()); - return " ".repeat(charCount); - } - } catch (Exception e) { - // try next alternative - } - } - - log.debug( - "All placeholder alternatives failed for font {}, using empty string", - font.getName()); - return ""; - - } catch (Exception e) { - log.debug("Alternative placeholder creation failed: {}", e.getMessage()); - return ""; - } - } - - // ----------------------------------------------------------------------- - // Width calculation - // ----------------------------------------------------------------------- - - private float safeGetStringWidth(PDFont font, String text) { - if (font == null || text == null || text.isEmpty()) { - return 0; - } - - if (!WidthCalculator.isWidthCalculationReliable(font)) { - log.debug( - "Font {} flagged as unreliable for width calculation, using fallback", - font.getName()); - return calculateConservativeWidth(font, text); - } - - if (!TextEncodingHelper.canEncodeCharacters(font, text)) { - log.debug( - "Text cannot be encoded by font {}, using character-based fallback", - font.getName()); - return calculateCharacterBasedWidth(font, text); - } - - try { - float width = font.getStringWidth(text); - log.debug("Direct width calculation successful for '{}': {}", text, width); - return width; - - } catch (Exception e) { - log.debug( - "Direct width calculation failed for font {}: {}", - font.getName(), - e.getMessage()); - return calculateFallbackWidth(font, text); - } - } - - private float calculateCharacterBasedWidth(PDFont font, String text) { - try { - float totalWidth = 0; - for (int i = 0; i < text.length(); i++) { - String character = text.substring(i, i + 1); - try { - if (!TextEncodingHelper.fontSupportsCharacter(font, character)) { - totalWidth += font.getAverageFontWidth(); - continue; - } - - byte[] encoded = font.encode(character); - if (encoded.length > 0) { - int glyphCode = encoded[0] & 0xFF; - float glyphWidth = font.getWidth(glyphCode); - - if (glyphWidth == 0) { - try { - glyphWidth = font.getWidthFromFont(glyphCode); - } catch (Exception e2) { - glyphWidth = font.getAverageFontWidth(); - } - } - - totalWidth += glyphWidth; - } else { - totalWidth += font.getAverageFontWidth(); - } - } catch (Exception e2) { - totalWidth += font.getAverageFontWidth(); - } - } - - log.debug("Character-based width calculation: {}", totalWidth); - return totalWidth; - - } catch (Exception e) { - log.debug("Character-based width calculation failed: {}", e.getMessage()); - return calculateConservativeWidth(font, text); - } - } - - private float calculateFallbackWidth(PDFont font, String text) { - try { - if (font.getFontDescriptor() != null - && font.getFontDescriptor().getFontBoundingBox() != null) { - - org.apache.pdfbox.pdmodel.common.PDRectangle bbox = - font.getFontDescriptor().getFontBoundingBox(); - float avgCharWidth = bbox.getWidth() * 0.6f; - float fallbackWidth = text.length() * avgCharWidth; - - log.debug("Bounding box fallback width: {}", fallbackWidth); - return fallbackWidth; - } - - try { - float avgWidth = font.getAverageFontWidth(); - if (avgWidth > 0) { - float fallbackWidth = text.length() * avgWidth; - log.debug("Average width fallback: {}", fallbackWidth); - return fallbackWidth; - } - } catch (Exception e2) { - log.debug("Average font width calculation failed: {}", e2.getMessage()); - } - - return calculateConservativeWidth(font, text); - - } catch (Exception e) { - log.debug("Fallback width calculation failed: {}", e.getMessage()); - return calculateConservativeWidth(font, text); - } - } - - private float calculateConservativeWidth(PDFont font, String text) { - float conservativeWidth = text.length() * 500f; - log.debug( - "Conservative width estimate for font {} text '{}': {}", - font.getName(), - text, - conservativeWidth); - return conservativeWidth; - } - - private float calculateWidthAdjustment(TextSegment segment, List matches) { - try { - if (segment.getFont() == null || segment.getFontSize() <= 0) { - return 0; - } - - String fontName = segment.getFont().getName(); - if (fontName != null - && (fontName.contains("HOEPAP") || TextEncodingHelper.isFontSubset(fontName))) { - log.debug("Skipping width adjustment for problematic/subset font: {}", fontName); - return 0; - } - - float totalOriginal = 0; - float totalPlaceholder = 0; - String text = segment.getText(); - - for (MatchRange match : matches) { - int segStart = Math.max(0, match.getStartPos() - segment.getStartPos()); - int segEnd = Math.min(text.length(), match.getEndPos() - segment.getStartPos()); - - if (segStart < text.length() && segEnd > segStart) { - String originalPart = text.substring(segStart, segEnd); - - float originalWidth = - safeGetStringWidth(segment.getFont(), originalPart) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - - String placeholderPart = - createPlaceholderWithWidth( - originalPart, - originalWidth, - segment.getFont(), - segment.getFontSize()); - - float origUnits = safeGetStringWidth(segment.getFont(), originalPart); - float placeUnits = safeGetStringWidth(segment.getFont(), placeholderPart); - - float orig = (origUnits / FONT_SCALE_FACTOR) * segment.getFontSize(); - float place = (placeUnits / FONT_SCALE_FACTOR) * segment.getFontSize(); - - totalOriginal += orig; - totalPlaceholder += place; - } - } - - float adjustment = totalOriginal - totalPlaceholder; - - float maxReasonableAdjustment = - Math.max( - segment.getText().length() * segment.getFontSize() * 2, - totalOriginal * 1.5f); - - if (Math.abs(adjustment) > maxReasonableAdjustment) { - log.debug( - "Width adjustment {} seems unreasonable for text length {}, capping to 0", - adjustment, - segment.getText().length()); - return 0; - } - - return adjustment; - } catch (Exception ex) { - log.debug("Width adjustment failed: {}", ex.getMessage()); - return 0; - } - } - - // ----------------------------------------------------------------------- - // Token and segment operations - // ----------------------------------------------------------------------- - - private void processPageXObjects( - PDDocument document, - PDResources resources, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch) { - processPageXObjects( - document, resources, targetWords, useRegex, wholeWordSearch, 0, new HashSet<>()); - } - - private void processPageXObjects( - PDDocument document, - PDResources resources, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch, - int depth, - Set visited) { - - if (depth > MAX_XOBJECT_DEPTH) { - log.warn("[redact] XObject nesting depth {} exceeded limit, stopping traversal", depth); - return; - } - - for (COSName xobjName : resources.getXObjectNames()) { - try { - PDXObject xobj = resources.getXObject(xobjName); - if (xobj instanceof PDFormXObject formXObj) { - if (!visited.add(formXObj.getCOSObject())) { - log.debug( - "[redact] Cycle detected in XObject graph, skipping {}", - xobjName.getName()); - continue; - } - processFormXObject( - document, - formXObj, - targetWords, - useRegex, - wholeWordSearch, - depth + 1, - visited); - log.debug("Processed Form XObject: {}", xobjName.getName()); - } - } catch (Exception e) { - log.warn("Failed to process XObject {}: {}", xobjName.getName(), e.getMessage()); - } - } - } - - private void processFormXObject( - PDDocument document, - PDFormXObject formXObject, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch, - int depth, - Set visited) { - - try { - PDResources xobjResources = formXObject.getResources(); - if (xobjResources == null) { - return; - } - - processPageXObjects( - document, - xobjResources, - targetWords, - useRegex, - wholeWordSearch, - depth, - visited); - - PDFStreamParser parser = new PDFStreamParser(formXObject); - List tokens = new ArrayList<>(); - Object token; - while ((token = parser.parseNextToken()) != null) { - tokens.add(token); - } - - List textSegments = extractTextSegmentsFromXObject(xobjResources, tokens); - String completeText = buildCompleteText(textSegments); - List matches = - findAllMatches(completeText, targetWords, useRegex, wholeWordSearch); - - if (!matches.isEmpty()) { - List redactedTokens = - applyRedactionsToTokens(tokens, textSegments, matches); - writeRedactedContentToXObject(document, formXObject, redactedTokens); - log.debug("Processed {} redactions in Form XObject", matches.size()); - } - - } catch (Exception e) { - log.warn("Failed to process Form XObject: {}", e.getMessage()); - } - } - - private void writeRedactedContentToXObject( - PDDocument document, PDFormXObject formXObject, List redactedTokens) - throws IOException { - - PDStream newStream = new PDStream(document); - - try (var out = newStream.createOutputStream()) { - ContentStreamWriter writer = new ContentStreamWriter(out); - writer.writeTokens(redactedTokens); - } - - formXObject.getCOSObject().removeItem(COSName.CONTENTS); - formXObject.getCOSObject().setItem(COSName.CONTENTS, newStream.getCOSObject()); - } - - private List extractTextSegments(PDPage page, List tokens) { - List segments = new ArrayList<>(); - int currentTextPos = 0; - GraphicsState graphicsState = new GraphicsState(); - PDResources resources = page.getResources(); - - for (int i = 0; i < tokens.size(); i++) { - Object currentToken = tokens.get(i); - - if (currentToken instanceof Operator op) { - String opName = op.getName(); - - if ("Tf".equals(opName) && i >= 2) { - try { - COSName fontName = (COSName) tokens.get(i - 2); - COSBase fontSizeBase = (COSBase) tokens.get(i - 1); - if (fontSizeBase instanceof COSNumber cosNumber) { - graphicsState.setFont(resources.getFont(fontName)); - graphicsState.setFontSize(cosNumber.floatValue()); - } - } catch (ClassCastException | IOException e) { - log.debug( - "Failed to extract font and font size from Tf operator: {}", - e.getMessage()); - } - } - - currentTextPos = - getCurrentTextPos( - tokens, segments, currentTextPos, graphicsState, i, opName); - } - } - - return segments; - } - - private List extractTextSegmentsFromXObject( - PDResources resources, List tokens) { - List segments = new ArrayList<>(); - int currentTextPos = 0; - GraphicsState graphicsState = new GraphicsState(); - - for (int i = 0; i < tokens.size(); i++) { - Object currentToken = tokens.get(i); - - if (currentToken instanceof Operator op) { - String opName = op.getName(); - - if ("Tf".equals(opName) && i >= 2) { - try { - COSName fontName = (COSName) tokens.get(i - 2); - COSBase fontSizeBase = (COSBase) tokens.get(i - 1); - if (fontSizeBase instanceof COSNumber cosNumber) { - graphicsState.setFont(resources.getFont(fontName)); - graphicsState.setFontSize(cosNumber.floatValue()); - } - } catch (ClassCastException | IOException e) { - log.debug("Font extraction failed in XObject: {}", e.getMessage()); - } - } - - currentTextPos = - getCurrentTextPos( - tokens, segments, currentTextPos, graphicsState, i, opName); - } - } - - return segments; - } - - private int getCurrentTextPos( - List tokens, - List segments, - int currentTextPos, - GraphicsState graphicsState, - int i, - String opName) { - if (isTextShowingOperator(opName) && i > 0) { - String textContent = extractTextFromToken(tokens.get(i - 1), opName); - if (!textContent.isEmpty()) { - segments.add( - new TextSegment( - i - 1, - opName, - textContent, - currentTextPos, - currentTextPos + textContent.length(), - graphicsState.font, - graphicsState.fontSize)); - currentTextPos += textContent.length(); - } - } - return currentTextPos; - } - - private String buildCompleteText(List segments) { - StringBuilder sb = new StringBuilder(); - for (TextSegment segment : segments) { - sb.append(segment.text); - } - return sb.toString(); - } - - private List findAllMatches( - String completeText, - Set targetWords, - boolean useRegex, - boolean wholeWordSearch) { - - List patterns = - TextFinderUtils.createOptimizedSearchPatterns( - targetWords, useRegex, wholeWordSearch); - - return patterns.stream() - .flatMap( - pattern -> { - try { - return pattern.matcher(completeText).results(); - } catch (Exception e) { - log.debug( - "Pattern matching failed for pattern {}: {}", - pattern.pattern(), - e.getMessage()); - return java.util.stream.Stream.empty(); - } - }) - .map(matchResult -> new MatchRange(matchResult.start(), matchResult.end())) - .sorted(Comparator.comparingInt(MatchRange::getStartPos)) - .collect(Collectors.toList()); - } - - private List applyRedactionsToTokens( - List tokens, List textSegments, List matches) { - - long startTime = System.currentTimeMillis(); - - try { - List newTokens = new ArrayList<>(tokens); - - Map> matchesBySegment = new HashMap<>(); - for (MatchRange match : matches) { - for (int i = 0; i < textSegments.size(); i++) { - TextSegment segment = textSegments.get(i); - int overlapStart = Math.max(match.startPos, segment.startPos); - int overlapEnd = Math.min(match.endPos, segment.endPos); - if (overlapStart < overlapEnd) { - matchesBySegment.computeIfAbsent(i, k -> new ArrayList<>()).add(match); - } - } - } - - List tasks = new ArrayList<>(); - for (Map.Entry> entry : matchesBySegment.entrySet()) { - int segmentIndex = entry.getKey(); - List segmentMatches = entry.getValue(); - TextSegment segment = textSegments.get(segmentIndex); - - if ("Tj".equals(segment.operatorName) || "'".equals(segment.operatorName)) { - String newText = applyRedactionsToSegmentText(segment, segmentMatches); - try { - float adjustment = calculateWidthAdjustment(segment, segmentMatches); - tasks.add(new ModificationTask(segment, newText, adjustment)); - } catch (Exception e) { - log.debug( - "Width adjustment calculation failed for segment: {}", - e.getMessage()); - } - } else if ("TJ".equals(segment.operatorName)) { - tasks.add(new ModificationTask(segment, null, 0)); - } - } - - tasks.sort((a, b) -> Integer.compare(b.segment.tokenIndex, a.segment.tokenIndex)); - - for (ModificationTask task : tasks) { - List segmentMatches = - matchesBySegment.getOrDefault( - textSegments.indexOf(task.segment), - java.util.Collections.emptyList()); - modifyTokenForRedaction( - newTokens, task.segment, task.newText, task.adjustment, segmentMatches); - } - - return newTokens; - } finally { - long processingTime = System.currentTimeMillis() - startTime; - log.debug( - "Token redaction processing completed in {} ms for {} matches", - processingTime, - matches.size()); - } - } - - private String applyRedactionsToSegmentText(TextSegment segment, List matches) { - String text = segment.getText(); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable(segment.getFont(), text)) { - log.debug( - "Skipping text segment '{}' - font {} cannot process this text reliably", - text, - segment.getFont().getName()); - return text; - } - - StringBuilder result = new StringBuilder(text); - - for (MatchRange match : matches) { - int segmentStart = Math.max(0, match.getStartPos() - segment.getStartPos()); - int segmentEnd = Math.min(text.length(), match.getEndPos() - segment.getStartPos()); - - if (segmentStart < text.length() && segmentEnd > segmentStart) { - String originalPart = text.substring(segmentStart, segmentEnd); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable( - segment.getFont(), originalPart)) { - log.debug( - "Skipping text part '{}' within segment - cannot be processed reliably", - originalPart); - continue; + if (tempIn != null && tempIn.exists()) { + try { + Files.delete(tempIn.toPath()); + } catch (IOException _) { + log.warn("Failed to delete temporary file: {}", tempIn.getAbsolutePath()); } - - float originalWidth = 0; - if (segment.getFont() != null && segment.getFontSize() > 0) { - try { - originalWidth = - safeGetStringWidth(segment.getFont(), originalPart) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - } catch (Exception e) { - log.debug( - "Failed to calculate original width for placeholder: {}", - e.getMessage()); - } - } - - String placeholder = - (originalWidth > 0) - ? createPlaceholderWithWidth( - originalPart, - originalWidth, - segment.getFont(), - segment.getFontSize()) - : createPlaceholderWithFont(originalPart, segment.getFont()); - - result.replace(segmentStart, segmentEnd, placeholder); - } - } - - return result.toString(); - } - - private void modifyTokenForRedaction( - List tokens, - TextSegment segment, - String newText, - float adjustment, - List matches) { - - if (segment.getTokenIndex() < 0 || segment.getTokenIndex() >= tokens.size()) { - return; - } - - Object token = tokens.get(segment.getTokenIndex()); - String operatorName = segment.getOperatorName(); - - try { - if (("Tj".equals(operatorName) || "'".equals(operatorName)) - && token instanceof COSString) { - - if (Math.abs(adjustment) < PRECISION_THRESHOLD) { - if (newText.isEmpty()) { - tokens.set(segment.getTokenIndex(), EMPTY_COS_STRING); - } else { - tokens.set(segment.getTokenIndex(), new COSString(newText)); - } - } else { - COSArray newArray = new COSArray(); - newArray.add(new COSString(newText)); - if (segment.getFontSize() > 0) { - float kerning = (-adjustment / segment.getFontSize()) * FONT_SCALE_FACTOR; - newArray.add(new COSFloat(kerning)); - } - tokens.set(segment.getTokenIndex(), newArray); - - int operatorIndex = segment.getTokenIndex() + 1; - if (operatorIndex < tokens.size() - && tokens.get(operatorIndex) instanceof Operator op - && op.getName().equals(operatorName)) { - tokens.set(operatorIndex, Operator.getOperator("TJ")); - } - } - } else if ("TJ".equals(operatorName) && token instanceof COSArray) { - COSArray newArray = createRedactedTJArray((COSArray) token, segment, matches); - tokens.set(segment.getTokenIndex(), newArray); } - } catch (Exception e) { - log.debug( - "Token modification failed for segment at index {}: {}", - segment.getTokenIndex(), - e.getMessage()); - } - } - - private COSArray createRedactedTJArray( - COSArray originalArray, TextSegment segment, List matches) { - try { - COSArray newArray = new COSArray(); - int textOffsetInSegment = 0; - - for (COSBase element : originalArray) { - if (element instanceof COSString cosString) { - String originalText = cosString.getString(); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable( - segment.getFont(), originalText)) { - log.debug( - "Skipping TJ text part '{}' - cannot be processed reliably with font {}", - originalText, - segment.getFont().getName()); - newArray.add(element); - textOffsetInSegment += originalText.length(); - continue; - } - - StringBuilder newText = new StringBuilder(originalText); - boolean modified = false; - - for (MatchRange match : matches) { - int stringStartInPage = segment.getStartPos() + textOffsetInSegment; - int stringEndInPage = stringStartInPage + originalText.length(); - - int overlapStart = Math.max(match.getStartPos(), stringStartInPage); - int overlapEnd = Math.min(match.getEndPos(), stringEndInPage); - - if (overlapStart < overlapEnd) { - int redactionStartInString = overlapStart - stringStartInPage; - int redactionEndInString = overlapEnd - stringStartInPage; - if (redactionStartInString >= 0 - && redactionEndInString <= originalText.length()) { - String originalPart = - originalText.substring( - redactionStartInString, redactionEndInString); - - if (segment.getFont() != null - && !TextEncodingHelper.isTextSegmentRemovable( - segment.getFont(), originalPart)) { - log.debug( - "Skipping TJ text part '{}' - cannot be redacted reliably", - originalPart); - continue; - } - - modified = true; - float originalWidth = 0; - if (segment.getFont() != null && segment.getFontSize() > 0) { - try { - originalWidth = - safeGetStringWidth(segment.getFont(), originalPart) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - } catch (Exception e) { - log.debug( - "Failed to calculate original width for TJ placeholder: {}", - e.getMessage()); - } - } - - String placeholder = - (originalWidth > 0) - ? createPlaceholderWithWidth( - originalPart, - originalWidth, - segment.getFont(), - segment.getFontSize()) - : createPlaceholderWithFont( - originalPart, segment.getFont()); - - newText.replace( - redactionStartInString, redactionEndInString, placeholder); - } - } - } - - String modifiedString = newText.toString(); - newArray.add(new COSString(modifiedString)); - - if (modified && segment.getFont() != null && segment.getFontSize() > 0) { - try { - float originalWidth = - safeGetStringWidth(segment.getFont(), originalText) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - float modifiedWidth = - safeGetStringWidth(segment.getFont(), modifiedString) - / FONT_SCALE_FACTOR - * segment.getFontSize(); - float adjustment = originalWidth - modifiedWidth; - if (Math.abs(adjustment) > PRECISION_THRESHOLD) { - float kerning = - (-adjustment / segment.getFontSize()) - * FONT_SCALE_FACTOR - * 1.10f; - newArray.add(new COSFloat(kerning)); - } - } catch (Exception e) { - log.debug( - "Width adjustment calculation failed for segment: {}", - e.getMessage()); - } - } - - textOffsetInSegment += originalText.length(); - } else { - newArray.add(element); + if (tempOut != null && tempOut.exists()) { + try { + Files.delete(tempOut.toPath()); + } catch (IOException _) { + log.warn("Failed to delete temporary file: {}", tempOut.getAbsolutePath()); } } - return newArray; - } catch (Exception e) { - return originalArray; } } - private String extractTextFromToken(Object token, String operatorName) { - return switch (operatorName) { - case "Tj", "'" -> { - if (token instanceof COSString cosString) { - yield cosString.getString(); - } - yield ""; - } - case "TJ" -> { - if (token instanceof COSArray cosArray) { - StringBuilder sb = new StringBuilder(); - for (COSBase element : cosArray) { - if (element instanceof COSString cosString) { - sb.append(cosString.getString()); - } - } - yield sb.toString(); - } - yield ""; - } - default -> ""; - }; - } - - // ----------------------------------------------------------------------- - // Inner data classes - // ----------------------------------------------------------------------- - - @Data - private static class GraphicsState { - private PDFont font = null; - private float fontSize = 0; - } - @Data @AllArgsConstructor static class TextSegment { @@ -1196,12 +188,4 @@ class TextRedactionService { private int startPos; private int endPos; } - - @Data - @AllArgsConstructor - private static class ModificationTask { - private TextSegment segment; - private String newText; - private float adjustment; - } } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java index 279a41a27f..791b7626c4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/RedactPdfRequest.java @@ -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; } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java index 150a2355de..0065b02cee 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java @@ -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); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java index 15774415a9..2f78ee033b 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerTest.java @@ -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 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 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 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 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 targetWords = Set.of("confidential"); + Map> found = + textRedactionService.findTextToRedact(realDocument, targetWords, false, false); + assertFalse(found.isEmpty(), "Should find target text to redact"); - List 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 targetWords = Set.of("secret"); - - List 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 targetWords = Set.of("redact"); - - List originalTokens = getOriginalTokens(); - List 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 targetWords = Set.of("\\d{3}-\\d{2}-\\d{4}"); // SSN pattern - - List 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 targetWords = Set.of("test"); - - List 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 targetWords = Set.of("sensitive"); - - List 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 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 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 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 targetWords = Set.of("secret"); - - List 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 targetWords = Set.of("confidential"); - - List 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 targetWords = Set.of("confidential"); - - List 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(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java index 49149e3ca7..246909efba 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java @@ -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"); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java index 8a8b335642..9d2f50a5e4 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceTest.java @@ -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 diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java index 6c94dd3a24..94e4d7cb6c 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java @@ -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 parseTokens(PDPage page) throws IOException { - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object t; - while ((t = parser.parseNextToken()) != null) { - tokens.add(t); - } - return tokens; - } - - private String tokensText(List 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> 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> 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 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 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 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 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 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 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 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 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 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 tokens = parseTokens(page); - - Method m = - TextRedactionService.class.getDeclaredMethod( - "extractTextSegments", PDPage.class, List.class); - m.setAccessible(true); - List segments = - (List) 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(); - } - } - } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java index ba0377a9e8..e0408bae55 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java @@ -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 parseTokens(PDPage page) throws IOException { - PDFStreamParser parser = new PDFStreamParser(page); - List tokens = new ArrayList<>(); - Object t; - while ((t = parser.parseNextToken()) != null) { - tokens.add(t); - } - return tokens; - } - - private String tokensText(List 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 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 before = parseTokens(page); - List 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 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 before = parseTokens(page); - List 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 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 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 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 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 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); } } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java index d487848e09..a47f2c15ab 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceTest.java @@ -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 originalTokens = parseTokens(page); - - List 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 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 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 originalTokens = parseTokens(page); - - List tokens = - service.createTokensWithoutTargetText( - doc, page, Collections.emptySet(), false, false); - - assertEquals(originalTokens.size(), tokens.size()); - } - } - - private List parseTokens(PDPage page) throws IOException { - PDFStreamParser parser = new PDFStreamParser(page); - List 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 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 matches = - (List) - 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 matches = - (List) - 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")); - } - } } diff --git a/build.gradle b/build.gradle index d6fa590ee4..a4a62df0a9 100644 --- a/build.gradle +++ b/build.gradle @@ -302,8 +302,17 @@ subprojects { tasks.withType(Test).configureEach { useJUnitPlatform() + jvmArgs '--enable-native-access=ALL-UNNAMED' systemProperty 'java.awt.headless', 'true' systemProperty 'apple.awt.UIElement', 'true' + + testLogging { + events "started", "failed" + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat "full" + } finalizedBy(jacocoReport) } diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts index 1a519d519e..e64c349a4a 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.test.ts @@ -19,7 +19,7 @@ describe("useRedactParameters", () => { { paramName: "wholeWordSearch" as const, value: true }, { paramName: "redactColor" as const, value: "#FF0000" }, { paramName: "customPadding" as const, value: 0.5 }, - { paramName: "convertPDFToImage" as const, value: false }, + { paramName: "convertPDFToImage" as const, value: true }, ])("should update parameter $paramName", ({ paramName, value }) => { const { result } = renderHook(() => useRedactParameters()); @@ -138,7 +138,7 @@ describe("useRedactParameters", () => { expect(result.current.parameters.mode).toBe("automatic"); expect(result.current.parameters.useRegex).toBe(false); expect(result.current.parameters.wholeWordSearch).toBe(false); - expect(result.current.parameters.convertPDFToImage).toBe(true); + expect(result.current.parameters.convertPDFToImage).toBe(false); }); test("should handle array parameter updates correctly", () => { diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts index 048b230567..a903908658 100644 --- a/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts +++ b/frontend/editor/src/core/hooks/tools/redact/useRedactParameters.ts @@ -25,7 +25,7 @@ export const defaultParameters: RedactParameters = { wholeWordSearch: false, redactColor: "#000000", customPadding: 0.1, - convertPDFToImage: true, + convertPDFToImage: false, }; export type RedactParametersHook = BaseParametersHook;