@lotics/xlsx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +30 -0
- package/src/auto_sum.test.ts +71 -0
- package/src/auto_sum.ts +83 -0
- package/src/canvas_cf.ts +135 -0
- package/src/canvas_chart.ts +687 -0
- package/src/canvas_fill.ts +156 -0
- package/src/canvas_images.ts +99 -0
- package/src/canvas_layout.test.ts +159 -0
- package/src/canvas_layout.ts +239 -0
- package/src/canvas_renderer.ts +1010 -0
- package/src/canvas_shape.ts +242 -0
- package/src/canvas_sparkline.ts +163 -0
- package/src/canvas_text.ts +454 -0
- package/src/cell_editor.ts +558 -0
- package/src/clipboard.test.ts +145 -0
- package/src/clipboard.ts +341 -0
- package/src/command_history.ts +102 -0
- package/src/csv_parser.test.ts +130 -0
- package/src/csv_parser.ts +172 -0
- package/src/data_validation.test.ts +95 -0
- package/src/data_validation.ts +114 -0
- package/src/editing_layer.test.ts +309 -0
- package/src/excel_cf_evaluate.test.ts +293 -0
- package/src/excel_cf_evaluate.ts +719 -0
- package/src/excel_cf_icons.ts +108 -0
- package/src/excel_cf_types.ts +67 -0
- package/src/excel_numfmt.test.ts +607 -0
- package/src/excel_numfmt.ts +1033 -0
- package/src/excel_parser.test.ts +1061 -0
- package/src/excel_parser.ts +393 -0
- package/src/excel_pattern_fills.ts +64 -0
- package/src/excel_table_styles.ts +160 -0
- package/src/excel_utils.test.ts +108 -0
- package/src/excel_utils.ts +162 -0
- package/src/fast-formula-parser.d.ts +53 -0
- package/src/fill_logic.ts +257 -0
- package/src/fill_patterns.test.ts +90 -0
- package/src/fill_patterns.ts +71 -0
- package/src/flash_fill.test.ts +75 -0
- package/src/flash_fill.ts +221 -0
- package/src/formula_deps.ts +189 -0
- package/src/formula_engine.test.ts +348 -0
- package/src/formula_engine.ts +401 -0
- package/src/formula_highlight.test.ts +81 -0
- package/src/formula_highlight.ts +139 -0
- package/src/formula_suggest.ts +100 -0
- package/src/hidden_sheets.test.ts +49 -0
- package/src/index.ts +149 -0
- package/src/keyboard_nav.test.ts +394 -0
- package/src/keyboard_nav.ts +891 -0
- package/src/move_logic.ts +63 -0
- package/src/numfmt_type.test.ts +158 -0
- package/src/numfmt_type.ts +231 -0
- package/src/ooxml_cell_format.ts +70 -0
- package/src/ooxml_chart.test.ts +85 -0
- package/src/ooxml_chart.ts +347 -0
- package/src/ooxml_chart_types.ts +207 -0
- package/src/ooxml_color.ts +339 -0
- package/src/ooxml_drawing.test.ts +87 -0
- package/src/ooxml_drawing.ts +287 -0
- package/src/ooxml_pivot.test.ts +195 -0
- package/src/ooxml_pivot.ts +468 -0
- package/src/ooxml_pivot_types.ts +165 -0
- package/src/ooxml_shape.ts +355 -0
- package/src/ooxml_sheet.ts +1271 -0
- package/src/ooxml_styles.ts +556 -0
- package/src/ooxml_table.test.ts +70 -0
- package/src/ooxml_table.ts +131 -0
- package/src/ooxml_workbook.test.ts +40 -0
- package/src/ooxml_workbook.ts +259 -0
- package/src/ooxml_zip.ts +33 -0
- package/src/pivot_model.ts +237 -0
- package/src/pivot_recompute.test.ts +210 -0
- package/src/pivot_recompute.ts +413 -0
- package/src/selection_model.ts +364 -0
- package/src/spreadsheet_commands.test.ts +772 -0
- package/src/spreadsheet_commands.ts +1805 -0
- package/src/structured_refs.test.ts +128 -0
- package/src/structured_refs.ts +160 -0
- package/src/style_helpers.test.ts +33 -0
- package/src/style_helpers.ts +30 -0
- package/src/template_builder.test.ts +139 -0
- package/src/template_builder.ts +36 -0
- package/src/types.ts +239 -0
- package/src/workbook_model.test.ts +536 -0
- package/src/workbook_model.ts +911 -0
- package/src/xlsx_round_trip.test.ts +279 -0
- package/src/xlsx_writer.test.ts +488 -0
- package/src/xlsx_writer.ts +1549 -0
- package/src/xml_entities.ts +23 -0
|
@@ -0,0 +1,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
|
+
});
|