Make manual-area redaction per-glyph precise and scope metadata scrubbing

This commit is contained in:
Anthony Stirling
2026-07-03 10:16:47 +01:00
parent e91d535c36
commit 7217e6ac15
11 changed files with 834 additions and 73 deletions
@@ -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<String> TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\"");
private static final Set<String> TEXT_SHOWING_OPERATORS =
stirling.software.SPDF.pdf.redaction.RedactionPipeline.TEXT_SHOWING_OPERATORS;
private static final COSString EMPTY_COS_STRING = new COSString("");
// Public API
@@ -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<String> targets, List<Pattern> 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<String> 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<String> dropLiterals = carrierDropLiterals(targets);
boolean keyHit = key != null && matches(key, dropLiterals, patterns);
boolean valueHit =
i + 1 < namesArray.size()
@@ -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<String> TEXT_SHOWING_OPERATORS = Set.of("Tj", "TJ", "'", "\"");
public static final Set<String> 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<String> 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<PDRectangle> rects) {
try {
List<Rectangle2D.Float> 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<PDRectangle> 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<Integer> dropTokenIndexes = collector.tokenIndexesToDrop;
int collectorTextOps = collector.totalTextOps();
List<Object> tokens = parseTokens(new PDFStreamParser(page));
Set<Integer> 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<Integer> 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<Integer> 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<Integer> dropGlyphs, int expectedGlyphs) {
if (font == null) {
return null;
}
List<COSBase> 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<Integer, Integer> 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<Rectangle2D.Float> rects;
private final Set<Integer> tokenIndexesToDrop = new HashSet<>();
private final Map<Integer, Set<Integer>> dropGlyphsByOp = new HashMap<>();
private final Map<Integer, Integer> glyphCountByOp = new HashMap<>();
private int showTextOpCounter = -1;
private boolean currentOpInRect = false;
private int glyphInOp = 0;
TokenIndexCollector(List<Rectangle2D.Float> 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);
}
}
}
@@ -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> <FFFF>\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()) {
@@ -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<Integer, List<PDRectangle>> 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) {
@@ -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)
@@ -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 <output.ttf>
"""
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 <output.ttf>", file=sys.stderr)
sys.exit(2)
main(sys.argv[1])
@@ -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 <out.pfb>
"""
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()