@shbernal/ts-xlsx 1.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 (223) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +230 -0
  3. package/dist/core/address.d.ts +49 -0
  4. package/dist/core/address.js +125 -0
  5. package/dist/core/autofilter.d.ts +55 -0
  6. package/dist/core/autofilter.js +48 -0
  7. package/dist/core/cell.d.ts +132 -0
  8. package/dist/core/cell.js +249 -0
  9. package/dist/core/color-resolution.d.ts +58 -0
  10. package/dist/core/color-resolution.js +170 -0
  11. package/dist/core/column.d.ts +87 -0
  12. package/dist/core/column.js +167 -0
  13. package/dist/core/comment-thread.d.ts +116 -0
  14. package/dist/core/comment-thread.js +62 -0
  15. package/dist/core/conditional-formatting-overlay.d.ts +15 -0
  16. package/dist/core/conditional-formatting-overlay.js +24 -0
  17. package/dist/core/conditional-formatting.d.ts +70 -0
  18. package/dist/core/conditional-formatting.js +35 -0
  19. package/dist/core/containers.d.ts +2 -0
  20. package/dist/core/containers.js +25 -0
  21. package/dist/core/data-validation-overlay.d.ts +28 -0
  22. package/dist/core/data-validation-overlay.js +56 -0
  23. package/dist/core/data-validation.d.ts +38 -0
  24. package/dist/core/data-validation.js +13 -0
  25. package/dist/core/date.d.ts +25 -0
  26. package/dist/core/date.js +58 -0
  27. package/dist/core/formula.d.ts +49 -0
  28. package/dist/core/formula.js +319 -0
  29. package/dist/core/grid-edits.d.ts +22 -0
  30. package/dist/core/grid-edits.js +206 -0
  31. package/dist/core/image.d.ts +76 -0
  32. package/dist/core/image.js +65 -0
  33. package/dist/core/internal.d.ts +11 -0
  34. package/dist/core/internal.js +29 -0
  35. package/dist/core/merge.d.ts +13 -0
  36. package/dist/core/merge.js +26 -0
  37. package/dist/core/modern-functions.d.ts +1 -0
  38. package/dist/core/modern-functions.js +165 -0
  39. package/dist/core/page-setup.d.ts +94 -0
  40. package/dist/core/page-setup.js +6 -0
  41. package/dist/core/pivot-table.d.ts +113 -0
  42. package/dist/core/pivot-table.js +249 -0
  43. package/dist/core/preserved.d.ts +58 -0
  44. package/dist/core/preserved.js +5 -0
  45. package/dist/core/protection.d.ts +67 -0
  46. package/dist/core/protection.js +68 -0
  47. package/dist/core/range.d.ts +112 -0
  48. package/dist/core/range.js +283 -0
  49. package/dist/core/row.d.ts +73 -0
  50. package/dist/core/row.js +135 -0
  51. package/dist/core/style.d.ts +246 -0
  52. package/dist/core/style.js +125 -0
  53. package/dist/core/table-style.d.ts +67 -0
  54. package/dist/core/table-style.js +90 -0
  55. package/dist/core/table.d.ts +194 -0
  56. package/dist/core/table.js +291 -0
  57. package/dist/core/theme.d.ts +90 -0
  58. package/dist/core/theme.js +272 -0
  59. package/dist/core/value.d.ts +115 -0
  60. package/dist/core/value.js +144 -0
  61. package/dist/core/workbook-protection.d.ts +27 -0
  62. package/dist/core/workbook-protection.js +25 -0
  63. package/dist/core/workbook.d.ts +528 -0
  64. package/dist/core/workbook.js +732 -0
  65. package/dist/core/worksheet-model.d.ts +34 -0
  66. package/dist/core/worksheet-model.js +100 -0
  67. package/dist/core/worksheet.d.ts +647 -0
  68. package/dist/core/worksheet.js +1025 -0
  69. package/dist/customui/errors.d.ts +15 -0
  70. package/dist/customui/errors.js +15 -0
  71. package/dist/customui/index.d.ts +2 -0
  72. package/dist/customui/index.js +7 -0
  73. package/dist/customui/ribbon.d.ts +86 -0
  74. package/dist/customui/ribbon.js +180 -0
  75. package/dist/entries/core.d.ts +23 -0
  76. package/dist/entries/core.js +20 -0
  77. package/dist/entries/csv.d.ts +2 -0
  78. package/dist/entries/csv.js +6 -0
  79. package/dist/entries/customui.d.ts +1 -0
  80. package/dist/entries/customui.js +3 -0
  81. package/dist/entries/errors.d.ts +7 -0
  82. package/dist/entries/errors.js +20 -0
  83. package/dist/entries/vba.d.ts +2 -0
  84. package/dist/entries/vba.js +7 -0
  85. package/dist/entries/xlsb.d.ts +1 -0
  86. package/dist/entries/xlsb.js +7 -0
  87. package/dist/entries/xlsx.d.ts +5 -0
  88. package/dist/entries/xlsx.js +18 -0
  89. package/dist/errors.d.ts +65 -0
  90. package/dist/errors.js +66 -0
  91. package/dist/index.d.ts +7 -0
  92. package/dist/index.js +19 -0
  93. package/dist/io/csv/read.d.ts +15 -0
  94. package/dist/io/csv/read.js +113 -0
  95. package/dist/io/csv/write.d.ts +28 -0
  96. package/dist/io/csv/write.js +124 -0
  97. package/dist/io/opc/errors.d.ts +46 -0
  98. package/dist/io/opc/errors.js +49 -0
  99. package/dist/io/opc/inflate.d.ts +15 -0
  100. package/dist/io/opc/inflate.js +91 -0
  101. package/dist/io/opc/namespaces.d.ts +18 -0
  102. package/dist/io/opc/namespaces.js +18 -0
  103. package/dist/io/opc/part-paths.d.ts +4 -0
  104. package/dist/io/opc/part-paths.js +35 -0
  105. package/dist/io/opc/read-opc.d.ts +24 -0
  106. package/dist/io/opc/read-opc.js +153 -0
  107. package/dist/io/opc/read-options.d.ts +10 -0
  108. package/dist/io/opc/read-options.js +7 -0
  109. package/dist/io/opc/rels.d.ts +15 -0
  110. package/dist/io/opc/rels.js +28 -0
  111. package/dist/io/opc/sniff-format.d.ts +27 -0
  112. package/dist/io/opc/sniff-format.js +92 -0
  113. package/dist/io/style/xf-style.d.ts +47 -0
  114. package/dist/io/style/xf-style.js +95 -0
  115. package/dist/io/xlsb/errors.d.ts +15 -0
  116. package/dist/io/xlsb/errors.js +15 -0
  117. package/dist/io/xlsb/formula.d.ts +48 -0
  118. package/dist/io/xlsb/formula.js +398 -0
  119. package/dist/io/xlsb/primitives.d.ts +86 -0
  120. package/dist/io/xlsb/primitives.js +230 -0
  121. package/dist/io/xlsb/ptg-functions.d.ts +16 -0
  122. package/dist/io/xlsb/ptg-functions.js +659 -0
  123. package/dist/io/xlsb/read-shared-strings.d.ts +2 -0
  124. package/dist/io/xlsb/read-shared-strings.js +22 -0
  125. package/dist/io/xlsb/read-styles.d.ts +4 -0
  126. package/dist/io/xlsb/read-styles.js +386 -0
  127. package/dist/io/xlsb/read-worksheet.d.ts +9 -0
  128. package/dist/io/xlsb/read-worksheet.js +301 -0
  129. package/dist/io/xlsb/read.d.ts +20 -0
  130. package/dist/io/xlsb/read.js +199 -0
  131. package/dist/io/xlsb/record-stream.d.ts +14 -0
  132. package/dist/io/xlsb/record-stream.js +56 -0
  133. package/dist/io/xlsb/record-types.d.ts +55 -0
  134. package/dist/io/xlsb/record-types.js +73 -0
  135. package/dist/io/xlsx/cell-accumulator.d.ts +25 -0
  136. package/dist/io/xlsx/cell-accumulator.js +172 -0
  137. package/dist/io/xlsx/cell-value.d.ts +32 -0
  138. package/dist/io/xlsx/cell-value.js +85 -0
  139. package/dist/io/xlsx/comments.d.ts +74 -0
  140. package/dist/io/xlsx/comments.js +243 -0
  141. package/dist/io/xlsx/conditional-formatting.d.ts +32 -0
  142. package/dist/io/xlsx/conditional-formatting.js +464 -0
  143. package/dist/io/xlsx/data-validation.d.ts +20 -0
  144. package/dist/io/xlsx/data-validation.js +258 -0
  145. package/dist/io/xlsx/edit-vba.d.ts +23 -0
  146. package/dist/io/xlsx/edit-vba.js +139 -0
  147. package/dist/io/xlsx/errors.d.ts +17 -0
  148. package/dist/io/xlsx/errors.js +17 -0
  149. package/dist/io/xlsx/hyperlinks.d.ts +42 -0
  150. package/dist/io/xlsx/hyperlinks.js +139 -0
  151. package/dist/io/xlsx/images.d.ts +34 -0
  152. package/dist/io/xlsx/images.js +218 -0
  153. package/dist/io/xlsx/namespaces.d.ts +55 -0
  154. package/dist/io/xlsx/namespaces.js +55 -0
  155. package/dist/io/xlsx/package-plan.d.ts +103 -0
  156. package/dist/io/xlsx/package-plan.js +168 -0
  157. package/dist/io/xlsx/pivot-read.d.ts +5 -0
  158. package/dist/io/xlsx/pivot-read.js +134 -0
  159. package/dist/io/xlsx/pivot.d.ts +12 -0
  160. package/dist/io/xlsx/pivot.js +141 -0
  161. package/dist/io/xlsx/read-rows.d.ts +85 -0
  162. package/dist/io/xlsx/read-rows.js +286 -0
  163. package/dist/io/xlsx/read-styles.d.ts +8 -0
  164. package/dist/io/xlsx/read-styles.js +551 -0
  165. package/dist/io/xlsx/read-worksheet.d.ts +4 -0
  166. package/dist/io/xlsx/read-worksheet.js +528 -0
  167. package/dist/io/xlsx/read.d.ts +32 -0
  168. package/dist/io/xlsx/read.js +680 -0
  169. package/dist/io/xlsx/relationships.d.ts +33 -0
  170. package/dist/io/xlsx/relationships.js +47 -0
  171. package/dist/io/xlsx/rich-runs.d.ts +13 -0
  172. package/dist/io/xlsx/rich-runs.js +59 -0
  173. package/dist/io/xlsx/rich-text.d.ts +7 -0
  174. package/dist/io/xlsx/rich-text.js +23 -0
  175. package/dist/io/xlsx/shared-formulas.d.ts +6 -0
  176. package/dist/io/xlsx/shared-formulas.js +52 -0
  177. package/dist/io/xlsx/shared-strings-read.d.ts +2 -0
  178. package/dist/io/xlsx/shared-strings-read.js +70 -0
  179. package/dist/io/xlsx/shared-strings.d.ts +17 -0
  180. package/dist/io/xlsx/shared-strings.js +50 -0
  181. package/dist/io/xlsx/sheet-properties.d.ts +13 -0
  182. package/dist/io/xlsx/sheet-properties.js +211 -0
  183. package/dist/io/xlsx/styles.d.ts +138 -0
  184. package/dist/io/xlsx/styles.js +821 -0
  185. package/dist/io/xlsx/tables.d.ts +9 -0
  186. package/dist/io/xlsx/tables.js +208 -0
  187. package/dist/io/xlsx/threaded-comments.d.ts +102 -0
  188. package/dist/io/xlsx/threaded-comments.js +308 -0
  189. package/dist/io/xlsx/workbook-xml.d.ts +12 -0
  190. package/dist/io/xlsx/workbook-xml.js +353 -0
  191. package/dist/io/xlsx/worksheet-xml.d.ts +86 -0
  192. package/dist/io/xlsx/worksheet-xml.js +643 -0
  193. package/dist/io/xlsx/write-stream.d.ts +171 -0
  194. package/dist/io/xlsx/write-stream.js +399 -0
  195. package/dist/io/xlsx/write.d.ts +81 -0
  196. package/dist/io/xlsx/write.js +396 -0
  197. package/dist/io/xlsx/x14-ext.d.ts +9 -0
  198. package/dist/io/xlsx/x14-ext.js +12 -0
  199. package/dist/vba/cfb-writer.d.ts +19 -0
  200. package/dist/vba/cfb-writer.js +277 -0
  201. package/dist/vba/cfb.d.ts +16 -0
  202. package/dist/vba/cfb.js +324 -0
  203. package/dist/vba/codepage.d.ts +18 -0
  204. package/dist/vba/codepage.js +87 -0
  205. package/dist/vba/errors.d.ts +24 -0
  206. package/dist/vba/errors.js +24 -0
  207. package/dist/vba/index.d.ts +5 -0
  208. package/dist/vba/index.js +15 -0
  209. package/dist/vba/ms-ovba.d.ts +12 -0
  210. package/dist/vba/ms-ovba.js +176 -0
  211. package/dist/vba/project-editor.d.ts +62 -0
  212. package/dist/vba/project-editor.js +377 -0
  213. package/dist/vba/project.d.ts +45 -0
  214. package/dist/vba/project.js +160 -0
  215. package/dist/vba/vba-encoding.d.ts +14 -0
  216. package/dist/vba/vba-encoding.js +39 -0
  217. package/dist/xml/errors.d.ts +15 -0
  218. package/dist/xml/errors.js +15 -0
  219. package/dist/xml/xml-read.d.ts +107 -0
  220. package/dist/xml/xml-read.js +283 -0
  221. package/dist/xml/xml.d.ts +41 -0
  222. package/dist/xml/xml.js +86 -0
  223. package/package.json +117 -0
@@ -0,0 +1,15 @@
1
+ import { XlsxError } from "../errors.js";
2
+ /**
3
+ * Thrown when XML text cannot be scanned into parse events — an unterminated tag, comment, CDATA
4
+ * section, markup declaration or processing instruction.
5
+ *
6
+ * This is a *typed* failure rather than the native `SyntaxError` it reads like, because the text
7
+ * being parsed is almost always a part lifted out of an untrusted `.xlsx`. A caller wrapping
8
+ * `readXlsx` needs to tell "the file I was handed is corrupt" from "something in my own code threw a
9
+ * `SyntaxError`", and a native error gives them no way to. The message still names the construct that
10
+ * did not terminate; only the type changed.
11
+ */
12
+ export class XmlParseError extends XlsxError {
13
+ name = 'XmlParseError';
14
+ code = 'malformed-input';
15
+ }
@@ -0,0 +1,107 @@
1
+ export interface XmlAttributes {
2
+ readonly [name: string]: string;
3
+ }
4
+ export interface SaxHandlers {
5
+ /** An element start. `selfClosing` is true for `<x/>`; no matching {@link onClose} fires for it. */
6
+ onOpen(name: string, attrs: XmlAttributes, selfClosing: boolean): void;
7
+ /** A run of character data (already entity-decoded; CDATA delivered verbatim). Omit to ignore text. */
8
+ onText?(text: string): void;
9
+ /** An element end (`</x>`); the synthetic end of a self-closing element is *not* reported here.
10
+ * Omit to ignore closes. */
11
+ onClose?(name: string): void;
12
+ }
13
+ /**
14
+ * One parse event from {@link xmlEvents}. The payloads match {@link SaxHandlers} exactly: `text`
15
+ * is already entity-decoded (or verbatim CDATA), and a `<x/>` yields one `open` with
16
+ * `selfClosing: true` and no matching `close`. The discriminated `kind` lets a *pull* consumer
17
+ * 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
+ };
31
+ /**
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.
36
+ */
37
+ export declare function decodeEntities(value: string): string;
38
+ /**
39
+ * Scan an XML document as a *pull* stream of {@link XmlEvent}s in a single O(n) pass with no
40
+ * recursion. This is the parser's core; {@link parseXml} is a thin push adapter over it. A
41
+ * consumer that must produce output incrementally (the streaming row reader) pulls events and
42
+ * yields as it goes, holding only its own running state — a push callback cannot.
43
+ *
44
+ * Throws {@link XmlParseError} on malformed markup.
45
+ */
46
+ export declare function xmlEvents(source: string): Generator<XmlEvent>;
47
+ /** An element start surfaced by {@link openElements}: its qualified `name`, the namespace-stripped
48
+ * `local` name the filter matched on, and its already-decoded `attrs`. */
49
+ export interface OpenElement {
50
+ readonly name: string;
51
+ readonly local: string;
52
+ readonly attrs: XmlAttributes;
53
+ }
54
+ /**
55
+ * Yield each element start in `source` as an {@link OpenElement}, optionally restricted to the given
56
+ * local names. This is the pull shape for the ubiquitous "scan opens, read attributes" pass: a caller
57
+ * writes a plain `for..of` and reads `attrs` directly, instead of threading a mutable accumulator out
58
+ * through a {@link parseXml} `onOpen` closure. With no names every start is yielded; with one or more,
59
+ * only starts whose namespace-stripped name matches one. Text and close events are skipped.
60
+ *
61
+ * Throws {@link XmlParseError} on malformed markup.
62
+ */
63
+ export declare function openElements(source: string, ...localNames: string[]): Generator<OpenElement>;
64
+ /**
65
+ * Wrap an {@link XmlEvent} stream so a self-closing `<x/>` whose local name is in `names` is
66
+ * presented as an open (with `selfClosing: false`) immediately followed by a close — the exact
67
+ * event shape of `<x></x>`. This lets a consumer commit such an element from its close handling
68
+ * alone, instead of hand-coding a parallel self-closing branch: {@link xmlEvents} fires no close
69
+ * for `<x/>`, and forgetting that branch silently drops the empty element. Names not in the set
70
+ * pass through untouched, so an element whose close would wrongly act on absent content — an empty
71
+ * `<v/>`/`<f/>` that must not commit captured text — is left as a bare self-closing open.
72
+ */
73
+ export declare function closeEmptyElements(events: Iterable<XmlEvent>, names: ReadonlySet<string>): Generator<XmlEvent>;
74
+ /** Options for {@link parseXml}. */
75
+ export interface ParseXmlOptions {
76
+ /**
77
+ * Local names whose self-closing form should also fire {@link SaxHandlers.onClose}. For each name
78
+ * listed, `<x/>` is delivered as an open followed by a close (via {@link closeEmptyElements}), so a
79
+ * handler commits the element once in `onClose` rather than duplicating that logic in a self-closing
80
+ * branch. Only name elements the handler is safe to run on close when empty.
81
+ */
82
+ readonly closeEmptyElements?: ReadonlySet<string>;
83
+ }
84
+ /**
85
+ * Parse an XML document, dispatching SAX events to `handlers`. A thin push adapter over
86
+ * {@link xmlEvents} — one scanning core serves both the callback and the pull consumers.
87
+ * Throws {@link XmlParseError} on malformed markup.
88
+ */
89
+ export declare function parseXml(source: string, handlers: SaxHandlers, options?: ParseXmlOptions): void;
90
+ /** Strip a namespace prefix from a qualified name (`r:id` → `id`, `sheet` → `sheet`). */
91
+ export declare function localName(qualified: string): string;
92
+ /** An OOXML boolean that is on when present with no value (`<b/>` is bold) and off only on an
93
+ * explicit `"0"`/`"false"`; absence reads as on. */
94
+ export declare function boolPresent(val: string | undefined): boolean;
95
+ /** An OOXML boolean that is on only when explicitly `"1"`/`"true"`; anything else — including
96
+ * absence and a truthy-looking `"0"` — is off. */
97
+ export declare function boolStrict(val: string | undefined): boolean;
98
+ /** An optional OOXML boolean: `undefined` when the attribute is absent or carries an unrecognised
99
+ * token, otherwise its `"1"`/`"true"` vs `"0"`/`"false"` value. Lets a caller store only the
100
+ * attributes the source actually carried, so a re-write stays byte-clean. */
101
+ export declare function boolTristate(val: string | undefined): boolean | undefined;
102
+ /** Read an operand's text as a number only when it is a canonical decimal literal (optional sign,
103
+ * digits, optional fraction). A cell reference, defined name, expression, or exotically-spelled
104
+ * number (`1E5`, hex) keeps its verbatim text, so it is neither coerced to `NaN` and lost nor
105
+ * re-spelled into a number that would not re-write byte-clean. Callers layer their own type rules
106
+ * (a data-validation `list`/`custom` operand stays a string regardless of what it looks like). */
107
+ export declare function coerceNumericLiteral(text: string): string | number;
@@ -0,0 +1,283 @@
1
+ // A lean, hostile-input-safe SAX pull parser for the read path.
2
+ //
3
+ // OOXML uses a small, regular subset of XML, so the reader does not need — and must not
4
+ // pay for — a general-purpose DOM library (see ADR 0004). This scans the source in a
5
+ // single O(n) pass with no recursion, emitting open/text/close events; the OOXML reader
6
+ // consumes them and builds only the model, so peak memory tracks real content rather
7
+ // than document structure.
8
+ //
9
+ // Security posture: entities are *decoded, never expanded*. Only the five predefined
10
+ // entities and numeric character references are recognised; DTDs and `<!ENTITY>`
11
+ // definitions are skipped, so entity-expansion (billion-laughs) and external-entity
12
+ // (XXE) attacks are structurally impossible here, not merely mitigated.
13
+ import { XmlParseError } from "./errors.js";
14
+ const PREDEFINED_ENTITIES = {
15
+ amp: '&',
16
+ lt: '<',
17
+ gt: '>',
18
+ quot: '"',
19
+ apos: "'",
20
+ };
21
+ const ENTITY = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g;
22
+ /**
23
+ * Decode XML character references and the five predefined entities. An unrecognised
24
+ * `&name;` is left verbatim rather than expanded — there is no DTD, so there is nothing
25
+ * to expand it to, and refusing to invent one is what makes entity-expansion attacks
26
+ * impossible.
27
+ */
28
+ export function decodeEntities(value) {
29
+ if (!value.includes('&'))
30
+ return value;
31
+ return value.replace(ENTITY, (match, body) => {
32
+ if (body.charCodeAt(0) === 0x23 /* # */) {
33
+ const codePoint = body.charCodeAt(1) === 0x78 /* x */
34
+ ? parseInt(body.slice(2), 16)
35
+ : parseInt(body.slice(1), 10);
36
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
37
+ return match;
38
+ try {
39
+ return String.fromCodePoint(codePoint);
40
+ }
41
+ catch {
42
+ return match;
43
+ }
44
+ }
45
+ const named = PREDEFINED_ENTITIES[body];
46
+ return named ?? match;
47
+ });
48
+ }
49
+ // Attribute values cannot contain their own delimiter and cannot contain a literal `<`,
50
+ // so a delimiter-respecting scan finds a tag's end even when an attribute value holds a
51
+ // `>` (legal but rare). Names may carry a namespace prefix (`r:id`, `xml:space`).
52
+ const ATTRIBUTE = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
53
+ function parseAttributes(source) {
54
+ const attrs = {};
55
+ ATTRIBUTE.lastIndex = 0;
56
+ let match = ATTRIBUTE.exec(source);
57
+ while (match !== null) {
58
+ const value = match[2] ?? match[3] ?? '';
59
+ attrs[match[1]] = decodeEntities(value);
60
+ match = ATTRIBUTE.exec(source);
61
+ }
62
+ return attrs;
63
+ }
64
+ // Scan to the tag's closing `>`, honouring quoted attribute values so a `>` inside a
65
+ // value does not end the tag prematurely.
66
+ function findTagEnd(source, start) {
67
+ let quote = '';
68
+ for (let i = start + 1; i < source.length; i++) {
69
+ const ch = source[i];
70
+ if (quote !== '') {
71
+ if (ch === quote)
72
+ quote = '';
73
+ }
74
+ else if (ch === '"' || ch === "'") {
75
+ quote = ch;
76
+ }
77
+ else if (ch === '>') {
78
+ return i;
79
+ }
80
+ }
81
+ throw new XmlParseError('unterminated tag: missing ">"');
82
+ }
83
+ // A `<!DOCTYPE …>` may contain a bracketed internal subset with its own `>`; balance the
84
+ // brackets so the declaration is skipped whole. We never act on its contents.
85
+ function skipDeclaration(source, start) {
86
+ let depth = 0;
87
+ for (let i = start; i < source.length; i++) {
88
+ const ch = source[i];
89
+ if (ch === '[')
90
+ depth++;
91
+ else if (ch === ']')
92
+ depth--;
93
+ else if (ch === '>' && depth <= 0)
94
+ return i + 1;
95
+ }
96
+ throw new XmlParseError('unterminated markup declaration: missing ">"');
97
+ }
98
+ /**
99
+ * Scan an XML document as a *pull* stream of {@link XmlEvent}s in a single O(n) pass with no
100
+ * recursion. This is the parser's core; {@link parseXml} is a thin push adapter over it. A
101
+ * consumer that must produce output incrementally (the streaming row reader) pulls events and
102
+ * yields as it goes, holding only its own running state — a push callback cannot.
103
+ *
104
+ * Throws {@link XmlParseError} on malformed markup.
105
+ */
106
+ export function* xmlEvents(source) {
107
+ const length = source.length;
108
+ let i = 0;
109
+ while (i < length) {
110
+ const lt = source.indexOf('<', i);
111
+ if (lt === -1) {
112
+ const chunk = source.slice(i);
113
+ if (chunk.length > 0)
114
+ yield { kind: 'text', text: decodeEntities(normalizeLineEndings(chunk)) };
115
+ return;
116
+ }
117
+ if (lt > i) {
118
+ const chunk = source.slice(i, lt);
119
+ if (chunk.length > 0)
120
+ yield { kind: 'text', text: decodeEntities(normalizeLineEndings(chunk)) };
121
+ }
122
+ if (source.startsWith('<!--', lt)) {
123
+ const end = source.indexOf('-->', lt + 4);
124
+ if (end === -1)
125
+ throw new XmlParseError('unterminated comment');
126
+ i = end + 3;
127
+ continue;
128
+ }
129
+ if (source.startsWith('<![CDATA[', lt)) {
130
+ const end = source.indexOf(']]>', lt + 9);
131
+ if (end === -1)
132
+ throw new XmlParseError('unterminated CDATA section');
133
+ yield { kind: 'text', text: source.slice(lt + 9, end) };
134
+ i = end + 3;
135
+ continue;
136
+ }
137
+ if (source.startsWith('<?', lt)) {
138
+ const end = source.indexOf('?>', lt + 2);
139
+ if (end === -1)
140
+ throw new XmlParseError('unterminated processing instruction');
141
+ i = end + 2;
142
+ continue;
143
+ }
144
+ if (source.startsWith('<!', lt)) {
145
+ i = skipDeclaration(source, lt);
146
+ continue;
147
+ }
148
+ const gt = findTagEnd(source, lt);
149
+ const raw = source.slice(lt + 1, gt);
150
+ if (raw.charCodeAt(0) === 0x2f /* / */) {
151
+ yield { kind: 'close', name: raw.slice(1).trim() };
152
+ }
153
+ else {
154
+ const selfClosing = raw.charCodeAt(raw.length - 1) === 0x2f;
155
+ const body = selfClosing ? raw.slice(0, -1) : raw;
156
+ const nameEnd = firstWhitespace(body);
157
+ const name = nameEnd === -1 ? body : body.slice(0, nameEnd);
158
+ const attrs = nameEnd === -1 ? {} : parseAttributes(body.slice(nameEnd));
159
+ yield { kind: 'open', name, attrs, selfClosing };
160
+ }
161
+ i = gt + 1;
162
+ }
163
+ }
164
+ /**
165
+ * Yield each element start in `source` as an {@link OpenElement}, optionally restricted to the given
166
+ * local names. This is the pull shape for the ubiquitous "scan opens, read attributes" pass: a caller
167
+ * writes a plain `for..of` and reads `attrs` directly, instead of threading a mutable accumulator out
168
+ * through a {@link parseXml} `onOpen` closure. With no names every start is yielded; with one or more,
169
+ * only starts whose namespace-stripped name matches one. Text and close events are skipped.
170
+ *
171
+ * Throws {@link XmlParseError} on malformed markup.
172
+ */
173
+ export function* openElements(source, ...localNames) {
174
+ const filter = localNames.length > 0 ? new Set(localNames) : undefined;
175
+ for (const event of xmlEvents(source)) {
176
+ if (event.kind !== 'open')
177
+ continue;
178
+ const local = localName(event.name);
179
+ if (filter !== undefined && !filter.has(local))
180
+ continue;
181
+ yield { name: event.name, local, attrs: event.attrs };
182
+ }
183
+ }
184
+ /**
185
+ * Wrap an {@link XmlEvent} stream so a self-closing `<x/>` whose local name is in `names` is
186
+ * presented as an open (with `selfClosing: false`) immediately followed by a close — the exact
187
+ * event shape of `<x></x>`. This lets a consumer commit such an element from its close handling
188
+ * alone, instead of hand-coding a parallel self-closing branch: {@link xmlEvents} fires no close
189
+ * for `<x/>`, and forgetting that branch silently drops the empty element. Names not in the set
190
+ * pass through untouched, so an element whose close would wrongly act on absent content — an empty
191
+ * `<v/>`/`<f/>` that must not commit captured text — is left as a bare self-closing open.
192
+ */
193
+ export function* closeEmptyElements(events, names) {
194
+ for (const event of events) {
195
+ if (event.kind === 'open' && event.selfClosing && names.has(localName(event.name))) {
196
+ yield { kind: 'open', name: event.name, attrs: event.attrs, selfClosing: false };
197
+ yield { kind: 'close', name: event.name };
198
+ }
199
+ else {
200
+ yield event;
201
+ }
202
+ }
203
+ }
204
+ /**
205
+ * Parse an XML document, dispatching SAX events to `handlers`. A thin push adapter over
206
+ * {@link xmlEvents} — one scanning core serves both the callback and the pull consumers.
207
+ * Throws {@link XmlParseError} on malformed markup.
208
+ */
209
+ export function parseXml(source, handlers, options) {
210
+ const events = options?.closeEmptyElements
211
+ ? closeEmptyElements(xmlEvents(source), options.closeEmptyElements)
212
+ : xmlEvents(source);
213
+ for (const event of events) {
214
+ switch (event.kind) {
215
+ case 'open':
216
+ handlers.onOpen(event.name, event.attrs, event.selfClosing);
217
+ break;
218
+ case 'text':
219
+ handlers.onText?.(event.text);
220
+ break;
221
+ case 'close':
222
+ handlers.onClose?.(event.name);
223
+ break;
224
+ }
225
+ }
226
+ }
227
+ // XML end-of-line handling (spec §2.11): a literal CRLF or lone CR in character data is
228
+ // normalized to a single LF, so a value's in-cell line breaks read back identically whatever
229
+ // newline convention the producer wrote. Normalization precedes entity decoding, so a
230
+ // deliberately-encoded carriage return (&#13;) survives it — the escape hatch for a real CR.
231
+ // CDATA is delivered verbatim (it bypasses this), matching the reader's CDATA contract.
232
+ function normalizeLineEndings(chunk) {
233
+ if (!chunk.includes('\r'))
234
+ return chunk;
235
+ return chunk.replace(/\r\n?/g, '\n');
236
+ }
237
+ function firstWhitespace(source) {
238
+ for (let i = 0; i < source.length; i++) {
239
+ const code = source.charCodeAt(i);
240
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d)
241
+ return i;
242
+ }
243
+ return -1;
244
+ }
245
+ /** Strip a namespace prefix from a qualified name (`r:id` → `id`, `sheet` → `sheet`). */
246
+ export function localName(qualified) {
247
+ const colon = qualified.indexOf(':');
248
+ return colon === -1 ? qualified : qualified.slice(colon + 1);
249
+ }
250
+ // OOXML spells booleans three ways, and the reader needs all three. A `<b/>`-style font flag
251
+ // defaults to on when present with no value, so its absence is meaningful (`boolPresent`). Most
252
+ // attributes are plain xsd:booleans that are off unless an explicit "1"/"true" turns them on
253
+ // (`boolStrict`). An optional attribute that must round-trip byte-clean has to distinguish absent
254
+ // from present-and-false and drop an unrecognised token rather than coerce it (`boolTristate`).
255
+ /** An OOXML boolean that is on when present with no value (`<b/>` is bold) and off only on an
256
+ * explicit `"0"`/`"false"`; absence reads as on. */
257
+ export function boolPresent(val) {
258
+ return val === undefined || (val !== '0' && val !== 'false');
259
+ }
260
+ /** An OOXML boolean that is on only when explicitly `"1"`/`"true"`; anything else — including
261
+ * absence and a truthy-looking `"0"` — is off. */
262
+ export function boolStrict(val) {
263
+ return val === '1' || val === 'true';
264
+ }
265
+ /** An optional OOXML boolean: `undefined` when the attribute is absent or carries an unrecognised
266
+ * token, otherwise its `"1"`/`"true"` vs `"0"`/`"false"` value. Lets a caller store only the
267
+ * attributes the source actually carried, so a re-write stays byte-clean. */
268
+ export function boolTristate(val) {
269
+ if (val === '1' || val === 'true')
270
+ return true;
271
+ if (val === '0' || val === 'false')
272
+ return false;
273
+ return undefined;
274
+ }
275
+ /** Read an operand's text as a number only when it is a canonical decimal literal (optional sign,
276
+ * digits, optional fraction). A cell reference, defined name, expression, or exotically-spelled
277
+ * number (`1E5`, hex) keeps its verbatim text, so it is neither coerced to `NaN` and lost nor
278
+ * re-spelled into a number that would not re-write byte-clean. Callers layer their own type rules
279
+ * (a data-validation `list`/`custom` operand stays a string regardless of what it looks like). */
280
+ export function coerceNumericLiteral(text) {
281
+ const trimmed = text.trim();
282
+ return /^-?\d+(?:\.\d+)?$/.test(trimmed) ? Number(trimmed) : text;
283
+ }
@@ -0,0 +1,41 @@
1
+ /** Escape a string for use as XML element text. */
2
+ export declare function escapeText(value: string): string;
3
+ /** Escape a string for use inside a double-quoted XML attribute value. */
4
+ export declare function escapeAttr(value: string): string;
5
+ /**
6
+ * Whether an element's text must be wrapped with `xml:space="preserve"` to survive a
7
+ * round-trip. Leading/trailing whitespace is otherwise collapsed by consumers, so a
8
+ * string cell value that begins or ends with a space needs the marker.
9
+ */
10
+ export declare function needsSpacePreserve(value: string): boolean;
11
+ /**
12
+ * A `<t>` text element carrying an escaped string, marked `xml:space="preserve"` when its
13
+ * whitespace would otherwise be collapsed. Shared by every string-bearing element — a plain
14
+ * inline string cell, a rich-text run — so all decode identically on the way back.
15
+ */
16
+ export declare function textElement(value: string): string;
17
+ /**
18
+ * Render a formula operand for serialisation: a number becomes its literal, a string is stripped of
19
+ * the single optional leading '=' an author may write (OOXML stores the expression without it, e.g.
20
+ * `=A1>0` on disk is `A1>0`). The result is unescaped — the caller escapes it for its target,
21
+ * whether that is element text or an attribute value.
22
+ */
23
+ export declare function stripFormulaEquals(value: string | number): string;
24
+ /**
25
+ * A boolean attribute rendered with a leading space (` name="1"` / ` name="0"`), or '' when the value
26
+ * is undefined. OOXML booleans serialise as 1/0; emitting the explicit `="0"` lets a writer force a
27
+ * flag off against a consumer's default, while an unset (undefined) flag stays out of the element
28
+ * entirely — the two-state-plus-absent contract every flag writer here shares.
29
+ */
30
+ export declare function boolAttr(name: string, value: boolean | undefined): string;
31
+ /**
32
+ * A numeric attribute rendered with a leading space (` name="42"`), or '' when the value is undefined
33
+ * — so a count an author never set stays out of the element rather than fabricating a default.
34
+ */
35
+ export declare function attr(name: string, value: number | undefined): string;
36
+ /**
37
+ * A finite number serialises as its shortest round-trippable decimal; a non-finite one
38
+ * has no OOXML numeric representation, so the writer refuses it rather than emit `NaN`.
39
+ */
40
+ export declare function numberText(value: number): string;
41
+ export declare const XML_DECLARATION = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
@@ -0,0 +1,86 @@
1
+ // Minimal XML serialisation helpers for the write path.
2
+ //
3
+ // Writing OOXML needs only correct escaping and well-formed structure; parsing (the
4
+ // reader's concern) is a separate, later decision, so no XML library is on the write
5
+ // path. Escaping is the one hard, security-relevant requirement — an unescaped `<`,
6
+ // `&`, or `"` produces a malformed package a consumer rejects — so it lives here,
7
+ // audited once, rather than sprinkled through the part emitters.
8
+ import { AuthoringError } from "../errors.js";
9
+ const TEXT_ESCAPES = {
10
+ '&': '&amp;',
11
+ '<': '&lt;',
12
+ '>': '&gt;',
13
+ };
14
+ const ATTR_ESCAPES = {
15
+ '&': '&amp;',
16
+ '<': '&lt;',
17
+ '>': '&gt;',
18
+ '"': '&quot;',
19
+ "'": '&apos;',
20
+ '\n': '&#10;',
21
+ '\r': '&#13;',
22
+ '\t': '&#9;',
23
+ };
24
+ /** Escape a string for use as XML element text. */
25
+ export function escapeText(value) {
26
+ return value.replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
27
+ }
28
+ /** Escape a string for use inside a double-quoted XML attribute value. */
29
+ export function escapeAttr(value) {
30
+ return value.replace(/[&<>"'\n\r\t]/g, (ch) => ATTR_ESCAPES[ch]);
31
+ }
32
+ /**
33
+ * Whether an element's text must be wrapped with `xml:space="preserve"` to survive a
34
+ * round-trip. Leading/trailing whitespace is otherwise collapsed by consumers, so a
35
+ * string cell value that begins or ends with a space needs the marker.
36
+ */
37
+ export function needsSpacePreserve(value) {
38
+ return value.length > 0 && (value !== value.trim() || /[\n\r\t]/.test(value));
39
+ }
40
+ /**
41
+ * A `<t>` text element carrying an escaped string, marked `xml:space="preserve"` when its
42
+ * whitespace would otherwise be collapsed. Shared by every string-bearing element — a plain
43
+ * inline string cell, a rich-text run — so all decode identically on the way back.
44
+ */
45
+ export function textElement(value) {
46
+ const space = needsSpacePreserve(value) ? ' xml:space="preserve"' : '';
47
+ return `<t${space}>${escapeText(value)}</t>`;
48
+ }
49
+ /**
50
+ * Render a formula operand for serialisation: a number becomes its literal, a string is stripped of
51
+ * the single optional leading '=' an author may write (OOXML stores the expression without it, e.g.
52
+ * `=A1>0` on disk is `A1>0`). The result is unescaped — the caller escapes it for its target,
53
+ * whether that is element text or an attribute value.
54
+ */
55
+ export function stripFormulaEquals(value) {
56
+ if (typeof value === 'number')
57
+ return String(value);
58
+ return value.startsWith('=') ? value.slice(1) : value;
59
+ }
60
+ /**
61
+ * A boolean attribute rendered with a leading space (` name="1"` / ` name="0"`), or '' when the value
62
+ * is undefined. OOXML booleans serialise as 1/0; emitting the explicit `="0"` lets a writer force a
63
+ * flag off against a consumer's default, while an unset (undefined) flag stays out of the element
64
+ * entirely — the two-state-plus-absent contract every flag writer here shares.
65
+ */
66
+ export function boolAttr(name, value) {
67
+ return value === undefined ? '' : ` ${name}="${value ? 1 : 0}"`;
68
+ }
69
+ /**
70
+ * A numeric attribute rendered with a leading space (` name="42"`), or '' when the value is undefined
71
+ * — so a count an author never set stays out of the element rather than fabricating a default.
72
+ */
73
+ export function attr(name, value) {
74
+ return value === undefined ? '' : ` ${name}="${value}"`;
75
+ }
76
+ /**
77
+ * A finite number serialises as its shortest round-trippable decimal; a non-finite one
78
+ * has no OOXML numeric representation, so the writer refuses it rather than emit `NaN`.
79
+ */
80
+ export function numberText(value) {
81
+ if (!Number.isFinite(value)) {
82
+ throw new AuthoringError(`cannot write a non-finite number (${value}) — it has no OOXML representation`);
83
+ }
84
+ return String(value);
85
+ }
86
+ export const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
package/package.json ADDED
@@ -0,0 +1,117 @@
1
+ {
2
+ "name": "@shbernal/ts-xlsx",
3
+ "version": "1.0.0",
4
+ "description": "A TypeScript-first library for reading and writing xlsx (OOXML) spreadsheets.",
5
+ "license": "MIT",
6
+ "author": "shbernal",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/shbernal/ts-xlsx.git"
11
+ },
12
+ "homepage": "https://github.com/shbernal/ts-xlsx#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/shbernal/ts-xlsx/issues"
15
+ },
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "packageManager": "pnpm@11.11.0",
20
+ "sideEffects": false,
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ },
26
+ "./core": {
27
+ "types": "./dist/entries/core.d.ts",
28
+ "default": "./dist/entries/core.js"
29
+ },
30
+ "./xlsx": {
31
+ "types": "./dist/entries/xlsx.d.ts",
32
+ "default": "./dist/entries/xlsx.js"
33
+ },
34
+ "./xlsb": {
35
+ "types": "./dist/entries/xlsb.d.ts",
36
+ "default": "./dist/entries/xlsb.js"
37
+ },
38
+ "./csv": {
39
+ "types": "./dist/entries/csv.d.ts",
40
+ "default": "./dist/entries/csv.js"
41
+ },
42
+ "./vba": {
43
+ "types": "./dist/entries/vba.d.ts",
44
+ "default": "./dist/entries/vba.js"
45
+ },
46
+ "./customui": {
47
+ "types": "./dist/entries/customui.d.ts",
48
+ "default": "./dist/entries/customui.js"
49
+ },
50
+ "./errors": {
51
+ "types": "./dist/entries/errors.d.ts",
52
+ "default": "./dist/entries/errors.js"
53
+ },
54
+ "./package.json": "./package.json"
55
+ },
56
+ "main": "./dist/index.js",
57
+ "types": "./dist/index.d.ts",
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "files": [
62
+ "dist",
63
+ "LICENSE",
64
+ "README.md"
65
+ ],
66
+ "scripts": {
67
+ "prepare": "lefthook install",
68
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
69
+ "build": "pnpm run clean && tsc -p tsconfig.build.json",
70
+ "size": "node scripts/size-budget.ts",
71
+ "smoke:dist": "node scripts/smoke-dist.ts",
72
+ "docs": "node scripts/gen-docs.ts",
73
+ "docs:check": "node scripts/gen-docs.ts && git add --intent-to-add -- docs/api && git diff --exit-code -- docs/api",
74
+ "constitution:check": "node scripts/check-constitution.ts",
75
+ "layering:check": "node scripts/check-layering.ts",
76
+ "entries:check": "node scripts/check-entries.ts",
77
+ "lint": "biome check --error-on-warnings src scripts test tools",
78
+ "lint:fix": "biome check --write --error-on-warnings src scripts test tools",
79
+ "format": "biome format --write src scripts test tools",
80
+ "typecheck": "tsc --noEmit -p tsconfig.json",
81
+ "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
82
+ "test:src": "node --test \"src/**/*.test.ts\"",
83
+ "test:coverage": "node --test --experimental-test-coverage \"src/**/*.test.ts\"",
84
+ "corpus": "node test/corpus/run.ts",
85
+ "corpus:dist": "node test/corpus/run.ts --target dist",
86
+ "verify": "node scripts/verify.ts --full",
87
+ "verify:quick": "node scripts/verify.ts --quick",
88
+ "test": "node scripts/verify.ts --full",
89
+ "prepublishOnly": "pnpm run build && pnpm run test && pnpm run smoke:dist && pnpm run size",
90
+ "validate:ooxml": "node scripts/ooxml-validator.ts",
91
+ "test:ooxml": "node test/ooxml-validation/run.ts",
92
+ "harvest": "node scripts/harvest/fetch-issue.ts",
93
+ "harvest:list": "node scripts/harvest/list-backlog.ts",
94
+ "harvest:all": "node scripts/harvest/harvest-all.ts",
95
+ "harvest:status": "node scripts/harvest/status.ts"
96
+ },
97
+ "keywords": [
98
+ "xlsx",
99
+ "ooxml",
100
+ "excel",
101
+ "spreadsheet",
102
+ "csv",
103
+ "typescript",
104
+ "workbook",
105
+ "streaming"
106
+ ],
107
+ "dependencies": {
108
+ "fflate": "^0.8.3"
109
+ },
110
+ "devDependencies": {
111
+ "@biomejs/biome": "2.5.4",
112
+ "@types/node": "^24.13.3",
113
+ "jszip": "^3.10.1",
114
+ "lefthook": "^2.1.10",
115
+ "typescript": "^6.0.3"
116
+ }
117
+ }