diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index 4022c58..a7bc5d6 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -11,7 +11,8 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **The yardstick is the style the block can name.** Only a **body** block carries a `styleName`, so a paragraph in a cell or a text box is measured against the *default* paragraph style instead of its own — otherwise everything its style provides is suppressed as "the style will supply it" and nothing ever does (a Word caption in a text box arrived without its italic or its colour).
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
- **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`), 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`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this (`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).
-- **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. **SVM/WMF/EMF** have no JS decoder (SVM is StarOffice-proprietary) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB).
+- **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).
+- **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`.
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (the thesis fixture lost every `[7]`-style reference and broke its lines one page short).
- **Formulas** (`convertFormulaFrame`): a `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
diff --git a/src/lib/import/emf.ts b/src/lib/import/emf.ts
new file mode 100644
index 0000000..7fa4ba7
--- /dev/null
+++ b/src/lib/import/emf.ts
@@ -0,0 +1,361 @@
+// A Windows Enhanced Metafile drawn as SVG. Covers the record subset the plotting tools
+// that feed Word actually emit — paths, text, embedded bitmaps; anything else is skipped.
+// Loaded lazily (imageFormats.ts), so a document without a metafile never pays for it.
+
+const EMF_SIGNATURE = 0x464d4520; // ' EMF'
+
+type Xform = { m11: number; m12: number; m21: number; m22: number; dx: number; dy: number };
+type Pen = { color: string; width: number; dash: string | null; none: boolean };
+type Brush = { color: string; none: boolean };
+type Font = { family: string; size: number; weight: number; italic: boolean; escapement: number };
+type GdiObject = Pen | Brush | Font;
+
+type State = {
+ xf: Xform;
+ winOrg: [number, number]; winExt: [number, number] | null;
+ vpOrg: [number, number]; vpExt: [number, number] | null;
+ pen: Pen; brush: Brush; font: Font;
+ textColor: string; textAlign: number; fillRule: 'evenodd' | 'nonzero';
+};
+
+const IDENTITY: Xform = { m11: 1, m12: 0, m21: 0, m22: 1, dx: 0, dy: 0 };
+const NO_PEN: Pen = { color: '#000000', width: 1, dash: null, none: true };
+const BLACK_PEN: Pen = { color: '#000000', width: 1, dash: null, none: false };
+const NO_BRUSH: Brush = { color: '#000000', none: true };
+const DEFAULT_FONT: Font = { family: 'sans-serif', size: 12, weight: 400, italic: false, escapement: 0 };
+
+const colorRef = (v: number) =>
+ '#' + [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff].map((c) => c.toString(16).padStart(2, '0')).join('');
+
+// A metafile stores text as raw code units, so a symbol font leaves lone surrogates
+// behind — they are dropped here, or the SVG cannot be encoded at all.
+const esc = (s: string) => s
+ .replace(/[\0-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff]/g, '')
+ .replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c]!);
+const n = (v: number) => (Math.round(v * 100) / 100).toString();
+
+// PS_DASH..PS_DASHDOTDOT as a stroke-dasharray, scaled by the pen's own width.
+function penDash(style: number, w: number): string | null {
+ const unit = Math.max(1, w);
+ switch (style & 0xf) {
+ case 1: return `${unit * 6} ${unit * 3}`;
+ case 2: return `${unit} ${unit * 2}`;
+ case 3: return `${unit * 6} ${unit * 2} ${unit} ${unit * 2}`;
+ case 4: return `${unit * 6} ${unit * 2} ${unit} ${unit * 2} ${unit} ${unit * 2}`;
+ default: return null;
+ }
+}
+
+// The stock objects a record may select instead of one it created (handle bit 31 set).
+function stockObject(id: number): GdiObject | null {
+ const grey = (v: number) => ({ color: colorRef(v * 0x010101), none: false });
+ switch (id & 0x7fffffff) {
+ case 0: return grey(0xff);
+ case 1: return grey(0xc0);
+ case 2: return grey(0x80);
+ case 3: return grey(0x40);
+ case 4: return grey(0x00);
+ case 5: return NO_BRUSH;
+ case 6: return { ...BLACK_PEN, color: '#ffffff' };
+ case 7: return BLACK_PEN;
+ case 8: return NO_PEN;
+ default: return null;
+ }
+}
+
+export function emfToSvg(bytes: Uint8Array): string | null {
+ if (bytes.length < 88) return null;
+ const d = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ if (d.getUint32(0, true) !== 1 || d.getUint32(40, true) !== EMF_SIGNATURE) return null;
+ const [bl, bt, br, bb] = [0, 1, 2, 3].map((i) => d.getInt32(8 + i * 4, true));
+ const vw = Math.max(1, br - bl + 1);
+ const vh = Math.max(1, bb - bt + 1);
+ if (vw > 20000 || vh > 20000) return null;
+
+ const out: string[] = [];
+ const objects = new Map();
+ const stack: State[] = [];
+ let st: State = {
+ xf: { ...IDENTITY }, winOrg: [0, 0], winExt: null, vpOrg: [0, 0], vpExt: null,
+ pen: BLACK_PEN, brush: { color: '#ffffff', none: false }, font: DEFAULT_FONT,
+ textColor: '#000000', textAlign: 0, fillRule: 'evenodd',
+ };
+ let cur: [number, number] = [0, 0];
+ let pathD = '';
+ let inPath = false;
+
+ const scaleX = () => (st.winExt && st.vpExt && st.winExt[0] ? st.vpExt[0] / st.winExt[0] : 1);
+ const scaleY = () => (st.winExt && st.vpExt && st.winExt[1] ? st.vpExt[1] / st.winExt[1] : 1);
+ const map = (x: number, y: number): [number, number] => {
+ const t = st.xf;
+ const wx = t.m11 * x + t.m21 * y + t.dx;
+ const wy = t.m12 * x + t.m22 * y + t.dy;
+ return [(wx - st.winOrg[0]) * scaleX() + st.vpOrg[0], (wy - st.winOrg[1]) * scaleY() + st.vpOrg[1]];
+ };
+ // One number for pen widths and font sizes: the area scale of the whole mapping.
+ const unitScale = () => {
+ const t = st.xf;
+ return Math.sqrt(Math.abs((t.m11 * t.m22 - t.m12 * t.m21) * scaleX() * scaleY())) || 1;
+ };
+
+ const strokeAttrs = () => {
+ const w = Math.max(0.4, st.pen.width * unitScale());
+ return ` fill="none" stroke="${st.pen.color}" stroke-width="${n(w)}"`
+ + (st.pen.dash ? ` stroke-dasharray="${st.pen.dash}"` : '');
+ };
+ const emitPath = (dStr: string, stroke: boolean, fill: boolean) => {
+ if (!dStr) return;
+ const parts: string[] = [];
+ parts.push(fill && !st.brush.none ? ` fill="${st.brush.color}" fill-rule="${st.fillRule}"` : ' fill="none"');
+ if (stroke && !st.pen.none) {
+ parts.push(` stroke="${st.pen.color}" stroke-width="${n(Math.max(0.4, st.pen.width * unitScale()))}"`);
+ if (st.pen.dash) parts.push(` stroke-dasharray="${st.pen.dash}"`);
+ }
+ out.push(``);
+ };
+ const draw = (dStr: string, stroke: boolean, fill: boolean) => {
+ if (inPath) pathD += (pathD ? ' ' : '') + dStr;
+ else emitPath(dStr, stroke, fill);
+ };
+
+ const pts = (off: number, count: number, short: boolean): [number, number][] => {
+ const list: [number, number][] = [];
+ for (let i = 0; i < count; i++) {
+ const p = off + i * (short ? 4 : 8);
+ list.push(map(
+ short ? d.getInt16(p, true) : d.getInt32(p, true),
+ short ? d.getInt16(p + 2, true) : d.getInt32(p + 4, true),
+ ));
+ }
+ return list;
+ };
+ const poly = (list: [number, number][], close: boolean, from: boolean) => {
+ if (!list.length) return;
+ const head = from ? `M ${n(cur[0])} ${n(cur[1])} L` : `M ${n(list[0][0])} ${n(list[0][1])} L`;
+ const rest = (from ? list : list.slice(1)).map(([x, y]) => `${n(x)} ${n(y)}`).join(' ');
+ cur = list[list.length - 1];
+ draw(`${head} ${rest}${close ? ' Z' : ''}`, true, close);
+ };
+ const bezier = (list: [number, number][], from: boolean) => {
+ const start = from ? cur : list[0];
+ const tail = from ? list : list.slice(1);
+ if (tail.length < 3) return;
+ let s = `M ${n(start[0])} ${n(start[1])}`;
+ for (let i = 0; i + 2 < tail.length; i += 3)
+ s += ` C ${tail.slice(i, i + 3).map(([x, y]) => `${n(x)} ${n(y)}`).join(' ')}`;
+ cur = tail[tail.length - 1];
+ draw(s, true, false);
+ };
+
+ let off = 0;
+ for (let guard = 0; off + 8 <= bytes.length && guard < 200000; guard++) {
+ const type = d.getUint32(off, true);
+ const size = d.getUint32(off + 4, true);
+ if (size < 8 || off + size > bytes.length) break;
+ const b = off + 8;
+ switch (type) {
+ case 0x0e: off = bytes.length; continue; // EOF
+ case 0x09: st.winExt = [d.getInt32(b, true), d.getInt32(b + 4, true)]; break;
+ case 0x0a: st.winOrg = [d.getInt32(b, true), d.getInt32(b + 4, true)]; break;
+ case 0x0b: st.vpExt = [d.getInt32(b, true), d.getInt32(b + 4, true)]; break;
+ case 0x0c: st.vpOrg = [d.getInt32(b, true), d.getInt32(b + 4, true)]; break;
+ case 0x13: st.fillRule = d.getUint32(b, true) === 2 ? 'nonzero' : 'evenodd'; break;
+ case 0x16: st.textAlign = d.getUint32(b, true); break;
+ case 0x18: st.textColor = colorRef(d.getUint32(b, true)); break;
+ case 0x21: stack.push({ ...st, xf: { ...st.xf } }); break; // SAVEDC
+ case 0x22: { const s = stack.pop(); if (s) st = s; break; } // RESTOREDC
+ case 0x23: case 0x24: { // SET/MODIFYWORLDTRANSFORM
+ const m: Xform = {
+ m11: d.getFloat32(b, true), m12: d.getFloat32(b + 4, true),
+ m21: d.getFloat32(b + 8, true), m22: d.getFloat32(b + 12, true),
+ dx: d.getFloat32(b + 16, true), dy: d.getFloat32(b + 20, true),
+ };
+ const mode = type === 0x23 ? 4 : d.getUint32(b + 24, true);
+ if (mode === 1) st.xf = { ...IDENTITY };
+ else if (mode === 4) st.xf = m;
+ else st.xf = mode === 2 ? compose(m, st.xf) : compose(st.xf, m);
+ break;
+ }
+ case 0x25: { // SELECTOBJECT
+ const h = d.getUint32(b, true);
+ const o = h & 0x80000000 ? stockObject(h) : objects.get(h) ?? null;
+ if (o) applyObject(o);
+ break;
+ }
+ case 0x26: { // CREATEPEN
+ const style = d.getUint32(b + 4, true);
+ const w = d.getInt32(b + 8, true);
+ objects.set(d.getUint32(b, true), {
+ color: colorRef(d.getUint32(b + 16, true)), width: w || 1,
+ dash: penDash(style, w || 1), none: (style & 0xf) === 5,
+ });
+ break;
+ }
+ case 0x5f: { // EXTCREATEPEN
+ const style = d.getUint32(b + 20, true);
+ const w = d.getInt32(b + 24, true);
+ objects.set(d.getUint32(b, true), {
+ color: colorRef(d.getUint32(b + 32, true)), width: w || 1,
+ dash: penDash(style, w || 1), none: (style & 0xf) === 5 || d.getUint32(b + 28, true) === 1,
+ });
+ break;
+ }
+ case 0x27: { // CREATEBRUSHINDIRECT
+ const style = d.getUint32(b + 4, true);
+ objects.set(d.getUint32(b, true), { color: colorRef(d.getUint32(b + 8, true)), none: style === 1 });
+ break;
+ }
+ case 0x52: objects.set(d.getUint32(b, true), readFont(d, bytes, b + 4, unitScale())); break;
+ case 0x28: objects.delete(d.getUint32(b, true)); break; // DELETEOBJECT
+ case 0x1b: cur = map(d.getInt32(b, true), d.getInt32(b + 4, true)); break; // MOVETOEX
+ case 0x36: { // LINETO
+ const p = map(d.getInt32(b, true), d.getInt32(b + 4, true));
+ draw(`M ${n(cur[0])} ${n(cur[1])} L ${n(p[0])} ${n(p[1])}`, true, false);
+ cur = p;
+ break;
+ }
+ case 0x3b: inPath = true; pathD = ''; break; // BEGINPATH
+ case 0x3c: inPath = false; break; // ENDPATH
+ case 0x3d: pathD += ' Z'; break; // CLOSEFIGURE
+ case 0x3e: emitPath(pathD, false, true); pathD = ''; break; // FILLPATH
+ case 0x3f: emitPath(pathD, true, true); pathD = ''; break; // STROKEANDFILLPATH
+ case 0x40: emitPath(pathD, true, false); pathD = ''; break; // STROKEPATH
+ case 0x2a: case 0x2b: { // ELLIPSE / RECTANGLE
+ const [x0, y0] = map(d.getInt32(b, true), d.getInt32(b + 4, true));
+ const [x1, y1] = map(d.getInt32(b + 8, true), d.getInt32(b + 12, true));
+ const box = ` x="${n(Math.min(x0, x1))}" y="${n(Math.min(y0, y1))}"`
+ + ` width="${n(Math.abs(x1 - x0))}" height="${n(Math.abs(y1 - y0))}"`;
+ const paint = (st.brush.none ? ' fill="none"' : ` fill="${st.brush.color}"`)
+ + (st.pen.none ? '' : ` stroke="${st.pen.color}" stroke-width="${n(Math.max(0.4, st.pen.width * unitScale()))}"`);
+ if (type === 0x2b) out.push(``);
+ else out.push(``);
+ break;
+ }
+ case 0x02: case 0x55: bezier(pts(b + 20, d.getUint32(b + 16, true), type === 0x55), false); break;
+ case 0x05: case 0x58: bezier(pts(b + 20, d.getUint32(b + 16, true), type === 0x58), true); break;
+ case 0x03: case 0x56: poly(pts(b + 20, d.getUint32(b + 16, true), type === 0x56), true, false); break;
+ case 0x04: case 0x57: poly(pts(b + 20, d.getUint32(b + 16, true), type === 0x57), false, false); break;
+ case 0x06: case 0x59: poly(pts(b + 20, d.getUint32(b + 16, true), type === 0x59), false, true); break;
+ case 0x07: case 0x08: case 0x5a: case 0x5b: { // POLYPOLYLINE/POLYGON
+ const short = type >= 0x5a;
+ const closed = type === 0x08 || type === 0x5b;
+ const nPoly = d.getUint32(b + 16, true);
+ let p = b + 24 + nPoly * 4;
+ let dStr = '';
+ for (let i = 0; i < nPoly; i++) {
+ const count = d.getUint32(b + 24 + i * 4, true);
+ const list = pts(p, count, short);
+ p += count * (short ? 4 : 8);
+ if (!list.length) continue;
+ dStr += `${dStr ? ' ' : ''}M ${n(list[0][0])} ${n(list[0][1])} L `
+ + list.slice(1).map(([x, y]) => `${n(x)} ${n(y)}`).join(' ') + (closed ? ' Z' : '');
+ }
+ draw(dStr, true, closed);
+ break;
+ }
+ case 0x53: case 0x54: emitText(type === 0x54); break; // EXTTEXTOUT A/W
+ case 0x51: { // STRETCHDIBITS
+ const img = dibImage(bytes, off, {
+ bmi: d.getUint32(b + 40, true), cbBmi: d.getUint32(b + 44, true),
+ bits: d.getUint32(b + 48, true), cbBits: d.getUint32(b + 52, true),
+ });
+ if (img) {
+ const [x0, y0] = map(d.getInt32(b + 16, true), d.getInt32(b + 20, true));
+ const [x1, y1] = map(
+ d.getInt32(b + 16, true) + d.getInt32(b + 64, true),
+ d.getInt32(b + 20, true) + d.getInt32(b + 68, true));
+ out.push(``);
+ }
+ break;
+ }
+ default: break;
+ }
+ off += size;
+
+ function applyObject(o: GdiObject) {
+ if ('family' in o) st.font = o;
+ else if ('width' in o) st.pen = o;
+ else st.brush = o;
+ }
+
+ // EMR_EXTTEXTOUT: the reference point, the string, and (when present) the per-character
+ // advances — their sum is the run's own width, which keeps labels from drifting.
+ function emitText(wide: boolean) {
+ const t = b + 28; // past Bounds, iGraphicsMode and the two scales
+ const [rx, ry] = map(d.getInt32(t, true), d.getInt32(t + 4, true));
+ const chars = d.getUint32(t + 8, true);
+ const offString = d.getUint32(t + 12, true);
+ const offDx = d.getUint32(t + 36, true);
+ if (!chars || chars > 8192) return;
+ let text = '';
+ for (let i = 0; i < chars; i++) {
+ const c = wide ? d.getUint16(off + offString + i * 2, true) : bytes[off + offString + i];
+ if (c) text += String.fromCharCode(c);
+ }
+ if (!text.trim()) return;
+ const f = st.font;
+ const anchor = (st.textAlign & 6) === 2 ? 'end' : (st.textAlign & 6) === 6 ? 'middle' : 'start';
+ const dy = (st.textAlign & 24) === 24 ? 0 : (st.textAlign & 8) === 8 ? -f.size * 0.2 : f.size * 0.8;
+ const stride = d.getUint32(t + 16, true) & 0x2000 ? 8 : 4; // ETO_PDY pairs the advances
+ let width = 0;
+ if (offDx && off + offDx + chars * stride <= bytes.length)
+ for (let i = 0; i < chars; i++) width += d.getInt32(off + offDx + i * stride, true);
+ const len = width > 0 ? ` textLength="${n(width * unitScale())}" lengthAdjust="spacingAndGlyphs"` : '';
+ const rot = f.escapement ? ` transform="rotate(${n(-f.escapement / 10)} ${n(rx)} ${n(ry + dy)})"` : '';
+ out.push(`= 600 ? ' font-weight="bold"' : '') + (f.italic ? ' font-style="italic"' : '')
+ + ` fill="${st.textColor}" text-anchor="${anchor}"${len}${rot}>${esc(text)}`);
+ }
+ }
+
+ if (!out.length) return null;
+ return `';
+}
+
+function compose(a: Xform, b: Xform): Xform {
+ return {
+ m11: a.m11 * b.m11 + a.m12 * b.m21, m12: a.m11 * b.m12 + a.m12 * b.m22,
+ m21: a.m21 * b.m11 + a.m22 * b.m21, m22: a.m21 * b.m12 + a.m22 * b.m22,
+ dx: a.dx * b.m11 + a.dy * b.m21 + b.dx, dy: a.dx * b.m12 + a.dy * b.m22 + b.dy,
+ };
+}
+
+// EMR_EXTCREATEFONTINDIRECTW's LogFont: a negative height is the character height, a
+// positive one the cell height; the face name is 32 UTF-16 code units.
+function readFont(d: DataView, bytes: Uint8Array, at: number, scale: number): Font {
+ let family = '';
+ for (let i = 0; i < 32; i++) {
+ const c = d.getUint16(at + 28 + i * 2, true);
+ if (!c) break;
+ family += String.fromCharCode(c);
+ }
+ const height = Math.abs(d.getInt32(at, true)) || 12;
+ return {
+ family: family || 'sans-serif',
+ size: Math.max(1, height * scale),
+ weight: d.getInt32(at + 16, true) || 400,
+ italic: bytes[at + 20] !== 0,
+ escapement: d.getInt32(at + 8, true),
+ };
+}
+
+// A device-independent bitmap inside a record, re-wrapped as a .bmp data-URI.
+function dibImage(bytes: Uint8Array, recAt: number, o: { bmi: number; cbBmi: number; bits: number; cbBits: number }): string | null {
+ if (!o.cbBmi || !o.cbBits) return null;
+ const end = recAt + o.bits + o.cbBits;
+ if (end > bytes.length) return null;
+ const file = new Uint8Array(14 + o.cbBmi + o.cbBits);
+ const v = new DataView(file.buffer);
+ file[0] = 0x42; file[1] = 0x4d;
+ v.setUint32(2, file.length, true);
+ v.setUint32(10, 14 + o.cbBmi, true);
+ file.set(bytes.subarray(recAt + o.bmi, recAt + o.bmi + o.cbBmi), 14);
+ file.set(bytes.subarray(recAt + o.bits, end), 14 + o.cbBmi);
+ let bin = '';
+ for (let i = 0; i < file.length; i += 0x8000) bin += String.fromCharCode(...file.subarray(i, i + 0x8000));
+ return `data:image/bmp;base64,${btoa(bin)}`;
+}
diff --git a/src/lib/import/imageFormats.ts b/src/lib/import/imageFormats.ts
index 2a32650..fbd38d7 100644
--- a/src/lib/import/imageFormats.ts
+++ b/src/lib/import/imageFormats.ts
@@ -85,10 +85,16 @@ function isTiff(bytes: Uint8Array, path: string): boolean {
|| (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a));
}
+// EMF: the record header's ' EMF' signature at byte 40, or a .emf extension.
+function isEmf(bytes: Uint8Array, path: string): boolean {
+ if (imageExtOf(path) === 'emf') return true;
+ return bytes.length >= 44 && new DataView(bytes.buffer, bytes.byteOffset, 44).getUint32(40, true) === 0x464d4520;
+}
+
// True when the browser can't display the bytes directly but a lazy decoder can turn
// them into something it can (see convertImageToDataUrl).
export function isConvertibleImage(bytes: Uint8Array, path: string): boolean {
- return displayableImageMime(bytes, path) === null && isTiff(bytes, path);
+ return displayableImageMime(bytes, path) === null && (isTiff(bytes, path) || isEmf(bytes, path));
}
// RGBA pixels → a PNG data-URI via an offscreen canvas (browser only, which is where
@@ -117,6 +123,10 @@ export async function convertImageToDataUrl(bytes: Uint8Array, path: string): Pr
UTIF.decodeImage(buf, ifds[0]);
return rgbaToPngDataUrl(UTIF.toRGBA8(ifds[0]), ifds[0].width, ifds[0].height);
}
+ if (isEmf(bytes, path)) {
+ const svg = (await import('./emf')).emfToSvg(bytes);
+ return svg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` : null;
+ }
} catch { /* corrupt/unsupported → the importer skips it with a warning */ }
return null;
}
diff --git a/tests/unit/emf.test.ts b/tests/unit/emf.test.ts
new file mode 100644
index 0000000..b573db0
--- /dev/null
+++ b/tests/unit/emf.test.ts
@@ -0,0 +1,85 @@
+// A Windows metafile drawn as SVG (import/emf.ts): the records a plot consists of —
+// a pen, a polyline, a label — and the signature check that rejects anything else.
+import { describe, it, expect } from 'vitest';
+import { emfToSvg } from '../../src/lib/import/emf';
+
+function record(type: number, body: Uint8Array): Uint8Array {
+ const out = new Uint8Array(8 + body.length);
+ const v = new DataView(out.buffer);
+ v.setUint32(0, type, true);
+ v.setUint32(4, out.length, true);
+ out.set(body, 8);
+ return out;
+}
+
+const body = (bytes: number, fill: (v: DataView) => void) => {
+ const b = new Uint8Array(bytes);
+ fill(new DataView(b.buffer));
+ return b;
+};
+
+function header(): Uint8Array {
+ return body(88, (v) => {
+ v.setUint32(0, 1, true);
+ v.setUint32(4, 88, true);
+ [0, 0, 199, 99].forEach((n, i) => v.setInt32(8 + i * 4, n, true)); // rclBounds
+ v.setUint32(40, 0x464d4520, true);
+ });
+}
+
+function metafile(...records: Uint8Array[]): Uint8Array {
+ const parts = [header(), ...records];
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+ let at = 0;
+ for (const p of parts) { out.set(p, at); at += p.length; }
+ return out;
+}
+
+const createPen = (handle: number, color: number) => record(0x26, body(20, (v) => {
+ v.setUint32(0, handle, true);
+ v.setUint32(4, 0, true); // PS_SOLID
+ v.setInt32(8, 2, true); // width
+ v.setUint32(16, color, true); // 0x00bbggrr
+}));
+
+const selectObject = (handle: number) => record(0x25, body(4, (v) => v.setUint32(0, handle, true)));
+
+const polyline16 = (points: [number, number][]) => record(0x57, body(20 + points.length * 4, (v) => {
+ v.setUint32(16, points.length, true);
+ points.forEach(([x, y], i) => { v.setInt16(20 + i * 4, x, true); v.setInt16(22 + i * 4, y, true); });
+}));
+
+const extTextOutW = (x: number, y: number, text: string) => record(0x54, body(68 + text.length * 2, (v) => {
+ v.setInt32(28, x, true); // EmrText.Reference
+ v.setInt32(32, y, true);
+ v.setUint32(36, text.length, true);
+ v.setUint32(40, 76, true); // offString, from the record's own start
+ [...text].forEach((c, i) => v.setUint16(68 + i * 2, c.charCodeAt(0), true));
+}));
+
+const svgOf = (bytes: Uint8Array) => {
+ const svg = emfToSvg(bytes);
+ expect(svg).toBeTruthy();
+ expect(new DOMParser().parseFromString(svg!, 'image/svg+xml').getElementsByTagName('parsererror').length).toBe(0);
+ return svg!;
+};
+
+describe('a metafile', () => {
+ it('draws a polyline in its pen’s colour, sized by the header bounds', () => {
+ const svg = svgOf(metafile(createPen(1, 0x0000ff), selectObject(1), polyline16([[0, 0], [50, 60]])));
+ expect(svg).toContain('width="200" height="100"');
+ expect(svg).toContain('stroke="#ff0000"'); // COLORREF is 0x00bbggrr
+ expect(svg).toContain('M 0 0 L 50 60');
+ });
+
+ it('writes out a label at its reference point', () => {
+ const svg = svgOf(metafile(extTextOutW(10, 40, 'Coherence')));
+ expect(svg).toContain('>Coherence<');
+ expect(svg).toContain('x="10"');
+ });
+
+ it('declines bytes that are not a metafile', () => {
+ expect(emfToSvg(new Uint8Array(200))).toBeNull();
+ expect(emfToSvg(new Uint8Array([1, 2, 3]))).toBeNull();
+ });
+});