@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,128 @@
|
|
|
1
|
+
import { expandStructuredRefs } from "./structured_refs";
|
|
2
|
+
import type { ParsedTable } from "./ooxml_chart_types";
|
|
3
|
+
|
|
4
|
+
function makeTable(overrides: Partial<ParsedTable> = {}): ParsedTable {
|
|
5
|
+
return {
|
|
6
|
+
name: "Sales",
|
|
7
|
+
displayName: "Sales",
|
|
8
|
+
ref: "B2:D10",
|
|
9
|
+
columns: [{ name: "Product" }, { name: "Revenue" }, { name: "Qty" }],
|
|
10
|
+
headerRow: true,
|
|
11
|
+
totalsRow: false,
|
|
12
|
+
styleName: "TableStyleMedium9",
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("expandStructuredRefs", () => {
|
|
18
|
+
test("returns formula unchanged when no tables exist", () => {
|
|
19
|
+
expect(expandStructuredRefs("SUM(A1:A10)", [], 5)).toBe("SUM(A1:A10)");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns formula unchanged when no structured refs present", () => {
|
|
23
|
+
const tables = [makeTable()];
|
|
24
|
+
expect(expandStructuredRefs("A1+B2", tables, 5)).toBe("A1+B2");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("expands TableName[ColumnName] to data body range", () => {
|
|
28
|
+
const tables = [makeTable()];
|
|
29
|
+
// Table B2:D10, header=true, no totals → data is rows 3-10
|
|
30
|
+
// "Revenue" is column index 1 → column C
|
|
31
|
+
const result = expandStructuredRefs("SUM(Sales[Revenue])", tables, 5);
|
|
32
|
+
expect(result).toBe("SUM(C3:C10)");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("expands TableName[ColumnName] with totals row", () => {
|
|
36
|
+
const tables = [makeTable({ totalsRow: true })];
|
|
37
|
+
// With totals: data is rows 3-9 (last row reserved for totals)
|
|
38
|
+
const result = expandStructuredRefs("SUM(Sales[Revenue])", tables, 5);
|
|
39
|
+
expect(result).toBe("SUM(C3:C9)");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("expands TableName[ColumnName] without header row", () => {
|
|
43
|
+
const tables = [makeTable({ headerRow: false })];
|
|
44
|
+
// No header: data starts at row 2
|
|
45
|
+
const result = expandStructuredRefs("SUM(Sales[Revenue])", tables, 5);
|
|
46
|
+
expect(result).toBe("SUM(C2:C10)");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("expands [@ColumnName] to this-row reference", () => {
|
|
50
|
+
const tables = [makeTable()];
|
|
51
|
+
const result = expandStructuredRefs("[@Revenue]*[@Qty]", tables, 5);
|
|
52
|
+
// Revenue → col C, Qty → col D, row 5
|
|
53
|
+
expect(result).toBe("C5*D5");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("expands [#Data] to entire data body", () => {
|
|
57
|
+
const tables = [makeTable()];
|
|
58
|
+
const result = expandStructuredRefs("SUM(Sales[#Data])", tables, 5);
|
|
59
|
+
expect(result).toBe("SUM(B3:D10)");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("expands [#Headers] to header row", () => {
|
|
63
|
+
const tables = [makeTable()];
|
|
64
|
+
const result = expandStructuredRefs("Sales[#Headers]", tables, 5);
|
|
65
|
+
expect(result).toBe("B2:D2");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("returns unchanged for [#Headers] when no header row", () => {
|
|
69
|
+
const tables = [makeTable({ headerRow: false })];
|
|
70
|
+
const result = expandStructuredRefs("Sales[#Headers]", tables, 5);
|
|
71
|
+
expect(result).toBe("Sales[#Headers]");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("expands [#Totals] to totals row", () => {
|
|
75
|
+
const tables = [makeTable({ totalsRow: true })];
|
|
76
|
+
const result = expandStructuredRefs("Sales[#Totals]", tables, 5);
|
|
77
|
+
expect(result).toBe("B10:D10");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("expands [[#Headers],[ColumnName]]", () => {
|
|
81
|
+
const tables = [makeTable()];
|
|
82
|
+
const result = expandStructuredRefs("Sales[[#Headers],[Revenue]]", tables, 5);
|
|
83
|
+
expect(result).toBe("C2");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("expands [[#Totals],[ColumnName]]", () => {
|
|
87
|
+
const tables = [makeTable({ totalsRow: true })];
|
|
88
|
+
const result = expandStructuredRefs("Sales[[#Totals],[Revenue]]", tables, 5);
|
|
89
|
+
expect(result).toBe("C10");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("case-insensitive column matching", () => {
|
|
93
|
+
const tables = [makeTable()];
|
|
94
|
+
const result = expandStructuredRefs("SUM(Sales[revenue])", tables, 5);
|
|
95
|
+
expect(result).toBe("SUM(C3:C10)");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("matches by displayName", () => {
|
|
99
|
+
const tables = [makeTable({ name: "tbl1", displayName: "Sales" })];
|
|
100
|
+
const result = expandStructuredRefs("SUM(Sales[Revenue])", tables, 5);
|
|
101
|
+
expect(result).toBe("SUM(C3:C10)");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns unchanged for unknown table name", () => {
|
|
105
|
+
const tables = [makeTable()];
|
|
106
|
+
const result = expandStructuredRefs("SUM(Other[Revenue])", tables, 5);
|
|
107
|
+
expect(result).toBe("SUM(Other[Revenue])");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns unchanged for unknown column name", () => {
|
|
111
|
+
const tables = [makeTable()];
|
|
112
|
+
const result = expandStructuredRefs("SUM(Sales[Nonexistent])", tables, 5);
|
|
113
|
+
expect(result).toBe("SUM(Sales[Nonexistent])");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("handles multiple structured refs in one formula", () => {
|
|
117
|
+
const tables = [makeTable()];
|
|
118
|
+
const result = expandStructuredRefs("Sales[Revenue]+Sales[Qty]", tables, 5);
|
|
119
|
+
expect(result).toBe("C3:C10+D3:D10");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("first column resolves correctly", () => {
|
|
123
|
+
const tables = [makeTable()];
|
|
124
|
+
const result = expandStructuredRefs("SUM(Sales[Product])", tables, 5);
|
|
125
|
+
// Product is column index 0 → column B
|
|
126
|
+
expect(result).toBe("SUM(B3:B10)");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Structured Reference Expansion
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Expands Excel structured table references into cell range references.
|
|
6
|
+
// Patterns:
|
|
7
|
+
// TableName[ColumnName] → data body column range
|
|
8
|
+
// TableName[[#Headers],[ColumnName]] → header cell
|
|
9
|
+
// TableName[[#Totals],[ColumnName]] → totals cell
|
|
10
|
+
// TableName[#Data] → entire data body
|
|
11
|
+
// [@ColumnName] → this-row reference (needs context)
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
import type { ParsedTable } from "./ooxml_chart_types";
|
|
15
|
+
import { colLettersToNum, colNumToLetters } from "./excel_utils";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Expand structured references in a formula, replacing them with A1-style ranges.
|
|
19
|
+
* @param formula Raw formula string (without leading =)
|
|
20
|
+
* @param tables Tables from the sheet model
|
|
21
|
+
* @param currentRow The row of the cell containing the formula (for @ references)
|
|
22
|
+
*/
|
|
23
|
+
export function expandStructuredRefs(
|
|
24
|
+
formula: string,
|
|
25
|
+
tables: ParsedTable[],
|
|
26
|
+
currentRow: number,
|
|
27
|
+
): string {
|
|
28
|
+
if (tables.length === 0) return formula;
|
|
29
|
+
|
|
30
|
+
// Match patterns like TableName[...] or [@ColumnName]
|
|
31
|
+
// The inner bracket group uses [^\[\]]* to avoid consuming nested brackets
|
|
32
|
+
return formula.replace(
|
|
33
|
+
/(\w+)?\[((?:[^[\]]*(?:\[[^\]]*\])?)*)\]/g,
|
|
34
|
+
(match, tableName: string | undefined, bracketContent: string) => {
|
|
35
|
+
// Handle [@ColumnName] — bare this-row reference without table name
|
|
36
|
+
if (!tableName && bracketContent.startsWith("@")) {
|
|
37
|
+
const colName = bracketContent.slice(1);
|
|
38
|
+
const table = findTableForColumn(tables, colName);
|
|
39
|
+
if (!table) return match;
|
|
40
|
+
return resolveThisRow(table, colName, currentRow);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!tableName) return match;
|
|
44
|
+
|
|
45
|
+
const table = tables.find(
|
|
46
|
+
(t) => t.name === tableName || t.displayName === tableName,
|
|
47
|
+
);
|
|
48
|
+
if (!table) return match;
|
|
49
|
+
|
|
50
|
+
return resolveStructuredRef(table, bracketContent, currentRow) ?? match;
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveStructuredRef(
|
|
56
|
+
table: ParsedTable,
|
|
57
|
+
content: string,
|
|
58
|
+
currentRow: number,
|
|
59
|
+
): string | undefined {
|
|
60
|
+
const { ref, columns, headerRow, totalsRow } = table;
|
|
61
|
+
const range = parseTableRange(ref);
|
|
62
|
+
if (!range) return undefined;
|
|
63
|
+
|
|
64
|
+
// [#Data] — entire data body
|
|
65
|
+
if (content === "#Data" || content === "#All") {
|
|
66
|
+
const dataStartRow = headerRow ? range.startRow + 1 : range.startRow;
|
|
67
|
+
const dataEndRow = totalsRow ? range.endRow - 1 : range.endRow;
|
|
68
|
+
return `${colNumToLetters(range.startCol)}${dataStartRow}:${colNumToLetters(range.endCol)}${dataEndRow}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// [#Headers] — entire header row
|
|
72
|
+
if (content === "#Headers") {
|
|
73
|
+
if (!headerRow) return undefined;
|
|
74
|
+
return `${colNumToLetters(range.startCol)}${range.startRow}:${colNumToLetters(range.endCol)}${range.startRow}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// [#Totals] — entire totals row
|
|
78
|
+
if (content === "#Totals") {
|
|
79
|
+
if (!totalsRow) return undefined;
|
|
80
|
+
return `${colNumToLetters(range.startCol)}${range.endRow}:${colNumToLetters(range.endCol)}${range.endRow}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// [[#Headers],[ColumnName]]
|
|
84
|
+
const headerMatch = content.match(/^\[#Headers\],\s*\[?([^\]]+)\]?$/);
|
|
85
|
+
if (headerMatch && headerRow) {
|
|
86
|
+
const colIndex = findColumnIndex(columns, headerMatch[1]);
|
|
87
|
+
if (colIndex < 0) return undefined;
|
|
88
|
+
const col = range.startCol + colIndex;
|
|
89
|
+
return `${colNumToLetters(col)}${range.startRow}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// [[#Totals],[ColumnName]]
|
|
93
|
+
const totalsMatch = content.match(/^\[#Totals\],\s*\[?([^\]]+)\]?$/);
|
|
94
|
+
if (totalsMatch && totalsRow) {
|
|
95
|
+
const colIndex = findColumnIndex(columns, totalsMatch[1]);
|
|
96
|
+
if (colIndex < 0) return undefined;
|
|
97
|
+
const col = range.startCol + colIndex;
|
|
98
|
+
return `${colNumToLetters(col)}${range.endRow}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// [@ColumnName] — this-row reference within table context
|
|
102
|
+
if (content.startsWith("@")) {
|
|
103
|
+
const colName = content.slice(1).replace(/^\[|\]$/g, "");
|
|
104
|
+
return resolveThisRow(table, colName, currentRow);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// [ColumnName] — data body column range
|
|
108
|
+
const colName = content.replace(/^\[|\]$/g, "");
|
|
109
|
+
const colIndex = findColumnIndex(columns, colName);
|
|
110
|
+
if (colIndex < 0) return undefined;
|
|
111
|
+
|
|
112
|
+
const col = range.startCol + colIndex;
|
|
113
|
+
const dataStartRow = headerRow ? range.startRow + 1 : range.startRow;
|
|
114
|
+
const dataEndRow = totalsRow ? range.endRow - 1 : range.endRow;
|
|
115
|
+
return `${colNumToLetters(col)}${dataStartRow}:${colNumToLetters(col)}${dataEndRow}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveThisRow(
|
|
119
|
+
table: ParsedTable,
|
|
120
|
+
colName: string,
|
|
121
|
+
currentRow: number,
|
|
122
|
+
): string {
|
|
123
|
+
const range = parseTableRange(table.ref);
|
|
124
|
+
if (!range) return colName;
|
|
125
|
+
|
|
126
|
+
const colIndex = findColumnIndex(table.columns, colName);
|
|
127
|
+
if (colIndex < 0) return colName;
|
|
128
|
+
|
|
129
|
+
const col = range.startCol + colIndex;
|
|
130
|
+
return `${colNumToLetters(col)}${currentRow}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findTableForColumn(
|
|
134
|
+
tables: ParsedTable[],
|
|
135
|
+
colName: string,
|
|
136
|
+
): ParsedTable | undefined {
|
|
137
|
+
return tables.find((t) => findColumnIndex(t.columns, colName) >= 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function findColumnIndex(
|
|
141
|
+
columns: { name: string }[],
|
|
142
|
+
name: string,
|
|
143
|
+
): number {
|
|
144
|
+
return columns.findIndex(
|
|
145
|
+
(c) => c.name.toLowerCase() === name.toLowerCase(),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseTableRange(
|
|
150
|
+
ref: string,
|
|
151
|
+
): { startRow: number; startCol: number; endRow: number; endCol: number } | undefined {
|
|
152
|
+
const match = ref.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/);
|
|
153
|
+
if (!match) return undefined;
|
|
154
|
+
return {
|
|
155
|
+
startCol: colLettersToNum(match[1]),
|
|
156
|
+
startRow: parseInt(match[2], 10),
|
|
157
|
+
endCol: colLettersToNum(match[3]),
|
|
158
|
+
endRow: parseInt(match[4], 10),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { allBorders, bottomBorder, topBorder } from "./style_helpers";
|
|
3
|
+
|
|
4
|
+
describe("allBorders", () => {
|
|
5
|
+
it("returns thin black borders by default", () => {
|
|
6
|
+
const b = allBorders();
|
|
7
|
+
expect(b.borderTop).toEqual({ width: 1, style: "thin", color: "#000000" });
|
|
8
|
+
expect(b.borderRight).toEqual({ width: 1, style: "thin", color: "#000000" });
|
|
9
|
+
expect(b.borderBottom).toEqual({ width: 1, style: "thin", color: "#000000" });
|
|
10
|
+
expect(b.borderLeft).toEqual({ width: 1, style: "thin", color: "#000000" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("accepts custom style and color", () => {
|
|
14
|
+
const b = allBorders("medium", "#4472C4");
|
|
15
|
+
expect(b.borderTop).toEqual({ width: 1, style: "medium", color: "#4472C4" });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("bottomBorder", () => {
|
|
20
|
+
it("returns medium black bottom border by default", () => {
|
|
21
|
+
const b = bottomBorder();
|
|
22
|
+
expect(b.borderBottom).toEqual({ width: 1, style: "medium", color: "#000000" });
|
|
23
|
+
expect(b).not.toHaveProperty("borderTop");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("topBorder", () => {
|
|
28
|
+
it("returns medium black top border by default", () => {
|
|
29
|
+
const b = topBorder();
|
|
30
|
+
expect(b.borderTop).toEqual({ width: 1, style: "medium", color: "#000000" });
|
|
31
|
+
expect(b).not.toHaveProperty("borderBottom");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CellStyle, BorderStyle } from "./types";
|
|
2
|
+
|
|
3
|
+
function border(style = "thin", color = "#000000"): BorderStyle {
|
|
4
|
+
return { width: 1, style, color };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Thin black borders on all four sides. */
|
|
8
|
+
export function allBorders(
|
|
9
|
+
style = "thin",
|
|
10
|
+
color = "#000000",
|
|
11
|
+
): Pick<CellStyle, "borderTop" | "borderRight" | "borderBottom" | "borderLeft"> {
|
|
12
|
+
const b = border(style, color);
|
|
13
|
+
return { borderTop: b, borderRight: b, borderBottom: b, borderLeft: b };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Single bottom border. */
|
|
17
|
+
export function bottomBorder(
|
|
18
|
+
style = "medium",
|
|
19
|
+
color = "#000000",
|
|
20
|
+
): Pick<CellStyle, "borderBottom"> {
|
|
21
|
+
return { borderBottom: border(style, color) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Single top border. */
|
|
25
|
+
export function topBorder(
|
|
26
|
+
style = "medium",
|
|
27
|
+
color = "#000000",
|
|
28
|
+
): Pick<CellStyle, "borderTop"> {
|
|
29
|
+
return { borderTop: border(style, color) };
|
|
30
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildTemplateWorkbook } from "./template_builder";
|
|
3
|
+
import { allBorders } from "./style_helpers";
|
|
4
|
+
import { exportWorkbook } from "./xlsx_writer";
|
|
5
|
+
|
|
6
|
+
describe("buildTemplateWorkbook", () => {
|
|
7
|
+
it("builds a single-sheet workbook with set()", () => {
|
|
8
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
9
|
+
sheet.set("A1", "Hello");
|
|
10
|
+
sheet.set("B1", 42);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(wb.sheets).toHaveLength(1);
|
|
14
|
+
expect(wb.sheets[0].getCell("A1")?.value).toBe("Hello");
|
|
15
|
+
expect(wb.sheets[0].getCell("B1")?.value).toBe(42);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("interns styles via set()", () => {
|
|
19
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
20
|
+
sheet.set("A1", "Bold", { fontBold: true });
|
|
21
|
+
sheet.set("A2", "Also Bold", { fontBold: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const a1 = wb.sheets[0].getCell("A1")!;
|
|
25
|
+
const a2 = wb.sheets[0].getCell("A2")!;
|
|
26
|
+
expect(a1.styleIndex).toBe(a2.styleIndex);
|
|
27
|
+
expect(a1.styleIndex).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("set() uses numFmt parameter", () => {
|
|
31
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
32
|
+
sheet.set("A1", 1500, { horizontalAlign: "right" }, "#,##0.00");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const cell = wb.sheets[0].getCell("A1")!;
|
|
36
|
+
expect(cell.numFmtCode).toBe("#,##0.00");
|
|
37
|
+
expect(cell.displayValue).toBe("1,500.00");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("set() defaults to styleIndex 0 and General numFmt", () => {
|
|
41
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
42
|
+
sheet.set("A1", "plain");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const cell = wb.sheets[0].getCell("A1")!;
|
|
46
|
+
expect(cell.styleIndex).toBe(0);
|
|
47
|
+
expect(cell.numFmtCode).toBe("General");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("merge() adds range and sets top-left value", () => {
|
|
51
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
52
|
+
sheet.merge("A1:C1", "Title", { fontBold: true, fontSize: 14 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(wb.sheets[0].mergedCells).toContain("A1:C1");
|
|
56
|
+
expect(wb.sheets[0].getCell("A1")?.value).toBe("Title");
|
|
57
|
+
expect(wb.sheets[0].getCell("A1")?.styleIndex).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("merge() without value just adds the range", () => {
|
|
61
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
62
|
+
sheet.merge("A1:C1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(wb.sheets[0].mergedCells).toContain("A1:C1");
|
|
66
|
+
expect(wb.sheets[0].getCell("A1")).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("setColumnWidth() accepts letter or index", () => {
|
|
70
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
71
|
+
sheet.setColumnWidth("A", 30);
|
|
72
|
+
sheet.setColumnWidth(2, 20); // index 2 = column B
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(wb.sheets[0].colWidths.get(1)).toBe(30); // "A" → 1
|
|
76
|
+
expect(wb.sheets[0].colWidths.get(2)).toBe(20);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("setRowHeight() sets height", () => {
|
|
80
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
81
|
+
sheet.setRowHeight(1, 25);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(wb.sheets[0].rowHeights.get(1)).toBe(25);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("headerRow() writes bold bordered cells with background", () => {
|
|
88
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
89
|
+
sheet.headerRow(1, ["Name", "Amount", "Date"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const sheet = wb.sheets[0];
|
|
93
|
+
expect(sheet.getCell("A1")?.value).toBe("Name");
|
|
94
|
+
expect(sheet.getCell("B1")?.value).toBe("Amount");
|
|
95
|
+
expect(sheet.getCell("C1")?.value).toBe("Date");
|
|
96
|
+
|
|
97
|
+
const style = wb.styles.get(sheet.getCell("A1")!.styleIndex);
|
|
98
|
+
expect(style.fontBold).toBe(true);
|
|
99
|
+
expect(style.horizontalAlign).toBe("center");
|
|
100
|
+
expect(style.backgroundColor).toBe("#D9E1F2");
|
|
101
|
+
expect(style.borderTop).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("headerRow() accepts custom background", () => {
|
|
105
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
106
|
+
sheet.headerRow(1, ["A"], { background: "#E2EFDA" });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const style = wb.styles.get(wb.sheets[0].getCell("A1")!.styleIndex);
|
|
110
|
+
expect(style.backgroundColor).toBe("#E2EFDA");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("builds multi-sheet workbook", () => {
|
|
114
|
+
const wb = buildTemplateWorkbook(
|
|
115
|
+
{ name: "Summary", build: (s) => s.set("A1", "Summary") },
|
|
116
|
+
{ name: "Details", build: (s) => s.set("A1", "Details") },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(wb.sheets).toHaveLength(2);
|
|
120
|
+
expect(wb.sheets[0].name).toBe("Summary");
|
|
121
|
+
expect(wb.sheets[1].name).toBe("Details");
|
|
122
|
+
expect(wb.sheets[0].getCell("A1")?.value).toBe("Summary");
|
|
123
|
+
expect(wb.sheets[1].getCell("A1")?.value).toBe("Details");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("exports to valid xlsx", () => {
|
|
127
|
+
const wb = buildTemplateWorkbook((sheet) => {
|
|
128
|
+
sheet.merge("A1:B1", "Header", { fontBold: true });
|
|
129
|
+
sheet.headerRow(2, ["Col A", "Col B"]);
|
|
130
|
+
sheet.set("A3", "data", { ...allBorders() });
|
|
131
|
+
sheet.set("B3", 100, { horizontalAlign: "right", ...allBorders() }, "#,##0.00");
|
|
132
|
+
sheet.setColumnWidth("A", 25);
|
|
133
|
+
sheet.setColumnWidth("B", 15);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const bytes = exportWorkbook(wb);
|
|
137
|
+
expect(bytes.byteLength).toBeGreaterThan(0);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { WorkbookModel, SheetModel, StyleRegistry } from "./workbook_model";
|
|
2
|
+
|
|
3
|
+
interface SheetDef {
|
|
4
|
+
name: string;
|
|
5
|
+
build: (sheet: SheetModel) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build a template workbook.
|
|
10
|
+
* Single-sheet: `buildTemplateWorkbook((sheet) => { ... })`
|
|
11
|
+
* Multi-sheet: `buildTemplateWorkbook({ name: "A", build: (s) => {} }, { name: "B", build: (s) => {} })`
|
|
12
|
+
*/
|
|
13
|
+
export function buildTemplateWorkbook(
|
|
14
|
+
...args: [(sheet: SheetModel) => void] | SheetDef[]
|
|
15
|
+
): WorkbookModel {
|
|
16
|
+
const styles = new StyleRegistry();
|
|
17
|
+
|
|
18
|
+
if (args.length === 1 && typeof args[0] === "function") {
|
|
19
|
+
const sheet = new SheetModel("Sheet1", styles);
|
|
20
|
+
args[0](sheet);
|
|
21
|
+
return new WorkbookModel({ sheets: [sheet], activeSheetIndex: 0, styles });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sheetDefs = args as SheetDef[];
|
|
25
|
+
const sheets = sheetDefs.map((def) => {
|
|
26
|
+
const sheet = new SheetModel(def.name, styles);
|
|
27
|
+
def.build(sheet);
|
|
28
|
+
return sheet;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return new WorkbookModel({
|
|
32
|
+
sheets: sheets.length > 0 ? sheets : [new SheetModel("Sheet1", styles)],
|
|
33
|
+
activeSheetIndex: 0,
|
|
34
|
+
styles,
|
|
35
|
+
});
|
|
36
|
+
}
|