Make manual-area redaction per-glyph precise and scope metadata scrubbing
This commit is contained in:
+2
-1
@@ -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()
|
||||
|
||||
+209
-46
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
-2
@@ -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()) {
|
||||
|
||||
+68
@@ -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) {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
Binary file not shown.
Reference in New Issue
Block a user