Files
bentopdf/src/js/logic/sanitize-pdf.ts
T
Vivek Gaddamandabdullahalam123 c9c3b33704 Word to pdf (#88)
* feat(pdf-tools): add sanitize-pdf tool for comprehensive document cleaning

Implement a new PDF sanitization tool that allows users to remove various potentially sensitive elements from PDFs including metadata, annotations, JavaScript, embedded files, and more. The tool provides configurable options through checkboxes to selectively remove different types of content while preserving the core document structure.

Extract reusable utility functions from existing tools (remove-metadata, remove-annotations, flatten) to support the new sanitization feature. The tool handles edge cases gracefully and provides feedback when no changes are made.

* fix(sanitize-pdf): remove javascript actions, external links and font files

- Remove JavaScript actions from annotations and form fields
- Remove external links (URI, Launch, GoTo) from annotations
- Remove embedded font files while preserving font descriptors
- Add detailed error logging for each operation

* fix(sanitize-pdf): improve link removal and add named destinations cleanup

- Refactor link annotation removal logic to handle more action types (URI, Launch, GoTo, GoToR)
- Add cleanup of named destinations in catalog and names dictionary
- Improve error handling and logging throughout the sanitization process

* chore: update config files, icons and readme

- Update FUNDING.yml formatting
- Remove debug log in sanitize-pdf.ts
- Adjust test expectation for tool count
- Standardize dependabot.yml quotes
- Update tool icons in config
- Expand and reorganize README features list
- Fix markdown formatting in README

* Release v1.1.0

* without confilt

---------

Co-authored-by: abdullahalam123 <[email protected]>
2025-10-22 19:45:04 +05:30

588 lines
19 KiB
TypeScript

import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile } from '../utils/helpers.js';
import { state } from '../state.js';
import { removeMetadataFromDoc } from './remove-metadata.js';
import { removeAnnotationsFromDoc } from './remove-annotations.js';
import { flattenFormsInDoc } from './flatten.js';
import { PDFName } from 'pdf-lib';
export async function sanitizePdf() {
if (!state.pdfDoc) {
showAlert('Error', 'No PDF document loaded.');
return;
}
showLoader('Sanitizing PDF...');
try {
const pdfDoc = state.pdfDoc;
const shouldFlattenForms = (
document.getElementById('flatten-forms') as HTMLInputElement
).checked;
const shouldRemoveMetadata = (
document.getElementById('remove-metadata') as HTMLInputElement
).checked;
const shouldRemoveAnnotations = (
document.getElementById('remove-annotations') as HTMLInputElement
).checked;
const shouldRemoveJavascript = (
document.getElementById('remove-javascript') as HTMLInputElement
).checked;
const shouldRemoveEmbeddedFiles = (
document.getElementById('remove-embedded-files') as HTMLInputElement
).checked;
const shouldRemoveLayers = (
document.getElementById('remove-layers') as HTMLInputElement
).checked;
const shouldRemoveLinks = (
document.getElementById('remove-links') as HTMLInputElement
).checked;
const shouldRemoveStructureTree = (
document.getElementById('remove-structure-tree') as HTMLInputElement
).checked;
const shouldRemoveMarkInfo = (
document.getElementById('remove-markinfo') as HTMLInputElement
).checked;
const shouldRemoveFonts = (
document.getElementById('remove-fonts') as HTMLInputElement
).checked;
let changesMade = false;
if (shouldFlattenForms) {
try {
flattenFormsInDoc(pdfDoc);
changesMade = true;
} catch (e) {
console.warn(`Could not flatten forms: ${e.message}`);
try {
const catalogDict = pdfDoc.catalog.dict;
if (catalogDict.has(PDFName.of('AcroForm'))) {
catalogDict.delete(PDFName.of('AcroForm'));
changesMade = true;
}
} catch (removeError) {
console.warn('Could not remove AcroForm:', removeError.message);
}
}
}
if (shouldRemoveMetadata) {
removeMetadataFromDoc(pdfDoc);
changesMade = true;
}
if (shouldRemoveAnnotations) {
removeAnnotationsFromDoc(pdfDoc);
changesMade = true;
}
if (shouldRemoveJavascript) {
try {
if (pdfDoc.javaScripts && pdfDoc.javaScripts.length > 0) {
pdfDoc.javaScripts = [];
changesMade = true;
}
const catalogDict = pdfDoc.catalog.dict;
const namesRef = catalogDict.get(PDFName.of('Names'));
if (namesRef) {
try {
const namesDict = pdfDoc.context.lookup(namesRef);
if (namesDict.has(PDFName.of('JavaScript'))) {
namesDict.delete(PDFName.of('JavaScript'));
changesMade = true;
}
} catch (e) {
console.warn('Could not access Names/JavaScript:', e.message);
}
}
if (catalogDict.has(PDFName.of('OpenAction'))) {
catalogDict.delete(PDFName.of('OpenAction'));
changesMade = true;
}
if (catalogDict.has(PDFName.of('AA'))) {
catalogDict.delete(PDFName.of('AA'));
changesMade = true;
}
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'));
changesMade = true;
}
const annotRefs = pageDict.Annots()?.asArray() || [];
for (const annotRef of annotRefs) {
try {
const annot = pdfDoc.context.lookup(annotRef);
if (annot.has(PDFName.of('A'))) {
const actionRef = annot.get(PDFName.of('A'));
try {
const actionDict = pdfDoc.context.lookup(actionRef);
const actionType = actionDict
.get(PDFName.of('S'))
?.toString()
.substring(1);
if (actionType === 'JavaScript') {
annot.delete(PDFName.of('A'));
changesMade = true;
}
} catch (e) {
console.warn('Could not read action:', e.message);
}
}
if (annot.has(PDFName.of('AA'))) {
annot.delete(PDFName.of('AA'));
changesMade = true;
}
} catch (e) {
console.warn('Could not process annotation for JS:', e.message);
}
}
} catch (e) {
console.warn('Could not remove page actions:', e.message);
}
}
try {
const acroFormRef = catalogDict.get(PDFName.of('AcroForm'));
if (acroFormRef) {
const acroFormDict = pdfDoc.context.lookup(acroFormRef);
const fieldsRef = acroFormDict.get(PDFName.of('Fields'));
if (fieldsRef) {
const fieldsArray = pdfDoc.context.lookup(fieldsRef);
const fields = fieldsArray.asArray();
for (const fieldRef of fields) {
try {
const field = pdfDoc.context.lookup(fieldRef);
if (field.has(PDFName.of('A'))) {
field.delete(PDFName.of('A'));
changesMade = true;
}
if (field.has(PDFName.of('AA'))) {
field.delete(PDFName.of('AA'));
changesMade = true;
}
} catch (e) {
console.warn('Could not process field for JS:', e.message);
}
}
}
}
} catch (e) {
console.warn('Could not process form fields for JS:', e.message);
}
} catch (e) {
console.warn(`Could not remove JavaScript: ${e.message}`);
}
}
if (shouldRemoveEmbeddedFiles) {
try {
const catalogDict = pdfDoc.catalog.dict;
const namesRef = catalogDict.get(PDFName.of('Names'));
if (namesRef) {
try {
const namesDict = pdfDoc.context.lookup(namesRef);
if (namesDict.has(PDFName.of('EmbeddedFiles'))) {
namesDict.delete(PDFName.of('EmbeddedFiles'));
changesMade = true;
}
} catch (e) {
console.warn('Could not access Names/EmbeddedFiles:', e.message);
}
}
if (catalogDict.has(PDFName.of('EmbeddedFiles'))) {
catalogDict.delete(PDFName.of('EmbeddedFiles'));
changesMade = true;
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const annotRefs = page.node.Annots()?.asArray() || [];
const annotsToKeep = [];
for (const ref of annotRefs) {
try {
const annot = pdfDoc.context.lookup(ref);
const subtype = annot
.get(PDFName.of('Subtype'))
?.toString()
.substring(1);
if (subtype !== 'FileAttachment') {
annotsToKeep.push(ref);
} else {
changesMade = true;
}
} catch (e) {
annotsToKeep.push(ref);
}
}
if (annotsToKeep.length !== annotRefs.length) {
if (annotsToKeep.length > 0) {
const newAnnotsArray = pdfDoc.context.obj(annotsToKeep);
page.node.set(PDFName.of('Annots'), newAnnotsArray);
} else {
page.node.delete(PDFName.of('Annots'));
}
}
} catch (pageError) {
console.warn(
`Could not process page for attachments: ${pageError.message}`
);
}
}
if (pdfDoc.embeddedFiles && pdfDoc.embeddedFiles.length > 0) {
pdfDoc.embeddedFiles = [];
changesMade = true;
}
if (catalogDict.has(PDFName.of('Collection'))) {
catalogDict.delete(PDFName.of('Collection'));
changesMade = true;
}
} catch (e) {
console.warn(`Could not remove embedded files: ${e.message}`);
}
}
if (shouldRemoveLayers) {
try {
const catalogDict = pdfDoc.catalog.dict;
if (catalogDict.has(PDFName.of('OCProperties'))) {
catalogDict.delete(PDFName.of('OCProperties'));
changesMade = true;
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const pageDict = page.node;
if (pageDict.has(PDFName.of('OCProperties'))) {
pageDict.delete(PDFName.of('OCProperties'));
changesMade = true;
}
const resourcesRef = pageDict.get(PDFName.of('Resources'));
if (resourcesRef) {
try {
const resourcesDict = pdfDoc.context.lookup(resourcesRef);
if (resourcesDict.has(PDFName.of('Properties'))) {
resourcesDict.delete(PDFName.of('Properties'));
changesMade = true;
}
} catch (e) {
console.warn('Could not access Resources:', e.message);
}
}
} catch (e) {
console.warn('Could not remove page layers:', e.message);
}
}
} catch (e) {
console.warn(`Could not remove layers: ${e.message}`);
}
}
// TODO:@ALAM
// Currently if the links are embedded in a stream they can't be removed
// Find a way to remove them from the stream
if (shouldRemoveLinks) {
try {
const pages = pdfDoc.getPages();
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
try {
const page = pages[pageIndex];
const pageDict = page.node;
const annotsRef = pageDict.get(PDFName.of('Annots'));
if (!annotsRef) continue;
const annotsArray = pdfDoc.context.lookup(annotsRef);
const annotRefs = annotsArray.asArray();
if (annotRefs.length === 0) continue;
const annotsToKeep = [];
let linksRemoved = 0;
for (const ref of annotRefs) {
try {
const annot = pdfDoc.context.lookup(ref);
const subtype = annot
.get(PDFName.of('Subtype'))
?.toString()
.substring(1);
let isLink = false;
if (subtype === 'Link') {
isLink = true;
linksRemoved++;
} else {
const actionRef = annot.get(PDFName.of('A'));
if (actionRef) {
try {
const actionDict = pdfDoc.context.lookup(actionRef);
const actionType = actionDict
.get(PDFName.of('S'))
?.toString()
.substring(1);
if (
actionType === 'URI' ||
actionType === 'Launch' ||
actionType === 'GoTo' ||
actionType === 'GoToR'
) {
isLink = true;
linksRemoved++;
}
} catch (e) {
console.warn('Could not read action:', e.message);
}
}
const dest = annot.get(PDFName.of('Dest'));
if (dest && !isLink) {
isLink = true;
linksRemoved++;
}
}
if (!isLink) {
annotsToKeep.push(ref);
}
} catch (e) {
console.warn('Could not process annotation:', e.message);
annotsToKeep.push(ref);
}
}
if (linksRemoved > 0) {
if (annotsToKeep.length > 0) {
const newAnnotsArray = pdfDoc.context.obj(annotsToKeep);
pageDict.set(PDFName.of('Annots'), newAnnotsArray);
} else {
pageDict.delete(PDFName.of('Annots'));
}
changesMade = true;
}
} catch (pageError) {
console.warn(
`Could not process page ${pageIndex + 1} for links: ${pageError.message}`
);
}
}
try {
const catalogDict = pdfDoc.catalog.dict;
const namesRef = catalogDict.get(PDFName.of('Names'));
if (namesRef) {
try {
const namesDict = pdfDoc.context.lookup(namesRef);
if (namesDict.has(PDFName.of('Dests'))) {
namesDict.delete(PDFName.of('Dests'));
changesMade = true;
}
} catch (e) {
console.warn('Could not access Names/Dests:', e.message);
}
}
if (catalogDict.has(PDFName.of('Dests'))) {
catalogDict.delete(PDFName.of('Dests'));
changesMade = true;
}
} catch (e) {
console.warn('Could not remove named destinations:', e.message);
}
} catch (e) {
console.warn(`Could not remove links: ${e.message}`);
}
}
if (shouldRemoveStructureTree) {
try {
const catalogDict = pdfDoc.catalog.dict;
if (catalogDict.has(PDFName.of('StructTreeRoot'))) {
catalogDict.delete(PDFName.of('StructTreeRoot'));
changesMade = true;
}
const pages = pdfDoc.getPages();
for (const page of pages) {
try {
const pageDict = page.node;
if (pageDict.has(PDFName.of('StructParents'))) {
pageDict.delete(PDFName.of('StructParents'));
changesMade = true;
}
} catch (e) {
console.warn('Could not remove page StructParents:', e.message);
}
}
if (catalogDict.has(PDFName.of('ParentTree'))) {
catalogDict.delete(PDFName.of('ParentTree'));
changesMade = true;
}
} catch (e) {
console.warn(`Could not remove structure tree: ${e.message}`);
}
}
if (shouldRemoveMarkInfo) {
try {
const catalogDict = pdfDoc.catalog.dict;
if (catalogDict.has(PDFName.of('MarkInfo'))) {
catalogDict.delete(PDFName.of('MarkInfo'));
changesMade = true;
}
if (catalogDict.has(PDFName.of('Marked'))) {
catalogDict.delete(PDFName.of('Marked'));
changesMade = true;
}
} catch (e) {
console.warn(`Could not remove MarkInfo: ${e.message}`);
}
}
if (shouldRemoveFonts) {
try {
const pages = pdfDoc.getPages();
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
try {
const page = pages[pageIndex];
const pageDict = page.node;
const resourcesRef = pageDict.get(PDFName.of('Resources'));
if (resourcesRef) {
try {
const resourcesDict = pdfDoc.context.lookup(resourcesRef);
if (resourcesDict.has(PDFName.of('Font'))) {
const fontRef = resourcesDict.get(PDFName.of('Font'));
try {
const fontDict = pdfDoc.context.lookup(fontRef);
const fontKeys = fontDict.keys();
for (const fontKey of fontKeys) {
try {
const specificFontRef = fontDict.get(fontKey);
const specificFont =
pdfDoc.context.lookup(specificFontRef);
if (specificFont.has(PDFName.of('FontDescriptor'))) {
const descriptorRef = specificFont.get(
PDFName.of('FontDescriptor')
);
const descriptor =
pdfDoc.context.lookup(descriptorRef);
const fontFileKeys = [
'FontFile',
'FontFile2',
'FontFile3',
];
for (const key of fontFileKeys) {
if (descriptor.has(PDFName.of(key))) {
descriptor.delete(PDFName.of(key));
changesMade = true;
}
}
}
// Users/Developers: Uncomment this if you can delete the entire font entry -- might break the rendering though
// fontDict.delete(fontKey);
// changesMade = true;
} catch (e) {
console.warn(
`Could not process font ${fontKey}:`,
e.message
);
}
}
} catch (e) {
console.warn(
'Could not access font dictionary:',
e.message
);
}
}
} catch (e) {
console.warn(
'Could not access Resources for fonts:',
e.message
);
}
}
} catch (e) {
console.warn(
`Could not remove fonts from page ${pageIndex + 1}:`,
e.message
);
}
}
if (pdfDoc.fonts && pdfDoc.fonts.length > 0) {
pdfDoc.fonts = [];
changesMade = true;
}
} catch (e) {
console.warn(`Could not remove fonts: ${e.message}`);
}
}
if (!changesMade) {
showAlert(
'No Changes',
'No items were selected for removal or none were found in the PDF.'
);
hideLoader();
return;
}
const sanitizedPdfBytes = await pdfDoc.save();
downloadFile(
new Blob([sanitizedPdfBytes], { type: 'application/pdf' }),
'sanitized.pdf'
);
showAlert('Success', 'PDF has been sanitized and downloaded.');
} catch (e) {
console.error('Sanitization Error:', e);
showAlert('Error', `An error occurred during sanitization: ${e.message}`);
} finally {
hideLoader();
}
}