diff --git a/src/App.svelte b/src/App.svelte index 7e6c5bc..40d06cf 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -76,8 +76,9 @@ import { OPEN_LINK_DIALOG_EVENT } from './lib/editor/extensions/link'; import { localizeImportMessage } from './lib/i18n/importMessages'; import { unavailableFonts } from './lib/utils/fontDetect'; - import { registerEmbeddedFonts, clearEmbeddedFonts } from './lib/fonts/embeddedFonts'; + import { registerEmbeddedFonts, clearEmbeddedFonts, embeddedFonts } from './lib/fonts/embeddedFonts'; import { saveEmbeddedFonts, loadEmbeddedFonts, clearEmbeddedFontStore } from './lib/storage/embeddedFontStore'; + import { noteEmbeddedFonts } from './lib/components/ribbon/fontList.svelte'; // launchQueue is not in lib.dom yet; reach it through this shape. type WithLaunchQueue = Window & { @@ -675,6 +676,7 @@ setStyleSheet(builtinStyleSheet()); setNoteSettings(DEFAULT_NOTE_SETTINGS); clearEmbeddedFonts(); + noteEmbeddedFonts([]); void clearEmbeddedFontStore(); } @@ -815,6 +817,7 @@ // rendering, so its text shows in the right face and isn't flagged as missing below. await registerEmbeddedFonts(result.fonts); void saveEmbeddedFonts(result.fonts); + noteEmbeddedFonts(result.fonts.map((f) => f.family)); loadContent(result.content); // onUpdate fires → autosave documentEpoch++; @@ -972,7 +975,7 @@ // Both exporters take the same document-wide arguments, and every save path needs // one of them. The exporter module loads on first use. function exportArgs() { - return [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart] as const; + return [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks, spacingAtPageStart, embeddedFonts()] as const; } async function buildBytes(kind: DocumentFormat, json: TiptapNode): Promise { @@ -1182,8 +1185,13 @@ onMount(() => { // Re-register the restored document's embedded fonts so it renders in the right face; - // FontFace load fires 'loadingdone', which Editor.svelte re-paginates on. - void loadEmbeddedFonts().then(registerEmbeddedFonts); + // FontFace load fires 'loadingdone', which Editor.svelte re-paginates on. A file opened + // while the store was still reading has its own, and registering would replace them. + void loadEmbeddedFonts().then((fonts) => { + if (embeddedFonts().length) return; + noteEmbeddedFonts(fonts.map((f) => f.family)); + void registerEmbeddedFonts(fonts); + }); // Shortcuts that must work regardless of focus and that suppress the browser's // own binding (save page, find, open, zoom). Everything editor-scoped lives in diff --git a/src/lib/components/ribbon/fontList.svelte.ts b/src/lib/components/ribbon/fontList.svelte.ts index 0317201..02e86c8 100644 --- a/src/lib/components/ribbon/fontList.svelte.ts +++ b/src/lib/components/ribbon/fontList.svelte.ts @@ -12,6 +12,7 @@ const RECENT_KEY = 'edentext-recent-fonts'; const MAX_RECENT = 5; let recents = $state(load()); +let embedded = $state([]); let detected = $state([]); let allInstalled = $state(null); let detectionRan = false; @@ -33,10 +34,16 @@ export function noteFontUse(font: string): void { try { localStorage.setItem(RECENT_KEY, JSON.stringify(recents)); } catch { /* quota or disabled */ } } +// The families the open document brought with it, registered via FontFace. Detection +// measures them as present, but they are in no candidate list, so they are named here. +export function noteEmbeddedFonts(families: string[]): void { + embedded = [...new Set(families)]; +} + // Everything installed that isn't already listed above, alphabetical. export function otherFonts(): string[] { const recentSet = new Set(recents); - return (allInstalled ?? detected) + return [...new Set([...embedded, ...(allInstalled ?? detected)])] .filter((f) => !WEB_SAFE_SET.has(f) && !recentSet.has(f)) .sort((a, b) => a.localeCompare(b)); } diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 25b10ba..61e4a02 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -47,6 +47,7 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **A text box's own parts, DOCX** (`applyTextBoxesDocx`): the `docx` package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes `word/media/tbx{n}.{ext}` plus a `Relationship` above the package's highest `rId` (Content_Types already declares the raster defaults), a list an ``/`` pair above its highest id, appended **before** the first `` because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a `TXBX_NUM` sentinel that the same pass substitutes. - **Templates** (`template.ts`): a `.ott`/`.dotx` is the built document with one label changed — ODF's package media type (in `mimetype` **and** in the manifest, the mimetype entry staying first and stored) and OOXML's content type for `/word/document.xml`. The template's format follows the document's (`handleSaveTemplate`). - **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `` (or `` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`. +- **Embedded fonts** (`applyEmbeddedFontsOdf`, `applyEmbeddedFontsDocx`): the faces the opened file carried (`fonts/embeddedFonts.ts`) are written back, or a document in a font nobody has installed would lose its look on the next machine. ODF stores plain TTFs under `Fonts/` with a manifest entry each and one `` per family whose `` names every cut (`loext:font-weight`/`-style`) — merged **into** the declaration odf-kit already wrote for the name, since a second one with the same `style:name` is invalid. Word obfuscates each part with a key of its own, which is the same XOR the importer undoes, so each cut gets a fresh GUID, a `word/fonts/font{n}.odttf`, a relationship in `word/_rels/fontTable.xml.rels` and its `` inside the family's `` in that element's schema order (Regular, Bold, Italic, BoldItalic). The `docx` package can embed too, but writes `w:embedRegular` only. - **`applyEndnoteImagesDocx`** — the `docx` package registers a footnote's pictures in `word/_rels/footnotes.xml.rels` and leaves the endnotes' `rId{file}` placeholders standing, so an endnote's picture pointed at a relationship no reader could follow. The pass mints the relationship in that part's rels and fills the id in. - **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks. - **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 23cce52..f880093 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -59,6 +59,7 @@ import { parseTableLook, regionText, type TableStyle } from '../styles/tableStyl import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime'; import { parseLatex } from '../math/latex'; import { ommlDocument, OMML_NS } from '../math/omml'; +import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; // BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from // the document language (Word recomputes an auto field on open using its own locale). @@ -1907,6 +1908,56 @@ function applyEndnoteImagesDocx(bytes: Uint8Array): Uint8Array { return zipSync(out); } +// Post-pack pass: the fonts the opened file carried, embedded again. A Word font part is +// obfuscated with a key of its own — the same XOR the importer undoes — so each cut gets a +// fresh GUID, a relationship, and its inside the family's . +function applyEmbeddedFontsDocx(bytes: Uint8Array, fonts: EmbeddedFont[]): Uint8Array { + if (!fonts.length) return bytes; + const files = unzipSync(bytes); + const relsPath = 'word/_rels/fontTable.xml.rels'; + const tableBytes = files['word/fontTable.xml']; + if (!tableBytes || !files[relsPath]) return bytes; + const rels = strFromU8(files[relsPath]).replace(/]*)\/>/, ''); + let next = maxIdIn(rels, /Id="rId(\d+)"/g) + 1; + const added: string[] = []; + // CT_Font is a sequence, so a family's cuts are written in this order. + const CUTS = ['embedRegular', 'embedBold', 'embedItalic', 'embedBoldItalic']; + const byFamily = new Map(); + fonts.forEach((font, i) => { + const name = `font${i + 1}.odttf`; + const key = `{${crypto.randomUUID().toUpperCase()}}`; + files[`word/fonts/${name}`] = deobfuscateOdttf(font.data.slice(), key) as Uint8Array; + added.push(``); + const rank = font.weight === 'bold' ? (font.style === 'italic' ? 3 : 1) : (font.style === 'italic' ? 2 : 0); + const list = byFamily.get(font.family) ?? []; + list.push([rank, ``]); + byFamily.set(font.family, list); + }); + + let table = strFromU8(tableBytes).replace(/]*)\/>/, ''); + for (const [family, cuts] of byFamily) { + const name = escapeXml(family); + const xml = cuts.sort((a, b) => a[0] - b[0]).map(([, x]) => x).join(''); + const quoted = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const empty = new RegExp(`]*)/>`); + const open = new RegExp(`(]*>[\\s\\S]*?)`); + if (empty.test(table)) table = table.replace(empty, `${xml}`); + else if (open.test(table)) table = table.replace(open, `$1${xml}`); + else table = table.replace('', `${xml}`); + } + files['word/fontTable.xml'] = strToU8(table); + files[relsPath] = strToU8(rels.replace('', added.join('') + '')); + + const ct = files['[Content_Types].xml'] && strFromU8(files['[Content_Types].xml']); + if (ct && !ct.includes('Extension="odttf"')) { + files['[Content_Types].xml'] = strToU8(ct.replace(/(]*>)/, + '$1')); + } + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + // Post-pack pass: the page's own decoration. Word keeps the background on w:document // (switched on in settings.xml), the border in every w:sectPr, and the watermark as a // VML fontwork shape in each header part — the shapes LibreOffice writes, probed. @@ -2885,6 +2936,7 @@ export async function buildDocx( recordChanges = false, foldMarks = false, spacingAtPageStart = true, + fonts: EmbeddedFont[] = [], ): Promise { docLangTag = localeTag(language ? language.language : 'en'); // Before the walk: every picture the file can hold has to be a raster by then, and @@ -3131,7 +3183,8 @@ export async function buildDocx( const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored); const dims = pageDimsCm(pageFormat, orientation); const foldMarked = applyFoldMarksDocx(bidi, foldMarks, dims.w * 10); - const marked = spacingAtPageStart ? foldMarked : applySpacingAtStartDocx(foldMarked); + const spaced = spacingAtPageStart ? foldMarked : applySpacingAtStartDocx(foldMarked); + const marked = applyEmbeddedFontsDocx(spaced, fonts); if (isEmptyPageDecor(decor)) return patchPackedXml(orderDocxSettings(marked)); const pt = (cm: number) => (cm / 2.54) * 72; return patchPackedXml(orderDocxSettings(applyPageDecorDocx(marked, decor, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index c4a1d41..294c1b8 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -13,6 +13,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor, type Watermark } from '../storage/pag import { FOLD_MARK_MM, PUNCH_MARK_MM, MARK_START_MM, FOLD_MARK_LEN_MM, PUNCH_MARK_LEN_MM, FOLD_MARK_NAME } from '../storage/foldMarks'; import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering'; import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps, type ParaProps } from '../styles/styleSheet'; +import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { HEADING_STYLE_OVERRIDES, HEADING_FONT, HEADING_LEVELS, MAX_HEADING_LEVEL } from '../styles/headings'; import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, @@ -4327,6 +4328,58 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array { return rezipOdt(files); } +// The fonts the opened file carried, written back: the binaries under Fonts/, a manifest +// entry each, and per family one whose source names every cut. Both +// content.xml and styles.xml declare them, the way LibreOffice writes an embedded font. +function applyEmbeddedFontsOdf(odtBytes: Uint8Array, fonts: EmbeddedFont[]): Uint8Array { + if (!fonts.length) return odtBytes; + const files = unzipSync(odtBytes); + const srcByFamily = new Map(); + fonts.forEach((font, i) => { + const path = `Fonts/font${i + 1}.ttf`; + files[path] = font.data as Uint8Array; + srcByFamily.set(font.family, (srcByFamily.get(font.family) ?? '') + + `` + + ''); + }); + + const manifestBytes = files['META-INF/manifest.xml']; + if (manifestBytes) { + const entries = fonts + .map((_f, i) => ``) + .join(''); + files['META-INF/manifest.xml'] = strToU8(strFromU8(manifestBytes).replace('', `${entries}`)); + } + for (const part of ['content.xml', 'styles.xml'] as const) { + const bytes = files[part]; + if (!bytes) continue; + const xml = part === 'styles.xml' ? ensureDrawNamespaces(strFromU8(bytes)) : strFromU8(bytes); + files[part] = strToU8(declareEmbeddedFonts(xml, srcByFamily)); + } + return rezipOdt(files); +} + +// Each source goes into the family's own declaration — odf-kit writes an empty one for +// every referenced name, and a second with the same style:name would be invalid. +function declareEmbeddedFonts(xml: string, srcByFamily: Map): string { + if (!xml.includes('')) return xml; + let out = xml.includes('xmlns:loext=') + ? xml + : xml.replace(/${uris}`; + const decl = new RegExp(`]*)/>`); + out = decl.test(out) + ? out.replace(decl, `${src}`) + : out.replace('', + `${src}`); + } + return out; +} + // The formula sub-document: an ODF formula document's content.xml is the MathML root // itself. The LaTeX rides in an annotation so our own files re-import exactly; other // readers ignore it and typeset the presentation markup. @@ -4993,7 +5046,7 @@ export type HfExport = { }; // The full document → .odt pipeline, DOM-free; returns the .odt bytes. -export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false, foldMarks = false, spacingAtPageStart = true): Promise { +export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false, foldMarks = false, spacingAtPageStart = true, fonts: EmbeddedFont[] = []): Promise { // Images become IMG sentinels before serialization; applyImages resolves them and writes // the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks // (so PGB misses their blocks) and before the inline passes (which then cover them). @@ -5267,7 +5320,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withSections = applySectionMasterPages(withHf, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation, { header: !!headerPara, footer: !!footerPara }, { header: headerDist, footer: footerDist }, borderInsetCm(decor)); const withWatermark = applyFoldMarksOdf(applyWatermarkOdf(withSections, decor.watermark), foldMarks); - return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withWatermark, spacingModel, spacingAtPageStart), pageNumbering.start), props))); + const withFonts = applyEmbeddedFontsOdf(withWatermark, fonts); + return zipFinal(applyOdfVersion(applyDocProperties(applyPageNumberStart(applySpacingModel(withFonts, spacingModel, spacingAtPageStart), pageNumbering.start), props))); } // The package declares ODF 1.3 in every part — the version LibreOffice writes, and diff --git a/src/lib/fonts/embeddedFonts.ts b/src/lib/fonts/embeddedFonts.ts index 9e32224..bd35a89 100644 --- a/src/lib/fonts/embeddedFonts.ts +++ b/src/lib/fonts/embeddedFonts.ts @@ -23,6 +23,13 @@ export function deobfuscateOdttf(data: Uint8Array, fontKey: string): Uint8Array // The faces this module added to document.fonts, tracked so a new document's set can // replace the previous one (only the current document's fonts stay registered). let registered: FontFace[] = []; +let current: EmbeddedFont[] = []; + +// The open document's fonts, for the exporters to embed again. Kept even for a face the +// browser refused: a word processor may still read it. +export function embeddedFonts(): EmbeddedFont[] { + return current; +} export function clearEmbeddedFonts(): void { if (typeof document !== 'undefined' && document.fonts) { @@ -31,13 +38,15 @@ export function clearEmbeddedFonts(): void { } } registered = []; + current = []; } // Register each font under its CSS family; an unreadable/blocked one is skipped so the // rest still load. No-op without FontFace (jsdom/tests). export async function registerEmbeddedFonts(fonts: EmbeddedFont[]): Promise { - if (typeof FontFace === 'undefined' || typeof document === 'undefined' || !document.fonts) return; clearEmbeddedFonts(); + current = fonts; + if (typeof FontFace === 'undefined' || typeof document === 'undefined' || !document.fonts) return; for (const f of fonts) { try { const face = new FontFace(f.family, f.data as BufferSource, { weight: f.weight, style: f.style }); diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index fe6f629..5d6157b 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -28,6 +28,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( - **A run's underline is suppressed only where it draws the *same* line.** `lineSig` compares the run's shape and colour against the style's, so a red or wavy underline under a plainly underlining style survives — only a bare one repeating its style is dropped, which is what neither format can tell from a failed resolution. Both importers, `marksFor`; a strike compares its `double` the same way. - **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`) — unless the list style is a **named** one (`StyleResolver.isNamedListStyle`): then the outermost list gets `listStyleName` (display name decoded), no per-level attrs are derived, and `collectStyleSheet`/`listStyleFromOdf` put the definition into `sheet.list`, tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`), and `draw:polygon`/`polyline`/`path`/`connector` — or a preset no gallery covers but whose `draw:enhanced-path` is a plain outline — → a box carrying that outline as `shapePath` (`convertFreeform`, `docs/architecture/frames.md`; a geometry built from modifier formulas still reads as "Unsupported shapes were removed"). A box is an inline node, so it stays exactly where the frame sits — in the paragraph's own content, beside the runs around it, cells and list items included; only a box **inside another box** is unwrapped in place, with a warning. The DOCX importer mirrors all of this — its drawing root is the `w:drawing`'s **direct** `wp:inline`/`wp:anchor` child, since a box holding a picture nests a second drawing and a subtree search reads *its* root instead, dropping the box and the caption in it (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import). A **group** (`wpg:wgp`, read before either) has no node of its own: `convertGroup` maps each child out of the group's child coordinate space (`a:chOff`/`a:chExt`) and gives the first the group's own placement — which is what reserves an inline group's box in the line — while the rest run through the text over it, offset by their place in the group. The frames over an inline group take their static position from the paragraph, so a group that shares its line carries them there; a group inside a group is dropped. - **Image formats (`import/imageFormats.ts`, shared by both importers):** `` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken ``. A frame's multiple `` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and ``-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **EMF** goes through the same lazy pre-pass into an SVG picture (`import/emf.ts`, below). **SVM/WMF** still have no decoder (SVM is StarOffice-proprietary, WMF a different 16-bit record format) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB). Both passes read the **same inflated archive** — `unzipArchive(bytes)` keeps it per byte array, so a picture-heavy file is inflated once however many passes read it (a quarter second on a 14 MB file) — and a picture is encoded with the browser's `Uint8Array.toBase64` where there is one, the chunked `String.fromCharCode` loop standing in where there is not (300 ms against 15 ms for 600 pictures). A **CMYK** JPEG loses its ICC profile (`stripCmykIccProfile`): Chromium colour-manages through it, LibreOffice and Word convert naively, and the file's own colours are the ones every word processor shows (measured on the contract fixture's logo: blue (0,80,131) → (0,77,183) against LibreOffice's (0,32,183), black (57,53,54) → (11,11,18) against (0,0,0)). +- **Embedded fonts** (`extractDocxFonts`, `StyleResolver.scanFontFaces` → `fonts/embeddedFonts.ts`): a file may carry the faces its text is set in, and without them a document in an uninstalled font reflows. DOCX keeps them as `word/fonts/*.odttf` referenced from `fontTable.xml` (`w:embedRegular`/`-Bold`/`-Italic`/`-BoldItalic`), each obfuscated with its own `w:fontKey` — the first 32 bytes XORed with the GUID's 16 bytes reversed, which is its own inverse; ODF stores plain TTFs under the `` of a ``. Both land in the import result's `fonts`, which `App.svelte` registers via `FontFace` **before** the content is loaded (so the text renders in the right face and `unavailableFonts` doesn't flag it) and persists per document in IndexedDB. - **EMF (`import/emf.ts`)**: `emfToSvg(bytes)` replays the record stream into SVG — window/viewport plus world transform mapped per point, a DC stack (`SAVEDC`/`RESTOREDC`), pens/brushes/fonts (incl. the stock objects), the 16- and 32-bit poly records, path brackets, `RECTANGLE`/`ELLIPSE`, `EXTTEXTOUT` (its per-character advances become `textLength`, so labels keep their width) and `STRETCHDIBITS` (the DIB re-wrapped as a .bmp data-URI). **Text is stored as raw code units**, so a symbol font leaves lone surrogates behind — they must be stripped or `encodeURIComponent` throws and the whole picture is lost. Unknown records are skipped; a file that yields nothing returns null and keeps the placeholder. - **Charts (`import/chart.ts`, DOCX)**: `word/charts/chartN.xml` is parsed and **drawn as an SVG picture** of the frame's own size — series values from the `numCache` the producer left, colours from the named theme accent (`themeAccents`), title/axis titles/gridlines, and the value axis' own `c:scaling` bounds where the file fixes them. Bar (incl. stacked and `barDir="bar"`), line, area, scatter and pie; anything else keeps the placeholder. Read-only — the editor has no chart object, so a re-export carries the picture, not the chart. **ODF** charts go through the same drawing (`odfChartDataUrl`, `convertChartFrame`): `` in the `draw:object`'s sub-document, series values read out of its own `local-table` by cell range, colours from the series' `draw:fill-color`, bounds from the y-axis style's `chart:maximum`/`-minimum`. - **Word's horizontal line** (`hrBorderAttr`, DOCX): a `w:pict` > `v:rect` flagged `o:hr` is not a shape but the paragraph's own **bottom rule** — the editor has no rule node. Height and `fillcolor` become the rule (`1.5pt`/`#A0A0A0` where the file names none), its width and `o:hralign` are dropped (a paragraph rule spans the text width) and a real `w:pBdr` bottom keeps precedence: only one line can be drawn. diff --git a/tests/embedded-fonts.test.ts b/tests/embedded-fonts.test.ts new file mode 100644 index 0000000..abaa26c --- /dev/null +++ b/tests/embedded-fonts.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../src/lib/export/odt'; +import { buildDocx } from '../src/lib/export/docx'; +import { importOdt } from '../src/lib/import/odt'; +import { importDocx } from '../src/lib/import/docx'; +import type { EmbeddedFont } from '../src/lib/fonts/embeddedFonts'; + +const ttf = (name: string) => new Uint8Array(readFileSync(`src/assets/fonts/${name}`)); +const fonts = (): EmbeddedFont[] => [ + { family: 'Probe & Co', weight: 'normal', style: 'normal', data: ttf('LiberationSans-Regular.ttf') }, + { family: 'Probe & Co', weight: 'bold', style: 'italic', data: ttf('LiberationSans-BoldItalic.ttf') }, + { family: 'Zweite', weight: 'normal', style: 'italic', data: ttf('LiberationSerif-Italic.ttf') }, +]; + +const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hallo' }] }] } as any; +// The fonts ride the last positional argument, after the 18 the two builders share. +const args = (list: EmbeddedFont[]) => [...new Array(18).fill(undefined), list] as any; + +const same = (got: EmbeddedFont[], want: EmbeddedFont[]) => { + expect(got.map((f) => `${f.family}|${f.weight}|${f.style}|${f.data.length}`)) + .toEqual(want.map((f) => `${f.family}|${f.weight}|${f.style}|${f.data.length}`)); + for (let i = 0; i < want.length; i++) expect(Array.from(got[i].data.slice(0, 64))).toEqual(Array.from(want[i].data.slice(0, 64))); +}; + +describe('embedded fonts survive a save', () => { + it('round-trips through .odt', async () => { + const want = fonts(); + const bytes = await buildOdt(doc, ...args(fonts())); + same(importOdt(bytes).fonts, want); + + // Our own importer reads the package by path; LibreOffice needs the manifest entry. + const manifest = strFromU8(unzipSync(bytes)['META-INF/manifest.xml']); + for (let i = 1; i <= want.length; i++) expect(manifest).toContain(`manifest:full-path="Fonts/font${i}.ttf"`); + }); + + it('round-trips through .docx', async () => { + const want = fonts(); + const bytes = await buildDocx(doc, ...args(fonts())); + same(importDocx(bytes).fonts, want); + + const files = unzipSync(bytes); + // Word obfuscates a font part, so the stored bytes are not the font's own header. + expect(Array.from(files['word/fonts/font1.odttf'].slice(0, 4))).not.toEqual(Array.from(want[0].data.slice(0, 4))); + expect(strFromU8(files['[Content_Types].xml'])).toContain('Extension="odttf"'); + }); + + it('carries a document opened as .docx into an .odt', async () => { + const want = fonts(); + const read = importDocx(await buildDocx(doc, ...args(fonts()))); + same(importOdt(await buildOdt(read.content as any, ...args(read.fonts))).fonts, want); + }); +});