@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,454 @@
1
+ // =============================================================================
2
+ // Canvas Text Layout
3
+ // =============================================================================
4
+ //
5
+ // Advanced text rendering for the spreadsheet canvas:
6
+ // - Word wrap using ctx.measureText()
7
+ // - Shrink to fit (reduce font size until text fits cell width)
8
+ // - Text rotation (angled and stacked vertical)
9
+ // - Rich text (per-run font changes)
10
+ // - Indent support
11
+ // - Superscript/subscript
12
+ // - Text overflow into adjacent empty cells
13
+ // =============================================================================
14
+
15
+ import type { CellStyle, RichTextPart } from "./types";
16
+
17
+ // =============================================================================
18
+ // Types
19
+ // =============================================================================
20
+
21
+ export interface TextLayoutInput {
22
+ text: string;
23
+ richText?: RichTextPart[];
24
+ style: CellStyle;
25
+ cellX: number;
26
+ cellY: number;
27
+ cellW: number;
28
+ cellH: number;
29
+ /** Callback to check if adjacent cell to the right is empty (for overflow). */
30
+ isAdjacentEmpty?: (colOffset: number) => boolean;
31
+ /** Adjacent column widths for overflow calculation. */
32
+ getAdjacentWidth?: (colOffset: number) => number;
33
+ }
34
+
35
+ // =============================================================================
36
+ // Main draw function
37
+ // =============================================================================
38
+
39
+ export function drawCellTextAdvanced(
40
+ ctx: CanvasRenderingContext2D,
41
+ input: TextLayoutInput,
42
+ ): void {
43
+ const { text, richText, style, cellX, cellY, cellW, cellH } = input;
44
+ if (!text && !richText) return;
45
+
46
+ const padding = 4;
47
+ const indentPx = (style.indent ?? 0) * 8;
48
+ const availW = cellW - padding * 2 - indentPx;
49
+ const availH = cellH - 4;
50
+
51
+ if (availW <= 0 || availH <= 0) return;
52
+
53
+ // Handle text rotation
54
+ const rotation = style.textRotation;
55
+ if (rotation === "vertical") {
56
+ drawVerticalText(ctx, text, style, cellX, cellY, cellW, cellH, padding);
57
+ return;
58
+ }
59
+ if (typeof rotation === "number" && rotation !== 0) {
60
+ drawRotatedText(ctx, text, style, cellX, cellY, cellW, cellH, padding, rotation);
61
+ return;
62
+ }
63
+
64
+ // Build font string
65
+ let fontSize = style.fontSize ?? 11;
66
+ const fontName = style.fontName ?? "Calibri, sans-serif";
67
+ const bold = style.fontBold ? "bold " : "";
68
+ const italic = style.fontItalic ? "italic " : "";
69
+
70
+ // Rich text rendering
71
+ if (richText && richText.length > 0) {
72
+ drawRichText(ctx, richText, style, cellX, cellY, cellW, cellH, padding, indentPx);
73
+ return;
74
+ }
75
+
76
+ let font = `${italic}${bold}${fontSize}px ${fontName}`;
77
+ ctx.font = font;
78
+
79
+ const textWidth = ctx.measureText(text).width;
80
+
81
+ // Shrink to fit
82
+ if (style.shrinkToFit && textWidth > availW) {
83
+ const scale = availW / textWidth;
84
+ fontSize = Math.max(6, Math.floor(fontSize * scale));
85
+ font = `${italic}${bold}${fontSize}px ${fontName}`;
86
+ ctx.font = font;
87
+ }
88
+
89
+ // Word wrap
90
+ if (style.wrapText) {
91
+ const lines = wrapText(ctx, text, availW);
92
+ drawWrappedLines(ctx, lines, style, cellX, cellY, cellW, cellH, padding, indentPx, fontSize);
93
+ return;
94
+ }
95
+
96
+ // Single line — check overflow
97
+ const finalWidth = ctx.measureText(text).width;
98
+ let drawWidth = cellW;
99
+ if (finalWidth > availW && input.isAdjacentEmpty && input.getAdjacentWidth) {
100
+ // Overflow into adjacent empty cells
101
+ let extra = 0;
102
+ for (let offset = 1; offset <= 10; offset++) {
103
+ if (!input.isAdjacentEmpty(offset)) break;
104
+ extra += input.getAdjacentWidth(offset);
105
+ if (availW + extra >= finalWidth) break;
106
+ }
107
+ drawWidth = cellW + extra;
108
+ }
109
+
110
+ // Clip and draw
111
+ ctx.save();
112
+ ctx.beginPath();
113
+ ctx.rect(cellX + 1, cellY + 1, drawWidth - 2, cellH - 2);
114
+ ctx.clip();
115
+
116
+ // RTL support: readingOrder 2 = right-to-left
117
+ const isRtl = style.readingOrder === 2;
118
+ if (isRtl) ctx.direction = "rtl";
119
+
120
+ ctx.fillStyle = style.fontColor ?? "#000000";
121
+ ctx.textBaseline = "alphabetic";
122
+
123
+ const effectiveStyle = isRtl ? rtlAlignStyle(style) : style;
124
+ const { x: textX, align } = computeHAlign(effectiveStyle, text, cellX, cellW, padding, indentPx);
125
+ const textY = computeVAlign(style, cellY, cellH, fontSize);
126
+ ctx.textAlign = align;
127
+ ctx.fillText(text, textX, textY);
128
+
129
+ drawDecorations(ctx, style, text, textX, textY, align, fontSize);
130
+
131
+ if (isRtl) ctx.direction = "ltr";
132
+ ctx.restore();
133
+ }
134
+
135
+ // =============================================================================
136
+ // Word wrap
137
+ // =============================================================================
138
+
139
+ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
140
+ if (maxWidth <= 0) return [text];
141
+
142
+ const words = text.split(/(\s+)/);
143
+ const lines: string[] = [];
144
+ let currentLine = "";
145
+
146
+ for (const word of words) {
147
+ const testLine = currentLine + word;
148
+ if (ctx.measureText(testLine).width > maxWidth && currentLine.length > 0) {
149
+ lines.push(currentLine);
150
+ currentLine = word.trimStart();
151
+ } else {
152
+ currentLine = testLine;
153
+ }
154
+ }
155
+ if (currentLine) lines.push(currentLine);
156
+ return lines.length > 0 ? lines : [text];
157
+ }
158
+
159
+ function drawWrappedLines(
160
+ ctx: CanvasRenderingContext2D,
161
+ lines: string[],
162
+ style: CellStyle,
163
+ cellX: number,
164
+ cellY: number,
165
+ cellW: number,
166
+ cellH: number,
167
+ padding: number,
168
+ indentPx: number,
169
+ fontSize: number,
170
+ ): void {
171
+ const lineHeight = fontSize * 1.2;
172
+ const totalHeight = lines.length * lineHeight;
173
+ const vAlign = style.verticalAlign;
174
+
175
+ let startY: number;
176
+ if (vAlign === "top") {
177
+ startY = cellY + fontSize + 2;
178
+ } else if (vAlign === "middle") {
179
+ startY = cellY + (cellH - totalHeight) / 2 + fontSize;
180
+ } else {
181
+ startY = cellY + cellH - totalHeight + fontSize - 2;
182
+ }
183
+
184
+ ctx.save();
185
+ ctx.beginPath();
186
+ ctx.rect(cellX + 1, cellY + 1, cellW - 2, cellH - 2);
187
+ ctx.clip();
188
+
189
+ ctx.fillStyle = style.fontColor ?? "#000000";
190
+ ctx.textBaseline = "alphabetic";
191
+
192
+ for (let i = 0; i < lines.length; i++) {
193
+ const y = startY + i * lineHeight;
194
+ if (y - fontSize > cellY + cellH) break;
195
+ if (y < cellY) continue;
196
+
197
+ const { x: textX, align } = computeHAlign(style, lines[i], cellX, cellW, padding, indentPx);
198
+ ctx.textAlign = align;
199
+ ctx.fillText(lines[i], textX, y);
200
+ }
201
+
202
+ ctx.restore();
203
+ }
204
+
205
+ // =============================================================================
206
+ // Rich text
207
+ // =============================================================================
208
+
209
+ function drawRichText(
210
+ ctx: CanvasRenderingContext2D,
211
+ parts: RichTextPart[],
212
+ style: CellStyle,
213
+ cellX: number,
214
+ cellY: number,
215
+ cellW: number,
216
+ cellH: number,
217
+ padding: number,
218
+ indentPx: number,
219
+ ): void {
220
+ ctx.save();
221
+ ctx.beginPath();
222
+ ctx.rect(cellX + 1, cellY + 1, cellW - 2, cellH - 2);
223
+ ctx.clip();
224
+
225
+ const baseFontSize = style.fontSize ?? 11;
226
+ const textY = computeVAlign(style, cellY, cellH, baseFontSize);
227
+
228
+ // Compute total width for alignment
229
+ let totalWidth = 0;
230
+ const runs: { text: string; font: string; color: string; width: number; yOffset: number; fontSize: number }[] = [];
231
+ for (const part of parts) {
232
+ const f = part.font;
233
+ const size = f?.size ?? baseFontSize;
234
+ const name = f?.name ?? style.fontName ?? "Calibri, sans-serif";
235
+ const b = (f?.bold ?? style.fontBold) ? "bold " : "";
236
+ const it = (f?.italic ?? style.fontItalic) ? "italic " : "";
237
+ const font = `${it}${b}${size}px ${name}`;
238
+ ctx.font = font;
239
+ const width = ctx.measureText(part.text).width;
240
+ let yOffset = 0;
241
+ if (f?.vertAlign === "superscript") yOffset = -size * 0.35;
242
+ else if (f?.vertAlign === "subscript") yOffset = size * 0.2;
243
+ runs.push({ text: part.text, font, color: f?.color ?? style.fontColor ?? "#000000", width, yOffset, fontSize: size });
244
+ totalWidth += width;
245
+ }
246
+
247
+ // Compute starting X based on alignment
248
+ const align = style.horizontalAlign;
249
+ let x: number;
250
+ if (align === "center") {
251
+ x = cellX + (cellW - totalWidth) / 2;
252
+ } else if (align === "right") {
253
+ x = cellX + cellW - padding - totalWidth;
254
+ } else {
255
+ x = cellX + padding + indentPx;
256
+ }
257
+
258
+ ctx.textAlign = "left";
259
+ ctx.textBaseline = "alphabetic";
260
+
261
+ for (const run of runs) {
262
+ ctx.font = run.font;
263
+ ctx.fillStyle = run.color;
264
+ ctx.fillText(run.text, x, textY + run.yOffset);
265
+ x += run.width;
266
+ }
267
+
268
+ ctx.restore();
269
+ }
270
+
271
+ // =============================================================================
272
+ // Rotation
273
+ // =============================================================================
274
+
275
+ function drawRotatedText(
276
+ ctx: CanvasRenderingContext2D,
277
+ text: string,
278
+ style: CellStyle,
279
+ cellX: number,
280
+ cellY: number,
281
+ cellW: number,
282
+ cellH: number,
283
+ padding: number,
284
+ degrees: number,
285
+ ): void {
286
+ const fontSize = style.fontSize ?? 11;
287
+ const fontName = style.fontName ?? "Calibri, sans-serif";
288
+ const bold = style.fontBold ? "bold " : "";
289
+ const italic = style.fontItalic ? "italic " : "";
290
+ ctx.font = `${italic}${bold}${fontSize}px ${fontName}`;
291
+
292
+ ctx.save();
293
+ ctx.beginPath();
294
+ ctx.rect(cellX + 1, cellY + 1, cellW - 2, cellH - 2);
295
+ ctx.clip();
296
+
297
+ const cx = cellX + cellW / 2;
298
+ const cy = cellY + cellH / 2;
299
+
300
+ // Excel rotation: 0-90 counterclockwise, 91-180 maps to -1 to -90
301
+ let radians: number;
302
+ if (degrees <= 90) {
303
+ radians = -(degrees * Math.PI) / 180;
304
+ } else {
305
+ radians = ((degrees - 90) * Math.PI) / 180;
306
+ }
307
+
308
+ ctx.translate(cx, cy);
309
+ ctx.rotate(radians);
310
+
311
+ ctx.fillStyle = style.fontColor ?? "#000000";
312
+ ctx.textAlign = "center";
313
+ ctx.textBaseline = "middle";
314
+ ctx.fillText(text, 0, 0);
315
+
316
+ ctx.restore();
317
+ }
318
+
319
+ function drawVerticalText(
320
+ ctx: CanvasRenderingContext2D,
321
+ text: string,
322
+ style: CellStyle,
323
+ cellX: number,
324
+ cellY: number,
325
+ cellW: number,
326
+ cellH: number,
327
+ padding: number,
328
+ ): void {
329
+ const fontSize = style.fontSize ?? 11;
330
+ const fontName = style.fontName ?? "Calibri, sans-serif";
331
+ const bold = style.fontBold ? "bold " : "";
332
+ const italic = style.fontItalic ? "italic " : "";
333
+ ctx.font = `${italic}${bold}${fontSize}px ${fontName}`;
334
+
335
+ ctx.save();
336
+ ctx.beginPath();
337
+ ctx.rect(cellX + 1, cellY + 1, cellW - 2, cellH - 2);
338
+ ctx.clip();
339
+
340
+ ctx.fillStyle = style.fontColor ?? "#000000";
341
+ ctx.textAlign = "center";
342
+ ctx.textBaseline = "middle";
343
+
344
+ const charHeight = fontSize * 1.3;
345
+ const totalHeight = text.length * charHeight;
346
+ const startY = cellY + Math.max(padding, (cellH - totalHeight) / 2) + charHeight / 2;
347
+ const cx = cellX + cellW / 2;
348
+
349
+ for (let i = 0; i < text.length; i++) {
350
+ const y = startY + i * charHeight;
351
+ if (y > cellY + cellH - padding) break;
352
+ ctx.fillText(text[i], cx, y);
353
+ }
354
+
355
+ ctx.restore();
356
+ }
357
+
358
+ // =============================================================================
359
+ // Alignment helpers
360
+ // =============================================================================
361
+
362
+ function computeHAlign(
363
+ style: CellStyle,
364
+ text: string,
365
+ cellX: number,
366
+ cellW: number,
367
+ padding: number,
368
+ indentPx: number,
369
+ ): { x: number; align: CanvasTextAlign } {
370
+ const align = style.horizontalAlign;
371
+ if (align === "center") {
372
+ return { x: cellX + cellW / 2, align: "center" };
373
+ }
374
+ if (align === "right") {
375
+ return { x: cellX + cellW - padding, align: "right" };
376
+ }
377
+ // Default: numbers right, text left
378
+ if (!align) {
379
+ const isNum = text !== "" && !isNaN(Number(text));
380
+ if (isNum) return { x: cellX + cellW - padding, align: "right" };
381
+ }
382
+ return { x: cellX + padding + indentPx, align: "left" };
383
+ }
384
+
385
+ function computeVAlign(
386
+ style: CellStyle,
387
+ cellY: number,
388
+ cellH: number,
389
+ fontSize: number,
390
+ ): number {
391
+ const vAlign = style.verticalAlign;
392
+ if (vAlign === "top") return cellY + fontSize + 2;
393
+ if (vAlign === "middle") return cellY + cellH / 2 + fontSize / 3;
394
+ return cellY + cellH - 4; // bottom (default)
395
+ }
396
+
397
+ // =============================================================================
398
+ // RTL helper
399
+ // =============================================================================
400
+
401
+ /** Reverse horizontal alignment for RTL text. */
402
+ function rtlAlignStyle(style: CellStyle): CellStyle {
403
+ const align = style.horizontalAlign;
404
+ if (align === "left") return { ...style, horizontalAlign: "right" };
405
+ if (align === "right") return { ...style, horizontalAlign: "left" };
406
+ return style;
407
+ }
408
+
409
+ // =============================================================================
410
+ // Text decorations (underline, strikethrough)
411
+ // =============================================================================
412
+
413
+ function drawDecorations(
414
+ ctx: CanvasRenderingContext2D,
415
+ style: CellStyle,
416
+ text: string,
417
+ textX: number,
418
+ textY: number,
419
+ textAlign: CanvasTextAlign,
420
+ fontSize: number,
421
+ ): void {
422
+ if (!style.fontUnderline && !style.fontStrike) return;
423
+
424
+ const metrics = ctx.measureText(text);
425
+ const startX = textAlign === "right" ? textX - metrics.width
426
+ : textAlign === "center" ? textX - metrics.width / 2
427
+ : textX;
428
+
429
+ ctx.strokeStyle = style.fontColor ?? "#000000";
430
+
431
+ if (style.fontUnderline) {
432
+ ctx.lineWidth = style.fontUnderline === "double" ? 0.5 : 1;
433
+ const underY = textY + 2;
434
+ ctx.beginPath();
435
+ ctx.moveTo(startX, underY);
436
+ ctx.lineTo(startX + metrics.width, underY);
437
+ ctx.stroke();
438
+ if (style.fontUnderline === "double") {
439
+ ctx.beginPath();
440
+ ctx.moveTo(startX, underY + 2);
441
+ ctx.lineTo(startX + metrics.width, underY + 2);
442
+ ctx.stroke();
443
+ }
444
+ }
445
+
446
+ if (style.fontStrike) {
447
+ ctx.lineWidth = 1;
448
+ const strikeY = textY - fontSize / 3;
449
+ ctx.beginPath();
450
+ ctx.moveTo(startX, strikeY);
451
+ ctx.lineTo(startX + metrics.width, strikeY);
452
+ ctx.stroke();
453
+ }
454
+ }