diff --git a/src/App.svelte b/src/App.svelte index 6c93c93..53136b6 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -31,6 +31,7 @@ import { t } from './lib/i18n/i18n.svelte'; import { withShortcut } from './lib/i18n/shortcut'; import { localizeImportMessage } from './lib/i18n/importMessages'; + import { unavailableFonts } from './lib/utils/fontDetect'; let editor: Editor | null = $state(null); let tick: number = $state(0); @@ -331,6 +332,18 @@ // Replace the document with a parsed .odt; adopt its geometry/header/footer and // track the source handle (null for the fallback file input) so Save overwrites it. + // Distinct explicit fontFamily values (textStyle marks) anywhere in a TipTap JSON tree. + function collectFontFamilies(node: unknown, out: Set): void { + if (!node || typeof node !== 'object') return; + const n = node as { marks?: { type?: string; attrs?: { fontFamily?: unknown } }[]; content?: unknown[] }; + if (Array.isArray(n.marks)) { + for (const m of n.marks) { + if (m?.type === 'textStyle' && typeof m.attrs?.fontFamily === 'string') out.add(m.attrs.fontFamily); + } + } + if (Array.isArray(n.content)) for (const c of n.content) collectFontFamilies(c, out); + } + async function applyImport(bytes: Uint8Array, handle: FileSystemFileHandle | null, sourceName?: string) { if (!editor) return; try { @@ -369,9 +382,19 @@ }; fileHandle = isTemplate ? null : handle; - if (result.warnings.length) { - console.warn('[import] Unsupported content in opened file:', result.warnings); - alert(t().dialogs.openedWithLimitations(result.warnings.map(localizeImportMessage).join('\n• '))); + // Warn about fonts the document uses but the browser can't render, so text + // silently shown in a substitute (Liberation Serif) is at least flagged. + const fontSet = new Set(); + collectFontFamilies(result.content, fontSet); + collectFontFamilies(result.header, fontSet); + collectFontFamilies(result.footer, fontSet); + const missingFonts = await unavailableFonts(fontSet); + + const warnings = result.warnings.map(localizeImportMessage); + if (missingFonts.length) warnings.push(t().importWarn.missingFonts(missingFonts.join(', '))); + if (warnings.length) { + console.warn('[import] Opened file with limitations:', result.warnings, missingFonts); + alert(t().dialogs.openedWithLimitations(warnings.join('\n• '))); } } catch (err) { console.error('[import] Failed to open file:', err); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index f46e043..2b869ac 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -356,6 +356,7 @@ const de: Messages = { nestedTables: 'Verschachtelte Tabellen wurden in Absätze umgewandelt', footnotes: 'Fuß- und Endnoten wurden entfernt', comments: 'Kommentare wurden entfernt', + missingFonts: (fonts) => `Diese Schriftarten sind nicht installiert und wurden durch Ersatzschriften dargestellt: ${fonts}`, }, importError: { odtArchive: 'Keine gültige .odt-Datei (Archiv konnte nicht gelesen werden).', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 291529d..0353142 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -361,6 +361,7 @@ const en = { nestedTables: 'Nested tables were flattened to paragraphs', footnotes: 'Footnotes and endnotes were removed', comments: 'Comments were removed', + missingFonts: (fonts: string) => `These fonts aren't installed and were shown with a substitute: ${fonts}`, }, importError: { odtArchive: 'Not a valid .odt file (could not read the archive).', diff --git a/src/lib/utils/fontDetect.ts b/src/lib/utils/fontDetect.ts index f2fab43..d200e59 100644 --- a/src/lib/utils/fontDetect.ts +++ b/src/lib/utils/fontDetect.ts @@ -106,6 +106,35 @@ export function detectAvailableFonts(candidates: readonly string[]): string[] { return found; } +// Families the app always renders via bundled @font-face twins, plus CSS generics — +// never flagged as missing. The bundled names must match the @font-face in global.css. +const BUNDLED_FONTS = ['Liberation Serif', 'Times New Roman', 'Calibri', 'Arial', 'Cambria', 'Courier New']; +const GENERIC_FAMILIES = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui']; + +// Of the given family names, those the browser can't render (so text declaring them is +// silently substituted). Bundled families and generics are excluded; result is deduped. +export async function unavailableFonts(families: Iterable): Promise { + const ctx = getCtx(); + if (!ctx) return []; + if (typeof document !== 'undefined' && document.fonts) { + try { + await Promise.all(BUNDLED_FONTS.map((f) => document.fonts.load(`12px "${f}"`))); + } catch { /* measure with whatever loaded */ } + } + baselineWidths = null; + const skip = new Set([...BUNDLED_FONTS, ...GENERIC_FAMILIES].map((f) => f.toLowerCase())); + const seen = new Set(); + const missing: string[] = []; + for (const raw of families) { + const family = raw.trim(); + const key = family.toLowerCase(); + if (!family || seen.has(key) || skip.has(key)) continue; + seen.add(key); + if (!isAvailable(ctx, family)) missing.push(family); + } + return missing; +} + interface LocalFontData { family: string } interface QueryLocalFontsWindow { queryLocalFonts?: () => Promise;