odf-kit 0.9.6 → 0.9.7
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/CHANGELOG.md +29 -0
- package/README.md +242 -279
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/ods/content.d.ts +0 -9
- package/dist/ods/content.d.ts.map +1 -1
- package/dist/ods/content.js +274 -97
- package/dist/ods/content.js.map +1 -1
- package/dist/ods/document.d.ts +2 -42
- package/dist/ods/document.d.ts.map +1 -1
- package/dist/ods/document.js +7 -42
- package/dist/ods/document.js.map +1 -1
- package/dist/ods/index.d.ts +1 -1
- package/dist/ods/index.d.ts.map +1 -1
- package/dist/ods/settings.d.ts +13 -0
- package/dist/ods/settings.d.ts.map +1 -0
- package/dist/ods/settings.js +67 -0
- package/dist/ods/settings.js.map +1 -0
- package/dist/ods/sheet-builder.d.ts +42 -28
- package/dist/ods/sheet-builder.d.ts.map +1 -1
- package/dist/ods/sheet-builder.js +57 -42
- package/dist/ods/sheet-builder.js.map +1 -1
- package/dist/ods/types.d.ts +68 -30
- package/dist/ods/types.d.ts.map +1 -1
- package/package.json +14 -5
package/dist/ods/content.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
616
|
+
cellEl.appendChild(buildTextP(formatDatetimeDisplay(date)));
|
|
477
617
|
}
|
|
478
618
|
else {
|
|
479
619
|
cellEl.attr("office:date-value", formatDateISO(date));
|
|
480
|
-
cellEl.appendChild(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
517
|
-
|
|
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 —
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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);
|