Files
edentext/src/lib/import/chart.ts
T
Steffen Becker 2380ed699b feat: an ODF chart object is drawn too
<chart:chart> in the object's sub-document, read through the same picture as
the DOCX leg: series values by cell range out of its own local-table, colours
from the series style, bounds from the axis style.
2026-08-08 23:54:59 +00:00

401 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;'));
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);
return chart ? svgUrl(chart, w, h) : null;
}
const svgUrl = (chart: Chart, widthPx: number, heightPx: number) =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(
chartSvg(chart, Math.max(80, Math.round(widthPx)), Math.max(60, Math.round(heightPx))))}`;
// ---- ODF charts ------------------------------------------------------------
// An embedded ODF chart document (LibreOffice's "Object N/content.xml"): the same
// picture, read from <chart:chart> and the local table that holds its numbers.
const NS_CHART = 'urn:oasis:names:tc:opendocument:xmlns:chart:1.0';
const NS_TABLE = 'urn:oasis:names:tc:opendocument:xmlns:table:1.0';
const NS_OFFICE = 'urn:oasis:names:tc:opendocument:xmlns:office:1.0';
const NS_STYLE = 'urn:oasis:names:tc:opendocument:xmlns:style:1.0';
const NS_DRAW = 'urn:oasis:names:tc:opendocument:xmlns:drawing:1.0';
const ODF_PLOTS: Record<string, Chart['kind']> = {
bar: 'bar', line: 'line', circle: 'pie', ring: 'pie', area: 'area', scatter: 'scatter',
};
// "local-table.$B$2:.$B$24" → the column index and the row span it covers (1-based,
// as the sheet counts them). Only single-column ranges occur in a chart's own table.
function cellRange(addr: string | null): { col: number; from: number; to: number } | null {
const m = /\.?\$?([A-Z]+)\$?(\d+)(?::\.?\$?[A-Z]+\$?(\d+))?/.exec(addr ?? '');
if (!m) return null;
const col = [...m[1]].reduce((a, ch) => a * 26 + (ch.charCodeAt(0) - 64), 0);
return { col, from: +m[2], to: +(m[3] ?? m[2]) };
}
// The chart's own <table:table> as rows of cells, repeats expanded. A cell's number is
// office:value; its label is the text, which is what a category axis shows.
function localTable(chart: Element): { num: (number | null)[]; text: string[] }[] {
const table = chart.getElementsByTagNameNS(NS_TABLE, 'table')[0];
const rows: { num: (number | null)[]; text: string[] }[] = [];
for (const tr of Array.from(table?.getElementsByTagNameNS(NS_TABLE, 'table-row') ?? [])) {
const num: (number | null)[] = [];
const text: string[] = [];
for (const td of Array.from(tr.children)) {
if (td.namespaceURI !== NS_TABLE || !td.localName.startsWith('table-cell')) continue;
const repeat = Math.min(64, parseInt(td.getAttributeNS(NS_TABLE, 'number-columns-repeated') ?? '1', 10) || 1);
const raw = td.getAttributeNS(NS_OFFICE, 'value');
const v = raw != null ? parseFloat(raw) : NaN;
for (let i = 0; i < repeat; i++) {
num.push(Number.isFinite(v) ? v : null);
text.push((td.textContent ?? '').trim());
}
}
rows.push({ num, text });
}
return rows;
}
const odfStyleProp = (styles: Map<string, Element>, name: string | null, ns: string, family: string, attr: string) => {
const props = styles.get(name ?? '')?.getElementsByTagNameNS(NS_STYLE, family)[0];
return props?.getAttributeNS(ns, attr) ?? null;
};
function parseOdfChart(doc: Document): Chart | null {
const chart = doc.getElementsByTagNameNS(NS_CHART, 'chart')[0];
const kind = ODF_PLOTS[(chart?.getAttributeNS(NS_CHART, 'class') ?? '').replace(/^chart:/, '')];
if (!chart || !kind) return null;
const styles = new Map<string, Element>();
for (const st of Array.from(doc.getElementsByTagNameNS(NS_STYLE, 'style'))) {
const n = st.getAttributeNS(NS_STYLE, 'name');
if (n) styles.set(n, st);
}
const plotArea = chart.getElementsByTagNameNS(NS_CHART, 'plot-area')[0] ?? null;
const rows = localTable(chart);
const column = (addr: string | null, pick: 'num' | 'text') => {
const range = cellRange(addr);
if (!range) return [];
return rows.slice(range.from - 1, range.to).map((row) => (row?.[pick][range.col - 1] ?? null));
};
const axis = (dim: string) => Array.from(plotArea?.getElementsByTagNameNS(NS_CHART, 'axis') ?? [])
.find((a) => a.getAttributeNS(NS_CHART, 'dimension') === dim) ?? null;
const titleOf = (el: Element | null) =>
(el?.getElementsByTagNameNS(NS_CHART, 'title')[0]?.textContent ?? '').trim();
const yStyle = axis('y')?.getAttributeNS(NS_CHART, 'style-name') ?? null;
const bound = (attr: string) => {
const v = parseFloat(odfStyleProp(styles, yStyle, NS_CHART, 'chart-properties', attr) ?? '');
return Number.isFinite(v) ? v : null;
};
const series: Series[] = Array.from(plotArea?.getElementsByTagNameNS(NS_CHART, 'series') ?? []).map((ser, i) => {
const fill = odfStyleProp(styles, ser.getAttributeNS(NS_CHART, 'style-name'), NS_DRAW, 'graphic-properties', 'fill-color');
const label = column(ser.getAttributeNS(NS_CHART, 'label-cell-address'), 'text')[0];
return {
name: (typeof label === 'string' && label) || `Series ${i + 1}`,
color: fill ?? DEFAULT_ACCENTS[i % DEFAULT_ACCENTS.length],
values: column(ser.getAttributeNS(NS_CHART, 'values-cell-range-address'), 'num') as (number | null)[],
};
});
if (!series.length) return null;
const catAddr = plotArea?.getElementsByTagNameNS(NS_CHART, 'categories')[0]
?.getAttributeNS(NS_TABLE, 'cell-range-address') ?? null;
return {
kind: kind === 'bar' && odfStyleProp(styles, plotArea?.getAttributeNS(NS_CHART, 'style-name') ?? null,
NS_CHART, 'chart-properties', 'vertical') === 'true' ? 'hbar' : kind,
stacked: odfStyleProp(styles, plotArea?.getAttributeNS(NS_CHART, 'style-name') ?? null,
NS_CHART, 'chart-properties', 'stacked') === 'true',
title: titleOf(chart),
cats: column(catAddr, 'text').map((v) => (typeof v === 'string' ? v : '')),
series,
legend: !!chart.getElementsByTagNameNS(NS_CHART, 'legend').length,
grid: !!axis('y')?.getElementsByTagNameNS(NS_CHART, 'grid').length,
catTitle: titleOf(axis('x')),
valTitle: titleOf(axis('y')),
axMin: bound('minimum'),
axMax: bound('maximum'),
};
}
// An embedded ODF chart document as an SVG data-URI, or null when it holds no plot
// this can draw — the caller then keeps its placeholder.
export function odfChartDataUrl(doc: Document, widthPx: number, heightPx: number): string | null {
const chart = parseOdfChart(doc);
return chart ? svgUrl(chart, widthPx, heightPx) : null;
}