@shbernal/ts-xlsx 2.0.0 → 3.0.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 (147) hide show
  1. package/README.md +20 -10
  2. package/dist/bytes.d.ts +24 -0
  3. package/dist/bytes.js +34 -0
  4. package/dist/core/address.d.ts +26 -4
  5. package/dist/core/address.js +21 -9
  6. package/dist/core/autofilter.d.ts +12 -1
  7. package/dist/core/autofilter.js +35 -12
  8. package/dist/core/axis-handle.d.ts +8 -0
  9. package/dist/core/axis-handle.js +14 -0
  10. package/dist/core/cell.d.ts +24 -0
  11. package/dist/core/cell.js +9 -1
  12. package/dist/core/color-resolution.d.ts +1 -1
  13. package/dist/core/color-resolution.js +7 -8
  14. package/dist/core/column.d.ts +4 -1
  15. package/dist/core/column.js +31 -35
  16. package/dist/core/conditional-formatting-overlay.d.ts +5 -0
  17. package/dist/core/conditional-formatting-overlay.js +11 -0
  18. package/dist/core/conditional-formatting.d.ts +45 -5
  19. package/dist/core/conditional-formatting.js +74 -0
  20. package/dist/core/data-validation-overlay.d.ts +11 -0
  21. package/dist/core/data-validation-overlay.js +21 -2
  22. package/dist/core/data-validation.d.ts +9 -2
  23. package/dist/core/data-validation.js +26 -0
  24. package/dist/core/grid-edits.d.ts +16 -0
  25. package/dist/core/grid-edits.js +26 -8
  26. package/dist/core/grid-shift.d.ts +19 -0
  27. package/dist/core/grid-shift.js +8 -0
  28. package/dist/core/image.d.ts +2 -0
  29. package/dist/core/image.js +2 -0
  30. package/dist/core/merge-index.d.ts +31 -0
  31. package/dist/core/merge-index.js +60 -0
  32. package/dist/core/merge.d.ts +12 -11
  33. package/dist/core/merge.js +36 -10
  34. package/dist/core/page-setup.d.ts +86 -2
  35. package/dist/core/page-setup.js +34 -1
  36. package/dist/core/pivot-table.d.ts +2 -0
  37. package/dist/core/pivot-table.js +23 -18
  38. package/dist/core/protection.d.ts +3 -0
  39. package/dist/core/protection.js +20 -14
  40. package/dist/core/row-input.d.ts +7 -0
  41. package/dist/core/row-input.js +10 -8
  42. package/dist/core/row.d.ts +4 -1
  43. package/dist/core/row.js +19 -23
  44. package/dist/core/style.d.ts +78 -7
  45. package/dist/core/style.js +90 -80
  46. package/dist/core/table.d.ts +26 -12
  47. package/dist/core/table.js +67 -33
  48. package/dist/core/theme.d.ts +3 -1
  49. package/dist/core/theme.js +4 -0
  50. package/dist/core/used-extent.d.ts +44 -0
  51. package/dist/core/used-extent.js +102 -0
  52. package/dist/core/workbook.d.ts +2 -2
  53. package/dist/core/worksheet-comments.d.ts +8 -0
  54. package/dist/core/worksheet-comments.js +18 -0
  55. package/dist/core/worksheet.d.ts +31 -7
  56. package/dist/core/worksheet.js +72 -86
  57. package/dist/customui/ribbon.js +23 -24
  58. package/dist/entries/core.d.ts +3 -3
  59. package/dist/entries/csv.d.ts +1 -1
  60. package/dist/entries/node-unavailable.d.ts +15 -0
  61. package/dist/entries/node-unavailable.js +20 -0
  62. package/dist/entries/node.d.ts +1 -0
  63. package/dist/entries/node.js +1 -0
  64. package/dist/entries/xlsx.d.ts +0 -1
  65. package/dist/entries/xlsx.js +0 -1
  66. package/dist/io/csv/read.js +1 -1
  67. package/dist/io/csv/write.d.ts +12 -2
  68. package/dist/io/csv/write.js +17 -2
  69. package/dist/io/opc/inflate.js +3 -13
  70. package/dist/io/opc/part-paths.d.ts +1 -0
  71. package/dist/io/opc/part-paths.js +15 -0
  72. package/dist/io/opc/read-opc.d.ts +22 -3
  73. package/dist/io/opc/read-opc.js +11 -39
  74. package/dist/io/opc/rels.d.ts +12 -6
  75. package/dist/io/opc/rels.js +3 -5
  76. package/dist/io/style/xf-style.d.ts +33 -4
  77. package/dist/io/style/xf-style.js +12 -1
  78. package/dist/io/xlsb/read-styles.js +6 -3
  79. package/dist/io/xlsb/read.d.ts +1 -1
  80. package/dist/io/xlsb/read.js +5 -8
  81. package/dist/io/xlsx/cell-accumulator.d.ts +2 -4
  82. package/dist/io/xlsx/cell-accumulator.js +20 -58
  83. package/dist/io/xlsx/cell-value.d.ts +1 -1
  84. package/dist/io/xlsx/cell-value.js +7 -4
  85. package/dist/io/xlsx/color-xml.d.ts +1 -1
  86. package/dist/io/xlsx/color-xml.js +8 -7
  87. package/dist/io/xlsx/comments.js +6 -3
  88. package/dist/io/xlsx/conditional-formatting.d.ts +4 -3
  89. package/dist/io/xlsx/conditional-formatting.js +81 -65
  90. package/dist/io/xlsx/data-validation.d.ts +10 -8
  91. package/dist/io/xlsx/data-validation.js +44 -41
  92. package/dist/io/xlsx/edit-vba.js +4 -11
  93. package/dist/io/xlsx/hyperlinks.d.ts +4 -2
  94. package/dist/io/xlsx/hyperlinks.js +26 -31
  95. package/dist/io/xlsx/images.js +33 -35
  96. package/dist/io/xlsx/package-plan.d.ts +9 -1
  97. package/dist/io/xlsx/package-plan.js +29 -16
  98. package/dist/io/xlsx/part-names.d.ts +41 -0
  99. package/dist/io/xlsx/part-names.js +46 -0
  100. package/dist/io/xlsx/read-pivot.js +4 -9
  101. package/dist/io/xlsx/read-rows.js +12 -13
  102. package/dist/io/xlsx/read-shared-strings.js +9 -47
  103. package/dist/io/xlsx/read-styles.d.ts +1 -1
  104. package/dist/io/xlsx/read-styles.js +65 -43
  105. package/dist/io/xlsx/read-worksheet.d.ts +10 -1
  106. package/dist/io/xlsx/read-worksheet.js +48 -63
  107. package/dist/io/xlsx/read.d.ts +3 -4
  108. package/dist/io/xlsx/read.js +124 -146
  109. package/dist/io/xlsx/rich-runs.d.ts +41 -7
  110. package/dist/io/xlsx/rich-runs.js +95 -30
  111. package/dist/io/xlsx/sheet-properties.d.ts +1 -1
  112. package/dist/io/xlsx/sheet-properties.js +23 -26
  113. package/dist/io/xlsx/styles.d.ts +3 -15
  114. package/dist/io/xlsx/styles.js +83 -105
  115. package/dist/io/xlsx/tables.js +14 -13
  116. package/dist/io/xlsx/theme-xml.js +8 -5
  117. package/dist/io/xlsx/threaded-comments.js +20 -19
  118. package/dist/io/xlsx/workbook-xml.js +43 -36
  119. package/dist/io/xlsx/worksheet-xml.d.ts +1 -1
  120. package/dist/io/xlsx/worksheet-xml.js +47 -52
  121. package/dist/io/xlsx/write-stream.d.ts +14 -1
  122. package/dist/io/xlsx/write-stream.js +40 -33
  123. package/dist/io/xlsx/write.d.ts +5 -0
  124. package/dist/io/xlsx/write.js +155 -131
  125. package/dist/sha512.d.ts +2 -0
  126. package/dist/sha512.js +146 -0
  127. package/dist/token-set.d.ts +15 -0
  128. package/dist/token-set.js +4 -0
  129. package/dist/vba/bytes.d.ts +1 -2
  130. package/dist/vba/bytes.js +1 -12
  131. package/dist/vba/cfb-format.d.ts +24 -0
  132. package/dist/vba/cfb-format.js +12 -0
  133. package/dist/vba/cfb-writer.js +17 -17
  134. package/dist/vba/cfb.js +1 -7
  135. package/dist/vba/dir-records.d.ts +53 -0
  136. package/dist/vba/dir-records.js +28 -0
  137. package/dist/vba/ms-ovba.js +32 -11
  138. package/dist/vba/project-editor.js +5 -34
  139. package/dist/vba/project.js +15 -31
  140. package/dist/vba/vba-encoding.js +1 -1
  141. package/dist/xml/xml-read.d.ts +117 -72
  142. package/dist/xml/xml-read.js +123 -169
  143. package/dist/xml/xml-scan.d.ts +100 -0
  144. package/dist/xml/xml-scan.js +208 -0
  145. package/dist/xml/xml.d.ts +44 -2
  146. package/dist/xml/xml.js +17 -5
  147. package/package.json +22 -6
@@ -1,6 +1,4 @@
1
- export interface XmlAttributes {
2
- readonly [name: string]: string;
3
- }
1
+ import { type XmlAttributes, type XmlEvent } from './xml-scan.ts';
4
2
  export interface SaxHandlers {
5
3
  /** An element start. `selfClosing` is true for `<x/>`; no matching {@link onClose} fires for it. */
6
4
  onOpen(name: string, attrs: XmlAttributes, selfClosing: boolean): void;
@@ -15,55 +13,42 @@ export interface SaxHandlers {
15
13
  * is already entity-decoded (or verbatim CDATA), and a `<x/>` yields one `open` with
16
14
  * `selfClosing: true` and no matching `close`. The discriminated `kind` lets a *pull* consumer
17
15
  * drive the parse: the shape the streaming reader needs, where a push callback cannot `yield`.
18
- */
19
- export type XmlEvent = {
20
- readonly kind: 'open';
21
- readonly name: string;
22
- readonly attrs: XmlAttributes;
23
- readonly selfClosing: boolean;
24
- } | {
25
- readonly kind: 'text';
26
- readonly text: string;
27
- } | {
28
- readonly kind: 'close';
29
- readonly name: string;
30
- };
16
+
31
17
  /**
32
- * Decode XML character references and the five predefined entities. An unrecognised
33
- * `&name;` is left verbatim rather than expanded: there is no DTD, so there is nothing
34
- * to expand it to, and refusing to invent one is what makes entity-expansion attacks
35
- * impossible.
18
+ * What {@link elementSubtrees} is to capture: for each container element's local name, the local name
19
+ * of the children to take verbatim inside it (`'dxfs' -> 'dxf'`). Scoping the child to a container is
20
+ * what keeps a `<color>` in `<mruColors>` from being confused with the many other `<color>` elements
21
+ * a stylesheet carries.
36
22
  */
37
- export declare function decodeEntities(value: string): string;
23
+ export type SubtreeSelection = ReadonlyMap<string, string>;
24
+ /** What {@link elementSubtrees} captured: the verbatim source of each selected child, keyed by its
25
+ * container's local name, and each container's own attributes as written. */
26
+ export interface SubtreeCapture {
27
+ readonly fragments: ReadonlyMap<string, readonly string[]>;
28
+ readonly attributes: ReadonlyMap<string, XmlAttributes>;
29
+ }
38
30
  /**
39
- * The SpreadsheetML `_xHHHH_` escape, in the only place it may appear: a complete cell-text value.
31
+ * Capture the verbatim source text of selected elements, in one scan.
40
32
  *
41
- * The mirror of `escapeSpreadsheetText` in `./xml.ts`, and it sits here rather than beside it for
42
- * the same reason `decodeEntities` sits apart from `escapeText`: the write helpers carry an
43
- * `AuthoringError` and a whole serialisation vocabulary the reader has no business importing.
33
+ * Some content is re-emitted byte for byte rather than modelled: a differential style, a custom
34
+ * indexed palette, an author's recent-colour swatches, a table-style definition. Preserving the raw
35
+ * text is what keeps a foreign `<dxf>`'s number format a real format code across a re-write instead
36
+ * of a coerced `"[object Object]"`, so the reader needs a subtree's *source*, which an event stream
37
+ * by definition cannot hand back.
44
38
  *
45
- * **One left-to-right pass, and that is load-bearing.** `005F` maps to `_` like any other code
46
- * point, with no special case, because a single pass already gives the underscore escape its
47
- * meaning: in `_x005F_x0041_` the match at 0 yields `_` and scanning resumes at `x0041_`, which has
48
- * no leading underscore left to start an escape. So the value reads back as the literal seven
49
- * characters `_x0041_` the author wrote. Decoding `_x005F_` in a pass of its own, before or after
50
- * the rest, collapses that to `A` and loses the distinction the encoder went to trouble to keep.
51
- * Excel agrees: it reads that cell as `_x0041_`.
52
- *
53
- * The decode is unconditional, not a repair of characters XML cannot carry. Excel reads
54
- * `a_x0009_b` as a tab even though a literal tab would have been perfectly legal there, so a
55
- * decoder that only handled the illegal range would disagree with Excel on files Excel wrote.
56
- */
57
- export declare function decodeSpreadsheetText(value: string): string;
58
- /**
59
- * Scan an XML document as a *pull* stream of {@link XmlEvent}s in a single O(n) pass with no
60
- * recursion. This is the parser's core; {@link parseXml} is a thin push adapter over it. A
61
- * consumer that must produce output incrementally (the streaming row reader) pulls events and
62
- * yields as it goes, holding only its own running state; a push callback cannot.
39
+ * That gap is why four callers each grew a `<container>([\s\S]*?)</container>` scanner of their own,
40
+ * which is a regular expression parsing XML, over untrusted input, in the same directory as the
41
+ * reader written specifically to avoid that (ADR-0004). This is the same capability done properly:
42
+ * one linear scan with comments, CDATA, processing instructions and declarations skipped as markup
43
+ * rather than matched as text, and the nesting depth counted so a same-named descendant does not end
44
+ * a capture early.
63
45
  *
64
- * Throws {@link XmlParseError} on malformed markup.
46
+ * Only a container's *first* occurrence is read, matching the single block these documents declare;
47
+ * a second is ignored rather than merged. A captured element that never closes throws
48
+ * {@link XmlParseError}, like the reader's other truncation cases: a partial subtree re-emitted
49
+ * verbatim is broken markup handed on as though it were content.
65
50
  */
66
- export declare function xmlEvents(source: string): Generator<XmlEvent>;
51
+ export declare function elementSubtrees(source: string, selection: SubtreeSelection): SubtreeCapture;
67
52
  /** An element start surfaced by {@link openElements}: its qualified `name`, the namespace-stripped
68
53
  * `local` name the filter matched on, and its already-decoded `attrs`. */
69
54
  export interface OpenElement {
@@ -101,36 +86,96 @@ export interface ParseXmlOptions {
101
86
  */
102
87
  readonly closeEmptyElements?: ReadonlySet<string>;
103
88
  }
89
+ /**
90
+ * One reader's share of a parse: the handlers it wants the events delivered to, and the self-closing
91
+ * elements it needs expanded. Named separately from {@link ParseXmlOptions} because several readers
92
+ * of the same part run over a single parse of it, and each has to bring its own requirements rather
93
+ * than have the caller remember them; see {@link parseXmlPasses}.
94
+ */
95
+ export interface SaxPass {
96
+ readonly handlers: SaxHandlers;
97
+ /** As {@link ParseXmlOptions.closeEmptyElements}, for this reader's elements. */
98
+ readonly closeEmptyElements?: ReadonlySet<string>;
99
+ }
100
+ /** A {@link SaxPass} that gathers something during the parse rather than committing as it goes. */
101
+ export interface CollectingPass<T> extends SaxPass {
102
+ /** What the pass collected. Meaningful only once the parse driving it has finished. */
103
+ result(): T;
104
+ }
105
+ /**
106
+ * Parse `source` once, delivering every event to each pass in turn. The alternative, a parse per
107
+ * reader, costs a full scan of the document per reader and finds nothing in most of them: the
108
+ * worksheet part is the largest in a package, and reading it five times over spent 45% of a large
109
+ * file's read on four scans that matched no element.
110
+ *
111
+ * The expansions the passes ask for are unioned, so *every* pass sees `<x/>` as an open plus a close
112
+ * for any name *any* of them named. That is the one way a pass can observe that it is sharing a
113
+ * parse, and it is why the option is a set of element names rather than a flag: a pass sees an extra
114
+ * close only for elements another pass had to name, and reaching a close for an element a reader
115
+ * does not handle is already the ordinary case.
116
+ */
117
+ export declare function parseXmlPasses(source: string, passes: readonly SaxPass[]): void;
104
118
  /**
105
119
  * Parse an XML document, dispatching SAX events to `handlers`. A thin push adapter over
106
120
  * {@link xmlEvents}: one scanning core serves both the callback and the pull consumers.
107
121
  * Throws {@link XmlParseError} on malformed markup.
108
122
  */
109
123
  export declare function parseXml(source: string, handlers: SaxHandlers, options?: ParseXmlOptions): void;
110
- /** Strip a namespace prefix from a qualified name (`r:id` → `id`, `sheet` → `sheet`). */
111
- export declare function localName(qualified: string): string;
112
- /** An OOXML boolean that is on when present with no value (`<b/>` is bold) and off only on an
113
- * explicit `"0"`/`"false"`; absence reads as on. */
114
- export declare function boolPresent(val: string | undefined): boolean;
115
- /** An OOXML boolean that is on only when explicitly `"1"`/`"true"`; anything else, including
116
- * absence and a truthy-looking `"0"`, is off. */
117
- export declare function boolStrict(val: string | undefined): boolean;
118
- /** An optional OOXML boolean: `undefined` when the attribute is absent or carries an unrecognised
119
- * token, otherwise its `"1"`/`"true"` vs `"0"`/`"false"` value. Lets a caller store only the
120
- * attributes the source actually carried, so a re-write stays byte-clean. */
121
- export declare function boolTristate(val: string | undefined): boolean | undefined;
122
- /** An OOXML integer attribute at or above `min` (default: unbounded below); `undefined` when the
123
- * attribute is absent, blank, fractional, not a number, or below the floor. Integers past
124
- * `Number.MAX_SAFE_INTEGER` read as `undefined` too: no index or count is usable out there, and
125
- * arithmetic on one silently lies. */
126
- export declare function numInteger(val: string | undefined, min?: number): number | undefined;
127
- /** An OOXML decimal attribute at or above `min` (default: unbounded below); `undefined` when the
128
- * attribute is absent, blank, not a number, or below the floor. Infinities are not finite numbers
129
- * and read as `undefined`. */
130
- export declare function numFinite(val: string | undefined, min?: number): number | undefined;
131
- /** Read an operand's text as a number only when it is a canonical decimal literal (optional sign,
132
- * digits, optional fraction). A cell reference, defined name, expression, or exotically-spelled
133
- * number (`1E5`, hex) keeps its verbatim text, so it is neither coerced to `NaN` and lost nor
134
- * re-spelled into a number that would not re-write byte-clean. Callers layer their own type rules
135
- * (a data-validation `list`/`custom` operand stays a string regardless of what it looks like). */
136
- export declare function coerceNumericLiteral(text: string): string | number;
124
+ /**
125
+ * Gathers one element's character data across the open/text/close events a SAX parse delivers it in.
126
+ *
127
+ * Nine parsers used to open-code this: latch a flag and clear a buffer on the open, append every
128
+ * chunk while latched, consume the buffer and unlatch on the close. Six spellings of one idea, and
129
+ * none of them honoured the one thing {@link SaxHandlers.onOpen} warns about. A self-closing `<x/>`
130
+ * fires no matching close, so `<t/>`, `<text/>`, `<xm:f/>` and `<totalsRowFormula/>`, all legal and
131
+ * all written by real files, latched a capture that nothing would ever close. What kept that from
132
+ * corrupting anything was the order the next open happened to reset things in, which is an accident
133
+ * rather than a property anyone chose, on a path that reads untrusted input. Taking `selfClosing`
134
+ * here makes it structural, once.
135
+ *
136
+ * The other thing the open-coded versions disagreed on is what an unrelated element opening
137
+ * mid-capture should do. Ending the capture is never what a caller wants: the text belongs to the
138
+ * element that opened it, and a nested or sibling element is not that element. So an open that is
139
+ * not for a captured name leaves an capture in progress alone, and {@link close} answers only for
140
+ * the element that started it.
141
+ *
142
+ * Decoding stays outside. A `<t>` needs `decodeSpreadsheetText` over the whole element and never
143
+ * over a chunk, an `<xm:f>` needs nothing, and a coordinate needs a number: the caller knows which.
144
+ */
145
+ export declare class TextCapture {
146
+ #private;
147
+ /** @param names the element local name, or the set of names this instance may capture. */
148
+ constructor(names: string | Iterable<string>);
149
+ /** Whether a capture is currently open. */
150
+ get capturing(): boolean;
151
+ /** Begin capturing `local` if it is one of this instance's names and is not self-closing. */
152
+ open(local: string, selfClosing: boolean): void;
153
+ /** Feed a chunk of character data; ignored when no capture is open. */
154
+ text(chunk: string): void;
155
+ /** The gathered text when `local` closes the captured element, else `undefined`. Unlatches. */
156
+ close(local: string): string | undefined;
157
+ }
158
+ /**
159
+ * Yield each named element's text as that element closes, as `{local, text}`.
160
+ *
161
+ * The third member of the pull-shaped family beside {@link openElements} ("scan opens, read
162
+ * attributes") and {@link closeEmptyElements}: this one is "capture these elements' text, tell me
163
+ * each as it closes". A parser whose whole job is reading a handful of text elements out of a part
164
+ * writes a `for..of` over it instead of a {@link parseXml} handler triple whose open and text arms
165
+ * are the same three lines every time.
166
+ *
167
+ * It is deliberately not for every {@link TextCapture} caller. A parser that interleaves capture
168
+ * with per-element state of its own (a `<dataValidation>` gathering formulae, a `<tableColumn>`
169
+ * attaching a totals formula to the column it is inside) needs the open and attribute events too,
170
+ * and stays bespoke; forcing it through here would trade a handler triple for a second pass.
171
+ *
172
+ * A self-closing `<x/>` carries no text and fires no close, so it yields nothing, which is the
173
+ * behaviour {@link TextCapture} exists to make structural rather than a branch each caller
174
+ * remembers.
175
+ *
176
+ * Throws {@link XmlParseError} on malformed markup.
177
+ */
178
+ export declare function capturedText(source: string, names: string | Iterable<string>): Generator<{
179
+ local: string;
180
+ text: string;
181
+ }>;
@@ -1,137 +1,77 @@
1
1
  import { XmlParseError } from './errors.js';
2
- const PREDEFINED_ENTITIES = {
3
- amp: '&',
4
- lt: '<',
5
- gt: '>',
6
- quot: '"',
7
- apos: "'",
8
- };
9
- const ENTITY = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g;
10
- export function decodeEntities(value) {
11
- if (!value.includes('&'))
12
- return value;
13
- return value.replace(ENTITY, (match, body) => {
14
- if (body.charCodeAt(0) === 0x23) {
15
- const codePoint = body.charCodeAt(1) === 0x78
16
- ? parseInt(body.slice(2), 16)
17
- : parseInt(body.slice(1), 10);
18
- if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
19
- return match;
20
- try {
21
- return String.fromCodePoint(codePoint);
22
- }
23
- catch {
24
- return match;
25
- }
26
- }
27
- const named = PREDEFINED_ENTITIES[body];
28
- return named ?? match;
29
- });
30
- }
31
- export function decodeSpreadsheetText(value) {
32
- if (!value.includes('_'))
33
- return value;
34
- return value.replace(/_x([0-9A-Fa-f]{4})_/g, (_match, hex) => String.fromCharCode(parseInt(hex, 16)));
35
- }
36
- const ATTRIBUTE = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
37
- function parseAttributes(source) {
38
- const attrs = {};
39
- ATTRIBUTE.lastIndex = 0;
40
- let match = ATTRIBUTE.exec(source);
41
- while (match !== null) {
42
- const value = match[2] ?? match[3] ?? '';
43
- attrs[match[1]] = decodeEntities(value);
44
- match = ATTRIBUTE.exec(source);
45
- }
46
- return attrs;
47
- }
48
- function findTagEnd(source, start) {
49
- let quote = '';
50
- for (let i = start + 1; i < source.length; i++) {
51
- const ch = source[i];
52
- if (quote !== '') {
53
- if (ch === quote)
54
- quote = '';
55
- }
56
- else if (ch === '"' || ch === "'") {
57
- quote = ch;
58
- }
59
- else if (ch === '>') {
60
- return i;
61
- }
62
- }
63
- throw new XmlParseError('unterminated tag: missing ">"');
64
- }
65
- function skipDeclaration(source, start) {
66
- let depth = 0;
67
- for (let i = start; i < source.length; i++) {
68
- const ch = source[i];
69
- if (ch === '[')
70
- depth++;
71
- else if (ch === ']')
72
- depth--;
73
- else if (ch === '>' && depth <= 0)
74
- return i + 1;
75
- }
76
- throw new XmlParseError('unterminated markup declaration: missing ">"');
77
- }
78
- export function* xmlEvents(source) {
2
+ import { localName, markupAt, parseAttributes, tagAt, xmlEvents, } from './xml-scan.js';
3
+ export function elementSubtrees(source, selection) {
4
+ const fragments = new Map();
5
+ const attributes = new Map();
6
+ const finished = new Set();
7
+ let container;
8
+ let capture;
79
9
  const length = source.length;
80
10
  let i = 0;
81
11
  while (i < length) {
82
12
  const lt = source.indexOf('<', i);
83
- if (lt === -1) {
84
- const chunk = source.slice(i);
85
- if (chunk.length > 0)
86
- yield { kind: 'text', text: decodeEntities(normalizeLineEndings(chunk)) };
87
- return;
88
- }
89
- if (lt > i) {
90
- const chunk = source.slice(i, lt);
91
- if (chunk.length > 0)
92
- yield { kind: 'text', text: decodeEntities(normalizeLineEndings(chunk)) };
93
- }
94
- if (source.startsWith('<!--', lt)) {
95
- const end = source.indexOf('-->', lt + 4);
96
- if (end === -1)
97
- throw new XmlParseError('unterminated comment');
98
- i = end + 3;
13
+ if (lt === -1)
14
+ break;
15
+ const markup = markupAt(source, lt);
16
+ if (markup !== undefined) {
17
+ i = markup.next;
99
18
  continue;
100
19
  }
101
- if (source.startsWith('<![CDATA[', lt)) {
102
- const end = source.indexOf(']]>', lt + 9);
103
- if (end === -1)
104
- throw new XmlParseError('unterminated CDATA section');
105
- yield { kind: 'text', text: source.slice(lt + 9, end) };
106
- i = end + 3;
107
- continue;
108
- }
109
- if (source.startsWith('<?', lt)) {
110
- const end = source.indexOf('?>', lt + 2);
111
- if (end === -1)
112
- throw new XmlParseError('unterminated processing instruction');
113
- i = end + 2;
20
+ const tag = tagAt(source, lt);
21
+ const local = localName(tag.name);
22
+ if (tag.close) {
23
+ if (capture !== undefined && local === capture.local) {
24
+ if (capture.depth > 0)
25
+ capture.depth -= 1;
26
+ else {
27
+ fragments.get(container?.local ?? '')?.push(source.slice(capture.start, tag.next));
28
+ capture = undefined;
29
+ }
30
+ }
31
+ else if (capture === undefined && container !== undefined && local === container.local) {
32
+ if (container.depth > 0)
33
+ container.depth -= 1;
34
+ else {
35
+ finished.add(container.local);
36
+ container = undefined;
37
+ }
38
+ }
39
+ i = tag.next;
114
40
  continue;
115
41
  }
116
- if (source.startsWith('<!', lt)) {
117
- i = skipDeclaration(source, lt);
118
- continue;
42
+ const { selfClosing } = tag;
43
+ if (capture !== undefined) {
44
+ if (!selfClosing && local === capture.local)
45
+ capture.depth += 1;
119
46
  }
120
- const gt = findTagEnd(source, lt);
121
- const raw = source.slice(lt + 1, gt);
122
- if (raw.charCodeAt(0) === 0x2f) {
123
- yield { kind: 'close', name: raw.slice(1).trim() };
47
+ else if (container !== undefined) {
48
+ if (!selfClosing && local === container.local)
49
+ container.depth += 1;
50
+ else if (local === container.child) {
51
+ if (selfClosing)
52
+ fragments.get(container.local)?.push(source.slice(lt, tag.next));
53
+ else
54
+ capture = { local, start: lt, depth: 0 };
55
+ }
124
56
  }
125
57
  else {
126
- const selfClosing = raw.charCodeAt(raw.length - 1) === 0x2f;
127
- const body = selfClosing ? raw.slice(0, -1) : raw;
128
- const nameEnd = firstWhitespace(body);
129
- const name = nameEnd === -1 ? body : body.slice(0, nameEnd);
130
- const attrs = nameEnd === -1 ? {} : parseAttributes(body.slice(nameEnd));
131
- yield { kind: 'open', name, attrs, selfClosing };
58
+ const child = selection.get(local);
59
+ if (child !== undefined && !finished.has(local)) {
60
+ attributes.set(local, parseAttributes(tag.attrSource));
61
+ if (selfClosing)
62
+ finished.add(local);
63
+ else {
64
+ fragments.set(local, fragments.get(local) ?? []);
65
+ container = { local, child, depth: 0 };
66
+ }
67
+ }
132
68
  }
133
- i = gt + 1;
69
+ i = tag.next;
70
+ }
71
+ if (capture !== undefined) {
72
+ throw new XmlParseError(`unterminated <${capture.local}> element`);
134
73
  }
74
+ return { fragments, attributes };
135
75
  }
136
76
  export function* openElements(source, ...localNames) {
137
77
  const filter = localNames.length > 0 ? new Set(localNames) : undefined;
@@ -155,6 +95,28 @@ export function* closeEmptyElements(events, names) {
155
95
  }
156
96
  }
157
97
  }
98
+ export function parseXmlPasses(source, passes) {
99
+ const expanded = new Set();
100
+ for (const pass of passes) {
101
+ for (const name of pass.closeEmptyElements ?? [])
102
+ expanded.add(name);
103
+ }
104
+ const handlers = passes.map((pass) => pass.handlers);
105
+ parseXml(source, {
106
+ onOpen(name, attrs, selfClosing) {
107
+ for (const handler of handlers)
108
+ handler.onOpen(name, attrs, selfClosing);
109
+ },
110
+ onText(text) {
111
+ for (const handler of handlers)
112
+ handler.onText?.(text);
113
+ },
114
+ onClose(name) {
115
+ for (const handler of handlers)
116
+ handler.onClose?.(name);
117
+ },
118
+ }, expanded.size > 0 ? { closeEmptyElements: expanded } : undefined);
119
+ }
158
120
  export function parseXml(source, handlers, options) {
159
121
  const events = options?.closeEmptyElements
160
122
  ? closeEmptyElements(xmlEvents(source), options.closeEmptyElements)
@@ -173,55 +135,47 @@ export function parseXml(source, handlers, options) {
173
135
  }
174
136
  }
175
137
  }
176
- function normalizeLineEndings(chunk) {
177
- if (!chunk.includes('\r'))
178
- return chunk;
179
- return chunk.replace(/\r\n?/g, '\n');
180
- }
181
- function firstWhitespace(source) {
182
- for (let i = 0; i < source.length; i++) {
183
- const code = source.charCodeAt(i);
184
- if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d)
185
- return i;
138
+ export class TextCapture {
139
+ #names;
140
+ #capturing;
141
+ #text = '';
142
+ constructor(names) {
143
+ this.#names = new Set(typeof names === 'string' ? [names] : names);
144
+ }
145
+ get capturing() {
146
+ return this.#capturing !== undefined;
147
+ }
148
+ open(local, selfClosing) {
149
+ if (selfClosing || !this.#names.has(local))
150
+ return;
151
+ this.#capturing = local;
152
+ this.#text = '';
153
+ }
154
+ text(chunk) {
155
+ if (this.#capturing !== undefined)
156
+ this.#text += chunk;
157
+ }
158
+ close(local) {
159
+ if (this.#capturing !== local)
160
+ return undefined;
161
+ this.#capturing = undefined;
162
+ return this.#text;
186
163
  }
187
- return -1;
188
- }
189
- export function localName(qualified) {
190
- const colon = qualified.indexOf(':');
191
- return colon === -1 ? qualified : qualified.slice(colon + 1);
192
- }
193
- export function boolPresent(val) {
194
- return val === undefined || (val !== '0' && val !== 'false');
195
- }
196
- export function boolStrict(val) {
197
- return val === '1' || val === 'true';
198
- }
199
- export function boolTristate(val) {
200
- if (val === '1' || val === 'true')
201
- return true;
202
- if (val === '0' || val === 'false')
203
- return false;
204
- return undefined;
205
- }
206
- export function numInteger(val, min = -Number.MAX_SAFE_INTEGER) {
207
- const n = parseAttrNumber(val);
208
- if (n === undefined || !Number.isSafeInteger(n) || n < min)
209
- return undefined;
210
- return n;
211
- }
212
- export function numFinite(val, min = -Infinity) {
213
- const n = parseAttrNumber(val);
214
- if (n === undefined || n < min)
215
- return undefined;
216
- return n;
217
- }
218
- function parseAttrNumber(val) {
219
- if (val === undefined || val.trim() === '')
220
- return undefined;
221
- const n = Number(val);
222
- return Number.isFinite(n) ? n : undefined;
223
164
  }
224
- export function coerceNumericLiteral(text) {
225
- const trimmed = text.trim();
226
- return /^-?\d+(?:\.\d+)?$/.test(trimmed) ? Number(trimmed) : text;
165
+ export function* capturedText(source, names) {
166
+ const capture = new TextCapture(names);
167
+ for (const event of xmlEvents(source)) {
168
+ if (event.kind === 'open') {
169
+ capture.open(localName(event.name), event.selfClosing);
170
+ }
171
+ else if (event.kind === 'text') {
172
+ capture.text(event.text);
173
+ }
174
+ else {
175
+ const local = localName(event.name);
176
+ const text = capture.close(local);
177
+ if (text !== undefined)
178
+ yield { local, text };
179
+ }
180
+ }
227
181
  }
@@ -0,0 +1,100 @@
1
+ export interface XmlAttributes {
2
+ readonly [name: string]: string;
3
+ }
4
+ export type XmlEvent = {
5
+ readonly kind: 'open';
6
+ readonly name: string;
7
+ readonly attrs: XmlAttributes;
8
+ readonly selfClosing: boolean;
9
+ } | {
10
+ readonly kind: 'text';
11
+ readonly text: string;
12
+ } | {
13
+ readonly kind: 'close';
14
+ readonly name: string;
15
+ };
16
+ /**
17
+ * Decode XML character references and the five predefined entities. An unrecognised
18
+ * `&name;` is left verbatim rather than expanded: there is no DTD, so there is nothing
19
+ * to expand it to, and refusing to invent one is what makes entity-expansion attacks
20
+ * impossible.
21
+ */
22
+ export declare function decodeEntities(value: string): string;
23
+ /**
24
+ * The SpreadsheetML `_xHHHH_` escape, in the only place it may appear: a complete cell-text value.
25
+ *
26
+ * The mirror of `escapeSpreadsheetText` in `./xml.ts`, and it sits here rather than beside it for
27
+ * the same reason `decodeEntities` sits apart from `escapeText`: the write helpers carry an
28
+ * `AuthoringError` and a whole serialisation vocabulary the reader has no business importing.
29
+ *
30
+ * **One left-to-right pass, and that is load-bearing.** `005F` maps to `_` like any other code
31
+ * point, with no special case, because a single pass already gives the underscore escape its
32
+ * meaning: in `_x005F_x0041_` the match at 0 yields `_` and scanning resumes at `x0041_`, which has
33
+ * no leading underscore left to start an escape. So the value reads back as the literal seven
34
+ * characters `_x0041_` the author wrote. Decoding `_x005F_` in a pass of its own, before or after
35
+ * the rest, collapses that to `A` and loses the distinction the encoder went to trouble to keep.
36
+ * Excel agrees: it reads that cell as `_x0041_`.
37
+ *
38
+ * The decode is unconditional, not a repair of characters XML cannot carry. Excel reads
39
+ * `a_x0009_b` as a tab even though a literal tab would have been perfectly legal there, so a
40
+ * decoder that only handled the illegal range would disagree with Excel on files Excel wrote.
41
+ */
42
+ export declare function decodeSpreadsheetText(value: string): string;
43
+ export declare function parseAttributes(source: string): XmlAttributes;
44
+ export type Markup = {
45
+ readonly kind: 'comment' | 'pi' | 'declaration';
46
+ readonly next: number;
47
+ } | {
48
+ readonly kind: 'cdata';
49
+ readonly contentStart: number;
50
+ readonly contentEnd: number;
51
+ readonly next: number;
52
+ };
53
+ export declare function markupAt(source: string, lt: number): Markup | undefined;
54
+ export interface Tag {
55
+ readonly close: boolean;
56
+ readonly name: string;
57
+ readonly attrSource: string;
58
+ readonly selfClosing: boolean;
59
+ /** One past the tag's `>`, so a caller capturing verbatim source can slice up to it. */
60
+ readonly next: number;
61
+ }
62
+ export declare function tagAt(source: string, lt: number): Tag;
63
+ /**
64
+ * Scan an XML document as a *pull* stream of {@link XmlEvent}s in a single O(n) pass with no
65
+ * recursion. This is the parser's core; {@link parseXml} is a thin push adapter over it. A
66
+ * consumer that must produce output incrementally (the streaming row reader) pulls events and
67
+ * yields as it goes, holding only its own running state; a push callback cannot.
68
+ *
69
+ * Throws {@link XmlParseError} on malformed markup.
70
+ */
71
+ export declare function xmlEvents(source: string): Generator<XmlEvent>;
72
+ /** Strip a namespace prefix from a qualified name (`r:id` → `id`, `sheet` → `sheet`). */
73
+ export declare function localName(qualified: string): string;
74
+ /** An OOXML boolean that is on when present with no value (`<b/>` is bold) and off only on an
75
+ * explicit `"0"`/`"false"`; absence reads as on. */
76
+ export declare function boolPresent(val: string | undefined): boolean;
77
+ /** An OOXML boolean that is on only when explicitly `"1"`/`"true"`; anything else, including
78
+ * absence and a truthy-looking `"0"`, is off. */
79
+ export declare function boolStrict(val: string | undefined): boolean;
80
+ /** An optional OOXML boolean: `undefined` when the attribute is absent or carries an unrecognised
81
+ * token, otherwise its `"1"`/`"true"` vs `"0"`/`"false"` value. Lets a caller store only the
82
+ * attributes the source actually carried, so a re-write stays byte-clean. */
83
+ export declare function boolTristate(val: string | undefined): boolean | undefined;
84
+ /** An OOXML integer attribute at or above `min` (default: unbounded below); `undefined` when the
85
+ * attribute is absent, blank, fractional, not a number, or below the floor. Integers past
86
+ * `Number.MAX_SAFE_INTEGER` read as `undefined` too: no index or count is usable out there, and
87
+ * arithmetic on one silently lies. */
88
+ export declare function numInteger(val: string | undefined, min?: number): number | undefined;
89
+ /** An OOXML decimal attribute at or above `min` (default: unbounded below); `undefined` when the
90
+ * attribute is absent, blank, not a number, or below the floor. Infinities are not finite numbers
91
+ * and read as `undefined`. */
92
+ export declare function numFinite(val: string | undefined, min?: number): number | undefined;
93
+ /** Read an operand's text as a number only when it is a canonical decimal literal (optional sign,
94
+ * digits, optional fraction). A cell reference, defined name, expression, or exotically-spelled
95
+ * number (`1E5`, hex) keeps its verbatim text, so it is neither coerced to `NaN` and lost nor
96
+ * re-spelled into a number that would not re-write byte-clean. Callers layer their own type rules
97
+ * (a data-validation `list`/`custom` operand stays a string regardless of what it looks like). */
98
+ export declare function coerceNumericLiteral(text: string): string | number;
99
+ /** Narrow an enumerated attribute through its guard; `undefined` when absent or not a member. */
100
+ export declare function enumToken<T extends string>(val: string | undefined, isMember: (candidate: string) => candidate is T): T | undefined;