@xlsxflow/core 1.1.1 → 1.1.2

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,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.2
4
+
5
+ ### Fixed
6
+
7
+ - `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.
8
+ - `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.
9
+ - `OdsWriter` stores formula results, so readers that don't recalculate show them.
10
+ - `sheetToJson` keeps every column when headers repeat: the second `Name` becomes `Name_2`.
11
+ - `parseCsv` returns `null` for empty unquoted fields with `{ convert: false }` too.
12
+
13
+ ### Added
14
+
15
+ - `StylePatcher` is exported, for `@xlsxflow/pro`.
16
+
3
17
  ## 1.1.1
4
18
 
5
19
  - README: the XlsxFlow website address, and Pro at a flat $5.
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
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 = {};
@@ -554,6 +563,8 @@ function encodeXString(s) {
554
563
  .replace(/_(?=x[0-9A-Fa-f]{4}_)/g, '_x005F_')
555
564
  .replace(/[\x00-\x08\x0B\x0C\r\x0E-\x1F]/g, c => `_x${c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`);
556
565
  }
566
+ // The format SheetWriter gives a date without one: the time only when there is one
567
+ const defaultDateFormat = (d) => d.getTime() % 86400000 === 0 ? 'yyyy-mm-dd' : 'yyyy-mm-dd hh:mm:ss';
557
568
  // True when a number format renders dates/times. Ignores quoted text, escapes, colours and
558
569
  // locale tags, so "0 \"days\"", "[Red]0.0" and "#,##0 \"USD\"" are not mistaken for dates.
559
570
  function isDateFormatCode(code) {
@@ -3362,7 +3373,6 @@ function escapeXml$1(val) {
3362
3373
  function isStyledCell$1(v) {
3363
3374
  return typeof v === 'object' && v !== null && 'value' in v;
3364
3375
  }
3365
- const DAY_MS = 86400000;
3366
3376
  // "A1:C10" -> "$A$1:$C$10"
3367
3377
  const absoluteRef = (ref) => ref.replace(/([A-Za-z]+)(\d+)/g, '$$$1$$$2');
3368
3378
  function stringXml(s) {
@@ -3389,13 +3399,13 @@ const W3CDTF = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
3389
3399
  const propXml = (tag, v) => v === undefined ? '' : `<${tag}>${escapeXml$1(v)}</${tag}>`;
3390
3400
  function corePropsXml(p) {
3391
3401
  const created = p.created ? `<dcterms:created xsi:type="dcterms:W3CDTF">${W3CDTF(p.created)}</dcterms:created>` : '';
3392
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3402
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3393
3403
  <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">` +
3394
3404
  propXml('dc:title', p.title) + propXml('dc:subject', p.subject) + propXml('dc:creator', p.creator) + propXml('cp:keywords', p.keywords) +
3395
3405
  propXml('dc:description', p.description) + propXml('cp:category', p.category) + created + '</cp:coreProperties>';
3396
3406
  }
3397
3407
  function appPropsXml(p) {
3398
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3408
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3399
3409
  <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>XlsxFlow</Application>${propXml('Manager', p.manager)}${propXml('Company', p.company)}</Properties>`;
3400
3410
  }
3401
3411
  // A name Excel accepts: letters, digits, _ . and \, not a cell reference (A1, R1C1) and not reserved
@@ -3563,7 +3573,7 @@ class SheetWriter {
3563
3573
  });
3564
3574
  await this.streamString(zip, `xl/drawings/drawing${n}.xml`, drawingXml(images, images.map(img => this.media.get(img.data).info), rIds));
3565
3575
  const rels = targets.map((t, k) => `<Relationship Id="rId${k + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="${t}"/>`).join('');
3566
- await this.streamString(zip, `xl/drawings/_rels/drawing${n}.xml.rels`, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3576
+ await this.streamString(zip, `xl/drawings/_rels/drawing${n}.xml.rels`, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3567
3577
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rels}</Relationships>`);
3568
3578
  }
3569
3579
  applyAutoFit(rows, options) {
@@ -3678,6 +3688,11 @@ class SheetWriter {
3678
3688
  if (done)
3679
3689
  break;
3680
3690
  const rowNum = ri + 1;
3691
+ // Past Excel's sheet size the file is damaged, so refuse it instead
3692
+ if (rowNum > MAX_ROWS$1)
3693
+ throw new Error(`Row ${rowNum} is past Excel's last row (${MAX_ROWS$1}).`);
3694
+ if (row.length > MAX_COLUMNS$1)
3695
+ throw new Error(`Row ${rowNum} has ${row.length} cells, more than Excel's ${MAX_COLUMNS$1} columns.`);
3681
3696
  chunkStr += optionRowsBefore(rowNum);
3682
3697
  if (rowOptions[nextRowOption]?.[0] === rowNum)
3683
3698
  nextRowOption++;
@@ -3688,7 +3703,7 @@ class SheetWriter {
3688
3703
  let cellStyle = styledCell.style;
3689
3704
  if (val instanceof Date && !cellStyle?.numFmt) {
3690
3705
  // 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' };
3706
+ cellStyle = { ...cellStyle, numFmt: defaultDateFormat(val) };
3692
3707
  }
3693
3708
  const style = cellStyle ? this.styleEngine.registerStyle(cellStyle) : 0;
3694
3709
  const sAttr = style > 0 ? ` s="${style}"` : '';
@@ -3826,7 +3841,7 @@ class SheetWriter {
3826
3841
  let items = '';
3827
3842
  for (const s of this.sharedStrings.keys())
3828
3843
  items += `<si>${stringXml(s)}</si>`;
3829
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3844
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3830
3845
  <sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="${this.sharedStringRefs}" uniqueCount="${this.sharedStrings.size}">${items}</sst>`;
3831
3846
  }
3832
3847
  // '' when the sheet has no relationships
@@ -3841,7 +3856,7 @@ class SheetWriter {
3841
3856
  tableIds.forEach((id, k) => { rels += `<Relationship Id="rIdTable${k + 1}" Type="${REL}/table" Target="../tables/table${id}.xml"/>`; });
3842
3857
  if (!rels)
3843
3858
  return '';
3844
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3859
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3845
3860
  <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${rels}</Relationships>`;
3846
3861
  }
3847
3862
  buildWorkbookXml() {
@@ -3866,14 +3881,14 @@ class SheetWriter {
3866
3881
  }
3867
3882
  return names;
3868
3883
  }).join('');
3869
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3870
- <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
3871
- xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3872
- ${active > 0 ? `<bookViews><workbookView firstSheet="${active}" activeTab="${active}"/></bookViews>` : ''}
3873
- <sheets>
3874
- ${sheetsXml}
3875
- </sheets>
3876
- ${filterNames || userNames ? `<definedNames>${filterNames}${userNames}</definedNames>` : ''}
3884
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3885
+ <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
3886
+ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
3887
+ ${active > 0 ? `<bookViews><workbookView firstSheet="${active}" activeTab="${active}"/></bookViews>` : ''}
3888
+ <sheets>
3889
+ ${sheetsXml}
3890
+ </sheets>
3891
+ ${filterNames || userNames ? `<definedNames>${filterNames}${userNames}</definedNames>` : ''}
3877
3892
  </workbook>`;
3878
3893
  }
3879
3894
  // The first visible sheet, which Excel opens on
@@ -3884,19 +3899,19 @@ class SheetWriter {
3884
3899
  const sheetRels = this.sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join('\n ');
3885
3900
  const ssId = this.sheets.length + 1;
3886
3901
  const stylesId = this.sheets.length + 2;
3887
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3888
- <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3889
- ${sheetRels}
3890
- <Relationship Id="rId${ssId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
3891
- <Relationship Id="rId${stylesId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
3902
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3903
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3904
+ ${sheetRels}
3905
+ <Relationship Id="rId${ssId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
3906
+ <Relationship Id="rId${stylesId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
3892
3907
  </Relationships>`;
3893
3908
  }
3894
3909
  buildRootRels() {
3895
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3896
- <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3897
- <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>${this.writerOptions.properties ? `
3898
- <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
3899
- <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>` : ''}
3910
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3911
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
3912
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>${this.writerOptions.properties ? `
3913
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
3914
+ <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>` : ''}
3900
3915
  </Relationships>`;
3901
3916
  }
3902
3917
  buildContentTypes(drawings, commentSheets, tables) {
@@ -3907,17 +3922,17 @@ class SheetWriter {
3907
3922
  commentSheets.map(n => `<Override PartName="/xl/comments${n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/>`).join('') +
3908
3923
  Array.from({ length: tables }, (_, i) => `<Override PartName="/xl/tables/table${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>`).join('') +
3909
3924
  (commentSheets.length ? '<Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/>' : '');
3910
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3911
- <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
3912
- <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
3913
- <Default Extension="xml" ContentType="application/xml"/>
3914
- ${imageTypes}${drawingOverrides}
3915
- <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
3916
- ${sheetOverrides}
3917
- <Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
3918
- <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${this.writerOptions.properties ? `
3919
- <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
3920
- <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>` : ''}
3925
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3926
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
3927
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
3928
+ <Default Extension="xml" ContentType="application/xml"/>
3929
+ ${imageTypes}${drawingOverrides}
3930
+ <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
3931
+ ${sheetOverrides}
3932
+ <Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
3933
+ <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${this.writerOptions.properties ? `
3934
+ <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
3935
+ <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>` : ''}
3921
3936
  </Types>`;
3922
3937
  }
3923
3938
  }
@@ -3949,7 +3964,7 @@ function pageSetupXml(page) {
3949
3964
  function commentsXml(comments) {
3950
3965
  const authors = [...new Set(comments.map(c => c.comment.author ?? ''))];
3951
3966
  const list = comments.map(c => `<comment ref="${c.ref}" authorId="${authors.indexOf(c.comment.author ?? '')}"><text>${runsXml(c.comment.text)}</text></comment>`).join('');
3952
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3967
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3953
3968
  <comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors>${authors.map(a => `<author>${escapeXml$1(a)}</author>`).join('')}</authors><commentList>${list}</commentList></comments>`;
3954
3969
  }
3955
3970
  // The legacy VML shapes Excel needs to show the note boxes (hidden until the cell is hovered)
@@ -3969,7 +3984,7 @@ function tableXml(table, id, columns) {
3969
3984
  const ref = table.ref.replace(/\$/g, '');
3970
3985
  const filter = table.autoFilter === false ? '' : `<autoFilter ref="${ref}"/>`;
3971
3986
  const cols = columns.map((c, k) => `<tableColumn id="${k + 1}" name="${escapeXml$1(c)}"/>`).join('');
3972
- return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3987
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
3973
3988
  <table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" id="${id}" name="${escapeXml$1(table.name)}" displayName="${escapeXml$1(table.name)}" ref="${ref}" totalsRowShown="0">` +
3974
3989
  `${filter}<tableColumns count="${columns.length}">${cols}</tableColumns>` +
3975
3990
  `<tableStyleInfo name="${escapeXml$1(table.style ?? 'TableStyleMedium2')}" showFirstColumn="0" showLastColumn="0" showRowStripes="${table.showRowStripes === false ? 0 : 1}" showColumnStripes="0"/></table>`;
@@ -4144,6 +4159,16 @@ class StylePatcher {
4144
4159
  this.memo.set(key, id);
4145
4160
  return id;
4146
4161
  }
4162
+ // The format for date `d` in a cell of format `base`: `base` when it already shows dates, otherwise
4163
+ // `base` with a date format, since a date in a General cell shows as its serial number
4164
+ dateFormat(base, d) {
4165
+ const xf = this.xfs[base] ?? this.added.cellXfs[base - this.xfs.length] ?? '';
4166
+ const id = parseInt(attr(/^<[^>]*>/.exec(xf)?.[0] ?? '', 'numFmtId') ?? '0', 10);
4167
+ const code = [...this.numFmts].find(([, v]) => v === id)?.[0];
4168
+ if (isBuiltinDateFormat(id) || (code !== undefined && isDateFormatCode(code)))
4169
+ return base;
4170
+ return this.patch(base, { numFmt: defaultDateFormat(d) });
4171
+ }
4147
4172
  toXml() {
4148
4173
  let xml = this.xml;
4149
4174
  for (const section of ['numFmts', 'fonts', 'fills', 'borders', 'cellXfs']) {
@@ -4938,7 +4963,10 @@ class SheetEditor {
4938
4963
  let patcher;
4939
4964
  const styled = [...editsByPath.values(), ...added.values()]
4940
4965
  .some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => styleOf(e))));
4941
- if (styled) {
4966
+ // Dates may need a date format added to their cells
4967
+ const dated = [...editsByPath.values(), ...added.values()]
4968
+ .some(rows => [...rows.values()].some(cells => [...cells.values()].some(e => contentOf(e) instanceof Date)));
4969
+ if (styled || (dated && parts.styles)) {
4942
4970
  if (!parts.styles)
4943
4971
  throw new Error('Workbook has no styles part.');
4944
4972
  patcher = new StylePatcher(await read(parts.styles));
@@ -5015,6 +5043,10 @@ class SheetEditor {
5015
5043
  attrs = `${attrs.replace(/\ss="[^"]*"/, '')} s="${patcher.patch(base, style)}"`;
5016
5044
  }
5017
5045
  const content = contentOf(edit);
5046
+ if (content instanceof Date && patcher) {
5047
+ const base = parseInt(/\ss="(\d+)"/.exec(attrs)?.[1] ?? '0', 10);
5048
+ attrs = `${attrs.replace(/\ss="[^"]*"/, '')} s="${patcher.dateFormat(base, content)}"`;
5049
+ }
5018
5050
  if (content === KEEP) {
5019
5051
  return existing?.[2] !== undefined ? `<${p}c r="${ref}"${attrs}>${existing[2]}</${p}c>` : `<${p}c r="${ref}"${attrs}/>`;
5020
5052
  }
@@ -5776,7 +5808,8 @@ async function* parseCsv(input, options = {}) {
5776
5808
  let first = true;
5777
5809
  const rows = [];
5778
5810
  const endField = () => {
5779
- row.push(quoted || !convert ? field : convertField(field));
5811
+ // An empty unquoted field is null in both modes; a quoted "" stays an empty string
5812
+ row.push(quoted ? field : !convert ? (field === '' ? null : field) : convertField(field));
5780
5813
  field = '';
5781
5814
  quoted = false;
5782
5815
  };
@@ -5908,11 +5941,14 @@ function textXml(s) {
5908
5941
  }
5909
5942
  const dateValue = (d) => d.toISOString().slice(0, 19); // UTC, like SheetWriter
5910
5943
  const hasTime = (d) => d.getTime() % 86400000 !== 0;
5911
- function cellXml(cell, span, covered) {
5944
+ function cellXml(cell, span, covered, evaluate) {
5912
5945
  if (covered)
5913
5946
  return '<table:covered-table-cell/>';
5914
- const value = isStyledCell(cell) ? cell.value : cell;
5947
+ let value = isStyledCell(cell) ? cell.value : cell;
5915
5948
  const formula = isStyledCell(cell) && cell.formula ? ` table:formula="${escapeXml(toOpenFormula(cell.formula))}"` : '';
5949
+ // A formula without a value gets its result stored, as SheetWriter does, for readers that don't recalculate
5950
+ if (formula && value == null)
5951
+ value = evaluate(cell.formula);
5916
5952
  if (value === null || value === undefined || (typeof value === 'number' && !isFinite(value))) {
5917
5953
  return formula || span ? `<table:table-cell${formula}${span}/>` : '<table:table-cell/>';
5918
5954
  }
@@ -6005,6 +6041,15 @@ class OdsWriter {
6005
6041
  const merges = (sheet.options.mergeCells ?? []).map(parseRange);
6006
6042
  const lastMergeRow = Math.max(-1, ...merges.map(m => m.r2));
6007
6043
  const mergeAt = (r, c) => merges.find(m => m.r1 === r && m.c1 === c);
6044
+ // Formulas resolve against the sheet they live in; streamed rows can't be looked back at
6045
+ const engine = new FormulaEngine();
6046
+ if (Array.isArray(sheet.rows)) {
6047
+ engine.loadData(sheet.rows.map(row => row.map(cell => {
6048
+ const v = isStyledCell(cell) ? cell.value : cell;
6049
+ return v instanceof Date ? dateToSerial(v) : v ?? null;
6050
+ })));
6051
+ }
6052
+ const evaluate = (f) => engine.evaluate(f);
6008
6053
  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
6054
  let r = 0, batch = '';
6010
6055
  const writeRow = (row) => {
@@ -6013,7 +6058,7 @@ class OdsWriter {
6013
6058
  for (let c = 0; c < width; c++) {
6014
6059
  const m = mergeAt(r, c);
6015
6060
  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));
6061
+ xml += cellXml(row[c], span, covered(r, c), evaluate);
6017
6062
  }
6018
6063
  r++;
6019
6064
  return xml + (width ? '' : '<table:table-cell/>') + '</table:table-row>';
@@ -6106,6 +6151,7 @@ exports.SheetEditor = SheetEditor;
6106
6151
  exports.SheetReader = SheetReader;
6107
6152
  exports.SheetWriter = SheetWriter;
6108
6153
  exports.StyleEngine = StyleEngine;
6154
+ exports.StylePatcher = StylePatcher;
6109
6155
  exports.XlsxFlow = XlsxFlow;
6110
6156
  exports.ZipRandomAccessParser = ZipRandomAccessParser;
6111
6157
  exports.ZipStreamWriter = ZipStreamWriter;