diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactionPdfVarietyTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactionPdfVarietyTest.java index 9a7ab3525b..47ef623287 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactionPdfVarietyTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactionPdfVarietyTest.java @@ -24,6 +24,7 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream; 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.PDFontDescriptor; import org.apache.pdfbox.pdmodel.font.PDTrueTypeFont; import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType1Font; @@ -142,6 +143,16 @@ class RedactionPdfVarietyTest { assertThat(pdfText(out)).contains("alpha").contains("omega"); } + @Test + @DisplayName("symbolic TrueType (no encoding, no ToUnicode): target not extractable") + void symbolicTrueType() throws IOException { + // PDFBox extraction is glyph-blind here, so removal is guaranteed by the native pass + + // rasterise fallback rather than surgical decode; this pins that the pipeline handles a + // symbolic font without error and leaves no extractable target. + byte[] out = autoRedact(symbolicTtfPdf("alpha SECRET omega"), "SECRET"); + assertGone(out, "SECRET"); + } + @Test @DisplayName("subset-tagged font (ABCDEF+): target gone, neighbours survive") void subsetTaggedFont() throws IOException { @@ -372,16 +383,46 @@ class RedactionPdfVarietyTest { } } + private byte[] symbolicTtfPdf(String line) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDTrueTypeFont font; + try (InputStream ttf = PDDocument.class.getResourceAsStream(LIBERATION)) { + font = PDTrueTypeFont.load(doc, ttf, WinAnsiEncoding.INSTANCE); + } + addTextPage(doc, font, line, 0, null); + // Strip /Encoding and flag the descriptor Symbolic so extraction must use the cmap. + font.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.ENCODING); + PDFontDescriptor fd = font.getFontDescriptor(); + fd.setFlags((fd.getFlags() | 4) & ~32); + return save(doc); + } + } + private byte[] type0NoToUnicodePdf(String line) throws IOException { + byte[] pdf; try (PDDocument doc = new PDDocument()) { PDFont font; try (InputStream ttf = PDDocument.class.getResourceAsStream(LIBERATION)) { - font = PDType0Font.load(doc, ttf, true); + // Full embed, not subset: the subsetter regenerates /ToUnicode at save, undoing the strip. + font = PDType0Font.load(doc, ttf, false); } addTextPage(doc, font, line, 0, null); font.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.TO_UNICODE); - return save(doc); + pdf = save(doc); } + // Guard: the strip must survive save so this fixture genuinely exercises the no-mapping path. + try (PDDocument reopened = Loader.loadPDF(pdf)) { + PDResources res = reopened.getPage(0).getResources(); + for (org.apache.pdfbox.cos.COSName n : res.getFontNames()) { + assertThat( + res.getFont(n) + .getCOSObject() + .containsKey(org.apache.pdfbox.cos.COSName.TO_UNICODE)) + .as("no-ToUnicode fixture must not ship /ToUnicode") + .isFalse(); + } + } + return pdf; } private byte[] subsetTaggedPdf(String line) throws IOException { diff --git a/testing/cucumber/features/redact.feature b/testing/cucumber/features/redact.feature index 5c1cb43f47..24a0ee77d9 100644 --- a/testing/cucumber/features/redact.feature +++ b/testing/cucumber/features/redact.feature @@ -104,3 +104,40 @@ Feature: PDF redaction physically removes text Then the response status code should be 200 And the response PDF should not contain the text "EXAMPLE" And the response PDF should contain the text "CROP" + + Scenario: Auto-redact removes the target from an embedded TrueType font PDF + Given I generate a PDF file as "fileInput" + And the pdf pages all contain the text "keepA SECRET88 keepB" in an embedded font + And the request data includes + | parameter | value | + | listOfText | SECRET88 | + When I send the API request to the endpoint "/api/v1/security/auto-redact" + Then the response status code should be 200 + And the response content type should be "application/pdf" + And the response PDF should not contain the text "SECRET88" + And the response PDF should contain the text "keepA" + And the response PDF should contain the text "keepB" + + Scenario: Auto-redact with a regex pattern on an embedded-font PDF + Given I generate a PDF file as "fileInput" + And the pdf pages all contain the text "invoice 987-65-4321 total" in an embedded font + And the request data includes + | parameter | value | + | listOfText | \d{3}-\d{2}-\d{4} | + | useRegex | true | + When I send the API request to the endpoint "/api/v1/security/auto-redact" + Then the response status code should be 200 + And the response PDF should not contain the text "987-65-4321" + And the response PDF should contain the text "invoice" + + Scenario: Auto-redact convert-to-image on an embedded-font PDF drops the text layer + Given I generate a PDF file as "fileInput" + And the pdf pages all contain the text "keep SECRET66 hidden" in an embedded font + And the request data includes + | parameter | value | + | listOfText | SECRET66 | + | convertPDFToImage | true | + When I send the API request to the endpoint "/api/v1/security/auto-redact" + Then the response status code should be 200 + And the response PDF should not contain the text "SECRET66" + And the response PDF should not contain the text "keep" diff --git a/testing/cucumber/features/steps/step_definitions.py b/testing/cucumber/features/steps/step_definitions.py index d9d8cd60a4..2aeea25384 100644 --- a/testing/cucumber/features/steps/step_definitions.py +++ b/testing/cucumber/features/steps/step_definitions.py @@ -280,6 +280,47 @@ def step_pdf_pages_contain_text(context, text): context.files[context.param_name] = open(context.file_name, "rb") +# DejaVuSans is a repo asset; embedding it exercises redaction over a real embedded (Type0/CID, +# non Standard-14) font whose text pypdf can still extract to verify removal end-to-end. +_DEJAVU_TTF = os.path.normpath( + os.path.join( + os.path.dirname(__file__), + "..", "..", "..", "..", + "app", "core", "src", "main", "resources", "static", "fonts", "DejaVuSans.ttf", + ) +) +_embedded_font_registered = False + + +def _ensure_embedded_font(): + global _embedded_font_registered + if not _embedded_font_registered: + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.pdfbase import pdfmetrics + + pdfmetrics.registerFont(TTFont("DejaVuEmbedded", _DEJAVU_TTF)) + _embedded_font_registered = True + + +@given('the pdf pages all contain the text "{text}" in an embedded font') +def step_pdf_pages_contain_text_embedded(context, text): + _ensure_embedded_font() + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=letter) + width, height = letter + for _ in range(len(PdfReader(context.file_name).pages)): + c.setFont("DejaVuEmbedded", 14) + c.drawString(100, height - 100, text) + c.showPage() + c.save() + + with open(context.file_name, "wb") as f: + f.write(buffer.getvalue()) + + context.files[context.param_name].close() + context.files[context.param_name] = open(context.file_name, "rb") + + @given('the pdf is encrypted with password "{password}"') def step_encrypt_pdf(context, password): writer = PdfWriter()