From 1a7189d25f86385f34e1835caf36394a35693f36 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 07:58:42 +0100 Subject: [PATCH 1/6] feat(xlsx): pictures and chart placeholders on sheets A sheet's drawing part (xl/drawings/drawingN.xml) is read for its pictures and charts with their anchors: two cells, one cell and a size, or a position and a size. The new `xlsx.drawings(sheet)` call returns them; the grid lays them over the cells, placed by the row and column axes, so a picture moves and grows with resized or hidden rows and columns, and the sheet extends far enough to reach every drawing. A picture the browser can draw (PNG, JPEG, GIF, SVG, ...) is shown as itself, through the client's media URLs. A chart, and a picture in a format the browser cannot draw (EMF, WMF), is shown as a box of its size naming what is there: the chart's kind (column, bar, line, pie, ...) and title. Shapes and text boxes are left out. Clicks go through to the cells below. Co-Authored-By: Claude Opus 5.5 --- docs/features.md | 2 +- src/core/worker/engine.ts | 6 ++ src/core/worker/protocol.ts | 3 + src/core/xlsx/drawings.ts | 111 ++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/react/edit/Toolbar.tsx | 1 + src/react/styles.css | 9 +++ src/react/xlsx/XlsxView.tsx | 18 +++++- src/react/xlsx/drawings.tsx | 81 ++++++++++++++++++++++++++ test/xlsx-drawings.test.ts | 82 ++++++++++++++++++++++++++ 10 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 src/core/xlsx/drawings.ts create mode 100644 src/react/xlsx/drawings.tsx create mode 100644 test/xlsx-drawings.test.ts diff --git a/docs/features.md b/docs/features.md index a8cbb6e..b984b87 100644 --- a/docs/features.md +++ b/docs/features.md @@ -15,7 +15,7 @@ | 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 (over the cells) | 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 | Password-protected (encrypted) files are not supported. diff --git a/src/core/worker/engine.ts b/src/core/worker/engine.ts index 772d840..301731d 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,11 @@ 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': { + const wb = this.need(this.xlsx) + const s = wb.sheets[args[0] as number] + return s ? readSheetDrawings(wb.pkg, s.path) : [] + } 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/drawings.ts b/src/core/xlsx/drawings.ts new file mode 100644 index 0000000..7a11e22 --- /dev/null +++ b/src/core/xlsx/drawings.ts @@ -0,0 +1,111 @@ +/** + * 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 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, type OpcPackage } from '../opc' +import { desc, descAll, kid, num, textOf, type XEl } from '../xml' + +/** 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 +} + +/** 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 and title, from its chart part */ +async function chartOf(pkg: OpcPackage, part: string): Promise<{ chart: string; title: string }> { + const root = await 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(root, 'title') + return { chart, title: title ? descAll(title, 't').map((t) => t.t).join('').trim() : '' } +} + +/** The pictures and charts on the sheet at `sheetPath`, in the order they are drawn */ +export async function readSheetDrawings(pkg: OpcPackage, sheetPath: string): Promise { + 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(pkg, target.target) : { chart: '', title: '' } + out.push({ kind: 'chart', ...place, ...chart }) + } 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..2efe84a 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' @@ -556,11 +557,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) + // 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 +727,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..b8c2c3e --- /dev/null +++ b/src/react/xlsx/drawings.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState, type CSSProperties, type ReactNode } from 'react' +import type { DocumentClient } from '../../core/worker/client' +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 +} + +/** The pictures and charts of the sheet (`xlsx.drawings`), read again when another sheet is shown */ +export function useSheetDrawings(client: DocumentClient, sheet: number): ShownDrawing[] { + const [list, setList] = useState([]) + useEffect(() => { + let live = true + setList([]) + 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((shown) => live && setList(shown)) + .catch((e: Error) => console.warn('[office-engine] drawings not read:', e.message)) + return () => { + live = false + } + }, [client, sheet]) + return list +} + +/** + * 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, or a box of its size saying what is there (a chart, a picture the browser cannot draw) */ +export function drawingEl(key: string, d: ShownDrawing, style: CSSProperties): ReactNode { + if (d.url) 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-drawings.test.ts b/test/xlsx-drawings.test.ts new file mode 100644 index 0000000..691bcca --- /dev/null +++ b/test/xlsx-drawings.test.ts @@ -0,0 +1,82 @@ +import { 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 { drawingBox, drawingExtent } from '../src/react/xlsx/drawings' +import { Axis } from '../src/react/xlsx/axis' + +const RELS = 'http://schemas.openxmlformats.org/package/2006/relationships' +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' +const XDR = '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 EMU = 9525 + +const corner = (tag: string, c: number, r: number, dx = 0, dy = 0) => + `${c}${dx * EMU}${r}${dy * EMU}` +const pic = (rid: string, descr: string) => + `` + +/** a workbook whose sheet has a picture between two cells, a column chart by one cell and a size, an EMF picture at a position, and a text box */ +async function withDrawings(): Promise { + const base = await rowsToXlsx([['a', 'b'], ['c', 'd']], 'Data') + const zip = await ZipArchive.open(new BlobSource(base)) + const files = new Map() + files.set('xl/worksheets/_rels/sheet1.xml.rels', `${HEAD}`) + files.set( + 'xl/drawings/_rels/drawing1.xml.rels', + `${HEAD}`, + ) + files.set( + 'xl/drawings/drawing1.xml', + `${HEAD}` + + `${corner('from', 1, 2, 10, 5)}${corner('to', 4, 8)}${pic('rId1', 'Logo')}` + + `${corner('from', 6, 1)}` + + `${pic('rId3', '')}` + + `${corner('from', 0, 0)}${corner('to', 1, 1)}` + + ``, + ) + files.set( + 'xl/charts/chart1.xml', + `${HEAD}Sales 2026`, + ) + files.set('xl/media/image1.png', new Uint8Array([0x89, 0x50, 0x4e, 0x47])) + files.set('xl/media/image2.emf', new Uint8Array([1, 0, 0, 0])) + const ct = (await zip.text('[Content_Types].xml')).replace( + '', + '', + ) + files.set('[Content_Types].xml', ct) + return rebuildZip(zip, files as Map, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') +} + +test('a sheet lists its pictures and charts with their anchors, leaving shapes out', async () => { + const engine = new Engine(() => {}) + await engine.open(await withDrawings(), {}) + const list = (await engine.call('xlsx.drawings', [0])) as SheetDrawing[] + expect(list.length).toBe(3) + const [logo, chart, emf] = list + expect(logo).toMatchObject({ kind: 'picture', title: 'Logo', target: 'xl/media/image1.png', from: { c: 1, r: 2, dx: 10, dy: 5 }, to: { c: 4, r: 8, dx: 0, dy: 0 } }) + expect(chart).toMatchObject({ kind: 'chart', chart: 'column', title: 'Sales 2026', from: { c: 6, r: 1 }, size: { w: 300, h: 200 } }) + // a format the browser cannot draw keeps its place but no picture + expect(emf).toMatchObject({ kind: 'picture', at: { x: 50, y: 600 }, size: { w: 80, h: 40 } }) + expect(emf.target).toBeUndefined() + expect(await engine.call('xlsx.drawings', [5])).toEqual([]) +}) + +test('a drawing is placed by its cells, so it moves and grows with them', () => { + const cols = new Axis(64) + const rows = new Axis(20) + cols.count = rows.count = 50 + const d: SheetDrawing = { kind: 'picture', title: '', from: { c: 1, r: 2, dx: 10, dy: 5 }, to: { c: 4, r: 8, dx: 0, dy: 0 } } + expect(drawingBox(d, cols, rows)).toEqual({ x: 74, y: 45, w: 182, h: 115 }) + cols.set(2, 100) + rows.set(3, 0) + expect(drawingBox(d, cols, rows)).toEqual({ x: 74, y: 45, w: 218, h: 95 }) + // by one cell and a size: the size stays + expect(drawingBox({ kind: 'chart', title: '', from: { c: 6, r: 1, dx: 0, dy: 0 }, size: { w: 300, h: 200 } }, cols, rows)).toMatchObject({ w: 300, h: 200 }) + // the sheet reaches past its last drawing + expect(drawingExtent([d, { kind: 'chart', title: '', at: { x: 0, y: 600 }, size: { w: 80, h: 40 } }], new Axis(64), new Axis(20))).toEqual({ rows: 33, cols: 5 }) +}) From 102e3c4181417aab3eeba7db636073f15f7f9d64 Mon Sep 17 00:00:00 2001 From: Markus Date: Sat, 14 Nov 2026 09:51:20 +0100 Subject: [PATCH 2/6] feat(xlsx): charts drawn as SVG, their values from the cells A sheet's charts were placeholders. Now src/core/xlsx/chart.ts reads a chart part into a model in the worker, and src/core/xlsx/chart-svg.ts draws it as a self-contained SVG (DOM-free), shown over the cells like a picture. - Kinds: bar and column (clustered, stacked, 100%, upright or horizontal), line (markers, smoothing, gaps for empty cells), area, pie and doughnut (exploded slices, first-slice angle), scatter, and combinations on primary and secondary axes; 3D charts as their flat form. Radar, bubble, stock, surface and chartex keep the placeholder. - Values: series references are read from the workbook's cells, loading a sheet not shown yet, so charts follow edits (the view reads them again after an edit result). A defined name, another workbook or an unreadable reference falls back to the chart's numCache / strCache. Hidden rows and columns are left out when plotVisOnly is set. - Look: Excel-like automatic axis bounds (zero kept, steps of 1/2/5), fixed min/max/major unit, reversed and log axes, gridlines, tick and data labels in their number formats, tilted category labels where they do not fit flat, title (Excel's automatic one included), legend (positions, overlay, deleted entries), theme accents for series without colors of their own, manual plot layout. Co-Authored-By: Claude Opus 5.5 --- docs/features.md | 4 +- src/core/worker/engine.ts | 7 +- src/core/xlsx/chart-svg.ts | 640 ++++++++++++++++++++++++++++++++++++ src/core/xlsx/chart.ts | 454 +++++++++++++++++++++++++ src/core/xlsx/drawings.ts | 101 +++++- src/react/xlsx/XlsxView.tsx | 5 +- src/react/xlsx/drawings.tsx | 73 +++- test/xlsx-chart.test.ts | 163 +++++++++ 8 files changed, 1417 insertions(+), 30 deletions(-) create mode 100644 src/core/xlsx/chart-svg.ts create mode 100644 src/core/xlsx/chart.ts create mode 100644 test/xlsx-chart.test.ts diff --git a/docs/features.md b/docs/features.md index b984b87..1c92f5e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -15,7 +15,9 @@ | 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, images (over the cells) | charts (placeholder), shapes, conditional formatting | +| 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 | radar / bubble / stock / surface / chartex charts (placeholder), shapes, conditional formatting | + +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, combinations of them on primary and secondary axes; 3D charts as their flat form. 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. | 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 | Password-protected (encrypted) files are not supported. diff --git a/src/core/worker/engine.ts b/src/core/worker/engine.ts index 301731d..6026cea 100644 --- a/src/core/worker/engine.ts +++ b/src/core/worker/engine.ts @@ -208,11 +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': { - const wb = this.need(this.xlsx) - const s = wb.sheets[args[0] as number] - return s ? readSheetDrawings(wb.pkg, s.path) : [] - } + 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/xlsx/chart-svg.ts b/src/core/xlsx/chart-svg.ts new file mode 100644 index 0000000..3082f75 --- /dev/null +++ b/src/core/xlsx/chart-svg.ts @@ -0,0 +1,640 @@ +/** + * 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, pie and + * doughnut, 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. Text is measured with the diagram renderers' widths (diagram/svg.ts), so labels are placed without a browser. + */ +import { lineHeight, pathData, Svg, textWidth, wrap, type Paint, type Pt } from '../diagram/svg' +import type { ChartAxis, ChartGroup, ChartModel, ChartSeries, ChartText } from './chart' +import { formatValue } from './numfmt' + +const PX = 96 / 72 +const GRID = '#d9d9d9' +const AXIS = '#bfbfbf' +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) + +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 +} + +// ---------------------------------------------------------------- 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) +} + +// ---------------------------------------------------------------- legend + +interface LegendEntry { + label: string + fill?: string | null + line?: ChartSeries['line'] + marker?: ChartSeries['marker'] +} + +function legendEntries(m: ChartModel): LegendEntry[] { + const g0 = m.groups[0] + // 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 })).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++)) out.push({ label: s.name, fill: g.type === 'line' || g.type === 'scatter' ? undefined : s.fill, line: g.type === 'line' || g.type === 'scatter' ? s.line : undefined, marker: s.marker }) + } + return out +} + +function drawLegend(svg: Svg, m: ChartModel, entries: LegendEntry[], box: Box, vertical: boolean, size: number) { + 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) + 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: m.textColor }) + x += sw * 1.6 + 4 + textWidth(e.label, size) + 12 + } else { + svg.rect(x, cy - sw / 2, sw, sw, { fill: e.fill ?? 'none' }) + svg.text(x + sw + 4, cy, e.label, { size, color: m.textColor }) + x += w + } + if (vertical) { + x = box.x + y += lh + } + } +} + +function legendSize(entries: LegendEntry[], vertical: boolean, size: number, maxW: number): { 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) + 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] + const vals = s.values.map((v) => (v && v > 0 ? 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 size = m.fontSize * PX + 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 fill = s.points[k]?.color ?? s.fill ?? '#4472c4' + const stroke = s.line?.color ?? '#ffffff' + 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: 1 }) + 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: 1 }) + } + 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(v, 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' + svg.text(p.x, p.y, parts.join(s.labels.sep ?? (outside ? '; ' : '\n')).split('\n'), { size, color: outside ? m.textColor : '#ffffff', 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' + +/** 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) + return all.length ? [Math.min(...all), Math.max(...all)] : [0, 1] +} + +/** 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() + if (m.border !== null) svg.rect(0.5, 0.5, W - 1, H - 1, { stroke: m.border ?? GRID, 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 pos = m.legend.pos + const vertical = pos === 'r' || pos === 'l' || pos === 'tr' + const ls = legendSize(entries, vertical, size, vertical ? area.w * 0.4 : area.w) + 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, m, entries, lb, vertical, size) + 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 pie = m.groups.find((g) => g.type === 'pie' || g.type === 'doughnut') + if (pie) { + drawPie(svg, m, pie, inner ?? area) + return svg.toString(W, H, m.bg === null ? '' : (m.bg ?? '#ffffff')) + } + drawCartesian(svg, m, area, inner, size) + return svg.toString(W, H, m.bg === null ? '' : (m.bg ?? '#ffffff')) +} + +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') + const lh = lineHeight(size) + // 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) => g.type !== 'scatter')?.series[0]?.cats ?? [] + const n = Math.max(1, ...groups.filter((g) => g.type !== 'scatter').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') + const scale = a?.logBase ? logScale(lo, hi, a.logBase, a) : niceScale(lo, hi, { min: a?.min, max: a?.max, 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: its x axis is a value axis too + const scatter = groups.find((g) => g.type === 'scatter') + 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 sc = niceScale(Math.min(...known), Math.max(...known), { 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) => g.type !== 'scatter')?.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) + + // 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, size))) + 6 : 0) + const catW = showCats ? Math.min(area.w * 0.35, Math.max(...cats.map((c) => textWidth(c, size)))) + 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, size))) : 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 + lh) : lh + 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 = lh / 2 + if (horizontal) { + left = catW + titleH(catAxis) + bottom = (shows(pp) ? lh + 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) ?? '', size) / 3 + bottom = (showCats ? catBottom : xPlan ? lh + 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) } + } + + // 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 + for (let k = 0; k < p.scale.ticks.length; k++) { + const pos = valPos(p, p.scale.ticks[k]) + if (p.axis.gridlines) horizontal ? svg.line(pos, inner.y, pos, inner.y + inner.h, { stroke: GRID }) : svg.line(inner.x, pos, inner.x + inner.w, pos, { stroke: GRID }) + if (!shows(p)) continue + if (horizontal) svg.text(pos, inner.y + inner.h + 4 + lh / 2, p.labels[k], { size, color: m.textColor, anchor: 'middle' }) + else svg.text(right ? inner.x + inner.w + 4 : inner.x - 4, pos, p.labels[k], { size, color: m.textColor, anchor: right ? 'start' : 'end' }) + } + } + if (xPlan) { + for (let k = 0; k < xPlan.scale.ticks.length; k++) { + const x = xPos(xPlan.scale.ticks[k]) + if (xPlan.axis.gridlines) svg.line(x, inner.y, x, inner.y + inner.h, { stroke: GRID }) + if (shows(xPlan)) svg.text(x, inner.y + inner.h + 4 + lh / 2, xPlan.labels[k], { size, color: m.textColor, 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 ? lh * (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 + cats.forEach((c, k) => { + if (k % every) return + if (horizontal) svg.text(inner.x - 4, catMid(k), c, { size, color: m.textColor, anchor: 'end' }) + else if (tilt) { + const x = catMid(k) + svg.raw(``).text(x, y0, c, { size, color: m.textColor, anchor: 'end' }).raw('') + } else svg.text(catMid(k), y0 + lh / 2, c, { size, color: m.textColor, anchor: 'middle' }) + }) + } + // the category axis line, where the value axis is 0 (or its minimum) + const pp = plans.get(primary) + if (pp && !(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, { stroke: AXIS }) : svg.line(inner.x, pos, inner.x + inner.w, pos, { stroke: AXIS }) + } + // 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 : 2) + 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, horizontal }) + else if (g.type === 'scatter' && xPlan) drawScatter(svg, m, g, p, { valPos, xPos, size }) + } +} + +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, size: 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)) + if (parts.length) svg.text(x, y, parts.join(l.sep ?? ', '), { size, color: color ?? m.textColor, anchor }) +} + +function drawBars(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: Geo & { band: number; horizontal: boolean; size: number }) { + const { valPos, catMid, band, horizontal, size } = 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)) + 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 fill = raw < 0 && s.invertIfNegative ? '#ffffff' : (s.points[k]?.color ?? s.fill ?? 'none') + 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 } + 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' + if (horizontal) label(svg, m, s, k, raw, inside ? mid : Math.max(a, b) + 4, c0 + w / 2, size, inside ? 'middle' : 'start', inside ? '#ffffff' : undefined) + else label(svg, m, s, k, raw, c0 + w / 2, inside ? mid : Math.min(a, b) - lineHeight(size) / 2, size, '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: s.fill ?? 'none', stroke: s.line?.color, sw: s.line ? s.line.width * PX : 0 }) + below = vals + } +} + +function drawLines(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: Geo & { size: number; horizontal: boolean }) { + const { valPos, catMid, size } = 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), { stroke: s.line.color, sw: Math.max(0.75, s.line.width * PX), dash: dashOf(s.line.dash, s.line.width) }) + 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 - lineHeight(size) * 0.8, size) + }) + } +} + +function drawScatter(svg: Svg, m: ChartModel, g: ChartGroup, p: AxisPlan, geo: { valPos: Geo['valPos']; xPos: (v: number) => number; size: number }) { + const { valPos, xPos, size } = geo + const lines = g.scatterStyle !== 'marker' && g.scatterStyle !== 'none' + for (const s of g.series) { + const pts: Pt[] = [] + s.values.forEach((v, k) => { + const xv = s.xs?.[k] ?? k + 1 + if (v === null || xv === null) return + pts.push({ x: xPos(xv), y: valPos(p, v) }) + }) + if (lines && s.line && pts.length > 1) svg.path(linePath(pts, s.smooth || g.scatterStyle === 'smooth' || g.scatterStyle === 'smoothMarker'), { stroke: s.line.color, sw: Math.max(0.75, s.line.width * PX), dash: dashOf(s.line.dash, s.line.width) }) + pts.forEach((pt, k) => { + if (s.marker) marker(svg, pt.x, pt.y, s.marker) + if (s.labels && s.values[k] !== null) label(svg, m, s, k, s.values[k]!, pt.x, pt.y - lineHeight(size) * 0.8, size) + }) + } +} diff --git a/src/core/xlsx/chart.ts b/src/core/xlsx/chart.ts new file mode 100644 index 0000000..382a03f --- /dev/null +++ b/src/core/xlsx/chart.ts @@ -0,0 +1,454 @@ +/** + * 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; 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. + * + * 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 another kind (radar, bubble, stock, surface, the newer chartex kinds) 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' +export type Grouping = 'clustered' | 'stacked' | 'percentStacked' | 'standard' + +export interface ChartText { + text: string + /** pt */ + size: number + bold: boolean + color?: string +} + +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 +} + +export interface ChartPoint { + color?: string + /** pie slices: % of the radius */ + explosion?: number +} + +export interface ChartSeries { + name: string + /** category labels (bar, line, area, pie) */ + cats: string[] + /** x values (scatter) */ + xs?: (number | null)[] + values: (number | null)[] + /** the values' number format */ + format?: string + /** fill (bars, areas, slices); null: none */ + fill: string | null + /** 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 + /** the ids of its category (x) and value (y) axes */ + axIds: string[] + series: ChartSeries[] +} + +export interface ChartAxis { + id: string + kind: 'cat' | 'val' | 'date' + /** 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 + /** tick labels shown */ + labels: boolean + 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[] } + groups: ChartGroup[] + axes: ChartAxis[] + plot?: ChartLayout + /** pt: the chart's base text size */ + fontSize: number + textColor: string + bg?: string | null + border?: string | null + 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', +} + +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 +} + +/** an accent of the theme, darker or lighter in later rounds (series 7 on), as Excel colors series without their own */ +function autoColor(i: number, ctx: ColorCtx): string { + const accent = `accent${(i % 6) + 1}` + const round = Math.floor(i / 6) + const mods: XEl[] = [] + const mod = (n: string, v: number) => mods.push({ n, a: { val: String(v) }, c: [], t: '' }) + if (round % 4 === 1) mod('lumMod', 60000) + else if (round % 4 === 2) (mod('lumMod', 80000), mod('lumOff', 20000)) + else if (round % 4 === 3) mod('lumMod', 80000) + return drawingColor({ n: 'schemeClr', a: { val: accent }, c: mods, t: '' }, ctx) ?? '#4472c4' +} + +/** a shape's fill: a color, null for none, undefined where it says nothing */ +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 grad = kid(spPr, 'gradFill') + // a gradient: its first stop's color + if (grad) return safe(colorOf(kid(kid(grad, 'gsLst'), 'gs'), ctx)) ?? undefined + return undefined +} + +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 } +} + +function labelsOf(el: XEl | undefined): 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, + } + 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 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 +} + +async function seriesOf(ser: XEl, i: number, type: ChartType, varyColors: boolean, ctx: ColorCtx, read: RangeReader, plotVisOnly: boolean, date1904: boolean): Promise { + 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' + const catSrc = await source(kid(ser, scatter ? 'xVal' : 'cat'), read) + const valSrc = await source(kid(ser, scatter ? 'yVal' : 'val'), read) + let values = (valSrc?.values ?? []).map(toNum) + // 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) + } + if (!cats.length) cats = values.map((_, k) => String(k + 1)) + const spPr = kid(ser, 'spPr') + const color = autoColor(num(val(kid(ser, 'idx')), i)!, ctx) + const ownFill = fillOf(spPr, ctx) + const ownLine = lineOf(spPr, ctx) + const lined = type === 'line' || type === 'scatter' + const fill = lined ? null : ownFill === undefined ? color : ownFill + const line = + ownLine === null + ? null + : lined + ? { color: ownLine?.color ?? color, width: ownLine?.width ?? 2.25, dash: ownLine?.dash } + : ownLine?.color + ? { color: ownLine.color, width: ownLine.width ?? 0.75, dash: ownLine.dash } + : null + const mk = kid(ser, 'marker') + const symbol = val(kid(mk, 'symbol'), lined ? 'auto' : 'none')! + const markerSpPr = kid(mk, 'spPr') + const marker = + symbol === 'none' || !lined + ? 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 theme's accents in turn + if (varyColors) values.forEach((_, k) => (points[k] = { color: autoColor(k, ctx) })) + 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) + points[k] = { ...points[k], ...(c !== undefined && c !== null ? { color: c } : {}), ...(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, + format: valSrc?.format, + fill, + line, + marker, + smooth: on(kid(ser, 'smooth')), + invertIfNegative: on(kid(ser, 'invertIfNegative')), + points, + labels: labelsOf(kid(ser, 'dLbls')), + } +} + +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')) + return { + id: val(kid(el, 'axId'), '')!, + kind: el.n === 'catAx' ? 'cat' : el.n === 'dateAx' ? 'date' : '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 base = txLook(root, ctx) + const fontSize = base?.size || 10 + const textColor = base?.color ?? '#595959' + const plotVisOnly = on(kid(chart, 'plotVisOnly'), true) + const groups: ChartGroup[] = [] + let seriesIndex = 0 + for (const g of plot.c) { + const type = GROUPS[g.n] + if (!type) { + // a kind this cannot draw (radar, bubble, stock, surface): the whole chart stays a placeholder + if (g.n.endsWith('Chart')) return null + continue + } + const grouping = val(kid(g, 'grouping'), type === 'bar' ? 'clustered' : 'standard') as Grouping + const varyColors = on(kid(g, 'varyColors'), type === 'pie' || type === 'doughnut') + 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 === 'pie' || type === 'doughnut' || kids(g, 'ser').length === 1) + for (const ser of kids(g, 'ser')) series.push(await seriesOf(ser, seriesIndex++, type, vary, ctx, opts.read, plotVisOnly, opts.date1904)) + const groupLabels = labelsOf(kid(g, 'dLbls')) + 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')), + 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').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) ?? 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 spPr = kid(root, 'spPr') + const border = lineOf(spPr, ctx) + 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)!) } : undefined, + groups, + axes, + plot: layoutOf(plot), + fontSize, + textColor, + bg: fillOf(spPr, ctx), + border: border === null ? null : border?.color, + date1904: opts.date1904, + } +} diff --git a/src/core/xlsx/drawings.ts b/src/core/xlsx/drawings.ts index 7a11e22..95dba95 100644 --- a/src/core/xlsx/drawings.ts +++ b/src/core/xlsx/drawings.ts @@ -2,12 +2,15 @@ * 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 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. + * 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, type OpcPackage } from '../opc' +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 { @@ -31,6 +34,8 @@ export interface SheetDrawing { 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 */ @@ -59,20 +64,90 @@ const corner = (el: XEl | undefined): DrawingCorner | undefined => } : undefined -/** a chart's kind and title, from its chart part */ -async function chartOf(pkg: OpcPackage, part: string): Promise<{ chart: string; title: string }> { - const root = await pkg.xml(part) +/** 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(root, 'title') - return { chart, title: title ? descAll(title, 't').map((t) => t.t).join('').trim() : '' } + 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 } } -/** The pictures and charts on the sheet at `sheetPath`, in the order they are drawn */ -export async function readSheetDrawings(pkg: OpcPackage, sheetPath: string): Promise { +/** + * 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()) { @@ -93,8 +168,8 @@ export async function readSheetDrawings(pkg: OpcPackage, sheetPath: string): Pro 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(pkg, target.target) : { chart: '', title: '' } - out.push({ kind: 'chart', ...place, ...chart }) + 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') diff --git a/src/react/xlsx/XlsxView.tsx b/src/react/xlsx/XlsxView.tsx index 2efe84a..04ef043 100644 --- a/src/react/xlsx/XlsxView.tsx +++ b/src/react/xlsx/XlsxView.tsx @@ -70,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 @@ -167,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 @@ -557,7 +560,7 @@ 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) + 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) diff --git a/src/react/xlsx/drawings.tsx b/src/react/xlsx/drawings.tsx index b8c2c3e..6cc382a 100644 --- a/src/react/xlsx/drawings.tsx +++ b/src/react/xlsx/drawings.tsx @@ -1,5 +1,7 @@ -import { useEffect, useState, type CSSProperties, type ReactNode } from 'react' +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' @@ -9,24 +11,71 @@ export interface ShownDrawing extends SheetDrawing { url?: string } -/** The pictures and charts of the sheet (`xlsx.drawings`), read again when another sheet is shown */ -export function useSheetDrawings(client: DocumentClient, sheet: number): ShownDrawing[] { +/** 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([]) - useEffect(() => { - let live = true - setList([]) + 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((shown) => live && setList(shown)) + .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]) + }, [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. @@ -67,9 +116,13 @@ const CHART_NAMES: Record = { scatter: 'Scatter chart', bubble: 'Bubble chart', radar: 'Radar chart', stock: 'Stock chart', surface: 'Surface chart', } -/** A drawing as shown: the picture itself, or a box of its size saying what is there (a chart, a picture the browser cannot draw) */ -export function drawingEl(key: string, d: ShownDrawing, style: CSSProperties): ReactNode { +/** + * 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 (
diff --git a/test/xlsx-chart.test.ts b/test/xlsx-chart.test.ts new file mode 100644 index 0000000..ec7f9e5 --- /dev/null +++ b/test/xlsx-chart.test.ts @@ -0,0 +1,163 @@ +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 } = {}): 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('`, `