diff --git a/.env.example b/.env.example index fee0b32..98bcda1 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,7 @@ VITE_TESSERACT_AVAILABLE_LANGUAGES= VITE_OCR_FONT_BASE_URL= # Default UI language (build-time) -# Supported: en, ar, be, fr, de, es, zh, zh-TW, vi, tr, id, it, pt, nl, da +# Supported: en, ar, be, fr, de, es, zh, zh-TW, vi, tr, id, it, pt, nl, da, ko, sv, ru VITE_DEFAULT_LANGUAGE= # Custom branding (build-time) diff --git a/src/js/logic/flatten-pdf-page.ts b/src/js/logic/flatten-pdf-page.ts index 509e9e3..520c11e 100644 --- a/src/js/logic/flatten-pdf-page.ts +++ b/src/js/logic/flatten-pdf-page.ts @@ -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 = ''; - 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 = ''; + 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(); + }); + } }); diff --git a/src/js/utils/flatten-annotations.ts b/src/js/utils/flatten-annotations.ts new file mode 100644 index 0000000..fa8bd93 --- /dev/null +++ b/src/js/utils/flatten-annotations.ts @@ -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) + ) { + 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')); + } + } + } +} diff --git a/src/js/workflow/nodes/flatten-node.ts b/src/js/workflow/nodes/flatten-node.ts index 1554626..f22138b 100644 --- a/src/js/workflow/nodes/flatten-node.ts +++ b/src/js/workflow/nodes/flatten-node.ts @@ -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', diff --git a/src/tests/flatten-annotations.test.ts b/src/tests/flatten-annotations.test.ts new file mode 100644 index 0000000..c7b2d77 --- /dev/null +++ b/src/tests/flatten-annotations.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect } from 'vitest'; +import { + PDFDocument, + PDFName, + PDFDict, + PDFArray, + PDFNumber, + PDFRef, +} from 'pdf-lib'; +import { flattenAnnotations } from '../js/utils/flatten-annotations'; + +function createAppearanceStream( + doc: PDFDocument, + bbox: [number, number, number, number] +): PDFRef { + const stream = doc.context.stream('0 0 1 rg 0 0 100 20 re f', { + Type: 'XObject', + Subtype: 'Form', + BBox: bbox, + }); + return doc.context.register(stream); +} + +function addAnnotation( + doc: PDFDocument, + page: ReturnType, + opts: { + subtype: string; + rect: [number, number, number, number]; + appearance?: PDFRef; + flags?: number; + hidden?: boolean; + } +): PDFRef { + const annotDict = doc.context.obj({ + Type: 'Annot', + Subtype: opts.subtype, + Rect: opts.rect, + }); + + if (opts.flags !== undefined) { + annotDict.set(PDFName.of('F'), PDFNumber.of(opts.flags)); + } + if (opts.hidden) { + const base = typeof opts.flags === 'number' ? opts.flags : 0; + annotDict.set(PDFName.of('F'), PDFNumber.of(base | 0x02)); + } + + const annot = annotDict; + + if (opts.appearance) { + const apDict = doc.context.obj({ N: opts.appearance }); + (annot as PDFDict).set(PDFName.of('AP'), apDict); + } + + const annotRef = doc.context.register(annot); + + const pageNode = page.node; + let annotsArr = pageNode.Annots(); + if (!annotsArr) { + annotsArr = doc.context.obj([]) as PDFArray; + pageNode.set(PDFName.of('Annots'), annotsArr); + } + annotsArr.push(annotRef); + + return annotRef; +} + +function getAnnotCount(page: ReturnType): number { + const annots = page.node.Annots(); + return annots ? annots.asArray().length : 0; +} + +async function roundTrip(doc: PDFDocument): Promise { + const bytes = await doc.save(); + return PDFDocument.load(bytes); +} + +describe('flattenAnnotations', () => { + it('should be a no-op on a PDF with no annotations', async () => { + const doc = await PDFDocument.create(); + doc.addPage([612, 792]); + + flattenAnnotations(doc); + + const reloaded = await roundTrip(doc); + expect(reloaded.getPageCount()).toBe(1); + expect(getAnnotCount(reloaded.getPage(0))).toBe(0); + }); + + it('should flatten a FreeText annotation and remove it from Annots', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [72, 700, 172, 720], + appearance: appRef, + }); + + expect(getAnnotCount(page)).toBe(1); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + + const reloaded = await roundTrip(doc); + expect(reloaded.getPageCount()).toBe(1); + expect(getAnnotCount(reloaded.getPage(0))).toBe(0); + }); + + it('should flatten multiple annotation types', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + + const app1 = createAppearanceStream(doc, [0, 0, 100, 20]); + const app2 = createAppearanceStream(doc, [0, 0, 50, 50]); + const app3 = createAppearanceStream(doc, [0, 0, 200, 30]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [72, 700, 172, 720], + appearance: app1, + }); + addAnnotation(doc, page, { + subtype: 'Stamp', + rect: [200, 600, 250, 650], + appearance: app2, + }); + addAnnotation(doc, page, { + subtype: 'Ink', + rect: [100, 500, 300, 530], + appearance: app3, + }); + + expect(getAnnotCount(page)).toBe(3); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + + const reloaded = await roundTrip(doc); + expect(getAnnotCount(reloaded.getPage(0))).toBe(0); + }); + + it('should preserve Widget annotations', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page, { + subtype: 'Widget', + rect: [72, 700, 172, 720], + appearance: appRef, + }); + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [72, 600, 172, 620], + appearance: appRef, + }); + + expect(getAnnotCount(page)).toBe(2); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(1); + + const reloaded = await roundTrip(doc); + const remaining = reloaded.getPage(0).node.Annots()!.asArray(); + const remainingAnnot = reloaded.context.lookup(remaining[0]) as PDFDict; + const subtype = remainingAnnot.get(PDFName.of('Subtype')); + expect(subtype).toEqual(PDFName.of('Widget')); + }); + + it('should remove Popup annotations without rendering', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + + addAnnotation(doc, page, { + subtype: 'Popup', + rect: [72, 700, 272, 800], + }); + + expect(getAnnotCount(page)).toBe(1); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + }); + + it('should remove hidden annotations without rendering', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [72, 700, 172, 720], + appearance: appRef, + hidden: true, + }); + + expect(getAnnotCount(page)).toBe(1); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + + const reloaded = await roundTrip(doc); + expect(getAnnotCount(reloaded.getPage(0))).toBe(0); + }); + + it('should remove annotations that have no appearance stream', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + + addAnnotation(doc, page, { + subtype: 'Text', + rect: [72, 700, 92, 720], + }); + + expect(getAnnotCount(page)).toBe(1); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + }); + + it('should handle annotations with zero-area Rect', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [72, 700, 72, 700], + appearance: appRef, + }); + + expect(getAnnotCount(page)).toBe(1); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + }); + + it('should flatten annotations across multiple pages', async () => { + const doc = await PDFDocument.create(); + const page1 = doc.addPage([612, 792]); + const page2 = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page1, { + subtype: 'FreeText', + rect: [72, 700, 172, 720], + appearance: appRef, + }); + addAnnotation(doc, page2, { + subtype: 'Stamp', + rect: [100, 500, 200, 520], + appearance: appRef, + }); + + flattenAnnotations(doc); + + expect(getAnnotCount(page1)).toBe(0); + expect(getAnnotCount(page2)).toBe(0); + + const reloaded = await roundTrip(doc); + expect(reloaded.getPageCount()).toBe(2); + expect(getAnnotCount(reloaded.getPage(0))).toBe(0); + expect(getAnnotCount(reloaded.getPage(1))).toBe(0); + }); + + it('should leave pages without annotations untouched', async () => { + const doc = await PDFDocument.create(); + const page1 = doc.addPage([612, 792]); + const page2 = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page1, { + subtype: 'FreeText', + rect: [72, 700, 172, 720], + appearance: appRef, + }); + + flattenAnnotations(doc); + + expect(getAnnotCount(page1)).toBe(0); + expect(page2.node.Annots()).toBeUndefined(); + }); + + it('should handle mixed Widget + renderable + Popup annotations', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page, { + subtype: 'Widget', + rect: [10, 10, 110, 30], + appearance: appRef, + }); + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [72, 700, 172, 720], + appearance: appRef, + }); + addAnnotation(doc, page, { + subtype: 'Popup', + rect: [200, 200, 400, 400], + }); + addAnnotation(doc, page, { + subtype: 'Highlight', + rect: [50, 500, 150, 520], + appearance: appRef, + }); + + expect(getAnnotCount(page)).toBe(4); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(1); + + const reloaded = await roundTrip(doc); + expect(getAnnotCount(reloaded.getPage(0))).toBe(1); + }); + + it('should produce a valid PDF that can be re-loaded after flattening', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 200, 50]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [50, 700, 250, 750], + appearance: appRef, + }); + addAnnotation(doc, page, { + subtype: 'Stamp', + rect: [300, 600, 500, 650], + appearance: appRef, + }); + + flattenAnnotations(doc); + + const first = await roundTrip(doc); + const secondBytes = await first.save(); + const second = await PDFDocument.load(secondBytes); + + expect(second.getPageCount()).toBe(1); + expect(getAnnotCount(second.getPage(0))).toBe(0); + }); + + it('should handle inverted Rect coordinates (x2 < x1)', async () => { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const appRef = createAppearanceStream(doc, [0, 0, 100, 20]); + + addAnnotation(doc, page, { + subtype: 'FreeText', + rect: [172, 720, 72, 700], + appearance: appRef, + }); + + flattenAnnotations(doc); + + expect(getAnnotCount(page)).toBe(0); + + const reloaded = await roundTrip(doc); + expect(reloaded.getPageCount()).toBe(1); + }); +});