diff --git a/docs/pdf-json-editor-backlog.md b/docs/pdf-json-editor-backlog.md index a87a596b1..fdc7d032e 100644 --- a/docs/pdf-json-editor-backlog.md +++ b/docs/pdf-json-editor-backlog.md @@ -6,11 +6,6 @@ - Extend conversion logic so fallback kicks in only when conversion fails, and track which elements rely on the synthetic font to avoid mixing source glyphs (`PdfJsonConversionService.java:998-1090`, `1840-2012`). - Update the viewer/renderer to surface conversion errors and block editing when no faithful font can be produced. -- **Vector Artwork Preview** - - Reuse `contentStreams` already emitted by the backend to render vector paths alongside text/images in the React workspace (`frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx:1048-1285`). - - Either render via Canvas/SVG on the client or call back to a server-rendered bitmap for the background. Keep edited text/images layered on top. - - Maintain export fidelity by writing any untouched vector stream back during PDF regeneration (`PdfJsonConversionService.java:1714-1799`, `520-612`). - - **Lazy Fetch Endpoints** - Provide separate endpoints to fetch: 1. Raw COS dictionaries/font programs when the user opens advanced panels. diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index a8aa15173..9c216ceec 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -4431,6 +4431,7 @@ "noTextOnPage": "No editable text was detected on this page.", "emptyGroup": "[Empty Group]", "imageLabel": "Placed image", + "pagePreviewAlt": "Page preview", "empty": { "title": "No document loaded", "subtitle": "Load a PDF or JSON file to begin editing text content." diff --git a/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx b/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx index c3ae8cd1e..e14ba1f64 100644 --- a/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx +++ b/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx @@ -219,9 +219,11 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => { document: pdfDocument, groupsByPage, imagesByPage, + pagePreviews, selectedPage, dirtyPages, hasDocument, + hasVectorPreview, fileName, errorMessage, isGeneratingPdf, @@ -229,6 +231,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => { conversionProgress, hasChanges, forceSingleTextElement, + requestPagePreview, onLoadJson, onSelectPage, onGroupEdit, @@ -403,6 +406,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => { const currentPage = pages[selectedPage] ?? null; const pageGroups = groupsByPage[selectedPage] ?? []; const pageImages = imagesByPage[selectedPage] ?? []; + const pagePreview = pagePreviews.get(selectedPage); const extractPreferredFontId = useCallback((target?: TextGroup | null) => { if (!target) { @@ -615,12 +619,21 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => { ), [pageImages], ); - const { width: pageWidth, height: pageHeight } = pageDimensions(currentPage); const scale = useMemo(() => Math.min(MAX_RENDER_WIDTH / pageWidth, 1.5), [pageWidth]); const scaledWidth = pageWidth * scale; const scaledHeight = pageHeight * scale; + useEffect(() => { + if (!hasDocument || !hasVectorPreview) { + return; + } + requestPagePreview(selectedPage, scale); + if (selectedPage + 1 < pages.length) { + requestPagePreview(selectedPage + 1, scale); + } + }, [hasDocument, hasVectorPreview, selectedPage, scale, pages.length, requestPagePreview]); + useEffect(() => { setActiveGroupId(null); setEditingGroupId(null); @@ -1123,7 +1136,23 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => { }} ref={containerRef} > - {orderedImages.map((image, imageIndex) => { + {pagePreview && ( + {t('pdfJsonEditor.pagePreviewAlt', + )} + {orderedImages.map((image, imageIndex) => { if (!image?.imageData) { return null; } diff --git a/frontend/src/proprietary/tools/pdfJsonEditor/PdfJsonEditor.tsx b/frontend/src/proprietary/tools/pdfJsonEditor/PdfJsonEditor.tsx index 47b183102..4c1c6034a 100644 --- a/frontend/src/proprietary/tools/pdfJsonEditor/PdfJsonEditor.tsx +++ b/frontend/src/proprietary/tools/pdfJsonEditor/PdfJsonEditor.tsx @@ -10,6 +10,8 @@ import { CONVERSION_ENDPOINTS } from '@app/constants/convertConstants'; import apiClient from '@app/services/apiClient'; import { downloadBlob, downloadTextAsFile } from '@app/utils/downloadUtils'; import { getFilenameFromHeaders } from '@app/utils/fileResponseUtils'; +import { pdfWorkerManager } from '@core/services/pdfWorkerManager'; +import { Util } from 'pdfjs-dist/legacy/build/pdf.mjs'; import { PdfJsonDocument, PdfJsonImageElement, @@ -27,6 +29,7 @@ import { valueOr, } from './pdfJsonEditorUtils'; import PdfJsonEditorView from '@app/components/tools/pdfJsonEditor/PdfJsonEditorView'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; const VIEW_ID = 'pdfJsonEditorView'; const WORKBENCH_ID = 'custom:pdfJsonEditor' as const; @@ -75,6 +78,8 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { message: string; } | null>(null); const [forceSingleTextElement, setForceSingleTextElement] = useState(false); + const [hasVectorPreview, setHasVectorPreview] = useState(false); + const [pagePreviews, setPagePreviews] = useState>(new Map()); // Lazy loading state const [isLazyMode, setIsLazyMode] = useState(false); @@ -90,6 +95,11 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { const loadedDocumentRef = useRef(null); const loadedImagePagesRef = useRef>(new Set()); const loadingImagePagesRef = useRef>(new Set()); + const pdfDocumentRef = useRef(null); + const previewRequestIdRef = useRef(0); + const previewRenderingRef = useRef>(new Set()); + const pagePreviewsRef = useRef>(pagePreviews); + const previewScaleRef = useRef>(new Map()); // Keep ref in sync with state for access in async callbacks useEffect(() => { @@ -104,6 +114,19 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { loadingImagePagesRef.current = new Set(loadingImagePages); }, [loadingImagePages]); + useEffect(() => { + pagePreviewsRef.current = pagePreviews; + }, [pagePreviews]); + + useEffect(() => { + return () => { + if (pdfDocumentRef.current) { + pdfWorkerManager.destroyDocument(pdfDocumentRef.current); + pdfDocumentRef.current = null; + } + }; + }, []); + const dirtyPages = useMemo( () => getDirtyPages(groupsByPage, imagesByPage, originalImagesRef.current), [groupsByPage, imagesByPage], @@ -147,6 +170,50 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { setSelectedPage(0); }, []); + const clearPdfPreview = useCallback(() => { + previewRequestIdRef.current += 1; + previewRenderingRef.current.clear(); + previewScaleRef.current.clear(); + const empty = new Map(); + pagePreviewsRef.current = empty; + setPagePreviews(empty); + if (pdfDocumentRef.current) { + pdfWorkerManager.destroyDocument(pdfDocumentRef.current); + pdfDocumentRef.current = null; + } + setHasVectorPreview(false); + }, []); + + const initializePdfPreview = useCallback( + async (file: File) => { + const requestId = ++previewRequestIdRef.current; + try { + const buffer = await file.arrayBuffer(); + const pdfDocument = await pdfWorkerManager.createDocument(buffer); + if (previewRequestIdRef.current !== requestId) { + pdfWorkerManager.destroyDocument(pdfDocument); + return; + } + if (pdfDocumentRef.current) { + pdfWorkerManager.destroyDocument(pdfDocumentRef.current); + } + pdfDocumentRef.current = pdfDocument; + previewRenderingRef.current.clear(); + previewScaleRef.current.clear(); + const empty = new Map(); + pagePreviewsRef.current = empty; + setPagePreviews(empty); + setHasVectorPreview(true); + } catch (error) { + if (previewRequestIdRef.current === requestId) { + console.warn('[PdfJsonEditor] Failed to initialise PDF preview:', error); + clearPdfPreview(); + } + } + }, + [clearPdfPreview], + ); + // Load images for a page in lazy mode const loadImagesForPage = useCallback( async (pageIndex: number) => { @@ -439,6 +506,12 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { }`, ); + if (isPdf) { + initializePdfPreview(file); + } else { + clearPdfPreview(); + } + setLoadedDocument(parsed); resetToDocument(parsed); setIsLazyMode(shouldUseLazyMode); @@ -460,6 +533,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { setLoadedDocument(null); resetToDocument(null); + clearPdfPreview(); if (isPdf) { const errorMsg = @@ -806,13 +880,99 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { t, ]); + const requestPagePreview = useCallback( + async (pageIndex: number, scale: number) => { + if (!hasVectorPreview || !pdfDocumentRef.current) { + return; + } + const currentToken = previewRequestIdRef.current; + const recordedScale = previewScaleRef.current.get(pageIndex); + if ( + pagePreviewsRef.current.has(pageIndex) && + recordedScale !== undefined && + Math.abs(recordedScale - scale) < 0.05 + ) { + return; + } + if (previewRenderingRef.current.has(pageIndex)) { + return; + } + previewRenderingRef.current.add(pageIndex); + try { + const page = await pdfDocumentRef.current.getPage(pageIndex + 1); + const viewport = page.getViewport({ scale: Math.max(scale, 0.5) }); + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const context = canvas.getContext('2d'); + if (!context) { + page.cleanup(); + return; + } + await page.render({ canvasContext: context, viewport }).promise; + + try { + const textContent = await page.getTextContent(); + const maskMarginX = Math.max(0.45 * scale, 0.45); + const maskMarginY = Math.max(0.85 * scale, 0.85); + context.save(); + context.globalCompositeOperation = 'destination-out'; + context.fillStyle = '#000000'; + for (const item of textContent.items) { + const transform = Util.transform(viewport.transform, item.transform); + const a = transform[0]; + const b = transform[1]; + const c = transform[2]; + const d = transform[3]; + const e = transform[4]; + const f = transform[5]; + const angle = Math.atan2(b, a); + + const width = (item.width || 0) * viewport.scale + maskMarginX * 2; + const fontHeight = Math.hypot(c, d); + const rawHeight = item.height ? item.height * viewport.scale : fontHeight; + const height = Math.max(rawHeight + maskMarginY * 2, fontHeight + maskMarginY * 2); + const baselineOffset = height - maskMarginY; + + context.save(); + context.translate(e, f); + context.rotate(angle); + context.fillRect(-maskMarginX, -baselineOffset, width, height); + context.restore(); + } + context.restore(); + } catch (textError) { + console.warn('[PdfJsonEditor] Failed to strip text from preview', textError); + } + const dataUrl = canvas.toDataURL('image/png'); + page.cleanup(); + if (previewRequestIdRef.current !== currentToken) { + return; + } + previewScaleRef.current.set(pageIndex, scale); + setPagePreviews((prev) => { + const next = new Map(prev); + next.set(pageIndex, dataUrl); + return next; + }); + } catch (error) { + console.warn('[PdfJsonEditor] Failed to render page preview', error); + } finally { + previewRenderingRef.current.delete(pageIndex); + } + }, + [hasVectorPreview], + ); + const viewData = useMemo(() => ({ document: loadedDocument, groupsByPage, imagesByPage, + pagePreviews, selectedPage, dirtyPages, hasDocument, + hasVectorPreview, fileName, errorMessage, isGeneratingPdf, @@ -820,6 +980,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { conversionProgress, hasChanges, forceSingleTextElement, + requestPagePreview, onLoadJson: handleLoadFile, onSelectPage: handleSelectPage, onGroupEdit: handleGroupTextChange, @@ -832,6 +993,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { }), [ handleImageTransform, imagesByPage, + pagePreviews, dirtyPages, errorMessage, fileName, @@ -845,12 +1007,14 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => { handleSelectPage, hasChanges, hasDocument, + hasVectorPreview, isGeneratingPdf, isConverting, conversionProgress, loadedDocument, selectedPage, forceSingleTextElement, + requestPagePreview, ]); const latestViewDataRef = useRef(viewData); diff --git a/frontend/src/proprietary/tools/pdfJsonEditor/pdfJsonEditorTypes.ts b/frontend/src/proprietary/tools/pdfJsonEditor/pdfJsonEditorTypes.ts index 2733509c6..116838bee 100644 --- a/frontend/src/proprietary/tools/pdfJsonEditor/pdfJsonEditorTypes.ts +++ b/frontend/src/proprietary/tools/pdfJsonEditor/pdfJsonEditorTypes.ts @@ -182,9 +182,11 @@ export interface PdfJsonEditorViewData { document: PdfJsonDocument | null; groupsByPage: TextGroup[][]; imagesByPage: PdfJsonImageElement[][]; + pagePreviews: Map; selectedPage: number; dirtyPages: boolean[]; hasDocument: boolean; + hasVectorPreview: boolean; fileName: string; errorMessage: string | null; isGeneratingPdf: boolean; @@ -192,6 +194,7 @@ export interface PdfJsonEditorViewData { conversionProgress: ConversionProgress | null; hasChanges: boolean; forceSingleTextElement: boolean; + requestPagePreview: (pageIndex: number, scale: number) => void; onLoadJson: (file: File | null) => Promise | void; onSelectPage: (pageIndex: number) => void; onGroupEdit: (pageIndex: number, groupId: string, value: string) => void;