@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/xlsx",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
@@ -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
+ }
@@ -352,7 +352,7 @@ describe("parseExcelFile", () => {
352
352
  expect(merge.endCol).toBe(3);
353
353
  });
354
354
 
355
- test("skips hidden sheets", async () => {
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
- expect(result.sheets).toHaveLength(1);
368
- expect(result.sheets[0].name).toBe("Visible");
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\"", () => {
@@ -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(arrayBuffer: ArrayBuffer): ParsedSpreadsheet {
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(zip: Record<string, Uint8Array>): ParsedSpreadsheet {
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
  // -----------------------------------------------------------------------------
@@ -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
- // Skip hidden rows
481
- if (rowEl["@_hidden"] === "1" || rowEl["@_hidden"] === "true") continue;
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 > MAX_ROWS_PER_SHEET) continue;
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
- if (rowCount <= 200) {
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: false,
523
+ hidden: rowHidden,
517
524
  });
518
525
  }
519
526
 
520
527
  return {
521
528
  rows,
522
529
  totalRowCount,
523
- truncated: rowCount > MAX_ROWS_PER_SHEET,
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, string>;
596
- formula = fObj["#text"];
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
- arrayRange = fObj["@_ref"];
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);
@@ -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) =>