@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.
Files changed (90) hide show
  1. package/package.json +30 -0
  2. package/src/auto_sum.test.ts +71 -0
  3. package/src/auto_sum.ts +83 -0
  4. package/src/canvas_cf.ts +135 -0
  5. package/src/canvas_chart.ts +687 -0
  6. package/src/canvas_fill.ts +156 -0
  7. package/src/canvas_images.ts +99 -0
  8. package/src/canvas_layout.test.ts +159 -0
  9. package/src/canvas_layout.ts +239 -0
  10. package/src/canvas_renderer.ts +1010 -0
  11. package/src/canvas_shape.ts +242 -0
  12. package/src/canvas_sparkline.ts +163 -0
  13. package/src/canvas_text.ts +454 -0
  14. package/src/cell_editor.ts +558 -0
  15. package/src/clipboard.test.ts +145 -0
  16. package/src/clipboard.ts +341 -0
  17. package/src/command_history.ts +102 -0
  18. package/src/csv_parser.test.ts +130 -0
  19. package/src/csv_parser.ts +172 -0
  20. package/src/data_validation.test.ts +95 -0
  21. package/src/data_validation.ts +114 -0
  22. package/src/editing_layer.test.ts +309 -0
  23. package/src/excel_cf_evaluate.test.ts +293 -0
  24. package/src/excel_cf_evaluate.ts +719 -0
  25. package/src/excel_cf_icons.ts +108 -0
  26. package/src/excel_cf_types.ts +67 -0
  27. package/src/excel_numfmt.test.ts +607 -0
  28. package/src/excel_numfmt.ts +1033 -0
  29. package/src/excel_parser.test.ts +1061 -0
  30. package/src/excel_parser.ts +393 -0
  31. package/src/excel_pattern_fills.ts +64 -0
  32. package/src/excel_table_styles.ts +160 -0
  33. package/src/excel_utils.test.ts +108 -0
  34. package/src/excel_utils.ts +162 -0
  35. package/src/fast-formula-parser.d.ts +53 -0
  36. package/src/fill_logic.ts +257 -0
  37. package/src/fill_patterns.test.ts +90 -0
  38. package/src/fill_patterns.ts +71 -0
  39. package/src/flash_fill.test.ts +75 -0
  40. package/src/flash_fill.ts +221 -0
  41. package/src/formula_deps.ts +189 -0
  42. package/src/formula_engine.test.ts +348 -0
  43. package/src/formula_engine.ts +401 -0
  44. package/src/formula_highlight.test.ts +81 -0
  45. package/src/formula_highlight.ts +139 -0
  46. package/src/formula_suggest.ts +100 -0
  47. package/src/hidden_sheets.test.ts +49 -0
  48. package/src/index.ts +149 -0
  49. package/src/keyboard_nav.test.ts +394 -0
  50. package/src/keyboard_nav.ts +891 -0
  51. package/src/move_logic.ts +63 -0
  52. package/src/numfmt_type.test.ts +158 -0
  53. package/src/numfmt_type.ts +231 -0
  54. package/src/ooxml_cell_format.ts +70 -0
  55. package/src/ooxml_chart.test.ts +85 -0
  56. package/src/ooxml_chart.ts +347 -0
  57. package/src/ooxml_chart_types.ts +207 -0
  58. package/src/ooxml_color.ts +339 -0
  59. package/src/ooxml_drawing.test.ts +87 -0
  60. package/src/ooxml_drawing.ts +287 -0
  61. package/src/ooxml_pivot.test.ts +195 -0
  62. package/src/ooxml_pivot.ts +468 -0
  63. package/src/ooxml_pivot_types.ts +165 -0
  64. package/src/ooxml_shape.ts +355 -0
  65. package/src/ooxml_sheet.ts +1271 -0
  66. package/src/ooxml_styles.ts +556 -0
  67. package/src/ooxml_table.test.ts +70 -0
  68. package/src/ooxml_table.ts +131 -0
  69. package/src/ooxml_workbook.test.ts +40 -0
  70. package/src/ooxml_workbook.ts +259 -0
  71. package/src/ooxml_zip.ts +33 -0
  72. package/src/pivot_model.ts +237 -0
  73. package/src/pivot_recompute.test.ts +210 -0
  74. package/src/pivot_recompute.ts +413 -0
  75. package/src/selection_model.ts +364 -0
  76. package/src/spreadsheet_commands.test.ts +772 -0
  77. package/src/spreadsheet_commands.ts +1805 -0
  78. package/src/structured_refs.test.ts +128 -0
  79. package/src/structured_refs.ts +160 -0
  80. package/src/style_helpers.test.ts +33 -0
  81. package/src/style_helpers.ts +30 -0
  82. package/src/template_builder.test.ts +139 -0
  83. package/src/template_builder.ts +36 -0
  84. package/src/types.ts +239 -0
  85. package/src/workbook_model.test.ts +536 -0
  86. package/src/workbook_model.ts +911 -0
  87. package/src/xlsx_round_trip.test.ts +279 -0
  88. package/src/xlsx_writer.test.ts +488 -0
  89. package/src/xlsx_writer.ts +1549 -0
  90. package/src/xml_entities.ts +23 -0
@@ -0,0 +1,63 @@
1
+ // =============================================================================
2
+ // Move Logic
3
+ // =============================================================================
4
+ //
5
+ // Pure spreadsheet move-cells computation. Given a source range and a delta,
6
+ // builds commands to write source cells to the target and delete the originals.
7
+ //
8
+ // No DOM or React dependencies.
9
+ // =============================================================================
10
+
11
+ import type { Command } from "./command_history";
12
+ import type { WorkbookModel } from "./workbook_model";
13
+ import type { FormulaEngine } from "./formula_engine";
14
+ import { rowColToRef } from "./excel_utils";
15
+ import { setCellValueCommand, deleteCellCommand, batchCommand } from "./spreadsheet_commands";
16
+
17
+ /**
18
+ * Compute commands to move a range of cells by a row/col delta.
19
+ * Returns a batch command, or null if nothing to move.
20
+ */
21
+ export function computeMoveCommand(
22
+ workbook: WorkbookModel,
23
+ engine: FormulaEngine | undefined,
24
+ sheetIndex: number,
25
+ sourceRange: { start: { row: number; col: number }; end: { row: number; col: number } },
26
+ deltaRow: number,
27
+ deltaCol: number,
28
+ ): Command | null {
29
+ if (deltaRow === 0 && deltaCol === 0) return null;
30
+
31
+ const { start, end } = sourceRange;
32
+ const sheet = workbook.sheets[sheetIndex];
33
+ const commands: Command[] = [];
34
+
35
+ // Write source cells to target
36
+ for (let r = start.row; r <= end.row; r++) {
37
+ for (let c = start.col; c <= end.col; c++) {
38
+ const srcRef = rowColToRef(r, c);
39
+ const srcCell = sheet.getCell(srcRef);
40
+ const targetR = r + deltaRow;
41
+ const targetC = c + deltaCol;
42
+ const value = srcCell?.formula ? `=${srcCell.formula}` : srcCell?.displayValue ?? "";
43
+ if (value !== "") {
44
+ commands.push(setCellValueCommand(workbook, engine, sheetIndex, targetR, targetC, value));
45
+ }
46
+ }
47
+ }
48
+
49
+ // Delete source cells (only those not overlapping with target)
50
+ for (let r = start.row; r <= end.row; r++) {
51
+ for (let c = start.col; c <= end.col; c++) {
52
+ const targetR = r + deltaRow;
53
+ const targetC = c + deltaCol;
54
+ const isInTarget = targetR >= start.row && targetR <= end.row && targetC >= start.col && targetC <= end.col;
55
+ if (!isInTarget) {
56
+ commands.push(deleteCellCommand(workbook, engine, sheetIndex, r, c));
57
+ }
58
+ }
59
+ }
60
+
61
+ if (commands.length === 0) return null;
62
+ return batchCommand("Move cells", commands);
63
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { classifyNumFmt, coerceByNumFmt, parseLocalizedNumber, parseToExcelSerial } from "./numfmt_type";
3
+
4
+ describe("classifyNumFmt", () => {
5
+ it("treats empty and General as general", () => {
6
+ expect(classifyNumFmt(undefined)).toBe("general");
7
+ expect(classifyNumFmt(null)).toBe("general");
8
+ expect(classifyNumFmt("")).toBe("general");
9
+ expect(classifyNumFmt("General")).toBe("general");
10
+ });
11
+
12
+ it("identifies numeric formats", () => {
13
+ expect(classifyNumFmt("0")).toBe("numeric");
14
+ expect(classifyNumFmt("#,##0")).toBe("numeric");
15
+ expect(classifyNumFmt("#,##0.00")).toBe("numeric");
16
+ expect(classifyNumFmt('#,##0;(#,##0);"-"')).toBe("numeric");
17
+ expect(classifyNumFmt("0.000")).toBe("numeric");
18
+ });
19
+
20
+ it("identifies percent formats", () => {
21
+ expect(classifyNumFmt("0%")).toBe("percent");
22
+ expect(classifyNumFmt("0.0%")).toBe("percent");
23
+ expect(classifyNumFmt("#,##0.00%;(#,##0.00%)")).toBe("percent");
24
+ });
25
+
26
+ it("identifies currency formats", () => {
27
+ expect(classifyNumFmt('"$"#,##0.00')).toBe("currency");
28
+ expect(classifyNumFmt("[$VND-42A]#,##0")).toBe("currency");
29
+ expect(classifyNumFmt("[$-409]$#,##0.00")).toBe("currency");
30
+ });
31
+
32
+ it("identifies date/time formats", () => {
33
+ expect(classifyNumFmt("yyyy-mm-dd")).toBe("date");
34
+ expect(classifyNumFmt("dd/mm/yyyy")).toBe("date");
35
+ expect(classifyNumFmt("d-mmm-yyyy")).toBe("date");
36
+ expect(classifyNumFmt("hh:mm:ss")).toBe("date");
37
+ expect(classifyNumFmt("yyyy-mm-dd hh:mm")).toBe("date");
38
+ expect(classifyNumFmt('"Date: "yyyy-mm-dd')).toBe("date");
39
+ });
40
+
41
+ it("identifies text format", () => {
42
+ expect(classifyNumFmt("@")).toBe("text");
43
+ });
44
+
45
+ it("does not misread bare literals as date tokens", () => {
46
+ // Quoted literals must not trigger date/number heuristics.
47
+ expect(classifyNumFmt('"Total: "0')).toBe("numeric");
48
+ expect(classifyNumFmt('"-"')).toBe("general");
49
+ });
50
+ });
51
+
52
+ describe("parseLocalizedNumber", () => {
53
+ it("parses bare numbers", () => {
54
+ expect(parseLocalizedNumber("0")).toBe(0);
55
+ expect(parseLocalizedNumber("1234")).toBe(1234);
56
+ expect(parseLocalizedNumber("1234.56")).toBe(1234.56);
57
+ expect(parseLocalizedNumber("-1234.56")).toBe(-1234.56);
58
+ });
59
+
60
+ it("parses EU thousand-dot grouping (the VN demo case)", () => {
61
+ expect(parseLocalizedNumber("308.500.000")).toBe(308500000);
62
+ expect(parseLocalizedNumber("1.234.567,89")).toBe(1234567.89);
63
+ expect(parseLocalizedNumber("-1.234,50")).toBe(-1234.5);
64
+ });
65
+
66
+ it("parses US thousand-comma grouping", () => {
67
+ expect(parseLocalizedNumber("308,500,000")).toBe(308500000);
68
+ expect(parseLocalizedNumber("1,234,567.89")).toBe(1234567.89);
69
+ });
70
+
71
+ it("parses accountants' parens as negative", () => {
72
+ expect(parseLocalizedNumber("(1,234)")).toBe(-1234);
73
+ expect(parseLocalizedNumber("(1.234,56)")).toBe(-1234.56);
74
+ });
75
+
76
+ it("strips leading/trailing currency noise", () => {
77
+ expect(parseLocalizedNumber("$1,234.56")).toBe(1234.56);
78
+ expect(parseLocalizedNumber("308.500.000 ₫")).toBe(308500000);
79
+ expect(parseLocalizedNumber("VND 308.500.000")).toBe(308500000);
80
+ });
81
+
82
+ it("returns null on unambiguous garbage", () => {
83
+ expect(parseLocalizedNumber("")).toBeNull();
84
+ expect(parseLocalizedNumber("N/A")).toBeNull();
85
+ expect(parseLocalizedNumber("abc")).toBeNull();
86
+ // Ambiguous separator pattern — conservative refusal beats misparsing.
87
+ expect(parseLocalizedNumber("1.234.56")).toBeNull();
88
+ });
89
+ });
90
+
91
+ describe("parseToExcelSerial", () => {
92
+ it("converts ISO dates to the 1900 serial system", () => {
93
+ // 2024-01-01 → 45292 (well-known value for the 1900 system).
94
+ expect(parseToExcelSerial("2024-01-01")).toBe(45292);
95
+ expect(parseToExcelSerial("2026-04-19")).toBe(46131);
96
+ });
97
+
98
+ it("supports ISO datetime", () => {
99
+ expect(parseToExcelSerial("2024-01-01T12:00:00")).toBe(45292.5);
100
+ expect(parseToExcelSerial("2024-01-01 06:00")).toBe(45292.25);
101
+ });
102
+
103
+ it("returns null on non-date strings", () => {
104
+ expect(parseToExcelSerial("not a date")).toBeNull();
105
+ expect(parseToExcelSerial("19/04/2026")).toBeNull(); // not ISO
106
+ });
107
+ });
108
+
109
+ describe("coerceByNumFmt", () => {
110
+ it("preserves typed number/boolean regardless of numFmt", () => {
111
+ expect(coerceByNumFmt(42, "#,##0")).toBe(42);
112
+ expect(coerceByNumFmt(true, "General")).toBe(true);
113
+ expect(coerceByNumFmt(0, "#,##0")).toBe(0);
114
+ });
115
+
116
+ it("nulls out null/undefined", () => {
117
+ expect(coerceByNumFmt(null, "#,##0")).toBeNull();
118
+ expect(coerceByNumFmt(undefined, "General")).toBeNull();
119
+ });
120
+
121
+ it("coerces locale-formatted strings into numbers for numeric cells", () => {
122
+ expect(coerceByNumFmt("308.500.000", "#,##0")).toBe(308500000);
123
+ expect(coerceByNumFmt("308,500,000", "#,##0")).toBe(308500000);
124
+ expect(coerceByNumFmt("15.425.000", '#,##0;(#,##0);"-"')).toBe(15425000);
125
+ });
126
+
127
+ it("interprets percent strings for percent cells", () => {
128
+ expect(coerceByNumFmt("5%", "0.0%")).toBeCloseTo(0.05, 10);
129
+ expect(coerceByNumFmt("0.05", "0.0%")).toBeCloseTo(0.05, 10);
130
+ });
131
+
132
+ it("coerces ISO dates for date cells", () => {
133
+ expect(coerceByNumFmt("2024-01-01", "yyyy-mm-dd")).toBe(45292);
134
+ });
135
+
136
+ it("falls through to string for non-parseable input", () => {
137
+ // Non-numeric text in a numeric cell: keep as text rather than inserting
138
+ // a silent 0 — the author will see it and fix the data.
139
+ expect(coerceByNumFmt("N/A", "#,##0")).toBe("N/A");
140
+ expect(coerceByNumFmt("TBD", "0.00%")).toBe("TBD");
141
+ });
142
+
143
+ it("keeps strings for text-format cells", () => {
144
+ expect(coerceByNumFmt("308500000", "@")).toBe("308500000");
145
+ expect(coerceByNumFmt("true", "@")).toBe("true");
146
+ });
147
+
148
+ it("coerces in general-format cells as a fallback (boolean then number)", () => {
149
+ expect(coerceByNumFmt("true", "General")).toBe(true);
150
+ expect(coerceByNumFmt("FALSE", "General")).toBe(false);
151
+ expect(coerceByNumFmt("1234", "General")).toBe(1234);
152
+ expect(coerceByNumFmt("not a value", "General")).toBe("not a value");
153
+ });
154
+
155
+ it("returns empty string for whitespace-only input", () => {
156
+ expect(coerceByNumFmt(" ", "#,##0")).toBe("");
157
+ });
158
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * NumFmt classification + value coercion for template substitution.
3
+ *
4
+ * Excel cells encode their intended data type via `numFmt` (number format
5
+ * code). When a template engine substitutes `{{foo}}` into a cell, the
6
+ * input's JS type (string/number/boolean) must be reconciled with the
7
+ * cell's intended type. Without coercion, a numeric-looking string like
8
+ * `"308.500.000"` lands in a number-formatted cell as `t="s"` — SUM
9
+ * ignores it, cached value is 0, and the zero-section of the numFmt (e.g.
10
+ * `"-"`) renders in every Excel-compatible reader. That's how the VN demo
11
+ * báo giá ended up showing dashes in totals despite the agent passing the
12
+ * "right" (display-formatted) numbers.
13
+ *
14
+ * The engine's contract: agents pass semantic values (raw number for
15
+ * money, ISO string for date). The cell's numFmt handles display. If the
16
+ * agent does pass a formatted string anyway, we try to parse it so the
17
+ * file still works.
18
+ */
19
+
20
+ export type NumFmtKind = "numeric" | "percent" | "currency" | "date" | "text" | "general";
21
+
22
+ /**
23
+ * Classify a cell's numFmt code by the data type it expects.
24
+ *
25
+ * Heuristic — OOXML doesn't tag formats with a type, so we inspect tokens:
26
+ * - `@` → text
27
+ * - date tokens (`y`/`yyyy`, `d`/`dd`, `h`/`hh`, `s`/`ss`, or adjacent `m`
28
+ * within a date sequence like `yyyy-mm-dd`) → date
29
+ * - `%` → percent
30
+ * - currency symbols (`$`, `¥`, `€`, `£`, `₫`) or `[$...]` locale tag → currency
31
+ * - digit placeholders (`#`, `0`, `?`) → numeric
32
+ * - otherwise (empty, `"General"`, unrecognized) → general
33
+ *
34
+ * Format codes can have up to 4 sections (pos;neg;zero;text) separated by
35
+ * `;`; classification uses the first section since that's the "active"
36
+ * format for the value being written.
37
+ */
38
+ export function classifyNumFmt(code: string | undefined | null): NumFmtKind {
39
+ if (!code || code === "General") return "general";
40
+
41
+ const first = code.split(";")[0] ?? code;
42
+
43
+ // Currency check runs against the original form: `"$"` and `[$VND-42A]`
44
+ // are canonical currency markers that sit inside quoted/bracketed
45
+ // literals and would be erased by the literal-stripping pass below.
46
+ const currency = hasCurrencyTokens(first);
47
+
48
+ const stripped = stripQuotedLiterals(first);
49
+
50
+ if (stripped.trim() === "@") return "text";
51
+
52
+ if (hasDateTokens(stripped)) return "date";
53
+ if (stripped.includes("%")) return "percent";
54
+ if (currency) return "currency";
55
+ if (/[#0?]/.test(stripped)) return "numeric";
56
+
57
+ return "general";
58
+ }
59
+
60
+ /**
61
+ * Coerce a substitution value to the type expected by the target cell.
62
+ *
63
+ * - Already-typed inputs (number/boolean) are preserved regardless of
64
+ * numFmt — trust the caller's explicit type.
65
+ * - String inputs are parsed according to numFmt kind; on parse failure
66
+ * the original string is returned (so "N/A" in a number cell stays as
67
+ * text rather than silently becoming 0).
68
+ * - `null`/`undefined` map to `null` (empty cell).
69
+ */
70
+ export function coerceByNumFmt(value: unknown, numFmtCode: string | undefined | null): string | number | boolean | null {
71
+ if (value === null || value === undefined) return null;
72
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
73
+ if (typeof value === "boolean") return value;
74
+ if (typeof value !== "string") return String(value);
75
+
76
+ const kind = classifyNumFmt(numFmtCode);
77
+ const trimmed = value.trim();
78
+ if (trimmed === "") return "";
79
+
80
+ // Integer numFmt (no decimal places): `98.680` in a VN context must mean
81
+ // 98680, not 98.68, since the format can't represent fractions. Pass the
82
+ // hint down so the parser treats single-dot-3-digits as thousand
83
+ // grouping instead of a decimal fraction.
84
+ const isInteger = !hasDecimalPart(numFmtCode ?? "");
85
+
86
+ if (kind === "numeric" || kind === "currency") {
87
+ const n = parseLocalizedNumber(trimmed, { integerOnly: isInteger });
88
+ if (n !== null) return n;
89
+ } else if (kind === "percent") {
90
+ // Accept bare "0.05" or "5%" — convert the latter to 0.05.
91
+ const hasPct = trimmed.endsWith("%");
92
+ const n = parseLocalizedNumber(hasPct ? trimmed.slice(0, -1) : trimmed, { integerOnly: false });
93
+ if (n !== null) return hasPct ? n / 100 : n;
94
+ } else if (kind === "date") {
95
+ const serial = parseToExcelSerial(trimmed);
96
+ if (serial !== null) return serial;
97
+ } else if (kind === "general") {
98
+ // Bare-cell, no format hint — try boolean, then numeric, else keep as
99
+ // string. Common case: a declared "boolean" variable pushed as JS
100
+ // boolean flows through the typed path above; this branch rescues the
101
+ // stringified form.
102
+ if (/^(true|false)$/i.test(trimmed)) return trimmed.toLowerCase() === "true";
103
+ const n = parseLocalizedNumber(trimmed);
104
+ if (n !== null) return n;
105
+ }
106
+
107
+ return value;
108
+ }
109
+
110
+ // ─── Helpers ─────────────────────────────────────────────────────────────
111
+
112
+ // Excel format codes use `"..."` and `\<c>` for literal characters. Strip
113
+ // them before pattern-matching so `"-"` (literal dash in zero-section)
114
+ // doesn't confuse `#` / `0` detection.
115
+ function stripQuotedLiterals(code: string): string {
116
+ return code
117
+ .replace(/"[^"]*"/g, "")
118
+ .replace(/\\./g, "");
119
+ }
120
+
121
+ function hasDateTokens(code: string): boolean {
122
+ // Whole-word date tokens: y/yy/yyyy, d/dd/ddd/dddd, h/hh, s/ss, am/pm, a/p.
123
+ if (/\b(y{1,4}|d{1,4}|h{1,2}|s{1,2}|am\/pm|a\/p)\b/i.test(code)) return true;
124
+ // `m` is ambiguous (month vs minutes vs literal). Count it as a date
125
+ // token only when it sits next to another date token — `yyyy-mm-dd`,
126
+ // `mm/yyyy`, `dd-mmm-yyyy` — to avoid misclassifying `"#,##0.00m"` or
127
+ // similar numeric suffixes.
128
+ if (/[ymdhs]\s*[-\/.:\s]\s*m/i.test(code)) return true;
129
+ if (/m\s*[-\/.:\s]\s*[ymdhs]/i.test(code)) return true;
130
+ return false;
131
+ }
132
+
133
+ function hasCurrencyTokens(code: string): boolean {
134
+ if (/[$¥€£₫¢]/.test(code)) return true;
135
+ if (/\[\$/.test(code)) return true; // locale-tagged: [$-409], [$VND-42A]
136
+ return false;
137
+ }
138
+
139
+ export interface ParseNumberOptions {
140
+ /**
141
+ * Resolve the `N.NNN` ambiguity when the target cell's numFmt has no
142
+ * decimal places (e.g. `#,##0`): treat a single-dot + exactly-three-
143
+ * digits string as thousand grouping (`98.680` → 98680) rather than a
144
+ * decimal fraction (→ 98.68). Defaults to `false` — decimal
145
+ * interpretation is more broadly safe.
146
+ */
147
+ integerOnly?: boolean;
148
+ }
149
+
150
+ /**
151
+ * Parse a number that may carry locale-specific grouping separators or a
152
+ * currency prefix/suffix. Supports:
153
+ * - bare: `1234`, `1234.56`, `-1234.56`
154
+ * - EU thousand-separator: `1.234.567,89`, `308.500.000`
155
+ * - US thousand-separator: `1,234,567.89`, `308,500,000`
156
+ * - accountants' parens for negatives: `(1,234)` → -1234
157
+ * - currency prefix/suffix: `$1,234.56`, `1.234,56 ₫`, `VND 308.500.000`
158
+ *
159
+ * Returns null on ambiguity or unparseable input (so the caller can fall
160
+ * back to the literal string).
161
+ */
162
+ export function parseLocalizedNumber(input: string, opts: ParseNumberOptions = {}): number | null {
163
+ let s = input.trim();
164
+ if (s === "") return null;
165
+
166
+ // Accountants' negative parens: `(1,234)` → `-1,234`.
167
+ let negParens = false;
168
+ if (s.startsWith("(") && s.endsWith(")")) {
169
+ negParens = true;
170
+ s = s.slice(1, -1).trim();
171
+ }
172
+
173
+ // Strip currency markers only — whitespace, currency symbols, and
174
+ // common ISO codes at string ends. Letters/digits inside the string
175
+ // must not be re-interpreted (e.g. `"Thép D16"` must NOT parse as 16).
176
+ const currencyTrimRe = /^[\s$¥€£₫¢]*(?:VND|VNĐ|USD|EUR|GBP|JPY)?\s*|\s*(?:VND|VNĐ|USD|EUR|GBP|JPY)?[\s$¥€£₫¢%]*$/gi;
177
+ s = s.replace(currencyTrimRe, "");
178
+ if (s === "") return null;
179
+ // Any remaining non-number character disqualifies the string.
180
+ if (!/^[\d+\-().,\s]+$/.test(s)) return null;
181
+ s = s.replace(/\s+/g, "");
182
+
183
+ let n: number | null = null;
184
+
185
+ // Single-dot three-trailing-digits is ambiguous (`1.234` → 1.234 vs
186
+ // 1234). When the caller hints that the target format has no decimal
187
+ // places, interpret the dot as thousand grouping.
188
+ if (opts.integerOnly && /^-?\d+\.\d{3}$/.test(s)) {
189
+ n = Number(s.replace(".", ""));
190
+ } else if (/^-?\d+(\.\d+)?$/.test(s)) {
191
+ n = Number(s);
192
+ } else if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(s)) {
193
+ n = Number(s.replace(/\./g, "").replace(",", "."));
194
+ } else if (/^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) {
195
+ n = Number(s.replace(/,/g, ""));
196
+ } else if (/^-?\d+,\d+$/.test(s)) {
197
+ n = Number(s.replace(",", "."));
198
+ }
199
+
200
+ if (n === null || !Number.isFinite(n)) return null;
201
+ return negParens ? -n : n;
202
+ }
203
+
204
+ /**
205
+ * True when the numFmt code contains a decimal part (`0.00`, `#,##0.0#`,
206
+ * `0.00%`). Used to resolve the single-dot-three-digits ambiguity in
207
+ * integer-formatted cells.
208
+ */
209
+ function hasDecimalPart(code: string): boolean {
210
+ const stripped = stripQuotedLiterals(code.split(";")[0] ?? "");
211
+ return /\.[0#?]/.test(stripped);
212
+ }
213
+
214
+ /**
215
+ * Convert an ISO-ish date string to an Excel 1900-system serial number.
216
+ *
217
+ * Supports `YYYY-MM-DD` and `YYYY-MM-DDTHH:mm[:ss]`. Anchor is 1899-12-30
218
+ * because Excel erroneously treats 1900 as a leap year (dating back to
219
+ * Lotus 1-2-3 compatibility); the shifted anchor makes dates from
220
+ * 1900-03-01 onward correct. Dates before that rarely appear in business
221
+ * templates and aren't worth handling specially.
222
+ */
223
+ export function parseToExcelSerial(input: string): number | null {
224
+ const m = input.trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
225
+ if (!m) return null;
226
+ const [, y, mo, d, hh, mm, ss] = m;
227
+ const dt = Date.UTC(+y, +mo - 1, +d, Number(hh) || 0, Number(mm) || 0, Number(ss) || 0);
228
+ if (!Number.isFinite(dt)) return null;
229
+ const epoch = Date.UTC(1899, 11, 30);
230
+ return (dt - epoch) / 86400000;
231
+ }
@@ -0,0 +1,70 @@
1
+ import type { CellContent, RichTextPart } from "./types";
2
+ import type { SharedStringEntry } from "./ooxml_workbook";
3
+ import { formatNumFmt } from "./excel_numfmt";
4
+
5
+ // =============================================================================
6
+ // Cell value formatting
7
+ // =============================================================================
8
+
9
+ /**
10
+ * Format a raw cell value from XML into a display string.
11
+ * Delegates all number/date formatting to the excel_numfmt engine.
12
+ */
13
+ export function formatCellValue(
14
+ rawValue: string | undefined,
15
+ type: string,
16
+ numFmt: string,
17
+ sharedString?: SharedStringEntry,
18
+ date1904?: boolean,
19
+ ): string {
20
+ if (rawValue === undefined || rawValue === "") {
21
+ if (type === "s" && sharedString) return sharedString.text;
22
+ return "";
23
+ }
24
+
25
+ switch (type) {
26
+ case "s":
27
+ return sharedString?.text ?? "";
28
+ case "b":
29
+ return rawValue === "1" ? "TRUE" : "FALSE";
30
+ case "e":
31
+ return rawValue;
32
+ case "str":
33
+ case "inlineStr":
34
+ return rawValue;
35
+ default: {
36
+ const num = parseFloat(rawValue);
37
+ if (isNaN(num)) return rawValue;
38
+ return formatNumFmt(num, numFmt, date1904 ?? false).text;
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Format a number using an Excel number format string.
45
+ * Thin wrapper around formatNumFmt for backward compatibility.
46
+ */
47
+ export function formatNumber(value: number, numFmt: string): string {
48
+ return formatNumFmt(value, numFmt).text;
49
+ }
50
+
51
+ // =============================================================================
52
+ // Cell content building
53
+ // =============================================================================
54
+
55
+ export function buildCellContent(
56
+ displayValue: string,
57
+ sharedString?: SharedStringEntry,
58
+ hyperlink?: string,
59
+ ): CellContent {
60
+ if (hyperlink) {
61
+ return { type: "hyperlink", text: displayValue || hyperlink, url: hyperlink };
62
+ }
63
+
64
+ if (sharedString?.richText) {
65
+ const parts: RichTextPart[] = sharedString.richText;
66
+ return { type: "rich_text", parts };
67
+ }
68
+
69
+ return { type: "plain", text: displayValue };
70
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { XMLParser } from "fast-xml-parser";
3
+ import { parseChartXml } from "./ooxml_chart";
4
+
5
+ const parser = new XMLParser({
6
+ ignoreAttributes: false,
7
+ attributeNamePrefix: "@_",
8
+ isArray: () => false,
9
+ });
10
+ const parseXml = (xml: string) => parser.parse(xml) as Record<string, unknown>;
11
+
12
+ const BAR_CHART_XML = `<?xml version="1.0"?>
13
+ <c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
14
+ <c:chart>
15
+ <c:title><c:tx><c:rich><a:p><a:r><a:t>Sales</a:t></a:r></a:p></c:rich></c:tx></c:title>
16
+ <c:legend><c:legendPos val="b"/></c:legend>
17
+ <c:plotArea>
18
+ <c:barChart>
19
+ <c:barDir val="bar"/>
20
+ <c:ser>
21
+ <c:tx><c:strRef><c:strCache><c:pt idx="0"><c:v>Series1</c:v></c:pt></c:strCache></c:strRef></c:tx>
22
+ <c:val><c:numRef><c:numCache><c:pt idx="0"><c:v>10</c:v></c:pt><c:pt idx="1"><c:v>20</c:v></c:pt></c:numCache></c:numRef></c:val>
23
+ </c:ser>
24
+ </c:barChart>
25
+ <c:catAx><c:scaling/></c:catAx>
26
+ <c:valAx><c:scaling/></c:valAx>
27
+ </c:plotArea>
28
+ </c:chart>
29
+ </c:chartSpace>`;
30
+
31
+ const LINE_CHART_XML = `<?xml version="1.0"?>
32
+ <c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">
33
+ <c:chart>
34
+ <c:plotArea>
35
+ <c:lineChart>
36
+ <c:ser>
37
+ <c:val><c:numRef><c:numCache><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val>
38
+ </c:ser>
39
+ </c:lineChart>
40
+ </c:plotArea>
41
+ </c:chart>
42
+ </c:chartSpace>`;
43
+
44
+ const EMPTY_CHART_XML = `<?xml version="1.0"?>
45
+ <c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">
46
+ <c:chart>
47
+ <c:plotArea/>
48
+ </c:chart>
49
+ </c:chartSpace>`;
50
+
51
+ describe("parseChartXml", () => {
52
+ it("parses bar chart with correct type, series, and title", () => {
53
+ const result = parseChartXml(BAR_CHART_XML, parseXml);
54
+ expect(result.chartType).toBe("bar");
55
+ expect(result.title).toBe("Sales");
56
+ expect(result.series).toHaveLength(1);
57
+ expect(result.series[0].name).toBe("Series1");
58
+ expect(result.series[0].values).toEqual([10, 20]);
59
+ });
60
+
61
+ it("parses line chart type", () => {
62
+ const result = parseChartXml(LINE_CHART_XML, parseXml);
63
+ expect(result.chartType).toBe("line");
64
+ expect(result.series).toHaveLength(1);
65
+ expect(result.series[0].values).toEqual([5]);
66
+ });
67
+
68
+ it("handles empty chart as unknown type with no series", () => {
69
+ const result = parseChartXml(EMPTY_CHART_XML, parseXml);
70
+ expect(result.chartType).toBe("unknown");
71
+ expect(result.series).toEqual([]);
72
+ });
73
+
74
+ it("extracts legend position", () => {
75
+ const result = parseChartXml(BAR_CHART_XML, parseXml);
76
+ expect(result.legendPosition).toBe("bottom");
77
+ });
78
+
79
+ it("extracts axes", () => {
80
+ const result = parseChartXml(BAR_CHART_XML, parseXml);
81
+ expect(result.axes).toHaveLength(2);
82
+ expect(result.axes![0].type).toBe("category");
83
+ expect(result.axes![1].type).toBe("value");
84
+ });
85
+ });