diff --git a/docs/features.md b/docs/features.md index a8cbb6e..7c19f9e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -15,9 +15,11 @@ | Format | Supported | Not yet | |--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------| | DOCX | inherited styles, run formatting, lists/numbering, tables (merge, border, shading, table style), images, hyperlinks, header/footer, PAGE field, text boxes, page/section breaks, text columns, tracked changes | footnote body, charts, exact float wrapping, paragraphs split across pages | -| XLSX | shared strings, number/date formats, font/fill/border/align, merge, freeze, hidden columns/rows, wrap, formula calculation (135 functions, recalculated on edit), multiple sheets | charts, conditional formatting, images | +| XLSX | shared strings, number/date formats, font/fill/border/align, merge, freeze, hidden columns/rows, wrap, formula calculation (135 functions, recalculated on edit), multiple sheets, images and charts over the cells | chartex charts (placeholder), shapes, conditional formatting | | PPTX | master/layout/theme, placeholder inheritance, ~50 preset shapes, custGeom, images (crop), tables + table style, groups, bullet/autonum, autofit | charts (placeholder), SmartArt, animations, video playback | +XLSX charts are drawn as SVG over the cells: bar and column (clustered, stacked, 100%), line (markers, smoothing), area, pie and doughnut (exploded slices), scatter, bubble, radar (lines, markers, filled), stock (high-low lines, up-down bars), surface (seen from above, as a contour chart, or as a wireframe), combinations of them on primary and secondary axes; 3D charts as their flat form. Fills, lines and text follow the file: colors, gradients, pattern fills, line widths and dashes, markers, the text color and size of titles, axes, legend and data labels, the chart and plot area backgrounds, and the built-in chart style (its colors and dark backgrounds; not its bevels and shadows). Their values come from the workbook's cells, so a chart follows edited cells (also on a sheet not yet shown); a series referring to a defined name or another workbook shows the values saved with the chart. Axis bounds, gridlines, number formats, title, legend, data labels, the theme's colors and the plot's manual layout follow the file; trendlines, error bars and data tables are not drawn yet. + Password-protected (encrypted) files are not supported. ### Word, Excel and PowerPoint 97-2003, RTF and OpenDocument diff --git a/src/core/worker/engine.ts b/src/core/worker/engine.ts index 772d840..6026cea 100644 --- a/src/core/worker/engine.ts +++ b/src/core/worker/engine.ts @@ -7,6 +7,7 @@ import { BlobSource, openSource, type DocSource } from '../source' import { decodeText, extOf, HAS_SOURCE, mayBePlain, plainFormat, plainToOffice, ungzipped } from '../plain' import { XlsxWorkbook } from '../xlsx/workbook' import { XlsxEditor } from '../xlsx/editor' +import { readSheetDrawings } from '../xlsx/drawings' import { loadDelimited } from '../xlsx/import' import type { BookOp, CellInput, FindOptions, FmtPatch, Range, SheetOp } from '../xlsx/types' import type { PParaSpec, ShapeOp, SlideOp } from '../pptx/types' @@ -207,6 +208,8 @@ export class Engine { return this.need(this.xlsx).block(args[0] as number, args[1] as number, args[2] as number) case 'xlsx.cell': return this.need(this.xlsx).cell(args[0] as number, args[1] as number, args[2] as number) + case 'xlsx.drawings': + return readSheetDrawings(this.need(this.xlsx), args[0] as number) case 'xlsx.find': return this.need(this.xlsx).find(args[0] as number, args[1] as string, args[2] as FindOptions) case 'xlsx.text': diff --git a/src/core/worker/protocol.ts b/src/core/worker/protocol.ts index dca9261..f4de718 100644 --- a/src/core/worker/protocol.ts +++ b/src/core/worker/protocol.ts @@ -7,6 +7,7 @@ import type { POp } from '../../collab/pptx-ops' import type { PptxCollabResult } from '../../collab/pptx-worker' import type { CanonStyle, DocxMeta, Block, EditOp, EditResult, HFSet, HFSetResult, PageSet, PageSetResult, ParaSpec, StyleInfo, StyleLook, StyleSetResult, TableOp } from '../docx/types' import type { BlockOut, BookOp, CellDetail, CellInput, FindMatch, FindOptions, FmtPatch, Range, SheetOp, SheetProgress, WorkbookMeta, XlsxEditResult } from '../xlsx/types' +import type { SheetDrawing } from '../xlsx/drawings' import type { PParaSpec, PresentationMeta, ShapeOp, SlideModel, SlideOp } from '../pptx/types' export interface OpenParams { @@ -69,6 +70,8 @@ export interface Methods { 'xlsx.load'(sheet: number): void 'xlsx.block'(sheet: number, r0: number, r1: number): BlockOut 'xlsx.cell'(sheet: number, r: number, c: number): CellDetail | undefined + /** the sheet's pictures and charts, where they are anchored */ + 'xlsx.drawings'(sheet: number): SheetDrawing[] /** cells whose shown value (or formula) contains the text */ 'xlsx.find'(sheet: number, query: string, opts?: FindOptions): FindMatch[] /** the sheet as delimited text (the values as shown): `sep` a comma (with a byte order mark) or a tab */ diff --git a/src/core/xlsx/chart-svg.ts b/src/core/xlsx/chart-svg.ts new file mode 100644 index 0000000..bdda8c5 --- /dev/null +++ b/src/core/xlsx/chart-svg.ts @@ -0,0 +1,1019 @@ +/** + * An Excel chart (chart.ts `ChartModel`) drawn as a self-contained SVG picture of `w` × `h` CSS pixels, DOM-free (it + * runs anywhere): bars and columns (clustered, stacked, 100%), lines (markers, smoothing), areas, scatter, bubbles, + * stock (high-low lines, up-down bars), pie and doughnut, radar, and surface (seen from above, as Excel's contour + * chart), combined on primary and secondary axes; axis bounds chosen the way Excel does when the file fixes none + * (zero included, steps of 1, 2 or 5), major gridlines, tick and data labels in the series' number formats, title and + * legend, in the colors, lines, fills (gradients and patterns too) and text looks the file gives. Text is measured + * with the diagram renderers' widths (diagram/svg.ts), so labels are placed without a browser. + * + * Every chart is its own picture, so the ids of its gradients, patterns and clip path need only be unique within it. + */ +import { lineHeight, pathData, Svg, textWidth, wrap, type Paint, type Pt } from '../diagram/svg' +import type { ChartAxis, ChartGroup, ChartModel, ChartSeries, ChartText, FillLook, LineLook, TextLook } from './chart' +import { SURFACE_BANDS } from './chart' +import { formatValue } from './numfmt' + +const PX = 96 / 72 +const PAD = 8 + +interface Box { + x: number + y: number + w: number + h: number +} + +/** a value axis' scale: its bounds, step and ticks, and how a value maps to pixels along `len` */ +interface Scale { + min: number + max: number + ticks: number[] + log?: number + format?: string +} + +const DASH: Record = { + dash: [4, 3], sysDash: [3, 1], dot: [1, 1], sysDot: [1, 1], dashDot: [4, 3, 1, 3], sysDashDot: [3, 1, 1, 1], + lgDash: [8, 3], lgDashDot: [8, 3, 1, 3], lgDashDotDot: [8, 3, 1, 3, 1, 3], sysDashDotDot: [3, 1, 1, 1, 1, 1], +} +const dashOf = (d: string | undefined, w: number) => (d && DASH[d] ? DASH[d].map((x) => x * Math.max(1, w)).join(' ') : undefined) + +/** a line's paint: its color, width and dash */ +const linePaint = (l: LineLook): Paint => ({ stroke: l.color, sw: Math.max(0.75, l.width * PX), dash: dashOf(l.dash, l.width) }) + +const fmt = (v: number, f: string | undefined, date1904: boolean) => formatValue(v, f || 'General', date1904).text + +/** Excel's automatic bounds: zero kept in view unless the values sit far from it, a step of 1, 2 or 5 × 10ⁿ */ +export function niceScale(lo: number, hi: number, opts: { min?: number; max?: number; major?: number; zero?: boolean; ticks?: number } = {}): Scale { + if (!Number.isFinite(lo) || !Number.isFinite(hi)) (lo = 0), (hi = 1) + if (lo > hi) [lo, hi] = [hi, lo] + if (opts.zero !== false) { + if (lo > 0 && lo < (hi * 5) / 6) lo = 0 + if (hi < 0 && hi > (lo * 5) / 6) hi = 0 + } + if (opts.min !== undefined) lo = opts.min + if (opts.max !== undefined) hi = opts.max + if (lo === hi) hi = lo === 0 ? 1 : lo + Math.abs(lo) * 0.1 + const want = Math.max(2, opts.ticks ?? 6) + let step = opts.major + if (!step || step <= 0) { + const rough = (hi - lo) / want + const mag = 10 ** Math.floor(Math.log10(rough)) + step = ([1, 2, 5, 10].find((m) => m * mag >= rough) ?? 10) * mag + } + let min = opts.min ?? Math.floor(lo / step + 1e-9) * step + let max = opts.max ?? Math.ceil(hi / step - 1e-9) * step + // the largest value is not left on the top line + if (opts.max === undefined && max === hi && hi !== 0 && hi > 0) max += step + if (opts.min === undefined && min === lo && lo < 0) min -= step + if (max <= min) max = min + step + const ticks: number[] = [] + for (let k = 0, v = min; v <= max + step * 1e-6 && k < 200; k++, v = min + k * step) ticks.push(Math.abs(v) < step * 1e-9 ? 0 : v) + return { min, max, ticks } +} + +function logScale(lo: number, hi: number, base: number, opts: { min?: number; max?: number }): Scale { + const b = base > 1 ? base : 10 + const p0 = opts.min ?? b ** Math.floor(Math.log(Math.max(lo, 1e-12)) / Math.log(b)) + const p1 = opts.max ?? b ** Math.ceil(Math.log(Math.max(hi, p0 * b)) / Math.log(b)) + const ticks: number[] = [] + for (let v = p0; v <= p1 * 1.0001 && ticks.length < 60; v *= b) ticks.push(v) + return { min: p0, max: p1, ticks, log: b } +} + +/** a value's position along an axis: 0 at its minimum, 1 at its maximum */ +function frac(s: Scale, v: number): number { + if (s.log) { + const l = (x: number) => Math.log(Math.max(x, 1e-12)) + return (l(v) - l(s.min)) / (l(s.max) - l(s.min)) + } + return (v - s.min) / (s.max - s.min) +} + +function textBlock(svg: Svg, t: ChartText, x: number, y: number, maxW: number, anchor: 'start' | 'middle' | 'end' = 'middle'): number { + const size = t.size * PX + const lines = wrap(t.text.split('\n'), Math.max(40, maxW), size, t.bold) + const h = lines.length * lineHeight(size) + svg.text(x, y + h / 2, lines, { size, bold: t.bold, color: t.color, anchor }) + return h +} + +/** a text's size in px and color: its own look over the chart's */ +const textOf = (m: ChartModel, look: TextLook | undefined) => ({ size: (look?.size ?? m.fontSize) * PX, color: look?.color ?? m.textColor, bold: look?.bold }) + +// ---------------------------------------------------------------- fills: gradients and patterns + +const defIds = new WeakMap() + +/** the paint for a fill: its color, or a gradient or pattern defined in the picture */ +function fillPaint(svg: Svg, fill: string | null | undefined, look?: FillLook): string { + if (!look || fill === null) return fill ?? 'none' + const k = (defIds.get(svg) ?? 0) + 1 + defIds.set(svg, k) + const id = `de-fill${k}` + if (look.kind === 'gradient') { + const stops = look.stops.map((s) => ``).join('') + if (look.radial) svg.raw(`${stops}`) + else { + const a = (look.angle * Math.PI) / 180 + const r = (v: number) => Math.round(v * 1000) / 10 + const [x1, y1, x2, y2] = [0.5 - Math.cos(a) / 2, 0.5 - Math.sin(a) / 2, 0.5 + Math.cos(a) / 2, 0.5 + Math.sin(a) / 2] + svg.raw(`${stops}`) + } + } else { + svg.raw(`${patternTile(look.preset, look.fg)}`) + } + return `url(#${id})` +} + +/** an 8 × 8 Bayer matrix: a pattern of n % fills the cells below n % of 64, spread evenly */ +const BAYER = [0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62, 30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31, 55, 23, 61, 29, 53, 21] + +/** a preset pattern's foreground in an 8 × 8 tile (DrawingML's `prst` names); one not drawn here is shown at 50 % */ +function patternTile(preset: string, fg: string): string { + const cells = (on: (x: number, y: number) => boolean) => { + let s = '' + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) if (on(x, y)) s += `M${x} ${y}h1v1h-1z` + return s ? `` : '' + } + const lines = (d: string, w: number) => `` + const horz = (gap: number, w: number, dash = false) => lines(Array.from({ length: 8 / gap }, (_, k) => `M0 ${k * gap + w / 2}H${dash ? 4 : 8}`).join(''), w) + const vert = (gap: number, w: number, dash = false) => lines(Array.from({ length: 8 / gap }, (_, k) => `M${k * gap + w / 2} 0V${dash ? 4 : 8}`).join(''), w) + const down = (gap: number, w: number) => lines([-8, ...Array.from({ length: 8 / gap }, (_, k) => k * gap)].map((o) => `M${o} 0L${o + 8} 8`).join(''), w) + const up = (gap: number, w: number) => lines([16, ...Array.from({ length: 8 / gap }, (_, k) => 8 + k * gap)].map((o) => `M${o} 0L${o - 8} 8`).join(''), w) + const pct = /^pct(\d+)$/.exec(preset) + if (pct) return cells((x, y) => BAYER[y * 8 + x] < Math.round((Number(pct[1]) / 100) * 64)) + switch (preset) { + case 'horz': return horz(4, 1) + case 'ltHorz': return horz(4, 1) + case 'narHorz': return horz(2, 1) + case 'dkHorz': return horz(4, 2) + case 'dashHorz': return horz(4, 1, true) + case 'vert': return vert(4, 1) + case 'ltVert': return vert(4, 1) + case 'narVert': return vert(2, 1) + case 'dkVert': return vert(4, 2) + case 'dashVert': return vert(4, 1, true) + case 'dnDiag': return down(4, 1) + case 'ltDnDiag': return down(4, 1) + case 'dkDnDiag': return down(4, 2) + case 'wdDnDiag': return down(8, 3) + case 'dashDnDiag': return down(8, 1) + case 'upDiag': return up(4, 1) + case 'ltUpDiag': return up(4, 1) + case 'dkUpDiag': return up(4, 2) + case 'wdUpDiag': return up(8, 3) + case 'dashUpDiag': return up(8, 1) + case 'cross': + case 'smGrid': return horz(4, 1) + vert(4, 1) + case 'lgGrid': return horz(8, 1) + vert(8, 1) + case 'diagCross': + case 'openDmnd': return down(8, 1) + up(8, 1) + case 'smCheck': return cells((x, y) => ((x >> 1) + (y >> 1)) % 2 === 0) + case 'lgCheck': return cells((x, y) => ((x >> 2) + (y >> 2)) % 2 === 0) + case 'solidDmnd': return `` + case 'dotGrid': return cells((x, y) => x % 4 === 0 && y % 4 === 0) + case 'dotDmnd': return cells((x, y) => (x % 4 === 0 && y % 4 === 0) || (x % 4 === 2 && y % 4 === 2)) + default: return cells((x, y) => BAYER[y * 8 + x] < 32) + } +} + +// ---------------------------------------------------------------- markers and lines + +function marker(svg: Svg, x: number, y: number, m: NonNullable) { + const r = Math.max(1.5, (m.size * PX) / 2) + const p: Paint = { fill: m.fill ?? 'none', stroke: m.line, sw: 1 } + switch (m.symbol) { + case 'circle': + case 'dot': + svg.circle(x, y, m.symbol === 'dot' ? r / 2 : r, p) + break + case 'square': + svg.rect(x - r, y - r, r * 2, r * 2, p) + break + case 'diamond': + svg.poly([[x, y - r], [x + r, y], [x, y + r], [x - r, y]], p) + break + case 'triangle': + svg.poly([[x, y - r], [x + r, y + r], [x - r, y + r]], p) + break + case 'x': + svg.line(x - r, y - r, x + r, y + r, { stroke: m.line ?? m.fill, sw: 1.5 }).line(x - r, y + r, x + r, y - r, { stroke: m.line ?? m.fill, sw: 1.5 }) + break + case 'plus': + svg.line(x - r, y, x + r, y, { stroke: m.line ?? m.fill, sw: 1.5 }).line(x, y - r, x, y + r, { stroke: m.line ?? m.fill, sw: 1.5 }) + break + case 'star': { + const pts: [number, number][] = [] + for (let k = 0; k < 10; k++) { + const a = -Math.PI / 2 + (k * Math.PI) / 5 + const rr = k % 2 ? r * 0.45 : r + pts.push([x + rr * Math.cos(a), y + rr * Math.sin(a)]) + } + svg.poly(pts, p) + break + } + case 'dash': + svg.line(x - r, y, x + r, y, { stroke: m.line ?? m.fill, sw: 2 }) + break + default: + svg.circle(x, y, r, p) + } +} + +/** a line through `pts`, smoothed as Excel does (a Catmull-Rom curve) when asked */ +function linePath(pts: Pt[], smooth: boolean): string { + if (!smooth || pts.length < 3) return pathData(pts.map((p, k) => ({ c: k ? 'L' : 'M', p: [p] }))) + const cmds: { c: 'M' | 'C'; p: Pt[] }[] = [{ c: 'M', p: [pts[0]] }] + for (let k = 0; k < pts.length - 1; k++) { + const p0 = pts[Math.max(0, k - 1)] + const p1 = pts[k] + const p2 = pts[k + 1] + const p3 = pts[Math.min(pts.length - 1, k + 2)] + cmds.push({ c: 'C', p: [{ x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6 }, { x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6 }, p2] }) + } + return pathData(cmds) +} + +/** a group drawn as lines (its legend entries show a line and a marker) */ +const lined = (g: ChartGroup) => g.type === 'line' || g.type === 'scatter' || g.type === 'stock' || (g.type === 'radar' && g.radarStyle !== 'filled') + +// ---------------------------------------------------------------- legend + +interface LegendEntry { + label: string + fill?: string | null + look?: FillLook + line?: ChartSeries['line'] + marker?: ChartSeries['marker'] +} + +function legendEntries(m: ChartModel): LegendEntry[] { + const g0 = m.groups[0] + if (g0.type === 'surface') return surfaceBands(g0, m).map((b) => ({ label: b.label, fill: b.fill, look: b.look })) + // a pie (or one series of varied colors): the points of its first series are the entries + const pie = g0.type === 'pie' || g0.type === 'doughnut' + if (m.groups.length === 1 && g0.varyColors && (pie || g0.series.length === 1)) { + const s = g0.series[0] + return s.cats.map((c, k) => ({ label: c, fill: s.points[k]?.color ?? s.fill, look: s.points[k]?.look ?? (s.points[k]?.color ? undefined : s.look) })).filter((_, k) => !m.legend?.deleted.includes(k)) + } + const out: LegendEntry[] = [] + let k = 0 + for (const g of m.groups) + for (const s of g.series) { + if (m.legend?.deleted.includes(k++)) continue + out.push(lined(g) ? { label: s.name, line: s.line, marker: s.marker } : { label: s.name, fill: s.fill, look: s.look }) + } + return out +} + +function drawLegend(svg: Svg, entries: LegendEntry[], box: Box, vertical: boolean, size: number, color: string, bold?: boolean) { + const sw = size * 0.8 + const lh = lineHeight(size) + 2 + let x = box.x + let y = box.y + const rowW = box.w + for (const e of entries) { + const w = sw + 4 + textWidth(e.label, size, bold) + 12 + if (!vertical && x + w > box.x + rowW + 1 && x > box.x) { + x = box.x + y += lh + } + const cy = y + lh / 2 + if (e.line || e.marker) { + if (e.line) svg.line(x, cy, x + sw * 1.6, cy, { stroke: e.line.color, sw: Math.min(3, e.line.width * PX), dash: dashOf(e.line.dash, e.line.width) }) + if (e.marker) marker(svg, x + sw * 0.8, cy, { ...e.marker, size: Math.min(e.marker.size, 7) }) + svg.text(x + sw * 1.6 + 4, cy, e.label, { size, color, bold }) + x += sw * 1.6 + 4 + textWidth(e.label, size, bold) + 12 + } else { + svg.rect(x, cy - sw / 2, sw, sw, { fill: fillPaint(svg, e.fill, e.look) }) + svg.text(x + sw + 4, cy, e.label, { size, color, bold }) + x += w + } + if (vertical) { + x = box.x + y += lh + } + } +} + +function legendSize(entries: LegendEntry[], vertical: boolean, size: number, maxW: number, bold?: boolean): { w: number; h: number } { + const lh = lineHeight(size) + 2 + const widths = entries.map((e) => size * 0.8 * (e.line || e.marker ? 1.6 : 1) + 4 + textWidth(e.label, size, bold) + 12) + if (vertical) return { w: Math.min(maxW, Math.max(0, ...widths)), h: entries.length * lh } + let rows = 1 + let x = 0 + for (const w of widths) { + if (x + w > maxW && x > 0) (rows++, (x = 0)) + x += w + } + return { w: Math.min(maxW, widths.reduce((a, b) => a + b, 0)), h: rows * lh } +} + +// ---------------------------------------------------------------- pie and doughnut + +function drawPie(svg: Svg, m: ChartModel, g: ChartGroup, box: Box) { + const s = g.series[0] + // a negative value is a slice of its magnitude, as in Excel; its label keeps the sign + const vals = s.values.map((v) => (v ? Math.abs(v) : 0)) + const total = vals.reduce((a, b) => a + b, 0) + if (!total) return + const exploded = Math.max(0, ...Object.values(s.points).map((p) => p.explosion ?? 0)) + const outside = s.labels && (s.labels.pos === 'outEnd' || s.labels.pos === 'bestFit') + const r = Math.max(4, (Math.min(box.w, box.h) / 2) * (outside ? 0.75 : 0.92) / (1 + exploded / 100)) + const cx = box.x + box.w / 2 + const cy = box.y + box.h / 2 + const hole = g.type === 'doughnut' ? Math.min(0.9, Math.max(0.1, g.holeSize / 100)) * r : 0 + let a = ((g.firstSliceAng - 90) * Math.PI) / 180 + const lt = textOf(m, s.labels?.text) + vals.forEach((v, k) => { + if (!v) return + const sweep = (v / total) * Math.PI * 2 + const mid = a + sweep / 2 + const off = ((s.points[k]?.explosion ?? 0) / 100) * r + const ox = cx + off * Math.cos(mid) + const oy = cy + off * Math.sin(mid) + const pt = s.points[k] + const fill = fillPaint(svg, pt?.color ?? s.fill ?? '#4472c4', pt?.look ?? (pt?.color ? undefined : s.look)) + const stroke = s.line?.color ?? '#ffffff' + const sw = s.line ? Math.max(0.75, s.line.width * PX) : 1 + const at = (rr: number, ang: number): Pt => ({ x: ox + rr * Math.cos(ang), y: oy + rr * Math.sin(ang) }) + const large = sweep > Math.PI ? 1 : 0 + if (sweep >= Math.PI * 2 - 1e-6) { + svg.circle(ox, oy, r, { fill, stroke, sw }) + if (hole) svg.circle(ox, oy, hole, { fill: m.bg ?? '#ffffff' }) + } else { + const p1 = at(r, a) + const p2 = at(r, a + sweep) + const n = (x: number) => Math.round(x * 100) / 100 + const d = hole + ? `M${n(p1.x)} ${n(p1.y)} A${n(r)} ${n(r)} 0 ${large} 1 ${n(p2.x)} ${n(p2.y)} L${n(at(hole, a + sweep).x)} ${n(at(hole, a + sweep).y)} A${n(hole)} ${n(hole)} 0 ${large} 0 ${n(at(hole, a).x)} ${n(at(hole, a).y)} Z` + : `M${n(ox)} ${n(oy)} L${n(p1.x)} ${n(p1.y)} A${n(r)} ${n(r)} 0 ${large} 1 ${n(p2.x)} ${n(p2.y)} Z` + svg.path(d, { fill, stroke, sw }) + } + if (s.labels) { + const parts: string[] = [] + if (s.labels.series) parts.push(s.name) + if (s.labels.category) parts.push(s.cats[k] ?? '') + if (s.labels.value) parts.push(fmt(s.values[k]!, s.labels.format ?? s.format, m.date1904)) + if (s.labels.percent) parts.push(fmt(v / total, s.labels.format && s.labels.format.includes('%') ? s.labels.format : '0%', m.date1904)) + const lr = outside ? r * 1.15 : hole ? (r + hole) / 2 : r * 0.65 + const p = at(lr, mid) + const anchor = outside ? (Math.cos(mid) >= 0 ? 'start' : 'end') : 'middle' + const color = s.labels.text?.color ?? (outside ? m.textColor : '#ffffff') + svg.text(p.x, p.y, parts.join(s.labels.sep ?? (outside ? '; ' : '\n')).split('\n'), { size: lt.size, color, bold: lt.bold, anchor }) + } + a += sweep + }) +} + +// ---------------------------------------------------------------- axes and plot + +interface AxisPlan { + axis: ChartAxis + scale: Scale + /** labels' formatted text */ + labels: string[] +} + +const isStacked = (g: ChartGroup) => g.grouping === 'stacked' || g.grouping === 'percentStacked' +const xValued = (g: ChartGroup) => g.type === 'scatter' || g.type === 'bubble' + +/** the extent of a group's values on its value axis (sums when stacked, 0…1 when 100%) */ +function extent(g: ChartGroup): [number, number] { + if (g.grouping === 'percentStacked') { + const neg = g.series.some((s) => s.values.some((v) => v !== null && v < 0)) + return [neg ? -1 : 0, 1] + } + if (isStacked(g) && g.type !== 'line') { + let lo = 0 + let hi = 0 + const n = Math.max(0, ...g.series.map((s) => s.values.length)) + for (let k = 0; k < n; k++) { + let p = 0 + let q = 0 + for (const s of g.series) { + const v = s.values[k] ?? 0 + if (v >= 0) p += v + else q += v + } + hi = Math.max(hi, p) + lo = Math.min(lo, q) + } + return [lo, hi] + } + const all = g.series.flatMap((s) => (isStacked(g) ? cumulative(g, s) : s.values)).filter((v): v is number => v !== null) + if (!all.length) return [0, 1] + const lo = Math.min(...all) + const hi = Math.max(...all) + return g.type === 'bubble' ? bubbleRoom(lo, hi) : [lo, hi] +} + +/** bubbles reach past their centers: the axes leave them room, as Excel's do, without crossing zero for it */ +function bubbleRoom(lo: number, hi: number): [number, number] { + const pad = (hi - lo || Math.abs(hi) || 1) * 0.15 + return [lo >= 0 ? Math.max(0, lo - pad) : lo - pad, hi <= 0 ? Math.min(0, hi + pad) : hi + pad] +} + +/** a stacked line's points: each series on top of those before it */ +function cumulative(g: ChartGroup, s: ChartSeries): (number | null)[] { + const i = g.series.indexOf(s) + return s.values.map((v, k) => { + if (v === null) return null + let sum = 0 + for (let j = 0; j <= i; j++) sum += g.series[j].values[k] ?? 0 + if (g.grouping === 'percentStacked') { + const tot = g.series.reduce((a, x) => a + Math.abs(x.values[k] ?? 0), 0) + return tot ? sum / tot : 0 + } + return sum + }) +} + +export function renderChart(m: ChartModel, W: number, H: number): string { + const svg = new Svg() + // the chart area: a plain color is the picture's canvas; a gradient or pattern a rect of its own + if (m.bgLook && m.bg !== null) svg.rect(0, 0, W, H, { fill: fillPaint(svg, m.bg, m.bgLook) }) + if (m.border !== null) svg.rect(0.5, 0.5, W - 1, H - 1, { stroke: m.border ?? m.gridColor, sw: 1 }) + const size = m.fontSize * PX + let top = PAD + if (m.title) top += textBlock(svg, m.title, W / 2, PAD, W - PAD * 2) + 6 + let area: Box = { x: PAD, y: top, w: W - PAD * 2, h: H - top - PAD } + + // legend: its own strip beside the plot, unless it lies over it + const entries = m.legend ? legendEntries(m) : [] + if (m.legend && entries.length) { + const lt = textOf(m, m.legend.text) + const pos = m.legend.pos + const vertical = pos === 'r' || pos === 'l' || pos === 'tr' + const ls = legendSize(entries, vertical, lt.size, vertical ? area.w * 0.4 : area.w, lt.bold) + let lb: Box + if (pos === 't') lb = { x: area.x + (area.w - ls.w) / 2, y: area.y, w: ls.w, h: ls.h } + else if (pos === 'b') lb = { x: area.x + (area.w - ls.w) / 2, y: area.y + area.h - ls.h, w: ls.w, h: ls.h } + else if (pos === 'l') lb = { x: area.x, y: area.y + (area.h - ls.h) / 2, w: ls.w, h: ls.h } + else if (pos === 'tr') lb = { x: area.x + area.w - ls.w, y: area.y, w: ls.w, h: ls.h } + else lb = { x: area.x + area.w - ls.w, y: area.y + (area.h - ls.h) / 2, w: ls.w, h: ls.h } + drawLegend(svg, entries, lb, vertical, lt.size, lt.color, lt.bold) + if (!m.legend.overlay) { + if (pos === 't') area = { ...area, y: area.y + ls.h + 6, h: area.h - ls.h - 6 } + else if (pos === 'b') area = { ...area, h: area.h - ls.h - 6 } + else if (pos === 'l') area = { ...area, x: area.x + ls.w + 8, w: area.w - ls.w - 8 } + else area = { ...area, w: area.w - ls.w - 8 } + } + } + // the plot's place where the file fixes it + let inner: Box | null = null + if (m.plot) { + const b = { x: m.plot.x * W, y: m.plot.y * H, w: m.plot.w * W, h: m.plot.h * H } + if (m.plot.target === 'inner') inner = b + else area = b + } + const bg = m.bgLook || m.bg === null ? '' : (m.bg ?? '#ffffff') + + const pie = m.groups.find((g) => g.type === 'pie' || g.type === 'doughnut') + const radar = m.groups.find((g) => g.type === 'radar') + const surface = m.groups.find((g) => g.type === 'surface') + if (pie) drawPie(svg, m, pie, inner ?? area) + else if (radar) drawRadar(svg, m, radar, inner ?? area, size) + else if (surface) drawSurface(svg, m, surface, area, inner, size) + else drawCartesian(svg, m, area, inner, size) + return svg.toString(W, H, bg) +} + +/** the plot area's own fill and border, under everything drawn in it */ +function plotArea(svg: Svg, m: ChartModel, b: Box) { + if (m.plotFill === null || m.plotFill === undefined) { + if (m.plotBorder) svg.rect(b.x, b.y, b.w, b.h, { fill: 'none', ...linePaint(m.plotBorder) }) + return + } + svg.rect(b.x, b.y, b.w, b.h, { fill: fillPaint(svg, m.plotFill, m.plotLook), ...(m.plotBorder ? linePaint(m.plotBorder) : {}) }) +} + +/** a value axis' gridline paint, or null where it has none */ +function gridPaint(m: ChartModel, a: ChartAxis): Paint | null { + if (!a.gridlines || a.grid === null) return null + return a.grid ? linePaint(a.grid) : { stroke: m.gridColor } +} + +function drawCartesian(svg: Svg, m: ChartModel, area: Box, fixed: Box | null, size: number) { + const axis = (id: string) => m.axes.find((a) => a.id === id) + const groups = m.groups.filter((g) => g.series.length) + const horizontal = groups.some((g) => g.type === 'bar' && g.dir === 'bar') + // value axes and the groups on them + const valIds = [...new Set(groups.map((g) => g.axIds[1]).filter(Boolean))] + const plans = new Map() + const cats = groups.find((g) => !xValued(g))?.series[0]?.cats ?? [] + const n = Math.max(1, ...groups.filter((g) => !xValued(g)).flatMap((g) => g.series.map((s) => s.values.length))) + const valLen = horizontal ? area.w : area.h + for (const id of valIds) { + const a = axis(id) + const on = groups.filter((g) => g.axIds[1] === id) + const ext = on.map(extent) + const lo = Math.min(...ext.map((e) => e[0])) + const hi = Math.max(...ext.map((e) => e[1])) + const pct = on.some((g) => g.grouping === 'percentStacked') + // an axis of 100% stacks alone ends at 100% (and -100%), with no headroom above + const whole = on.every((g) => g.grouping === 'percentStacked') + const scale = a?.logBase + ? logScale(lo, hi, a.logBase, a) + : niceScale(lo, hi, { min: a?.min ?? (whole ? lo : undefined), max: a?.max ?? (whole ? hi : undefined), major: a?.major, ticks: Math.round(valLen / 40) }) + const f = a?.format ?? (pct ? '0%' : on[0]?.series.find((s) => s.format)?.format) + plans.set(id, { axis: a ?? fallbackAxis(id, horizontal ? 'b' : 'l'), scale: { ...scale, format: f }, labels: scale.ticks.map((t) => fmt(t, f, m.date1904)) }) + } + // scatter and bubbles: their x axis is a value axis too + const scatter = groups.find(xValued) + let xPlan: AxisPlan | null = null + if (scatter) { + const xs = scatter.series.flatMap((s) => (s.xs ?? []).filter((v): v is number => v !== null)) + const a = axis(scatter.axIds[0]) + const known = xs.length ? xs : scatter.series[0].values.map((_, k) => k + 1) + const [lo, hi] = scatter.type === 'bubble' ? bubbleRoom(Math.min(...known), Math.max(...known)) : [Math.min(...known), Math.max(...known)] + const sc = niceScale(lo, hi, { min: a?.min, max: a?.max, major: a?.major, ticks: Math.round(area.w / 70) }) + const f = a?.format ?? scatter.series[0]?.format + xPlan = { axis: a ?? fallbackAxis(scatter.axIds[0], 'b'), scale: { ...sc, format: f }, labels: sc.ticks.map((t) => fmt(t, f, m.date1904)) } + } + const catAxis = axis(groups.find((g) => !xValued(g))?.axIds[0] ?? '') + const showCats = !!cats.length && !scatter && !(catAxis?.deleted ?? false) && (catAxis?.labels ?? true) + const primary = valIds.find((id) => !['r', 't'].includes(plans.get(id)!.axis.pos)) ?? valIds[0] + const secondary = valIds.find((id) => id !== primary) + + // each axis' labels in their own size and color + const look = (a: ChartAxis | undefined) => textOf(m, a?.text) + const catT = look(catAxis) + const catLh = lineHeight(catT.size) + + // room for labels and axis titles + const shows = (p: AxisPlan | undefined) => !!p && !p.axis.deleted && p.axis.labels + const valW = (p: AxisPlan | undefined) => (shows(p) ? Math.max(...p!.labels.map((l) => textWidth(l, look(p!.axis).size, look(p!.axis).bold))) + 6 : 0) + const catW = showCats ? Math.min(area.w * 0.35, Math.max(...cats.map((c) => textWidth(c, catT.size, catT.bold)))) + 6 : 0 + const titleH = (a: ChartAxis | undefined) => (a?.title && !a.deleted ? lineHeight(a.title.size * PX) + 4 : 0) + // category labels too wide to stand side by side are tilted by 45°, as Excel does + const catTextW = showCats ? Math.max(...cats.map((c) => textWidth(c, catT.size, catT.bold))) : 0 + const tilt = showCats && !horizontal && catTextW + 2 > (area.w * 0.85) / n + const catBottom = tilt ? Math.min(area.h * 0.4, catTextW * Math.SQRT1_2 + catLh) : catLh + 4 + let inner: Box + if (fixed) inner = fixed + else { + const pp = plans.get(primary) + const sp = secondary ? plans.get(secondary) : undefined + let left = 0 + let right = 0 + let bottom = 0 + const topPad = lineHeight(size) / 2 + if (horizontal) { + left = catW + titleH(catAxis) + bottom = (shows(pp) ? lineHeight(look(pp!.axis).size) + 4 : 0) + titleH(pp?.axis) + if (sp && shows(sp)) right = 0 + } else { + left = valW(pp) + titleH(pp?.axis) + right = sp ? valW(sp) + titleH(sp.axis) : textWidth(cats.at(-1) ?? '', catT.size) / 3 + bottom = (showCats ? catBottom : xPlan ? lineHeight(look(xPlan.axis).size) + 4 : 0) + titleH(xPlan?.axis ?? catAxis) + } + inner = { x: area.x + left, y: area.y + topPad, w: Math.max(10, area.w - left - right - 4), h: Math.max(10, area.h - topPad - bottom) } + } + plotArea(svg, m, inner) + + // positions: value v of plan p, category k (its band's middle) + const valPos = (p: AxisPlan, v: number) => { + let f = frac(p.scale, v) + if (p.axis.reverse) f = 1 - f + return horizontal ? inner.x + f * inner.w : inner.y + inner.h - f * inner.h + } + const catReverse = catAxis?.reverse ?? false + const band = (horizontal ? inner.h : inner.w) / n + const catMid = (k: number) => { + // horizontal bars run from the bottom up, as Excel draws them + const i = horizontal !== catReverse ? n - 1 - k : k + return (horizontal ? inner.y : inner.x) + (i + 0.5) * band + } + const xPos = (v: number) => inner.x + (xPlan!.axis.reverse ? 1 - frac(xPlan!.scale, v) : frac(xPlan!.scale, v)) * inner.w + + // gridlines and value labels + for (const id of valIds) { + const p = plans.get(id)! + const right = id !== primary + const t = look(p.axis) + const grid = gridPaint(m, p.axis) + for (let k = 0; k < p.scale.ticks.length; k++) { + const pos = valPos(p, p.scale.ticks[k]) + if (grid) horizontal ? svg.line(pos, inner.y, pos, inner.y + inner.h, grid) : svg.line(inner.x, pos, inner.x + inner.w, pos, grid) + if (!shows(p)) continue + if (horizontal) svg.text(pos, inner.y + inner.h + 4 + lineHeight(t.size) / 2, p.labels[k], { size: t.size, color: t.color, bold: t.bold, anchor: 'middle' }) + else svg.text(right ? inner.x + inner.w + 4 : inner.x - 4, pos, p.labels[k], { size: t.size, color: t.color, bold: t.bold, anchor: right ? 'start' : 'end' }) + } + // a value axis' line only where the file draws one + if (p.axis.line && !p.axis.deleted) { + const lp = linePaint(p.axis.line) + if (horizontal) svg.line(inner.x, inner.y + inner.h, inner.x + inner.w, inner.y + inner.h, lp) + else if (right) svg.line(inner.x + inner.w, inner.y, inner.x + inner.w, inner.y + inner.h, lp) + else svg.line(inner.x, inner.y, inner.x, inner.y + inner.h, lp) + } + } + if (xPlan) { + const t = look(xPlan.axis) + const grid = gridPaint(m, xPlan.axis) + for (let k = 0; k < xPlan.scale.ticks.length; k++) { + const x = xPos(xPlan.scale.ticks[k]) + if (grid) svg.line(x, inner.y, x, inner.y + inner.h, grid) + if (shows(xPlan)) svg.text(x, inner.y + inner.h + 4 + lineHeight(t.size) / 2, xPlan.labels[k], { size: t.size, color: t.color, bold: t.bold, anchor: 'middle' }) + } + } + // category labels: flat, tilted where they do not fit flat, and every k-th where they overlap even so + if (showCats) { + const space = horizontal || tilt ? catLh * (tilt ? Math.SQRT2 : 1) : catTextW + 2 + const every = Math.max(1, Math.ceil(space / Math.max(1, band))) + const y0 = inner.y + inner.h + 4 + const o = { size: catT.size, color: catT.color, bold: catT.bold } + cats.forEach((c, k) => { + if (k % every) return + if (horizontal) svg.text(inner.x - 4, catMid(k), c, { ...o, anchor: 'end' }) + else if (tilt) { + const x = catMid(k) + svg.raw(``).text(x, y0, c, { ...o, anchor: 'end' }).raw('') + } else svg.text(catMid(k), y0 + catLh / 2, c, { ...o, anchor: 'middle' }) + }) + } + // the category axis line, where the value axis is 0 (or its minimum) + const pp = plans.get(primary) + const catLine = catAxis?.line === null ? null : catAxis?.line ? linePaint(catAxis.line) : { stroke: m.axisColor } + if (pp && catLine && !(catAxis?.deleted ?? false)) { + const at = pp.scale.min <= 0 && pp.scale.max >= 0 && !pp.scale.log ? 0 : pp.scale.min + const pos = valPos(pp, at) + horizontal ? svg.line(pos, inner.y, pos, inner.y + inner.h, catLine) : svg.line(inner.x, pos, inner.x + inner.w, pos, catLine) + } + // axis titles + const axisTitle = (a: ChartAxis | undefined, where: 'left' | 'right' | 'bottom') => { + if (!a?.title || a.deleted) return + const s = a.title.size * PX + const o = { size: s, bold: a.title.bold, color: a.title.color ?? m.textColor, anchor: 'middle' as const } + if (where === 'bottom') svg.text(inner.x + inner.w / 2, area.y + area.h - lineHeight(s) / 2, a.title.text, o) + else { + const x = where === 'left' ? area.x + lineHeight(s) / 2 : area.x + area.w - lineHeight(s) / 2 + const y = inner.y + inner.h / 2 + svg.raw(``).text(x, y, a.title.text, o).raw('') + } + } + if (horizontal) { + axisTitle(catAxis, 'left') + axisTitle(pp?.axis, 'bottom') + } else { + axisTitle(pp?.axis, 'left') + if (secondary) axisTitle(plans.get(secondary)?.axis, 'right') + axisTitle(xPlan?.axis ?? catAxis, 'bottom') + } + + // the series: areas first, then bars, then lines and points on top + const order = (g: ChartGroup) => (g.type === 'area' ? 0 : g.type === 'bar' ? 1 : g.type === 'bubble' ? 2 : 3) + // values outside a fixed axis range stop at the plot area; the margin keeps markers on its edge whole + const c = CLIP_MARGIN + svg.raw(``) + for (const g of [...groups].sort((a, b) => order(a) - order(b))) { + const p = plans.get(g.axIds[1]) ?? plans.get(primary) + if (!p) continue + if (g.type === 'bar') drawBars(svg, m, g, p, { valPos, catMid, band, horizontal, size }) + else if (g.type === 'area') drawArea(svg, g, p, { valPos, catMid }) + else if (g.type === 'line') drawLines(svg, m, g, p, { valPos, catMid, size }) + else if (g.type === 'stock') drawStock(svg, m, g, p, { valPos, catMid, band, size }) + else if (g.type === 'scatter' && xPlan) drawScatter(svg, m, g, p, { valPos, xPos, size }) + else if (g.type === 'bubble' && xPlan) drawBubbles(svg, m, g, p, { valPos, xPos, size, plot: inner }) + } + svg.raw('') +} + +const CLIP_MARGIN = 6 + +function fallbackAxis(id: string, pos: string): ChartAxis { + return { id, kind: 'val', pos, deleted: false, reverse: false, gridlines: true, labels: true, crossAx: '', crosses: 'autoZero' } +} + +interface Geo { + valPos: (p: AxisPlan, v: number) => number + catMid: (k: number) => number +} + +function label(svg: Svg, m: ChartModel, s: ChartSeries, k: number, v: number, x: number, y: number, anchor: 'start' | 'middle' | 'end' = 'middle', color?: string) { + const l = s.labels! + const parts: string[] = [] + if (l.series) parts.push(s.name) + if (l.category) parts.push(s.cats[k] ?? '') + if (l.value) parts.push(fmt(v, l.format ?? s.format, m.date1904)) + const t = textOf(m, l.text) + if (parts.length) svg.text(x, y, parts.join(l.sep ?? ', '), { size: t.size, color: l.text?.color ?? color ?? m.textColor, bold: t.bold, anchor }) +} + +function drawBars(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: Geo & { band: number; horizontal: boolean; size: number }) { + const { valPos, catMid, band, horizontal } = geo + const stacked = isStacked(g) + const n = stacked ? 1 : g.series.length + const ov = Math.max(-1, Math.min(1, g.overlap / 100)) + const w = band / (n - (n - 1) * ov + g.gapWidth / 100) + const total = n * w - (n - 1) * w * ov + const base = p.scale.min <= 0 && p.scale.max >= 0 && !p.scale.log ? 0 : p.scale.min + const len = Math.max(...g.series.map((s) => s.values.length)) + // one gradient or pattern per series (and per point with its own), shared by its bars + const paints = g.series.map((s) => fillPaint(svg, s.fill, s.look)) + for (let k = 0; k < len; k++) { + let posSum = 0 + let negSum = 0 + const tot = g.grouping === 'percentStacked' ? g.series.reduce((a, s) => a + Math.abs(s.values[k] ?? 0), 0) || 1 : 1 + g.series.forEach((s, i) => { + const raw = s.values[k] + if (raw === null || raw === undefined) return + const v = raw / tot + let from = base + let to = v + if (stacked) { + from = v >= 0 ? posSum : negSum + to = from + v + if (v >= 0) posSum = to + else negSum = to + } + const off = stacked ? 0 : i * w * (1 - ov) + const c0 = catMid(k) - total / 2 + off + const a = valPos(p, from) + const b = valPos(p, to) + const pt = s.points[k] + const fill = raw < 0 && s.invertIfNegative ? '#ffffff' : pt?.look || pt?.color ? fillPaint(svg, pt.color ?? s.fill, pt.look) : paints[i] + const paint: Paint = { fill, stroke: s.line?.color ?? (raw < 0 && s.invertIfNegative ? (s.fill ?? undefined) : undefined), sw: s.line ? Math.max(0.75, s.line.width * PX) : 1, dash: s.line ? dashOf(s.line.dash, s.line.width) : undefined } + if (horizontal) svg.rect(Math.min(a, b), c0, Math.abs(b - a), w, paint) + else svg.rect(c0, Math.min(a, b), w, Math.abs(b - a), paint) + if (s.labels) { + const mid = (a + b) / 2 + const inside = stacked || s.labels.pos === 'ctr' || s.labels.pos === 'inEnd' || s.labels.pos === 'inBase' + const lh = lineHeight(textOf(m, s.labels.text).size) + if (horizontal) label(svg, m, s, k, raw, inside ? mid : Math.max(a, b) + 4, c0 + w / 2, inside ? 'middle' : 'start', inside ? '#ffffff' : undefined) + else label(svg, m, s, k, raw, c0 + w / 2, inside ? mid : Math.min(a, b) - lh / 2, 'middle', inside ? '#ffffff' : undefined) + } + }) + } +} + +function drawArea(svg: Svg, g: ChartGroup, p: AxisPlan, geo: Geo) { + const { valPos, catMid } = geo + const base = p.scale.min <= 0 && p.scale.max >= 0 ? 0 : p.scale.min + let below: number[] | null = null + for (const s of g.series) { + const vals = isStacked(g) ? cumulative(g, s).map((v) => v ?? 0) : s.values.map((v) => v ?? 0) + const top = vals.map((v, k) => [catMid(k), valPos(p, v)] as [number, number]) + const floor = (below && isStacked(g) ? below.map((v, k) => [catMid(k), valPos(p, v)] as [number, number]) : vals.map((_, k) => [catMid(k), valPos(p, base)] as [number, number])).reverse() + svg.poly([...top, ...floor], { fill: fillPaint(svg, s.fill, s.look), ...(s.line ? linePaint(s.line) : {}) }) + below = vals + } +} + +function drawLines(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: Geo & { size: number }) { + const { valPos, catMid } = geo + for (const s of g.series) { + const vals = isStacked(g) ? cumulative(g, s) : s.values + // empty cells leave a gap in the line + const runs: Pt[][] = [[]] + vals.forEach((v, k) => { + if (v === null) runs.push([]) + else runs[runs.length - 1].push({ x: catMid(k), y: valPos(p, v) }) + }) + if (s.line) for (const run of runs) if (run.length > 1) svg.path(linePath(run, s.smooth), linePaint(s.line)) + const lh = lineHeight(textOf(m, s.labels?.text).size) + vals.forEach((v, k) => { + if (v === null) return + const x = catMid(k) + const y = valPos(p, v) + if (s.marker) marker(svg, x, y, s.marker) + if (s.labels) label(svg, m, s, k, s.values[k] ?? v, x, y - lh * 0.8) + }) + } +} + +/** a stock chart: each category's high-low line, its up-down bar from the first series to the last, then the series */ +function drawStock(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: Geo & { band: number; size: number }) { + const { valPos, catMid, band } = geo + const n = Math.max(0, ...g.series.map((s) => s.values.length)) + for (let k = 0; k < n; k++) { + const vs = g.series.map((s) => s.values[k]).filter((v): v is number => v !== null && v !== undefined) + if (!vs.length) continue + const x = catMid(k) + if (g.hiLow) svg.line(x, valPos(p, Math.max(...vs)), x, valPos(p, Math.min(...vs)), linePaint(g.hiLow)) + const open = g.series[0]?.values[k] + const close = g.series.at(-1)?.values[k] + if (g.upDown && g.series.length > 1 && open !== null && open !== undefined && close !== null && close !== undefined) { + const bar = close >= open ? g.upDown.up : g.upDown.down + const w = band / (1 + g.upDown.gapWidth / 100) + const a = valPos(p, open) + const b = valPos(p, close) + svg.rect(x - w / 2, Math.min(a, b), w, Math.max(1, Math.abs(b - a)), { fill: fillPaint(svg, bar.fill, bar.look), ...(bar.line ? linePaint(bar.line) : {}) }) + } + } + drawLines(svg, m, g, p, geo) +} + +function drawScatter(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: { valPos: Geo['valPos']; xPos: (v: number) => number; size: number }) { + const { valPos, xPos } = geo + const lines = g.scatterStyle !== 'marker' && g.scatterStyle !== 'none' + for (const s of g.series) { + const pts: (Pt & { k: number })[] = [] + s.values.forEach((v, k) => { + // without X values the points are numbered; with them, a point missing its X is not drawn + const xv = s.xs ? s.xs[k] : k + 1 + if (v === null || xv === null || xv === undefined) return + pts.push({ x: xPos(xv), y: valPos(p, v), k }) + }) + if (lines && s.line && pts.length > 1) svg.path(linePath(pts, s.smooth || g.scatterStyle === 'smooth' || g.scatterStyle === 'smoothMarker'), linePaint(s.line)) + const lh = lineHeight(textOf(m, s.labels?.text).size) + pts.forEach((pt) => { + if (s.marker) marker(svg, pt.x, pt.y, s.marker) + if (s.labels) label(svg, m, s, pt.k, s.values[pt.k]!, pt.x, pt.y - lh * 0.8) + }) + } +} + +/** + * Bubbles: the largest of the chart a quarter of the plot's smaller side across at a scale of 100 %, the others by area + * (or width) in proportion; larger ones drawn first so smaller ones stay in view. A negative size is drawn as an + * outline where the chart shows negative bubbles, else not at all. + */ +function drawBubbles(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: { valPos: Geo['valPos']; xPos: (v: number) => number; size: number; plot: Box }) { + const { valPos, xPos, plot } = geo + const biggest = Math.max(0, ...g.series.flatMap((s) => (s.sizes ?? []).map((z) => Math.abs(z ?? 0)))) + if (!biggest) return + const maxR = ((Math.min(plot.w, plot.h) / 4) * ((g.bubbleScale ?? 100) / 100)) / 2 + const radius = (z: number) => (g.sizeRepresents === 'w' ? (maxR * Math.abs(z)) / biggest : maxR * Math.sqrt(Math.abs(z) / biggest)) + for (const s of g.series) { + const paint = fillPaint(svg, s.fill, s.look) + const pts: { x: number; y: number; r: number; k: number; neg: boolean }[] = [] + s.values.forEach((v, k) => { + const xv = s.xs ? s.xs[k] : k + 1 + const z = s.sizes?.[k] + if (v === null || xv === null || xv === undefined || z === null || z === undefined || z === 0) return + if (z < 0 && !g.showNegBubbles) return + pts.push({ x: xPos(xv), y: valPos(p, v), r: Math.max(1, radius(z)), k, neg: z < 0 }) + }) + pts.sort((a, b) => b.r - a.r) + const lh = lineHeight(textOf(m, s.labels?.text).size) + for (const pt of pts) { + const own = s.points[pt.k] + const fill = pt.neg ? 'none' : own?.color || own?.look ? fillPaint(svg, own.color ?? s.fill, own.look) : paint + svg.circle(pt.x, pt.y, pt.r, { fill, stroke: pt.neg ? (s.fill ?? m.textColor) : s.line?.color, sw: s.line ? Math.max(0.75, s.line.width * PX) : 1 }) + if (s.labels) label(svg, m, s, pt.k, s.values[pt.k]!, pt.x, pt.y - (s.labels.pos === 'ctr' ? 0 : pt.r + lh / 2)) + } + } +} + +// ---------------------------------------------------------------- radar + +/** a radar: a spoke per category from 12 o'clock clockwise, the value axis along the spokes and rings at its ticks */ +function drawRadar(svg: Svg, m: ChartModel, g: ChartGroup, box: Box, size: number) { + const a = m.axes.find((x) => x.id === g.axIds[1]) + const catAxis = m.axes.find((x) => x.id === g.axIds[0]) + const cats = g.series[0]?.cats ?? [] + const n = Math.max(3, ...g.series.map((s) => s.values.length)) + const catT = textOf(m, catAxis?.text) + const valT = textOf(m, a?.text) + const showCats = !(catAxis?.deleted ?? false) && (catAxis?.labels ?? true) + const labelW = showCats ? Math.max(0, ...cats.map((c) => textWidth(c, catT.size, catT.bold))) : 0 + const R = Math.max(10, Math.min(box.w / 2 - labelW - 8, box.h / 2 - lineHeight(catT.size) - 6)) + const cx = box.x + box.w / 2 + const cy = box.y + box.h / 2 + const all = g.series.flatMap((s) => s.values).filter((v): v is number => v !== null) + const scale = niceScale(all.length ? Math.min(...all) : 0, all.length ? Math.max(...all) : 1, { min: a?.min, max: a?.max, major: a?.major, ticks: Math.max(2, Math.round(R / 30)) }) + const f = a?.format ?? g.series.find((s) => s.format)?.format + const ang = (k: number) => -Math.PI / 2 + (2 * Math.PI * k) / n + const at = (k: number, v: number): [number, number] => { + const r = Math.max(0, Math.min(1, frac(scale, v))) * R + return [cx + r * Math.cos(ang(k)), cy + r * Math.sin(ang(k))] + } + if (m.plotFill) svg.poly(Array.from({ length: n }, (_, k) => at(k, scale.max)), { fill: fillPaint(svg, m.plotFill, m.plotLook) }) + const grid = a ? gridPaint(m, a) : { stroke: m.gridColor } + if (grid) for (const t of scale.ticks) if (t !== scale.min) svg.poly(Array.from({ length: n }, (_, k) => at(k, t)), { ...grid, fill: 'none' }) + const spoke = catAxis?.line === null ? null : catAxis?.line ? linePaint(catAxis.line) : { stroke: m.axisColor } + if (spoke) for (let k = 0; k < n; k++) svg.line(cx, cy, ...at(k, scale.max), spoke) + if (showCats) + cats.forEach((c, k) => { + const x = cx + (R + 6) * Math.cos(ang(k)) + const y = cy + (R + 6) * Math.sin(ang(k)) + const cos = Math.cos(ang(k)) + svg.text(x, y + Math.sin(ang(k)) * lineHeight(catT.size) * 0.4, c, { size: catT.size, color: catT.color, bold: catT.bold, anchor: Math.abs(cos) < 0.2 ? 'middle' : cos > 0 ? 'start' : 'end' }) + }) + if (!(a?.deleted ?? false) && (a?.labels ?? true)) + for (const t of scale.ticks) svg.text(cx - 4, at(0, t)[1], fmt(t, f, m.date1904), { size: valT.size, color: valT.color, bold: valT.bold, anchor: 'end' }) + const filled = g.radarStyle === 'filled' + for (const s of g.series) { + const pts = s.values.map((v, k) => (v === null ? null : { ...ptOf(at(k, v)), k })).filter((p): p is Pt & { k: number } => p !== null) + if (pts.length < 2) continue + const poly = pts.map((p) => [p.x, p.y] as [number, number]) + if (filled) svg.poly(poly, { fill: fillPaint(svg, s.fill, s.look), ...(s.line ? linePaint(s.line) : {}) }) + else if (s.line) svg.poly(poly, { fill: 'none', ...linePaint(s.line) }) + const lh = lineHeight(textOf(m, s.labels?.text).size) + for (const p of pts) { + if (s.marker && !filled) marker(svg, p.x, p.y, s.marker) + if (s.labels) label(svg, m, s, p.k, s.values[p.k]!, p.x, p.y - lh * 0.8) + } + } + void size +} + +const ptOf = ([x, y]: [number, number]): Pt => ({ x, y }) + +// ---------------------------------------------------------------- surface + +interface Band { + lo: number + hi: number + label: string + fill: string | null + look?: FillLook +} + +/** a surface's value bands: one per step of its value axis, in the style's colors unless the file gives a band its own */ +function surfaceBands(g: ChartGroup, m: ChartModel): Band[] { + const a = m.axes.find((x) => x.kind === 'val' && g.axIds.includes(x.id)) + const all = g.series.flatMap((s) => s.values).filter((v): v is number => v !== null) + const s = niceScale(all.length ? Math.min(...all) : 0, all.length ? Math.max(...all) : 1, { min: a?.min, max: a?.max, major: a?.major, ticks: 6 }) + const f = a?.format ?? g.series.find((x) => x.format)?.format + const out: Band[] = [] + for (let k = 0; k + 1 < s.ticks.length && out.length < SURFACE_BANDS; k++) { + const own = g.bands?.[k] + out.push({ lo: s.ticks[k], hi: s.ticks[k + 1], label: `${fmt(s.ticks[k], f, m.date1904)}-${fmt(s.ticks[k + 1], f, m.date1904)}`, fill: own?.fill ?? g.bandColors?.[k] ?? '#4472c4', ...(own?.look ? { look: own.look } : {}) }) + } + return out +} + +/** + * A surface seen from above, as Excel's contour chart: categories across, series up, each point of the grid between + * the values colored by the band its value (interpolated between the four around it) falls in. A wireframe draws the + * grid's lines in their bands' colors instead. A 3D surface is drawn the same way, flat. + */ +function drawSurface(svg: Svg, m: ChartModel, g: ChartGroup, area: Box, fixed: Box | null, size: number) { + const bands = surfaceBands(g, m) + if (!bands.length) return + const rows = g.series.map((s) => s.values.map((v) => v ?? 0)) + const cols = Math.max(1, ...rows.map((r) => r.length)) + // a single row or column is stretched into a strip + const grid = rows.length > 1 ? rows : [rows[0] ?? [], rows[0] ?? []] + const z = grid.map((r) => (cols > 1 ? Array.from({ length: cols }, (_, c) => r[c] ?? 0) : [r[0] ?? 0, r[0] ?? 0])) + const ny = z.length + const nx = z[0].length + const catAxis = m.axes.find((x) => x.id === g.axIds[0]) + const serAxis = m.axes.find((x) => x.kind === 'ser' && g.axIds.includes(x.id)) + const catT = textOf(m, catAxis?.text) + const serT = textOf(m, serAxis?.text) + const showCats = !(catAxis?.deleted ?? false) && (catAxis?.labels ?? true) + const showSer = !(serAxis?.deleted ?? false) && (serAxis?.labels ?? true) + const names = g.series.map((s) => s.name) + const cats = g.series[0]?.cats ?? [] + const left = showSer ? Math.max(0, ...names.map((x) => textWidth(x, serT.size, serT.bold))) + 8 : 0 + const bottom = showCats ? lineHeight(catT.size) + 6 : 0 + const inner = fixed ?? { x: area.x + left, y: area.y + lineHeight(size) / 2, w: Math.max(10, area.w - left - 8), h: Math.max(10, area.h - bottom - lineHeight(size) / 2) } + plotArea(svg, m, inner) + const X = (i: number) => inner.x + (i / (nx - 1)) * inner.w + const Y = (j: number) => inner.y + inner.h - (j / (ny - 1)) * inner.h + const bandOf = (v: number) => { + for (let k = 0; k < bands.length; k++) if (v < bands[k].hi || k === bands.length - 1) return k + return bands.length - 1 + } + const paints = bands.map((b) => fillPaint(svg, b.fill, b.look)) + if (g.wireframe) { + for (let j = 0; j < ny; j++) + for (let i = 0; i < nx; i++) { + if (i + 1 < nx) svg.line(X(i), Y(j), X(i + 1), Y(j), { stroke: bands[bandOf((z[j][i] + z[j][i + 1]) / 2)].fill ?? m.textColor, sw: 1.5 }) + if (j + 1 < ny) svg.line(X(i), Y(j), X(i), Y(j + 1), { stroke: bands[bandOf((z[j][i] + z[j + 1][i]) / 2)].fill ?? m.textColor, sw: 1.5 }) + } + } else { + // each cell of the grid split into parts about 2 px across, so band edges look smooth; runs of one band in a row of + // parts are one rect, and crisp edges keep neighboring rects from showing a seam between them + const sub = Math.max(1, Math.min(60, Math.ceil(Math.min(inner.w / (nx - 1), inner.h / (ny - 1)) / 2))) + const W = (nx - 1) * sub + const H = (ny - 1) * sub + const cw = inner.w / W + const ch = inner.h / H + svg.raw('') + for (let y = 0; y < H; y++) { + const fy = (y + 0.5) / sub + const j = Math.min(ny - 2, Math.floor(fy)) + const ty = fy - j + let start = 0 + let cur = -1 + for (let x = 0; x <= W; x++) { + let b = -1 + if (x < W) { + const fx = (x + 0.5) / sub + const i = Math.min(nx - 2, Math.floor(fx)) + const tx = fx - i + const v = (z[j][i] * (1 - tx) + z[j][i + 1] * tx) * (1 - ty) + (z[j + 1][i] * (1 - tx) + z[j + 1][i + 1] * tx) * ty + b = bandOf(v) + } + if (b !== cur) { + if (cur >= 0 && paints[cur] !== 'none') svg.rect(inner.x + start * cw, inner.y + inner.h - (y + 1) * ch, (x - start) * cw + 0.5, ch + 0.5, { fill: paints[cur] }) + start = x + cur = b + } + } + } + svg.raw('') + } + const o = (t: { size: number; color: string; bold?: boolean }) => ({ size: t.size, color: t.color, bold: t.bold }) + if (showCats) { + const every = Math.max(1, Math.ceil((Math.max(0, ...cats.map((c) => textWidth(c, catT.size))) + 6) / (inner.w / Math.max(1, nx - 1)))) + cats.forEach((c, i) => i % every || i >= nx || svg.text(X(i), inner.y + inner.h + 4 + lineHeight(catT.size) / 2, c, { ...o(catT), anchor: 'middle' })) + } + if (showSer && rows.length > 1) names.forEach((s, j) => svg.text(inner.x - 4, Y(j), s, { ...o(serT), anchor: 'end' })) +} diff --git a/src/core/xlsx/chart.ts b/src/core/xlsx/chart.ts new file mode 100644 index 0000000..55f0241 --- /dev/null +++ b/src/core/xlsx/chart.ts @@ -0,0 +1,738 @@ +/** + * An Excel chart part (`xl/charts/chartN.xml`) read into what the SVG renderer (chart-svg.ts) draws: its plot groups + * (bar / column, line, area, pie, doughnut, scatter, bubble, radar, stock, surface; 3D ones as their flat form; several + * groups make a combo chart, each on its own axes), series with their values, axes, title, legend and the plot area's + * manual layout, and the looks the file gives them: fills (a color, a gradient, a pattern), lines, text colors and sizes, + * and the built-in chart style (`c:style`) for what it leaves to that. + * + * Series values are read from the workbook's cells (`RangeReader`), so a chart follows edited cells; where a reference + * cannot be read there (a defined name, another workbook, an unknown sheet) the values the file saved with the chart + * (`numCache` / `strCache`) stand in. Colors are resolved here against the workbook's theme: the view gets CSS colors. + * A chart of the newer chartex kinds (waterfall, treemap, ...) is in another part and has no model: it stays a placeholder. + */ +import { colorOf, cssColor, drawingColor, type ColorCtx } from '../color' +import type { Theme } from '../theme' +import { kid, kids, num, textOf, type XEl } from '../xml' +import { formatValue } from './numfmt' + +export type ChartType = 'bar' | 'line' | 'area' | 'pie' | 'doughnut' | 'scatter' | 'bubble' | 'radar' | 'stock' | 'surface' +export type Grouping = 'clustered' | 'stacked' | 'percentStacked' | 'standard' + +export interface ChartText { + text: string + /** pt */ + size: number + bold: boolean + color?: string +} + +/** a fill that is more than one color; the shape's plain `fill` holds a color standing in for it */ +export type FillLook = + | { + kind: 'gradient' + /** pos 0…1 along the gradient */ + stops: { pos: number; color: string }[] + /** degrees clockwise from left-to-right (linear) */ + angle: number + radial: boolean + } + | { kind: 'pattern'; preset: string; fg: string; bg: string } + +/** text's own size (pt), weight and color, where the file gives them */ +export interface TextLook { + size?: number + bold?: boolean + color?: string +} + +export interface LineLook { + color: string + /** pt */ + width: number + dash?: string +} + +/** a fill with its look: a color (null: none) and a gradient or pattern over it */ +export interface Fill { + fill: string | null + look?: FillLook +} + +export interface DataLabels { + value: boolean + percent: boolean + category: boolean + series: boolean + /** ctr, inEnd, inBase, outEnd, t, b, l, r, bestFit */ + pos?: string + format?: string + sep?: string + text?: TextLook +} + +export interface ChartPoint { + color?: string + look?: FillLook + /** pie slices: % of the radius */ + explosion?: number +} + +export interface ChartSeries { + name: string + /** category labels (bar, line, area, pie) */ + cats: string[] + /** x values (scatter, bubble) */ + xs?: (number | null)[] + values: (number | null)[] + /** bubble sizes */ + sizes?: (number | null)[] + /** the values' number format */ + format?: string + /** fill (bars, areas, slices, bubbles); null: none */ + fill: string | null + /** a gradient or pattern over the fill */ + look?: FillLook + /** line (lines, scatter; bar outlines); null: none */ + line: { color: string; width: number; dash?: string } | null + marker: { symbol: string; size: number; fill?: string; line?: string } | null + smooth: boolean + invertIfNegative: boolean + points: Record + labels?: DataLabels +} + +export interface ChartGroup { + type: ChartType + /** bars: 'col' upright, 'bar' horizontal */ + dir: 'col' | 'bar' + grouping: Grouping + /** % of a bar's width */ + gapWidth: number + /** % of a bar's width, -100…100 */ + overlap: number + varyColors: boolean + /** doughnut: % of the radius */ + holeSize: number + /** pie: degrees clockwise from 12 o'clock */ + firstSliceAng: number + scatterStyle?: string + /** radar: 'standard' (lines), 'marker' (lines with markers), 'filled' */ + radarStyle?: string + /** bubble: the largest bubble's size, % of Excel's default (a quarter of the plot's smaller side) */ + bubbleScale?: number + /** bubble: a size gives the bubble's area ('area') or its width ('w') */ + sizeRepresents?: 'area' | 'w' + showNegBubbles?: boolean + /** stock: lines from each category's highest to its lowest value; null: none */ + hiLow?: LineLook | null + /** stock: bars from the first series (open) to the last (close), rising and falling */ + upDown?: { gapWidth: number; up: Fill & { line?: LineLook | null }; down: Fill & { line?: LineLook | null } } + /** surface: drawn as a mesh of lines without fills */ + wireframe?: boolean + /** surface: the bands' own fills, by band */ + bands?: Record + /** surface: the built-in style's color for each band without a fill of its own */ + bandColors?: string[] + /** the ids of its category (x) and value (y) axes */ + axIds: string[] + series: ChartSeries[] +} + +export interface ChartAxis { + id: string + /** 'ser': a surface chart's series axis */ + kind: 'cat' | 'val' | 'date' | 'ser' + /** where it is drawn: l, r, t, b */ + pos: string + deleted: boolean + reverse: boolean + logBase?: number + min?: number + max?: number + major?: number + format?: string + gridlines: boolean + /** the major gridlines' own line; null: none drawn */ + grid?: LineLook | null + /** the axis line's own line; null: none drawn */ + line?: LineLook | null + /** tick labels shown */ + labels: boolean + /** the tick labels' own look */ + text?: TextLook + title?: ChartText + crossAx: string + crosses: 'autoZero' | 'min' | 'max' + crossesAt?: number +} + +/** fractions of the chart's size */ +export interface ChartLayout { + x: number + y: number + w: number + h: number + /** 'inner': the plot's own box; 'outer': with its axis labels */ + target: 'inner' | 'outer' +} + +export interface ChartModel { + title?: ChartText + legend?: { pos: string; overlay: boolean; deleted: number[]; text?: TextLook } + groups: ChartGroup[] + axes: ChartAxis[] + plot?: ChartLayout + /** pt: the chart's base text size */ + fontSize: number + textColor: string + /** the chart area's fill (null: none, undefined: white) */ + bg?: string | null + bgLook?: FillLook + border?: string | null + /** the plot area's own fill and border (null / undefined: none) */ + plotFill?: string | null + plotLook?: FillLook + plotBorder?: LineLook | null + /** gridlines, and axis lines and the chart's border, where the file gives them no line: the theme's grays, or the + * built-in style's (a dark one has lighter ones) */ + gridColor: string + axisColor: string + date1904: boolean +} + +/** a reference's cells, row by row: values (numbers or text), number formats, and which lie in hidden rows / columns */ +export interface RangeValues { + values: (number | string | null)[] + formats: (string | undefined)[] + hidden: boolean[] +} + +/** reads a series reference (`'Sheet 1'!$B$2:$B$9`) from the workbook; null where it cannot */ +export type RangeReader = (ref: string) => Promise + +const GROUPS: Record = { + barChart: 'bar', bar3DChart: 'bar', lineChart: 'line', line3DChart: 'line', areaChart: 'area', area3DChart: 'area', + pieChart: 'pie', pie3DChart: 'pie', ofPieChart: 'pie', doughnutChart: 'doughnut', scatterChart: 'scatter', + bubbleChart: 'bubble', radarChart: 'radar', stockChart: 'stock', surfaceChart: 'surface', surface3DChart: 'surface', +} + +const val = (el: XEl | undefined, def?: string) => el?.a.val ?? def +const on = (el: XEl | undefined, def = false) => (el ? el.a.val === undefined || el.a.val === '1' || el.a.val === 'true' : def) +const EMU_PER_PT = 12700 + +/** the text of a title / rich text body, its size and look from the first run or the body's defaults */ +function richText(el: XEl | undefined, ctx: ColorCtx, defSize: number, defBold: boolean): ChartText | undefined { + const rich = kid(kid(el, 'tx'), 'rich') + if (!rich) return undefined + const paras = kids(rich, 'p') + const text = paras.map((p) => kids(p, 'r').map((r) => textOf(kid(r, 't'))).join('') + kids(p, 'fld').map((f) => textOf(kid(f, 't'))).join('')).join('\n').trim() + if (!text) return undefined + const rPr = kid(kids(paras[0], 'r')[0], 'rPr') ?? kid(kid(paras[0], 'pPr'), 'defRPr') + return { text, ...runLook(rPr, ctx, defSize, defBold) } +} + +function runLook(rPr: XEl | undefined, ctx: ColorCtx, defSize: number, defBold: boolean) { + const sz = num(rPr?.a.sz) + return { + size: sz ? sz / 100 : defSize, + bold: rPr?.a.b !== undefined ? rPr.a.b === '1' || rPr.a.b === 'true' : defBold, + color: safe(colorOf(kid(rPr, 'solidFill'), ctx)), + } +} + +const safe = (c: string | undefined) => (c && cssColor(c) ? c : undefined) + +/** a text body's default run size and look (`c:txPr`) */ +function txLook(el: XEl | undefined, ctx: ColorCtx) { + const rPr = kid(kid(kids(kid(el, 'txPr'), 'p')[0], 'pPr'), 'defRPr') + return rPr ? runLook(rPr, ctx, 0, false) : undefined +} + +/** the text look an element's `c:txPr` gives (size 0 there: none given), or undefined */ +function textLookOf(el: XEl | undefined, ctx: ColorCtx): TextLook | undefined { + const rPr = kid(kid(kids(kid(el, 'txPr'), 'p')[0], 'pPr'), 'defRPr') + if (!rPr) return undefined + const sz = num(rPr.a.sz) + const look: TextLook = { + ...(sz ? { size: sz / 100 } : {}), + ...(rPr.a.b !== undefined ? { bold: rPr.a.b === '1' || rPr.a.b === 'true' } : {}), + ...(safe(colorOf(kid(rPr, 'solidFill'), ctx)) ? { color: safe(colorOf(kid(rPr, 'solidFill'), ctx)) } : {}), + } + return Object.keys(look).length ? look : undefined +} + +const mod = (n: string, v: number): XEl => ({ n, a: { val: String(Math.round(v)) }, c: [], t: '' }) +const scheme = (name: string, mods: XEl[], ctx: ColorCtx, def: string) => drawingColor({ n: 'schemeClr', a: { val: name }, c: mods, t: '' }, ctx) ?? def + +/** + * The colors series take where they have none of their own, from the built-in chart style (`c:style`, 1…48, 2 when + * absent): eight columns of styles (gray shades, the theme's accents in turn, shades of one accent each) in six rows of + * effects, of which the fifth has tinted plot areas and the sixth dark backgrounds. Effects (bevels, shadows, gradients) + * are not drawn: each style is drawn by its flat colors. + */ +interface ChartStyle { + /** the color of series `i` of `n` */ + color(i: number, n: number): string + bg?: string + plot?: string + text: string + /** gridlines, axis lines and the chart's border, where the file gives them no line */ + grid: string + axis: string +} + +function chartStyle(style: number | undefined, ctx: ColorCtx): ChartStyle { + const s = style && style >= 1 && style <= 48 ? style : 2 + const col = (s - 1) % 8 + const row = Math.floor((s - 1) / 8) + // a spread from dark to light: series i of n + const spread = (name: string, lo: number, hi: number) => (i: number, n: number) => { + const t = n > 1 ? lo + ((hi - lo) * i) / (n - 1) : (lo + hi) / 2 + return t < 0 ? scheme(name, [mod('lumMod', (1 + t) * 100000)], ctx, '#808080') : scheme(name, [mod('lumMod', (1 - t) * 100000), mod('lumOff', t * 100000)], ctx, '#808080') + } + const color = + col === 0 + ? (i: number, n: number) => spread('tx1', 0.25, 0.85)(i, n) + : col === 1 + ? (i: number) => accentRound(i, ctx) + : spread(`accent${col - 1}`, -0.5, 0.5) + // Excel's own grays are shades of the theme's text color: text at 65 %, axis lines at 25 %, gridlines at 15 % + const gray = (pct: number, def: string) => scheme('tx1', [mod('lumMod', pct * 1000), mod('lumOff', (100 - pct) * 1000)], ctx, def) + const out: ChartStyle = { color, text: gray(65, '#595959'), axis: gray(25, '#bfbfbf'), grid: gray(15, '#d9d9d9') } + if (row === 4) out.plot = col <= 1 ? scheme('bg1', [mod('lumMod', 95000)], ctx, '#f2f2f2') : scheme(`accent${col - 1}`, [mod('lumMod', 20000), mod('lumOff', 80000)], ctx, '#f2f2f2') + if (row === 5) { + out.bg = scheme('tx1', [mod('lumMod', 85000), mod('lumOff', 15000)], ctx, '#262626') + out.plot = scheme('tx1', [mod('lumMod', 75000), mod('lumOff', 25000)], ctx, '#404040') + out.text = scheme('bg1', [], ctx, '#ffffff') + out.grid = scheme('bg1', [mod('lumMod', 50000)], ctx, '#808080') + out.axis = scheme('bg1', [mod('lumMod', 65000)], ctx, '#a6a6a6') + } + return out +} + +/** an accent of the theme, darker or lighter in later rounds (series 7 on), as Excel colors series without their own */ +function accentRound(i: number, ctx: ColorCtx): string { + const round = Math.floor(i / 6) + const mods: XEl[] = [] + if (round % 4 === 1) mods.push(mod('lumMod', 60000)) + else if (round % 4 === 2) mods.push(mod('lumMod', 80000), mod('lumOff', 20000)) + else if (round % 4 === 3) mods.push(mod('lumMod', 80000)) + return scheme(`accent${(i % 6) + 1}`, mods, ctx, '#4472c4') +} + +/** a shape's fill: a color, null for none, undefined where it says nothing; a gradient or pattern stands as one color */ +function fillOf(spPr: XEl | undefined, ctx: ColorCtx): string | null | undefined { + if (!spPr) return undefined + if (kid(spPr, 'noFill')) return null + const solid = kid(spPr, 'solidFill') + if (solid) return safe(colorOf(solid, ctx)) ?? undefined + const look = lookOf(spPr, ctx) + if (look?.kind === 'gradient') return look.stops[0].color + if (look?.kind === 'pattern') return look.fg + return undefined +} + +/** a shape's gradient or pattern fill */ +function lookOf(spPr: XEl | undefined, ctx: ColorCtx): FillLook | undefined { + const grad = kid(spPr, 'gradFill') + if (grad) { + const stops = kids(kid(grad, 'gsLst'), 'gs') + .map((gs) => ({ pos: Math.max(0, Math.min(1, num(gs.a.pos, 0)! / 100000)), color: safe(colorOf(gs, ctx)) })) + .filter((s): s is { pos: number; color: string } => !!s.color) + .sort((a, b) => a.pos - b.pos) + if (!stops.length) return undefined + const lin = kid(grad, 'lin') + return { kind: 'gradient', stops, angle: lin ? num(lin.a.ang, 0)! / 60000 : 90, radial: !lin && !!kid(grad, 'path') } + } + const patt = kid(spPr, 'pattFill') + if (patt) { + const fg = safe(colorOf(kid(patt, 'fgClr'), ctx)) ?? '#000000' + const bg = safe(colorOf(kid(patt, 'bgClr'), ctx)) ?? '#ffffff' + return { kind: 'pattern', preset: patt.a.prst ?? 'pct5', fg, bg } + } + return undefined +} + +/** a shape's fill and its look together */ +function fullFill(spPr: XEl | undefined, ctx: ColorCtx, def: string | null): Fill { + const f = fillOf(spPr, ctx) + const look = lookOf(spPr, ctx) + return { fill: f === undefined ? def : f, ...(look ? { look } : {}) } +} + +function lineOf(spPr: XEl | undefined, ctx: ColorCtx): { color?: string; width?: number; dash?: string } | null | undefined { + const ln = kid(spPr, 'ln') + if (!ln) return undefined + if (kid(ln, 'noFill')) return null + const w = num(ln.a.w) + const dash = val(kid(ln, 'prstDash')) + return { color: safe(colorOf(kid(ln, 'solidFill'), ctx)), width: w !== undefined ? w / EMU_PER_PT : undefined, dash: dash && dash !== 'solid' ? dash : undefined } +} + +/** a line the file gives in full (its color at least); null where it says none, undefined where it says nothing */ +function ownLine(spPr: XEl | undefined, ctx: ColorCtx, defWidth: number): LineLook | null | undefined { + const l = lineOf(spPr, ctx) + if (l === null) return null + if (!l?.color) return undefined + return { color: l.color, width: l.width ?? defWidth, ...(l.dash ? { dash: l.dash } : {}) } +} + +function labelsOf(el: XEl | undefined, ctx: ColorCtx): DataLabels | undefined { + if (!el || on(kid(el, 'delete'))) return undefined + const l: DataLabels = { + value: on(kid(el, 'showVal')), + percent: on(kid(el, 'showPercent')), + category: on(kid(el, 'showCatName')), + series: on(kid(el, 'showSerName')), + pos: val(kid(el, 'dLblPos')), + format: kid(el, 'numFmt')?.a.formatCode, + sep: textOf(kid(el, 'separator')) || undefined, + text: textLookOf(el, ctx), + } + return l.value || l.percent || l.category || l.series ? l : undefined +} + +/** a cache's points by index (`c:pt idx`), `ptCount` long */ +function cacheOf(cache: XEl | undefined): { values: (string | null)[]; format?: string } | null { + if (!cache) return null + const n = num(val(kid(cache, 'ptCount')), 0)! + const values: (string | null)[] = new Array(n).fill(null) + for (const pt of kids(cache, 'pt')) { + const i = num(pt.a.idx, -1)! + if (i >= 0 && i < 100_000) values[i] = textOf(kid(pt, 'v')) + } + return { values, format: textOf(kid(cache, 'formatCode')) || undefined } +} + +interface Read { + values: (number | string | null)[] + format?: string + hidden?: boolean[] +} + +/** a title whose text is a cell (`c:tx/c:strRef`): the cell's text, else the cached one */ +async function referencedText(el: XEl, ctx: ColorCtx, read: RangeReader, size: number): Promise { + const text = (await source(kid(el, 'tx'), read))?.values.filter((x) => x !== null && x !== '').join(' ') + return text ? { text: String(text), size, bold: false, ...txLook(el, ctx) } : undefined +} + +/** a data source (`c:cat`, `c:val`, `c:xVal`, `c:yVal`, `c:tx`): its reference read from the cells, else its cache */ +async function source(el: XEl | undefined, read: RangeReader): Promise { + if (!el) return null + const ref = kid(el, 'numRef') ?? kid(el, 'strRef') ?? kid(el, 'multiLvlStrRef') + const lit = kid(el, 'numLit') ?? kid(el, 'strLit') + const f = textOf(kid(ref, 'f')) + if (f) { + const got = await read(f).catch(() => null) + if (got) return { values: got.values, format: got.formats.find((x) => x && x !== 'General'), hidden: got.hidden } + } + const cache = cacheOf(kid(ref, 'numCache') ?? kid(ref, 'strCache') ?? lit) ?? multiLevel(kid(ref, 'multiLvlStrCache')) + return cache ? { values: cache.values, format: cache.format } : null +} + +/** multi-level categories: the innermost level (the labels next to the axis) */ +function multiLevel(cache: XEl | undefined) { + const lvl = kids(cache, 'lvl')[0] + if (!cache || !lvl) return null + return cacheOf({ ...lvl, c: [kid(cache, 'ptCount')!, ...lvl.c].filter(Boolean) }) +} + +const toNum = (v: number | string | null | undefined): number | null => { + if (typeof v === 'number') return Number.isFinite(v) ? v : null + if (v === null || v === undefined || v === '') return null + const n = Number(v) + return Number.isFinite(n) ? n : null +} + +interface SeriesCtx { + ctx: ColorCtx + read: RangeReader + plotVisOnly: boolean + date1904: boolean + style: ChartStyle + /** the series in the chart, for a style's spread of shades */ + count: number + /** radar: 'standard', 'marker' or 'filled' */ + radarStyle?: string +} + +async function seriesOf(ser: XEl, i: number, type: ChartType, varyColors: boolean, sc: SeriesCtx): Promise { + const { ctx, read, plotVisOnly, date1904, style } = sc + const tx = await source(kid(ser, 'tx'), read) + const name = tx?.values.filter((x) => x !== null && x !== '').join(' ') || textOf(kid(kid(ser, 'tx'), 'v')) || `Series${num(val(kid(ser, 'idx')), i)! + 1}` + const scatter = type === 'scatter' || type === 'bubble' + const catSrc = await source(kid(ser, scatter ? 'xVal' : 'cat'), read) + const valSrc = await source(kid(ser, scatter ? 'yVal' : 'val'), read) + const sizeSrc = type === 'bubble' ? await source(kid(ser, 'bubbleSize'), read) : null + let values = (valSrc?.values ?? []).map(toNum) + let sizes = sizeSrc ? sizeSrc.values.map(toNum) : undefined + // categories that are numbers or dates are shown in their format, text as it is + const catText = (v: number | string | null) => { + if (v === null) return '' + const n = typeof v === 'number' ? v : catSrc?.format ? toNum(v) : null + return n === null ? String(v) : formatValue(n, catSrc?.format || 'General', date1904).text + } + let cats = (catSrc?.values ?? []).map(catText) + let xs = scatter ? (catSrc?.values ?? []).map(toNum) : undefined + // hidden rows / columns are left out where the chart shows visible cells only + const hidden = valSrc?.hidden + if (plotVisOnly && hidden?.some(Boolean)) { + const keep = (_: unknown, k: number) => !hidden[k] + values = values.filter(keep) + cats = cats.filter(keep) + xs = xs?.filter(keep) + sizes = sizes?.filter(keep) + } + if (!cats.length) cats = values.map((_, k) => String(k + 1)) + const spPr = kid(ser, 'spPr') + const color = style.color(num(val(kid(ser, 'idx')), i)!, sc.count) + const ownFill = fillOf(spPr, ctx) + const look = lookOf(spPr, ctx) + const lineOwn = lineOf(spPr, ctx) + // lines, scatter, stock and radar draw lines; a filled radar draws areas as bars and areas do + const lined = type === 'line' || type === 'scatter' || type === 'stock' || (type === 'radar' && sc.radarStyle !== 'filled') + const fill = lined ? null : ownFill === undefined ? color : ownFill + const line = + lineOwn === null + ? null + : lined + ? { color: lineOwn?.color ?? color, width: lineOwn?.width ?? 2.25, dash: lineOwn?.dash } + : lineOwn?.color + ? { color: lineOwn.color, width: lineOwn.width ?? 0.75, dash: lineOwn.dash } + : null + const mk = kid(ser, 'marker') + // a radar of style 'standard' has no markers; one of style 'marker' has them unless a series says none + const markable = lined && !(type === 'radar' && sc.radarStyle === 'standard') + const symbol = val(kid(mk, 'symbol'), markable ? 'auto' : 'none')! + const markerSpPr = kid(mk, 'spPr') + const marker = + symbol === 'none' || !markable + ? null + : { + symbol: symbol === 'auto' ? ['diamond', 'square', 'triangle', 'x', 'star', 'circle'][i % 6] : symbol, + size: num(val(kid(mk, 'size')), 5)!, + fill: fillOf(markerSpPr, ctx) ?? line?.color ?? color, + line: lineOf(markerSpPr, ctx)?.color ?? line?.color ?? color, + } + const points: Record = {} + // pie slices (and the points of a single series told to vary its colors) get the style's colors in turn + if (varyColors) values.forEach((_, k) => (points[k] = { color: style.color(k, values.length) })) + for (const dPt of kids(ser, 'dPt')) { + const k = num(val(kid(dPt, 'idx')), -1)! + if (k < 0) continue + const c = fillOf(kid(dPt, 'spPr'), ctx) + const pl = lookOf(kid(dPt, 'spPr'), ctx) + points[k] = { + ...points[k], + ...(c !== undefined && c !== null ? { color: c } : {}), + ...(pl ? { look: pl } : {}), + ...(kid(dPt, 'explosion') ? { explosion: num(val(kid(dPt, 'explosion')), 0) } : {}), + } + } + const explosion = num(val(kid(ser, 'explosion'))) + if (explosion) values.forEach((_, k) => (points[k] = { explosion, ...points[k] })) + return { + name, + cats, + xs, + values, + ...(sizes ? { sizes } : {}), + format: valSrc?.format, + fill, + ...(look && fill !== null ? { look } : {}), + line, + marker, + smooth: on(kid(ser, 'smooth')), + invertIfNegative: on(kid(ser, 'invertIfNegative')), + points, + labels: labelsOf(kid(ser, 'dLbls'), ctx), + } +} + +function axisOf(el: XEl, ctx: ColorCtx, defSize: number): ChartAxis { + const scaling = kid(el, 'scaling') + const fmt = kid(el, 'numFmt') + const crosses = val(kid(el, 'crosses')) + const grid = kid(el, 'majorGridlines') + const gridLine = grid ? ownLine(kid(grid, 'spPr'), ctx, 0.75) : undefined + const axLine = ownLine(kid(el, 'spPr'), ctx, 0.75) + const text = textLookOf(el, ctx) + return { + ...(gridLine !== undefined ? { grid: gridLine } : {}), + ...(axLine !== undefined ? { line: axLine } : {}), + ...(text ? { text } : {}), + id: val(kid(el, 'axId'), '')!, + kind: el.n === 'catAx' ? 'cat' : el.n === 'dateAx' ? 'date' : el.n === 'serAx' ? 'ser' : 'val', + pos: val(kid(el, 'axPos'), 'b')!, + deleted: on(kid(el, 'delete')), + reverse: val(kid(scaling, 'orientation')) === 'maxMin', + logBase: num(val(kid(scaling, 'logBase'))), + min: num(val(kid(scaling, 'min'))), + max: num(val(kid(scaling, 'max'))), + major: num(val(kid(el, 'majorUnit'))), + format: fmt && fmt.a.sourceLinked !== '1' ? fmt.a.formatCode : undefined, + gridlines: !!kid(el, 'majorGridlines'), + labels: val(kid(el, 'tickLblPos'), 'nextTo') !== 'none', + title: richText(kid(el, 'title'), ctx, defSize, true), + crossAx: val(kid(el, 'crossAx'), '')!, + crosses: crosses === 'min' || crosses === 'max' ? crosses : 'autoZero', + crossesAt: num(val(kid(el, 'crossesAt'))), + } +} + +function layoutOf(el: XEl | undefined): ChartLayout | undefined { + const m = kid(kid(el, 'layout'), 'manualLayout') + if (!m) return undefined + const x = num(val(kid(m, 'x'))) + const y = num(val(kid(m, 'y'))) + const w = num(val(kid(m, 'w'))) + const h = num(val(kid(m, 'h'))) + // only 'edge' positions (fractions from the top left) are read; 'factor' ones move Excel's own layout + if (x === undefined || y === undefined || w === undefined || h === undefined || val(kid(m, 'xMode')) === 'factor' || val(kid(m, 'yMode')) === 'factor') return undefined + return { x, y, w, h, target: val(kid(m, 'layoutTarget')) === 'inner' ? 'inner' : 'outer' } +} + +/** + * The chart in `root` (a chart part's `c:chartSpace`), or null where it has no kind this reads (it stays a placeholder). + * `read` gives a series reference's cells; `theme` resolves its colors. + */ +export async function readChart(root: XEl | undefined, opts: { theme: Theme; date1904: boolean; read: RangeReader }): Promise { + const chart = kid(root, 'chart') + const plot = kid(chart, 'plotArea') + if (!chart || !plot) return null + const ctx: ColorCtx = { theme: opts.theme } + const style = chartStyle(styleOf(root), ctx) + const base = txLook(root, ctx) + const fontSize = base?.size || 10 + const textColor = base?.color ?? style.text + const plotVisOnly = on(kid(chart, 'plotVisOnly'), true) + const groups: ChartGroup[] = [] + let seriesIndex = 0 + const count = plot.c.reduce((a, g) => a + (GROUPS[g.n] ? kids(g, 'ser').length : 0), 0) + for (const g of plot.c) { + const type = GROUPS[g.n] + if (!type) continue + const grouping = val(kid(g, 'grouping'), type === 'bar' ? 'clustered' : 'standard') as Grouping + const varyColors = on(kid(g, 'varyColors'), type === 'pie' || type === 'doughnut') + const radarStyle = type === 'radar' ? val(kid(g, 'radarStyle'), 'marker') : undefined + const series: ChartSeries[] = [] + // a pie shows its first series, a slice per point in its own color; other kinds vary colors for a lone series only + const vary = varyColors && type !== 'surface' && type !== 'stock' && (type === 'pie' || type === 'doughnut' || kids(g, 'ser').length === 1) + const sc: SeriesCtx = { ctx, read: opts.read, plotVisOnly, date1904: opts.date1904, style, count, radarStyle } + for (const ser of kids(g, 'ser')) series.push(await seriesOf(ser, seriesIndex++, type, vary, sc)) + const groupLabels = labelsOf(kid(g, 'dLbls'), ctx) + if (groupLabels) for (const s of series) s.labels ??= groupLabels + groups.push({ + type, + dir: val(kid(g, 'barDir')) === 'bar' ? 'bar' : 'col', + grouping, + gapWidth: num(val(kid(g, 'gapWidth')), 150)!, + overlap: num(val(kid(g, 'overlap')), grouping === 'stacked' || grouping === 'percentStacked' ? 100 : 0)!, + varyColors, + holeSize: num(val(kid(g, 'holeSize')), 50)!, + firstSliceAng: num(val(kid(g, 'firstSliceAng')), 0)!, + scatterStyle: val(kid(g, 'scatterStyle')), + ...(radarStyle ? { radarStyle } : {}), + ...(type === 'bubble' + ? { + bubbleScale: num(val(kid(g, 'bubbleScale')), 100)!, + sizeRepresents: val(kid(g, 'sizeRepresents')) === 'w' ? ('w' as const) : ('area' as const), + showNegBubbles: on(kid(g, 'showNegBubbles')), + } + : {}), + ...(type === 'stock' ? stockParts(g, ctx, textColor) : {}), + ...(type === 'surface' ? surfaceParts(g, ctx, style) : {}), + axIds: kids(g, 'axId').map((a) => a.a.val ?? ''), + series, + }) + } + if (!groups.length || !groups.some((g) => g.series.length)) return null + const axes = plot.c.filter((a) => a.n === 'catAx' || a.n === 'valAx' || a.n === 'dateAx' || a.n === 'serAx').map((a) => axisOf(a, ctx, fontSize)) + // a title without text of its own is Excel's automatic one: the series' name for one series, else "Chart Title"; a chart + // of one series without a title element has that automatic title too unless it was deleted + const single = groups.length === 1 && groups[0].series.length === 1 + const titleEl = kid(chart, 'title') + const auto = (): ChartText => ({ text: single ? groups[0].series[0].name : 'Chart Title', size: fontSize * 1.4, bold: false, ...txLook(titleEl, ctx) }) + let title = titleEl ? (richText(titleEl, ctx, fontSize * 1.4, false) ?? (await referencedText(titleEl, ctx, opts.read, fontSize * 1.4)) ?? auto()) : undefined + if (!titleEl && single && !on(kid(chart, 'autoTitleDeleted')) && groups[0].type !== 'scatter') title = auto() + if (title) { + if (!title.size) title.size = fontSize * 1.4 + title.color ??= textColor + } + const legend = kid(chart, 'legend') + const legendText = textLookOf(legend, ctx) + const spPr = kid(root, 'spPr') + const border = lineOf(spPr, ctx) + const bg = fillOf(spPr, ctx) + const bgLook = lookOf(spPr, ctx) + const plotSp = kid(plot, 'spPr') + const plotFill = fillOf(plotSp, ctx) + const plotLook = lookOf(plotSp, ctx) + const plotBorder = ownLine(plotSp, ctx, 0.75) + return { + title, + legend: legend + ? { + pos: val(kid(legend, 'legendPos'), 'r')!, + overlay: on(kid(legend, 'overlay')), + deleted: kids(legend, 'legendEntry').filter((e) => on(kid(e, 'delete'))).map((e) => num(val(kid(e, 'idx')), -1)!), + ...(legendText ? { text: legendText } : {}), + } + : undefined, + groups, + axes, + plot: layoutOf(plot), + fontSize, + textColor, + bg: bg === undefined ? style.bg : bg, + ...(bgLook ? { bgLook } : {}), + border: border === null ? null : border?.color, + plotFill: plotFill === undefined ? style.plot : plotFill, + ...(plotLook ? { plotLook } : {}), + ...(plotBorder !== undefined ? { plotBorder } : {}), + gridColor: style.grid, + axisColor: style.axis, + date1904: opts.date1904, + } +} + +/** the built-in style number: Excel 2010's own (`c14:style`, 101…148) where it is given, else `c:style` */ +function styleOf(root: XEl | undefined): number | undefined { + for (const el of root?.c ?? []) { + if (el.n === 'style') return num(el.a.val) + if (el.n === 'AlternateContent') { + const choice = kid(el, 'Choice') + const s14 = num(kid(choice, 'style')?.a.val) + if (s14 !== undefined) return s14 > 100 ? s14 - 100 : s14 + const fb = num(kid(kid(el, 'Fallback'), 'style')?.a.val) + if (fb !== undefined) return fb + } + } + return undefined +} + +/** a stock group's high-low lines and up-down bars */ +function stockParts(g: XEl, ctx: ColorCtx, textColor: string): Pick { + const hl = kid(g, 'hiLowLines') + const ud = kid(g, 'upDownBars') + const bar = (el: XEl | undefined, def: string) => ({ ...fullFill(kid(el, 'spPr'), ctx, def), line: ownLine(kid(el, 'spPr'), ctx, 0.75) ?? { color: textColor, width: 0.75 } }) + return { + hiLow: hl ? (ownLine(kid(hl, 'spPr'), ctx, 0.75) ?? { color: textColor, width: 0.75 }) : null, + ...(ud ? { upDown: { gapWidth: num(val(kid(ud, 'gapWidth')), 150)!, up: bar(kid(ud, 'upBars'), '#ffffff'), down: bar(kid(ud, 'downBars'), '#000000') } } : {}), + } +} + +/** a surface group's wireframe flag and its bands' own fills (`c:bandFmts`) */ +function surfaceParts(g: XEl, ctx: ColorCtx, style: ChartStyle): Pick { + const bands: Record = {} + for (const b of kids(kid(g, 'bandFmts'), 'bandFmt')) { + const k = num(val(kid(b, 'idx')), -1)! + const f = fillOf(kid(b, 'spPr'), ctx) + if (k >= 0 && f !== undefined) bands[k] = fullFill(kid(b, 'spPr'), ctx, null) + } + // how many bands there are depends on the axis drawn, so the style's colors are given for as many as can be + return { wireframe: on(kid(g, 'wireframe')), bands, bandColors: Array.from({ length: SURFACE_BANDS }, (_, k) => style.color(k, SURFACE_BANDS)) } +} + +/** the most value bands a surface chart is drawn with */ +export const SURFACE_BANDS = 12 diff --git a/src/core/xlsx/drawings.ts b/src/core/xlsx/drawings.ts new file mode 100644 index 0000000..95dba95 --- /dev/null +++ b/src/core/xlsx/drawings.ts @@ -0,0 +1,186 @@ +/** + * The pictures and charts on a sheet, read from its drawing part (`xl/drawings/drawingN.xml`, linked from the sheet's + * rels), each with the place it is anchored to: two cell corners (`twoCellAnchor`), one corner and a size + * (`oneCellAnchor`), or a position on the sheet and a size (`absoluteAnchor`). A picture carries its media part, which + * the view shows where the browser can draw it (not EMF / WMF); a chart carries its model with the values read from the + * cells (chart.ts), or, of a kind not drawn, its kind and title for a placeholder the size of the chart. Shapes, text + * boxes and the like are left out, as are drawings in a group the anchor's own frame does not hold. + */ +import { isWebImage } from '../opc' +import { desc, descAll, kid, num, textOf, type XEl } from '../xml' +import { parseFormula, references } from './formula' +import { readChart, type ChartModel, type RangeReader, type RangeValues } from './chart' +import { T_BOOL, T_DATE, T_EMPTY, T_ERR, T_NUM, T_SST, type XlsxWorkbook } from './workbook' + +/** A cell corner: 0-based column and row, and the offset into that cell in CSS pixels */ +export interface DrawingCorner { + c: number + r: number + dx: number + dy: number +} + +export interface SheetDrawing { + kind: 'picture' | 'chart' + from?: DrawingCorner + to?: DrawingCorner + /** position from the sheet's top left, px (absoluteAnchor) */ + at?: { x: number; y: number } + /** px (oneCellAnchor, absoluteAnchor) */ + size?: { w: number; h: number } + /** a picture's media part, where the browser can draw it */ + target?: string + /** a chart's title, or a picture's description */ + title: string + /** a chart's kind: 'column', 'bar', 'line', 'pie', … ('' where it is none the reader knows) */ + chart?: string + /** a chart's model with its values, for drawing it (chart-svg.ts); absent where it is a kind not drawn (a placeholder) */ + model?: ChartModel +} + +/** DrawingML measures in EMU: 914400 to the inch, 96 CSS pixels to the inch */ +const EMU_PER_PX = 9525 + +const CHART_KINDS: Record = { + barChart: 'bar', bar3DChart: 'bar', lineChart: 'line', line3DChart: 'line', pieChart: 'pie', pie3DChart: 'pie', ofPieChart: 'pie', + doughnutChart: 'doughnut', areaChart: 'area', area3DChart: 'area', scatterChart: 'scatter', bubbleChart: 'bubble', radarChart: 'radar', + stockChart: 'stock', surfaceChart: 'surface', surface3DChart: 'surface', +} + +/** an attribute whatever its namespace prefix: `r:id` is the usual, not the only one */ +const attr = (el: XEl | undefined, local: string) => { + if (!el) return undefined + for (const [k, v] of Object.entries(el.a)) if (k === local || k.endsWith(':' + local)) return v + return undefined +} + +const corner = (el: XEl | undefined): DrawingCorner | undefined => + el + ? { + c: num(textOf(kid(el, 'col')), 0)!, + r: num(textOf(kid(el, 'row')), 0)!, + dx: num(textOf(kid(el, 'colOff')), 0)! / EMU_PER_PX, + dy: num(textOf(kid(el, 'rowOff')), 0)! / EMU_PER_PX, + } + : undefined + +/** a chart's kind, title and model (null where it is a kind chart.ts does not draw), from its chart part */ +async function chartOf(wb: XlsxWorkbook, sheet: number, part: string): Promise<{ chart: string; title: string; model: ChartModel | null }> { + const root = await wb.pkg.xml(part) + const plot = desc(root, 'plotArea') + const type = plot?.c.find((e) => e.n.endsWith('Chart')) + let chart = type ? (CHART_KINDS[type.n] ?? '') : '' + // a bar chart is drawn with upright columns unless its direction says bars + if (type?.n.startsWith('bar') && attr(kid(type, 'barDir'), 'val') !== 'bar') chart = 'column' + const title = desc(kid(root, 'chart'), 'title') + const model = await readChart(root, { theme: wb.theme, date1904: wb.date1904, read: workbookReader(wb, sheet) }).catch((e: Error) => { + console.warn('[office-engine] chart not read:', e.message) + return null + }) + return { chart, title: model?.title?.text ?? (title ? descAll(title, 't').map((t) => t.t).join('').trim() : ''), model } +} + +/** + * Reads a chart's series references from the workbook's cells: `'Sheet 1'!$B$2:$B$9`, several areas, a sheet not shown + * yet (loaded for it). Null for a defined name, another workbook or an unknown sheet: the chart's saved values stand in. + */ +export function workbookReader(wb: XlsxWorkbook, self: number): RangeReader { + return async (ref) => { + let refs + try { + refs = references(parseFormula(ref)) + } catch { + return null + } + if (!refs.length) return null + const out: RangeValues = { values: [], formats: [], hidden: [] } + for (const r of refs) { + if (r.sheet?.startsWith('[')) return null + const s = r.sheet === undefined ? self : wb.sheets.findIndex((x) => x.name.toLowerCase() === r.sheet!.toLowerCase()) + if (s < 0) return null + if (!wb.sheetsData.get(s)?.done) await wb.loadSheet(s, () => {}) + const d = wb.sheetsData.get(s) + if (!d || d.error) return null + const [r0, c0, r1, c1] = 'r' in r ? [r.r, r.c, r.r, r.c] : [r.r0, r.c0, Math.min(r.r1, Math.max(r.r0, d.maxRow)), Math.min(r.c1, Math.max(r.c0, d.maxCol))] + if ((r1 - r0 + 1) * (c1 - c0 + 1) > MAX_POINTS) return null + await wb.ensure(d, r0, r1) + const hiddenCols = new Set() + for (const ci of d.colInfo) if (ci.w === 0) for (let c = Math.max(ci.min, c0); c <= Math.min(ci.max, c1); c++) hiddenCols.add(c) + for (let rr = r0; rr <= r1; rr++) { + const row = d.rows[rr] + const cells = new Map() + if (row) for (let k = 0; k < row.length; k += 4) cells.set(row[k] as number, [row[k + 1] as number, row[k + 2], row[k + 3] as number]) + const rowHidden = !!d.rowAttrs.get(rr)?.hidden + for (let c = c0; c <= c1; c++) { + const cell = cells.get(c) + out.values.push(cell ? cellValue(wb, cell[0], cell[1]) : null) + out.formats.push(cell ? wb.styles.xfs[cell[2]]?.numFmt : undefined) + out.hidden.push(rowHidden || hiddenCols.has(c)) + } + } + } + return out + } +} + +/** more cells than a chart reads from one reference (a whole column): its saved values are used instead */ +const MAX_POINTS = 50_000 + +function cellValue(wb: XlsxWorkbook, t: number, v: unknown): number | string | null { + switch (t) { + case T_NUM: + case T_DATE: + return typeof v === 'number' ? v : Number(v) + case T_SST: + return wb.sst[v as number] ?? '' + case T_BOOL: + return v ? 1 : 0 + case T_EMPTY: + case T_ERR: + return null + default: + return String(v) + } +} + +/** The pictures and charts on the workbook's sheet `sheet`, in the order they are drawn */ +export async function readSheetDrawings(wb: XlsxWorkbook, sheet: number): Promise { + const pkg = wb.pkg + const sheetPath = wb.sheets[sheet]?.path + if (!sheetPath) return [] + const sheetRels = await pkg.rels(sheetPath) + const out: SheetDrawing[] = [] + for (const rel of sheetRels.values()) { + if (rel.external || !rel.type.endsWith('/drawing') || !pkg.has(rel.target)) continue + const root = await pkg.xml(rel.target) + const rels = await pkg.rels(rel.target) + for (const anchor of root?.c ?? []) { + if (anchor.n !== 'twoCellAnchor' && anchor.n !== 'oneCellAnchor' && anchor.n !== 'absoluteAnchor') continue + const ext = kid(anchor, 'ext') + const pos = kid(anchor, 'pos') + const place = { + from: corner(kid(anchor, 'from')), + to: corner(kid(anchor, 'to')), + at: pos ? { x: num(pos.a.x, 0)! / EMU_PER_PX, y: num(pos.a.y, 0)! / EMU_PER_PX } : undefined, + size: ext ? { w: num(ext.a.cx, 0)! / EMU_PER_PX, h: num(ext.a.cy, 0)! / EMU_PER_PX } : undefined, + } + const chartRef = desc(anchor, 'chart') + const blip = desc(anchor, 'blip') + if (chartRef) { + const target = rels.get(attr(chartRef, 'id') ?? '') + const chart = target && !target.external && pkg.has(target.target) ? await chartOf(wb, sheet, target.target) : { chart: '', title: '', model: null } + out.push({ kind: 'chart', ...place, chart: chart.chart, title: chart.title, ...(chart.model ? { model: chart.model } : {}) }) + } else if (blip) { + const target = rels.get(attr(blip, 'embed') ?? '') + const pr = desc(anchor, 'cNvPr') + out.push({ + kind: 'picture', + ...place, + target: target && !target.external && isWebImage(target.target) ? target.target : undefined, + title: pr?.a.descr || pr?.a.title || '', + }) + } + } + } + return out +} diff --git a/src/index.ts b/src/index.ts index f1fb2e3..15d70c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export type { DocSource } from './core/source' export type { DocKind } from './core/opc' export type * from './core/docx/types' export type * from './core/xlsx/types' +export type { DrawingCorner, SheetDrawing } from './core/xlsx/drawings' export type * from './core/pptx/types' export { startCollab, CollabHub, CollabSession, MemoryTransport, WebSocketTransport, type Collab, type CollabOptions, type CollabStatus, type Transport } from './collab' diff --git a/src/react/edit/Toolbar.tsx b/src/react/edit/Toolbar.tsx index e49e36a..3556f3d 100644 --- a/src/react/edit/Toolbar.tsx +++ b/src/react/edit/Toolbar.tsx @@ -42,6 +42,7 @@ const P: Record = { view: , close: , image: , + chart: , table: , link: , headerFooter: , diff --git a/src/react/styles.css b/src/react/styles.css index 0a07b1d..e6bdaf1 100644 --- a/src/react/styles.css +++ b/src/react/styles.css @@ -397,6 +397,15 @@ position: absolute; overflow: hidden; } +/* XLSX: pictures and charts over the cells; clicks go through to the cell below */ +.de-xl-pic { position: absolute; z-index: 2; object-fit: fill; pointer-events: none; user-select: none; } +.de-xl-drawing { + position: absolute; z-index: 2; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 3px; padding: 6px; overflow: hidden; border: 1px dashed var(--de-border); border-radius: 4px; background: color-mix(in srgb, var(--de-chrome) 94%, transparent); + color: var(--de-muted); font: 12px/1.3 system-ui, sans-serif; text-align: center; pointer-events: none; +} +.de-xl-drawing svg { color: var(--de-accent); } +.de-xl-drawing strong { color: var(--de-text); font-weight: 600; } .de-gl-h, .de-gl-v { position: absolute; diff --git a/src/react/xlsx/XlsxView.tsx b/src/react/xlsx/XlsxView.tsx index 7cc6335..04ef043 100644 --- a/src/react/xlsx/XlsxView.tsx +++ b/src/react/xlsx/XlsxView.tsx @@ -15,6 +15,7 @@ import { Toast } from '../common/Dialog' import { FindBar } from '../docx/FindBar' import { XlsxToolbar, type MergeMode } from './XlsxToolbar' import { Axis } from './axis' +import { drawingBox, drawingEl, drawingExtent, useSheetDrawings } from './drawings' import { fitNumber, fontPx } from './fit' import { sheetPrintable } from './print' import { useCommentsCtl, useCommentsState, type CommentsCtl, type PlacedThread } from '../comments/Comments' @@ -69,6 +70,8 @@ class XlsxStore { sheets = new Map() /** focus the grid when the next sheet grid mounts */ focusGrid = false + /** edit results applied (here or a collaborator's): charts read their cells again */ + edits = 0 private listeners = new Set<() => void>() private off: () => void @@ -166,6 +169,7 @@ class XlsxStore { /** applies an edit result: new styles, layout, sheet list + refetch of touched rows */ applied(res: XlsxEditResult | null, meta: WorkbookMeta) { if (!res) return + this.edits++ for (const st of res.styles ?? []) { meta.styles[st.index] = st.css meta.formats[st.index] = st.format @@ -556,11 +560,14 @@ function SheetGrid({ store, sheet, meta, client, edit, setEdit, hold, onSheet }: const [resizing, setResizing] = useState<{ axis: 'col' | 'row'; i: number; start: number; size: number } | null>(null) const { rowAxis, colAxis, frozen } = s + const drawings = useSheetDrawings(client, sheet, store.edits) + // the sheet reaches as far as its drawings, past its last filled cell + const drawn = drawingExtent(drawings, colAxis, rowAxis) const HW = Math.max(40, 12 + 7 * String(Math.max(s.rows, 1000)).length) const viewW = Math.max(0, size.w - HW) const viewH = Math.max(0, size.h - HH) - rowAxis.count = Math.max(s.rows + (s.done ? 20 : 1), Math.ceil(viewH / rowAxis.def) + 1, frozen.rows + 1, sel.fr + 2) - colAxis.count = Math.max(s.cols + 3, 26, Math.ceil(viewW / Math.max(1, colAxis.def)) + 1, frozen.cols + 1, sel.fc + 2) + rowAxis.count = Math.max(s.rows + (s.done ? 20 : 1), Math.ceil(viewH / rowAxis.def) + 1, frozen.rows + 1, sel.fr + 2, drawn.rows + 1) + colAxis.count = Math.max(s.cols + 3, 26, Math.ceil(viewW / Math.max(1, colAxis.def)) + 1, frozen.cols + 1, sel.fc + 2, drawn.cols + 1) const frozenW = colAxis.pos(frozen.cols) const frozenH = rowAxis.pos(frozen.rows) const totalW = colAxis.total() @@ -723,6 +730,16 @@ function SheetGrid({ store, sheet, meta, client, edit, setEdit, hold, onSheet }: out.push(cellEl(r, cell, undefined, undefined, filled)) } } + // pictures and charts over the cells, where they reach into this pane + const px0 = colAxis.pos(cc0) + const px1 = colAxis.pos(cc1 + 1) + const py0 = rowAxis.pos(rr0) + const py1 = rowAxis.pos(rr1 + 1) + drawings.forEach((d, i) => { + const b = drawingBox(d, colAxis, rowAxis) + if (!b || b.x > px1 || b.x + b.w < px0 || b.y > py1 || b.y + b.h < py0) return + out.push(drawingEl('dr' + i, d, { left: b.x - ox, top: b.y - oy, width: b.w, height: b.h })) + }) // selection range + active cell if (rect.r1 >= rr0 && rect.r0 <= rr1 && rect.c1 >= cc0 && rect.c0 <= cc1 && !oneCell) { out.push( diff --git a/src/react/xlsx/drawings.tsx b/src/react/xlsx/drawings.tsx new file mode 100644 index 0000000..6cc382a --- /dev/null +++ b/src/react/xlsx/drawings.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import type { DocumentClient } from '../../core/worker/client' +import type { ChartModel } from '../../core/xlsx/chart' +import { renderChart } from '../../core/xlsx/chart-svg' +import type { SheetDrawing } from '../../core/xlsx/drawings' +import { Icon } from '../edit/Toolbar' +import type { Axis } from './axis' + +/** a sheet's drawing with its picture's object URL, where the browser can draw it */ +export interface ShownDrawing extends SheetDrawing { + url?: string +} + +/** Edits re-read the charts this long after they rest: the values come from the edited cells. */ +const REREAD_MS = 300 + +/** + * The pictures and charts of the sheet (`xlsx.drawings`), read again when another sheet is shown and after edits + * (`edits` counts them), so charts follow the cells; the charts' pictures of the list replaced are released. + */ +export function useSheetDrawings(client: DocumentClient, sheet: number, edits = 0): ShownDrawing[] { + const [list, setList] = useState([]) + const shown = useRef([]) + const read = (live: () => boolean) => + client + .call('xlsx.drawings', sheet) + .then((drawings) => Promise.all(drawings.map(async (d) => ({ ...d, url: d.target ? await client.mediaUrl(d.target).catch(() => undefined) : undefined })))) + .then((next) => { + if (!live()) return + releaseCharts(shown.current) + shown.current = next + setList(next) + }) + .catch((e: Error) => console.warn('[office-engine] drawings not read:', e.message)) + useEffect(() => { + let live = true + setList([]) + void read(() => live) + return () => { + live = false + } + }, [client, sheet]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (!edits) return + let live = true + const timer = setTimeout(() => void read(() => live), REREAD_MS) + return () => { + live = false + clearTimeout(timer) + } + }, [edits]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => () => releaseCharts(shown.current), []) + return list +} + +/** each chart's picture, drawn for the size it was last shown at */ +const chartUrls = new WeakMap() + +/** the chart drawn at `w` × `h` as an SVG picture's object URL (in an img: nothing in it runs) */ +function chartUrl(m: ChartModel, w: number, h: number): string { + const key = `${Math.round(w)}x${Math.round(h)}` + const had = chartUrls.get(m) + if (had?.key === key) return had.url + if (had) URL.revokeObjectURL(had.url) + const url = URL.createObjectURL(new Blob([renderChart(m, Math.round(w), Math.round(h))], { type: 'image/svg+xml' })) + chartUrls.set(m, { key, url }) + return url +} + +function releaseCharts(list: ShownDrawing[]) { + for (const d of list) { + const had = d.model && chartUrls.get(d.model) + if (!had) continue + URL.revokeObjectURL(had.url) + chartUrls.delete(d.model!) + } +} + +/** + * Where a drawing lies on the sheet, px from its top left: anchored to cells it moves and grows with their rows and + * columns (a hidden one is 0 wide); given by a size it keeps that size. + */ +export function drawingBox(d: SheetDrawing, cols: Axis, rows: Axis): { x: number; y: number; w: number; h: number } | null { + let x: number + let y: number + if (d.from) { + x = cols.pos(d.from.c) + d.from.dx + y = rows.pos(d.from.r) + d.from.dy + } else if (d.at) { + x = d.at.x + y = d.at.y + } else return null + const w = d.to ? cols.pos(d.to.c) + d.to.dx - x : (d.size?.w ?? 0) + const h = d.to ? rows.pos(d.to.r) + d.to.dy - y : (d.size?.h ?? 0) + return w >= 2 && h >= 2 ? { x, y, w, h } : null +} + +/** How many rows and columns the sheet needs to reach every drawing (a drawing given by a size taken over default-sized cells) */ +export function drawingExtent(list: SheetDrawing[], cols: Axis, rows: Axis): { rows: number; cols: number } { + let r = 0 + let c = 0 + for (const d of list) { + const end = d.to ?? (d.from && d.size ? { c: d.from.c + Math.ceil(d.size.w / cols.def), r: d.from.r + Math.ceil(d.size.h / rows.def) } : undefined) + const fromAt = !end && d.at && d.size ? { c: Math.ceil((d.at.x + d.size.w) / cols.def), r: Math.ceil((d.at.y + d.size.h) / rows.def) } : undefined + const e = end ?? fromAt + if (e) { + r = Math.max(r, e.r + 1) + c = Math.max(c, e.c + 1) + } + } + return { rows: r, cols: c } +} + +const CHART_NAMES: Record = { + column: 'Column chart', bar: 'Bar chart', line: 'Line chart', pie: 'Pie chart', doughnut: 'Doughnut chart', area: 'Area chart', + scatter: 'Scatter chart', bubble: 'Bubble chart', radar: 'Radar chart', stock: 'Stock chart', surface: 'Surface chart', +} + +/** + * A drawing as shown: the picture itself, a chart drawn at its size, or a box of its size saying what is there (a chart + * of a kind not drawn, a picture the browser cannot draw). + */ +export function drawingEl(key: string, d: ShownDrawing, style: CSSProperties & { width: number; height: number }): ReactNode { + if (d.url) return {d.title} + if (d.model) return {d.title + const what = d.kind === 'chart' ? CHART_NAMES[d.chart ?? ''] ?? 'Chart' : 'Picture' + return ( +
+ + {what} + {d.title && {d.title}} +
+ ) +} diff --git a/test/xlsx-chart.test.ts b/test/xlsx-chart.test.ts new file mode 100644 index 0000000..c034645 --- /dev/null +++ b/test/xlsx-chart.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, test } from 'bun:test' +import { Engine } from '../src/core/worker/engine' +import { rowsToXlsx } from '../src/core/convert' +import { ZipArchive } from '../src/core/zip' +import { BlobSource } from '../src/core/source' +import { rebuildZip } from '../src/core/zipwrite' +import type { SheetDrawing } from '../src/core/xlsx/drawings' +import type { ChartModel } from '../src/core/xlsx/chart' +import { niceScale, renderChart } from '../src/core/xlsx/chart-svg' + +const RELS = 'http://schemas.openxmlformats.org/package/2006/relationships' +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' +const NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"' +const HEAD = '' + +const ROWS = [ + ['Quarter', 'Sales', 'Cost'], + ['Q1', '10', '4'], + ['Q2', '20', '8'], + ['Q3', '15', '9'], +] + +/** a numeric data source: a reference and a cache whose values differ from the cells (so the test tells them apart) */ +const numRef = (f: string, cache: number[], fmt = 'General') => + `${f}${fmt}${cache.map((v, i) => `${v}`).join('')}` +const strRef = (f: string, cache: string[]) => + `${f}${cache.map((v, i) => `${v}`).join('')}` +const ser = (i: number, name: string, val: string, extra = '') => + `${strRef(`Data!$${name}$1`, ['cached name'])}${extra}${strRef('Data!$A$2:$A$4', ['x', 'y', 'z'])}${val}` +const AXES = '' + +/** a workbook whose sheet "Data" holds ROWS and one chart (its `plotArea` body given); `hideRow` hides a row (1-based) */ +async function withChart(plot: string, opts: { hideRow?: number; title?: string; titleXml?: string; style?: number } = {}): Promise { + const base = await rowsToXlsx(ROWS, 'Data') + const zip = await ZipArchive.open(new BlobSource(base)) + const files = new Map() + let sheet = (await zip.text('xl/worksheets/sheet1.xml')).replace('', '') + if (!sheet.includes('xmlns:r=')) sheet = sheet.replace('`, `