cleaner UI

This commit is contained in:
Anthony Stirling 2025-11-02 23:31:04 +00:00
parent bbcb23ca11
commit d4e95a6ed7
7 changed files with 305 additions and 109 deletions

View File

@ -7,9 +7,9 @@ logging.level.org.eclipse.jetty=WARN
#logging.level.org.opensaml=DEBUG #logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG #logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN logging.level.com.zaxxer.hikari=WARN
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=TRACE logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
logging.level.stirling.software.common.service.JobExecutorService=DEBUG logging.level.stirling.software.common.service.JobExecutorService=INFO
logging.level.stirling.software.common.service.TaskManager=DEBUG logging.level.stirling.software.common.service.TaskManager=INFO
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE server.forward-headers-strategy=NATIVE
server.error.path=/error server.error.path=/error

View File

@ -500,7 +500,8 @@ public class PdfJsonConversionService {
rewriteSucceeded = false; rewriteSucceeded = false;
} else if (!preservedStreams.isEmpty()) { } else if (!preservedStreams.isEmpty()) {
log.info("Attempting token rewrite for page {}", pageNumberValue); log.info("Attempting token rewrite for page {}", pageNumberValue);
rewriteSucceeded = rewriteTextOperators(document, page, elements, false); rewriteSucceeded =
rewriteTextOperators(document, page, elements, false, false);
if (!rewriteSucceeded) { if (!rewriteSucceeded) {
log.info( log.info(
"Token rewrite failed for page {}, regenerating text stream", "Token rewrite failed for page {}, regenerating text stream",
@ -2209,7 +2210,12 @@ public class PdfJsonConversionService {
PDDocument document, PDDocument document,
PDPage page, PDPage page,
List<PdfJsonTextElement> elements, List<PdfJsonTextElement> elements,
boolean removeOnly) { boolean removeOnly,
boolean forceRegenerate) {
if (forceRegenerate) {
log.debug("forceRegenerate flag set; skipping token rewrite for page");
return false;
}
if (elements == null || elements.isEmpty()) { if (elements == null || elements.isEmpty()) {
return true; return true;
} }
@ -2226,6 +2232,8 @@ public class PdfJsonConversionService {
PDFont currentFont = null; PDFont currentFont = null;
String currentFontName = null; String currentFontName = null;
boolean encounteredModifiedFont = false;
for (int i = 0; i < tokens.size(); i++) { for (int i = 0; i < tokens.size(); i++) {
Object token = tokens.get(i); Object token = tokens.get(i);
if (!(token instanceof Operator operator)) { if (!(token instanceof Operator operator)) {
@ -2240,6 +2248,9 @@ public class PdfJsonConversionService {
log.trace( log.trace(
"Encountered Tf operator; switching to font resource {}", "Encountered Tf operator; switching to font resource {}",
currentFontName); currentFontName);
if (forceRegenerate) {
encounteredModifiedFont = true;
}
} else { } else {
currentFont = null; currentFont = null;
currentFontName = null; currentFontName = null;
@ -2253,7 +2264,11 @@ public class PdfJsonConversionService {
"Encountered Tj without preceding string operand; aborting rewrite"); "Encountered Tj without preceding string operand; aborting rewrite");
return false; return false;
} }
log.trace("Rewriting Tj operator using font {}", currentFontName); log.trace(
"Rewriting Tj operator using font {} (token index {}, cursor remaining {})",
currentFontName,
i,
cursor.remaining());
if (!rewriteShowText( if (!rewriteShowText(
cosString, currentFont, currentFontName, cursor, removeOnly)) { cosString, currentFont, currentFontName, cursor, removeOnly)) {
log.debug("Failed to rewrite Tj operator; aborting rewrite"); log.debug("Failed to rewrite Tj operator; aborting rewrite");
@ -2265,7 +2280,11 @@ public class PdfJsonConversionService {
log.debug("Encountered TJ without array operand; aborting rewrite"); log.debug("Encountered TJ without array operand; aborting rewrite");
return false; return false;
} }
log.trace("Rewriting TJ operator using font {}", currentFontName); log.trace(
"Rewriting TJ operator using font {} (token index {}, cursor remaining {})",
currentFontName,
i,
cursor.remaining());
if (!rewriteShowTextArray( if (!rewriteShowTextArray(
array, currentFont, currentFontName, cursor, removeOnly)) { array, currentFont, currentFontName, cursor, removeOnly)) {
log.debug("Failed to rewrite TJ operator; aborting rewrite"); log.debug("Failed to rewrite TJ operator; aborting rewrite");
@ -2282,6 +2301,12 @@ public class PdfJsonConversionService {
return false; return false;
} }
if (forceRegenerate && encounteredModifiedFont) {
log.debug(
"Rewrite succeeded but forceRegenerate=true, returning false to trigger rebuild");
return false;
}
PDStream newStream = new PDStream(document); PDStream newStream = new PDStream(document);
try (OutputStream outputStream = newStream.createOutputStream(COSName.FLATE_DECODE)) { try (OutputStream outputStream = newStream.createOutputStream(COSName.FLATE_DECODE)) {
new ContentStreamWriter(outputStream).writeTokens(tokens); new ContentStreamWriter(outputStream).writeTokens(tokens);
@ -2303,11 +2328,24 @@ public class PdfJsonConversionService {
boolean removeOnly) boolean removeOnly)
throws IOException { throws IOException {
if (font == null) { if (font == null) {
log.debug(
"rewriteShowText aborted: no active font for expected resource {}",
expectedFontName);
return false; return false;
} }
int glyphCount = countGlyphs(cosString, font); int glyphCount = countGlyphs(cosString, font);
log.trace(
"rewriteShowText consuming {} glyphs at cursor index {} for font {}",
glyphCount,
cursor.index,
expectedFontName);
List<PdfJsonTextElement> consumed = cursor.consume(expectedFontName, glyphCount); List<PdfJsonTextElement> consumed = cursor.consume(expectedFontName, glyphCount);
if (consumed == null) { if (consumed == null) {
log.debug(
"Failed to consume {} glyphs for font {} (cursor remaining {})",
glyphCount,
expectedFontName,
cursor.remaining());
return false; return false;
} }
if (removeOnly) { if (removeOnly) {
@ -2320,7 +2358,10 @@ public class PdfJsonConversionService {
cosString.setValue(encoded); cosString.setValue(encoded);
return true; return true;
} catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) { } catch (IOException | IllegalArgumentException | UnsupportedOperationException ex) {
log.debug("Failed to encode replacement text: {}", ex.getMessage()); log.debug(
"Failed to encode replacement text with font {}: {}",
expectedFontName,
ex.getMessage());
return false; return false;
} }
} }
@ -2333,6 +2374,9 @@ public class PdfJsonConversionService {
boolean removeOnly) boolean removeOnly)
throws IOException { throws IOException {
if (font == null) { if (font == null) {
log.debug(
"rewriteShowTextArray aborted: no active font for expected resource {}",
expectedFontName);
return false; return false;
} }
for (int i = 0; i < array.size(); i++) { for (int i = 0; i < array.size(); i++) {
@ -2341,6 +2385,12 @@ public class PdfJsonConversionService {
int glyphCount = countGlyphs(cosString, font); int glyphCount = countGlyphs(cosString, font);
List<PdfJsonTextElement> consumed = cursor.consume(expectedFontName, glyphCount); List<PdfJsonTextElement> consumed = cursor.consume(expectedFontName, glyphCount);
if (consumed == null) { if (consumed == null) {
log.debug(
"Failed to consume {} glyphs for font {} in TJ segment {} (cursor remaining {})",
glyphCount,
expectedFontName,
i,
cursor.remaining());
return false; return false;
} }
if (removeOnly) { if (removeOnly) {
@ -2354,7 +2404,11 @@ public class PdfJsonConversionService {
} catch (IOException } catch (IOException
| IllegalArgumentException | IllegalArgumentException
| UnsupportedOperationException ex) { | UnsupportedOperationException ex) {
log.debug("Failed to encode replacement text in TJ array: {}", ex.getMessage()); log.debug(
"Failed to encode replacement text in TJ array for font {} segment {}: {}",
expectedFontName,
i,
ex.getMessage());
return false; return false;
} }
} }
@ -2417,6 +2471,11 @@ public class PdfJsonConversionService {
while (remaining > 0 && index < elements.size()) { while (remaining > 0 && index < elements.size()) {
PdfJsonTextElement element = elements.get(index); PdfJsonTextElement element = elements.get(index);
if (!fontMatches(expectedFontName, element.getFontId())) { if (!fontMatches(expectedFontName, element.getFontId())) {
log.debug(
"Cursor consume failed: font mismatch (expected={}, actual={}) at element {}",
expectedFontName,
element.getFontId(),
index);
return null; return null;
} }
consumed.add(element); consumed.add(element);
@ -2424,6 +2483,11 @@ public class PdfJsonConversionService {
index++; index++;
} }
if (remaining > 0) { if (remaining > 0) {
log.debug(
"Cursor consume failed: ran out of elements (remaining={}, currentIndex={}, total={})",
remaining,
index,
elements.size());
return null; return null;
} }
return consumed; return consumed;
@ -3995,7 +4059,8 @@ public class PdfJsonConversionService {
} }
if (hasText && !preflightResult.usesFallback()) { if (hasText && !preflightResult.usesFallback()) {
boolean rewriteSucceeded = rewriteTextOperators(document, page, textElements, false); boolean rewriteSucceeded =
rewriteTextOperators(document, page, textElements, false, true);
if (rewriteSucceeded) { if (rewriteSucceeded) {
return RegenerateMode.REUSE_EXISTING; return RegenerateMode.REUSE_EXISTING;
} }

View File

@ -4412,9 +4412,12 @@
"viewLabel": "PDF Editor", "viewLabel": "PDF Editor",
"title": "PDF Editor", "title": "PDF Editor",
"badges": { "badges": {
"unsaved": "Edited", "unsaved": "Unsaved changes",
"modified": "Edited" "modified": "Edited"
}, },
"controls": {
"title": "Document Controls"
},
"actions": { "actions": {
"load": "Load File", "load": "Load File",
"reset": "Reset Changes", "reset": "Reset Changes",
@ -4439,9 +4442,14 @@
"pdfConversion": "Unable to convert the edited JSON back into a PDF." "pdfConversion": "Unable to convert the edited JSON back into a PDF."
}, },
"options": { "options": {
"title": "Viewing options",
"autoScaleText": { "autoScaleText": {
"title": "Auto-scale text to fit boxes", "title": "Auto-scale text to fit boxes",
"description": "Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF." "description": "Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF."
},
"forceSingleElement": {
"title": "Lock edited text to a single PDF element",
"description": "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts."
} }
}, },
"disclaimer": { "disclaimer": {

View File

@ -228,6 +228,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
isConverting, isConverting,
conversionProgress, conversionProgress,
hasChanges, hasChanges,
forceSingleTextElement,
onLoadJson, onLoadJson,
onSelectPage, onSelectPage,
onGroupEdit, onGroupEdit,
@ -236,6 +237,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
onReset, onReset,
onDownloadJson, onDownloadJson,
onGeneratePdf, onGeneratePdf,
onForceSingleTextElementChange,
} = data; } = data;
const resolveFont = (fontId: string | null | undefined, pageIndex: number | null | undefined): PdfJsonFont | null => { const resolveFont = (fontId: string | null | undefined, pageIndex: number | null | undefined): PdfJsonFont | null => {
@ -402,6 +404,56 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
const pageGroups = groupsByPage[selectedPage] ?? []; const pageGroups = groupsByPage[selectedPage] ?? [];
const pageImages = imagesByPage[selectedPage] ?? []; const pageImages = imagesByPage[selectedPage] ?? [];
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 fontMetrics = useMemo(() => {
const metrics = new Map<string, { unitsPerEm: number; ascent: number; descent: number }>(); const metrics = new Map<string, { unitsPerEm: number; ascent: number; descent: number }>();
pdfDocument?.fonts?.forEach((font) => { pdfDocument?.fonts?.forEach((font) => {
@ -545,11 +597,15 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
}, [pdfDocument?.fonts]); }, [pdfDocument?.fonts]);
const visibleGroups = useMemo( const visibleGroups = useMemo(
() => () =>
pageGroups.filter((group) => { pageGroups
const hasContent = ((group.text ?? '').trim().length > 0) || ((group.originalText ?? '').trim().length > 0); .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; return hasContent || editingGroupId === group.id;
}), }),
[editingGroupId, pageGroups] [editingGroupId, pageGroups],
); );
const orderedImages = useMemo( const orderedImages = useMemo(
@ -590,7 +646,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
const measureTextScales = () => { const measureTextScales = () => {
const newScales = new Map<string, number>(); const newScales = new Map<string, number>();
visibleGroups.forEach((group) => { visibleGroups.forEach(({ group }) => {
// Skip groups that are being edited // Skip groups that are being edited
if (editingGroupId === group.id) { if (editingGroupId === group.id) {
return; return;
@ -644,7 +700,10 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
if (!editor) { if (!editor) {
return; return;
} }
const offset = caretOffsetsRef.current.get(editingGroupId) ?? editor.innerText.length; const offset = caretOffsetsRef.current.get(editingGroupId);
if (offset === undefined) {
return;
}
setCaretOffset(editor, offset); setCaretOffset(editor, offset);
}, [editingGroupId, groupsByPage, imagesByPage]); }, [editingGroupId, groupsByPage, imagesByPage]);
@ -654,14 +713,8 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
} }
const editor = document.querySelector<HTMLElement>(`[data-editor-group="${editingGroupId}"]`); const editor = document.querySelector<HTMLElement>(`[data-editor-group="${editingGroupId}"]`);
if (editor) { if (editor) {
if (document.activeElement !== editor) {
editor.focus(); editor.focus();
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.addRange(range);
} }
} }
}, [editingGroupId]); }, [editingGroupId]);
@ -744,8 +797,25 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
); );
return ( return (
<Stack gap="xl" className="h-full" style={{ padding: '1.5rem', overflow: 'auto' }}> <Stack
<Card withBorder radius="md" shadow="xs" padding="lg"> gap="xl"
className="h-full"
style={{
padding: '1.5rem',
overflow: 'hidden',
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 320px',
alignItems: 'start',
gap: '1.5rem',
}}
>
<Card
withBorder
radius="md"
shadow="xs"
padding="lg"
style={{ gridColumn: '2 / 3', gridRow: 1, position: 'sticky', top: '1.5rem', zIndex: 2 }}
>
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="xs" align="center"> <Group gap="xs" align="center">
@ -753,13 +823,14 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
<Title order={3}>{t('pdfJsonEditor.title', 'PDF JSON Editor')}</Title> <Title order={3}>{t('pdfJsonEditor.title', 'PDF JSON Editor')}</Title>
{hasChanges && <Badge color="yellow" size="sm">{t('pdfJsonEditor.badges.unsaved', 'Edited')}</Badge>} {hasChanges && <Badge color="yellow" size="sm">{t('pdfJsonEditor.badges.unsaved', 'Edited')}</Badge>}
</Group> </Group>
<Group gap="sm"> <Stack gap="sm">
<FileButton onChange={onLoadJson} accept="application/pdf,application/json,.pdf,.json"> <FileButton onChange={onLoadJson} accept="application/pdf,application/json,.pdf,.json">
{(props) => ( {(props) => (
<Button <Button
variant="light" variant="light"
leftSection={<UploadIcon fontSize="small" />} leftSection={<UploadIcon fontSize="small" />}
loading={isConverting} loading={isConverting}
fullWidth
{...props} {...props}
> >
{t('pdfJsonEditor.actions.load', 'Load File')} {t('pdfJsonEditor.actions.load', 'Load File')}
@ -771,6 +842,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
leftSection={<AutorenewIcon fontSize="small" />} leftSection={<AutorenewIcon fontSize="small" />}
onClick={onReset} onClick={onReset}
disabled={!hasDocument || isConverting} disabled={!hasDocument || isConverting}
fullWidth
> >
{t('pdfJsonEditor.actions.reset', 'Reset Changes')} {t('pdfJsonEditor.actions.reset', 'Reset Changes')}
</Button> </Button>
@ -779,6 +851,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
leftSection={<FileDownloadIcon fontSize="small" />} leftSection={<FileDownloadIcon fontSize="small" />}
onClick={onDownloadJson} onClick={onDownloadJson}
disabled={!hasDocument || isConverting} disabled={!hasDocument || isConverting}
fullWidth
> >
{t('pdfJsonEditor.actions.downloadJson', 'Download JSON')} {t('pdfJsonEditor.actions.downloadJson', 'Download JSON')}
</Button> </Button>
@ -787,10 +860,11 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
onClick={onGeneratePdf} onClick={onGeneratePdf}
loading={isGeneratingPdf} loading={isGeneratingPdf}
disabled={!hasDocument || !hasChanges || isConverting} disabled={!hasDocument || !hasChanges || isConverting}
fullWidth
> >
{t('pdfJsonEditor.actions.generatePdf', 'Generate PDF')} {t('pdfJsonEditor.actions.generatePdf', 'Generate PDF')}
</Button> </Button>
</Group> </Stack>
</Group> </Group>
{fileName && ( {fileName && (
@ -819,6 +893,25 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
onChange={(event) => setAutoScaleText(event.currentTarget.checked)} onChange={(event) => setAutoScaleText(event.currentTarget.checked)}
/> />
</Group> </Group>
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('pdfJsonEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'pdfJsonEditor.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>
</Stack> </Stack>
</Card> </Card>
@ -827,6 +920,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
color="yellow" color="yellow"
radius="md" radius="md"
variant="light" variant="light"
style={{ gridColumn: '2 / 3' }}
> >
<Stack gap={4}> <Stack gap={4}>
<Text fw={600}> <Text fw={600}>
@ -853,14 +947,82 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
</Stack> </Stack>
</Alert> </Alert>
{hasDocument && (
<Card
withBorder
radius="md"
padding="md"
shadow="xs"
style={{ gridColumn: '2 / 3' }}
>
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={500}>{t('pdfJsonEditor.groupList', 'Detected Text Groups')}</Text>
<ActionIcon
variant="subtle"
onClick={() => setTextGroupsExpanded(!textGroupsExpanded)}
aria-label={textGroupsExpanded ? 'Collapse' : 'Expand'}
>
{textGroupsExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ActionIcon>
</Group>
<Collapse in={textGroupsExpanded}>
<ScrollArea h={240} offsetScrollbars>
<Stack gap="sm">
{visibleGroups.map(({ group }) => {
const changed = group.text !== group.originalText;
return (
<Card
key={`list-${group.id}`}
padding="sm"
radius="md"
withBorder
shadow={changed ? 'sm' : 'none'}
onMouseEnter={() => setActiveGroupId(group.id)}
onMouseLeave={() => setActiveGroupId((current) => (current === group.id ? null : current))}
style={{ cursor: 'pointer' }}
onClick={() => {
setActiveGroupId(group.id);
setEditingGroupId(group.id);
}}
>
<Stack gap={4}>
<Group gap="xs">
{changed && <Badge color="yellow" size="xs">{t('pdfJsonEditor.badges.modified', 'Edited')}</Badge>}
{group.fontId && <Badge size="xs" variant="outline">{group.fontId}</Badge>}
{group.fontSize && (
<Badge size="xs" variant="light">
{t('pdfJsonEditor.fontSizeValue', '{{size}}pt', { size: group.fontSize.toFixed(1) })}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed" lineClamp={2}>
{group.text || t('pdfJsonEditor.emptyGroup', '[Empty Group]')}
</Text>
</Stack>
</Card>
);
})}
</Stack>
</ScrollArea>
</Collapse>
</Stack>
</Card>
)}
{errorMessage && ( {errorMessage && (
<Alert icon={<WarningAmberIcon fontSize="small" />} color="red" radius="md"> <Alert
icon={<WarningAmberIcon fontSize="small" />}
color="red"
radius="md"
style={{ gridColumn: '2 / 3' }}
>
{errorMessage} {errorMessage}
</Alert> </Alert>
)} )}
{!hasDocument && !isConverting && ( {!hasDocument && !isConverting && (
<Card withBorder radius="md" padding="xl"> <Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<DescriptionIcon sx={{ fontSize: 48 }} /> <DescriptionIcon sx={{ fontSize: 48 }} />
<Text size="lg" fw={600}> <Text size="lg" fw={600}>
@ -874,7 +1036,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
)} )}
{isConverting && ( {isConverting && (
<Card withBorder radius="md" padding="xl"> <Card withBorder radius="md" padding="xl" style={{ gridColumn: '1 / 2', gridRow: 1 }}>
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="flex-start"> <Group justify="space-between" align="flex-start">
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@ -899,22 +1061,13 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
</div> </div>
<AutorenewIcon sx={{ fontSize: 36 }} className="animate-spin" /> <AutorenewIcon sx={{ fontSize: 36 }} className="animate-spin" />
</Group> </Group>
<Progress <Progress value={conversionProgress?.percent || 0} size="lg" radius="md" />
value={conversionProgress?.percent || 0}
size="lg"
radius="md"
animated
striped
/>
<Text size="sm" c="dimmed" ta="right">
{conversionProgress?.percent || 0}% complete
</Text>
</Stack> </Stack>
</Card> </Card>
)} )}
{hasDocument && ( {hasDocument && (
<Stack gap="lg" className="flex-1" style={{ minHeight: 0 }}> <Stack gap="lg" className="flex-1" style={{ gridColumn: '1 / 2', gridRow: 1, minHeight: 0 }}>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
<Text fw={500}> <Text fw={500}>
@ -1091,20 +1244,21 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
</Stack> </Stack>
</Group> </Group>
) : ( ) : (
visibleGroups.map((group) => { visibleGroups.map(({ group, pageGroupIndex }) => {
const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds); const bounds = toCssBounds(currentPage, pageHeight, scale, group.bounds);
const changed = group.text !== group.originalText; const changed = group.text !== group.originalText;
const isActive = activeGroupId === group.id || editingGroupId === group.id; const isActive = activeGroupId === group.id || editingGroupId === group.id;
const isEditing = editingGroupId === group.id; const isEditing = editingGroupId === group.id;
const baseFontSize = group.fontMatrixSize ?? group.fontSize ?? 12; const baseFontSize = group.fontMatrixSize ?? group.fontSize ?? 12;
const fontSizePx = Math.max(baseFontSize * scale, 6); const fontSizePx = Math.max(baseFontSize * scale, 6);
const fontFamily = getFontFamily(group.fontId, group.pageIndex); const effectiveFontId = resolveFontIdForIndex(pageGroupIndex) ?? group.fontId;
let lineHeightPx = getLineHeightPx(group.fontId, group.pageIndex, fontSizePx); 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; let lineHeightRatio = fontSizePx > 0 ? Math.max(lineHeightPx / fontSizePx, 1.05) : 1.2;
const rotation = group.rotation ?? 0; const rotation = group.rotation ?? 0;
const hasRotation = Math.abs(rotation) > 0.5; const hasRotation = Math.abs(rotation) > 0.5;
const baselineLength = group.baselineLength ?? Math.max(group.bounds.right - group.bounds.left, 0); const baselineLength = group.baselineLength ?? Math.max(group.bounds.right - group.bounds.left, 0);
const geometry = getFontGeometry(group.fontId, group.pageIndex); const geometry = getFontGeometry(effectiveFontId, group.pageIndex);
const ascentPx = geometry ? Math.max(fontSizePx * geometry.ascentRatio, fontSizePx * 0.7) : fontSizePx * 0.82; 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; const descentPx = geometry ? Math.max(fontSizePx * geometry.descentRatio, fontSizePx * 0.2) : fontSizePx * 0.22;
lineHeightPx = Math.max(lineHeightPx, ascentPx + descentPx); lineHeightPx = Math.max(lineHeightPx, ascentPx + descentPx);
@ -1143,7 +1297,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
// Extract styling from group // Extract styling from group
const textColor = group.color || '#111827'; const textColor = group.color || '#111827';
const fontWeight = group.fontWeight || getFontWeight(group.fontId, group.pageIndex); const fontWeight = group.fontWeight || getFontWeight(effectiveFontId, group.pageIndex);
const containerStyle: React.CSSProperties = { const containerStyle: React.CSSProperties = {
position: 'absolute', position: 'absolute',
@ -1179,6 +1333,22 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
data-editor-group={group.id} 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;
}}
onBlur={(event) => { onBlur={(event) => {
const value = event.currentTarget.innerText.replace(/\u00A0/g, ' '); const value = event.currentTarget.innerText.replace(/\u00A0/g, ' ');
caretOffsetsRef.current.delete(group.id); caretOffsetsRef.current.delete(group.id);
@ -1280,65 +1450,6 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
</ScrollArea> </ScrollArea>
</Card> </Card>
<Card padding="md" withBorder radius="md">
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={500}>{t('pdfJsonEditor.groupList', 'Detected Text Groups')}</Text>
<ActionIcon
variant="subtle"
onClick={() => setTextGroupsExpanded(!textGroupsExpanded)}
aria-label={textGroupsExpanded ? 'Collapse' : 'Expand'}
>
{textGroupsExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</ActionIcon>
</Group>
<Collapse in={textGroupsExpanded}>
<Stack gap="xs">
<Divider />
<ScrollArea h={180} offsetScrollbars>
<Stack gap="sm">
{visibleGroups.map((group) => {
const changed = group.text !== group.originalText;
return (
<Card
key={`list-${group.id}`}
padding="sm"
radius="md"
withBorder
shadow={changed ? 'sm' : 'none'}
onMouseEnter={() => setActiveGroupId(group.id)}
onMouseLeave={() => setActiveGroupId((current) => (current === group.id ? null : current))}
style={{ cursor: 'pointer' }}
onClick={() => {
setActiveGroupId(group.id);
setEditingGroupId(group.id);
}}
>
<Stack gap={4}>
<Group gap="xs">
{changed && <Badge color="yellow" size="xs">{t('pdfJsonEditor.badges.modified', 'Edited')}</Badge>}
{group.fontId && (
<Badge size="xs" variant="outline">{group.fontId}</Badge>
)}
{group.fontSize && (
<Badge size="xs" variant="light">
{t('pdfJsonEditor.fontSizeValue', '{{size}}pt', { size: group.fontSize.toFixed(1) })}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed" lineClamp={2}>
{group.text || t('pdfJsonEditor.emptyGroup', '[Empty Group]')}
</Text>
</Stack>
</Card>
);
})}
</Stack>
</ScrollArea>
</Stack>
</Collapse>
</Stack>
</Card>
</Stack> </Stack>
)} )}
</Stack> </Stack>

View File

@ -74,6 +74,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
stage: string; stage: string;
message: string; message: string;
} | null>(null); } | null>(null);
const [forceSingleTextElement, setForceSingleTextElement] = useState(false);
// Lazy loading state // Lazy loading state
const [isLazyMode, setIsLazyMode] = useState(false); const [isLazyMode, setIsLazyMode] = useState(false);
@ -615,13 +616,14 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
groupsByPage, groupsByPage,
imagesByPageRef.current, imagesByPageRef.current,
originalImagesRef.current, originalImagesRef.current,
forceSingleTextElement,
); );
const baseName = sanitizeBaseName(fileName || loadedDocument.metadata?.title || undefined); const baseName = sanitizeBaseName(fileName || loadedDocument.metadata?.title || undefined);
return { return {
document: updatedDocument, document: updatedDocument,
filename: `${baseName}.json`, filename: `${baseName}.json`,
}; };
}, [fileName, groupsByPage, loadedDocument]); }, [fileName, forceSingleTextElement, groupsByPage, loadedDocument]);
const handleDownloadJson = useCallback(() => { const handleDownloadJson = useCallback(() => {
const payload = buildPayload(); const payload = buildPayload();
@ -817,6 +819,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
isConverting, isConverting,
conversionProgress, conversionProgress,
hasChanges, hasChanges,
forceSingleTextElement,
onLoadJson: handleLoadFile, onLoadJson: handleLoadFile,
onSelectPage: handleSelectPage, onSelectPage: handleSelectPage,
onGroupEdit: handleGroupTextChange, onGroupEdit: handleGroupTextChange,
@ -825,6 +828,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
onReset: handleResetEdits, onReset: handleResetEdits,
onDownloadJson: handleDownloadJson, onDownloadJson: handleDownloadJson,
onGeneratePdf: handleGeneratePdf, onGeneratePdf: handleGeneratePdf,
onForceSingleTextElementChange: setForceSingleTextElement,
}), [ }), [
handleImageTransform, handleImageTransform,
imagesByPage, imagesByPage,
@ -846,6 +850,7 @@ const PdfJsonEditor = ({ onComplete, onError }: BaseToolProps) => {
conversionProgress, conversionProgress,
loadedDocument, loadedDocument,
selectedPage, selectedPage,
forceSingleTextElement,
]); ]);
const latestViewDataRef = useRef<PdfJsonEditorViewData>(viewData); const latestViewDataRef = useRef<PdfJsonEditorViewData>(viewData);

View File

@ -191,6 +191,7 @@ export interface PdfJsonEditorViewData {
isConverting: boolean; isConverting: boolean;
conversionProgress: ConversionProgress | null; conversionProgress: ConversionProgress | null;
hasChanges: boolean; hasChanges: boolean;
forceSingleTextElement: boolean;
onLoadJson: (file: File | null) => Promise<void> | void; onLoadJson: (file: File | null) => Promise<void> | void;
onSelectPage: (pageIndex: number) => void; onSelectPage: (pageIndex: number) => void;
onGroupEdit: (pageIndex: number, groupId: string, value: string) => void; onGroupEdit: (pageIndex: number, groupId: string, value: string) => void;
@ -209,4 +210,5 @@ export interface PdfJsonEditorViewData {
onReset: () => void; onReset: () => void;
onDownloadJson: () => void; onDownloadJson: () => void;
onGeneratePdf: () => void; onGeneratePdf: () => void;
onForceSingleTextElementChange: (value: boolean) => void;
} }

View File

@ -689,6 +689,7 @@ export const restoreGlyphElements = (
groupsByPage: TextGroup[][], groupsByPage: TextGroup[][],
imagesByPage: PdfJsonImageElement[][], imagesByPage: PdfJsonImageElement[][],
originalImagesByPage: PdfJsonImageElement[][], originalImagesByPage: PdfJsonImageElement[][],
forceMergedGroups: boolean = false,
): PdfJsonDocument => { ): PdfJsonDocument => {
const updated = deepCloneDocument(source); const updated = deepCloneDocument(source);
const pages = updated.pages ?? []; const pages = updated.pages ?? [];
@ -709,6 +710,10 @@ export const restoreGlyphElements = (
groups.forEach((group) => { groups.forEach((group) => {
if (group.text !== group.originalText) { if (group.text !== group.originalText) {
if (forceMergedGroups) {
rebuiltElements.push(createMergedElement(group));
return;
}
const originalGlyphCount = group.originalElements.reduce( const originalGlyphCount = group.originalElements.reduce(
(sum, element) => sum + countGraphemes(element.text ?? ''), (sum, element) => sum + countGraphemes(element.text ?? ''),
0, 0,