odf-kit 0.9.6 → 0.9.8

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 (41) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +306 -272
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/ods/content.d.ts +0 -9
  6. package/dist/ods/content.d.ts.map +1 -1
  7. package/dist/ods/content.js +274 -97
  8. package/dist/ods/content.js.map +1 -1
  9. package/dist/ods/document.d.ts +2 -42
  10. package/dist/ods/document.d.ts.map +1 -1
  11. package/dist/ods/document.js +7 -42
  12. package/dist/ods/document.js.map +1 -1
  13. package/dist/ods/index.d.ts +1 -1
  14. package/dist/ods/index.d.ts.map +1 -1
  15. package/dist/ods/settings.d.ts +13 -0
  16. package/dist/ods/settings.d.ts.map +1 -0
  17. package/dist/ods/settings.js +67 -0
  18. package/dist/ods/settings.js.map +1 -0
  19. package/dist/ods/sheet-builder.d.ts +42 -28
  20. package/dist/ods/sheet-builder.d.ts.map +1 -1
  21. package/dist/ods/sheet-builder.js +57 -42
  22. package/dist/ods/sheet-builder.js.map +1 -1
  23. package/dist/ods/types.d.ts +68 -30
  24. package/dist/ods/types.d.ts.map +1 -1
  25. package/dist/ods-reader/html-renderer.d.ts +19 -0
  26. package/dist/ods-reader/html-renderer.d.ts.map +1 -0
  27. package/dist/ods-reader/html-renderer.js +123 -0
  28. package/dist/ods-reader/html-renderer.js.map +1 -0
  29. package/dist/ods-reader/index.d.ts +19 -0
  30. package/dist/ods-reader/index.d.ts.map +1 -0
  31. package/dist/ods-reader/index.js +22 -0
  32. package/dist/ods-reader/index.js.map +1 -0
  33. package/dist/ods-reader/parser.d.ts +24 -0
  34. package/dist/ods-reader/parser.d.ts.map +1 -0
  35. package/dist/ods-reader/parser.js +544 -0
  36. package/dist/ods-reader/parser.js.map +1 -0
  37. package/dist/ods-reader/types.d.ts +139 -0
  38. package/dist/ods-reader/types.d.ts.map +1 -0
  39. package/dist/ods-reader/types.js +7 -0
  40. package/dist/ods-reader/types.js.map +1 -0
  41. package/package.json +22 -5
@@ -1,6 +1,5 @@
1
1
  import { ODF_NS, ODF_VERSION } from "../core/namespaces.js";
2
2
  import { el, xmlDocument } from "../core/xml.js";
3
- /** Generate a stable deduplication key for a NormalizedCellStyle. */
4
3
  function cellStyleKey(cs) {
5
4
  const parts = [];
6
5
  if (cs.fontWeight)
@@ -37,7 +36,6 @@ function cellStyleKey(cs) {
37
36
  parts.push(`ds:${cs.dataStyleName}`);
38
37
  return parts.join("|");
39
38
  }
40
- /** Normalize OdsCellOptions (merged effective options) into a NormalizedCellStyle. */
41
39
  function normalizeOdsCellStyle(opts, dataStyleName) {
42
40
  const result = {};
43
41
  if (opts?.bold)
@@ -54,7 +52,6 @@ function normalizeOdsCellStyle(opts, dataStyleName) {
54
52
  result.underline = true;
55
53
  if (opts?.backgroundColor)
56
54
  result.backgroundColor = opts.backgroundColor;
57
- // Border resolution: side-specific overrides uniform shorthand
58
55
  const border = opts?.border;
59
56
  result.borderTop = opts?.borderTop ?? border;
60
57
  result.borderBottom = opts?.borderBottom ?? border;
@@ -80,15 +77,10 @@ function normalizeOdsCellStyle(opts, dataStyleName) {
80
77
  result.dataStyleName = dataStyleName;
81
78
  return result;
82
79
  }
83
- /** Normalize a fontSize value to an ODF string with units. */
84
80
  function normalizeFontSize(fontSize) {
85
81
  return typeof fontSize === "number" ? `${fontSize}pt` : fontSize;
86
82
  }
87
83
  // ─── Option Merging ───────────────────────────────────────────────────
88
- /**
89
- * Merge row-level options with cell-level options.
90
- * Cell options take precedence over row options for any property defined in both.
91
- */
92
84
  function mergeOptions(rowOpts, cellOpts) {
93
85
  if (!rowOpts && !cellOpts)
94
86
  return undefined;
@@ -99,7 +91,6 @@ function mergeOptions(rowOpts, cellOpts) {
99
91
  return { ...rowOpts, ...cellOpts };
100
92
  }
101
93
  // ─── Date Format Helpers ──────────────────────────────────────────────
102
- /** Map a date format enum value to its number:date-style name. */
103
94
  function dateFormatToStyleName(format) {
104
95
  switch (format) {
105
96
  case "YYYY-MM-DD":
@@ -110,9 +101,7 @@ function dateFormatToStyleName(format) {
110
101
  return "Ndate-mdy";
111
102
  }
112
103
  }
113
- /** Style name for the auto-detected datetime format (date + time). */
114
104
  const DATETIME_STYLE_NAME = "Ndate-dt";
115
- /** Build a number:date-style element for automatic-styles. */
116
105
  function buildDateFormatStyle(format) {
117
106
  const dateStyle = el("number:date-style").attr("style:name", dateFormatToStyleName(format));
118
107
  switch (format) {
@@ -140,30 +129,18 @@ function buildDateFormatStyle(format) {
140
129
  }
141
130
  return dateStyle;
142
131
  }
143
- /**
144
- * Format a Date as an ISO date string (YYYY-MM-DD).
145
- * Always uses UTC to avoid timezone offsets shifting the date.
146
- */
147
132
  function formatDateISO(date) {
148
133
  const y = date.getUTCFullYear();
149
134
  const m = String(date.getUTCMonth() + 1).padStart(2, "0");
150
135
  const d = String(date.getUTCDate()).padStart(2, "0");
151
136
  return `${y}-${m}-${d}`;
152
137
  }
153
- /**
154
- * Return true if the Date has a nonzero UTC time component.
155
- * Used to decide whether to render as a date-only or datetime value.
156
- */
157
138
  function isDatetime(date) {
158
139
  return (date.getUTCHours() !== 0 ||
159
140
  date.getUTCMinutes() !== 0 ||
160
141
  date.getUTCSeconds() !== 0 ||
161
142
  date.getUTCMilliseconds() !== 0);
162
143
  }
163
- /**
164
- * Format a Date as an ISO datetime string (YYYY-MM-DDTHH:MM:SS).
165
- * Always uses UTC to avoid timezone offsets.
166
- */
167
144
  function formatDatetimeISO(date) {
168
145
  const y = date.getUTCFullYear();
169
146
  const mo = String(date.getUTCMonth() + 1).padStart(2, "0");
@@ -173,16 +150,12 @@ function formatDatetimeISO(date) {
173
150
  const s = String(date.getUTCSeconds()).padStart(2, "0");
174
151
  return `${y}-${mo}-${d}T${h}:${mi}:${s}`;
175
152
  }
176
- /**
177
- * Format a Date as a display string for a datetime cell: "YYYY-MM-DD HH:MM:SS".
178
- */
179
153
  function formatDatetimeDisplay(date) {
180
154
  const h = String(date.getUTCHours()).padStart(2, "0");
181
155
  const mi = String(date.getUTCMinutes()).padStart(2, "0");
182
156
  const s = String(date.getUTCSeconds()).padStart(2, "0");
183
157
  return `${formatDateISO(date)} ${h}:${mi}:${s}`;
184
158
  }
185
- /** Format a Date for display according to the given format. */
186
159
  function formatDateDisplay(date, format) {
187
160
  const y = String(date.getUTCFullYear());
188
161
  const m = String(date.getUTCMonth() + 1).padStart(2, "0");
@@ -196,7 +169,6 @@ function formatDateDisplay(date, format) {
196
169
  return `${m}/${d}/${y}`;
197
170
  }
198
171
  }
199
- /** Build a number:date-style element for datetime display (YYYY-MM-DD HH:MM:SS). */
200
172
  function buildDatetimeFormatStyle() {
201
173
  const dtStyle = el("number:date-style").attr("style:name", DATETIME_STYLE_NAME);
202
174
  dtStyle.appendChild(el("number:year").attr("number:style", "long"));
@@ -212,7 +184,6 @@ function buildDatetimeFormatStyle() {
212
184
  dtStyle.appendChild(el("number:seconds").attr("number:style", "long"));
213
185
  return dtStyle;
214
186
  }
215
- /** Return true if any date cell in any sheet has a nonzero UTC time component. */
216
187
  function hasDatetimeCells(sheets) {
217
188
  for (const sheet of sheets) {
218
189
  for (const row of sheet.rows) {
@@ -225,12 +196,144 @@ function hasDatetimeCells(sheets) {
225
196
  }
226
197
  return false;
227
198
  }
228
- /** Resolve the effective date format for a cell: cell > row > document default. */
229
199
  function effectiveDateFormat(cell, row, defaultFormat) {
230
200
  return cell.options?.dateFormat ?? row.options?.dateFormat ?? defaultFormat;
231
201
  }
202
+ // ─── Number Format Helpers ────────────────────────────────────────────
203
+ /**
204
+ * Parse a numberFormat string and return a stable style name.
205
+ * Returns undefined if the format string is not recognized.
206
+ *
207
+ * Format strings:
208
+ * "integer" → "Nnum-int"
209
+ * "decimal:N" → "Nnum-decN"
210
+ * "percentage" → "Nnum-pct2"
211
+ * "percentage:N" → "Nnum-pctN"
212
+ * "currency:CODE" → "Nnum-CODE2" (lowercase code)
213
+ * "currency:CODE:N" → "Nnum-CODEN"
214
+ */
215
+ function numberFormatToStyleName(format) {
216
+ if (format === "integer")
217
+ return "Nnum-int";
218
+ const decMatch = format.match(/^decimal:(\d+)$/);
219
+ if (decMatch)
220
+ return `Nnum-dec${decMatch[1]}`;
221
+ if (format === "percentage")
222
+ return "Nnum-pct2";
223
+ const pctMatch = format.match(/^percentage:(\d+)$/);
224
+ if (pctMatch)
225
+ return `Nnum-pct${pctMatch[1]}`;
226
+ const curMatch = format.match(/^currency:([A-Z]{3})(?::(\d+))?$/);
227
+ if (curMatch) {
228
+ const code = curMatch[1].toLowerCase();
229
+ const decimals = curMatch[2] ?? "2";
230
+ return `Nnum-${code}${decimals}`;
231
+ }
232
+ return undefined;
233
+ }
234
+ /** Currency code → symbol mapping for common currencies. */
235
+ const CURRENCY_SYMBOLS = {
236
+ USD: "$",
237
+ EUR: "€",
238
+ GBP: "£",
239
+ JPY: "¥",
240
+ CNY: "¥",
241
+ CHF: "Fr",
242
+ CAD: "CA$",
243
+ AUD: "A$",
244
+ INR: "₹",
245
+ KRW: "₩",
246
+ SEK: "kr",
247
+ NOK: "kr",
248
+ DKK: "kr",
249
+ PLN: "zł",
250
+ CZK: "Kč",
251
+ HUF: "Ft",
252
+ RON: "lei",
253
+ BGN: "лв",
254
+ HRK: "kn",
255
+ RUB: "₽",
256
+ TRY: "₺",
257
+ BRL: "R$",
258
+ MXN: "MX$",
259
+ ZAR: "R",
260
+ SGD: "S$",
261
+ HKD: "HK$",
262
+ NZD: "NZ$",
263
+ ILS: "₪",
264
+ AED: "د.إ",
265
+ SAR: "﷼",
266
+ };
267
+ /** Build a number:number-style or number:currency-style element. */
268
+ function buildNumberFormatStyle(format) {
269
+ const styleName = numberFormatToStyleName(format);
270
+ if (!styleName)
271
+ return undefined;
272
+ if (format === "integer") {
273
+ const s = el("number:number-style").attr("style:name", styleName);
274
+ s.appendChild(el("number:number")
275
+ .attr("number:decimal-places", "0")
276
+ .attr("number:grouping", "true")
277
+ .attr("number:min-integer-digits", "1"));
278
+ return s;
279
+ }
280
+ const decMatch = format.match(/^decimal:(\d+)$/);
281
+ if (decMatch) {
282
+ const s = el("number:number-style").attr("style:name", styleName);
283
+ s.appendChild(el("number:number")
284
+ .attr("number:decimal-places", decMatch[1])
285
+ .attr("number:grouping", "true")
286
+ .attr("number:min-integer-digits", "1"));
287
+ return s;
288
+ }
289
+ if (format === "percentage") {
290
+ const s = el("number:number-style").attr("style:name", styleName);
291
+ s.appendChild(el("number:number").attr("number:decimal-places", "2").attr("number:min-integer-digits", "1"));
292
+ s.appendChild(el("number:text").text("%"));
293
+ return s;
294
+ }
295
+ const pctMatch = format.match(/^percentage:(\d+)$/);
296
+ if (pctMatch) {
297
+ const s = el("number:number-style").attr("style:name", styleName);
298
+ s.appendChild(el("number:number")
299
+ .attr("number:decimal-places", pctMatch[1])
300
+ .attr("number:min-integer-digits", "1"));
301
+ s.appendChild(el("number:text").text("%"));
302
+ return s;
303
+ }
304
+ const curMatch = format.match(/^currency:([A-Z]{3})(?::(\d+))?$/);
305
+ if (curMatch) {
306
+ const code = curMatch[1];
307
+ const decimals = curMatch[2] ?? "2";
308
+ const symbol = CURRENCY_SYMBOLS[code] ?? code;
309
+ const s = el("number:currency-style").attr("style:name", styleName);
310
+ s.appendChild(el("number:currency-symbol")
311
+ .attr("number:language", "en")
312
+ .attr("number:country", "US")
313
+ .text(symbol));
314
+ s.appendChild(el("number:number")
315
+ .attr("number:decimal-places", decimals)
316
+ .attr("number:grouping", "true")
317
+ .attr("number:min-integer-digits", "1"));
318
+ return s;
319
+ }
320
+ return undefined;
321
+ }
322
+ /** Collect all unique numberFormat values used across all sheets. */
323
+ function collectUsedNumberFormats(sheets) {
324
+ const used = new Set();
325
+ for (const sheet of sheets) {
326
+ for (const row of sheet.rows) {
327
+ for (const cell of row.cells) {
328
+ const fmt = cell.options?.numberFormat ?? row.options?.numberFormat;
329
+ if (fmt)
330
+ used.add(fmt);
331
+ }
332
+ }
333
+ }
334
+ return used;
335
+ }
232
336
  // ─── Style Collection ─────────────────────────────────────────────────
233
- /** Scan all sheets for date formats actually used — only emit those styles. */
234
337
  function collectUsedDateFormats(sheets, defaultFormat) {
235
338
  const used = new Set();
236
339
  for (const sheet of sheets) {
@@ -245,9 +348,19 @@ function collectUsedDateFormats(sheets, defaultFormat) {
245
348
  return used;
246
349
  }
247
350
  /**
248
- * Build a map from cell style key [style name, normalized style].
249
- * Deduplicates identical styles across all sheets and rows.
351
+ * Resolve the data style name for a cell covers both date and number formats.
250
352
  */
353
+ function resolveDataStyleName(cell, row, defaultDateFormat) {
354
+ if (cell.type === "date") {
355
+ return cell.value instanceof Date && isDatetime(cell.value)
356
+ ? DATETIME_STYLE_NAME
357
+ : dateFormatToStyleName(effectiveDateFormat(cell, row, defaultDateFormat));
358
+ }
359
+ const fmt = cell.options?.numberFormat ?? row.options?.numberFormat;
360
+ if (fmt)
361
+ return numberFormatToStyleName(fmt);
362
+ return undefined;
363
+ }
251
364
  function buildCellStyleMap(sheets, defaultDateFormat) {
252
365
  const map = new Map();
253
366
  let counter = 1;
@@ -257,11 +370,7 @@ function buildCellStyleMap(sheets, defaultDateFormat) {
257
370
  if (cell.type === "empty")
258
371
  continue;
259
372
  const effective = mergeOptions(row.options, cell.options);
260
- const dataStyleName = cell.type === "date"
261
- ? cell.value instanceof Date && isDatetime(cell.value)
262
- ? DATETIME_STYLE_NAME
263
- : dateFormatToStyleName(effectiveDateFormat(cell, row, defaultDateFormat))
264
- : undefined;
373
+ const dataStyleName = resolveDataStyleName(cell, row, defaultDateFormat);
265
374
  const normalized = normalizeOdsCellStyle(effective, dataStyleName);
266
375
  const key = cellStyleKey(normalized);
267
376
  if (key === "")
@@ -275,10 +384,6 @@ function buildCellStyleMap(sheets, defaultDateFormat) {
275
384
  }
276
385
  return map;
277
386
  }
278
- /**
279
- * Build a map from column width → style name, plus the shared optimal style name.
280
- * Columns without explicit widths share one "optimal" style.
281
- */
282
387
  function buildColumnStyleMap(sheets) {
283
388
  const widthMap = new Map();
284
389
  let counter = 1;
@@ -292,10 +397,6 @@ function buildColumnStyleMap(sheets) {
292
397
  }
293
398
  return { widthMap, optimalStyleName: `co${counter}` };
294
399
  }
295
- /**
296
- * Build a map from row height → style name, plus the shared optimal style name.
297
- * Rows without explicit heights share one "optimal" style.
298
- */
299
400
  function buildRowStyleMap(sheets) {
300
401
  const heightMap = new Map();
301
402
  let counter = 1;
@@ -310,12 +411,17 @@ function buildRowStyleMap(sheets) {
310
411
  return { heightMap, optimalStyleName: `ro${counter}` };
311
412
  }
312
413
  // ─── Style Element Builders ───────────────────────────────────────────
313
- function buildTableStyle(styleName) {
414
+ function buildTableStyle(styleName, tabColor) {
314
415
  const style = el("style:style")
315
416
  .attr("style:name", styleName)
316
417
  .attr("style:family", "table")
317
418
  .attr("style:master-page-name", "Default");
318
- style.appendChild(el("style:table-properties").attr("table:display", "true").attr("style:writing-mode", "lr-tb"));
419
+ const tableProps = el("style:table-properties")
420
+ .attr("table:display", "true")
421
+ .attr("style:writing-mode", "lr-tb");
422
+ if (tabColor)
423
+ tableProps.attr("table:tab-color", tabColor);
424
+ style.appendChild(tableProps);
319
425
  return style;
320
426
  }
321
427
  function buildColumnStyle(styleName, width) {
@@ -350,7 +456,6 @@ function buildCellStyle(styleName, cs) {
350
456
  if (cs.dataStyleName) {
351
457
  style.attr("style:data-style-name", cs.dataStyleName);
352
458
  }
353
- // Table cell properties
354
459
  const hasCellProps = cs.backgroundColor ||
355
460
  cs.borderTop ||
356
461
  cs.borderBottom ||
@@ -379,7 +484,6 @@ function buildCellStyle(styleName, cs) {
379
484
  cellProps.attr("fo:wrap-option", "wrap");
380
485
  style.appendChild(cellProps);
381
486
  }
382
- // Text properties — tripled for Western/Asian/Complex script consistency
383
487
  const hasTextProps = cs.fontWeight || cs.fontStyle || cs.fontSize || cs.fontFamily || cs.color || cs.underline;
384
488
  if (hasTextProps) {
385
489
  const textProps = el("style:text-properties");
@@ -413,21 +517,21 @@ function buildCellStyle(styleName, cs) {
413
517
  }
414
518
  style.appendChild(textProps);
415
519
  }
416
- // Paragraph properties for text alignment (fo:text-align lives here in ODS)
417
520
  if (cs.textAlign) {
418
521
  style.appendChild(el("style:paragraph-properties").attr("fo:text-align", cs.textAlign));
419
522
  }
420
523
  return style;
421
524
  }
422
525
  // ─── Column Count ─────────────────────────────────────────────────────
423
- /**
424
- * Determine the number of columns in a sheet.
425
- * Takes the maximum of: cells in any row, and the highest explicit column index + 1.
426
- */
427
526
  function getColumnCount(sheet) {
428
527
  let max = 0;
429
528
  for (const row of sheet.rows) {
430
- max = Math.max(max, row.cells.length);
529
+ // Account for colSpan when computing column count
530
+ let colCount = 0;
531
+ for (const cell of row.cells) {
532
+ colCount += cell.colSpan ?? 1;
533
+ }
534
+ max = Math.max(max, colCount);
431
535
  }
432
536
  for (const colIdx of sheet.columns.keys()) {
433
537
  max = Math.max(max, colIdx + 1);
@@ -441,15 +545,16 @@ function buildCellElement(cell, row, cellStyleMap, defaultDateFormat) {
441
545
  return el("table:table-cell");
442
546
  }
443
547
  const cellEl = el("table:table-cell");
444
- // Effective options: row defaults merged with cell overrides
548
+ // Column and row span
549
+ const colSpan = cell.colSpan ?? 1;
550
+ const rowSpan = cell.rowSpan ?? 1;
551
+ if (colSpan > 1)
552
+ cellEl.attr("table:number-columns-spanned", String(colSpan));
553
+ if (rowSpan > 1)
554
+ cellEl.attr("table:number-rows-spanned", String(rowSpan));
555
+ // Effective options and style lookup
445
556
  const effective = mergeOptions(row.options, cell.options);
446
- const cellDateFmt = effectiveDateFormat(cell, row, defaultDateFormat);
447
- const dataStyleName = cell.type === "date"
448
- ? cell.value instanceof Date && isDatetime(cell.value)
449
- ? DATETIME_STYLE_NAME
450
- : dateFormatToStyleName(cellDateFmt)
451
- : undefined;
452
- // Look up deduplicated cell style
557
+ const dataStyleName = resolveDataStyleName(cell, row, defaultDateFormat);
453
558
  const normalized = normalizeOdsCellStyle(effective, dataStyleName);
454
559
  const key = cellStyleKey(normalized);
455
560
  if (key !== "") {
@@ -457,41 +562,75 @@ function buildCellElement(cell, row, cellStyleMap, defaultDateFormat) {
457
562
  if (entry)
458
563
  cellEl.attr("table:style-name", entry[0]);
459
564
  }
565
+ // Cell content — build the text:p content, wrapping in text:a for hyperlinks
566
+ const buildTextP = (content) => {
567
+ const p = el("text:p");
568
+ if (cell.href) {
569
+ const a = el("text:a")
570
+ .attr("xlink:type", "simple")
571
+ .attr("xlink:href", cell.href)
572
+ .text(content);
573
+ p.appendChild(a);
574
+ }
575
+ else {
576
+ p.text(content);
577
+ }
578
+ return p;
579
+ };
460
580
  // Value type, value attributes, and display paragraph
461
581
  switch (cell.type) {
462
582
  case "string":
463
583
  cellEl.attr("office:value-type", "string");
464
- cellEl.appendChild(el("text:p").text(String(cell.value ?? "")));
584
+ cellEl.appendChild(buildTextP(String(cell.value ?? "")));
465
585
  break;
466
586
  case "float":
467
587
  cellEl.attr("office:value-type", "float");
468
588
  cellEl.attr("office:value", String(cell.value));
469
- cellEl.appendChild(el("text:p").text(String(cell.value)));
589
+ cellEl.appendChild(buildTextP(String(cell.value)));
590
+ break;
591
+ case "percentage": {
592
+ // ODS stores raw decimal, displays as percentage
593
+ const rawVal = Number(cell.value ?? 0);
594
+ cellEl.attr("office:value-type", "percentage");
595
+ cellEl.attr("office:value", String(rawVal));
596
+ // Display value: multiply by 100 for display (LibreOffice recalculates)
597
+ cellEl.appendChild(buildTextP(String(rawVal)));
470
598
  break;
599
+ }
600
+ case "currency": {
601
+ const fmt = cell.options?.numberFormat ?? row.options?.numberFormat ?? "";
602
+ const curMatch = fmt.match(/^currency:([A-Z]{3})/);
603
+ const currencyCode = curMatch ? curMatch[1] : "USD";
604
+ cellEl.attr("office:value-type", "currency");
605
+ cellEl.attr("office:currency", currencyCode);
606
+ cellEl.attr("office:value", String(cell.value));
607
+ cellEl.appendChild(buildTextP(String(cell.value)));
608
+ break;
609
+ }
471
610
  case "date": {
472
611
  const date = cell.value;
612
+ const cellDateFmt = effectiveDateFormat(cell, row, defaultDateFormat);
473
613
  cellEl.attr("office:value-type", "date");
474
614
  if (isDatetime(date)) {
475
615
  cellEl.attr("office:date-value", formatDatetimeISO(date));
476
- cellEl.appendChild(el("text:p").text(formatDatetimeDisplay(date)));
616
+ cellEl.appendChild(buildTextP(formatDatetimeDisplay(date)));
477
617
  }
478
618
  else {
479
619
  cellEl.attr("office:date-value", formatDateISO(date));
480
- cellEl.appendChild(el("text:p").text(formatDateDisplay(date, cellDateFmt)));
620
+ cellEl.appendChild(buildTextP(formatDateDisplay(date, cellDateFmt)));
481
621
  }
482
622
  break;
483
623
  }
484
624
  case "boolean":
485
625
  cellEl.attr("office:value-type", "boolean");
486
626
  cellEl.attr("office:boolean-value", cell.value ? "true" : "false");
487
- cellEl.appendChild(el("text:p").text(cell.value ? "TRUE" : "FALSE"));
627
+ cellEl.appendChild(buildTextP(cell.value ? "TRUE" : "FALSE"));
488
628
  break;
489
629
  case "formula": {
490
- // Prepend OpenFormula namespace prefix; LibreOffice recalculates on open
491
630
  cellEl.attr("table:formula", `of:${String(cell.value)}`);
492
631
  cellEl.attr("office:value-type", "float");
493
632
  cellEl.attr("office:value", "0");
494
- cellEl.appendChild(el("text:p").text("0"));
633
+ cellEl.appendChild(buildTextP("0"));
495
634
  break;
496
635
  }
497
636
  }
@@ -503,7 +642,7 @@ function buildSheetElement(sheet, tableStyleName, cellStyleMap, widthMap, optima
503
642
  const tableEl = el("table:table")
504
643
  .attr("table:name", sheet.name)
505
644
  .attr("table:style-name", tableStyleName);
506
- // Column definitions — one per column
645
+ // Column definitions
507
646
  for (let colIdx = 0; colIdx < numCols; colIdx++) {
508
647
  const colData = sheet.columns.get(colIdx);
509
648
  const colStyleName = colData?.width
@@ -513,14 +652,60 @@ function buildSheetElement(sheet, tableStyleName, cellStyleMap, widthMap, optima
513
652
  .attr("table:style-name", colStyleName)
514
653
  .attr("table:default-cell-style-name", "Default"));
515
654
  }
516
- // Row definitions
517
- for (const row of sheet.rows) {
655
+ // Two-pass approach for rowSpan:
656
+ // Pass 1: build a set of (rowIndex, colIndex) positions covered by rowSpan cells
657
+ const coveredCells = new Set();
658
+ // Track physical column positions accounting for colSpan
659
+ for (let rowIdx = 0; rowIdx < sheet.rows.length; rowIdx++) {
660
+ const row = sheet.rows[rowIdx];
661
+ let physicalCol = 0;
662
+ for (const cell of row.cells) {
663
+ // Skip positions already covered
664
+ while (coveredCells.has(`${rowIdx}:${physicalCol}`)) {
665
+ physicalCol++;
666
+ }
667
+ const colSpan = cell.colSpan ?? 1;
668
+ const rowSpan = cell.rowSpan ?? 1;
669
+ // Mark all covered cells for rowSpan
670
+ if (rowSpan > 1) {
671
+ for (let rs = 1; rs < rowSpan; rs++) {
672
+ for (let cs = 0; cs < colSpan; cs++) {
673
+ coveredCells.add(`${rowIdx + rs}:${physicalCol + cs}`);
674
+ }
675
+ }
676
+ }
677
+ // Mark covered cells for colSpan within the same row
678
+ // (these are emitted as covered cells inline — no need to track separately)
679
+ physicalCol += colSpan;
680
+ }
681
+ }
682
+ // Pass 2: build row elements
683
+ for (let rowIdx = 0; rowIdx < sheet.rows.length; rowIdx++) {
684
+ const row = sheet.rows[rowIdx];
518
685
  const rowStyleName = row.height
519
686
  ? (heightMap.get(row.height) ?? optimalRowStyle)
520
687
  : optimalRowStyle;
521
688
  const rowEl = el("table:table-row").attr("table:style-name", rowStyleName);
689
+ let physicalCol = 0;
522
690
  for (const cell of row.cells) {
691
+ // Emit covered cells for any rowSpan from previous rows
692
+ while (coveredCells.has(`${rowIdx}:${physicalCol}`)) {
693
+ rowEl.appendChild(el("table:covered-table-cell"));
694
+ physicalCol++;
695
+ }
696
+ // Emit the actual cell
523
697
  rowEl.appendChild(buildCellElement(cell, row, cellStyleMap, defaultDateFormat));
698
+ const colSpan = cell.colSpan ?? 1;
699
+ // Emit inline covered cells for colSpan
700
+ for (let cs = 1; cs < colSpan; cs++) {
701
+ rowEl.appendChild(el("table:covered-table-cell"));
702
+ }
703
+ physicalCol += colSpan;
704
+ }
705
+ // Fill any remaining covered cells at end of row
706
+ while (coveredCells.has(`${rowIdx}:${physicalCol}`)) {
707
+ rowEl.appendChild(el("table:covered-table-cell"));
708
+ physicalCol++;
524
709
  }
525
710
  tableEl.appendChild(rowEl);
526
711
  }
@@ -529,19 +714,15 @@ function buildSheetElement(sheet, tableStyleName, cellStyleMap, widthMap, optima
529
714
  // ─── Public API ───────────────────────────────────────────────────────
530
715
  /**
531
716
  * Generate the content.xml for an ODS document.
532
- *
533
- * @param sheets - Sheet data in tab order.
534
- * @param defaultDateFormat - Document-level default date display format.
535
- * @returns Serialized content.xml string.
536
717
  */
537
718
  export function generateOdsContent(sheets, defaultDateFormat) {
538
- // Collect all style information up front
539
719
  const usedDateFormats = collectUsedDateFormats(sheets, defaultDateFormat);
540
720
  const needsDatetime = hasDatetimeCells(sheets);
721
+ const usedNumberFormats = collectUsedNumberFormats(sheets);
541
722
  const cellStyleMap = buildCellStyleMap(sheets, defaultDateFormat);
542
723
  const { widthMap, optimalStyleName: optimalColStyle } = buildColumnStyleMap(sheets);
543
724
  const { heightMap, optimalStyleName: optimalRowStyle } = buildRowStyleMap(sheets);
544
- // Root element — ODS uses office, style, text, table, fo, number, of namespaces
725
+ // Root element — add xlink namespace for hyperlinks
545
726
  const root = el("office:document-content")
546
727
  .attr("xmlns:office", ODF_NS.office)
547
728
  .attr("xmlns:style", ODF_NS.style)
@@ -550,37 +731,41 @@ export function generateOdsContent(sheets, defaultDateFormat) {
550
731
  .attr("xmlns:fo", ODF_NS.fo)
551
732
  .attr("xmlns:number", ODF_NS.number)
552
733
  .attr("xmlns:of", "urn:oasis:names:tc:opendocument:xmlns:of:1.2")
734
+ .attr("xmlns:xlink", "http://www.w3.org/1999/xlink")
553
735
  .attr("office:version", ODF_VERSION);
554
- // Automatic styles
555
736
  const autoStyles = el("office:automatic-styles");
556
- // Date format styles — only those actually used in this document
737
+ // Date format styles
557
738
  for (const format of usedDateFormats) {
558
739
  autoStyles.appendChild(buildDateFormatStyle(format));
559
740
  }
560
- // Datetime style — emitted only if any date cell has a nonzero time component
561
741
  if (needsDatetime) {
562
742
  autoStyles.appendChild(buildDatetimeFormatStyle());
563
743
  }
564
- // Table styles — one per sheet
744
+ // Number format styles
745
+ for (const format of usedNumberFormats) {
746
+ const styleEl = buildNumberFormatStyle(format);
747
+ if (styleEl)
748
+ autoStyles.appendChild(styleEl);
749
+ }
750
+ // Table styles — pass tab color
565
751
  for (let i = 0; i < sheets.length; i++) {
566
- autoStyles.appendChild(buildTableStyle(`ta${i + 1}`));
752
+ autoStyles.appendChild(buildTableStyle(`ta${i + 1}`, sheets[i].tabColor));
567
753
  }
568
- // Column styles: shared optimal-width style, then width-specific styles
754
+ // Column styles
569
755
  autoStyles.appendChild(buildOptimalColumnStyle(optimalColStyle));
570
756
  for (const [width, styleName] of widthMap) {
571
757
  autoStyles.appendChild(buildColumnStyle(styleName, width));
572
758
  }
573
- // Row styles: shared optimal-height style, then height-specific styles
759
+ // Row styles
574
760
  autoStyles.appendChild(buildOptimalRowStyle(optimalRowStyle));
575
761
  for (const [height, styleName] of heightMap) {
576
762
  autoStyles.appendChild(buildRowStyle(styleName, height));
577
763
  }
578
- // Cell styles (deduplicated)
764
+ // Cell styles
579
765
  for (const [styleName, cs] of cellStyleMap.values()) {
580
766
  autoStyles.appendChild(buildCellStyle(styleName, cs));
581
767
  }
582
768
  root.appendChild(autoStyles);
583
- // Body → spreadsheet → sheets
584
769
  const body = el("office:body");
585
770
  const spreadsheet = el("office:spreadsheet");
586
771
  for (let i = 0; i < sheets.length; i++) {
@@ -592,11 +777,6 @@ export function generateOdsContent(sheets, defaultDateFormat) {
592
777
  }
593
778
  /**
594
779
  * Generate the styles.xml for an ODS document.
595
- *
596
- * ODS requires styles.xml for:
597
- * - The `Default` table-cell style (referenced via `style:parent-style-name` on
598
- * all automatic cell styles, and via `table:default-cell-style-name` on columns)
599
- * - The master page definition (referenced via `style:master-page-name` on table styles)
600
780
  */
601
781
  export function generateOdsStyles() {
602
782
  const root = el("office:document-styles")
@@ -604,17 +784,14 @@ export function generateOdsStyles() {
604
784
  .attr("xmlns:style", ODF_NS.style)
605
785
  .attr("xmlns:fo", ODF_NS.fo)
606
786
  .attr("office:version", ODF_VERSION);
607
- // Named styles — Default table-cell style
608
787
  const styles = el("office:styles");
609
788
  styles.appendChild(el("style:style").attr("style:name", "Default").attr("style:family", "table-cell"));
610
789
  root.appendChild(styles);
611
- // Automatic styles — page layout required for master page reference
612
790
  const autoStyles = el("office:automatic-styles");
613
791
  const pageLayout = el("style:page-layout").attr("style:name", "Mlayout");
614
792
  pageLayout.appendChild(el("style:page-layout-properties"));
615
793
  autoStyles.appendChild(pageLayout);
616
794
  root.appendChild(autoStyles);
617
- // Master styles — Default master page referenced by table styles
618
795
  const masterStyles = el("office:master-styles");
619
796
  masterStyles.appendChild(el("style:master-page").attr("style:name", "Default").attr("style:page-layout-name", "Mlayout"));
620
797
  root.appendChild(masterStyles);