feat: a saved file carries the fonts it was opened with
Both importers already read a package's embedded faces and register them, but nothing wrote them back: a document set in a font nobody has installed lost its look on the next machine. ODF gets plain TTFs under Fonts/ with a manifest entry each, merged into the font-face declaration the name already has; Word gets a per-part GUID, an obfuscated .odttf and its w:embed... in the family's w:font. The picker lists the families too, and a slow store read no longer replaces the faces of a file opened while it was still running.
This commit is contained in:
1 parent
1b7d2e0de8
commit
a8ecd8ce06
8 files changed
+196
-9
No files matched your search
+12
-4
@@ -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<Uint8Array> {
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ const RECENT_KEY = 'edentext-recent-fonts';
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
let recents = $state<string[]>(load());
|
||||
let embedded = $state<string[]>([]);
|
||||
let detected = $state<string[]>([]);
|
||||
let allInstalled = $state<string[] | null>(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));
|
||||
}
|
||||
|
||||
@@ -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 `<w:abstractNum>`/`<w:num>` pair above its highest id, appended **before** the first `<w:num>` 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 `<draw:frame><draw:object xlink:href="./Formula{n}"/>` 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 `<m:oMath>` (or `<m:oMathPara>` 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 `<style:font-face>` per family whose `<svg:font-face-src>` 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 `<w:embed…>` inside the family's `<w:font>` 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 `<w:bidi/>` 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.
|
||||
|
||||
+54
-1
@@ -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 <w:embed…> inside the family's <w:font>.
|
||||
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(/<Relationships([^>]*)\/>/, '<Relationships$1></Relationships>');
|
||||
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<string, [number, string][]>();
|
||||
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<ArrayBuffer>;
|
||||
added.push(`<Relationship Id="rId${next}" Type="${R_NS}/font" Target="fonts/${name}"/>`);
|
||||
const rank = font.weight === 'bold' ? (font.style === 'italic' ? 3 : 1) : (font.style === 'italic' ? 2 : 0);
|
||||
const list = byFamily.get(font.family) ?? [];
|
||||
list.push([rank, `<w:${CUTS[rank]} r:id="rId${next++}" w:fontKey="${key}"/>`]);
|
||||
byFamily.set(font.family, list);
|
||||
});
|
||||
|
||||
let table = strFromU8(tableBytes).replace(/<w:fonts([^>]*)\/>/, '<w:fonts$1></w:fonts>');
|
||||
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(`<w:font w:name="${quoted}"([^>]*)/>`);
|
||||
const open = new RegExp(`(<w:font w:name="${quoted}"[^>]*>[\\s\\S]*?)</w:font>`);
|
||||
if (empty.test(table)) table = table.replace(empty, `<w:font w:name="${name}"$1>${xml}</w:font>`);
|
||||
else if (open.test(table)) table = table.replace(open, `$1${xml}</w:font>`);
|
||||
else table = table.replace('</w:fonts>', `<w:font w:name="${name}">${xml}</w:font></w:fonts>`);
|
||||
}
|
||||
files['word/fontTable.xml'] = strToU8(table);
|
||||
files[relsPath] = strToU8(rels.replace('</Relationships>', added.join('') + '</Relationships>'));
|
||||
|
||||
const ct = files['[Content_Types].xml'] && strFromU8(files['[Content_Types].xml']);
|
||||
if (ct && !ct.includes('Extension="odttf"')) {
|
||||
files['[Content_Types].xml'] = strToU8(ct.replace(/(<Types\b[^>]*>)/,
|
||||
'$1<Default Extension="odttf" ContentType="application/vnd.openxmlformats-officedocument.obfuscatedFont"/>'));
|
||||
}
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
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<Uint8Array> {
|
||||
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,
|
||||
|
||||
+56
-2
@@ -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 <style:font-face> 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<string, string>();
|
||||
fonts.forEach((font, i) => {
|
||||
const path = `Fonts/font${i + 1}.ttf`;
|
||||
files[path] = font.data as Uint8Array<ArrayBuffer>;
|
||||
srcByFamily.set(font.family, (srcByFamily.get(font.family) ?? '')
|
||||
+ `<svg:font-face-uri xlink:href="${path}" xlink:type="simple"`
|
||||
+ ` loext:font-style="${font.style}" loext:font-weight="${font.weight}">`
|
||||
+ '<svg:font-face-format svg:string="truetype"/></svg:font-face-uri>');
|
||||
});
|
||||
|
||||
const manifestBytes = files['META-INF/manifest.xml'];
|
||||
if (manifestBytes) {
|
||||
const entries = fonts
|
||||
.map((_f, i) => `<manifest:file-entry manifest:full-path="Fonts/font${i + 1}.ttf" manifest:media-type="application/x-font-ttf"/>`)
|
||||
.join('');
|
||||
files['META-INF/manifest.xml'] = strToU8(strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`));
|
||||
}
|
||||
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, string>): string {
|
||||
if (!xml.includes('</office:font-face-decls>')) return xml;
|
||||
let out = xml.includes('xmlns:loext=')
|
||||
? xml
|
||||
: xml.replace(/<office:document-(?:content|styles)\b/,
|
||||
'$& xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0"');
|
||||
for (const [family, uris] of srcByFamily) {
|
||||
const name = escapeXml(family);
|
||||
const src = `<svg:font-face-src>${uris}</svg:font-face-src>`;
|
||||
const decl = new RegExp(`<style:font-face style:name="${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"([^>]*)/>`);
|
||||
out = decl.test(out)
|
||||
? out.replace(decl, `<style:font-face style:name="${name}"$1>${src}</style:font-face>`)
|
||||
: out.replace('</office:font-face-decls>',
|
||||
`<style:font-face style:name="${name}" svg:font-family="${name}">${src}</style:font-face></office:font-face-decls>`);
|
||||
}
|
||||
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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
// 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
|
||||
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
|
||||
@@ -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):** `<img>` 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 `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-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 `<svg:font-face-uri>` of a `<style:font-face>`. 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`): `<chart:chart>` 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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user