@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,22 +1,3 @@
1
- // Streaming row reader: yield a worksheet's rows one at a time, without ever building the whole
2
- // {@link Workbook} model.
3
- //
4
- // `readXlsx` materialises every cell of every sheet as a live `Cell` object held in nested Maps —
5
- // fine for editing, but for a large sheet read purely to extract its data it holds the entire grid
6
- // in memory at once. This reader instead *pulls* the sheet's XML through `xmlEvents` and yields a
7
- // plain {@link StreamedRow} at each `</row>`, retaining only the row currently in hand. Peak model
8
- // memory is one row, not the sheet.
9
- //
10
- // Two entry points sit on the same scanner:
11
- // - {@link readSheetRows} streams a single selected sheet's rows (the terse data-extraction case).
12
- // - {@link readWorkbookStream} yields a {@link StreamedSheet} per worksheet in workbook order, so a
13
- // caller can walk every sheet — each sheet's rows still stream one at a time.
14
- //
15
- // Scope of this slice: the package is still inflated whole (bounded by the running counter in
16
- // `./inflate.ts`) and shared strings / styles are read as whole parts — both are legitimately
17
- // document-sized and cheap. What this avoids is retaining N materialised cells. A later slice can
18
- // make the inflate itself per-part lazy; the pull primitive this stands on (`xmlEvents`) is the
19
- // same one that path will use.
20
1
  import { MAX_COLUMN } from '../../core/address.js';
21
2
  import { AuthoringError } from '../../errors.js';
22
3
  import { boolStrict, closeEmptyElements, localName, xmlEvents } from '../../xml/xml-read.js';
@@ -25,50 +6,18 @@ import { inflateSpreadsheetPackage, unsupportedWorkbookPart } from '../opc/sniff
25
6
  import { CellAccumulator } from './cell-accumulator.js';
26
7
  import { XlsxParseError } from './errors.js';
27
8
  import { DEFAULT_MAX_UNCOMPRESSED, parseRelationships, parseStyleTable, parseWorkbookSheets, resolveWorkbookPart, } from './read.js';
28
- import { parseSharedStrings } from './shared-strings-read.js';
29
- /**
30
- * Stream a worksheet's rows from an `.xlsx` package, yielding each in sheet order without building
31
- * the workbook model. Only rows the sheet actually declares are yielded, and within a row only its
32
- * non-empty cells — a blank or style-only cell contributes nothing, matching the intent of a data
33
- * read.
34
- *
35
- * @param data The raw `.xlsx` bytes.
36
- * @param options Sheet selector and the inflate bound (see {@link ReadSheetRowsOptions}).
37
- * @throws {UnsupportedFormatError} if the input is not a readable `.xlsx` package (a legacy `.xls`, a
38
- * binary `.xlsb`, or an unrecognised/non-ZIP blob — branch on `.format`).
39
- * @throws {PackageReadError} if the input is a ZIP that cannot be unpacked — a corrupt or
40
- * truncated archive, or one exceeding the inflate bound (a probable zip bomb).
41
- * @throws {XlsxParseError} if the package's workbook part declares no worksheets.
42
- * @throws {RangeError} / {@link AuthoringError} if `options.sheet` selects a position, or a name,
43
- * that no worksheet has.
44
- */
9
+ import { parseSharedStrings } from './read-shared-strings.js';
45
10
  export function* readSheetRows(data, options = {}) {
46
11
  const pkg = openPackage(data, options.maxUncompressedBytes);
47
12
  const chosen = pickSheet(pkg.sheets, options.sheet);
48
13
  const sheetXml = pkg.sheetXml(chosen.relId);
49
- // The sheet is named but its part is missing (a truncated or foreign package) — it has no rows.
50
14
  if (sheetXml === undefined)
51
15
  return;
52
16
  yield* scanSheet(sheetXml, pkg.sharedStrings, pkg.xfStyles, new Set(), []);
53
17
  }
54
- /**
55
- * Stream every worksheet of an `.xlsx` package in workbook order, without building the workbook
56
- * model. Each yielded {@link StreamedSheet} carries the declared sheet name and lets the caller
57
- * stream that sheet's rows and read its hidden-column and merge summaries — the streaming analogue
58
- * of walking `readXlsx(data).worksheets`.
59
- *
60
- * @param data The raw `.xlsx` bytes.
61
- * @param options The inflate bound (see {@link ReadXlsxOptions}).
62
- * @throws {UnsupportedFormatError} if the input is not a readable `.xlsx` package (a legacy `.xls`, a
63
- * binary `.xlsb`, or an unrecognised/non-ZIP blob — branch on `.format`).
64
- * @throws {PackageReadError} if the input is a ZIP that cannot be unpacked — a corrupt or
65
- * truncated archive, or one exceeding the inflate bound (a probable zip bomb).
66
- */
67
18
  export function* readWorkbookStream(data, options = {}) {
68
19
  const pkg = openPackage(data, options.maxUncompressedBytes);
69
20
  for (const sheet of pkg.sheets) {
70
- // A named sheet whose part is missing (truncated/foreign package) still surfaces — with no rows,
71
- // no hidden columns, and no merges — rather than vanishing from the workbook's sheet list.
72
21
  const xml = pkg.sheetXml(sheet.relId) ?? '';
73
22
  yield new StreamedSheetReader(sheet.name, xml, pkg.sharedStrings, pkg.xfStyles);
74
23
  }
@@ -77,9 +26,6 @@ function openPackage(data, maxUncompressedBytes) {
77
26
  const cap = maxUncompressedBytes ?? DEFAULT_MAX_UNCOMPRESSED;
78
27
  const { partText: text } = packageAccessors(inflateSpreadsheetPackage(data, cap));
79
28
  const workbookXml = text('xl/workbook.xml');
80
- // A binary `.xlsb` is a workbook this library *can* read — just not through here. Row streaming is
81
- // built on the XML worksheet parser, so the binary cell table has no streaming path yet; say so,
82
- // rather than reporting the format as unreadable when `readXlsx` would take the very same bytes.
83
29
  if (workbookXml === undefined) {
84
30
  throw unsupportedWorkbookPart(text, 'the binary .xlsb format (BIFF12) cannot be row-streamed yet; read it with readXlsx or readXlsb');
85
31
  }
@@ -115,10 +61,6 @@ function pickSheet(sheets, selector) {
115
61
  throw new AuthoringError(`no worksheet named ${JSON.stringify(selector)}`);
116
62
  return sheet;
117
63
  }
118
- // A single worksheet exposed by readWorkbookStream. Its rows() re-scans on each call (a fresh pass,
119
- // so it is safely re-iterable); the hidden-column and merge accessors reuse a completed scan or, if
120
- // the rows were never drained, drive one of their own. The hidden/merge state is filled in by the
121
- // same scanSheet pass that yields the rows.
122
64
  class StreamedSheetReader {
123
65
  name;
124
66
  #xml;
@@ -148,37 +90,19 @@ class StreamedSheetReader {
148
90
  this.#ensureScanned();
149
91
  return this.#merges;
150
92
  }
151
- // Drain a scan purely for its summaries when the caller reads them without (or before) iterating
152
- // rows. A completed row iteration already set #scanned, so this re-scans nothing in the common
153
- // streaming idiom.
154
93
  #ensureScanned() {
155
94
  if (this.#scanned)
156
95
  return;
157
96
  for (const _row of this.rows()) {
158
- // The rows themselves are irrelevant here — we only want the hidden/merge side effects.
159
97
  }
160
98
  }
161
99
  }
162
- // A formatted-but-empty `<c/>` is expanded to open+close so it finalises once on close, matching
163
- // the buffered reader; the text-bearing `<f/>`/`<v/>`/`<t/>` are excluded so an empty one never
164
- // commits (their close captures text, which an empty tag has none of).
165
100
  const CELL_EMPTY_CLOSE = new Set(['c']);
166
- // Pull the sheet XML through the event stream, yielding a StreamedRow at each `</row>`, while
167
- // recording the sheet's hidden columns (from `<col hidden>`, before <sheetData>) and merged ranges
168
- // (from `<mergeCells>`, after <sheetData>) into the caller-supplied collectors. The cell state
169
- // mirrors the buffered reader's `parseWorksheet` (same self-closing-`<c/>` handling, same capture
170
- // flags), but commits into a row buffer that is handed off and discarded per row rather than into a
171
- // persistent Worksheet — that hand-off is what bounds retained memory to one row.
172
101
  function* scanSheet(xml, sharedStrings, xfStyles, hiddenColumns, merges) {
173
102
  let rowNumber = 0;
174
103
  let lastRow = 0;
175
104
  let rowHidden = false;
176
105
  let cells = [];
177
- // The in-flight `<c>`, gathered exactly as the buffered reader gathers it. This reader drives the
178
- // same beginCell/setFormula/setValue/appendText methods, then takes only the cell's plain decoded
179
- // value (via decode) — never the shared-formula / data-table resolution the buffered finalize adds,
180
- // which a data read does not want. Rich `<r>` runs are deliberately not opened here, so a rich
181
- // inline string flattens to its concatenated text as a streamed value always has.
182
106
  const cell = new CellAccumulator();
183
107
  let inInlineString = false;
184
108
  let capture = false;
@@ -188,8 +112,6 @@ function* scanSheet(xml, sharedStrings, xfStyles, hiddenColumns, merges) {
188
112
  return;
189
113
  const style = cell.styleIndex >= 0 ? xfStyles[cell.styleIndex] : undefined;
190
114
  const value = cell.decode(sharedStrings, style);
191
- // A blank or purely style-only cell decodes to null; a data read wants only cells that carry
192
- // something (a formula object, an empty string, a false, and a 0 all count — only null drops).
193
115
  if (value !== null) {
194
116
  const { col, ref } = cell;
195
117
  cells.push(style ? { col, address: ref, value, style } : { col, address: ref, value });
@@ -243,7 +165,6 @@ function* scanSheet(xml, sharedStrings, xfStyles, hiddenColumns, merges) {
243
165
  capture = false;
244
166
  continue;
245
167
  }
246
- // close
247
168
  const local = localName(event.name);
248
169
  switch (local) {
249
170
  case 'f':
@@ -270,9 +191,6 @@ function* scanSheet(xml, sharedStrings, xfStyles, hiddenColumns, merges) {
270
191
  capture = false;
271
192
  }
272
193
  }
273
- // Record the hidden columns a `<col min max hidden>` element declares. The span is clamped to the
274
- // format's column ceiling and gathered into a Set, so even a hostile file full of full-width hidden
275
- // spans can add at most MAX_COLUMN distinct entries — never an unbounded allocation.
276
194
  function collectHiddenColumn(attrs, hiddenColumns) {
277
195
  if (attrs.hidden !== '1' && attrs.hidden !== 'true')
278
196
  return;
@@ -1,19 +1,9 @@
1
- // Parser for the shared-string table (`xl/sharedStrings.xml`), the pool that `t="s"` cells index into.
2
- // Split out of read.ts beside its sibling parsers (read-styles.ts, rich-runs.ts) so read.ts stays
3
- // orchestration; the run structure it decodes is owned by RunAccumulator, shared with inline strings.
4
1
  import { localName, parseXml } from '../../xml/xml-read.js';
5
2
  import { RunAccumulator } from './rich-runs.js';
6
- // Shared strings resolve `t="s"` cells. Each `<si>` is one entry: a plain `<si><t>…</t>` decodes to a
7
- // string, while a rich `<si><r><rPr>…</rPr><t>…</t></r>…` decodes to a {@link RichTextValue} whose runs
8
- // carry their per-run fonts — so rich text Excel pooled reads back formatted, not flattened to text.
9
- // The run structure inside an `<si>` is identical to an inline string's `<is>`, so it is parsed the
10
- // same way (see the inline-run accumulation in `parseWorksheet`).
11
3
  export function parseSharedStrings(xml) {
12
4
  if (xml === '')
13
5
  return [];
14
6
  const strings = [];
15
- // Per-`<si>` accumulation: `plain` gathers a bare `<t>`; `runs` gathers `<r>` runs. An `<si>` is
16
- // rich the moment it holds one `<r>`, at which point its runs — not `plain` — become the entry.
17
7
  let plain = '';
18
8
  const runs = new RunAccumulator();
19
9
  let isRich = false;
@@ -52,7 +42,6 @@ export function parseSharedStrings(xml) {
52
42
  const local = localName(name);
53
43
  switch (local) {
54
44
  case 't':
55
- // A `<t>` inside a run is that run's text; a bare `<t>` directly in the `<si>` is plain.
56
45
  if (!runs.appendText(text))
57
46
  plain += text;
58
47
  capture = false;
@@ -1,4 +1,4 @@
1
- import { type Font } from '../../core/style.ts';
1
+ import { type Font, type TableStyleTable } from '../../core/style.ts';
2
2
  import { type XmlAttributes } from '../../xml/xml-read.ts';
3
3
  import { type StyleTable } from '../style/xf-style.ts';
4
4
  export type FontDraft = {
@@ -6,3 +6,27 @@ export type FontDraft = {
6
6
  };
7
7
  export declare function parseStyleTable(xml: string): StyleTable;
8
8
  export declare function applyFontChild(draft: FontDraft, local: string, attrs: XmlAttributes): void;
9
+ /**
10
+ * Extract the custom indexed-color palette (`<colors><indexedColors>`) from styles.xml as verbatim
11
+ * `<rgbColor rgb="…"/>` fragments, or an empty list when the file rides the default palette. Kept raw
12
+ * — rather than parsed into RGB and re-serialised — so the exact entries (count, order, casing) a
13
+ * source file declared survive a round-trip and every `indexed="…"` reference keeps its RGB.
14
+ */
15
+ export declare function parseIndexedColors(stylesXml: string): string[];
16
+ /**
17
+ * Extract the most-recently-used colour swatches (`<colors><mruColors>`) from styles.xml as verbatim
18
+ * `<color .../>` fragments, or an empty list when the file declares none. Kept raw for the same reason
19
+ * the indexed palette is: the list is the author's own working set of colours and the model has no
20
+ * use for its contents, only for not losing them.
21
+ */
22
+ export declare function parseMruColors(stylesXml: string): string[];
23
+ /**
24
+ * Extract the `<tableStyles>` block from styles.xml: each `<tableStyle>` definition verbatim, plus the
25
+ * container's nominated `defaultTableStyle`/`defaultPivotStyle`. See {@link TableStyleTable} for why
26
+ * the definitions stay raw while the two names are decoded.
27
+ *
28
+ * A file with no such block — or with the self-closing `count="0"` container Excel writes when it has
29
+ * only defaults to state — yields an empty {@link TableStyleTable.styles} and whichever names it did
30
+ * carry.
31
+ */
32
+ export declare function parseTableStyles(stylesXml: string): TableStyleTable;
@@ -1,24 +1,9 @@
1
- // The style-table reader: `xl/styles.xml` in, a flat table of resolved cell formats out. It is a
2
- // single streaming pass over the shared sub-tables (`<numFmts>`, `<fills>`, `<fonts>`, `<borders>`)
3
- // and the two xf tables (`<cellXfs>`, `<cellStyleXfs>`), flattening the id-indirection so a cell's
4
- // `s` index maps straight to its facets. A construct it does not recognise is skipped, never guessed.
5
- //
6
- // Only the parsing is XML-specific. What an xf resolves *to* — `XfStyle`, the built-in number
7
- // formats, applying an xf to a cell — is a property of the OOXML style model rather than of its
8
- // spelling, and lives above both codecs in `../style/xf-style.ts`; the `.xlsb` style reader builds
9
- // the same table from BIFF12 records.
10
1
  import { assignStyleFacets, isBorderStyle, isFillPatternType, isFontScheme, isFontVerticalAlignment, isHorizontalAlignment, isNamedUnderlineStyle, isVerticalAlignment, } from '../../core/style.js';
11
- import { boolPresent, boolStrict, closeEmptyElements, localName, xmlEvents, } from '../../xml/xml-read.js';
2
+ import { boolPresent, boolStrict, closeEmptyElements, localName, openElements, xmlEvents, } from '../../xml/xml-read.js';
12
3
  import { numFmtCodeFor } from '../style/xf-style.js';
13
- import { parseColor } from './styles.js';
14
- // The four sides plus the diagonal — the edge elements a <border> can hold, in the order the
15
- // schema lists them. This one tuple drives the edge-name union, the membership set (which drives
16
- // edge parsing without a per-name branch), and the "does any edge carry a style" scan below.
4
+ import { parseColor } from './color-xml.js';
17
5
  const BORDER_EDGE_NAMES = ['left', 'right', 'top', 'bottom', 'diagonal'];
18
6
  const BORDER_EDGES = new Set(BORDER_EDGE_NAMES);
19
- // Style-table elements that commit on their close: a bare <font/>/<border/>/<patternFill/>/
20
- // <gradientFill/>/<xf/> or a self-closing border edge is expanded to open+close so each commits
21
- // exactly once in onClose, never in a duplicated (and easily-forgotten) self-closing branch.
22
7
  const STYLE_EMPTY_CLOSES = new Set([
23
8
  'font',
24
9
  'border',
@@ -27,11 +12,6 @@ const STYLE_EMPTY_CLOSES = new Set([
27
12
  'xf',
28
13
  ...BORDER_EDGES,
29
14
  ]);
30
- // styles.xml is a shared table: <numFmts> defines custom format codes by id, <fills> lists
31
- // the fills, and <cellXfs> lists the cell formats, each naming a fill and a number format by
32
- // id. We flatten that indirection into one array — cellXfs index → resolved {fill, numFmt} —
33
- // so a cell/row/column style index maps straight to its facets. The schema orders <numFmts>
34
- // and <fills> before <cellXfs>, so both lookups are complete before an xf references them.
35
15
  export function parseStyleTable(xml) {
36
16
  if (xml === '')
37
17
  return { cellXfs: [], namedStyles: [] };
@@ -40,16 +20,8 @@ export function parseStyleTable(xml) {
40
20
  let borders = [];
41
21
  let numFmtCodes = new Map();
42
22
  const xfStyles = [];
43
- // The named-style layer: <cellStyleXfs> holds the base formats a cell's xfId links to; <cellStyles>
44
- // labels them by name/builtinId. Parsed in parallel with cellXfs, then zipped and merged below.
45
23
  const namedXfs = [];
46
24
  let cellStyleNames = [];
47
- // One streaming pass, but each top-level sub-table drives its own focused sub-parser over the slice
48
- // of events between its open and close. The schema orders the shared tables (<numFmts>, <fonts>,
49
- // <fills>, <borders>) before the xf tables, so their results are complete before an <xf> resolves
50
- // against them. Every recognised container name is plural and unique to the styleSheet root — none
51
- // appears inside a <dxf>'s singular <font>/<fill>/<border> children — so skipping an unrecognised
52
- // section here drops exactly what the old flat pass gated off with its `in*` flags.
53
25
  const events = closeEmptyElements(xmlEvents(xml), STYLE_EMPTY_CLOSES);
54
26
  let next = events.next();
55
27
  while (next.done !== true) {
@@ -81,18 +53,12 @@ export function parseStyleTable(xml) {
81
53
  }
82
54
  next = events.next();
83
55
  }
84
- // Layer each cellXfs entry over the named style its xfId links to: a facet the direct format sets
85
- // wins; one it leaves unset falls through to the named style. The xfId is carried through so the
86
- // link survives a re-write. A draft only holds keys for facets it actually set, so the spread merge
87
- // takes the named base and lets the direct entry override exactly what it names.
88
56
  const cellXfs = xfStyles.map((xf) => {
89
57
  if (xf.xfId === undefined)
90
58
  return xf;
91
59
  const named = namedXfs[xf.xfId];
92
60
  return named === undefined ? xf : { ...named, ...xf };
93
61
  });
94
- // Zip the resolved cellStyleXfs facets with their cellStyles name/builtinId into the model's named
95
- // styles, index for index (a cellStyle's xfId is its cellStyleXfs index).
96
62
  const namedStyles = namedXfs.map((xf, index) => {
97
63
  const label = cellStyleNames.find((entry) => entry.xfId === index);
98
64
  const style = {};
@@ -103,15 +69,9 @@ export function parseStyleTable(xml) {
103
69
  style.builtinId = label.builtinId;
104
70
  return style;
105
71
  });
106
- // Font 0 is the workbook's declared default, so it is carried out whole as well as flattened onto
107
- // the xfs that name it — see {@link StyleTable.defaultFont}.
108
72
  const defaultFont = fonts[0];
109
73
  return defaultFont === undefined ? { cellXfs, namedStyles } : { cellXfs, namedStyles, defaultFont };
110
74
  }
111
- // Pull events off the shared stream up to — and consuming — the close of `container`, yielding only
112
- // those strictly inside it. A sub-table parser loops this to completion (never breaking), so it drives
113
- // its own small state machine over exactly its section without closing the underlying generator, and
114
- // the outer pass resumes at the element after the container's close.
115
75
  function* until(events, container) {
116
76
  let next = events.next();
117
77
  while (next.done !== true) {
@@ -122,8 +82,6 @@ function* until(events, container) {
122
82
  next = events.next();
123
83
  }
124
84
  }
125
- // <numFmts> entries are self-closing, so they are read on open. A code with no id, or the General
126
- // id 0, contributes nothing.
127
85
  function parseNumFmts(events) {
128
86
  const codes = new Map();
129
87
  for (const event of until(events, 'numFmts')) {
@@ -142,7 +100,6 @@ function parseFonts(events) {
142
100
  for (const event of until(events, 'fonts')) {
143
101
  if (event.kind === 'open') {
144
102
  const local = localName(event.name);
145
- // A <font>'s children are self-closing, so they are read here on open.
146
103
  if (local === 'font')
147
104
  fontDraft = {};
148
105
  else if (fontDraft !== null)
@@ -160,9 +117,6 @@ function parseFills(events) {
160
117
  let pattern = '';
161
118
  let fgColor;
162
119
  let bgColor;
163
- // A gradient fill accumulates from <gradientFill> open to close; its stops fill in as <stop>/<color>
164
- // pairs arrive. `fillSlotAt` marks where in `fills` the current <fill> began, so its close can keep a
165
- // slot even when the fill body was neither a pattern nor a gradient — index alignment is load-bearing.
166
120
  let gradientDraft = null;
167
121
  let fillSlotAt = -1;
168
122
  for (const event of until(events, 'fills')) {
@@ -170,9 +124,6 @@ function parseFills(events) {
170
124
  const attrs = event.attrs;
171
125
  switch (localName(event.name)) {
172
126
  case 'fill':
173
- // Mark where this <fill> starts so its close can guarantee exactly one slot — a fill body
174
- // that is neither <patternFill> nor <gradientFill> (or a gradient we could not parse) must
175
- // still consume an id, or every later fill index shifts and cells mis-resolve their fill.
176
127
  fillSlotAt = fills.length;
177
128
  break;
178
129
  case 'patternFill':
@@ -206,7 +157,6 @@ function parseFills(events) {
206
157
  }
207
158
  break;
208
159
  case 'color':
209
- // The colour of the open <stop>; committed to a GradientStop when the stop closes.
210
160
  if (gradientDraft !== null)
211
161
  gradientDraft.stopColor = parseColor(attrs);
212
162
  break;
@@ -235,8 +185,6 @@ function parseFills(events) {
235
185
  }
236
186
  break;
237
187
  case 'fill':
238
- // Backstop the slot: if this <fill>'s body pushed nothing (unparsed/unknown content), keep an
239
- // empty slot so id alignment holds and later fills still resolve to the right cells.
240
188
  if (fills.length === fillSlotAt)
241
189
  fills.push(undefined);
242
190
  break;
@@ -248,7 +196,6 @@ function parseFills(events) {
248
196
  function parseBorders(events) {
249
197
  const borders = [];
250
198
  let borderDraft = null;
251
- // Which edge of the current border a nested <color> belongs to; null between edges.
252
199
  let currentEdge = null;
253
200
  for (const event of until(events, 'borders')) {
254
201
  if (event.kind === 'open') {
@@ -263,9 +210,6 @@ function parseBorders(events) {
263
210
  borderDraft.diagonalDown = true;
264
211
  }
265
212
  else if (borderDraft !== null) {
266
- // A border's edges and their <color> children are all read on open (each is self-closing bar a
267
- // coloured edge, whose colour child is itself self-closing). An edge whose style is absent or
268
- // an unrecognised token is dropped — the side simply carries no border.
269
213
  if (BORDER_EDGES.has(local)) {
270
214
  if (attrs.style !== undefined && isBorderStyle(attrs.style)) {
271
215
  currentEdge = local;
@@ -292,39 +236,28 @@ function parseBorders(events) {
292
236
  }
293
237
  }
294
238
  else if (borderDraft !== null && BORDER_EDGES.has(local)) {
295
- // A coloured edge closes after its <color> child; drop the edge context so a stray later
296
- // <color> cannot attach to it.
297
239
  currentEdge = null;
298
240
  }
299
241
  }
300
242
  }
301
243
  return borders;
302
244
  }
303
- // Both <cellXfs> and <cellStyleXfs> hold <xf> with identical structure; they differ only in which
304
- // table the result lands in and whether an xfId link is meaningful (only cellXfs entries link to a
305
- // named style). One parser serves both, told by `container` which it is reading.
306
245
  function parseXfTable(events, container, deps) {
307
246
  const xfs = [];
308
247
  const captureXfId = container === 'cellXfs';
309
- // The xf being read; held from open to close so its <alignment>/<protection> children can attach
310
- // before it is committed. null outside an <xf>.
311
248
  let pendingXf = null;
312
249
  for (const event of until(events, container)) {
313
250
  if (event.kind === 'open') {
314
251
  const local = localName(event.name);
315
252
  if (local === 'xf') {
316
- // Hold the xf open until its close so an <alignment>/<protection> child can attach first; a
317
- // self-closing <xf/> is expanded to a close, so it commits there too, child-free.
318
253
  pendingXf = resolveXf(event.attrs, deps, captureXfId);
319
254
  }
320
255
  else if (pendingXf !== null && local === 'alignment') {
321
- // An xf's <alignment> child arrives before the xf closes; attach it to the pending xf.
322
256
  const alignment = parseAlignment(event.attrs);
323
257
  if (alignment !== undefined)
324
258
  pendingXf.alignment = alignment;
325
259
  }
326
260
  else if (pendingXf !== null && local === 'protection') {
327
- // An xf's <protection> child likewise arrives before the xf closes.
328
261
  const protection = parseProtection(event.attrs);
329
262
  if (protection !== undefined)
330
263
  pendingXf.protection = protection;
@@ -337,18 +270,12 @@ function parseXfTable(events, container, deps) {
337
270
  }
338
271
  return xfs;
339
272
  }
340
- // Resolve an <xf>'s facet ids against the shared sub-tables into a draft. `captureXfId` is set only
341
- // for cellXfs entries, the sole table whose xfId links to a named style.
342
273
  function resolveXf(attrs, deps, captureXfId) {
343
274
  const fillId = Number(attrs.fillId);
344
275
  const fill = Number.isInteger(fillId) ? deps.fills[fillId] : undefined;
345
276
  const fontId = Number(attrs.fontId);
346
- // Font id 0 is the workbook default font (a real Calibri-11-style face), not an absence — unlike
347
- // border id 0, which is a genuinely empty border. So an xf naming font 0 resolves to that default
348
- // face, giving every cell a concrete font to render.
349
277
  const font = Number.isInteger(fontId) ? deps.fonts[fontId] : undefined;
350
278
  const borderId = Number(attrs.borderId);
351
- // Border id 0 is the empty default; only a custom border (id > 0) is an explicit one.
352
279
  const border = Number.isInteger(borderId) && borderId > 0 ? deps.borders[borderId] : undefined;
353
280
  const numFmt = resolveNumFmt(attrs.numFmtId, deps.numFmtCodes);
354
281
  const draft = {};
@@ -360,12 +287,8 @@ function resolveXf(attrs, deps, captureXfId) {
360
287
  draft.font = font;
361
288
  if (border)
362
289
  draft.border = border;
363
- // The quote-prefix flag is an attribute on the xf itself (no shared sub-table); carry it only when
364
- // set so an ordinary cell does not gain a spurious `quotePrefix: false`.
365
290
  if (boolStrict(attrs.quotePrefix))
366
291
  draft.quotePrefix = true;
367
- // A cellXfs entry's xfId links it to a named style; capture it only when it points beyond the Normal
368
- // default (0), so an ordinary cell carries no spurious named-style link.
369
292
  if (captureXfId && attrs.xfId !== undefined) {
370
293
  const xfId = Number(attrs.xfId);
371
294
  if (Number.isInteger(xfId) && xfId > 0)
@@ -373,8 +296,6 @@ function resolveXf(attrs, deps, captureXfId) {
373
296
  }
374
297
  return draft;
375
298
  }
376
- // A <cellStyle> (inside <cellStyles>) names a cellStyleXfs entry by xfId; it is self-closing, so it
377
- // is read on open.
378
299
  function parseCellStyles(events) {
379
300
  const names = [];
380
301
  for (const event of until(events, 'cellStyles')) {
@@ -396,9 +317,6 @@ function parseCellStyles(events) {
396
317
  }
397
318
  return names;
398
319
  }
399
- // A <font> child element sets one facet on the draft. Boolean flags honour their `val`: a
400
- // bare tag or val="1"/"true" is on, val="0"/"false" is off (an explicit-false flag is not
401
- // truthy merely because the tag is present). An unrecognised child is ignored.
402
320
  export function applyFontChild(draft, local, attrs) {
403
321
  switch (local) {
404
322
  case 'b':
@@ -414,10 +332,6 @@ export function applyFontChild(draft, local, attrs) {
414
332
  draft.outline = boolPresent(attrs.val);
415
333
  break;
416
334
  case 'u':
417
- // A bare <u/> is a single underline; a named style (single/double/…) carries through; but
418
- // val="none" is the explicit ABSENCE of an underline, so it must read back falsy — not the
419
- // truthy string "none" that a consumer's `if (font.underline)` would mistake for underlined. An
420
- // unrecognised token keeps the "is underlined" fact but drops the unknown style (a plain true).
421
335
  draft.underline =
422
336
  attrs.val === undefined
423
337
  ? true
@@ -440,7 +354,6 @@ export function applyFontChild(draft, local, attrs) {
440
354
  case 'color':
441
355
  draft.color = parseColor(attrs);
442
356
  break;
443
- // `<name>` in a styles `<font>`, `<rFont>` in a rich-text run's `<rPr>` — the same font face.
444
357
  case 'name':
445
358
  case 'rFont':
446
359
  if (attrs.val !== undefined)
@@ -466,15 +379,10 @@ export function applyFontChild(draft, local, attrs) {
466
379
  break;
467
380
  }
468
381
  }
469
- // An xf's numFmtId resolves against the custom codes first, then the built-in table; the
470
- // General format (id 0) and any unrecognised id mean the cell carries no explicit format.
471
382
  function resolveNumFmt(raw, custom) {
472
383
  return raw === undefined ? undefined : numFmtCodeFor(Number(raw), custom);
473
384
  }
474
385
  function toFill(pattern, fgColor, bgColor) {
475
- // `none` (and an absent patternType) is the absence of a fill; an unrecognised token is dropped the
476
- // same way — like the border-edge style above — so a foreign pattern we do not model leaves the cell
477
- // unfilled rather than propagating a token the writer would later re-emit unvalidated.
478
386
  if (!isFillPatternType(pattern) || pattern === 'none')
479
387
  return undefined;
480
388
  return {
@@ -484,8 +392,6 @@ function toFill(pattern, fgColor, bgColor) {
484
392
  ...(bgColor ? { bgColor } : {}),
485
393
  };
486
394
  }
487
- // Copy the numeric <gradientFill> attributes (degree; the path insets) onto a gradient draft, keeping
488
- // only the finite ones so an absent or malformed attribute leaves the field its OOXML default (unset).
489
395
  function assignGradientNumbers(fill, attrs) {
490
396
  for (const key of ['degree', 'left', 'right', 'top', 'bottom']) {
491
397
  const value = Number(attrs[key]);
@@ -493,22 +399,14 @@ function assignGradientNumbers(fill, attrs) {
493
399
  fill[key] = value;
494
400
  }
495
401
  }
496
- // An accumulated border with no styled edge and no diagonal direction is the empty default:
497
- // it carries nothing, so it resolves to undefined rather than an all-empty Border object.
498
402
  function borderToStyle(draft) {
499
403
  const hasEdge = BORDER_EDGE_NAMES.some((edge) => draft[edge] !== undefined);
500
404
  if (!hasEdge && draft.diagonalUp === undefined && draft.diagonalDown === undefined)
501
405
  return undefined;
502
406
  return draft;
503
407
  }
504
- // Read an <alignment> element's attributes into an Alignment, keeping only facets that differ
505
- // from the default. Boolean flags honour their parsed value — wrapText="0" is off, so it must
506
- // not fabricate a { wrapText: false } alignment — and an element carrying only defaults yields
507
- // undefined rather than an empty alignment object.
508
408
  function parseAlignment(attrs) {
509
409
  const out = {};
510
- // `general` is the default and reads back as no explicit horizontal alignment; an unrecognised
511
- // token (like an out-of-enum vertical one) is dropped rather than trusted into the model.
512
410
  if (attrs.horizontal !== undefined &&
513
411
  attrs.horizontal !== 'general' &&
514
412
  isHorizontalAlignment(attrs.horizontal)) {
@@ -537,10 +435,6 @@ function parseAlignment(attrs) {
537
435
  }
538
436
  return Object.keys(out).length > 0 ? out : undefined;
539
437
  }
540
- // Read a <protection> element into a Protection, keeping only facets that differ from the OOXML
541
- // default. `locked` defaults to TRUE, so only an explicit `locked="0"` carries information (an
542
- // unlocked cell) — a default or explicit-true cell must not read back as { locked: true }; `hidden`
543
- // defaults to false, so only `hidden="1"` is carried. An element with only defaults yields undefined.
544
438
  function parseProtection(attrs) {
545
439
  const out = {};
546
440
  if (attrs.locked === '0' || attrs.locked === 'false')
@@ -549,3 +443,62 @@ function parseProtection(attrs) {
549
443
  out.hidden = true;
550
444
  return Object.keys(out).length > 0 ? out : undefined;
551
445
  }
446
+ export function parseIndexedColors(stylesXml) {
447
+ return elementFragments(stylesXml, 'indexedColors', 'rgbColor');
448
+ }
449
+ export function parseMruColors(stylesXml) {
450
+ return elementFragments(stylesXml, 'mruColors', 'color');
451
+ }
452
+ export function parseTableStyles(stylesXml) {
453
+ const styles = elementFragments(stylesXml, 'tableStyles', 'tableStyle');
454
+ const table = { styles };
455
+ for (const { attrs } of openElements(stylesXml, 'tableStyles')) {
456
+ if (attrs.defaultTableStyle !== undefined)
457
+ table.defaultTableStyle = attrs.defaultTableStyle;
458
+ if (attrs.defaultPivotStyle !== undefined)
459
+ table.defaultPivotStyle = attrs.defaultPivotStyle;
460
+ break;
461
+ }
462
+ const namespaces = fragmentNamespaces(stylesXml, styles);
463
+ if (namespaces.length > 0)
464
+ table.namespaces = namespaces;
465
+ return table;
466
+ }
467
+ function fragmentNamespaces(stylesXml, fragments) {
468
+ if (fragments.length === 0)
469
+ return [];
470
+ const declared = new Map();
471
+ const ignorable = new Set();
472
+ for (const { attrs } of openElements(stylesXml, 'styleSheet')) {
473
+ for (const [name, value] of Object.entries(attrs)) {
474
+ if (name.startsWith('xmlns:'))
475
+ declared.set(name.slice('xmlns:'.length), value);
476
+ }
477
+ for (const prefix of (attrs['mc:Ignorable'] ?? '').split(/\s+/)) {
478
+ if (prefix !== '')
479
+ ignorable.add(prefix);
480
+ }
481
+ break;
482
+ }
483
+ const used = new Set();
484
+ for (const fragment of fragments) {
485
+ for (const match of fragment.matchAll(/[\s</]([A-Za-z_][\w.-]*):[A-Za-z_]/g)) {
486
+ used.add(match[1]);
487
+ }
488
+ }
489
+ return [...used]
490
+ .filter((prefix) => declared.has(prefix))
491
+ .map((prefix) => ({
492
+ prefix,
493
+ uri: declared.get(prefix),
494
+ ignorable: ignorable.has(prefix),
495
+ }));
496
+ }
497
+ function elementFragments(xml, container, child) {
498
+ const block = new RegExp(`<${container}\\b[^>]*>([\\s\\S]*?)</${container}>`).exec(xml);
499
+ if (block === null)
500
+ return [];
501
+ const inner = block[1] ?? '';
502
+ const pattern = new RegExp(`<${child}\\b[^>]*/>|<${child}\\b[^>]*>[\\s\\S]*?</${child}>`, 'g');
503
+ return [...inner.matchAll(pattern)].map((m) => m[0] ?? '');
504
+ }