import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Accordion, ActionIcon, Alert, Badge, Box, Button, Card, Divider, FileButton, Group, Modal, Pagination, Progress, ScrollArea, SegmentedControl, Stack, Switch, Text, Title, } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import DescriptionIcon from '@mui/icons-material/DescriptionOutlined'; import FileDownloadIcon from '@mui/icons-material/FileDownloadOutlined'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdfOutlined'; import AutorenewIcon from '@mui/icons-material/Autorenew'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import UploadIcon from '@mui/icons-material/Upload'; import CloseIcon from '@mui/icons-material/Close'; import { Rnd } from 'react-rnd'; import { PdfTextEditorViewData, PdfJsonFont, PdfJsonPage, ConversionProgress, } from '@app/tools/pdfTextEditor/pdfTextEditorTypes'; import { getImageBounds, pageDimensions } from '@app/tools/pdfTextEditor/pdfTextEditorUtils'; import FontStatusPanel from '@app/components/tools/pdfTextEditor/FontStatusPanel'; const MAX_RENDER_WIDTH = 820; const MIN_BOX_SIZE = 18; const normalizeFontFormat = (format?: string | null): string => { if (!format) { return 'ttf'; } const lower = format.toLowerCase(); if (lower.includes('woff2')) { return 'woff2'; } if (lower.includes('woff')) { return 'woff'; } if (lower.includes('otf')) { return 'otf'; } if (lower.includes('cff')) { return 'otf'; } return 'ttf'; }; const getFontMimeType = (format: string): string => { switch (format) { case 'woff2': return 'font/woff2'; case 'woff': return 'font/woff'; case 'otf': return 'font/otf'; default: return 'font/ttf'; } }; const getFontFormatHint = (format: string): string | null => { switch (format) { case 'woff2': return 'woff2'; case 'woff': return 'woff'; case 'otf': return 'opentype'; case 'ttf': return 'truetype'; default: return null; } }; const decodeBase64ToUint8Array = (value: string): Uint8Array => { const binary = window.atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; }; const buildFontFamilyName = (font: PdfJsonFont): string => { const preferred = (font.baseName ?? '').trim(); const identifier = preferred.length > 0 ? preferred : (font.uid ?? font.id ?? 'font').toString(); return `pdf-font-${identifier.replace(/[^a-zA-Z0-9_-]/g, '')}`; }; const getCaretOffset = (element: HTMLElement): number => { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || !element.contains(selection.focusNode)) { return element.innerText.length; } const range = selection.getRangeAt(0).cloneRange(); range.selectNodeContents(element); range.setEnd(selection.focusNode as Node, selection.focusOffset); return range.toString().length; }; const setCaretOffset = (element: HTMLElement, offset: number): void => { const selection = window.getSelection(); if (!selection) { return; } const targetOffset = Math.max(0, Math.min(offset, element.innerText.length)); const range = document.createRange(); let remaining = targetOffset; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { const textNode = node as Text; const length = textNode.length; if (remaining <= length) { range.setStart(textNode, remaining); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return; } remaining -= length; node = walker.nextNode(); } range.selectNodeContents(element); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); }; const extractTextWithSoftBreaks = (element: HTMLElement): { text: string; insertedBreaks: boolean } => { const normalized = element.innerText.replace(/\u00A0/g, ' '); if (!element.isConnected) { return { text: normalized, insertedBreaks: false }; } const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); const range = document.createRange(); let result = ''; let previousTop: number | null = null; let insertedBreaks = false; while (walker.nextNode()) { const node = walker.currentNode as Text; const nodeText = node.textContent ?? ''; for (let index = 0; index < nodeText.length; index += 1) { const char = nodeText[index]; range.setStart(node, index); range.setEnd(node, index + 1); const rect = range.getClientRects()[0]; if (previousTop !== null && rect && Math.abs(rect.top - previousTop) > 0.5 && result[result.length - 1] !== '\n') { result += '\n'; insertedBreaks = true; } result += char; if (rect) { previousTop = rect.top; } if (char === '\n') { previousTop = null; } } } return { text: result.replace(/\u00A0/g, ' '), insertedBreaks, }; }; interface PdfTextEditorViewProps { data: PdfTextEditorViewData; } const toCssBounds = ( _page: PdfJsonPage | null | undefined, pageHeight: number, scale: number, bounds: { left: number; right: number; top: number; bottom: number }, ) => { const width = Math.max(bounds.right - bounds.left, 1); // Note: This codebase uses inverted naming where bounds.bottom > bounds.top // bounds.bottom = visually upper edge (larger Y in PDF coords) // bounds.top = visually lower edge (smaller Y in PDF coords) const height = Math.max(bounds.bottom - bounds.top, 1); const scaledWidth = Math.max(width * scale, MIN_BOX_SIZE); const scaledHeight = Math.max(height * scale, MIN_BOX_SIZE / 2); // Convert PDF's visually upper edge (bounds.bottom) to CSS top const top = Math.max(pageHeight - bounds.bottom, 0) * scale; return { left: bounds.left * scale, top, width: scaledWidth, height: scaledHeight, }; }; const normalizePageNumber = (pageIndex: number | null | undefined): number | null => { if (pageIndex === null || pageIndex === undefined || Number.isNaN(pageIndex)) { return null; } return pageIndex + 1; }; const buildFontLookupKeys = ( fontId: string, font: PdfJsonFont | null | undefined, pageIndex: number | null | undefined, ): string[] => { const keys: string[] = []; const pageNumber = normalizePageNumber(pageIndex); if (pageNumber !== null) { keys.push(`${pageNumber}:${fontId}`); } if (font?.uid) { keys.push(font.uid); } if (font?.pageNumber !== null && font?.pageNumber !== undefined && font?.id) { keys.push(`${font.pageNumber}:${font.id}`); } keys.push(fontId); return Array.from(new Set(keys.filter((value) => value && value.length > 0))); }; /** * Analyzes text groups on a page to determine if it's paragraph-heavy or sparse. * Returns true if the page appears to be document-like with substantial text content. */ const analyzePageContentType = (groups: TextGroup[]): boolean => { if (groups.length === 0) return false; let multiLineGroups = 0; let totalWords = 0; let longTextGroups = 0; let totalGroups = 0; const groupDetails: Array<{ id: string; lines: number; words: number; chars: number; text: string; }> = []; groups.forEach((group) => { const text = (group.text || '').trim(); if (text.length === 0) return; totalGroups++; const lines = text.split('\n'); const lineCount = lines.length; const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length; totalWords += wordCount; // Count multi-line paragraphs if (lineCount > 1) { multiLineGroups++; } // Count text groups with substantial content (more than a few words) if (wordCount >= 5 || text.length >= 30) { longTextGroups++; } groupDetails.push({ id: group.id, lines: lineCount, words: wordCount, chars: text.length, text: text.substring(0, 50) + (text.length > 50 ? '...' : ''), }); }); if (totalGroups === 0) return false; // Heuristics for paragraph mode: // 1. Has multiple substantial multi-line groups (2+) AND decent average words // 2. Average words per group > 12 (strong indicator of document text) // 3. More than 40% of groups have substantial text (typical of documents) const avgWordsPerGroup = totalWords / totalGroups; const longTextRatio = longTextGroups / totalGroups; const isParagraphPage = (multiLineGroups >= 2 && avgWordsPerGroup > 8) || avgWordsPerGroup > 12 || longTextRatio > 0.4; return isParagraphPage; }; type GroupingMode = 'auto' | 'paragraph' | 'singleLine'; const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { const { t } = useTranslation(); const [activeGroupId, setActiveGroupId] = useState(null); const [editingGroupId, setEditingGroupId] = useState(null); const [activeImageId, setActiveImageId] = useState(null); const draggingImageRef = useRef(null); const rndRefs = useRef>(new Map()); const pendingDragUpdateRef = useRef(null); const [fontFamilies, setFontFamilies] = useState>(new Map()); const [autoScaleText, setAutoScaleText] = useState(true); const [textScales, setTextScales] = useState>(new Map()); const [pendingModeChange, setPendingModeChange] = useState(null); const measurementKeyRef = useRef(''); const containerRef = useRef(null); const editorRefs = useRef>(new Map()); const caretOffsetsRef = useRef>(new Map()); const { document: pdfDocument, groupsByPage, imagesByPage, pagePreviews, selectedPage, dirtyPages, hasDocument, hasVectorPreview, fileName, errorMessage, isGeneratingPdf, isConverting, conversionProgress, hasChanges, forceSingleTextElement, groupingMode: externalGroupingMode, requestPagePreview, onLoadJson, onSelectPage, onGroupEdit, onGroupDelete, onImageTransform, onImageReset, onReset, onDownloadJson, onGeneratePdf, onForceSingleTextElementChange, onGroupingModeChange, } = data; const handleModeChangeRequest = useCallback((newMode: GroupingMode) => { if (hasChanges && newMode !== externalGroupingMode) { // Show confirmation dialog setPendingModeChange(newMode); } else { // No changes, switch immediately onGroupingModeChange(newMode); } }, [hasChanges, externalGroupingMode, onGroupingModeChange]); const handleConfirmModeChange = useCallback(() => { if (pendingModeChange) { onGroupingModeChange(pendingModeChange); setPendingModeChange(null); } }, [pendingModeChange, onGroupingModeChange]); const handleCancelModeChange = useCallback(() => { setPendingModeChange(null); }, []); const resolveFont = (fontId: string | null | undefined, pageIndex: number | null | undefined): PdfJsonFont | null => { if (!fontId || !pdfDocument?.fonts) { return null; } const fonts = pdfDocument.fonts; const pageNumber = normalizePageNumber(pageIndex); if (pageNumber !== null) { const pageMatch = fonts.find((font) => font?.id === fontId && font?.pageNumber === pageNumber); if (pageMatch) { return pageMatch; } const uidKey = `${pageNumber}:${fontId}`; const uidMatch = fonts.find((font) => font?.uid === uidKey); if (uidMatch) { return uidMatch; } } const directUid = fonts.find((font) => font?.uid === fontId); if (directUid) { return directUid; } return fonts.find((font) => font?.id === fontId) ?? null; }; const getFontFamily = (fontId: string | null | undefined, pageIndex: number | null | undefined): string => { if (!fontId) { return 'sans-serif'; } const font = resolveFont(fontId, pageIndex); const lookupKeys = buildFontLookupKeys(fontId, font ?? undefined, pageIndex); for (const key of lookupKeys) { const loadedFamily = fontFamilies.get(key); if (loadedFamily) { return `'${loadedFamily}', sans-serif`; } } const fontName = font?.standard14Name || font?.baseName || ''; const lowerName = fontName.toLowerCase(); if (lowerName.includes('times')) { return '"Times New Roman", Times, serif'; } if (lowerName.includes('helvetica') || lowerName.includes('arial')) { return 'Arial, Helvetica, sans-serif'; } if (lowerName.includes('courier')) { return '"Courier New", Courier, monospace'; } if (lowerName.includes('symbol')) { return 'Symbol, serif'; } return 'Arial, Helvetica, sans-serif'; }; const getFontMetricsFor = ( fontId: string | null | undefined, pageIndex: number | null | undefined, ): { unitsPerEm: number; ascent: number; descent: number } | undefined => { if (!fontId) { return undefined; } const font = resolveFont(fontId, pageIndex); const lookupKeys = buildFontLookupKeys(fontId, font ?? undefined, pageIndex); for (const key of lookupKeys) { const metrics = fontMetrics.get(key); if (metrics) { return metrics; } } return undefined; }; const getLineHeightPx = ( fontId: string | null | undefined, pageIndex: number | null | undefined, fontSizePx: number, ): number => { if (fontSizePx <= 0) { return fontSizePx; } const metrics = getFontMetricsFor(fontId, pageIndex); if (!metrics || metrics.unitsPerEm <= 0) { return fontSizePx * 1.2; } const unitsPerEm = metrics.unitsPerEm > 0 ? metrics.unitsPerEm : 1000; const ascentUnits = metrics.ascent ?? unitsPerEm; const descentUnits = Math.abs(metrics.descent ?? -(unitsPerEm * 0.2)); const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); if (totalUnits <= 0) { return fontSizePx * 1.2; } const lineHeight = (totalUnits / unitsPerEm) * fontSizePx; return Math.max(lineHeight, fontSizePx * 1.05); }; const getFontGeometry = ( fontId: string | null | undefined, pageIndex: number | null | undefined, ): { unitsPerEm: number; ascentUnits: number; descentUnits: number; totalUnits: number; ascentRatio: number; descentRatio: number; } | undefined => { const metrics = getFontMetricsFor(fontId, pageIndex); if (!metrics) { return undefined; } const unitsPerEm = metrics.unitsPerEm > 0 ? metrics.unitsPerEm : 1000; const rawAscent = metrics.ascent ?? unitsPerEm; const rawDescent = metrics.descent ?? -(unitsPerEm * 0.2); const ascentUnits = Number.isFinite(rawAscent) ? rawAscent : unitsPerEm; const descentUnits = Number.isFinite(rawDescent) ? Math.abs(rawDescent) : unitsPerEm * 0.2; const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); if (totalUnits <= 0 || !Number.isFinite(totalUnits)) { return undefined; } return { unitsPerEm, ascentUnits, descentUnits, totalUnits, ascentRatio: ascentUnits / totalUnits, descentRatio: descentUnits / totalUnits, }; }; const getFontWeight = ( fontId: string | null | undefined, pageIndex: number | null | undefined, ): number | 'normal' | 'bold' => { if (!fontId) { return 'normal'; } const font = resolveFont(fontId, pageIndex); if (!font || !font.fontDescriptorFlags) { return 'normal'; } // PDF font descriptor flag bit 18 (value 262144 = 0x40000) indicates ForceBold const FORCE_BOLD_FLAG = 262144; if ((font.fontDescriptorFlags & FORCE_BOLD_FLAG) !== 0) { return 'bold'; } // Also check if font name contains "Bold" const fontName = font.standard14Name || font.baseName || ''; if (fontName.toLowerCase().includes('bold')) { return 'bold'; } return 'normal'; }; const pages = pdfDocument?.pages ?? []; const currentPage = pages[selectedPage] ?? null; const pageGroups = groupsByPage[selectedPage] ?? []; const pageImages = imagesByPage[selectedPage] ?? []; const pagePreview = pagePreviews.get(selectedPage); // Detect if current page contains paragraph-heavy content const isParagraphPage = useMemo(() => analyzePageContentType(pageGroups), [pageGroups]); const isParagraphLayout = externalGroupingMode === 'paragraph' || (externalGroupingMode === 'auto' && isParagraphPage); const paragraphWhiteSpace = isParagraphLayout ? 'pre-wrap' : 'pre'; const paragraphWordBreak = isParagraphLayout ? 'break-word' : 'normal'; const paragraphOverflowWrap = isParagraphLayout ? 'break-word' : 'normal'; const syncEditorValue = useCallback( ( element: HTMLElement, pageIndex: number, groupId: string, options?: { skipCaretRestore?: boolean }, ) => { const { text: value } = extractTextWithSoftBreaks(element); const offset = getCaretOffset(element); caretOffsetsRef.current.set(groupId, offset); onGroupEdit(pageIndex, groupId, value); if (options?.skipCaretRestore) { return; } requestAnimationFrame(() => { if (editingGroupId !== groupId) { return; } const editor = editorRefs.current.get(groupId); if (editor) { const savedOffset = caretOffsetsRef.current.get(groupId) ?? editor.innerText.length; setCaretOffset(editor, savedOffset); } }); }, [editingGroupId, onGroupEdit], ); const extractPreferredFontId = useCallback((target?: TextGroup | null) => { if (!target) { return undefined; } if (target.fontId) { return target.fontId; } for (const element of target.originalElements ?? []) { if (element.fontId) { return element.fontId; } } for (const element of target.elements ?? []) { if (element.fontId) { return element.fontId; } } return undefined; }, []); const resolveFontIdForIndex = useCallback( (index: number): string | null | undefined => { if (index < 0 || index >= pageGroups.length) { return undefined; } const direct = extractPreferredFontId(pageGroups[index]); if (direct) { return direct; } for (let offset = 1; offset < pageGroups.length; offset += 1) { const prevIndex = index - offset; if (prevIndex >= 0) { const candidate = extractPreferredFontId(pageGroups[prevIndex]); if (candidate) { return candidate; } } const nextIndex = index + offset; if (nextIndex < pageGroups.length) { const candidate = extractPreferredFontId(pageGroups[nextIndex]); if (candidate) { return candidate; } } } return undefined; }, [extractPreferredFontId, pageGroups], ); const fontMetrics = useMemo(() => { const metrics = new Map(); pdfDocument?.fonts?.forEach((font) => { if (!font?.id) { return; } const unitsPerEm = font.unitsPerEm && font.unitsPerEm > 0 ? font.unitsPerEm : 1000; const ascent = font.ascent ?? unitsPerEm; const descent = font.descent ?? -(unitsPerEm * 0.2); const metric = { unitsPerEm, ascent, descent }; metrics.set(font.id, metric); if (font.uid) { metrics.set(font.uid, metric); } if (font.pageNumber !== null && font.pageNumber !== undefined) { metrics.set(`${font.pageNumber}:${font.id}`, metric); } }); return metrics; }, [pdfDocument?.fonts]); useEffect(() => { if (typeof FontFace === 'undefined') { setFontFamilies(new Map()); return undefined; } let disposed = false; const active: { fontFace: FontFace; url?: string }[] = []; const registerFonts = async () => { const fonts = pdfDocument?.fonts ?? []; if (fonts.length === 0) { setFontFamilies(new Map()); return; } const next = new Map(); const pickFontSource = ( font: PdfJsonFont ): { data: string; format?: string | null; source: 'pdfProgram' | 'webProgram' | 'program' } | null => { if (font.pdfProgram && font.pdfProgram.length > 0) { return { data: font.pdfProgram, format: font.pdfProgramFormat, source: 'pdfProgram' }; } if (font.webProgram && font.webProgram.length > 0) { return { data: font.webProgram, format: font.webProgramFormat, source: 'webProgram' }; } if (font.program && font.program.length > 0) { return { data: font.program, format: font.programFormat, source: 'program' }; } return null; }; const registerLoadedFontKeys = (font: PdfJsonFont, familyName: string) => { if (font.id) { next.set(font.id, familyName); } if (font.uid) { next.set(font.uid, familyName); } if (font.pageNumber !== null && font.pageNumber !== undefined && font.id) { next.set(`${font.pageNumber}:${font.id}`, familyName); } }; for (const font of fonts) { if (!font || !font.id) { continue; } const selection = pickFontSource(font); if (!selection) { continue; } try { const formatSource = selection.format; const format = normalizeFontFormat(formatSource); const data = decodeBase64ToUint8Array(selection.data); const blob = new Blob([data as BlobPart], { type: getFontMimeType(format) }); const url = URL.createObjectURL(blob); const formatHint = getFontFormatHint(format); const familyName = buildFontFamilyName(font); const source = formatHint ? `url(${url}) format('${formatHint}')` : `url(${url})`; const fontFace = new FontFace(familyName, source); console.debug(`[FontLoader] Loading font ${font.id} (${font.baseName}) using ${selection.source}:`, { formatSource, format, formatHint, familyName, dataLength: data.length, hasPdfProgram: !!font.pdfProgram, hasWebProgram: !!font.webProgram, hasProgram: !!font.program }); await fontFace.load(); if (disposed) { document.fonts.delete(fontFace); URL.revokeObjectURL(url); continue; } document.fonts.add(fontFace); active.push({ fontFace, url }); registerLoadedFontKeys(font, familyName); console.debug(`[FontLoader] Successfully loaded font ${font.id}`); } catch (error) { console.warn(`[FontLoader] Failed to load font ${font.id} (${font.baseName}) using ${selection.source}:`, { error: error instanceof Error ? error.message : String(error), formatSource: selection.format, hasPdfProgram: !!font.pdfProgram, hasWebProgram: !!font.webProgram, hasProgram: !!font.program }); // Fallback to web-safe fonts is already implemented via getFontFamily() } } if (!disposed) { setFontFamilies(next); } else { active.forEach(({ fontFace, url }) => { document.fonts.delete(fontFace); if (url) { URL.revokeObjectURL(url); } }); } }; registerFonts(); return () => { disposed = true; active.forEach(({ fontFace, url }) => { document.fonts.delete(fontFace); if (url) { URL.revokeObjectURL(url); } }); }; }, [pdfDocument?.fonts]); const visibleGroups = useMemo( () => pageGroups .map((group, index) => ({ group, pageGroupIndex: index })) .filter(({ group }) => { const hasContent = ((group.text ?? '').trim().length > 0) || ((group.originalText ?? '').trim().length > 0); return hasContent || editingGroupId === group.id; }), [editingGroupId, pageGroups], ); const orderedImages = useMemo( () => [...pageImages].sort( (first, second) => (first?.zOrder ?? -1_000_000) - (second?.zOrder ?? -1_000_000), ), [pageImages], ); const { width: pageWidth, height: pageHeight } = pageDimensions(currentPage); const scale = useMemo(() => Math.min(MAX_RENDER_WIDTH / pageWidth, 2.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); setActiveImageId(null); setTextScales(new Map()); measurementKeyRef.current = ''; }, [selectedPage]); // Measure text widths once per page/configuration and apply static scaling useLayoutEffect(() => { if (!autoScaleText) { // Clear all scales when auto-scale is disabled setTextScales(new Map()); measurementKeyRef.current = ''; return; } if (visibleGroups.length === 0) { return; } // Create a stable key for this measurement configuration const currentKey = `${selectedPage}-${fontFamilies.size}-${autoScaleText}`; // Skip if we've already measured for this configuration if (measurementKeyRef.current === currentKey) { return; } const measureTextScales = () => { const newScales = new Map(); visibleGroups.forEach(({ group }) => { // Skip groups that are being edited if (editingGroupId === group.id) { return; } // Skip multi-line paragraphs - auto-scaling doesn't work well with wrapped text const lineCount = (group.text || '').split('\n').length; if (lineCount > 1) { newScales.set(group.id, 1); return; } const element = document.querySelector(`[data-text-group="${group.id}"]`); if (!element) { return; } const textSpan = element.querySelector('span[data-text-content]'); if (!textSpan) { return; } // Temporarily remove any existing transform to get natural width const originalTransform = textSpan.style.transform; textSpan.style.transform = 'none'; const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds); const containerWidth = bounds.width; const textWidth = textSpan.getBoundingClientRect().width; // Restore original transform textSpan.style.transform = originalTransform; // Only scale if text overflows by more than 2% if (textWidth > 0 && textWidth > containerWidth * 1.02) { const scaleX = Math.max(containerWidth / textWidth, 0.5); // Min 50% scale newScales.set(group.id, scaleX); } else { newScales.set(group.id, 1); } }); // Mark this configuration as measured measurementKeyRef.current = currentKey; setTextScales(newScales); }; // Delay measurement to ensure fonts and layout are ready const timer = setTimeout(measureTextScales, 150); return () => clearTimeout(timer); }, [ autoScaleText, visibleGroups, editingGroupId, currentPage, pageHeight, scale, fontFamilies.size, selectedPage, ]); useLayoutEffect(() => { // Only restore caret position during re-renders while already editing // Don't interfere with initial click-to-position behavior if (!editingGroupId) { return; } const editor = editorRefs.current.get(editingGroupId); if (!editor) { return; } const offset = caretOffsetsRef.current.get(editingGroupId); // Only restore if we have a saved offset (meaning user was already typing) if (offset === undefined || offset === 0) { return; } setCaretOffset(editor, offset); }, [editingGroupId, groupsByPage, imagesByPage]); useEffect(() => { if (!editingGroupId) { return; } const editor = document.querySelector(`[data-editor-group="${editingGroupId}"]`); if (editor) { if (document.activeElement !== editor) { editor.focus(); } } }, [editingGroupId]); // Sync image positions when not dragging (handles stutters/re-renders) useLayoutEffect(() => { const isDragging = draggingImageRef.current !== null; if (isDragging) { return; // Don't sync during drag } pageImages.forEach((image) => { if (!image?.id) return; const imageId = image.id; const rndRef = rndRefs.current.get(imageId); if (!rndRef || !rndRef.updatePosition) return; const bounds = getImageBounds(image); const width = Math.max(bounds.right - bounds.left, 1); const height = Math.max(bounds.top - bounds.bottom, 1); const cssLeft = bounds.left * scale; const cssTop = (pageHeight - bounds.top) * scale; // Get current position from Rnd component const currentState = rndRef.state || {}; const currentX = currentState.x ?? 0; const currentY = currentState.y ?? 0; // Calculate drift const drift = Math.abs(currentX - cssLeft) + Math.abs(currentY - cssTop); // Only sync if drift is significant (more than 3px) if (drift > 3) { rndRef.updatePosition({ x: cssLeft, y: cssTop }); } }); }, [pageImages, scale, pageHeight]); const handlePageChange = (pageNumber: number) => { setActiveGroupId(null); setEditingGroupId(null); onSelectPage(pageNumber - 1); }; const handleBackgroundClick = () => { setEditingGroupId(null); setActiveGroupId(null); setActiveImageId(null); }; const renderGroupContainer = ( groupId: string, pageIndex: number, isActive: boolean, isChanged: boolean, content: React.ReactNode, onActivate?: (event: React.MouseEvent) => void, onClick?: (event: React.MouseEvent) => void, ) => ( { event.stopPropagation(); if (onClick) { onClick(event); } else { onActivate?.(event); } }} > {content} {activeGroupId === groupId && ( { event.stopPropagation(); onGroupDelete(pageIndex, groupId); setActiveGroupId(null); setEditingGroupId(null); }} > )} ); const emitImageTransform = useCallback( ( imageId: string, leftPx: number, topPx: number, widthPx: number, heightPx: number, ) => { const rawLeft = leftPx / scale; const rawTop = pageHeight - topPx / scale; const width = Math.max(widthPx / scale, 0.01); const height = Math.max(heightPx / scale, 0.01); const maxLeft = Math.max(pageWidth - width, 0); const left = Math.min(Math.max(rawLeft, 0), maxLeft); const minTop = Math.min(height, pageHeight); const top = Math.min(Math.max(rawTop, minTop), pageHeight); const bottom = Math.max(top - height, 0); onImageTransform(selectedPage, imageId, { left, bottom, width, height, transform: [] }); }, [onImageTransform, pageHeight, pageWidth, scale, selectedPage], ); return ( {t('pdfTextEditor.title', 'PDF JSON Editor')} {hasChanges && {t('pdfTextEditor.badges.unsaved', 'Edited')}} {(props) => ( )} {fileName && ( {t('pdfTextEditor.currentFile', 'Current file: {{name}}', { name: fileName })} )}
{t('pdfTextEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')} {t( 'pdfTextEditor.options.autoScaleText.description', 'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.' )}
setAutoScaleText(event.currentTarget.checked)} />
{t('pdfTextEditor.options.groupingMode.title', 'Text Grouping Mode')} {externalGroupingMode === 'auto' && isParagraphPage && ( {t('pdfTextEditor.pageType.paragraph', 'Paragraph page')} )} {externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && ( {t('pdfTextEditor.pageType.sparse', 'Sparse text')} )} {externalGroupingMode === 'auto' ? t( 'pdfTextEditor.options.groupingMode.autoDescription', 'Automatically detects page type and groups text appropriately.' ) : externalGroupingMode === 'paragraph' ? t( 'pdfTextEditor.options.groupingMode.paragraphDescription', 'Groups aligned lines into multi-line paragraph text boxes.' ) : t( 'pdfTextEditor.options.groupingMode.singleLineDescription', 'Keeps each PDF text line as a separate text box.' )} handleModeChangeRequest(value as GroupingMode)} data={[ { label: t('pdfTextEditor.groupingMode.auto', 'Auto'), value: 'auto' }, { label: t('pdfTextEditor.groupingMode.paragraph', 'Paragraph'), value: 'paragraph' }, { label: t('pdfTextEditor.groupingMode.singleLine', 'Single Line'), value: 'singleLine' }, ]} fullWidth />
{t('pdfTextEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')} {t( 'pdfTextEditor.options.forceSingleElement.description', 'When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts.' )}
onForceSingleTextElementChange(event.currentTarget.checked)} />
{t('pdfTextEditor.disclaimer.heading', 'Preview Limitations')} {t( 'pdfTextEditor.disclaimer.textFocus', 'This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here.' )} {t( 'pdfTextEditor.disclaimer.previewVariance', 'Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible.' )} {t( 'pdfTextEditor.disclaimer.alpha', 'This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing.' )} {hasDocument && }
{errorMessage && ( } color="red" radius="md" style={{ gridColumn: '2 / 3' }} > {errorMessage} )} {!hasDocument && !isConverting && ( {t('pdfTextEditor.empty.title', 'No document loaded')} {t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')} )} {isConverting && (
{conversionProgress ? conversionProgress.message : t('pdfTextEditor.converting', 'Converting PDF to editable format...')} {conversionProgress && ( {t(`pdfTextEditor.stages.${conversionProgress.stage}`, conversionProgress.stage)} {conversionProgress.current !== undefined && conversionProgress.total !== undefined && ( • Page {conversionProgress.current} of {conversionProgress.total} )} )}
)} {hasDocument && ( {t('pdfTextEditor.pageSummary', 'Page {{number}} of {{total}}', { number: selectedPage + 1, total: pages.length, })} {dirtyPages[selectedPage] && ( {t('pdfTextEditor.badges.modified', 'Edited')} )} {t('pdfTextEditor.badges.earlyAccess', 'Early Access')} {pages.length > 1 && ( )} {pagePreview && ( {t('pdfTextEditor.pagePreviewAlt', )} {orderedImages.map((image, imageIndex) => { if (!image?.imageData) { return null; } const bounds = getImageBounds(image); const width = Math.max(bounds.right - bounds.left, 1); const height = Math.max(bounds.top - bounds.bottom, 1); const cssWidth = Math.max(width * scale, 2); const cssHeight = Math.max(height * scale, 2); const cssLeft = bounds.left * scale; const cssTop = (pageHeight - bounds.top) * scale; const imageId = image.id ?? `page-${selectedPage}-image-${imageIndex}`; const isActive = activeImageId === imageId; const src = `data:image/${image.imageFormat ?? 'png'};base64,${image.imageData}`; const baseZIndex = (image.zOrder ?? -1_000_000) + 1_050_000; const zIndex = isActive ? baseZIndex + 1_000_000 : baseZIndex; return ( { if (ref) { rndRefs.current.set(imageId, ref); } else { rndRefs.current.delete(imageId); } }} key={`image-${imageId}`} bounds="parent" size={{ width: cssWidth, height: cssHeight }} position={{ x: cssLeft, y: cssTop }} onDragStart={(_event, data) => { setActiveGroupId(null); setEditingGroupId(null); setActiveImageId(imageId); draggingImageRef.current = imageId; }} onDrag={(_event, data) => { // Cancel any pending update if (pendingDragUpdateRef.current) { cancelAnimationFrame(pendingDragUpdateRef.current); } // Schedule update on next frame to batch rapid drag events pendingDragUpdateRef.current = requestAnimationFrame(() => { const rndRef = rndRefs.current.get(imageId); if (rndRef && rndRef.updatePosition) { rndRef.updatePosition({ x: data.x, y: data.y }); } }); }} onDragStop={(_event, data) => { if (pendingDragUpdateRef.current) { cancelAnimationFrame(pendingDragUpdateRef.current); pendingDragUpdateRef.current = null; } draggingImageRef.current = null; emitImageTransform( imageId, data.x, data.y, cssWidth, cssHeight, ); }} onResizeStart={() => { setActiveImageId(imageId); setActiveGroupId(null); setEditingGroupId(null); draggingImageRef.current = imageId; }} onResizeStop={(_event, _direction, ref, _delta, position) => { draggingImageRef.current = null; const nextWidth = parseFloat(ref.style.width); const nextHeight = parseFloat(ref.style.height); emitImageTransform( imageId, position.x, position.y, nextWidth, nextHeight, ); }} style={{ zIndex }} > setActiveImageId(imageId)} onMouseLeave={() => { setActiveImageId((current) => (current === imageId ? null : current)); }} onDoubleClick={(event) => { event.stopPropagation(); onImageReset(selectedPage, imageId); }} style={{ width: '100%', height: '100%', cursor: isActive ? 'grabbing' : 'grab', outline: isActive ? '2px solid rgba(59, 130, 246, 0.9)' : '1px solid rgba(148, 163, 184, 0.4)', outlineOffset: '-1px', borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.04)', transition: 'outline 120ms ease', }} > {t('pdfTextEditor.imageLabel', ); })} {visibleGroups.length === 0 && orderedImages.length === 0 ? ( {t('pdfTextEditor.noTextOnPage', 'No editable text was detected on this page.')} ) : ( visibleGroups.map(({ group, pageGroupIndex }) => { const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds); const changed = group.text !== group.originalText; const isActive = activeGroupId === group.id || editingGroupId === group.id; const isEditing = editingGroupId === group.id; const baseFontSize = group.fontMatrixSize ?? group.fontSize ?? 12; const fontSizePx = Math.max(baseFontSize * scale, 6); const effectiveFontId = resolveFontIdForIndex(pageGroupIndex) ?? group.fontId; const fontFamily = getFontFamily(effectiveFontId, group.pageIndex); let lineHeightPx = getLineHeightPx(effectiveFontId, group.pageIndex, fontSizePx); let lineHeightRatio = fontSizePx > 0 ? Math.max(lineHeightPx / fontSizePx, 1.05) : 1.2; const rotation = group.rotation ?? 0; const hasRotation = Math.abs(rotation) > 0.5; const baselineLength = group.baselineLength ?? Math.max(group.bounds.right - group.bounds.left, 0); const geometry = getFontGeometry(effectiveFontId, group.pageIndex); const ascentPx = geometry ? Math.max(fontSizePx * geometry.ascentRatio, fontSizePx * 0.7) : fontSizePx * 0.82; const descentPx = geometry ? Math.max(fontSizePx * geometry.descentRatio, fontSizePx * 0.2) : fontSizePx * 0.22; lineHeightPx = Math.max(lineHeightPx, ascentPx + descentPx); if (fontSizePx > 0) { lineHeightRatio = Math.max(lineHeightRatio, lineHeightPx / fontSizePx); } const detectedSpacingPx = group.lineSpacing && group.lineSpacing > 0 ? group.lineSpacing * scale : undefined; if (detectedSpacingPx && detectedSpacingPx > 0) { lineHeightPx = Math.max(lineHeightPx, detectedSpacingPx); if (fontSizePx > 0) { lineHeightRatio = Math.max(lineHeightRatio, detectedSpacingPx / fontSizePx); } } const lineCount = Math.max(group.text.split('\n').length, 1); const paragraphHeightPx = lineCount > 1 ? lineHeightPx + (lineCount - 1) * (detectedSpacingPx ?? lineHeightPx) : lineHeightPx; let containerLeft = bounds.left; let containerTop = bounds.top; let containerWidth = Math.max(bounds.width, fontSizePx); let containerHeight = Math.max(bounds.height, paragraphHeightPx); let transform: string | undefined; let transformOrigin: React.CSSProperties['transformOrigin']; if (hasRotation) { const anchorX = group.anchor?.x ?? group.bounds.left; const anchorY = group.anchor?.y ?? group.bounds.bottom; containerLeft = anchorX * scale; const anchorTop = Math.max(pageHeight - anchorY, 0) * scale; containerWidth = Math.max(baselineLength * scale, MIN_BOX_SIZE); containerHeight = Math.max(lineHeightPx, fontSizePx * lineHeightRatio); transformOrigin = 'left bottom'; // Negate rotation because Y-axis is flipped from PDF to web coordinates transform = `rotate(${-rotation}deg)`; // Align the baseline (PDF anchor) with the bottom edge used as the // transform origin. Without this adjustment rotated text appears shifted // downward by roughly one line height. containerTop = anchorTop - containerHeight; } if ( lineCount === 1 && !hasRotation && group.baseline !== null && group.baseline !== undefined && geometry ) { const cssBaselineTop = (pageHeight - group.baseline) * scale; containerTop = Math.max(cssBaselineTop - ascentPx, 0); containerHeight = Math.max(containerHeight, ascentPx + descentPx); } // Extract styling from group const textColor = group.color || '#111827'; const fontWeight = group.fontWeight || getFontWeight(effectiveFontId, group.pageIndex); const containerStyle: React.CSSProperties = { position: 'absolute', left: `${containerLeft}px`, top: `${containerTop}px`, width: `${containerWidth}px`, height: isEditing ? 'auto' : `${containerHeight}px`, minHeight: `${containerHeight}px`, display: 'flex', alignItems: 'flex-start', justifyContent: 'flex-start', pointerEvents: 'auto', cursor: 'text', zIndex: 2_000_000, transform, transformOrigin, }; if (isEditing) { return ( {renderGroupContainer( group.id, group.pageIndex, true, changed,
{ if (node) { editorRefs.current.set(group.id, node); } else { editorRefs.current.delete(group.id); } }} contentEditable suppressContentEditableWarning data-editor-group={group.id} onFocus={(event) => { const primaryFont = fontFamily.split(',')[0]?.replace(/['"]/g, '').trim(); if (primaryFont && typeof document !== 'undefined') { try { if (document.queryCommandSupported?.('styleWithCSS')) { document.execCommand('styleWithCSS', false, true); } if (document.queryCommandSupported?.('fontName')) { document.execCommand('fontName', false, primaryFont); } } catch { // ignore execCommand failures; inline style already enforces font } } event.currentTarget.style.fontFamily = fontFamily; }} onClick={(event) => { // Allow click position to determine cursor placement event.stopPropagation(); }} onBlur={(event) => { syncEditorValue(event.currentTarget, group.pageIndex, group.id, { skipCaretRestore: true, }); caretOffsetsRef.current.delete(group.id); editorRefs.current.delete(group.id); setActiveGroupId(null); setEditingGroupId(null); }} onInput={(event) => { syncEditorValue(event.currentTarget, group.pageIndex, group.id); }} style={{ width: '100%', minHeight: '100%', height: 'auto', padding: 0, backgroundColor: 'rgba(255,255,255,0.95)', color: textColor, fontSize: `${fontSizePx}px`, fontFamily, fontWeight, lineHeight: lineHeightRatio, outline: 'none', border: 'none', display: 'block', whiteSpace: paragraphWhiteSpace, wordBreak: paragraphWordBreak, overflowWrap: paragraphOverflowWrap, cursor: 'text', overflow: 'visible', }} > {group.text || '\u00A0'}
, )}
); } const textScale = textScales.get(group.id) ?? 1; const shouldScale = autoScaleText && textScale < 0.98; return ( {renderGroupContainer( group.id, group.pageIndex, isActive, changed,
{group.text || '\u00A0'}
, undefined, (event: React.MouseEvent) => { const clickX = event.clientX; const clickY = event.clientY; setActiveGroupId(group.id); setEditingGroupId(group.id); caretOffsetsRef.current.delete(group.id); requestAnimationFrame(() => { const editor = document.querySelector(`[data-editor-group="${group.id}"]`); if (!editor) return; editor.focus(); setTimeout(() => { if (document.caretRangeFromPoint) { const range = document.caretRangeFromPoint(clickX, clickY); if (range) { const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(range); } } } else if ((document as any).caretPositionFromPoint) { const pos = (document as any).caretPositionFromPoint(clickX, clickY); if (pos) { const range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); range.collapse(true); const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(range); } } } }, 10); }); }, )}
); }) )}
)} {/* Mode Change Confirmation Modal */} {t( 'pdfTextEditor.modeChange.warning', 'Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?' )}
); }; export default PdfTextEditorView;