@lotics/xlsx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- package/src/xml_entities.ts +23 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// OOXML Table Parser
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Parses xl/tables/tableN.xml files into ParsedTable objects.
|
|
6
|
+
// Structured tables in Excel define header rows, totals rows, banded styling,
|
|
7
|
+
// auto-filters, and column definitions with structured references.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import type { ParsedTable, TableColumn } from "./ooxml_chart_types";
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Parser
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a table XML string into a ParsedTable object.
|
|
18
|
+
*/
|
|
19
|
+
export function parseTableXml(
|
|
20
|
+
xml: string,
|
|
21
|
+
parseXml: (xml: string) => Record<string, unknown>,
|
|
22
|
+
): ParsedTable {
|
|
23
|
+
const doc = parseXml(xml);
|
|
24
|
+
const table = doc["table"] ?? doc;
|
|
25
|
+
const t = table as Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
const name = strAttr(t, "name") ?? "Table1";
|
|
28
|
+
const displayName = strAttr(t, "displayName") ?? name;
|
|
29
|
+
const ref = strAttr(t, "ref") ?? "A1:A1";
|
|
30
|
+
|
|
31
|
+
// Header/totals row
|
|
32
|
+
const headerRowCount = parseInt(strAttr(t, "headerRowCount") ?? "1", 10);
|
|
33
|
+
const totalsRowCount = parseInt(strAttr(t, "totalsRowCount") ?? "0", 10);
|
|
34
|
+
|
|
35
|
+
// Auto filter
|
|
36
|
+
const autoFilter = t["autoFilter"] !== undefined;
|
|
37
|
+
|
|
38
|
+
// Style
|
|
39
|
+
const tableStyleInfo = t["tableStyleInfo"];
|
|
40
|
+
let styleName: string | undefined;
|
|
41
|
+
if (tableStyleInfo && typeof tableStyleInfo === "object") {
|
|
42
|
+
const raw = (tableStyleInfo as Record<string, unknown>)["@_name"];
|
|
43
|
+
styleName = typeof raw === "string" ? raw : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Columns
|
|
47
|
+
const columns = parseTableColumns(t);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
displayName,
|
|
52
|
+
ref,
|
|
53
|
+
columns,
|
|
54
|
+
headerRow: headerRowCount > 0,
|
|
55
|
+
totalsRow: totalsRowCount > 0,
|
|
56
|
+
styleName,
|
|
57
|
+
autoFilter,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =============================================================================
|
|
62
|
+
// Column parsing
|
|
63
|
+
// =============================================================================
|
|
64
|
+
|
|
65
|
+
function parseTableColumns(table: Record<string, unknown>): TableColumn[] {
|
|
66
|
+
const cols: TableColumn[] = [];
|
|
67
|
+
const tableCols = table["tableColumns"];
|
|
68
|
+
if (!tableCols || typeof tableCols !== "object") return cols;
|
|
69
|
+
|
|
70
|
+
const colNodes = ensureArray((tableCols as Record<string, unknown>)["tableColumn"]);
|
|
71
|
+
for (const colNode of colNodes) {
|
|
72
|
+
if (!colNode || typeof colNode !== "object") continue;
|
|
73
|
+
const c = colNode as Record<string, unknown>;
|
|
74
|
+
|
|
75
|
+
const id = parseInt(strAttr(c, "id") ?? "0", 10);
|
|
76
|
+
const name = strAttr(c, "name") ?? `Column${id}`;
|
|
77
|
+
const totalsFunction = strAttr(c, "totalsRowFunction");
|
|
78
|
+
const rawFormula = c["totalsRowFormula"];
|
|
79
|
+
const totalsFormula = typeof rawFormula === "string" ? rawFormula : undefined;
|
|
80
|
+
|
|
81
|
+
cols.push({ id, name, totalsFunction, totalsFormula });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return cols;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// Relationship helpers
|
|
89
|
+
// =============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse table relationships from a sheet rels file.
|
|
93
|
+
* Returns a map of rId → table path.
|
|
94
|
+
*/
|
|
95
|
+
export function extractTableRIds(
|
|
96
|
+
rels: Record<string, unknown>,
|
|
97
|
+
): Map<string, string> {
|
|
98
|
+
const result = new Map<string, string>();
|
|
99
|
+
const relationships = rels["Relationships"];
|
|
100
|
+
if (!relationships || typeof relationships !== "object") return result;
|
|
101
|
+
|
|
102
|
+
const relNodes = ensureArray((relationships as Record<string, unknown>)["Relationship"]);
|
|
103
|
+
for (const rel of relNodes) {
|
|
104
|
+
if (!rel || typeof rel !== "object") continue;
|
|
105
|
+
const r = rel as Record<string, unknown>;
|
|
106
|
+
const type = r["@_Type"] as string | undefined;
|
|
107
|
+
if (type?.includes("/table")) {
|
|
108
|
+
const id = r["@_Id"] as string | undefined;
|
|
109
|
+
const target = r["@_Target"] as string | undefined;
|
|
110
|
+
if (id && target) {
|
|
111
|
+
result.set(id, target);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// =============================================================================
|
|
120
|
+
// Helpers
|
|
121
|
+
// =============================================================================
|
|
122
|
+
|
|
123
|
+
function strAttr(obj: Record<string, unknown>, attr: string): string | undefined {
|
|
124
|
+
const val = obj[`@_${attr}`];
|
|
125
|
+
return typeof val === "string" ? val : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ensureArray(val: unknown): unknown[] {
|
|
129
|
+
if (val === undefined || val === null) return [];
|
|
130
|
+
return Array.isArray(val) ? val : [val];
|
|
131
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseWorkbook } from "./ooxml_workbook";
|
|
3
|
+
|
|
4
|
+
describe("parseWorkbook", () => {
|
|
5
|
+
it("reads calcPr.fullCalcOnLoad=1", () => {
|
|
6
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
7
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
8
|
+
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
|
|
9
|
+
<calcPr fullCalcOnLoad="1"/>
|
|
10
|
+
</workbook>`;
|
|
11
|
+
const info = parseWorkbook(xml);
|
|
12
|
+
expect(info.fullCalcOnLoad).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("treats calcPr.fullCalcOnLoad=true as truthy", () => {
|
|
16
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
17
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
18
|
+
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
|
|
19
|
+
<calcPr fullCalcOnLoad="true"/>
|
|
20
|
+
</workbook>`;
|
|
21
|
+
expect(parseWorkbook(xml).fullCalcOnLoad).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("defaults fullCalcOnLoad=false when calcPr is absent", () => {
|
|
25
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
26
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
27
|
+
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
|
|
28
|
+
</workbook>`;
|
|
29
|
+
expect(parseWorkbook(xml).fullCalcOnLoad).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("defaults fullCalcOnLoad=false when calcPr omits the attribute", () => {
|
|
33
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
|
35
|
+
<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
|
|
36
|
+
<calcPr calcMode="auto"/>
|
|
37
|
+
</workbook>`;
|
|
38
|
+
expect(parseWorkbook(xml).fullCalcOnLoad).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
import type { RichTextPart, RichTextFont } from "./types";
|
|
3
|
+
import type { ThemeData, OoxmlColor } from "./ooxml_color";
|
|
4
|
+
import { parseColorElement, resolveColor, prefixHex } from "./ooxml_color";
|
|
5
|
+
import { decodeXmlEntities } from "./xml_entities";
|
|
6
|
+
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// =============================================================================
|
|
10
|
+
|
|
11
|
+
export interface WorkbookInfo {
|
|
12
|
+
sheets: { name: string; rId: string; state?: string }[];
|
|
13
|
+
activeSheetIndex: number;
|
|
14
|
+
date1904: boolean;
|
|
15
|
+
namedRanges: Map<string, string>;
|
|
16
|
+
/** Sheet-scoped `_xlnm.Print_Titles` raw reference strings, keyed by sheet index. */
|
|
17
|
+
printTitlesBySheet: Map<number, string>;
|
|
18
|
+
/**
|
|
19
|
+
* Excel's `<calcPr fullCalcOnLoad="1"/>` flag. When true, consumers must
|
|
20
|
+
* recompute every formula on file open — cached `<v>` values cannot be
|
|
21
|
+
* trusted. Writers set this when they can't guarantee cache freshness
|
|
22
|
+
* (e.g. our template-substitution pipeline before the formula engine
|
|
23
|
+
* was wired in, or files produced by tools that never compute).
|
|
24
|
+
*/
|
|
25
|
+
fullCalcOnLoad: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SharedStringEntry {
|
|
29
|
+
text: string;
|
|
30
|
+
richText?: RichTextPart[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// XML parser
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
const xmlParser = new XMLParser({
|
|
38
|
+
ignoreAttributes: false,
|
|
39
|
+
attributeNamePrefix: "@_",
|
|
40
|
+
trimValues: false,
|
|
41
|
+
processEntities: false,
|
|
42
|
+
isArray: (tagName) =>
|
|
43
|
+
tagName === "sheet" ||
|
|
44
|
+
tagName === "si" ||
|
|
45
|
+
tagName === "r" ||
|
|
46
|
+
tagName === "Relationship" ||
|
|
47
|
+
tagName === "definedName",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Workbook parsing
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
export function parseWorkbook(workbookXml: string): WorkbookInfo {
|
|
55
|
+
const doc = xmlParser.parse(workbookXml);
|
|
56
|
+
const wb = doc?.["workbook"];
|
|
57
|
+
if (!wb) return { sheets: [], activeSheetIndex: 0, date1904: false, namedRanges: new Map(), printTitlesBySheet: new Map(), fullCalcOnLoad: false };
|
|
58
|
+
|
|
59
|
+
// Sheets
|
|
60
|
+
const sheetsEl = wb["sheets"];
|
|
61
|
+
const sheetArr = sheetsEl?.["sheet"] as Array<Record<string, string>> | undefined;
|
|
62
|
+
const sheets = (sheetArr ?? []).map((s) => ({
|
|
63
|
+
name: decodeXmlEntities(s["@_name"] ?? "Sheet"),
|
|
64
|
+
rId: s["@_r:id"] ?? "",
|
|
65
|
+
state: s["@_state"],
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Active sheet
|
|
69
|
+
let activeSheetIndex = 0;
|
|
70
|
+
const bookViews = wb["bookViews"];
|
|
71
|
+
if (bookViews) {
|
|
72
|
+
const wbView = bookViews["workbookView"];
|
|
73
|
+
if (wbView) {
|
|
74
|
+
const activeTab = (Array.isArray(wbView) ? wbView[0] : wbView)?.["@_activeTab"];
|
|
75
|
+
if (activeTab !== undefined) {
|
|
76
|
+
activeSheetIndex = parseInt(activeTab, 10);
|
|
77
|
+
if (isNaN(activeSheetIndex)) activeSheetIndex = 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Date system
|
|
83
|
+
const wp = wb["workbookPr"];
|
|
84
|
+
const date1904 = wp?.["@_date1904"] === "1" || wp?.["@_date1904"] === "true";
|
|
85
|
+
|
|
86
|
+
// Recompute-on-load flag
|
|
87
|
+
const calcPr = wb["calcPr"] as Record<string, string> | undefined;
|
|
88
|
+
const fullCalcOnLoad = calcPr?.["@_fullCalcOnLoad"] === "1" || calcPr?.["@_fullCalcOnLoad"] === "true";
|
|
89
|
+
|
|
90
|
+
// Named ranges (definedNames) — workbook-scoped names plus sheet-scoped
|
|
91
|
+
// built-ins like `_xlnm.Print_Titles` (duplicated per sheet via localSheetId).
|
|
92
|
+
const namedRanges = new Map<string, string>();
|
|
93
|
+
const printTitlesBySheet = new Map<number, string>();
|
|
94
|
+
const defNamesEl = wb["definedNames"] as Record<string, unknown> | undefined;
|
|
95
|
+
if (defNamesEl) {
|
|
96
|
+
const raw = defNamesEl["definedName"];
|
|
97
|
+
const defArr: Array<Record<string, unknown>> = Array.isArray(raw)
|
|
98
|
+
? (raw as Array<Record<string, unknown>>)
|
|
99
|
+
: raw && typeof raw === "object"
|
|
100
|
+
? [raw as Record<string, unknown>]
|
|
101
|
+
: [];
|
|
102
|
+
for (const dn of defArr) {
|
|
103
|
+
const nameRaw = dn["@_name"] as string | undefined;
|
|
104
|
+
const textRaw = dn["#text"] as string | undefined;
|
|
105
|
+
if (!nameRaw || !textRaw) continue;
|
|
106
|
+
const name = decodeXmlEntities(nameRaw);
|
|
107
|
+
const text = decodeXmlEntities(textRaw);
|
|
108
|
+
const localSheetIdRaw = dn["@_localSheetId"] as string | undefined;
|
|
109
|
+
if (name === "_xlnm.Print_Titles" && localSheetIdRaw !== undefined) {
|
|
110
|
+
const idx = parseInt(localSheetIdRaw, 10);
|
|
111
|
+
if (!Number.isNaN(idx)) printTitlesBySheet.set(idx, text);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
namedRanges.set(name, text);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { sheets, activeSheetIndex, date1904, namedRanges, printTitlesBySheet, fullCalcOnLoad };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// =============================================================================
|
|
122
|
+
// Workbook relationships
|
|
123
|
+
// =============================================================================
|
|
124
|
+
|
|
125
|
+
export function parseWorkbookRels(relsXml: string): Map<string, string> {
|
|
126
|
+
const doc = xmlParser.parse(relsXml);
|
|
127
|
+
const rels = doc?.["Relationships"];
|
|
128
|
+
if (!rels) return new Map();
|
|
129
|
+
|
|
130
|
+
const relArr = rels["Relationship"] as Array<Record<string, string>> | undefined;
|
|
131
|
+
if (!relArr) return new Map();
|
|
132
|
+
|
|
133
|
+
const map = new Map<string, string>();
|
|
134
|
+
for (const rel of relArr) {
|
|
135
|
+
const id = rel["@_Id"];
|
|
136
|
+
const target = rel["@_Target"];
|
|
137
|
+
if (id && target) {
|
|
138
|
+
map.set(id, decodeXmlEntities(target));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// =============================================================================
|
|
145
|
+
// Shared strings
|
|
146
|
+
// =============================================================================
|
|
147
|
+
|
|
148
|
+
export function parseSharedStrings(
|
|
149
|
+
ssXml: string,
|
|
150
|
+
theme: ThemeData,
|
|
151
|
+
indexedColors: string[],
|
|
152
|
+
): SharedStringEntry[] {
|
|
153
|
+
const doc = xmlParser.parse(ssXml);
|
|
154
|
+
const sst = doc?.["sst"];
|
|
155
|
+
if (!sst) return [];
|
|
156
|
+
|
|
157
|
+
const siArr = sst["si"] as Array<Record<string, unknown>> | undefined;
|
|
158
|
+
if (!siArr) return [];
|
|
159
|
+
|
|
160
|
+
return siArr.map((si) => parseSharedStringItem(si, theme, indexedColors));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseSharedStringItem(
|
|
164
|
+
si: Record<string, unknown>,
|
|
165
|
+
theme: ThemeData,
|
|
166
|
+
indexedColors: string[],
|
|
167
|
+
): SharedStringEntry {
|
|
168
|
+
// Plain text: <si><t>text</t></si>
|
|
169
|
+
const t = si["t"];
|
|
170
|
+
if (t !== undefined) {
|
|
171
|
+
const raw = typeof t === "object" && t !== null ? (t as Record<string, string>)["#text"] ?? "" : String(t);
|
|
172
|
+
return { text: decodeXmlEntities(raw) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Rich text: <si><r><rPr>...</rPr><t>text</t></r>...</si>
|
|
176
|
+
const rArr = si["r"] as Array<Record<string, unknown>> | undefined;
|
|
177
|
+
if (!rArr) return { text: "" };
|
|
178
|
+
|
|
179
|
+
const parts: RichTextPart[] = [];
|
|
180
|
+
let fullText = "";
|
|
181
|
+
|
|
182
|
+
for (const run of rArr) {
|
|
183
|
+
const runT = run["t"];
|
|
184
|
+
const rawRun = typeof runT === "object" && runT !== null ? (runT as Record<string, string>)["#text"] ?? "" : String(runT ?? "");
|
|
185
|
+
const text = decodeXmlEntities(rawRun);
|
|
186
|
+
fullText += text;
|
|
187
|
+
|
|
188
|
+
const rPr = run["rPr"] as Record<string, unknown> | undefined;
|
|
189
|
+
const part: RichTextPart = { text };
|
|
190
|
+
|
|
191
|
+
if (rPr) {
|
|
192
|
+
const font = parseRichTextFont(rPr, theme, indexedColors);
|
|
193
|
+
if (font) part.font = font;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
parts.push(part);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// If no runs have font properties, treat as plain text
|
|
200
|
+
const hasFormatting = parts.some((p) => p.font !== undefined);
|
|
201
|
+
if (!hasFormatting) {
|
|
202
|
+
return { text: fullText };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { text: fullText, richText: parts };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parseRichTextFont(
|
|
209
|
+
rPr: Record<string, unknown>,
|
|
210
|
+
theme: ThemeData,
|
|
211
|
+
indexedColors: string[],
|
|
212
|
+
): RichTextFont | undefined {
|
|
213
|
+
const result: RichTextFont = {};
|
|
214
|
+
let hasProps = false;
|
|
215
|
+
|
|
216
|
+
if (rPr["b"] !== undefined) { result.bold = true; hasProps = true; }
|
|
217
|
+
if (rPr["i"] !== undefined) { result.italic = true; hasProps = true; }
|
|
218
|
+
if (rPr["strike"] !== undefined) { result.strike = true; hasProps = true; }
|
|
219
|
+
|
|
220
|
+
const u = rPr["u"] as Record<string, string> | string | undefined;
|
|
221
|
+
if (u !== undefined) {
|
|
222
|
+
if (typeof u === "object" && u["@_val"] === "double") {
|
|
223
|
+
result.underline = "double";
|
|
224
|
+
} else {
|
|
225
|
+
result.underline = "single";
|
|
226
|
+
}
|
|
227
|
+
hasProps = true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const va = rPr["vertAlign"] as Record<string, string> | undefined;
|
|
231
|
+
if (va?.["@_val"] === "superscript" || va?.["@_val"] === "subscript") {
|
|
232
|
+
result.vertAlign = va["@_val"];
|
|
233
|
+
hasProps = true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sz = rPr["sz"] as Record<string, string> | undefined;
|
|
237
|
+
if (sz?.["@_val"]) {
|
|
238
|
+
result.size = parseFloat(sz["@_val"]);
|
|
239
|
+
hasProps = true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const name = rPr["rFont"] as Record<string, string> | undefined;
|
|
243
|
+
if (name?.["@_val"]) {
|
|
244
|
+
result.name = name["@_val"];
|
|
245
|
+
hasProps = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const colorEl = rPr["color"] as Record<string, string> | undefined;
|
|
249
|
+
if (colorEl) {
|
|
250
|
+
const ooxmlColor: OoxmlColor = parseColorElement(colorEl);
|
|
251
|
+
const hex = resolveColor(ooxmlColor, theme, indexedColors, "font");
|
|
252
|
+
if (hex) {
|
|
253
|
+
result.color = prefixHex(hex);
|
|
254
|
+
hasProps = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return hasProps ? result : undefined;
|
|
259
|
+
}
|
package/src/ooxml_zip.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { unzipSync } from "fflate";
|
|
2
|
+
import { PasswordProtectedError } from "./types";
|
|
3
|
+
|
|
4
|
+
// OLE2 Compound Document magic bytes — used by legacy .xls and encrypted .xlsx
|
|
5
|
+
const OLE2_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Unzip an .xlsx file and return a map of entry paths to their raw bytes.
|
|
9
|
+
* Detects OLE2-encrypted files and throws PasswordProtectedError.
|
|
10
|
+
*/
|
|
11
|
+
export function unzipXlsx(buffer: ArrayBuffer): Record<string, Uint8Array> {
|
|
12
|
+
const bytes = new Uint8Array(buffer);
|
|
13
|
+
|
|
14
|
+
// Check for OLE2 magic (encrypted .xlsx or legacy .xls)
|
|
15
|
+
if (bytes.length >= 8 && OLE2_MAGIC.every((b, i) => bytes[i] === b)) {
|
|
16
|
+
throw new PasswordProtectedError();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return unzipSync(bytes);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
23
|
+
if (msg.includes("encrypted") || msg.includes("password")) {
|
|
24
|
+
throw new PasswordProtectedError();
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`Failed to unzip .xlsx file: ${msg}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Decode a Uint8Array as UTF-8 text. */
|
|
31
|
+
export function decodeUtf8(bytes: Uint8Array): string {
|
|
32
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
33
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Pivot Table Model
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Mutable counterpart of `ParsedPivotTable`. A SheetModel carries one or more
|
|
6
|
+
// of these. Each model owns:
|
|
7
|
+
// - a static config (axes, fields, layout flags) the user manipulates via
|
|
8
|
+
// the editor UI
|
|
9
|
+
// - a reference to the workbook-level cache definition
|
|
10
|
+
// - a derived `result` grid produced by `recompute()`
|
|
11
|
+
//
|
|
12
|
+
// Recompute is intentionally pull-based and idempotent: callers run it after
|
|
13
|
+
// editing the pivot config, after the source range changes, or before render.
|
|
14
|
+
// The model never mutates the host sheet's cell map; the renderer reads
|
|
15
|
+
// `result` directly and overlays it onto the grid.
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
import type { ParsedPivotCache, ParsedPivotTable } from "./ooxml_pivot_types";
|
|
19
|
+
import type { WorkbookModel, SheetModel } from "./workbook_model";
|
|
20
|
+
import { refToRowCol, rowColToRef } from "./excel_utils";
|
|
21
|
+
import {
|
|
22
|
+
recomputePivot,
|
|
23
|
+
type PivotResultGrid,
|
|
24
|
+
type PivotSourceData,
|
|
25
|
+
type PivotSourceRecord,
|
|
26
|
+
} from "./pivot_recompute";
|
|
27
|
+
|
|
28
|
+
export class PivotTableModel {
|
|
29
|
+
config: ParsedPivotTable;
|
|
30
|
+
cache: ParsedPivotCache;
|
|
31
|
+
/** Last computed result, or undefined before first recompute. */
|
|
32
|
+
result: PivotResultGrid | undefined;
|
|
33
|
+
|
|
34
|
+
constructor(config: ParsedPivotTable, cache: ParsedPivotCache) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.cache = cache;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Recompute the pivot result from the workbook's current source data.
|
|
41
|
+
* Callers are responsible for triggering recomputation; the model does
|
|
42
|
+
* not subscribe to workbook changes itself.
|
|
43
|
+
*/
|
|
44
|
+
recompute(workbook: WorkbookModel): void {
|
|
45
|
+
const source = readSourceData(workbook, this.cache);
|
|
46
|
+
if (!source) {
|
|
47
|
+
this.result = undefined;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.result = recomputePivot(this.config, this.cache, source);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Source reader
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the cache's source range into a `PivotSourceData`. Returns undefined
|
|
60
|
+
* when the source can't be resolved (external/consolidation/missing sheet).
|
|
61
|
+
*
|
|
62
|
+
* Shape: row 1 of the range = header text; rows 2..N = records.
|
|
63
|
+
*/
|
|
64
|
+
export function readSourceData(
|
|
65
|
+
workbook: WorkbookModel,
|
|
66
|
+
cache: ParsedPivotCache,
|
|
67
|
+
): PivotSourceData | undefined {
|
|
68
|
+
if (cache.source.type !== "worksheet") return undefined;
|
|
69
|
+
const source = cache.source;
|
|
70
|
+
const sheet = workbook.sheets.find((s) => s.name === source.sheetName);
|
|
71
|
+
if (!sheet) return undefined;
|
|
72
|
+
|
|
73
|
+
const range = parseRange(source.ref);
|
|
74
|
+
if (!range) return undefined;
|
|
75
|
+
|
|
76
|
+
const header: string[] = [];
|
|
77
|
+
for (let col = range.startCol; col <= range.endCol; col++) {
|
|
78
|
+
const cell = sheet.getCell(rowColToRef(range.startRow, col));
|
|
79
|
+
header.push(formatHeader(cell?.value));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const records: PivotSourceRecord[] = [];
|
|
83
|
+
for (let row = range.startRow + 1; row <= range.endRow; row++) {
|
|
84
|
+
const rec: PivotSourceRecord = [];
|
|
85
|
+
for (let col = range.startCol; col <= range.endCol; col++) {
|
|
86
|
+
const cell = sheet.getCell(rowColToRef(row, col));
|
|
87
|
+
rec.push(coerceValue(cell?.value));
|
|
88
|
+
}
|
|
89
|
+
records.push(rec);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { header, records };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatHeader(v: unknown): string {
|
|
96
|
+
if (v === undefined || v === null) return "";
|
|
97
|
+
return String(v);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function coerceValue(
|
|
101
|
+
v: unknown,
|
|
102
|
+
): string | number | boolean | null | undefined {
|
|
103
|
+
if (v === undefined || v === null) return undefined;
|
|
104
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
105
|
+
return v;
|
|
106
|
+
}
|
|
107
|
+
return String(v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface ParsedRange {
|
|
111
|
+
startRow: number;
|
|
112
|
+
startCol: number;
|
|
113
|
+
endRow: number;
|
|
114
|
+
endCol: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseRange(ref: string): ParsedRange | undefined {
|
|
118
|
+
// Strip optional "Sheet!" prefix; the caller already resolved the sheet.
|
|
119
|
+
const range = ref.includes("!") ? ref.split("!")[1] : ref;
|
|
120
|
+
const cleaned = range.replace(/\$/g, "");
|
|
121
|
+
const m = cleaned.match(/^([A-Z]+\d+)(?::([A-Z]+\d+))?$/);
|
|
122
|
+
if (!m) return undefined;
|
|
123
|
+
const start = refToRowCol(m[1]);
|
|
124
|
+
if (!start) return undefined;
|
|
125
|
+
const endRef = m[2] ?? m[1];
|
|
126
|
+
const end = refToRowCol(endRef);
|
|
127
|
+
if (!end) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
startRow: start.row,
|
|
130
|
+
startCol: start.col,
|
|
131
|
+
endRow: end.row,
|
|
132
|
+
endCol: end.col,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// SheetModel integration
|
|
138
|
+
// =============================================================================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether `(row, col)` falls inside any pivot's anchor range. Used by the
|
|
142
|
+
* editing layer to block direct cell edits inside a pivot — Excel rejects
|
|
143
|
+
* such edits and so do we.
|
|
144
|
+
*/
|
|
145
|
+
export function pointInsideAnyPivot(
|
|
146
|
+
sheet: SheetModel,
|
|
147
|
+
row: number,
|
|
148
|
+
col: number,
|
|
149
|
+
): boolean {
|
|
150
|
+
return findPivotAt(sheet, row, col) !== undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Return the pivot covering `(row, col)`, or undefined if none. */
|
|
154
|
+
export function findPivotAt(
|
|
155
|
+
sheet: SheetModel,
|
|
156
|
+
row: number,
|
|
157
|
+
col: number,
|
|
158
|
+
): PivotTableModel | undefined {
|
|
159
|
+
const pivots = sheet.pivotTables;
|
|
160
|
+
if (!pivots || pivots.length === 0) return undefined;
|
|
161
|
+
for (const p of pivots) {
|
|
162
|
+
const r = parseRange(p.config.ref);
|
|
163
|
+
if (!r) continue;
|
|
164
|
+
if (
|
|
165
|
+
row >= r.startRow &&
|
|
166
|
+
row <= r.endRow &&
|
|
167
|
+
col >= r.startCol &&
|
|
168
|
+
col <= r.endCol
|
|
169
|
+
) {
|
|
170
|
+
return p;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Look up the pivot result cell at sheet coordinates `(row, col)`. Returns
|
|
178
|
+
* undefined when there is no pivot, the cell is outside the pivot's
|
|
179
|
+
* computed grid, or the pivot has not been recomputed.
|
|
180
|
+
*/
|
|
181
|
+
export function getPivotResultCellAt(
|
|
182
|
+
sheet: SheetModel,
|
|
183
|
+
row: number,
|
|
184
|
+
col: number,
|
|
185
|
+
):
|
|
186
|
+
| {
|
|
187
|
+
pivot: PivotTableModel;
|
|
188
|
+
cell: import("./pivot_recompute").PivotResultCell;
|
|
189
|
+
gridRow: number;
|
|
190
|
+
gridCol: number;
|
|
191
|
+
}
|
|
192
|
+
| undefined {
|
|
193
|
+
const pivot = findPivotAt(sheet, row, col);
|
|
194
|
+
if (!pivot || !pivot.result) return undefined;
|
|
195
|
+
const r = parseRange(pivot.config.ref);
|
|
196
|
+
if (!r) return undefined;
|
|
197
|
+
const gridRow = row - r.startRow;
|
|
198
|
+
const gridCol = col - r.startCol;
|
|
199
|
+
if (gridRow < 0 || gridCol < 0) return undefined;
|
|
200
|
+
if (gridRow >= pivot.result.rows || gridCol >= pivot.result.cols) return undefined;
|
|
201
|
+
const resultRow = pivot.result.cells[gridRow];
|
|
202
|
+
if (!resultRow) return undefined;
|
|
203
|
+
const cell = resultRow[gridCol];
|
|
204
|
+
if (!cell) return undefined;
|
|
205
|
+
return { pivot, cell, gridRow, gridCol };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render a `PivotResultCell` to a display string. Number-format hints from
|
|
210
|
+
* data fields are not honoured yet — values format with a default decimal
|
|
211
|
+
* representation (matches Excel's "General" for pivot defaults).
|
|
212
|
+
*/
|
|
213
|
+
export function formatPivotCell(
|
|
214
|
+
cell: import("./pivot_recompute").PivotResultCell,
|
|
215
|
+
): string {
|
|
216
|
+
switch (cell.kind) {
|
|
217
|
+
case "blank":
|
|
218
|
+
return "";
|
|
219
|
+
case "rowHeader":
|
|
220
|
+
case "colHeader":
|
|
221
|
+
case "valueLabel":
|
|
222
|
+
case "totalLabel":
|
|
223
|
+
return cell.text;
|
|
224
|
+
case "value":
|
|
225
|
+
case "rowTotal":
|
|
226
|
+
case "colTotal":
|
|
227
|
+
case "grandTotal":
|
|
228
|
+
return formatPivotNumber(cell.value);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatPivotNumber(v: number | null): string {
|
|
233
|
+
if (v === null) return "";
|
|
234
|
+
if (Number.isInteger(v)) return String(v);
|
|
235
|
+
// Trim trailing zeros from a 6-digit precision representation.
|
|
236
|
+
return Number(v.toFixed(6)).toString();
|
|
237
|
+
}
|