diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 86e105329..5517000b8 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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": { diff --git a/frontend/src/proprietary/components/tools/pdfJsonEditor/FontStatusPanel.tsx b/frontend/src/proprietary/components/tools/pdfJsonEditor/FontStatusPanel.tsx new file mode 100644 index 000000000..88bdf6a41 --- /dev/null +++ b/frontend/src/proprietary/components/tools/pdfJsonEditor/FontStatusPanel.tsx @@ -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 ; + case 'embedded-subset': + return ; + case 'system-fallback': + return ; + case 'missing': + return ; + default: + return ; + } + }, [analysis.status]); + + return ( + + + {analysis.status.replace('-', ' ')} + + + ); +}; + +const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + return ( + setExpanded(!expanded)}> + + + + + + {analysis.baseName} + + {analysis.isSubset && ( + + subset + + )} + + + + {expanded ? : } + + + + + + {/* Font Details */} + + + {t('pdfJsonEditor.fontAnalysis.details', 'Font Details')}: + + + + + {t('pdfJsonEditor.fontAnalysis.embedded', 'Embedded')}: + + {analysis.embedded ? 'Yes' : 'No'} + + {analysis.subtype && ( + + + {t('pdfJsonEditor.fontAnalysis.type', 'Type')}: + + {analysis.subtype} + + )} + {analysis.webFormat && ( + + + {t('pdfJsonEditor.fontAnalysis.webFormat', 'Web Format')}: + + {analysis.webFormat} + + )} + + + + {/* Warnings */} + {analysis.warnings.length > 0 && ( + + + {t('pdfJsonEditor.fontAnalysis.warnings', 'Warnings')}: + + + {analysis.warnings.map((warning, index) => ( + + {warning} + + ))} + + + )} + + {/* Suggestions */} + {analysis.suggestions.length > 0 && ( + + + {t('pdfJsonEditor.fontAnalysis.suggestions', 'Notes')}: + + + {analysis.suggestions.map((suggestion, index) => ( + + {suggestion} + + ))} + + + )} + + + + + ); +}; + +const FontStatusPanel: React.FC = ({ 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 ; + } + if (hasWarnings) { + return ; + } + return ; + }, [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 ( + + + + + {statusIcon} + + {pageLabel} + + + {fonts.length} + + + + + + {/* Overall Status Message */} + + {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.' + )} + + + {/* Summary Statistics */} + + {summary.perfect > 0 && ( + }> + {summary.perfect} {t('pdfJsonEditor.fontAnalysis.perfect', 'perfect')} + + )} + {summary.embeddedSubset > 0 && ( + }> + {summary.embeddedSubset} {t('pdfJsonEditor.fontAnalysis.subset', 'subset')} + + )} + {summary.systemFallback > 0 && ( + }> + {summary.systemFallback} {t('pdfJsonEditor.fontAnalysis.fallback', 'fallback')} + + )} + {summary.missing > 0 && ( + }> + {summary.missing} {t('pdfJsonEditor.fontAnalysis.missing', 'missing')} + + )} + + + {/* Font List */} + + {fonts.map((font) => ( + + ))} + + + + + + ); +}; + +export default FontStatusPanel; diff --git a/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx b/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx index 6b2aee7d5..a91f6cffa 100644 --- a/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx +++ b/frontend/src/proprietary/components/tools/pdfJsonEditor/PdfJsonEditorView.tsx @@ -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' + }} > - - - - - {t('pdfJsonEditor.title', 'PDF JSON Editor')} - {hasChanges && {t('pdfJsonEditor.badges.unsaved', 'Edited')}} + + + + + + {t('pdfJsonEditor.title', 'PDF JSON Editor')} + {hasChanges && {t('pdfJsonEditor.badges.unsaved', 'Edited')}} + - + + {(props) => ( - - {fileName && ( - - {t('pdfJsonEditor.currentFile', 'Current file: {{name}}', { name: fileName })} - - )} - - - - -
- - {t('pdfJsonEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')} + {fileName && ( + + {t('pdfJsonEditor.currentFile', 'Current file: {{name}}', { name: fileName })} - - {t( - 'pdfJsonEditor.options.autoScaleText.description', - 'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.' - )} - -
- setAutoScaleText(event.currentTarget.checked)} - /> -
+ )} - - - - {t('pdfJsonEditor.options.groupingMode.title', 'Text Grouping Mode')} - - {externalGroupingMode === 'auto' && isParagraphPage && ( - - {t('pdfJsonEditor.pageType.paragraph', 'Paragraph page')} - - )} - {externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && ( - - {t('pdfJsonEditor.pageType.sparse', 'Sparse text')} - - )} + + + +
+ + {t('pdfJsonEditor.options.autoScaleText.title', 'Auto-scale text to fit boxes')} + + + {t( + 'pdfJsonEditor.options.autoScaleText.description', + 'Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF.' + )} + +
+ setAutoScaleText(event.currentTarget.checked)} + />
- - {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.' - )} - - 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 - /> -
- -
- - {t('pdfJsonEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')} - - - {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.' + + + + {t('pdfJsonEditor.options.groupingMode.title', 'Text Grouping Mode')} + + {externalGroupingMode === 'auto' && isParagraphPage && ( + + {t('pdfJsonEditor.pageType.paragraph', 'Paragraph page')} + )} + {externalGroupingMode === 'auto' && !isParagraphPage && hasDocument && ( + + {t('pdfJsonEditor.pageType.sparse', 'Sparse text')} + + )} + + + {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.' + )} -
- onForceSingleTextElementChange(event.currentTarget.checked)} - /> -
+ 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 + /> +
- + +
+ + {t('pdfJsonEditor.options.forceSingleElement.title', 'Lock edited text to a single PDF element')} + + + {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.' + )} + +
+ onForceSingleTextElementChange(event.currentTarget.checked)} + /> +
+ + + + + + + + + + {t('pdfJsonEditor.disclaimer.heading', 'Preview Limitations')} + + + + + + + {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.' + )} + + + {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.' + )} + + + {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.' + )} + + + + + + + {hasDocument && } + + - } - color="yellow" - radius="md" - variant="light" - style={{ gridColumn: '2 / 3' }} - > - - - {t('pdfJsonEditor.disclaimer.heading', 'Preview limitations')} - - - {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.' - )} - - - {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.' - )} - - - {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.' - )} - - - - {hasDocument && ( { + 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(); + 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(); + 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 '?'; + } +};