@shbernal/ts-xlsx 1.0.3 → 1.2.0

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 (136) hide show
  1. package/README.md +21 -0
  2. package/dist/core/address.js +0 -33
  3. package/dist/core/autofilter.js +0 -9
  4. package/dist/core/cell.d.ts +10 -0
  5. package/dist/core/cell.js +4 -123
  6. package/dist/core/color-resolution.js +0 -60
  7. package/dist/core/column.d.ts +9 -1
  8. package/dist/core/column.js +0 -52
  9. package/dist/core/comment-thread.js +0 -45
  10. package/dist/core/conditional-formatting-overlay.js +0 -11
  11. package/dist/core/conditional-formatting.js +0 -10
  12. package/dist/core/containers.js +0 -15
  13. package/dist/core/data-validation-overlay.js +0 -25
  14. package/dist/core/data-validation.js +0 -7
  15. package/dist/core/date.js +0 -39
  16. package/dist/core/formula.js +0 -117
  17. package/dist/core/grid-edits.js +0 -38
  18. package/dist/core/image.js +0 -25
  19. package/dist/core/internal.js +0 -27
  20. package/dist/core/limits.d.ts +30 -0
  21. package/dist/core/limits.js +2 -0
  22. package/dist/core/merge.d.ts +18 -0
  23. package/dist/core/merge.js +22 -7
  24. package/dist/core/modern-functions.js +0 -17
  25. package/dist/core/page-setup.js +0 -5
  26. package/dist/core/pivot-table.d.ts +1 -1
  27. package/dist/core/pivot-table.js +0 -44
  28. package/dist/core/preserved.js +0 -4
  29. package/dist/core/protection.js +0 -26
  30. package/dist/core/range.js +0 -112
  31. package/dist/core/row-input.d.ts +24 -0
  32. package/dist/core/row-input.js +29 -0
  33. package/dist/core/row.d.ts +9 -1
  34. package/dist/core/row.js +0 -58
  35. package/dist/core/style.js +0 -26
  36. package/dist/core/table-style.js +0 -28
  37. package/dist/core/table.js +0 -97
  38. package/dist/core/text-metrics.d.ts +20 -0
  39. package/dist/core/text-metrics.js +10 -0
  40. package/dist/core/theme.js +0 -85
  41. package/dist/core/value.d.ts +40 -0
  42. package/dist/core/value.js +30 -48
  43. package/dist/core/workbook-protection.js +0 -13
  44. package/dist/core/workbook.d.ts +13 -0
  45. package/dist/core/workbook.js +11 -368
  46. package/dist/core/worksheet-model.js +2 -23
  47. package/dist/core/worksheet.d.ts +22 -0
  48. package/dist/core/worksheet.js +14 -485
  49. package/dist/customui/errors.js +0 -10
  50. package/dist/customui/index.js +0 -5
  51. package/dist/customui/ribbon.js +0 -43
  52. package/dist/entries/core.d.ts +3 -1
  53. package/dist/entries/core.js +3 -7
  54. package/dist/entries/csv.js +0 -4
  55. package/dist/entries/customui.js +0 -2
  56. package/dist/entries/errors.js +0 -13
  57. package/dist/entries/vba.js +0 -5
  58. package/dist/entries/xlsb.js +0 -6
  59. package/dist/entries/xlsx.js +0 -13
  60. package/dist/errors.d.ts +14 -0
  61. package/dist/errors.js +5 -56
  62. package/dist/index.js +0 -12
  63. package/dist/io/csv/read.js +0 -18
  64. package/dist/io/csv/write.js +5 -35
  65. package/dist/io/opc/errors.js +0 -30
  66. package/dist/io/opc/inflate.js +0 -35
  67. package/dist/io/opc/namespaces.js +0 -15
  68. package/dist/io/opc/part-paths.js +0 -11
  69. package/dist/io/opc/read-opc.js +0 -31
  70. package/dist/io/opc/read-options.js +0 -6
  71. package/dist/io/opc/rels.js +0 -13
  72. package/dist/io/opc/sniff-format.js +0 -50
  73. package/dist/io/style/xf-style.js +0 -31
  74. package/dist/io/xlsb/errors.js +0 -10
  75. package/dist/io/xlsb/formula.js +1 -99
  76. package/dist/io/xlsb/primitives.js +1 -83
  77. package/dist/io/xlsb/ptg-functions.js +0 -34
  78. package/dist/io/xlsb/read-shared-strings.js +0 -7
  79. package/dist/io/xlsb/read-styles.js +3 -66
  80. package/dist/io/xlsb/read-worksheet.js +4 -87
  81. package/dist/io/xlsb/read.js +2 -60
  82. package/dist/io/xlsb/record-stream.js +0 -28
  83. package/dist/io/xlsb/record-types.js +0 -19
  84. package/dist/io/xlsx/cell-accumulator.js +0 -32
  85. package/dist/io/xlsx/cell-value.js +0 -29
  86. package/dist/io/xlsx/color-xml.d.ts +6 -0
  87. package/dist/io/xlsx/color-xml.js +42 -0
  88. package/dist/io/xlsx/comments.js +0 -84
  89. package/dist/io/xlsx/conditional-formatting.d.ts +1 -1
  90. package/dist/io/xlsx/conditional-formatting.js +1 -97
  91. package/dist/io/xlsx/data-validation.js +0 -56
  92. package/dist/io/xlsx/edit-vba.js +0 -54
  93. package/dist/io/xlsx/errors.js +0 -12
  94. package/dist/io/xlsx/hyperlinks.js +0 -40
  95. package/dist/io/xlsx/images.js +0 -32
  96. package/dist/io/xlsx/namespaces.js +0 -42
  97. package/dist/io/xlsx/package-plan.js +0 -47
  98. package/dist/io/xlsx/pivot.js +0 -22
  99. package/dist/io/xlsx/{pivot-read.js → read-pivot.js} +0 -30
  100. package/dist/io/xlsx/read-rows.js +1 -83
  101. package/dist/io/xlsx/{shared-strings-read.js → read-shared-strings.js} +0 -11
  102. package/dist/io/xlsx/read-styles.d.ts +25 -1
  103. package/dist/io/xlsx/read-styles.js +61 -108
  104. package/dist/io/xlsx/read-worksheet.js +1 -88
  105. package/dist/io/xlsx/read.js +3 -211
  106. package/dist/io/xlsx/relationships.js +0 -11
  107. package/dist/io/xlsx/rich-runs.js +0 -16
  108. package/dist/io/xlsx/rich-text.js +0 -12
  109. package/dist/io/xlsx/shared-formulas.js +0 -7
  110. package/dist/io/xlsx/shared-strings.js +0 -21
  111. package/dist/io/xlsx/sheet-properties.js +1 -64
  112. package/dist/io/xlsx/styles.d.ts +1 -29
  113. package/dist/io/xlsx/styles.js +2 -366
  114. package/dist/io/xlsx/tables.js +5 -52
  115. package/dist/io/xlsx/threaded-comments.js +0 -103
  116. package/dist/io/xlsx/workbook-xml.js +0 -94
  117. package/dist/io/xlsx/worksheet-xml.js +0 -174
  118. package/dist/io/xlsx/write-stream.js +0 -137
  119. package/dist/io/xlsx/write.js +1 -134
  120. package/dist/io/xlsx/x14-ext.js +0 -8
  121. package/dist/vba/cfb-writer.js +12 -59
  122. package/dist/vba/cfb.js +2 -32
  123. package/dist/vba/codepage.js +1 -23
  124. package/dist/vba/errors.js +0 -15
  125. package/dist/vba/index.js +0 -10
  126. package/dist/vba/ms-ovba.js +3 -46
  127. package/dist/vba/project-editor.js +4 -89
  128. package/dist/vba/project.js +2 -34
  129. package/dist/vba/vba-encoding.js +1 -17
  130. package/dist/xml/errors.js +0 -10
  131. package/dist/xml/xml-read.js +3 -82
  132. package/dist/xml/xml.js +0 -39
  133. package/package.json +9 -10
  134. package/skills/ts-xlsx-upstream/SKILL.md +178 -0
  135. /package/dist/io/xlsx/{pivot-read.d.ts → read-pivot.d.ts} +0 -0
  136. /package/dist/io/xlsx/{shared-strings-read.d.ts → read-shared-strings.d.ts} +0 -0
@@ -1,14 +1,3 @@
1
- // Structural edits to an existing `vbaProject.bin` that do NOT touch any module's compiled p-code:
2
- // remove a standard module, or add a registered library reference. Each is a surgical splice — parse the
3
- // original container, rebuild its whole storage/stream tree, and change only the `dir` records (and, for
4
- // a removal, the `PROJECT`/`PROJECTwm` text) the edit demands. Every module stream, `_VBA_PROJECT`, and
5
- // every untouched record rides through byte-for-byte; preservation is by *not touching* them.
6
- //
7
- // Authoring or editing module SOURCE is deliberately NOT here. Excel does not recompile from source on
8
- // open — a module runs the p-code it ships, and only a real Excel can produce genuinely source-matched
9
- // p-code — so source authoring/editing lives in the offline `tools/vba-compiler` (VBIDE), not in this
10
- // pure-TS path (ADR 0019). These splices are safe precisely because they leave every module's p-code
11
- // exactly as its own compiler wrote it.
12
1
  import { CompoundFile } from './cfb.js';
13
2
  import { writeCompoundFile } from './cfb-writer.js';
14
3
  import { decoderForCodePage, encoderForCodePage } from './codepage.js';
@@ -20,40 +9,17 @@ const DIR_STREAM = 'dir';
20
9
  const PROJECT_STREAM = 'PROJECT';
21
10
  const PROJECTWM_STREAM = 'PROJECTwm';
22
11
  const VBA_STORAGE = 'VBA';
23
- // `dir`-record ids the remove-module splice reads to delimit a module's record block ([MS-OVBA] 2.3.4.2);
24
- // every other record is preserved verbatim. MODULES_COUNT is decremented as a block is removed.
25
12
  const REC_MODULE_NAME = 0x0019;
26
13
  const REC_MODULE_STREAMNAME = 0x001a;
27
14
  const REC_MODULE_TERMINATOR = 0x002b;
28
- const REC_PROJECT_VERSION = 0x0009; // its uncounted 2-byte VersionMinor trails the counted payload
15
+ const REC_PROJECT_VERSION = 0x0009;
29
16
  const REC_MODULES_COUNT = 0x000f;
30
- // `dir`-record ids the add-reference splice builds ([MS-OVBA] 2.3.4.2.2). REFERENCENAME's Unicode half
31
- // is a *literal* 0x003E marker, not a nested record id — but it is laid out as its own Id+Size+data TLV,
32
- // so a generic walk (and this splice) sees REFERENCENAME as two chained records, exactly like
33
- // MODULE_NAME/MODULE_NAME_UNICODE. Verified against a real Excel-authored dir stream (2026-07-23).
34
17
  const REC_REFERENCE_NAME = 0x0016;
35
18
  const REC_REFERENCE_NAME_UNICODE = 0x003e;
36
19
  const REC_REFERENCE_REGISTERED = 0x000d;
37
- /**
38
- * Remove a standard module from an existing `vbaProject.bin`, returning new bytes that carry every
39
- * remaining module, reference, and host-info record unchanged. It drops the module's `VBA/<name>`
40
- * stream, its MODULE record block in `dir` (decrementing `MODULES_COUNT`), and its `Module=`/`Class=` +
41
- * workspace lines in `PROJECT`/`PROJECTwm`.
42
- *
43
- * Only `procedural` and `class` modules can be removed this way — removing a `document` module (e.g.
44
- * `ThisWorkbook`) or a `designer` module (a UserForm) would leave the host referencing code that no
45
- * longer exists, since their names are tied to a worksheet/workbook `codeName` or a designer storage
46
- * this project-level primitive has no visibility into. Editing such a module's code-behind is a job for
47
- * the offline `tools/vba-compiler` (in-place mode), which drives the real host.
48
- *
49
- * @throws {VbaParseError} if `bin` is not a parseable VBA project (validated before any edit).
50
- * @throws {VbaAuthorError} if `name` is not in the project, or names a `document`/`designer` module.
51
- */
52
20
  export function removeVbaModule(bin, name) {
53
- // Parse fail-closed first: validates the container and resolves the module's kind/stream name, so
54
- // nothing is mutated on a bad input or an unsupported module kind.
55
21
  const project = parseVbaProject(bin);
56
- const nameKey = name.toUpperCase(); // VBA names are case-insensitive
22
+ const nameKey = name.toUpperCase();
57
23
  const module = project.modules.find((m) => m.name.toUpperCase() === nameKey);
58
24
  if (!module)
59
25
  throw new VbaAuthorError(`module '${name}' is not in the VBA project`);
@@ -66,11 +32,6 @@ export function removeVbaModule(bin, name) {
66
32
  if (!dirCompressed)
67
33
  throw new VbaParseError("VBA project has no 'dir' stream");
68
34
  const patchedDir = removeModuleDirRecord(decompressContainer(dirCompressed), module.streamName, project.codePage);
69
- // Leave _VBA_PROJECT untouched. Resetting it to an "unmatchable version" cookie does NOT force Excel
70
- // to recompile from source (Excel runs the p-code as-is); on a project that carries real p-code the
71
- // reset actively crashes the VBA load (verified 2026-07-24, ADR 0019). The surviving modules keep
72
- // their own compiled p-code; the `dir` stream — authoritative for the module list — no longer names
73
- // the removed module, which is what makes the removal take.
74
35
  const replacements = new Map([[DIR_STREAM, compressContainer(patchedDir)]]);
75
36
  const decoder = decoderForCodePage(project.codePage);
76
37
  const encode = encoderForCodePage(project.codePage);
@@ -93,17 +54,10 @@ export function removeVbaModule(bin, name) {
93
54
  }
94
55
  return writeCompoundFile(newTree);
95
56
  }
96
- // LibidMajorVersion/LibidMinorVersion ([MS-OVBA] 2.1.1.8): 1*4HEXDIG, so at most 0xFFFF.
97
57
  const MAX_LIBID_VERSION = 0xffff;
98
- // LibidLcid: 1*8HEXDIG, so at most 0xFFFFFFFF (practically always 0 — locale-neutral).
99
58
  const MAX_LIBID_LCID = 0xffffffff;
100
- // LibidRegName: *255(%x01-FF) — at most 255 bytes, never NUL.
101
59
  const MAX_DISPLAY_NAME_CHARS = 255;
102
60
  const GUID_PATTERN = /^\{?([0-9A-Fa-f]{8})-([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})-([0-9A-Fa-f]{4})-([0-9A-Fa-f]{12})\}?$/;
103
- // Validate every field fail-closed and assemble the Libid string ([MS-OVBA] 2.1.1.8 LibidReference ABNF:
104
- // `*\G{GUID}#Major.Minor#LCID#Path#RegName`, hex digit strings with no `0x` prefix) — confirmed
105
- // byte-for-byte against a real Excel-authored reference (2026-07-23):
106
- // `*\G{420B2830-E718-11CF-893D-00A0C9054228}#1.0#0#C:\Windows\System32\scrrun.dll#Microsoft Scripting Runtime`.
107
61
  function normalizeReference(ref) {
108
62
  validateVbaName(ref.name, 'reference');
109
63
  const guidMatch = GUID_PATTERN.exec(ref.guid.trim());
@@ -136,22 +90,8 @@ function normalizeReference(ref) {
136
90
  `#${ref.path}#${displayName}`;
137
91
  return { name: ref.name, libid };
138
92
  }
139
- /**
140
- * Add a registered (COM type-library) reference to an existing `vbaProject.bin`, returning new bytes
141
- * that carry every existing module, reference, and host-info record unchanged. It grows the project's
142
- * `dir` stream by one `REFERENCENAME` + `REFERENCEREGISTERED` record pair, positioned immediately before
143
- * `MODULES_COUNT` (references have no count field of their own — `MODULES_COUNT` simply marks where the
144
- * reference array ends). It needs no change to `PROJECT`/`PROJECTwm`: a real Excel-authored `PROJECT`
145
- * stream carries no `Reference=` line at all — references live only in `dir` (confirmed against a genuine
146
- * Excel-authored project).
147
- *
148
- * @throws {VbaParseError} if `bin` is not a parseable VBA project (validated before any edit).
149
- * @throws {VbaAuthorError} if any field of `ref` is invalid (see {@link VbaLibraryReference}), or the
150
- * assembled reference text has a character the project's code page cannot represent.
151
- */
152
93
  export function addVbaReference(bin, ref) {
153
94
  const normalized = normalizeReference(ref);
154
- // Parse fail-closed first: validates the container before any mutation.
155
95
  const project = parseVbaProject(bin);
156
96
  const encode = encoderForCodePage(project.codePage);
157
97
  const cfb = new CompoundFile(bin);
@@ -160,8 +100,6 @@ export function addVbaReference(bin, ref) {
160
100
  throw new VbaParseError("VBA project has no 'dir' stream");
161
101
  const records = buildReferenceDirRecords(normalized, encode);
162
102
  const patchedDir = insertReferenceDirRecords(decompressContainer(dirCompressed), records);
163
- // Leave _VBA_PROJECT untouched — see the note in removeVbaModule. The new reference is unused by the
164
- // existing modules' p-code, so they load and run unchanged; only the `dir` reference array grows.
165
103
  const replacements = new Map([[DIR_STREAM, compressContainer(patchedDir)]]);
166
104
  const applied = new Set();
167
105
  const newTree = replaceStreams(cfb.tree(), replacements, applied);
@@ -169,9 +107,6 @@ export function addVbaReference(bin, ref) {
169
107
  throw new VbaParseError("VBA project 'dir' stream is not in the container tree");
170
108
  return writeCompoundFile(newTree);
171
109
  }
172
- // Build the REFERENCENAME + REFERENCEREGISTERED record bytes ([MS-OVBA] 2.3.4.2.2.2 / .2.2.5) for one
173
- // reference. REFERENCENAME's MBCS/Unicode name pair mirrors MODULE_NAME/MODULE_NAME_UNICODE's shape;
174
- // REFERENCEREGISTERED is one record carrying SizeOfLibid + Libid + two zero Reserved fields.
175
110
  function buildReferenceDirRecords(ref, encode) {
176
111
  const r = [];
177
112
  const nameBytes = [...encode(ref.name)];
@@ -186,10 +121,6 @@ function buildReferenceDirRecords(ref, encode) {
186
121
  ]);
187
122
  return r;
188
123
  }
189
- // Insert new reference dir records right before MODULES_COUNT (0x000f) — the reference array has no
190
- // explicit count field; MODULES_COUNT is simply the next record once the last reference ends (confirmed
191
- // against a real Excel-authored dir stream). Every other record — other references, all modules — rides
192
- // through unchanged.
193
124
  function insertReferenceDirRecords(dir, records) {
194
125
  let insertAt = -1;
195
126
  let pos = 0;
@@ -203,7 +134,7 @@ function insertReferenceDirRecords(dir, records) {
203
134
  }
204
135
  pos = dataStart + size;
205
136
  if (id === REC_PROJECT_VERSION)
206
- pos += 2; // uncounted VersionMinor (u16)
137
+ pos += 2;
207
138
  if (id === REC_MODULES_COUNT) {
208
139
  insertAt = recordStart;
209
140
  break;
@@ -218,11 +149,6 @@ function insertReferenceDirRecords(dir, records) {
218
149
  out.set(dir.subarray(insertAt), insertAt + rec.length);
219
150
  return out;
220
151
  }
221
- // Remove one module's MODULE record block from a decompressed `dir` stream, and decrement MODULES_COUNT.
222
- // A block runs from its MODULE_NAME record (which always opens the block — mirrors buildModuleDirRecord's
223
- // emission order) through its own MODULE_TERMINATOR, identified by matching MODULE_STREAMNAME against
224
- // `streamName`. Every other record — PROJECTREFERENCES, other modules, project-level fields — is carried
225
- // through untouched.
226
152
  function removeModuleDirRecord(dir, streamName, codePage) {
227
153
  const decoder = decoderForCodePage(codePage);
228
154
  let countAt = -1;
@@ -241,7 +167,7 @@ function removeModuleDirRecord(dir, streamName, codePage) {
241
167
  }
242
168
  pos = dataStart + size;
243
169
  if (id === REC_PROJECT_VERSION)
244
- pos += 2; // uncounted VersionMinor (u16)
170
+ pos += 2;
245
171
  if (id === REC_MODULES_COUNT) {
246
172
  if (size < 2)
247
173
  throw new VbaParseError('PROJECTMODULES MODULES_COUNT record is malformed');
@@ -267,7 +193,6 @@ function removeModuleDirRecord(dir, streamName, codePage) {
267
193
  if (removeStart < 0 || removeEnd < 0) {
268
194
  throw new VbaParseError(`module stream '${streamName}' not found in the dir stream`);
269
195
  }
270
- // MODULES_COUNT always precedes every module block, so countAt is unaffected by removing bytes after it.
271
196
  const out = new Uint8Array(dir.length - (removeEnd - removeStart));
272
197
  out.set(dir.subarray(0, removeStart), 0);
273
198
  out.set(dir.subarray(removeEnd), removeStart);
@@ -276,8 +201,6 @@ function removeModuleDirRecord(dir, streamName, codePage) {
276
201
  out[countAt + 1] = (newCount >> 8) & 0xff;
277
202
  return out;
278
203
  }
279
- // Remove a module's declaration line (`Module=`/`Class=`) and its workspace line from the `PROJECT` text
280
- // stream — the inverse of insertProjectStreamLines. Every other line is left exactly as it was.
281
204
  function removeProjectStreamLines(text, name, kind) {
282
205
  const eol = text.includes('\r\n') ? '\r\n' : '\n';
283
206
  const lines = text.split(/\r\n|\r|\n/);
@@ -299,9 +222,6 @@ function removeProjectStreamLines(text, name, kind) {
299
222
  }
300
223
  return lines.join(eol);
301
224
  }
302
- // Remove a module's (MBCS name, UTF-16 name) pair from the binary PROJECTwm stream — the inverse of
303
- // insertProjectwmRecord. `existingModuleCount` (from the already fail-closed-parsed project, before
304
- // removal) bounds the walk to the module records, so it never mistakes the terminator for a record.
305
225
  function removeProjectwmRecord(wm, existingModuleCount, name, decoder) {
306
226
  let pos = 0;
307
227
  let removeStart = -1;
@@ -333,9 +253,6 @@ function removeProjectwmRecord(wm, existingModuleCount, name, decoder) {
333
253
  out.set(wm.subarray(removeEnd), removeStart);
334
254
  return out;
335
255
  }
336
- // Remove the first direct child stream named `streamName` from the first storage named `storageName`
337
- // found in the tree (depth-first), marking `storageName` in `removed` once done. The inverse of
338
- // insertIntoStorage.
339
256
  function removeFromStorage(nodes, storageName, streamName, removed) {
340
257
  return nodes.map((n) => {
341
258
  if ('data' in n)
@@ -350,8 +267,6 @@ function removeFromStorage(nodes, storageName, streamName, removed) {
350
267
  return { name: n.name, children };
351
268
  });
352
269
  }
353
- // Rebuild the node tree, swapping any stream whose name has a replacement. Non-stream nodes (storages)
354
- // recurse; everything without a replacement is carried through byte-for-byte.
355
270
  function replaceStreams(nodes, replacements, applied) {
356
271
  return nodes.map((node) => {
357
272
  if ('data' in node) {
@@ -1,41 +1,18 @@
1
- // Turn a raw `vbaProject.bin` into readable module source.
2
- //
3
- // Pipeline ([MS-OVBA] 2.3.4.2): the CFB container holds a `VBA/dir` stream (itself MS-OVBA compressed)
4
- // that lists each module's stream name, its code page, and the byte offset in that module's stream
5
- // where the compressed source text begins — past the p-code / PerformanceCache. We decompress `dir`,
6
- // read those facts, then decompress each module stream from its text offset and decode with the
7
- // project code page. The p-code is version-specific and deliberately not exposed; a reader wants source.
8
1
  import { CompoundFile } from './cfb.js';
9
2
  import { decoderForCodePage } from './codepage.js';
10
3
  import { VbaParseError } from './errors.js';
11
4
  import { decompressContainer } from './ms-ovba.js';
12
- // The OPC packaging identity of a workbook's VBA project — the relationship Type URI Excel wires it
13
- // under, the content type its `.bin` part declares, and the canonical package path. These are the
14
- // facts an authoring path needs to attach a macro blob so the writer emits a valid macro-enabled
15
- // package; the reader matches the same relationship type by suffix (`isPreservedWorkbookRelType`).
16
5
  export const VBA_PROJECT_REL_TYPE = 'http://schemas.microsoft.com/office/2006/relationships/vbaProject';
17
6
  export const VBA_PROJECT_CONTENT_TYPE = 'application/vnd.ms-office.vbaProject';
18
7
  export const VBA_PROJECT_PART_PATH = 'xl/vbaProject.bin';
19
- // A signature part is matched by the FINAL segment of its relationship Type, not the whole URI: the
20
- // segment (`vbaProjectSignature{,Agile,V3}`) names the generation, while the year the URI carries
21
- // (2006 / 2014 / 2020) is incidental and varies. The closure walk already carries any such part
22
- // through verbatim regardless of type, so recognising the generation token is all detection needs —
23
- // no exact URI to pin, and a future scheme this map does not know stays preserved but unreported.
24
8
  const SIGNATURE_KIND_BY_REL_SEGMENT = {
25
9
  vbaProjectSignature: 'legacy',
26
10
  vbaProjectSignatureAgile: 'agile',
27
11
  vbaProjectSignatureV3: 'v3',
28
12
  };
29
- /**
30
- * The VBA-signature generation a relationship Type names, or `undefined` if it is not a signature
31
- * relationship. Keys off the Type's final path segment, so it is independent of the year the URI
32
- * carries (`.../office/2006/...` vs `.../2014/...` vs `.../2020/...`).
33
- */
34
13
  export function vbaProjectSignatureKind(relType) {
35
14
  return SIGNATURE_KIND_BY_REL_SEGMENT[relType.slice(relType.lastIndexOf('/') + 1)];
36
15
  }
37
- // `dir`-stream record ids we consume ([MS-OVBA] 2.3.4.2). Every other record is skipped by the uniform
38
- // TLV walk; its Size field already accounts for its payload, so skipping is just advancing past it.
39
16
  const REC_PROJECT_CODEPAGE = 0x0003;
40
17
  const REC_MODULE_NAME = 0x0019;
41
18
  const REC_MODULE_STREAMNAME = 0x001a;
@@ -43,9 +20,6 @@ const REC_MODULE_TYPE_PROCEDURAL = 0x0021;
43
20
  const REC_MODULE_TYPE_DOCUMENT = 0x0022;
44
21
  const REC_MODULE_OFFSET = 0x0031;
45
22
  const REC_MODULE_TERMINATOR = 0x002b;
46
- // PROJECTVERSION carries VersionMajor (u32) + VersionMinor (u16) after its fixed Size=4 field, but Size
47
- // only accounts for the 4-byte major. The extra 2-byte minor is uncounted, so a uniform TLV walk
48
- // misaligns here by 2 bytes unless it is skipped explicitly.
49
23
  const REC_PROJECT_VERSION = 0x0009;
50
24
  export function parseVbaProject(bin) {
51
25
  const cfb = new CompoundFile(bin);
@@ -53,10 +27,9 @@ export function parseVbaProject(bin) {
53
27
  if (!dirCompressed)
54
28
  throw new VbaParseError("VBA project has no 'dir' stream");
55
29
  const dir = decompressContainer(dirCompressed);
56
- let codePage = 1252; // Western-European default until PROJECTCODEPAGE says otherwise.
30
+ let codePage = 1252;
57
31
  const rawModules = [];
58
32
  let pending = {};
59
- // The dir stream is a flat sequence of TLV records: Id(u16) Size(u32) data[Size].
60
33
  let pos = 0;
61
34
  while (pos + 6 <= dir.length) {
62
35
  const id = readU16(dir, pos);
@@ -66,7 +39,7 @@ export function parseVbaProject(bin) {
66
39
  throw new VbaParseError(`dir record 0x${id.toString(16)} overruns stream`);
67
40
  pos = dataStart + size;
68
41
  if (id === REC_PROJECT_VERSION)
69
- pos += 2; // uncounted VersionMinor (u16)
42
+ pos += 2;
70
43
  switch (id) {
71
44
  case REC_PROJECT_CODEPAGE:
72
45
  if (size >= 2)
@@ -118,10 +91,6 @@ function readModuleSource(cfb, streamName, textOffset, decoder) {
118
91
  throw new VbaParseError(`module stream '${streamName}' not found in container`);
119
92
  return decoder.decode(decompressContainer(stream, textOffset));
120
93
  }
121
- // The `PROJECT` stream (uncounted, plain MBCS text) declares each module's exact kind by keyword:
122
- // `Document=`, `Module=` (procedural .bas), `Class=`, `BaseClass=` (designer/UserForm). The `dir`
123
- // stream's MODULETYPE only distinguishes procedural from non-procedural, so PROJECT refines it. Absent
124
- // or unparsable PROJECT just falls back to that coarser MODULETYPE classification.
125
94
  function readProjectStreamKinds(cfb, decoder) {
126
95
  const kinds = new Map();
127
96
  const stream = cfb.readStream('PROJECT');
@@ -141,7 +110,6 @@ function readProjectStreamKinds(cfb, decoder) {
141
110
  const kind = keyword[line.slice(0, eq)];
142
111
  if (kind === undefined)
143
112
  continue;
144
- // Value is `Name` or `Name/&H00000000` (document modules carry a cookie); take the name.
145
113
  const name = (line.slice(eq + 1).split('/')[0] ?? '').trim();
146
114
  if (name)
147
115
  kinds.set(name, kind);
@@ -1,26 +1,11 @@
1
- // Shared low-level encoders for the VBA `dir` stream and name validation — the primitives the structural
2
- // splices in `project-editor.ts` build on. ([MS-OVBA] 2.3.4.2 record TLVs, and the VBA identifier rules.)
3
- //
4
- // There is no from-scratch `vbaProject.bin` synthesizer here. Excel does not recompile VBA from source
5
- // on open — a module runs the compiled p-code it ships — so authoring/editing module SOURCE is done by
6
- // the offline `tools/vba-compiler` (VBIDE), which produces genuinely compiled p-code. This module holds
7
- // only what the pure-TS structural edits (remove module, add reference) still need (ADR 0019).
8
1
  import { VbaAuthorError } from './errors.js';
9
2
  const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_]*$/;
10
- const MAX_NAME_CHARS = 31; // the CFB stream-name limit, and VBA's own module-name limit
11
- /**
12
- * Validate a module, project, or reference name against the shared VBA identifier contract: a valid
13
- * identifier, at most 31 characters (the CFB stream-name limit, which doubles as VBA's own module-name
14
- * limit). Used by {@link project-editor.ts | project-editor}'s structural edits.
15
- *
16
- * @throws {VbaAuthorError} if `name` is not a valid VBA identifier or exceeds 31 characters.
17
- */
3
+ const MAX_NAME_CHARS = 31;
18
4
  export function validateVbaName(name, what) {
19
5
  if (!IDENTIFIER.test(name) || name.length > MAX_NAME_CHARS) {
20
6
  throw new VbaAuthorError(`invalid ${what} name '${name}' (must be a VBA identifier ≤ 31 chars)`);
21
7
  }
22
8
  }
23
- /** Append one `dir`-stream TLV record (Id, Size, data) to `out`. */
24
9
  export function push(out, id, data) {
25
10
  out.push(...u16(id), ...u32(data.length), ...data);
26
11
  }
@@ -30,7 +15,6 @@ export function u16(n) {
30
15
  export function u32(n) {
31
16
  return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff];
32
17
  }
33
- /** A name as NUL-free UTF-16LE code units — the encoding [MS-OVBA] uses for every "Unicode" name field. */
34
18
  export function utf16le(s) {
35
19
  const out = [];
36
20
  for (let i = 0; i < s.length; i++)
@@ -1,14 +1,4 @@
1
1
  import { XlsxError } from '../errors.js';
2
- /**
3
- * Thrown when XML text cannot be scanned into parse events — an unterminated tag, comment, CDATA
4
- * section, markup declaration or processing instruction.
5
- *
6
- * This is a *typed* failure rather than the native `SyntaxError` it reads like, because the text
7
- * being parsed is almost always a part lifted out of an untrusted `.xlsx`. A caller wrapping
8
- * `readXlsx` needs to tell "the file I was handed is corrupt" from "something in my own code threw a
9
- * `SyntaxError`", and a native error gives them no way to. The message still names the construct that
10
- * did not terminate; only the type changed.
11
- */
12
2
  export class XmlParseError extends XlsxError {
13
3
  name = 'XmlParseError';
14
4
  code = 'malformed-input';
@@ -1,15 +1,3 @@
1
- // A lean, hostile-input-safe SAX pull parser for the read path.
2
- //
3
- // OOXML uses a small, regular subset of XML, so the reader does not need — and must not
4
- // pay for — a general-purpose DOM library (see ADR 0004). This scans the source in a
5
- // single O(n) pass with no recursion, emitting open/text/close events; the OOXML reader
6
- // consumes them and builds only the model, so peak memory tracks real content rather
7
- // than document structure.
8
- //
9
- // Security posture: entities are *decoded, never expanded*. Only the five predefined
10
- // entities and numeric character references are recognised; DTDs and `<!ENTITY>`
11
- // definitions are skipped, so entity-expansion (billion-laughs) and external-entity
12
- // (XXE) attacks are structurally impossible here, not merely mitigated.
13
1
  import { XmlParseError } from './errors.js';
14
2
  const PREDEFINED_ENTITIES = {
15
3
  amp: '&',
@@ -19,18 +7,12 @@ const PREDEFINED_ENTITIES = {
19
7
  apos: "'",
20
8
  };
21
9
  const ENTITY = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g;
22
- /**
23
- * Decode XML character references and the five predefined entities. An unrecognised
24
- * `&name;` is left verbatim rather than expanded — there is no DTD, so there is nothing
25
- * to expand it to, and refusing to invent one is what makes entity-expansion attacks
26
- * impossible.
27
- */
28
10
  export function decodeEntities(value) {
29
11
  if (!value.includes('&'))
30
12
  return value;
31
13
  return value.replace(ENTITY, (match, body) => {
32
- if (body.charCodeAt(0) === 0x23 /* # */) {
33
- const codePoint = body.charCodeAt(1) === 0x78 /* x */
14
+ if (body.charCodeAt(0) === 0x23) {
15
+ const codePoint = body.charCodeAt(1) === 0x78
34
16
  ? parseInt(body.slice(2), 16)
35
17
  : parseInt(body.slice(1), 10);
36
18
  if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
@@ -46,9 +28,6 @@ export function decodeEntities(value) {
46
28
  return named ?? match;
47
29
  });
48
30
  }
49
- // Attribute values cannot contain their own delimiter and cannot contain a literal `<`,
50
- // so a delimiter-respecting scan finds a tag's end even when an attribute value holds a
51
- // `>` (legal but rare). Names may carry a namespace prefix (`r:id`, `xml:space`).
52
31
  const ATTRIBUTE = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
53
32
  function parseAttributes(source) {
54
33
  const attrs = {};
@@ -61,8 +40,6 @@ function parseAttributes(source) {
61
40
  }
62
41
  return attrs;
63
42
  }
64
- // Scan to the tag's closing `>`, honouring quoted attribute values so a `>` inside a
65
- // value does not end the tag prematurely.
66
43
  function findTagEnd(source, start) {
67
44
  let quote = '';
68
45
  for (let i = start + 1; i < source.length; i++) {
@@ -80,8 +57,6 @@ function findTagEnd(source, start) {
80
57
  }
81
58
  throw new XmlParseError('unterminated tag: missing ">"');
82
59
  }
83
- // A `<!DOCTYPE …>` may contain a bracketed internal subset with its own `>`; balance the
84
- // brackets so the declaration is skipped whole. We never act on its contents.
85
60
  function skipDeclaration(source, start) {
86
61
  let depth = 0;
87
62
  for (let i = start; i < source.length; i++) {
@@ -95,14 +70,6 @@ function skipDeclaration(source, start) {
95
70
  }
96
71
  throw new XmlParseError('unterminated markup declaration: missing ">"');
97
72
  }
98
- /**
99
- * Scan an XML document as a *pull* stream of {@link XmlEvent}s in a single O(n) pass with no
100
- * recursion. This is the parser's core; {@link parseXml} is a thin push adapter over it. A
101
- * consumer that must produce output incrementally (the streaming row reader) pulls events and
102
- * yields as it goes, holding only its own running state — a push callback cannot.
103
- *
104
- * Throws {@link XmlParseError} on malformed markup.
105
- */
106
73
  export function* xmlEvents(source) {
107
74
  const length = source.length;
108
75
  let i = 0;
@@ -147,7 +114,7 @@ export function* xmlEvents(source) {
147
114
  }
148
115
  const gt = findTagEnd(source, lt);
149
116
  const raw = source.slice(lt + 1, gt);
150
- if (raw.charCodeAt(0) === 0x2f /* / */) {
117
+ if (raw.charCodeAt(0) === 0x2f) {
151
118
  yield { kind: 'close', name: raw.slice(1).trim() };
152
119
  }
153
120
  else {
@@ -161,15 +128,6 @@ export function* xmlEvents(source) {
161
128
  i = gt + 1;
162
129
  }
163
130
  }
164
- /**
165
- * Yield each element start in `source` as an {@link OpenElement}, optionally restricted to the given
166
- * local names. This is the pull shape for the ubiquitous "scan opens, read attributes" pass: a caller
167
- * writes a plain `for..of` and reads `attrs` directly, instead of threading a mutable accumulator out
168
- * through a {@link parseXml} `onOpen` closure. With no names every start is yielded; with one or more,
169
- * only starts whose namespace-stripped name matches one. Text and close events are skipped.
170
- *
171
- * Throws {@link XmlParseError} on malformed markup.
172
- */
173
131
  export function* openElements(source, ...localNames) {
174
132
  const filter = localNames.length > 0 ? new Set(localNames) : undefined;
175
133
  for (const event of xmlEvents(source)) {
@@ -181,15 +139,6 @@ export function* openElements(source, ...localNames) {
181
139
  yield { name: event.name, local, attrs: event.attrs };
182
140
  }
183
141
  }
184
- /**
185
- * Wrap an {@link XmlEvent} stream so a self-closing `<x/>` whose local name is in `names` is
186
- * presented as an open (with `selfClosing: false`) immediately followed by a close — the exact
187
- * event shape of `<x></x>`. This lets a consumer commit such an element from its close handling
188
- * alone, instead of hand-coding a parallel self-closing branch: {@link xmlEvents} fires no close
189
- * for `<x/>`, and forgetting that branch silently drops the empty element. Names not in the set
190
- * pass through untouched, so an element whose close would wrongly act on absent content — an empty
191
- * `<v/>`/`<f/>` that must not commit captured text — is left as a bare self-closing open.
192
- */
193
142
  export function* closeEmptyElements(events, names) {
194
143
  for (const event of events) {
195
144
  if (event.kind === 'open' && event.selfClosing && names.has(localName(event.name))) {
@@ -201,11 +150,6 @@ export function* closeEmptyElements(events, names) {
201
150
  }
202
151
  }
203
152
  }
204
- /**
205
- * Parse an XML document, dispatching SAX events to `handlers`. A thin push adapter over
206
- * {@link xmlEvents} — one scanning core serves both the callback and the pull consumers.
207
- * Throws {@link XmlParseError} on malformed markup.
208
- */
209
153
  export function parseXml(source, handlers, options) {
210
154
  const events = options?.closeEmptyElements
211
155
  ? closeEmptyElements(xmlEvents(source), options.closeEmptyElements)
@@ -224,11 +168,6 @@ export function parseXml(source, handlers, options) {
224
168
  }
225
169
  }
226
170
  }
227
- // XML end-of-line handling (spec §2.11): a literal CRLF or lone CR in character data is
228
- // normalized to a single LF, so a value's in-cell line breaks read back identically whatever
229
- // newline convention the producer wrote. Normalization precedes entity decoding, so a
230
- // deliberately-encoded carriage return (&#13;) survives it — the escape hatch for a real CR.
231
- // CDATA is delivered verbatim (it bypasses this), matching the reader's CDATA contract.
232
171
  function normalizeLineEndings(chunk) {
233
172
  if (!chunk.includes('\r'))
234
173
  return chunk;
@@ -242,29 +181,16 @@ function firstWhitespace(source) {
242
181
  }
243
182
  return -1;
244
183
  }
245
- /** Strip a namespace prefix from a qualified name (`r:id` → `id`, `sheet` → `sheet`). */
246
184
  export function localName(qualified) {
247
185
  const colon = qualified.indexOf(':');
248
186
  return colon === -1 ? qualified : qualified.slice(colon + 1);
249
187
  }
250
- // OOXML spells booleans three ways, and the reader needs all three. A `<b/>`-style font flag
251
- // defaults to on when present with no value, so its absence is meaningful (`boolPresent`). Most
252
- // attributes are plain xsd:booleans that are off unless an explicit "1"/"true" turns them on
253
- // (`boolStrict`). An optional attribute that must round-trip byte-clean has to distinguish absent
254
- // from present-and-false and drop an unrecognised token rather than coerce it (`boolTristate`).
255
- /** An OOXML boolean that is on when present with no value (`<b/>` is bold) and off only on an
256
- * explicit `"0"`/`"false"`; absence reads as on. */
257
188
  export function boolPresent(val) {
258
189
  return val === undefined || (val !== '0' && val !== 'false');
259
190
  }
260
- /** An OOXML boolean that is on only when explicitly `"1"`/`"true"`; anything else — including
261
- * absence and a truthy-looking `"0"` — is off. */
262
191
  export function boolStrict(val) {
263
192
  return val === '1' || val === 'true';
264
193
  }
265
- /** An optional OOXML boolean: `undefined` when the attribute is absent or carries an unrecognised
266
- * token, otherwise its `"1"`/`"true"` vs `"0"`/`"false"` value. Lets a caller store only the
267
- * attributes the source actually carried, so a re-write stays byte-clean. */
268
194
  export function boolTristate(val) {
269
195
  if (val === '1' || val === 'true')
270
196
  return true;
@@ -272,11 +198,6 @@ export function boolTristate(val) {
272
198
  return false;
273
199
  return undefined;
274
200
  }
275
- /** Read an operand's text as a number only when it is a canonical decimal literal (optional sign,
276
- * digits, optional fraction). A cell reference, defined name, expression, or exotically-spelled
277
- * number (`1E5`, hex) keeps its verbatim text, so it is neither coerced to `NaN` and lost nor
278
- * re-spelled into a number that would not re-write byte-clean. Callers layer their own type rules
279
- * (a data-validation `list`/`custom` operand stays a string regardless of what it looks like). */
280
201
  export function coerceNumericLiteral(text) {
281
202
  const trimmed = text.trim();
282
203
  return /^-?\d+(?:\.\d+)?$/.test(trimmed) ? Number(trimmed) : text;
package/dist/xml/xml.js CHANGED
@@ -1,10 +1,3 @@
1
- // Minimal XML serialisation helpers for the write path.
2
- //
3
- // Writing OOXML needs only correct escaping and well-formed structure; parsing (the
4
- // reader's concern) is a separate, later decision, so no XML library is on the write
5
- // path. Escaping is the one hard, security-relevant requirement — an unescaped `<`,
6
- // `&`, or `"` produces a malformed package a consumer rejects — so it lives here,
7
- // audited once, rather than sprinkled through the part emitters.
8
1
  import { AuthoringError } from '../errors.js';
9
2
  const TEXT_ESCAPES = {
10
3
  '&': '&amp;',
@@ -21,62 +14,30 @@ const ATTR_ESCAPES = {
21
14
  '\r': '&#13;',
22
15
  '\t': '&#9;',
23
16
  };
24
- /** Escape a string for use as XML element text. */
25
17
  export function escapeText(value) {
26
18
  return value.replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
27
19
  }
28
- /** Escape a string for use inside a double-quoted XML attribute value. */
29
20
  export function escapeAttr(value) {
30
21
  return value.replace(/[&<>"'\n\r\t]/g, (ch) => ATTR_ESCAPES[ch]);
31
22
  }
32
- /**
33
- * Whether an element's text must be wrapped with `xml:space="preserve"` to survive a
34
- * round-trip. Leading/trailing whitespace is otherwise collapsed by consumers, so a
35
- * string cell value that begins or ends with a space needs the marker.
36
- */
37
23
  export function needsSpacePreserve(value) {
38
24
  return value.length > 0 && (value !== value.trim() || /[\n\r\t]/.test(value));
39
25
  }
40
- /**
41
- * A `<t>` text element carrying an escaped string, marked `xml:space="preserve"` when its
42
- * whitespace would otherwise be collapsed. Shared by every string-bearing element — a plain
43
- * inline string cell, a rich-text run — so all decode identically on the way back.
44
- */
45
26
  export function textElement(value) {
46
27
  const space = needsSpacePreserve(value) ? ' xml:space="preserve"' : '';
47
28
  return `<t${space}>${escapeText(value)}</t>`;
48
29
  }
49
- /**
50
- * Render a formula operand for serialisation: a number becomes its literal, a string is stripped of
51
- * the single optional leading '=' an author may write (OOXML stores the expression without it, e.g.
52
- * `=A1>0` on disk is `A1>0`). The result is unescaped — the caller escapes it for its target,
53
- * whether that is element text or an attribute value.
54
- */
55
30
  export function stripFormulaEquals(value) {
56
31
  if (typeof value === 'number')
57
32
  return String(value);
58
33
  return value.startsWith('=') ? value.slice(1) : value;
59
34
  }
60
- /**
61
- * A boolean attribute rendered with a leading space (` name="1"` / ` name="0"`), or '' when the value
62
- * is undefined. OOXML booleans serialise as 1/0; emitting the explicit `="0"` lets a writer force a
63
- * flag off against a consumer's default, while an unset (undefined) flag stays out of the element
64
- * entirely — the two-state-plus-absent contract every flag writer here shares.
65
- */
66
35
  export function boolAttr(name, value) {
67
36
  return value === undefined ? '' : ` ${name}="${value ? 1 : 0}"`;
68
37
  }
69
- /**
70
- * A numeric attribute rendered with a leading space (` name="42"`), or '' when the value is undefined
71
- * — so a count an author never set stays out of the element rather than fabricating a default.
72
- */
73
38
  export function attr(name, value) {
74
39
  return value === undefined ? '' : ` ${name}="${value}"`;
75
40
  }
76
- /**
77
- * A finite number serialises as its shortest round-trippable decimal; a non-finite one
78
- * has no OOXML numeric representation, so the writer refuses it rather than emit `NaN`.
79
- */
80
41
  export function numberText(value) {
81
42
  if (!Number.isFinite(value)) {
82
43
  throw new AuthoringError(`cannot write a non-finite number (${value}) — it has no OOXML representation`);