From dadeee4bb8b7f1f5d1d31618ae2267133673f5c3 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 28 Apr 2026 19:29:41 +0100 Subject: [PATCH 1/2] feat(modules): promote xlsx to builtin module Signed-off-by: Simon Davies --- builtin-modules/CLAUDE.md | 25 +- builtin-modules/src/types/ha-modules.d.ts | 464 ++++ builtin-modules/src/xlsx.ts | 2475 +++++++++++++++++++++ builtin-modules/tsconfig.json | 1 + builtin-modules/xlsx.json | 41 + builtin-modules/xml-escape.json | 4 +- builtin-modules/zip-format.json | 8 +- skills/api-explorer/SKILL.md | 41 +- skills/data-processor/SKILL.md | 29 + skills/mcp-services/SKILL.md | 21 + skills/pdf-expert/SKILL.md | 40 +- skills/pptx-expert/SKILL.md | 13 +- skills/report-builder/SKILL.md | 32 +- skills/research-synthesiser/SKILL.md | 36 + skills/web-scraper/SKILL.md | 29 + skills/xlsx-expert/SKILL.md | 140 ++ tests/pattern-integrity.test.ts | 47 +- tests/sandbox-tool.test.ts | 399 ++++ 18 files changed, 3806 insertions(+), 39 deletions(-) create mode 100644 builtin-modules/src/xlsx.ts create mode 100644 builtin-modules/xlsx.json create mode 100644 skills/xlsx-expert/SKILL.md diff --git a/builtin-modules/CLAUDE.md b/builtin-modules/CLAUDE.md index 09e30ff..37769af 100644 --- a/builtin-modules/CLAUDE.md +++ b/builtin-modules/CLAUDE.md @@ -24,18 +24,19 @@ Each module should have a standard header comment: ## Current Modules -| Module | Purpose | -|--------|---------| -| `base64` | Base64 encode/decode for Uint8Array | -| `crc32` | CRC32 checksum calculation | -| `xml-escape` | XML entity escaping | -| `zip-format` | ZIP file creation | -| `str-bytes` | String/bytes conversion | -| `ooxml-core` | Core OOXML utilities | -| `pptx` | PowerPoint generation | -| `pptx-charts` | Chart support for PPTX | -| `pptx-tables` | Table support for PPTX | -| `shared-state` | Cross-handler state management | +| Module | Purpose | +| -------------- | ----------------------------------- | +| `base64` | Base64 encode/decode for Uint8Array | +| `crc32` | CRC32 checksum calculation | +| `xml-escape` | XML entity escaping | +| `zip-format` | ZIP file creation | +| `str-bytes` | String/bytes conversion | +| `ooxml-core` | Core OOXML utilities | +| `pptx` | PowerPoint generation | +| `pptx-charts` | Chart support for PPTX | +| `pptx-tables` | Table support for PPTX | +| `xlsx` | Excel workbook generation | +| `shared-state` | Cross-handler state management | ## Workflow diff --git a/builtin-modules/src/types/ha-modules.d.ts b/builtin-modules/src/types/ha-modules.d.ts index b7a27e5..9b485ad 100644 --- a/builtin-modules/src/types/ha-modules.d.ts +++ b/builtin-modules/src/types/ha-modules.d.ts @@ -5046,6 +5046,470 @@ declare module "ha:str-bytes" { export declare function concatBytes(...arrays: Uint8Array[]): Uint8Array; } +declare module "ha:xlsx" { + type CellValue = string | number | boolean | Date | null | undefined; + type RowData = readonly CellValue[] | Record; + type CellRefLike = string | { + row: number; + col: number; + }; + type HexColor = string; + type RelationshipId = string | null; + /** Excel border style for one side of a cell border. */ + export interface BorderSide { + style?: string; + color?: HexColor; + } + /** Excel border style, either one style for every side or per-side settings. */ + export type BorderSpec = string | { + left?: string | BorderSide | null; + right?: string | BorderSide | null; + top?: string | BorderSide | null; + bottom?: string | BorderSide | null; + }; + /** Cell style options accepted by setCell, addRow, addData, and table helpers. */ + export interface CellStyle { + /** Font size in points. Defaults to 11. */ + fontSize?: number; + /** Font family name. Defaults to Calibri. */ + fontFamily?: string; + /** Text colour as RGB hex, with or without leading #. */ + color?: HexColor; + bold?: boolean; + italic?: boolean; + underline?: boolean; + /** Solid fill colour as RGB hex, with or without leading #. */ + fill?: HexColor; + /** Border style string or per-side border specification. */ + border?: BorderSpec; + /** Excel number format code, e.g. "#,##0.00", "mm-dd-yy", "0%". */ + numFmt?: string; + /** Horizontal alignment. */ + align?: "left" | "center" | "right" | "justify" | "distributed" | string; + /** Vertical alignment. */ + valign?: "top" | "center" | "bottom" | "justify" | "distributed" | string; + wrapText?: boolean; + /** Formula text without leading =. If present, cell value is ignored. */ + formula?: string; + } + export type ChartType = "column" | "bar" | "line" | "area" | "pie" | "doughnut"; + export type ChartLegendPosition = "l" | "r" | "t" | "b"; + export interface ChartSeries { + name: string; + values: readonly number[]; + } + export interface ChartAnchor { + from?: string; + to?: string; + } + export interface ChartOptions { + type?: ChartType; + title?: string; + categories?: readonly string[]; + series?: readonly ChartSeries[]; + stacked?: boolean; + percentStacked?: boolean; + legend?: boolean; + legendPosition?: ChartLegendPosition; + dataLabels?: boolean; + holeSize?: number; + anchor?: ChartAnchor; + } + export type SparklineType = "line" | "column" | "winLoss"; + export interface SparklineOptions { + type?: SparklineType; + dataRange: string; + locationRange: string; + color?: HexColor; + negativeColor?: HexColor; + firstColor?: HexColor; + lastColor?: HexColor; + highColor?: HexColor; + lowColor?: HexColor; + markers?: boolean; + showHigh?: boolean; + showLow?: boolean; + showFirst?: boolean; + showLast?: boolean; + showNegative?: boolean; + lineWeight?: number; + } + export interface DataBarRule { + type: "dataBar"; + color?: HexColor; + } + export interface ColorScaleRule { + type: "colorScale"; + minColor?: HexColor; + midColor?: HexColor; + maxColor?: HexColor; + } + export interface IconSetRule { + type: "iconSet"; + iconSet?: string; + } + export interface CellIsRule { + type: "cellIs"; + operator?: string; + formula?: string | number; + formula2?: string | number; + style?: CellStyle; + } + export interface Top10Rule { + type: "top10"; + rank?: number; + bottom?: boolean; + percent?: boolean; + style?: CellStyle; + } + export interface AboveAverageRule { + type: "aboveAverage"; + below?: boolean; + style?: CellStyle; + } + export interface DuplicateValuesRule { + type: "duplicateValues"; + style?: CellStyle; + } + export type ConditionalFormatRule = DataBarRule | ColorScaleRule | IconSetRule | CellIsRule | Top10Rule | AboveAverageRule | DuplicateValuesRule; + export type DataValidationType = "list" | "whole" | "decimal" | "textLength" | "custom"; + export interface DataValidationOptions { + type?: DataValidationType; + values?: readonly string[]; + formula?: string; + operator?: string; + min?: number; + max?: number; + errorTitle?: string; + error?: string; + promptTitle?: string; + prompt?: string; + allowBlank?: boolean; + } + interface DataValidationEntry extends DataValidationOptions { + range: string; + } + export interface HyperlinkOptions { + display?: string; + tooltip?: string; + } + export type HyperlinkTarget = string | { + sheet?: string; + cell?: string; + }; + export interface ImageOptions { + from?: string; + to?: string; + } + export interface GroupOptions { + level?: number; + collapsed?: boolean; + } + export interface SheetProtectionOptions { + /** Legacy Excel XOR hash; deters casual edits only, not secure encryption. */ + password?: string; + allowSort?: boolean; + allowFilter?: boolean; + allowFormatCells?: boolean; + allowFormatColumns?: boolean; + allowFormatRows?: boolean; + allowInsertColumns?: boolean; + allowInsertRows?: boolean; + allowDeleteColumns?: boolean; + allowDeleteRows?: boolean; + } + export interface PageSetupOptions { + orientation?: "landscape" | "portrait"; + paperSize?: number; + fitToWidth?: number; + fitToHeight?: number; + scale?: number; + } + export interface PageMarginsOptions { + top?: number; + bottom?: number; + left?: number; + right?: number; + header?: number; + footer?: number; + } + export interface HeaderFooterOptions { + header?: string; + footer?: string; + } + export interface PivotValueSpec { + field?: string; + name?: string; + func?: "sum" | "count" | "average" | "min" | "max" | string; + label?: string; + } + export interface PivotTableOptions { + sourceRange: string; + targetCell?: string; + rows?: readonly string[]; + columns?: readonly string[]; + filters?: readonly string[]; + values?: readonly PivotValueSpec[]; + } + export interface PivotTableAddOptions extends PivotTableOptions { + sourceSheet: string | Sheet; + targetSheet: string | Sheet; + } + export interface TableToWorkbookOptions { + sheetName?: string; + headers?: readonly string[]; + data: readonly RowData[]; + headerStyle?: CellStyle; + columnWidths?: readonly number[]; + rowStyle?: CellStyle | ((rowIndex: number, row: RowData) => CellStyle); + } + export interface ExportResult { + path: string; + size: number; + } + interface ParsedCellRef { + col: number; + row: number; + } + interface CellEntry { + v: CellValue; + s: CellStyle | null; + } + interface AnchorPoint { + col: number; + row: number; + } + interface InternalAnchor { + from: AnchorPoint; + to: AnchorPoint; + } + interface ChartSpec { + type: ChartType; + title: string | null; + categories: string[]; + series: ChartSeries[]; + stacked: boolean; + percentStacked: boolean; + legend: boolean; + legendPosition: ChartLegendPosition; + dataLabels: boolean; + holeSize: number; + _anchor: InternalAnchor; + } + interface CondFmtEntry { + range: string; + rule: ConditionalFormatRule; + } + interface HyperlinkEntry { + ref: string; + url?: string | null; + location?: string | null; + display: string; + tooltip: string | null; + internal: boolean; + } + interface ImageEntry { + data: Uint8Array; + ext: ImageExt; + _anchor: InternalAnchor; + } + interface ColumnOutlineEntry { + from: number; + to: number; + level: number; + } + interface NamedRangeEntry { + name: string; + ref: string; + localSheetId?: number; + } + type ImageExt = "png" | "jpeg" | "gif"; + interface FontEntry { + sz: number; + nm: string; + c: string | null; + b: boolean; + i: boolean; + u: boolean; + } + interface FillEntry { + t: "none" | "gray125" | "solid"; + c?: string | null; + } + interface ParsedBorderSide { + s: string; + c: string | null; + } + interface BorderEntry { + l: ParsedBorderSide | null; + r: ParsedBorderSide | null; + t: ParsedBorderSide | null; + b: ParsedBorderSide | null; + } + interface CellXfEntry { + fi: number; + fli: number; + bi: number; + ni: number; + aF: 0 | 1; + aFl: 0 | 1; + aB: 0 | 1; + aN: 0 | 1; + aA: 0 | 1; + hA: string | null; + vA: string | null; + wr: 0 | 1; + } + interface DxfOptions { + bold?: boolean; + italic?: boolean; + color?: string; + fill?: string; + } + interface PivotFieldSpec { + name: string; + idx: number; + shared: boolean; + unique?: CellValue[]; + valueMap?: Map; + min?: number; + max?: number; + } + interface InternalPivotValueSpec { + fld: number; + func: string; + label: string; + } + /** Convert column letter(s) to 1-based number. A=1, Z=26, AA=27 */ + export declare function colToNum(letters: string): number; + /** Convert 1-based column number to letter(s). 1=A, 26=Z, 27=AA */ + export declare function numToCol(num: number): string; + /** Parse "A1" cell reference to { col, row } (both 1-based). */ + export declare function parseCellRef(ref: string): ParsedCellRef; + /** Build "A1" reference from 1-based row and col. */ + export declare function cellRef(row: number, col: number): string; + /** Convert JS Date to Excel serial date number. */ + export declare function dateToSerial(d: Date): number; + export declare class Sheet { + name: string; + index: number; + _rows: Map>; + _colW: Map; + _rowH: Map; + _merges: string[]; + _fzR: number; + _fzC: number; + _af: string | null; + _charts: ChartSpec[]; + _sparkGroups: SparklineOptions[]; + _condFmts: CondFmtEntry[]; + _dataVals: DataValidationEntry[]; + _tabColor: string | null; + _hyperlinks: HyperlinkEntry[]; + _images: ImageEntry[]; + _rowOutline: Map; + _colOutline: ColumnOutlineEntry[]; + _protection: SheetProtectionOptions | null; + _printArea: string | null; + _pageSetup: PageSetupOptions | null | undefined; + _pageMargins: PageMarginsOptions | null | undefined; + _headerFooter: HeaderFooterOptions | null | undefined; + constructor(name: string, idx: number); + setCell(ref: CellRefLike, value: CellValue, style?: CellStyle | null): this; + setColumnWidth(colRef: number | string, width: number): this; + setRowHeight(row: number, height: number): this; + mergeCells(from: string, to: string): this; + freezeRows(n: number): this; + freezeColumns(n: number): this; + setAutoFilter(range: string): this; + addRow(rowNum: number, values: readonly CellValue[], style?: CellStyle | null): this; + addData(data: readonly (readonly CellValue[])[], startRef?: CellRefLike, style?: CellStyle | ((rowIndex: number, colIndex: number, value: CellValue) => CellStyle)): this; + getCellValue(ref: CellRefLike): CellValue; + addChart(opts: ChartOptions): this; + addSparklines(opts: SparklineOptions): this; + addConditionalFormat(range: string, rule: ConditionalFormatRule): this; + addDataValidation(range: string, opts: DataValidationOptions): this; + setTabColor(color: string): this; + addHyperlink(ref: string, target: HyperlinkTarget, opts?: HyperlinkOptions): this; + addImage(data: Uint8Array, opts?: ImageOptions): this; + groupRows(from: number, to: number, opts?: GroupOptions): this; + groupColumns(from: number | string, to: number | string, opts?: GroupOptions): this; + protect(opts?: SheetProtectionOptions): this; + setPrintArea(range: string): this; + setPageSetup(opts?: PageSetupOptions): this; + setPageMargins(opts?: PageMarginsOptions): this; + setHeaderFooter(opts?: HeaderFooterOptions): this; + } + export declare class StyleMgr { + _nf: { + id: number; + fc: string; + }[]; + _nfNext: number; + _fonts: FontEntry[]; + _fills: FillEntry[]; + _borders: BorderEntry[]; + _xfs: CellXfEntry[]; + _xfMap: Map; + _dxfs: DxfOptions[]; + _defaultXf(): CellXfEntry; + _fontIdx(f: FontEntry): number; + _fillIdx(f: FillEntry): number; + _borderIdx(b: BorderEntry): number; + _nfId(fmt?: string): number; + _parseBdr(b?: BorderSpec): BorderEntry; + resolve(opts?: CellStyle | null): number; + addDxf(opts?: DxfOptions): number; + toXml(): string; + } + export declare class PivotConfig { + id: number; + srcSheet: Sheet; + tgtSheet: Sheet; + sourceRange: string; + targetCell: string; + headers: string[]; + dataRows: CellValue[][]; + rowIdxs: number[]; + colIdxs: number[]; + filterIdxs: number[]; + valSpecs: InternalPivotValueSpec[]; + fields: PivotFieldSpec[]; + constructor(id: number, opts: PivotTableOptions, srcSheet: Sheet, tgtSheet: Sheet); + _preCompute(): void; + cacheDefXml(): string; + cacheRecXml(): string; + tableXml(): string; + } + export declare class Workbook { + _sheets: Sheet[]; + _sm: StyleMgr; + _sst: string[]; + _sstMap: Map; + _pivots: PivotConfig[]; + _namedRanges: NamedRangeEntry[]; + _globalImages: ImageEntry[]; + addSheet(name?: string): Sheet; + addPivotTable(opts: PivotTableAddOptions): this; + addNamedRange(name: string, ref: string, sheetName?: string): this; + _addStr(s: string): number; + build(): Uint8Array; + _ctXml(totalCharts: number, sheetChartMap: Map, imageExts: Set): string; + _wbXml(): string; + _wbRels(): string; + _wsXml(sh: Sheet, drawingRId: RelationshipId, isFirst: boolean, hlRids: number[]): string; + _cXml(ref: string, v: CellValue, si: number, style: CellStyle | null): string; + _sstXml(): string; + } + /** Create a new empty workbook. */ + export declare function createWorkbook(): Workbook; + /** Build and write workbook to an .xlsx file. */ + export declare function exportToFile(wb: Workbook, path: string, writeFn: (path: string, bytes: Uint8Array) => void): ExportResult; + /** Convenience: create a workbook with a single formatted table. */ + export declare function tableToWorkbook(opts: TableToWorkbookOptions): Workbook; + export {}; +} + declare module "ha:xml-escape" { /** * Escape a string for use as XML text content. diff --git a/builtin-modules/src/xlsx.ts b/builtin-modules/src/xlsx.ts new file mode 100644 index 0000000..2ea583e --- /dev/null +++ b/builtin-modules/src/xlsx.ts @@ -0,0 +1,2475 @@ +// @module xlsx +// @description Excel XLSX builder — cells, styles, formulas, merges, freeze, filter, pivots, charts, sparklines, conditional formatting, validation, hyperlinks, images, grouping, protection, print settings, named ranges, tab colors +// @created 2026-04-28T00:00:00.000Z +// @modified 2026-04-28T00:00:00.000Z +// @mutable false +// @author system + +import { escapeXml as _escXml } from "ha:xml-escape"; +import { createZip } from "ha:zip-format"; + +const escapeXml = _escXml; + +type CellValue = string | number | boolean | Date | null | undefined; +type RowData = readonly CellValue[] | Record; +type CellRefLike = string | { row: number; col: number }; +type HexColor = string; + +type RelationshipId = string | null; +type ZipEntry = { name: string; data: string | Uint8Array }; + +/** Excel border style for one side of a cell border. */ +export interface BorderSide { + style?: string; + color?: HexColor; +} + +/** Excel border style, either one style for every side or per-side settings. */ +export type BorderSpec = + | string + | { + left?: string | BorderSide | null; + right?: string | BorderSide | null; + top?: string | BorderSide | null; + bottom?: string | BorderSide | null; + }; + +/** Cell style options accepted by setCell, addRow, addData, and table helpers. */ +export interface CellStyle { + /** Font size in points. Defaults to 11. */ + fontSize?: number; + /** Font family name. Defaults to Calibri. */ + fontFamily?: string; + /** Text colour as RGB hex, with or without leading #. */ + color?: HexColor; + bold?: boolean; + italic?: boolean; + underline?: boolean; + /** Solid fill colour as RGB hex, with or without leading #. */ + fill?: HexColor; + /** Border style string or per-side border specification. */ + border?: BorderSpec; + /** Excel number format code, e.g. "#,##0.00", "mm-dd-yy", "0%". */ + numFmt?: string; + /** Horizontal alignment. */ + align?: "left" | "center" | "right" | "justify" | "distributed" | string; + /** Vertical alignment. */ + valign?: "top" | "center" | "bottom" | "justify" | "distributed" | string; + wrapText?: boolean; + /** Formula text without leading =. If present, cell value is ignored. */ + formula?: string; +} + +export type ChartType = "column" | "bar" | "line" | "area" | "pie" | "doughnut"; +export type ChartLegendPosition = "l" | "r" | "t" | "b"; + +export interface ChartSeries { + name: string; + values: readonly number[]; +} + +export interface ChartAnchor { + from?: string; + to?: string; +} + +export interface ChartOptions { + type?: ChartType; + title?: string; + categories?: readonly string[]; + series?: readonly ChartSeries[]; + stacked?: boolean; + percentStacked?: boolean; + legend?: boolean; + legendPosition?: ChartLegendPosition; + dataLabels?: boolean; + holeSize?: number; + anchor?: ChartAnchor; +} + +export type SparklineType = "line" | "column" | "winLoss"; + +export interface SparklineOptions { + type?: SparklineType; + dataRange: string; + locationRange: string; + color?: HexColor; + negativeColor?: HexColor; + firstColor?: HexColor; + lastColor?: HexColor; + highColor?: HexColor; + lowColor?: HexColor; + markers?: boolean; + showHigh?: boolean; + showLow?: boolean; + showFirst?: boolean; + showLast?: boolean; + showNegative?: boolean; + lineWeight?: number; +} + +export interface DataBarRule { + type: "dataBar"; + color?: HexColor; +} + +export interface ColorScaleRule { + type: "colorScale"; + minColor?: HexColor; + midColor?: HexColor; + maxColor?: HexColor; +} + +export interface IconSetRule { + type: "iconSet"; + iconSet?: string; +} + +export interface CellIsRule { + type: "cellIs"; + operator?: string; + formula?: string | number; + formula2?: string | number; + style?: CellStyle; +} + +export interface Top10Rule { + type: "top10"; + rank?: number; + bottom?: boolean; + percent?: boolean; + style?: CellStyle; +} + +export interface AboveAverageRule { + type: "aboveAverage"; + below?: boolean; + style?: CellStyle; +} + +export interface DuplicateValuesRule { + type: "duplicateValues"; + style?: CellStyle; +} + +export type ConditionalFormatRule = + | DataBarRule + | ColorScaleRule + | IconSetRule + | CellIsRule + | Top10Rule + | AboveAverageRule + | DuplicateValuesRule; + +export type DataValidationType = + | "list" + | "whole" + | "decimal" + | "textLength" + | "custom"; + +export interface DataValidationOptions { + type?: DataValidationType; + values?: readonly string[]; + formula?: string; + operator?: string; + min?: number; + max?: number; + errorTitle?: string; + error?: string; + promptTitle?: string; + prompt?: string; + allowBlank?: boolean; +} + +interface DataValidationEntry extends DataValidationOptions { + range: string; +} + +export interface HyperlinkOptions { + display?: string; + tooltip?: string; +} + +export type HyperlinkTarget = string | { sheet?: string; cell?: string }; + +export interface ImageOptions { + from?: string; + to?: string; +} + +export interface GroupOptions { + level?: number; + collapsed?: boolean; +} + +export interface SheetProtectionOptions { + /** Legacy Excel XOR hash; deters casual edits only, not secure encryption. */ + password?: string; + allowSort?: boolean; + allowFilter?: boolean; + allowFormatCells?: boolean; + allowFormatColumns?: boolean; + allowFormatRows?: boolean; + allowInsertColumns?: boolean; + allowInsertRows?: boolean; + allowDeleteColumns?: boolean; + allowDeleteRows?: boolean; +} + +export interface PageSetupOptions { + orientation?: "landscape" | "portrait"; + paperSize?: number; + fitToWidth?: number; + fitToHeight?: number; + scale?: number; +} + +export interface PageMarginsOptions { + top?: number; + bottom?: number; + left?: number; + right?: number; + header?: number; + footer?: number; +} + +export interface HeaderFooterOptions { + header?: string; + footer?: string; +} + +export interface PivotValueSpec { + field?: string; + name?: string; + func?: "sum" | "count" | "average" | "min" | "max" | string; + label?: string; +} + +export interface PivotTableOptions { + sourceRange: string; + targetCell?: string; + rows?: readonly string[]; + columns?: readonly string[]; + filters?: readonly string[]; + values?: readonly PivotValueSpec[]; +} + +export interface PivotTableAddOptions extends PivotTableOptions { + sourceSheet: string | Sheet; + targetSheet: string | Sheet; +} + +export interface TableToWorkbookOptions { + sheetName?: string; + headers?: readonly string[]; + data: readonly RowData[]; + headerStyle?: CellStyle; + columnWidths?: readonly number[]; + rowStyle?: CellStyle | ((rowIndex: number, row: RowData) => CellStyle); +} + +export interface ExportResult { + path: string; + size: number; +} + +interface ParsedCellRef { + col: number; + row: number; +} + +interface CellEntry { + v: CellValue; + s: CellStyle | null; +} + +interface AnchorPoint { + col: number; + row: number; +} + +interface InternalAnchor { + from: AnchorPoint; + to: AnchorPoint; +} + +interface ChartSpec { + type: ChartType; + title: string | null; + categories: string[]; + series: ChartSeries[]; + stacked: boolean; + percentStacked: boolean; + legend: boolean; + legendPosition: ChartLegendPosition; + dataLabels: boolean; + holeSize: number; + _anchor: InternalAnchor; +} + +interface CondFmtEntry { + range: string; + rule: ConditionalFormatRule; +} + +interface HyperlinkEntry { + ref: string; + url?: string | null; + location?: string | null; + display: string; + tooltip: string | null; + internal: boolean; +} + +interface ImageEntry { + data: Uint8Array; + ext: ImageExt; + _anchor: InternalAnchor; +} + +interface ColumnOutlineEntry { + from: number; + to: number; + level: number; +} + +interface NamedRangeEntry { + name: string; + ref: string; + localSheetId?: number; +} + +type ImageExt = "png" | "jpeg" | "gif"; + +interface FontEntry { + sz: number; + nm: string; + c: string | null; + b: boolean; + i: boolean; + u: boolean; +} + +interface FillEntry { + t: "none" | "gray125" | "solid"; + c?: string | null; +} + +interface ParsedBorderSide { + s: string; + c: string | null; +} + +interface BorderEntry { + l: ParsedBorderSide | null; + r: ParsedBorderSide | null; + t: ParsedBorderSide | null; + b: ParsedBorderSide | null; +} + +interface CellXfEntry { + fi: number; + fli: number; + bi: number; + ni: number; + aF: 0 | 1; + aFl: 0 | 1; + aB: 0 | 1; + aN: 0 | 1; + aA: 0 | 1; + hA: string | null; + vA: string | null; + wr: 0 | 1; +} + +interface DxfOptions { + bold?: boolean; + italic?: boolean; + color?: string; + fill?: string; +} + +interface PivotFieldSpec { + name: string; + idx: number; + shared: boolean; + unique?: CellValue[]; + valueMap?: Map; + min?: number; + max?: number; +} + +interface InternalPivotValueSpec { + fld: number; + func: string; + label: string; +} + +const X = '\n'; +const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +const NR = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +const NP = "http://schemas.openxmlformats.org/package/2006/relationships"; +const NC = "http://schemas.openxmlformats.org/package/2006/content-types"; +const RD = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"; +const RW = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"; +const RS = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"; +const RT = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"; +const RPT = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable"; +const RPR = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords"; +const RPC = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition"; +const RDR = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"; +const RCH = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"; +const RHL = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; +const RIM = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; +const COLORS = [ + "4472C4", + "ED7D31", + "A5A5A5", + "FFC000", + "5B9BD5", + "70AD47", + "264478", + "9B57A0", + "636363", + "EB7D3C", +]; +const BUILTIN_FMTS: Record = { + General: 0, + "0": 1, + "0.00": 2, + "#,##0": 3, + "#,##0.00": 4, + "0%": 9, + "0.00%": 10, + "mm-dd-yy": 14, + "d-mmm-yy": 15, + "d-mmm": 16, + "mmm-yy": 17, + "h:mm AM/PM": 18, + "h:mm:ss AM/PM": 19, + "h:mm": 20, + "h:mm:ss": 21, + "m/d/yy h:mm": 22, + "#,##0 ;(#,##0)": 37, + "#,##0 ;[Red](#,##0)": 38, + "#,##0.00;(#,##0.00)": 39, + "#,##0.00;[Red](#,##0.00)": 40, + "mm:ss": 45, + "[h]:mm:ss": 46, + "mmss.0": 47, + "##0.0E+0": 48, + "@": 49, +}; +const CONTENT_TYPES: Record = { + png: "image/png", + jpeg: "image/jpeg", + jpg: "image/jpeg", + gif: "image/gif", +}; + +function escapeAttr(s: unknown): string { + return escapeXml(String(s)); +} + +function strip(c: string | null | undefined): string { + return c && c.charAt(0) === "#" ? c.slice(1) : c || ""; +} + +function quoteSheet(name: string): string { + if (/[^A-Za-z0-9_]/.test(name)) return "'" + name.replace(/'/g, "''") + "'"; + return name; +} + +function hashPassword(pw: string): string { + let h = 0; + for (let i = pw.length - 1; i >= 0; i--) { + h = ((h >> 14) & 0x01) | ((h << 1) & 0x7fff); + h ^= pw.charCodeAt(i); + } + h = ((h >> 14) & 0x01) | ((h << 1) & 0x7fff); + h ^= pw.length; + h ^= 0xce4b; + return h.toString(16).toUpperCase().padStart(4, "0"); +} + +function detectImageType(data: Uint8Array): ImageExt { + if ( + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 + ) + return "png"; + if (data[0] === 0xff && data[1] === 0xd8) return "jpeg"; + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) return "gif"; + return "png"; +} + +/** Convert column letter(s) to 1-based number. A=1, Z=26, AA=27 */ +export function colToNum(letters: string): number { + let n = 0; + for (let i = 0; i < letters.length; i++) + n = n * 26 + (letters.charCodeAt(i) - 64); + return n; +} + +/** Convert 1-based column number to letter(s). 1=A, 26=Z, 27=AA */ +export function numToCol(num: number): string { + let s = ""; + while (num > 0) { + num--; + s = String.fromCharCode(65 + (num % 26)) + s; + num = Math.floor(num / 26); + } + return s; +} + +/** Parse "A1" cell reference to { col, row } (both 1-based). */ +export function parseCellRef(ref: string): ParsedCellRef { + const m = ref.match(/^([A-Z]+)(\d+)$/); + if (!m) throw new Error("Invalid cell ref: " + ref); + return { col: colToNum(m[1]!), row: parseInt(m[2]!, 10) }; +} + +/** Build "A1" reference from 1-based row and col. */ +export function cellRef(row: number, col: number): string { + return numToCol(col) + row; +} + +/** Convert JS Date to Excel serial date number. */ +export function dateToSerial(d: Date): number { + const epoch = new Date(1899, 11, 30); + return (d.getTime() - epoch.getTime()) / 86400000; +} + +export class Sheet { + name: string; + index: number; + _rows: Map>; + _colW: Map; + _rowH: Map; + _merges: string[]; + _fzR: number; + _fzC: number; + _af: string | null; + _charts: ChartSpec[]; + _sparkGroups: SparklineOptions[]; + _condFmts: CondFmtEntry[]; + _dataVals: DataValidationEntry[]; + _tabColor: string | null; + _hyperlinks: HyperlinkEntry[]; + _images: ImageEntry[]; + _rowOutline: Map; + _colOutline: ColumnOutlineEntry[]; + _protection: SheetProtectionOptions | null; + _printArea: string | null; + _pageSetup: PageSetupOptions | null | undefined; + _pageMargins: PageMarginsOptions | null | undefined; + _headerFooter: HeaderFooterOptions | null | undefined; + + constructor(name: string, idx: number) { + this.name = name; + this.index = idx; + this._rows = new Map(); + this._colW = new Map(); + this._rowH = new Map(); + this._merges = []; + this._fzR = 0; + this._fzC = 0; + this._af = null; + this._charts = []; + this._sparkGroups = []; + this._condFmts = []; + this._dataVals = []; + this._tabColor = null; + this._hyperlinks = []; + this._images = []; + this._rowOutline = new Map(); + this._colOutline = []; + this._protection = null; + this._printArea = null; + this._pageSetup = null; + this._pageMargins = null; + this._headerFooter = null; + } + + setCell(ref: CellRefLike, value: CellValue, style?: CellStyle | null): this { + const { row, col } = typeof ref === "string" ? parseCellRef(ref) : ref; + if (!this._rows.has(row)) this._rows.set(row, new Map()); + this._rows.get(row)!.set(col, { v: value, s: style || null }); + return this; + } + + setColumnWidth(colRef: number | string, width: number): this { + this._colW.set( + typeof colRef === "number" ? colRef : colToNum(colRef), + width, + ); + return this; + } + + setRowHeight(row: number, height: number): this { + this._rowH.set(row, height); + return this; + } + + mergeCells(from: string, to: string): this { + this._merges.push(from + ":" + to); + return this; + } + + freezeRows(n: number): this { + this._fzR = n; + return this; + } + + freezeColumns(n: number): this { + this._fzC = n; + return this; + } + + setAutoFilter(range: string): this { + this._af = range; + return this; + } + + addRow( + rowNum: number, + values: readonly CellValue[], + style?: CellStyle | null, + ): this { + for (let c = 0; c < values.length; c++) + this.setCell({ row: rowNum, col: c + 1 }, values[c], style || null); + return this; + } + + addData( + data: readonly (readonly CellValue[])[], + startRef?: CellRefLike, + style?: + | CellStyle + | ((rowIndex: number, colIndex: number, value: CellValue) => CellStyle), + ): this { + const s = startRef + ? typeof startRef === "string" + ? parseCellRef(startRef) + : startRef + : { row: 1, col: 1 }; + for (let r = 0; r < data.length; r++) { + for (let c = 0; c < data[r]!.length; c++) { + const st = + typeof style === "function" + ? style(r, c, data[r]![c]) + : style || null; + this.setCell({ row: s.row + r, col: s.col + c }, data[r]![c], st); + } + } + return this; + } + + getCellValue(ref: CellRefLike): CellValue { + const { row, col } = typeof ref === "string" ? parseCellRef(ref) : ref; + const r = this._rows.get(row); + if (!r) return undefined; + const c = r.get(col); + return c ? c.v : undefined; + } + + addChart(opts: ChartOptions): this { + const anc = opts.anchor || {}; + const from = anc.from ? parseCellRef(anc.from) : { col: 6, row: 2 }; + const to = anc.to + ? parseCellRef(anc.to) + : { col: from.col + 7, row: from.row + 13 }; + this._charts.push({ + type: opts.type || "column", + title: opts.title || null, + categories: [...(opts.categories || [])], + series: [...(opts.series || [])], + stacked: !!opts.stacked, + percentStacked: !!opts.percentStacked, + legend: opts.legend !== false, + legendPosition: opts.legendPosition || "r", + dataLabels: !!opts.dataLabels, + holeSize: opts.holeSize || 50, + _anchor: { + from: { col: from.col - 1, row: from.row - 1 }, + to: { col: to.col - 1, row: to.row - 1 }, + }, + }); + return this; + } + + addSparklines(opts: SparklineOptions): this { + this._sparkGroups.push(opts); + return this; + } + + addConditionalFormat(range: string, rule: ConditionalFormatRule): this { + this._condFmts.push({ range, rule }); + return this; + } + + addDataValidation(range: string, opts: DataValidationOptions): this { + this._dataVals.push({ range, ...opts }); + return this; + } + + setTabColor(color: string): this { + this._tabColor = strip(color); + return this; + } + + addHyperlink( + ref: string, + target: HyperlinkTarget, + opts?: HyperlinkOptions, + ): this { + const o = opts || {}; + const display = o.display || target; + if (typeof target === "string") { + this._hyperlinks.push({ + ref, + url: target, + display: typeof display === "string" ? display : target, + tooltip: o.tooltip || null, + internal: false, + }); + } else { + this._hyperlinks.push({ + ref, + location: + (target.sheet ? quoteSheet(target.sheet) + "!" : "") + + (target.cell || "A1"), + display: + typeof display === "string" + ? display + : target.sheet || target.cell || "A1", + tooltip: o.tooltip || null, + internal: true, + }); + } + return this; + } + + addImage(data: Uint8Array, opts?: ImageOptions): this { + const anc = opts || {}; + const from = anc.from ? parseCellRef(anc.from) : { col: 1, row: 1 }; + const to = anc.to + ? parseCellRef(anc.to) + : { col: from.col + 4, row: from.row + 8 }; + this._images.push({ + data, + ext: detectImageType(data), + _anchor: { + from: { col: from.col - 1, row: from.row - 1 }, + to: { col: to.col - 1, row: to.row - 1 }, + }, + }); + return this; + } + + groupRows(from: number, to: number, opts?: GroupOptions): this { + const o = opts || {}; + const lvl = o.level || 1; + for (let r = from; r <= to; r++) { + const cur = this._rowOutline.get(r) || 0; + this._rowOutline.set(r, Math.max(cur, lvl)); + } + return this; + } + + groupColumns( + from: number | string, + to: number | string, + opts?: GroupOptions, + ): this { + const o = opts || {}; + const f = typeof from === "string" ? colToNum(from) : from; + const t = typeof to === "string" ? colToNum(to) : to; + this._colOutline.push({ from: f, to: t, level: o.level || 1 }); + return this; + } + + protect(opts?: SheetProtectionOptions): this { + this._protection = opts || {}; + return this; + } + + setPrintArea(range: string): this { + this._printArea = range; + return this; + } + + setPageSetup(opts?: PageSetupOptions): this { + this._pageSetup = opts; + return this; + } + + setPageMargins(opts?: PageMarginsOptions): this { + this._pageMargins = opts; + return this; + } + + setHeaderFooter(opts?: HeaderFooterOptions): this { + this._headerFooter = opts; + return this; + } +} + +export class StyleMgr { + _nf: { id: number; fc: string }[] = []; + _nfNext = 164; + _fonts: FontEntry[] = [ + { sz: 11, nm: "Calibri", c: null, b: false, i: false, u: false }, + ]; + _fills: FillEntry[] = [{ t: "none" }, { t: "gray125" }]; + _borders: BorderEntry[] = [{ l: null, r: null, t: null, b: null }]; + _xfs: CellXfEntry[] = [this._defaultXf()]; + _xfMap: Map = new Map([ + [JSON.stringify(this._defaultXf()), 0], + ]); + _dxfs: DxfOptions[] = []; + + _defaultXf(): CellXfEntry { + return { + fi: 0, + fli: 0, + bi: 0, + ni: 0, + aF: 0, + aFl: 0, + aB: 0, + aN: 0, + aA: 0, + hA: null, + vA: null, + wr: 0, + }; + } + + _fontIdx(f: FontEntry): number { + for (let i = 0; i < this._fonts.length; i++) { + const e = this._fonts[i]!; + if ( + e.sz === f.sz && + e.nm === f.nm && + e.c === f.c && + e.b === f.b && + e.i === f.i && + e.u === f.u + ) + return i; + } + this._fonts.push(f); + return this._fonts.length - 1; + } + + _fillIdx(f: FillEntry): number { + for (let i = 0; i < this._fills.length; i++) { + const e = this._fills[i]!; + if (e.t === f.t && e.c === f.c) return i; + } + this._fills.push(f); + return this._fills.length - 1; + } + + _borderIdx(b: BorderEntry): number { + const k = JSON.stringify(b); + for (let i = 0; i < this._borders.length; i++) + if (JSON.stringify(this._borders[i]) === k) return i; + this._borders.push(b); + return this._borders.length - 1; + } + + _nfId(fmt?: string): number { + if (!fmt) return 0; + if (fmt in BUILTIN_FMTS) return BUILTIN_FMTS[fmt]!; + for (const nf of this._nf) if (nf.fc === fmt) return nf.id; + const id = this._nfNext++; + this._nf.push({ id, fc: fmt }); + return id; + } + + _parseBdr(b?: BorderSpec): BorderEntry { + if (!b) return { l: null, r: null, t: null, b: null }; + const p = (v?: string | BorderSide | null): ParsedBorderSide | null => { + if (!v) return null; + if (typeof v === "string") return { s: v, c: null }; + return { s: v.style || "thin", c: v.color ? strip(v.color) : null }; + }; + if (typeof b === "string") { + const x = p(b); + return { l: x, r: x, t: x, b: x }; + } + return { l: p(b.left), r: p(b.right), t: p(b.top), b: p(b.bottom) }; + } + + resolve(opts?: CellStyle | null): number { + if (!opts) return 0; + const fi = this._fontIdx({ + sz: opts.fontSize || 11, + nm: opts.fontFamily || "Calibri", + c: opts.color ? strip(opts.color) : null, + b: !!opts.bold, + i: !!opts.italic, + u: !!opts.underline, + }); + const fli = opts.fill + ? this._fillIdx({ t: "solid", c: strip(opts.fill) }) + : 0; + const bi = this._borderIdx(this._parseBdr(opts.border)); + const ni = this._nfId(opts.numFmt); + const xf: CellXfEntry = { + fi, + fli, + bi, + ni, + aF: fi ? 1 : 0, + aFl: fli ? 1 : 0, + aB: bi ? 1 : 0, + aN: ni ? 1 : 0, + aA: opts.align || opts.valign || opts.wrapText ? 1 : 0, + hA: opts.align || null, + vA: opts.valign || null, + wr: opts.wrapText ? 1 : 0, + }; + const k = JSON.stringify(xf); + if (this._xfMap.has(k)) return this._xfMap.get(k)!; + this._xfs.push(xf); + const idx = this._xfs.length - 1; + this._xfMap.set(k, idx); + return idx; + } + + addDxf(opts?: DxfOptions): number { + this._dxfs.push(opts || {}); + return this._dxfs.length - 1; + } + + toXml(): string { + let x = X + ''; + if (this._nf.length) { + x += ''; + for (const n of this._nf) + x += + ''; + x += ""; + } + x += ''; + for (const f of this._fonts) { + x += ""; + if (f.b) x += ""; + if (f.i) x += ""; + if (f.u) x += ""; + x += ''; + if (f.c) x += ''; + x += ''; + x += ""; + } + x += ''; + for (const f of this._fills) { + if (f.t === "none") x += ''; + else if (f.t === "gray125") + x += ''; + else + x += + ''; + } + x += ''; + for (const bd of this._borders) { + x += ""; + const tags = { l: "left", r: "right", t: "top", b: "bottom" } as const; + for (const s of ["l", "r", "t", "b"] as const) { + const tag = tags[s]; + const side = bd[s]; + if (side) { + x += "<" + tag + ' style="' + side.s + '">'; + if (side.c) x += ''; + x += ""; + } else x += "<" + tag + "/>"; + } + x += ""; + } + x += + ''; + x += ''; + for (const xf of this._xfs) { + let a = + 'numFmtId="' + + xf.ni + + '" fontId="' + + xf.fi + + '" fillId="' + + xf.fli + + '" borderId="' + + xf.bi + + '"'; + if (xf.aF) a += ' applyFont="1"'; + if (xf.aFl) a += ' applyFill="1"'; + if (xf.aB) a += ' applyBorder="1"'; + if (xf.aN) a += ' applyNumberFormat="1"'; + if (xf.aA) { + a += ' applyAlignment="1"'; + x += ""; + } + x += + ''; + if (this._dxfs.length > 0) { + x += ''; + for (const d of this._dxfs) { + x += ""; + if (d.bold || d.italic || d.color) { + x += ""; + if (d.bold) x += ""; + if (d.italic) x += ""; + if (d.color) x += ''; + x += ""; + } + if (d.fill) + x += + ''; + x += ""; + } + x += ""; + } + return x + ""; + } +} + +export class PivotConfig { + id: number; + srcSheet: Sheet; + tgtSheet: Sheet; + sourceRange: string; + targetCell: string; + headers: string[]; + dataRows: CellValue[][]; + rowIdxs: number[]; + colIdxs: number[]; + filterIdxs: number[]; + valSpecs: InternalPivotValueSpec[]; + fields: PivotFieldSpec[]; + + constructor( + id: number, + opts: PivotTableOptions, + srcSheet: Sheet, + tgtSheet: Sheet, + ) { + this.id = id; + this.srcSheet = srcSheet; + this.tgtSheet = tgtSheet; + this.sourceRange = opts.sourceRange; + this.targetCell = opts.targetCell || "A3"; + const [fromRef, toRef] = opts.sourceRange.split(":"); + const from = parseCellRef(fromRef!); + const to = parseCellRef(toRef!); + this.headers = []; + for (let c = from.col; c <= to.col; c++) { + const v = srcSheet.getCellValue({ row: from.row, col: c }); + this.headers.push(v != null ? String(v) : "Column" + c); + } + this.dataRows = []; + for (let r = from.row + 1; r <= to.row; r++) { + const row: CellValue[] = []; + for (let c = from.col; c <= to.col; c++) { + const v = srcSheet.getCellValue({ row: r, col: c }); + row.push(v != null ? v : null); + } + this.dataRows.push(row); + } + const fm: Record = {}; + this.headers.forEach((h, i) => (fm[h] = i)); + const resolve = (name: string | undefined, label: string): number => { + if (!name) throw new Error(label + " field not found: " + name); + const i = fm[name]; + if (i === undefined) throw new Error(label + " field not found: " + name); + return i; + }; + this.rowIdxs = [...(opts.rows || [])].map((n) => resolve(n, "Row")); + this.colIdxs = [...(opts.columns || [])].map((n) => resolve(n, "Column")); + this.filterIdxs = [...(opts.filters || [])].map((n) => + resolve(n, "Filter"), + ); + this.valSpecs = [...(opts.values || [])].map((v) => { + const name = v.field || v.name; + const fld = resolve(name, "Value"); + const func = v.func || "sum"; + const cap = func.charAt(0).toUpperCase() + func.slice(1); + return { fld, func, label: v.label || cap + " of " + name }; + }); + const axisSet = new Set([ + ...this.rowIdxs, + ...this.colIdxs, + ...this.filterIdxs, + ]); + this.fields = this.headers.map((name, idx) => { + const vals = this.dataRows.map((r) => r[idx]); + const isAxis = axisSet.has(idx); + const allNum = vals.every((v) => v === null || typeof v === "number"); + if (isAxis || !allNum) { + const unique: CellValue[] = []; + const seen = new Map(); + for (const v of vals) { + const k = v === null ? "\x00null" : String(v); + if (!seen.has(k)) { + seen.set(k, unique.length); + unique.push(v); + } + } + return { name, idx, shared: true, unique, valueMap: seen }; + } + const nums = vals.filter((v): v is number => typeof v === "number"); + return { + name, + idx, + shared: false, + min: nums.length ? Math.min(...nums) : 0, + max: nums.length ? Math.max(...nums) : 0, + }; + }); + this._preCompute(); + } + + _preCompute(): void { + const tgt = parseCellRef(this.targetCell); + const nFR = this.filterIdxs.length; + const startRow = tgt.row + (nFR > 0 ? nFR + 1 : 0); + const startCol = tgt.col; + if (this.rowIdxs.length > 0) + for (let ri = 0; ri < this.rowIdxs.length; ri++) + this.tgtSheet.setCell( + { row: startRow, col: startCol + ri }, + this.fields[this.rowIdxs[ri]!]!.name, + ); + for (let vi = 0; vi < this.valSpecs.length; vi++) + this.tgtSheet.setCell( + { + row: startRow, + col: startCol + Math.max(this.rowIdxs.length, 1) + vi, + }, + this.valSpecs[vi]!.label, + ); + if (this.rowIdxs.length > 0) { + const rowField = this.fields[this.rowIdxs[0]!]!; + let r = startRow + 1; + const grand = this.valSpecs.map(() => ({ + sum: 0, + count: 0, + min: 0, + max: 0, + hasValue: false, + })); + for (let ui = 0; ui < (rowField.unique || []).length; ui++) { + const uVal = rowField.unique![ui]; + this.tgtSheet.setCell({ row: r, col: startCol }, uVal); + for (let vi = 0; vi < this.valSpecs.length; vi++) { + const vs = this.valSpecs[vi]!; + const grandValue = grand[vi]!; + let agg = 0; + let cnt = 0; + for (const dRow of this.dataRows) { + if ( + dRow[this.rowIdxs[0]!] === uVal || + String(dRow[this.rowIdxs[0]!]) === String(uVal) + ) { + const rawVal = dRow[vs.fld]; + const val = typeof rawVal === "number" ? rawVal : 0; + if (vs.func === "count") { + cnt++; + grandValue.count++; + } else if (vs.func === "min") { + agg = cnt === 0 ? val : Math.min(agg, val); + cnt++; + grandValue.min = grandValue.hasValue + ? Math.min(grandValue.min, val) + : val; + grandValue.hasValue = true; + } else if (vs.func === "max") { + agg = cnt === 0 ? val : Math.max(agg, val); + cnt++; + grandValue.max = grandValue.hasValue + ? Math.max(grandValue.max, val) + : val; + grandValue.hasValue = true; + } else { + agg += val; + cnt++; + grandValue.sum += val; + grandValue.count++; + } + } + } + const result = + vs.func === "count" + ? cnt + : vs.func === "average" && cnt > 0 + ? agg / cnt + : agg; + this.tgtSheet.setCell( + { row: r, col: startCol + Math.max(this.rowIdxs.length, 1) + vi }, + result, + ); + } + r++; + } + this.tgtSheet.setCell({ row: r, col: startCol }, "Grand Total"); + for (let vi = 0; vi < this.valSpecs.length; vi++) { + const vs = this.valSpecs[vi]!; + const grandValue = grand[vi]!; + let g = grandValue.sum; + if (vs.func === "count") g = grandValue.count; + else if (vs.func === "average" && grandValue.count > 0) + g = grandValue.sum / grandValue.count; + else if (vs.func === "min") + g = grandValue.hasValue ? grandValue.min : 0; + else if (vs.func === "max") + g = grandValue.hasValue ? grandValue.max : 0; + this.tgtSheet.setCell( + { row: r, col: startCol + Math.max(this.rowIdxs.length, 1) + vi }, + g, + ); + } + } + } + + cacheDefXml(): string { + let x = + X + + ''; + x += + ''; + x += ''; + for (const f of this.fields) { + x += ''; + if (f.shared) { + x += ''; + for (const v of f.unique || []) { + if (v === null) x += ""; + else if (typeof v === "number") x += ''; + else x += ''; + } + x += ""; + } else + x += + ''; + x += ""; + } + return x + ""; + } + + cacheRecXml(): string { + let x = + X + + ''; + for (const row of this.dataRows) { + x += ""; + for (let i = 0; i < this.fields.length; i++) { + const f = this.fields[i]!; + const v = row[i]; + if (f.shared) { + const k = v === null ? "\x00null" : String(v); + x += ''; + } else { + if (v === null) x += ""; + else x += ''; + } + } + x += ""; + } + return x + ""; + } + + tableXml(): string { + const tgt = parseCellRef(this.targetCell); + const nRL = Math.max(this.rowIdxs.length, 1); + const nFR = this.filterIdxs.length; + const locTop = tgt.row + (nFR > 0 ? nFR + 1 : 0); + const locLeft = tgt.col; + const nDataRows = + this.rowIdxs.length > 0 + ? (this.fields[this.rowIdxs[0]!]!.unique || []).length + : this.dataRows.length; + const nDataCols = Math.max(this.valSpecs.length, 1); + const hasColFields = this.colIdxs.length > 0 || this.valSpecs.length > 1; + const locRef = + cellRef(locTop, locLeft) + + ":" + + cellRef(locTop + 1 + nDataRows, locLeft + nRL + nDataCols - 1); + let x = + X + + ''; + x += + ' 0) x += ' rowPageCount="' + nFR + '" colPageCount="1"'; + x += "/>"; + x += ''; + for (let i = 0; i < this.fields.length; i++) { + const f = this.fields[i]!; + const isR = this.rowIdxs.includes(i); + const isC = this.colIdxs.includes(i); + const isF = this.filterIdxs.includes(i); + const isV = this.valSpecs.some((v) => v.fld === i); + if (isR || isC || isF) { + const axis = isR ? "axisRow" : isC ? "axisCol" : "axisPage"; + x += + ''; + for (let j = 0; j < (f.unique || []).length; j++) + x += ''; + x += ''; + } else if (isV) x += ''; + else x += ''; + } + x += ""; + if (this.rowIdxs.length > 0) { + x += ''; + for (const ri of this.rowIdxs) x += ''; + x += ""; + const nItems = (this.fields[this.rowIdxs[0]!]!.unique || []).length; + x += ''; + for (let j = 0; j < nItems; j++) x += ''; + x += ''; + } + if (hasColFields) { + const cfs = [...this.colIdxs]; + if (this.valSpecs.length > 1) cfs.push(-2); + x += ''; + for (const cf of cfs) x += ''; + x += ''; + } else x += ''; + if (this.filterIdxs.length > 0) { + x += ''; + for (const fi of this.filterIdxs) + x += ''; + x += ""; + } + x += ''; + for (const vs of this.valSpecs) { + x += + ''; + return x + ""; + } +} + +function buildChartXml(ch: ChartSpec, sheetName: string): string { + const type = ch.type, + nCat = ch.categories.length, + isPie = type === "pie" || type === "doughnut", + qn = quoteSheet(sheetName); + let chartTag: string; + let closeTag: string; + if (type === "column" || type === "bar") { + chartTag = + ''; + closeTag = ""; + } else if (type === "line") { + chartTag = + ''; + closeTag = ""; + } else if (type === "area") { + chartTag = + ''; + closeTag = ""; + } else if (type === "pie") { + chartTag = ""; + closeTag = ""; + } else { + chartTag = ""; + closeTag = ""; + } + chartTag += ''; + let s = ""; + for (let si = 0; si < ch.series.length; si++) { + const ser = ch.series[si]!; + const vl = numToCol(si + 2); + s += + '' + + qn + + "!$" + + vl + + '$1' + + escapeXml(ser.name) + + ""; + if (!isPie) + s += + ''; + if (ch.dataLabels) + s += + ''; + s += + "" + + qn + + "!$A$2:$A$" + + (nCat + 1) + + ''; + for (let ci = 0; ci < nCat; ci++) + s += + '' + + escapeXml(String(ch.categories[ci])) + + ""; + s += + "" + + qn + + "!$" + + vl + + "$2:$" + + vl + + "$" + + (nCat + 1) + + 'General'; + for (let ci = 0; ci < nCat; ci++) + s += + '' + + (ser.values[ci] ?? 0) + + ""; + s += ""; + } + if (ch.dataLabels) + s += + ''; + if (type === "doughnut") s += ''; + const ax1 = 468642094, + ax2 = 468642096; + if (!isPie) s += ''; + let ax = ""; + if (!isPie) { + const cp = type === "bar" ? "l" : "b", + vp = type === "bar" ? "b" : "l"; + ax = + ''; + } + let tt = ""; + if (ch.title) + tt = + '' + + escapeXml(ch.title) + + ''; + const lg = ch.legend + ? '' + : ""; + return ( + X + + '' + + tt + + "" + + chartTag + + s + + closeTag + + ax + + "" + + lg + + '' + ); +} + +function buildDrawingXml( + charts: readonly ChartSpec[], + images: readonly ImageEntry[], + chartRIdStart: number, + imageRIdStart: number, +): string { + let xml = + X + + ''; + let nextId = 2; + for (let i = 0; i < charts.length; i++) { + const anc = charts[i]!._anchor; + xml += + "" + + anc.from.col + + "0" + + anc.from.row + + "0" + + anc.to.col + + "0" + + anc.to.row + + '0'; + } + for (let i = 0; i < images.length; i++) { + const anc = images[i]!._anchor; + xml += + '' + + anc.from.col + + "0" + + anc.from.row + + "0" + + anc.to.col + + "0" + + anc.to.row + + '0'; + } + return xml + ""; +} + +function buildCondFmtXml( + cfList: readonly CondFmtEntry[], + sm: StyleMgr, +): string { + let x = ""; + let pr = 1; + for (const cf of cfList) { + x += ''; + const r = cf.rule; + if (r.type === "dataBar") + x += + ''; + else if (r.type === "colorScale") { + x += + ''; + if (r.midColor) x += ''; + x += + ''; + if (r.midColor) x += ''; + x += + ''; + } else if (r.type === "iconSet") { + const is = r.iconSet || "3TrafficLights1"; + const n = parseInt(is.charAt(0), 10) || 3; + x += + ''; + for (let i = 0; i < n; i++) + x += ''; + x += ""; + } else if (r.type === "cellIs") { + const di = sm.addDxf(r.style || {}); + x += + '' + + escapeXml(String(r.formula)) + + ""; + if (r.formula2) + x += "" + escapeXml(String(r.formula2)) + ""; + x += ""; + } else if (r.type === "top10") { + const di = sm.addDxf(r.style || {}); + x += + ''; + } + x += ""; + } + return x; +} + +function buildDataValXml(dvList: readonly DataValidationEntry[]): string { + if (!dvList.length) return ""; + let x = ''; + for (const dv of dvList) { + x += + ''; + else if (dv.formula) + x += "" + escapeXml(dv.formula) + ""; + } else if (dv.type === "custom") + x += "" + escapeXml(dv.formula || "") + ""; + else { + if (dv.min !== undefined) x += "" + dv.min + ""; + if (dv.max !== undefined) x += "" + dv.max + ""; + } + x += ""; + } + return x + ""; +} + +function buildSparklineXml( + sparkGroups: readonly SparklineOptions[], + sheetName: string, +): string { + if (!sparkGroups.length) return ""; + const qn = quoteSheet(sheetName); + let x = + ''; + for (const sg of sparkGroups) { + const type = sg.type || "line"; + const clr = strip(sg.color || "#4472C4"); + const negClr = strip(sg.negativeColor || "#ED7D31"); + x += "'; + if (sg.firstColor) + x += ''; + if (sg.lastColor) + x += ''; + if (sg.highColor) + x += ''; + if (sg.lowColor) x += ''; + x += ""; + const dRef = sg.dataRange.split(":"); + const lRef = sg.locationRange.split(":"); + const dFrom = parseCellRef(dRef[0]!); + const dTo = parseCellRef(dRef[1] || dRef[0]!); + const lFrom = parseCellRef(lRef[0]!); + const lTo = parseCellRef(lRef[1] || lRef[0]!); + const n = lTo.row - lFrom.row + 1; + for (let i = 0; i < n; i++) { + const dr = dFrom.row + i; + x += + "" + + qn + + "!" + + cellRef(dr, dFrom.col) + + ":" + + cellRef(dr, dTo.col) + + "" + + cellRef(lFrom.row + i, lFrom.col) + + ""; + } + x += ""; + } + return x + ""; +} + +export class Workbook { + _sheets: Sheet[] = []; + _sm = new StyleMgr(); + _sst: string[] = []; + _sstMap = new Map(); + _pivots: PivotConfig[] = []; + _namedRanges: NamedRangeEntry[] = []; + _globalImages: ImageEntry[] = []; + + addSheet(name?: string): Sheet { + const s = new Sheet( + name || "Sheet" + (this._sheets.length + 1), + this._sheets.length + 1, + ); + this._sheets.push(s); + return s; + } + + addPivotTable(opts: PivotTableAddOptions): this { + const src = + typeof opts.sourceSheet === "string" + ? this._sheets.find((s) => s.name === opts.sourceSheet) + : opts.sourceSheet; + const tgt = + typeof opts.targetSheet === "string" + ? this._sheets.find((s) => s.name === opts.targetSheet) + : opts.targetSheet; + if (!src) throw new Error("Source sheet not found"); + if (!tgt) throw new Error("Target sheet not found"); + this._pivots.push(new PivotConfig(this._pivots.length, opts, src, tgt)); + return this; + } + + addNamedRange(name: string, ref: string, sheetName?: string): this { + if (sheetName) ref = quoteSheet(sheetName) + "!" + ref; + this._namedRanges.push({ name, ref }); + return this; + } + + _addStr(s: string): number { + if (this._sstMap.has(s)) return this._sstMap.get(s)!; + const i = this._sst.length; + this._sst.push(s); + this._sstMap.set(s, i); + return i; + } + + build(): Uint8Array { + if (!this._sheets.length) this.addSheet(); + const entries: ZipEntry[] = []; + let chartIdx = 0; + const sheetChartMap = new Map(); + let globalImgIdx = 0; + const sheetImgMap = new Map(); + const imageExts = new Set(); + for (const sh of this._sheets) { + if (sh._charts.length > 0) { + const indices: number[] = []; + for (let c = 0; c < sh._charts.length; c++) indices.push(chartIdx++); + sheetChartMap.set(sh, indices); + } + if (sh._images.length > 0) { + const indices: number[] = []; + for (let im = 0; im < sh._images.length; im++) { + indices.push(globalImgIdx++); + imageExts.add(sh._images[im]!.ext); + } + sheetImgMap.set(sh, indices); + } + } + entries.push({ + name: "[Content_Types].xml", + data: this._ctXml(chartIdx, sheetChartMap, imageExts), + }); + entries.push({ + name: "_rels/.rels", + data: + X + + '', + }); + entries.push({ name: "xl/workbook.xml", data: this._wbXml() }); + entries.push({ name: "xl/_rels/workbook.xml.rels", data: this._wbRels() }); + for (let i = 0; i < this._sheets.length; i++) { + const sh = this._sheets[i]!; + const pvIdxs: number[] = []; + for (let p = 0; p < this._pivots.length; p++) + if (this._pivots[p]!.tgtSheet === sh) pvIdxs.push(p); + const hasCharts = sheetChartMap.has(sh); + const hasImages = sheetImgMap.has(sh); + const hasDrawing = hasCharts || hasImages; + const extHyperlinks = sh._hyperlinks.filter((h) => !h.internal); + let shRid = 1; + const pvRids = pvIdxs.map(() => shRid++); + const drawingRId = hasDrawing ? "rId" + shRid++ : null; + const hlRids = extHyperlinks.map(() => shRid++); + void pvRids; + entries.push({ + name: "xl/worksheets/sheet" + (i + 1) + ".xml", + data: this._wsXml(sh, drawingRId, i === 0, hlRids), + }); + if (pvIdxs.length > 0 || hasDrawing || extHyperlinks.length > 0) { + let r = X + ''; + let rid = 1; + for (const pi of pvIdxs) + r += + ''; + if (hasDrawing) + r += + ''; + for (const hl of extHyperlinks) + r += + ''; + r += ""; + entries.push({ + name: "xl/worksheets/_rels/sheet" + sh.index + ".xml.rels", + data: r, + }); + } + } + entries.push({ name: "xl/styles.xml", data: this._sm.toXml() }); + entries.push({ name: "xl/sharedStrings.xml", data: this._sstXml() }); + for (let p = 0; p < this._pivots.length; p++) { + const pv = this._pivots[p]!; + entries.push({ + name: "xl/pivotCache/pivotCacheDefinition" + (p + 1) + ".xml", + data: pv.cacheDefXml(), + }); + entries.push({ + name: "xl/pivotCache/pivotCacheRecords" + (p + 1) + ".xml", + data: pv.cacheRecXml(), + }); + entries.push({ + name: "xl/pivotTables/pivotTable" + (p + 1) + ".xml", + data: pv.tableXml(), + }); + entries.push({ + name: + "xl/pivotCache/_rels/pivotCacheDefinition" + (p + 1) + ".xml.rels", + data: + X + + '', + }); + entries.push({ + name: "xl/pivotTables/_rels/pivotTable" + (p + 1) + ".xml.rels", + data: + X + + '', + }); + } + for (const sh of this._sheets) { + const hasCharts = sheetChartMap.has(sh); + const hasImages = sheetImgMap.has(sh); + if (!hasCharts && !hasImages) continue; + const chartIndices = sheetChartMap.get(sh) || []; + const imgIndices = sheetImgMap.get(sh) || []; + let drRid = 1; + entries.push({ + name: "xl/drawings/drawing" + sh.index + ".xml", + data: buildDrawingXml( + sh._charts, + sh._images, + drRid, + drRid + chartIndices.length, + ), + }); + let dr = X + ''; + for (let c = 0; c < chartIndices.length; c++) + dr += + ''; + for (let im = 0; im < imgIndices.length; im++) + dr += + ''; + dr += ""; + entries.push({ + name: "xl/drawings/_rels/drawing" + sh.index + ".xml.rels", + data: dr, + }); + for (let c = 0; c < chartIndices.length; c++) + entries.push({ + name: "xl/charts/chart" + (chartIndices[c]! + 1) + ".xml", + data: buildChartXml(sh._charts[c]!, sh.name), + }); + } + let gImg = 0; + for (const sh of this._sheets) + for (const img of sh._images) + entries.push({ + name: "xl/media/image" + ++gImg + "." + img.ext, + data: img.data, + }); + return createZip(entries); + } + + _ctXml( + totalCharts: number, + sheetChartMap: Map, + imageExts: Set, + ): string { + let x = X + ''; + x += + ''; + x += ''; + for (const ext of imageExts) + x += + ''; + x += + ''; + for (let i = 0; i < this._sheets.length; i++) + x += + ''; + x += + ''; + x += + ''; + for (let p = 0; p < this._pivots.length; p++) { + x += + ''; + x += + ''; + x += + ''; + } + for (const [sh] of sheetChartMap) + x += + ''; + for (let c = 0; c < totalCharts; c++) + x += + ''; + return x + ""; + } + + _wbXml(): string { + let x = + X + + ''; + for (let i = 0; i < this._sheets.length; i++) + x += + ''; + x += ""; + const allNR: NamedRangeEntry[] = [...this._namedRanges]; + for (let i = 0; i < this._sheets.length; i++) { + const sh = this._sheets[i]!; + if (sh._printArea) + allNR.push({ + name: "_xlnm.Print_Area", + ref: quoteSheet(sh.name) + "!" + sh._printArea, + localSheetId: i, + }); + } + if (allNR.length > 0) { + x += ""; + for (const nr of allNR) { + x += '"; + } + x += ""; + } + if (this._pivots.length > 0) { + x += ""; + const base = this._sheets.length + 3; + for (let p = 0; p < this._pivots.length; p++) + x += ''; + x += ""; + } + return x + ""; + } + + _wbRels(): string { + let x = X + ''; + for (let i = 0; i < this._sheets.length; i++) + x += + ''; + const n = this._sheets.length; + x += + ''; + x += + ''; + for (let p = 0; p < this._pivots.length; p++) + x += + ''; + return x + ""; + } + + _wsXml( + sh: Sheet, + drawingRId: RelationshipId, + isFirst: boolean, + hlRids: number[], + ): string { + let x = X + ''; + const hasOutline = sh._rowOutline.size > 0 || sh._colOutline.length > 0; + if (sh._tabColor || hasOutline) { + x += ""; + if (sh._tabColor) x += ''; + if (hasOutline) x += ''; + x += ""; + } + const rows = [...sh._rows.keys()]; + let minR = 1, + maxR = 1, + minC = 1, + maxC = 1; + if (rows.length) { + minR = Math.min(...rows); + maxR = Math.max(...rows); + for (const [, rc] of sh._rows) { + const cols = [...rc.keys()]; + if (cols.length) { + minC = Math.min(minC, ...cols); + maxC = Math.max(maxC, ...cols); + } + } + } + x += + ''; + x += + "'; + if (sh._fzR > 0 || sh._fzC > 0) { + const tl = cellRef(sh._fzR + 1, Math.max(sh._fzC, 0) + 1); + x += " 0) x += ' xSplit="' + sh._fzC + '"'; + if (sh._fzR > 0) x += ' ySplit="' + sh._fzR + '"'; + x += + ' topLeftCell="' + tl + '" activePane="bottomRight" state="frozen"/>'; + } + x += '(); + for (const cg of sh._colOutline) + for (let c = cg.from; c <= cg.to; c++) + colOutMap.set(c, Math.max(colOutMap.get(c) || 0, cg.level)); + const allCols = new Set([...sh._colW.keys(), ...colOutMap.keys()]); + if (allCols.size) { + x += ""; + for (const c of [...allCols].sort((a, b) => a - b)) { + const w = sh._colW.get(c) || 8.43; + const ol = colOutMap.get(c) || 0; + x += ' a - b)) { + const rc = sh._rows.get(rn)!; + x += ' a - b)) { + const cell = rc.get(cn)!; + let st = cell.s; + if (cell.v instanceof Date && (!st || !st.numFmt)) + st = Object.assign({}, st || {}, { numFmt: "mm-dd-yy" }); + const si = st ? this._sm.resolve(st) : 0; + x += this._cXml(cellRef(rn, cn), cell.v, si, st); + } + x += ""; + } + x += ""; + if (sh._protection) { + x += ''; + if (sh._merges.length) { + x += ''; + for (const m of sh._merges) x += ''; + x += ""; + } + if (sh._condFmts.length) x += buildCondFmtXml(sh._condFmts, this._sm); + if (sh._dataVals.length) x += buildDataValXml(sh._dataVals); + if (sh._hyperlinks.length) { + x += ""; + let extIdx = 0; + for (const hl of sh._hyperlinks) { + x += ''; + } + if (sh._pageSetup) { + const ps = sh._pageSetup; + x += ""; + if (hf.footer) x += "" + escapeXml(hf.footer) + ""; + x += ""; + } + if (drawingRId) x += ''; + if (sh._sparkGroups.length) + x += buildSparklineXml(sh._sparkGroups, sh.name); + return x + ""; + } + + _cXml( + ref: string, + v: CellValue, + si: number, + style: CellStyle | null, + ): string { + if (v === null || v === undefined) + return si ? '' : ""; + const sAttr = si ? ' s="' + si + '"' : ""; + const f = + (style && style.formula) || + (typeof v === "string" && v.startsWith("=") ? v.slice(1) : null); + if (f) + return '" + escapeXml(f) + ""; + if (typeof v === "number") + return '" + v + ""; + if (typeof v === "boolean") + return ( + '" + (v ? 1 : 0) + "" + ); + if (v instanceof Date) + return ( + '" + dateToSerial(v) + "" + ); + const idx = this._addStr(String(v)); + return '" + idx + ""; + } + + _sstXml(): string { + let x = + X + + ''; + for (const s of this._sst) + x += '' + escapeXml(s) + ""; + return x + ""; + } +} + +/** Create a new empty workbook. */ +export function createWorkbook(): Workbook { + return new Workbook(); +} + +/** Build and write workbook to an .xlsx file. */ +export function exportToFile( + wb: Workbook, + path: string, + writeFn: (path: string, bytes: Uint8Array) => void, +): ExportResult { + const bytes = wb.build(); + writeFn(path, bytes); + return { path, size: bytes.length }; +} + +/** Convenience: create a workbook with a single formatted table. */ +export function tableToWorkbook(opts: TableToWorkbookOptions): Workbook { + const wb = createWorkbook(); + const sh = wb.addSheet(opts.sheetName || "Sheet1"); + const hs = Object.assign( + { bold: true, fill: "#4472C4", color: "#FFFFFF", border: "thin" }, + opts.headerStyle || {}, + ); + const first = opts.data[0]; + const headers = [ + ...(opts.headers || + (first && !Array.isArray(first) ? Object.keys(first) : [])), + ]; + sh.addRow(1, headers, hs); + if (opts.columnWidths) + for (let i = 0; i < opts.columnWidths.length; i++) + sh.setColumnWidth(i + 1, opts.columnWidths[i]!); + for (let r = 0; r < opts.data.length; r++) { + const sourceRow = opts.data[r]!; + const row = Array.isArray(sourceRow) + ? sourceRow + : headers.map((h) => (sourceRow as Record)[h]); + const rs = + typeof opts.rowStyle === "function" + ? opts.rowStyle(r, sourceRow) + : opts.rowStyle || + (r % 2 === 0 + ? { border: "thin" } + : { fill: "#D9E2F3", border: "thin" }); + sh.addRow(r + 2, row, rs); + } + sh.freezeRows(1); + sh.setAutoFilter("A1:" + cellRef(1, headers.length)); + return wb; +} diff --git a/builtin-modules/tsconfig.json b/builtin-modules/tsconfig.json index f978711..1f0b5fc 100644 --- a/builtin-modules/tsconfig.json +++ b/builtin-modules/tsconfig.json @@ -27,6 +27,7 @@ "ha:pptx": ["./src/pptx.ts"], "ha:pptx-tables": ["./src/pptx-tables.ts"], "ha:pptx-charts": ["./src/pptx-charts.ts"], + "ha:xlsx": ["./src/xlsx.ts"], "ha:shared-state": ["./src/shared-state.ts"] } }, diff --git a/builtin-modules/xlsx.json b/builtin-modules/xlsx.json new file mode 100644 index 0000000..27c24e4 --- /dev/null +++ b/builtin-modules/xlsx.json @@ -0,0 +1,41 @@ +{ + "name": "xlsx", + "description": "Excel XLSX workbook builder - cells, styles, formulas, tables, charts, pivots, sparklines, validation, hyperlinks, images, print setup", + "author": "system", + "mutable": false, + "sourceHash": "sha256:452f9d8d980cd824", + "dtsHash": "sha256:15aa0248dc90a4f5", + "importStyle": "named", + "hints": { + "overview": "Create Excel .xlsx workbooks using strongly typed workbook, sheet, style, chart, pivot, validation, image, hyperlink, and print setup APIs.", + "relatedModules": [ + "ha:xml-escape", + "ha:zip-format" + ], + "requiredPlugins": [ + "fs-write" + ], + "criticalRules": [ + "Call module_info('xlsx') before using the API — the type definitions list every options bag.", + "Use createWorkbook() then wb.addSheet(name), populate sheets, then exportToFile(wb, path, fsWrite).", + "Cell refs are A1-style strings, or { row, col } objects using 1-based indexes.", + "Use tableToWorkbook({ data, headers }) for simple formatted tables instead of hand-building rows.", + "Chart series values must be numbers, and categories must align with each series length.", + "Sheet protection uses Excel's legacy XOR hash; it prevents accidental edits only and is not encryption." + ], + "antiPatterns": [ + "Don't hand-write OOXML XML — use xlsx workbook/sheet methods.", + "Don't use zero-based row or column numbers in public APIs; rows and columns are 1-based.", + "Don't rely on protect({ password }) for security or sensitive data protection.", + "Don't guess option names — use module_info('xlsx') and follow the exported interfaces.", + "Don't pass JavaScript objects directly to addRow; use tableToWorkbook or map object rows through headers." + ], + "commonPatterns": [ + "const wb = createWorkbook(); const sh = wb.addSheet('Report'); sh.addRow(1, headers, headerStyle); exportToFile(wb, 'report.xlsx', writeFileBinary)", + "tableToWorkbook({ sheetName: 'Data', data, headers, columnWidths }) for one-sheet reports.", + "sh.freezeRows(1).setAutoFilter('A1:D100') for scan-friendly tables.", + "sh.addConditionalFormat('D2:D100', { type: 'dataBar', color: '#4472C4' }) for visual metrics.", + "wb.addPivotTable({ sourceSheet, targetSheet, sourceRange, rows, values }) after populating source data." + ] + } +} diff --git a/builtin-modules/xml-escape.json b/builtin-modules/xml-escape.json index c4ca246..0bfe143 100644 --- a/builtin-modules/xml-escape.json +++ b/builtin-modules/xml-escape.json @@ -7,7 +7,7 @@ "dtsHash": "sha256:df392595863d1808", "importStyle": "named", "hints": { - "overview": "XML escaping and element construction. Used internally by PPTX modules.", - "relatedModules": ["ha:pptx", "ha:ooxml-core"] + "overview": "XML escaping and element construction. Used internally by PPTX/XLSX modules.", + "relatedModules": ["ha:pptx", "ha:ooxml-core", "ha:xlsx"] } } diff --git a/builtin-modules/zip-format.json b/builtin-modules/zip-format.json index 77c6cfc..56f17cf 100644 --- a/builtin-modules/zip-format.json +++ b/builtin-modules/zip-format.json @@ -8,7 +8,13 @@ "importStyle": "named", "hints": { "overview": "Assembles arrays of {name, data} entries into valid ZIP files.", - "relatedModules": ["ha:ziplib", "ha:crc32", "ha:str-bytes", "ha:pptx"], + "relatedModules": [ + "ha:ziplib", + "ha:crc32", + "ha:str-bytes", + "ha:pptx", + "ha:xlsx" + ], "requiredPlugins": ["fs-write"], "criticalRules": [ "Returns Uint8Array — write with writeFileBinary, not writeFile" diff --git a/skills/api-explorer/SKILL.md b/skills/api-explorer/SKILL.md index a41a07e..4756fe4 100644 --- a/skills/api-explorer/SKILL.md +++ b/skills/api-explorer/SKILL.md @@ -32,6 +32,30 @@ antiPatterns: - Don't ignore pagination — check for next/Link headers and follow them - Don't scrape SPA API documentation sites — fetch the OpenAPI spec directly or use raw API endpoints - Don't parse HTML documentation with regex — use ha:html parseHtml() if you must read docs pages +allowed-tools: + - register_handler + - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user --- # API Explorer @@ -123,24 +147,25 @@ Produce clear, structured output: For each endpoint discovered, document: -| Method | Path | Description | Response Type | Paginated | -|--------|------|-------------|---------------|-----------| -| GET | /users | List users | Array of User | Yes (Link header) | -| GET | /users/:id | Get user by ID | User object | No | +| Method | Path | Description | Response Type | Paginated | +| ------ | ---------- | -------------- | ------------- | ----------------- | +| GET | /users | List users | Array of User | Yes (Link header) | +| GET | /users/:id | Get user by ID | User object | No | ### Response Schema Format For each unique response type, document field names and types: -| Field | Type | Description | -|-------|------|-------------| -| id | number | Unique identifier | -| name | string | Display name | +| Field | Type | Description | +| ---------- | ----------------- | ------------------ | +| id | number | Unique identifier | +| name | string | Display name | | created_at | string (ISO 8601) | Creation timestamp | ### Output Use `write_output` for the final documentation. Markdown is the default format. Include: + - API base URL and version - Endpoint table with all discovered routes - Response schemas with field descriptions diff --git a/skills/data-processor/SKILL.md b/skills/data-processor/SKILL.md index a80e80d..b4f713e 100644 --- a/skills/data-processor/SKILL.md +++ b/skills/data-processor/SKILL.md @@ -21,11 +21,36 @@ antiPatterns: - Don't load entire datasets into handler source code — pass via event parameter - Don't process everything in one giant handler call — chunk large datasets - Don't use string manipulation for structured data — parse properly first +allowed-tools: + - register_handler + - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user --- ## Data Processing Guidance Use the event dispatch pattern for multi-step processing: + ``` register_handler('processor', ` export function handler(event) { @@ -37,17 +62,21 @@ register_handler('processor', ` ``` For large datasets: + - Pass data in chunks via event parameter - Accumulate results in module-level state - Use ha:shared-state if results need to survive recompilation For file I/O: + - Use write_output(path, content) for text output (CSV, JSON, Markdown) — no sandbox needed - Use read_input(path) for reading text input files — no sandbox needed - Use fs-write plugin in sandbox only for binary output - Enable file-builder profile for generous limits on large datasets Available modules: + - ha:str-bytes for string↔bytes conversion - ha:base64 for encoding/decoding - ha:markdown for Markdown generation +- ha:xlsx for Excel workbook generation from processed tabular data diff --git a/skills/mcp-services/SKILL.md b/skills/mcp-services/SKILL.md index f9a219b..a989e7b 100644 --- a/skills/mcp-services/SKILL.md +++ b/skills/mcp-services/SKILL.md @@ -22,10 +22,29 @@ antiPatterns: - Don't guess tool names — always call mcp_server_info() first - Don't hardcode MCP tool schemas — they change when servers update allowed-tools: + - register_handler - list_mcp_servers - mcp_server_info - manage_mcp - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user --- ## MCP Server Workflow @@ -73,6 +92,7 @@ export default async function handler(event) { ``` Key rules: + - Import from `host:mcp-` (the name from list_mcp_servers) - Tool function names are EXACTLY as returned by mcp_server_info - All MCP tool calls are async — use `await` @@ -86,6 +106,7 @@ Key rules: ### Server name patterns M365 servers use the `work-iq-` prefix: + - `work-iq-mail` — Email (search, send, reply, drafts) - `work-iq-teams` — Teams (channels, chats, messages) - `work-iq-calendar` — Calendar (events, scheduling) diff --git a/skills/pdf-expert/SKILL.md b/skills/pdf-expert/SKILL.md index a5a42ce..ebaa8b0 100644 --- a/skills/pdf-expert/SKILL.md +++ b/skills/pdf-expert/SKILL.md @@ -33,16 +33,24 @@ allowed-tools: - execute_javascript - delete_handler - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox - list_modules - module_info - list_plugins - plugin_info - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp - apply_profile - configure_sandbox - sandbox_help - - llm_thought - register_module + - write_output + - read_input + - read_output - ask_user --- @@ -61,12 +69,14 @@ inside the Hyperlight sandbox. ## Two APIs — When to Use Which ### Flow Layout (PREFERRED for all documents) + Use `addContent(doc, elements)` — elements auto-paginate, no coordinate math. `addContent()` starts on the current page. When content overflows, it auto-creates new pages. Call it multiple times — each continues where the last left off. Do NOT try to control exact page count — let content flow naturally. ### Low-Level (custom positioning only) + Use `doc.addPage()` + `doc.drawText()` / `doc.drawRect()` only for letterheads, custom headers, or precise positioning. Call `addContent()` after low-level draws to flow content below them. @@ -88,18 +98,21 @@ Call `module_info('doc-core')` to see all available themes and their colours. A professional document tells a story. Every element must have context. ### Structure Rules + - **Every document starts with a title** — use `heading({ level: 1 })` or `titlePage()` - **Every section has a heading** — `heading({ level: 2 })` before each section - **Charts NEVER appear alone** — heading above + interpretation paragraph below - **Tables NEVER appear alone** — introduce with context explaining what it shows ### Content Rules + - **Add narrative text** — explain what data means, don't just show numbers - **Highlight key findings** — call out trends, anomalies, comparisons - **Use bullet lists for summaries** — after charts/tables, summarize 2-3 takeaways - **Include footer and page numbers** — `addFooter()` and `addPageNumbers()` for multi-page docs ### Quality Checklist + 1. Does every chart have a heading AND interpretation paragraph? 2. Are numeric values given context (comparison, % change, trend)? 3. Would a reader understand the data without the original request? @@ -110,22 +123,24 @@ A professional document tells a story. Every element must have context. Use `estimateHeight(elements)` to predict total height BEFORE rendering. ### Available space per page + - **Letter** (612×792pt): ~648pt usable with default 1" margins - **A4** (595×842pt): ~698pt usable with default 1" margins - `contentPage()` heading uses ~50pt (h1 + spacing) ### Approximate element heights -| Element | Height | -|---------|--------| -| heading level 1 | ~60pt | -| heading level 2 | ~45pt | -| paragraph (3 lines) | ~50pt | -| table row | ~24pt | -| chart (default) | ~250pt + 21pt if titled | -| spacer(12) | 12pt | -| rule() | ~16pt | -| bullet list item | ~15pt | -| metricCard | ~62pt (76pt with change indicator) | + +| Element | Height | +| ------------------- | ---------------------------------- | +| heading level 1 | ~60pt | +| heading level 2 | ~45pt | +| paragraph (3 lines) | ~50pt | +| table row | ~24pt | +| chart (default) | ~250pt + 21pt if titled | +| spacer(12) | 12pt | +| rule() | ~16pt | +| bullet list item | ~15pt | +| metricCard | ~62pt (76pt with change indicator) | ## Setup Sequence @@ -149,6 +164,7 @@ Use `estimateHeight(elements)` to predict total height BEFORE rendering. ## Validation `exportToFile()` runs automatic validation before saving: + - **Text overlap detection** — overlapping text elements throw an error - **Bounds checking** — text outside page edges throws an error - **Whitespace detection** — nearly-empty interior pages warn diff --git a/skills/pptx-expert/SKILL.md b/skills/pptx-expert/SKILL.md index 196c945..464d30d 100644 --- a/skills/pptx-expert/SKILL.md +++ b/skills/pptx-expert/SKILL.md @@ -28,16 +28,24 @@ allowed-tools: - execute_javascript - delete_handler - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox - list_modules - module_info - list_plugins - plugin_info - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp - apply_profile - configure_sandbox - sandbox_help - - llm_thought - register_module + - write_output + - read_input + - read_output - ask_user --- @@ -57,6 +65,7 @@ inside the Hyperlight sandbox. ## CRITICAL: ShapeFragment API All shape-builder functions return `ShapeFragment` objects — NOT strings. + - Pass ShapeFragment arrays to `customSlide(pres, [shape1, shape2, ...])` - Do NOT concatenate with `+` — pass as arrays - Charts return `{ shape: ShapeFragment, rels: ... }` — use the `.shape` property @@ -71,6 +80,7 @@ object directly — methods are lost. ## Slide Templates (Use These!) Use high-level slide functions instead of manual positioning: + - `titleSlide()` — cover slide - `contentSlide()` — title + body text - `twoColumnSlide()` — side-by-side layout @@ -91,6 +101,7 @@ Call `module_info('pptx', 'functionName')` to see parameters for any function. ## Theme & Colour Rules Call `module_info('ooxml-core')` to see available themes. + - All colours: 6-char hex without `#` (e.g. `"2196F3"`) - Named colours, rgb(), 3-char hex are NOT supported — runtime error - Contrast is enforced — if text/bg contrast fails WCAG AA, you get an error diff --git a/skills/report-builder/SKILL.md b/skills/report-builder/SKILL.md index 917869d..652e40e 100644 --- a/skills/report-builder/SKILL.md +++ b/skills/report-builder/SKILL.md @@ -18,26 +18,54 @@ antiPatterns: - Don't build the entire report in one monolithic handler - Don't inline large template strings — keep handlers under 4KB - Don't forget to enable fs-write plugin before writing files +allowed-tools: + - register_handler + - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user --- ## Report Building Guidance For TEXT reports (Markdown, plain text, CSV, JSON): + - Use write_output(path, content) directly — no sandbox needed - Build the content as a string in the LLM context - write_output requires fs-write plugin to be enabled -For binary formats (DOCX, PPTX, ZIP): -- Use the sandbox with ha:zip-format or ha:pptx +For binary formats (DOCX, PPTX, XLSX, ZIP): + +- Use the sandbox with ha:zip-format, ha:pptx, or ha:xlsx - Apply file-builder profile for generous heap/scratch limits - Write binary output via fs-write plugin from the handler For multi-section reports: + - Build sections as strings, concatenate, then write_output - Or use event dispatch in sandbox for complex transformations - Use ha:shared-state if data needs to survive handler recompiles For reading input files: + - Use read_input(path) directly — no sandbox needed - Requires fs-read plugin to be enabled diff --git a/skills/research-synthesiser/SKILL.md b/skills/research-synthesiser/SKILL.md index 8ffe5fd..a1b6901 100644 --- a/skills/research-synthesiser/SKILL.md +++ b/skills/research-synthesiser/SKILL.md @@ -35,6 +35,30 @@ antiPatterns: - Don't fetch the same URL twice — deduplicate URLs before fetching - Don't scrape SPA websites (React, Next.js, Astro) — content is loaded by JavaScript and won't appear in the HTML. Use JSON APIs instead - Don't store 80KB of raw HTML — parseHtml() extracts ~5KB of useful text from a typical page +allowed-tools: + - register_handler + - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user --- # Research Synthesiser @@ -48,6 +72,7 @@ tasks into discovery → extraction → analysis → output phases. ### Phase 1: Plan & Discover Sources Before fetching anything: + 1. Ask the user to clarify scope if the topic is broad 2. **Prefer JSON APIs over website scraping** — most provider websites are SPAs (React, Next.js, Astro) where content is loaded by JavaScript. @@ -63,6 +88,7 @@ Before fetching anything: ### Phase 2: Research (Handler 1 — "researcher") Dedicated handler for fetching and extracting: + - Enable fetch plugin with appropriate allowedDomains - Fetch sources in batches (3-5 at a time via handler re-execution with different events) - **For HTML responses: ALWAYS import { parseHtml } from 'ha:html'** @@ -77,6 +103,7 @@ Dedicated handler for fetching and extracting: - Track source URLs alongside each finding for citation Data structure pattern for shared-state: + ``` set("findings", { sources: ["url1", "url2", ...], @@ -91,6 +118,7 @@ set("findings", { ### Phase 3: Analyse & Cross-Reference (Handler 2 — "analyser") Process the raw findings: + - Cross-reference facts across sources - Identify consensus, contradictions, and gaps - Calculate aggregates and comparisons @@ -100,6 +128,7 @@ Process the raw findings: ### Phase 4: Build Output (Handler 3 or write_output) Produce the final deliverable: + - **For PPTX**: Use ha:pptx in the sandbox with appropriate theme, charts, tables - **For Markdown/text reports**: Use write_output(path, content) directly — no sandbox needed! Build the report as a string and call write_output("report.md", content) @@ -107,6 +136,7 @@ Produce the final deliverable: - Only use the sandbox for binary output or complex computation Always include: + - Executive summary / key findings at the top - Source attribution (footnotes, citations, or a references section) - Data visualisations where numbers tell the story (charts, tables, comparison grids) @@ -115,6 +145,7 @@ Always include: ## Output Format Selection Match output to what the user asked for: + - "presentation" / "slides" / "deck" → PPTX (use pptx-expert patterns) - "report" / "document" / "analysis" / "paper" → write_output(path, content) directly - "data" / "dataset" / "spreadsheet" → write_output(path, content) for JSON/CSV @@ -123,11 +154,13 @@ Match output to what the user asked for: ## Profile & Plugin Setup Always start with: + ``` apply_profile("web-research file-builder") ``` This gives you: + - fetch plugin (with configurable allowedDomains) - fs-write plugin (for output files) - Generous timeouts (120s wall for multiple fetches) @@ -136,6 +169,7 @@ This gives you: ## Image Research If the user wants images (e.g. for a PPTX with visuals): + 1. Discover image URLs during the research phase (don't guess!) 2. Use API endpoints for image discovery (e.g. Wikipedia media-list API) 3. Download all images in the research handler using fetchBinaryBatch @@ -145,6 +179,7 @@ If the user wants images (e.g. for a PPTX with visuals): ## Handler Size Management Research tasks accumulate lots of data. Keep handlers under 4KB: + - Don't inline fetched content in handler source code - Pass data via event parameter or shared-state - Use event dispatch to run the same handler with different actions @@ -153,6 +188,7 @@ Research tasks accumulate lots of data. Keep handlers under 4KB: ## Error Recovery Research is inherently unreliable (sites go down, rate limits, 404s): + - The fetch plugin auto-retries on 429 (configurable) - Always check response status before reading content - Log failed URLs and continue — don't abort the whole research diff --git a/skills/web-scraper/SKILL.md b/skills/web-scraper/SKILL.md index 0933c21..ae93590 100644 --- a/skills/web-scraper/SKILL.md +++ b/skills/web-scraper/SKILL.md @@ -21,28 +21,57 @@ antiPatterns: - Don't hardcode URLs — pass them via event parameter - Don't scrape SPA websites (React, Next.js, Astro) — content is loaded by JavaScript. Use JSON APIs or look for API endpoints instead - Don't store raw HTML — use parseHtml() to extract text first +allowed-tools: + - register_handler + - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user --- ## Web Scraping Guidance ALWAYS use the two-step fetch pattern: + 1. `f.get(url)` → check `meta.ok` and `meta.contentType` 2. `f.read(url)` in a loop → collect chunks → join For HTML content: + - `parseHtml(html)` returns `{text, links}` — most efficient for both - `htmlToText(html)` if you only need text - `extractLinks(html)` if you only need links For Markdown content (e.g. GitHub READMEs): + - `markdownToText(md)` strips formatting - `markdownToHtml(md)` converts to HTML (then use `htmlToText` if needed) For structured data: + - Parse JSON responses directly - Filter links by href pattern for navigation - Use ha:shared-state to pass data to a build handler Required plugin config: + - `fetch.allowedDomains` MUST be set (comma-separated) - Enable appropriate content types in `allowedContentTypes` diff --git a/skills/xlsx-expert/SKILL.md b/skills/xlsx-expert/SKILL.md new file mode 100644 index 0000000..74a0ecf --- /dev/null +++ b/skills/xlsx-expert/SKILL.md @@ -0,0 +1,140 @@ +--- +name: xlsx-expert +description: Expert at building Excel XLSX workbooks using Hyperlight sandbox modules +triggers: + - Excel + - excel + - XLSX + - xlsx + - spreadsheet + - workbook + - worksheet + - pivot table + - pivot + - chart workbook + - sales report +patterns: + - two-handler-pipeline + - file-generation +antiPatterns: + - Don't write inline OOXML XML — use ha:xlsx workbook and sheet APIs + - Don't use zero-based public row or column indexes — xlsx APIs use 1-based indexes + - Don't guess option names — call module_info('xlsx') and read the exported interfaces + - Don't rely on sheet protection passwords for security — they use legacy Excel XOR hashing + - Don't pass JavaScript objects directly to addRow — use tableToWorkbook or map rows through headers +allowed-tools: + - register_handler + - execute_javascript + - delete_handler + - get_handler_source + - edit_handler + - list_handlers + - reset_sandbox + - list_modules + - module_info + - list_plugins + - plugin_info + - manage_plugin + - list_mcp_servers + - mcp_server_info + - manage_mcp + - apply_profile + - configure_sandbox + - sandbox_help + - register_module + - write_output + - read_input + - read_output + - ask_user +--- + +# Excel Workbook Expert + +You are an expert at building professional Excel `.xlsx` workbooks inside the Hyperlight sandbox. + +## CRITICAL: API Discovery — DO NOT GUESS + +1. Call `module_info('xlsx')` before writing handler code. +2. Read the type definitions for every options bag you plan to use: `CellStyle`, `ChartOptions`, `ConditionalFormatRule`, `DataValidationOptions`, `PivotTableAddOptions`, and `TableToWorkbookOptions`. +3. The module is strongly typed. Follow the exported interface names exactly. + +## Setup Sequence + +1. `apply_profile({ profiles: 'file-builder' })` for binary output and larger buffers. +2. `manage_plugin('fs-write', 'enable')` if the workbook needs to be written to disk. +3. `module_info('xlsx')` and read the type definitions. +4. Register a handler that imports from `ha:xlsx`. +5. Build the workbook, then write the returned `Uint8Array` with the fs-write binary API. + +## Common Patterns + +For simple tabular reports, prefer `tableToWorkbook()`: + +```javascript +import { tableToWorkbook, exportToFile } from "ha:xlsx"; + +export async function handler(event) { + const wb = tableToWorkbook({ + sheetName: "Report", + headers: ["name", "value"], + data: event.rows, + columnWidths: [24, 14], + }); + return exportToFile(wb, "report.xlsx", event.writeFileBinary); +} +``` + +For richer workbooks, use the workbook/sheet API: + +```javascript +import { createWorkbook, exportToFile } from "ha:xlsx"; + +export async function handler(event) { + const wb = createWorkbook(); + const sh = wb.addSheet("Data"); + sh.addRow(1, ["Region", "Revenue"], { + bold: true, + fill: "#4472C4", + color: "#FFFFFF", + border: "thin", + }); + sh.addData(event.rows, "A2", { border: "thin" }); + sh.freezeRows(1).setAutoFilter("A1:B100"); + sh.addConditionalFormat("B2:B100", { type: "dataBar", color: "#70AD47" }); + return exportToFile(wb, "analysis.xlsx", event.writeFileBinary); +} +``` + +## Workbook Rules + +- Public row and column indexes are 1-based. Cell refs are A1-style strings like `A1`, `C12`, `AA7`. +- `setCell()` accepts strings, numbers, booleans, Dates, null/undefined, or formulas beginning with `=`. +- Dates are stored as Excel serial numbers and default to `mm-dd-yy` if no `numFmt` is provided. +- Use `setColumnWidth()`, `freezeRows()`, and `setAutoFilter()` for scan-friendly reports. +- Use `tableToWorkbook()` for object rows; use `addRow()` only with arrays. + +## Chart Rules + +- `ChartOptions.series` is an array of `{ name, values }`. +- Every series needs a `name` and numeric `values`. +- `categories.length` should match each series `values.length`. +- Supported chart types are `column`, `bar`, `line`, `area`, `pie`, and `doughnut`. + +## Pivot Rules + +- Populate the source sheet first, including header row. +- `sourceRange` must include the header row, for example `A1:D100`. +- Field names in `rows`, `columns`, `filters`, and `values[].field` must exactly match headers. +- Create or select a separate target sheet before calling `addPivotTable()`. + +## Protection Warning + +`protect({ password })` uses Excel's legacy XOR sheet-protection hash. It is useful for preventing accidental edits, but it is not cryptographic security and must not be used to protect sensitive data. + +## Common Mistakes + +- Forgetting to call `module_info('xlsx')` and guessing option names. +- Passing object rows to `addRow()` instead of arrays. +- Using zero-based row/column indexes in public APIs. +- Writing workbook bytes as text instead of binary. +- Treating `protect({ password })` as secure encryption. diff --git a/tests/pattern-integrity.test.ts b/tests/pattern-integrity.test.ts index ddf86df..7461914 100644 --- a/tests/pattern-integrity.test.ts +++ b/tests/pattern-integrity.test.ts @@ -9,8 +9,9 @@ // ───────────────────────────────────────────────────────────────────── import { describe, it, expect } from "vitest"; -import { readdirSync } from "fs"; +import { readdirSync, readFileSync } from "fs"; import { join } from "path"; +import { ALLOWED_TOOLS } from "../src/agent/tool-gating.js"; import { loadSkills } from "../src/agent/skill-loader.js"; import { loadPatterns } from "../src/agent/pattern-loader.js"; @@ -32,6 +33,28 @@ const PRIVATE_MODULES = new Set(["_restore", "_save"]); const skills = loadSkills(SKILLS_DIR); const patterns = loadPatterns(PATTERNS_DIR); +function parseAllowedTools(skillName: string): string[] { + const skillFile = join(SKILLS_DIR, skillName, "SKILL.md"); + const content = readFileSync(skillFile, "utf-8"); + const lines = content.split("\n"); + const tools: string[] = []; + let inAllowedTools = false; + + for (const line of lines) { + if (line.trim() === "---" && inAllowedTools) break; + if (/^allowed-tools:\s*$/.test(line.trim())) { + inAllowedTools = true; + continue; + } + if (!inAllowedTools) continue; + if (/^\S/.test(line) && !line.trim().startsWith("-")) break; + const match = line.match(/^\s+-\s+(.+)\s*$/); + if (match) tools.push(match[1]!.trim()); + } + + return tools; +} + describe("pattern-integrity", () => { describe("skill → pattern references", () => { for (const [skillName, skill] of skills) { @@ -89,4 +112,26 @@ describe("pattern-integrity", () => { }); } }); + + describe("skill allowed-tools metadata", () => { + const mcpTools = ["list_mcp_servers", "mcp_server_info", "manage_mcp"]; + + for (const [skillName] of skills) { + const allowedTools = parseAllowedTools(skillName); + + it(`skill "${skillName}" only references real HyperAgent tools`, () => { + expect( + allowedTools.filter((tool) => !ALLOWED_TOOLS.has(tool)), + `Skill "${skillName}" has stale/unknown allowed-tools entries`, + ).toEqual([]); + }); + + it(`skill "${skillName}" includes MCP discovery/connect tools`, () => { + expect( + mcpTools.filter((tool) => !allowedTools.includes(tool)), + `Skill "${skillName}" should allow MCP discovery/connect tools so it can use external data sources when relevant`, + ).toEqual([]); + }); + } + }); }); diff --git a/tests/sandbox-tool.test.ts b/tests/sandbox-tool.test.ts index 7df6167..05310a6 100644 --- a/tests/sandbox-tool.test.ts +++ b/tests/sandbox-tool.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, beforeAll } from "vitest"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { inflateRawSync } from "node:zlib"; import { createSandboxTool, parsePositiveInt } from "../src/sandbox/tool.js"; @@ -43,6 +44,58 @@ function loadSaveRestoreModules( ]); } +function u16(data: Uint8Array, offset: number): number { + return data[offset]! | (data[offset + 1]! << 8); +} + +function u32(data: Uint8Array, offset: number): number { + return ( + data[offset]! | + (data[offset + 1]! << 8) | + (data[offset + 2]! << 16) | + (data[offset + 3]! << 24) + ); +} + +function parseZipEntries(bytes: readonly number[]): Map { + const zip = Uint8Array.from(bytes); + const entries = new Map(); + let offset = 0; + + while (offset + 30 <= zip.length && u32(zip, offset) === 0x04034b50) { + const method = u16(zip, offset + 8); + const compressedSize = u32(zip, offset + 18); + const fileNameLength = u16(zip, offset + 26); + const extraLength = u16(zip, offset + 28); + const nameStart = offset + 30; + const nameEnd = nameStart + fileNameLength; + const dataStart = nameEnd + extraLength; + const dataEnd = dataStart + compressedSize; + const name = Buffer.from(zip.subarray(nameStart, nameEnd)).toString("utf8"); + const compressed = zip.subarray(dataStart, dataEnd); + + if (method === 0) { + entries.set(name, compressed); + } else if (method === 8) { + entries.set(name, new Uint8Array(inflateRawSync(compressed))); + } else { + throw new Error( + `Unsupported ZIP compression method ${method} for ${name}`, + ); + } + + offset = dataEnd; + } + + return entries; +} + +function zipText(entries: Map, name: string): string { + const entry = entries.get(name); + if (!entry) throw new Error(`Missing ZIP entry: ${name}`); + return Buffer.from(entry).toString("utf8"); +} + // ── parsePositiveInt ───────────────────────────────────────────────── describe("parsePositiveInt", () => { @@ -1907,3 +1960,349 @@ describe("zip-format deduplication", () => { expect(r.result.size).toBeGreaterThan(0); }); }); + +// ── XLSX workbook generation ───────────────────────────────────────── + +describe("xlsx builtin module", () => { + function loadXlsxModules(tool: ReturnType): void { + tool.setModules([ + readModule("xml-escape"), + readModule("str-bytes"), + readModule("crc32"), + readModule("zip-format"), + readModule("xlsx"), + ]); + } + + it("should build a workbook with styles, formulas, validation, chart, and pivot data", async () => { + const tool = createSandboxTool({ inputBufferKb: 256, outputBufferKb: 256 }); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-smoke-test", + [ + 'import { createWorkbook } from "ha:xlsx";', + "export function handler() {", + " const wb = createWorkbook();", + " const data = wb.addSheet('Data');", + " data.addRow(1, ['Region', 'Quarter', 'Revenue', 'Active'], { bold: true, fill: '#4472C4', color: '#FFFFFF', border: 'thin' });", + " data.addRow(2, ['North', 'Q1', 120, true], { border: 'thin' });", + " data.addRow(3, ['South', 'Q1', 90, false], { border: 'thin' });", + " data.addRow(4, ['North', 'Q2', 150, true], { border: 'thin' });", + " data.setCell('E1', 'Total', { bold: true });", + " data.setCell('E2', '=SUM(C2:C4)', { numFmt: '#,##0' });", + " data.setCell('F2', new Date(2026, 3, 28));", + " data.setColumnWidth('A', 18).setColumnWidth('C', 12);", + " data.freezeRows(1).setAutoFilter('A1:D4');", + " data.addConditionalFormat('C2:C4', { type: 'dataBar', color: '#70AD47' });", + " data.addDataValidation('B2:B100', { type: 'list', values: ['Q1', 'Q2', 'Q3', 'Q4'] });", + " data.addChart({ type: 'column', title: 'Revenue', categories: ['North Q1', 'South Q1', 'North Q2'], series: [{ name: 'Revenue', values: [120, 90, 150] }] });", + " const pivots = wb.addSheet('Pivots');", + " wb.addPivotTable({ sourceSheet: data, targetSheet: pivots, sourceRange: 'A1:D4', targetCell: 'A3', rows: ['Region'], values: [{ field: 'Revenue', func: 'sum' }] });", + " const bytes = wb.build();", + " return {", + " isUint8Array: bytes instanceof Uint8Array,", + " size: bytes.length,", + " signature: [bytes[0], bytes[1], bytes[2], bytes[3]],", + " pivotNorth: pivots.getCellValue('A4'),", + " pivotTotal: pivots.getCellValue('B6'),", + " };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-smoke-test"); + expect(r.success).toBe(true); + expect(r.result).toMatchObject({ + isUint8Array: true, + signature: [0x50, 0x4b, 0x03, 0x04], + pivotNorth: "North", + pivotTotal: 360, + }); + expect(r.result.size).toBeGreaterThan(2000); + }); + + it("should build a simple formatted table with tableToWorkbook", async () => { + const tool = createSandboxTool({ inputBufferKb: 128, outputBufferKb: 128 }); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-table-test", + [ + 'import { tableToWorkbook } from "ha:xlsx";', + "export function handler() {", + " const wb = tableToWorkbook({", + " sheetName: 'Sales',", + " headers: ['name', 'value'],", + " data: [{ name: 'Alpha', value: 10 }, { name: 'Beta', value: 20 }],", + " columnWidths: [20, 12],", + " });", + " const bytes = wb.build();", + " return { size: bytes.length, signature: [bytes[0], bytes[1], bytes[2], bytes[3]] };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-table-test"); + expect(r.success).toBe(true); + expect(r.result.signature).toEqual([0x50, 0x4b, 0x03, 0x04]); + expect(r.result.size).toBeGreaterThan(1000); + }); + + it("should expose cell reference and date helper functions", async () => { + const tool = createSandboxTool(); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-helper-test", + [ + 'import { colToNum, numToCol, parseCellRef, cellRef, dateToSerial } from "ha:xlsx";', + "export function handler() {", + " let invalidRef = '';", + " try { parseCellRef('not-a-cell'); } catch (err) { invalidRef = err.message; }", + " return {", + " colAA: colToNum('AA'),", + " colXfd: colToNum('XFD'),", + " num703: numToCol(703),", + " parsed: parseCellRef('BC42'),", + " ref: cellRef(99, 28),", + " serial: dateToSerial(new Date(1900, 0, 1)),", + " invalidRef,", + " };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-helper-test"); + expect(r.success).toBe(true); + expect(r.result).toEqual({ + colAA: 27, + colXfd: 16384, + num703: "AAA", + parsed: { col: 55, row: 42 }, + ref: "AB99", + serial: 2, + invalidRef: "Invalid cell ref: not-a-cell", + }); + }); + + it("should write core workbook entries and worksheet layout XML", async () => { + const tool = createSandboxTool({ + inputBufferKb: 512, + outputBufferKb: 1024, + }); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-worksheet-xml-test", + [ + 'import { createWorkbook } from "ha:xlsx";', + "export function handler() {", + " const wb = createWorkbook();", + " const sh = wb.addSheet('Ops & Plan');", + " sh.addRow(1, ['Merged title', null, null], { bold: true, fill: '#D9EAD3' });", + " sh.addRow(2, ['Team', 'Count', 'Revenue'], { bold: true });", + " sh.addRow(3, ['North', 2, 30]);", + " sh.addRow(4, ['South', 1, 5]);", + " sh.addRow(5, ['Total', 3, '=SUM(C3:C4)']);", + " sh.setColumnWidth('A', 22).setRowHeight(1, 24);", + " sh.mergeCells('A1', 'C1').freezeRows(1).freezeColumns(1).setAutoFilter('A2:C5');", + " sh.groupRows(3, 4, { level: 2 }).groupColumns('B', 'C', { level: 1 });", + " sh.setTabColor('#FFAA00');", + " sh.protect({ password: 'secret', allowSort: true, allowFilter: true });", + " sh.setPrintArea('A1:C5');", + " sh.setPageSetup({ orientation: 'landscape', paperSize: 9, fitToWidth: 1, fitToHeight: 0 });", + " sh.setPageMargins({ left: 0.5, right: 0.5, top: 0.6, bottom: 0.6, header: 0.2, footer: 0.2 });", + " sh.setHeaderFooter({ header: '&CReport', footer: '&P of &N' });", + " wb.addNamedRange('Totals', \"'Ops & Plan'!$C$5\");", + " const bytes = wb.build();", + " return { bytes: Array.from(bytes) };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-worksheet-xml-test"); + expect(r.success).toBe(true); + const entries = parseZipEntries(r.result.bytes); + expect([...entries.keys()]).toEqual( + expect.arrayContaining([ + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/worksheets/sheet1.xml", + "xl/styles.xml", + "xl/sharedStrings.xml", + ]), + ); + + const workbookXml = zipText(entries, "xl/workbook.xml"); + expect(workbookXml).toContain('sheet name="Ops & Plan"'); + expect(workbookXml).toContain(''); + expect(workbookXml).toContain("'Ops & Plan'!$C$5"); + expect(workbookXml).toContain('name="_xlnm.Print_Area" localSheetId="0"'); + + const sheetXml = zipText(entries, "xl/worksheets/sheet1.xml"); + expect(sheetXml).toContain(''); + expect(sheetXml).toContain('', + ); + expect(sheetXml).toContain( + '', + ); + expect(sheetXml).toContain(''); + expect(sheetXml).toContain(''); + expect(sheetXml).toContain( + '', + ); + expect(sheetXml).toContain( + '', + ); + expect(sheetXml).toContain("&CReport"); + expect(sheetXml).toContain("&P of &N"); + }); + + it("should write relationships for hyperlinks, drawings, charts, images, and sparklines", async () => { + const tool = createSandboxTool({ + inputBufferKb: 512, + outputBufferKb: 1024, + }); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-relationship-xml-test", + [ + 'import { createWorkbook } from "ha:xlsx";', + "export function handler() {", + " const wb = createWorkbook();", + " const data = wb.addSheet('Data');", + " wb.addSheet('Other').setCell('B2', 'Target');", + " data.addRow(1, ['Metric', 'Q1', 'Q2', 'Q3']);", + " data.addRow(2, ['Revenue', 10, 20, 30]);", + " data.addRow(3, ['Cost', 4, 8, 12]);", + " data.addHyperlink('A5', 'https://example.com/report', { display: 'External report', tooltip: 'Open report' });", + " data.addHyperlink('A6', { sheet: 'Other', cell: 'B2' }, { display: 'Internal target' });", + " data.addChart({ type: 'line', title: 'Trend', categories: ['Q1', 'Q2', 'Q3'], series: [{ name: 'Revenue', values: [10, 20, 30] }], dataLabels: true, anchor: { from: 'F2', to: 'M16' } });", + " data.addSparklines({ type: 'line', dataRange: 'B2:D3', locationRange: 'E2:E3', markers: true, showHigh: true, showLow: true });", + " data.addImage(new Uint8Array([0x89, 0x50, 0x4E, 0x47, 1, 2, 3, 4]), { from: 'F18', to: 'H24' });", + " const bytes = wb.build();", + " return { bytes: Array.from(bytes) };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-relationship-xml-test"); + expect(r.success).toBe(true); + const entries = parseZipEntries(r.result.bytes); + expect([...entries.keys()]).toEqual( + expect.arrayContaining([ + "xl/worksheets/sheet1.xml", + "xl/worksheets/_rels/sheet1.xml.rels", + "xl/drawings/drawing1.xml", + "xl/drawings/_rels/drawing1.xml.rels", + "xl/charts/chart1.xml", + "xl/media/image1.png", + ]), + ); + + const sheetXml = zipText(entries, "xl/worksheets/sheet1.xml"); + expect(sheetXml).toContain( + '', + ); + expect(sheetXml).toContain( + '', + ); + expect(sheetXml).toContain(''); + expect(sheetXml).toContain("Data!B2:D2E2", + ); + + const sheetRels = zipText(entries, "xl/worksheets/_rels/sheet1.xml.rels"); + expect(sheetRels).toContain( + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"', + ); + expect(sheetRels).toContain( + 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/report" TargetMode="External"', + ); + + const drawingRels = zipText(entries, "xl/drawings/_rels/drawing1.xml.rels"); + expect(drawingRels).toContain('Target="../charts/chart1.xml"'); + expect(drawingRels).toContain('Target="../media/image1.png"'); + + const chartXml = zipText(entries, "xl/charts/chart1.xml"); + expect(chartXml).toContain(""); + expect(chartXml).toContain("Trend"); + expect(chartXml).toContain("Revenue"); + expect(entries.get("xl/media/image1.png")).toEqual( + Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]), + ); + }); + + it("should compute pivot aggregators for sum, count, average, min, and max", async () => { + const tool = createSandboxTool({ inputBufferKb: 256, outputBufferKb: 256 }); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-pivot-aggregator-test", + [ + 'import { createWorkbook } from "ha:xlsx";', + "export function handler() {", + " const wb = createWorkbook();", + " const data = wb.addSheet('Data');", + " data.addRow(1, ['Region', 'Revenue']);", + " data.addRow(2, ['North', 10]);", + " data.addRow(3, ['North', 20]);", + " data.addRow(4, ['South', 5]);", + " const pivot = wb.addSheet('Pivot');", + " wb.addPivotTable({", + " sourceSheet: data,", + " targetSheet: pivot,", + " sourceRange: 'A1:B4',", + " targetCell: 'A3',", + " rows: ['Region'],", + " values: [", + " { field: 'Revenue', func: 'sum' },", + " { field: 'Revenue', func: 'count' },", + " { field: 'Revenue', func: 'average' },", + " { field: 'Revenue', func: 'min' },", + " { field: 'Revenue', func: 'max' },", + " ],", + " });", + " return {", + " headers: ['B3', 'C3', 'D3', 'E3', 'F3'].map((ref) => pivot.getCellValue(ref)),", + " north: ['A4', 'B4', 'C4', 'D4', 'E4', 'F4'].map((ref) => pivot.getCellValue(ref)),", + " south: ['A5', 'B5', 'C5', 'D5', 'E5', 'F5'].map((ref) => pivot.getCellValue(ref)),", + " total: ['A6', 'B6', 'C6', 'D6', 'E6', 'F6'].map((ref) => pivot.getCellValue(ref)),", + " };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-pivot-aggregator-test"); + expect(r.success).toBe(true); + expect(r.result.headers).toEqual([ + "Sum of Revenue", + "Count of Revenue", + "Average of Revenue", + "Min of Revenue", + "Max of Revenue", + ]); + expect(r.result.north).toEqual(["North", 30, 2, 15, 10, 20]); + expect(r.result.south).toEqual(["South", 5, 1, 5, 5, 5]); + expect(r.result.total[0]).toBe("Grand Total"); + expect(r.result.total[1]).toBe(35); + expect(r.result.total[2]).toBe(3); + expect(r.result.total[3]).toBeCloseTo(35 / 3); + expect(r.result.total[4]).toBe(5); + expect(r.result.total[5]).toBe(20); + }); +}); From 1900287bb03fe97d425d6a776986a555dd2863c5 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 28 Apr 2026 20:46:40 +0100 Subject: [PATCH 2/2] fix(modules): address xlsx review feedback Signed-off-by: Simon Davies --- builtin-modules/src/types/ha-modules.d.ts | 7 +- builtin-modules/src/xlsx.ts | 85 +++++++++++++++++------ builtin-modules/xlsx.json | 4 +- tests/sandbox-tool.test.ts | 52 ++++++++++++-- 4 files changed, 116 insertions(+), 32 deletions(-) diff --git a/builtin-modules/src/types/ha-modules.d.ts b/builtin-modules/src/types/ha-modules.d.ts index 9b485ad..fe9538e 100644 --- a/builtin-modules/src/types/ha-modules.d.ts +++ b/builtin-modules/src/types/ha-modules.d.ts @@ -5317,6 +5317,11 @@ declare module "ha:xlsx" { from: number; to: number; level: number; + collapsed: boolean; + } + interface RowOutlineEntry { + level: number; + collapsed: boolean; } interface NamedRangeEntry { name: string; @@ -5407,7 +5412,7 @@ declare module "ha:xlsx" { _tabColor: string | null; _hyperlinks: HyperlinkEntry[]; _images: ImageEntry[]; - _rowOutline: Map; + _rowOutline: Map; _colOutline: ColumnOutlineEntry[]; _protection: SheetProtectionOptions | null; _printArea: string | null; diff --git a/builtin-modules/src/xlsx.ts b/builtin-modules/src/xlsx.ts index 2ea583e..414b764 100644 --- a/builtin-modules/src/xlsx.ts +++ b/builtin-modules/src/xlsx.ts @@ -332,6 +332,12 @@ interface ColumnOutlineEntry { from: number; to: number; level: number; + collapsed: boolean; +} + +interface RowOutlineEntry { + level: number; + collapsed: boolean; } interface NamedRangeEntry { @@ -551,8 +557,9 @@ export function cellRef(row: number, col: number): string { /** Convert JS Date to Excel serial date number. */ export function dateToSerial(d: Date): number { - const epoch = new Date(1899, 11, 30); - return (d.getTime() - epoch.getTime()) / 86400000; + const epoch = new Date(1899, 11, 31); + const serial = (d.getTime() - epoch.getTime()) / 86400000; + return serial >= 60 ? serial + 1 : serial; } export class Sheet { @@ -572,7 +579,7 @@ export class Sheet { _tabColor: string | null; _hyperlinks: HyperlinkEntry[]; _images: ImageEntry[]; - _rowOutline: Map; + _rowOutline: Map; _colOutline: ColumnOutlineEntry[]; _protection: SheetProtectionOptions | null; _printArea: string | null; @@ -786,8 +793,11 @@ export class Sheet { const o = opts || {}; const lvl = o.level || 1; for (let r = from; r <= to; r++) { - const cur = this._rowOutline.get(r) || 0; - this._rowOutline.set(r, Math.max(cur, lvl)); + const cur = this._rowOutline.get(r); + this._rowOutline.set(r, { + level: Math.max(cur ? cur.level : 0, lvl), + collapsed: !!(cur?.collapsed || o.collapsed), + }); } return this; } @@ -800,7 +810,12 @@ export class Sheet { const o = opts || {}; const f = typeof from === "string" ? colToNum(from) : from; const t = typeof to === "string" ? colToNum(to) : to; - this._colOutline.push({ from: f, to: t, level: o.level || 1 }); + this._colOutline.push({ + from: f, + to: t, + level: o.level || 1, + collapsed: !!o.collapsed, + }); return this; } @@ -1754,7 +1769,7 @@ function buildDataValXml(dvList: readonly DataValidationEntry[]): string { x += ">"; if (dv.type === "list" || !dv.type) { if (dv.values) - x += '"' + [...dv.values].join(",") + '"'; + x += '"' + inlineListFormula(dv.values) + '"'; else if (dv.formula) x += "" + escapeXml(dv.formula) + ""; } else if (dv.type === "custom") @@ -1768,6 +1783,16 @@ function buildDataValXml(dvList: readonly DataValidationEntry[]): string { return x + ""; } +function inlineListFormula(values: readonly string[]): string { + for (const value of values) { + if (value.includes(",") || value.includes('"')) + throw new Error( + "Inline XLSX validation lists cannot contain comma or quote characters; use a formula range instead", + ); + } + return escapeXml([...values].join(",")); +} + function buildSparklineXml( sparkGroups: readonly SparklineOptions[], sheetName: string, @@ -2233,6 +2258,7 @@ export class Workbook { if (rows.length) { minR = Math.min(...rows); maxR = Math.max(...rows); + minC = Infinity; for (const [, rc] of sh._rows) { const cols = [...rc.keys()]; if (cols.length) { @@ -2240,6 +2266,7 @@ export class Workbook { maxC = Math.max(maxC, ...cols); } } + if (!Number.isFinite(minC)) minC = 1; } x += '(); + const colOutMap = new Map(); for (const cg of sh._colOutline) for (let c = cg.from; c <= cg.to; c++) - colOutMap.set(c, Math.max(colOutMap.get(c) || 0, cg.level)); + colOutMap.set(c, { + from: c, + to: c, + level: Math.max(colOutMap.get(c)?.level || 0, cg.level), + collapsed: !!(colOutMap.get(c)?.collapsed || cg.collapsed), + }); const allCols = new Set([...sh._colW.keys(), ...colOutMap.keys()]); if (allCols.size) { x += ""; for (const c of [...allCols].sort((a, b) => a - b)) { const w = sh._colW.get(c) || 8.43; - const ol = colOutMap.get(c) || 0; + const ol = colOutMap.get(c); x += ' a - b)) { const cell = rc.get(cn)!; @@ -2304,15 +2343,15 @@ export class Workbook { x += ''; diff --git a/builtin-modules/xlsx.json b/builtin-modules/xlsx.json index 27c24e4..e2a45a3 100644 --- a/builtin-modules/xlsx.json +++ b/builtin-modules/xlsx.json @@ -3,8 +3,8 @@ "description": "Excel XLSX workbook builder - cells, styles, formulas, tables, charts, pivots, sparklines, validation, hyperlinks, images, print setup", "author": "system", "mutable": false, - "sourceHash": "sha256:452f9d8d980cd824", - "dtsHash": "sha256:15aa0248dc90a4f5", + "sourceHash": "sha256:85eeace97a71a974", + "dtsHash": "sha256:cef548ac935b1a80", "importStyle": "named", "hints": { "overview": "Create Excel .xlsx workbooks using strongly typed workbook, sheet, style, chart, pivot, validation, image, hyperlink, and print setup APIs.", diff --git a/tests/sandbox-tool.test.ts b/tests/sandbox-tool.test.ts index 05310a6..c146195 100644 --- a/tests/sandbox-tool.test.ts +++ b/tests/sandbox-tool.test.ts @@ -2067,6 +2067,7 @@ describe("xlsx builtin module", () => { " parsed: parseCellRef('BC42'),", " ref: cellRef(99, 28),", " serial: dateToSerial(new Date(1900, 0, 1)),", + " leapBugSerial: dateToSerial(new Date(1900, 2, 1)),", " invalidRef,", " };", "}", @@ -2081,11 +2082,50 @@ describe("xlsx builtin module", () => { num703: "AAA", parsed: { col: 55, row: 42 }, ref: "AB99", - serial: 2, + serial: 1, + leapBugSerial: 61, invalidRef: "Invalid cell ref: not-a-cell", }); }); + it("should write accurate dimensions and safe validation list XML", async () => { + const tool = createSandboxTool({ + inputBufferKb: 512, + outputBufferKb: 1024, + }); + loadXlsxModules(tool); + + await tool.registerHandler( + "xlsx-validation-xml-test", + [ + 'import { createWorkbook } from "ha:xlsx";', + "export function handler() {", + " const wb = createWorkbook();", + " const sh = wb.addSheet('Validation');", + " sh.setCell('C3', 'First');", + " sh.setCell('D4', 'Last');", + " sh.addDataValidation('C3:C10', { type: 'list', values: ['R&D', ''] });", + " const bytes = wb.build();", + " let invalidMessage = '';", + " try {", + " const bad = createWorkbook();", + " bad.addSheet('Bad').addDataValidation('A1:A2', { type: 'list', values: ['Needs, comma'] });", + " bad.build();", + " } catch (err) { invalidMessage = err.message; }", + " return { bytes: Array.from(bytes), invalidMessage };", + "}", + ].join("\n"), + ); + + const r = await tool.executeJavaScript("xlsx-validation-xml-test"); + expect(r.success).toBe(true); + const entries = parseZipEntries(r.result.bytes); + const sheetXml = zipText(entries, "xl/worksheets/sheet1.xml"); + expect(sheetXml).toContain(''); + expect(sheetXml).toContain('"R&D,<Open>"'); + expect(r.result.invalidMessage).toContain("use a formula range instead"); + }); + it("should write core workbook entries and worksheet layout XML", async () => { const tool = createSandboxTool({ inputBufferKb: 512, @@ -2107,7 +2147,7 @@ describe("xlsx builtin module", () => { " sh.addRow(5, ['Total', 3, '=SUM(C3:C4)']);", " sh.setColumnWidth('A', 22).setRowHeight(1, 24);", " sh.mergeCells('A1', 'C1').freezeRows(1).freezeColumns(1).setAutoFilter('A2:C5');", - " sh.groupRows(3, 4, { level: 2 }).groupColumns('B', 'C', { level: 1 });", + " sh.groupRows(3, 4, { level: 2, collapsed: true }).groupColumns('B', 'C', { level: 1, collapsed: true });", " sh.setTabColor('#FFAA00');", " sh.protect({ password: 'secret', allowSort: true, allowFilter: true });", " sh.setPrintArea('A1:C5');", @@ -2149,15 +2189,15 @@ describe("xlsx builtin module", () => { '', ); expect(sheetXml).toContain( - '', + '', ); expect(sheetXml).toContain(''); expect(sheetXml).toContain(''); expect(sheetXml).toContain(