@niicojs/excel 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 ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@niicojs/excel",
3
+ "version": "0.1.0",
4
+ "description": "typescript library to manipulate excel files",
5
+ "homepage": "https://github.com/niicojs/excel#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/niicojs/excel/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/niicojs/excel.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": "niico",
15
+ "type": "module",
16
+ "main": "./dist/index.cjs",
17
+ "module": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "require": {
26
+ "types": "./dist/index.d.cts",
27
+ "default": "./dist/index.cjs"
28
+ }
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "scripts": {
37
+ "dev": "tsdx dev",
38
+ "build": "tsdx build",
39
+ "test": "tsdx test",
40
+ "test:watch": "tsdx test --watch",
41
+ "lint": "tsdx lint",
42
+ "format": "tsdx format",
43
+ "format:check": "tsdx format --check",
44
+ "typecheck": "tsdx typecheck",
45
+ "prepublishOnly": "bun run build"
46
+ },
47
+ "dependencies": {
48
+ "fast-xml-parser": "^5.3.3",
49
+ "fflate": "^0.8.2"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^25.0.10",
53
+ "bunchee": "^6.9.4",
54
+ "tsdx": "latest",
55
+ "typescript": "^5.7.2",
56
+ "vitest": "^4.0.18"
57
+ },
58
+ "engines": {
59
+ "node": ">=20"
60
+ }
61
+ }
package/src/cell.ts ADDED
@@ -0,0 +1,318 @@
1
+ import type { CellValue, CellType, CellStyle, CellData, ErrorType } from './types';
2
+ import type { Worksheet } from './worksheet';
3
+ import { parseAddress, toAddress } from './utils/address';
4
+
5
+ // Excel epoch: December 30, 1899 (accounting for the 1900 leap year bug)
6
+ const EXCEL_EPOCH = new Date(Date.UTC(1899, 11, 30));
7
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
+
9
+ // Excel error types
10
+ const ERROR_TYPES: Set<string> = new Set([
11
+ '#NULL!',
12
+ '#DIV/0!',
13
+ '#VALUE!',
14
+ '#REF!',
15
+ '#NAME?',
16
+ '#NUM!',
17
+ '#N/A',
18
+ '#GETTING_DATA',
19
+ ]);
20
+
21
+ /**
22
+ * Represents a single cell in a worksheet
23
+ */
24
+ export class Cell {
25
+ private _row: number;
26
+ private _col: number;
27
+ private _data: CellData;
28
+ private _worksheet: Worksheet;
29
+ private _dirty = false;
30
+
31
+ constructor(worksheet: Worksheet, row: number, col: number, data?: CellData) {
32
+ this._worksheet = worksheet;
33
+ this._row = row;
34
+ this._col = col;
35
+ this._data = data || {};
36
+ }
37
+
38
+ /**
39
+ * Get the cell address (e.g., 'A1')
40
+ */
41
+ get address(): string {
42
+ return toAddress(this._row, this._col);
43
+ }
44
+
45
+ /**
46
+ * Get the 0-based row index
47
+ */
48
+ get row(): number {
49
+ return this._row;
50
+ }
51
+
52
+ /**
53
+ * Get the 0-based column index
54
+ */
55
+ get col(): number {
56
+ return this._col;
57
+ }
58
+
59
+ /**
60
+ * Get the cell type
61
+ */
62
+ get type(): CellType {
63
+ const t = this._data.t;
64
+ if (!t && this._data.v === undefined && !this._data.f) {
65
+ return 'empty';
66
+ }
67
+ switch (t) {
68
+ case 'n':
69
+ return 'number';
70
+ case 's':
71
+ case 'str':
72
+ return 'string';
73
+ case 'b':
74
+ return 'boolean';
75
+ case 'e':
76
+ return 'error';
77
+ case 'd':
78
+ return 'date';
79
+ default:
80
+ // If no type but has value, infer from value
81
+ if (typeof this._data.v === 'number') return 'number';
82
+ if (typeof this._data.v === 'string') return 'string';
83
+ if (typeof this._data.v === 'boolean') return 'boolean';
84
+ return 'empty';
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the cell value
90
+ */
91
+ get value(): CellValue {
92
+ const t = this._data.t;
93
+ const v = this._data.v;
94
+
95
+ if (v === undefined && !this._data.f) {
96
+ return null;
97
+ }
98
+
99
+ switch (t) {
100
+ case 'n':
101
+ return typeof v === 'number' ? v : parseFloat(String(v));
102
+ case 's':
103
+ // Shared string reference
104
+ if (typeof v === 'number') {
105
+ return this._worksheet.workbook.sharedStrings.getString(v) ?? '';
106
+ }
107
+ return String(v);
108
+ case 'str':
109
+ // Inline string
110
+ return String(v);
111
+ case 'b':
112
+ return v === 1 || v === '1' || v === true;
113
+ case 'e':
114
+ return { error: String(v) as ErrorType };
115
+ case 'd':
116
+ // ISO 8601 date string
117
+ return new Date(String(v));
118
+ default:
119
+ // No type specified - try to infer
120
+ if (typeof v === 'number') {
121
+ // Check if this might be a date based on number format
122
+ if (this._isDateFormat()) {
123
+ return this._excelDateToJs(v);
124
+ }
125
+ return v;
126
+ }
127
+ if (typeof v === 'string') {
128
+ if (ERROR_TYPES.has(v)) {
129
+ return { error: v as ErrorType };
130
+ }
131
+ return v;
132
+ }
133
+ if (typeof v === 'boolean') return v;
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Set the cell value
140
+ */
141
+ set value(val: CellValue) {
142
+ this._dirty = true;
143
+
144
+ if (val === null || val === undefined) {
145
+ this._data.v = undefined;
146
+ this._data.t = undefined;
147
+ this._data.f = undefined;
148
+ return;
149
+ }
150
+
151
+ if (typeof val === 'number') {
152
+ this._data.v = val;
153
+ this._data.t = 'n';
154
+ } else if (typeof val === 'string') {
155
+ // Store as shared string
156
+ const index = this._worksheet.workbook.sharedStrings.addString(val);
157
+ this._data.v = index;
158
+ this._data.t = 's';
159
+ } else if (typeof val === 'boolean') {
160
+ this._data.v = val ? 1 : 0;
161
+ this._data.t = 'b';
162
+ } else if (val instanceof Date) {
163
+ // Store as ISO date string with 'd' type
164
+ this._data.v = val.toISOString();
165
+ this._data.t = 'd';
166
+ } else if ('error' in val) {
167
+ this._data.v = val.error;
168
+ this._data.t = 'e';
169
+ }
170
+
171
+ // Clear formula when setting value directly
172
+ this._data.f = undefined;
173
+ }
174
+
175
+ /**
176
+ * Write a 2D array of values starting at this cell
177
+ */
178
+ set values(data: CellValue[][]) {
179
+ for (let r = 0; r < data.length; r++) {
180
+ const row = data[r];
181
+ for (let c = 0; c < row.length; c++) {
182
+ const cell = this._worksheet.cell(this._row + r, this._col + c);
183
+ cell.value = row[c];
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Get the formula (without leading '=')
190
+ */
191
+ get formula(): string | undefined {
192
+ return this._data.f;
193
+ }
194
+
195
+ /**
196
+ * Set the formula (without leading '=')
197
+ */
198
+ set formula(f: string | undefined) {
199
+ this._dirty = true;
200
+ if (f === undefined) {
201
+ this._data.f = undefined;
202
+ } else {
203
+ // Remove leading '=' if present
204
+ this._data.f = f.startsWith('=') ? f.slice(1) : f;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get the formatted text (as displayed in Excel)
210
+ */
211
+ get text(): string {
212
+ if (this._data.w) {
213
+ return this._data.w;
214
+ }
215
+ const val = this.value;
216
+ if (val === null) return '';
217
+ if (typeof val === 'object' && 'error' in val) return val.error;
218
+ if (val instanceof Date) return val.toISOString().split('T')[0];
219
+ return String(val);
220
+ }
221
+
222
+ /**
223
+ * Get the style index
224
+ */
225
+ get styleIndex(): number | undefined {
226
+ return this._data.s;
227
+ }
228
+
229
+ /**
230
+ * Set the style index
231
+ */
232
+ set styleIndex(index: number | undefined) {
233
+ this._dirty = true;
234
+ this._data.s = index;
235
+ }
236
+
237
+ /**
238
+ * Get the cell style
239
+ */
240
+ get style(): CellStyle {
241
+ if (this._data.s === undefined) {
242
+ return {};
243
+ }
244
+ return this._worksheet.workbook.styles.getStyle(this._data.s);
245
+ }
246
+
247
+ /**
248
+ * Set the cell style (merges with existing)
249
+ */
250
+ set style(style: CellStyle) {
251
+ this._dirty = true;
252
+ const currentStyle = this.style;
253
+ const merged = { ...currentStyle, ...style };
254
+ this._data.s = this._worksheet.workbook.styles.createStyle(merged);
255
+ }
256
+
257
+ /**
258
+ * Check if cell has been modified
259
+ */
260
+ get dirty(): boolean {
261
+ return this._dirty;
262
+ }
263
+
264
+ /**
265
+ * Get internal cell data
266
+ */
267
+ get data(): CellData {
268
+ return this._data;
269
+ }
270
+
271
+ /**
272
+ * Check if this cell has a date number format
273
+ */
274
+ private _isDateFormat(): boolean {
275
+ // TODO: Check actual number format from styles
276
+ // For now, return false - dates should be explicitly typed
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * Convert Excel serial date to JavaScript Date
282
+ * Used when reading dates stored as numbers with date formats
283
+ */
284
+ _excelDateToJs(serial: number): Date {
285
+ // Excel incorrectly considers 1900 a leap year
286
+ // Dates after Feb 28, 1900 need adjustment
287
+ const adjusted = serial > 60 ? serial - 1 : serial;
288
+ const ms = Math.round((adjusted - 1) * MS_PER_DAY);
289
+ return new Date(EXCEL_EPOCH.getTime() + ms);
290
+ }
291
+
292
+ /**
293
+ * Convert JavaScript Date to Excel serial date
294
+ * Used when writing dates as numbers for Excel compatibility
295
+ */
296
+ _jsDateToExcel(date: Date): number {
297
+ const ms = date.getTime() - EXCEL_EPOCH.getTime();
298
+ let serial = ms / MS_PER_DAY + 1;
299
+ // Account for Excel's 1900 leap year bug
300
+ if (serial > 60) {
301
+ serial += 1;
302
+ }
303
+ return serial;
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Parse a cell address or row/col to get row and col indices
309
+ */
310
+ export const parseCellRef = (rowOrAddress: number | string, col?: number): { row: number; col: number } => {
311
+ if (typeof rowOrAddress === 'string') {
312
+ return parseAddress(rowOrAddress);
313
+ }
314
+ if (col === undefined) {
315
+ throw new Error('Column must be provided when row is a number');
316
+ }
317
+ return { row: rowOrAddress, col };
318
+ };
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Main exports
2
+ export { Workbook } from './workbook';
3
+ export { Worksheet } from './worksheet';
4
+ export { Cell } from './cell';
5
+ export { Range } from './range';
6
+ export { SharedStrings } from './shared-strings';
7
+ export { Styles } from './styles';
8
+ export { PivotTable } from './pivot-table';
9
+ export { PivotCache } from './pivot-cache';
10
+
11
+ // Type exports
12
+ export type {
13
+ CellValue,
14
+ CellType,
15
+ CellStyle,
16
+ CellError,
17
+ ErrorType,
18
+ CellAddress,
19
+ RangeAddress,
20
+ BorderStyle,
21
+ BorderType,
22
+ Alignment,
23
+ // Pivot table types
24
+ PivotTableConfig,
25
+ PivotValueConfig,
26
+ AggregationType,
27
+ PivotFieldAxis,
28
+ } from './types';
29
+
30
+ // Utility exports
31
+ export { parseAddress, toAddress, parseRange, toRange } from './utils/address';
@@ -0,0 +1,268 @@
1
+ import type { PivotCacheField, CellValue } from './types';
2
+ import { createElement, stringifyXml, XmlNode } from './utils/xml';
3
+
4
+ /**
5
+ * Manages the pivot cache (definition and records) for a pivot table.
6
+ * The cache stores source data metadata and cached values.
7
+ */
8
+ export class PivotCache {
9
+ private _cacheId: number;
10
+ private _sourceSheet: string;
11
+ private _sourceRange: string;
12
+ private _fields: PivotCacheField[] = [];
13
+ private _records: CellValue[][] = [];
14
+ private _recordCount = 0;
15
+ private _refreshOnLoad = true; // Default to true
16
+
17
+ constructor(cacheId: number, sourceSheet: string, sourceRange: string) {
18
+ this._cacheId = cacheId;
19
+ this._sourceSheet = sourceSheet;
20
+ this._sourceRange = sourceRange;
21
+ }
22
+
23
+ /**
24
+ * Get the cache ID
25
+ */
26
+ get cacheId(): number {
27
+ return this._cacheId;
28
+ }
29
+
30
+ /**
31
+ * Set refreshOnLoad option
32
+ */
33
+ set refreshOnLoad(value: boolean) {
34
+ this._refreshOnLoad = value;
35
+ }
36
+
37
+ /**
38
+ * Get refreshOnLoad option
39
+ */
40
+ get refreshOnLoad(): boolean {
41
+ return this._refreshOnLoad;
42
+ }
43
+
44
+ /**
45
+ * Get the source sheet name
46
+ */
47
+ get sourceSheet(): string {
48
+ return this._sourceSheet;
49
+ }
50
+
51
+ /**
52
+ * Get the source range
53
+ */
54
+ get sourceRange(): string {
55
+ return this._sourceRange;
56
+ }
57
+
58
+ /**
59
+ * Get the full source reference (Sheet!Range)
60
+ */
61
+ get sourceRef(): string {
62
+ return `${this._sourceSheet}!${this._sourceRange}`;
63
+ }
64
+
65
+ /**
66
+ * Get the fields in this cache
67
+ */
68
+ get fields(): PivotCacheField[] {
69
+ return this._fields;
70
+ }
71
+
72
+ /**
73
+ * Get the number of data records
74
+ */
75
+ get recordCount(): number {
76
+ return this._recordCount;
77
+ }
78
+
79
+ /**
80
+ * Build the cache from source data.
81
+ * @param headers - Array of column header names
82
+ * @param data - 2D array of data rows (excluding headers)
83
+ */
84
+ buildFromData(headers: string[], data: CellValue[][]): void {
85
+ this._recordCount = data.length;
86
+
87
+ // Initialize fields from headers
88
+ this._fields = headers.map((name, index) => ({
89
+ name,
90
+ index,
91
+ isNumeric: true,
92
+ isDate: false,
93
+ sharedItems: [],
94
+ minValue: undefined,
95
+ maxValue: undefined,
96
+ }));
97
+
98
+ // Analyze data to determine field types and collect unique values
99
+ for (const row of data) {
100
+ for (let colIdx = 0; colIdx < row.length && colIdx < this._fields.length; colIdx++) {
101
+ const value = row[colIdx];
102
+ const field = this._fields[colIdx];
103
+
104
+ if (value === null || value === undefined) {
105
+ continue;
106
+ }
107
+
108
+ if (typeof value === 'string') {
109
+ field.isNumeric = false;
110
+ if (!field.sharedItems.includes(value)) {
111
+ field.sharedItems.push(value);
112
+ }
113
+ } else if (typeof value === 'number') {
114
+ if (field.minValue === undefined || value < field.minValue) {
115
+ field.minValue = value;
116
+ }
117
+ if (field.maxValue === undefined || value > field.maxValue) {
118
+ field.maxValue = value;
119
+ }
120
+ } else if (value instanceof Date) {
121
+ field.isDate = true;
122
+ field.isNumeric = false;
123
+ } else if (typeof value === 'boolean') {
124
+ field.isNumeric = false;
125
+ }
126
+ }
127
+ }
128
+
129
+ // Store records
130
+ this._records = data;
131
+ }
132
+
133
+ /**
134
+ * Get field by name
135
+ */
136
+ getField(name: string): PivotCacheField | undefined {
137
+ return this._fields.find((f) => f.name === name);
138
+ }
139
+
140
+ /**
141
+ * Get field index by name
142
+ */
143
+ getFieldIndex(name: string): number {
144
+ const field = this._fields.find((f) => f.name === name);
145
+ return field ? field.index : -1;
146
+ }
147
+
148
+ /**
149
+ * Generate the pivotCacheDefinition XML
150
+ */
151
+ toDefinitionXml(recordsRelId: string): string {
152
+ const cacheFieldNodes: XmlNode[] = this._fields.map((field) => {
153
+ const sharedItemsAttrs: Record<string, string> = {};
154
+ const sharedItemChildren: XmlNode[] = [];
155
+
156
+ if (field.sharedItems.length > 0) {
157
+ // String field with shared items - Excel just uses count attribute
158
+ sharedItemsAttrs.count = String(field.sharedItems.length);
159
+
160
+ for (const item of field.sharedItems) {
161
+ sharedItemChildren.push(createElement('s', { v: item }, []));
162
+ }
163
+ } else if (field.isNumeric) {
164
+ // Numeric field - use "0"/"1" for boolean attributes as Excel expects
165
+ sharedItemsAttrs.containsSemiMixedTypes = '0';
166
+ sharedItemsAttrs.containsString = '0';
167
+ sharedItemsAttrs.containsNumber = '1';
168
+ // Check if all values are integers
169
+ if (field.minValue !== undefined && field.maxValue !== undefined) {
170
+ const isInteger = Number.isInteger(field.minValue) && Number.isInteger(field.maxValue);
171
+ if (isInteger) {
172
+ sharedItemsAttrs.containsInteger = '1';
173
+ }
174
+ sharedItemsAttrs.minValue = String(field.minValue);
175
+ sharedItemsAttrs.maxValue = String(field.maxValue);
176
+ }
177
+ }
178
+
179
+ const sharedItemsNode = createElement('sharedItems', sharedItemsAttrs, sharedItemChildren);
180
+ return createElement('cacheField', { name: field.name, numFmtId: '0' }, [sharedItemsNode]);
181
+ });
182
+
183
+ const cacheFieldsNode = createElement('cacheFields', { count: String(this._fields.length) }, cacheFieldNodes);
184
+
185
+ const worksheetSourceNode = createElement(
186
+ 'worksheetSource',
187
+ { ref: this._sourceRange, sheet: this._sourceSheet },
188
+ [],
189
+ );
190
+ const cacheSourceNode = createElement('cacheSource', { type: 'worksheet' }, [worksheetSourceNode]);
191
+
192
+ // Build attributes - refreshOnLoad should come early per OOXML schema
193
+ const definitionAttrs: Record<string, string> = {
194
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
195
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
196
+ 'r:id': recordsRelId,
197
+ };
198
+
199
+ // Add refreshOnLoad early in attributes (default is true)
200
+ if (this._refreshOnLoad) {
201
+ definitionAttrs.refreshOnLoad = '1';
202
+ }
203
+
204
+ // Continue with remaining attributes
205
+ definitionAttrs.refreshedBy = 'User';
206
+ definitionAttrs.refreshedVersion = '8';
207
+ definitionAttrs.minRefreshableVersion = '3';
208
+ definitionAttrs.createdVersion = '8';
209
+ definitionAttrs.recordCount = String(this._recordCount);
210
+
211
+ const definitionNode = createElement('pivotCacheDefinition', definitionAttrs, [cacheSourceNode, cacheFieldsNode]);
212
+
213
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([definitionNode])}`;
214
+ }
215
+
216
+ /**
217
+ * Generate the pivotCacheRecords XML
218
+ */
219
+ toRecordsXml(): string {
220
+ const recordNodes: XmlNode[] = [];
221
+
222
+ for (const row of this._records) {
223
+ const fieldNodes: XmlNode[] = [];
224
+
225
+ for (let colIdx = 0; colIdx < this._fields.length; colIdx++) {
226
+ const field = this._fields[colIdx];
227
+ const value = colIdx < row.length ? row[colIdx] : null;
228
+
229
+ if (value === null || value === undefined) {
230
+ // Missing value
231
+ fieldNodes.push(createElement('m', {}, []));
232
+ } else if (typeof value === 'string') {
233
+ // String value - use index into sharedItems
234
+ const idx = field.sharedItems.indexOf(value);
235
+ if (idx >= 0) {
236
+ fieldNodes.push(createElement('x', { v: String(idx) }, []));
237
+ } else {
238
+ // Direct string value (shouldn't happen if cache is built correctly)
239
+ fieldNodes.push(createElement('s', { v: value }, []));
240
+ }
241
+ } else if (typeof value === 'number') {
242
+ fieldNodes.push(createElement('n', { v: String(value) }, []));
243
+ } else if (typeof value === 'boolean') {
244
+ fieldNodes.push(createElement('b', { v: value ? '1' : '0' }, []));
245
+ } else if (value instanceof Date) {
246
+ fieldNodes.push(createElement('d', { v: value.toISOString() }, []));
247
+ } else {
248
+ // Unknown type, treat as missing
249
+ fieldNodes.push(createElement('m', {}, []));
250
+ }
251
+ }
252
+
253
+ recordNodes.push(createElement('r', {}, fieldNodes));
254
+ }
255
+
256
+ const recordsNode = createElement(
257
+ 'pivotCacheRecords',
258
+ {
259
+ xmlns: 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
260
+ 'xmlns:r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
261
+ count: String(this._recordCount),
262
+ },
263
+ recordNodes,
264
+ );
265
+
266
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n${stringifyXml([recordsNode])}`;
267
+ }
268
+ }