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", "metadata": "Metadata",
"serializing": "Finalizing", "serializing": "Finalizing",
"complete": "Complete" "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": { "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 React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { import {
Accordion,
ActionIcon, ActionIcon,
Alert, Alert,
Badge, Badge,
@ -39,6 +40,7 @@ import {
ConversionProgress, ConversionProgress,
} from '@app/tools/pdfJsonEditor/pdfJsonEditorTypes'; } from '@app/tools/pdfJsonEditor/pdfJsonEditorTypes';
import { getImageBounds, pageDimensions } from '@app/tools/pdfJsonEditor/pdfJsonEditorUtils'; import { getImageBounds, pageDimensions } from '@app/tools/pdfJsonEditor/pdfJsonEditorUtils';
import FontStatusPanel from '@app/components/tools/pdfJsonEditor/FontStatusPanel';
const MAX_RENDER_WIDTH = 820; const MAX_RENDER_WIDTH = 820;
const MIN_BOX_SIZE = 18; const MIN_BOX_SIZE = 18;
@ -995,17 +997,27 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
withBorder withBorder
radius="md" radius="md"
shadow="xs" shadow="xs"
padding="lg" padding="md"
style={{ gridColumn: '2 / 3', gridRow: 1, position: 'sticky', top: '1.5rem', zIndex: 2 }} style={{
gridColumn: '2 / 3',
gridRow: 1,
maxHeight: 'calc(100vh - 3rem)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
> >
<Stack gap="sm"> <ScrollArea style={{ flex: 1 }} offsetScrollbars>
<Stack gap="sm" pb="md">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<DescriptionIcon fontSize="small" /> <DescriptionIcon fontSize="small" />
<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>
<Stack gap="sm"> </Group>
<Stack gap="xs">
<FileButton onChange={onLoadJson} accept="application/pdf,application/json,.pdf,.json"> <FileButton onChange={onLoadJson} accept="application/pdf,application/json,.pdf,.json">
{(props) => ( {(props) => (
<Button <Button
@ -1047,7 +1059,6 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
{t('pdfJsonEditor.actions.generatePdf', 'Generate PDF')} {t('pdfJsonEditor.actions.generatePdf', 'Generate PDF')}
</Button> </Button>
</Stack> </Stack>
</Group>
{fileName && ( {fileName && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@ -1055,7 +1066,7 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
</Text> </Text>
)} )}
<Divider my="sm" /> <Divider my="xs" />
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<div> <div>
@ -1139,40 +1150,47 @@ const PdfJsonEditorView = ({ data }: PdfJsonEditorViewProps) => {
/> />
</Group> </Group>
</Stack> <Divider my="xs" />
</Card>
<Alert <Accordion variant="contained">
icon={<InfoOutlinedIcon fontSize="small" />} <Accordion.Item value="disclaimer">
color="yellow" <Accordion.Control>
radius="md" <Group gap="xs" wrap="nowrap">
variant="light" <InfoOutlinedIcon fontSize="small" />
style={{ gridColumn: '2 / 3' }} <Text size="sm" fw={500}>
> {t('pdfJsonEditor.disclaimer.heading', 'Preview Limitations')}
<Stack gap={4}>
<Text fw={600}>
{t('pdfJsonEditor.disclaimer.heading', 'Preview limitations')}
</Text> </Text>
<Text size="sm"> </Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap={4}>
<Text size="xs">
{t( {t(
'pdfJsonEditor.disclaimer.textFocus', '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.' '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>
<Text size="sm"> <Text size="xs">
{t( {t(
'pdfJsonEditor.disclaimer.previewVariance', '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.' '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>
<Text size="sm"> <Text size="xs">
{t( {t(
'pdfJsonEditor.disclaimer.alpha', '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.' '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> </Text>
</Stack> </Stack>
</Alert> </Accordion.Panel>
</Accordion.Item>
</Accordion>
{hasDocument && <FontStatusPanel document={pdfDocument} pageIndex={selectedPage} />}
</Stack>
</ScrollArea>
</Card>
{hasDocument && ( {hasDocument && (
<Card <Card

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