feat: implement annotation flattening functionality and add tests

This commit is contained in:
alam00000
2026-03-17 22:46:13 +05:30
parent 6c0e9c7232
commit 8bf52b9ef1
5 changed files with 765 additions and 174 deletions

View File

@@ -1,231 +1,267 @@
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers.js';
import {
downloadFile,
formatBytes,
readFileAsArrayBuffer,
} from '../utils/helpers.js';
import { PDFDocument } from 'pdf-lib';
import { flattenAnnotations } from '../utils/flatten-annotations.js';
import { icons, createIcons } from 'lucide';
import JSZip from 'jszip';
import { FlattenPdfState } from '@/types';
const pageState: FlattenPdfState = {
files: [],
files: [],
};
function flattenFormsInDoc(pdfDoc: PDFDocument) {
const form = pdfDoc.getForm();
form.flatten();
const form = pdfDoc.getForm();
form.flatten();
}
function resetState() {
pageState.files = [];
pageState.files = [];
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const toolOptions = document.getElementById('tool-options');
if (toolOptions) toolOptions.classList.add('hidden');
const toolOptions = document.getElementById('tool-options');
if (toolOptions) toolOptions.classList.add('hidden');
const fileControls = document.getElementById('file-controls');
if (fileControls) fileControls.classList.add('hidden');
const fileControls = document.getElementById('file-controls');
if (fileControls) fileControls.classList.add('hidden');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
}
async function updateUI() {
const fileDisplayArea = document.getElementById('file-display-area');
const toolOptions = document.getElementById('tool-options');
const fileControls = document.getElementById('file-controls');
const fileDisplayArea = document.getElementById('file-display-area');
const toolOptions = document.getElementById('tool-options');
const fileControls = document.getElementById('file-controls');
if (!fileDisplayArea) return;
if (!fileDisplayArea) return;
fileDisplayArea.innerHTML = '';
fileDisplayArea.innerHTML = '';
if (pageState.files.length > 0) {
pageState.files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
if (pageState.files.length > 0) {
pageState.files.forEach((file, index) => {
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col overflow-hidden';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = formatBytes(file.size);
infoContainer.append(nameSpan, metaSpan);
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = function () {
pageState.files.splice(index, 1);
updateUI();
};
const removeBtn = document.createElement('button');
removeBtn.className =
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = function () {
pageState.files.splice(index, 1);
updateUI();
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
});
createIcons({ icons });
createIcons({ icons });
if (toolOptions) toolOptions.classList.remove('hidden');
if (fileControls) fileControls.classList.remove('hidden');
} else {
if (toolOptions) toolOptions.classList.add('hidden');
if (fileControls) fileControls.classList.add('hidden');
}
if (toolOptions) toolOptions.classList.remove('hidden');
if (fileControls) fileControls.classList.remove('hidden');
} else {
if (toolOptions) toolOptions.classList.add('hidden');
if (fileControls) fileControls.classList.add('hidden');
}
}
function handleFileSelect(files: FileList | null) {
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length > 0) {
pageState.files.push(...pdfFiles);
updateUI();
}
if (files && files.length > 0) {
const pdfFiles = Array.from(files).filter(
(f) =>
f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
);
if (pdfFiles.length > 0) {
pageState.files.push(...pdfFiles);
updateUI();
}
}
}
async function flattenPdf() {
if (pageState.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
if (pageState.files.length === 0) {
showAlert('No Files', 'Please select at least one PDF file.');
return;
}
const loaderModal = document.getElementById('loader-modal');
const loaderText = document.getElementById('loader-text');
const loaderModal = document.getElementById('loader-modal');
const loaderText = document.getElementById('loader-text');
try {
if (pageState.files.length === 1) {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Flattening PDF...';
try {
if (pageState.files.length === 1) {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Flattening PDF...';
const file = pageState.files[0];
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true });
const file = pageState.files[0];
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
ignoreEncryption: true,
});
try {
flattenFormsInDoc(pdfDoc);
} catch (e: any) {
if (e.message.includes('getForm')) {
// Ignore if no form found
} else {
throw e;
}
}
const newPdfBytes = await pdfDoc.save();
downloadFile(
new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }),
`flattened_${file.name}`
);
if (loaderModal) loaderModal.classList.add('hidden');
try {
flattenFormsInDoc(pdfDoc);
} catch (e: any) {
if (e.message.includes('getForm')) {
// Ignore if no form found
} else {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Flattening multiple PDFs...';
const zip = new JSZip();
let processedCount = 0;
for (let i = 0; i < pageState.files.length; i++) {
const file = pageState.files[i];
if (loaderText) loaderText.textContent = `Flattening ${i + 1}/${pageState.files.length}: ${file.name}...`;
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true });
try {
flattenFormsInDoc(pdfDoc);
} catch (e: any) {
if (e.message.includes('getForm')) {
// Ignore if no form found
} else {
throw e;
}
}
const flattenedBytes = await pdfDoc.save();
zip.file(`flattened_${file.name}`, flattenedBytes);
processedCount++;
} catch (e) {
console.error(`Error processing ${file.name}:`, e);
}
}
if (processedCount > 0) {
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'flattened_pdfs.zip');
showAlert('Success', `Processed ${processedCount} PDFs.`, 'success', () => { resetState(); });
} else {
showAlert('Error', 'No PDFs could be processed.');
}
if (loaderModal) loaderModal.classList.add('hidden');
throw e;
}
} catch (e: any) {
console.error(e);
if (loaderModal) loaderModal.classList.add('hidden');
showAlert('Error', e.message || 'An unexpected error occurred.');
}
try {
flattenAnnotations(pdfDoc);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('Could not flatten annotations:', msg);
}
const newPdfBytes = await pdfDoc.save();
downloadFile(
new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }),
`flattened_${file.name}`
);
if (loaderModal) loaderModal.classList.add('hidden');
} else {
if (loaderModal) loaderModal.classList.remove('hidden');
if (loaderText) loaderText.textContent = 'Flattening multiple PDFs...';
const zip = new JSZip();
let processedCount = 0;
for (let i = 0; i < pageState.files.length; i++) {
const file = pageState.files[i];
if (loaderText)
loaderText.textContent = `Flattening ${i + 1}/${pageState.files.length}: ${file.name}...`;
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
ignoreEncryption: true,
});
try {
flattenFormsInDoc(pdfDoc);
} catch (e: any) {
if (e.message.includes('getForm')) {
// Ignore if no form found
} else {
throw e;
}
}
try {
flattenAnnotations(pdfDoc);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.warn('Could not flatten annotations:', msg);
}
const flattenedBytes = await pdfDoc.save();
zip.file(`flattened_${file.name}`, flattenedBytes);
processedCount++;
} catch (e) {
console.error(`Error processing ${file.name}:`, e);
}
}
if (processedCount > 0) {
const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'flattened_pdfs.zip');
showAlert(
'Success',
`Processed ${processedCount} PDFs.`,
'success',
() => {
resetState();
}
);
} else {
showAlert('Error', 'No PDFs could be processed.');
}
if (loaderModal) loaderModal.classList.add('hidden');
}
} catch (e: any) {
console.error(e);
if (loaderModal) loaderModal.classList.add('hidden');
showAlert('Error', e.message || 'An unexpected error occurred.');
}
}
document.addEventListener('DOMContentLoaded', function () {
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const processBtn = document.getElementById('process-btn');
const addMoreBtn = document.getElementById('add-more-btn');
const clearFilesBtn = document.getElementById('clear-files-btn');
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
backBtn.addEventListener('click', function () {
window.location.href = import.meta.env.BASE_URL;
});
}
if (backBtn) {
backBtn.addEventListener('click', function () {
window.location.href = import.meta.env.BASE_URL;
});
}
if (fileInput && dropZone) {
fileInput.addEventListener('change', function (e) {
handleFileSelect((e.target as HTMLInputElement).files);
});
if (fileInput && dropZone) {
fileInput.addEventListener('change', function (e) {
handleFileSelect((e.target as HTMLInputElement).files);
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragover', function (e) {
e.preventDefault();
dropZone.classList.add('bg-gray-700');
});
dropZone.addEventListener('dragleave', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('dragleave', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files);
});
dropZone.addEventListener('drop', function (e) {
e.preventDefault();
dropZone.classList.remove('bg-gray-700');
handleFileSelect(e.dataTransfer?.files);
});
fileInput.addEventListener('click', function () {
fileInput.value = '';
});
}
fileInput.addEventListener('click', function () {
fileInput.value = '';
});
}
if (processBtn) {
processBtn.addEventListener('click', flattenPdf);
}
if (processBtn) {
processBtn.addEventListener('click', flattenPdf);
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', function () {
fileInput.value = '';
fileInput.click();
});
}
if (addMoreBtn) {
addMoreBtn.addEventListener('click', function () {
fileInput.value = '';
fileInput.click();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', function () {
resetState();
});
}
if (clearFilesBtn) {
clearFilesBtn.addEventListener('click', function () {
resetState();
});
}
});

View File

@@ -0,0 +1,175 @@
import {
PDFDocument,
PDFName,
PDFDict,
PDFArray,
PDFRef,
PDFNumber,
PDFOperator,
PDFOperatorNames,
} from 'pdf-lib';
function extractNumbers(arr: PDFArray, count: number): number[] | null {
if (arr.size() < count) return null;
const result: number[] = [];
for (let i = 0; i < count; i++) {
const val = arr.lookup(i);
if (!(val instanceof PDFNumber)) return null;
result.push(val.asNumber());
}
return result;
}
function resolveStreamDict(obj: unknown): PDFDict | null {
if (obj instanceof PDFDict) return obj;
if (
obj !== null &&
typeof obj === 'object' &&
'dict' in (obj as Record<string, unknown>)
) {
const dict = (obj as { dict: unknown }).dict;
if (dict instanceof PDFDict) return dict;
}
return null;
}
export function flattenAnnotations(pdfDoc: PDFDocument): void {
const pages = pdfDoc.getPages();
for (const page of pages) {
const pageNode = page.node;
const annotsArr = pageNode.Annots();
if (!annotsArr) continue;
const annotRefs = annotsArr.asArray();
if (annotRefs.length === 0) continue;
const keptAnnots: PDFRef[] = [];
let hasChanges = false;
for (const annotRef of annotRefs) {
const annot = pdfDoc.context.lookup(annotRef);
if (!(annot instanceof PDFDict)) {
if (annotRef instanceof PDFRef) keptAnnots.push(annotRef);
continue;
}
const subtype = annot.get(PDFName.of('Subtype'));
const subtypeStr = subtype instanceof PDFName ? subtype.decodeText() : '';
if (subtypeStr === 'Widget') {
if (annotRef instanceof PDFRef) keptAnnots.push(annotRef);
continue;
}
if (subtypeStr === 'Popup') {
hasChanges = true;
continue;
}
const flagsObj = annot.get(PDFName.of('F'));
const flags = flagsObj instanceof PDFNumber ? flagsObj.asNumber() : 0;
if (flags & 0x02) {
hasChanges = true;
continue;
}
const apDict = annot.lookup(PDFName.of('AP'));
if (!(apDict instanceof PDFDict)) {
hasChanges = true;
continue;
}
let normalAppRef = apDict.get(PDFName.of('N'));
if (!normalAppRef) {
hasChanges = true;
continue;
}
const normalApp = pdfDoc.context.lookup(normalAppRef);
if (normalApp instanceof PDFDict && !normalApp.has(PDFName.of('BBox'))) {
const as = annot.get(PDFName.of('AS'));
if (as instanceof PDFName && normalApp.has(as)) {
normalAppRef = normalApp.get(as)!;
} else {
hasChanges = true;
continue;
}
}
const rectObj = annot.lookup(PDFName.of('Rect'));
if (!(rectObj instanceof PDFArray)) {
hasChanges = true;
continue;
}
const rectNums = extractNumbers(rectObj, 4);
if (!rectNums) {
hasChanges = true;
continue;
}
const x1 = Math.min(rectNums[0], rectNums[2]);
const y1 = Math.min(rectNums[1], rectNums[3]);
const x2 = Math.max(rectNums[0], rectNums[2]);
const y2 = Math.max(rectNums[1], rectNums[3]);
if (x2 - x1 < 0.001 || y2 - y1 < 0.001) {
hasChanges = true;
continue;
}
const resolvedStream = pdfDoc.context.lookup(normalAppRef);
let bbox = [0, 0, x2 - x1, y2 - y1];
const streamDict = resolveStreamDict(resolvedStream);
if (streamDict) {
const bboxObj = streamDict.lookup(PDFName.of('BBox'));
if (bboxObj instanceof PDFArray) {
const bboxNums = extractNumbers(bboxObj, 4);
if (bboxNums) bbox = bboxNums;
}
}
let appRef: PDFRef;
if (normalAppRef instanceof PDFRef) {
appRef = normalAppRef;
} else {
appRef = pdfDoc.context.register(normalAppRef as PDFDict);
}
const xObjKey = pageNode.newXObject('FlatAnnot', appRef);
const bw = bbox[2] - bbox[0];
const bh = bbox[3] - bbox[1];
const sx = Math.abs(bw) > 0.001 ? (x2 - x1) / bw : 1;
const sy = Math.abs(bh) > 0.001 ? (y2 - y1) / bh : 1;
const tx = x1 - bbox[0] * sx;
const ty = y1 - bbox[1] * sy;
page.pushOperators(
PDFOperator.of(PDFOperatorNames.PushGraphicsState),
PDFOperator.of(PDFOperatorNames.ConcatTransformationMatrix, [
PDFNumber.of(sx),
PDFNumber.of(0),
PDFNumber.of(0),
PDFNumber.of(sy),
PDFNumber.of(tx),
PDFNumber.of(ty),
]),
PDFOperator.of(PDFOperatorNames.DrawObject, [xObjKey]),
PDFOperator.of(PDFOperatorNames.PopGraphicsState)
);
hasChanges = true;
}
if (hasChanges) {
if (keptAnnots.length > 0) {
pageNode.set(PDFName.of('Annots'), pdfDoc.context.obj(keptAnnots));
} else {
pageNode.delete(PDFName.of('Annots'));
}
}
}
}

View File

@@ -4,6 +4,7 @@ import { pdfSocket } from '../sockets';
import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types';
import { PDFDocument } from 'pdf-lib';
import { flattenAnnotations } from '../../utils/flatten-annotations.js';
export class FlattenNode extends BaseWorkflowNode {
readonly category = 'Secure PDF' as const;
@@ -32,6 +33,12 @@ export class FlattenNode extends BaseWorkflowNode {
console.error('Flatten form error (may have no forms):', err);
}
try {
flattenAnnotations(pdfDoc);
} catch (err) {
console.error('Flatten annotations error:', err);
}
const pdfBytes = await pdfDoc.save();
return {
type: 'pdf',