@cj-tech-master/excelts 9.5.5 → 9.5.6

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 (60) hide show
  1. package/dist/browser/modules/excel/worksheet.d.ts +11 -0
  2. package/dist/browser/modules/excel/worksheet.js +13 -0
  3. package/dist/browser/modules/formula/integration/apply-writeback-plan.js +17 -3
  4. package/dist/browser/modules/formula/integration/workbook-adapter.js +20 -1
  5. package/dist/browser/modules/formula/integration/workbook-snapshot.d.ts +12 -0
  6. package/dist/browser/modules/formula/materialize/build-writeback-plan.js +47 -0
  7. package/dist/browser/modules/formula/materialize/types.d.ts +19 -3
  8. package/dist/browser/modules/formula/materialize/types.js +13 -3
  9. package/dist/browser/modules/pdf/builder/document-builder.js +2 -2
  10. package/dist/browser/modules/pdf/font/system-fonts.d.ts +24 -4
  11. package/dist/browser/modules/pdf/font/system-fonts.js +76 -32
  12. package/dist/browser/modules/pdf/render/pdf-exporter.js +6 -3
  13. package/dist/browser/modules/word/advanced/field-engine.js +151 -23
  14. package/dist/browser/modules/word/advanced/math-convert.js +2 -1
  15. package/dist/browser/modules/word/advanced/style-map.js +44 -6
  16. package/dist/browser/modules/word/convert/html/html-import.js +434 -71
  17. package/dist/browser/modules/word/convert/markdown/markdown-renderer.js +11 -3
  18. package/dist/browser/modules/word/layout/layout-full.js +4 -1
  19. package/dist/browser/modules/word/security/digital-signatures.js +160 -33
  20. package/dist/browser/modules/word/security/encryption.js +109 -9
  21. package/dist/cjs/modules/excel/worksheet.js +13 -0
  22. package/dist/cjs/modules/formula/integration/apply-writeback-plan.js +17 -3
  23. package/dist/cjs/modules/formula/integration/workbook-adapter.js +20 -1
  24. package/dist/cjs/modules/formula/materialize/build-writeback-plan.js +47 -0
  25. package/dist/cjs/modules/formula/materialize/types.js +13 -3
  26. package/dist/cjs/modules/pdf/builder/document-builder.js +1 -1
  27. package/dist/cjs/modules/pdf/font/system-fonts.js +77 -32
  28. package/dist/cjs/modules/pdf/render/pdf-exporter.js +5 -2
  29. package/dist/cjs/modules/word/advanced/field-engine.js +151 -23
  30. package/dist/cjs/modules/word/advanced/math-convert.js +2 -1
  31. package/dist/cjs/modules/word/advanced/style-map.js +44 -6
  32. package/dist/cjs/modules/word/convert/html/html-import.js +434 -71
  33. package/dist/cjs/modules/word/convert/markdown/markdown-renderer.js +11 -3
  34. package/dist/cjs/modules/word/layout/layout-full.js +4 -1
  35. package/dist/cjs/modules/word/security/digital-signatures.js +160 -33
  36. package/dist/cjs/modules/word/security/encryption.js +109 -9
  37. package/dist/esm/modules/excel/worksheet.js +13 -0
  38. package/dist/esm/modules/formula/integration/apply-writeback-plan.js +17 -3
  39. package/dist/esm/modules/formula/integration/workbook-adapter.js +20 -1
  40. package/dist/esm/modules/formula/materialize/build-writeback-plan.js +47 -0
  41. package/dist/esm/modules/formula/materialize/types.js +13 -3
  42. package/dist/esm/modules/pdf/builder/document-builder.js +2 -2
  43. package/dist/esm/modules/pdf/font/system-fonts.js +76 -32
  44. package/dist/esm/modules/pdf/render/pdf-exporter.js +6 -3
  45. package/dist/esm/modules/word/advanced/field-engine.js +151 -23
  46. package/dist/esm/modules/word/advanced/math-convert.js +2 -1
  47. package/dist/esm/modules/word/advanced/style-map.js +44 -6
  48. package/dist/esm/modules/word/convert/html/html-import.js +434 -71
  49. package/dist/esm/modules/word/convert/markdown/markdown-renderer.js +11 -3
  50. package/dist/esm/modules/word/layout/layout-full.js +4 -1
  51. package/dist/esm/modules/word/security/digital-signatures.js +160 -33
  52. package/dist/esm/modules/word/security/encryption.js +109 -9
  53. package/dist/iife/excelts.iife.js +40 -26
  54. package/dist/iife/excelts.iife.js.map +1 -1
  55. package/dist/iife/excelts.iife.min.js +3 -3
  56. package/dist/types/modules/excel/worksheet.d.ts +11 -0
  57. package/dist/types/modules/formula/integration/workbook-snapshot.d.ts +12 -0
  58. package/dist/types/modules/formula/materialize/types.d.ts +19 -3
  59. package/dist/types/modules/pdf/font/system-fonts.d.ts +24 -4
  60. package/package.json +1 -1
@@ -46,52 +46,179 @@ export function parseSignatureXml(xmlStr, fileName) {
46
46
  fileName,
47
47
  cryptographicStatus: "not-verified"
48
48
  };
49
- // Extract Office-specific metadata from <SignatureInfoV1>
50
- const sigTextMatch = /<SignatureText[^>]*>([^<]*)<\/SignatureText>/.exec(xmlStr);
51
- if (sigTextMatch) {
52
- info.signer = xmlDecode(sigTextMatch[1]);
49
+ // Each `<TagName ...>...</TagName>` lookup used to be a regex of the
50
+ // form `/<Tag[^>]*>([^<]*)<\/Tag>/.exec(xmlStr)`. Although `[^>]*` and
51
+ // `[^<]*` are linear in isolation, running ten such regexes against
52
+ // attacker-controlled signature XML triggers CodeQL's
53
+ // `js/polynomial-redos` rule. `extractTextElement` performs the same
54
+ // job in a single linear scan and cannot exhibit super-linear runtime.
55
+ const signer = extractTextElement(xmlStr, "SignatureText");
56
+ if (signer !== undefined) {
57
+ info.signer = xmlDecode(signer);
53
58
  }
54
- const sigCommentsMatch = /<SignatureComments[^>]*>([^<]*)<\/SignatureComments>/.exec(xmlStr);
55
- if (sigCommentsMatch) {
56
- info.signatureComments = xmlDecode(sigCommentsMatch[1]);
59
+ const sigComments = extractTextElement(xmlStr, "SignatureComments");
60
+ if (sigComments !== undefined) {
61
+ info.signatureComments = xmlDecode(sigComments);
57
62
  }
58
- const purposeMatch = /<SignaturePurpose[^>]*>([^<]*)<\/SignaturePurpose>/.exec(xmlStr);
59
- if (purposeMatch) {
60
- info.purpose = xmlDecode(purposeMatch[1]);
63
+ const purpose = extractTextElement(xmlStr, "SignaturePurpose");
64
+ if (purpose !== undefined) {
65
+ info.purpose = xmlDecode(purpose);
61
66
  }
62
- const dateMatch = /<SignatureDate[^>]*>([^<]*)<\/SignatureDate>/.exec(xmlStr);
63
- if (dateMatch) {
64
- info.signDate = xmlDecode(dateMatch[1]);
67
+ const signDate = extractTextElement(xmlStr, "SignatureDate");
68
+ if (signDate !== undefined) {
69
+ info.signDate = xmlDecode(signDate);
65
70
  }
66
- const providerMatch = /<SignatureProviderUrl[^>]*>([^<]*)<\/SignatureProviderUrl>/.exec(xmlStr);
67
- if (providerMatch) {
68
- info.providerUrl = xmlDecode(providerMatch[1]);
71
+ const providerUrl = extractTextElement(xmlStr, "SignatureProviderUrl");
72
+ if (providerUrl !== undefined) {
73
+ info.providerUrl = xmlDecode(providerUrl);
69
74
  }
70
- // Commitment type
71
- const commitMatch = /<CommitmentType[^>]*>\s*<CommitmentTypeIndication[^>]*>\s*<CommitmentTypeId>([^<]*)<\/CommitmentTypeId>/.exec(xmlStr);
72
- if (commitMatch) {
73
- info.commitmentType = xmlDecode(commitMatch[1]);
75
+ // Commitment type — nested element. Read the full `<CommitmentType>`
76
+ // body (which contains nested elements, hence `allowAngleBrackets`)
77
+ // then look for `<CommitmentTypeId>` inside.
78
+ const commitmentBlock = extractTextElement(xmlStr, "CommitmentType", {
79
+ allowAngleBrackets: true
80
+ });
81
+ if (commitmentBlock !== undefined) {
82
+ const commitmentId = extractTextElement(commitmentBlock, "CommitmentTypeId");
83
+ if (commitmentId !== undefined) {
84
+ info.commitmentType = xmlDecode(commitmentId);
85
+ }
74
86
  }
75
- // Extract signature value (base64)
76
- const sigValMatch = /<SignatureValue[^>]*>([^]*?)<\/SignatureValue>/.exec(xmlStr);
77
- if (sigValMatch) {
78
- info.signatureValue = sigValMatch[1].trim();
87
+ // Signature value (base64 — may legitimately span newlines, so don't strip).
88
+ const signatureValue = extractTextElement(xmlStr, "SignatureValue", { allowAngleBrackets: true });
89
+ if (signatureValue !== undefined) {
90
+ info.signatureValue = signatureValue.trim();
79
91
  }
80
92
  // Certificate details from <X509Data>
81
- const certSubjectMatch = /<X509SubjectName[^>]*>([^<]*)<\/X509SubjectName>/.exec(xmlStr);
82
- if (certSubjectMatch) {
83
- info.certificateSubject = xmlDecode(certSubjectMatch[1]);
93
+ const certSubject = extractTextElement(xmlStr, "X509SubjectName");
94
+ if (certSubject !== undefined) {
95
+ info.certificateSubject = xmlDecode(certSubject);
84
96
  }
85
- const certIssuerMatch = /<X509IssuerName[^>]*>([^<]*)<\/X509IssuerName>/.exec(xmlStr);
86
- if (certIssuerMatch) {
87
- info.certificateIssuer = xmlDecode(certIssuerMatch[1]);
97
+ const certIssuer = extractTextElement(xmlStr, "X509IssuerName");
98
+ if (certIssuer !== undefined) {
99
+ info.certificateIssuer = xmlDecode(certIssuer);
88
100
  }
89
- const certSerialMatch = /<X509SerialNumber[^>]*>([^<]*)<\/X509SerialNumber>/.exec(xmlStr);
90
- if (certSerialMatch) {
91
- info.certificateSerialNumber = xmlDecode(certSerialMatch[1]);
101
+ const certSerial = extractTextElement(xmlStr, "X509SerialNumber");
102
+ if (certSerial !== undefined) {
103
+ info.certificateSerialNumber = xmlDecode(certSerial);
92
104
  }
93
105
  return info;
94
106
  }
107
+ /**
108
+ * Find the first occurrence of `<tagName ...>...</tagName>` in `xml` and
109
+ * return the inner text (verbatim — the caller is responsible for entity
110
+ * decoding via `xmlDecode`).
111
+ *
112
+ * Implemented as a linear index scan rather than a regex match. The previous
113
+ * regex-based implementation tripped CodeQL's polynomial-regex detector
114
+ * because the input is attacker-controlled signature XML.
115
+ *
116
+ * @param xml - The XML text to search.
117
+ * @param tagName - Local element name (no namespace prefix). The match
118
+ * ignores any namespace prefix actually present in the document.
119
+ * @param options.allowAngleBrackets - When true, the inner text is read up
120
+ * to the literal `</tagName>` close tag rather than the next `<`. This
121
+ * is appropriate for elements like `SignatureValue` where the body is
122
+ * base64 and cannot legitimately contain `<` anyway, but lets the function
123
+ * tolerate accidental whitespace/newlines that some signers insert.
124
+ */
125
+ function extractTextElement(xml, tagName, options = {}) {
126
+ const n = xml.length;
127
+ let from = 0;
128
+ while (from < n) {
129
+ const lt = xml.indexOf("<", from);
130
+ if (lt < 0) {
131
+ return undefined;
132
+ }
133
+ // Skip an optional namespace prefix: <ns:Tag ...>.
134
+ let nameStart = lt + 1;
135
+ // Look ahead for either the bare tag name or `prefix:tagName`. We do
136
+ // a forward scan rather than an unbounded regex match.
137
+ const colon = xml.indexOf(":", nameStart);
138
+ const ws = findTagNameEnd(xml, nameStart);
139
+ if (colon > 0 && colon < ws) {
140
+ nameStart = colon + 1;
141
+ }
142
+ if (xml.slice(nameStart, nameStart + tagName.length) !== tagName ||
143
+ !isTagNameBoundary(xml.charCodeAt(nameStart + tagName.length))) {
144
+ from = lt + 1;
145
+ continue;
146
+ }
147
+ // Found `<…tagName`. Find the closing `>` of the open tag.
148
+ const openEnd = xml.indexOf(">", nameStart + tagName.length);
149
+ if (openEnd < 0) {
150
+ return undefined;
151
+ }
152
+ // Self-closing? Then the element has no text content.
153
+ if (xml.charCodeAt(openEnd - 1) === 0x2f /* '/' */) {
154
+ return "";
155
+ }
156
+ const bodyStart = openEnd + 1;
157
+ if (options.allowAngleBrackets) {
158
+ // Search for the matching close tag (allowing namespace prefix).
159
+ const closeIdx = findCloseTag(xml, bodyStart, tagName);
160
+ if (closeIdx < 0) {
161
+ return undefined;
162
+ }
163
+ return xml.slice(bodyStart, closeIdx);
164
+ }
165
+ // Default behaviour: text content has no `<`. Stop at the next `<`.
166
+ const lt2 = xml.indexOf("<", bodyStart);
167
+ if (lt2 < 0) {
168
+ return undefined;
169
+ }
170
+ return xml.slice(bodyStart, lt2);
171
+ }
172
+ return undefined;
173
+ }
174
+ function findTagNameEnd(xml, start) {
175
+ const n = xml.length;
176
+ let i = start;
177
+ while (i < n) {
178
+ const c = xml.charCodeAt(i);
179
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d || c === 0x2f || c === 0x3e) {
180
+ return i;
181
+ }
182
+ i++;
183
+ }
184
+ return n;
185
+ }
186
+ function isTagNameBoundary(c) {
187
+ return (c === 0x20 || // space
188
+ c === 0x09 || // tab
189
+ c === 0x0a || // LF
190
+ c === 0x0d || // CR
191
+ c === 0x2f || // '/'
192
+ c === 0x3e // '>'
193
+ );
194
+ }
195
+ function findCloseTag(xml, from, tagName) {
196
+ const n = xml.length;
197
+ let i = from;
198
+ while (i < n) {
199
+ const lt = xml.indexOf("</", i);
200
+ if (lt < 0) {
201
+ return -1;
202
+ }
203
+ let p = lt + 2;
204
+ // Optional namespace prefix
205
+ const colon = xml.indexOf(":", p);
206
+ const gt = xml.indexOf(">", p);
207
+ if (gt < 0) {
208
+ return -1;
209
+ }
210
+ if (colon > 0 && colon < gt) {
211
+ p = colon + 1;
212
+ }
213
+ if (xml.slice(p, p + tagName.length) === tagName &&
214
+ // Allow trailing whitespace before '>' but require a boundary char.
215
+ isTagNameBoundary(xml.charCodeAt(p + tagName.length))) {
216
+ return lt;
217
+ }
218
+ i = lt + 2;
219
+ }
220
+ return -1;
221
+ }
95
222
  /**
96
223
  * Extract all digital signatures from opaque parts of a document.
97
224
  *
@@ -324,13 +324,17 @@ function bytesEqual(a, b) {
324
324
  * @returns Parsed agile encryption info.
325
325
  */
326
326
  export function parseEncryptionInfoXml(xmlStr) {
327
- // Simple regex-based extraction for the key <keyEncryptors><keyEncryptor>... element
328
- const keyDataMatch = /<keyData\s([\s\S]*?)\/>/.exec(xmlStr);
329
- const pwdEncryptorMatch = /<p:encryptedKey\s([\s\S]*?)\/>/.exec(xmlStr);
330
- if (!keyDataMatch || !pwdEncryptorMatch) {
327
+ // Locate the `<keyData ... />` and `<p:encryptedKey ... />` elements
328
+ // with a linear scan. Using regular expressions with lazy `[\s\S]*?`
329
+ // quantifiers triggers CodeQL's polynomial-regex warning because the
330
+ // input is attacker-controlled (a hostile EncryptionInfo XML stream
331
+ // with very long unterminated tags caused catastrophic backtracking).
332
+ const keyDataAttrs = extractSelfClosingTagAttrs(xmlStr, "keyData");
333
+ const pwdAttrs = extractSelfClosingTagAttrs(xmlStr, "p:encryptedKey");
334
+ if (!keyDataAttrs || !pwdAttrs) {
331
335
  throw new DocxDecryptionError("Invalid EncryptionInfo XML - missing keyData or encryptedKey");
332
336
  }
333
- const pwdData = parseAttrs(pwdEncryptorMatch[1]);
337
+ const pwdData = pwdAttrs;
334
338
  // Required cryptographic fields — empty/missing values cannot decrypt and
335
339
  // produce confusing CryptoOperation errors downstream. Fail fast with a
336
340
  // clear message instead.
@@ -393,12 +397,108 @@ export function parseEncryptionInfoXml(xmlStr) {
393
397
  blockSize
394
398
  };
395
399
  }
400
+ /**
401
+ * Find a `<tagName ... />` element and return its parsed attributes, or
402
+ * `null` if no such self-closing element exists.
403
+ *
404
+ * Uses a linear scan instead of a regex with `[\s\S]*?` to avoid
405
+ * catastrophic backtracking on adversarial EncryptionInfo XML.
406
+ */
407
+ function extractSelfClosingTagAttrs(xml, tagName) {
408
+ const needle = `<${tagName}`;
409
+ let from = 0;
410
+ while (from <= xml.length) {
411
+ const start = xml.indexOf(needle, from);
412
+ if (start < 0) {
413
+ return null;
414
+ }
415
+ const after = start + needle.length;
416
+ const ch = xml.charCodeAt(after);
417
+ // Require a whitespace, '/' or '>' after the tag name so `<keyDataExtra`
418
+ // does not match `<keyData`.
419
+ if (ch !== 0x20 && ch !== 0x09 && ch !== 0x0a && ch !== 0x0d && ch !== 0x2f && ch !== 0x3e) {
420
+ from = after;
421
+ continue;
422
+ }
423
+ // Find the closing '>' from `start`. Bail out if there isn't one.
424
+ const close = xml.indexOf(">", after);
425
+ if (close < 0) {
426
+ return null;
427
+ }
428
+ // The element must be self-closing: the char before '>' is '/'.
429
+ if (xml.charCodeAt(close - 1) !== 0x2f) {
430
+ from = close + 1;
431
+ continue;
432
+ }
433
+ const inner = xml.slice(after, close - 1);
434
+ return parseAttrs(inner);
435
+ }
436
+ return null;
437
+ }
438
+ /**
439
+ * Parse XML-style attributes (`name="value"`) from a fragment. Implemented
440
+ * as a single linear scan rather than a global regex so attacker-controlled
441
+ * input cannot trigger polynomial-time backtracking (CodeQL js/polynomial-redos).
442
+ */
396
443
  function parseAttrs(str) {
397
444
  const attrs = {};
398
- const re = /(\w+)="([^"]*)"/g;
399
- let match;
400
- while ((match = re.exec(str)) !== null) {
401
- attrs[match[1]] = match[2];
445
+ const n = str.length;
446
+ let i = 0;
447
+ while (i < n) {
448
+ // Skip whitespace.
449
+ while (i < n) {
450
+ const c = str.charCodeAt(i);
451
+ if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) {
452
+ break;
453
+ }
454
+ i++;
455
+ }
456
+ if (i >= n) {
457
+ break;
458
+ }
459
+ // Read attribute name (\w+ equivalent: [A-Za-z0-9_]+).
460
+ const nameStart = i;
461
+ while (i < n) {
462
+ const c = str.charCodeAt(i);
463
+ const isWord = (c >= 0x30 && c <= 0x39) || // 0-9
464
+ (c >= 0x41 && c <= 0x5a) || // A-Z
465
+ (c >= 0x61 && c <= 0x7a) || // a-z
466
+ c === 0x5f; // _
467
+ if (!isWord) {
468
+ break;
469
+ }
470
+ i++;
471
+ }
472
+ if (i === nameStart) {
473
+ // Not at an attribute — advance one char so we make progress.
474
+ i++;
475
+ continue;
476
+ }
477
+ const name = str.slice(nameStart, i);
478
+ // Expect `="` exactly. Anything else means we resync to the next
479
+ // whitespace and try again — robust to malformed input.
480
+ if (i + 1 >= n || str.charCodeAt(i) !== 0x3d || str.charCodeAt(i + 1) !== 0x22) {
481
+ // Skip to next whitespace.
482
+ while (i < n) {
483
+ const c = str.charCodeAt(i);
484
+ if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) {
485
+ break;
486
+ }
487
+ i++;
488
+ }
489
+ continue;
490
+ }
491
+ i += 2; // past `="`
492
+ // Read until next `"`.
493
+ const valStart = i;
494
+ const valEnd = str.indexOf('"', i);
495
+ if (valEnd < 0) {
496
+ // Unterminated value — store what we have and stop.
497
+ attrs[name] = str.slice(valStart);
498
+ break;
499
+ }
500
+ attrs[name] = str.slice(valStart, valEnd);
501
+ i = valEnd + 1;
402
502
  }
403
503
  return attrs;
404
504
  }
@@ -910,6 +910,19 @@ class Worksheet {
910
910
  // return true if this._merges has a merge object
911
911
  return Object.values(this._merges).some(Boolean);
912
912
  }
913
+ /**
914
+ * Read-only enumeration of every merged region on this sheet
915
+ * (1-based, inclusive). Consumed by the formula engine's snapshot
916
+ * builder to detect `#SPILL!` conflicts. See issue #162 follow-up.
917
+ */
918
+ get mergedRegions() {
919
+ return Object.values(this._merges).map(merge => ({
920
+ top: merge.top,
921
+ left: merge.left,
922
+ bottom: merge.bottom,
923
+ right: merge.right
924
+ }));
925
+ }
913
926
  /**
914
927
  * Scan the range and if any cell is part of a merge, un-merge the group.
915
928
  * Note this function can affect multiple merges and merge-blocks are
@@ -139,8 +139,14 @@ function applySpillWrite(workbook, op) {
139
139
  }
140
140
  }
141
141
  else {
142
- // Ghost cell: set value (not result)
142
+ // Ghost cell: set value (not result). Defence in depth — the
143
+ // plan builder rejects spills onto merged regions, so a Merge
144
+ // type here is unreachable; guard anyway because writing
145
+ // through `MergeValue`'s setter would clobber the master.
143
146
  const targetCell = ws.getCell(targetRow, targetCol);
147
+ if (targetCell.type === types_1.CellValueTypeLike.Merge) {
148
+ continue;
149
+ }
144
150
  targetCell.value = snapshotValueToRaw(val);
145
151
  }
146
152
  }
@@ -163,9 +169,17 @@ function applyCleanupWrite(workbook, op) {
163
169
  }
164
170
  for (const { row, col } of op.cells) {
165
171
  const cell = ws.findCell(row, col);
166
- if (cell) {
167
- cell.value = null;
172
+ if (!cell) {
173
+ continue;
174
+ }
175
+ // Defence in depth: writing `null` to a merge slave would forward
176
+ // through `MergeValue`'s setter and wipe the master's value. The
177
+ // plan builder already skips merged regions in `collectStaleGhosts`,
178
+ // so this guard is belt-and-suspenders.
179
+ if (cell.type === types_1.CellValueTypeLike.Merge) {
180
+ continue;
168
181
  }
182
+ cell.value = null;
169
183
  }
170
184
  }
171
185
  // ============================================================================
@@ -98,13 +98,24 @@ function buildWorksheetSnapshot(ws, date1904) {
98
98
  ? { top: dims.top, left: dims.left, bottom: dims.bottom, right: dims.right }
99
99
  : null;
100
100
  const tables = buildTables(ws);
101
+ // Defensive copy — snapshot must not alias host-owned arrays.
102
+ const hostMergedRegions = ws.mergedRegions;
103
+ const mergedRegions = hostMergedRegions
104
+ ? hostMergedRegions.map(r => ({
105
+ top: r.top,
106
+ left: r.left,
107
+ bottom: r.bottom,
108
+ right: r.right
109
+ }))
110
+ : [];
101
111
  return {
102
112
  id: ws.id,
103
113
  name: ws.name,
104
114
  dimensions,
105
115
  cells,
106
116
  hiddenRows,
107
- tables
117
+ tables,
118
+ mergedRegions
108
119
  };
109
120
  }
110
121
  // ============================================================================
@@ -116,6 +127,14 @@ function buildCellSnapshot(cell, row, col, date1904) {
116
127
  if (cellType === types_1.CellValueTypeLike.Null) {
117
128
  return null;
118
129
  }
130
+ // Skip merge slaves — Excel treats them as blank for formula
131
+ // purposes, but the host's `MergeValue` proxy would forward
132
+ // `cell.value` from the master, so letting them into `cells` would
133
+ // double-count master values in range aggregates. See issue #162
134
+ // and the `Merge` case in `CellValueTypeLike`.
135
+ if (cellType === types_1.CellValueTypeLike.Merge) {
136
+ return null;
137
+ }
119
138
  // ── Formula cells ──
120
139
  if (cellType === types_1.CellValueTypeLike.Formula) {
121
140
  return buildFormulaCellSnapshot(cell, row, col, date1904);
@@ -249,6 +249,14 @@ activeSpillTargets) {
249
249
  if (inst.row + arr.height - 1 > 1048576 || inst.col + arr.width - 1 > 16384) {
250
250
  return "error";
251
251
  }
252
+ // Reject if the source cell itself sits inside a merged region.
253
+ // Excel reports #SPILL! whenever a dynamic-array formula is placed
254
+ // in a merged cell, even when the ghosts land outside the merge.
255
+ // The ghost loop below skips `(r=0, c=0)` and the master's value is
256
+ // already in `cells`, so it would not catch this case.
257
+ if (isInMergedRegion(ws, inst.row, inst.col)) {
258
+ return "error";
259
+ }
252
260
  // Check spill availability: verify all target ghost cells are unoccupied
253
261
  for (let r = 0; r < arr.height; r++) {
254
262
  for (let c = 0; c < arr.width; c++) {
@@ -263,6 +271,16 @@ activeSpillTargets) {
263
271
  if (activeSpillTargets.has(targetKey)) {
264
272
  return "error";
265
273
  }
274
+ // Refuse to spill onto any cell that belongs to a merged region.
275
+ // The cell itself may be a merge slave — which the snapshot
276
+ // builder filters out of `ws.cells`, so the value/formula checks
277
+ // below would treat it as empty — but writing there would mutate
278
+ // the master via `MergeValue`'s setter in `@excel/cell` and
279
+ // silently corrupt the merge. Excel reports `#SPILL!` whenever a
280
+ // dynamic-array result tries to land in a merge.
281
+ if (isInMergedRegion(ws, targetRow, targetCol)) {
282
+ return "error";
283
+ }
266
284
  // Check if the cell is a ghost from ANY previous spill.
267
285
  // If the user hasn't modified it, it's safe to overwrite — the
268
286
  // originating formula will clean it up (or we'll overwrite it).
@@ -351,6 +369,19 @@ function collectStaleGhosts(region, previousGhosts, snapshot) {
351
369
  }
352
370
  const targetRow = region.sourceRow + r;
353
371
  const targetCol = region.sourceCol + c;
372
+ // If the user (or a previous edit) has placed this former ghost
373
+ // inside a merged region, skip it. The cell is now either a merge
374
+ // master (carrying the user's intentional value) or a merge slave
375
+ // (whose `cell.value = null` writeback would forward through
376
+ // `MergeValue`'s setter and clobber the master). Either way,
377
+ // cleanup must not touch it. The snapshot builder filters merge
378
+ // slaves out of `ws.cells` (see issue #162), so the
379
+ // `isGhostUnmodified` check below would otherwise miss this case
380
+ // — `cell` would be `undefined`, which currently means
381
+ // "unmodified, safe to wipe".
382
+ if (isInMergedRegion(ws, targetRow, targetCol)) {
383
+ continue;
384
+ }
354
385
  const targetKey = (0, workbook_snapshot_1.spillCellKeyFromId)(region.worksheetId, targetRow, targetCol);
355
386
  const cell = ws.cells.get((0, workbook_snapshot_1.snapshotCellKey)(targetRow, targetCol));
356
387
  if (isGhostUnmodified(cell, targetKey, previousGhosts)) {
@@ -382,6 +413,22 @@ function emitPreviousSpillCleanup(previousRegion, previousGhosts, snapshot, oper
382
413
  cells: cleanupCells
383
414
  });
384
415
  }
416
+ /**
417
+ * Test whether `(row, col)` falls inside any merged region of `ws`.
418
+ *
419
+ * Linear scan — merge counts per sheet are small in practice. The
420
+ * snapshot builder filters merge slaves out of `ws.cells`, so callers
421
+ * use this helper to recover the "is this cell part of a merge?"
422
+ * signal that the cell map alone no longer carries.
423
+ */
424
+ function isInMergedRegion(ws, row, col) {
425
+ for (const region of ws.mergedRegions) {
426
+ if (row >= region.top && row <= region.bottom && col >= region.left && col <= region.right) {
427
+ return true;
428
+ }
429
+ }
430
+ return false;
431
+ }
385
432
  function isGhostUnmodified(cell, ghostKey, previousGhosts) {
386
433
  if (!cell) {
387
434
  return true;
@@ -16,17 +16,27 @@ exports.CellValueTypeLike = void 0;
16
16
  // ValueType — numeric mirror of `@excel/enums` ValueType
17
17
  // ============================================================================
18
18
  /**
19
- * Numeric cell-type tag exposed by host cells. The engine only compares
20
- * against `Null` and `Formula`; any other value is treated as a scalar
21
- * literal.
19
+ * Numeric cell-type tag exposed by host cells. The engine compares
20
+ * against `Null`, `Merge`, and `Formula`; any other value is treated as
21
+ * a scalar literal.
22
+ *
23
+ * `Merge` identifies a non-master cell inside a merged region. The
24
+ * host's in-memory model may proxy `cell.value` from slaves to the
25
+ * master (see `MergeValue` in `@excel/cell`), so the snapshot builder
26
+ * must filter merge slaves out — otherwise range aggregates count the
27
+ * master's value once per slave. See issue #162.
22
28
  *
23
29
  * Kept as inline numeric literals (not an enum) so this file stays free
24
30
  * of runtime dependencies. The `const` object and `type` alias share a
25
31
  * name via TypeScript's declaration merging — the value form
26
32
  * (`CellValueTypeLike.Null`, `CellValueTypeLike.Formula`) is used at
27
33
  * comparison sites, the type form annotates `CellLike.type`.
34
+ *
35
+ * The numeric values must stay in sync with `ValueType` in
36
+ * `@excel/enums`, which is what `@excel/cell` writes into `cell.type`.
28
37
  */
29
38
  exports.CellValueTypeLike = {
30
39
  Null: 0,
40
+ Merge: 1,
31
41
  Formula: 6
32
42
  };
@@ -877,7 +877,7 @@ class PdfDocumentBuilder {
877
877
  if (nonWinAnsi.size > 0) {
878
878
  // Try auto-discovery unless the caller opted out.
879
879
  if (!this._disableFontAutoDiscovery) {
880
- for (const candidate of (0, system_fonts_1.discoverSystemFontCandidates)()) {
880
+ for (const candidate of (0, system_fonts_1.iterateSystemFontCandidates)()) {
881
881
  try {
882
882
  const testTtf = (0, ttf_parser_1.parseTtf)(candidate);
883
883
  const allCovered = [...nonWinAnsi].every(cp => testTtf.cmap.has(cp));