@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,11 +1,3 @@
1
- // Worksheet tables (OOXML `<table>` parts, `xl/tables/table{n}.xml`) — both directions. The writer
2
- // (`tableXml`) turns a `Table` into its part; the reader (`parseTable`) is its inverse, turning a
3
- // stored part back into the `TableOptions` a worksheet re-registers.
4
- //
5
- // The part stores the table's *full* occupied range (`ref="A1:B3"`), whereas the model anchors at a
6
- // single top-left cell plus a data-row count. The two are equivalent: the data-row count is the
7
- // range height minus the header row (present unless `headerRowCount="0"`) and the totals row (present
8
- // only when `totalsRowCount` is positive), so reconstructing one from the other is lossless.
9
1
  import { decodeRange, encodeAddress } from '../../core/address.js';
10
2
  import { isTotalsRowFunction, } from '../../core/table.js';
11
3
  import { boolAttr, escapeAttr, escapeText, XML_DECLARATION } from '../../xml/xml.js';
@@ -14,12 +6,7 @@ import { NS } from './relationships.js';
14
6
  export function tableXml(table, id) {
15
7
  const name = escapeAttr(table.name);
16
8
  const displayName = escapeAttr(table.displayName);
17
- // headerRowCount defaults to 1 in OOXML, so only a headerless table needs it stated.
18
9
  const headerRowCount = table.headerRow ? '' : ' headerRowCount="0"';
19
- // A present totals row implies it is shown, so it only needs the count. Without a totals row the
20
- // model's tri-state totalsRowShown decides: emit the flag Excel recorded, or nothing when the
21
- // source omitted it — injecting `totalsRowShown="0"` onto a table that lacked the attribute is
22
- // exactly the spurious change that makes Excel treat an otherwise-valid table as corrupt.
23
10
  let totals;
24
11
  if (table.totalsRow) {
25
12
  totals = ' totalsRowCount="1"';
@@ -40,12 +27,8 @@ export function tableXml(table, id) {
40
27
  tableStyleInfoXml(table.style) +
41
28
  '</table>');
42
29
  }
43
- // Excel's default table appearance, written for a table that carries no style of its own.
44
30
  const DEFAULT_TABLE_STYLE = '<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" ' +
45
31
  'showRowStripes="1" showColumnStripes="0"/>';
46
- // Emit `<tableStyleInfo>` from the model's style, or the default when none was captured. Each
47
- // attribute is written only when the model holds it, so a style read without (say) a `name` — or a
48
- // part that omitted a banding flag — re-emits exactly as it arrived rather than gaining an attribute.
49
32
  function tableStyleInfoXml(style) {
50
33
  if (style === undefined)
51
34
  return DEFAULT_TABLE_STYLE;
@@ -67,46 +50,30 @@ function tableColumnXml(column, id) {
67
50
  if (column.totalsRowFunction !== undefined) {
68
51
  attrs += ` totalsRowFunction="${escapeAttr(column.totalsRowFunction)}"`;
69
52
  }
70
- // A `custom` total is carried by a `<totalsRowFormula>` child rather than a built-in function, so
71
- // the element is non-self-closing when one is present. The formula is stored without a leading `=`,
72
- // matching how Excel writes it.
73
53
  if (column.totalsRowFormula !== undefined) {
74
54
  return `<tableColumn ${attrs}><totalsRowFormula>${escapeText(column.totalsRowFormula)}</totalsRowFormula></tableColumn>`;
75
55
  }
76
56
  return `<tableColumn ${attrs}/>`;
77
57
  }
78
- // OOXML booleans spell false as "0" or "false"; every other spelling (including "1"/"true") is true.
79
58
  function parseOoxmlBool(value) {
80
59
  return value !== '0' && value !== 'false';
81
60
  }
82
- /**
83
- * Parse a `<table>` part into the options that reconstruct it, or `undefined` when the XML is not a
84
- * usable table (no name, no ref, or no columns — Excel treats such a part as corrupt, so we drop it
85
- * rather than fabricate a degenerate table). Duplicate column names are not resolved here — the
86
- * {@link Table} constructor disambiguates them, so authoring and loading share one implementation.
87
- */
88
61
  export function parseTable(xml) {
89
62
  let name;
90
63
  let displayName;
91
64
  let ref;
92
- let headerRowCount = 1; // OOXML default: a table carries a header row unless it says otherwise.
93
- let totalsRowCount = 0; // OOXML default: no totals row.
94
- let totalsRowShown; // Absent unless the part states the attribute.
95
- let style; // Absent unless the part carries a `<tableStyleInfo>`.
96
- let hasAutoFilter = false; // Only present when the part carries an `<autoFilter>` element.
65
+ let headerRowCount = 1;
66
+ let totalsRowCount = 0;
67
+ let totalsRowShown;
68
+ let style;
69
+ let hasAutoFilter = false;
97
70
  const columns = [];
98
- // A `<totalsRowFormula>` is a text child of the current `<tableColumn>`, so it is captured across
99
- // open/text/close rather than from an attribute. `calculatedColumnFormula` is a sibling child of
100
- // the same type (CT_TableFormula), so guard on the exact element to avoid capturing its text.
101
71
  let inTotalsFormula = false;
102
72
  let totalsFormula = '';
103
73
  parseXml(xml, {
104
74
  onOpen(elementName, attrs) {
105
75
  switch (localName(elementName)) {
106
76
  case 'table':
107
- // OOXML makes `displayName` the required identifier and `name` an optional alias; the
108
- // model inverts the roles (`name` is the formula identifier, `displayName` the label),
109
- // so read each from its own attribute and fall back across the pair when one is absent.
110
77
  name = attrs.name ?? attrs.displayName;
111
78
  displayName = attrs.displayName ?? attrs.name;
112
79
  ref = attrs.ref;
@@ -114,8 +81,6 @@ export function parseTable(xml) {
114
81
  headerRowCount = Number(attrs.headerRowCount);
115
82
  if (attrs.totalsRowCount !== undefined)
116
83
  totalsRowCount = Number(attrs.totalsRowCount);
117
- // Capture the flag verbatim so it re-emits exactly (or, absent, stays absent) rather
118
- // than being normalised.
119
84
  if (attrs.totalsRowShown !== undefined)
120
85
  totalsRowShown = parseOoxmlBool(attrs.totalsRowShown);
121
86
  break;
@@ -123,8 +88,6 @@ export function parseTable(xml) {
123
88
  hasAutoFilter = true;
124
89
  break;
125
90
  case 'tableStyleInfo': {
126
- // Keep each attribute off the literal so an absent one stays absent (not `key: undefined`),
127
- // preserving the round-trip — the writer re-emits only the attributes we actually saw.
128
91
  const captured = {};
129
92
  if (attrs.name !== undefined)
130
93
  captured.name = attrs.name;
@@ -146,9 +109,6 @@ export function parseTable(xml) {
146
109
  const column = { name: attrs.name };
147
110
  if (attrs.totalsRowLabel !== undefined)
148
111
  column.totalsRowLabel = attrs.totalsRowLabel;
149
- // An unrecognised totalsRowFunction is dropped rather than trusted in verbatim — the token
150
- // is a closed OOXML enumeration, so a foreign value is malformed input, not a future Excel
151
- // addition to accommodate.
152
112
  if (attrs.totalsRowFunction !== undefined &&
153
113
  isTotalsRowFunction(attrs.totalsRowFunction)) {
154
114
  column.totalsRowFunction = attrs.totalsRowFunction;
@@ -170,9 +130,6 @@ export function parseTable(xml) {
170
130
  if (localName(elementName) !== 'totalsRowFormula')
171
131
  return;
172
132
  inTotalsFormula = false;
173
- // Attach to the column currently being parsed — the last one pushed. Excel writes the child
174
- // only for `totalsRowFunction="custom"`, so a formula on any other column is meaningless, but
175
- // preserving whatever the part carried keeps the round-trip faithful rather than second-guessing.
176
133
  const column = columns[columns.length - 1];
177
134
  if (column !== undefined)
178
135
  column.totalsRowFormula = totalsFormula;
@@ -194,12 +151,8 @@ export function parseTable(xml) {
194
151
  rowCount: Math.max(0, dataRows),
195
152
  headerRow,
196
153
  totalsRow,
197
- // Reconstruct the autoFilter state explicitly from the part: a header table read without an
198
- // `<autoFilter>` must not have one fabricated on the next write.
199
154
  autoFilter: hasAutoFilter,
200
155
  };
201
- // Kept off the literal so an absent attribute stays absent (not `totalsRowShown: undefined`),
202
- // preserving the round-trip: a table that never stated the flag must not gain one.
203
156
  if (totalsRowShown !== undefined)
204
157
  options.totalsRowShown = totalsRowShown;
205
158
  if (style !== undefined)
@@ -1,44 +1,12 @@
1
- // Modern threaded comments — the review-style conversations Excel has written since 2018, read from and
2
- // written to their two parts: `xl/threadedComments/threadedComment{n}.xml` (per sheet) and
3
- // `xl/persons/person.xml` (per workbook, the author registry).
4
- //
5
- // These are a Microsoft extension, not base ECMA-376, and they are a *separate* feature from legacy
6
- // notes (`comments{n}.xml`, see `comments.ts`) rather than a newer spelling of them. A cell carries
7
- // one or the other, never both — Excel refuses to add a note to a threaded cell and vice versa.
8
- //
9
- // A message identifies its author by `personId` into the registry and its thread by `id`/`parentId`:
10
- // the first message of a thread has no `parentId`, every reply carries the head's `id`. Only the head
11
- // carries `done`, so a thread's resolved state is its head's — a reply never says.
12
- //
13
- // These parsers describe a file that already exists, so they read leniently: an unrecognised or
14
- // missing attribute yields a sensible default rather than a throw, and a message too incomplete to
15
- // anchor is skipped instead of crashing the read. Optional wire attributes stay optional in the
16
- // parsed shape rather than collapsing to `''`, so "the file did not say" never masquerades as a value
17
- // the file contained.
18
- //
19
- // {@link buildCommentThreads} turns the flat message list into the model's threads, resolving each
20
- // author and mention against the workbook's person registry. That grouping is deliberately not the
21
- // parsers' job: they stay faithful to the part, one function per wire form.
22
- //
23
- // The writers are the exact inverse and carry no clock and no id generator: every guid and every
24
- // timestamp is written from the model, verbatim, so the same workbook always serialises to the same
25
- // bytes. A conversation Excel wrote round-trips as itself; one authored in the model carries whatever
26
- // ids and dates the caller supplied.
27
1
  import { decodeAddress } from '../../core/address.js';
28
2
  import { MENTION_OFFSET_MAX, } from '../../core/comment-thread.js';
29
3
  import { escapeAttr, escapeText, XML_DECLARATION } from '../../xml/xml.js';
30
4
  import { boolStrict, localName, parseXml } from '../../xml/xml-read.js';
31
5
  import { THREADED_COMMENTS_NS } from './namespaces.js';
32
- /**
33
- * Parse `xl/persons/person.xml` into its registered authors, in document order. Order carries no
34
- * meaning — Excel re-sorts the list by person id when it saves — so nothing may depend on it. An
35
- * entry without an `id` is skipped: no message could reference it.
36
- */
37
6
  export function parsePersons(xml) {
38
7
  const persons = [];
39
8
  parseXml(xml, {
40
9
  onOpen(name, attrs) {
41
- // `personList` shares the prefix, so match the exact local name rather than a `startsWith`.
42
10
  if (localName(name) !== 'person')
43
11
  return;
44
12
  const { id, displayName, userId, providerId } = attrs;
@@ -54,15 +22,7 @@ export function parsePersons(xml) {
54
22
  });
55
23
  return persons;
56
24
  }
57
- // Committing a message from its close handling alone would silently drop `<threadedComment …/>`,
58
- // which fires no close of its own; presenting the self-closing form as an empty element gives it one.
59
25
  const THREADED_COMMENT_EMPTY_CLOSES = new Set(['threadedComment']);
60
- /**
61
- * Parse a `threadedComment{n}.xml` part into its messages, in document order — thread order, with
62
- * each thread's replies following its head. Grouping into threads is {@link buildCommentThreads}'s
63
- * job; this stays faithful to the part. A message without a `ref` or `id` cannot be anchored or
64
- * replied to and is skipped.
65
- */
66
26
  export function parseThreadedComments(xml) {
67
27
  const messages = [];
68
28
  let open;
@@ -124,21 +84,12 @@ function threadedCommentFrom(attrs, text, mentions) {
124
84
  ...(parentId !== undefined ? { parentId } : {}),
125
85
  };
126
86
  }
127
- // A mention without a target person or a usable span cannot be resolved or rendered, so it is dropped
128
- // rather than carried as a mention over nothing — a `length` of 0 would be an invisible chip, and a
129
- // negative or non-numeric offset would place it outside the text it is supposed to cover.
130
- //
131
- // The upper bound is what makes this a hostile-input guard rather than a tidiness check: an offset the
132
- // wire cannot express would be re-emitted by the writer as an invalid attribute — and JavaScript spells a
133
- // large enough number in exponent form (`1e+21`), which no schema accepts at all — costing the reader's
134
- // leniency the entire conversation when Excel repairs the part. Dropping the chip costs a highlight.
135
87
  function mentionFrom(attrs) {
136
88
  const personId = attrs.mentionpersonId;
137
89
  const startIndex = integerAttribute(attrs.startIndex);
138
90
  const length = integerAttribute(attrs.length);
139
91
  if (personId === undefined || startIndex === undefined || length === undefined)
140
92
  return undefined;
141
- // A negative offset points outside the text and a zero length spans nothing, so neither could render.
142
93
  if (startIndex < 0 || length <= 0)
143
94
  return undefined;
144
95
  if (startIndex > MENTION_OFFSET_MAX || length > MENTION_OFFSET_MAX)
@@ -150,26 +101,14 @@ function mentionFrom(attrs) {
150
101
  ...(attrs.mentionId !== undefined ? { mentionId: attrs.mentionId } : {}),
151
102
  };
152
103
  }
153
- // An integer attribute, or undefined when the file did not write a usable one. Blank is rejected before
154
- // `Number` sees it, since `Number('')` is 0 — an empty attribute must not read as offset zero.
155
104
  function integerAttribute(raw) {
156
105
  if (raw === undefined || raw.trim() === '')
157
106
  return undefined;
158
107
  const value = Number(raw);
159
108
  return Number.isInteger(value) ? value : undefined;
160
109
  }
161
- /**
162
- * Group a part's messages into the model's threads, resolving every author and mention against the
163
- * workbook's person registry (`personById`, typically `Workbook.getPerson`).
164
- *
165
- * Document order is thread order with each thread's replies following its head, so one pass suffices: a
166
- * message with no `parentId` opens a thread, and a reply joins the thread its `parentId` names. A reply
167
- * whose parent is unknown — a dangling `parentId` no Excel file produces — opens a thread of its own
168
- * rather than being dropped, so a foreign generator's damage costs structure, never content.
169
- */
170
110
  export function buildCommentThreads(messages, personById) {
171
111
  const threads = [];
172
- // The head's own message list, so a reply appends to the thread already published in `threads`.
173
112
  const commentsByHeadId = new Map();
174
113
  for (const message of messages) {
175
114
  const comment = commentFrom(message, personById);
@@ -183,15 +122,10 @@ export function buildCommentThreads(messages, personById) {
183
122
  continue;
184
123
  const comments = [comment];
185
124
  commentsByHeadId.set(message.id, comments);
186
- // Resolved is the head's flag: a reply never carries `done`, so it never contradicts its thread.
187
125
  threads.push({ ref, resolved: message.done, comments });
188
126
  }
189
127
  return threads;
190
128
  }
191
- // The canonical A1 form of a thread's anchor, or undefined when the file wrote something that cannot
192
- // anchor one — a range, a bare row or column, or outright garbage. Canonicalising here is what lets
193
- // every later consumer compare anchors as plain strings (`$B$2` and `B2` are one cell) and keeps a
194
- // foreign file's malformed reference out of the writer, which anchors the thread's legacy fallback by it.
195
129
  function anchorRef(reference) {
196
130
  let decoded;
197
131
  try {
@@ -223,18 +157,6 @@ function mentionOf(mention, personById) {
223
157
  ...(mention.mentionId !== undefined ? { mentionId: mention.mentionId } : {}),
224
158
  };
225
159
  }
226
- /**
227
- * Serialise one sheet's conversations into its `xl/threadedComments/threadedComment{n}.xml` part.
228
- *
229
- * Messages are written flat, in thread order with each thread's replies after its head — the shape
230
- * {@link parseThreadedComments} reads back. The head/reply distinction the model holds as array position
231
- * becomes `parentId` on every reply but the head, and `done="1"` goes on the head alone: only the head
232
- * carries the flag on the wire, so a reply can never contradict the thread it belongs to. An open thread
233
- * omits `done` entirely rather than writing `done="0"`, exactly as Excel does.
234
- *
235
- * A thread with no messages writes nothing: it has neither text to say nor a head id for its replies and
236
- * its legacy fallback to hang off.
237
- */
238
160
  export function threadedCommentsXml(threads) {
239
161
  const messages = threads.flatMap((thread) => {
240
162
  const [head, ...replies] = thread.comments;
@@ -248,11 +170,6 @@ export function threadedCommentsXml(threads) {
248
170
  return (XML_DECLARATION +
249
171
  `<ThreadedComments xmlns="${THREADED_COMMENTS_NS}">${messages.join('')}</ThreadedComments>`);
250
172
  }
251
- // One `<threadedComment>`. `tail` is the attribute that distinguishes the message's role — `done` for a
252
- // resolved head, `parentId` for a reply, nothing for an open head. A `dT` or `personId` the model never
253
- // held is omitted rather than written empty, so "the file did not say" stays distinguishable from "the
254
- // file said nothing". Every value is escaped: an authored message's text and a foreign file's ids alike
255
- // are untrusted, and an unescaped `"` would end the attribute and reshape the part.
256
173
  function threadedCommentXml(ref, comment, tail) {
257
174
  const date = comment.date === undefined ? '' : ` dT="${escapeAttr(comment.date)}"`;
258
175
  const person = comment.personId === undefined ? '' : ` personId="${escapeAttr(comment.personId)}"`;
@@ -261,21 +178,10 @@ function threadedCommentXml(ref, comment, tail) {
261
178
  mentionsXml(comment.mentions) +
262
179
  '</threadedComment>');
263
180
  }
264
- // The `<mentions>` block, which follows `<text>` in the message. All four `<mention>` attributes are
265
- // required — verified by dropping each in turn and getting `Sch_MissRequiredAttribute` — so a mention the
266
- // model holds without a `mentionId`, or with a span the wire cannot express, cannot be written at all. It
267
- // is dropped rather than given an invented id or a clamped span: the `@name` stays in the text and only
268
- // the chip is lost, whereas an invalid part risks Excel repairing the whole conversation away.
269
- //
270
- // The bounds check is deliberately here as well as in the reader. Both the authoring verb and the parser
271
- // already refuse an out-of-range offset, so nothing should reach this — but `restoreCommentThreads` takes
272
- // a model wholesale, and a serialiser that *cannot* emit `length="1e+21"` beats one that merely is not
273
- // currently handed one.
274
181
  function mentionsXml(mentions) {
275
182
  const entries = mentions.flatMap(({ personId, mentionId, startIndex, length }) => {
276
183
  if (mentionId === undefined)
277
184
  return [];
278
- // A zero-length span is in range but renders nothing, so it is no more writable than one out of range.
279
185
  if (!writableOffset(startIndex) || !writableOffset(length) || length === 0)
280
186
  return [];
281
187
  return [
@@ -285,18 +191,9 @@ function mentionsXml(mentions) {
285
191
  });
286
192
  return entries.length === 0 ? '' : `<mentions>${entries.join('')}</mentions>`;
287
193
  }
288
- // An offset the wire can express: a whole number within the schema's UInt32 ceiling.
289
194
  function writableOffset(value) {
290
195
  return Number.isInteger(value) && value >= 0 && value <= MENTION_OFFSET_MAX;
291
196
  }
292
- /**
293
- * Serialise the workbook's identity registry into `xl/persons/person.xml` — singular and unnumbered,
294
- * unlike the per-sheet thread parts.
295
- *
296
- * Entries are written in registry order, which carries no meaning: Excel re-sorts the list by person id
297
- * whenever it saves, so this only has to be deterministic, not canonical. `userId`/`providerId` are
298
- * written when the model holds them; a registry read from a file holds whatever that file stated.
299
- */
300
197
  export function personsXml(persons) {
301
198
  const entries = persons.map((person) => {
302
199
  const userId = person.userId === undefined ? '' : ` userId="${escapeAttr(person.userId)}"`;
@@ -1,6 +1,3 @@
1
- // Workbook-level serialisation: the package's `[Content_Types].xml`, its root and workbook `.rels`
2
- // parts, `xl/workbook.xml` (sheets, defined names, calc/protection settings, pivot-cache and slicer
3
- // registrations), and the `docProps` core/app property parts.
4
1
  import { mangleFormula, quoteSheetName } from '../../core/formula.js';
5
2
  import { WORKBOOK_PROTECTION_CREDENTIAL_ATTRS } from '../../core/workbook-protection.js';
6
3
  import { escapeAttr, escapeText, XML_DECLARATION } from '../../xml/xml.js';
@@ -27,15 +24,10 @@ const CT = {
27
24
  pivotTable: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml',
28
25
  pivotCacheDefinition: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml',
29
26
  pivotCacheRecords: 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml',
30
- // Both threaded-comment parts are Microsoft extensions, hence the `vnd.ms-excel` vendor prefix rather
31
- // than the `openxmlformats-officedocument` one every standard part carries.
32
27
  threadedComments: 'application/vnd.ms-excel.threadedcomments+xml',
33
28
  person: 'application/vnd.ms-excel.person+xml',
34
29
  };
35
30
  export function contentTypesXml(sheetCount, tables, commentNumbers, drawingNumbers, printerSettingsNumbers, mediaExtensions, hasSharedStrings, preservedParts, pivots, preservedWorkbookRefs, threadedCommentNumbers, hasPersons) {
36
- // One extension→default-content-type map both halves read: the defaults render it, the overrides
37
- // correct any preserved part whose own type differs from its extension's default. Sharing it is what
38
- // keeps a `<Default>` and its `<Override>`s from ever disagreeing.
39
31
  const extensionDefaults = buildExtensionDefaults(commentNumbers, printerSettingsNumbers, mediaExtensions, preservedParts);
40
32
  return (XML_DECLARATION +
41
33
  `<Types xmlns="${NS.contentTypes}">` +
@@ -43,12 +35,6 @@ export function contentTypesXml(sheetCount, tables, commentNumbers, drawingNumbe
43
35
  contentTypeOverrides(sheetCount, tables, drawingNumbers, commentNumbers, hasSharedStrings, preservedParts, pivots, preservedWorkbookRefs, extensionDefaults, threadedCommentNumbers, hasPersons) +
44
36
  '</Types>');
45
37
  }
46
- // The content type declared at the `<Default>` level for each file extension the package carries, in
47
- // emission order: the built-in rels/xml pair, the vml/bin/image kinds the writer itself generates,
48
- // then one default per *new* extension a preserved binary part introduces (first part of an extension
49
- // wins). Keys are lower-cased for case-insensitive extension matching; each value keeps the extension
50
- // token as it will be emitted so rendering stays byte-stable. A preserved `.xml` part is deliberately
51
- // left out — it always carries its own type as a per-part override, never a generic `xml` default.
52
38
  function buildExtensionDefaults(commentNumbers, printerSettingsNumbers, mediaExtensions, preservedParts) {
53
39
  const defaults = new Map();
54
40
  const add = (extension, contentType) => {
@@ -72,36 +58,16 @@ function buildExtensionDefaults(commentNumbers, printerSettingsNumbers, mediaExt
72
58
  }
73
59
  return defaults;
74
60
  }
75
- // A macro-enabled workbook's own VBA project is round-tripped as a preserved workbook reference
76
- // (see `isPreservedWorkbookRelType` in read.ts), so its presence is what distinguishes a re-emitted
77
- // .xlsm from a plain .xlsx: `xl/workbook.xml` must declare the macro-enabled content type or Excel
78
- // flags the package as needing repair on open.
79
61
  function isMacroEnabled(preservedWorkbookRefs) {
80
62
  return preservedWorkbookRefs.some((ref) => ref.relType.endsWith('/vbaProject'));
81
63
  }
82
- // The extension-level `<Default>` declarations, rendered from the shared extension-default map (see
83
- // {@link buildExtensionDefaults}) in insertion order. The raw bytes of a VML, a printer-settings blob,
84
- // or an image carry no XML content type of their own, so each is declared once per extension rather
85
- // than per part; a preserved binary part whose type differs from its extension's default is corrected
86
- // by a per-part `<Override>` in {@link contentTypeOverrides}.
87
64
  function contentTypeDefaults(extensionDefaults) {
88
65
  return [...extensionDefaults.values()]
89
66
  .map(({ extension, contentType }) => defaultType(extension, contentType))
90
67
  .join('');
91
68
  }
92
- // The per-part `<Override>` declarations, in canonical package order: workbook, worksheets, tables,
93
- // drawings, comments, the threaded-comment parts, each pivot's three parts, theme, styles, the optional
94
- // shared strings and threaded-comment person registry, the doc-props pair, then any preserved part whose
95
- // content type its extension's `<Default>` does not already carry — every `.xml` part (the generic `xml`
96
- // default never matches a real part type) and any binary part sharing an extension with a differently-typed
97
- // sibling (a `vbaProjectSignature.bin` next to a `vbaProject.bin`), which a lone extension default would
98
- // otherwise mis-type.
99
69
  function contentTypeOverrides(sheetCount, tables, drawingNumbers, commentNumbers, hasSharedStrings, preservedParts, pivots, preservedWorkbookRefs, extensionDefaults, threadedCommentNumbers, hasPersons) {
100
70
  const preservedOverrides = preservedParts
101
- // A preserved theme lands at the fixed theme path, whose override is already in the list below —
102
- // and OPC forbids declaring the same PartName twice. The fixed declaration is the right one to
103
- // keep: the part reached the model through a `.../theme` relationship, so its type is the theme
104
- // type whatever the source package happened to declare.
105
71
  .filter((part) => part.path !== THEME_PART_PATH)
106
72
  .filter((part) => extensionDefaults.get(extensionOf(part.path).toLowerCase())?.contentType !==
107
73
  part.contentType)
@@ -128,15 +94,9 @@ function contentTypeOverrides(sheetCount, tables, drawingNumbers, commentNumbers
128
94
  function override(partName, contentType) {
129
95
  return `<Override PartName="${partName}" ContentType="${contentType}"/>`;
130
96
  }
131
- // A `<Default>` content-type declaration binding a file extension to the type every part with that
132
- // extension carries — the extension-level counterpart to {@link override}'s per-part declaration.
133
97
  function defaultType(extension, contentType) {
134
98
  return `<Default Extension="${extension}" ContentType="${contentType}"/>`;
135
99
  }
136
- // The package root relationships: the three the writer regenerates from the model (the office
137
- // document and the core/app properties), followed by any preserved root references — customUI ribbon
138
- // parts, custom properties, a thumbnail — re-declared with fresh ids past the fixed three so a
139
- // round-trip keeps content wired from `_rels/.rels` that the model does not otherwise emit.
140
100
  export function rootRelsXml(rootRefs) {
141
101
  return relationshipsPart([
142
102
  relationship('rId1', REL.officeDocument, 'xl/workbook.xml'),
@@ -165,12 +125,6 @@ export function workbookXml(workbook, preservedRels, pivots) {
165
125
  workbookExtLstXml(preservedRels) +
166
126
  '</workbook>');
167
127
  }
168
- // `<bookViews>` follows `<workbookProtection>` and precedes `<sheets>` in CT_Workbook order. Unlike
169
- // every other optional block here it is emitted unconditionally: a consumer restores the document
170
- // window from this rect and lays the sheet's panes out inside it, so a package without one leaves a
171
- // frozen split measured against an uninitialised window (see `DEFAULT_WORKBOOK_VIEW`). The
172
- // visibility and minimised flags are written only when they differ from the schema default, and
173
- // `activeTab` only for a non-first sheet — exactly the shape Excel writes for an ordinary window.
174
128
  function bookViewsXml(workbook) {
175
129
  const view = workbook.view;
176
130
  const activeTab = workbook.activeTabIndex;
@@ -182,12 +136,6 @@ function bookViewsXml(workbook) {
182
136
  (activeTab === 0 ? '' : ` activeTab="${activeTab}"`);
183
137
  return `<bookViews><workbookView${attrs}/></bookViews>`;
184
138
  }
185
- // The `<externalReferences>` element registers each preserved external link (a link to a source
186
- // workbook), wired to the relationship reaching its `externalLink` part. It follows `<sheets>` in
187
- // CT_Workbook order and precedes `<definedNames>`; its child order is the `[n]` index a formula or
188
- // defined name resolves an external cell through, so entries are emitted by their captured
189
- // `externalReferenceIndex` — keeping every `[1]`, `[2]`, … pointing at the same linked workbook it did
190
- // before the round-trip. '' when no external link was preserved.
191
139
  function externalReferencesXml(preservedRels) {
192
140
  const links = preservedRels
193
141
  .filter((ref) => ref.externalReferenceIndex !== undefined)
@@ -197,10 +145,6 @@ function externalReferencesXml(preservedRels) {
197
145
  const entries = links.map((ref) => `<externalReference r:id="${ref.relId}"/>`).join('');
198
146
  return `<externalReferences>${entries}</externalReferences>`;
199
147
  }
200
- // The workbook-body `<x14:slicerCaches>` extension that registers each preserved slicer cache, wired
201
- // to the relationship reaching its cache part. Slicer caches (unlike pivot caches, which register in
202
- // `<pivotCaches>`) live only in this extension block, so re-emitting it is what lets Excel rediscover
203
- // the slicers. `<extLst>` is the final child of CT_Workbook. '' when no slicer cache was preserved.
204
148
  function workbookExtLstXml(preservedRels) {
205
149
  const caches = preservedRels.filter((ref) => ref.relType.endsWith('/slicerCache'));
206
150
  if (caches.length === 0)
@@ -208,11 +152,6 @@ function workbookExtLstXml(preservedRels) {
208
152
  const entries = caches.map((ref) => `<x14:slicerCache r:id="${ref.relId}"/>`).join('');
209
153
  return `<extLst>${x14Ext(SLICER_CACHES_EXT_URI, `<x14:slicerCaches>${entries}</x14:slicerCaches>`)}</extLst>`;
210
154
  }
211
- // The `<pivotCaches>` element registers each pivot cache under the `cacheId` a pivot table resolves
212
- // its cache through, wired to the relationship that reaches the cache definition. It follows
213
- // `<calcPr>` in CT_Workbook order and carries both preserved caches (passed through from a read file)
214
- // and caches the writer generated for modeled pivot tables. A slicer cache (no `cacheId`) is
215
- // registered in a workbook extension block, not here, so it is skipped.
216
155
  function pivotCachesXml(preservedRels, pivots) {
217
156
  const preserved = preservedRels
218
157
  .filter((ref) => ref.pivotCacheId !== undefined)
@@ -223,10 +162,6 @@ function pivotCachesXml(preservedRels, pivots) {
223
162
  return '';
224
163
  return `<pivotCaches>${entries.join('')}</pivotCaches>`;
225
164
  }
226
- // `<workbookProtection>` precedes `<sheets>` in CT_Workbook order. It re-emits the workbook's
227
- // structure/window lock flags (each written only when true, so an unlocked aspect stays absent) and
228
- // the preserved password/agile-hash credential attributes verbatim. Emitted only when the workbook
229
- // actually declares protection — the flags or a credential — so an unprotected workbook stays clean.
230
165
  function workbookProtectionXml(workbook) {
231
166
  const p = workbook.protection;
232
167
  if (p === undefined)
@@ -247,24 +182,10 @@ function workbookProtectionXml(workbook) {
247
182
  return '';
248
183
  return `<workbookProtection ${attrs.join(' ')}/>`;
249
184
  }
250
- // The `calcId` desktop Excel stamps into `<calcPr>` — the build number of the calc engine that last
251
- // evaluated the workbook. Consumers only compare it to decide whether cached results are stale; we
252
- // emit a fixed recent value alongside `fullCalcOnLoad`, which already forces a full recalculation.
253
185
  const EXCEL_CALC_ID = '171027';
254
- // `<calcPr>` follows `<definedNames>` in CT_Workbook order and carries the calculation settings.
255
- // Today the model exposes a single one: `fullCalcOnLoad`, which tells the consumer to recalculate
256
- // every formula on open instead of trusting the cached results. Emitted only when set, so an
257
- // unmarked workbook keeps the element (and its `calcId`) out of the file entirely.
258
186
  function calcPrXml(workbook) {
259
187
  return workbook.fullCalcOnLoad ? `<calcPr calcId="${EXCEL_CALC_ID}" fullCalcOnLoad="1"/>` : '';
260
188
  }
261
- // The `<definedNames>` block follows `<sheets>` in the schema. A sheet-scoped name carries a
262
- // `localSheetId` — the 0-based position of its sheet among the `<sheet>` entries, NOT the sheet's
263
- // own id — so the index is resolved against the worksheet order here. The refersTo formula is the
264
- // element's text content, run through the same `_xlfn.` function mangling the writer applies to a
265
- // cell formula so a name defined as a modern function (a LAMBDA, an XLOOKUP-based name) is stored
266
- // under the prefix Excel requires; a plain reference has no function call and passes through
267
- // untouched. Only names that are actually set emit anything.
268
189
  function definedNamesXml(workbook) {
269
190
  const sheets = workbook.worksheets;
270
191
  const userEntries = workbook.definedNames.map((name) => {
@@ -276,10 +197,6 @@ function definedNamesXml(workbook) {
276
197
  return (`<definedName name="${escapeAttr(name.name)}"${scopeAttr}${commentAttr}${hiddenAttr}>` +
277
198
  `${escapeText(mangleFormula(name.refersTo))}</definedName>`);
278
199
  });
279
- // Every sheet-level autofilter contributes the hidden, sheet-scoped `_FilterDatabase` built-in that
280
- // Excel derives from its range. The reader drops these on load and rebuilds them from the sheet's
281
- // `<autoFilter>`, so `Worksheet.autoFilter` stays the single source of truth and a round-trip never
282
- // duplicates them.
283
200
  const filterEntries = sheets.flatMap((sheet, index) => sheet.autoFilter === undefined
284
201
  ? []
285
202
  : [
@@ -291,17 +208,10 @@ function definedNamesXml(workbook) {
291
208
  return '';
292
209
  return `<definedNames>${entries.join('')}</definedNames>`;
293
210
  }
294
- // Build the sheet-qualified, fully-absolute reference a `_FilterDatabase` name carries
295
- // (`'Sheet 1'!$A$1:$C$10`) from a sheet name and its already-canonical `A1:C10` autofilter range.
296
211
  function filterDatabaseRefersTo(sheetName, range) {
297
212
  const absolute = range.replace(/([A-Z]+)(\d+)/g, '$$$1$$$2');
298
213
  return `${quoteSheetName(sheetName)}!${absolute}`;
299
214
  }
300
- // The relationships the workbook part always carries after its per-sheet rels: `styles.xml` and
301
- // `theme/theme1.xml`. Their count anchors every downstream rel id — `sharedStrings.xml` and the
302
- // threaded-comment person registry (when present), then the preserved/pivot caches — so `write.ts`
303
- // derives its `workbookRelBase` from this same constant rather than repeating the literal and risking
304
- // drift.
305
215
  export const FIXED_WORKBOOK_REL_COUNT = 2;
306
216
  export function workbookRelsXml(sheetCount, hasSharedStrings, personsRelId, preservedRels, pivots) {
307
217
  return relationshipsPart([
@@ -313,14 +223,10 @@ export function workbookRelsXml(sheetCount, hasSharedStrings, personsRelId, pres
313
223
  relationship(`rId${sheetCount + FIXED_WORKBOOK_REL_COUNT + 1}`, REL.sharedStrings, 'sharedStrings.xml'),
314
224
  ]
315
225
  : []),
316
- // The threaded-comment identity registry every conversation on every sheet resolves its authors and
317
- // @mentions through. Workbook-level and singular, so this one relationship serves all the sheets.
318
226
  ...(personsRelId === null
319
227
  ? []
320
228
  : [relationship(personsRelId, REL.person, 'persons/person.xml')]),
321
- // A preserved cache's target is package-absolute; express it relative to the workbook part.
322
229
  ...preservedRels.map((ref) => relationship(ref.relId, ref.relType, escapeAttr(relativePartPath('xl/workbook.xml', ref.entryPath)))),
323
- // A generated pivot cache's workbook relationship reaches its cache definition part.
324
230
  ...pivots.map((pivot) => relationship(pivot.workbookRelId, REL.pivotCacheDefinition, `pivotCache/pivotCacheDefinition${pivot.number}.xml`)),
325
231
  ]);
326
232
  }