From df01b3537c9373b9a6d89d7d11132dd6af813dc3 Mon Sep 17 00:00:00 2001 From: stidven-wq <254276468+stidven-wq@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:42:31 +0200 Subject: [PATCH] feat(edit-pdf): edit existing PDF text --- messages/en.json | 8 + messages/es.json | 8 + .../lib/pdf/replace-existing-text.test.ts | 138 +++++ src/components/tools/edit-pdf/EditPDFTool.tsx | 487 +++++++++++++++++- src/config/tool-content/en.ts | 2 +- src/config/tool-content/es.ts | 2 +- src/config/tools.ts | 2 +- .../pdf/processors/replace-existing-text.ts | 102 ++++ src/lib/pdf/pymupdf-loader.ts | 302 +++++++++++ 9 files changed, 1045 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/lib/pdf/replace-existing-text.test.ts create mode 100644 src/lib/pdf/processors/replace-existing-text.ts diff --git a/messages/en.json b/messages/en.json index 2a70ae3a1..891c7783e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1755,6 +1755,14 @@ "saveError": "Failed to save PDF. Please use the export button in the toolbar.", "savingMessage": "Saving PDF with annotations...", "successMessage": "PDF saved successfully! Click the download button to save your edited file.", + "textHistory": "Text change history", + "textUndo": "Undo text", + "textRedo": "Redo text", + "textUndoApplied": "The last text change was undone.", + "textRedoApplied": "The text change was redone.", + "overflowAppliedWarning": "The replacement text exceeds the original area. Review the result visually before exporting.", + "fallbackFontWarning": "The original font could not be reused and a fallback was used. Review the characters and spacing.", + "signatureInvalidatedWarning": "This PDF contained digital signatures. Editing its content invalidates the existing signatures.", "redactWarningTitle": "Redaction Mode Active", "redactWarningDescription": "Select areas to permanently remove content. Redacted content cannot be recovered after saving.", "toolbar": { diff --git a/messages/es.json b/messages/es.json index 173abf6a8..b4393db6e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1754,6 +1754,14 @@ "saveError": "No se pudo guardar el PDF. Utilice el botón exportar en la barra de herramientas.", "savingMessage": "Guardando PDF con anotaciones...", "successMessage": "¡PDF guardado exitosamente! Haga clic en el botón de descarga para guardar su archivo editado.", + "textHistory": "Historial de cambios de texto", + "textUndo": "Deshacer texto", + "textRedo": "Rehacer texto", + "textUndoApplied": "Se ha deshecho el último cambio de texto.", + "textRedoApplied": "Se ha rehecho el cambio de texto.", + "overflowAppliedWarning": "El texto sustituido excede el área original. Revise visualmente el resultado antes de exportar.", + "fallbackFontWarning": "La fuente original no pudo reutilizarse y se empleó una alternativa. Revise los caracteres y el espaciado.", + "signatureInvalidatedWarning": "Este PDF contenía firmas digitales. La edición del contenido invalida sus firmas existentes.", "redactWarningTitle": "Modo de redacción activo", "redactWarningDescription": "Seleccione áreas para eliminar contenido permanentemente. El contenido redactado no se puede recuperar después de guardarlo.", "toolbar": { diff --git a/src/__tests__/lib/pdf/replace-existing-text.test.ts b/src/__tests__/lib/pdf/replace-existing-text.test.ts new file mode 100644 index 000000000..cb7c04902 --- /dev/null +++ b/src/__tests__/lib/pdf/replace-existing-text.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + replaceExistingText, +} from '@/lib/pdf/processors/replace-existing-text'; +import type { TextMatch } from '@/lib/pdf/processors/find-and-redact'; +import { loadPyMuPDF } from '@/lib/pdf/pymupdf-loader'; + +vi.mock('@/lib/pdf/pymupdf-loader', () => ({ + loadPyMuPDF: vi.fn(), +})); + +const matches: TextMatch[] = [ + { + page: 1, + text: 'Original', + x: 10, + y: 20, + width: 50, + height: 12, + id: 'first', + selected: true, + }, + { + page: 2, + text: 'Original', + x: 15, + y: 25, + width: 50, + height: 12, + id: 'second', + selected: false, + }, +]; + +describe('replaceExistingText', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes only selected coordinates to the local PyMuPDF engine', async () => { + const output = new Blob(['edited'], { type: 'application/pdf' }); + const diagnostics = { + usedFallbackFont: false, + overflowDetected: false, + hasDigitalSignatures: false, + }; + const replaceExistingTextMock = vi.fn().mockResolvedValue({ + blob: output, + diagnostics, + }); + vi.mocked(loadPyMuPDF).mockResolvedValue({ + replaceExistingText: replaceExistingTextMock, + }); + const file = new File(['pdf'], 'sample.pdf', { type: 'application/pdf' }); + + const result = await replaceExistingText(file, matches, { + replacementText: 'Updated', + selectedMatchIds: ['first'], + }); + + expect(result).toEqual({ + success: true, + result: output, + replacedCount: 1, + diagnostics, + }); + expect(replaceExistingTextMock).toHaveBeenCalledWith( + file, + [{ page: 1, x: 10, y: 20, width: 50, height: 12 }], + 'Updated', + 'preserve' + ); + }); + + it('passes the requested overflow strategy to the engine', async () => { + const output = new Blob(['edited'], { type: 'application/pdf' }); + const diagnostics = { + usedFallbackFont: true, + overflowDetected: true, + hasDigitalSignatures: true, + }; + const replaceExistingTextMock = vi.fn().mockResolvedValue({ + blob: output, + diagnostics, + }); + vi.mocked(loadPyMuPDF).mockResolvedValue({ + replaceExistingText: replaceExistingTextMock, + }); + + const result = await replaceExistingText( + new File(['pdf'], 'sample.pdf', { type: 'application/pdf' }), + matches, + { + replacementText: 'A much longer replacement', + selectedMatchIds: ['first'], + fitMode: 'shrink', + } + ); + + expect(replaceExistingTextMock).toHaveBeenCalledWith( + expect.any(File), + [{ page: 1, x: 10, y: 20, width: 50, height: 12 }], + 'A much longer replacement', + 'shrink' + ); + expect(result.diagnostics).toEqual(diagnostics); + }); + + it('rejects an empty selection without loading the PDF engine', async () => { + const file = new File(['pdf'], 'sample.pdf', { type: 'application/pdf' }); + + const result = await replaceExistingText(file, matches, { + replacementText: 'Updated', + selectedMatchIds: ['missing'], + }); + + expect(result.success).toBe(false); + expect(result.replacedCount).toBe(0); + expect(loadPyMuPDF).not.toHaveBeenCalled(); + }); + + it('reports engine errors without returning a partial PDF', async () => { + vi.mocked(loadPyMuPDF).mockResolvedValue({ + replaceExistingText: vi.fn().mockRejectedValue(new Error('WASM failed')), + }); + const file = new File(['pdf'], 'sample.pdf', { type: 'application/pdf' }); + + const result = await replaceExistingText(file, matches, { + replacementText: 'Updated', + }); + + expect(result).toEqual({ + success: false, + replacedCount: 0, + error: 'WASM failed', + }); + }); +}); diff --git a/src/components/tools/edit-pdf/EditPDFTool.tsx b/src/components/tools/edit-pdf/EditPDFTool.tsx index 82efec16e..70d91d66d 100644 --- a/src/components/tools/edit-pdf/EditPDFTool.tsx +++ b/src/components/tools/edit-pdf/EditPDFTool.tsx @@ -5,6 +5,11 @@ import { useTranslations } from 'next-intl'; import { FileUploader } from '../FileUploader'; import { Button } from '@/components/ui/Button'; import { Card } from '@/components/ui/Card'; +import { + replaceExistingText, + type ReplaceExistingTextDiagnostics, + type TextFitMode, +} from '@/lib/pdf/processors/replace-existing-text'; export interface EditPDFToolProps { className?: string; @@ -25,14 +30,28 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) { const [pdfUrl, setPdfUrl] = useState(null); const [error, setError] = useState(null); const [isEditorReady, setIsEditorReady] = useState(false); + const [isTextReplacing, setIsTextReplacing] = useState(false); + const [replacementNotice, setReplacementNotice] = useState(null); + const [replacementDiagnostics, setReplacementDiagnostics] = + useState(null); + const [textUndoCount, setTextUndoCount] = useState(0); + const [textRedoCount, setTextRedoCount] = useState(0); const iframeRef = useRef(null); + const textUndoStackRef = useRef([]); + const textRedoStackRef = useRef([]); const handleFilesSelected = useCallback((files: File[]) => { if (files.length > 0) { const selectedFile = files[0]; setFile(selectedFile); setError(null); + setReplacementNotice(null); + setReplacementDiagnostics(null); + textUndoStackRef.current = []; + textRedoStackRef.current = []; + setTextUndoCount(0); + setTextRedoCount(0); setPdfUrl(URL.createObjectURL(selectedFile)); } }, []); @@ -106,9 +125,284 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) { setupUndoRedoAndAuthorPatch(); setupSnapping(); setupChineseFontPatch(); + setupExistingTextEditing(); } }, 200); + function setupExistingTextEditing() { + if (document.getElementById('pdfcraft-edit-existing-text')) return; + const toolbar = document.querySelector('.CustomToolbar ul.buttons'); + if (!toolbar) return; + + const parentLang = window.parent?.document?.documentElement?.lang || 'en'; + const parentLocale = window.parent?.location?.pathname?.split('/')[1] || parentLang; + const isSpanish = parentLocale.toLowerCase().startsWith('es'); + const labels = isSpanish + ? { + tool: 'Editar texto', + heading: 'Editar texto existente', + original: 'Texto original', + replacement: 'Texto nuevo', + apply: 'Aplicar', + confirm: 'Confirmar cambio', + cancel: 'Cancelar', + hint: 'Haz clic sobre un bloque de texto del PDF', + overflow: 'El texto nuevo no cabe en el área original.', + fit: 'Si no cabe', + preserve: 'Mantener tamaño', + shrink: 'Reducir para encajar', + expand: 'Ampliar el área', + signature: 'Editar el contenido invalida las firmas digitales existentes.' + } + : { + tool: 'Edit text', + heading: 'Edit existing text', + original: 'Original text', + replacement: 'New text', + apply: 'Apply', + confirm: 'Confirm change', + cancel: 'Cancel', + hint: 'Click a text block in the PDF', + overflow: 'The new text does not fit in the original area.', + fit: 'If it does not fit', + preserve: 'Keep original size', + shrink: 'Shrink to fit', + expand: 'Expand the area', + signature: 'Editing content invalidates existing digital signatures.' + }; + + const item = document.createElement('li'); + item.id = 'pdfcraft-edit-existing-text'; + item.title = labels.tool; + item.innerHTML = + '
T✎
' + + '
' + labels.tool + '
'; + + const selectItem = toolbar.querySelector('li[title="Select"]'); + if (selectItem?.nextSibling) { + toolbar.insertBefore(item, selectItem.nextSibling); + } else { + toolbar.insertBefore(item, toolbar.firstChild); + } + + const style = document.createElement('style'); + style.id = 'pdfcraft-existing-text-styles'; + style.textContent = \` + body.pdfcraft-text-edit-mode .textLayer { pointer-events: auto !important; } + body.pdfcraft-text-edit-mode .textLayer span { + cursor: text !important; + pointer-events: auto !important; + border-radius: 2px; + transition: outline-color .12s, background .12s; + } + body.pdfcraft-text-edit-mode .textLayer span:hover { + outline: 2px solid #2563eb !important; + background: rgba(37, 99, 235, .14) !important; + } + #pdfcraft-edit-existing-text.pdfcraft-active { + background: rgba(37, 99, 235, .18) !important; + color: #2563eb !important; + } + #pdfcraft-text-edit-hint { + position: fixed; left: 50%; top: 74px; transform: translateX(-50%); + z-index: 100000; padding: 7px 12px; border-radius: 999px; + color: white; background: #1d4ed8; box-shadow: 0 5px 18px rgba(0,0,0,.2); + font: 500 12px/1.2 system-ui, sans-serif; pointer-events: none; + } + #pdfcraft-text-edit-popover { + position: fixed; z-index: 100001; width: min(380px, calc(100vw - 24px)); + padding: 14px; border: 1px solid #cbd5e1; border-radius: 10px; + background: white; color: #0f172a; box-shadow: 0 16px 40px rgba(15,23,42,.28); + font: 13px/1.4 system-ui, sans-serif; + } + #pdfcraft-text-edit-popover textarea { + box-sizing: border-box; width: 100%; min-height: 62px; resize: vertical; + margin-top: 4px; padding: 8px; border: 1px solid #94a3b8; border-radius: 6px; + color: #0f172a; background: white; font: inherit; + } + #pdfcraft-text-edit-popover .pdfcraft-actions { + display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; + } + #pdfcraft-text-edit-popover button { + padding: 6px 11px; border: 1px solid #94a3b8; border-radius: 6px; + cursor: pointer; background: white; color: #0f172a; font: inherit; + } + #pdfcraft-text-edit-popover button[data-action="apply"] { + border-color: #2563eb; background: #2563eb; color: white; + } + #pdfcraft-text-edit-popover .pdfcraft-overflow { + display: none; margin-top: 8px; padding: 7px 8px; border-radius: 6px; + color: #92400e; background: #fffbeb; border: 1px solid #fde68a; + } + #pdfcraft-text-edit-popover select { + box-sizing: border-box; width: 100%; margin-top: 4px; padding: 7px; + border: 1px solid #94a3b8; border-radius: 6px; background: white; + } + .pdfcraft-live-text-preview { + outline: 2px dashed #16a34a !important; + background: rgba(22, 163, 74, .10) !important; + } + \`; + document.head.appendChild(style); + + let active = false; + let popover = null; + let previewSpan = null; + let previewOriginalText = ''; + + function restorePreview() { + if (previewSpan) { + previewSpan.textContent = previewOriginalText; + previewSpan.classList.remove('pdfcraft-live-text-preview'); + } + previewSpan = null; + previewOriginalText = ''; + } + + function closePopover(restore = true) { + if (restore) restorePreview(); + if (popover) popover.remove(); + popover = null; + } + + function setActive(nextActive) { + active = nextActive; + document.body.classList.toggle('pdfcraft-text-edit-mode', active); + item.classList.toggle('pdfcraft-active', active); + closePopover(); + + document.getElementById('pdfcraft-text-edit-hint')?.remove(); + if (active) { + const hint = document.createElement('div'); + hint.id = 'pdfcraft-text-edit-hint'; + hint.textContent = labels.hint; + document.body.appendChild(hint); + } + } + + item.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + setActive(!active); + }); + + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape' && (active || popover)) { + if (popover) closePopover(); + else setActive(false); + } + }); + + document.addEventListener('click', async function(event) { + if (!active) return; + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const span = target.closest('.textLayer span'); + if (!span || !span.textContent?.trim()) return; + + event.preventDefault(); + event.stopPropagation(); + + const pageElement = span.closest('.page'); + const pageNumber = Number(pageElement?.getAttribute('data-page-number')); + const app = window.PDFViewerApplication; + const pageView = app?.pdfViewer?.getPageView(pageNumber - 1); + const pdfPage = pageView?.pdfPage; + if (!pageElement || !pageNumber || !pdfPage) return; + + const viewport = pdfPage.getViewport({ scale: 1 }); + const pageRect = pageElement.getBoundingClientRect(); + const textRect = span.getBoundingClientRect(); + const originalWidth = textRect.width; + const originalHeight = textRect.height; + const x = (textRect.left - pageRect.left) / pageRect.width * viewport.width; + const top = (textRect.top - pageRect.top) / pageRect.height * viewport.height; + const width = textRect.width / pageRect.width * viewport.width; + const height = textRect.height / pageRect.height * viewport.height; + const y = viewport.height - top - height; + + closePopover(); + previewSpan = span; + previewOriginalText = span.textContent; + popover = document.createElement('div'); + popover.id = 'pdfcraft-text-edit-popover'; + popover.innerHTML = + '' + labels.heading + '' + + '
' + labels.original + '
' + + '
' + + span.textContent.replace(/&/g, '&').replace(/' + + '' + + '
' + labels.overflow + '
' + + '' + + '
' + labels.signature + '
' + + '
' + + '' + + '' + + '
'; + + const left = Math.min(Math.max(12, textRect.left), window.innerWidth - 392); + const topPosition = Math.min( + window.innerHeight - 245, + Math.max(90, textRect.bottom + 8) + ); + popover.style.left = left + 'px'; + popover.style.top = topPosition + 'px'; + document.body.appendChild(popover); + + const textarea = popover.querySelector('textarea'); + textarea.value = span.textContent; + textarea.focus(); + textarea.select(); + const overflowNotice = popover.querySelector('.pdfcraft-overflow'); + + function updatePreview() { + span.textContent = textarea.value; + span.classList.add('pdfcraft-live-text-preview'); + const previewRect = span.getBoundingClientRect(); + const lineCount = Math.max(1, textarea.value.split(/\\r?\\n/).length); + const overflow = previewRect.width > originalWidth + 1 || + lineCount * originalHeight > originalHeight + 1; + overflowNotice.style.display = overflow ? 'block' : 'none'; + } + textarea.addEventListener('input', updatePreview); + updatePreview(); + + popover.querySelector('[data-action="cancel"]').addEventListener('click', closePopover); + popover.querySelector('[data-action="apply"]').addEventListener('click', function() { + const newText = textarea.value; + if (newText === previewOriginalText) { + closePopover(); + return; + } + + const applyButton = popover.querySelector('[data-action="apply"]'); + applyButton.disabled = true; + applyButton.textContent = '…'; + window.parent.postMessage({ + type: 'pdfcraft:replace-existing-text', + payload: { + page: pageNumber, + text: previewOriginalText, + replacementText: newText, + fitMode: popover.querySelector('[data-fit-mode]').value, + x, y, width, height + } + }, window.location.origin); + closePopover(false); + }); + }, true); + } + function setupSnapping() { const ext = window.pdfjsAnnotationExtensionInstance; const stage = ext?.stage || ext?.konvaStage || (window.Konva && window.Konva.stages[0]); @@ -204,7 +498,10 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) { let hasChinese = false; // Inspect the annotation store inside PDFJS Annotation Extension - const store = window.pdfjsAnnotationExtensionInstance?.getAnnotationStore(); + const annotationExtension = window.pdfjsAnnotationExtensionInstance; + const store = typeof annotationExtension?.getAnnotationStore === 'function' + ? annotationExtension.getAnnotationStore() + : null; if (store && store.annotations) { store.annotations.forEach(ann => { if (ann.name === 'freeText' && /[\u4e00-\u9fa5]/.test(ann.text || '')) { @@ -423,6 +720,7 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) { function getAnnotationsSnapshot() { const ext = window.pdfjsAnnotationExtensionInstance; if (!ext) return null; + if (typeof ext.getAnnotationStore !== 'function') return null; const store = ext.getAnnotationStore(); if (!store) return null; return JSON.stringify(store); @@ -442,11 +740,13 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) { if (!ext) return; // Dynamic author override for tool name labels in comments list - const store = ext.getAnnotationStore(); + const store = typeof ext.getAnnotationStore === 'function' + ? ext.getAnnotationStore() + : null; let authorUpdated = false; if (store && store.annotations) { store.annotations.forEach(ann => { - const transName = toolNameTranslations[ann.name] || '${t('editPdf.annDefault')}'; + const transName = toolNameTranslations[ann.name] || 'Annotation'; const targetAuthor = transName + ' (${t('editPdf.unnamedUser')})'; if (ann.author !== targetAuthor && ann.author === '${t('editPdf.unnamedUser')}') { ann.author = targetAuthor; @@ -587,8 +887,132 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) { setPdfUrl(null); setError(null); setIsEditorReady(false); + setReplacementNotice(null); + setReplacementDiagnostics(null); + textUndoStackRef.current = []; + textRedoStackRef.current = []; + setTextUndoCount(0); + setTextRedoCount(0); }, [pdfUrl]); + const showFileVersion = useCallback((nextFile: File) => { + setFile(nextFile); + setIsEditorReady(false); + setPdfUrl(URL.createObjectURL(nextFile)); + }, []); + + const handleExistingTextReplaced = useCallback(( + result: Blob, + count: number, + diagnostics?: ReplaceExistingTextDiagnostics + ) => { + if (!file) return; + const editedFile = new File([result], file.name, { type: 'application/pdf' }); + textUndoStackRef.current.push(file); + textRedoStackRef.current = []; + setTextUndoCount(textUndoStackRef.current.length); + setTextRedoCount(0); + setReplacementNotice(`${count} text occurrence${count === 1 ? '' : 's'} replaced.`); + setReplacementDiagnostics(diagnostics ?? null); + showFileVersion(editedFile); + }, [file, showFileVersion]); + + const handleTextUndo = useCallback(() => { + if (!file || textUndoStackRef.current.length === 0 || isTextReplacing) return; + const previousFile = textUndoStackRef.current.pop(); + if (!previousFile) return; + textRedoStackRef.current.push(file); + setTextUndoCount(textUndoStackRef.current.length); + setTextRedoCount(textRedoStackRef.current.length); + setReplacementNotice(tTools('textUndoApplied')); + setReplacementDiagnostics(null); + showFileVersion(previousFile); + }, [file, isTextReplacing, showFileVersion, tTools]); + + const handleTextRedo = useCallback(() => { + if (!file || textRedoStackRef.current.length === 0 || isTextReplacing) return; + const nextFile = textRedoStackRef.current.pop(); + if (!nextFile) return; + textUndoStackRef.current.push(file); + setTextUndoCount(textUndoStackRef.current.length); + setTextRedoCount(textRedoStackRef.current.length); + setReplacementNotice(tTools('textRedoApplied')); + setReplacementDiagnostics(null); + showFileVersion(nextFile); + }, [file, isTextReplacing, showFileVersion, tTools]); + + useEffect(() => { + const handleMessage = async (event: MessageEvent) => { + if ( + event.origin !== window.location.origin || + event.source !== iframeRef.current?.contentWindow || + event.data?.type !== 'pdfcraft:replace-existing-text' || + !file || + isTextReplacing + ) { + return; + } + + const payload = event.data.payload as { + page: number; + text: string; + replacementText: string; + x: number; + y: number; + width: number; + height: number; + fitMode?: TextFitMode; + }; + + if ( + !payload || + !Number.isFinite(payload.page) || + !Number.isFinite(payload.x) || + !Number.isFinite(payload.y) || + !Number.isFinite(payload.width) || + !Number.isFinite(payload.height) + ) { + return; + } + + setIsTextReplacing(true); + setError(null); + const match = { + page: payload.page, + text: payload.text, + x: payload.x, + y: payload.y, + width: payload.width, + height: payload.height, + id: `inline-${payload.page}-${payload.x}-${payload.y}`, + selected: true, + }; + + const result = await replaceExistingText( + file, + [match], + { + replacementText: payload.replacementText, + fitMode: payload.fitMode ?? 'preserve', + } + ); + + if (result.success && result.result) { + handleExistingTextReplaced( + result.result, + result.replacedCount, + result.diagnostics + ); + } else { + setError(result.error || 'Unable to edit the selected text.'); + } + setIsTextReplacing(false); + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [file, handleExistingTextReplaced, isTextReplacing]); + return (
{!file && ( @@ -631,6 +1055,55 @@ export function EditPDFTool({ className = '' }: EditPDFToolProps) {
+ {replacementNotice && ( +
+

{replacementNotice}

+
+ )} + +
+ + {tTools('textHistory')}: + + + +
+ + {replacementDiagnostics?.overflowDetected && ( +
+

{tTools('overflowAppliedWarning')}

+
+ )} + + {replacementDiagnostics?.usedFallbackFont && ( +
+

{tTools('fallbackFontWarning')}

+
+ )} + + {replacementDiagnostics?.hasDigitalSignatures && ( +
+

{tTools('signatureInvalidatedWarning')}

+
+ )} + {/* PDF Viewer iframe */}