@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,279 @@
1
+ // =============================================================================
2
+ // xlsx round-trip — parse(real .xlsx) → export → parse → assert identical
3
+ // =============================================================================
4
+ //
5
+ // Real-fixture round-trip coverage. The earlier round-trip suite in
6
+ // `xlsx_writer.test.ts` only exercised in-memory model → write → parse,
7
+ // so the parser → writer path was never tested with colors that come from
8
+ // an OOXML source. The bug behind the "Debit Note shows black backgrounds"
9
+ // regression lived exactly there: the parser stored colors as `rgb(r, g, b)`
10
+ // CSS strings while the writer assumed `#RRGGBB` hex.
11
+ //
12
+ // These tests build a styled fixture via ExcelJS (so the colors take the
13
+ // "real" path through the parser), then verify that:
14
+ // 1. The parser normalizes colors to canonical `#RRGGBB`.
15
+ // 2. The writer emits valid OOXML for those colors (`FFRRGGBB` ARGB),
16
+ // not garbled `FFrgb(r, g, b)`.
17
+ // 3. Re-parsing the exported file yields the same model.
18
+ // 4. Styles passthrough kicks in when no styles have been mutated.
19
+
20
+ import { describe, it, expect } from "vitest";
21
+ import fs from "node:fs";
22
+ import ExcelJS from "exceljs";
23
+ import { exportWorkbook } from "./xlsx_writer";
24
+ import { parseExcelFromZip } from "./excel_parser";
25
+ import { unzipXlsx } from "./ooxml_zip";
26
+ import { loadWorkbookFromSnapshot } from "./workbook_model";
27
+
28
+ async function buildStyledFixture(): Promise<ArrayBuffer> {
29
+ const wb = new ExcelJS.Workbook();
30
+ const ws = wb.addWorksheet("Sales");
31
+
32
+ // Title row: dark-blue background, white bold text, merged
33
+ ws.getCell("A1").value = "Q1 Report";
34
+ ws.getCell("A1").font = { bold: true, color: { argb: "FFFFFFFF" }, size: 16 };
35
+ ws.getCell("A1").fill = {
36
+ type: "pattern",
37
+ pattern: "solid",
38
+ fgColor: { argb: "FF003366" },
39
+ };
40
+ ws.mergeCells("A1:C1");
41
+
42
+ // Header row with gray background and dark text
43
+ ws.getCell("A2").value = "Product";
44
+ ws.getCell("B2").value = "Revenue";
45
+ ws.getCell("C2").value = "Margin";
46
+ for (const ref of ["A2", "B2", "C2"]) {
47
+ ws.getCell(ref).font = { bold: true, color: { argb: "FF333333" } };
48
+ ws.getCell(ref).fill = {
49
+ type: "pattern",
50
+ pattern: "solid",
51
+ fgColor: { argb: "FFF2F2F2" },
52
+ };
53
+ }
54
+
55
+ // Data rows
56
+ ws.getCell("A3").value = "Widget";
57
+ ws.getCell("B3").value = 1234.56;
58
+ ws.getCell("C3").value = 0.42;
59
+ ws.getCell("A4").value = "Gadget";
60
+ ws.getCell("B4").value = 789;
61
+ ws.getCell("C4").value = 0.31;
62
+
63
+ // Totals row: red text, cream background
64
+ ws.getCell("A5").value = "Total";
65
+ ws.getCell("A5").font = { bold: true, color: { argb: "FFCC0000" } };
66
+ ws.getCell("B5").value = 2023.56;
67
+ ws.getCell("B5").font = { bold: true, color: { argb: "FFCC0000" } };
68
+ for (const ref of ["A5", "B5", "C5"]) {
69
+ ws.getCell(ref).fill = {
70
+ type: "pattern",
71
+ pattern: "solid",
72
+ fgColor: { argb: "FFFFFBF0" },
73
+ };
74
+ }
75
+
76
+ const buffer = await wb.xlsx.writeBuffer();
77
+ return buffer as ArrayBuffer;
78
+ }
79
+
80
+ function unzipToEntries(bytes: Uint8Array): Record<string, Uint8Array> {
81
+ const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
82
+ return unzipXlsx(ab as ArrayBuffer);
83
+ }
84
+
85
+ describe("xlsx round-trip — real fixture", () => {
86
+ it("parser normalizes solid fill colors to #RRGGBB", async () => {
87
+ const buffer = await buildStyledFixture();
88
+ const zip = unzipXlsx(buffer);
89
+ const parsed = parseExcelFromZip(zip);
90
+ const sheet = parsed.sheets[0];
91
+
92
+ const titleCell = sheet.rows.find((r) => r.index === 1)?.cells.find((c) => c.column === 1);
93
+ expect(titleCell?.style.backgroundColor).toBe("#003366");
94
+ expect(titleCell?.style.fontColor).toBe("#FFFFFF");
95
+
96
+ const headerCell = sheet.rows.find((r) => r.index === 2)?.cells.find((c) => c.column === 1);
97
+ expect(headerCell?.style.backgroundColor).toBe("#F2F2F2");
98
+ expect(headerCell?.style.fontColor).toBe("#333333");
99
+
100
+ const totalCell = sheet.rows.find((r) => r.index === 5)?.cells.find((c) => c.column === 1);
101
+ expect(totalCell?.style.fontColor).toBe("#CC0000");
102
+ expect(totalCell?.style.backgroundColor).toBe("#FFFBF0");
103
+ });
104
+
105
+ it("writer emits valid 8-char ARGB for solid fills (regression: not FFrgb(...))", async () => {
106
+ const buffer = await buildStyledFixture();
107
+ const zip = unzipXlsx(buffer);
108
+ const parsed = parseExcelFromZip(zip);
109
+ const wb = loadWorkbookFromSnapshot(parsed);
110
+
111
+ // Force a value mutation so we exit passthrough mode and exercise the
112
+ // styles XML rebuild path. Setting an existing cell's value with the
113
+ // same style should NOT dirty the registry, but we want to ensure the
114
+ // regen path itself produces valid colors when it does run.
115
+ wb.styles.dirty = true;
116
+
117
+ const exported = exportWorkbook(wb, zip);
118
+ const exportedZip = unzipToEntries(exported);
119
+ const stylesXml = new TextDecoder().decode(exportedZip["xl/styles.xml"]);
120
+
121
+ // No invalid `FFrgb(...)` substrings — the bug signature.
122
+ expect(stylesXml).not.toContain("FFrgb(");
123
+ // The original solid fill colors survive as proper 8-char ARGB.
124
+ expect(stylesXml).toContain("FF003366");
125
+ expect(stylesXml).toContain("FFF2F2F2");
126
+ expect(stylesXml).toContain("FFCC0000");
127
+ expect(stylesXml).toContain("FFFFFBF0");
128
+ });
129
+
130
+ it("re-parsing the exported file yields identical colors and merges", async () => {
131
+ const buffer = await buildStyledFixture();
132
+ const zip = unzipXlsx(buffer);
133
+ const parsed = parseExcelFromZip(zip);
134
+ const wb = loadWorkbookFromSnapshot(parsed);
135
+
136
+ // Force a regen to test the writer's color emission, not just passthrough.
137
+ wb.styles.dirty = true;
138
+
139
+ const exportedBytes = exportWorkbook(wb, zip);
140
+ const exportedZip = unzipToEntries(exportedBytes);
141
+ const reParsed = parseExcelFromZip(exportedZip);
142
+ const sheet = reParsed.sheets[0];
143
+
144
+ // Title fill survives parser → writer → parser.
145
+ const titleCell = sheet.rows.find((r) => r.index === 1)?.cells.find((c) => c.column === 1);
146
+ expect(titleCell?.style.backgroundColor).toBe("#003366");
147
+ expect(titleCell?.style.fontColor).toBe("#FFFFFF");
148
+
149
+ // Header background.
150
+ const headerCell = sheet.rows.find((r) => r.index === 2)?.cells.find((c) => c.column === 1);
151
+ expect(headerCell?.style.backgroundColor).toBe("#F2F2F2");
152
+
153
+ // Merged title range.
154
+ const merge = sheet.mergedCells.find((m) => m.startRow === 1 && m.startCol === 1);
155
+ expect(merge).toBeDefined();
156
+ expect(merge?.endRow).toBe(1);
157
+ expect(merge?.endCol).toBe(3);
158
+ });
159
+
160
+ it("passthrough mode skips styles regeneration on a value-only mutation", async () => {
161
+ const buffer = await buildStyledFixture();
162
+ const zip = unzipXlsx(buffer);
163
+ const parsed = parseExcelFromZip(zip);
164
+ const wb = loadWorkbookFromSnapshot(parsed);
165
+
166
+ // Sanity: parser → loadWorkbookFromSnapshot leaves the registry clean.
167
+ expect(wb.styles.dirty).toBe(false);
168
+
169
+ // Mutate a cell value via the fast path the placeholder engine uses.
170
+ const sheet = wb.sheets[0];
171
+ const titleCell = sheet.getCell("A1");
172
+ sheet.setWithStyleIndex("A1", "Q2 Report", titleCell!.styleIndex, titleCell!.numFmtCode, undefined, titleCell!.originalXfIndex);
173
+
174
+ // Still clean — no new style or numFmt was introduced.
175
+ expect(wb.styles.dirty).toBe(false);
176
+
177
+ const exported = exportWorkbook(wb, zip);
178
+ const exportedZip = unzipToEntries(exported);
179
+ const stylesXml = new TextDecoder().decode(exportedZip["xl/styles.xml"]);
180
+
181
+ // Passthrough: the bytes should be byte-identical to the original.
182
+ expect(stylesXml).toBe(new TextDecoder().decode(zip["xl/styles.xml"]));
183
+
184
+ // And re-parsing still resolves the title cell with the new value and
185
+ // the original styling (proves cell `s=` indexes survived passthrough).
186
+ const reParsed = parseExcelFromZip(exportedZip);
187
+ const reTitle = reParsed.sheets[0].rows.find((r) => r.index === 1)?.cells.find((c) => c.column === 1);
188
+ expect(reTitle?.value).toBe("Q2 Report");
189
+ expect(reTitle?.style.backgroundColor).toBe("#003366");
190
+ expect(reTitle?.style.fontColor).toBe("#FFFFFF");
191
+ });
192
+
193
+ it("styles dirty flag flips when a new style is interned", async () => {
194
+ const buffer = await buildStyledFixture();
195
+ const zip = unzipXlsx(buffer);
196
+ const parsed = parseExcelFromZip(zip);
197
+ const wb = loadWorkbookFromSnapshot(parsed);
198
+ expect(wb.styles.dirty).toBe(false);
199
+
200
+ // Intern a brand-new style — should flip dirty.
201
+ wb.styles.intern({ fontBold: true, backgroundColor: "#ABCDEF" });
202
+ expect(wb.styles.dirty).toBe(true);
203
+ });
204
+
205
+ // End-to-end validation against the actual customer template that
206
+ // surfaced the bug. Skipped if the file isn't present (developers without
207
+ // a local copy can still run the rest of the suite).
208
+ const debitNotePath = "/tmp/debit_note_template_v2.xlsx";
209
+ const hasDebitNote = fs.existsSync(debitNotePath);
210
+ it.skipIf(!hasDebitNote)("regenerates the real Debit Note template without the FFrgb(...) bug", () => {
211
+ const bytes = fs.readFileSync(debitNotePath);
212
+ const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
213
+ const zip = unzipXlsx(ab);
214
+ const parsed = parseExcelFromZip(zip);
215
+ const wb = loadWorkbookFromSnapshot(parsed);
216
+
217
+ expect(wb.styles.dirty).toBe(false);
218
+
219
+ // Force the regen path so we exercise the writer's color emission, not
220
+ // just the passthrough fast path. (Passthrough is also tested above.)
221
+ wb.styles.dirty = true;
222
+ const exported = exportWorkbook(wb, zip);
223
+ const exportedZip = unzipToEntries(exported);
224
+ const stylesXml = new TextDecoder().decode(exportedZip["xl/styles.xml"]);
225
+
226
+ // The bug signature: never appears.
227
+ expect(stylesXml).not.toContain("FFrgb(");
228
+
229
+ // The original DebitNote palette survives intact.
230
+ expect(stylesXml).toContain("FF003366"); // header dark blue
231
+ expect(stylesXml).toContain("FFF2F2F2"); // light gray
232
+ expect(stylesXml).toContain("FFCC0000"); // alert red
233
+ expect(stylesXml).toContain("FFFFFBF0"); // cream
234
+ });
235
+
236
+ it("registerNumFmt flips dirty for unknown codes only", async () => {
237
+ const buffer = await buildStyledFixture();
238
+ const zip = unzipXlsx(buffer);
239
+ const parsed = parseExcelFromZip(zip);
240
+ const wb = loadWorkbookFromSnapshot(parsed);
241
+ expect(wb.styles.dirty).toBe(false);
242
+
243
+ // Already-known code: stays clean.
244
+ wb.styles.registerNumFmt("General");
245
+ expect(wb.styles.dirty).toBe(false);
246
+
247
+ // Brand-new code: dirties.
248
+ wb.styles.registerNumFmt('"$"#,##0.00');
249
+ expect(wb.styles.dirty).toBe(true);
250
+ });
251
+
252
+ it("preserves XML entities in formatCode across round-trip", async () => {
253
+ // `processEntities: false` means the parser returns attribute values with
254
+ // `&quot;`, `&amp;`, etc. still escaped; every parser site must decode them
255
+ // before storing in the model. Skipping the decode causes the writer to
256
+ // re-escape `&` → `&amp;`, producing `&amp;quot;` — Excel/LibreOffice then
257
+ // renders the literal garbage `&quot-&quot` where `"-"` should appear.
258
+ const wb = new ExcelJS.Workbook();
259
+ const ws = wb.addWorksheet("S");
260
+ ws.getCell("A1").value = 0;
261
+ ws.getCell("A1").numFmt = '#,##0;(#,##0);"-"';
262
+ const ab = (await wb.xlsx.writeBuffer()) as ArrayBuffer;
263
+
264
+ const zip = unzipXlsx(ab);
265
+ const parsed = parseExcelFromZip(zip);
266
+ const wbModel = loadWorkbookFromSnapshot(parsed);
267
+
268
+ // Parser surfaces the decoded code, not the escaped XML form.
269
+ const cellFmt = wbModel.sheets[0].cells.get("A1")?.numFmtCode;
270
+ expect(cellFmt).toBe('#,##0;(#,##0);"-"');
271
+
272
+ // Writer emits single-escaped XML, never double-escaped.
273
+ wbModel.styles.dirty = true;
274
+ const exported = exportWorkbook(wbModel, zip);
275
+ const stylesXml = new TextDecoder().decode(unzipToEntries(exported)["xl/styles.xml"]);
276
+ expect(stylesXml).toContain("&quot;-&quot;");
277
+ expect(stylesXml).not.toContain("&amp;quot;");
278
+ });
279
+ });