diff --git a/docs/TOC.md b/docs/TOC.md index 0ae2b53..bcfedda 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -17,6 +17,7 @@ - [Tables](tables.md) - [Theming Tables](theming-tables.md) - [Tables Summaries](tables-summaries.md) -- [Adding Headers and Footers to a Worksheet](worksheet-headers-footers.md) -- [Inserting images into spreadsheets](inserting-pictures.md) +- [Adding Headers/Footers to a Worksheet](worksheet-headers-footers.md) +- [Inserting Images](inserting-pictures.md) +- [Inserting Charts](inserting-charts.md) - [Streaming Export API](streaming.md) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md new file mode 100644 index 0000000..4ea7d44 --- /dev/null +++ b/docs/inserting-charts.md @@ -0,0 +1,435 @@ +## Inserting charts + +Add charts to a workbook: add data, build a chart with cell ranges, position it. + +### Supported types +`column` (vertical clustered), `bar` (horizontal), `line`, `pie`, `doughnut`, `scatter` + +### Core steps +1. Create a workbook & worksheet +2. Add data rows +3. Create a chart (using cell ranges) +4. Call `wb.addChart(chart)` +5. Anchor it (e.g. `twoCellAnchor`) +6. Generate files + +{% hint style="info" %} +**Tips** Categories typically populate the X-axis, while series values go on the Y-axis. +{% endhint %} + +### Option summary (ChartOptions) +| Option | Purpose | Notes | +|--------|---------|-------| +| type | Chart type | One of: column, bar, line, pie, doughnut, scatter (default: column) | +| title | Chart title | Omit for no title | +| axis.x.title / axis.y.title | Axis labels | Ignored for pie/doughnut | +| axis.x.showGridLines / axis.y.showGridLines | Gridlines toggles | x = vertical lines, y = horizontal lines | +| axis.y.minimum / axis.y.maximum | Value axis bounds | Numbers (e.g. 0, 1) | +| stacking | Stack series | 'stacked' or 'percent' (column / bar / line only) | +| width / height | Size (EMUs) | Usually omit (auto size) | +| categoriesRange | Category labels range | Not used by scatter (use scatterXRange instead) | +| series | Data series | Array of { name, valuesRange, color } | +| series[].scatterXRange | X values (scatter) | Only for scatter charts | +| dataLabels | Point label toggles | { showValue, showCategory, showPercent, showSeriesName } | + + +### Quick start (multi‑series column chart) +```ts +const wb = createWorkbook(); +const ws = wb.createWorksheet({ name: 'Sales' }); +wb.addWorksheet(ws); + +ws.setData([ + ['Month', 'Q1', 'Q2'], + ['Jan', 10, 15], + ['Feb', 20, 25], + ['Mar', 30, 35], +]); + +const chart = new Chart({ + type: 'column', + title: 'Quarterly Sales', + axis: { + x: { title: 'Month' }, // X-Axis: Horizontal categories (months) + y: { title: 'Revenue', minimum: 0, showGridLines: true } // Y-Axis: Vertical values (sales amounts) + }, + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, + ], + categoriesRange: 'Sales!$A$2:$A$4', +}); +wb.addChart(chart); +chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, to: { x: 10, y: 16 } }); + +// (Workbook export depends on your surrounding setup) +await wb.generateFiles(); +``` + + + + +## Resizing (width & height) +```ts +new Chart({ + title: 'Wide Chart', + width: 6_000_000, + height: 2_000_000, + series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }], + categoriesRange: 'Sales!$A$2:$A$4', +}); +``` + +## Positioning +Position a chart with a two‑cell anchor (start & end grid cells): +```ts +chart.createAnchor('twoCellAnchor', { + from: { x: 4, y: 1 }, + to: { x: 10, y: 16 } +}); +``` +Indices are zero‑based (0 = first column / row). + +### Legend +Auto behavior (no `legend` option provided): show legend only when there are 2 or more series. + +You can override with the `legend` option: +```ts +legend: { + show: true, // force show (even for single series) | false to hide + position: 'topRight', // 'right' (default) | 'left' | 'top' | 'bottom' | 'topRight' + overlay: false, // true => overlay plot area (no layout space) +} +``` +Rules: +- `show: true` forces a legend even for 1 series. +- `show: false` suppresses legend even for multiple series. +- If `show` is undefined, auto mode (2+ series) applies. +- `overlay` emits `` when true; otherwise `0`. + +Note: Pie / Doughnut with multiple series produces multiple pies/rings; legend lists series names. + +### Data Labels +Provide high-level toggles for what text appears on each point. + +API flags: +```ts +dataLabels: { + showValue?: boolean; // numeric value (Y value or slice value) + showCategory?: boolean; // category text (Month, Region, etc.) + showPercent?: boolean; // percentage (pie/doughnut, or percent-stacked series) + showSeriesName?: boolean; // series name (useful with multiple series where value alone is ambiguous) +} +``` + +Behavior: +- Pick the parts you want (value, percent, category, series name). Omitted = hidden. +- Omit `dataLabels` completely for none. +- Hover tooltips are unchanged (Excel shows full details on hover). + +Examples: +1. Value-only on a column chart: +```ts +dataLabels: { showValue: true } +``` +2. Percent-only on a pie (concise slice labels): +```ts +dataLabels: { showPercent: true } +``` +3. Value + percent on a doughnut: +```ts +dataLabels: { showValue: true, showPercent: true } +``` +4. Series name only (multi-line chart where legend is hidden): +```ts +dataLabels: { showSeriesName: true } +``` + +Full example (pie with percent only): +```ts +new Chart({ + type: 'pie', + title: 'Share', + dataLabels: { showPercent: true }, + series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], + categoriesRange: 'Regions!$A$2:$A$6', +}); +``` + +Example (legend will show 2 entries and be placed top-right): +```ts +new Chart({ + type: 'bar', + title: 'Year Comparison', + axis: { x: { title: 'Month' }, y: { title: 'Revenue' } }, + series: [ + { name: '2024', valuesRange: 'Sales!$B$2:$B$5' }, + { name: '2025', valuesRange: 'Sales!$C$2:$C$5' }, + ], + categoriesRange: 'Sales!$A$2:$A$5', + legend: { position: 'topRight' }, +}); +``` + +### Troubleshooting +| Problem | Cause | Fix | +|---------|-------|-----| +| Missing chart | Not added to workbook | Call `wb.addChart(chart)` | +| No legend | Only one series | Add a second series | +| Axis titles missing | Using pie chart | Pie charts have no axes | +| Wrong data | Typo in range string | Check sheet name & `$A$1` format | + +### Minimal example +```ts +const simple = new Chart({ + type: 'bar', + axis: { y: { minimum: 0 } }, + series: [{ name: 'Sales', valuesRange: 'Sales!$B$2:$B$4' }], + categoriesRange: 'Sales!$A$2:$A$4', +}); +wb.addChart(simple); +``` + +That's it — build your workbook and open in Excel. + +### Stacked & Percent Stacked + +Enable stacking on multi-series column, bar, or line charts: +```ts +new Chart({ + type: 'column', + stacking: 'stacked', // or 'percent' + axis: { + x: { title: 'Month' }, + y: { title: 'Revenue', minimum: 0, showGridLines: true } + }, + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, + ], + categoriesRange: 'Sales!$A$2:$A$4', +}); +``` + +Notes: +- Stacking applies only to column, bar, line. +- Percent stacking rescales each category to 100%. + +--- + +## Chart Type Examples + +Below are small, focused snippets for each type. They assume you already created a workbook (`wb`) and worksheet (`ws`) with matching ranges. + +#### Column +```ts +const col = new Chart({ + type: 'column', + title: 'Monthly Revenue', + axis: { + x: { title: 'Month' }, // X-Axis: Horizontal categories (months) + y: { title: 'Amount', minimum: 0, showGridLines: true } // Y-Axis: Vertical values (revenue) + }, + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF3366CC' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13', color: 'FFFF9933' }, + ], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(col); +``` + +#### Bar (horizontal) +```ts +const bar = new Chart({ + type: 'bar', + title: 'Monthly Revenue (Horizontal)', + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$7' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$7' }, + ], + categoriesRange: 'Sales!$A$2:$A$7', +}); +wb.addChart(bar); +``` + +#### Line +```ts +const line = new Chart({ + type: 'line', + title: 'Trend', + axis: { x: { title: 'Month' }, y: { title: 'Value', showGridLines: true } }, + series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF99CC00' }], + categoriesRange: 'Sales!$A$2:$A$13', + dataLabels: { showValue: true }, +}); +wb.addChart(line); +``` + +#### Pie (single series for one pie) +```ts +const pie = new Chart({ + type: 'pie', + title: 'Share by Region', + dataLabels: { showValue: true, showPercent: true }, + series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], + categoriesRange: 'Regions!$A$2:$A$6', +}); +wb.addChart(pie); +``` + +#### Doughnut (single series for one ring) +```ts +const doughnut = new Chart({ + type: 'doughnut', + title: 'Share by Category', + series: [{ name: '2025', valuesRange: 'Categories!$B$2:$B$6' }], + categoriesRange: 'Categories!$A$2:$A$6', +}); +wb.addChart(doughnut); +``` + +#### Scatter (X/Y numeric ranges) +```ts +const scatter = new Chart({ + type: 'scatter', + title: 'Distance vs Speed', + axis: { x: { title: 'Distance' }, y: { title: 'Speed' } }, + series: [{ + name: 'Run A', + scatterXRange: 'Runs!$A$2:$A$21', + valuesRange: 'Runs!$B$2:$B$21', + color: 'FFFF0000', // ARGB stroke color (opaque red) + }], + dataLabels: { showValue: true }, // shows Y values at each point +}); +wb.addChart(scatter); +``` + +#### Column Stacked +```ts +const colStacked = new Chart({ + type: 'column', + stacking: 'stacked', + title: 'Stacked Revenue', + axis: { x: { title: 'Month' }, y: { title: 'Total', minimum: 0, showGridLines: true } }, + series: [ + { name: 'Product A', valuesRange: 'Sales!$B$2:$B$13' }, + { name: 'Product B', valuesRange: 'Sales!$C$2:$C$13' }, + ], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(colStacked); +``` + +#### Column Percent Stacked +```ts +const colPct = new Chart({ + type: 'column', + stacking: 'percent', + title: 'Product Mix %', + axis: { + x: { title: 'Month' }, + y: { title: 'Percent', minimum: 0, maximum: 1, showGridLines: true } }, + series: [ + { name: 'Product A', valuesRange: 'Sales!$B$2:$B$13' }, + { name: 'Product B', valuesRange: 'Sales!$C$2:$C$13' }, + ], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(colPct); +``` + +#### Line Stacked +```ts +const lineStacked = new Chart({ + type: 'line', + stacking: 'stacked', + title: 'Cumulative Trend', + axis: { x: { title: 'Month' }, y: { title: 'Total', minimum: 0 } }, + series: [ + { name: 'North', valuesRange: 'Regions!$B$2:$B$13' }, + { name: 'South', valuesRange: 'Regions!$C$2:$C$13' }, + ], + categoriesRange: 'Regions!$A$2:$A$13', +}); +wb.addChart(lineStacked); +``` + +#### Line Percent Stacked +```ts +const linePct = new Chart({ + type: 'line', + stacking: 'percent', + title: 'Regional Contribution %', + axis: { + x: { title: 'Month' }, + y: { title: 'Percent', minimum: 0, maximum: 1 } + }, + series: [ + { name: 'North', valuesRange: 'Regions!$B$2:$B$13' }, + { name: 'South', valuesRange: 'Regions!$C$2:$C$13' }, + ], + categoriesRange: 'Regions!$A$2:$A$13', +}); +wb.addChart(linePct); +``` + +#### Bar Stacked +```ts +const barStacked = new Chart({ + type: 'bar', + stacking: 'stacked', + title: 'Stacked Horizontal', + series: [ + { name: 'Segment A', valuesRange: 'Segments!$B$2:$B$10' }, + { name: 'Segment B', valuesRange: 'Segments!$C$2:$C$10' }, + ], + categoriesRange: 'Segments!$A$2:$A$10', +}); +wb.addChart(barStacked); +``` + +#### Bar Percent Stacked +```ts +const barPct = new Chart({ + type: 'bar', + stacking: 'percent', + title: 'Segment Share %', + axis: { y: { minimum: 0, maximum: 1 } }, + series: [ + { name: 'Segment A', valuesRange: 'Segments!$B$2:$B$10' }, + { name: 'Segment B', valuesRange: 'Segments!$C$2:$C$10' }, + ], + categoriesRange: 'Segments!$A$2:$A$10', +}); +wb.addChart(barPct); +``` + +--- +End of chart type examples. + +### Series Colors +Format: opaque ARGB `FFRRGGBB` (examples: `FFFF9933` = orange, `FF3366CC` = blue). + +Effects: +- Column / Bar: fill color +- Line / Scatter: stroke color +- Pie / Doughnut: ignored (Excel auto colors slices) + +Notes: +- Alpha (anything other than `FF`) is ignored; colors are always rendered fully opaque. +- Invalid strings are ignored silently. +- Theme colors are not supported; supply an ARGB hex. + +### Cell Range Cheat Sheet +| Want | Pattern | Example | +|------|---------|---------| +| 3 category labels | Sheet!$A$2:$A$4 | `Sales!$A$2:$A$4` | +| Series values | Sheet!$B$2:$B$4 | `Sales!$B$2:$B$4` | +| Scatter X values | Sheet!$A$2:$A$21 | `Runs!$A$2:$A$21` | +| Scatter Y values | Sheet!$B$2:$B$21 | `Runs!$B$2:$B$21` | + +Tips: +- Always use absolute refs (`$A$1`) so range stays stable. +- Category and each series range must have the same number of rows. diff --git a/packages/demo/src/app-routing.ts b/packages/demo/src/app-routing.ts index be95935..b58fde9 100644 --- a/packages/demo/src/app-routing.ts +++ b/packages/demo/src/app-routing.ts @@ -15,6 +15,7 @@ import Example14 from './examples/example14.js'; import Example15 from './examples/example15.js'; import Example16 from './examples/example16.js'; import Example17 from './examples/example17.js'; +import Example18 from './examples/example18.js'; import GettingStarted from './getting-started.js'; export const navbarRouting = [ @@ -48,6 +49,7 @@ export const exampleRouting = [ { name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: '15- Streaming Excel Export' }, { name: 'example16', view: '/src/examples/example16.html', viewModel: Example16, title: '16- Streaming Features Demo' }, { name: 'example17', view: '/src/examples/example17.html', viewModel: Example17, title: '17- Streaming Export with Images' }, + { name: 'example18', view: '/src/examples/example18.html', viewModel: Example18, title: '18- Charts' }, ], }, ]; diff --git a/packages/demo/src/examples/example18.html b/packages/demo/src/examples/example18.html new file mode 100644 index 0000000..f2736a4 --- /dev/null +++ b/packages/demo/src/examples/example18.html @@ -0,0 +1,65 @@ +
+
+
+

+ Example 18: Create Charts + + Code + + html + | + ts + + +

+
+ Create multiple chart types (column, bar, line, pie, doughnut, scatter + stacked & percent stacked variants) and export them to an + Excel file. +
+
+
+ +
+
+ +
+
+
+
+
Excel Preview (single sheet example)
+

+ This screenshot shows one chart sheet only. The exported workbook includes every chart listed on the right. +

+ +
+
+
+
Charts Created:
+
    +
  • Column
  • +
  • Bar
  • +
  • Line
  • +
  • Pie
  • +
  • Doughnut
  • +
  • Scatter
  • +
  • Column Stacked
  • +
  • Bar Stacked
  • +
  • Line Stacked
  • +
  • Column % Stacked
  • +
  • Bar % Stacked
  • +
  • Line % Stacked
  • +
+

Each item becomes a worksheet with its own data table and chart.

+
+
+
+
diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts new file mode 100644 index 0000000..f926591 --- /dev/null +++ b/packages/demo/src/examples/example18.ts @@ -0,0 +1,170 @@ +import { Chart, type ChartType, Drawings, downloadExcelFile, Workbook } from 'excel-builder-vanilla'; +import chartUrl from '../images/charts.png?url'; + +export default class Example18 { + exportBtnElm!: HTMLButtonElement; + + mount() { + this.exportBtnElm = document.querySelector('#export-chart') as HTMLButtonElement; + this.exportBtnElm.addEventListener('click', this.startProcess.bind(this)); + // If an image placeholder exists, set its src (Vite will resolve the imported URL) + const imgElm = document.querySelector('#chart-screenshot'); + if (imgElm) { + imgElm.src = chartUrl; + imgElm.alt = 'Exported Excel charts screenshot'; + imgElm.loading = 'lazy'; + } + } + + unmount() { + this.exportBtnElm.removeEventListener('click', this.startProcess.bind(this)); + } + + async startProcess() { + // Base data (will be duplicated into each chart sheet) + const months = ['Jan', 'Feb', 'Mar']; + const q1 = [120, 150, 170]; + const q2 = [180, 160, 200]; + + const wb = new Workbook(); + + // Helper: create a sheet that includes its own data table & a chart of given type + const createChartSheetWithLocalData = (type: ChartType, sheetName: string, stacking?: 'stacked' | 'percent') => { + // Excel range sheet names with spaces or special chars must be quoted (e.g. 'Column Stacked'!$A$1) + const qSheet = /[\s%]/.test(sheetName) ? `'${sheetName}'` : sheetName; + const ws = wb.createWorksheet({ name: sheetName }); + let categoriesRange: string | undefined; + let seriesDefs: { name: string; valuesRange: string; scatterXRange?: string; color?: string }[] = []; + + if (type === 'scatter') { + // Provide a richer numeric dataset for scatter (X,Y pairs) with 8 points + const xVals = [10, 20, 30, 40, 55, 65, 80, 95]; + const yVals = [12, 18, 34, 33, 50, 58, 72, 90]; + ws.setData([['X', 'Y'], ...xVals.map((x, i) => [x, yVals[i]])]); + wb.addWorksheet(ws); + const xRange = `${qSheet}!$A$2:$A$${xVals.length + 1}`; + const yRange = `${qSheet}!$B$2:$B$${yVals.length + 1}`; + seriesDefs = [ + { + name: 'Y vs X', + valuesRange: yRange, + scatterXRange: xRange, + color: 'FFFF3333', // optional ARGB (FF opaque) stroke color (line/marker) for scatter + }, + ]; + } else { + // Use month/Q1/Q2 table for most non-scatter charts. + // Doughnut: intentionally single-series to avoid visual confusion (multi-series would render concentric rings) + if (type === 'doughnut') { + ws.setData([['Month', 'Q1'], ...months.map((m, i) => [m, q1[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + seriesDefs = [ + { + name: 'Q1', + valuesRange: q1Range, + // color intentionally omitted for doughnut: series color is ignored (Excel auto-colors slices) + }, + ]; + } else { + if (type === 'pie') { + // Single-series pie (Q1 only) + ws.setData([['Month', 'Q1'], ...months.map((m, i) => [m, q1[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + seriesDefs = [ + { + name: 'Q1', + valuesRange: q1Range, + // color intentionally omitted for pie to let Excel vary slice colors + }, + ]; + } else { + ws.setData([['Month', 'Q1', 'Q2'], ...months.map((m, i) => [m, q1[i], q2[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + const q2Range = `${qSheet}!$C$2:$C$${months.length + 1}`; + seriesDefs = [ + { name: 'Q1', valuesRange: q1Range /* color: 'FF3366CC'*/ }, // ARGB (FF opaque) custom solid fill + { name: 'Q2', valuesRange: q2Range /* color: 'FFFF9933'*/ }, // ARGB (FF opaque) + ]; + } + } + } + + const drawings = new Drawings(); + const legendConfig = (() => { + // Demonstrate legend options selectively + if (type === 'pie') return { show: true, position: 'topRight' as const }; // force legend for single-series pie + if (sheetName === 'Column') return { position: 'topRight' as const }; // custom position (auto show since >1 series) + if (sheetName === 'Bar Stacked') return { overlay: true }; // overlay example + return undefined; + })(); + + const chart = new Chart({ + type, + stacking, + title: `${sheetName} (${type}${stacking ? ` ${stacking}` : ''}) Chart`, + axis: { + x: { + title: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month', + // Show gridlines only on line & percent stacked line charts for demo + showGridLines: sheetName.includes('Line') && !sheetName.includes('Bar'), + }, + y: { + title: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : sheetName.includes('% Stacked') ? 'Percent' : 'Values', + // Use 0 baseline for stacked & percent stacked; cap percent stacks at 1 + minimum: sheetName.includes('Stacked') ? 0 : undefined, + maximum: sheetName.includes('% Stacked') ? 1 : undefined, + showGridLines: sheetName.includes('Column') || sheetName.includes('Line % Stacked'), + }, + }, + width: 460 * 9525, + height: 288 * 9525, + categoriesRange, + series: seriesDefs, + legend: legendConfig, + dataLabels: + type === 'pie' || type === 'doughnut' + ? { showPercent: true } + : sheetName === 'Column' || sheetName === 'Bar' || sheetName === 'Line' + ? { showValue: true } + : undefined, + }); + + const anchor = chart.createAnchor('twoCellAnchor', { + from: { x: 4, y: 1 }, // start Chart at E2 cell + to: { x: 14, y: 28 }, // adjusted end cell to reflect 10% smaller chart footprint + }); + chart.anchor = anchor; + drawings.addDrawing(chart); + ws.addDrawings(drawings); + wb.addDrawings(drawings); + wb.addChart(chart); + }; + + // Base chart types + createChartSheetWithLocalData('column', 'Column'); // vertical column chart + createChartSheetWithLocalData('bar', 'Bar'); // horizontal bar chart + createChartSheetWithLocalData('line', 'Line'); + createChartSheetWithLocalData('pie', 'Pie'); + createChartSheetWithLocalData('doughnut', 'Doughnut'); + createChartSheetWithLocalData('scatter', 'Scatter'); + + // Stacked variants (multi-series required for meaningful stack) + createChartSheetWithLocalData('column', 'Column Stacked', 'stacked'); + createChartSheetWithLocalData('bar', 'Bar Stacked', 'stacked'); + createChartSheetWithLocalData('line', 'Line Stacked', 'stacked'); + + // Percent stacked variants + createChartSheetWithLocalData('column', 'Column % Stacked', 'percent'); + createChartSheetWithLocalData('bar', 'Bar % Stacked', 'percent'); + createChartSheetWithLocalData('line', 'Line % Stacked', 'percent'); + + // Export workbook (chart will be included if supported) + downloadExcelFile(wb, 'Multiple-Charts.xlsx'); + } +} diff --git a/packages/demo/src/images/charts.png b/packages/demo/src/images/charts.png new file mode 100644 index 0000000..272f90c Binary files /dev/null and b/packages/demo/src/images/charts.png differ diff --git a/packages/demo/src/main.ts b/packages/demo/src/main.ts index 0c44172..0f9d4fd 100644 --- a/packages/demo/src/main.ts +++ b/packages/demo/src/main.ts @@ -127,7 +127,6 @@ class Main { } if (foundRouter?.view) { this.currentRouter = foundRouter; - // const html = await import(/*@vite-ignore*/ `${foundRouter.view}?raw`).default; document.querySelector('.panel-wm-content')!.innerHTML = pageLayoutGlobs[foundRouter.view] as string; const vm = new foundRouter.viewModel() as ViewModel; this.currentModel = vm; diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 381365b..5243778 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -149,7 +149,213 @@ export declare class AbsoluteAnchor { setDimensions(width: number, height: number): void; toXML(xmlDoc: XMLDOM, content: any): XMLNode; } -export declare class Chart { +/** + * Excel Color in ARGB format, for color aren't transparent just use "FF" as prefix. + * For example if the color you want to add is a blue with HTML color "#0000FF", then the excel color we need to add is "FF0000FF" + * Online tool: https://www.myfixguide.com/color-converter/ + */ +export type ExcelColorStyle = string | { + theme: number; +}; +export interface ExcelAlignmentStyle { + horizontal?: "center" | "fill" | "general" | "justify" | "left" | "right"; + justifyLastLine?: boolean; + readingOrder?: string; + relativeIndent?: boolean; + shrinkToFit?: boolean; + textRotation?: string | number; + vertical?: "bottom" | "distributed" | "center" | "justify" | "top"; + wrapText?: boolean; +} +export type ExcelBorderLineStyle = "continuous" | "dash" | "dashDot" | "dashDotDot" | "dotted" | "double" | "lineStyleNone" | "medium" | "slantDashDot" | "thin" | "thick"; +export interface ExcelBorderStyle { + bottom?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + top?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + left?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + right?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + diagonal?: any; + outline?: boolean; + diagonalUp?: boolean; + diagonalDown?: boolean; +} +export interface ExcelColumn { + bestFit?: boolean; + collapsed?: boolean; + customWidth?: number; + hidden?: boolean; + max?: number; + min?: number; + outlineLevel?: number; + phonetic?: boolean; + style?: number; + width?: number; +} +export interface ExcelTableColumn { + name: string; + dataCellStyle?: any; + dataDxfId?: number; + headerRowCellStyle?: ExcelStyleInstruction; + headerRowDxfId?: number; + totalsRowCellStyle?: ExcelStyleInstruction; + totalsRowDxfId?: number; + totalsRowFunction?: any; + totalsRowLabel?: string; + columnFormula?: string; + columnFormulaIsArrayType?: boolean; + totalFormula?: string; + totalFormulaIsArrayType?: boolean; +} +export interface ExcelFillStyle { + type?: "gradient" | "pattern"; + patternType?: string; + degree?: number; + fgColor?: ExcelColorStyle; + start?: ExcelColorStyle; + end?: { + pureAt?: number; + color?: ExcelColorStyle; + }; +} +export interface ExcelFontStyle { + bold?: boolean; + color?: ExcelColorStyle; + fontName?: string; + italic?: boolean; + outline?: boolean; + size?: number; + shadow?: boolean; + strike?: boolean; + subscript?: boolean; + superscript?: boolean; + underline?: boolean | "single" | "double" | "singleAccounting" | "doubleAccounting"; +} +export interface ExcelMetadata { + type?: string; + style?: number; +} +export interface ExcelColumnMetadata { + value: any; + metadata?: ExcelMetadata; +} +export interface ExcelMargin { + top: number; + bottom: number; + left: number; + right: number; + header: number; + footer: number; +} +export interface ExcelSortState { + caseSensitive?: boolean; + dataRange?: any; + columnSort?: boolean; + sortDirection?: "ascending" | "descending"; + sortRange?: any; +} +/** Excel custom formatting that will be applied to a column */ +export interface ExcelStyleInstruction { + id?: number; + alignment?: ExcelAlignmentStyle; + border?: ExcelBorderStyle | number; + borderId?: number; + fill?: ExcelFillStyle | number; + fillId?: number; + font?: ExcelFontStyle | number; + fontId?: number; + format?: string | number; + height?: number; + numFmt?: string; + numFmtId?: number; + width?: number; + xfId?: number; + protection?: { + locked?: boolean; + hidden?: boolean; + }; + /** style id */ + style?: number; +} +export type ChartType = "column" | "bar" | "line" | "pie" | "doughnut" | "scatter"; +/** Axis configuration options */ +export interface AxisOptions { + /** Axis title label */ + title?: string; + /** Explicit minimum value (value axis only; ignored for category axis unless future numeric category support) */ + minimum?: number; + /** Explicit maximum value (value axis only) */ + maximum?: number; + /** Show major gridlines */ + showGridLines?: boolean; +} +export interface ChartSeriesRef { + /** Series display name */ + name: string; + /** Cell range for series values (e.g. `Sheet1!$B$2:$B$5`) */ + valuesRange: string; + /** + * Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). + * Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. + */ + color?: string; + /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ + scatterXRange?: string; +} +/** Legend configuration (minimal) */ +export interface LegendOptions { + /** Force show (true) or hide (false). If undefined, auto: show only when multiple series */ + show?: boolean; + /** Legend position (defaults to 'right' if omitted) */ + position?: "right" | "left" | "top" | "bottom" | "topRight"; + /** Overlay the legend on the plot area (no space reservation) */ + overlay?: boolean; +} +export interface ChartOptions { + /** Chart type (defaults to 'column' if omitted) */ + type?: ChartType; + /** Chart title shown above plot area */ + title?: string; + /** Axis configuration (ignored for pie except title for completeness) */ + axis?: { + /** Category/X axis options */ + x?: AxisOptions; + /** Value/Y axis options */ + y?: AxisOptions; + }; + /** Width in EMUs */ + width?: number; + /** Height in EMUs */ + height?: number; + /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ + categoriesRange?: string; + /** Stacking mode for supported chart types (column, bar, line). 'stacked' for cumulative, 'percent' for 100% scaling. Undefined => no stacking */ + stacking?: "stacked" | "percent"; + /** Multi-series cell references */ + series?: ChartSeriesRef[]; + /** Legend configuration */ + legend?: LegendOptions; + /** Global data label toggles (applies to the whole chart). If any flag true a node is emitted. */ + dataLabels?: { + /** Show numerical value */ + showValue?: boolean; + /** Show category text (for non-scatter) */ + showCategory?: boolean; + /** Show percentage (mainly useful for pie/doughnut or percent stacked) */ + showPercent?: boolean; + /** Show series name (useful when multiple series and category/value alone is ambiguous) */ + showSeriesName?: boolean; + }; } /** * @module Excel/Util @@ -220,6 +426,48 @@ export declare class Util { hyperlink: string; }; } +/** + * Minimal Chart implementation (clustered column) required for Excel to render without repair. + * This produces 2 parts: + * 1) Drawing graphicFrame (returned by toXML for inclusion in `/xl/drawings/drawingN.xml`) + * 2) Chart part XML (returned by toChartSpaceXML for inclusion in `/xl/charts/chartN.xml`) + * Relationships: + * `drawingN.xml.rels` -> `../charts/chartN.xml` (Type chart) + */ +export declare class Chart extends Drawing { + relId: string | null; + index: number | null; + target: string | null; + options: ChartOptions; + constructor(options: ChartOptions); + /** Return relationship type for this drawing */ + getMediaType(): keyof typeof Util.schemas; + /** RelationshipManager calls this via Drawings */ + setRelationshipId(rId: string): void; + /** Drawing part representation (inside an anchor) */ + toXML(xmlDoc: XMLDOM): XMLNode; + /** Chart part XML: `/xl/charts/chartN.xml` */ + toChartSpaceXML(): XMLDOM; + /** Creates the graphicFrame container that goes inside an anchor in drawing part */ + private createGraphicFrame; + /** Create the primary chart node based on type and stacking */ + private _createPrimaryChartNode; + /** Build a node */ + private _createSeriesNode; + /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ + private _applySeriesColor; + /** Create legend node honoring position + overlay */ + private _createLegendNode; + /** Create a c:title node with minimal rich text required for Excel to render */ + private _createTitleNode; + /** Create a category axis (catAx) */ + private _createCategoryAxis; + /** Create a value axis (valAx) */ + private _createValueAxis; + private _nextAxisIdBase; + /** Resolve grouping value based on chart type and stacking */ + private _resolveGrouping; +} export type Relation = { [id: string]: { id: string; @@ -259,7 +507,7 @@ export declare class RelationshipManager { * @module Excel/Drawings */ export declare class Drawings { - drawings: (Drawing | Picture)[]; + drawings: Drawing[]; relations: RelationshipManager; id: string; /** @@ -294,144 +542,6 @@ export declare class SharedStrings { }; toXML(): XMLDOM; } -/** - * Excel Color in ARGB format, for color aren't transparent just use "FF" as prefix. - * For example if the color you want to add is a blue with HTML color "#0000FF", then the excel color we need to add is "FF0000FF" - * Online tool: https://www.myfixguide.com/color-converter/ - */ -export type ExcelColorStyle = string | { - theme: number; -}; -export interface ExcelAlignmentStyle { - horizontal?: "center" | "fill" | "general" | "justify" | "left" | "right"; - justifyLastLine?: boolean; - readingOrder?: string; - relativeIndent?: boolean; - shrinkToFit?: boolean; - textRotation?: string | number; - vertical?: "bottom" | "distributed" | "center" | "justify" | "top"; - wrapText?: boolean; -} -export type ExcelBorderLineStyle = "continuous" | "dash" | "dashDot" | "dashDotDot" | "dotted" | "double" | "lineStyleNone" | "medium" | "slantDashDot" | "thin" | "thick"; -export interface ExcelBorderStyle { - bottom?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - top?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - left?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - right?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - diagonal?: any; - outline?: boolean; - diagonalUp?: boolean; - diagonalDown?: boolean; -} -export interface ExcelColumn { - bestFit?: boolean; - collapsed?: boolean; - customWidth?: number; - hidden?: boolean; - max?: number; - min?: number; - outlineLevel?: number; - phonetic?: boolean; - style?: number; - width?: number; -} -export interface ExcelTableColumn { - name: string; - dataCellStyle?: any; - dataDxfId?: number; - headerRowCellStyle?: ExcelStyleInstruction; - headerRowDxfId?: number; - totalsRowCellStyle?: ExcelStyleInstruction; - totalsRowDxfId?: number; - totalsRowFunction?: any; - totalsRowLabel?: string; - columnFormula?: string; - columnFormulaIsArrayType?: boolean; - totalFormula?: string; - totalFormulaIsArrayType?: boolean; -} -export interface ExcelFillStyle { - type?: "gradient" | "pattern"; - patternType?: string; - degree?: number; - fgColor?: ExcelColorStyle; - start?: ExcelColorStyle; - end?: { - pureAt?: number; - color?: ExcelColorStyle; - }; -} -export interface ExcelFontStyle { - bold?: boolean; - color?: ExcelColorStyle; - fontName?: string; - italic?: boolean; - outline?: boolean; - size?: number; - shadow?: boolean; - strike?: boolean; - subscript?: boolean; - superscript?: boolean; - underline?: boolean | "single" | "double" | "singleAccounting" | "doubleAccounting"; -} -export interface ExcelMetadata { - type?: string; - style?: number; -} -export interface ExcelColumnMetadata { - value: any; - metadata?: ExcelMetadata; -} -export interface ExcelMargin { - top: number; - bottom: number; - left: number; - right: number; - header: number; - footer: number; -} -export interface ExcelSortState { - caseSensitive?: boolean; - dataRange?: any; - columnSort?: boolean; - sortDirection?: "ascending" | "descending"; - sortRange?: any; -} -/** Excel custom formatting that will be applied to a column */ -export interface ExcelStyleInstruction { - id?: number; - alignment?: ExcelAlignmentStyle; - border?: ExcelBorderStyle | number; - borderId?: number; - fill?: ExcelFillStyle | number; - fillId?: number; - font?: ExcelFontStyle | number; - fontId?: number; - format?: string | number; - height?: number; - numFmt?: string; - numFmtId?: number; - width?: number; - xfId?: number; - protection?: { - locked?: boolean; - hidden?: boolean; - }; - /** style id */ - style?: number; -} /** * @module Excel/StyleSheet */ @@ -949,6 +1059,7 @@ export declare class Workbook { sharedStrings: SharedStrings; relations: RelationshipManager; worksheets: Worksheet[]; + charts: Chart[]; tables: Table[]; drawings: Drawings[]; media: { @@ -961,6 +1072,7 @@ export declare class Workbook { getStyleSheet(): StyleSheet$1; addTable(table: Table): void; addDrawings(drawings: Drawings): void; + addChart(chart: Chart): void; /** * Set number of rows to repeat for this sheet. * diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 1ecbb4a..aec1578 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -1 +1,439 @@ -export class Chart {} +import type { ChartOptions } from '../../interfaces.js'; +import { Util } from '../Util.js'; +import type { XMLDOM, XMLNode } from '../XMLDOM.js'; +import { Drawing } from './Drawing.js'; + +/** + * Minimal Chart implementation (clustered column) required for Excel to render without repair. + * This produces 2 parts: + * 1) Drawing graphicFrame (returned by toXML for inclusion in `/xl/drawings/drawingN.xml`) + * 2) Chart part XML (returned by toChartSpaceXML for inclusion in `/xl/charts/chartN.xml`) + * Relationships: + * `drawingN.xml.rels` -> `../charts/chartN.xml` (Type chart) + */ +export class Chart extends Drawing { + relId: string | null = null; // relationship id from drawing rels + index: number | null = null; // 1-based index assigned by workbook + target: string | null = null; // relative target path (`../charts/chartN.xml`) + options: ChartOptions; + + constructor(options: ChartOptions) { + super(); + this.options = options; + } + + /** Return relationship type for this drawing */ + getMediaType(): keyof typeof Util.schemas { + return 'chart'; + } + + /** RelationshipManager calls this via Drawings */ + setRelationshipId(rId: string) { + this.relId = rId; + } + + /** Drawing part representation (inside an anchor) */ + toXML(xmlDoc: XMLDOM) { + return this.anchor.toXML(xmlDoc, this.createGraphicFrame(xmlDoc)); + } + + /** Chart part XML: `/xl/charts/chartN.xml` */ + toChartSpaceXML(): XMLDOM { + const doc = Util.createXmlDoc('http://schemas.openxmlformats.org/drawingml/2006/chart', 'c:chartSpace'); + const chartSpace = doc.documentElement; + chartSpace.setAttribute('xmlns:c', 'http://schemas.openxmlformats.org/drawingml/2006/chart'); + chartSpace.setAttribute('xmlns:a', Util.schemas.drawing); + chartSpace.setAttribute('xmlns:r', Util.schemas.relationships); + + const chart = Util.createElement(doc, 'c:chart'); + // Title (only if provided). `autoTitleDeleted` must be 0 or omitted when we set a title. + if (this.options.title) { + chart.appendChild(this._createTitleNode(doc, this.options.title)); + chart.appendChild(Util.createElement(doc, 'c:autoTitleDeleted', [['val', '0']])); + } else { + chart.appendChild(Util.createElement(doc, 'c:autoTitleDeleted', [['val', '1']])); + } + + const plotArea = Util.createElement(doc, 'c:plotArea'); + const axisBase = this._nextAxisIdBase(); + const axIdCat = axisBase + 1; + const axIdVal = axisBase + 2; + + // Default chart type (column) if caller omitted + const type = this.options.type || 'column'; + // Categories range (applies to every non-scatter series) + const categoriesRange = this.options.categoriesRange || ''; + const primaryChartNode = this._createPrimaryChartNode(doc, type, this.options.stacking); + // Series + const series = this.options.series || []; + series.forEach((s, idx) => { + primaryChartNode.appendChild(this._createSeriesNode(doc, s, idx, type, categoriesRange)); + }); + + // Data labels (chart-level). Placed inside the primary chart-type node. + const dLblsCfg = this.options.dataLabels; + if (dLblsCfg) { + // Always emit all four known toggles with explicit 0/1 to suppress Excel auto behavior. + const dLbls = Util.createElement(doc, 'c:dLbls'); + const valNode = (tag: string, enabled: boolean | undefined) => + dLbls.appendChild(Util.createElement(doc, tag, [['val', enabled === true ? '1' : '0']])); + valNode('c:showVal', dLblsCfg.showValue); + valNode('c:showCatName', dLblsCfg.showCategory); + valNode('c:showPercent', dLblsCfg.showPercent); + valNode('c:showSerName', dLblsCfg.showSeriesName); + primaryChartNode.appendChild(dLbls); + } + + // Axis IDs (except pie which has no axes) + if (type !== 'pie' && type !== 'doughnut') { + primaryChartNode.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); + } + plotArea.appendChild(primaryChartNode); + + if (type !== 'pie' && type !== 'doughnut') { + const xAxisOpts = this.options.axis?.x; + const yAxisOpts = this.options.axis?.y; + const xAxisTitle = xAxisOpts?.title; + const yAxisTitle = yAxisOpts?.title; + if (type === 'scatter') { + plotArea.appendChild(this._createValueAxis(doc, axIdCat, axIdVal, 'b', xAxisTitle, xAxisOpts)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle, yAxisOpts)); + } else { + plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, xAxisTitle, xAxisOpts)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle, yAxisOpts)); + } + } + + // Legend (auto show for >1 series unless overridden) + const legendOpts = this.options.legend; + const autoShouldShow = series.length > 1; + const effectiveShow = typeof legendOpts?.show === 'boolean' ? legendOpts.show : autoShouldShow; + if (effectiveShow) { + chart.appendChild(this._createLegendNode(doc, legendOpts)); + } + + chart.appendChild(plotArea); + chart.appendChild(Util.createElement(doc, 'c:plotVisOnly', [['val', '1']])); + chartSpace.appendChild(chart); + chartSpace.appendChild(Util.createElement(doc, 'c:printSettings')); + return doc; + } + + // -- private functions + + /** Creates the graphicFrame container that goes inside an anchor in drawing part */ + private createGraphicFrame(xmlDoc: XMLDOM) { + const graphicFrame = Util.createElement(xmlDoc, 'xdr:graphicFrame'); + const nvGraphicFramePr = Util.createElement(xmlDoc, 'xdr:nvGraphicFramePr'); + nvGraphicFramePr.appendChild( + Util.createElement(xmlDoc, 'xdr:cNvPr', [ + ['id', String(this.index || 1)], + ['name', this.options.title || 'Chart'], + ]), + ); + nvGraphicFramePr.appendChild(Util.createElement(xmlDoc, 'xdr:cNvGraphicFramePr')); + graphicFrame.appendChild(nvGraphicFramePr); + + // basic transform (off + ext) – values are arbitrary but required structure + const xfrm = Util.createElement(xmlDoc, 'xdr:xfrm'); + xfrm.appendChild( + Util.createElement(xmlDoc, 'a:off', [ + ['x', '0'], + ['y', '0'], + ]), + ); + xfrm.appendChild( + Util.createElement(xmlDoc, 'a:ext', [ + ['cx', String(this.options.width || 4000000)], + ['cy', String(this.options.height || 3000000)], + ]), + ); + graphicFrame.appendChild(xfrm); + + const graphic = Util.createElement(xmlDoc, 'a:graphic'); + const graphicData = Util.createElement(xmlDoc, 'a:graphicData', [['uri', 'http://schemas.openxmlformats.org/drawingml/2006/chart']]); + graphicData.appendChild( + Util.createElement(xmlDoc, 'c:chart', [ + ['xmlns:c', 'http://schemas.openxmlformats.org/drawingml/2006/chart'], + ['xmlns:r', Util.schemas.relationships], + ['r:id', this.relId || ''], + ]), + ); + graphic.appendChild(graphicData); + graphicFrame.appendChild(graphic); + + return graphicFrame; + } + + /** Create the primary chart node based on type and stacking */ + private _createPrimaryChartNode(doc: XMLDOM, type: string, stacking?: 'stacked' | 'percent'): XMLNode { + let node: XMLNode; + const groupingValue = this._resolveGrouping(type, stacking); + switch (type) { + case 'line': { + node = Util.createElement(doc, 'c:lineChart'); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', groupingValue]])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + case 'pie': { + node = Util.createElement(doc, 'c:pieChart'); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); + break; + } + case 'doughnut': { + node = Util.createElement(doc, 'c:doughnutChart'); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); + // Add a default holeSize (50%) to visualize doughnut; Excel defaults to 50 if absent but explicit for clarity + node.appendChild(Util.createElement(doc, 'c:holeSize', [['val', '50']])); + break; + } + case 'scatter': { + node = Util.createElement(doc, 'c:scatterChart'); + node.appendChild(Util.createElement(doc, 'c:scatterStyle', [['val', 'marker']])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + case 'bar': { + node = Util.createElement(doc, 'c:barChart'); + node.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'bar']])); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', groupingValue]])); + if (stacking) { + // Ensure stacked bars/columns align in same category slot + node.appendChild(Util.createElement(doc, 'c:overlap', [['val', '100']])); + } + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + case 'column': + default: { + node = Util.createElement(doc, 'c:barChart'); + node.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'col']])); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', groupingValue]])); + if (stacking) { + node.appendChild(Util.createElement(doc, 'c:overlap', [['val', '100']])); + } + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + } + return node; + } + + /** Build a node */ + private _createSeriesNode( + doc: XMLDOM, + s: { name: string; valuesRange: string; scatterXRange?: string; color?: string }, + idx: number, + type: string, + categoriesRange: string, + ): XMLNode { + const ser = Util.createElement(doc, 'c:ser'); + const idxStr = String(idx); + ser.appendChild(Util.createElement(doc, 'c:idx', [['val', idxStr]])); + ser.appendChild(Util.createElement(doc, 'c:order', [['val', idxStr]])); + + // Series title literal + const tx = Util.createElement(doc, 'c:tx'); + const txV = Util.createElement(doc, 'c:v'); + txV.appendChild(doc.createTextNode(s.name)); + tx.appendChild(txV); + ser.appendChild(tx); + + if (type === 'scatter') { + // xVal + const xVal = Util.createElement(doc, 'c:xVal'); + if (s.scatterXRange) { + const numRefX = Util.createElement(doc, 'c:numRef'); + const fNodeX = Util.createElement(doc, 'c:f'); + fNodeX.appendChild(doc.createTextNode(s.scatterXRange)); + numRefX.appendChild(fNodeX); + xVal.appendChild(numRefX); + } else { + const numLitX = Util.createElement(doc, 'c:numLit'); + numLitX.appendChild(Util.createElement(doc, 'c:ptCount', [['val', '0']])); + xVal.appendChild(numLitX); + } + ser.appendChild(xVal); + // yVal + const yVal = Util.createElement(doc, 'c:yVal'); + const numRefY = Util.createElement(doc, 'c:numRef'); + const fNodeY = Util.createElement(doc, 'c:f'); + fNodeY.appendChild(doc.createTextNode(s.valuesRange)); + numRefY.appendChild(fNodeY); + yVal.appendChild(numRefY); + ser.appendChild(yVal); + } else { + if (categoriesRange) { + const cat = Util.createElement(doc, 'c:cat'); + const strRef = Util.createElement(doc, 'c:strRef'); + const fNodeCat = Util.createElement(doc, 'c:f'); + fNodeCat.appendChild(doc.createTextNode(categoriesRange)); + strRef.appendChild(fNodeCat); + cat.appendChild(strRef); + ser.appendChild(cat); + } + if (s.valuesRange) { + const val = Util.createElement(doc, 'c:val'); + const numRef = Util.createElement(doc, 'c:numRef'); + const fNodeVal = Util.createElement(doc, 'c:f'); + fNodeVal.appendChild(doc.createTextNode(s.valuesRange)); + numRef.appendChild(fNodeVal); + val.appendChild(numRef); + ser.appendChild(val); + } + } + + // Optional per-series color + this._applySeriesColor(doc, ser, type, s.color); + return ser; + } + + /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ + private _applySeriesColor(doc: XMLDOM, serNode: XMLNode, type: string, color?: string) { + if (!color || typeof color !== 'string') return; + let hex = color.trim().replace(/^#/, '').toUpperCase(); + // Accept 6 (RGB) or 8 (ARGB) hex chars; strip leading alpha if present + if (/^[0-9A-F]{8}$/.test(hex)) { + hex = hex.slice(2); + } else if (!/^[0-9A-F]{6}$/.test(hex)) { + return; // invalid format; silently ignore + } + // Create spPr container + const spPr = Util.createElement(doc, 'c:spPr'); + if (type === 'line' || type === 'scatter') { + // For line/scatter charts define stroke color (ln) + const ln = Util.createElement(doc, 'a:ln'); + const solidFill = Util.createElement(doc, 'a:solidFill'); + solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); + ln.appendChild(solidFill); + spPr.appendChild(ln); + } else if (type !== 'pie' && type !== 'doughnut') { + // For column/bar (and future types) define a solid fill + const solidFill = Util.createElement(doc, 'a:solidFill'); + solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); + spPr.appendChild(solidFill); + } else { + // For pie/doughnut omit series-level color (Excel varies slice colors automatically) + return; + } + serNode.appendChild(spPr); + } + + /** Create legend node honoring position + overlay */ + private _createLegendNode(doc: XMLDOM, legendOpts?: { position?: string; overlay?: boolean }): XMLNode { + const legend = Util.createElement(doc, 'c:legend'); + const posMap: Record = { right: 'r', left: 'l', top: 't', bottom: 'b', topRight: 'tr' }; + const pos = posMap[legendOpts?.position || 'right'] || 'r'; + legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', pos]])); + legend.appendChild(Util.createElement(doc, 'c:layout')); + legend.appendChild(Util.createElement(doc, 'c:overlay', [['val', legendOpts?.overlay ? '1' : '0']])); + return legend; + } + + /** Create a c:title node with minimal rich text required for Excel to render */ + private _createTitleNode(doc: XMLDOM, text: string): XMLNode { + const title = Util.createElement(doc, 'c:title'); + const tx = Util.createElement(doc, 'c:tx'); + const rich = Util.createElement(doc, 'c:rich'); + rich.appendChild(Util.createElement(doc, 'a:bodyPr')); + rich.appendChild(Util.createElement(doc, 'a:lstStyle')); + const p = Util.createElement(doc, 'a:p'); + const r = Util.createElement(doc, 'a:r'); + const rPr = Util.createElement(doc, 'a:rPr', [['lang', 'en-US']]); + r.appendChild(rPr); + const t = Util.createElement(doc, 'a:t'); + t.appendChild(doc.createTextNode(text)); + r.appendChild(t); + p.appendChild(r); + p.appendChild(Util.createElement(doc, 'a:endParaRPr', [['lang', 'en-US']])); + rich.appendChild(p); + tx.appendChild(rich); + title.appendChild(tx); + title.appendChild(Util.createElement(doc, 'c:layout')); + title.appendChild(Util.createElement(doc, 'c:overlay', [['val', '0']])); + return title; + } + + /** Create a category axis (catAx) */ + private _createCategoryAxis(doc: XMLDOM, axId: number, crossAx: number, title?: string, opts?: { showGridLines?: boolean }): XMLNode { + const catAx = Util.createElement(doc, 'c:catAx'); + catAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axId)]])); + const scaling = Util.createElement(doc, 'c:scaling'); + scaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + catAx.appendChild(scaling); + catAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + catAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'b']])); + catAx.appendChild(Util.createElement(doc, 'c:tickLblPos', [['val', 'nextTo']])); + catAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(crossAx)]])); + catAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + if (opts?.showGridLines) { + catAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); + } + if (title) { + catAx.appendChild(this._createTitleNode(doc, title)); + } + return catAx; + } + + /** Create a value axis (valAx) */ + private _createValueAxis( + doc: XMLDOM, + axId: number, + crossAx: number, + pos: 'l' | 'b', + title?: string, + opts?: { minimum?: number; maximum?: number; showGridLines?: boolean }, + ): XMLNode { + const valAx = Util.createElement(doc, 'c:valAx'); + valAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axId)]])); + const scaling = Util.createElement(doc, 'c:scaling'); + scaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + if (typeof opts?.minimum === 'number') { + scaling.appendChild(Util.createElement(doc, 'c:min', [['val', String(opts.minimum)]])); + } + if (typeof opts?.maximum === 'number') { + scaling.appendChild(Util.createElement(doc, 'c:max', [['val', String(opts.maximum)]])); + } + valAx.appendChild(scaling); + valAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + valAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', pos]])); + valAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(crossAx)]])); + valAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + valAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); + if (opts?.showGridLines) { + valAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); + } + if (title) { + valAx.appendChild(this._createTitleNode(doc, title)); + } + return valAx; + } + + private _nextAxisIdBase(): number { + // Simple axis id base using index plus a constant offset + return (this.index || 1) * 1000; + } + + /** Resolve grouping value based on chart type and stacking */ + private _resolveGrouping(type: string, stacking?: 'stacked' | 'percent'): string { + if (type === 'pie' || type === 'doughnut') { + return 'clustered'; // required but cosmetic + } + if (type === 'line') { + if (stacking === 'stacked') return 'stacked'; + if (stacking === 'percent') return 'percentStacked'; + return 'standard'; + } + if (type === 'bar' || type === 'column') { + if (stacking === 'stacked') return 'stacked'; + if (stacking === 'percent') return 'percentStacked'; + return 'clustered'; + } + // scatter doesn't use grouping; still return default for structural consistency + return 'standard'; + } +} diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 2d9efa1..8148dd8 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -1,10 +1,795 @@ import { describe, expect, it } from 'vitest'; +import { Util } from '../../Util.js'; import { Chart } from '../Chart.js'; +function buildChart(opts: any) { + const chart = new Chart(opts); + // simulate workbook assigning index to make axis ids stable-ish + chart.index = 1; + const xml = chart.toChartSpaceXML().toString(); + return { chart, xml }; +} + describe('Chart', () => { - it('can be instantiated', () => { - const chart = new Chart(); - expect(chart).toBeInstanceOf(Chart); + it('emits barChart node for horizontal bar type (barDir bar)', () => { + const { xml } = buildChart({ + type: 'bar', + title: 'Bar Chart', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Line Chart', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const chart = new Chart({ + type: 'column', + title: 'Defaults', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 3; + chart.setRelationshipId('rId99'); + chart.createAnchor('twoCellAnchor', { from: { x: 1, y: 1, height: 1, width: 1 }, to: { x: 5, y: 20, height: 1, width: 1 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const drawingNode = chart.toXML(drawingDoc).toString(); + // Attribute order (cx/cy) isn't guaranteed; accept either order. + expect(drawingNode).toMatch(/ { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter Chart', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', scatterXRange: 'Sheet!$A$2:$A$4' }], + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Custom Title', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain('Custom Title'); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Line', + axis: { x: { title: 'Months' }, y: { title: 'Values' } }, + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + // Expect two axis title occurrences plus main chart title (3 total c:title nodes) + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(3); + expect(xml).toContain('Months'); + expect(xml).toContain('Values'); + }); + + it('does not include axis titles for pie even if provided', () => { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie', + axis: { x: { title: 'ShouldNotShow' }, y: { title: 'ShouldNotShow' } }, + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + // Should only have chart-level title + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(1); + expect(xml).not.toContain('ShouldNotShow'); + }); + + it('emits multiple series with correct idx/order', () => { + const { xml } = buildChart({ + type: 'column', + title: 'Bar', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + // Two c:ser nodes + const serCount = xml.split('').length - 1; + expect(serCount).toBe(2); + expect(xml).toContain(' { + const { xml } = buildChart({ + title: 'Implicit Column', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Single', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + }); + + it('includes legend when more than one series', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Multi', + series: [ + { name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'S2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(''); + }); + + it('generates no implicit series when only title provided', () => { + const { xml } = buildChart({ title: 'Fallback' }); + expect(xml).not.toContain(''); + }); + + it('scatter emits empty numLit xVal when scatterXRange missing', () => { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter No X Range', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + }); + expect(xml).toContain(''); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Overlay Check', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ type: 'line', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4' }); + // Only chart title autoDeleted present, no axis title nodes + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(0); + }); + + it('scatter axis titles render on both value axes when provided', () => { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter With Axis Titles', + axis: { x: { title: 'X Axis' }, y: { title: 'Y Axis' } }, + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', scatterXRange: 'Sheet!$A$2:$A$4' }], + }); + // Expect 3 title nodes: chart + x axis + y axis + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(3); + expect(xml).toContain('X Axis'); + expect(xml).toContain('Y Axis'); + }); + + it('getMediaType returns chart', () => { + const chart = new Chart({ + type: 'bar', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(chart.getMediaType()).toBe('chart'); + }); + + it('bar chart specific attributes present', () => { + const { xml } = buildChart({ + type: 'bar', + title: 'Bar Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Column Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Line Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'doughnut', + title: 'Doughnut Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', scatterXRange: 'S!$A$2:$A$4' }], + }); + expect(xml).toContain(' { + const chart1 = new Chart({ + type: 'line', + title: 'C1', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart1.index = 1; + const xml1 = chart1.toChartSpaceXML().toString(); + expect(xml1).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Bar Single X', + axis: { x: { title: 'Only X' } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(2); // chart + x axis + expect(xml).toContain('Only X'); + }); + + it('single yAxisTitle only adds chart + y axis title nodes', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Line Single Y', + axis: { y: { title: 'Only Y' } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(2); // chart + y axis + expect(xml).toContain('Only Y'); + }); + + it('custom width/height override graphicFrame ext', () => { + const chart = new Chart({ + type: 'column', + title: 'Sized', + width: 5000000, + height: 1000000, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 5; + chart.setRelationshipId('rId50'); + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 3, y: 10 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + expect(xml).toMatch(/ { + const { xml } = buildChart({ + type: 'line', + title: 'Legend Struct', + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4' }, + { name: 'S2', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(' { + const { xml } = buildChart({ title: 'Empty Series', series: [] }); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + }); + + it('scatter numLit ptCount is 0 when no categories provided', () => { + const chart = new Chart({ + type: 'scatter', + title: 'Zero Scatter', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + }); + chart.index = 3; + const xml = chart.toChartSpaceXML().toString(); + expect(xml).toContain(''); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Vis Only', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const chart = new Chart({ + type: 'column', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 7; + chart.setRelationshipId('rId707'); + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 2, y: 8 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + // Attribute order isn't guaranteed; accept either order. + expect(xml).toMatch(/ { + const chart = new Chart({ + type: 'line', + title: 'Has Rel', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 4; + chart.setRelationshipId('rId404'); + chart.createAnchor('twoCellAnchor', { from: { x: 1, y: 1 }, to: { x: 4, y: 12 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + // Ensure r:id attribute present pointing to relationship id + expect(xml).toMatch(/]*r:id="rId404"/); + }); + + it('axis IDs reflect index multiplier base for higher index value', () => { + const chart = new Chart({ + type: 'column', + title: 'Axis Base High', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 5; // expect 5001 & 5002 + const xml = chart.toChartSpaceXML().toString(); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Title Layout Overlay', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Verify both layout and overlay appear inside title block + const titleSegment = xml.match(/[\s\S]*?<\/c:title>/); + expect(titleSegment?.[0]).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Axis MinMax', + axis: { y: { minimum: 0, maximum: 500 } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Expect c:min and c:max under scaling + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Axis Max Only', + axis: { y: { maximum: 300 } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Colored Column', + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4', color: 'FFFF0000' }, + { name: 'S2', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + // Expect a:solidFill with srgbClr val="FF0000" (alpha stripped from ARGB FFFF0000) + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Colored Line', + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4', color: '80ABCDEF' }, // ARGB; expect ABCDEF + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'scatter', + title: 'Colored Scatter', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', scatterXRange: 'S!$A$2:$A$4', color: 'FF00FF00' }], + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Invalid Color', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', color: 'GARBAGE' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + }); + + it('does not emit series color styling for pie', () => { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie No Series Color', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', color: 'FF112233' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Pie chart series should not contain c:spPr produced by our color logic + expect(xml).not.toContain(''); + }); + + it('category axis renders majorGridlines when showGridLines true', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Cat Gridlines', + axis: { x: { showGridLines: true }, y: { showGridLines: true } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Expect two majorGridlines nodes (one for category axis, one for value axis) + const gridCount = xml.split(' { + const { xml } = buildChart({ + type: 'line', + title: 'No Gridlines', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(' { + const chart = new Chart({ + type: 'column', + title: 'No Rel', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 9; + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 3, y: 6 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + // r:id attribute present but empty string value + expect(xml).toMatch(/]*r:id=""/); + }); + + // ----------------- + // Stacking tests + // ----------------- + it('column stacked chart uses grouping stacked and overlap 100', () => { + const { xml } = buildChart({ + type: 'column', + stacking: 'stacked', + title: 'Column Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + stacking: 'percent', + title: 'Column % Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + stacking: 'stacked', + title: 'Bar Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + stacking: 'percent', + title: 'Bar % Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + stacking: 'stacked', + title: 'Line Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + stacking: 'percent', + title: 'Line % Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Force Legend', + legend: { show: true }, + series: [{ name: 'Only', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(''); + }); + + it('legend.show false hides legend even for multiple series', () => { + const { xml } = buildChart({ + type: 'column', + title: 'Hide Legend', + legend: { show: false }, + series: [ + { name: 'A', valuesRange: 'S!$B$2:$B$4' }, + { name: 'B', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + }); + + it('legend position maps to topRight', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Legend Position', + legend: { show: true, position: 'topRight' }, + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4' }, + { name: 'S2', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toMatch(/[\s\S]*? { + const { xml } = buildChart({ + type: 'bar', + title: 'Legend Overlay', + legend: { show: true, overlay: true }, + series: [ + { name: 'A', valuesRange: 'S!$B$2:$B$4' }, + { name: 'B', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + const legendSegment = xml.match(/[\s\S]*?<\/c:legend>/)?.[0]; + expect(legendSegment).toContain(' { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie Labels', + dataLabels: { showValue: true, showPercent: true }, + series: [{ name: 'S', valuesRange: 'S!$B$2:$B$5' }], + categoriesRange: 'S!$A$2:$A$5', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'No Labels', + dataLabels: {}, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // We now always emit for provided object, but with no child nodes since no keys specified. + expect(xml).toContain('[\s\S]*?<\/c:dLbls>/) ? xml : xml; // self-closing OK + expect(seg).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Series Name Labels', + dataLabels: { showSeriesName: true, showValue: false }, + series: [ + { name: 'Alpha', valuesRange: 'S!$B$2:$B$4' }, + { name: 'Beta', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + const dLblsSegment = xml.match(/[\s\S]*?<\/c:dLbls>/)?.[0] || xml; // fallback to whole xml + expect(dLblsSegment).toContain(' { @@ -90,4 +91,21 @@ describe('Drawings', () => { d.relations = { getRelationshipId: () => null, addRelation: () => 'rId1' } as any; expect(() => d.toXML()).not.toThrow(); }); + + test('toXML chart branch assigns relationship and appends XML', () => { + const d = new Drawings(); + const chart = new Chart({ + type: 'bar', + title: 'ChartRel', + series: [{ name: 'S1', valuesRange: 'Sheet!$A$1:$A$1' }], + categoriesRange: 'Sheet!$A$1:$A$1', + }); + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 2, y: 5 } }); + d.addDrawing(chart); + const xmlDoc = d.toXML(); + expect(chart.relId).toMatch(/^rId\d+$/); + const xmlStr = xmlDoc.toString(); + expect(xmlStr).toContain('ChartRel'); + expect(xmlStr).toContain(' { it('should initialize with default properties', () => { @@ -130,4 +131,50 @@ describe('Workbook', () => { delete (globalThis as any).window; }); }); + + describe('chart-related branches', () => { + it('addChart assigns index and target', () => { + const wb = new Workbook(); + const chart = new Chart({ + type: 'bar', + title: 'C1', + series: [{ name: 'S1', valuesRange: 'Sheet!$A$1:$A$1' }], + categoriesRange: 'Sheet!$A$1:$A$1', + }); + wb.addChart(chart); + expect(chart.index).toBe(1); + expect(chart.target).toBe('../charts/chart1.xml'); + }); + + it('_generateCorePaths adds chart XML and path', () => { + const wb = new Workbook(); + const chart = new Chart({ + type: 'line', + title: 'LineChart', + series: [{ name: 'S1', valuesRange: 'Sheet!$A$1:$A$1' }], + categoriesRange: 'Sheet!$A$1:$A$1', + }); + wb.addChart(chart); + const files: any = {}; + wb._generateCorePaths(files); + expect(files['/xl/charts/chart1.xml']).toBeTruthy(); + expect(Paths[chart.id]).toBe('/xl/charts/chart1.xml'); + }); + + it('generateFiles includes worksheet rel file and chart file', async () => { + const wb = new Workbook(); + const ws = wb.createWorksheet({ name: 'Data' }); + wb.addWorksheet(ws); + const chart = new Chart({ + type: 'pie', + title: 'PieChart', + series: [{ name: 'S1', valuesRange: 'Data!$A$1:$A$1' }], + categoriesRange: 'Data!$A$1:$A$1', + }); + wb.addChart(chart); + const files = await wb.generateFiles(); + expect(files['/xl/worksheets/_rels/sheet1.xml.rels']).toBeTruthy(); + expect(files['/xl/charts/chart1.xml']).toBeTruthy(); + }); + }); }); diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index d69edf4..4fd39af 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -4,6 +4,7 @@ * Online tool: https://www.myfixguide.com/color-converter/ */ export type ExcelColorStyle = string | { theme: number }; + export interface ExcelAlignmentStyle { horizontal?: 'center' | 'fill' | 'general' | 'justify' | 'left' | 'right'; justifyLastLine?: boolean; @@ -140,3 +141,81 @@ export interface ExcelStyleInstruction { /** style id */ style?: number; } + +// --------------------------- +// Chart related interfaces +// --------------------------- +export type ChartType = 'column' | 'bar' | 'line' | 'pie' | 'doughnut' | 'scatter'; + +/** Axis configuration options */ +export interface AxisOptions { + /** Axis title label */ + title?: string; + /** Explicit minimum value (value axis only; ignored for category axis unless future numeric category support) */ + minimum?: number; + /** Explicit maximum value (value axis only) */ + maximum?: number; + /** Show major gridlines */ + showGridLines?: boolean; +} + +export interface ChartSeriesRef { + /** Series display name */ + name: string; + /** Cell range for series values (e.g. `Sheet1!$B$2:$B$5`) */ + valuesRange: string; + /** + * Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). + * Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. + */ + color?: string; + /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ + scatterXRange?: string; +} + +/** Legend configuration (minimal) */ +export interface LegendOptions { + /** Force show (true) or hide (false). If undefined, auto: show only when multiple series */ + show?: boolean; + /** Legend position (defaults to 'right' if omitted) */ + position?: 'right' | 'left' | 'top' | 'bottom' | 'topRight'; + /** Overlay the legend on the plot area (no space reservation) */ + overlay?: boolean; +} + +export interface ChartOptions { + /** Chart type (defaults to 'column' if omitted) */ + type?: ChartType; + /** Chart title shown above plot area */ + title?: string; + /** Axis configuration (ignored for pie except title for completeness) */ + axis?: { + /** Category/X axis options */ + x?: AxisOptions; + /** Value/Y axis options */ + y?: AxisOptions; + }; + /** Width in EMUs */ + width?: number; + /** Height in EMUs */ + height?: number; + /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ + categoriesRange?: string; + /** Stacking mode for supported chart types (column, bar, line). 'stacked' for cumulative, 'percent' for 100% scaling. Undefined => no stacking */ + stacking?: 'stacked' | 'percent'; + /** Multi-series cell references */ + series?: ChartSeriesRef[]; + /** Legend configuration */ + legend?: LegendOptions; + /** Global data label toggles (applies to the whole chart). If any flag true a node is emitted. */ + dataLabels?: { + /** Show numerical value */ + showValue?: boolean; + /** Show category text (for non-scatter) */ + showCategory?: boolean; + /** Show percentage (mainly useful for pie/doughnut or percent stacked) */ + showPercent?: boolean; + /** Show series name (useful when multiple series and category/value alone is ambiguous) */ + showSeriesName?: boolean; + }; +} diff --git a/packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts b/packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts new file mode 100644 index 0000000..aabbf36 --- /dev/null +++ b/packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { htmlEscape } from '../escape.js'; + +describe('htmlEscape', () => { + it('should escape special HTML characters', () => { + expect(htmlEscape('&')).toBe('&'); + expect(htmlEscape('<')).toBe('<'); + expect(htmlEscape('>')).toBe('>'); + expect(htmlEscape('"')).toBe('"'); + expect(htmlEscape("'")).toBe('''); + }); + + it('should escape multiple special characters in a string', () => { + expect(htmlEscape('fred, barney, & pebbles')).toBe('fred, barney, & pebbles'); + expect(htmlEscape('')).toBe('<script>alert("XSS");</script>'); + }); + + it('should convert non-string inputs to strings', () => { + expect(htmlEscape(123 as any)).toBe('123'); + expect(htmlEscape(null as any)).toBe('null'); + expect(htmlEscape(undefined as any)).toBe('undefined'); + expect(htmlEscape(true as any)).toBe('true'); + }); + + it('should not modify strings without special characters', () => { + expect(htmlEscape('normal text')).toBe('normal text'); + expect(htmlEscape('')).toBe(''); + }); + + it('should handle mixed special and normal characters', () => { + expect(htmlEscape('Tom & Jerry < cartoon > "quote" \'test\'')).toBe( + 'Tom & Jerry < cartoon > "quote" 'test'', + ); + }); + + it('should work with repeated special characters', () => { + expect(htmlEscape('&&<<>>""\'\'')).toBe('&&<<>>""'''); + }); +}); diff --git a/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts b/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts index eaec560..fa0f412 100644 --- a/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts +++ b/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts @@ -34,6 +34,13 @@ describe('isPlainObject() method', () => { const output = isPlainObject(null); expect(output).toBeFalsy(); }); + + it('should return truthy when object has a null prototype', () => { + const obj = Object.create(null); + (obj as any).foo = 'bar'; + const output = isPlainObject(obj); + expect(output).toBeTruthy(); + }); }); describe('isString() method', () => {