diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md
index 35bbed4..bdf8091 100644
--- a/src/lib/import/CLAUDE.md
+++ b/src/lib/import/CLAUDE.md
@@ -12,6 +12,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **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).
+- **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.
- **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`.
- **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
diff --git a/src/lib/import/chart.ts b/src/lib/import/chart.ts
new file mode 100644
index 0000000..04e143d
--- /dev/null
+++ b/src/lib/import/chart.ts
@@ -0,0 +1,282 @@
+// A DrawingML chart part (word/charts/chartN.xml) drawn as an SVG picture, so a chart
+// occupies its frame with its own data instead of a placeholder. Read-only: the editor
+// has no chart object, so what a re-export carries is this picture.
+
+const C = 'http://schemas.openxmlformats.org/drawingml/2006/chart';
+const A = 'http://schemas.openxmlformats.org/drawingml/2006/main';
+
+// Office's default accent cycle, the fallback when the file names no theme colours.
+const DEFAULT_ACCENTS = ['#4472C4', '#ED7D31', '#A5A5A5', '#FFC000', '#5B9BD5', '#70AD47'];
+
+const AXIS_COLOR = '#a6a6a6';
+const TEXT_COLOR = '#595959';
+const GRID_COLOR = '#d9d9d9';
+
+type Series = { name: string; color: string; values: (number | null)[] };
+type Chart = {
+ kind: 'bar' | 'hbar' | 'line' | 'pie' | 'area' | 'scatter';
+ stacked: boolean;
+ title: string;
+ cats: string[];
+ series: Series[];
+ legend: boolean;
+ grid: boolean;
+ catTitle: string;
+ valTitle: string;
+ // The value axis' own bounds where the file fixes them, else the data decides.
+ axMin: number | null;
+ axMax: number | null;
+};
+
+const kids = (el: Element | null, ns: string, name: string): Element[] =>
+ el ? Array.from(el.children).filter((c) => c.namespaceURI === ns && c.localName === name) : [];
+const kid = (el: Element | null, ns: string, name: string): Element | null => kids(el, ns, name)[0] ?? null;
+const val = (el: Element | null, ns: string, name: string): string | null =>
+ kid(el, ns, name)?.getAttribute('val') ?? null;
+
+// Every under the element, which is how a chart stores any piece of rich text.
+function richText(el: Element | null): string {
+ if (!el) return '';
+ return Array.from(el.getElementsByTagNameNS(A, 't')).map((t) => t.textContent ?? '').join('').trim();
+}
+
+// A // cache: the values the producer last computed, indexed by
+// . Gaps stay null so a line breaks where the data does.
+function cachePoints(holder: Element | null): { text: string[]; nums: (number | null)[] } {
+ const text: string[] = [];
+ const nums: (number | null)[] = [];
+ if (!holder) return { text, nums };
+ for (const cache of Array.from(holder.getElementsByTagName('*'))) {
+ if (cache.namespaceURI !== C || !/Cache$/.test(cache.localName)) continue;
+ const count = parseInt(val(cache, C, 'ptCount') ?? '0', 10);
+ for (let i = 0; i < count; i++) { text[i] = ''; nums[i] = null; }
+ for (const pt of kids(cache, C, 'pt')) {
+ const idx = parseInt(pt.getAttribute('idx') ?? '', 10);
+ const raw = kid(pt, C, 'v')?.textContent ?? '';
+ if (!Number.isFinite(idx)) continue;
+ text[idx] = raw;
+ const n = parseFloat(raw);
+ nums[idx] = Number.isFinite(n) ? n : null;
+ }
+ break;
+ }
+ return { text, nums };
+}
+
+// A series' fill: an explicit sRGB, else the theme accent the file names, else the
+// cycle position — which is what Word itself falls back to.
+function seriesColor(ser: Element, index: number, accents: string[]): string {
+ const fill = kid(kid(ser, C, 'spPr'), A, 'solidFill');
+ const srgb = kid(fill, A, 'srgbClr')?.getAttribute('val');
+ if (srgb) return `#${srgb}`;
+ const scheme = kid(fill, A, 'schemeClr')?.getAttribute('val') ?? '';
+ const m = /^accent([1-6])$/.exec(scheme);
+ if (m) return accents[+m[1] - 1] ?? DEFAULT_ACCENTS[+m[1] - 1];
+ return accents[index % accents.length] ?? DEFAULT_ACCENTS[index % DEFAULT_ACCENTS.length];
+}
+
+const PLOTS: Record = {
+ barChart: 'bar', bar3DChart: 'bar', lineChart: 'line', line3DChart: 'line',
+ pieChart: 'pie', pie3DChart: 'pie', doughnutChart: 'pie',
+ areaChart: 'area', area3DChart: 'area', scatterChart: 'scatter',
+};
+
+function parseChart(doc: Document, accents: string[]): Chart | null {
+ const space = doc.documentElement;
+ if (!space || space.localName !== 'chartSpace') return null;
+ const chart = kid(space, C, 'chart');
+ const plotArea = kid(chart, C, 'plotArea');
+ const plot = Array.from(plotArea?.children ?? []).find((c) => c.namespaceURI === C && PLOTS[c.localName]);
+ if (!plot) return null;
+
+ const series: Series[] = [];
+ let cats: string[] = [];
+ kids(plot, C, 'ser').forEach((ser, i) => {
+ const { nums } = cachePoints(kid(ser, C, 'val') ?? kid(ser, C, 'yVal'));
+ const cat = cachePoints(kid(ser, C, 'cat') ?? kid(ser, C, 'xVal'));
+ if (cat.text.length > cats.length) cats = cat.text;
+ series.push({ name: richText(kid(ser, C, 'tx')) || `Series ${i + 1}`, color: seriesColor(ser, i, accents), values: nums });
+ });
+ if (!series.length) return null;
+
+ const axes = ['catAx', 'valAx', 'dateAx'].flatMap((n) => kids(plotArea, C, n));
+ const axisTitle = (i: number) => richText(kid(axes[i] ?? null, C, 'title'));
+ const bound = (name: string) => {
+ for (const ax of kids(plotArea, C, 'valAx')) {
+ const v = parseFloat(val(kid(ax, C, 'scaling'), C, name) ?? '');
+ if (Number.isFinite(v)) return v;
+ }
+ return null;
+ };
+ return {
+ kind: PLOTS[plot.localName] === 'bar' && val(plot, C, 'barDir') === 'bar' ? 'hbar' : PLOTS[plot.localName],
+ stacked: (val(plot, C, 'grouping') ?? '').startsWith('stacked'),
+ title: val(chart, C, 'autoTitleDeleted') === '1' ? '' : richText(kid(chart, C, 'title')),
+ cats,
+ series,
+ legend: !!kid(chart, C, 'legend'),
+ grid: axes.some((ax) => !!kid(ax, C, 'majorGridlines')),
+ catTitle: axisTitle(0),
+ valTitle: axisTitle(1),
+ axMin: bound('min'),
+ axMax: bound('max'),
+ };
+}
+
+// ---- drawing ---------------------------------------------------------------
+
+const esc = (s: string) => s.replace(/[<>&]/g, (c) => (c === '<' ? '<' : c === '>' ? '>' : '&'));
+
+const text = (x: number, y: number, s: string, size: number, anchor = 'middle', rotate = 0) =>
+ `${esc(s)}`;
+
+const r = (n: number) => Math.round(n * 100) / 100;
+
+// A tick step of 1, 2 or 5 × a power of ten — the series a spreadsheet picks — and the
+// axis bounds it rounds the data to. A zero baseline is kept whenever the data allows.
+function scale(lo: number, hi: number, want: number, fixLo = false, fixHi = false):
+ { min: number; max: number; step: number } {
+ if (!fixLo && lo > 0) lo = 0;
+ if (!fixHi && hi < 0) hi = 0;
+ if (hi === lo) hi = lo + 1;
+ const raw = (hi - lo) / Math.max(1, want);
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
+ const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;
+ return {
+ min: fixLo ? lo : Math.floor(lo / step) * step,
+ max: fixHi ? hi : Math.ceil(hi / step) * step,
+ step,
+ };
+}
+
+// Ticks come out of repeated addition, so 0.1 steps would print 0.30000000000000004.
+const tickLabel = (v: number, step: number) => {
+ const decimals = Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));
+ return v.toFixed(decimals).replace(/\.?0+$/, (m) => (m.includes('.') ? '' : m));
+};
+
+function drawPie(c: Chart, w: number, h: number, top: number): string {
+ const values = c.series[0].values.map((v) => Math.abs(v ?? 0));
+ const total = values.reduce((a, b) => a + b, 0) || 1;
+ const cx = w / 2;
+ const cy = top + (h - top) / 2;
+ const rad = Math.max(8, Math.min(w, h - top) / 2 - 8);
+ let angle = -Math.PI / 2;
+ const at = (t: number) => `${r(cx + rad * Math.cos(t))} ${r(cy + rad * Math.sin(t))}`;
+ return values.map((v, i) => {
+ const sweep = (v / total) * Math.PI * 2;
+ const end = angle + sweep;
+ const path = sweep >= Math.PI * 2 - 1e-9
+ ? `M ${r(cx)} ${r(cy - rad)} A ${r(rad)} ${r(rad)} 0 1 1 ${r(cx - 0.01)} ${r(cy - rad)} Z`
+ : `M ${r(cx)} ${r(cy)} L ${at(angle)} A ${r(rad)} ${r(rad)} 0 ${sweep > Math.PI ? 1 : 0} 1 ${at(end)} Z`;
+ angle = end;
+ const fill = DEFAULT_ACCENTS[i % DEFAULT_ACCENTS.length];
+ return ``;
+ }).join('');
+}
+
+function chartSvg(c: Chart, width: number, height: number): string {
+ const fs = Math.max(7, Math.min(11, height / 26));
+ const titleFs = fs * 1.3;
+ const top = c.title ? titleFs * 2 : 8;
+ const legendH = c.legend && c.series.length > 1 ? fs * 2 : 0;
+ const head = ``;
+
+ const nums = c.series.flatMap((s) => s.values.filter((v): v is number => v != null));
+ const sums = c.stacked
+ ? c.cats.map((_, i) => c.series.reduce((a, s) => a + (s.values[i] ?? 0), 0))
+ : nums;
+ const want = Math.max(2, Math.round(height / 45));
+ const { min, max, step } = scale(c.axMin ?? Math.min(...sums, 0), c.axMax ?? Math.max(...sums, 0), want, c.axMin != null, c.axMax != null);
+ const ticks: number[] = [];
+ for (let v = min; v <= max + step / 2; v += step) ticks.push(v);
+ const labelW = Math.max(...ticks.map((t) => tickLabel(t, step).length)) * fs * 0.6;
+
+ const left = labelW + (c.valTitle ? fs * 1.6 : 0) + fs * 0.8;
+ const right = fs * 0.8;
+ const bottom = fs * 2 + (c.catTitle ? fs * 1.4 : 0) + legendH;
+ const plot = { x: left, y: top, w: Math.max(10, width - left - right), h: Math.max(10, height - top - bottom) };
+ const yOf = (v: number) => plot.y + plot.h - ((v - min) / (max - min)) * plot.h;
+
+ const axes = ticks.map((t) =>
+ (c.grid ? `` : '')
+ + text(plot.x - fs * 0.4, yOf(t) + fs * 0.35, tickLabel(t, step), fs, 'end')).join('')
+ + ``
+ + ``;
+
+ const n = Math.max(1, c.cats.length || Math.max(...c.series.map((s) => s.values.length)));
+ const slot = plot.w / n;
+ // No categories in the file means the spreadsheet's own 1..n, which is what it draws.
+ const labels = c.cats.length ? c.cats : Array.from({ length: n }, (_, i) => String(i + 1));
+ const every = Math.ceil((Math.max(...labels.map((l) => l.length)) * fs * 0.62) / Math.max(1, slot));
+ const catLabels = labels.map((label, i) => (label && i % every === 0
+ ? text(plot.x + slot * (i + 0.5), plot.y + plot.h + fs * 1.3, label, fs) : '')).join('');
+ const titles = (c.catTitle ? text(plot.x + plot.w / 2, height - legendH - fs * 0.4, c.catTitle, fs) : '')
+ + (c.valTitle ? text(fs * 1.1, plot.y + plot.h / 2, c.valTitle, fs, 'middle', -90) : '');
+
+ let body = '';
+ if (c.kind === 'bar' || c.kind === 'hbar') {
+ const gap = slot * 0.2;
+ const bw = (slot - gap) / (c.stacked ? 1 : c.series.length);
+ const tops = new Array(n).fill(0);
+ c.series.forEach((s, si) => {
+ for (let i = 0; i < n; i++) {
+ const v = s.values[i];
+ if (v == null) continue;
+ const base = c.stacked ? tops[i] : 0;
+ const y0 = yOf(base + v), y1 = yOf(base);
+ const x = plot.x + slot * i + gap / 2 + (c.stacked ? 0 : bw * si);
+ body += ``;
+ tops[i] = base + v;
+ }
+ });
+ } else {
+ c.series.forEach((s) => {
+ const pts = s.values.slice(0, n).map((v, i) => (v == null ? null : `${r(plot.x + slot * (i + 0.5))},${r(yOf(v))}`));
+ const line = pts.filter(Boolean).join(' ');
+ if (!line) return;
+ if (c.kind === 'area') {
+ body += ``;
+ }
+ body += ``;
+ if (c.kind === 'scatter') {
+ body += s.values.slice(0, n).map((v, i) => (v == null ? '' :
+ ``)).join('');
+ }
+ });
+ }
+ return `${head}${axes}${body}${catLabels}${titles}${legend()}`;
+}
+
+// The chart part as an SVG data-URI of the frame's own size, or null when it holds no
+// plot this can draw — the caller then keeps its placeholder.
+export function chartDataUrl(xml: string, widthPx: number, heightPx: number, accents: string[]): string | null {
+ const w = Math.max(80, Math.round(widthPx));
+ const h = Math.max(60, Math.round(heightPx));
+ let doc: Document;
+ try {
+ doc = new DOMParser().parseFromString(xml, 'application/xml');
+ } catch { return null; }
+ if (doc.getElementsByTagName('parsererror').length) return null;
+ const chart = parseChart(doc, accents.length ? accents : DEFAULT_ACCENTS);
+ if (!chart) return null;
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(chartSvg(chart, w, h))}`;
+}
diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts
index 6f7ce03..e61eceb 100644
--- a/src/lib/import/docx.ts
+++ b/src/lib/import/docx.ts
@@ -19,6 +19,7 @@ import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
import { applyUniformRunFont, sinkOffsetFrames, type OdtImportResult } from './odt';
+import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
import { astToLatex } from '../math/latex';
@@ -55,6 +56,8 @@ type Ctx = {
leftMarginCm: number;
// The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs.
cellSpacing: ParaSpacing;
+ // theme1.xml's accent1..6, the colours a chart series names instead of an sRGB.
+ accents: string[];
};
// ---- units & editor defaults to suppress -----------------------------------
@@ -136,7 +139,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
- const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {} };
+ const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, accents: themeAccents(themeDoc) };
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
@@ -1209,11 +1212,13 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
};
const blip = drawing.getElementsByTagNameNS(A, 'blip')[0];
if (!blip) {
- // A chart (or any other graphic the editor cannot draw) still occupies its box, so
- // it comes in as a placeholder of that size rather than collapsing the layout.
if (boxPx.w > 0 && boxPx.h > 0) {
+ // A chart is drawn from its own part as a picture; anything else the editor
+ // cannot draw still occupies its box, as a placeholder of that size.
+ const drawn = chartImage(drawing, boxPx, ctx);
+ if (drawn) return frameNode(drawn, boxPx, 'Chart', anchor, ctx);
ctx.warnings.add('Charts and other drawings were replaced by a placeholder');
- return placeholderNode(boxPx, 'Chart', anchor, ctx);
+ return frameNode(placeholderImage('Chart', boxPx.w, boxPx.h), boxPx, 'Chart', anchor, ctx);
}
ctx.warnings.add('Drawings were removed');
return null;
@@ -1234,7 +1239,7 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
if (!src) {
if (boxPx.w > 0 && boxPx.h > 0) {
ctx.warnings.add('Images in a format the browser can’t display (e.g. WMF, EMF) were replaced by a placeholder');
- return placeholderNode(boxPx, 'Image', anchor, ctx);
+ return frameNode(placeholderImage('Image', boxPx.w, boxPx.h), boxPx, 'Image', anchor, ctx);
}
ctx.warnings.add('Some images could not be read and were skipped');
return null;
@@ -1264,9 +1269,29 @@ function convertDrawing(drawing: Element, ctx: Ctx): Node | null {
return { type: 'image', attrs };
}
-// An undrawable frame as an image node of the same size, wrapped like the real one.
-function placeholderNode(box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
- const attrs: Record = { src: placeholderImage(label, box.w, box.h), width: box.w, height: box.h, alt: label };
+// theme1.xml's accent1..6 as #RRGGBB, in order — a chart series names them by role.
+function themeAccents(theme: Document | null): string[] {
+ const scheme = theme?.getElementsByTagNameNS(A, 'clrScheme')[0];
+ return [1, 2, 3, 4, 5, 6].map((n) => {
+ const el = scheme ? Array.from(scheme.children).find((c) => c.localName === `accent${n}`) : null;
+ const srgb = el ? el.getElementsByTagNameNS(A, 'srgbClr')[0]?.getAttribute('val') : null;
+ return srgb ? `#${srgb}` : '';
+ }).filter(Boolean);
+}
+
+// → the chart part, drawn as a picture of the frame's own size.
+function chartImage(drawing: Element, box: { w: number; h: number }, ctx: Ctx): string | null {
+ const ref = Array.from(drawing.getElementsByTagName('*')).find((e) => e.localName === 'chart' && e.getAttributeNS(R, 'id'));
+ const rel = ref ? ctx.rels.get(ref.getAttributeNS(R, 'id')!) : undefined;
+ if (!rel || rel.external) return null;
+ const bytes = ctx.files[`word/${rel.target.replace(/^\/+/, '')}`];
+ return bytes ? chartDataUrl(strFromU8(bytes), box.w, box.h, ctx.accents) : null;
+}
+
+// A frame the editor draws itself (a chart) or cannot draw at all, as an image node of
+// the same size, wrapped like the real one.
+function frameNode(src: string, box: { w: number; h: number }, label: string, anchor: Element | undefined, ctx: Ctx): Node {
+ const attrs: Record = { src, width: box.w, height: box.h, alt: label };
if (anchor) {
const { wrap, offsetCm, offsetYCm } = anchorWrap(anchor, ctx);
attrs.wrap = wrap;
diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md
index e361e20..1a36750 100644
--- a/tests/render-parity/README.md
+++ b/tests/render-parity/README.md
@@ -111,10 +111,11 @@ with 51 line-level differences — most of them the two engine rules below, not
paragraph mark is an `::after` on the block, so the same rule takes the *last* line's
height away with it. An empty widget span at the block's end gets that line back but not
to the same value. Whatever replaces the strut has to carry both.
-- **Charts and metafiles are placeholders.** No renderer exists for either, so an
- undrawable frame keeps its box and its label but not its picture (`imageFormats.ts`).
- Exporting writes the placeholder back out — the original is gone from the moment it
- is imported.
+- **Metafiles are placeholders, and a re-export keeps the placeholder.** EMF/WMF/SVM have
+ no JS decoder, so such a frame keeps its box and its label but not its picture
+ (`imageFormats.ts`) — and what is exported is that placeholder. **Charts** are drawn
+ now (`import/chart.ts`), but as a *picture*: a re-export carries the drawing, not a
+ chart object, since the editor has none.
- **A list level's own hanging indent is not read**, only the 0.635cm both exports
write (`LIST_HANGING_CM`). A **left**-set label wider than that overflows into the text,
where Word moves the text to the next list tab — reading `w:lvl/w:pPr/w:ind w:hanging`
diff --git a/tests/unit/chart.test.ts b/tests/unit/chart.test.ts
new file mode 100644
index 0000000..86126ce
--- /dev/null
+++ b/tests/unit/chart.test.ts
@@ -0,0 +1,59 @@
+// A DrawingML chart part drawn as an SVG picture (import/chart.ts): series values,
+// the file's own axis bounds, and the 1..n categories a chart without any gets.
+import { describe, it, expect } from 'vitest';
+import { chartDataUrl } from '../../src/lib/import/chart';
+
+const C = 'xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"';
+const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
+
+const points = (vals: number[]) =>
+ `` +
+ vals.map((v, i) => `${v}`).join('') + '';
+
+const chartXml = (plot: string, opts: { max?: string; cats?: string[] } = {}) =>
+ `` +
+ `Phase Std` +
+ `<${plot}>` +
+ (opts.cats ? `` +
+ opts.cats.map((t, i) => `${t}`).join('') + '' : '') +
+ `${points([1, 2, 3])}${plot}>` +
+ `interferogram` +
+ `${opts.max ? `` : ''}` +
+ `` +
+ ``;
+
+const svgOf = (xml: string, accents: string[] = []) => {
+ const url = chartDataUrl(xml, 400, 300, accents);
+ expect(url?.startsWith('data:image/svg+xml')).toBe(true);
+ return decodeURIComponent(url!.replace(/^data:image\/svg\+xml;charset=utf-8,/, ''));
+};
+
+describe('a chart part', () => {
+ it('draws its bars, title, axis title and the theme accent it names', () => {
+ const svg = svgOf(chartXml('c:barChart'), ['#111111', '#22AA33']);
+ expect(svg).toContain('Phase Std');
+ expect(svg).toContain('interferogram');
+ expect(svg).toContain('#22AA33');
+ expect((svg.match(/ {
+ expect(svgOf(chartXml('c:barChart', { max: '10' }))).toContain('>10<');
+ expect(svgOf(chartXml('c:barChart'))).not.toContain('>10<');
+ });
+
+ it('numbers the categories 1..n when the file names none, and keeps the ones it does', () => {
+ const plain = svgOf(chartXml('c:barChart'));
+ expect(plain).toContain('>1<');
+ expect(plain).toContain('>3<');
+ expect(svgOf(chartXml('c:barChart', { cats: ['Mo', 'Di', 'Mi'] }))).toContain('>Di<');
+ });
+
+ it('draws a line and a pie chart, and declines a plot it cannot draw', () => {
+ expect(svgOf(chartXml('c:lineChart'))).toContain('