mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-10-20 15:21:27 +02:00
231 lines
7.3 KiB
TypeScript
231 lines
7.3 KiB
TypeScript
import {
|
|
PDFArray,
|
|
PDFDict,
|
|
PDFHexString,
|
|
PDFName,
|
|
PDFObject,
|
|
PDFRawStream,
|
|
PDFRef,
|
|
PDFStream,
|
|
PDFString,
|
|
decodePDFRawStream,
|
|
} from 'pdf-lib';
|
|
import type { PDFDocument } from 'pdf-lib';
|
|
import {
|
|
loadPdfDocument,
|
|
removeUnreferencedObjects,
|
|
} from './load-pdf-document.js';
|
|
|
|
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
|
|
|
|
function decodeText(value: PDFObject | undefined): string | null {
|
|
if (value instanceof PDFString || value instanceof PDFHexString) {
|
|
return value.decodeText();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function decodeStream(stream: PDFRawStream): Uint8Array | null {
|
|
try {
|
|
return decodePDFRawStream(stream).decode();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
|
const context = doc.context;
|
|
const needle = term.toLowerCase();
|
|
const pattern = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
const contains = (text: string) => text.toLowerCase().includes(needle);
|
|
const valueContains = (value: PDFObject | undefined): boolean => {
|
|
if (value instanceof PDFArray) return value.asArray().some(valueContains);
|
|
const text = decodeText(value);
|
|
return text !== null && contains(text);
|
|
};
|
|
const resolve = (value: PDFObject | undefined) =>
|
|
value instanceof PDFRef ? context.lookup(value) : value;
|
|
|
|
const matchedFiles = new Set<string>();
|
|
for (const [ref, object] of context.enumerateIndirectObjects()) {
|
|
if (
|
|
!(object instanceof PDFRawStream) ||
|
|
object.dict.get(PDFName.of('Type')) !== PDFName.of('EmbeddedFile')
|
|
) {
|
|
continue;
|
|
}
|
|
const bytes = decodeStream(object);
|
|
if (!bytes) continue;
|
|
const latin1 = new TextDecoder('latin1').decode(bytes);
|
|
const utf16 = new TextDecoder('utf-16le').decode(bytes);
|
|
if (contains(latin1) || contains(utf16)) matchedFiles.add(ref.toString());
|
|
}
|
|
|
|
const isMatchedFileSpec = (value: PDFObject | undefined): boolean => {
|
|
const spec = resolve(value);
|
|
if (!(spec instanceof PDFDict)) return false;
|
|
const ef = resolve(spec.get(PDFName.of('EF')));
|
|
if (!(ef instanceof PDFDict)) return false;
|
|
return ef
|
|
.values()
|
|
.some((v) => v instanceof PDFRef && matchedFiles.has(v.toString()));
|
|
};
|
|
|
|
const fieldValueContains = (widget: PDFDict): boolean => {
|
|
let node: PDFObject | undefined = widget;
|
|
for (let depth = 0; node instanceof PDFDict && depth < 32; depth++) {
|
|
if (valueContains(resolve(node.get(PDFName.of('V'))))) return true;
|
|
node = resolve(node.get(PDFName.of('Parent')));
|
|
}
|
|
return false;
|
|
};
|
|
|
|
let clearedFieldAppearance = false;
|
|
for (const page of doc.getPages()) {
|
|
page.node.delete(PDFName.of('Thumb'));
|
|
const annots = page.node.Annots();
|
|
if (!annots) continue;
|
|
|
|
const removed = new Set<string>();
|
|
for (const entry of annots.asArray()) {
|
|
const annot = resolve(entry);
|
|
if (!(annot instanceof PDFDict)) continue;
|
|
const subtype = annot.get(PDFName.of('Subtype'));
|
|
|
|
if (subtype === PDFName.of('Widget')) {
|
|
if (fieldValueContains(annot)) {
|
|
annot.delete(PDFName.of('AP'));
|
|
clearedFieldAppearance = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const hasText = ANNOTATION_TEXT_KEYS.some((key) =>
|
|
valueContains(resolve(annot.get(PDFName.of(key))))
|
|
);
|
|
const hasFile =
|
|
subtype === PDFName.of('FileAttachment') &&
|
|
isMatchedFileSpec(annot.get(PDFName.of('FS')));
|
|
if (hasText || hasFile) {
|
|
removed.add(entry.toString());
|
|
const popup = annot.get(PDFName.of('Popup'));
|
|
if (popup) removed.add(popup.toString());
|
|
}
|
|
}
|
|
|
|
if (removed.size > 0) {
|
|
page.node.set(
|
|
PDFName.of('Annots'),
|
|
context.obj(
|
|
annots.asArray().filter((entry) => !removed.has(entry.toString()))
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (clearedFieldAppearance) {
|
|
const acroForm = resolve(doc.catalog.get(PDFName.of('AcroForm')));
|
|
if (acroForm instanceof PDFDict) {
|
|
acroForm.set(PDFName.of('NeedAppearances'), context.obj(true));
|
|
}
|
|
}
|
|
|
|
const pruneNameTree = (value: PDFObject | undefined, depth = 0) => {
|
|
const node = resolve(value);
|
|
if (!(node instanceof PDFDict) || depth > 32) return;
|
|
const names = resolve(node.get(PDFName.of('Names')));
|
|
if (names instanceof PDFArray) {
|
|
const items = names.asArray();
|
|
const kept: PDFObject[] = [];
|
|
for (let i = 0; i + 1 < items.length; i += 2) {
|
|
if (!isMatchedFileSpec(items[i + 1])) kept.push(items[i], items[i + 1]);
|
|
}
|
|
node.set(PDFName.of('Names'), context.obj(kept));
|
|
}
|
|
const kids = resolve(node.get(PDFName.of('Kids')));
|
|
if (kids instanceof PDFArray) {
|
|
for (const kid of kids.asArray()) pruneNameTree(kid, depth + 1);
|
|
}
|
|
};
|
|
const pruneAssociatedFiles = (dict: PDFDict) => {
|
|
const list = resolve(dict.get(PDFName.of('AF')));
|
|
if (!(list instanceof PDFArray)) return;
|
|
const kept = list.asArray().filter((spec) => !isMatchedFileSpec(spec));
|
|
if (kept.length !== list.size()) {
|
|
dict.set(PDFName.of('AF'), context.obj(kept));
|
|
}
|
|
};
|
|
|
|
if (matchedFiles.size > 0) {
|
|
const names = resolve(doc.catalog.get(PDFName.of('Names')));
|
|
if (names instanceof PDFDict) {
|
|
pruneNameTree(names.get(PDFName.of('EmbeddedFiles')));
|
|
}
|
|
for (const [, object] of context.enumerateIndirectObjects()) {
|
|
if (object instanceof PDFDict) pruneAssociatedFiles(object);
|
|
else if (object instanceof PDFStream) pruneAssociatedFiles(object.dict);
|
|
}
|
|
}
|
|
|
|
const scrubValue = (value: PDFObject): PDFObject | null => {
|
|
const text = decodeText(value);
|
|
if (text !== null && contains(text)) {
|
|
return PDFHexString.fromText(text.replace(pattern, ''));
|
|
}
|
|
if (value instanceof PDFDict || value instanceof PDFArray) {
|
|
scrubContainer(value);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const scrubContainer = (container: PDFDict | PDFArray) => {
|
|
if (container instanceof PDFDict) {
|
|
for (const [key, value] of container.entries()) {
|
|
const replacement = scrubValue(value);
|
|
if (replacement) container.set(key, replacement);
|
|
}
|
|
} else {
|
|
container.asArray().forEach((value, index) => {
|
|
const replacement = scrubValue(value);
|
|
if (replacement) container.set(index, replacement);
|
|
});
|
|
}
|
|
};
|
|
|
|
for (const [ref, object] of context.enumerateIndirectObjects()) {
|
|
if (object instanceof PDFStream) {
|
|
scrubContainer(object.dict);
|
|
const isMetadata =
|
|
object.dict.get(PDFName.of('Type')) === PDFName.of('Metadata');
|
|
if (isMetadata && object instanceof PDFRawStream) {
|
|
const bytes = decodeStream(object);
|
|
const xml = bytes ? new TextDecoder('utf-8').decode(bytes) : null;
|
|
if (xml !== null && contains(xml)) {
|
|
context.assign(
|
|
ref,
|
|
context.flateStream(xml.replace(pattern, ''), {
|
|
Type: 'Metadata',
|
|
Subtype: 'XML',
|
|
})
|
|
);
|
|
}
|
|
}
|
|
} else if (object instanceof PDFDict || object instanceof PDFArray) {
|
|
scrubContainer(object);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function redactDocumentData(
|
|
pdf: Uint8Array,
|
|
term?: string
|
|
): Promise<Uint8Array> {
|
|
const doc = await loadPdfDocument(pdf);
|
|
if (term) {
|
|
removeTextFromDocument(doc, term);
|
|
} else {
|
|
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
|
|
}
|
|
return removeUnreferencedObjects(await doc.save());
|
|
}
|