Files
bentopdf/src/js/logic/edit-pdf-page.ts
T
alam00000 356128c60d feat: burn FreeText annotations into the page content stream
Exported annotations stayed as live FreeText objects, so viewers that
ignore appearance streams showed nothing and the text was not part of
the page. Flattening draws them into the content stream with an
embedded subset of the font, falling back to the old embed path for
anything the flattener cannot place.
2026-08-21 12:49:25 +05:30

381 lines
12 KiB
TypeScript

// Logic for PDF Editor Page
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { formatBytes, downloadFile } from '../utils/helpers.js';
import { makeUniqueFileKey } from '../utils/deduplicate-filename.js';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import { getEditorDisabledCategories } from '../utils/disabled-tools.js';
import { editorFontFallback } from '../config/editor-fonts.js';
import { needsFontEmbedding } from '../utils/freetext-script.js';
const embedPdfWasmUrl = new URL(
'bentopdf-pdfium/editcore.wasm',
import.meta.url
).href;
import type { EmbedPdfContainer } from 'bentopdf-viewer';
import type {
AnnotationPluginLite,
DocManagerPlugin,
FreeTextSystemFontAnnotation,
} from '@/types';
const FREETEXT_SUBTYPE = 3;
function collectAllFreeTexts(
annotationPlugin: AnnotationPluginLite | null
): FreeTextSystemFontAnnotation[] {
if (!annotationPlugin) return [];
try {
const state = annotationPlugin.getState();
const out: FreeTextSystemFontAnnotation[] = [];
for (const tracked of Object.values(state.byUid)) {
const obj = tracked.object;
if (obj.type !== FREETEXT_SUBTYPE) continue;
if (obj.intent === 'FreeTextCallout') continue;
if (!obj.id || obj.pageIndex == null || !obj.rect) continue;
out.push({
id: obj.id,
pageIndex: obj.pageIndex,
contents: obj.contents ?? '',
fontSize: obj.fontSize ?? 12,
fontColor: obj.fontColor ?? '#000000',
textAlign: obj.textAlign ?? 0,
verticalAlign: obj.verticalAlign ?? 0,
opacity: obj.opacity ?? 1,
backgroundColor: obj.color ?? obj.backgroundColor,
rect: obj.rect,
fontPostScriptName: obj.fontPostScriptName ?? '',
rotation: obj.rotation ?? 0,
});
}
return out;
} catch {
return [];
}
}
let viewerInstance: EmbedPdfContainer | null = null;
let docManagerPlugin: DocManagerPlugin | null = null;
let isViewerInitialized = false;
let currentFileName = 'document.pdf';
const fileEntryMap = new Map<string, HTMLElement>();
function resetViewer() {
const pdfWrapper = document.getElementById('embed-pdf-wrapper');
const pdfContainer = document.getElementById('embed-pdf-container');
const downloadBtn = document.getElementById('download-edited-pdf');
const fileDisplayArea = document.getElementById('file-display-area');
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (pdfContainer) pdfContainer.textContent = '';
if (pdfWrapper) pdfWrapper.classList.add('hidden');
if (downloadBtn) downloadBtn.classList.add('hidden');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
if (fileInput) fileInput.value = '';
viewerInstance = null;
docManagerPlugin = null;
isViewerInitialized = false;
fileEntryMap.clear();
}
function removeFileEntry(documentId: string) {
const entry = fileEntryMap.get(documentId);
if (entry) {
entry.remove();
fileEntryMap.delete(documentId);
}
if (fileEntryMap.size === 0) {
resetViewer();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage);
} else {
initializePage();
}
function initializePage() {
createIcons({ icons });
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
if (fileInput) {
fileInput.addEventListener('change', handleFileUpload);
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-indigo-500');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-indigo-500');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-indigo-500');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFiles(files);
}
});
fileInput?.addEventListener('click', () => {
if (fileInput) fileInput.value = '';
});
}
document.getElementById('back-to-tools')?.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
async function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await handleFiles(input.files);
}
}
async function handleFiles(files: FileList) {
const pdfFiles = Array.from(files).filter(
(f) => f.type === 'application/pdf'
);
if (pdfFiles.length === 0) {
showAlert('Invalid File', 'Please upload a valid PDF file.');
return;
}
showLoader('Loading PDF Editor...');
try {
const pdfWrapper = document.getElementById('embed-pdf-wrapper');
const pdfContainer = document.getElementById('embed-pdf-container');
const fileDisplayArea = document.getElementById('file-display-area');
if (!pdfWrapper || !pdfContainer || !fileDisplayArea) return;
hideLoader();
const decryptedFiles = await batchDecryptIfNeeded(pdfFiles);
showLoader('Loading PDF Editor...');
if (decryptedFiles.length === 0) {
hideLoader();
return;
}
if (!isViewerInitialized) {
const firstFile = decryptedFiles[0];
currentFileName = firstFile.name;
const firstBuffer = await firstFile.arrayBuffer();
pdfContainer.textContent = '';
pdfWrapper.classList.remove('hidden');
const { default: EmbedPDF } = await import('bentopdf-viewer');
const disabledCategories = getEditorDisabledCategories();
viewerInstance = EmbedPDF.init({
disabledCategories,
type: 'container',
target: pdfContainer,
worker: true,
wasmUrl: embedPdfWasmUrl,
fontFallback: editorFontFallback,
export: {
defaultFileName: firstFile.name,
},
documentManager: {
maxDocuments: 10,
},
tabBar: 'always',
});
const registry = await viewerInstance.registry;
docManagerPlugin = registry
.getPlugin('document-manager')
.provides() as unknown as DocManagerPlugin;
docManagerPlugin.onDocumentClosed((data: { id?: string }) => {
const docId = data?.id || '';
removeFileEntry(docId);
});
docManagerPlugin.onDocumentOpened(
(data: { id?: string; name?: string }) => {
const docId = data?.id;
const docKey = data?.name;
if (!docId) return;
const pendingEntry = fileDisplayArea.querySelector(
`[data-pending-name="${CSS.escape(docKey)}"]`
) as HTMLElement;
if (pendingEntry) {
pendingEntry.removeAttribute('data-pending-name');
fileEntryMap.set(docId, pendingEntry);
const removeBtn = pendingEntry.querySelector(
'[data-remove-btn]'
) as HTMLElement;
if (removeBtn) {
removeBtn.onclick = () => {
docManagerPlugin.closeDocument(docId);
};
}
}
}
);
addFileEntries(fileDisplayArea, decryptedFiles);
docManagerPlugin.openDocumentBuffer({
buffer: firstBuffer,
name: makeUniqueFileKey(0, firstFile.name),
autoActivate: true,
});
for (let i = 1; i < decryptedFiles.length; i++) {
const buffer = await decryptedFiles[i].arrayBuffer();
docManagerPlugin.openDocumentBuffer({
buffer,
name: makeUniqueFileKey(i, decryptedFiles[i].name),
autoActivate: false,
});
}
isViewerInitialized = true;
let downloadBtn = document.getElementById('download-edited-pdf');
if (!downloadBtn) {
downloadBtn = document.createElement('button');
downloadBtn.id = 'download-edited-pdf';
downloadBtn.className = 'btn-gradient w-full mt-6';
downloadBtn.textContent = 'Download Edited PDF';
pdfWrapper.appendChild(downloadBtn);
}
downloadBtn.classList.remove('hidden');
downloadBtn.onclick = async () => {
try {
const exportPlugin = registry.getPlugin('export').provides();
const arrayBuffer = await exportPlugin.saveAsCopy().toPromise();
let outBytes = new Uint8Array(arrayBuffer);
let annotationPlugin: AnnotationPluginLite | null = null;
try {
annotationPlugin = registry
.getPlugin('annotation')
.provides() as unknown as AnnotationPluginLite;
} catch {
annotationPlugin = null;
}
const allFreeTexts = collectAllFreeTexts(annotationPlugin);
let pending = allFreeTexts;
if (pending.length > 0) {
try {
const { flattenFreeTextToPageText } =
await import('../utils/freetext-flatten.js');
const res = await flattenFreeTextToPageText(outBytes, pending);
if (res.flattened > 0) {
outBytes = res.bytes;
pending = [];
}
} catch (err) {
console.error('Flatten pass failed:', err);
}
}
const customFontAnnots = pending.filter(
(a) =>
a.fontPostScriptName.trim() !== '' ||
needsFontEmbedding(a.contents)
);
if (customFontAnnots.length > 0) {
try {
const { embedFreeTextSystemFonts } =
await import('../utils/freetext-font-embed.js');
outBytes = await embedFreeTextSystemFonts(
outBytes,
customFontAnnots
);
} catch (err) {
console.error('Font embed pass failed:', err);
}
}
const blob = new Blob([new Uint8Array(outBytes)], {
type: 'application/pdf',
});
downloadFile(blob, currentFileName);
} catch (err) {
console.error('Error downloading PDF:', err);
showAlert('Error', 'Failed to download the edited PDF.');
}
};
const backBtn = document.getElementById('back-to-tools');
if (backBtn) {
const newBackBtn = backBtn.cloneNode(true);
backBtn.parentNode?.replaceChild(newBackBtn, backBtn);
newBackBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
}
} else {
addFileEntries(fileDisplayArea, decryptedFiles);
for (let i = 0; i < decryptedFiles.length; i++) {
const buffer = await decryptedFiles[i].arrayBuffer();
docManagerPlugin.openDocumentBuffer({
buffer,
name: makeUniqueFileKey(i, decryptedFiles[i].name),
autoActivate: true,
});
}
}
} catch (error) {
console.error('Error loading PDF Editor:', error);
showAlert('Error', 'Failed to load the PDF Editor.');
} finally {
hideLoader();
}
}
function addFileEntries(fileDisplayArea: HTMLElement, files: File[]) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
fileDiv.setAttribute('data-pending-name', makeUniqueFileKey(i, file.name));
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
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);
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.setAttribute('data-remove-btn', 'true');
removeBtn.onclick = () => {
fileDiv.remove();
if (fileDisplayArea.children.length === 0) {
resetViewer();
}
};
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
}
createIcons({ icons });
}