@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.
- package/README.md +21 -0
- package/dist/core/address.js +0 -33
- package/dist/core/autofilter.js +0 -9
- package/dist/core/cell.d.ts +10 -0
- package/dist/core/cell.js +4 -123
- package/dist/core/color-resolution.js +0 -60
- package/dist/core/column.d.ts +9 -1
- package/dist/core/column.js +0 -52
- package/dist/core/comment-thread.js +0 -45
- package/dist/core/conditional-formatting-overlay.js +0 -11
- package/dist/core/conditional-formatting.js +0 -10
- package/dist/core/containers.js +0 -15
- package/dist/core/data-validation-overlay.js +0 -25
- package/dist/core/data-validation.js +0 -7
- package/dist/core/date.js +0 -39
- package/dist/core/formula.js +0 -117
- package/dist/core/grid-edits.js +0 -38
- package/dist/core/image.js +0 -25
- package/dist/core/internal.js +0 -27
- package/dist/core/limits.d.ts +30 -0
- package/dist/core/limits.js +2 -0
- package/dist/core/merge.d.ts +18 -0
- package/dist/core/merge.js +22 -7
- package/dist/core/modern-functions.js +0 -17
- package/dist/core/page-setup.js +0 -5
- package/dist/core/pivot-table.d.ts +1 -1
- package/dist/core/pivot-table.js +0 -44
- package/dist/core/preserved.js +0 -4
- package/dist/core/protection.js +0 -26
- package/dist/core/range.js +0 -112
- package/dist/core/row-input.d.ts +24 -0
- package/dist/core/row-input.js +29 -0
- package/dist/core/row.d.ts +9 -1
- package/dist/core/row.js +0 -58
- package/dist/core/style.js +0 -26
- package/dist/core/table-style.js +0 -28
- package/dist/core/table.js +0 -97
- package/dist/core/text-metrics.d.ts +20 -0
- package/dist/core/text-metrics.js +10 -0
- package/dist/core/theme.js +0 -85
- package/dist/core/value.d.ts +40 -0
- package/dist/core/value.js +30 -48
- package/dist/core/workbook-protection.js +0 -13
- package/dist/core/workbook.d.ts +13 -0
- package/dist/core/workbook.js +11 -368
- package/dist/core/worksheet-model.js +2 -23
- package/dist/core/worksheet.d.ts +22 -0
- package/dist/core/worksheet.js +14 -485
- package/dist/customui/errors.js +0 -10
- package/dist/customui/index.js +0 -5
- package/dist/customui/ribbon.js +0 -43
- package/dist/entries/core.d.ts +3 -1
- package/dist/entries/core.js +3 -7
- package/dist/entries/csv.js +0 -4
- package/dist/entries/customui.js +0 -2
- package/dist/entries/errors.js +0 -13
- package/dist/entries/vba.js +0 -5
- package/dist/entries/xlsb.js +0 -6
- package/dist/entries/xlsx.js +0 -13
- package/dist/errors.d.ts +14 -0
- package/dist/errors.js +5 -56
- package/dist/index.js +0 -12
- package/dist/io/csv/read.js +0 -18
- package/dist/io/csv/write.js +5 -35
- package/dist/io/opc/errors.js +0 -30
- package/dist/io/opc/inflate.js +0 -35
- package/dist/io/opc/namespaces.js +0 -15
- package/dist/io/opc/part-paths.js +0 -11
- package/dist/io/opc/read-opc.js +0 -31
- package/dist/io/opc/read-options.js +0 -6
- package/dist/io/opc/rels.js +0 -13
- package/dist/io/opc/sniff-format.js +0 -50
- package/dist/io/style/xf-style.js +0 -31
- package/dist/io/xlsb/errors.js +0 -10
- package/dist/io/xlsb/formula.js +1 -99
- package/dist/io/xlsb/primitives.js +1 -83
- package/dist/io/xlsb/ptg-functions.js +0 -34
- package/dist/io/xlsb/read-shared-strings.js +0 -7
- package/dist/io/xlsb/read-styles.js +3 -66
- package/dist/io/xlsb/read-worksheet.js +4 -87
- package/dist/io/xlsb/read.js +2 -60
- package/dist/io/xlsb/record-stream.js +0 -28
- package/dist/io/xlsb/record-types.js +0 -19
- package/dist/io/xlsx/cell-accumulator.js +0 -32
- package/dist/io/xlsx/cell-value.js +0 -29
- package/dist/io/xlsx/color-xml.d.ts +6 -0
- package/dist/io/xlsx/color-xml.js +42 -0
- package/dist/io/xlsx/comments.js +0 -84
- package/dist/io/xlsx/conditional-formatting.d.ts +1 -1
- package/dist/io/xlsx/conditional-formatting.js +1 -97
- package/dist/io/xlsx/data-validation.js +0 -56
- package/dist/io/xlsx/edit-vba.js +0 -54
- package/dist/io/xlsx/errors.js +0 -12
- package/dist/io/xlsx/hyperlinks.js +0 -40
- package/dist/io/xlsx/images.js +0 -32
- package/dist/io/xlsx/namespaces.js +0 -42
- package/dist/io/xlsx/package-plan.js +0 -47
- package/dist/io/xlsx/pivot.js +0 -22
- package/dist/io/xlsx/{pivot-read.js → read-pivot.js} +0 -30
- package/dist/io/xlsx/read-rows.js +1 -83
- package/dist/io/xlsx/{shared-strings-read.js → read-shared-strings.js} +0 -11
- package/dist/io/xlsx/read-styles.d.ts +25 -1
- package/dist/io/xlsx/read-styles.js +61 -108
- package/dist/io/xlsx/read-worksheet.js +1 -88
- package/dist/io/xlsx/read.js +3 -211
- package/dist/io/xlsx/relationships.js +0 -11
- package/dist/io/xlsx/rich-runs.js +0 -16
- package/dist/io/xlsx/rich-text.js +0 -12
- package/dist/io/xlsx/shared-formulas.js +0 -7
- package/dist/io/xlsx/shared-strings.js +0 -21
- package/dist/io/xlsx/sheet-properties.js +1 -64
- package/dist/io/xlsx/styles.d.ts +1 -29
- package/dist/io/xlsx/styles.js +2 -366
- package/dist/io/xlsx/tables.js +5 -52
- package/dist/io/xlsx/threaded-comments.js +0 -103
- package/dist/io/xlsx/workbook-xml.js +0 -94
- package/dist/io/xlsx/worksheet-xml.js +0 -174
- package/dist/io/xlsx/write-stream.js +0 -137
- package/dist/io/xlsx/write.js +1 -134
- package/dist/io/xlsx/x14-ext.js +0 -8
- package/dist/vba/cfb-writer.js +12 -59
- package/dist/vba/cfb.js +2 -32
- package/dist/vba/codepage.js +1 -23
- package/dist/vba/errors.js +0 -15
- package/dist/vba/index.js +0 -10
- package/dist/vba/ms-ovba.js +3 -46
- package/dist/vba/project-editor.js +4 -89
- package/dist/vba/project.js +2 -34
- package/dist/vba/vba-encoding.js +1 -17
- package/dist/xml/errors.js +0 -10
- package/dist/xml/xml-read.js +3 -82
- package/dist/xml/xml.js +0 -39
- package/package.json +9 -10
- package/skills/ts-xlsx-upstream/SKILL.md +178 -0
- /package/dist/io/xlsx/{pivot-read.d.ts → read-pivot.d.ts} +0 -0
- /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;
|
|
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();
|
|
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;
|
|
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;
|
|
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) {
|
package/dist/vba/project.js
CHANGED
|
@@ -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;
|
|
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;
|
|
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);
|
package/dist/vba/vba-encoding.js
CHANGED
|
@@ -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;
|
|
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++)
|
package/dist/xml/errors.js
CHANGED
|
@@ -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';
|
package/dist/xml/xml-read.js
CHANGED
|
@@ -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
|
|
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 ( ) 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
|
'&': '&',
|
|
@@ -21,62 +14,30 @@ const ATTR_ESCAPES = {
|
|
|
21
14
|
'\r': ' ',
|
|
22
15
|
'\t': '	',
|
|
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`);
|