@shbernal/ts-xlsx 1.1.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +26 -0
  2. package/dist/core/address.js +0 -33
  3. package/dist/core/autofilter.js +0 -9
  4. package/dist/core/cell.js +0 -131
  5. package/dist/core/color-resolution.js +0 -60
  6. package/dist/core/column.js +0 -60
  7. package/dist/core/comment-thread.js +0 -45
  8. package/dist/core/conditional-formatting-overlay.js +0 -11
  9. package/dist/core/conditional-formatting.js +0 -10
  10. package/dist/core/containers.js +0 -15
  11. package/dist/core/data-validation-overlay.js +0 -25
  12. package/dist/core/data-validation.js +0 -7
  13. package/dist/core/date.js +0 -39
  14. package/dist/core/formula.js +0 -117
  15. package/dist/core/grid-edits.js +0 -38
  16. package/dist/core/image.js +0 -25
  17. package/dist/core/internal.js +0 -27
  18. package/dist/core/limits.js +0 -45
  19. package/dist/core/merge.d.ts +18 -0
  20. package/dist/core/merge.js +22 -7
  21. package/dist/core/modern-functions.js +0 -17
  22. package/dist/core/page-setup.js +0 -5
  23. package/dist/core/pivot-table.d.ts +1 -1
  24. package/dist/core/pivot-table.js +0 -48
  25. package/dist/core/preserved.js +0 -4
  26. package/dist/core/protection.js +0 -26
  27. package/dist/core/range.js +0 -112
  28. package/dist/core/row-input.d.ts +24 -0
  29. package/dist/core/row-input.js +29 -0
  30. package/dist/core/row.js +0 -66
  31. package/dist/core/style.js +0 -26
  32. package/dist/core/table-style.js +0 -28
  33. package/dist/core/table.js +0 -97
  34. package/dist/core/text-metrics.js +0 -40
  35. package/dist/core/theme.js +0 -85
  36. package/dist/core/value.js +0 -94
  37. package/dist/core/workbook-protection.js +0 -13
  38. package/dist/core/workbook.d.ts +13 -1
  39. package/dist/core/workbook.js +0 -380
  40. package/dist/core/worksheet-model.js +2 -23
  41. package/dist/core/worksheet.d.ts +9 -0
  42. package/dist/core/worksheet.js +7 -506
  43. package/dist/customui/errors.js +0 -10
  44. package/dist/customui/index.js +0 -5
  45. package/dist/customui/ribbon.js +0 -43
  46. package/dist/entries/core.js +0 -6
  47. package/dist/entries/csv.js +0 -4
  48. package/dist/entries/customui.js +0 -2
  49. package/dist/entries/errors.js +0 -13
  50. package/dist/entries/vba.js +0 -5
  51. package/dist/entries/xlsb.js +0 -6
  52. package/dist/entries/xlsx.js +0 -13
  53. package/dist/errors.d.ts +14 -0
  54. package/dist/errors.js +5 -56
  55. package/dist/index.js +0 -12
  56. package/dist/io/csv/read.js +0 -18
  57. package/dist/io/csv/write.js +0 -18
  58. package/dist/io/opc/errors.js +0 -30
  59. package/dist/io/opc/inflate.js +0 -35
  60. package/dist/io/opc/namespaces.js +0 -15
  61. package/dist/io/opc/part-paths.js +0 -11
  62. package/dist/io/opc/read-opc.js +0 -31
  63. package/dist/io/opc/read-options.js +0 -6
  64. package/dist/io/opc/rels.js +0 -13
  65. package/dist/io/opc/sniff-format.js +0 -50
  66. package/dist/io/opc/zip-mtime.d.ts +5 -0
  67. package/dist/io/opc/zip-mtime.js +1 -0
  68. package/dist/io/style/xf-style.js +0 -31
  69. package/dist/io/xlsb/errors.js +0 -10
  70. package/dist/io/xlsb/formula.js +1 -99
  71. package/dist/io/xlsb/primitives.js +1 -83
  72. package/dist/io/xlsb/ptg-functions.js +0 -34
  73. package/dist/io/xlsb/read-shared-strings.js +0 -7
  74. package/dist/io/xlsb/read-styles.js +3 -66
  75. package/dist/io/xlsb/read-worksheet.js +4 -87
  76. package/dist/io/xlsb/read.js +2 -60
  77. package/dist/io/xlsb/record-stream.js +0 -28
  78. package/dist/io/xlsb/record-types.js +0 -19
  79. package/dist/io/xlsx/cell-accumulator.js +0 -32
  80. package/dist/io/xlsx/cell-value.js +0 -29
  81. package/dist/io/xlsx/color-xml.d.ts +6 -0
  82. package/dist/io/xlsx/color-xml.js +42 -0
  83. package/dist/io/xlsx/comments.js +0 -84
  84. package/dist/io/xlsx/conditional-formatting.d.ts +1 -1
  85. package/dist/io/xlsx/conditional-formatting.js +1 -97
  86. package/dist/io/xlsx/data-validation.js +0 -56
  87. package/dist/io/xlsx/edit-vba.js +2 -55
  88. package/dist/io/xlsx/errors.js +0 -12
  89. package/dist/io/xlsx/hyperlinks.js +0 -40
  90. package/dist/io/xlsx/images.js +0 -32
  91. package/dist/io/xlsx/namespaces.js +0 -42
  92. package/dist/io/xlsx/package-plan.js +0 -47
  93. package/dist/io/xlsx/pivot.js +0 -22
  94. package/dist/io/xlsx/{pivot-read.js → read-pivot.js} +0 -30
  95. package/dist/io/xlsx/read-rows.js +1 -83
  96. package/dist/io/xlsx/{shared-strings-read.js → read-shared-strings.js} +0 -11
  97. package/dist/io/xlsx/read-styles.d.ts +25 -1
  98. package/dist/io/xlsx/read-styles.js +61 -108
  99. package/dist/io/xlsx/read-worksheet.js +7 -88
  100. package/dist/io/xlsx/read.js +36 -213
  101. package/dist/io/xlsx/relationships.js +0 -11
  102. package/dist/io/xlsx/rich-runs.js +0 -16
  103. package/dist/io/xlsx/rich-text.js +0 -12
  104. package/dist/io/xlsx/shared-formulas.js +0 -7
  105. package/dist/io/xlsx/shared-strings.js +0 -21
  106. package/dist/io/xlsx/sheet-properties.js +4 -66
  107. package/dist/io/xlsx/styles.d.ts +1 -29
  108. package/dist/io/xlsx/styles.js +2 -366
  109. package/dist/io/xlsx/tables.js +5 -52
  110. package/dist/io/xlsx/threaded-comments.js +0 -103
  111. package/dist/io/xlsx/workbook-xml.d.ts +1 -1
  112. package/dist/io/xlsx/workbook-xml.js +6 -96
  113. package/dist/io/xlsx/worksheet-xml.js +0 -174
  114. package/dist/io/xlsx/write-stream.js +2 -137
  115. package/dist/io/xlsx/write.d.ts +5 -1
  116. package/dist/io/xlsx/write.js +5 -137
  117. package/dist/io/xlsx/x14-ext.js +0 -8
  118. package/dist/vba/cfb-writer.js +12 -59
  119. package/dist/vba/cfb.js +2 -32
  120. package/dist/vba/codepage.js +1 -23
  121. package/dist/vba/errors.js +0 -15
  122. package/dist/vba/index.js +0 -10
  123. package/dist/vba/ms-ovba.js +3 -46
  124. package/dist/vba/project-editor.js +4 -89
  125. package/dist/vba/project.js +2 -34
  126. package/dist/vba/vba-encoding.js +1 -17
  127. package/dist/xml/errors.js +0 -10
  128. package/dist/xml/xml-read.js +3 -82
  129. package/dist/xml/xml.js +0 -39
  130. package/package.json +8 -9
  131. package/skills/ts-xlsx-upstream/SKILL.md +178 -0
  132. /package/dist/io/xlsx/{pivot-read.d.ts → read-pivot.d.ts} +0 -0
  133. /package/dist/io/xlsx/{shared-strings-read.d.ts → read-shared-strings.d.ts} +0 -0
@@ -1,29 +1,3 @@
1
- // The streaming `.xlsx` writer: author a workbook incrementally and emit its package through a Node
2
- // stream, rather than holding the finished bytes in one buffer as {@link writeXlsx} does.
3
- //
4
- // A producer adds worksheets, appends rows as it generates them, commits each sheet, then commits the
5
- // workbook — at which point the package is assembled and streamed out. The output goes to whichever
6
- // destination the caller chose at construction: its own pull-based `stream` to pipe, a caller-owned
7
- // `Writable` sink (an outbound upload), or a `filename` the writer opens. In every case `commit()`
8
- // settles — it resolves once a supplied sink has flushed the whole package and rejects if that sink
9
- // errors (an unopenable file), never hanging on a finish signal that will not come. The output rides a
10
- // genuinely
11
- // streamed zip container (fflate's `Zip`/`ZipDeflate`), which computes each entry's CRC-32
12
- // incrementally, so the archive is well-formed by construction — the defect the upstream "streaming
13
- // writer emits a corrupt zip" reports describe is structurally absent here. The bytes reload
14
- // identically to a whole-file write because both writers share `buildPackageParts` for every part.
15
- //
16
- // Peak memory: a row committed with `row.commit()` is serialised to its `<row>` XML immediately and
17
- // its cells evicted from the model, so an append-driven producer holds only the rows still in flight
18
- // rather than the whole sheet's cell graph. The eagerly-rendered rows intern into the workbook's live
19
- // style registry — the same one that emits `xl/styles.xml` — so their style ids stay correct; their
20
- // XML is handed to `buildPackageParts` and emitted at the head of `<sheetData>`. This eager path runs
21
- // with strings inline (a shared-strings pool is inherently whole-workbook, so it defeats bounding);
22
- // turning `useSharedStrings` on falls back to holding every row live until commit. A flushed row is a
23
- // finished row: it cannot join whole-sheet derivations, so a shared-formula clone in a committed row
24
- // is rejected, and rows reached only through `getCell` (never `row.commit()`) stay live and serialise
25
- // the ordinary way. The package bytes themselves are still assembled once at commit — a later slice
26
- // can flush each sheet's `<sheetData>` straight into its streamed zip entry to bound that half too.
27
1
  import { createWriteStream } from 'node:fs';
28
2
  import { PassThrough } from 'node:stream';
29
3
  import { Zip, ZipDeflate } from 'fflate';
@@ -32,13 +6,8 @@ import { INTERNAL } from '../../core/internal.js';
32
6
  import { isSharedFormulaValue } from '../../core/value.js';
33
7
  import { Workbook } from '../../core/workbook.js';
34
8
  import { AuthoringError } from '../../errors.js';
9
+ import { FIXED_ENTRY_MTIME } from '../opc/zip-mtime.js';
35
10
  import { buildColumnDefaults, buildPackageParts, createStyleRegistry, Extent, renderRow, } from './write.js';
36
- /**
37
- * A row appended to a {@link WorksheetStreamWriter}. Style its cells through {@link cells}, then call
38
- * {@link commit} to mark it finished. In an eager (inline-strings) writer, committing serialises the
39
- * row and frees its cells from the model, bounding peak memory; with `useSharedStrings` on it is a
40
- * no-op and the row stays live until the workbook commits.
41
- */
42
11
  export class StreamedRow {
43
12
  #cells;
44
13
  #sheet;
@@ -49,12 +18,9 @@ export class StreamedRow {
49
18
  this.#sheet = sheet;
50
19
  this.#number = number;
51
20
  }
52
- /** The cells this row materialised, for styling before it is committed. */
53
21
  get cells() {
54
22
  return this.#cells;
55
23
  }
56
- /** Finalise the row: an eager writer serialises it now and releases its cells; otherwise a no-op.
57
- * Committing twice is harmless — the second call does nothing rather than re-emitting the row. */
58
24
  commit() {
59
25
  if (this.#committed)
60
26
  return;
@@ -62,21 +28,12 @@ export class StreamedRow {
62
28
  this.#sheet?.flushRow(this.#number, this.#cells);
63
29
  }
64
30
  }
65
- /**
66
- * A worksheet being written incrementally. Append rows with {@link addRow}/{@link addRows}, style
67
- * cells through {@link getCell}, then {@link commit} to freeze it — after which any further mutation
68
- * is rejected with a legible error rather than silently accepted or crashing.
69
- */
70
31
  export class WorksheetStreamWriter {
71
32
  #sheet;
72
33
  #eager;
73
34
  #styles;
74
35
  #committed = false;
75
- // The last row number this writer appended, tracked independently of the model: an eager writer
76
- // evicts flushed rows, which lowers the model's used range, so leaning on it would reuse numbers.
77
36
  #lastRow = 0;
78
- // The column defaults an eagerly-rendered row inherits, frozen at the first flush so every flushed
79
- // row composes against the same columns even as later ones are defined.
80
37
  #columnDefaults;
81
38
  #flushedRows = [];
82
39
  #extent = new Extent();
@@ -86,16 +43,12 @@ export class WorksheetStreamWriter {
86
43
  this.#eager = eager;
87
44
  this.#styles = styles;
88
45
  }
89
- /** The sheet's name. */
90
46
  get name() {
91
47
  return this.#sheet.name;
92
48
  }
93
- /** The number of rows written so far — spans gaps and formatted-only rows, like the model, and
94
- * survives the eviction of eagerly-flushed rows. */
95
49
  get rowCount() {
96
50
  return Math.max(this.#lastRow, this.#sheet.rowCount);
97
51
  }
98
- /** Append one row of values after the last used row; the cells are returned for styling. */
99
52
  addRow(values) {
100
53
  this.#assertOpen();
101
54
  if (!this.#eager)
@@ -103,21 +56,16 @@ export class WorksheetStreamWriter {
103
56
  const number = this.#nextRowNumber();
104
57
  return new StreamedRow(this.#placeRow(number, values), this, number);
105
58
  }
106
- /** Append a batch of rows in one call, each landing directly below the previous. */
107
59
  addRows(rows) {
108
60
  this.#assertOpen();
109
61
  if (!this.#eager)
110
62
  return this.#sheet.addRows(rows).map((cells) => new StreamedRow(cells, null, 0));
111
63
  return rows.map((values) => this.addRow(values));
112
64
  }
113
- // The next append position: past both this writer's own high-water mark and any rows a `getCell`
114
- // materialised, so appends never collide with random-access edits or with already-evicted rows.
115
65
  #nextRowNumber() {
116
66
  this.#lastRow = Math.max(this.#lastRow, this.#sheet.rowCount) + 1;
117
67
  return this.#lastRow;
118
68
  }
119
- // Materialise a positional row at an explicit number (the model's own append would reuse numbers once
120
- // eviction shrinks its used range), returning the cells for styling. A hole leaves its cell absent.
121
69
  #placeRow(number, values) {
122
70
  const cells = [];
123
71
  values.forEach((value, index) => {
@@ -127,14 +75,6 @@ export class WorksheetStreamWriter {
127
75
  });
128
76
  return cells;
129
77
  }
130
- /**
131
- * Serialise an eagerly-committed row and release its cells from the model. Called by
132
- * {@link StreamedRow.commit}; the row's `<row>` XML is retained (interned into the workbook's live
133
- * style registry so its ids stay valid) and the cell graph is dropped, bounding peak memory.
134
- *
135
- * @throws {AuthoringError} if the row carries a shared-formula cell — a finished row cannot join the
136
- * whole-sheet formula planning, so shared formulas must be authored through {@link getCell}.
137
- */
138
78
  flushRow(number, cells) {
139
79
  for (const cell of cells) {
140
80
  if (isSharedFormulaValue(cell.value)) {
@@ -145,9 +85,6 @@ export class WorksheetStreamWriter {
145
85
  }
146
86
  this.#columnDefaults ??= buildColumnDefaults(this.#sheet);
147
87
  const properties = this.#sheet.getRow(number).properties;
148
- // The row's outline depth is read off here because eviction is about to take its properties with
149
- // it, and `<sheetFormatPr outlineLevelRow>` — written long after, once every row is flushed —
150
- // reports the deepest level on the whole sheet.
151
88
  this.#maxRowOutlineLevel = Math.max(this.#maxRowOutlineLevel, properties?.outlineLevel ?? 0);
152
89
  const { xml, minCol, maxCol } = renderRow({ number, cells, properties }, {
153
90
  columnDefaults: this.#columnDefaults,
@@ -162,7 +99,6 @@ export class WorksheetStreamWriter {
162
99
  }
163
100
  this.#sheet[INTERNAL].evictRow(number);
164
101
  }
165
- // The rows this writer flushed, or undefined if none — handed to buildPackageParts at commit.
166
102
  flushedSheet() {
167
103
  if (this.#flushedRows.length === 0)
168
104
  return undefined;
@@ -172,44 +108,22 @@ export class WorksheetStreamWriter {
172
108
  maxRowOutlineLevel: this.#maxRowOutlineLevel,
173
109
  };
174
110
  }
175
- /** Address a cell by its A1 reference to read or style it before the sheet is committed. */
176
111
  getCell(reference) {
177
112
  this.#assertOpen();
178
113
  return this.#sheet.getCell(reference);
179
114
  }
180
- /**
181
- * Attach a data validation to a range before the sheet is committed. Delegates to the model, so the
182
- * streamed package emits the `<dataValidations>` block in its CT_Worksheet position — before
183
- * `<hyperlinks>` — because both writers share one worksheet serializer.
184
- */
185
115
  addDataValidation(sqref, rule, options = {}) {
186
116
  this.#assertOpen();
187
117
  this.#sheet.addDataValidation(sqref, rule, options);
188
118
  }
189
- /**
190
- * Attach a conditional formatting to a range before the sheet is committed. Like every other block,
191
- * it lands in its schema-mandated slot — after `<mergeCells>`, before `<dataValidations>` and
192
- * `<hyperlinks>` — since the streamed sheet is serialized through the same path as a buffered write.
193
- */
194
119
  addConditionalFormatting(formatting) {
195
120
  this.#assertOpen();
196
121
  this.#sheet.addConditionalFormatting(formatting);
197
122
  }
198
- /**
199
- * Anchor a workbook image (the id from {@link WorkbookStreamWriter.addImage}) to this sheet,
200
- * spanning the rectangle from the top-left grid point `tl` to the bottom-right `br`. The streamed
201
- * package emits the drawing part, its media relationship, and the sheet's `<drawing>` reference
202
- * exactly as a buffered write does — both writers share `buildPackageParts`.
203
- */
204
123
  addImage(imageId, anchor) {
205
124
  this.#assertOpen();
206
125
  this.#sheet.addImage(imageId, anchor);
207
126
  }
208
- /**
209
- * Apply the sheet's autofilter before it is committed; mirrors {@link Worksheet.autoFilter}. The
210
- * streamed package emits `<autoFilter>` in its CT_Worksheet slot — after `<sheetProtection>` — and
211
- * contributes the hidden `_FilterDatabase` defined name, exactly as a buffered write does.
212
- */
213
127
  set autoFilter(filter) {
214
128
  this.#assertOpen();
215
129
  this.#sheet.autoFilter = filter;
@@ -217,24 +131,16 @@ export class WorksheetStreamWriter {
217
131
  get autoFilter() {
218
132
  return this.#sheet.autoFilter;
219
133
  }
220
- /**
221
- * Apply sheet-level protection before the sheet is committed; mirrors {@link Worksheet.protect}. The
222
- * shared serializer places `<sheetProtection>` ahead of `<autoFilter>` per CT_Worksheet, so a
223
- * streamed sheet carrying both stays valid rather than corrupt.
224
- */
225
134
  protect(password, options = {}) {
226
135
  this.#assertOpen();
227
136
  this.#sheet.protect(password, options);
228
137
  }
229
- /** Freeze the sheet: no more rows or edits may be added after this. */
230
138
  commit() {
231
139
  this.#committed = true;
232
140
  }
233
- /** Whether the sheet has been committed. */
234
141
  get committed() {
235
142
  return this.#committed;
236
143
  }
237
- // Internal: the underlying model sheet, so the workbook writer can serialise it at commit time.
238
144
  get model() {
239
145
  return this.#sheet;
240
146
  }
@@ -244,26 +150,15 @@ export class WorksheetStreamWriter {
244
150
  }
245
151
  }
246
152
  }
247
- /**
248
- * A workbook written incrementally to a Node stream. Add worksheets, append their rows, commit each
249
- * sheet, then {@link commit} the workbook to assemble and stream the package. The produced bytes are
250
- * available both as the resolved value of `commit()` and through {@link stream} (a Node `Readable`
251
- * that a caller can `pipe`).
252
- */
253
153
  export class WorkbookStreamWriter {
254
154
  #workbook = new Workbook();
255
155
  #sheets = [];
256
156
  #writeOptions;
257
157
  #sink;
258
- // The single style registry shared by the eager per-row flush and the commit-time serialisation, so a
259
- // flushed row's style ids match the styles.xml built from the same table.
260
158
  #styles;
261
- // Eager per-row flushing runs with strings inline; a shared-strings pool is inherently whole-workbook,
262
- // so it cannot bound memory — turning it on keeps every row live until commit.
263
159
  #eager;
264
160
  #stream;
265
161
  #committed = false;
266
- /** Calculation settings for the workbook; set `fullCalcOnLoad` before committing to emit it. */
267
162
  calcProperties = {};
268
163
  constructor(options = {}) {
269
164
  this.#writeOptions = { useSharedStrings: options.useSharedStrings ?? false };
@@ -275,32 +170,19 @@ export class WorkbookStreamWriter {
275
170
  this.#sink =
276
171
  options.stream ?? (options.filename ? createWriteStream(options.filename) : undefined);
277
172
  }
278
- /** Document-level metadata written to the package's core properties. */
279
173
  get properties() {
280
174
  return this.#workbook.properties;
281
175
  }
282
- /**
283
- * The output stream carrying the package bytes. A caller drives it with Node's standard idiom —
284
- * `writer.stream.pipe(out)` — which composes because `pipe` returns its destination. The stream is
285
- * created lazily on first access so a caller handing the writer its own sink is still free to
286
- * ignore this one.
287
- */
288
176
  get stream() {
289
177
  this.#stream ??= new PassThrough();
290
178
  return this.#stream;
291
179
  }
292
- /**
293
- * Register a picture's bytes on the workbook's shared media registry and return its id, to anchor
294
- * on any sheet with {@link WorksheetStreamWriter.addImage}. Mirrors {@link Workbook.addImage}: one
295
- * media part backs an image anchored on several sheets. Rejected once the workbook is committed.
296
- */
297
180
  addImage(options) {
298
181
  if (this.#committed) {
299
182
  throw new AuthoringError('the workbook is already committed — no more images can be registered');
300
183
  }
301
184
  return this.#workbook.addImage(options);
302
185
  }
303
- /** Create a worksheet and append it to the workbook. */
304
186
  addWorksheet(name, options = {}) {
305
187
  if (this.#committed) {
306
188
  throw new AuthoringError('the workbook is already committed — no more worksheets can be added');
@@ -309,11 +191,6 @@ export class WorkbookStreamWriter {
309
191
  this.#sheets.push(sheet);
310
192
  return sheet;
311
193
  }
312
- /**
313
- * Assemble the workbook into its package, stream the bytes through {@link stream}, and resolve with
314
- * the same bytes. Every sheet is frozen first, so a row added after this rejects legibly. Idempotent
315
- * only in that a second call throws rather than re-emitting.
316
- */
317
194
  async commit() {
318
195
  if (this.#committed) {
319
196
  throw new AuthoringError('the workbook is already committed');
@@ -323,9 +200,6 @@ export class WorkbookStreamWriter {
323
200
  sheet.commit();
324
201
  if (this.calcProperties.fullCalcOnLoad)
325
202
  this.#workbook.fullCalcOnLoad = true;
326
- // Hand every sheet's eagerly-flushed rows to the shared serialiser, which emits them alongside the
327
- // rows still live in the model. The style registry is the same one the flushed rows interned into,
328
- // so styles.xml stays consistent with the ids already baked into their XML.
329
203
  const flushed = new Map();
330
204
  for (const sheet of this.#sheets) {
331
205
  const sheetFlushed = sheet.flushedSheet();
@@ -339,9 +213,6 @@ export class WorkbookStreamWriter {
339
213
  });
340
214
  const owned = this.#stream;
341
215
  const sink = this.#sink;
342
- // Track the caller sink's terminal state before writing a byte, so an open failure that errors on a
343
- // later tick (a bad filename) is caught rather than lost — the whole point of the reject-not-hang
344
- // contract.
345
216
  const sinkSettled = sink ? settleOnFinish(sink) : undefined;
346
217
  const bytes = await streamZipPackage(parts, (chunk) => {
347
218
  owned?.write(chunk);
@@ -353,19 +224,12 @@ export class WorkbookStreamWriter {
353
224
  return bytes;
354
225
  }
355
226
  }
356
- // Resolve when a caller-supplied sink has flushed the whole package (`finish`), or reject if it errors
357
- // (`error`) — the commit promise must settle either way, never hang. Whichever fires first wins; the
358
- // other is ignored.
359
227
  function settleOnFinish(sink) {
360
228
  return new Promise((resolve, reject) => {
361
229
  sink.once('finish', resolve);
362
230
  sink.once('error', reject);
363
231
  });
364
232
  }
365
- // Zip the package parts through fflate's streaming container, forwarding each output chunk to `onChunk`
366
- // as it is produced and resolving with the whole archive once the final chunk arrives. `ZipDeflate`
367
- // deflates synchronously, so the callback fires inline as each part is pushed — the CRC-32 fflate
368
- // stamps into every entry's header therefore always matches the bytes it just compressed.
369
233
  function streamZipPackage(parts, onChunk) {
370
234
  return new Promise((resolve, reject) => {
371
235
  const collected = [];
@@ -381,6 +245,7 @@ function streamZipPackage(parts, onChunk) {
381
245
  });
382
246
  for (const [name, data] of Object.entries(parts)) {
383
247
  const entry = new ZipDeflate(name, { level: 6 });
248
+ entry.mtime = FIXED_ENTRY_MTIME;
384
249
  zip.add(entry);
385
250
  entry.push(data, true);
386
251
  }
@@ -37,6 +37,10 @@ export interface InternalWriteOptions extends WriteOptions {
37
37
  /**
38
38
  * Serialise a workbook into an `.xlsx` package.
39
39
  *
40
+ * The bytes are a pure function of the workbook: an unchanged model written twice produces two
41
+ * identical archives, because entry timestamps are pinned to a fixed date rather than taken from the
42
+ * clock. A committed `.xlsx` therefore only changes when something about it changed.
43
+ *
40
44
  * @throws {AuthoringError} if the workbook has no worksheets (a zero-sheet package is corrupt),
41
45
  * or holds a value the writer cannot yet represent.
42
46
  */
@@ -44,7 +48,7 @@ export declare function writeXlsx(workbook: Workbook, options?: WriteOptions): U
44
48
  /**
45
49
  * Serialise a workbook into an `.xlsx` package, deflating off the calling thread.
46
50
  *
47
- * Produces the same package {@link writeXlsx} does — every part compresses to identical bytes — and
51
+ * Produces the same package {@link writeXlsx} does — byte for byte, entry timestamps included — and
48
52
  * exists for one reason: DEFLATE dominates the cost of writing a large workbook, and {@link writeXlsx}
49
53
  * spends all of it on the caller's thread. Here `fflate` deflates each part in a worker, so the event
50
54
  * loop keeps turning (stalls drop from the whole write to tens of milliseconds) and parts compress in
@@ -1,23 +1,9 @@
1
- // The buffered `.xlsx` writer: a Workbook model in, an OPC zip package out.
2
- //
3
- // It serialises the part of the model that exists today — worksheets; cells holding a
4
- // number, string, boolean, or formula; column/row formatting; page margins and
5
- // header/footer; merged ranges; and worksheet tables — into a valid package (content
6
- // types, relationships, workbook, per-sheet XML, table parts, the default theme and
7
- // stylesheet, and core/app properties). Styles, images, and the richer value kinds land
8
- // as the model grows; until then the writer refuses a value it cannot represent
9
- // faithfully rather than emitting a lossy or corrupt package.
10
- //
11
- // This module is the orchestrator: it plans the package graph (via `package-plan.ts`) and
12
- // stitches the serialised parts (from `workbook-xml.ts` and `worksheet-xml.ts`) into the
13
- // part map. The row/cell renderer and the sheet's public render types live in
14
- // `worksheet-xml.ts` and are re-exported here so the streaming writer's import surface is
15
- // unchanged.
16
1
  import { strToU8, zip, zipSync } from 'fflate';
17
2
  import { DEFAULT_THEME_XML } from '../../core/theme.js';
18
3
  import { AuthoringError } from '../../errors.js';
19
4
  import { THEME_PART_PATH } from '../opc/part-paths.js';
20
5
  import { relsPartXml } from '../opc/rels.js';
6
+ import { FIXED_ENTRY_MTIME } from '../opc/zip-mtime.js';
21
7
  import { collectComments, commentsXml, vmlDrawingXml } from './comments.js';
22
8
  import { collectHyperlinks, planHyperlinks } from './hyperlinks.js';
23
9
  import { drawingRelsXml, drawingXml } from './images.js';
@@ -31,39 +17,13 @@ import { personsXml, threadedCommentsXml } from './threaded-comments.js';
31
17
  import { appPropsXml, contentTypesXml, corePropsXml, FIXED_WORKBOOK_REL_COUNT, rootRelsXml, workbookRelsXml, workbookXml, } from './workbook-xml.js';
32
18
  import { worksheetRelsXml, worksheetXml, } from './worksheet-xml.js';
33
19
  export { buildColumnDefaults, Extent, renderRow, } from './worksheet-xml.js';
34
- /**
35
- * Serialise a workbook into an `.xlsx` package.
36
- *
37
- * @throws {AuthoringError} if the workbook has no worksheets (a zero-sheet package is corrupt),
38
- * or holds a value the writer cannot yet represent.
39
- */
40
20
  export function writeXlsx(workbook, options = {}) {
41
- return zipSync(buildPackageParts(workbook, options), { level: 6 });
21
+ return zipSync(buildPackageParts(workbook, options), { level: 6, mtime: FIXED_ENTRY_MTIME });
42
22
  }
43
- /**
44
- * Serialise a workbook into an `.xlsx` package, deflating off the calling thread.
45
- *
46
- * Produces the same package {@link writeXlsx} does — every part compresses to identical bytes — and
47
- * exists for one reason: DEFLATE dominates the cost of writing a large workbook, and {@link writeXlsx}
48
- * spends all of it on the caller's thread. Here `fflate` deflates each part in a worker, so the event
49
- * loop keeps turning (stalls drop from the whole write to tens of milliseconds) and parts compress in
50
- * parallel, which on a multi-sheet workbook also finishes sooner. On a single-sheet workbook there is
51
- * only one part to deflate, so expect responsiveness rather than speed.
52
- *
53
- * Building the parts still happens on the calling thread — only compression moves. That is why there
54
- * is no `readXlsxAsync` mirroring this: reading is dominated by XML parsing and model building, which
55
- * no worker can take, and the reader's zip-bomb ceiling is enforced by counting output between
56
- * synchronous input slices. See ADR-0024.
57
- *
58
- * @throws {AuthoringError} — as a rejection — under the same conditions as {@link writeXlsx};
59
- * the part-building it shares happens before any worker is involved. A failure raised by the zip
60
- * layer itself (including an environment that cannot spawn a worker) propagates unwrapped, exactly
61
- * as it does from {@link writeXlsx}.
62
- */
63
23
  export async function writeXlsxAsync(workbook, options = {}) {
64
24
  const parts = buildPackageParts(workbook, options);
65
25
  return await new Promise((resolve, reject) => {
66
- zip(parts, { level: 6 }, (error, data) => {
26
+ zip(parts, { level: 6, mtime: FIXED_ENTRY_MTIME }, (error, data) => {
67
27
  if (error)
68
28
  reject(error);
69
29
  else
@@ -71,47 +31,22 @@ export async function writeXlsxAsync(workbook, options = {}) {
71
31
  });
72
32
  });
73
33
  }
74
- /**
75
- * A style registry seeded from a workbook's read-in style layers (differential styles, named cell
76
- * styles, custom indexed palette), ready to intern authored styles after them. Both the buffered
77
- * pass and the streaming writer build their registry through here so a cell's style id means the
78
- * same thing whichever writer emits it.
79
- */
80
34
  export function createStyleRegistry(workbook) {
81
- // Font id 0 is the workbook's own default face, resolved from what it declared, what was authored,
82
- // and its theme's body typeface — never an assumed Calibri, which would re-face every empty cell
83
- // and change the metric every character-unit column width is expressed in.
84
35
  const styles = new StyleRegistry({
85
36
  defaultFont: workbook.defaultFont,
86
37
  ...(workbook.declaredDefaultFont === undefined
87
38
  ? {}
88
39
  : { declaredDefaultFont: workbook.declaredDefaultFont }),
89
40
  });
90
- // Seed the differential-style table with the fragments read from a source file so conditional
91
- // formatting's dxfId references stay valid; styles authored on rules append after them.
92
41
  styles.seedDifferentialStyles(workbook.differentialStyles);
93
- // Seed the named cell-style layer (cellStyleXfs/cellStyles) so each style's facets re-intern into the
94
- // rebuilt sub-tables and a cell's xfId link stays valid; without any, the default Normal alone emits.
95
42
  styles.seedNamedStyles(workbook.namedStyles);
96
- // Seed the custom indexed-color palette so it re-emits verbatim and an `indexed="…"` colour keeps
97
- // its intended RGB; a workbook that never overrode the palette seeds nothing and writes no <colors>.
98
43
  styles.seedIndexedColors(workbook.indexedColors);
99
- // Seed the author's "Recent Colors" swatches so they re-emit unchanged rather than being reset.
100
44
  styles.seedMruColors(workbook.mruColors);
101
- // Seed the custom table-style definitions so a table's `styleName` still names a real definition and
102
- // each element's dxfId still indexes the differential-style table seeded above at its original index.
103
45
  styles.seedTableStyles(workbook.tableStyles);
104
- // Authored styles append after the preserved ones, and intern their elements' formatting after the
105
- // seeded dxfs — the ordering that keeps every preserved dxfId pointing where it did.
106
46
  for (const style of workbook.customTableStyles)
107
47
  styles.addTableStyle(style);
108
48
  return styles;
109
49
  }
110
- // Resolve one sheet's tail reference ids (the `<drawing>`/`<legacyDrawing>`/`<legacyDrawingHF>`/
111
- // `<picture>` slots and the slicer list) from its plan. A preserved `<drawing>` and a modeled one are
112
- // mutually exclusive, so the drawing slot takes whichever exists; a comment's VML rides the legacy-
113
- // drawing slot; and each preserved slicer surfaces its rel id so the `<x14:slicerList>` can reactivate
114
- // the widget rather than orphan its part.
115
50
  function resolveSheetReferences(plan) {
116
51
  const refs = plan.preservedRefs;
117
52
  const preservedDrawingRelId = refs.find((ref) => ref.element === 'drawing')?.relId ?? null;
@@ -128,39 +63,15 @@ function resolveSheetReferences(plan) {
128
63
  slicerRelIds,
129
64
  };
130
65
  }
131
- /**
132
- * Assemble a workbook into the map of OPC package parts (part name → bytes) that make up an `.xlsx`,
133
- * short of zipping them. This is the whole serialisation — content types, relationships, workbook,
134
- * per-sheet XML, styles, theme, media, tables, and props — factored out of {@link writeXlsx} so the
135
- * streaming writer can drive the identical parts through a streamed zip container rather than
136
- * `zipSync`. Neither writer duplicates a byte of serialisation.
137
- *
138
- * @throws {AuthoringError} if the workbook has no worksheets, or holds a value the writer cannot represent.
139
- */
140
66
  export function buildPackageParts(workbook, options = {}) {
141
67
  const sheets = workbook.worksheets;
142
68
  if (sheets.length === 0) {
143
69
  throw new AuthoringError('cannot write a workbook with no worksheets — a zero-sheet package is corrupt to Excel');
144
70
  }
145
- // With the option on, plain string cell values are pooled into a shared-strings table interned
146
- // during the sheet pass (like the style registry); a null table keeps every string inline.
147
71
  const sharedStrings = options.useSharedStrings ? new SharedStringTable() : null;
148
- // Anchored images share workbook-wide media: every image a sheet references becomes one media part,
149
- // addressed by a global number. Resolved before the sheet loop so a drawing's embeds can target it.
150
72
  const media = planMedia(workbook, sheets);
151
- // Content the model does not interpret — a vector-shape drawing, a header/footer image, a pivot
152
- // table and its caches, a slicer — captured on read and re-emitted verbatim onto collision-proof
153
- // paths. Preserved parts are renumbered past the parts the writer generates of the same kind
154
- // (drawings, VML, media), so resolving them needs only those generated counts; each sheet's
155
- // preserved references take their sheet-local rel ids in canonical position in the loop below.
156
73
  const generatedDrawingCount = sheets.filter((sheet) => sheet.images.length > 0).length;
157
74
  const preserved = planPreservedParts(workbook, generatedDrawingCount, media.parts.length);
158
- // Plan every sheet's parts in a single pass, drawing each sheet-local relationship id from that
159
- // sheet's allocator in canonical order: tables, drawing, comments (VML + comments part), printer
160
- // settings, external hyperlinks, background, preserved references, pivot tables. One running
161
- // allocator per sheet is what keeps the ids gapless and collision-free — no step re-derives its
162
- // offset by summing the ones before it, so none can drift into another's id. Part numbers (tables,
163
- // drawings, pivots) are global across the workbook and counted here in the same pass.
164
75
  let tableNumber = 0;
165
76
  let drawingNumber = 0;
166
77
  let pivotNumber = 0;
@@ -177,8 +88,6 @@ export function buildPackageParts(workbook, options = {}) {
177
88
  const registered = workbook.getImage(image.imageId);
178
89
  return {
179
90
  anchor: image.anchor,
180
- // The embed id is local to the drawing part's own rels, not the sheet's, so it is numbered
181
- // per image from rId1 rather than drawn from the sheet allocator.
182
91
  embedId: `rId${j + 1}`,
183
92
  mediaNumber: media.numberById.get(image.imageId),
184
93
  extension: registered.extension,
@@ -186,12 +95,6 @@ export function buildPackageParts(workbook, options = {}) {
186
95
  });
187
96
  drawing = { number: ++drawingNumber, relId: rels.next(), images };
188
97
  }
189
- // A conversation and the legacy fallback `<comment>` that binds its cell to it are two halves of one
190
- // representation, so both are derived from this single list and neither can be emitted without the
191
- // other. Verified against desktop Excel: a `tc=` fallback whose thread part is absent shows as neither
192
- // a thread nor a note — the text disappears rather than degrading — and a thread part whose fallback is
193
- // absent is ignored, leaving the cell blank. A thread with no messages is not one of them: it has
194
- // nothing to say, and no head id for its replies or its fallback to hang off.
195
98
  const threads = sheet.commentThreads.filter((thread) => thread.comments.length > 0);
196
99
  const sheetComments = collectComments(sheet, threads);
197
100
  const comments = sheetComments.length === 0
@@ -221,8 +124,6 @@ export function buildPackageParts(workbook, options = {}) {
221
124
  const preservedRefs = (preserved.perSheet[i] ?? []).map((reference) => ({ ...reference, relId: rels.next() }));
222
125
  const pivots = sheet.pivotTables.map((table) => {
223
126
  const number = ++pivotNumber;
224
- // Each pivot is numbered globally (its parts and its `cacheId` must be workbook-unique); the
225
- // workbook relationship reaching its cache is assigned once the modeled workbook rels are known.
226
127
  return { number, cacheId: String(number), table, sheetRelId: rels.next(), workbookRelId: '' };
227
128
  });
228
129
  return {
@@ -239,22 +140,11 @@ export function buildPackageParts(workbook, options = {}) {
239
140
  });
240
141
  const allTables = perSheet.flatMap((plan) => plan.tables);
241
142
  const allPivots = perSheet.flatMap((plan) => plan.pivots);
242
- // Serialise the worksheets first: interning each cell/row fill into the style table is a
243
- // side effect of that pass, so styles.xml can only be generated once every sheet is done. The
244
- // streaming writer supplies its own registry (already seeded, and already carrying its eagerly
245
- // flushed rows' styles); the buffered path seeds a fresh one here.
246
143
  const styles = options.styles ?? createStyleRegistry(workbook);
247
144
  const sheetXml = sheets.map((sheet, i) => {
248
145
  const plan = perSheet[i];
249
- return worksheetXml(sheet, plan.tables, styles, resolveSheetReferences(plan), plan.hyperlinks, sharedStrings,
250
- // Exactly one sheet is marked selected; the model resolves which, so no package can ship with
251
- // none selected (no view initialised on open) or with several (an accidental group selection,
252
- // where an edit to one sheet lands on all of them).
253
- i === workbook.activeTabIndex, options.flushed?.get(sheet));
146
+ return worksheetXml(sheet, plan.tables, styles, resolveSheetReferences(plan), plan.hyperlinks, sharedStrings, i === workbook.activeTabIndex, options.flushed?.get(sheet));
254
147
  });
255
- // The pool is filled only once every sheet is serialised. Emit the part (and its rel + content
256
- // type) solely when the option is on and at least one string was interned, so a workbook with no
257
- // string cells never fabricates an empty table.
258
148
  const hasSharedStrings = sharedStrings !== null && !sharedStrings.isEmpty;
259
149
  const commentNumbers = perSheet
260
150
  .map((plan) => plan.comments)
@@ -272,14 +162,7 @@ export function buildPackageParts(workbook, options = {}) {
272
162
  .map((plan) => plan.threadedComments)
273
163
  .filter((t) => t !== null)
274
164
  .map((t) => t.number);
275
- // The identity registry is emitted only beside the thread parts that point into it. With no conversation
276
- // in the package nothing can reference a `<person>`, so the part would be a workbook-level relationship
277
- // to dead weight — and it is the messages, not the registry, that make an identity worth carrying.
278
165
  const persons = threadedCommentNumbers.length === 0 ? [] : workbook.persons;
279
- // A preserved workbook reference's relationship id follows the modeled workbook rels — the sheets,
280
- // styles, theme, and (when emitted) shared strings and the threaded-comment person registry — so adding
281
- // one never renumbers an id already used. The workbook body and its rels part are wired from the same
282
- // assignment, so a pivot cache's `<pivotCaches>` registration and its relationship agree on the id.
283
166
  const modeledWorkbookRelCount = sheets.length + FIXED_WORKBOOK_REL_COUNT + (hasSharedStrings ? 1 : 0);
284
167
  const personsRelId = persons.length === 0 ? null : `rId${modeledWorkbookRelCount + 1}`;
285
168
  const workbookRelBase = modeledWorkbookRelCount + (personsRelId === null ? 0 : 1);
@@ -287,8 +170,6 @@ export function buildPackageParts(workbook, options = {}) {
287
170
  ...ref,
288
171
  relId: `rId${workbookRelBase + 1 + i}`,
289
172
  }));
290
- // A generated pivot cache's workbook relationship follows the preserved ones; the assignment
291
- // mutates the shared plan so the `<pivotCaches>` body and the rels part read the same id.
292
173
  const pivotWorkbookRelBase = workbookRelBase + preserved.workbook.length;
293
174
  allPivots.forEach((pivot, i) => {
294
175
  pivot.workbookRelId = `rId${pivotWorkbookRelBase + 1 + i}`;
@@ -297,22 +178,17 @@ export function buildPackageParts(workbook, options = {}) {
297
178
  '[Content_Types].xml': strToU8(contentTypesXml(sheets.length, allTables, commentNumbers, drawingNumbers, printerSettingsNumbers, media.extensions, hasSharedStrings, preserved.parts, allPivots, preservedWorkbookRels, threadedCommentNumbers, persons.length > 0)),
298
179
  '_rels/.rels': strToU8(rootRelsXml(preserved.root)),
299
180
  'docProps/core.xml': strToU8(corePropsXml(workbook.properties)),
300
- 'docProps/app.xml': strToU8(appPropsXml()),
181
+ 'docProps/app.xml': strToU8(appPropsXml(workbook.properties)),
301
182
  'xl/workbook.xml': strToU8(workbookXml(workbook, preservedWorkbookRels, allPivots)),
302
183
  'xl/_rels/workbook.xml.rels': strToU8(workbookRelsXml(sheets.length, hasSharedStrings, personsRelId, preservedWorkbookRels, allPivots)),
303
184
  'xl/styles.xml': strToU8(styles.toXml()),
304
185
  };
305
- // A theme read from a source package is emitted through the preserved-part path, closure and all —
306
- // with any authored overrides already spliced into its entry part by the planner. A workbook without
307
- // one gets its authored theme, or the library's default, which the stylesheet's `theme="1"` default
308
- // font still needs something to resolve against.
309
186
  if (!preserved.themeEmitted) {
310
187
  files[THEME_PART_PATH] = strToU8(workbook.authoredThemeXml() ?? DEFAULT_THEME_XML);
311
188
  }
312
189
  if (hasSharedStrings) {
313
190
  files['xl/sharedStrings.xml'] = strToU8(sharedStrings.toXml());
314
191
  }
315
- // Singular and unnumbered, unlike the per-sheet thread parts: one registry serves the whole workbook.
316
192
  if (persons.length > 0)
317
193
  files['xl/persons/person.xml'] = strToU8(personsXml(persons));
318
194
  for (const part of media.parts) {
@@ -326,9 +202,6 @@ export function buildPackageParts(workbook, options = {}) {
326
202
  emitPreservedParts(files, preserved.parts);
327
203
  return files;
328
204
  }
329
- // Emit each sheet's own parts: the sheet XML, its rels part (only when the sheet references something),
330
- // and the drawing/comment/printer-settings parts those relationships point at. `sheetXml[i]` is the
331
- // already-serialised body for `perSheet[i]`, indexed in lockstep.
332
205
  function emitSheetParts(files, perSheet, sheetXml) {
333
206
  perSheet.forEach((plan, i) => {
334
207
  const { tables, drawing, comments, threadedComments, printerSettings, background, hyperlinks, preservedRefs, pivots, } = plan;
@@ -363,9 +236,6 @@ function emitSheetParts(files, perSheet, sheetXml) {
363
236
  }
364
237
  });
365
238
  }
366
- // Emit every pivot table's three chained parts. A pivot spans a pivot-table part (linked from its host
367
- // sheet) that references a cache definition, which references its cache records. Each cache carries a
368
- // rels part naming the next link by `rId1` — the id the definition/table XML resolves against.
369
239
  function emitPivotParts(files, allPivots) {
370
240
  for (const pivot of allPivots) {
371
241
  const { number, cacheId, table } = pivot;
@@ -384,8 +254,6 @@ function emitPivotParts(files, allPivots) {
384
254
  files[`xl/pivotCache/pivotCacheRecords${number}.xml`] = strToU8(pivotCacheRecordsXml(table));
385
255
  }
386
256
  }
387
- // Emit the verbatim-preserved parts (and their rewired rels) last: their paths are collision-proof, so
388
- // ordering against the generated parts does not matter.
389
257
  function emitPreservedParts(files, parts) {
390
258
  for (const part of parts) {
391
259
  files[part.path] = part.bytes;
@@ -1,12 +1,4 @@
1
1
  import { X14_NS } from './namespaces.js';
2
- /**
3
- * Wrap an x14 extension body in the `<ext>` envelope every worksheet- and workbook-level x14
4
- * extension shares: a `uri` scoping the extension to its feature (a fixed GUID from
5
- * {@link namespaces.ts}) plus the `xmlns:x14` binding the extension namespace on the element itself —
6
- * Excel declares it per `<ext>`, never at the part root. Callers needing an `<extLst>` wrapper add it
7
- * around the result; the producers the worksheet serialiser gathers return this bare so several exts
8
- * compose under a single shared `<extLst>`.
9
- */
10
2
  export function x14Ext(uri, body) {
11
3
  return `<ext uri="${uri}" xmlns:x14="${X14_NS}">${body}</ext>`;
12
4
  }