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 = `` + + (c.title ? text(width / 2, titleFs * 1.2, c.title, titleFs) : ''); + const legend = () => { + if (!legendH) return ''; + const y = height - fs * 0.6; + const w = c.series.map((s) => s.name.length * fs * 0.55 + fs * 2.2); + let x = (width - w.reduce((a, b) => a + b, 0)) / 2; + return c.series.map((s, i) => { + const box = `` + + text(x + fs * 1.3, y, s.name, fs, 'start'); + x += w[i]; + return box; + }).join(''); + }; + + if (c.kind === 'pie') return `${head}${drawPie(c, width, height - legendH, top)}${legend()}`; + + 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])}` + + `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('