mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
font detection
This commit is contained in:
parent
2d8113b3f6
commit
84c0962b4c
@ -4535,6 +4535,32 @@
|
||||
"metadata": "Metadata",
|
||||
"serializing": "Finalizing",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"fontAnalysis": {
|
||||
"currentPageFonts": "Fonts on this page",
|
||||
"allFonts": "All fonts",
|
||||
"perfectTitle": "All Fonts Available",
|
||||
"perfectMessage": "All fonts are fully available. You can edit and add text without issues in exported PDFs.",
|
||||
"warningTitle": "Font Limitations",
|
||||
"warningMessage": "Some fonts have limitations. Existing text will export correctly, but adding new text may cause issues.",
|
||||
"infoTitle": "Font Information",
|
||||
"infoMessage": "Font reproduction information available.",
|
||||
"summary": "Font Summary",
|
||||
"font": "font",
|
||||
"fonts": "fonts",
|
||||
"perfect": "perfect",
|
||||
"subset": "subset",
|
||||
"fallback": "fallback",
|
||||
"missing": "missing",
|
||||
"fontDetails": "Font Details",
|
||||
"details": "Font Details",
|
||||
"embedded": "Embedded",
|
||||
"type": "Type",
|
||||
"encoding": "Encoding",
|
||||
"webFormat": "Web Format",
|
||||
"standard14": "Standard PDF Font",
|
||||
"warnings": "Warnings",
|
||||
"suggestions": "Notes"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -0,0 +1,269 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Box,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import FontDownloadIcon from '@mui/icons-material/FontDownload';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
import { PdfJsonDocument } from '@app/tools/pdfJsonEditor/pdfJsonEditorTypes';
|
||||
import {
|
||||
analyzeDocumentFonts,
|
||||
DocumentFontAnalysis,
|
||||
FontAnalysis,
|
||||
getFontStatusColor,
|
||||
getFontStatusDescription,
|
||||
} from '@app/tools/pdfJsonEditor/fontAnalysis';
|
||||
|
||||
interface FontStatusPanelProps {
|
||||
document: PdfJsonDocument | null;
|
||||
pageIndex?: number;
|
||||
}
|
||||
|
||||
const FontStatusBadge = ({ analysis }: { analysis: FontAnalysis }) => {
|
||||
const color = getFontStatusColor(analysis.status);
|
||||
const description = getFontStatusDescription(analysis.status);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (analysis.status) {
|
||||
case 'perfect':
|
||||
return <CheckCircleIcon sx={{ fontSize: 14 }} />;
|
||||
case 'embedded-subset':
|
||||
return <InfoIcon sx={{ fontSize: 14 }} />;
|
||||
case 'system-fallback':
|
||||
return <WarningIcon sx={{ fontSize: 14 }} />;
|
||||
case 'missing':
|
||||
return <ErrorIcon sx={{ fontSize: 14 }} />;
|
||||
default:
|
||||
return <InfoIcon sx={{ fontSize: 14 }} />;
|
||||
}
|
||||
}, [analysis.status]);
|
||||
|
||||
return (
|
||||
<Tooltip label={description} position="top" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={color}
|
||||
variant="light"
|
||||
leftSection={icon}
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
{analysis.status.replace('-', ' ')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xs" style={{ cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Group gap={4}>
|
||||
<FontDownloadIcon sx={{ fontSize: 16 }} />
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{analysis.baseName}
|
||||
</Text>
|
||||
{analysis.isSubset && (
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
subset
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<FontStatusBadge analysis={analysis} />
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Stack gap={4} mt={4}>
|
||||
{/* Font Details */}
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={2}>
|
||||
{t('pdfJsonEditor.fontAnalysis.details', 'Font Details')}:
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfJsonEditor.fontAnalysis.embedded', 'Embedded')}:
|
||||
</Text>
|
||||
<Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.embedded ? 'Yes' : 'No'}</Code>
|
||||
</Group>
|
||||
{analysis.subtype && (
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfJsonEditor.fontAnalysis.type', 'Type')}:
|
||||
</Text>
|
||||
<Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.subtype}</Code>
|
||||
</Group>
|
||||
)}
|
||||
{analysis.webFormat && (
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfJsonEditor.fontAnalysis.webFormat', 'Web Format')}:
|
||||
</Text>
|
||||
<Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.webFormat}</Code>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Warnings */}
|
||||
{analysis.warnings.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="orange" fw={500}>
|
||||
{t('pdfJsonEditor.fontAnalysis.warnings', 'Warnings')}:
|
||||
</Text>
|
||||
<List size="xs" spacing={2} withPadding>
|
||||
{analysis.warnings.map((warning, index) => (
|
||||
<List.Item key={index}>
|
||||
<Text size="xs">{warning}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="blue" fw={500}>
|
||||
{t('pdfJsonEditor.fontAnalysis.suggestions', 'Notes')}:
|
||||
</Text>
|
||||
<List size="xs" spacing={2} withPadding>
|
||||
{analysis.suggestions.map((suggestion, index) => (
|
||||
<List.Item key={index}>
|
||||
<Text size="xs">{suggestion}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fontAnalysis: DocumentFontAnalysis = useMemo(
|
||||
() => analyzeDocumentFonts(document, pageIndex),
|
||||
[document, pageIndex]
|
||||
);
|
||||
|
||||
if (!document || fontAnalysis.fonts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { canReproducePerfectly, hasWarnings, summary, fonts } = fontAnalysis;
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (canReproducePerfectly) {
|
||||
return <CheckCircleIcon sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
if (hasWarnings) {
|
||||
return <WarningIcon sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
return <InfoIcon sx={{ fontSize: 16 }} />;
|
||||
}, [canReproducePerfectly, hasWarnings]);
|
||||
|
||||
const statusColor = canReproducePerfectly ? 'green' : hasWarnings ? 'yellow' : 'blue';
|
||||
|
||||
const pageLabel = pageIndex !== undefined
|
||||
? t('pdfJsonEditor.fontAnalysis.currentPageFonts', 'Fonts on this page')
|
||||
: t('pdfJsonEditor.fontAnalysis.allFonts', 'All fonts');
|
||||
|
||||
return (
|
||||
<Accordion variant="contained" defaultValue={hasWarnings ? 'fonts' : undefined}>
|
||||
<Accordion.Item value="fonts">
|
||||
<Accordion.Control>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{statusIcon}
|
||||
<Text size="sm" fw={500}>
|
||||
{pageLabel}
|
||||
</Text>
|
||||
<Badge size="xs" color={statusColor} variant="dot">
|
||||
{fonts.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{/* Overall Status Message */}
|
||||
<Text size="xs" c="dimmed">
|
||||
{canReproducePerfectly
|
||||
? t(
|
||||
'pdfJsonEditor.fontAnalysis.perfectMessage',
|
||||
'All fonts can be reproduced perfectly.'
|
||||
)
|
||||
: hasWarnings
|
||||
? t(
|
||||
'pdfJsonEditor.fontAnalysis.warningMessage',
|
||||
'Some fonts may not render correctly.'
|
||||
)
|
||||
: t(
|
||||
'pdfJsonEditor.fontAnalysis.infoMessage',
|
||||
'Font reproduction information available.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{summary.perfect > 0 && (
|
||||
<Badge size="xs" color="green" variant="light" leftSection={<CheckCircleIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.perfect} {t('pdfJsonEditor.fontAnalysis.perfect', 'perfect')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.embeddedSubset > 0 && (
|
||||
<Badge size="xs" color="blue" variant="light" leftSection={<InfoIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.embeddedSubset} {t('pdfJsonEditor.fontAnalysis.subset', 'subset')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.systemFallback > 0 && (
|
||||
<Badge size="xs" color="yellow" variant="light" leftSection={<WarningIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.systemFallback} {t('pdfJsonEditor.fontAnalysis.fallback', 'fallback')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.missing > 0 && (
|
||||
<Badge size="xs" color="red" variant="light" leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.missing} {t('pdfJsonEditor.fontAnalysis.missing', 'missing')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Font List */}
|
||||
<Stack gap={4} mt="xs">
|
||||
{fonts.map((font) => (
|
||||
<FontDetailItem key={font.fontId} analysis={font} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontStatusPanel;
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
@ -39,6 +40,7 @@ import {
|
||||
ConversionProgress,
|
||||
} from '@app/tools/pdfJsonEditor/pdfJsonEditorTypes';
|
||||
import { getImageBounds, pageDimensions } from '@app/tools/pdfJsonEditor/pdfJsonEditorUtils';
|
||||
import FontStatusPanel from '@app/components/tools/pdfJsonEditor/FontStatusPanel';
|
||||
|
||||
const MAX_RENDER_WIDTH = 820;
|
||||
const MIN_BOX_SIZE = 18;
|
||||
@ -995,17 +997,27 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
withBorder
|
||||
radius="md"
|
||||
shadow="xs"
|
||||
padding="lg"
|
||||
style={{ gridColumn: '2 / 3', gridRow: 1, position: 'sticky', top: '1.5rem', zIndex: 2 }}
|
||||
padding="md"
|
||||
style={{
|
||||
gridColumn: '2 / 3',
|
||||
gridRow: 1,
|
||||
maxHeight: 'calc(100vh - 3rem)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs" align="center">
|
||||
<DescriptionIcon fontSize="small" />
|
||||
<Title order={3}>{t('pdfJsonEditor.title', 'PDF JSON Editor')}</Title>
|
||||
{hasChanges && <Badge color="yellow" size="sm">{t('pdfJsonEditor.badges.unsaved', 'Edited')}</Badge>}
|
||||
<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('pdfJsonEditor.title', 'PDF JSON Editor')}</Title>
|
||||
{hasChanges && <Badge color="yellow" size="sm">{t('pdfJsonEditor.badges.unsaved', 'Edited')}</Badge>}
|
||||
</Group>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
|
||||
<Stack gap="xs">
|
||||
<FileButton onChange={onLoadJson} accept="application/pdf,application/json,.pdf,.json">
|
||||
{(props) => (
|
||||
<Button
|
||||
@ -1047,133 +1059,139 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
|
||||
{t('pdfJsonEditor.actions.generatePdf', 'Generate PDF')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{fileName && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('pdfJsonEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('pdfJsonEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
|
||||
{fileName && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('pdfJsonEditor.currentFile', 'Current file: {{name}}', { name: fileName })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'pdfJsonEditor.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('pdfJsonEditor.options.groupingMode.title', 'Text Grouping Mode')}
|
||||
</Text>
|
||||
{externalGroupingMode === 'auto' && isParagraphPage && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
{t('pdfJsonEditor.pageType.paragraph', 'Paragraph page')}
|
||||
</Badge>
|
||||
)}
|
||||
{externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t('pdfJsonEditor.pageType.sparse', 'Sparse text')}
|
||||
</Badge>
|
||||
)}
|
||||
<Divider my="xs" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('pdfJsonEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t(
|
||||
'pdfJsonEditor.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>
|
||||
<Text size="xs" c="dimmed">
|
||||
{externalGroupingMode === 'auto'
|
||||
? t(
|
||||
'pdfJsonEditor.options.groupingMode.autoDescription',
|
||||
'Automatically detects page type and groups text appropriately.'
|
||||
)
|
||||
: externalGroupingMode === 'paragraph'
|
||||
? t(
|
||||
'pdfJsonEditor.options.groupingMode.paragraphDescription',
|
||||
'Groups aligned lines into multi-line paragraph text boxes.'
|
||||
)
|
||||
: t(
|
||||
'pdfJsonEditor.options.groupingMode.singleLineDescription',
|
||||
'Keeps each PDF text line as a separate text box.'
|
||||
)}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={externalGroupingMode}
|
||||
onChange={(value) => onGroupingModeChange(value as GroupingMode)}
|
||||
data={[
|
||||
{ label: t('pdfJsonEditor.groupingMode.auto', 'Auto'), value: 'auto' },
|
||||
{ label: t('pdfJsonEditor.groupingMode.paragraph', 'Paragraph'), value: 'paragraph' },
|
||||
{ label: t('pdfJsonEditor.groupingMode.singleLine', 'Single Line'), value: 'singleLine' },
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<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.'
|
||||
<Stack gap="xs">
|
||||
<Group gap={4} align="center">
|
||||
<Text fw={500} size="sm">
|
||||
{t('pdfJsonEditor.options.groupingMode.title', 'Text Grouping Mode')}
|
||||
</Text>
|
||||
{externalGroupingMode === 'auto' && isParagraphPage && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
{t('pdfJsonEditor.pageType.paragraph', 'Paragraph page')}
|
||||
</Badge>
|
||||
)}
|
||||
{externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t('pdfJsonEditor.pageType.sparse', 'Sparse text')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{externalGroupingMode === 'auto'
|
||||
? t(
|
||||
'pdfJsonEditor.options.groupingMode.autoDescription',
|
||||
'Automatically detects page type and groups text appropriately.'
|
||||
)
|
||||
: externalGroupingMode === 'paragraph'
|
||||
? t(
|
||||
'pdfJsonEditor.options.groupingMode.paragraphDescription',
|
||||
'Groups aligned lines into multi-line paragraph text boxes.'
|
||||
)
|
||||
: t(
|
||||
'pdfJsonEditor.options.groupingMode.singleLineDescription',
|
||||
'Keeps each PDF text line as a separate text box.'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
checked={forceSingleTextElement}
|
||||
onChange={(event) => onForceSingleTextElementChange(event.currentTarget.checked)}
|
||||
/>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
value={externalGroupingMode}
|
||||
onChange={(value) => onGroupingModeChange(value as GroupingMode)}
|
||||
data={[
|
||||
{ label: t('pdfJsonEditor.groupingMode.auto', 'Auto'), value: 'auto' },
|
||||
{ label: t('pdfJsonEditor.groupingMode.paragraph', 'Paragraph'), value: 'paragraph' },
|
||||
{ label: t('pdfJsonEditor.groupingMode.singleLine', 'Single Line'), value: 'singleLine' },
|
||||
]}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
</Stack>
|
||||
<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>
|
||||
|
||||
<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('pdfJsonEditor.disclaimer.heading', 'Preview Limitations')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs">
|
||||
{t(
|
||||
'pdfJsonEditor.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(
|
||||
'pdfJsonEditor.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(
|
||||
'pdfJsonEditor.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>
|
||||
|
||||
<Alert
|
||||
icon={<InfoOutlinedIcon fontSize="small" />}
|
||||
color="yellow"
|
||||
radius="md"
|
||||
variant="light"
|
||||
style={{ gridColumn: '2 / 3' }}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text fw={600}>
|
||||
{t('pdfJsonEditor.disclaimer.heading', 'Preview limitations')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'pdfJsonEditor.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="sm">
|
||||
{t(
|
||||
'pdfJsonEditor.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="sm">
|
||||
{t(
|
||||
'pdfJsonEditor.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>
|
||||
</Alert>
|
||||
|
||||
{hasDocument && (
|
||||
<Card
|
||||
withBorder
|
||||
|
||||
421
frontend/src/proprietary/tools/pdfJsonEditor/fontAnalysis.ts
Normal file
421
frontend/src/proprietary/tools/pdfJsonEditor/fontAnalysis.ts
Normal file
@ -0,0 +1,421 @@
|
||||
import { PdfJsonDocument, PdfJsonFont } from './pdfJsonEditorTypes';
|
||||
|
||||
export type FontStatus = 'perfect' | 'embedded-subset' | 'system-fallback' | 'missing' | 'unknown';
|
||||
|
||||
export interface FontAnalysis {
|
||||
fontId: string;
|
||||
baseName: string;
|
||||
status: FontStatus;
|
||||
embedded: boolean;
|
||||
isSubset: boolean;
|
||||
isStandard14: boolean;
|
||||
hasWebFormat: boolean;
|
||||
webFormat?: string;
|
||||
subtype?: string;
|
||||
encoding?: string;
|
||||
warnings: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface DocumentFontAnalysis {
|
||||
fonts: FontAnalysis[];
|
||||
canReproducePerfectly: boolean;
|
||||
hasWarnings: boolean;
|
||||
summary: {
|
||||
perfect: number;
|
||||
embeddedSubset: number;
|
||||
systemFallback: number;
|
||||
missing: number;
|
||||
unknown: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a font name indicates it's a subset font.
|
||||
* Subset fonts typically have a 6-character prefix like "ABCDEE+"
|
||||
*/
|
||||
const isSubsetFont = (baseName: string | null | undefined): boolean => {
|
||||
if (!baseName) return false;
|
||||
// Check for common subset patterns: ABCDEF+FontName
|
||||
return /^[A-Z]{6}\+/.test(baseName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font is one of the standard 14 PDF fonts that are guaranteed
|
||||
* to be available on all PDF readers
|
||||
*/
|
||||
const isStandard14Font = (font: PdfJsonFont): boolean => {
|
||||
if (font.standard14Name) return true;
|
||||
|
||||
const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
|
||||
const standard14Patterns = [
|
||||
'timesroman', 'timesbold', 'timesitalic', 'timesbolditalic',
|
||||
'helvetica', 'helveticabold', 'helveticaoblique', 'helveticaboldoblique',
|
||||
'courier', 'courierbold', 'courieroblique', 'courierboldoblique',
|
||||
'symbol', 'zapfdingbats'
|
||||
];
|
||||
|
||||
// Check exact matches or if the base name contains the pattern
|
||||
return standard14Patterns.some(pattern => {
|
||||
// Exact match
|
||||
if (baseName === pattern) return true;
|
||||
// Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica")
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font has a fallback available on the backend.
|
||||
* These fonts are embedded in the Stirling PDF backend and can be used
|
||||
* for PDF export even if not in the original PDF.
|
||||
*
|
||||
* Based on PdfJsonFallbackFontService.java
|
||||
*/
|
||||
const hasBackendFallbackFont = (font: PdfJsonFont): boolean => {
|
||||
const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
|
||||
// Backend has these font families available (from PdfJsonFallbackFontService)
|
||||
const backendFonts = [
|
||||
// Liberation fonts (metric-compatible with MS core fonts)
|
||||
'arial', 'helvetica', 'arimo',
|
||||
'times', 'timesnewroman', 'tinos',
|
||||
'courier', 'couriernew', 'cousine',
|
||||
'liberation', 'liberationsans', 'liberationserif', 'liberationmono',
|
||||
// DejaVu fonts
|
||||
'dejavu', 'dejavusans', 'dejavuserif', 'dejavumono', 'dejavusansmono',
|
||||
// Noto fonts
|
||||
'noto', 'notosans'
|
||||
];
|
||||
|
||||
return backendFonts.some(pattern => {
|
||||
if (baseName === pattern) return true;
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the base font name from a subset font name
|
||||
* e.g., "ABCDEF+Arial" -> "Arial"
|
||||
*/
|
||||
const extractBaseFontName = (baseName: string | null | undefined): string | null => {
|
||||
if (!baseName) return null;
|
||||
const match = baseName.match(/^[A-Z]{6}\+(.+)$/);
|
||||
return match ? match[1] : baseName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes a single font to determine if it can be reproduced perfectly
|
||||
* Takes allFonts to check if full versions of subset fonts are available
|
||||
*/
|
||||
export const analyzeFontReproduction = (font: PdfJsonFont, allFonts?: PdfJsonFont[]): FontAnalysis => {
|
||||
const fontId = font.id || font.uid || 'unknown';
|
||||
const baseName = font.baseName || 'Unknown Font';
|
||||
const isSubset = isSubsetFont(font.baseName);
|
||||
const isStandard14 = isStandard14Font(font);
|
||||
const hasBackendFallback = hasBackendFallbackFont(font);
|
||||
const embedded = font.embedded ?? false;
|
||||
|
||||
// Check available web formats (ordered by preference)
|
||||
const webFormats = [
|
||||
{ key: 'webProgram', format: font.webProgramFormat },
|
||||
{ key: 'pdfProgram', format: font.pdfProgramFormat },
|
||||
{ key: 'program', format: font.programFormat },
|
||||
];
|
||||
|
||||
const availableWebFormat = webFormats.find(f => f.format);
|
||||
const hasWebFormat = !!availableWebFormat;
|
||||
const webFormat = availableWebFormat?.format || undefined;
|
||||
|
||||
const warnings: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
let status: FontStatus = 'unknown';
|
||||
|
||||
// Check if we have the full font when this is a subset
|
||||
let hasFullFontVersion = false;
|
||||
if (isSubset && allFonts) {
|
||||
const baseFont = extractBaseFontName(font.baseName);
|
||||
if (baseFont) {
|
||||
// Look for a non-subset version of this font with a web format
|
||||
hasFullFontVersion = allFonts.some(f => {
|
||||
const otherBaseName = extractBaseFontName(f.baseName);
|
||||
const isNotSubset = !isSubsetFont(f.baseName);
|
||||
const hasFormat = !!(f.webProgramFormat || f.pdfProgramFormat || f.programFormat);
|
||||
const sameBase = otherBaseName?.toLowerCase() === baseFont.toLowerCase();
|
||||
return sameBase && isNotSubset && hasFormat && (f.embedded ?? false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze font status - focusing on PDF export quality
|
||||
if (isStandard14) {
|
||||
// Standard 14 fonts are always available in PDF readers - perfect for export!
|
||||
status = 'perfect';
|
||||
suggestions.push('Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.');
|
||||
suggestions.push('Exported PDFs will render consistently across all PDF readers.');
|
||||
} else if (embedded && !isSubset) {
|
||||
// Perfect: Fully embedded with complete character set
|
||||
status = 'perfect';
|
||||
suggestions.push('Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.');
|
||||
} else if (embedded && isSubset && (hasFullFontVersion || hasBackendFallback)) {
|
||||
// Subset but we have the full font or backend fallback - perfect!
|
||||
status = 'perfect';
|
||||
if (hasFullFontVersion) {
|
||||
suggestions.push('Full font version is also available in the document. Exported PDFs can reproduce all characters.');
|
||||
} else if (hasBackendFallback) {
|
||||
suggestions.push('Backend has the full font available. Exported PDFs can reproduce all characters, including new text.');
|
||||
}
|
||||
} else if (embedded && isSubset) {
|
||||
// Good, but subset: May have missing characters if user adds new text
|
||||
status = 'embedded-subset';
|
||||
warnings.push('This is a subset font - only specific characters are embedded in the PDF.');
|
||||
warnings.push('Exported PDFs may have missing characters if you add new text with this font.');
|
||||
suggestions.push('Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.');
|
||||
} else if (!embedded && hasBackendFallback) {
|
||||
// Not embedded, but backend has it - perfect for export!
|
||||
status = 'perfect';
|
||||
suggestions.push('Backend has this font available. Exported PDFs will use the backend fallback font.');
|
||||
suggestions.push('Text will export correctly with consistent appearance.');
|
||||
} else if (!embedded) {
|
||||
// Not embedded - must rely on system fonts (risky for export)
|
||||
status = 'missing';
|
||||
warnings.push('Font is not embedded in the PDF.');
|
||||
warnings.push('Exported PDFs will substitute with a fallback font, which may look very different.');
|
||||
suggestions.push('Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.');
|
||||
} else if (embedded && !hasWebFormat) {
|
||||
// Embedded but no web format available (still okay for export)
|
||||
status = 'perfect';
|
||||
suggestions.push('Font is embedded in the PDF. Exported PDFs will reproduce correctly.');
|
||||
suggestions.push('Web preview may use a fallback font, but the final PDF export will be accurate.');
|
||||
}
|
||||
|
||||
// Additional warnings based on font properties
|
||||
if (font.subtype === 'Type0' && font.cidSystemInfo) {
|
||||
const registry = font.cidSystemInfo.registry || '';
|
||||
const ordering = font.cidSystemInfo.ordering || '';
|
||||
if (registry.includes('Adobe') && (ordering.includes('Identity') || ordering.includes('UCS'))) {
|
||||
// CID fonts with Identity encoding are common for Asian languages
|
||||
if (!embedded || !hasWebFormat) {
|
||||
warnings.push('This CID font may contain Asian or Unicode characters.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (font.encoding && !font.encoding.includes('WinAnsiEncoding') && !font.encoding.includes('MacRomanEncoding')) {
|
||||
// Custom encodings may cause issues
|
||||
if (font.encoding !== 'Identity-H' && font.encoding !== 'Identity-V') {
|
||||
warnings.push(`Custom encoding detected: ${font.encoding}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fontId,
|
||||
baseName,
|
||||
status,
|
||||
embedded,
|
||||
isSubset,
|
||||
isStandard14,
|
||||
hasWebFormat,
|
||||
webFormat,
|
||||
subtype: font.subtype || undefined,
|
||||
encoding: font.encoding || undefined,
|
||||
warnings,
|
||||
suggestions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets fonts used on a specific page
|
||||
*/
|
||||
export const getFontsForPage = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex: number
|
||||
): PdfJsonFont[] => {
|
||||
if (!document?.fonts || !document?.pages || pageIndex < 0 || pageIndex >= document.pages.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const page = document.pages[pageIndex];
|
||||
if (!page?.textElements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get unique font IDs used on this page
|
||||
const fontIdsOnPage = new Set<string>();
|
||||
page.textElements.forEach(element => {
|
||||
if (element?.fontId) {
|
||||
fontIdsOnPage.add(element.fontId);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter fonts to only those used on this page
|
||||
const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined);
|
||||
|
||||
const fontsOnPage = allFonts.filter(font => {
|
||||
// Match by ID
|
||||
if (font.id && fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
// Match by UID
|
||||
if (font.uid && fontIdsOnPage.has(font.uid)) {
|
||||
return true;
|
||||
}
|
||||
// Match by page-specific ID (pageNumber:id format)
|
||||
if (font.pageNumber === pageIndex + 1 && font.id) {
|
||||
const pageSpecificId = `${font.pageNumber}:${font.id}`;
|
||||
if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Deduplicate by base font name to avoid showing the same font multiple times
|
||||
const uniqueFonts = new Map<string, PdfJsonFont>();
|
||||
fontsOnPage.forEach(font => {
|
||||
const baseName = extractBaseFontName(font.baseName) || font.baseName || font.id || 'unknown';
|
||||
const key = baseName.toLowerCase();
|
||||
|
||||
// Keep the first occurrence, or prefer non-subset over subset
|
||||
const existing = uniqueFonts.get(key);
|
||||
if (!existing) {
|
||||
uniqueFonts.set(key, font);
|
||||
} else {
|
||||
// Prefer non-subset fonts over subset fonts
|
||||
const existingIsSubset = isSubsetFont(existing.baseName);
|
||||
const currentIsSubset = isSubsetFont(font.baseName);
|
||||
if (existingIsSubset && !currentIsSubset) {
|
||||
uniqueFonts.set(key, font);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueFonts.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes all fonts in a PDF document (or just fonts for a specific page)
|
||||
*/
|
||||
export const analyzeDocumentFonts = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex?: number
|
||||
): DocumentFontAnalysis => {
|
||||
if (!document?.fonts || document.fonts.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined);
|
||||
|
||||
// Filter to page-specific fonts if pageIndex is provided
|
||||
const fontsToAnalyze = pageIndex !== undefined
|
||||
? getFontsForPage(document, pageIndex)
|
||||
: allFonts;
|
||||
|
||||
if (fontsToAnalyze.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fontAnalyses = fontsToAnalyze.map(font => analyzeFontReproduction(font, allFonts));
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
perfect: fontAnalyses.filter(f => f.status === 'perfect').length,
|
||||
embeddedSubset: fontAnalyses.filter(f => f.status === 'embedded-subset').length,
|
||||
systemFallback: fontAnalyses.filter(f => f.status === 'system-fallback').length,
|
||||
missing: fontAnalyses.filter(f => f.status === 'missing').length,
|
||||
unknown: fontAnalyses.filter(f => f.status === 'unknown').length,
|
||||
};
|
||||
|
||||
// Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets)
|
||||
const canReproducePerfectly = fontAnalyses.every(f => f.status === 'perfect');
|
||||
|
||||
// Has warnings if any font has issues (including subsets)
|
||||
const hasWarnings = fontAnalyses.some(
|
||||
f => f.warnings.length > 0 || f.status === 'missing' || f.status === 'system-fallback' || f.status === 'embedded-subset'
|
||||
);
|
||||
|
||||
return {
|
||||
fonts: fontAnalyses,
|
||||
canReproducePerfectly,
|
||||
hasWarnings,
|
||||
summary,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a human-readable description of the font status
|
||||
*/
|
||||
export const getFontStatusDescription = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return 'Fully embedded - perfect reproduction';
|
||||
case 'embedded-subset':
|
||||
return 'Embedded (subset) - existing text will render correctly';
|
||||
case 'system-fallback':
|
||||
return 'Using system font - appearance may differ';
|
||||
case 'missing':
|
||||
return 'Not embedded - will use fallback font';
|
||||
case 'unknown':
|
||||
return 'Unknown status';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a color indicator for the font status
|
||||
*/
|
||||
export const getFontStatusColor = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return 'green';
|
||||
case 'embedded-subset':
|
||||
return 'blue';
|
||||
case 'system-fallback':
|
||||
return 'yellow';
|
||||
case 'missing':
|
||||
return 'red';
|
||||
case 'unknown':
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an icon indicator for the font status
|
||||
*/
|
||||
export const getFontStatusIcon = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return '✓';
|
||||
case 'embedded-subset':
|
||||
return '⚠';
|
||||
case 'system-fallback':
|
||||
return '⚠';
|
||||
case 'missing':
|
||||
return '✗';
|
||||
case 'unknown':
|
||||
return '?';
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user