mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-12 09:34:01 +01:00
fix(security): harden sanitize, flatten, redaction, signature validation, CSP, proxies and CI
This commit is contained in:
1 parent
b0a186839a
commit
edd959f345
62 files changed
+4813
-926
No files matched your search
@@ -7,8 +7,65 @@ import {
|
||||
PDFNumber,
|
||||
PDFOperator,
|
||||
PDFOperatorNames,
|
||||
PDFObject,
|
||||
PDFTextField,
|
||||
} from 'pdf-lib';
|
||||
|
||||
const HIDDEN_ANNOTATION_FLAGS = 0x02 | 0x20;
|
||||
|
||||
function isHiddenAnnotation(annot: PDFDict): boolean {
|
||||
const flags = annot.lookup(PDFName.of('F'));
|
||||
return (
|
||||
flags instanceof PDFNumber &&
|
||||
(flags.asNumber() & HIDDEN_ANNOTATION_FLAGS) !== 0
|
||||
);
|
||||
}
|
||||
|
||||
function detachAnnotations(pdfDoc: PDFDocument, refs: Set<PDFObject>) {
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const annots = page.node.Annots();
|
||||
if (!annots) continue;
|
||||
for (let i = annots.size() - 1; i >= 0; i--) {
|
||||
if (refs.has(annots.get(i))) annots.remove(i);
|
||||
}
|
||||
}
|
||||
for (const ref of refs) {
|
||||
if (ref instanceof PDFRef) pdfDoc.context.delete(ref);
|
||||
}
|
||||
}
|
||||
|
||||
function removeHiddenFormFields(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
const removedRefs = new Set<PDFObject>();
|
||||
for (const field of form.getFields()) {
|
||||
const removeAll = field instanceof PDFTextField && field.isPassword();
|
||||
const kids = field.acroField.Kids();
|
||||
if (kids) {
|
||||
for (let i = kids.size() - 1; i >= 0; i--) {
|
||||
const widget = kids.lookup(i);
|
||||
if (
|
||||
removeAll ||
|
||||
(widget instanceof PDFDict && isHiddenAnnotation(widget))
|
||||
) {
|
||||
removedRefs.add(kids.get(i));
|
||||
kids.remove(i);
|
||||
}
|
||||
}
|
||||
if (kids.size() > 0) continue;
|
||||
} else if (!removeAll && !isHiddenAnnotation(field.acroField.dict)) {
|
||||
continue;
|
||||
}
|
||||
form.acroForm.removeField(field.acroField);
|
||||
removedRefs.add(field.ref);
|
||||
}
|
||||
if (removedRefs.size > 0) detachAnnotations(pdfDoc, removedRefs);
|
||||
}
|
||||
|
||||
export function flattenFormsInDoc(pdfDoc: PDFDocument): void {
|
||||
removeHiddenFormFields(pdfDoc);
|
||||
pdfDoc.getForm().flatten();
|
||||
}
|
||||
|
||||
function extractNumbers(arr: PDFArray, count: number): number[] | null {
|
||||
if (arr.size() < count) return null;
|
||||
const result: number[] = [];
|
||||
@@ -68,9 +125,7 @@ export function flattenAnnotations(pdfDoc: PDFDocument): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flagsObj = annot.get(PDFName.of('F'));
|
||||
const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0;
|
||||
if (flags & 0x02) {
|
||||
if (isHiddenAnnotation(annot)) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export async function convertToPdfA(
|
||||
}
|
||||
|
||||
const args = [
|
||||
'-dNOSAFER',
|
||||
'-dSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
@@ -418,7 +418,7 @@ export async function convertFontsToOutlines(
|
||||
onProgress?.('Converting fonts to outlines...');
|
||||
|
||||
const args = [
|
||||
'-dNOSAFER',
|
||||
'-dSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=pdfwrite',
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { ContentOperation, ContentToken } from '@/types';
|
||||
|
||||
const charCode = (char: string) => char.charCodeAt(0);
|
||||
|
||||
const BACKSLASH = charCode('\\');
|
||||
const OPEN_PAREN = charCode('(');
|
||||
const CLOSE_PAREN = charCode(')');
|
||||
const LESS_THAN = charCode('<');
|
||||
const GREATER_THAN = charCode('>');
|
||||
const OPEN_BRACKET = charCode('[');
|
||||
const CLOSE_BRACKET = charCode(']');
|
||||
const SLASH = charCode('/');
|
||||
const PERCENT = charCode('%');
|
||||
const LINE_FEED = charCode('\n');
|
||||
const CARRIAGE_RETURN = charCode('\r');
|
||||
const LETTER_E = charCode('E');
|
||||
const LETTER_I = charCode('I');
|
||||
|
||||
const WHITESPACE = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]);
|
||||
const DELIMITERS = new Set([...'()<>[]{}/%'].map(charCode));
|
||||
const OPERAND_KEYWORDS = new Set(['true', 'false', 'null']);
|
||||
const TEXT_CHECK_LENGTH = 16;
|
||||
|
||||
const COMPONENTS_PER_COLOR_SPACE = new Map([
|
||||
['G', 1],
|
||||
['DeviceGray', 1],
|
||||
['RGB', 3],
|
||||
['DeviceRGB', 3],
|
||||
['CMYK', 4],
|
||||
['DeviceCMYK', 4],
|
||||
['I', 1],
|
||||
['Indexed', 1],
|
||||
]);
|
||||
|
||||
function isRegularChar(byte: number): boolean {
|
||||
return !WHITESPACE.has(byte) && !DELIMITERS.has(byte);
|
||||
}
|
||||
|
||||
function isHexDigit(byte: number): boolean {
|
||||
return (
|
||||
(byte >= charCode('0') && byte <= charCode('9')) ||
|
||||
(byte >= charCode('A') && byte <= charCode('F')) ||
|
||||
(byte >= charCode('a') && byte <= charCode('f'))
|
||||
);
|
||||
}
|
||||
|
||||
function bytesToString(bytes: Uint8Array, start: number, end: number): string {
|
||||
let text = '';
|
||||
for (let i = start; i < end; i++) text += String.fromCharCode(bytes[i]);
|
||||
return text;
|
||||
}
|
||||
|
||||
function skipWhitespaceAndComments(bytes: Uint8Array, pos: number): number {
|
||||
while (pos < bytes.length) {
|
||||
if (WHITESPACE.has(bytes[pos])) {
|
||||
pos++;
|
||||
} else if (bytes[pos] === PERCENT) {
|
||||
while (
|
||||
pos < bytes.length &&
|
||||
bytes[pos] !== LINE_FEED &&
|
||||
bytes[pos] !== CARRIAGE_RETURN
|
||||
) {
|
||||
pos++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
function findLiteralStringEnd(bytes: Uint8Array, start: number): number {
|
||||
let depth = 0;
|
||||
for (let i = start; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
if (byte === BACKSLASH) {
|
||||
i++;
|
||||
} else if (byte === OPEN_PAREN) {
|
||||
depth++;
|
||||
} else if (byte === CLOSE_PAREN) {
|
||||
depth--;
|
||||
if (depth === 0) return i + 1;
|
||||
}
|
||||
}
|
||||
throw new Error('Unterminated string in content stream');
|
||||
}
|
||||
|
||||
function findHexStringEnd(bytes: Uint8Array, start: number): number {
|
||||
for (let i = start + 1; i < bytes.length; i++) {
|
||||
const byte = bytes[i];
|
||||
if (byte === GREATER_THAN) return i + 1;
|
||||
if (!isHexDigit(byte) && !WHITESPACE.has(byte)) {
|
||||
throw new Error('Invalid hex string in content stream');
|
||||
}
|
||||
}
|
||||
throw new Error('Unterminated hex string in content stream');
|
||||
}
|
||||
|
||||
function findClosingDelimiter(
|
||||
bytes: Uint8Array,
|
||||
start: number,
|
||||
closing: number[]
|
||||
): number {
|
||||
let pos = start;
|
||||
while (true) {
|
||||
pos = skipWhitespaceAndComments(bytes, pos);
|
||||
if (pos >= bytes.length) {
|
||||
throw new Error('Unterminated array or dictionary in content stream');
|
||||
}
|
||||
if (closing.every((byte, offset) => bytes[pos + offset] === byte)) {
|
||||
return pos + closing.length;
|
||||
}
|
||||
pos = readToken(bytes, pos).end;
|
||||
}
|
||||
}
|
||||
|
||||
function readToken(bytes: Uint8Array, start: number): ContentToken {
|
||||
const byte = bytes[start];
|
||||
|
||||
if (byte === OPEN_PAREN) {
|
||||
const end = findLiteralStringEnd(bytes, start);
|
||||
return { kind: 'operand', value: '', start, end };
|
||||
}
|
||||
if (byte === LESS_THAN && bytes[start + 1] === LESS_THAN) {
|
||||
const end = findClosingDelimiter(bytes, start + 2, [
|
||||
GREATER_THAN,
|
||||
GREATER_THAN,
|
||||
]);
|
||||
return { kind: 'dict', value: '', start, end };
|
||||
}
|
||||
if (byte === LESS_THAN) {
|
||||
const end = findHexStringEnd(bytes, start);
|
||||
return { kind: 'operand', value: '', start, end };
|
||||
}
|
||||
if (byte === OPEN_BRACKET) {
|
||||
const end = findClosingDelimiter(bytes, start + 1, [CLOSE_BRACKET]);
|
||||
return { kind: 'operand', value: '', start, end };
|
||||
}
|
||||
if (byte === SLASH) {
|
||||
let end = start + 1;
|
||||
while (end < bytes.length && isRegularChar(bytes[end])) end++;
|
||||
const name = bytesToString(bytes, start + 1, end);
|
||||
return { kind: 'name', value: name, start, end };
|
||||
}
|
||||
if (DELIMITERS.has(byte)) {
|
||||
throw new Error('Unexpected delimiter in content stream');
|
||||
}
|
||||
|
||||
let end = start;
|
||||
while (end < bytes.length && isRegularChar(bytes[end])) end++;
|
||||
const word = bytesToString(bytes, start, end);
|
||||
const isNumber = /^[\d+.-]/.test(word);
|
||||
const kind = isNumber || OPERAND_KEYWORDS.has(word) ? 'operand' : 'operator';
|
||||
return { kind, value: word, start, end };
|
||||
}
|
||||
|
||||
function isFollowedByText(bytes: Uint8Array, start: number): boolean {
|
||||
const end = Math.min(bytes.length, start + TEXT_CHECK_LENGTH);
|
||||
for (let i = start; i < end; i++) {
|
||||
const byte = bytes[i];
|
||||
const isPrintable = byte >= 0x20 && byte <= 0x7e;
|
||||
if (!isPrintable && !WHITESPACE.has(byte)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getImageDataLength(
|
||||
bytes: Uint8Array,
|
||||
imageDict: Map<string, ContentToken>
|
||||
): number | undefined {
|
||||
const lookup = (shortKey: string, longKey: string) =>
|
||||
imageDict.get(shortKey) ?? imageDict.get(longKey);
|
||||
const toInteger = (token: ContentToken | undefined) =>
|
||||
token?.kind === 'operand' && /^\d+$/.test(token.value)
|
||||
? Number(token.value)
|
||||
: undefined;
|
||||
|
||||
if (lookup('F', 'Filter')) return undefined;
|
||||
|
||||
const isImageMask = lookup('IM', 'ImageMask')?.value === 'true';
|
||||
const colorSpace = lookup('CS', 'ColorSpace');
|
||||
const isIndexedColor =
|
||||
colorSpace?.kind === 'operand' &&
|
||||
/^\[\s*\/(I|Indexed)[\s/]/.test(
|
||||
bytesToString(bytes, colorSpace.start, colorSpace.end)
|
||||
);
|
||||
|
||||
let componentsPerPixel: number | undefined;
|
||||
if (isImageMask || isIndexedColor) {
|
||||
componentsPerPixel = 1;
|
||||
} else if (colorSpace?.kind === 'name') {
|
||||
componentsPerPixel = COMPONENTS_PER_COLOR_SPACE.get(colorSpace.value);
|
||||
}
|
||||
|
||||
const width = toInteger(lookup('W', 'Width'));
|
||||
const height = toInteger(lookup('H', 'Height'));
|
||||
const bitsPerComponent = isImageMask
|
||||
? 1
|
||||
: toInteger(lookup('BPC', 'BitsPerComponent'));
|
||||
|
||||
if (
|
||||
width === undefined ||
|
||||
height === undefined ||
|
||||
bitsPerComponent === undefined ||
|
||||
componentsPerPixel === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const bytesPerRow = Math.ceil(
|
||||
(width * bitsPerComponent * componentsPerPixel) / 8
|
||||
);
|
||||
return height * bytesPerRow;
|
||||
}
|
||||
|
||||
function isEndImageOperator(bytes: Uint8Array, pos: number): boolean {
|
||||
return (
|
||||
bytes[pos] === LETTER_E &&
|
||||
bytes[pos + 1] === LETTER_I &&
|
||||
(pos + 2 >= bytes.length || !isRegularChar(bytes[pos + 2]))
|
||||
);
|
||||
}
|
||||
|
||||
function skipInlineImage(bytes: Uint8Array, start: number): number {
|
||||
const imageDict = new Map<string, ContentToken>();
|
||||
let pendingKey: ContentToken | undefined;
|
||||
let pos = start;
|
||||
|
||||
while (true) {
|
||||
pos = skipWhitespaceAndComments(bytes, pos);
|
||||
if (pos >= bytes.length) {
|
||||
throw new Error('Unterminated inline image in content stream');
|
||||
}
|
||||
const token = readToken(bytes, pos);
|
||||
pos = token.end;
|
||||
if (token.kind === 'operator' && token.value === 'ID') break;
|
||||
if (pendingKey) {
|
||||
imageDict.set(pendingKey.value, token);
|
||||
pendingKey = undefined;
|
||||
} else if (token.kind === 'name') {
|
||||
pendingKey = token;
|
||||
} else {
|
||||
throw new Error('Malformed inline image in content stream');
|
||||
}
|
||||
}
|
||||
|
||||
const dataStart = WHITESPACE.has(bytes[pos]) ? pos + 1 : pos;
|
||||
const dataLength = getImageDataLength(bytes, imageDict);
|
||||
|
||||
if (dataLength !== undefined) {
|
||||
let end = dataStart + dataLength;
|
||||
while (end < bytes.length && WHITESPACE.has(bytes[end])) end++;
|
||||
if (isEndImageOperator(bytes, end)) return end + 2;
|
||||
throw new Error('Malformed inline image in content stream');
|
||||
}
|
||||
|
||||
for (let i = dataStart; i + 1 < bytes.length; i++) {
|
||||
if (!WHITESPACE.has(bytes[i - 1]) || !isEndImageOperator(bytes, i)) {
|
||||
continue;
|
||||
}
|
||||
if (!isFollowedByText(bytes, i + 2)) break;
|
||||
return i + 2;
|
||||
}
|
||||
throw new Error('Unterminated inline image in content stream');
|
||||
}
|
||||
|
||||
export function parseContentOperations(bytes: Uint8Array): ContentOperation[] {
|
||||
const operations: ContentOperation[] = [];
|
||||
let operands: ContentToken[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (true) {
|
||||
pos = skipWhitespaceAndComments(bytes, pos);
|
||||
if (pos >= bytes.length) break;
|
||||
const token = readToken(bytes, pos);
|
||||
pos = token.end;
|
||||
if (token.kind !== 'operator') {
|
||||
operands.push(token);
|
||||
continue;
|
||||
}
|
||||
if (token.value === 'BI') pos = skipInlineImage(bytes, pos);
|
||||
operations.push({
|
||||
operator: token.value,
|
||||
operands,
|
||||
start: operands.length > 0 ? operands[0].start : token.start,
|
||||
end: pos,
|
||||
});
|
||||
operands = [];
|
||||
}
|
||||
|
||||
if (operands.length > 0) {
|
||||
operations.push({
|
||||
operator: '',
|
||||
operands,
|
||||
start: operands[0].start,
|
||||
end: operands[operands.length - 1].end,
|
||||
});
|
||||
}
|
||||
return operations;
|
||||
}
|
||||
@@ -17,6 +17,15 @@ import {
|
||||
} from './load-pdf-document.js';
|
||||
|
||||
const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj'];
|
||||
const NON_TEXT_KEYS = new Set(
|
||||
['CreationDate', 'ModDate', 'M', 'ID'].map((key) => PDFName.of(key))
|
||||
);
|
||||
const SIGNATURE_TYPES = new Set(
|
||||
['Sig', 'DocTimeStamp'].map((type) => PDFName.of(type))
|
||||
);
|
||||
const SIGNATURE_BINARY_KEYS = new Set(
|
||||
['Contents', 'Cert'].map((key) => PDFName.of(key))
|
||||
);
|
||||
|
||||
function decodeText(value: PDFObject | undefined): string | null {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
@@ -33,11 +42,20 @@ function decodeStream(stream: PDFRawStream): Uint8Array | null {
|
||||
}
|
||||
}
|
||||
|
||||
function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
function removeTextFromDocument(doc: PDFDocument, terms: 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 needles = terms.map((term) => term.toLowerCase());
|
||||
const pattern = new RegExp(
|
||||
[...terms]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|'),
|
||||
'gi'
|
||||
);
|
||||
const contains = (text: string) => {
|
||||
const lowered = text.toLowerCase();
|
||||
return needles.some((needle) => lowered.includes(needle));
|
||||
};
|
||||
const valueContains = (value: PDFObject | undefined): boolean => {
|
||||
if (value instanceof PDFArray) return value.asArray().some(valueContains);
|
||||
const text = decodeText(value);
|
||||
@@ -180,7 +198,11 @@ function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
|
||||
const scrubContainer = (container: PDFDict | PDFArray) => {
|
||||
if (container instanceof PDFDict) {
|
||||
const type = container.get(PDFName.of('Type'));
|
||||
const isSignature = type instanceof PDFName && SIGNATURE_TYPES.has(type);
|
||||
for (const [key, value] of container.entries()) {
|
||||
if (NON_TEXT_KEYS.has(key)) continue;
|
||||
if (isSignature && SIGNATURE_BINARY_KEYS.has(key)) continue;
|
||||
const replacement = scrubValue(value);
|
||||
if (replacement) container.set(key, replacement);
|
||||
}
|
||||
@@ -218,11 +240,12 @@ function removeTextFromDocument(doc: PDFDocument, term: string): void {
|
||||
|
||||
export async function redactDocumentData(
|
||||
pdf: Uint8Array,
|
||||
term?: string
|
||||
terms: readonly string[] = []
|
||||
): Promise<Uint8Array> {
|
||||
const doc = await loadPdfDocument(pdf);
|
||||
if (term) {
|
||||
removeTextFromDocument(doc, term);
|
||||
const searchTerms = terms.filter((term) => term.length > 0);
|
||||
if (searchTerms.length > 0) {
|
||||
removeTextFromDocument(doc, searchTerms);
|
||||
} else {
|
||||
for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb'));
|
||||
}
|
||||
|
||||
+521
-128
@@ -1,9 +1,24 @@
|
||||
import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } from 'pdf-lib';
|
||||
import {
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFDict,
|
||||
PDFArray,
|
||||
PDFStream,
|
||||
PDFObject,
|
||||
PDFRef,
|
||||
PDFRawStream,
|
||||
PDFFlateStream,
|
||||
PDFPageLeaf,
|
||||
PDFContext,
|
||||
decodePDFRawStream,
|
||||
} from 'pdf-lib';
|
||||
import {
|
||||
loadPdfDocument,
|
||||
removeUnreferencedObjects,
|
||||
} from './load-pdf-document.js';
|
||||
import type { PDFDocumentInternal } from '@/types';
|
||||
import { flattenFormsInDoc } from './flatten-annotations.js';
|
||||
import { parseContentOperations } from './pdf-content-tokens.js';
|
||||
import type { ContentOperation, PDFDocumentInternal } from '@/types';
|
||||
|
||||
function getCatalogDict(pdfDoc: PDFDocument): PDFDict {
|
||||
return pdfDoc.catalog;
|
||||
@@ -33,6 +48,22 @@ function getErrorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function forEachDict(pdfDoc: PDFDocument, visit: (dict: PDFDict) => void) {
|
||||
const walk = (value: PDFObject) => {
|
||||
if (value instanceof PDFStream) {
|
||||
walk(value.dict);
|
||||
} else if (value instanceof PDFDict) {
|
||||
visit(value);
|
||||
for (const [, entry] of value.entries()) walk(entry);
|
||||
} else if (value instanceof PDFArray) {
|
||||
for (const entry of value.asArray()) walk(entry);
|
||||
}
|
||||
};
|
||||
for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) {
|
||||
walk(object);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SanitizeOptions {
|
||||
flattenForms: boolean;
|
||||
removeMetadata: boolean;
|
||||
@@ -74,10 +105,11 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
pdfDoc.setProducer('');
|
||||
|
||||
try {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
if (catalogDict.has(PDFName.of('Metadata'))) {
|
||||
catalogDict.delete(PDFName.of('Metadata'));
|
||||
}
|
||||
forEachDict(pdfDoc, (dict) => {
|
||||
dict.delete(PDFName.of('Metadata'));
|
||||
dict.delete(PDFName.of('PieceInfo'));
|
||||
dict.delete(PDFName.of('Thumb'));
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove XMP metadata:', getErrorMessage(e));
|
||||
}
|
||||
@@ -90,15 +122,6 @@ function removeMetadataFromDoc(pdfDoc: PDFDocument) {
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove document IDs:', getErrorMessage(e));
|
||||
}
|
||||
|
||||
try {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
if (catalogDict.has(PDFName.of('PieceInfo'))) {
|
||||
catalogDict.delete(PDFName.of('PieceInfo'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove PieceInfo:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -115,9 +138,72 @@ function removeAnnotationsFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
function flattenFormsInDoc(pdfDoc: PDFDocument) {
|
||||
const form = pdfDoc.getForm();
|
||||
form.flatten();
|
||||
const DANGEROUS_ACTIONS = new Set([
|
||||
'JavaScript',
|
||||
'Launch',
|
||||
'ImportData',
|
||||
'SubmitForm',
|
||||
'GoToE',
|
||||
'Rendition',
|
||||
]);
|
||||
|
||||
function isDangerousAction(action: PDFDict): boolean {
|
||||
const type = action.lookup(PDFName.of('S'));
|
||||
return (
|
||||
action.has(PDFName.of('JS')) ||
|
||||
(type instanceof PDFName && DANGEROUS_ACTIONS.has(type.decodeText()))
|
||||
);
|
||||
}
|
||||
|
||||
function keepSafeActions(
|
||||
context: PDFContext,
|
||||
value: PDFObject,
|
||||
seen: Set<PDFObject>
|
||||
): PDFObject[] {
|
||||
const resolved = context.lookup(value);
|
||||
if (resolved instanceof PDFArray) {
|
||||
if (seen.has(resolved)) return [];
|
||||
seen.add(resolved);
|
||||
return resolved
|
||||
.asArray()
|
||||
.flatMap((item) => keepSafeActions(context, item, seen));
|
||||
}
|
||||
if (!(resolved instanceof PDFDict) || !isDangerousAction(resolved)) {
|
||||
return [value];
|
||||
}
|
||||
if (seen.has(resolved)) return [];
|
||||
seen.add(resolved);
|
||||
const next = resolved.get(PDFName.of('Next'));
|
||||
return next ? keepSafeActions(context, next, seen) : [];
|
||||
}
|
||||
|
||||
function removeDangerousActions(context: PDFContext, dict: PDFDict) {
|
||||
dict.delete(PDFName.of('AA'));
|
||||
const keys = ['A', 'OpenAction', 'PA'];
|
||||
if (dict.has(PDFName.of('S'))) keys.push('Next');
|
||||
for (const key of keys) {
|
||||
const name = PDFName.of(key);
|
||||
const value = dict.get(name);
|
||||
if (value === undefined) continue;
|
||||
const resolved = context.lookup(value);
|
||||
if (key !== 'Next' && !(resolved instanceof PDFDict)) continue;
|
||||
const safeActions = keepSafeActions(context, value, new Set());
|
||||
const originalActions =
|
||||
resolved instanceof PDFArray ? resolved.asArray() : [value];
|
||||
if (
|
||||
safeActions.length === originalActions.length &&
|
||||
safeActions.every((item, i) => item === originalActions[i])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (safeActions.length === 0) {
|
||||
dict.delete(name);
|
||||
} else if (key === 'Next' && safeActions.length > 1) {
|
||||
dict.set(name, context.obj(safeActions));
|
||||
} else {
|
||||
dict.set(name, safeActions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -140,92 +226,13 @@ function removeJavascriptFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('OpenAction'))) {
|
||||
catalogDict.delete(PDFName.of('OpenAction'));
|
||||
const acroForm = catalogDict.lookup(PDFName.of('AcroForm'));
|
||||
if (acroForm instanceof PDFDict) {
|
||||
acroForm.delete(PDFName.of('XFA'));
|
||||
catalogDict.delete(PDFName.of('NeedsRendering'));
|
||||
}
|
||||
|
||||
if (catalogDict.has(PDFName.of('AA'))) {
|
||||
catalogDict.delete(PDFName.of('AA'));
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
|
||||
if (pageDict.has(PDFName.of('AA'))) {
|
||||
pageDict.delete(PDFName.of('AA'));
|
||||
}
|
||||
|
||||
const annotRefs = pageDict.Annots()?.asArray() || [];
|
||||
for (const annotRef of annotRefs) {
|
||||
try {
|
||||
const annot = lookupAsDict(pdfDoc, annotRef);
|
||||
if (!annot) continue;
|
||||
|
||||
if (annot.has(PDFName.of('A'))) {
|
||||
const actionRef = annot.get(PDFName.of('A'));
|
||||
try {
|
||||
const actionDict = lookupAsDict(pdfDoc, actionRef);
|
||||
const actionType = actionDict
|
||||
?.get(PDFName.of('S'))
|
||||
?.toString()
|
||||
.substring(1);
|
||||
|
||||
if (actionType === 'JavaScript') {
|
||||
annot.delete(PDFName.of('A'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not read action:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
if (annot.has(PDFName.of('AA'))) {
|
||||
annot.delete(PDFName.of('AA'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(
|
||||
'Could not process annotation for JS:',
|
||||
getErrorMessage(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove page actions:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
|
||||
if (acroFormRef) {
|
||||
const acroFormDict = lookupAsDict(pdfDoc, acroFormRef);
|
||||
const fieldsRef = acroFormDict?.get(PDFName.of('Fields'));
|
||||
|
||||
if (fieldsRef) {
|
||||
const fieldsArray = lookupAsArray(pdfDoc, fieldsRef);
|
||||
const fields = fieldsArray?.asArray() || [];
|
||||
|
||||
for (const fieldRef of fields) {
|
||||
try {
|
||||
const field = lookupAsDict(pdfDoc, fieldRef);
|
||||
if (!field) continue;
|
||||
|
||||
if (field.has(PDFName.of('A'))) {
|
||||
field.delete(PDFName.of('A'));
|
||||
}
|
||||
|
||||
if (field.has(PDFName.of('AA'))) {
|
||||
field.delete(PDFName.of('AA'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not process field for JS:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not process form fields for JS:', getErrorMessage(e));
|
||||
}
|
||||
forEachDict(pdfDoc, (dict) => removeDangerousActions(pdfDoc.context, dict));
|
||||
}
|
||||
|
||||
function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -302,37 +309,410 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeLayersFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
function createOptionalContentVisibility(pdfDoc: PDFDocument) {
|
||||
const context = pdfDoc.context;
|
||||
const ocProperties = pdfDoc.catalog.lookup(PDFName.of('OCProperties'));
|
||||
const config =
|
||||
ocProperties instanceof PDFDict
|
||||
? ocProperties.lookup(PDFName.of('D'))
|
||||
: undefined;
|
||||
const getConfigGroups = (key: string) => {
|
||||
const list =
|
||||
config instanceof PDFDict ? config.lookup(PDFName.of(key)) : undefined;
|
||||
return new Set(
|
||||
list instanceof PDFArray
|
||||
? list.asArray().map((item) => context.lookup(item))
|
||||
: []
|
||||
);
|
||||
};
|
||||
const onGroups = getConfigGroups('ON');
|
||||
const offGroups = getConfigGroups('OFF');
|
||||
const hiddenByDefault =
|
||||
config instanceof PDFDict &&
|
||||
config.lookup(PDFName.of('BaseState')) === PDFName.of('OFF');
|
||||
const isGroupVisible = (group: PDFObject) =>
|
||||
!offGroups.has(group) && (!hiddenByDefault || onGroups.has(group));
|
||||
const expressionCache = new Map<PDFObject, boolean | undefined>();
|
||||
|
||||
if (catalogDict.has(PDFName.of('OCProperties'))) {
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
const isExpressionVisible = (
|
||||
value: PDFObject | undefined,
|
||||
depth: number
|
||||
): boolean | undefined => {
|
||||
const resolved = context.lookup(value);
|
||||
if (resolved instanceof PDFDict) return isGroupVisible(resolved);
|
||||
if (!(resolved instanceof PDFArray) || resolved.size() < 2 || depth > 32) {
|
||||
return undefined;
|
||||
}
|
||||
if (expressionCache.has(resolved)) return expressionCache.get(resolved);
|
||||
const operator = resolved.lookup(0);
|
||||
const results = resolved
|
||||
.asArray()
|
||||
.slice(1)
|
||||
.map((item) => isExpressionVisible(item, depth + 1));
|
||||
let result: boolean | undefined;
|
||||
if (!results.every((item) => item !== undefined)) {
|
||||
result = undefined;
|
||||
} else if (operator === PDFName.of('And')) {
|
||||
result = results.every(Boolean);
|
||||
} else if (operator === PDFName.of('Or')) {
|
||||
result = results.some(Boolean);
|
||||
} else if (operator === PDFName.of('Not') && results.length === 1) {
|
||||
result = !results[0];
|
||||
}
|
||||
expressionCache.set(resolved, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
return (value: PDFObject | undefined): boolean => {
|
||||
const dict = context.lookup(value);
|
||||
if (!(dict instanceof PDFDict)) return true;
|
||||
if (dict.lookup(PDFName.of('Type')) !== PDFName.of('OCMD')) {
|
||||
return isGroupVisible(dict);
|
||||
}
|
||||
if (dict.has(PDFName.of('VE'))) {
|
||||
return isExpressionVisible(dict.get(PDFName.of('VE')), 0) === true;
|
||||
}
|
||||
const groups = dict.lookup(PDFName.of('OCGs'));
|
||||
const states = (
|
||||
groups instanceof PDFArray
|
||||
? groups.asArray().map((group) => context.lookup(group))
|
||||
: [groups]
|
||||
)
|
||||
.filter((group) => group instanceof PDFDict)
|
||||
.map((group) => isGroupVisible(group));
|
||||
if (states.length === 0) return true;
|
||||
const policy = dict.lookup(PDFName.of('P'));
|
||||
if (policy === undefined || policy === PDFName.of('AnyOn')) {
|
||||
return states.some(Boolean);
|
||||
}
|
||||
if (policy === PDFName.of('AllOn')) return states.every(Boolean);
|
||||
if (policy === PDFName.of('AnyOff')) return !states.every(Boolean);
|
||||
if (policy === PDFName.of('AllOff')) return !states.some(Boolean);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
const STATE_OPERATORS = new Set(
|
||||
'q Q cm w J j M d ri i gs CS cs SC SCN sc scn G g RG rg K k BT ET Tc Tw Tz TL Tf Tr Ts Td TD Tm T* BX EX'.split(
|
||||
' '
|
||||
)
|
||||
);
|
||||
const PATH_OPERATORS = new Set('m l c v y h re W W*'.split(' '));
|
||||
const PAINT_OPERATORS = new Set('S s f F f* B B* b b* n'.split(' '));
|
||||
|
||||
function readStreamBytes(stream: PDFStream): Uint8Array {
|
||||
if (stream instanceof PDFRawStream) {
|
||||
return decodePDFRawStream(stream).decode();
|
||||
}
|
||||
if (stream instanceof PDFFlateStream) return stream.getUnencodedContents();
|
||||
return stream.getContents();
|
||||
}
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
try {
|
||||
const pageDict = page.node;
|
||||
const NEWLINE = new Uint8Array([0x0a]);
|
||||
|
||||
if (pageDict.has(PDFName.of('OCProperties'))) {
|
||||
pageDict.delete(PDFName.of('OCProperties'));
|
||||
function concatBytes(pieces: Uint8Array[]): Uint8Array {
|
||||
const result = new Uint8Array(
|
||||
pieces.reduce((size, piece) => size + piece.length, 0)
|
||||
);
|
||||
let offset = 0;
|
||||
for (const piece of pieces) {
|
||||
result.set(piece, offset);
|
||||
offset += piece.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readPageContent(page: PDFPageLeaf): Uint8Array | undefined {
|
||||
const contents = page.Contents();
|
||||
if (!contents) return undefined;
|
||||
const streams =
|
||||
contents instanceof PDFArray ? contents.asArray() : [contents];
|
||||
return concatBytes(
|
||||
streams.flatMap((item) => {
|
||||
const stream = page.context.lookup(item);
|
||||
return stream instanceof PDFStream
|
||||
? [readStreamBytes(stream), NEWLINE]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isContentStream(dict: PDFDict): boolean {
|
||||
const subtype = dict.lookup(PDFName.of('Subtype'));
|
||||
return (
|
||||
subtype === PDFName.of('Form') ||
|
||||
(subtype === undefined &&
|
||||
dict.has(PDFName.of('BBox')) &&
|
||||
!dict.has(PDFName.of('ShadingType')))
|
||||
);
|
||||
}
|
||||
|
||||
function removeHiddenContent(
|
||||
context: PDFContext,
|
||||
bytes: Uint8Array,
|
||||
resources: PDFDict | undefined,
|
||||
isVisible: (value: PDFObject | undefined) => boolean
|
||||
): Uint8Array | undefined {
|
||||
const hiddenXObjects = new Set<PDFName>();
|
||||
const xObjects = resources?.lookup(PDFName.of('XObject'));
|
||||
if (xObjects instanceof PDFDict) {
|
||||
for (const [name, value] of xObjects.entries()) {
|
||||
const xObject = context.lookup(value);
|
||||
if (
|
||||
xObject instanceof PDFStream &&
|
||||
xObject.dict.has(PDFName.of('OC')) &&
|
||||
!isVisible(xObject.dict.get(PDFName.of('OC')))
|
||||
) {
|
||||
hiddenXObjects.add(name);
|
||||
}
|
||||
|
||||
const resourcesRef = pageDict.get(PDFName.of('Resources'));
|
||||
if (resourcesRef) {
|
||||
try {
|
||||
const resourcesDict = lookupAsDict(pdfDoc, resourcesRef);
|
||||
if (resourcesDict?.has(PDFName.of('Properties'))) {
|
||||
resourcesDict.delete(PDFName.of('Properties'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not access Resources:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn('Could not remove page layers:', getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
if (
|
||||
hiddenXObjects.size === 0 &&
|
||||
!new TextDecoder('latin1').decode(bytes).includes('BDC')
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const properties = resources?.lookup(PDFName.of('Properties'));
|
||||
const startsHiddenSection = (operation: ContentOperation) => {
|
||||
const [tag, property] = operation.operands;
|
||||
if (tag?.kind !== 'name' || PDFName.of(tag.value) !== PDFName.of('OC')) {
|
||||
return false;
|
||||
}
|
||||
const value =
|
||||
property?.kind === 'name' && properties instanceof PDFDict
|
||||
? properties.get(PDFName.of(property.value))
|
||||
: undefined;
|
||||
if (value === undefined) {
|
||||
throw new Error('Unresolved optional content reference');
|
||||
}
|
||||
return !isVisible(value);
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const pieces: Uint8Array[] = [];
|
||||
const write = (...parts: Uint8Array[]) => pieces.push(NEWLINE, ...parts);
|
||||
const writeOperation = (operation: ContentOperation) =>
|
||||
write(bytes.subarray(operation.start, operation.end));
|
||||
const openSections: boolean[] = [];
|
||||
let hiddenDepth = 0;
|
||||
let path: ContentOperation[] = [];
|
||||
let changed = false;
|
||||
let previousEnd = 0;
|
||||
|
||||
for (const operation of parseContentOperations(bytes)) {
|
||||
const { operator, operands } = operation;
|
||||
const hidden = hiddenDepth > 0;
|
||||
const gapStart = previousEnd;
|
||||
previousEnd = operation.end;
|
||||
const keepOriginal = () =>
|
||||
pieces.push(bytes.subarray(gapStart, operation.end));
|
||||
if (operator === 'BMC' || operator === 'BDC') {
|
||||
const startsHidden =
|
||||
!hidden && operator === 'BDC' && startsHiddenSection(operation);
|
||||
openSections.push(startsHidden);
|
||||
if (startsHidden) hiddenDepth++;
|
||||
if (hidden || startsHidden) {
|
||||
changed = true;
|
||||
} else {
|
||||
keepOriginal();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (operator === 'EMC') {
|
||||
if (openSections.pop()) hiddenDepth--;
|
||||
if (hidden) {
|
||||
changed = true;
|
||||
if (hiddenDepth === 0) path = [];
|
||||
} else {
|
||||
keepOriginal();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!hidden) {
|
||||
const target = operands[0];
|
||||
if (
|
||||
operator === 'Do' &&
|
||||
target?.kind === 'name' &&
|
||||
hiddenXObjects.has(PDFName.of(target.value))
|
||||
) {
|
||||
changed = true;
|
||||
} else {
|
||||
keepOriginal();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
if (STATE_OPERATORS.has(operator)) {
|
||||
writeOperation(operation);
|
||||
} else if (PATH_OPERATORS.has(operator)) {
|
||||
path.push(operation);
|
||||
} else if (PAINT_OPERATORS.has(operator)) {
|
||||
if (
|
||||
path.some((item) => item.operator === 'W' || item.operator === 'W*')
|
||||
) {
|
||||
path.forEach(writeOperation);
|
||||
write(encoder.encode('n'));
|
||||
}
|
||||
path = [];
|
||||
} else if (operator === "'") {
|
||||
write(encoder.encode('T*'));
|
||||
} else if (operator === '"' && operands.length === 3) {
|
||||
write(
|
||||
bytes.subarray(operands[0].start, operands[0].end),
|
||||
encoder.encode(' Tw '),
|
||||
bytes.subarray(operands[1].start, operands[1].end),
|
||||
encoder.encode(' Tc T*')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return undefined;
|
||||
if (hiddenDepth === 0) pieces.push(bytes.subarray(previousEnd));
|
||||
return concatBytes(pieces);
|
||||
}
|
||||
|
||||
function mergePageResources(pdfDoc: PDFDocument): PDFDict {
|
||||
const context = pdfDoc.context;
|
||||
const merged = new Map<string, Map<string, PDFObject>>();
|
||||
const conflicts = new Set<string>();
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const resources = page.node.Resources();
|
||||
for (const category of ['XObject', 'Properties']) {
|
||||
const entries = resources?.lookup(PDFName.of(category));
|
||||
if (!(entries instanceof PDFDict)) continue;
|
||||
const mergedEntries = merged.get(category) ?? new Map();
|
||||
merged.set(category, mergedEntries);
|
||||
for (const [name, value] of entries.entries()) {
|
||||
const key = `${category}/${name.decodeText()}`;
|
||||
const existing = mergedEntries.get(name.decodeText());
|
||||
if (existing && existing.toString() !== value.toString()) {
|
||||
conflicts.add(key);
|
||||
}
|
||||
mergedEntries.set(name.decodeText(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resources = context.obj({});
|
||||
for (const [category, entries] of merged) {
|
||||
const dict = context.obj({});
|
||||
for (const [name, value] of entries) {
|
||||
if (!conflicts.has(`${category}/${name}`)) {
|
||||
dict.set(PDFName.of(name), value);
|
||||
}
|
||||
}
|
||||
resources.set(PDFName.of(category), dict);
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
function removeLayersFromDoc(pdfDoc: PDFDocument) {
|
||||
const catalogDict = getCatalogDict(pdfDoc);
|
||||
const context = pdfDoc.context;
|
||||
const pageContents: [PDFPageLeaf, Uint8Array][] = [];
|
||||
const streamContents: [PDFRef, PDFStream, Uint8Array][] = [];
|
||||
const hiddenAnnots = new Set<PDFObject>();
|
||||
const hiddenXObjects = new Set<PDFObject>();
|
||||
|
||||
if (catalogDict.has(PDFName.of('OCProperties'))) {
|
||||
const isVisible = createOptionalContentVisibility(pdfDoc);
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const content = readPageContent(page.node);
|
||||
const filtered =
|
||||
content &&
|
||||
removeHiddenContent(context, content, page.node.Resources(), isVisible);
|
||||
if (filtered) pageContents.push([page.node, filtered]);
|
||||
|
||||
for (const annotRef of page.node.Annots()?.asArray() ?? []) {
|
||||
const annot = context.lookup(annotRef);
|
||||
if (
|
||||
annot instanceof PDFDict &&
|
||||
annot.has(PDFName.of('OC')) &&
|
||||
!isVisible(annot.get(PDFName.of('OC')))
|
||||
) {
|
||||
hiddenAnnots.add(annotRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inheritedResources = mergePageResources(pdfDoc);
|
||||
for (const [ref, object] of context.enumerateIndirectObjects()) {
|
||||
if (!(object instanceof PDFStream)) continue;
|
||||
if (
|
||||
object.dict.has(PDFName.of('OC')) &&
|
||||
!isVisible(object.dict.get(PDFName.of('OC')))
|
||||
) {
|
||||
hiddenXObjects.add(ref);
|
||||
}
|
||||
if (!isContentStream(object.dict)) continue;
|
||||
const resources = object.dict.lookup(PDFName.of('Resources'));
|
||||
const filtered = removeHiddenContent(
|
||||
context,
|
||||
readStreamBytes(object),
|
||||
resources instanceof PDFDict ? resources : inheritedResources,
|
||||
isVisible
|
||||
);
|
||||
if (filtered) streamContents.push([ref, object, filtered]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [page, content] of pageContents) {
|
||||
page.set(
|
||||
PDFName.of('Contents'),
|
||||
context.register(context.flateStream(content))
|
||||
);
|
||||
}
|
||||
|
||||
for (const [ref, stream, content] of streamContents) {
|
||||
const replacement = context.flateStream(content);
|
||||
for (const [key, value] of stream.dict.entries()) {
|
||||
if (
|
||||
!['Filter', 'DecodeParms', 'Length', 'DL'].includes(key.decodeText())
|
||||
) {
|
||||
replacement.dict.set(key, value);
|
||||
}
|
||||
}
|
||||
context.assign(ref, replacement);
|
||||
}
|
||||
|
||||
for (const page of pdfDoc.getPages()) {
|
||||
const annots = page.node.Annots();
|
||||
if (!annots) continue;
|
||||
for (let i = annots.size() - 1; i >= 0; i--) {
|
||||
if (hiddenAnnots.has(annots.get(i))) annots.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
catalogDict.delete(PDFName.of('OCProperties'));
|
||||
|
||||
forEachDict(pdfDoc, (dict) => {
|
||||
dict.delete(PDFName.of('OCProperties'));
|
||||
dict.delete(PDFName.of('OC'));
|
||||
|
||||
const xObjects = dict.lookup(PDFName.of('XObject'));
|
||||
if (xObjects instanceof PDFDict) {
|
||||
for (const [name, value] of xObjects.entries()) {
|
||||
if (hiddenXObjects.has(value)) xObjects.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const properties = dict.lookup(PDFName.of('Properties'));
|
||||
if (properties instanceof PDFDict) {
|
||||
for (const [name, value] of properties.entries()) {
|
||||
const type = lookupAsDict(pdfDoc, value)?.lookup(PDFName.of('Type'));
|
||||
if (type === PDFName.of('OCG') || type === PDFName.of('OCMD')) {
|
||||
properties.delete(name);
|
||||
}
|
||||
}
|
||||
if (properties.entries().length === 0) {
|
||||
dict.delete(PDFName.of('Properties'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeLinksFromDoc(pdfDoc: PDFDocument) {
|
||||
@@ -565,8 +945,9 @@ function removeFontsFromDoc(pdfDoc: PDFDocument) {
|
||||
export async function sanitizePdf(
|
||||
pdfBytes: Uint8Array,
|
||||
options: SanitizeOptions
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array }> {
|
||||
): Promise<{ pdfDoc: PDFDocument; bytes: Uint8Array; skipped: string[] }> {
|
||||
const pdfDoc = await loadPdfDocument(pdfBytes);
|
||||
const skipped: string[] = [];
|
||||
|
||||
if (options.flattenForms) {
|
||||
try {
|
||||
@@ -583,6 +964,7 @@ export async function sanitizePdf(
|
||||
'Could not remove AcroForm:',
|
||||
getErrorMessage(removeError)
|
||||
);
|
||||
skipped.push('flatten forms');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,6 +982,7 @@ export async function sanitizePdf(
|
||||
removeJavascriptFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove JavaScript: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove JavaScript');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,6 +991,7 @@ export async function sanitizePdf(
|
||||
removeEmbeddedFilesFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove embedded files: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove embedded files');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +1000,7 @@ export async function sanitizePdf(
|
||||
removeLayersFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove layers: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove layers');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,6 +1009,7 @@ export async function sanitizePdf(
|
||||
removeLinksFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove links: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove links');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +1018,7 @@ export async function sanitizePdf(
|
||||
removeStructureTreeFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove structure tree: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove the structure tree');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +1027,7 @@ export async function sanitizePdf(
|
||||
removeMarkInfoFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove MarkInfo: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove MarkInfo');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,9 +1036,14 @@ export async function sanitizePdf(
|
||||
removeFontsFromDoc(pdfDoc);
|
||||
} catch (e: unknown) {
|
||||
console.warn(`Could not remove fonts: ${getErrorMessage(e)}`);
|
||||
skipped.push('remove fonts');
|
||||
}
|
||||
}
|
||||
|
||||
const savedBytes = await pdfDoc.save();
|
||||
return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) };
|
||||
return {
|
||||
pdfDoc,
|
||||
bytes: await removeUnreferencedObjects(savedBytes),
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
const ALLOWED_TAGS = ['br', 'strong', 'em'];
|
||||
|
||||
export function sanitizeTranslationHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR: [] });
|
||||
}
|
||||
Reference in new issue
Block a user