@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,31 +1,17 @@
1
- // Writer for the OLE2 / Compound File Binary format ([MS-CFB]) — the encode counterpart to cfb.ts.
2
- //
3
- // Produces a v3 (512-byte sector) container from a hierarchy of storages and streams, the substrate a
4
- // synthesized vbaProject.bin is built on (its modules live inside a `VBA` storage, with `PROJECT` and
5
- // `PROJECTwm` at the root). Streams below the 4096-byte mini cutoff are packed into the mini stream and
6
- // chained through the mini-FAT; larger streams take whole regular sectors. Each storage's children are
7
- // emitted as a name-ordered balanced binary tree ([MS-CFB] 2.6.4), so a host that *navigates* the tree
8
- // (Excel) reaches every entry — not only a linear scanner like this library's own reader.
9
- //
10
- // Unlike the reader, this is not a hostile-input path: we are the producer. It still validates its
11
- // contract (name length, sibling-name uniqueness, size bound) and fails closed with VbaAuthorError,
12
- // because a silently malformed container would surface far downstream as an unopenable workbook.
13
1
  import { VbaAuthorError } from './errors.js';
14
2
  const SECTOR = 512;
15
3
  const MINI_SECTOR = 64;
16
4
  const MINI_CUTOFF = 4096;
17
5
  const DIR_ENTRY_SIZE = 128;
18
- const ENTRIES_PER_DIR_SECTOR = SECTOR / DIR_ENTRY_SIZE; // 4
19
- const FAT_ENTRIES_PER_SECTOR = SECTOR / 4; // 128
20
- const DIFAT_HEADER_SLOTS = 109; // FAT-sector pointers that fit in the header before DIFAT sectors
21
- const MAX_NAME_CHARS = 31; // 32 UTF-16 code units incl. the NUL terminator
22
- // Sector chain markers ([MS-CFB] 2.2).
6
+ const ENTRIES_PER_DIR_SECTOR = SECTOR / DIR_ENTRY_SIZE;
7
+ const FAT_ENTRIES_PER_SECTOR = SECTOR / 4;
8
+ const DIFAT_HEADER_SLOTS = 109;
9
+ const MAX_NAME_CHARS = 31;
23
10
  const FREESECT = 0xffffffff;
24
11
  const ENDOFCHAIN = 0xfffffffe;
25
12
  const FATSECT = 0xfffffffd;
26
13
  const DIFSECT = 0xfffffffc;
27
14
  const NOSTREAM = 0xffffffff;
28
- // Object types ([MS-CFB] 2.6.1).
29
15
  const TYPE_STORAGE = 1;
30
16
  const TYPE_STREAM = 2;
31
17
  const TYPE_ROOT = 5;
@@ -33,14 +19,6 @@ const COLOR_BLACK = 1;
33
19
  function isStream(node) {
34
20
  return 'data' in node;
35
21
  }
36
- /**
37
- * Encode a hierarchy of storages and streams into a v3 compound file. The Root Entry is synthesized
38
- * automatically; `root` is its top-level children. Every stream becomes a directory entry reachable both
39
- * by linear scan and by tree navigation.
40
- *
41
- * @throws {VbaAuthorError} if any name is empty or exceeds 31 characters, sibling names collide, or the
42
- * project is so large it would need more than 109 FAT sectors (~7 MB — far beyond any real project).
43
- */
44
22
  export function writeCompoundFile(root) {
45
23
  const entries = [];
46
24
  const addEntry = (name, type, startSector) => {
@@ -58,20 +36,15 @@ export function writeCompoundFile(root) {
58
36
  return entry;
59
37
  };
60
38
  const rootEntry = addEntry('Root Entry', TYPE_ROOT, ENDOFCHAIN);
61
- // Sub-cutoff stream bytes accumulate into the mini stream (chained in the mini-FAT); larger streams
62
- // are laid out later directly in the regular FAT. Depth-first walk fixes a deterministic layout.
63
39
  const miniBytes = [];
64
40
  const miniFat = [];
65
41
  const bigStreams = [];
66
- // `siblings` is the parent's child list itself rather than its index, so a child is appended to an
67
- // array we hold — no lookup that could come back empty.
68
42
  const addNode = (node, siblings) => {
69
43
  if (isStream(node)) {
70
44
  const entry = addEntry(node.name, TYPE_STREAM, ENDOFCHAIN);
71
45
  entry.size = node.data.length;
72
46
  siblings.push(entry);
73
47
  if (node.data.length === 0) {
74
- // an empty stream owns no sectors
75
48
  }
76
49
  else if (node.data.length >= MINI_CUTOFF) {
77
50
  bigStreams.push({ entry, data: node.data, sectors: Math.ceil(node.data.length / SECTOR) });
@@ -91,7 +64,6 @@ export function writeCompoundFile(root) {
91
64
  const kids = [];
92
65
  for (const c of node.children)
93
66
  addNode(c, kids);
94
- // Each storage (Root included) links its children as a balanced search tree the host navigates.
95
67
  linkChildren(entry, kids);
96
68
  }
97
69
  };
@@ -100,9 +72,6 @@ export function writeCompoundFile(root) {
100
72
  for (const n of root)
101
73
  addNode(n, rootKids);
102
74
  linkChildren(rootEntry, rootKids);
103
- // ── Sector layout ─────────────────────────────────────────────────────────────────────────────────
104
- // Physical order: directory, mini-FAT, mini stream, each big stream, FAT, DIFAT. Region starts are
105
- // assigned first so chains can reference them; the FAT is filled last, once every sector is placed.
106
75
  const dirSectors = Math.ceil(entries.length / ENTRIES_PER_DIR_SECTOR);
107
76
  const miniFatSectors = miniFat.length > 0 ? Math.ceil((miniFat.length * 4) / FAT_ENTRIES_PER_SECTOR) : 0;
108
77
  const miniStreamSectors = Math.ceil(miniBytes.length / SECTOR);
@@ -115,7 +84,6 @@ export function writeCompoundFile(root) {
115
84
  for (;;) {
116
85
  const total = baseSectors + fatSectors + difatSectors;
117
86
  const needFat = Math.ceil(total / FAT_ENTRIES_PER_SECTOR);
118
- // Each DIFAT sector holds 127 FAT pointers + a next-DIFAT pointer; the first 109 live in the header.
119
87
  const needDifat = needFat > DIFAT_HEADER_SLOTS
120
88
  ? Math.ceil((needFat - DIFAT_HEADER_SLOTS) / (FAT_ENTRIES_PER_SECTOR - 1))
121
89
  : 0;
@@ -145,7 +113,6 @@ export function writeCompoundFile(root) {
145
113
  const totalSectors = cursor;
146
114
  rootEntry.startSector = miniStreamStart;
147
115
  rootEntry.size = miniBytes.length;
148
- // ── FAT ─────────────────────────────────────────────────────────────────────────────────────────
149
116
  const fat = new Array(fatSectors * FAT_ENTRIES_PER_SECTOR).fill(FREESECT);
150
117
  const chainRegion = (start, count) => {
151
118
  for (let k = 0; k < count; k++)
@@ -162,8 +129,7 @@ export function writeCompoundFile(root) {
162
129
  fat[fatStart + k] = FATSECT;
163
130
  for (let k = 0; k < difatSectors; k++)
164
131
  fat[difatStart + k] = DIFSECT;
165
- // ── Serialize ──────────────────────────────────────────────────────────────────────────────────
166
- const buf = new Uint8Array((totalSectors + 1) * SECTOR); // +1 for the header sector
132
+ const buf = new Uint8Array((totalSectors + 1) * SECTOR);
167
133
  const dv = new DataView(buf.buffer);
168
134
  const at = (sector) => (sector + 1) * SECTOR;
169
135
  writeHeader(dv, {
@@ -180,16 +146,12 @@ export function writeCompoundFile(root) {
180
146
  for (let i = 0; i < miniFatSectors * FAT_ENTRIES_PER_SECTOR; i++) {
181
147
  dv.setUint32(at(miniFatStart) + i * 4, miniFat[i] ?? FREESECT, true);
182
148
  }
183
- // Guarded rather than looped-and-skipped: with no mini stream, miniStreamStart is ENDOFCHAIN and
184
- // `at()` of it is far outside the buffer, which a zero-length `set` would still reject.
185
149
  if (miniBytes.length > 0)
186
150
  buf.set(Uint8Array.from(miniBytes), at(miniStreamStart));
187
151
  for (const big of bigStreams)
188
152
  buf.set(big.data, at(big.entry.startSector));
189
153
  for (const [i, sector] of fat.entries())
190
154
  dv.setUint32(at(fatStart) + i * 4, sector, true);
191
- // DIFAT sectors carry FAT-sector pointers 110.. (unreachable under the enforced bound, but the layout
192
- // is honoured: each DIFAT sector's tail points to the next, the last to ENDOFCHAIN).
193
155
  for (let d = 0; d < difatSectors; d++) {
194
156
  const base = at(difatStart + d);
195
157
  for (let i = 0; i < FAT_ENTRIES_PER_SECTOR - 1; i++) {
@@ -216,13 +178,6 @@ function validateSiblingNames(storageName, siblings) {
216
178
  validateSiblingNames(node.name, node.children);
217
179
  }
218
180
  }
219
- // Order a storage's children by the [MS-CFB] 2.6.4 comparison (shorter names sort first; ties broken by
220
- // uppercased UTF-16 code units) and link them as a balanced binary tree. A host locates a child by
221
- // walking this tree from the storage's `child` pointer, so the ordering and links must form a valid
222
- // search tree.
223
- //
224
- // The recursion halves a slice rather than a lo/hi index pair, which makes "the slice is empty" and
225
- // "there is no midpoint entry" the same observable fact: the `undefined` check *is* the base case.
226
181
  function linkChildren(storage, kids) {
227
182
  const build = (nodes) => {
228
183
  const mid = nodes.length >> 1;
@@ -243,13 +198,13 @@ function compareNames(a, b) {
243
198
  return ua < ub ? -1 : ua > ub ? 1 : 0;
244
199
  }
245
200
  function writeHeader(dv, p) {
246
- dv.setUint32(0, 0xe011cfd0, true); // OLE2 signature (lo/hi)
201
+ dv.setUint32(0, 0xe011cfd0, true);
247
202
  dv.setUint32(4, 0xe11ab1a1, true);
248
- dv.setUint16(24, 0x003e, true); // minor version
249
- dv.setUint16(26, 0x0003, true); // major version → v3 (512-byte sectors)
250
- dv.setUint16(28, 0xfffe, true); // little-endian byte order mark
251
- dv.setUint16(30, 9, true); // sector shift → 512
252
- dv.setUint16(32, 6, true); // mini-sector shift → 64
203
+ dv.setUint16(24, 0x003e, true);
204
+ dv.setUint16(26, 0x0003, true);
205
+ dv.setUint16(28, 0xfffe, true);
206
+ dv.setUint16(30, 9, true);
207
+ dv.setUint16(32, 6, true);
253
208
  dv.setUint32(44, p.fatSectors, true);
254
209
  dv.setUint32(48, p.dirStart, true);
255
210
  dv.setUint32(56, MINI_CUTOFF, true);
@@ -257,7 +212,6 @@ function writeHeader(dv, p) {
257
212
  dv.setUint32(64, p.miniFatSectors, true);
258
213
  dv.setUint32(68, p.difatSectors > 0 ? p.difatStart : ENDOFCHAIN, true);
259
214
  dv.setUint32(72, p.difatSectors, true);
260
- // Header DIFAT: the first 109 FAT-sector pointers. Contiguous from fatStart under the enforced bound.
261
215
  for (let i = 0; i < DIFAT_HEADER_SLOTS; i++) {
262
216
  dv.setUint32(76 + i * 4, i < p.fatSectors ? p.fatStart + i : FREESECT, true);
263
217
  }
@@ -265,7 +219,7 @@ function writeHeader(dv, p) {
265
219
  function writeDirEntry(dv, off, e) {
266
220
  for (let i = 0; i < e.name.length; i++)
267
221
  dv.setUint16(off + i * 2, e.name.charCodeAt(i), true);
268
- dv.setUint16(off + 64, (e.name.length + 1) * 2, true); // name byte length incl. NUL terminator
222
+ dv.setUint16(off + 64, (e.name.length + 1) * 2, true);
269
223
  dv.setUint8(off + 66, e.type);
270
224
  dv.setUint8(off + 67, COLOR_BLACK);
271
225
  dv.setUint32(off + 68, e.left, true);
@@ -273,5 +227,4 @@ function writeDirEntry(dv, off, e) {
273
227
  dv.setUint32(off + 76, e.child, true);
274
228
  dv.setUint32(off + 116, e.startSector, true);
275
229
  dv.setUint32(off + 120, e.size, true);
276
- // Size high (124), CLSID (80..95), state/time fields stay zero — valid for a v3 entry.
277
230
  }
package/dist/vba/cfb.js CHANGED
@@ -1,18 +1,4 @@
1
- // Reader for the OLE2 / Compound File Binary format ([MS-CFB]).
2
- //
3
- // `vbaProject.bin` is a CFB container (the same "structured storage" behind legacy .doc/.xls). We read
4
- // streams by name and, for the edit-in-place path, reconstruct the whole storage/stream hierarchy so it
5
- // can be re-emitted through the writer with one stream swapped — so this is a deliberate subset of
6
- // [MS-CFB]: header → FAT → directory, plus the mini-FAT for sub-cutoff streams, and the red-black
7
- // sibling tree each storage navigates.
8
- //
9
- // It parses an untrusted blob, so every sector index, chain, and stream size is bounds-checked against
10
- // the file and every chain walk is cycle-guarded. A malformed container fails closed with a
11
- // VbaParseError instead of reading out of bounds, looping forever, or over-allocating.
12
1
  import { VbaParseError } from './errors.js';
13
- // Sector values 0xFFFFFFFA..0xFFFFFFFF are reserved markers (DIFSECT/FATSECT/ENDOFCHAIN/FREESECT), not
14
- // data-sector indices; any value at or above this is chain-terminal. Directory-tree links reuse the same
15
- // convention: NOSTREAM (0xFFFFFFFF) and any value at or above the ceiling mean "no such sibling/child".
16
2
  const MAX_REGULAR_SECTOR = 0xfffffffa;
17
3
  const NOSTREAM = 0xffffffff;
18
4
  const TYPE_EMPTY = 0;
@@ -43,7 +29,6 @@ export class CompoundFile {
43
29
  }
44
30
  const sectorShift = this.#u16(30);
45
31
  const miniSectorShift = this.#u16(32);
46
- // [MS-CFB] fixes these: 512-byte sectors (shift 9) for v3, 4096 (shift 12) for v4; mini shift 6.
47
32
  if (sectorShift !== 9 && sectorShift !== 12) {
48
33
  throw new VbaParseError(`unsupported sector shift ${sectorShift}`);
49
34
  }
@@ -65,28 +50,19 @@ export class CompoundFile {
65
50
  const root = this.#dir.find((e) => e.type === TYPE_ROOT);
66
51
  if (!root)
67
52
  throw new VbaParseError('compound file has no root storage entry');
68
- // The mini-stream lives in the regular FAT, addressed from the root entry's start sector.
69
53
  this.#miniStream = this.#readViaFat(root.startSector, root.size);
70
54
  }
71
- /** List every stream/storage name in the directory (order as stored). */
72
55
  names() {
73
56
  return this.#dir
74
57
  .filter((e) => e.type === TYPE_STREAM || e.type === TYPE_STORAGE)
75
58
  .map((e) => e.name);
76
59
  }
77
- /** Read a stream's raw bytes by exact entry name, or `undefined` if absent. */
78
60
  readStream(name) {
79
61
  const entry = this.#dir.find((e) => e.type === TYPE_STREAM && e.name === name);
80
62
  if (!entry)
81
63
  return undefined;
82
64
  return this.#readEntryData(entry);
83
65
  }
84
- /**
85
- * Reconstruct the container's top-level children as the writer's node shape, recursing into every
86
- * storage — so a caller can swap one stream and re-emit the whole hierarchy with {@link writeCompoundFile}.
87
- * Walks the red-black sibling tree each storage navigates (not the linear directory scan), so any part
88
- * a host reaches is carried through. Cycle- and bounds-guarded like every other chain walk here.
89
- */
90
66
  tree() {
91
67
  const rootIdx = this.#dir.findIndex((e) => e.type === TYPE_ROOT);
92
68
  if (rootIdx < 0)
@@ -98,7 +74,7 @@ export class CompoundFile {
98
74
  const nodes = [];
99
75
  const walk = (idx) => {
100
76
  if (idx >= MAX_REGULAR_SECTOR)
101
- return; // NOSTREAM / terminal marker → no such sibling
77
+ return;
102
78
  if (idx >= this.#dir.length)
103
79
  throw new VbaParseError('directory sibling index out of range');
104
80
  if (seen.has(idx))
@@ -126,7 +102,6 @@ export class CompoundFile {
126
102
  }
127
103
  #readDifat(numFatSectors, firstDifat, numDifat) {
128
104
  const ids = [];
129
- // The first 109 FAT-sector pointers live in the header; the rest chain through DIFAT sectors.
130
105
  for (let i = 0; i < 109 && ids.length < numFatSectors; i++) {
131
106
  const v = this.#u32(76 + i * 4);
132
107
  if (v >= MAX_REGULAR_SECTOR)
@@ -161,7 +136,6 @@ export class CompoundFile {
161
136
  return fat;
162
137
  }
163
138
  #readChainValues(firstSector) {
164
- // Every uint32 in a sector chain (used for the mini-FAT), cycle-guarded.
165
139
  const values = [];
166
140
  const perSector = this.#sectorSize / 4;
167
141
  const seen = new Set();
@@ -180,8 +154,6 @@ export class CompoundFile {
180
154
  #readDirectory(firstDirSector) {
181
155
  const raw = this.#readChainFull(firstDirSector);
182
156
  const entries = [];
183
- // Empty slots are kept as placeholders (not skipped) so array indices stay equal to the on-disk
184
- // directory-entry ids the sibling-tree links reference — the tree walk in #buildSiblings needs them.
185
157
  for (let off = 0; off + DIR_ENTRY_SIZE <= raw.length; off += DIR_ENTRY_SIZE) {
186
158
  const type = raw[off + 66];
187
159
  if (type !== TYPE_EMPTY &&
@@ -211,14 +183,12 @@ export class CompoundFile {
211
183
  }
212
184
  const name = decodeUtf16le(raw.subarray(off, off + Math.max(0, nameLen - 2)));
213
185
  const startSector = readU32(raw, off + 116);
214
- const size = readU32(raw, off + 120); // low 32 bits — ample for a VBA project
186
+ const size = readU32(raw, off + 120);
215
187
  entries.push({ name, type, startSector, size, left, right, child });
216
188
  }
217
189
  return entries;
218
190
  }
219
191
  #readChainFull(startSector) {
220
- // Follow a FAT chain to its end, collecting whole sectors (used for the directory, whose byte
221
- // length is not declared). Cycle-guarded and bounded by the sector count.
222
192
  const chunks = [];
223
193
  const seen = new Set();
224
194
  let sector = startSector;
@@ -1,11 +1,4 @@
1
- // The VBA project stores module names and source as MBCS bytes in the project code page
2
- // (`PROJECTCODEPAGE`, [MS-OVBA] 2.3.4.2). To recover text we decode those bytes with the matching
3
- // encoding rather than assuming latin1 — a CJK or Cyrillic project would otherwise mojibake.
4
1
  import { VbaAuthorError, VbaParseError } from './errors.js';
5
- // Windows code-page numbers → the WHATWG encoding label `TextDecoder` understands. Node's built-in
6
- // ICU covers all of these. Only the code pages a VBA project realistically declares are listed; an
7
- // unlisted one falls back to windows-1252 (the overwhelmingly common Western-European default) so a
8
- // rare project still decodes readably instead of throwing.
9
2
  const CODEPAGE_LABEL = new Map([
10
3
  [1250, 'windows-1250'],
11
4
  [1251, 'windows-1251'],
@@ -28,19 +21,12 @@ const CODEPAGE_LABEL = new Map([
28
21
  [28592, 'iso-8859-2'],
29
22
  [65001, 'utf-8'],
30
23
  ]);
31
- /**
32
- * A `TextDecoder` for the given VBA project code page. Non-fatal (malformed bytes become U+FFFD rather
33
- * than throwing) because recovered source is for reading, not re-encoding — a stray byte must not sink
34
- * the whole extraction. An unknown code page falls back to windows-1252.
35
- */
36
24
  export function decoderForCodePage(codePage) {
37
25
  const label = CODEPAGE_LABEL.get(codePage) ?? 'windows-1252';
38
26
  try {
39
27
  return new TextDecoder(label, { fatal: false });
40
28
  }
41
29
  catch {
42
- // A runtime whose ICU lacks the label (a minimal build) still gets usable Western-European text
43
- // rather than a hard failure on an otherwise-valid project.
44
30
  try {
45
31
  return new TextDecoder('windows-1252', { fatal: false });
46
32
  }
@@ -51,14 +37,6 @@ export function decoderForCodePage(codePage) {
51
37
  }
52
38
  }
53
39
  }
54
- /**
55
- * The write-side inverse of {@link decoderForCodePage}: an encoder for a project's code page, built by
56
- * decoding every byte 0x00..0xFF through the matching decoder and inverting the map. This is exact for
57
- * the single-byte code pages a VBA project realistically declares. Under a multi-byte code page (Shift
58
- * JIS, GBK, …) the high bytes are lead bytes, not standalone characters, so only ASCII inverts and any
59
- * non-ASCII character is rejected — fail-closed rather than emit a corrupt byte. A character the chosen
60
- * code page cannot represent throws {@link VbaAuthorError}.
61
- */
62
40
  export function encoderForCodePage(codePage) {
63
41
  const decoder = decoderForCodePage(codePage);
64
42
  const charToByte = new Map();
@@ -67,7 +45,7 @@ export function encoderForCodePage(codePage) {
67
45
  one[0] = b;
68
46
  const decoded = decoder.decode(one);
69
47
  if (decoded.length !== 1)
70
- continue; // a lead byte / incomplete sequence under a multi-byte code page
48
+ continue;
71
49
  const code = decoded.charCodeAt(0);
72
50
  if (code !== 0xfffd && !charToByte.has(code))
73
51
  charToByte.set(code, b);
@@ -1,23 +1,8 @@
1
1
  import { XlsxError } from '../errors.js';
2
- /**
3
- * Thrown when a VBA project (`vbaProject.bin`) is present but cannot be parsed — a malformed
4
- * compound file, a corrupt compressed stream, or a `dir` record that does not conform to [MS-OVBA].
5
- * A workbook with no macros never produces this: {@link Workbook.vbaProject} is `undefined` instead.
6
- *
7
- * The parser treats the blob as hostile input (a spreadsheet library parses untrusted files), so every
8
- * malformed structure fails closed with this error rather than crashing, hanging, or over-allocating.
9
- */
10
2
  export class VbaParseError extends XlsxError {
11
3
  name = 'VbaParseError';
12
4
  code = 'malformed-input';
13
5
  }
14
- /**
15
- * Thrown when authoring a VBA project (synthesizing a `vbaProject.bin` from module source) is asked to
16
- * produce something that cannot be encoded to a well-formed container — a stream name longer than the
17
- * [MS-CFB] 31-character limit, a duplicate stream name, or a project so large it would exceed the
18
- * writer's single-header DIFAT bound. This is a caller-side contract violation, distinct from
19
- * {@link VbaParseError} (which reports a malformed blob *read* from an untrusted file).
20
- */
21
6
  export class VbaAuthorError extends XlsxError {
22
7
  name = 'VbaAuthorError';
23
8
  code = 'authoring';
package/dist/vba/index.js CHANGED
@@ -1,13 +1,3 @@
1
- // Native VBA read, plus pure-TS structural edits: decode a macro-enabled workbook's `vbaProject.bin`
2
- // into readable module source, and remove a module or add a library reference from an existing project
3
- // by splicing the original bytes (every module's compiled p-code preserved untouched).
4
- //
5
- // Authoring or editing module SOURCE is NOT here. Excel runs a module's compiled p-code, not its source,
6
- // and only a real Excel can produce source-matched p-code — so that lives in the offline
7
- // `tools/vba-compiler` (VBIDE), whose output is attached via `Workbook.vbaProjectBytes`. The read path
8
- // is a projection over bytes the model preserves opaquely on round-trip. For the design invariants and
9
- // the wider VBA feature map see the ADRs: read view (docs/decisions/0016), authoring (0017/0019), and
10
- // structural edits (0018/0019), plus docs/knowledge/specs/xlsm-macro-preservation.md.
11
1
  export { writeCompoundFile, } from './cfb-writer.js';
12
2
  export { VbaAuthorError, VbaParseError } from './errors.js';
13
3
  export { compressContainer, decompressContainer } from './ms-ovba.js';
@@ -1,29 +1,8 @@
1
- // MS-OVBA §2.4.1 — compression and decompression of a "CompressedContainer".
2
- //
3
- // VBA module source and the project `dir` stream are stored in Office's own run-length compression,
4
- // NOT deflate. A container is a 0x01 signature byte followed by one or more chunks; each chunk
5
- // decompresses to at most 4096 bytes and is either a raw 4096-byte copy or a stream of literal/copy
6
- // tokens. Reference: [MS-OVBA] 2.4.1.3.6 (decompressing a CompressedContainer), 2.4.1.3.19.3 (the
7
- // CopyToken bit-packing), and 2.4.1.3.7 (compressing a chunk).
8
- //
9
- // The decompressor is a hostile-input parser: the container comes from an untrusted file, so every
10
- // length and back-reference is bounds-checked and the total output is capped. A malformed container
11
- // fails closed with a VbaParseError rather than over-allocating, looping, or reading out of bounds. The
12
- // compressor is the authoring inverse — it is fed our own bytes, and its output re-expands to the input
13
- // byte-for-byte (the round-trip is the correctness contract).
14
1
  import { VbaParseError } from './errors.js';
15
- // A decompressed chunk covers at most 4096 bytes; both directions honour this window ([MS-OVBA]
16
- // 2.4.1.3.6). The chunk header's bits 12-14 carry a fixed 0b011 signature, bit 15 the compressed flag.
17
2
  const MAX_CHUNK_DECOMPRESSED = 4096;
18
3
  const CHUNK_SIGNATURE = 0b011 << 12;
19
4
  const CHUNK_COMPRESSED_FLAG = 0x8000;
20
- // A single VBA project is well under a megabyte; 64 MiB is far above any legitimate container yet
21
- // bounds a decompression bomb (a small container that expands without limit) to a survivable size.
22
5
  const DEFAULT_MAX_OUTPUT = 64 * 1024 * 1024;
23
- /**
24
- * Decompress an MS-OVBA CompressedContainer beginning at `start` in `buf`.
25
- * @param maxOutput hard ceiling on decompressed bytes; exceeding it throws (bomb guard).
26
- */
27
6
  export function decompressContainer(buf, start = 0, maxOutput = DEFAULT_MAX_OUTPUT) {
28
7
  if (start >= buf.length) {
29
8
  throw new VbaParseError(`compressed container starts past end of stream (${start} >= ${buf.length})`);
@@ -36,7 +15,6 @@ export function decompressContainer(buf, start = 0, maxOutput = DEFAULT_MAX_OUTP
36
15
  while (pos + 2 <= buf.length) {
37
16
  const header = readU16(buf, pos);
38
17
  pos += 2;
39
- // Bits 0-11: (chunk data size - 1). Bit 15: compressed flag. Bits 12-14: the fixed 0b011 signature.
40
18
  const chunkDataSize = (header & 0x0fff) + 1;
41
19
  const compressed = (header & 0x8000) !== 0;
42
20
  if (((header >> 12) & 0x7) !== 0b011) {
@@ -47,14 +25,13 @@ export function decompressContainer(buf, start = 0, maxOutput = DEFAULT_MAX_OUTP
47
25
  throw new VbaParseError(`chunk data (${chunkDataSize} bytes) runs past end of stream`);
48
26
  }
49
27
  if (!compressed) {
50
- // A raw chunk carries its bytes verbatim (Excel emits one only when compression would expand).
51
28
  for (let i = pos; i < chunkEnd; i++)
52
29
  out.push(buf[i]);
53
30
  guardOutput(out.length, maxOutput);
54
31
  pos = chunkEnd;
55
32
  continue;
56
33
  }
57
- const chunkStart = out.length; // decompressed offset where this chunk began
34
+ const chunkStart = out.length;
58
35
  while (pos < chunkEnd) {
59
36
  const flagByte = buf[pos++];
60
37
  for (let bit = 0; bit < 8 && pos < chunkEnd; bit++) {
@@ -74,10 +51,8 @@ export function decompressContainer(buf, start = 0, maxOutput = DEFAULT_MAX_OUTP
74
51
  const copyOffset = (token >> (16 - bitCount)) + 1;
75
52
  const src = out.length - copyOffset;
76
53
  if (src < chunkStart) {
77
- // A back-reference may only reach data emitted since this chunk began ([MS-OVBA] 2.4.1.3.19).
78
54
  throw new VbaParseError('copy token references before the start of its chunk');
79
55
  }
80
- // Byte-by-byte so overlapping runs (run-length expansion) grow correctly.
81
56
  for (let i = 0; i < length; i++)
82
57
  out.push(out[src + i]);
83
58
  guardOutput(out.length, maxOutput);
@@ -87,19 +62,11 @@ export function decompressContainer(buf, start = 0, maxOutput = DEFAULT_MAX_OUTP
87
62
  }
88
63
  return Uint8Array.from(out);
89
64
  }
90
- /**
91
- * Compress `data` into an MS-OVBA CompressedContainer — the inverse of {@link decompressContainer}.
92
- * Every 4096-decompressed-byte window is emitted as a compressed chunk of literal and copy tokens, or
93
- * stored verbatim when compression would not shrink it (so the encoded chunk never exceeds the 12-bit
94
- * size field). The result re-expands to `data` byte-for-byte.
95
- */
96
65
  export function compressContainer(data) {
97
- const out = [0x01]; // container signature; an empty input yields just this byte
66
+ const out = [0x01];
98
67
  for (let start = 0; start < data.length; start += MAX_CHUNK_DECOMPRESSED) {
99
68
  const chunk = data.subarray(start, Math.min(start + MAX_CHUNK_DECOMPRESSED, data.length));
100
69
  const tokens = compressChunk(chunk);
101
- // Prefer the token stream only when it is strictly smaller; otherwise store the chunk raw. Both
102
- // encode their exact length in the header, so the decompressor reconstructs the window either way.
103
70
  const compressed = tokens.length < chunk.length;
104
71
  const body = compressed ? tokens : chunk;
105
72
  const header = (compressed ? CHUNK_COMPRESSED_FLAG : 0) | CHUNK_SIGNATURE | ((body.length - 1) & 0x0fff);
@@ -109,11 +76,6 @@ export function compressContainer(data) {
109
76
  }
110
77
  return Uint8Array.from(out);
111
78
  }
112
- // Encode one decompressed chunk (≤ 4096 bytes) as a sequence of MS-OVBA token groups: a flag byte whose
113
- // bits mark the next up-to-8 tokens as literal (0) or copy (1). A copy token replaces a run of 3+ bytes
114
- // that recurs earlier in the *same* chunk; matches may overlap the current position (run-length growth),
115
- // which the decompressor reproduces byte-by-byte. The bit split between the offset and length fields
116
- // widens as the chunk fills, exactly as the decoder computes it, so both agree on every token's shape.
117
79
  function compressChunk(chunk) {
118
80
  const tokens = [];
119
81
  let pos = 0;
@@ -127,7 +89,6 @@ function compressChunk(chunk) {
127
89
  const windowStart = Math.max(0, pos - (1 << bitCount));
128
90
  let bestLength = 0;
129
91
  let bestOffset = 0;
130
- // Scan nearest-first so equal-length matches keep the smallest offset (a marginally cheaper token).
131
92
  for (let cand = pos - 1; cand >= windowStart; cand--) {
132
93
  let len = 0;
133
94
  while (len < maxLength &&
@@ -139,7 +100,7 @@ function compressChunk(chunk) {
139
100
  bestLength = len;
140
101
  bestOffset = pos - cand;
141
102
  if (bestLength === maxLength)
142
- break; // cannot improve
103
+ break;
143
104
  }
144
105
  }
145
106
  if (bestLength >= 3) {
@@ -162,10 +123,6 @@ function guardOutput(size, maxOutput) {
162
123
  throw new VbaParseError(`decompressed output exceeds the ${maxOutput}-byte ceiling (possible bomb)`);
163
124
  }
164
125
  }
165
- /**
166
- * Bit split for a CopyToken given how many bytes have been emitted since the current chunk began
167
- * ([MS-OVBA] 2.4.1.3.19.3): the offset field grows and the length field shrinks as the chunk fills.
168
- */
169
126
  function copyTokenHelp(decompressedSoFar) {
170
127
  const bitCount = Math.max(Math.ceil(Math.log2(Math.max(decompressedSoFar, 1))), 4);
171
128
  const lengthMask = 0xffff >> bitCount;