1860 lines
68 KiB
TypeScript
1860 lines
68 KiB
TypeScript
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<string | null>(null);
|
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
|
const [activeImageId, setActiveImageId] = useState<string | null>(null);
|
|
const draggingImageRef = useRef<string | null>(null);
|
|
const rndRefs = useRef<Map<string, any>>(new Map());
|
|
const pendingDragUpdateRef = useRef<number | null>(null);
|
|
const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(new Map());
|
|
const [autoScaleText, setAutoScaleText] = useState(true);
|
|
const [textScales, setTextScales] = useState<Map<string, number>>(new Map());
|
|
const [pendingModeChange, setPendingModeChange] = useState<GroupingMode | null>(null);
|
|
const measurementKeyRef = useRef<string>('');
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const editorRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const caretOffsetsRef = useRef<Map<string, number>>(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<string, { unitsPerEm: number; ascent: number; descent: number }>();
|
|
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<string, string>();
|
|
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<string, number>();
|
|
|
|
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<HTMLElement>(`[data-text-group="${group.id}"]`);
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const textSpan = element.querySelector<HTMLSpanElement>('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<HTMLElement>(`[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,
|
|
) => (
|
|
<Box
|
|
component="div"
|
|
style={{
|
|
width: 'calc(100% + 4px)',
|
|
height: 'calc(100% + 6px)',
|
|
marginLeft: '-2px',
|
|
marginTop: '-3px',
|
|
outline: isActive
|
|
? '2px solid var(--mantine-color-blue-5)'
|
|
: isChanged
|
|
? '1px solid var(--mantine-color-yellow-5)'
|
|
: 'none',
|
|
outlineOffset: '-1px',
|
|
borderRadius: 6,
|
|
backgroundColor: isChanged || isActive ? 'rgba(250,255,189,0.28)' : 'transparent',
|
|
transition: 'outline 120ms ease, background-color 120ms ease',
|
|
pointerEvents: 'auto',
|
|
overflow: 'visible',
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
justifyContent: 'flex-start',
|
|
padding: '3px 2px 3px 2px',
|
|
position: 'relative',
|
|
}}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (onClick) {
|
|
onClick(event);
|
|
} else {
|
|
onActivate?.(event);
|
|
}
|
|
}}
|
|
>
|
|
{content}
|
|
{activeGroupId === groupId && (
|
|
<ActionIcon
|
|
size="xs"
|
|
variant="filled"
|
|
color="red"
|
|
radius="xl"
|
|
style={{
|
|
position: 'absolute',
|
|
top: -8,
|
|
right: -8,
|
|
zIndex: 10,
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onGroupDelete(pageIndex, groupId);
|
|
setActiveGroupId(null);
|
|
setEditingGroupId(null);
|
|
}}
|
|
>
|
|
<CloseIcon style={{ fontSize: 12 }} />
|
|
</ActionIcon>
|
|
)}
|
|
</Box>
|
|
);
|
|
|
|
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 (
|
|
<Stack
|
|
gap="xl"
|
|
className="h-full"
|
|
style={{
|
|
padding: '1.5rem',
|
|
overflow: 'hidden',
|
|
height: '100%',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'minmax(0, 1fr) 320px',
|
|
alignItems: 'start',
|
|
gap: '1.5rem',
|
|
}}
|
|
>
|
|
<Card
|
|
withBorder
|
|
radius="md"
|
|
shadow="xs"
|
|
padding="md"
|
|
style={{
|
|
gridColumn: '2 / 3',
|
|
gridRow: 1,
|
|
maxHeight: 'calc(100vh - 3rem)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden'
|
|
}}
|
|
>
|
|
<ScrollArea style={{ flex: 1 }} offsetScrollbars>
|
|
<Stack gap="sm" pb="md">
|
|
<Group justify="space-between" align="center">
|
|
<Group gap="xs" align="center">
|
|
<DescriptionIcon fontSize="small" />
|
|
<Title order={3}>{t('pdfTextEditor.title', 'PDF JSON Editor')}</Title>
|
|
{hasChanges && <Badge color="orange" variant="light" size="sm">{t('pdfTextEditor.badges.unsaved', 'Edited')}</Badge>}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Stack gap="xs">
|
|
<FileButton onChange={onLoadJson} accept="application/pdf,application/json,.pdf,.json">
|
|
{(props) => (
|
|
<Button
|
|
variant="light"
|
|
leftSection={<UploadIcon fontSize="small" />}
|
|
loading={isConverting}
|
|
fullWidth
|
|
{...props}
|
|
>
|
|
{t('pdfTextEditor.actions.load', 'Load File')}
|
|
</Button>
|
|
)}
|
|
</FileButton>
|
|
<Button
|
|
variant="subtle"
|
|
leftSection={<AutorenewIcon fontSize="small" />}
|
|
onClick={onReset}
|
|
disabled={!hasDocument || isConverting}
|
|
fullWidth
|
|
>
|
|
{t('pdfTextEditor.actions.reset', 'Reset Changes')}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<FileDownloadIcon fontSize="small" />}
|
|
onClick={onDownloadJson}
|
|
disabled={!hasDocument || isConverting}
|
|
fullWidth
|
|
>
|
|
{t('pdfTextEditor.actions.downloadJson', 'Download JSON')}
|
|
</Button>
|
|
<Button
|
|
leftSection={<PictureAsPdfIcon fontSize="small" />}
|
|
onClick={onGeneratePdf}
|
|
loading={isGeneratingPdf}
|
|
disabled={!hasDocument || !hasChanges || isConverting}
|
|
fullWidth
|
|
>
|
|
{t('pdfTextEditor.actions.generatePdf', 'Generate PDF')}
|
|
</Button>
|
|
</Stack>
|
|
|
|
{fileName && (
|
|
<Text size="sm" c="dimmed">
|
|
{t('pdfTextEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
|
|
</Text>
|
|
)}
|
|
|
|
<Divider my="xs" />
|
|
|
|
<Group justify="space-between" align="center">
|
|
<div>
|
|
<Text fw={500} size="sm">
|
|
{t('pdfTextEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
{t(
|
|
'pdfTextEditor.options.autoScaleText.description',
|
|
'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.'
|
|
)}
|
|
</Text>
|
|
</div>
|
|
<Switch
|
|
size="md"
|
|
checked={autoScaleText}
|
|
onChange={(event) => setAutoScaleText(event.currentTarget.checked)}
|
|
/>
|
|
</Group>
|
|
|
|
<Stack gap="xs">
|
|
<Group gap={4} align="center">
|
|
<Text fw={500} size="sm">
|
|
{t('pdfTextEditor.options.groupingMode.title', 'Text Grouping Mode')}
|
|
</Text>
|
|
{externalGroupingMode === 'auto' && isParagraphPage && (
|
|
<Badge size="xs" color="blue" variant="light">
|
|
{t('pdfTextEditor.pageType.paragraph', 'Paragraph page')}
|
|
</Badge>
|
|
)}
|
|
{externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && (
|
|
<Badge size="xs" color="gray" variant="light">
|
|
{t('pdfTextEditor.pageType.sparse', 'Sparse text')}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<Text size="xs" c="dimmed">
|
|
{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.'
|
|
)}
|
|
</Text>
|
|
<SegmentedControl
|
|
value={externalGroupingMode}
|
|
onChange={(value) => 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
|
|
/>
|
|
</Stack>
|
|
|
|
<Group justify="space-between" align="center">
|
|
<div>
|
|
<Text fw={500} size="sm">
|
|
{t('pdfTextEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mt={4}>
|
|
{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.'
|
|
)}
|
|
</Text>
|
|
</div>
|
|
<Switch
|
|
size="md"
|
|
checked={forceSingleTextElement}
|
|
onChange={(event) => onForceSingleTextElementChange(event.currentTarget.checked)}
|
|
/>
|
|
</Group>
|
|
|
|
<Divider my="xs" />
|
|
|
|
<Accordion variant="contained">
|
|
<Accordion.Item value="disclaimer">
|
|
<Accordion.Control>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<InfoOutlinedIcon fontSize="small" />
|
|
<Text size="sm" fw={500}>
|
|
{t('pdfTextEditor.disclaimer.heading', 'Preview Limitations')}
|
|
</Text>
|
|
</Group>
|
|
</Accordion.Control>
|
|
<Accordion.Panel>
|
|
<Stack gap={4}>
|
|
<Text size="xs">
|
|
{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.'
|
|
)}
|
|
</Text>
|
|
<Text size="xs">
|
|
{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.'
|
|
)}
|
|
</Text>
|
|
<Text size="xs">
|
|
{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.'
|
|
)}
|
|
</Text>
|
|
</Stack>
|
|
</Accordion.Panel>
|
|
</Accordion.Item>
|
|
</Accordion>
|
|
|
|
{hasDocument && <FontStatusPanel document={pdfDocument} pageIndex={selectedPage} />}
|
|
</Stack>
|
|
</ScrollArea>
|
|
</Card>
|
|
|
|
{errorMessage && (
|
|
<Alert
|
|
icon={<WarningAmberIcon fontSize="small" />}
|
|
color="red"
|
|
radius="md"
|
|
style={{ gridColumn: '2 / 3' }}
|
|
>
|
|
{errorMessage}
|
|
</Alert>
|
|
)}
|
|
|
|
{!hasDocument && !isConverting && (
|
|
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
|
|
<Stack align="center" gap="md">
|
|
<DescriptionIcon sx={{ fontSize: 48 }} />
|
|
<Text size="lg" fw={600}>
|
|
{t('pdfTextEditor.empty.title', 'No document loaded')}
|
|
</Text>
|
|
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
|
{t('pdfTextEditor.empty.subtitle', 'Load a PDF or JSON file to begin editing text content.')}
|
|
</Text>
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
{isConverting && (
|
|
<Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
|
|
<Stack gap="md">
|
|
<Group justify="space-between" align="flex-start">
|
|
<div style={{ flex: 1 }}>
|
|
<Text size="lg" fw={600} mb="xs">
|
|
{conversionProgress
|
|
? conversionProgress.message
|
|
: t('pdfTextEditor.converting', 'Converting PDF to editable format...')}
|
|
</Text>
|
|
{conversionProgress && (
|
|
<Group gap="xs">
|
|
<Text size="sm" c="dimmed" tt="capitalize">
|
|
{t(`pdfTextEditor.stages.${conversionProgress.stage}`, conversionProgress.stage)}
|
|
</Text>
|
|
{conversionProgress.current !== undefined &&
|
|
conversionProgress.total !== undefined && (
|
|
<Text size="sm" c="dimmed">
|
|
• Page {conversionProgress.current} of {conversionProgress.total}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</div>
|
|
<AutorenewIcon sx={{ fontSize: 36 }} className="animate-spin" />
|
|
</Group>
|
|
<Progress value={conversionProgress?.percent || 0} size="lg" radius="md" />
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
|
|
{hasDocument && (
|
|
<Stack
|
|
gap="lg"
|
|
className="flex-1"
|
|
style={{
|
|
gridColumn: '1 / 2',
|
|
gridRow: 1,
|
|
minHeight: 0,
|
|
height: 'calc(100vh - 3rem)',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Group justify="space-between" align="center">
|
|
<Group gap="sm">
|
|
<Text fw={500}>
|
|
{t('pdfTextEditor.pageSummary', 'Page {{number}} of {{total}}', {
|
|
number: selectedPage + 1,
|
|
total: pages.length,
|
|
})}
|
|
</Text>
|
|
{dirtyPages[selectedPage] && (
|
|
<Badge color="yellow" size="xs">
|
|
{t('pdfTextEditor.badges.modified', 'Edited')}
|
|
</Badge>
|
|
)}
|
|
<Badge color="blue" variant="dot" size="xs">
|
|
{t('pdfTextEditor.badges.earlyAccess', 'Early Access')}
|
|
</Badge>
|
|
</Group>
|
|
{pages.length > 1 && (
|
|
<Pagination
|
|
value={selectedPage + 1}
|
|
onChange={handlePageChange}
|
|
total={pages.length}
|
|
size="sm"
|
|
/>
|
|
)}
|
|
</Group>
|
|
|
|
<Card
|
|
withBorder
|
|
padding="md"
|
|
radius="md"
|
|
shadow="xs"
|
|
style={{ flex: 1, minHeight: 0, height: '100%', overflow: 'hidden' }}
|
|
>
|
|
<ScrollArea style={{ height: '100%', maxHeight: '100%' }} offsetScrollbars>
|
|
<Box
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'flex-start',
|
|
width: '100%',
|
|
minHeight: '100%',
|
|
}}
|
|
>
|
|
<Box
|
|
style={{
|
|
background: '#f3f4f6',
|
|
padding: '0.5rem',
|
|
borderRadius: '0.75rem',
|
|
}}
|
|
onClick={handleBackgroundClick}
|
|
>
|
|
<Box
|
|
style={{
|
|
position: 'relative',
|
|
width: `${scaledWidth}px`,
|
|
height: `${scaledHeight}px`,
|
|
backgroundColor: '#ffffff',
|
|
boxShadow: '0 0 12px rgba(15, 23, 42, 0.12)',
|
|
borderRadius: '0.5rem',
|
|
overflow: 'hidden',
|
|
}}
|
|
ref={containerRef}
|
|
>
|
|
{pagePreview && (
|
|
<img
|
|
src={pagePreview}
|
|
alt={t('pdfTextEditor.pagePreviewAlt', 'Page preview')}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
pointerEvents: 'none',
|
|
userSelect: 'none',
|
|
zIndex: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
{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 (
|
|
<Rnd
|
|
ref={(ref) => {
|
|
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 }}
|
|
>
|
|
<Box
|
|
onMouseEnter={() => 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',
|
|
}}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt={t('pdfTextEditor.imageLabel', 'Placed image')}
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'contain',
|
|
pointerEvents: 'none',
|
|
userSelect: 'none',
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Rnd>
|
|
);
|
|
})}
|
|
{visibleGroups.length === 0 && orderedImages.length === 0 ? (
|
|
<Group justify="center" align="center" style={{ height: '100%' }}>
|
|
<Stack gap={4} align="center">
|
|
<Text size="sm" c="dimmed">
|
|
{t('pdfTextEditor.noTextOnPage', 'No editable text was detected on this page.')}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
) : (
|
|
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 (
|
|
<Box key={group.id} style={containerStyle}>
|
|
{renderGroupContainer(
|
|
group.id,
|
|
group.pageIndex,
|
|
true,
|
|
changed,
|
|
<div
|
|
ref={(node) => {
|
|
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'}
|
|
</div>,
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const textScale = textScales.get(group.id) ?? 1;
|
|
const shouldScale = autoScaleText && textScale < 0.98;
|
|
|
|
return (
|
|
<Box key={group.id} style={containerStyle}>
|
|
{renderGroupContainer(
|
|
group.id,
|
|
group.pageIndex,
|
|
isActive,
|
|
changed,
|
|
<div
|
|
data-text-group={group.id}
|
|
style={{
|
|
width: '100%',
|
|
minHeight: '100%',
|
|
padding: 0,
|
|
whiteSpace: paragraphWhiteSpace,
|
|
wordBreak: paragraphWordBreak,
|
|
overflowWrap: paragraphOverflowWrap,
|
|
fontSize: `${fontSizePx}px`,
|
|
fontFamily,
|
|
fontWeight,
|
|
lineHeight: lineHeightRatio,
|
|
color: textColor,
|
|
display: 'block',
|
|
cursor: 'text',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<span
|
|
data-text-content
|
|
style={{
|
|
pointerEvents: 'none',
|
|
display: 'inline-block',
|
|
transform: shouldScale ? `scaleX(${textScale})` : 'none',
|
|
transformOrigin: 'left center',
|
|
whiteSpace: paragraphWhiteSpace,
|
|
}}
|
|
>
|
|
{group.text || '\u00A0'}
|
|
</span>
|
|
</div>,
|
|
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<HTMLElement>(`[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);
|
|
});
|
|
},
|
|
)}
|
|
</Box>
|
|
);
|
|
})
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</ScrollArea>
|
|
</Card>
|
|
|
|
</Stack>
|
|
)}
|
|
|
|
{/* Mode Change Confirmation Modal */}
|
|
<Modal
|
|
opened={pendingModeChange !== null}
|
|
onClose={handleCancelModeChange}
|
|
title={t('pdfTextEditor.modeChange.title', 'Confirm Mode Change')}
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text>
|
|
{t(
|
|
'pdfTextEditor.modeChange.warning',
|
|
'Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?'
|
|
)}
|
|
</Text>
|
|
<Group justify="flex-end" gap="sm">
|
|
<Button variant="default" onClick={handleCancelModeChange}>
|
|
{t('pdfTextEditor.modeChange.cancel', 'Cancel')}
|
|
</Button>
|
|
<Button color="red" onClick={handleConfirmModeChange}>
|
|
{t('pdfTextEditor.modeChange.confirm', 'Reset and Change Mode')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default PdfTextEditorView;
|