@xlsxflow/core 1.1.1 → 1.1.3

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 CHANGED
@@ -1,94 +1,122 @@
1
- # Changelog
2
-
3
- ## 1.1.1
4
-
5
- - README: the XlsxFlow website address, and Pro at a flat $5.
6
- - More npm keywords: `xls` and `ods`.
7
-
8
- ## 1.1.0
9
-
10
- ### Added
11
-
12
- - `SheetWriter`: workbook properties (title, author, company and so on), defined names, hidden and very hidden sheets, and sheet views (zoom, gridlines, headings, right-to-left). `SheetReader.readWorkbook` reads them back.
13
- - `{ formatted: true }` reports each cell's text as Excel shows it, from its number format.
14
- - `SheetEditor.insertRows`, `deleteRows`, `insertColumns` and `deleteColumns`. Everything that points at the moved cells moves with them: formulas on all sheets, defined names, merges, conditional formats, validations, hyperlinks, filters, page breaks, column widths, tables, pictures, notes, sparklines, data tables, chart series and pivot sources.
15
- - `SheetEditor.setCells` edits and restyles cells of existing files; `addSheet` and `deleteSheet` add and remove sheets.
16
- - `parseCsv` streams CSV rows, which `SheetWriter.addSheet` turns into a sheet.
17
- - `SheetReader` reads Excel 97-2003 `.xls` files and OpenDocument `.ods` files: values, dates, formula results, merges, hidden rows, columns and sheets, frozen panes, defined names and document properties. `.ods` also gives formulas, hyperlinks and notes.
18
- - `OdsWriter` writes `.ods` files: values, dates, formulas, merges, column widths, frozen panes and hidden sheets.
19
- - Password-protected `.xlsx` files are rejected with an error that points to `decryptWorkbook` in `@xlsxflow/pro`, instead of a ZIP error.
20
- - Cell notes on write (plain or formatted text) and `getComments()` on read.
21
- - Conditional formats `cellIs`, `expression`, `top10`, `aboveAverage`, text rules, `duplicateValues`/`uniqueValues` and `iconSet`.
22
- - Excel tables, sheet protection, page setup (margins, header and footer, print area and titles), row heights, hidden rows and columns, outline grouping, tab colour, and validation operators and messages.
23
- - `Date` cell values, hyperlinks (URLs and locations in the workbook), `autoFilter`, and an optional shared string table on write.
24
- - Reading returns formulas (`formulas: true`, shared formulas expanded), styles (`styles: true`) and rich text (`richText: true`). Theme and indexed colours are resolved to ARGB.
25
- - PNG, JPEG and GIF images (`images` sheet option), anchored to a cell or stretched over a range. `getImages()` reads them back.
26
- - Low-level parts are exported for add-ons: the ZIP reader and writer, `resolveWorkbookParts`, `readSharedStrings`, and `mapFormulaRefs`/`shiftFormula`.
27
- - Tested: `.xlsm` files keep their macros through `SheetEditor`; the library runs in Cloudflare Workers without `nodejs_compat` (`scripts/workers` checks it).
28
-
29
- ### Security
30
-
31
- Fixed crashes, hangs and memory blowups caused by crafted files:
32
-
33
- - A ZIP directory pointing outside the file crashed Node.
34
- - Unclosed tags and elements made the parser quadratic. The XML tokenizer is now linear on giant tags, text nodes and CDATA.
35
- - Repeated hidden columns, far-right cells, fraction formats with long denominators and large style tables were slow or used unbounded memory.
36
- - One deflated entry could be read many times under different names.
37
-
38
- Also:
39
-
40
- - `SheetEditor.edit` takes `maxUncompressedBytes`.
41
- - Option values typed as enums are escaped, and appended rows encode control characters.
42
- - `sheetToJson` keeps a `__proto__` header as an ordinary key.
43
- - In-memory parts are capped at 1 GiB uncompressed by default.
44
-
45
- ### Fixed
46
-
47
- - Sheets with frozen panes were all marked as selected, so Excel opened them grouped.
48
- - The reader dropped or corrupted cells at stream chunk boundaries.
49
- - Styles pointed at the wrong font, fill or border, and styles used only by `AsyncIterable` rows were missing from `styles.xml`.
50
- - Number formats with quoted text, escapes or colours (`#,##0.00 "USD"`, `[Red]0.0`) were taken for date formats.
51
- - Reading a corrupt file without calling `getMetadata()` caused an unhandled promise rejection, which is fatal in Node.
52
- - `addSheet` accepted sheet names Excel refuses to open. It now throws.
53
- - Files Excel would repair:
54
- - gradient stops were written as `<gradientStop>`;
55
- - `vertical: 'middle'` was written as is instead of `center`;
56
- - conditional formats came after validations.
57
- - Data bar `minValue`/`maxValue` were ignored, every conditional format had priority 1, and colours and ranges were not XML-escaped.
58
- - Strings with `_xHHHH_`, control characters, CR, or leading and trailing spaces now survive a round trip. `NaN` and `Infinity` are written as `#NUM!`.
59
- - Reader:
60
- - Strict OOXML and non-standard part names work;
61
- - empty `<v/>` reads as empty and `-0` as `0`;
62
- - out-of-order cells land in the right column;
63
- - time-only values are no longer a day off, and datetimes keep milliseconds.
64
- - frozen panes saved by Excel as `frozenSplit` (frozen after a split) are reported.
65
- - `sheetToJson` ignored its `headerRowIndex` argument.
66
-
67
- ### Changed
68
-
69
- - The package ships compiled ESM and CommonJS builds with type declarations.
70
- - Browser bundles no longer try to resolve Node's `fs`.
71
- - `[Content_Types].xml` is written last in the ZIP.
72
- - Real backpressure in the ZIP writer and worksheet stream. Producer errors now error the output stream instead of hanging it.
73
- - About 2.5× faster reads, and a table-driven CRC-32 for faster writes.
74
- - Styles, formulas and conditional formats are part of the MIT core. Paid add-ons are in the separate `@xlsxflow/pro` package.
75
- - Benchmarks corrected: the earlier ExcelJS write time came from a cold first run, and write-excel-file was run with an old API.
76
-
77
- ## 1.0.0
78
-
79
- - `SheetWriter` and `SheetEditor` stream the ZIP with data descriptors instead of buffering the file in memory.
80
- - Dates are returned as ISO-8601 strings, detected from each cell's number format.
81
-
82
- ## 0.3.0-beta
83
-
84
- - Workbooks with several sheets.
85
- - Fixed a buffer bug that made parsing quadratic.
86
-
87
- ## 0.2.0-beta
88
-
89
- - Deflate compression with `CompressionStream`: a 1M-cell file went from 30 MB to 2.9 MB.
90
- - Inline and rich text strings are read.
91
-
92
- ## 0.1.0-beta
93
-
94
- - First release: streaming reader, writer, and shared strings.
1
+ # Changelog
2
+
3
+ ## 1.1.3
4
+
5
+ ### Fixed
6
+
7
+ - `SheetEditor.setCells` refuses addresses past column XFD or row 1,048,576, instead of writing a file Excel reports as damaged.
8
+ - `SheetWriter` and `SheetEditor` refuse text longer than Excel's 32,767 characters per cell.
9
+ - Colours are checked: ARGB (`FFFF0000`) and RGB (`FF0000`, `#FF0000`) hex are accepted, anything else (`'red'`) throws instead of making Excel repair the file.
10
+ - Picture anchors past the last column or row throw; a range given the wrong way round (`F20:A2`) is turned round.
11
+ - `SheetReader`: a shared string index past the end of the table reads as an empty cell, not as the index.
12
+
13
+ ### Added
14
+
15
+ - `checkCellText` is exported, for `@xlsxflow/pro`.
16
+
17
+ ## 1.1.2
18
+
19
+ ### Fixed
20
+
21
+ - `SheetWriter` throws an error for a row longer than 16,384 cells or past row 1,048,576, instead of writing a file Excel reports as damaged.
22
+ - `SheetEditor.setCells`: a date in a cell without a date format gets `yyyy-mm-dd` (or `yyyy-mm-dd hh:mm:ss` with a time), as in `SheetWriter`, instead of showing as a serial number.
23
+ - `OdsWriter` stores formula results, so readers that don't recalculate show them.
24
+ - `sheetToJson` keeps every column when headers repeat: the second `Name` becomes `Name_2`.
25
+ - `parseCsv` returns `null` for empty unquoted fields with `{ convert: false }` too.
26
+
27
+ ### Added
28
+
29
+ - `StylePatcher` is exported, for `@xlsxflow/pro`.
30
+
31
+ ## 1.1.1
32
+
33
+ - README: the XlsxFlow website address, and Pro at a flat $5.
34
+ - More npm keywords: `xls` and `ods`.
35
+
36
+ ## 1.1.0
37
+
38
+ ### Added
39
+
40
+ - `SheetWriter`: workbook properties (title, author, company and so on), defined names, hidden and very hidden sheets, and sheet views (zoom, gridlines, headings, right-to-left). `SheetReader.readWorkbook` reads them back.
41
+ - `{ formatted: true }` reports each cell's text as Excel shows it, from its number format.
42
+ - `SheetEditor.insertRows`, `deleteRows`, `insertColumns` and `deleteColumns`. Everything that points at the moved cells moves with them: formulas on all sheets, defined names, merges, conditional formats, validations, hyperlinks, filters, page breaks, column widths, tables, pictures, notes, sparklines, data tables, chart series and pivot sources.
43
+ - `SheetEditor.setCells` edits and restyles cells of existing files; `addSheet` and `deleteSheet` add and remove sheets.
44
+ - `parseCsv` streams CSV rows, which `SheetWriter.addSheet` turns into a sheet.
45
+ - `SheetReader` reads Excel 97-2003 `.xls` files and OpenDocument `.ods` files: values, dates, formula results, merges, hidden rows, columns and sheets, frozen panes, defined names and document properties. `.ods` also gives formulas, hyperlinks and notes.
46
+ - `OdsWriter` writes `.ods` files: values, dates, formulas, merges, column widths, frozen panes and hidden sheets.
47
+ - Password-protected `.xlsx` files are rejected with an error that points to `decryptWorkbook` in `@xlsxflow/pro`, instead of a ZIP error.
48
+ - Cell notes on write (plain or formatted text) and `getComments()` on read.
49
+ - Conditional formats `cellIs`, `expression`, `top10`, `aboveAverage`, text rules, `duplicateValues`/`uniqueValues` and `iconSet`.
50
+ - Excel tables, sheet protection, page setup (margins, header and footer, print area and titles), row heights, hidden rows and columns, outline grouping, tab colour, and validation operators and messages.
51
+ - `Date` cell values, hyperlinks (URLs and locations in the workbook), `autoFilter`, and an optional shared string table on write.
52
+ - Reading returns formulas (`formulas: true`, shared formulas expanded), styles (`styles: true`) and rich text (`richText: true`). Theme and indexed colours are resolved to ARGB.
53
+ - PNG, JPEG and GIF images (`images` sheet option), anchored to a cell or stretched over a range. `getImages()` reads them back.
54
+ - Low-level parts are exported for add-ons: the ZIP reader and writer, `resolveWorkbookParts`, `readSharedStrings`, and `mapFormulaRefs`/`shiftFormula`.
55
+ - Tested: `.xlsm` files keep their macros through `SheetEditor`; the library runs in Cloudflare Workers without `nodejs_compat` (`scripts/workers` checks it).
56
+
57
+ ### Security
58
+
59
+ Fixed crashes, hangs and memory blowups caused by crafted files:
60
+
61
+ - A ZIP directory pointing outside the file crashed Node.
62
+ - Unclosed tags and elements made the parser quadratic. The XML tokenizer is now linear on giant tags, text nodes and CDATA.
63
+ - Repeated hidden columns, far-right cells, fraction formats with long denominators and large style tables were slow or used unbounded memory.
64
+ - One deflated entry could be read many times under different names.
65
+
66
+ Also:
67
+
68
+ - `SheetEditor.edit` takes `maxUncompressedBytes`.
69
+ - Option values typed as enums are escaped, and appended rows encode control characters.
70
+ - `sheetToJson` keeps a `__proto__` header as an ordinary key.
71
+ - In-memory parts are capped at 1 GiB uncompressed by default.
72
+
73
+ ### Fixed
74
+
75
+ - Sheets with frozen panes were all marked as selected, so Excel opened them grouped.
76
+ - The reader dropped or corrupted cells at stream chunk boundaries.
77
+ - Styles pointed at the wrong font, fill or border, and styles used only by `AsyncIterable` rows were missing from `styles.xml`.
78
+ - Number formats with quoted text, escapes or colours (`#,##0.00 "USD"`, `[Red]0.0`) were taken for date formats.
79
+ - Reading a corrupt file without calling `getMetadata()` caused an unhandled promise rejection, which is fatal in Node.
80
+ - `addSheet` accepted sheet names Excel refuses to open. It now throws.
81
+ - Files Excel would repair:
82
+ - gradient stops were written as `<gradientStop>`;
83
+ - `vertical: 'middle'` was written as is instead of `center`;
84
+ - conditional formats came after validations.
85
+ - Data bar `minValue`/`maxValue` were ignored, every conditional format had priority 1, and colours and ranges were not XML-escaped.
86
+ - Strings with `_xHHHH_`, control characters, CR, or leading and trailing spaces now survive a round trip. `NaN` and `Infinity` are written as `#NUM!`.
87
+ - Reader:
88
+ - Strict OOXML and non-standard part names work;
89
+ - empty `<v/>` reads as empty and `-0` as `0`;
90
+ - out-of-order cells land in the right column;
91
+ - time-only values are no longer a day off, and datetimes keep milliseconds.
92
+ - frozen panes saved by Excel as `frozenSplit` (frozen after a split) are reported.
93
+ - `sheetToJson` ignored its `headerRowIndex` argument.
94
+
95
+ ### Changed
96
+
97
+ - The package ships compiled ESM and CommonJS builds with type declarations.
98
+ - Browser bundles no longer try to resolve Node's `fs`.
99
+ - `[Content_Types].xml` is written last in the ZIP.
100
+ - Real backpressure in the ZIP writer and worksheet stream. Producer errors now error the output stream instead of hanging it.
101
+ - About 2.5× faster reads, and a table-driven CRC-32 for faster writes.
102
+ - Styles, formulas and conditional formats are part of the MIT core. Paid add-ons are in the separate `@xlsxflow/pro` package.
103
+ - Benchmarks corrected: the earlier ExcelJS write time came from a cold first run, and write-excel-file was run with an old API.
104
+
105
+ ## 1.0.0
106
+
107
+ - `SheetWriter` and `SheetEditor` stream the ZIP with data descriptors instead of buffering the file in memory.
108
+ - Dates are returned as ISO-8601 strings, detected from each cell's number format.
109
+
110
+ ## 0.3.0-beta
111
+
112
+ - Workbooks with several sheets.
113
+ - Fixed a buffer bug that made parsing quadratic.
114
+
115
+ ## 0.2.0-beta
116
+
117
+ - Deflate compression with `CompressionStream`: a 1M-cell file went from 30 MB to 2.9 MB.
118
+ - Inline and rich text strings are read.
119
+
120
+ ## 0.1.0-beta
121
+
122
+ - First release: streaming reader, writer, and shared strings.
package/README.md CHANGED
@@ -83,7 +83,7 @@ Parts held in memory (workbook, shared strings, styles) are capped at 1 GiB unco
83
83
 
84
84
  **Reading files from untrusted users** (uploads on a server): set `maxUncompressedBytes` to what you expect, such as `50_000_000`. Corrupt or crafted ZIPs (overlapping entries, entries that inflate past their stated size, directories pointing outside the file) are rejected either way. The limit also bounds the empty cells added before far-right cells, since one tiny cell in column XFD pads its row to 16,384 values. Values are returned as written: hyperlinks may be `javascript:` URLs and text may start with `=`, so check them before putting them in a web page or a CSV that a spreadsheet will open.
85
85
 
86
- `sheetToJson(rows, headerRowIndex = 0)` turns the rows into objects keyed by the header row, and `streamToCsv(rows)` returns the sheet as CSV text. Both hold the whole result in memory.
86
+ `sheetToJson(rows, headerRowIndex = 0)` turns the rows into objects keyed by the header row (a repeated header gets a suffix: `Name`, `Name_2`), and `streamToCsv(rows)` returns the sheet as CSV text. Both hold the whole result in memory.
87
87
 
88
88
  In Node.js, read straight from disk with `XlsxFlow.readFile`, or pass `await createFileReader(path)` to any function that takes a reader:
89
89
 
@@ -140,7 +140,7 @@ import { SheetWriter, parseCsv } from '@xlsxflow/core';
140
140
  const xlsx = new SheetWriter().addSheet('Data', parseCsv(csvStream)).write();
141
141
  ```
142
142
 
143
- Quoted fields can hold delimiters, line breaks and `""`. Unquoted numbers and `TRUE`/`FALSE` become numbers and booleans, and empty fields become empty cells; `{ convert: false }` keeps everything as text, and `{ delimiter: ';' }` sets the delimiter. Dates stay text, since CSV files do not say which date order they use.
143
+ Quoted fields can hold delimiters, line breaks and `""`. Unquoted numbers and `TRUE`/`FALSE` become numbers and booleans, and empty fields become empty cells (`null`; a quoted `""` stays an empty string); `{ convert: false }` keeps everything else as text, and `{ delimiter: ';' }` sets the delimiter. Dates stay text, since CSV files do not say which date order they use.
144
144
 
145
145
  ### .xls and .ods Files
146
146
 
@@ -158,7 +158,7 @@ and document properties. `formatted: true` works on both; `formulas: true` works
158
158
  [`@xlsxflow/pro`](#free-and-pro).
159
159
 
160
160
  `OdsWriter` writes `.ods` with the same rows as `SheetWriter`: values, dates, formulas (converted to
161
- OpenFormula), merged cells, column widths, frozen panes, hidden sheets and document properties. Cell styles,
161
+ OpenFormula, with their results), merged cells, column widths, frozen panes, hidden sheets and document properties. Cell styles,
162
162
  hyperlinks, notes and images are not written to `.ods`.
163
163
 
164
164
  ```typescript
@@ -224,6 +224,8 @@ writer.addSheet('Sales', [
224
224
  });
225
225
  ```
226
226
 
227
+ Colours are ARGB hex (`FFC00000`); RGB hex (`C00000` or `#C00000`) is opaque. A cell holds at most 32,767 characters, as in Excel.
228
+
227
229
  Other sheet options:
228
230
 
229
231
  ```typescript
package/dist/index.cjs CHANGED
@@ -350,7 +350,16 @@ async function sheetToJson(parseResult, headerRowIndex = 0) {
350
350
  if (skip-- > 0)
351
351
  continue;
352
352
  if (headers.length === 0) {
353
- headers = row.cells.map((c, i) => (c !== null && c !== undefined ? String(c) : `Column${i + 1}`));
353
+ const seen = new Set();
354
+ headers = row.cells.map((c, i) => {
355
+ const base = c !== null && c !== undefined ? String(c) : `Column${i + 1}`;
356
+ // A repeated header gets a suffix ("Name_2") so its column isn't lost
357
+ let name = base;
358
+ for (let n = 2; seen.has(name); n++)
359
+ name = `${base}_${n}`;
360
+ seen.add(name);
361
+ return name;
362
+ });
354
363
  continue;
355
364
  }
356
365
  const obj = {};
@@ -455,6 +464,21 @@ function decodeXString(s) {
455
464
  // Excel's sheet size (A..XFD, 1..1048576)
456
465
  const MAX_COLUMNS$1 = 16384;
457
466
  const MAX_ROWS$1 = 1048576;
467
+ // Excel's limit on the characters in one cell
468
+ const MAX_CELL_TEXT = 32767;
469
+ function checkCellText(s, ref) {
470
+ if (s.length > MAX_CELL_TEXT)
471
+ throw new Error(`Cell ${ref} has ${s.length} characters, more than Excel's ${MAX_CELL_TEXT}.`);
472
+ return s;
473
+ }
474
+ // Colours are ARGB hex ("FFFF0000"); "FF0000" and "#FF0000" get an opaque alpha. Anything else
475
+ // makes Excel repair the file.
476
+ function argb(c) {
477
+ const m = /^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.exec(String(c));
478
+ if (!m)
479
+ throw new Error(`Invalid colour "${c}": use ARGB hex such as "FFFF0000".`);
480
+ return (m[1].length === 6 ? 'FF' + m[1] : m[1]).toUpperCase();
481
+ }
458
482
  // 0-based column of letters like "BC" (or a reference like "BC12"); -1 when there are none
459
483
  function colIndex(ref) {
460
484
  const match = ref.match(/[A-Za-z]+/);
@@ -554,6 +578,8 @@ function encodeXString(s) {
554
578
  .replace(/_(?=x[0-9A-Fa-f]{4}_)/g, '_x005F_')
555
579
  .replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
556
580
  }
581
+ // The format SheetWriter gives a date without one: the time only when there is one
582
+ const defaultDateFormat = (d) => d.getTime() % 86400000 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm:ss';
557
583
  // True when a number format renders dates/times. Ignores quoted text, escapes, colours and
558
584
  // locale tags, so "0 \"days\"", "[Red]0.0" and "#,##0 \"USD\"" are not mistaken for dates.
559
585
  function isDateFormatCode(code) {
@@ -1450,7 +1476,8 @@ function parseWorksheet(xmlTokenStream, sharedStrings, styles, is1904 = false, o
1450
1476
  }
1451
1477
  else if (currentCellType === 's') {
1452
1478
  const index = parseInt(currentCellValue, 10);
1453
- resolvedValue = sharedStrings.get(index) ?? currentCellValue;
1479
+ // An index past the table points nowhere: the cell is empty
1480
+ resolvedValue = sharedStrings.get(index) ?? null;
1454
1481
  }
1455
1482
  else if (currentCellType === 'b') {
1456
1483
  resolvedValue = currentCellValue === '1' || currentCellValue === 'true';
@@ -1624,9 +1651,10 @@ async function readSheetImages(readText, readBytes, sheetPath) {
1624
1651
  const escapeXml$4 = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1625
1652
  const cellPos = (ref) => {
1626
1653
  const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(ref.trim());
1627
- if (!m)
1654
+ const col = m ? colIndex(m[1]) : -1, row = m ? parseInt(m[2], 10) - 1 : -1;
1655
+ if (col < 0 || col >= MAX_COLUMNS$1 || row < 0 || row >= MAX_ROWS$1)
1628
1656
  throw new Error(`Invalid anchor cell "${ref}".`);
1629
- return { col: colIndex(m[1]), row: parseInt(m[2], 10) - 1 };
1657
+ return { col, row };
1630
1658
  };
1631
1659
  const marker = (tag, { col, row }) => `<xdr:${tag}><xdr:col>${col}</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>${row}</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:${tag}>`;
1632
1660
  const DRAWING_NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" ' +
@@ -1638,9 +1666,11 @@ const DRAWING_NS = 'xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/
1638
1666
  function anchorXml(place, natural, body, attrs = '') {
1639
1667
  if ('range' in place) {
1640
1668
  const [from, to = from] = place.range.split(':');
1641
- const end = cellPos(to);
1642
- // The object fills the range: it ends at the far edge of the last cell
1643
- return `<xdr:twoCellAnchor editAs="oneCell"${attrs}>${marker('from', cellPos(from))}${marker('to', { col: end.col + 1, row: end.row + 1 })}${body}<xdr:clientData/></xdr:twoCellAnchor>`;
1669
+ const a = cellPos(from), b = cellPos(to);
1670
+ // The object fills the range, given either way round: it ends at the far edge of the last cell
1671
+ const start = { col: Math.min(a.col, b.col), row: Math.min(a.row, b.row) };
1672
+ const end = { col: Math.max(a.col, b.col) + 1, row: Math.max(a.row, b.row) + 1 };
1673
+ return `<xdr:twoCellAnchor editAs="oneCell"${attrs}>${marker('from', start)}${marker('to', end)}${body}<xdr:clientData/></xdr:twoCellAnchor>`;
1644
1674
  }
1645
1675
  const { width: w, height: h } = natural;
1646
1676
  const width = place.width ?? (place.height ? w * place.height / h : w);
@@ -2625,22 +2655,22 @@ function fontXml(font, nameTag = 'name') {
2625
2655
  if (font.size)
2626
2656
  xml += `<sz val="${escapeXml$3(font.size)}"/>`;
2627
2657
  if (font.color)
2628
- xml += `<color rgb="${escapeXml$3(font.color)}"/>`;
2658
+ xml += `<color rgb="${argb(font.color)}"/>`;
2629
2659
  if (font.name)
2630
2660
  xml += `<${nameTag} val="${escapeXml$3(font.name)}"/>`;
2631
2661
  return xml;
2632
2662
  }
2633
- const borderSideXml = (tag, s) => s ? `<${tag} style="${escapeXml$3(s.style)}">${s.color ? `<color rgb="${escapeXml$3(s.color)}"/>` : ''}</${tag}>` : `<${tag}/>`;
2663
+ const borderSideXml = (tag, s) => s ? `<${tag} style="${escapeXml$3(s.style)}">${s.color ? `<color rgb="${argb(s.color)}"/>` : ''}</${tag}>` : `<${tag}/>`;
2634
2664
  function borderXml(b) {
2635
2665
  const side = borderSideXml;
2636
2666
  return `<border>${side('left', b.left)}${side('right', b.right)}${side('top', b.top)}${side('bottom', b.bottom)}<diagonal/></border>`;
2637
2667
  }
2638
2668
  function fillXml(fill) {
2639
2669
  if (fill.type === 'gradient') {
2640
- const stops = fill.stops.map(s => `<stop position="${s.position}"><color rgb="${escapeXml$3(s.color)}"/></stop>`).join('');
2670
+ const stops = fill.stops.map(s => `<stop position="${s.position}"><color rgb="${argb(s.color)}"/></stop>`).join('');
2641
2671
  return `<fill><gradientFill degree="${escapeXml$3(fill.degree ?? 0)}">${stops}</gradientFill></fill>`;
2642
2672
  }
2643
- return `<fill><patternFill patternType="solid"><fgColor rgb="${escapeXml$3(fill.fgColor)}"/></patternFill></fill>`;
2673
+ return `<fill><patternFill patternType="solid"><fgColor rgb="${argb(fill.fgColor)}"/></patternFill></fill>`;
2644
2674
  }
2645
2675
  // Attributes of an <alignment> element
2646
2676
  function alignmentAttrs(alignment) {
@@ -2682,7 +2712,7 @@ class StyleEngine {
2682
2712
  xml += `<numFmt numFmtId="${this.registerNumFmt(style.numFmt)}" formatCode="${escapeXml$3(style.numFmt)}"/>`;
2683
2713
  if (style.fill?.type === 'solid') {
2684
2714
  // A differential solid fill takes its colour from bgColor
2685
- const c = escapeXml$3(style.fill.fgColor);
2715
+ const c = argb(style.fill.fgColor);
2686
2716
  xml += `<fill><patternFill patternType="solid"><fgColor rgb="${c}"/><bgColor rgb="${c}"/></patternFill></fill>`;
2687
2717
  }
2688
2718
  if (style.border)
@@ -2810,7 +2840,7 @@ ${this.dxfs.size ? `<dxfs count="${this.dxfs.size}">${[...this.dxfs.keys()].map(
2810
2840
  function escapeXml$2(val) {
2811
2841
  return String(val).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2812
2842
  }
2813
- const color = (rgb) => `<color rgb="${escapeXml$2(rgb)}"/>`;
2843
+ const color = (rgb) => `<color rgb="${argb(rgb)}"/>`;
2814
2844
  const bound = (type, value) => value === undefined ? `<cfvo type="${type}"/>` : `<cfvo type="num" val="${escapeXml$2(value)}"/>`;
2815
2845
  const formula = (f) => `<formula>${escapeXml$2(String(f).replace(/^=/, ''))}</formula>`;
2816
2846
  // Excel string literal inside a formula
@@ -3362,7 +3392,6 @@ function escapeXml$1(val) {
3362
3392
  function isStyledCell$1(v) {
3363
3393
  return typeof v === 'object' && v !== null && 'value' in v;
3364
3394
  }
3365
- const DAY_MS = 86400000;
3366
3395
  // "A1:C10" -> "$A$1:$C$10"
3367
3396
  const absoluteRef = (ref) => ref.replace(/([A-Za-z]+)(\d+)/g, '$$$1$$$2');
3368
3397
  function stringXml(s) {
@@ -3640,7 +3669,7 @@ class SheetWriter {
3640
3669
  const page = options.pageSetup;
3641
3670
  const fitToPage = page && (page.fitToWidth !== undefined || page.fitToHeight !== undefined);
3642
3671
  const sheetPr = options.tabColor || fitToPage
3643
- ? `<sheetPr>${options.tabColor ? `<tabColor rgb="${escapeXml$1(options.tabColor)}"/>` : ''}${fitToPage ? '<pageSetUpPr fitToPage="1"/>' : ''}</sheetPr>`
3672
+ ? `<sheetPr>${options.tabColor ? `<tabColor rgb="${argb(options.tabColor)}"/>` : ''}${fitToPage ? '<pageSetUpPr fitToPage="1"/>' : ''}</sheetPr>`
3644
3673
  : '';
3645
3674
  const sheetFormatPr = outlineRow || outlineCol
3646
3675
  ? `<sheetFormatPr defaultRowHeight="15"${outlineRow ? ` outlineLevelRow="${outlineRow}"` : ''}${outlineCol ? ` outlineLevelCol="${outlineCol}"` : ''}/>`
@@ -3678,6 +3707,11 @@ class SheetWriter {
3678
3707
  if (done)
3679
3708
  break;
3680
3709
  const rowNum = ri + 1;
3710
+ // Past Excel's sheet size the file is damaged, so refuse it instead
3711
+ if (rowNum > MAX_ROWS$1)
3712
+ throw new Error(`Row ${rowNum} is past Excel's last row (${MAX_ROWS$1}).`);
3713
+ if (row.length > MAX_COLUMNS$1)
3714
+ throw new Error(`Row ${rowNum} has ${row.length} cells, more than Excel's ${MAX_COLUMNS$1} columns.`);
3681
3715
  chunkStr += optionRowsBefore(rowNum);
3682
3716
  if (rowOptions[nextRowOption]?.[0] === rowNum)
3683
3717
  nextRowOption++;
@@ -3688,7 +3722,7 @@ class SheetWriter {
3688
3722
  let cellStyle = styledCell.style;
3689
3723
  if (val instanceof Date && !cellStyle?.numFmt) {
3690
3724
  // A date needs a date format, or Excel shows the bare serial number
3691
- cellStyle = { ...cellStyle, numFmt: val.getTime() % DAY_MS === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm:ss' };
3725
+ cellStyle = { ...cellStyle, numFmt: defaultDateFormat(val) };
3692
3726
  }
3693
3727
  const style = cellStyle ? this.styleEngine.registerStyle(cellStyle) : 0;
3694
3728
  const sAttr = style > 0 ? ` s="${style}"` : '';
@@ -3739,6 +3773,7 @@ class SheetWriter {
3739
3773
  return `<c r="${colRef}"${sAttr}><v>${dateToSerial(val)}</v></c>`;
3740
3774
  }
3741
3775
  if (typeof val === 'string') {
3776
+ checkCellText(val, colRef);
3742
3777
  if (this.writerOptions.sharedStrings) {
3743
3778
  let index = this.sharedStrings.get(val);
3744
3779
  if (index === undefined)
@@ -4144,6 +4179,16 @@ class StylePatcher {
4144
4179
  this.memo.set(key, id);
4145
4180
  return id;
4146
4181
  }
4182
+ // The format for date `d` in a cell of format `base`: `base` when it already shows dates, otherwise
4183
+ // `base` with a date format, since a date in a General cell shows as its serial number
4184
+ dateFormat(base, d) {
4185
+ const xf = this.xfs[base] ?? this.added.cellXfs[base - this.xfs.length] ?? '';
4186
+ const id = parseInt(attr(/^<[^>]*>/.exec(xf)?.[0] ?? '', 'numFmtId') ?? '0', 10);
4187
+ const code = [...this.numFmts].find(([, v]) => v === id)?.[0];
4188
+ if (isBuiltinDateFormat(id) || (code !== undefined && isDateFormatCode(code)))
4189
+ return base;
4190
+ return this.patch(base, { numFmt: defaultDateFormat(d) });
4191
+ }
4147
4192
  toXml() {
4148
4193
  let xml = this.xml;
4149
4194
  for (const section of ['numFmts', 'fonts', 'fills', 'borders', 'cellXfs']) {
@@ -4699,7 +4744,7 @@ function editedCell(p, ref, attrs, edit) {
4699
4744
  }
4700
4745
  if (typeof edit === 'object')
4701
4746
  return `<${p}c r="${ref}"${attrs}><${p}f>${escapeXml$5(edit.formula.replace(/^=/, ''))}</${p}f></${p}c>`;
4702
- const text = escapeXml$5(encodeXString(edit));
4747
+ const text = escapeXml$5(encodeXString(checkCellText(edit, ref)));
4703
4748
  const t = /^\s|\s$/.test(edit) ? `<${p}t xml:space="preserve">${text}</${p}t>` : `<${p}t>${text}</${p}t>`;
4704
4749
  return `<${p}c r="${ref}"${attrs} t="inlineStr"><${p}is>${t}</${p}is></${p}c>`;
4705
4750
  }
@@ -4750,9 +4795,9 @@ class SheetEditor {
4750
4795
  this.cellEdits.set(sheetName, rows = new Map());
4751
4796
  for (const [ref, edit] of Object.entries(cells)) {
4752
4797
  const m = /^\$?([A-Za-z]{1,3})\$?(\d+)$/.exec(ref);
4753
- if (!m || parseInt(m[2], 10) < 1)
4798
+ const r = m ? parseInt(m[2], 10) : 0;
4799
+ if (!m || r < 1 || r > MAX_ROWS$1 || colIndex(m[1]) >= MAX_COLUMNS$1)
4754
4800
  throw new Error(`Invalid cell reference "${ref}".`);
4755
- const r = parseInt(m[2], 10);
4756
4801
  if (!rows.has(r))
4757
4802
  rows.set(r, new Map());
4758
4803
  rows.get(r).set(colIndex(m[1]), edit);
@@ -4938,7 +4983,10 @@ class SheetEditor {
4938
4983
  let patcher;
4939
4984
  const styled = [...editsByPath.values(), ...added.values()]
4940
4985
  .some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => styleOf(e))));
4941
- if (styled) {
4986
+ // Dates may need a date format added to their cells
4987
+ const dated = [...editsByPath.values(), ...added.values()]
4988
+ .some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => contentOf(e) instanceof Date)));
4989
+ if (styled || (dated && parts.styles)) {
4942
4990
  if (!parts.styles)
4943
4991
  throw new Error('Workbook has no styles part.');
4944
4992
  patcher = new StylePatcher(await read(parts.styles));
@@ -5015,6 +5063,10 @@ class SheetEditor {
5015
5063
  attrs = `${attrs.replace(/\ss="[^"]*"/, '')} s="${patcher.patch(base, style)}"`;
5016
5064
  }
5017
5065
  const content = contentOf(edit);
5066
+ if (content instanceof Date && patcher) {
5067
+ const base = parseInt(/\ss="(\d+)"/.exec(attrs)?.[1] ?? '0', 10);
5068
+ attrs = `${attrs.replace(/\ss="[^"]*"/, '')} s="${patcher.dateFormat(base, content)}"`;
5069
+ }
5018
5070
  if (content === KEEP) {
5019
5071
  return existing?.[2] !== undefined ? `<${p}c r="${ref}"${attrs}>${existing[2]}</${p}c>` : `<${p}c r="${ref}"${attrs}/>`;
5020
5072
  }
@@ -5164,7 +5216,7 @@ class SheetEditor {
5164
5216
  if (typeof val === 'number')
5165
5217
  return `<c r="${colRef}"><v>${val}</v></c>`;
5166
5218
  if (typeof val === 'string') {
5167
- return `<c r="${colRef}" t="inlineStr"><is><t xml:space="preserve">${escapeXml$5(encodeXString(val))}</t></is></c>`;
5219
+ return `<c r="${colRef}" t="inlineStr"><is><t xml:space="preserve">${escapeXml$5(encodeXString(checkCellText(val, colRef)))}</t></is></c>`;
5168
5220
  }
5169
5221
  return `<c r="${colRef}"/>`;
5170
5222
  }).join('');
@@ -5776,7 +5828,8 @@ async function* parseCsv(input, options = {}) {
5776
5828
  let first = true;
5777
5829
  const rows = [];
5778
5830
  const endField = () => {
5779
- row.push(quoted || !convert ? field : convertField(field));
5831
+ // An empty unquoted field is null in both modes; a quoted "" stays an empty string
5832
+ row.push(quoted ? field : !convert ? (field === '' ? null : field) : convertField(field));
5780
5833
  field = '';
5781
5834
  quoted = false;
5782
5835
  };
@@ -5908,11 +5961,14 @@ function textXml(s) {
5908
5961
  }
5909
5962
  const dateValue = (d) => d.toISOString().slice(0, 19); // UTC, like SheetWriter
5910
5963
  const hasTime = (d) => d.getTime() % 86400000 !== 0;
5911
- function cellXml(cell, span, covered) {
5964
+ function cellXml(cell, span, covered, evaluate) {
5912
5965
  if (covered)
5913
5966
  return '<table:covered-table-cell/>';
5914
- const value = isStyledCell(cell) ? cell.value : cell;
5967
+ let value = isStyledCell(cell) ? cell.value : cell;
5915
5968
  const formula = isStyledCell(cell) && cell.formula ? ` table:formula="${escapeXml(toOpenFormula(cell.formula))}"` : '';
5969
+ // A formula without a value gets its result stored, as SheetWriter does, for readers that don't recalculate
5970
+ if (formula && value == null)
5971
+ value = evaluate(cell.formula);
5916
5972
  if (value === null || value === undefined || (typeof value === 'number' && !isFinite(value))) {
5917
5973
  return formula || span ? `<table:table-cell${formula}${span}/>` : '<table:table-cell/>';
5918
5974
  }
@@ -6005,6 +6061,15 @@ class OdsWriter {
6005
6061
  const merges = (sheet.options.mergeCells ?? []).map(parseRange);
6006
6062
  const lastMergeRow = Math.max(-1, ...merges.map(m => m.r2));
6007
6063
  const mergeAt = (r, c) => merges.find(m => m.r1 === r && m.c1 === c);
6064
+ // Formulas resolve against the sheet they live in; streamed rows can't be looked back at
6065
+ const engine = new FormulaEngine();
6066
+ if (Array.isArray(sheet.rows)) {
6067
+ engine.loadData(sheet.rows.map(row => row.map(cell => {
6068
+ const v = isStyledCell(cell) ? cell.value : cell;
6069
+ return v instanceof Date ? dateToSerial(v) : v ?? null;
6070
+ })));
6071
+ }
6072
+ const evaluate = (f) => engine.evaluate(f);
6008
6073
  const covered = (r, c) => merges.some(m => r >= m.r1 && r <= m.r2 && c >= m.c1 && c <= m.c2 && (r !== m.r1 || c !== m.c1));
6009
6074
  let r = 0, batch = '';
6010
6075
  const writeRow = (row) => {
@@ -6013,7 +6078,7 @@ class OdsWriter {
6013
6078
  for (let c = 0; c < width; c++) {
6014
6079
  const m = mergeAt(r, c);
6015
6080
  const span = m ? ` table:number-columns-spanned="${m.c2 - m.c1 + 1}" table:number-rows-spanned="${m.r2 - m.r1 + 1}"` : '';
6016
- xml += cellXml(row[c], span, covered(r, c));
6081
+ xml += cellXml(row[c], span, covered(r, c), evaluate);
6017
6082
  }
6018
6083
  r++;
6019
6084
  return xml + (width ? '' : '<table:table-cell/>') + '</table:table-row>';
@@ -6106,10 +6171,12 @@ exports.SheetEditor = SheetEditor;
6106
6171
  exports.SheetReader = SheetReader;
6107
6172
  exports.SheetWriter = SheetWriter;
6108
6173
  exports.StyleEngine = StyleEngine;
6174
+ exports.StylePatcher = StylePatcher;
6109
6175
  exports.XlsxFlow = XlsxFlow;
6110
6176
  exports.ZipRandomAccessParser = ZipRandomAccessParser;
6111
6177
  exports.ZipStreamWriter = ZipStreamWriter;
6112
6178
  exports.anchorXml = anchorXml;
6179
+ exports.checkCellText = checkCellText;
6113
6180
  exports.colIndex = colIndex;
6114
6181
  exports.colLetter = colLetter;
6115
6182
  exports.crc32 = crc32;