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);
+ });
+});