xls-codec 0.0.0 → 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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +128 -0
  3. package/dist/biff/cursor.cjs +73 -0
  4. package/dist/biff/cursor.d.cts +28 -0
  5. package/dist/biff/cursor.d.ts +28 -0
  6. package/dist/biff/cursor.js +72 -0
  7. package/dist/biff/errors.cjs +18 -0
  8. package/dist/biff/errors.d.cts +5 -0
  9. package/dist/biff/errors.d.ts +5 -0
  10. package/dist/biff/errors.js +17 -0
  11. package/dist/biff/record-types.cjs +108 -0
  12. package/dist/biff/record-types.d.cts +73 -0
  13. package/dist/biff/record-types.d.ts +73 -0
  14. package/dist/biff/record-types.js +73 -0
  15. package/dist/biff/records.cjs +40 -0
  16. package/dist/biff/records.d.cts +2 -0
  17. package/dist/biff/records.d.ts +2 -0
  18. package/dist/biff/records.js +38 -0
  19. package/dist/biff/rk.cjs +29 -0
  20. package/dist/biff/rk.d.cts +5 -0
  21. package/dist/biff/rk.d.ts +5 -0
  22. package/dist/biff/rk.js +28 -0
  23. package/dist/biff/strings.cjs +66 -0
  24. package/dist/biff/strings.d.cts +14 -0
  25. package/dist/biff/strings.d.ts +14 -0
  26. package/dist/biff/strings.js +63 -0
  27. package/dist/biff/substreams.cjs +71 -0
  28. package/dist/biff/substreams.d.cts +21 -0
  29. package/dist/biff/substreams.d.ts +21 -0
  30. package/dist/biff/substreams.js +69 -0
  31. package/dist/container.cjs +46 -0
  32. package/dist/container.d.cts +15 -0
  33. package/dist/container.d.ts +15 -0
  34. package/dist/container.js +44 -0
  35. package/dist/content.cjs +230 -0
  36. package/dist/content.d.cts +20 -0
  37. package/dist/content.d.ts +20 -0
  38. package/dist/content.js +228 -0
  39. package/dist/index.cjs +74 -0
  40. package/dist/index.d.cts +15 -0
  41. package/dist/index.d.ts +15 -0
  42. package/dist/index.js +15 -0
  43. package/dist/number-format.cjs +298 -0
  44. package/dist/number-format.d.cts +32 -0
  45. package/dist/number-format.d.ts +32 -0
  46. package/dist/number-format.js +296 -0
  47. package/dist/records-DVIqXFKk.d.cts +20 -0
  48. package/dist/records-DVIqXFKk.d.ts +20 -0
  49. package/dist/serial.cjs +62 -0
  50. package/dist/serial.d.cts +7 -0
  51. package/dist/serial.d.ts +7 -0
  52. package/dist/serial.js +59 -0
  53. package/dist/units.cjs +30 -0
  54. package/dist/units.d.cts +11 -0
  55. package/dist/units.d.ts +11 -0
  56. package/dist/units.js +28 -0
  57. package/dist/workbook/globals.cjs +106 -0
  58. package/dist/workbook/globals.d.cts +45 -0
  59. package/dist/workbook/globals.d.ts +45 -0
  60. package/dist/workbook/globals.js +104 -0
  61. package/dist/workbook/sheet.cjs +372 -0
  62. package/dist/workbook/sheet.d.cts +58 -0
  63. package/dist/workbook/sheet.d.ts +58 -0
  64. package/dist/workbook/sheet.js +371 -0
  65. package/package.json +85 -2
@@ -0,0 +1,32 @@
1
+ //#region src/number-format.d.ts
2
+ /** What a format code says the value is. `elapsedTime` is kept distinct from `time` because a duration may exceed 24 hours and so has no wall-clock spelling in the schema. */
3
+ type NumberFormatClass = {
4
+ kind: "number";
5
+ } | {
6
+ kind: "text";
7
+ } | {
8
+ kind: "percentage";
9
+ } | {
10
+ kind: "currency";
11
+ code?: string;
12
+ } | {
13
+ kind: "date";
14
+ } | {
15
+ kind: "time";
16
+ } | {
17
+ kind: "dateTime";
18
+ } | {
19
+ kind: "elapsedTime";
20
+ };
21
+ /** Classifies a format code, reading the FIRST section only. Sections two through four are the negative/zero/text renderings of the same underlying value: they differ in colour, parentheses, and literal text, never in what kind of thing the cell holds, and a cell whose value happens to be negative must not classify differently from the identical cell holding a positive one. */
22
+ declare function classifyNumberFormat(formatCode: string): NumberFormatClass;
23
+ /**
24
+ * The built-in format codes, which a file never writes into its own Format records and every reader is expected to know.
25
+ *
26
+ * [MS-XLS] 2.4.126 constrains a Format record's own ifmt to 5-8, 23-26, 41-44, 63-66, and 164-382, so an XF pointing at any other identifier resolves through this table instead. The codes are ECMA-376 Part 1 SS18.8.30's table, which BIFF8 and xlsx share.
27
+ *
28
+ * Ids 23-36 are deliberately absent: that table leaves them reserved, and inventing codes for them would fabricate a mapping no specification defines -- an XF pointing at one resolves to no code at all, which the caller reports as absent rather than silently substituting General. These strings are fed through the SAME classifyNumberFormat as a producer-declared code, never a second table of pre-decided kinds, so the two feeds cannot drift apart.
29
+ */
30
+ declare const BUILTIN_NUMBER_FORMATS: ReadonlyMap<number, string>;
31
+ //#endregion
32
+ export { BUILTIN_NUMBER_FORMATS, NumberFormatClass, classifyNumberFormat };
@@ -0,0 +1,32 @@
1
+ //#region src/number-format.d.ts
2
+ /** What a format code says the value is. `elapsedTime` is kept distinct from `time` because a duration may exceed 24 hours and so has no wall-clock spelling in the schema. */
3
+ type NumberFormatClass = {
4
+ kind: "number";
5
+ } | {
6
+ kind: "text";
7
+ } | {
8
+ kind: "percentage";
9
+ } | {
10
+ kind: "currency";
11
+ code?: string;
12
+ } | {
13
+ kind: "date";
14
+ } | {
15
+ kind: "time";
16
+ } | {
17
+ kind: "dateTime";
18
+ } | {
19
+ kind: "elapsedTime";
20
+ };
21
+ /** Classifies a format code, reading the FIRST section only. Sections two through four are the negative/zero/text renderings of the same underlying value: they differ in colour, parentheses, and literal text, never in what kind of thing the cell holds, and a cell whose value happens to be negative must not classify differently from the identical cell holding a positive one. */
22
+ declare function classifyNumberFormat(formatCode: string): NumberFormatClass;
23
+ /**
24
+ * The built-in format codes, which a file never writes into its own Format records and every reader is expected to know.
25
+ *
26
+ * [MS-XLS] 2.4.126 constrains a Format record's own ifmt to 5-8, 23-26, 41-44, 63-66, and 164-382, so an XF pointing at any other identifier resolves through this table instead. The codes are ECMA-376 Part 1 SS18.8.30's table, which BIFF8 and xlsx share.
27
+ *
28
+ * Ids 23-36 are deliberately absent: that table leaves them reserved, and inventing codes for them would fabricate a mapping no specification defines -- an XF pointing at one resolves to no code at all, which the caller reports as absent rather than silently substituting General. These strings are fed through the SAME classifyNumberFormat as a producer-declared code, never a second table of pre-decided kinds, so the two feeds cannot drift apart.
29
+ */
30
+ declare const BUILTIN_NUMBER_FORMATS: ReadonlyMap<number, string>;
31
+ //#endregion
32
+ export { BUILTIN_NUMBER_FORMATS, NumberFormatClass, classifyNumberFormat };
@@ -0,0 +1,296 @@
1
+ //#region src/number-format.ts
2
+ /** Excel honours at most four sections (positive; negative; zero; text); a fifth is malformed and is dropped rather than guessed at. */
3
+ const MAX_SECTIONS = 4;
4
+ /** Mirrors String.prototype.charAt's past-the-end contract, but over a CODE POINT array, so a rare astral currency symbol stays one token instead of splitting into two lone surrogates. */
5
+ function at(chars, index) {
6
+ return chars[index] ?? "";
7
+ }
8
+ function tokenize(formatCode) {
9
+ const chars = [...formatCode];
10
+ const tokens = [];
11
+ let index = 0;
12
+ while (index < chars.length) {
13
+ const char = at(chars, index);
14
+ if (char === "\"") {
15
+ let text = "";
16
+ index += 1;
17
+ while (index < chars.length && at(chars, index) !== "\"") {
18
+ text += at(chars, index);
19
+ index += 1;
20
+ }
21
+ index += 1;
22
+ tokens.push({
23
+ kind: "literal",
24
+ text
25
+ });
26
+ continue;
27
+ }
28
+ if (char === "\\" || char === "_" || char === "*") {
29
+ tokens.push({
30
+ kind: "literal",
31
+ text: at(chars, index + 1)
32
+ });
33
+ index += 2;
34
+ continue;
35
+ }
36
+ if (char === "[") {
37
+ let body = "";
38
+ index += 1;
39
+ while (index < chars.length && at(chars, index) !== "]") {
40
+ body += at(chars, index);
41
+ index += 1;
42
+ }
43
+ index += 1;
44
+ tokens.push({
45
+ kind: "bracket",
46
+ body
47
+ });
48
+ continue;
49
+ }
50
+ if (char === ";") {
51
+ tokens.push({ kind: "separator" });
52
+ index += 1;
53
+ continue;
54
+ }
55
+ tokens.push({
56
+ kind: "code",
57
+ char
58
+ });
59
+ index += 1;
60
+ }
61
+ return tokens;
62
+ }
63
+ /** Splits on separator tokens only: a ';' inside a quote or bracket was already consumed as part of that token, so it can never split a section here. */
64
+ function splitSections(tokens) {
65
+ const sections = [];
66
+ let current = [];
67
+ for (const token of tokens) {
68
+ if (token.kind === "separator") {
69
+ sections.push(current);
70
+ current = [];
71
+ continue;
72
+ }
73
+ current.push(token);
74
+ }
75
+ sections.push(current);
76
+ return sections.slice(0, MAX_SECTIONS);
77
+ }
78
+ /** The Unicode Currency_Symbol category IS the definition of "this character means money", so it is tested directly rather than against a hand-listed subset that would omit whichever symbol a real file happens to use. */
79
+ const CURRENCY_SYMBOL = /\p{Sc}/u;
80
+ /** `[$GBP-809]` carries an ISO 4217 code; `[$£-809]` carries a display symbol instead. Only the three-ASCII-letter shape counts as a code, because ContentCellValue's `currency` field is documented as the ISO code and there is no faithful symbol-to-code mapping ('$' alone is USD, CAD, AUD and a dozen others). */
81
+ function isIsoCurrencyCodeShape(marker) {
82
+ if (marker.length !== 3) return false;
83
+ for (const char of marker) {
84
+ const upper = char.toUpperCase();
85
+ if (upper < "A" || upper > "Z") return false;
86
+ }
87
+ return true;
88
+ }
89
+ /** An elapsed-time bucket is a bracket holding one repeated h/m/s and nothing else -- the marker that the value is a DURATION, which may legitimately exceed 24 hours, rather than a time of day. */
90
+ function isElapsedBracketBody(body) {
91
+ let letter;
92
+ for (const char of body) {
93
+ const lower = char.toLowerCase();
94
+ if (letter === void 0) {
95
+ if (lower !== "h" && lower !== "m" && lower !== "s") return false;
96
+ letter = lower;
97
+ } else if (lower !== letter) return false;
98
+ }
99
+ return letter !== void 0;
100
+ }
101
+ function classifyBracket(body) {
102
+ if (body.startsWith("$")) {
103
+ const rest = body.slice(1);
104
+ const dashIndex = rest.indexOf("-");
105
+ const marker = dashIndex === -1 ? rest : rest.slice(0, dashIndex);
106
+ if (marker === "") return { kind: "none" };
107
+ return isIsoCurrencyCodeShape(marker) ? {
108
+ kind: "currency",
109
+ code: marker.toUpperCase()
110
+ } : { kind: "currency" };
111
+ }
112
+ return isElapsedBracketBody(body) ? { kind: "elapsed" } : { kind: "none" };
113
+ }
114
+ const AMPM_MARKERS = ["am/pm", "a/p"];
115
+ const AMPM_LETTER = "ampm";
116
+ function matchesAt(chars, index, marker) {
117
+ return [...marker].every((char, offset) => at(chars, index + offset).toLowerCase() === char);
118
+ }
119
+ function codeRunsOf(section) {
120
+ const chars = [];
121
+ for (const token of section) if (token.kind === "code") chars.push(token.char);
122
+ const runs = [];
123
+ let index = 0;
124
+ while (index < chars.length) {
125
+ const marker = AMPM_MARKERS.find((candidate) => matchesAt(chars, index, candidate));
126
+ if (marker !== void 0) {
127
+ runs.push({
128
+ letter: AMPM_LETTER,
129
+ length: marker.length
130
+ });
131
+ index += marker.length;
132
+ continue;
133
+ }
134
+ const char = at(chars, index).toLowerCase();
135
+ let length = 0;
136
+ while (index + length < chars.length && at(chars, index + length).toLowerCase() === char) length += 1;
137
+ runs.push({
138
+ letter: char,
139
+ length
140
+ });
141
+ index += length;
142
+ }
143
+ return runs;
144
+ }
145
+ /** The letters an ambiguous 'm' looks past its neighbours for. 'm' itself is excluded: an unresolved 'm' carries no information for resolving another, so `hh:mm:mm` resolves both against the 'hh'. */
146
+ const RESOLVING_LETTERS = [
147
+ "y",
148
+ "d",
149
+ "h",
150
+ "s"
151
+ ];
152
+ function nearestResolvingLetter(runs, from, step) {
153
+ for (let index = from + step; index >= 0 && index < runs.length; index += step) {
154
+ const run = runs[index];
155
+ if (run !== void 0 && RESOLVING_LETTERS.includes(run.letter)) return run.letter;
156
+ }
157
+ }
158
+ /** Excel's minutes-vs-months rule: 'm'/'mm' is minutes when the nearest preceding date/time code is an hour or the nearest following one is a second, and a month otherwise. 'mmm' and longer are always month names. This is what makes `yyyy-mm-dd hh:mm:ss` resolve its two identical 'mm' runs oppositely. */
159
+ function monthRunIsMinutes(runs, index) {
160
+ return nearestResolvingLetter(runs, index, -1) === "h" || nearestResolvingLetter(runs, index, 1) === "s";
161
+ }
162
+ const PLAIN_NUMBER = { kind: "number" };
163
+ /** Digit placeholders ('0' required, '#' suppressed, '?' space-padded), the decimal and thousands separators. Scientific notation's 'e' is handled at its own run, since a bare 'e' also occurs inside the literal word "General". */
164
+ const NUMERIC_CODES = [
165
+ "0",
166
+ "#",
167
+ "?",
168
+ ".",
169
+ ","
170
+ ];
171
+ function collectSignals(section) {
172
+ const signals = {
173
+ hasDate: false,
174
+ hasTime: false,
175
+ hasElapsed: false,
176
+ hasPercent: false,
177
+ hasNumeric: false,
178
+ hasText: false,
179
+ hasCurrency: false
180
+ };
181
+ for (const token of section) {
182
+ if (token.kind === "literal" && CURRENCY_SYMBOL.test(token.text)) signals.hasCurrency = true;
183
+ if (token.kind === "bracket") {
184
+ const meaning = classifyBracket(token.body);
185
+ if (meaning.kind === "elapsed") signals.hasElapsed = true;
186
+ if (meaning.kind === "currency") {
187
+ signals.hasCurrency = true;
188
+ if (signals.currencyCode === void 0 && meaning.code !== void 0) signals.currencyCode = meaning.code;
189
+ }
190
+ }
191
+ }
192
+ const runs = codeRunsOf(section);
193
+ runs.forEach((run, index) => {
194
+ if (run.letter === "y" || run.letter === "d") {
195
+ signals.hasDate = true;
196
+ return;
197
+ }
198
+ if (run.letter === "h" || run.letter === "s" || run.letter === AMPM_LETTER) {
199
+ signals.hasTime = true;
200
+ return;
201
+ }
202
+ if (run.letter === "m") {
203
+ if (run.length <= 2 && monthRunIsMinutes(runs, index)) signals.hasTime = true;
204
+ else signals.hasDate = true;
205
+ return;
206
+ }
207
+ if (run.letter === "e") {
208
+ const next = runs[index + 1];
209
+ signals.hasNumeric = signals.hasNumeric || next?.letter === "+" || next?.letter === "-";
210
+ return;
211
+ }
212
+ if (run.letter === "%") {
213
+ signals.hasPercent = true;
214
+ return;
215
+ }
216
+ if (run.letter === "@") {
217
+ signals.hasText = true;
218
+ return;
219
+ }
220
+ if (NUMERIC_CODES.includes(run.letter)) {
221
+ signals.hasNumeric = true;
222
+ return;
223
+ }
224
+ if (CURRENCY_SYMBOL.test(run.letter)) signals.hasCurrency = true;
225
+ });
226
+ return signals;
227
+ }
228
+ /** Precedence when a format carries several signals at once, most specific first: an elapsed-time bracket beats everything (the only marker separating a duration from a time of day); any date code beats any time code (a format with both is a genuine combined date-and-time); a percent sign beats a currency marker (`[$GBP-809]0.00%` is still a percentage); and a text placeholder only wins when the section has no numeric placeholder to be a number with. */
229
+ function classifySection(section) {
230
+ const signals = collectSignals(section);
231
+ if (signals.hasElapsed) return { kind: "elapsedTime" };
232
+ if (signals.hasDate) return signals.hasTime ? { kind: "dateTime" } : { kind: "date" };
233
+ if (signals.hasTime) return { kind: "time" };
234
+ if (signals.hasPercent) return { kind: "percentage" };
235
+ if (signals.hasCurrency) {
236
+ const code = signals.currencyCode;
237
+ return code === void 0 ? { kind: "currency" } : {
238
+ kind: "currency",
239
+ code
240
+ };
241
+ }
242
+ if (signals.hasText && !signals.hasNumeric) return { kind: "text" };
243
+ return PLAIN_NUMBER;
244
+ }
245
+ /** Classifies a format code, reading the FIRST section only. Sections two through four are the negative/zero/text renderings of the same underlying value: they differ in colour, parentheses, and literal text, never in what kind of thing the cell holds, and a cell whose value happens to be negative must not classify differently from the identical cell holding a positive one. */
246
+ function classifyNumberFormat(formatCode) {
247
+ const first = splitSections(tokenize(formatCode))[0];
248
+ return first === void 0 ? PLAIN_NUMBER : classifySection(first);
249
+ }
250
+ /**
251
+ * The built-in format codes, which a file never writes into its own Format records and every reader is expected to know.
252
+ *
253
+ * [MS-XLS] 2.4.126 constrains a Format record's own ifmt to 5-8, 23-26, 41-44, 63-66, and 164-382, so an XF pointing at any other identifier resolves through this table instead. The codes are ECMA-376 Part 1 SS18.8.30's table, which BIFF8 and xlsx share.
254
+ *
255
+ * Ids 23-36 are deliberately absent: that table leaves them reserved, and inventing codes for them would fabricate a mapping no specification defines -- an XF pointing at one resolves to no code at all, which the caller reports as absent rather than silently substituting General. These strings are fed through the SAME classifyNumberFormat as a producer-declared code, never a second table of pre-decided kinds, so the two feeds cannot drift apart.
256
+ */
257
+ const BUILTIN_NUMBER_FORMATS = /* @__PURE__ */ new Map([
258
+ [0, "General"],
259
+ [1, "0"],
260
+ [2, "0.00"],
261
+ [3, "#,##0"],
262
+ [4, "#,##0.00"],
263
+ [5, "$#,##0_);($#,##0)"],
264
+ [6, "$#,##0_);[Red]($#,##0)"],
265
+ [7, "$#,##0.00_);($#,##0.00)"],
266
+ [8, "$#,##0.00_);[Red]($#,##0.00)"],
267
+ [9, "0%"],
268
+ [10, "0.00%"],
269
+ [11, "0.00E+00"],
270
+ [12, "# ?/?"],
271
+ [13, "# ??/??"],
272
+ [14, "mm-dd-yy"],
273
+ [15, "d-mmm-yy"],
274
+ [16, "d-mmm"],
275
+ [17, "mmm-yy"],
276
+ [18, "h:mm AM/PM"],
277
+ [19, "h:mm:ss AM/PM"],
278
+ [20, "h:mm"],
279
+ [21, "h:mm:ss"],
280
+ [22, "m/d/yy h:mm"],
281
+ [37, "#,##0 ;(#,##0)"],
282
+ [38, "#,##0 ;[Red](#,##0)"],
283
+ [39, "#,##0.00;(#,##0.00)"],
284
+ [40, "#,##0.00;[Red](#,##0.00)"],
285
+ [41, "_(* #,##0_);_(* \\(#,##0\\);_(* \"-\"_);_(@_)"],
286
+ [42, "_(\"$\"* #,##0_);_(\"$\"* \\(#,##0\\);_(\"$\"* \"-\"_);_(@_)"],
287
+ [43, "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)"],
288
+ [44, "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"],
289
+ [45, "mm:ss"],
290
+ [46, "[h]:mm:ss"],
291
+ [47, "mmss.0"],
292
+ [48, "##0.0E+0"],
293
+ [49, "@"]
294
+ ]);
295
+ //#endregion
296
+ export { BUILTIN_NUMBER_FORMATS, classifyNumberFormat };
@@ -0,0 +1,20 @@
1
+ //#region src/biff/records.d.ts
2
+ /** A single record as the stream carries it: its type from the enumeration ([MS-XLS] 2.3), and its data component, exactly `size` bytes long. */
3
+ interface BiffRecord {
4
+ readonly type: number;
5
+ readonly data: Uint8Array<ArrayBuffer>;
6
+ /** The record's own start offset in the stream. Carried because BoundSheet8's lbPlyPos ([MS-XLS] 2.4.28) addresses a sheet's substream by the byte offset of its BOF, so matching a sheet to its records means knowing where each record began. */
7
+ readonly offset: number;
8
+ }
9
+ /** Thrown when a byte sequence cannot be read as the structure [MS-XLS] specifies, at any level from the record framing up to a record's own fields. */
10
+ declare class BiffFormatError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ /**
14
+ * Splits a BIFF record stream into its records, in stream order.
15
+ *
16
+ * Every failure is thrown rather than tolerated, because there is no safe way to resume: a record's size field is the only thing that says where the next record begins, so a stream that stops making sense at one record makes no sense from there on, and returning the records read so far would be reporting a truncated workbook as a complete one.
17
+ */
18
+ declare function readRecords(stream: Uint8Array<ArrayBuffer>): readonly BiffRecord[];
19
+ //#endregion
20
+ export { BiffRecord as n, readRecords as r, BiffFormatError as t };
@@ -0,0 +1,20 @@
1
+ //#region src/biff/records.d.ts
2
+ /** A single record as the stream carries it: its type from the enumeration ([MS-XLS] 2.3), and its data component, exactly `size` bytes long. */
3
+ interface BiffRecord {
4
+ readonly type: number;
5
+ readonly data: Uint8Array<ArrayBuffer>;
6
+ /** The record's own start offset in the stream. Carried because BoundSheet8's lbPlyPos ([MS-XLS] 2.4.28) addresses a sheet's substream by the byte offset of its BOF, so matching a sheet to its records means knowing where each record began. */
7
+ readonly offset: number;
8
+ }
9
+ /** Thrown when a byte sequence cannot be read as the structure [MS-XLS] specifies, at any level from the record framing up to a record's own fields. */
10
+ declare class BiffFormatError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ /**
14
+ * Splits a BIFF record stream into its records, in stream order.
15
+ *
16
+ * Every failure is thrown rather than tolerated, because there is no safe way to resume: a record's size field is the only thing that says where the next record begins, so a stream that stops making sense at one record makes no sense from there on, and returning the records read so far would be reporting a truncated workbook as a complete one.
17
+ */
18
+ declare function readRecords(stream: Uint8Array<ArrayBuffer>): readonly BiffRecord[];
19
+ //#endregion
20
+ export { BiffRecord as n, readRecords as r, BiffFormatError as t };
@@ -0,0 +1,62 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/serial.ts
3
+ const MS_PER_DAY = 864e5;
4
+ const MS_PER_HOUR = 36e5;
5
+ const MS_PER_MINUTE = 6e4;
6
+ const MS_PER_SECOND = 1e3;
7
+ /** The serial the 1900 system reserves for a day that never existed: 1900-02-29. Lotus 1-2-3 treated 1900 as a leap year and Excel reproduced the bug for file compatibility, so serials at or above 61 are one day ahead of a true day count from 1899-12-31, and serial 60 itself denotes a date with no place on the calendar. */
8
+ const PHANTOM_LEAP_DAY_SERIAL = 60;
9
+ /** The three day-count origins, named once so a serial and its parts can never be counted from different days. Below the phantom leap day the 1900 system is a true offset from 1899-12-31 (serial 1 = 1900-01-01); at and above it every serial is one too high, expressed by moving the origin back a day rather than by subtracting from the count. The 1904 system is a plain day count from its own epoch, with serial 0 being 1904-01-01 -- no phantom day, since 1904 genuinely was a leap year and the count starts after February. */
10
+ const ORIGIN_1900_BELOW_PHANTOM_UTC_MS = Date.UTC(1899, 11, 31);
11
+ const ORIGIN_1900_ABOVE_PHANTOM_UTC_MS = Date.UTC(1899, 11, 30);
12
+ const ORIGIN_1904_UTC_MS = Date.UTC(1904, 0, 1);
13
+ /** Rounding the fractional part to the nearest millisecond is what recovers a clean wall-clock time from a serial a producer stored to fifteen significant digits (14:30 is commonly stored as 0.604166666666667, whose exact product with 86400000 is 52199999.999999 ms). Rounding can legitimately reach a full day -- 0.9999999 rounds to 86400000 ms -- which rolls into the next day rather than producing an impossible 24:00:00. */
14
+ function splitSerial(serial) {
15
+ const days = Math.floor(serial);
16
+ const msWithinDay = Math.round((serial - days) * MS_PER_DAY);
17
+ return msWithinDay >= MS_PER_DAY ? {
18
+ days: days + 1,
19
+ msWithinDay: 0
20
+ } : {
21
+ days,
22
+ msWithinDay
23
+ };
24
+ }
25
+ function pad(value, length) {
26
+ return String(value).padStart(length, "0");
27
+ }
28
+ /** Every calculation is done in UTC deliberately: a serial carries no timezone, and local-time Date methods would shift a date across a day boundary for any host west of Greenwich. */
29
+ function isoDateOfUtcMs(ms) {
30
+ const date = new Date(ms);
31
+ return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`;
32
+ }
33
+ /** The day-count half of a serial, as a calendar date -- undefined when the serial names no real date, which the caller degrades to a plain number rather than emitting an invalid one. Two cases produce that: a negative serial (no date exists before either epoch), and serial 60 in the 1900 system (the phantom leap day). */
34
+ function isoDateOfDayCount(days, date1904) {
35
+ if (days < 0) return;
36
+ if (date1904) return isoDateOfUtcMs(ORIGIN_1904_UTC_MS + days * MS_PER_DAY);
37
+ if (days === PHANTOM_LEAP_DAY_SERIAL) return;
38
+ return isoDateOfUtcMs((days < PHANTOM_LEAP_DAY_SERIAL ? ORIGIN_1900_BELOW_PHANTOM_UTC_MS : ORIGIN_1900_ABOVE_PHANTOM_UTC_MS) + days * MS_PER_DAY);
39
+ }
40
+ function isoTimeOfMsWithinDay(msWithinDay) {
41
+ const hours = Math.floor(msWithinDay / MS_PER_HOUR);
42
+ const minutes = Math.floor(msWithinDay % MS_PER_HOUR / MS_PER_MINUTE);
43
+ const seconds = Math.floor(msWithinDay % MS_PER_MINUTE / MS_PER_SECOND);
44
+ return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
45
+ }
46
+ function serialToIsoDate(serial, date1904) {
47
+ return Number.isFinite(serial) ? isoDateOfDayCount(splitSerial(serial).days, date1904) : void 0;
48
+ }
49
+ /** A time-of-day format renders only the fractional part -- a serial of 2.5 under `h:mm` displays as noon, not as "two days and twelve hours" -- so the day count is discarded here rather than made an error. Sub-second precision is discarded too: ContentCellValue's own 'time' spelling is fixed at HH:MM:SS. */
50
+ function serialToIsoTime(serial) {
51
+ return Number.isFinite(serial) && serial >= 0 ? isoTimeOfMsWithinDay(splitSerial(serial).msWithinDay) : void 0;
52
+ }
53
+ function serialToIsoDateTime(serial, date1904) {
54
+ if (!Number.isFinite(serial)) return;
55
+ const { days, msWithinDay } = splitSerial(serial);
56
+ const date = isoDateOfDayCount(days, date1904);
57
+ return date === void 0 ? void 0 : `${date}T${isoTimeOfMsWithinDay(msWithinDay)}`;
58
+ }
59
+ //#endregion
60
+ exports.serialToIsoDate = serialToIsoDate;
61
+ exports.serialToIsoDateTime = serialToIsoDateTime;
62
+ exports.serialToIsoTime = serialToIsoTime;
@@ -0,0 +1,7 @@
1
+ //#region src/serial.d.ts
2
+ declare function serialToIsoDate(serial: number, date1904: boolean): string | undefined;
3
+ /** A time-of-day format renders only the fractional part -- a serial of 2.5 under `h:mm` displays as noon, not as "two days and twelve hours" -- so the day count is discarded here rather than made an error. Sub-second precision is discarded too: ContentCellValue's own 'time' spelling is fixed at HH:MM:SS. */
4
+ declare function serialToIsoTime(serial: number): string | undefined;
5
+ declare function serialToIsoDateTime(serial: number, date1904: boolean): string | undefined;
6
+ //#endregion
7
+ export { serialToIsoDate, serialToIsoDateTime, serialToIsoTime };
@@ -0,0 +1,7 @@
1
+ //#region src/serial.d.ts
2
+ declare function serialToIsoDate(serial: number, date1904: boolean): string | undefined;
3
+ /** A time-of-day format renders only the fractional part -- a serial of 2.5 under `h:mm` displays as noon, not as "two days and twelve hours" -- so the day count is discarded here rather than made an error. Sub-second precision is discarded too: ContentCellValue's own 'time' spelling is fixed at HH:MM:SS. */
4
+ declare function serialToIsoTime(serial: number): string | undefined;
5
+ declare function serialToIsoDateTime(serial: number, date1904: boolean): string | undefined;
6
+ //#endregion
7
+ export { serialToIsoDate, serialToIsoDateTime, serialToIsoTime };
package/dist/serial.js ADDED
@@ -0,0 +1,59 @@
1
+ //#region src/serial.ts
2
+ const MS_PER_DAY = 864e5;
3
+ const MS_PER_HOUR = 36e5;
4
+ const MS_PER_MINUTE = 6e4;
5
+ const MS_PER_SECOND = 1e3;
6
+ /** The serial the 1900 system reserves for a day that never existed: 1900-02-29. Lotus 1-2-3 treated 1900 as a leap year and Excel reproduced the bug for file compatibility, so serials at or above 61 are one day ahead of a true day count from 1899-12-31, and serial 60 itself denotes a date with no place on the calendar. */
7
+ const PHANTOM_LEAP_DAY_SERIAL = 60;
8
+ /** The three day-count origins, named once so a serial and its parts can never be counted from different days. Below the phantom leap day the 1900 system is a true offset from 1899-12-31 (serial 1 = 1900-01-01); at and above it every serial is one too high, expressed by moving the origin back a day rather than by subtracting from the count. The 1904 system is a plain day count from its own epoch, with serial 0 being 1904-01-01 -- no phantom day, since 1904 genuinely was a leap year and the count starts after February. */
9
+ const ORIGIN_1900_BELOW_PHANTOM_UTC_MS = Date.UTC(1899, 11, 31);
10
+ const ORIGIN_1900_ABOVE_PHANTOM_UTC_MS = Date.UTC(1899, 11, 30);
11
+ const ORIGIN_1904_UTC_MS = Date.UTC(1904, 0, 1);
12
+ /** Rounding the fractional part to the nearest millisecond is what recovers a clean wall-clock time from a serial a producer stored to fifteen significant digits (14:30 is commonly stored as 0.604166666666667, whose exact product with 86400000 is 52199999.999999 ms). Rounding can legitimately reach a full day -- 0.9999999 rounds to 86400000 ms -- which rolls into the next day rather than producing an impossible 24:00:00. */
13
+ function splitSerial(serial) {
14
+ const days = Math.floor(serial);
15
+ const msWithinDay = Math.round((serial - days) * MS_PER_DAY);
16
+ return msWithinDay >= MS_PER_DAY ? {
17
+ days: days + 1,
18
+ msWithinDay: 0
19
+ } : {
20
+ days,
21
+ msWithinDay
22
+ };
23
+ }
24
+ function pad(value, length) {
25
+ return String(value).padStart(length, "0");
26
+ }
27
+ /** Every calculation is done in UTC deliberately: a serial carries no timezone, and local-time Date methods would shift a date across a day boundary for any host west of Greenwich. */
28
+ function isoDateOfUtcMs(ms) {
29
+ const date = new Date(ms);
30
+ return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`;
31
+ }
32
+ /** The day-count half of a serial, as a calendar date -- undefined when the serial names no real date, which the caller degrades to a plain number rather than emitting an invalid one. Two cases produce that: a negative serial (no date exists before either epoch), and serial 60 in the 1900 system (the phantom leap day). */
33
+ function isoDateOfDayCount(days, date1904) {
34
+ if (days < 0) return;
35
+ if (date1904) return isoDateOfUtcMs(ORIGIN_1904_UTC_MS + days * MS_PER_DAY);
36
+ if (days === PHANTOM_LEAP_DAY_SERIAL) return;
37
+ return isoDateOfUtcMs((days < PHANTOM_LEAP_DAY_SERIAL ? ORIGIN_1900_BELOW_PHANTOM_UTC_MS : ORIGIN_1900_ABOVE_PHANTOM_UTC_MS) + days * MS_PER_DAY);
38
+ }
39
+ function isoTimeOfMsWithinDay(msWithinDay) {
40
+ const hours = Math.floor(msWithinDay / MS_PER_HOUR);
41
+ const minutes = Math.floor(msWithinDay % MS_PER_HOUR / MS_PER_MINUTE);
42
+ const seconds = Math.floor(msWithinDay % MS_PER_MINUTE / MS_PER_SECOND);
43
+ return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
44
+ }
45
+ function serialToIsoDate(serial, date1904) {
46
+ return Number.isFinite(serial) ? isoDateOfDayCount(splitSerial(serial).days, date1904) : void 0;
47
+ }
48
+ /** A time-of-day format renders only the fractional part -- a serial of 2.5 under `h:mm` displays as noon, not as "two days and twelve hours" -- so the day count is discarded here rather than made an error. Sub-second precision is discarded too: ContentCellValue's own 'time' spelling is fixed at HH:MM:SS. */
49
+ function serialToIsoTime(serial) {
50
+ return Number.isFinite(serial) && serial >= 0 ? isoTimeOfMsWithinDay(splitSerial(serial).msWithinDay) : void 0;
51
+ }
52
+ function serialToIsoDateTime(serial, date1904) {
53
+ if (!Number.isFinite(serial)) return;
54
+ const { days, msWithinDay } = splitSerial(serial);
55
+ const date = isoDateOfDayCount(days, date1904);
56
+ return date === void 0 ? void 0 : `${date}T${isoTimeOfMsWithinDay(msWithinDay)}`;
57
+ }
58
+ //#endregion
59
+ export { serialToIsoDate, serialToIsoDateTime, serialToIsoTime };
package/dist/units.cjs ADDED
@@ -0,0 +1,30 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/units.ts
3
+ /** Points per inch, and the pixel grid Excel's column-width formula is defined against (96 px/inch, its screen-rendering assumption). */
4
+ const POINTS_PER_INCH = 72;
5
+ const PIXELS_PER_INCH = 96;
6
+ /** A twip is a twentieth of a point; a Row record's miyRw is in twips ([MS-XLS] 2.4.221). */
7
+ const TWIPS_PER_POINT = 20;
8
+ /**
9
+ * The "maximum digit width": the widest rendered width, in pixels, of the digits 0-9 in the workbook's Normal-style font, which is the unit a column width is expressed in multiples of.
10
+ *
11
+ * 7 is the Calibri-11-at-96dpi value every mainstream spreadsheet tool assumes by default, and the same constant ooxml.js's xlsx reader uses -- so a column of a given width reads back the same number of points whether it arrived as .xls or .xlsx. This package has no font-metrics engine and so cannot compute a workbook's actual digit width from whatever font its Normal style really uses: a workbook using a materially narrower or wider font reads column widths that are honestly approximate rather than exact.
12
+ */
13
+ const MAX_DIGIT_WIDTH_PX = 7;
14
+ /** A Row record's height, in twips, as points. */
15
+ function twipsToPoints(twips) {
16
+ return twips / TWIPS_PER_POINT;
17
+ }
18
+ /**
19
+ * A ColInfo record's coldx -- a width in 1/256ths of a character width ([MS-XLS] 2.4.53) -- as points.
20
+ *
21
+ * The pixel step reproduces Excel's own integer-pixel-grid truncation rather than smoothing it into a continuous formula, so a width read here matches the pixel count Excel itself would render, and matches what ooxml.js computes for the equivalent xlsx column.
22
+ */
23
+ function columnWidthToPoints(coldx) {
24
+ const chars = coldx / 256;
25
+ const digitWidthAllowance = Math.trunc(128 / MAX_DIGIT_WIDTH_PX);
26
+ return Math.trunc((256 * chars + digitWidthAllowance) / 256 * MAX_DIGIT_WIDTH_PX) / PIXELS_PER_INCH * POINTS_PER_INCH;
27
+ }
28
+ //#endregion
29
+ exports.columnWidthToPoints = columnWidthToPoints;
30
+ exports.twipsToPoints = twipsToPoints;
@@ -0,0 +1,11 @@
1
+ //#region src/units.d.ts
2
+ /** A Row record's height, in twips, as points. */
3
+ declare function twipsToPoints(twips: number): number;
4
+ /**
5
+ * A ColInfo record's coldx -- a width in 1/256ths of a character width ([MS-XLS] 2.4.53) -- as points.
6
+ *
7
+ * The pixel step reproduces Excel's own integer-pixel-grid truncation rather than smoothing it into a continuous formula, so a width read here matches the pixel count Excel itself would render, and matches what ooxml.js computes for the equivalent xlsx column.
8
+ */
9
+ declare function columnWidthToPoints(coldx: number): number;
10
+ //#endregion
11
+ export { columnWidthToPoints, twipsToPoints };
@@ -0,0 +1,11 @@
1
+ //#region src/units.d.ts
2
+ /** A Row record's height, in twips, as points. */
3
+ declare function twipsToPoints(twips: number): number;
4
+ /**
5
+ * A ColInfo record's coldx -- a width in 1/256ths of a character width ([MS-XLS] 2.4.53) -- as points.
6
+ *
7
+ * The pixel step reproduces Excel's own integer-pixel-grid truncation rather than smoothing it into a continuous formula, so a width read here matches the pixel count Excel itself would render, and matches what ooxml.js computes for the equivalent xlsx column.
8
+ */
9
+ declare function columnWidthToPoints(coldx: number): number;
10
+ //#endregion
11
+ export { columnWidthToPoints, twipsToPoints };
package/dist/units.js ADDED
@@ -0,0 +1,28 @@
1
+ //#region src/units.ts
2
+ /** Points per inch, and the pixel grid Excel's column-width formula is defined against (96 px/inch, its screen-rendering assumption). */
3
+ const POINTS_PER_INCH = 72;
4
+ const PIXELS_PER_INCH = 96;
5
+ /** A twip is a twentieth of a point; a Row record's miyRw is in twips ([MS-XLS] 2.4.221). */
6
+ const TWIPS_PER_POINT = 20;
7
+ /**
8
+ * The "maximum digit width": the widest rendered width, in pixels, of the digits 0-9 in the workbook's Normal-style font, which is the unit a column width is expressed in multiples of.
9
+ *
10
+ * 7 is the Calibri-11-at-96dpi value every mainstream spreadsheet tool assumes by default, and the same constant ooxml.js's xlsx reader uses -- so a column of a given width reads back the same number of points whether it arrived as .xls or .xlsx. This package has no font-metrics engine and so cannot compute a workbook's actual digit width from whatever font its Normal style really uses: a workbook using a materially narrower or wider font reads column widths that are honestly approximate rather than exact.
11
+ */
12
+ const MAX_DIGIT_WIDTH_PX = 7;
13
+ /** A Row record's height, in twips, as points. */
14
+ function twipsToPoints(twips) {
15
+ return twips / TWIPS_PER_POINT;
16
+ }
17
+ /**
18
+ * A ColInfo record's coldx -- a width in 1/256ths of a character width ([MS-XLS] 2.4.53) -- as points.
19
+ *
20
+ * The pixel step reproduces Excel's own integer-pixel-grid truncation rather than smoothing it into a continuous formula, so a width read here matches the pixel count Excel itself would render, and matches what ooxml.js computes for the equivalent xlsx column.
21
+ */
22
+ function columnWidthToPoints(coldx) {
23
+ const chars = coldx / 256;
24
+ const digitWidthAllowance = Math.trunc(128 / MAX_DIGIT_WIDTH_PX);
25
+ return Math.trunc((256 * chars + digitWidthAllowance) / 256 * MAX_DIGIT_WIDTH_PX) / PIXELS_PER_INCH * POINTS_PER_INCH;
26
+ }
27
+ //#endregion
28
+ export { columnWidthToPoints, twipsToPoints };