From 7217e6ac158351d5a2b38f2ff3a5be43bb8f1397 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:16:47 +0100 Subject: [PATCH] Make manual-area redaction per-glyph precise and scope metadata scrubbing --- .../api/security/TextRedactionService.java | 3 +- .../SPDF/pdf/redaction/CatalogScrubber.java | 54 ++-- .../SPDF/pdf/redaction/RedactionPipeline.java | 255 ++++++++++++++---- .../api/security/RedactionPdfVarietyTest.java | 167 +++++++++++- .../pdf/redaction/RedactionPipelineTest.java | 68 +++++ .../test/resources/redaction/cidfont0_cff.bin | Bin 0 -> 334 bytes .../test/resources/redaction/cjk_minimal.ttf | Bin 0 -> 1592 bytes .../redaction/generators/gen_cff_cidfont.py | 118 ++++++++ .../redaction/generators/gen_cjk_font.py | 114 ++++++++ .../redaction/generators/gen_type1.py | 128 +++++++++ .../resources/redaction/type1_minimal.pfb | Bin 0 -> 2436 bytes 11 files changed, 834 insertions(+), 73 deletions(-) create mode 100644 app/core/src/test/resources/redaction/cidfont0_cff.bin create mode 100644 app/core/src/test/resources/redaction/cjk_minimal.ttf create mode 100644 app/core/src/test/resources/redaction/generators/gen_cff_cidfont.py create mode 100644 app/core/src/test/resources/redaction/generators/gen_cjk_font.py create mode 100644 app/core/src/test/resources/redaction/generators/gen_type1.py create mode 100644 app/core/src/test/resources/redaction/type1_minimal.pfb 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 9d6c91860f..118853edaa 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 @@ -46,7 +46,8 @@ 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 Set TEXT_SHOWING_OPERATORS = + stirling.software.SPDF.pdf.redaction.RedactionPipeline.TEXT_SHOWING_OPERATORS; private static final COSString EMPTY_COS_STRING = new COSString(""); // Public API diff --git a/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/CatalogScrubber.java b/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/CatalogScrubber.java index a9a40a37ad..dd4a443aaf 100644 --- a/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/CatalogScrubber.java +++ b/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/CatalogScrubber.java @@ -1,6 +1,5 @@ package stirling.software.SPDF.pdf.redaction; -import java.util.Calendar; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; @@ -57,6 +56,7 @@ public final class CatalogScrubber { scrubStructTree(catalog.getStructureTreeRoot(), literalTargets, patterns); scrubNames(catalog.getNames(), literalTargets, patterns); scrubCatalogActions(catalog, literalTargets, patterns); + scrubDocumentInfo(document, literalTargets, patterns); } // Catalog actions: OpenAction, AA, and any JavaScript / URI payloads @@ -162,37 +162,43 @@ public final class CatalogScrubber { return false; } - /** Wipe Info-dict entries and the XMP metadata stream. */ - public static void wipeMetadata(PDDocument document) { - if (document == null) { - return; - } + /** + * Remove the redaction target from /Info string entries and drop the XMP packet only if it + * carries a target. Non-matching document properties (Title, Author, dates) are left intact - + * redaction removes the target, it does not blanket-wipe metadata. + */ + private static void scrubDocumentInfo( + PDDocument document, Set targets, List patterns) { PDDocumentInformation info = document.getDocumentInformation(); - if (info != null) { - info.setAuthor(null); - info.setSubject(null); - info.setKeywords(null); - info.setTitle(null); - info.setCreator(null); - info.setProducer(null); - info.setModificationDate(Calendar.getInstance()); - // Drop any non-standard custom /Info string entries (e.g. a client-added "CaseName") - // that the typed setters above don't cover. + if (info != null && info.getCOSObject() != null) { COSDictionary infoDict = info.getCOSObject(); - if (infoDict != null) { - for (COSName key : new HashSet<>(infoDict.keySet())) { - if (infoDict.getDictionaryObject(key) instanceof COSString) { - infoDict.removeItem(key); + for (COSName key : new HashSet<>(infoDict.keySet())) { + if (infoDict.getDictionaryObject(key) instanceof COSString cs) { + String stripped = stripMatches(cs.getString(), targets, patterns); + if (!stripped.equals(cs.getString())) { + if (stripped.isEmpty()) { + infoDict.removeItem(key); + } else { + infoDict.setString(key, stripped); + } } } } } PDDocumentCatalog catalog = document.getDocumentCatalog(); - if (catalog != null) { + if (catalog != null && catalog.getMetadata() != null) { try { - catalog.setMetadata(null); + String xmp = + new String( + catalog.getMetadata().toByteArray(), + java.nio.charset.StandardCharsets.UTF_8); + // XMP is RDF/XML that usually mirrors /Info; drop the whole packet if it carries a + // target rather than risk a broken partial edit. + if (matches(xmp, targets, patterns)) { + catalog.setMetadata(null); + } } catch (Exception e) { - log.debug("Could not clear XMP metadata: {}", e.getMessage()); + log.debug("Could not scan XMP metadata: {}", e.getMessage()); } } } @@ -576,12 +582,12 @@ public final class CatalogScrubber { COSArray namesArray = dict.getDictionaryObject(COSName.NAMES) instanceof COSArray a ? a : null; if (namesArray != null) { + Set dropLiterals = carrierDropLiterals(targets); for (int i = namesArray.size() - 2; i >= 0; i -= 2) { COSBase keyBase = namesArray.getObject(i); String key = keyBase instanceof COSString s ? s.getString() : null; // Drop the pair when the KEY or the VALUE (JS /JS stream, embedded-file bytes) // contains a target - not just the key. - Set dropLiterals = carrierDropLiterals(targets); boolean keyHit = key != null && matches(key, dropLiterals, patterns); boolean valueHit = i + 1 < namesArray.size() diff --git a/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/RedactionPipeline.java b/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/RedactionPipeline.java index c44efeef94..15a4d2f667 100644 --- a/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/RedactionPipeline.java +++ b/app/core/src/main/java/stirling/software/SPDF/pdf/redaction/RedactionPipeline.java @@ -26,6 +26,7 @@ import org.apache.pdfbox.contentstream.operator.Operator; import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSFloat; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdfparser.PDFStreamParser; @@ -56,7 +57,7 @@ import stirling.software.SPDF.utils.text.TextFinderUtils; @Slf4j public final class RedactionPipeline { - private static final Set TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\""); + public static final Set TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\""); private static final int MAX_XOBJECT_DEPTH = 10; // Latched false when the native PDFium binding can't load, so the host falls back to the PDFBox @@ -223,7 +224,6 @@ public final class RedactionPipeline { throws IOException { CatalogScrubber.scrub(document, literalTargets, patterns); - CatalogScrubber.wipeMetadata(document); warnAboutEmbeddedFontGlyphs(document); ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -246,7 +246,6 @@ public final class RedactionPipeline { : new HashSet<>(affectedPages); try (PDDocument rasterised = rasterisePages(bytes, pagesToRaster)) { CatalogScrubber.scrub(rasterised, literalTargets, patterns); - CatalogScrubber.wipeMetadata(rasterised); ByteArrayOutputStream rasterOut = new ByteArrayOutputStream(); rasterised.save(rasterOut); byte[] rasterBytes = rasterOut.toByteArray(); @@ -272,7 +271,6 @@ public final class RedactionPipeline { Set carrierTargets = capturedTargets == null ? Collections.emptySet() : capturedTargets; CatalogScrubber.scrub(document, carrierTargets, Collections.emptyList()); - CatalogScrubber.wipeMetadata(document); warnAboutEmbeddedFontGlyphs(document); ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -287,7 +285,6 @@ public final class RedactionPipeline { } log.warn("Manual redaction rasterising page(s) {} to guarantee removal", toRaster); try (PDDocument rasterised = rasterisePages(bytes, toRaster)) { - CatalogScrubber.wipeMetadata(rasterised); ByteArrayOutputStream rasterOut = new ByteArrayOutputStream(); rasterised.save(rasterOut); return rasterOut.toByteArray(); @@ -311,7 +308,6 @@ public final class RedactionPipeline { log.warn("Rasterising page(s) {} to guarantee geometric redaction removal", pages); try (PDDocument rasterised = rasterisePages(bytes, new HashSet<>(pages))) { CatalogScrubber.scrub(rasterised, literalTargets, patterns); - CatalogScrubber.wipeMetadata(rasterised); ByteArrayOutputStream out = new ByteArrayOutputStream(); rasterised.save(out); return out.toByteArray(); @@ -332,10 +328,8 @@ public final class RedactionPipeline { continue; } PDPage page = reopened.getPage(pageIndex); - boolean textLeft = - captureTextInRects(page, entry.getValue()).stream() - .anyMatch(s -> s != null && !s.isBlank()); - if (textLeft || annotationOverlapsRect(page, entry.getValue())) { + if (glyphStillInRects(reopened, page, pageIndex, entry.getValue()) + || annotationOverlapsRect(page, entry.getValue())) { leaking.add(pageIndex); } } @@ -346,6 +340,31 @@ public final class RedactionPipeline { return leaking; } + /** + * True if any non-blank glyph is still painted inside a rect. Position-based (not ToUnicode), + * so it catches residual CID/Type3/no-ToUnicode glyphs the text stripper would miss; fails + * closed. + */ + private static boolean glyphStillInRects( + PDDocument doc, PDPage page, int pageIndex, List rects) { + try { + List areaRects = new ArrayList<>(); + for (PDRectangle rect : rects) { + float pdfY = page.getBBox().getHeight() - rect.getUpperRightY(); + areaRects.add( + new Rectangle2D.Float( + rect.getLowerLeftX(), pdfY, rect.getWidth(), rect.getHeight())); + } + TokenIndexCollector collector = new TokenIndexCollector(areaRects); + collector.setStartPage(pageIndex + 1); + collector.setEndPage(pageIndex + 1); + collector.getText(doc); + return collector.anyGlyphInRect(); + } catch (Exception e) { + return true; // cannot prove the rect is clean - rasterise to be safe + } + } + private static boolean annotationOverlapsRect(PDPage page, List rects) { try { for (var ann : page.getAnnotations()) { @@ -888,47 +907,175 @@ public final class RedactionPipeline { collector.setStartPage(pageIndex + 1); collector.setEndPage(pageIndex + 1); collector.getText(document); - Set dropTokenIndexes = collector.tokenIndexesToDrop; int collectorTextOps = collector.totalTextOps(); List tokens = parseTokens(new PDFStreamParser(page)); - Set blankArgIndexes = new HashSet<>(); int pageTextOps = 0; - for (int i = 0; i < tokens.size(); i++) { - Object t = tokens.get(i); + for (Object t : tokens) { if (t instanceof Operator op && TEXT_SHOWING_OPERATORS.contains(op.getName())) { - if (dropTokenIndexes.contains(pageTextOps) && i > 0) { - blankArgIndexes.add(i - 1); - } pageTextOps++; } } - - // Collector ordinals span form XObjects. + // Collector ordinals span form XObjects; images under a rect can't be surgically removed. if (collectorTextOps != pageTextOps || imageIntersectsAnyRect(page, pageIndex, rects)) { return false; } - for (Integer idx : blankArgIndexes) { - Object arg = tokens.get(idx); - if (arg instanceof COSString) { - tokens.set(idx, new COSString("")); - } else if (arg instanceof COSArray arr) { - COSArray empty = new COSArray(); - // Preserve numeric kerning entries so page layout doesn't shift wildly. - for (COSBase element : arr) { - if (!(element instanceof COSString)) { - empty.add(element); - } + // Drop ONLY the in-rect glyphs of each operand (not the whole run), tracking the active + // font so codes decode. Bail to rasterise if the font can't decode or glyph!=code counts. + PDResources resources = page.getResources(); + PDFont currentFont = null; + int opCounter = -1; + boolean modified = false; + for (int i = 0; i < tokens.size(); i++) { + Object tok = tokens.get(i); + if (!(tok instanceof Operator op)) { + continue; + } + String name = op.getName(); + if ("Tf".equals(name) && i >= 2 && tokens.get(i - 2) instanceof COSName fn) { + try { + currentFont = resources != null ? resources.getFont(fn) : null; + } catch (IOException e) { + currentFont = null; } - tokens.set(idx, empty); + } else if (TEXT_SHOWING_OPERATORS.contains(name)) { + opCounter++; + Set dropGlyphs = collector.dropGlyphsByOp.get(opCounter); + if (dropGlyphs == null || dropGlyphs.isEmpty() || i < 1) { + continue; + } + // ' and " also move to the next line; converting them to TJ would lose that - + // raster. + if ("'".equals(name) || "\"".equals(name)) { + return false; + } + int expected = collector.glyphCountByOp.getOrDefault(opCounter, -1); + COSArray rebuilt = + rebuildAdvancePreserving( + tokens.get(i - 1), currentFont, dropGlyphs, expected); + if (rebuilt == null) { + return false; // undecodable / glyph-count mismatch - rasterise instead + } + // Emit as TJ so the removed glyphs' advance is preserved (no reflow). + tokens.set(i - 1, rebuilt); + tokens.set(i, Operator.getOperator("TJ")); + modified = true; } } - writePageTokens(document, page, tokens); + if (modified) { + writePageTokens(document, page, tokens); + } return true; } + /** Distinct code byte-starts in order (one per glyph/code) from a per-char DecodeResult. */ + private static int[] distinctCodeStarts(DecodeResult d) { + List starts = new ArrayList<>(); + int last = -1; + for (int s : d.codeStarts) { + if (s != last) { + starts.add(s); + last = s; + } + } + return starts.stream().mapToInt(Integer::intValue).toArray(); + } + + private static int codeAt(PDFont font, byte[] bytes, int start, int len) throws IOException { + try (ByteArrayInputStream in = new ByteArrayInputStream(bytes, start, len)) { + return font.readCode(in); + } + } + + /** + * Rebuild a Tj/TJ operand as a TJ array that keeps every glyph EXCEPT the given 0-based + * indexes, replacing each dropped run with a numeric adjustment equal to its advance so the + * surviving text does not reflow into (or out of) the box. Returns null (caller rasterises) if + * anything can't be decoded or the glyph count doesn't line up with the position pass. + */ + private static COSArray rebuildAdvancePreserving( + Object operand, PDFont font, Set dropGlyphs, int expectedGlyphs) { + if (font == null) { + return null; + } + List elements = new ArrayList<>(); + if (operand instanceof COSString cs) { + elements.add(cs); + } else if (operand instanceof COSArray arr) { + for (COSBase b : arr) { + elements.add(b); + } + } else { + return null; + } + + COSArray out = new COSArray(); + ByteArrayOutputStream seg = new ByteArrayOutputStream(); + float pendingAdvance = 0f; // accumulated width of dropped codes, in 1/1000 text units + int globalCode = 0; + for (COSBase el : elements) { + if (!(el instanceof COSString s)) { + // Existing numeric adjustment: flush whichever run is pending, then preserve it. + if (seg.size() > 0) { + out.add(new COSString(seg.toByteArray())); + seg.reset(); + } else if (pendingAdvance != 0f) { + out.add(new COSFloat(-pendingAdvance)); + pendingAdvance = 0f; + } + out.add(el); + continue; + } + DecodeResult d = decodeCosString(s, font); + if (d == null) { + return null; + } + byte[] b = s.getBytes(); + int[] starts = distinctCodeStarts(d); + Map lenByStart = new HashMap<>(); + for (int j = 0; j < d.codeStarts.length; j++) { + lenByStart.putIfAbsent(d.codeStarts[j], d.codeLens[j]); + } + for (int k = 0; k < starts.length; k++) { + int bs = starts[k]; + int len = lenByStart.getOrDefault(bs, -1); + if (len <= 0) { + return null; + } + if (dropGlyphs.contains(globalCode)) { + if (seg.size() > 0) { // close the kept run before accumulating advance + out.add(new COSString(seg.toByteArray())); + seg.reset(); + } + try { + pendingAdvance += font.getWidth(codeAt(font, b, bs, len)); + } catch (Exception e) { + return null; + } + } else { + if (pendingAdvance != 0f) { // emit the removed run's advance, then keep glyphs + out.add(new COSFloat(-pendingAdvance)); + pendingAdvance = 0f; + } + seg.write(b, bs, len); + } + globalCode++; + } + } + if (pendingAdvance != 0f) { + out.add(new COSFloat(-pendingAdvance)); + } + if (seg.size() > 0) { + out.add(new COSString(seg.toByteArray())); + } + if (expectedGlyphs >= 0 && globalCode != expectedGlyphs) { + return null; + } + return out; + } + /** * True if any image on the page overlaps a redaction rect (would survive under the overlay). */ @@ -1374,11 +1521,18 @@ public final class RedactionPipeline { } /** A PDFTextStripper subclass that records the ordinal index of every */ + /** + * Position-based (ToUnicode-independent) glyph locator: records, per text-showing operator, the + * 0-based indexes of the non-blank glyphs whose box intersects a rect, plus each operator's + * total glyph count. Blank glyphs are ignored so a space left behind by removal is not a + * "leak". + */ private static final class TokenIndexCollector extends PDFTextStripper { private final List rects; - private final Set tokenIndexesToDrop = new HashSet<>(); + private final Map> dropGlyphsByOp = new HashMap<>(); + private final Map glyphCountByOp = new HashMap<>(); private int showTextOpCounter = -1; - private boolean currentOpInRect = false; + private int glyphInOp = 0; TokenIndexCollector(List rects) throws IOException { this.rects = rects; @@ -1389,17 +1543,26 @@ public final class RedactionPipeline { return showTextOpCounter + 1; } + boolean anyGlyphInRect() { + return !dropGlyphsByOp.isEmpty(); + } + @Override protected void processTextPosition(TextPosition text) { - // PDFBox reports coordinates with top-left origin here. - float x = text.getX(); - float y = text.getY() - text.getHeight(); - Rectangle2D.Float glyph = - new Rectangle2D.Float(x, y, text.getWidth(), text.getHeight()); - for (Rectangle2D.Float rect : rects) { - if (rect.intersects(glyph)) { - currentOpInRect = true; - return; + int op = showTextOpCounter; + int idx = glyphInOp++; + String u = text.getUnicode(); + if (u != null && !u.isBlank()) { + // PDFBox reports coordinates with top-left origin here. + float x = text.getX(); + float y = text.getY() - text.getHeight(); + Rectangle2D.Float glyph = + new Rectangle2D.Float(x, y, text.getWidth(), text.getHeight()); + for (Rectangle2D.Float rect : rects) { + if (rect.intersects(glyph)) { + dropGlyphsByOp.computeIfAbsent(op, k -> new HashSet<>()).add(idx); + return; + } } } super.processTextPosition(text); @@ -1412,11 +1575,11 @@ public final class RedactionPipeline { boolean textOp = TEXT_SHOWING_OPERATORS.contains(name); if (textOp) { showTextOpCounter++; - currentOpInRect = false; + glyphInOp = 0; } super.processOperator(operator, operands); - if (textOp && currentOpInRect) { - tokenIndexesToDrop.add(showTextOpCounter); + if (textOp) { + glyphCountByOp.put(showTextOpCounter, glyphInOp); } } } 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 47ef623287..692ec096e8 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 @@ -17,12 +17,18 @@ import java.util.Set; import java.util.regex.Pattern; import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSInteger; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDFormContentStream; 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.PDFontDescriptor; import org.apache.pdfbox.pdmodel.font.PDTrueTypeFont; @@ -190,6 +196,30 @@ class RedactionPdfVarietyTest { assertThat(pdfText(out)).contains("alpha").contains("omega"); } + @Test + @DisplayName("embedded Type1 (PFB) font: target gone, neighbours survive") + void embeddedType1() throws IOException { + byte[] out = autoRedact(type1Pdf("PUBLICA SECRETWORD KEEP"), "SECRETWORD"); + assertGone(out, "SECRETWORD"); + assertThat(pdfText(out)).contains("PUBLICA").contains("KEEP"); + } + + @Test + @DisplayName("embedded CJK Type0 (CIDFontType2) font: CJK target gone, neighbours survive") + void embeddedCjkType0() throws IOException { + byte[] out = autoRedact(cjkPdf("PUBLICA \u79D8\u5BC6 KEEP"), "\u79D8\u5BC6"); + assertGone(out, "\u79D8\u5BC6"); + assertThat(pdfText(out)).contains("PUBLICA").contains("KEEP"); + } + + @Test + @DisplayName("embedded CIDFontType0 (CFF) font: target gone, neighbours survive") + void embeddedCidFontType0() throws IOException { + byte[] out = autoRedact(cidFontType0Pdf("PUBLICA SECRETWORD KEEP"), "SECRETWORD"); + assertGone(out, "SECRETWORD"); + assertThat(pdfText(out)).contains("PUBLICA").contains("KEEP"); + } + @Test @DisplayName("simple embedded TrueType (WinAnsi) font: target gone, neighbours survive") void simpleTrueTypeWinAnsi() throws IOException { @@ -383,6 +413,137 @@ class RedactionPdfVarietyTest { } } + // GID order emitted by generators/gen_cff_cidfont.py: 0=.notdef 1=space then the letters. + private static final String CFF_GLYPHS = "\0 ABCDEIKLOPRSTUW"; + + private static int cffGid(char c) { + int i = CFF_GLYPHS.indexOf(c); + return i < 0 ? 0 : i; + } + + /** + * PDFBox 3.0.7 has no CFF embedder, so hand-assemble a Type0 -> CIDFontType0 (CFF in + * /FontFile3, Identity-H, CID==GID, explicit /ToUnicode) from the generated CFF program. + */ + private byte[] cidFontType0Pdf(String line) throws IOException { + byte[] cff; + try (InputStream in = getClass().getResourceAsStream("/redaction/cidfont0_cff.bin")) { + cff = in.readAllBytes(); + } + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + PDStream fontFile = new PDStream(doc); + try (var os = fontFile.createOutputStream()) { + os.write(cff); + } + COSStream ff = fontFile.getCOSObject(); + ff.setItem(COSName.SUBTYPE, COSName.getPDFName("CIDFontType0C")); + + COSDictionary fdesc = new COSDictionary(); + fdesc.setItem(COSName.TYPE, COSName.FONT_DESC); + fdesc.setName(COSName.FONT_NAME, "MinimalCFFCID"); + fdesc.setInt(COSName.FLAGS, 4); + COSArray bbox = new COSArray(); + for (int v : new int[] {0, -200, 600, 800}) { + bbox.add(COSInteger.get(v)); + } + fdesc.setItem(COSName.FONT_BBOX, bbox); + fdesc.setInt(COSName.ITALIC_ANGLE, 0); + fdesc.setInt(COSName.ASCENT, 800); + fdesc.setInt(COSName.DESCENT, -200); + fdesc.setInt(COSName.CAP_HEIGHT, 700); + fdesc.setInt(COSName.STEM_V, 80); + fdesc.setItem(COSName.getPDFName("FontFile3"), ff); + + COSDictionary cidSystemInfo = new COSDictionary(); + cidSystemInfo.setString(COSName.REGISTRY, "Adobe"); + cidSystemInfo.setString(COSName.ORDERING, "Identity"); + cidSystemInfo.setInt(COSName.SUPPLEMENT, 0); + + COSDictionary cidFont = new COSDictionary(); + cidFont.setItem(COSName.TYPE, COSName.FONT); + cidFont.setItem(COSName.SUBTYPE, COSName.CID_FONT_TYPE0); + cidFont.setName(COSName.BASE_FONT, "MinimalCFFCID"); + cidFont.setItem(COSName.CIDSYSTEMINFO, cidSystemInfo); + cidFont.setItem(COSName.FONT_DESC, fdesc); + cidFont.setInt(COSName.DW, 600); + + COSStream toUni = new PDStream(doc).getCOSObject(); + try (var os = toUni.createOutputStream()) { + os.write(cffToUnicode().getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + + COSDictionary type0 = new COSDictionary(); + type0.setItem(COSName.TYPE, COSName.FONT); + type0.setItem(COSName.SUBTYPE, COSName.TYPE0); + type0.setName(COSName.BASE_FONT, "MinimalCFFCID"); + type0.setItem(COSName.ENCODING, COSName.IDENTITY_H); + COSArray descendants = new COSArray(); + descendants.add(cidFont); + type0.setItem(COSName.DESCENDANT_FONTS, descendants); + type0.setItem(COSName.TO_UNICODE, toUni); + + PDFont font = org.apache.pdfbox.pdmodel.font.PDFontFactory.createFont(type0); + PDResources res = new PDResources(); + COSName fName = res.add(font); + page.setResources(res); + + StringBuilder csb = new StringBuilder(); + csb.append("BT\n/").append(fName.getName()).append(" 24 Tf\n50 700 Td\n<"); + for (int i = 0; i < line.length(); i++) { + csb.append(String.format("%04X", cffGid(line.charAt(i)))); + } + csb.append("> Tj\nET\n"); + PDStream content = new PDStream(doc); + try (var os = content.createOutputStream()) { + os.write(csb.toString().getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + page.setContents(content); + return save(doc); + } + } + + private static String cffToUnicode() { + StringBuilder sb = new StringBuilder(); + sb.append("/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n"); + sb.append("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"); + sb.append("/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n"); + sb.append("1 begincodespacerange\n<0000> \nendcodespacerange\n"); + StringBuilder body = new StringBuilder(); + int count = 0; + for (int gid = 1; gid < CFF_GLYPHS.length(); gid++) { + body.append(String.format("<%04X> <%04X>\n", gid, (int) CFF_GLYPHS.charAt(gid))); + count++; + } + sb.append(count).append(" beginbfchar\n").append(body).append("endbfchar\n"); + sb.append("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + return sb.toString(); + } + + private byte[] type1Pdf(String line) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDFont font; + try (InputStream pfb = getClass().getResourceAsStream("/redaction/type1_minimal.pfb")) { + font = new PDType1Font(doc, pfb, WinAnsiEncoding.INSTANCE); + } + addTextPage(doc, font, line, 0, null); + return save(doc); + } + } + + private byte[] cjkPdf(String line) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDFont font; + try (InputStream ttf = getClass().getResourceAsStream("/redaction/cjk_minimal.ttf")) { + font = PDType0Font.load(doc, ttf, true); + } + addTextPage(doc, font, line, 0, null); + return save(doc); + } + } + private byte[] symbolicTtfPdf(String line) throws IOException { try (PDDocument doc = new PDDocument()) { PDTrueTypeFont font; @@ -403,14 +564,16 @@ class RedactionPdfVarietyTest { try (PDDocument doc = new PDDocument()) { PDFont font; try (InputStream ttf = PDDocument.class.getResourceAsStream(LIBERATION)) { - // Full embed, not subset: the subsetter regenerates /ToUnicode at save, undoing the strip. + // 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); pdf = save(doc); } - // Guard: the strip must survive save so this fixture genuinely exercises the no-mapping path. + // 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()) { diff --git a/app/core/src/test/java/stirling/software/SPDF/pdf/redaction/RedactionPipelineTest.java b/app/core/src/test/java/stirling/software/SPDF/pdf/redaction/RedactionPipelineTest.java index f98693d08c..a4615bf924 100644 --- a/app/core/src/test/java/stirling/software/SPDF/pdf/redaction/RedactionPipelineTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/pdf/redaction/RedactionPipelineTest.java @@ -894,6 +894,74 @@ class RedactionPipelineTest { } } + @Test + @DisplayName("manual area over part of a run removes only the boxed glyphs, not the whole run") + void manualAreaRemovesOnlyBoxedGlyphs() throws Exception { + float fontSize = 16f; + float startX = 72f; + float baselineY = 700f; + PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + String prefix = "Label: "; + String target = "SECRET"; + byte[] out; + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(font, fontSize); + cs.newLineAtOffset(startX, baselineY); + cs.showText(prefix + target + " value"); + cs.endText(); + } + float rectX = startX + font.getStringWidth(prefix) / 1000f * fontSize; + float targetW = font.getStringWidth(target) / 1000f * fontSize; + PDRectangle rect = new PDRectangle(rectX, baselineY - fontSize, targetW, 3 * fontSize); + Map> rectsByPage = new HashMap<>(); + rectsByPage.put(0, List.of(rect)); + RedactionPipeline.RedactionResult result = + RedactionPipeline.redactAreas(doc, rectsByPage, Color.BLACK); + out = + RedactionPipeline.finalizeAreas( + doc, + rectsByPage, + result.getForceRasterPages(), + result.getCapturedStrings()); + } + try (PDDocument reopened = Loader.loadPDF(out)) { + String text = new PDFTextStripper().getText(reopened); + assertFalse(text.contains("SECRET"), "boxed target must be removed"); + assertTrue(text.contains("Label:"), "text left of the box must survive"); + assertTrue(text.contains("value"), "text right of the box must survive"); + assertFalse(pageHasImage(reopened.getPage(0)), "should be surgical, not rasterised"); + } + } + + @Test + @DisplayName("metadata scrub keeps non-matching Info entries and strips only the target") + void metadataScrubIsTargetScoped() throws Exception { + byte[] bytes; + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + org.apache.pdfbox.pdmodel.PDDocumentInformation info = doc.getDocumentInformation(); + info.setTitle("Quarterly Public Report"); + info.setAuthor("Written by Smith"); + info.setCustomMetadataValue("CaseName", "Smith matter"); + CatalogScrubber.scrub( + doc, new LinkedHashSet<>(Set.of("Smith")), Collections.emptyList()); + ByteArrayOutputStream b = new ByteArrayOutputStream(); + doc.save(b); + bytes = b.toByteArray(); + } + try (PDDocument reopened = Loader.loadPDF(bytes)) { + org.apache.pdfbox.pdmodel.PDDocumentInformation info = + reopened.getDocumentInformation(); + assertEquals( + "Quarterly Public Report", info.getTitle(), "non-matching Title must survive"); + assertFalse(info.getAuthor().contains("Smith"), "matching Author must be scrubbed"); + } + } + private static boolean pageHasImage(PDPage page) throws Exception { PDResources res = page.getResources(); if (res == null) { diff --git a/app/core/src/test/resources/redaction/cidfont0_cff.bin b/app/core/src/test/resources/redaction/cidfont0_cff.bin new file mode 100644 index 0000000000000000000000000000000000000000..9a8ad9323e156a5907010147ad705cb9c8bd4eaf GIT binary patch literal 334 zcmZQ%U}0ilWMt&?&CJWpP0Vq2b944|0f|WckY@TJ!~8>nWpDS7T|Wa@AMSthNU}RuWVqwr?V9;S?FkoabW@fNpU=U&=<7dF14mQ5;eS5{eYq9;-V&g*=9x7}A6#1BG literal 0 HcmV?d00001 diff --git a/app/core/src/test/resources/redaction/cjk_minimal.ttf b/app/core/src/test/resources/redaction/cjk_minimal.ttf new file mode 100644 index 0000000000000000000000000000000000000000..40a7dcd99db4e03dfeb900f144b7b5376383ab2e GIT binary patch literal 1592 zcmZQzWME+6VrXDsW>D}C);DrxIQoo%fl-5jfgvF|H?bhLxmJaNf$<6h14CbWPGy?x zi41iH2Bsbc2F6_(sfj82CDk$v3=C@+7#PelGEx)SmNBnpU|@K{z`&rAky}!s^@Npy zfq}7ufq}s*CqFrn`3{Q<0|QeE0|SFbZem3NgD3+V0|O%m0|SFXUSe))?2$SX1_l-n z1_nljg8br=<1IhGGcd4bFfcG&0K1iufkB|xS}>m9<|_j;^9u$B28L(3(g)yl00RTF z3DX`11_ovZCXgt|dIkn&h5r@|49qVW0vIZoO~58WCBc*sSVWkC1xz!*b%G_q1T#|+ z;{*m)1{N?6KLPd*Oo_d{y*v{{!GIMr>fo0L=>z*N z0Bk16=gbTY3?dB7U>}JvurO4C`2q~g3@i)^46Y2G3_c724519+(Z?!p{QnPfBZDJ@ zCxbVGKSK~q^#2P6hX0_@WME+Uzio+6nI2h8vX#*5n zypUYS0Ln@147^Zr76wrU5h$CDL6kuglsZ{B85kL)80?^ICI&u+2q>F{L5m>`%4TEG zVwm9UO1Q8v0|O%yBQqllBP$~tBReAp12ZEh zBNrn#BM&1lBOfC_qX45IqY!&(UZ#P8sR5KWh0rEOP}&(vTS94LC~XL#O<{bfK2xYZ eQxmAXIfOQGgVIoO6DJVg+|tDYM4LJ}n*jjP>=W_; literal 0 HcmV?d00001 diff --git a/app/core/src/test/resources/redaction/generators/gen_cff_cidfont.py b/app/core/src/test/resources/redaction/generators/gen_cff_cidfont.py new file mode 100644 index 0000000000..37080f2880 --- /dev/null +++ b/app/core/src/test/resources/redaction/generators/gen_cff_cidfont.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Generate a minimal OpenType-CFF (.otf) font for a CIDFontType0 redaction fixture. + +Why this exists +--------------- +Real-world Adobe/InDesign PDFs embed CFF-based CID fonts (Type0 -> CIDFontType0, +program in /FontFile3 /Subtype /CIDFontType0C). PDFBox 3.0.7 can *read* those but +ships NO CFF embedder: every PDType0Font.load(...) overload routes CFF outlines +through the TrueType path (which either rejects "TTF using CFF outlines" or, via the +OTFParser+TrueTypeFont overload, emits a CIDFontType2 and then fails to subset CFF +because there is no glyf table). So to build a genuine CIDFontType0 fixture you must: + 1. generate the CFF .otf with fontTools (this script), and + 2. assemble the Type0/CIDFontType0 dictionary by hand, attaching the raw CFF table + as /FontFile3 /Subtype /CIDFontType0C (see the companion Java harness). + +This script emits BOTH minimal_cff.otf and cff_raw.bin (the raw CFF table bytes, +ready to drop into /FontFile3). + +Glyph coverage: only the ASCII letters in "PUBLICA SECRETWORD KEEP" +(P U B L I C A S E R T W O D K) plus .notdef and space. Outlines are trivial filled +boxes; only the cmap / encoding correctness matters so the drawn text extracts. +""" +import sys +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.t2CharStringPen import T2CharStringPen +from fontTools.ttLib import TTFont + +UPM = 1000 +LETTERS = sorted(set("PUBLICASECRETWORDKEEP")) # A B C D E I K L O P R S T U W + + +def box_charstring(width=600, height=700): + """A simple filled rectangle as a Type2 (CFF) charstring.""" + pen = T2CharStringPen(width, None) + pen.moveTo((50, 0)) + pen.lineTo((width - 50, 0)) + pen.lineTo((width - 50, height)) + pen.lineTo((50, height)) + pen.closePath() + return pen.getCharString() + + +def build_otf(out_otf): + # GID order: .notdef (GID 0), space (GID 1), then the letters. The companion + # harness relies on this exact order for its Identity-H CID==GID mapping. + glyph_order = [".notdef", "space"] + list(LETTERS) + + fb = FontBuilder(unitsPerEm=UPM, isTTF=False) # isTTF=False -> CFF/OTF + fb.setupGlyphOrder(glyph_order) + + # Unicode cmap: codepoint -> glyph name. PDFBox reads this to build /ToUnicode, + # and it also lets any consumer resolve the glyphs by character. + cmap = {0x20: "space"} + for c in LETTERS: + cmap[ord(c)] = c + fb.setupCharacterMap(cmap) + + charstrings = { + ".notdef": box_charstring(), + "space": box_charstring(width=250, height=0), # empty advance-only glyph + } + for c in LETTERS: + charstrings[c] = box_charstring() + + fb.setupCFF( + psName="MinimalCFFCID", + charStringsDict=charstrings, + fontInfo={ + "FullName": "Minimal CFF CID", + "FamilyName": "MinimalCFFCID", + "Weight": "Regular", + }, + privateDict={}, + ) + + metrics = {".notdef": (600, 50), "space": (250, 0)} + for c in LETTERS: + metrics[c] = (600, 50) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=800, descent=-200) + + fb.setupNameTable( + { + "familyName": "MinimalCFFCID", + "styleName": "Regular", + "psName": "MinimalCFFCID", + "fullName": "Minimal CFF CID", + "version": "Version 1.000", + "uniqueFontIdentifier": "MinimalCFFCID;1.000", + } + ) + fb.setupOS2( + sTypoAscender=800, + sTypoDescender=-200, + usWinAscent=800, + usWinDescent=200, + sCapHeight=700, + ) + fb.setupPost(keepGlyphNames=True) + + fb.save(out_otf) + print("wrote", out_otf, "(", len(glyph_order), "glyphs )") + + +def dump_cff(out_otf, out_cff): + """Extract the raw CFF table bytes for use as PDF /FontFile3.""" + f = TTFont(out_otf) + cff = f["CFF "].compile(f) + with open(out_cff, "wb") as fh: + fh.write(cff) + print("wrote", out_cff, "(", len(cff), "bytes CFF )") + + +if __name__ == "__main__": + otf = sys.argv[1] if len(sys.argv) > 1 else "minimal_cff.otf" + cffbin = sys.argv[2] if len(sys.argv) > 2 else "cff_raw.bin" + build_otf(otf) + dump_cff(otf, cffbin) diff --git a/app/core/src/test/resources/redaction/generators/gen_cjk_font.py b/app/core/src/test/resources/redaction/generators/gen_cjk_font.py new file mode 100644 index 0000000000..f0f97073c1 --- /dev/null +++ b/app/core/src/test/resources/redaction/generators/gen_cjk_font.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Generate a minimal TrueType font (.ttf) that covers exactly the characters +needed for a CJK Type0 (Identity-H) redaction test: + + - ASCII letters in "PUBLICA SECRETWORD KEEP": P U B L I C A S E R T W O D K + space + - Two CJK ideographs used as the redaction target: + U+79D8 (secret1) and U+5BC6 (secret2) -> "秘密" + - .notdef + +Glyph outlines are trivial boxes; the point of the test is that the cmap / +encoding is correct so PDFBox can build a working /ToUnicode and the text is +EXTRACTABLE. When PDFBox loads this via PDType0Font.load(doc, in, true) it +becomes an Identity-H Type0 font with a CIDFontType2 descendant. + +Usage: python gen_cjk_font.py +""" +import sys +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + +UPM = 1000 + + +def box_glyph(width_units): + """A trivial filled box outline so the glyph has real contours.""" + pen = TTGlyphPen(None) + x0, y0, x1, y1 = 80, 0, max(120, width_units - 80), 700 + pen.moveTo((x0, y0)) + pen.lineTo((x0, y1)) + pen.lineTo((x1, y1)) + pen.lineTo((x1, y0)) + pen.closePath() + return pen.glyph() + + +def empty_glyph(): + return TTGlyphPen(None).glyph() + + +def main(out_path): + # Characters we must cover. Order defines glyph order (after .notdef). + ascii_chars = list("PUBLICA SECRETWORD KEEP") + ascii_unique = [] + for ch in ascii_chars: + if ch not in ascii_unique: + ascii_unique.append(ch) + + cjk = ["秘", "密"] # 秘 密 + + # glyph name -> codepoint mapping + def gname(cp): + if cp == 0x20: + return "space" + return "uni%04X" % cp + + codepoints = [] + for ch in ascii_unique: + codepoints.append(ord(ch)) + for ch in cjk: + codepoints.append(ord(ch)) + + glyph_order = [".notdef"] + [gname(cp) for cp in codepoints] + + fb = FontBuilder(UPM, isTTF=True) + fb.setupGlyphOrder(glyph_order) + + # cmap: map every codepoint to its glyph. FontBuilder builds format-4 + # (BMP) + format-12 as needed. Both CJK codepoints are BMP so format-4 + # covers them. + cmap = {cp: gname(cp) for cp in codepoints} + fb.setupCharacterMap(cmap) + + # Build glyf table with box outlines; advance widths. + glyphs = {".notdef": empty_glyph()} + metrics = {".notdef": (600, 0)} + for cp in codepoints: + name = gname(cp) + if cp == 0x20: + glyphs[name] = empty_glyph() + metrics[name] = (300, 0) # space advance, no outline + elif cp > 0x2000: # CJK -> full-width + glyphs[name] = box_glyph(900) + metrics[name] = (1000, 80) + else: + glyphs[name] = box_glyph(560) + metrics[name] = (600, 80) + + fb.setupGlyf(glyphs) + fb.setupHorizontalMetrics(metrics) + fb.setupHorizontalHeader(ascent=800, descent=-200) + + # Names + the rest of the required tables. + fb.setupNameTable({ + "familyName": "CJKRedactTest", + "styleName": "Regular", + "psName": "CJKRedactTest-Regular", + "fullName": "CJKRedactTest Regular", + }) + fb.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, + usWinDescent=200) + fb.setupPost() + + fb.save(out_path) + print("Wrote", out_path) + print("Glyph order:", glyph_order) + print("cmap entries:", {("U+%04X" % k): v for k, v in cmap.items()}) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("usage: gen_cjk_font.py ", file=sys.stderr) + sys.exit(2) + main(sys.argv[1]) diff --git a/app/core/src/test/resources/redaction/generators/gen_type1.py b/app/core/src/test/resources/redaction/generators/gen_type1.py new file mode 100644 index 0000000000..31dde563d3 --- /dev/null +++ b/app/core/src/test/resources/redaction/generators/gen_type1.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Generate a minimal real Type1 font as a classic PFB (0x80 0x01 segment format). + +Pure-python, uses fontTools.t1Lib.T1Font.createData() + writePFB. The font contains +.notdef plus the ASCII letters needed for "PUBLICA SECRETWORD KEEP", with a proper +Encoding array + standard PostScript glyph names. Because the glyph names are the +standard ones and the PDF font uses WinAnsiEncoding, PDFBox extracts text via the +base encoding (code -> glyphname -> Unicode); no /ToUnicode CMap is required. + +Usage: python gen_type1.py +""" +import sys +from fontTools import t1Lib +from fontTools.misc import psCharStrings + + +# Glyph name for each character we must render. PostScript standard glyph names. +CHARS = { + "P": "P", "U": "U", "B": "B", "L": "L", "I": "I", "C": "C", "A": "A", + "S": "S", "E": "E", "R": "R", "T": "T", "W": "W", "O": "O", "D": "D", + "K": "K", " ": "space", +} + +EM = 1000 +WIDTH = 600 +BOX_L, BOX_B, BOX_R, BOX_T = 50, 0, 550, 700 + + +def box_charstring(width): + """A trivial filled box outline. hsbw sets sidebearing+width; then a closed rect.""" + prog = [ + 0, width, "hsbw", # sb=0, width + BOX_L, BOX_B, "rmoveto", # move to bottom-left (relative from 0,0) + (BOX_R - BOX_L), "hlineto", # right + (BOX_T - BOX_B), "vlineto", # up + -(BOX_R - BOX_L), "hlineto", # left + "closepath", + "endchar", + ] + cs = psCharStrings.T1CharString(program=prog) + return cs + + +def space_charstring(width): + prog = [0, width, "hsbw", "endchar"] + return psCharStrings.T1CharString(program=prog) + + +def build_font(font_name="MinType1Test"): + # 256-slot encoding, default .notdef, filled at each char's codepoint. + encoding = [".notdef"] * 256 + for ch, gname in CHARS.items(): + encoding[ord(ch)] = gname + + charstrings = {".notdef": box_charstring(WIDTH)} + for ch, gname in CHARS.items(): + if gname == "space": + charstrings[gname] = space_charstring(WIDTH) + else: + charstrings[gname] = box_charstring(WIDTH) + + # Subrs: the 5 standard Type1 subrs (division/hint machinery). Provide as + # T1CharString objects so createData can compile+encrypt them. + subrs = [psCharStrings.T1CharString(bytecode=s) for s in t1Lib.std_subrs] + + # Order matters. fontTools emits a stray "put" after the Subrs array; fontbox's + # Type1Parser breaks its Private loop on the first non-LITERAL after Subrs, so + # every key AFTER Subrs is dropped. Keep Subrs LAST so nothing gets skipped. + private = { + "RD": t1Lib.RD_value, # ('string','currentfile','exch','readstring','pop') + "ND": t1Lib.ND_values[0], # ('def',) + "NP": t1Lib.PD_values[0], # ('put',) + "MinFeature": [16, 16], + # PDFBox reads BlueValues[2] for CapHeight, so >=3 entries required. + # Pairs: baseline (0 0), then cap-height zone (700 712). + "BlueValues": [0, 0, BOX_T, BOX_T + 12], + "StdHW": [60], + "StdVW": [80], + "lenIV": 4, + "password": 5839, + "Subrs": subrs, + } + + font_info = { + "version": "001.001", + "FullName": font_name, + "FamilyName": font_name, + "Weight": "Regular", + "ItalicAngle": 0, + "isFixedPitch": False, + "UnderlinePosition": -100, + "UnderlineThickness": 50, + } + + font = { + "FontName": font_name, + "FontInfo": font_info, + "FontType": 1, + "PaintType": 0, + "FontMatrix": [0.001, 0, 0, 0.001, 0, 0], + "FontBBox": [0, 0, WIDTH, BOX_T], + "Encoding": encoding, + "Private": private, + "CharStrings": charstrings, + } + return font + + +def main(): + out = sys.argv[1] if len(sys.argv) > 1 else "min.pfb" + # Construct a T1Font without reading a file, then hand-build .font. + t1 = t1Lib.T1Font.__new__(t1Lib.T1Font) + t1.encoding = "ascii" + t1.font = build_font() + data = t1.createData() # full ASCII+eexec Type1 program (binary eexec inline) + t1.data = data + t1.saveAs(out, "PFB") # split into 0x80 0x01 / 0x80 0x02 / 0x80 0x03 segments + # Report first bytes to confirm PFB marker. + with open(out, "rb") as f: + head = f.read(6) + print("WROTE", out) + print("PFB head bytes:", " ".join(f"{b:02x}" for b in head)) + assert head[0] == 0x80 and head[1] == 0x01, "not a classic PFB 0x80 0x01 header" + print("OK: classic PFB 0x80 0x01 ASCII segment marker present") + + +if __name__ == "__main__": + main() diff --git a/app/core/src/test/resources/redaction/type1_minimal.pfb b/app/core/src/test/resources/redaction/type1_minimal.pfb new file mode 100644 index 0000000000000000000000000000000000000000..091dda9ba5e047766a8fd0c244441384c7a33c71 GIT binary patch literal 2436 zcmZo*dS=FY2AW)| zs!pltnR!8}#rdU0$*EQfY5932sG1Co6jCyiOB7N{3lx$-in;VbhWI7srYh(o8?TU( zng)^c%uCBxFf_!_SC(2-oSC1epkZKOsAphcs0r0lnv(-IP6OFEm}FvZW=G0Id z^o$Y(jiA)@(wxL1s3y;n#GK4z$Gr5MR0RWwC7H!;nH8xi0huMq8478MImM|E@zA`K z)S{fsywrgF;>;3|>vRnbpnBkPAsLy;*?FnO#R{ensnom_h;~p=fC5;-5W){g%!GtL z%wCWJ-^7xl%nF5Q1CXZ`3_uXZiiKzcsd9442dPmoP%twvP%t+zfXcb%CFiGP=A|nb znVKmi78NB{av3NXDj1oXDpVUPWag!$Rw$%aBxfk->*eK_q@<=P6qJ_KDx~EXae?B@ z*hoRYxF9h(6(kH6F*8-rcSP{a6!e`CJaYwoX9UkeLEiW~LE4j3&C^fGnEi)%oAvLukHCds7$%ci2Vb>PJS8Thv1PmXp5}Y`%(CgUzgY~*^ zHY@0E)H{2#dWFbFo{h}+il-)?y5jci@u_V$qh5aa7tzIbtiPk<`h1*r~r+yJe=$S+)O8b(4ty3k!}P=iAvXp7H)R|A(Q`wUoIn zubV!4M}+Bh?aZ}hHCNi3u!BE4Kx19r)7sO85t+VUcFT#rNm{wQc&X;XX~&kfa?Co! zZjyU5kNLLu-RPA2S^52YJ>I-&e`*r>pwqFIBVykziDQa4BB$7v1ofJA^>GyXZcF{q zD10fZ|FKH^1;MsOuHh@Ja(1@A_4z3F-gxSUq@BjEL;nX)se90>5SIC@{^w#}k!y~t z<`loXp0GhhP-u>r@d9D%TcWkf6OHwjzYE>GF8rhWat41DW6LwO3~#s-KW=e;)2X&7 zeCk$iuD_WI%NH=OZ+7rF^kd3p-dUV}dfFQ5|2DDaRe!vEqp{__27D>$x~ zb4$W;b9$XnsQg{qhC`l*9&BsvKH=LIFmK6Ir@gPmm-Ovnarip_P|)l>`^-7C&nH!< z>QC;Rw&L=mqaTy_?ut*Np?UYm0!7`1)9Vtj~CbZ(;3i5F9KmL!C;BaWm=it5zBsGN+dK+08s!df>T9iqV^l!f&z{ciz3Nd6w(k zt60O!8yX(`d0qEz?;ii+hVb ztYpur3utk2?q@xvc+8>sNUo;GH&4L=!QJavW#i3uRj|e+hPMOnLk-p&tuo2B~> zf16`7*RZ?e#8lZ!Qo3`!XGPW|eGHhusB|DXJAI<{N0N~AnUe0}Yh3v+>rTSW7HuA*&|G8H;6nx8eaQR4o1J1o9TS2#iT&a8O? zbvw>YeU_=2V6weRb_Ih{{!^<3>1NmO2r*0ATQUDQx_XV^a>tU#%ah;#txCNU+?Wyl zMeIeC_lxg#6KuYSU$1?lct_atvDC^9(hEL0^EEK4Gchn2Pzbmv)Ne3qfWcr0gXEml b#G;b?+{B`6E>%_6ycAfo3B>bvYhVTdwW@k* literal 0 HcmV?d00001