feat: a chart is drawn from its own part instead of a placeholder
word/charts/chartN.xml → an SVG picture of the frame's size: series from the producer's numCache, theme accent colours, titles, gridlines and the axis bounds the file fixes. Bar, line, area, scatter and pie; the rest keep the box.
This commit is contained in:
1 parent
4088bffbaf
commit
bf98014a3a
5 files changed
+380
-12
No files matched your search
@@ -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):** `<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. **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 `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` 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`).
|
||||
|
||||
@@ -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 <a:t> 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 <c:cat>/<c:val>/<c:xVal> cache: the values the producer last computed, indexed by
|
||||
// <c:pt idx>. 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<string, Chart['kind']> = {
|
||||
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) =>
|
||||
`<text x="${r(x)}" y="${r(y)}" font-family="sans-serif" font-size="${r(size)}" fill="${TEXT_COLOR}"` +
|
||||
` text-anchor="${anchor}"${rotate ? ` transform="rotate(${rotate} ${r(x)} ${r(y)})"` : ''}>${esc(s)}</text>`;
|
||||
|
||||
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 `<path d="${path}" fill="${c.series.length > 1 ? c.series[i % c.series.length].color : fill}"/>`;
|
||||
}).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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"`
|
||||
+ ` viewBox="0 0 ${width} ${height}"><rect width="${width}" height="${height}" fill="#ffffff"/>`
|
||||
+ (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 = `<rect x="${r(x)}" y="${r(y - fs * 0.9)}" width="${r(fs)}" height="${r(fs * 0.8)}" fill="${s.color}"/>`
|
||||
+ 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()}</svg>`;
|
||||
|
||||
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 ? `<line x1="${r(plot.x)}" y1="${r(yOf(t))}" x2="${r(plot.x + plot.w)}" y2="${r(yOf(t))}" stroke="${GRID_COLOR}"/>` : '')
|
||||
+ text(plot.x - fs * 0.4, yOf(t) + fs * 0.35, tickLabel(t, step), fs, 'end')).join('')
|
||||
+ `<line x1="${r(plot.x)}" y1="${r(plot.y)}" x2="${r(plot.x)}" y2="${r(plot.y + plot.h)}" stroke="${AXIS_COLOR}"/>`
|
||||
+ `<line x1="${r(plot.x)}" y1="${r(yOf(0))}" x2="${r(plot.x + plot.w)}" y2="${r(yOf(0))}" stroke="${AXIS_COLOR}"/>`;
|
||||
|
||||
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 += `<rect x="${r(x)}" y="${r(Math.min(y0, y1))}" width="${r(bw)}" height="${r(Math.abs(y1 - y0))}" fill="${s.color}"/>`;
|
||||
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 += `<polygon points="${r(plot.x + slot * 0.5)},${r(yOf(0))} ${line} ${r(plot.x + slot * (n - 0.5))},${r(yOf(0))}"`
|
||||
+ ` fill="${s.color}" fill-opacity="0.7"/>`;
|
||||
}
|
||||
body += `<polyline points="${line}" fill="none" stroke="${s.color}" stroke-width="1.6"/>`;
|
||||
if (c.kind === 'scatter') {
|
||||
body += s.values.slice(0, n).map((v, i) => (v == null ? '' :
|
||||
`<circle cx="${r(plot.x + slot * (i + 0.5))}" cy="${r(yOf(v))}" r="2" fill="${s.color}"/>`)).join('');
|
||||
}
|
||||
});
|
||||
}
|
||||
return `${head}${axes}${body}${catLabels}${titles}${legend()}</svg>`;
|
||||
}
|
||||
|
||||
// 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))}`;
|
||||
}
|
||||
+33
-8
@@ -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<string, unknown> = { 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);
|
||||
}
|
||||
|
||||
// <c:chart r:id> → 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<string, unknown> = { src, width: box.w, height: box.h, alt: label };
|
||||
if (anchor) {
|
||||
const { wrap, offsetCm, offsetYCm } = anchorWrap(anchor, ctx);
|
||||
attrs.wrap = wrap;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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[]) =>
|
||||
`<c:numCache><c:ptCount val="${vals.length}"/>` +
|
||||
vals.map((v, i) => `<c:pt idx="${i}"><c:v>${v}</c:v></c:pt>`).join('') + '</c:numCache>';
|
||||
|
||||
const chartXml = (plot: string, opts: { max?: string; cats?: string[] } = {}) =>
|
||||
`<c:chartSpace ${C} ${A}><c:chart>` +
|
||||
`<c:title><c:tx><c:rich><a:p><a:r><a:t>Phase Std</a:t></a:r></a:p></c:rich></c:tx></c:title>` +
|
||||
`<c:plotArea><${plot}><c:ser><c:spPr><a:solidFill><a:schemeClr val="accent2"/></a:solidFill></c:spPr>` +
|
||||
(opts.cats ? `<c:cat><c:strCache><c:ptCount val="${opts.cats.length}"/>` +
|
||||
opts.cats.map((t, i) => `<c:pt idx="${i}"><c:v>${t}</c:v></c:pt>`).join('') + '</c:strCache></c:cat>' : '') +
|
||||
`<c:val><c:numRef>${points([1, 2, 3])}</c:numRef></c:val></c:ser></${plot}>` +
|
||||
`<c:catAx><c:title><c:tx><c:rich><a:p><a:r><a:t>interferogram</a:t></a:r></a:p></c:rich></c:tx></c:title></c:catAx>` +
|
||||
`<c:valAx><c:scaling><c:orientation val="minMax"/>${opts.max ? `<c:max val="${opts.max}"/>` : ''}</c:scaling>` +
|
||||
`<c:majorGridlines/></c:valAx>` +
|
||||
`</c:plotArea></c:chart></c:chartSpace>`;
|
||||
|
||||
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(/<rect /g) ?? []).length).toBe(4); // one background + three bars
|
||||
});
|
||||
|
||||
it('takes the value axis bound from the file, not from the data', () => {
|
||||
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('<polyline');
|
||||
expect(svgOf(chartXml('c:pieChart'))).toContain('<path');
|
||||
expect(chartDataUrl(chartXml('c:surfaceChart'), 400, 300, [])).toBeNull();
|
||||
expect(chartDataUrl('not xml at all', 400, 300, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user