font detection

This commit is contained in:
Anthony Stirling 2025-11-11 15:37:29 +00:00
parent 2d8113b3f6
commit 84c0962b4c
4 changed files with 860 additions and 126 deletions

View File

@ -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": {

View File

@ -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;

View File

@ -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

View 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 '?';
}
};