@lotics/xlsx 0.1.0 → 0.1.2
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 +1 -1
- package/src/data_workbook.test.ts +111 -0
- package/src/data_workbook.ts +186 -0
- package/src/excel_parser.test.ts +6 -4
- package/src/excel_parser.ts +26 -4
- package/src/hidden_rows_round_trip.test.ts +75 -0
- package/src/index.ts +9 -0
- package/src/ooxml_sheet.ts +22 -11
- package/src/ooxml_workbook.ts +6 -0
package/package.json
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildDataWorkbook } from "./data_workbook";
|
|
3
|
+
import { exportWorkbook } from "./xlsx_writer";
|
|
4
|
+
import { parseExcelBuffer } from "./excel_parser";
|
|
5
|
+
import { parseToExcelSerial } from "./numfmt_type";
|
|
6
|
+
import type { ParsedSheet, ParsedCell } from "./types";
|
|
7
|
+
|
|
8
|
+
interface FeeRow {
|
|
9
|
+
container: string;
|
|
10
|
+
date: Date | null;
|
|
11
|
+
amount: number | null;
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ROWS: FeeRow[] = [
|
|
16
|
+
{ container: "ABCD1234567", date: new Date(2026, 5, 15), amount: 1_500_000, count: 2 },
|
|
17
|
+
{ container: "WXYZ7654321", date: null, amount: null, count: 0 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function roundTrip<R>(opts: Parameters<typeof buildDataWorkbook<R>>[0]): ParsedSheet {
|
|
21
|
+
const wb = buildDataWorkbook(opts);
|
|
22
|
+
const bytes = exportWorkbook(wb);
|
|
23
|
+
// Fresh, tightly-sized ArrayBuffer for the parser.
|
|
24
|
+
const parsed = parseExcelBuffer(new Uint8Array(bytes).buffer);
|
|
25
|
+
return parsed.sheets[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cellAt(sheet: ParsedSheet, row: number, col: number): ParsedCell | undefined {
|
|
29
|
+
return sheet.rows.find((r) => r.index === row)?.cells.find((c) => c.column === col);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("buildDataWorkbook", () => {
|
|
33
|
+
it("writes a header row and typed body cells that survive an .xlsx round-trip", () => {
|
|
34
|
+
const sheet = roundTrip<FeeRow>({
|
|
35
|
+
sheetName: "Phí",
|
|
36
|
+
currencyFormat: '#,##0" ₫"',
|
|
37
|
+
columns: [
|
|
38
|
+
{ header: "Container", value: (r) => r.container },
|
|
39
|
+
{ header: "Ngày", type: "date", value: (r) => r.date },
|
|
40
|
+
{ header: "Số tiền", type: "currency", value: (r) => r.amount },
|
|
41
|
+
{ header: "Số khoản", type: "integer", value: (r) => r.count },
|
|
42
|
+
],
|
|
43
|
+
rows: ROWS,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(sheet.name).toBe("Phí");
|
|
47
|
+
|
|
48
|
+
// Header row (row 1).
|
|
49
|
+
expect(cellAt(sheet, 1, 1)?.value).toBe("Container");
|
|
50
|
+
expect(cellAt(sheet, 1, 2)?.value).toBe("Ngày");
|
|
51
|
+
expect(cellAt(sheet, 1, 3)?.value).toBe("Số tiền");
|
|
52
|
+
expect(cellAt(sheet, 1, 4)?.value).toBe("Số khoản");
|
|
53
|
+
expect(cellAt(sheet, 1, 1)?.style?.fontBold).toBe(true);
|
|
54
|
+
|
|
55
|
+
// Data row 1 (row 2): text + real date serial + numeric amount.
|
|
56
|
+
expect(cellAt(sheet, 2, 1)?.value).toBe("ABCD1234567");
|
|
57
|
+
expect(cellAt(sheet, 2, 2)?.rawValue).toBe(parseToExcelSerial("2026-06-15"));
|
|
58
|
+
expect(cellAt(sheet, 2, 3)?.rawValue).toBe(1_500_000);
|
|
59
|
+
expect(cellAt(sheet, 2, 4)?.rawValue).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("preserves leading zeros on numeric-looking text (tax IDs, phone numbers)", () => {
|
|
63
|
+
// A text cell whose content looks like a number must round-trip as the exact
|
|
64
|
+
// string — fast-xml-parser's default tag coercion would otherwise read the
|
|
65
|
+
// shared-string `<t>0312345678</t>` as 312345678 and drop the leading zero.
|
|
66
|
+
const sheet = roundTrip<{ mst: string }>({
|
|
67
|
+
columns: [{ header: "MST", type: "text", value: (r) => r.mst }],
|
|
68
|
+
rows: [{ mst: "0312345678" }, { mst: "0001" }],
|
|
69
|
+
});
|
|
70
|
+
expect(cellAt(sheet, 2, 1)?.value).toBe("0312345678");
|
|
71
|
+
expect(cellAt(sheet, 3, 1)?.value).toBe("0001");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("leaves null cells empty rather than writing zeros or blanks", () => {
|
|
75
|
+
const sheet = roundTrip<FeeRow>({
|
|
76
|
+
columns: [
|
|
77
|
+
{ header: "Container", value: (r) => r.container },
|
|
78
|
+
{ header: "Ngày", type: "date", value: (r) => r.date },
|
|
79
|
+
{ header: "Số tiền", type: "currency", value: (r) => r.amount },
|
|
80
|
+
],
|
|
81
|
+
rows: ROWS,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Row 3 = ROWS[1], whose date + amount are null → no cells there.
|
|
85
|
+
expect(cellAt(sheet, 3, 1)?.value).toBe("WXYZ7654321");
|
|
86
|
+
expect(cellAt(sheet, 3, 2)).toBeUndefined();
|
|
87
|
+
expect(cellAt(sheet, 3, 3)).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("sanitizes an invalid sheet name and defaults an empty one", () => {
|
|
91
|
+
const named = roundTrip<FeeRow>({
|
|
92
|
+
sheetName: "Phí/2026:Q2",
|
|
93
|
+
columns: [{ header: "C", value: (r) => r.container }],
|
|
94
|
+
rows: ROWS,
|
|
95
|
+
});
|
|
96
|
+
expect(named.name).toBe("Phí 2026 Q2");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("auto-fits column width to the widest of header and content", () => {
|
|
100
|
+
const wb = buildDataWorkbook<FeeRow>({
|
|
101
|
+
columns: [
|
|
102
|
+
{ header: "C", value: (r) => r.container }, // content (11 chars) wider than header
|
|
103
|
+
{ header: "Fixed", value: (r) => r.container, width: 40 },
|
|
104
|
+
],
|
|
105
|
+
rows: ROWS,
|
|
106
|
+
});
|
|
107
|
+
const widths = wb.sheets[0].colWidths;
|
|
108
|
+
expect(widths.get(1)).toBe(13); // 11 content + 2 padding
|
|
109
|
+
expect(widths.get(2)).toBe(40); // explicit override wins
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// buildDataWorkbook — tabular data → a styled, ready-to-export WorkbookModel
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// The companion to `exportWorkbook` for the common "I have rows, give me an
|
|
6
|
+
// .xlsx" case (a query result, a report, a register). Without it every caller
|
|
7
|
+
// re-hand-rolls the same header styling, number formats, date-serial math, and
|
|
8
|
+
// column sizing. Columns are declared once with a type + a `value` accessor;
|
|
9
|
+
// the helper writes a bold frozen header row, per-type number formats (dates as
|
|
10
|
+
// real Excel serials, currency via a caller-supplied format), and auto-fit
|
|
11
|
+
// widths. Returns a `WorkbookModel` — the caller pairs it with `exportWorkbook`.
|
|
12
|
+
|
|
13
|
+
import { WorkbookModel, SheetModel, StyleRegistry, type CellValue } from "./workbook_model";
|
|
14
|
+
import type { CellStyle } from "./types";
|
|
15
|
+
import { rowColToRef } from "./excel_utils";
|
|
16
|
+
import { parseToExcelSerial } from "./numfmt_type";
|
|
17
|
+
|
|
18
|
+
/** How a column's cells are typed and formatted. */
|
|
19
|
+
export type DataColumnType = "text" | "integer" | "number" | "currency" | "date";
|
|
20
|
+
|
|
21
|
+
export interface DataColumn<R> {
|
|
22
|
+
/** Header text, written to row 1. */
|
|
23
|
+
header: string;
|
|
24
|
+
/**
|
|
25
|
+
* The cell value for a row. Return `null`/`undefined` for an empty cell. A
|
|
26
|
+
* `date` column may return a `Date` or an ISO `YYYY-MM-DD` string; numeric
|
|
27
|
+
* columns may return a number or a numeric string (coerced).
|
|
28
|
+
*/
|
|
29
|
+
value: (row: R) => string | number | Date | null | undefined;
|
|
30
|
+
/** Cell type — drives number format + alignment. Default `"text"`. */
|
|
31
|
+
type?: DataColumnType;
|
|
32
|
+
/** Column width in characters. Default: auto-fit from header + sampled rows. */
|
|
33
|
+
width?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BuildDataWorkbookOptions<R> {
|
|
37
|
+
columns: DataColumn<R>[];
|
|
38
|
+
rows: readonly R[];
|
|
39
|
+
/** Sheet (tab) name. Default `"Sheet1"`. Sanitized to Excel's rules. */
|
|
40
|
+
sheetName?: string;
|
|
41
|
+
/** Number format for `currency` columns. Default `"#,##0"` (no decimals). */
|
|
42
|
+
currencyFormat?: string;
|
|
43
|
+
/** Number format for `date` columns. Default `"dd/mm/yyyy"`. */
|
|
44
|
+
dateFormat?: string;
|
|
45
|
+
/** Override the header-row style. */
|
|
46
|
+
headerStyle?: CellStyle;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_CURRENCY_FORMAT = "#,##0";
|
|
50
|
+
const DEFAULT_DATE_FORMAT = "dd/mm/yyyy";
|
|
51
|
+
const INTEGER_FORMAT = "#,##0";
|
|
52
|
+
const NUMBER_FORMAT = "#,##0.##";
|
|
53
|
+
|
|
54
|
+
const DEFAULT_HEADER_STYLE: CellStyle = {
|
|
55
|
+
fontBold: true,
|
|
56
|
+
backgroundColor: "#F4F4F5",
|
|
57
|
+
verticalAlign: "middle",
|
|
58
|
+
borderBottom: { width: 1, style: "thin", color: "#D4D4D8" },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const RIGHT_ALIGN: ReadonlySet<DataColumnType> = new Set<DataColumnType>([
|
|
62
|
+
"integer",
|
|
63
|
+
"number",
|
|
64
|
+
"currency",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// Excel sheet names: ≤ 31 chars, none of : \ / ? * [ ], non-empty.
|
|
68
|
+
function sanitizeSheetName(name: string): string {
|
|
69
|
+
const cleaned = name.replace(/[:\\/?*[\]]/g, " ").trim().slice(0, 31);
|
|
70
|
+
return cleaned || "Sheet1";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function numFmtFor(type: DataColumnType, currencyFormat: string, dateFormat: string): string | undefined {
|
|
74
|
+
switch (type) {
|
|
75
|
+
case "currency":
|
|
76
|
+
return currencyFormat;
|
|
77
|
+
case "integer":
|
|
78
|
+
return INTEGER_FORMAT;
|
|
79
|
+
case "number":
|
|
80
|
+
return NUMBER_FORMAT;
|
|
81
|
+
case "date":
|
|
82
|
+
return dateFormat;
|
|
83
|
+
case "text":
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Local `YYYY-MM-DD` for a Date (date-only — fee dates carry no meaningful time). */
|
|
89
|
+
function toLocalIsoDate(d: Date): string {
|
|
90
|
+
const y = d.getFullYear();
|
|
91
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
92
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
93
|
+
return `${y}-${m}-${day}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Coerce a raw accessor result to the cell value + the text used for width fit. */
|
|
97
|
+
function coerce(
|
|
98
|
+
raw: string | number | Date | null | undefined,
|
|
99
|
+
type: DataColumnType,
|
|
100
|
+
): { value: CellValue; sample: string } {
|
|
101
|
+
if (raw === null || raw === undefined || raw === "") return { value: null, sample: "" };
|
|
102
|
+
|
|
103
|
+
if (type === "date") {
|
|
104
|
+
const iso = raw instanceof Date ? toLocalIsoDate(raw) : typeof raw === "string" ? raw : null;
|
|
105
|
+
const serial = iso ? parseToExcelSerial(iso) : null;
|
|
106
|
+
// Unparseable date → fall back to its text so nothing is silently dropped.
|
|
107
|
+
if (serial === null) return { value: String(raw), sample: String(raw) };
|
|
108
|
+
return { value: serial, sample: raw instanceof Date ? toLocalIsoDate(raw) : String(raw) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (RIGHT_ALIGN.has(type)) {
|
|
112
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
113
|
+
if (!Number.isFinite(n)) return { value: String(raw), sample: String(raw) };
|
|
114
|
+
return { value: n, sample: n.toLocaleString("en-US") };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const str = raw instanceof Date ? toLocalIsoDate(raw) : String(raw);
|
|
118
|
+
return { value: str, sample: str };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function clampWidth(chars: number): number {
|
|
122
|
+
return Math.min(60, Math.max(8, Math.ceil(chars) + 2));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a single-sheet workbook from typed columns + rows.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* const wb = buildDataWorkbook({
|
|
130
|
+
* sheetName: "Phí",
|
|
131
|
+
* currencyFormat: '#,##0" ₫"',
|
|
132
|
+
* columns: [
|
|
133
|
+
* { header: "Container", value: (r) => r.container },
|
|
134
|
+
* { header: "Ngày", type: "date", value: (r) => r.date },
|
|
135
|
+
* { header: "Số tiền", type: "currency", value: (r) => r.amount },
|
|
136
|
+
* ],
|
|
137
|
+
* rows,
|
|
138
|
+
* });
|
|
139
|
+
* const bytes = exportWorkbook(wb);
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export function buildDataWorkbook<R>(opts: BuildDataWorkbookOptions<R>): WorkbookModel {
|
|
143
|
+
const {
|
|
144
|
+
columns,
|
|
145
|
+
rows,
|
|
146
|
+
sheetName = "Sheet1",
|
|
147
|
+
currencyFormat = DEFAULT_CURRENCY_FORMAT,
|
|
148
|
+
dateFormat = DEFAULT_DATE_FORMAT,
|
|
149
|
+
headerStyle = DEFAULT_HEADER_STYLE,
|
|
150
|
+
} = opts;
|
|
151
|
+
|
|
152
|
+
const styles = new StyleRegistry();
|
|
153
|
+
const sheet = new SheetModel(sanitizeSheetName(sheetName), styles);
|
|
154
|
+
|
|
155
|
+
// Per-type body cell style (interned once each via the StyleRegistry).
|
|
156
|
+
const bodyStyleFor = (type: DataColumnType): CellStyle | undefined =>
|
|
157
|
+
RIGHT_ALIGN.has(type) ? { horizontalAlign: "right" } : undefined;
|
|
158
|
+
|
|
159
|
+
// Cap width sampling so a huge export doesn't scan every row for sizing.
|
|
160
|
+
const widthSampleRows = rows.slice(0, 1000);
|
|
161
|
+
|
|
162
|
+
columns.forEach((col, c) => {
|
|
163
|
+
const type = col.type ?? "text";
|
|
164
|
+
const numFmt = numFmtFor(type, currencyFormat, dateFormat);
|
|
165
|
+
const bodyStyle = bodyStyleFor(type);
|
|
166
|
+
|
|
167
|
+
// Header.
|
|
168
|
+
sheet.set(rowColToRef(1, c + 1), col.header, headerStyle);
|
|
169
|
+
|
|
170
|
+
// Body.
|
|
171
|
+
let maxSample = col.header.length;
|
|
172
|
+
rows.forEach((rowData, r) => {
|
|
173
|
+
const { value, sample } = coerce(col.value(rowData), type);
|
|
174
|
+
if (r < widthSampleRows.length && sample.length > maxSample) maxSample = sample.length;
|
|
175
|
+
if (value === null) return; // leave empty cells truly empty
|
|
176
|
+
sheet.set(rowColToRef(r + 2, c + 1), value, bodyStyle, numFmt);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
sheet.colWidths.set(c + 1, col.width ?? clampWidth(maxSample));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Freeze the header row.
|
|
183
|
+
if (rows.length > 0 || columns.length > 0) sheet.setFreeze("A2");
|
|
184
|
+
|
|
185
|
+
return new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
|
|
186
|
+
}
|
package/src/excel_parser.test.ts
CHANGED
|
@@ -352,7 +352,7 @@ describe("parseExcelFile", () => {
|
|
|
352
352
|
expect(merge.endCol).toBe(3);
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
-
test("
|
|
355
|
+
test("includes hidden sheets, flagged so renderers can skip them", async () => {
|
|
356
356
|
const workbook = new ExcelJS.Workbook();
|
|
357
357
|
workbook.addWorksheet("Visible");
|
|
358
358
|
const hidden = workbook.addWorksheet("Hidden");
|
|
@@ -363,9 +363,11 @@ describe("parseExcelFile", () => {
|
|
|
363
363
|
|
|
364
364
|
const result = await parseExcelFile("https://example.com/test.xlsx");
|
|
365
365
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
expect(result.sheets
|
|
366
|
+
// The parser preserves hidden sheets (data consumers still read them) and
|
|
367
|
+
// marks them via `hidden` so renderers can choose to skip them.
|
|
368
|
+
expect(result.sheets.map((s) => s.name)).toEqual(["Visible", "Hidden"]);
|
|
369
|
+
expect(result.sheets[0].hidden).toBeFalsy();
|
|
370
|
+
expect(result.sheets[1].hidden).toBe(true);
|
|
369
371
|
});
|
|
370
372
|
|
|
371
373
|
test("preserves cell text when <v> carries xml:space=\"preserve\"", () => {
|
package/src/excel_parser.ts
CHANGED
|
@@ -54,31 +54,52 @@ export type {
|
|
|
54
54
|
// Parsing
|
|
55
55
|
// =============================================================================
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Options controlling how a workbook is parsed.
|
|
59
|
+
*/
|
|
60
|
+
export interface ParseOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Maximum number of data rows to parse per sheet. Defaults to a preview
|
|
63
|
+
* guard (10_000) that protects the canvas renderer from pathological
|
|
64
|
+
* sheets. Pass `Infinity` for full data extraction (e.g. importing a
|
|
65
|
+
* sheet into a table) — `ParsedSheet.truncated` reports whether the limit
|
|
66
|
+
* was hit.
|
|
67
|
+
*/
|
|
68
|
+
maxRowsPerSheet?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
export async function parseExcelFile(
|
|
58
72
|
url: string,
|
|
59
73
|
signal?: AbortSignal,
|
|
74
|
+
options?: ParseOptions,
|
|
60
75
|
): Promise<ParsedSpreadsheet> {
|
|
61
76
|
const response = await fetch(url, { signal });
|
|
62
77
|
if (!response.ok) {
|
|
63
78
|
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
|
|
64
79
|
}
|
|
65
|
-
return parseExcelBuffer(await response.arrayBuffer());
|
|
80
|
+
return parseExcelBuffer(await response.arrayBuffer(), options);
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
/**
|
|
69
84
|
* Parse an in-memory .xlsx buffer into a ParsedSpreadsheet.
|
|
70
85
|
* Use this when you already have the bytes (e.g. from File.arrayBuffer()).
|
|
71
86
|
*/
|
|
72
|
-
export function parseExcelBuffer(
|
|
87
|
+
export function parseExcelBuffer(
|
|
88
|
+
arrayBuffer: ArrayBuffer,
|
|
89
|
+
options?: ParseOptions,
|
|
90
|
+
): ParsedSpreadsheet {
|
|
73
91
|
const zip = unzipXlsx(arrayBuffer);
|
|
74
|
-
return parseExcelFromZip(zip);
|
|
92
|
+
return parseExcelFromZip(zip, options);
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/**
|
|
78
96
|
* Parse from already-unzipped entries. Use this when you need both the
|
|
79
97
|
* ParsedSpreadsheet and the raw ZIP entries (e.g. for delta export).
|
|
80
98
|
*/
|
|
81
|
-
export function parseExcelFromZip(
|
|
99
|
+
export function parseExcelFromZip(
|
|
100
|
+
zip: Record<string, Uint8Array>,
|
|
101
|
+
options?: ParseOptions,
|
|
102
|
+
): ParsedSpreadsheet {
|
|
82
103
|
// Parse theme
|
|
83
104
|
const themeEntry = findEntry(zip, "xl/theme/theme1.xml");
|
|
84
105
|
const theme: ThemeData = themeEntry
|
|
@@ -190,6 +211,7 @@ export function parseExcelFromZip(zip: Record<string, Uint8Array>): ParsedSpread
|
|
|
190
211
|
styles.indexedColors,
|
|
191
212
|
zip,
|
|
192
213
|
workbookInfo.date1904,
|
|
214
|
+
options?.maxRowsPerSheet,
|
|
193
215
|
);
|
|
194
216
|
parsed.name = sheetInfo.name;
|
|
195
217
|
if (isHidden) parsed.hidden = true;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Hidden rows are data, not throwaway — they must survive a parse → write → parse
|
|
3
|
+
// round-trip.
|
|
4
|
+
// =============================================================================
|
|
5
|
+
//
|
|
6
|
+
// Regression: the OOXML row parser dropped hidden rows entirely (`continue`)
|
|
7
|
+
// instead of flagging them. On a real customer workbook — an INV&PKL export form
|
|
8
|
+
// whose BBGH tab pulls its line items from a master sheet via cross-sheet formulas,
|
|
9
|
+
// with hidden spacer rows interleaved through the line block — a single edit + save
|
|
10
|
+
// lost the whole line table: the hidden rows vanished outright, and the visible rows
|
|
11
|
+
// around them broke because their cross-sheet formulas now referenced rows that no
|
|
12
|
+
// longer existed in the model. Columns were always parsed-and-flagged (parseCols);
|
|
13
|
+
// rows must behave the same.
|
|
14
|
+
//
|
|
15
|
+
// Built entirely on the package's own model → export → parse path (no ExcelJS), so
|
|
16
|
+
// it exercises the fixed parser directly.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import { exportWorkbook } from "./xlsx_writer";
|
|
20
|
+
import { parseExcelBuffer } from "./excel_parser";
|
|
21
|
+
import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
|
|
22
|
+
|
|
23
|
+
function buildHiddenRowWorkbook(): WorkbookModel {
|
|
24
|
+
const styles = new StyleRegistry();
|
|
25
|
+
|
|
26
|
+
// Master sheet: 6 line rows, all with data; rows 2 and 5 hidden (spacers).
|
|
27
|
+
const data = new SheetModel("Data", styles);
|
|
28
|
+
for (let r = 1; r <= 6; r++) {
|
|
29
|
+
data.set(`A${r}`, r);
|
|
30
|
+
data.set(`B${r}`, `item ${r}`);
|
|
31
|
+
}
|
|
32
|
+
data.hiddenRows.add(2);
|
|
33
|
+
data.hiddenRows.add(5);
|
|
34
|
+
|
|
35
|
+
// View sheet: each cell mirrors a Data row via a cross-sheet formula —
|
|
36
|
+
// including the hidden rows. (value = cached result, formula = the ref.)
|
|
37
|
+
const view = new SheetModel("View", styles);
|
|
38
|
+
for (let r = 1; r <= 6; r++) {
|
|
39
|
+
view.set(`A${r}`, `item ${r}`, undefined, undefined, `Data!B${r}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new WorkbookModel({ sheets: [data, view], activeSheetIndex: 0, styles });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("xlsx hidden rows round-trip", () => {
|
|
46
|
+
it("keeps hidden rows (values + hidden flag) and the cross-sheet refs into them", () => {
|
|
47
|
+
const exported = exportWorkbook(buildHiddenRowWorkbook());
|
|
48
|
+
const reparsed = parseExcelBuffer(exported.buffer as ArrayBuffer);
|
|
49
|
+
|
|
50
|
+
const dataSheet = reparsed.sheets.find((s) => s.name === "Data")!;
|
|
51
|
+
const rowIndexes = dataSheet.rows.map((r) => r.index).sort((a, b) => a - b);
|
|
52
|
+
// All six rows survive — not just the visible ones.
|
|
53
|
+
expect(rowIndexes).toEqual([1, 2, 3, 4, 5, 6]);
|
|
54
|
+
|
|
55
|
+
const cellVal = (sheetName: string, r: number, col: number) => {
|
|
56
|
+
const sheet = reparsed.sheets.find((s) => s.name === sheetName)!;
|
|
57
|
+
return sheet.rows.find((row) => row.index === r)?.cells.find((c) => c.column === col)?.value;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Hidden rows keep their data.
|
|
61
|
+
expect(cellVal("Data", 2, 2)).toBe("item 2");
|
|
62
|
+
expect(cellVal("Data", 5, 2)).toBe("item 5");
|
|
63
|
+
// Visible row after the last hidden row survives (no cascade loss).
|
|
64
|
+
expect(cellVal("Data", 6, 2)).toBe("item 6");
|
|
65
|
+
|
|
66
|
+
// The hidden flag round-trips on exactly the hidden rows.
|
|
67
|
+
const hidden = new Set(dataSheet.rows.filter((r) => r.hidden).map((r) => r.index));
|
|
68
|
+
expect(hidden).toEqual(new Set([2, 5]));
|
|
69
|
+
|
|
70
|
+
// Cross-sheet references into the hidden rows still resolve to their cached values.
|
|
71
|
+
expect(cellVal("View", 2, 1)).toBe("item 2");
|
|
72
|
+
expect(cellVal("View", 5, 1)).toBe("item 5");
|
|
73
|
+
expect(cellVal("View", 6, 1)).toBe("item 6");
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -98,8 +98,17 @@ export {
|
|
|
98
98
|
parseExcelFromZip,
|
|
99
99
|
} from "./excel_parser";
|
|
100
100
|
|
|
101
|
+
export type { ParseOptions } from "./excel_parser";
|
|
102
|
+
|
|
101
103
|
export { exportWorkbook } from "./xlsx_writer";
|
|
102
104
|
|
|
105
|
+
// -----------------------------------------------------------------------------
|
|
106
|
+
// Data → workbook (the common "rows in, .xlsx out" helper)
|
|
107
|
+
// -----------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
export { buildDataWorkbook } from "./data_workbook";
|
|
110
|
+
export type { DataColumn, DataColumnType, BuildDataWorkbookOptions } from "./data_workbook";
|
|
111
|
+
|
|
103
112
|
export { unzipXlsx } from "./ooxml_zip";
|
|
104
113
|
|
|
105
114
|
// -----------------------------------------------------------------------------
|
package/src/ooxml_sheet.ts
CHANGED
|
@@ -114,6 +114,7 @@ export function parseSheet(
|
|
|
114
114
|
indexedColors: string[],
|
|
115
115
|
zipEntries: Record<string, Uint8Array>,
|
|
116
116
|
date1904: boolean,
|
|
117
|
+
maxRows: number = MAX_ROWS_PER_SHEET,
|
|
117
118
|
): ParsedSheet {
|
|
118
119
|
const doc = xmlParser.parse(sheetXml);
|
|
119
120
|
const worksheet = doc?.["worksheet"];
|
|
@@ -148,7 +149,7 @@ export function parseSheet(
|
|
|
148
149
|
maxCol,
|
|
149
150
|
maxContentWidth,
|
|
150
151
|
rowOutlineLevels,
|
|
151
|
-
} = parseSheetData(worksheet, styles, sharedStrings, hyperlinkMap, defaultRowHeight, date1904);
|
|
152
|
+
} = parseSheetData(worksheet, styles, sharedStrings, hyperlinkMap, defaultRowHeight, date1904, maxRows);
|
|
152
153
|
|
|
153
154
|
// Build final columns from parsed col defs and data bounds
|
|
154
155
|
const colCount = Math.max(parsedCols.length > 0 ? parsedCols[parsedCols.length - 1].max : 0, maxCol);
|
|
@@ -425,6 +426,7 @@ function parseSheetData(
|
|
|
425
426
|
hyperlinkMap: Map<string, string>,
|
|
426
427
|
defaultRowHeight: number,
|
|
427
428
|
date1904: boolean,
|
|
429
|
+
maxRows: number,
|
|
428
430
|
): {
|
|
429
431
|
rows: ParsedRow[];
|
|
430
432
|
totalRowCount: number;
|
|
@@ -477,12 +479,16 @@ function parseSheetData(
|
|
|
477
479
|
rowOutlineLevels.set(rowIndex, outlineLevel);
|
|
478
480
|
}
|
|
479
481
|
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
+
// Hidden rows are real data — keep them, flagged hidden, so they round-trip.
|
|
483
|
+
// Excel preserves hidden rows; dropping them silently loses the row on the next
|
|
484
|
+
// write AND breaks any cross-sheet formula that references it (the reference
|
|
485
|
+
// resolves to an empty cell), which cascades into whole blocks being lost.
|
|
486
|
+
// Columns already do this (parseCols flags hidden cols rather than skipping).
|
|
487
|
+
const rowHidden = rowEl["@_hidden"] === "1" || rowEl["@_hidden"] === "true";
|
|
482
488
|
|
|
483
489
|
// Truncation check
|
|
484
490
|
rowCount++;
|
|
485
|
-
if (rowCount >
|
|
491
|
+
if (rowCount > maxRows) continue;
|
|
486
492
|
|
|
487
493
|
const height = rowEl["@_ht"]
|
|
488
494
|
? parseFloat(rowEl["@_ht"] as string)
|
|
@@ -498,8 +504,9 @@ function parseSheetData(
|
|
|
498
504
|
cells.push(parsed);
|
|
499
505
|
if (parsed.column > maxCol) maxCol = parsed.column;
|
|
500
506
|
|
|
501
|
-
// Track content width (sample first 200 rows
|
|
502
|
-
|
|
507
|
+
// Track content width (sample first 200 rows; hidden rows don't drive
|
|
508
|
+
// visible column auto-width)
|
|
509
|
+
if (rowCount <= 200 && !rowHidden) {
|
|
503
510
|
const currentMax = maxContentWidth.get(parsed.column) ?? 0;
|
|
504
511
|
if (parsed.value.length > currentMax) {
|
|
505
512
|
maxContentWidth.set(parsed.column, parsed.value.length);
|
|
@@ -513,14 +520,14 @@ function parseSheetData(
|
|
|
513
520
|
index: rowIndex,
|
|
514
521
|
height,
|
|
515
522
|
cells,
|
|
516
|
-
hidden:
|
|
523
|
+
hidden: rowHidden,
|
|
517
524
|
});
|
|
518
525
|
}
|
|
519
526
|
|
|
520
527
|
return {
|
|
521
528
|
rows,
|
|
522
529
|
totalRowCount,
|
|
523
|
-
truncated: rowCount >
|
|
530
|
+
truncated: rowCount > maxRows,
|
|
524
531
|
maxCol,
|
|
525
532
|
maxContentWidth,
|
|
526
533
|
rowOutlineLevels,
|
|
@@ -592,11 +599,15 @@ function parseCell(
|
|
|
592
599
|
let arrayRange: string | undefined;
|
|
593
600
|
if (fEl !== undefined && fEl !== null) {
|
|
594
601
|
if (typeof fEl === "object") {
|
|
595
|
-
const fObj = fEl as Record<string,
|
|
596
|
-
formula =
|
|
602
|
+
const fObj = fEl as Record<string, unknown>;
|
|
603
|
+
// fast-xml-parser coerces numeric formula text (e.g. `<f ca="1">2026</f>`)
|
|
604
|
+
// to a number, so `#text` is not guaranteed to be a string.
|
|
605
|
+
const text = fObj["#text"];
|
|
606
|
+
formula = text == null ? undefined : String(text);
|
|
597
607
|
if (fObj["@_t"] === "array") {
|
|
598
608
|
isArrayFormula = true;
|
|
599
|
-
|
|
609
|
+
const ref = fObj["@_ref"];
|
|
610
|
+
arrayRange = ref == null ? undefined : String(ref);
|
|
600
611
|
// Strip {} braces from array formula text
|
|
601
612
|
if (formula?.startsWith("{") && formula.endsWith("}")) {
|
|
602
613
|
formula = formula.slice(1, -1);
|
package/src/ooxml_workbook.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface SharedStringEntry {
|
|
|
37
37
|
const xmlParser = new XMLParser({
|
|
38
38
|
ignoreAttributes: false,
|
|
39
39
|
attributeNamePrefix: "@_",
|
|
40
|
+
// Shared-string and workbook text is textual BY DEFINITION (tax IDs, phone
|
|
41
|
+
// numbers, account codes, sheet/defined names). fast-xml-parser's default tag
|
|
42
|
+
// coercion would parse `<t>0312345678</t>` as the number 312345678 — silently
|
|
43
|
+
// dropping the leading zero before we can read it. Cell `<v>` numerics are
|
|
44
|
+
// parsed deliberately (parseFloat) in ooxml_sheet.ts, so this never affects them.
|
|
45
|
+
parseTagValue: false,
|
|
40
46
|
trimValues: false,
|
|
41
47
|
processEntities: false,
|
|
42
48
|
isArray: (tagName) =>
|