documonster 0.20.1 → 0.20.2
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.
- package/README.md +1 -1
- package/README_zh.md +1 -1
- package/dist/esm/modules/excel/core/cell.js +5 -6
- package/dist/esm/modules/excel/core/pivot-table.js +17 -9
- package/dist/esm/modules/excel/stream/xlsb-worksheet-reader.js +4 -6
- package/dist/esm/modules/excel/utils/cell-format-parse.js +112 -174
- package/dist/esm/modules/excel/utils/cell-format.js +42 -946
- package/dist/esm/modules/formula/functions/text.js +23 -631
- package/dist/esm/modules/pdf/excel-bridge.js +5 -5
- package/dist/esm/utils/number-format-render.js +814 -0
- package/dist/esm/utils/number-format.js +477 -0
- package/dist/esm/utils/utils.base.js +3 -70
- package/dist/iife/documonster.archive.iife.min.js +1 -1
- package/dist/iife/documonster.csv.iife.min.js +1 -1
- package/dist/iife/documonster.draw.iife.min.js +1 -1
- package/dist/iife/documonster.excel.iife.min.js +50 -50
- package/dist/iife/documonster.formula.iife.min.js +1 -1
- package/dist/iife/documonster.markdown.iife.min.js +1 -1
- package/dist/iife/documonster.mermaid.iife.min.js +1 -1
- package/dist/iife/documonster.pdf.iife.min.js +18 -18
- package/dist/iife/documonster.stream.iife.min.js +1 -1
- package/dist/iife/documonster.word.iife.min.js +60 -60
- package/dist/iife/documonster.xml.iife.min.js +1 -1
- package/dist/types/modules/excel/core/cell.d.ts +11 -0
- package/dist/types/modules/excel/core/pivot-table.d.ts +7 -0
- package/dist/types/modules/excel/utils/cell-format-parse.d.ts +13 -9
- package/dist/types/modules/excel/utils/cell-format.d.ts +21 -33
- package/dist/types/modules/excel/utils/text-metrics.d.ts +1 -0
- package/dist/types/utils/number-format-render.d.ts +47 -0
- package/dist/types/utils/number-format.d.ts +215 -0
- package/dist/types/utils/utils.base.d.ts +1 -9
- package/package.json +1 -1
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Excel number formats, read once.
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists.** A number format such as `\$0.0,,"M"` or `[h]:mm:ss` is a small language,
|
|
5
|
+
* and this codebase used to read it with a separate scanner per consumer: the XLSX/XLSB readers
|
|
6
|
+
* deciding whether a serial is a date, the display formatter, the date-kind classifier, the
|
|
7
|
+
* pivot cache writer, the `TEXT` function and the input parser. Each one stripped a different
|
|
8
|
+
* subset of the constructs that make a character *literal* — quotes, backslash escapes,
|
|
9
|
+
* `_x` padding, `*x` fill, bracket tags — so each one mistook a different literal for a code.
|
|
10
|
+
* `\$0.0,,\M` turned a five-billion-dollar figure into `Invalid Date` on load because one of them
|
|
11
|
+
* read the escaped `M` as a month; `0_m` was a date to one reader and a number to another.
|
|
12
|
+
*
|
|
13
|
+
* Patching each scanner is how they drifted apart in the first place. So there is now one
|
|
14
|
+
* tokenizer: every consumer asks it what a format says, and a character is a code or a literal
|
|
15
|
+
* for all of them or for none.
|
|
16
|
+
*
|
|
17
|
+
* What it models:
|
|
18
|
+
*
|
|
19
|
+
* - **Sections** split on `;`, except inside quotes, brackets or after a backslash.
|
|
20
|
+
* - **Literals**: `"quoted"`, `\x`, and any character that is not a code.
|
|
21
|
+
* - **Layout**: `_x` (space the width of `x`) and `*x` (repeat `x` to fill).
|
|
22
|
+
* - **Bracket tags**: colours, conditions (`[>=100]`), locale/currency (`[$€-407]`), elapsed time
|
|
23
|
+
* (`[h]`, `[mm]`, `[ss]`) and anything else (`[DBNum1]`), each kept apart so a consumer can act
|
|
24
|
+
* on the one it needs.
|
|
25
|
+
* - **Date and time codes**, with each `m`/`mm` resolved to month or minute where it stands —
|
|
26
|
+
* Excel's rule is positional (minute directly after an hour or directly before a second), so
|
|
27
|
+
* `yyyy-mm-dd hh:mm` holds one of each and no format-wide flag can describe it.
|
|
28
|
+
* - **Number codes**: `0 # ?`, `.`, `,`, `%`, `E+`/`E-`, `/` and `@`, and the word `General`.
|
|
29
|
+
*
|
|
30
|
+
* `e` and `g` (era years in East Asian locales) are deliberately literals: an unquoted word such
|
|
31
|
+
* as `zero` in `0;-0;zero` must stay a word, and no locale this library renders uses them.
|
|
32
|
+
* `b` (Buddhist year) is a code, as it was for the XLSX reader before this module existed.
|
|
33
|
+
*/
|
|
34
|
+
/** English month names, January first: what `mmm`/`mmmm`/`mmmmm` print and what input parsing accepts. */
|
|
35
|
+
export const MONTH_NAMES = [
|
|
36
|
+
"January",
|
|
37
|
+
"February",
|
|
38
|
+
"March",
|
|
39
|
+
"April",
|
|
40
|
+
"May",
|
|
41
|
+
"June",
|
|
42
|
+
"July",
|
|
43
|
+
"August",
|
|
44
|
+
"September",
|
|
45
|
+
"October",
|
|
46
|
+
"November",
|
|
47
|
+
"December"
|
|
48
|
+
];
|
|
49
|
+
/** English weekday names, Sunday first: what `ddd`/`dddd` print and what input parsing accepts. */
|
|
50
|
+
export const DAY_NAMES = [
|
|
51
|
+
"Sunday",
|
|
52
|
+
"Monday",
|
|
53
|
+
"Tuesday",
|
|
54
|
+
"Wednesday",
|
|
55
|
+
"Thursday",
|
|
56
|
+
"Friday",
|
|
57
|
+
"Saturday"
|
|
58
|
+
];
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Sections
|
|
61
|
+
// =============================================================================
|
|
62
|
+
/**
|
|
63
|
+
* Split a format into its `;`-separated sections (positive; negative; zero; text).
|
|
64
|
+
*
|
|
65
|
+
* A `;` inside `"…"`, inside `[…]`, or taken as the argument of `\`, `_` or `*` is part of a section,
|
|
66
|
+
* not a separator — the same rules `tokenizeFormatSection` reads a character by.
|
|
67
|
+
*/
|
|
68
|
+
export function splitFormatSections(fmt) {
|
|
69
|
+
const sections = [];
|
|
70
|
+
let start = 0;
|
|
71
|
+
let inQuote = false;
|
|
72
|
+
let inBracket = false;
|
|
73
|
+
for (let i = 0; i < fmt.length; i++) {
|
|
74
|
+
const ch = fmt[i];
|
|
75
|
+
if (inQuote) {
|
|
76
|
+
if (ch === '"') {
|
|
77
|
+
inQuote = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (inBracket) {
|
|
81
|
+
if (ch === "]") {
|
|
82
|
+
inBracket = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (ch === "\\" || ch === "_" || ch === "*") {
|
|
86
|
+
// Each takes the next character as its argument, whatever it is: `0_;0` pads with the width
|
|
87
|
+
// of `;`, and `0_";0` with the width of `"` — neither ends a section or opens a literal.
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
else if (ch === '"') {
|
|
91
|
+
inQuote = true;
|
|
92
|
+
}
|
|
93
|
+
else if (ch === "[") {
|
|
94
|
+
inBracket = true;
|
|
95
|
+
}
|
|
96
|
+
else if (ch === ";") {
|
|
97
|
+
sections.push(fmt.slice(start, i));
|
|
98
|
+
start = i + 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
sections.push(fmt.slice(start));
|
|
102
|
+
return sections;
|
|
103
|
+
}
|
|
104
|
+
// =============================================================================
|
|
105
|
+
// Tokenizer
|
|
106
|
+
// =============================================================================
|
|
107
|
+
const COLOR_RE = /^(?:black|blue|cyan|green|magenta|red|white|yellow|color\s*\d+)$/i;
|
|
108
|
+
const CONDITION_RE = /^(<>|<=|>=|=|<|>)\s*(-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)$/i;
|
|
109
|
+
const ELAPSED_RE = /^(h+|m+|s+)$/i;
|
|
110
|
+
function bracketToken(body) {
|
|
111
|
+
if (ELAPSED_RE.test(body)) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "elapsed",
|
|
114
|
+
unit: body[0].toLowerCase(),
|
|
115
|
+
width: body.length,
|
|
116
|
+
fraction: 0
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const condition = CONDITION_RE.exec(body);
|
|
120
|
+
if (condition) {
|
|
121
|
+
return { kind: "condition", op: condition[1], value: Number(condition[2]) };
|
|
122
|
+
}
|
|
123
|
+
if (body.startsWith("$")) {
|
|
124
|
+
const dash = body.indexOf("-");
|
|
125
|
+
return { kind: "locale", symbol: body.slice(1, dash === -1 ? body.length : dash) };
|
|
126
|
+
}
|
|
127
|
+
if (COLOR_RE.test(body)) {
|
|
128
|
+
return { kind: "color", name: body };
|
|
129
|
+
}
|
|
130
|
+
return { kind: "bracket", text: body };
|
|
131
|
+
}
|
|
132
|
+
/** Length of the run of `letter` (case-insensitive) starting at `i`. */
|
|
133
|
+
function runLength(section, i, letter) {
|
|
134
|
+
let j = i;
|
|
135
|
+
while (j < section.length && section[j].toLowerCase() === letter) {
|
|
136
|
+
j++;
|
|
137
|
+
}
|
|
138
|
+
return j - i;
|
|
139
|
+
}
|
|
140
|
+
function startsWithIgnoreCase(section, i, word) {
|
|
141
|
+
return section.slice(i, i + word.length).toLowerCase() === word.toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Read one section into tokens.
|
|
145
|
+
*
|
|
146
|
+
* Every construct that makes a character literal is recognised here and only here, so no consumer
|
|
147
|
+
* can mistake `\M`, `"M"`, `_M` or `*M` for a month.
|
|
148
|
+
*/
|
|
149
|
+
export function tokenizeFormatSection(section) {
|
|
150
|
+
const raw = [];
|
|
151
|
+
let i = 0;
|
|
152
|
+
while (i < section.length) {
|
|
153
|
+
const ch = section[i];
|
|
154
|
+
const lower = ch.toLowerCase();
|
|
155
|
+
if (ch === '"') {
|
|
156
|
+
const end = section.indexOf('"', i + 1);
|
|
157
|
+
const stop = end === -1 ? section.length : end;
|
|
158
|
+
raw.push({ kind: "literal", text: section.slice(i + 1, stop) });
|
|
159
|
+
i = stop + 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (ch === "\\") {
|
|
163
|
+
if (i + 1 < section.length) {
|
|
164
|
+
raw.push({ kind: "literal", text: section[i + 1] });
|
|
165
|
+
}
|
|
166
|
+
i += 2;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (ch === "_" || ch === "*") {
|
|
170
|
+
if (i + 1 < section.length) {
|
|
171
|
+
raw.push(ch === "_"
|
|
172
|
+
? { kind: "pad", char: section[i + 1] }
|
|
173
|
+
: { kind: "fill", char: section[i + 1] });
|
|
174
|
+
}
|
|
175
|
+
i += 2;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (ch === "[") {
|
|
179
|
+
const end = section.indexOf("]", i + 1);
|
|
180
|
+
if (end === -1) {
|
|
181
|
+
raw.push({ kind: "literal", text: section.slice(i) });
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
raw.push(bracketToken(section.slice(i + 1, end)));
|
|
185
|
+
i = end + 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (startsWithIgnoreCase(section, i, "General")) {
|
|
189
|
+
raw.push({ kind: "general" });
|
|
190
|
+
i += 7;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (startsWithIgnoreCase(section, i, "AM/PM")) {
|
|
194
|
+
raw.push({ kind: "ampm", short: false, lower: ch === "a" });
|
|
195
|
+
i += 5;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (startsWithIgnoreCase(section, i, "A/P")) {
|
|
199
|
+
raw.push({ kind: "ampm", short: true, lower: ch === "a" });
|
|
200
|
+
i += 3;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (lower === "e" && (section[i + 1] === "+" || section[i + 1] === "-")) {
|
|
204
|
+
raw.push({ kind: "exponent", sign: section[i + 1], upper: ch === "E" });
|
|
205
|
+
i += 2;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (lower === "b" && (section[i + 1] === "1" || section[i + 1] === "2")) {
|
|
209
|
+
// `B1`/`B2` switch calendars (Gregorian/Hijri); they print nothing.
|
|
210
|
+
raw.push({ kind: "bracket", text: section.slice(i, i + 2) });
|
|
211
|
+
i += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (lower === "y" || lower === "d" || lower === "h" || lower === "s" || lower === "b") {
|
|
215
|
+
const width = runLength(section, i, lower);
|
|
216
|
+
const part = lower === "y"
|
|
217
|
+
? "year"
|
|
218
|
+
: lower === "b"
|
|
219
|
+
? "buddhistYear"
|
|
220
|
+
: lower === "d"
|
|
221
|
+
? "day"
|
|
222
|
+
: lower === "h"
|
|
223
|
+
? "hour"
|
|
224
|
+
: "second";
|
|
225
|
+
raw.push({ kind: "date", part, width, fraction: 0 });
|
|
226
|
+
i += width;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (lower === "m") {
|
|
230
|
+
const width = runLength(section, i, "m");
|
|
231
|
+
raw.push({ kind: "m", width });
|
|
232
|
+
i += width;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
switch (ch) {
|
|
236
|
+
case "0":
|
|
237
|
+
case "#":
|
|
238
|
+
case "?":
|
|
239
|
+
raw.push({ kind: "digit", char: ch });
|
|
240
|
+
break;
|
|
241
|
+
case ".":
|
|
242
|
+
raw.push({ kind: "decimal" });
|
|
243
|
+
break;
|
|
244
|
+
case ",":
|
|
245
|
+
raw.push({ kind: "thousands" });
|
|
246
|
+
break;
|
|
247
|
+
case "%":
|
|
248
|
+
raw.push({ kind: "percent" });
|
|
249
|
+
break;
|
|
250
|
+
case "/":
|
|
251
|
+
raw.push({ kind: "slash" });
|
|
252
|
+
break;
|
|
253
|
+
case "@":
|
|
254
|
+
raw.push({ kind: "text" });
|
|
255
|
+
break;
|
|
256
|
+
case "1":
|
|
257
|
+
case "2":
|
|
258
|
+
case "3":
|
|
259
|
+
case "4":
|
|
260
|
+
case "5":
|
|
261
|
+
case "6":
|
|
262
|
+
case "7":
|
|
263
|
+
case "8":
|
|
264
|
+
case "9":
|
|
265
|
+
raw.push({ kind: "numeral", text: ch });
|
|
266
|
+
break;
|
|
267
|
+
default:
|
|
268
|
+
raw.push({ kind: "literal", text: ch });
|
|
269
|
+
}
|
|
270
|
+
i++;
|
|
271
|
+
}
|
|
272
|
+
return foldSecondFractions(resolveMonthOrMinute(raw));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Attach `.0`, `.00`, … after a seconds code to that code.
|
|
276
|
+
*
|
|
277
|
+
* In `ss.00` and `[ss].00` the point and zeros are fractions of a second, not a decimal number layout,
|
|
278
|
+
* and every consumer — the renderer rounding to them, the input parser reading them back — needs to know
|
|
279
|
+
* that. Deciding it here, once, is what keeps the two from reading the same format differently.
|
|
280
|
+
*/
|
|
281
|
+
function foldSecondFractions(tokens) {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
284
|
+
const token = tokens[k];
|
|
285
|
+
const isSecond = (token.kind === "date" && token.part === "second") ||
|
|
286
|
+
(token.kind === "elapsed" && token.unit === "s");
|
|
287
|
+
if (!isSecond || tokens[k + 1]?.kind !== "decimal") {
|
|
288
|
+
out.push(token);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
let zeros = 0;
|
|
292
|
+
for (let j = k + 2; j < tokens.length; j++) {
|
|
293
|
+
const next = tokens[j];
|
|
294
|
+
if (next.kind !== "digit" || next.char !== "0") {
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
zeros++;
|
|
298
|
+
}
|
|
299
|
+
if (zeros === 0) {
|
|
300
|
+
out.push(token);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
out.push({ ...token, fraction: zeros });
|
|
304
|
+
k += 1 + zeros;
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Excel's rule for `m` and `mm`, measured against Excel itself (33 formats). `mmm` and longer are always
|
|
310
|
+
* months. A shorter run is a minute when
|
|
311
|
+
*
|
|
312
|
+
* - the time code before it, skipping year, day and month-name codes, is an hour (`h dd mm`, `h mmm mm`,
|
|
313
|
+
* `[h] mm`);
|
|
314
|
+
* - the code right after it is a seconds code (`mm:ss`), but not an elapsed `[ss]` and not across a day or year
|
|
315
|
+
* code (`mm dd ss` is a month);
|
|
316
|
+
* - or the time code before it is a seconds code and no minute has appeared yet (`ss mm`, but the second
|
|
317
|
+
* `mm` of `h:mm:ss mm` is a month).
|
|
318
|
+
*
|
|
319
|
+
* Anything else is a month (`dd mm`, `mm h`).
|
|
320
|
+
*/
|
|
321
|
+
function resolveMonthOrMinute(raw) {
|
|
322
|
+
const isCode = (t) => t.kind === "m" || t.kind === "date" || t.kind === "elapsed";
|
|
323
|
+
const isCalendar = (t) => (t.kind === "m" && t.width >= 3) ||
|
|
324
|
+
(t.kind === "date" && t.part !== "hour" && t.part !== "minute" && t.part !== "second");
|
|
325
|
+
let minuteSeen = false;
|
|
326
|
+
const out = [];
|
|
327
|
+
raw.forEach((token, index) => {
|
|
328
|
+
if (token.kind !== "m") {
|
|
329
|
+
out.push(token);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (token.width >= 3) {
|
|
333
|
+
out.push({ kind: "date", part: "month", width: token.width, fraction: 0 });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
let before;
|
|
337
|
+
for (let k = index - 1; k >= 0; k--) {
|
|
338
|
+
if (isCode(raw[k]) && !isCalendar(raw[k])) {
|
|
339
|
+
before = raw[k];
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
let after;
|
|
344
|
+
for (let k = index + 1; k < raw.length; k++) {
|
|
345
|
+
if (isCode(raw[k])) {
|
|
346
|
+
after = raw[k];
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const isPlain = (t, part) => t?.kind === "date" && t.part === part;
|
|
351
|
+
const minute = isPlain(before, "hour") ||
|
|
352
|
+
(before?.kind === "elapsed" && before.unit === "h") ||
|
|
353
|
+
isPlain(after, "second") ||
|
|
354
|
+
(isPlain(before, "second") && !minuteSeen);
|
|
355
|
+
minuteSeen || (minuteSeen = minute);
|
|
356
|
+
out.push({ kind: "date", part: minute ? "minute" : "month", width: token.width, fraction: 0 });
|
|
357
|
+
});
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
// =============================================================================
|
|
361
|
+
// Parsed formats
|
|
362
|
+
// =============================================================================
|
|
363
|
+
function describeSection(source) {
|
|
364
|
+
const tokens = tokenizeFormatSection(source);
|
|
365
|
+
let hasDate = false;
|
|
366
|
+
let hasTime = false;
|
|
367
|
+
let hasElapsed = false;
|
|
368
|
+
let hasText = false;
|
|
369
|
+
let hasGeneral = false;
|
|
370
|
+
let condition;
|
|
371
|
+
for (const token of tokens) {
|
|
372
|
+
switch (token.kind) {
|
|
373
|
+
case "date":
|
|
374
|
+
if (token.part === "hour" || token.part === "minute" || token.part === "second") {
|
|
375
|
+
hasTime = true;
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
hasDate = true;
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
case "ampm":
|
|
382
|
+
hasTime = true;
|
|
383
|
+
break;
|
|
384
|
+
case "elapsed":
|
|
385
|
+
hasElapsed = true;
|
|
386
|
+
break;
|
|
387
|
+
case "text":
|
|
388
|
+
hasText = true;
|
|
389
|
+
break;
|
|
390
|
+
case "general":
|
|
391
|
+
hasGeneral = true;
|
|
392
|
+
break;
|
|
393
|
+
case "condition":
|
|
394
|
+
condition ?? (condition = { op: token.op, value: token.value });
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return { source, tokens, hasDate, hasTime, hasElapsed, hasText, hasGeneral, condition };
|
|
399
|
+
}
|
|
400
|
+
/** Formats seen in a workbook are few and asked about very often; past this the cache restarts. */
|
|
401
|
+
const PARSE_CACHE_LIMIT = 1024;
|
|
402
|
+
const parseCache = new Map();
|
|
403
|
+
/**
|
|
404
|
+
* Parse a whole format. Results are cached: a reader asks about the same handful of formats once
|
|
405
|
+
* per cell.
|
|
406
|
+
*/
|
|
407
|
+
export function parseNumberFormat(fmt) {
|
|
408
|
+
const cached = parseCache.get(fmt);
|
|
409
|
+
if (cached !== undefined) {
|
|
410
|
+
return cached;
|
|
411
|
+
}
|
|
412
|
+
const sections = splitFormatSections(fmt).map(describeSection);
|
|
413
|
+
let textSection;
|
|
414
|
+
let numberSections = sections;
|
|
415
|
+
if (sections.length >= 4) {
|
|
416
|
+
textSection = sections[3];
|
|
417
|
+
numberSections = sections.slice(0, 3);
|
|
418
|
+
}
|
|
419
|
+
else if (sections[sections.length - 1].hasText) {
|
|
420
|
+
textSection = sections[sections.length - 1];
|
|
421
|
+
numberSections = sections.length > 1 ? sections.slice(0, -1) : [];
|
|
422
|
+
}
|
|
423
|
+
const parsed = { source: fmt, sections, numberSections, textSection };
|
|
424
|
+
if (parseCache.size >= PARSE_CACHE_LIMIT) {
|
|
425
|
+
parseCache.clear();
|
|
426
|
+
}
|
|
427
|
+
parseCache.set(fmt, parsed);
|
|
428
|
+
return parsed;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The date and time facts of a format, read from its **first** section — the one that applies to
|
|
432
|
+
* a positive serial, and therefore to every date. `";;;dd"` hides numbers; it is not a date format.
|
|
433
|
+
*/
|
|
434
|
+
export function numberFormatFacets(fmt) {
|
|
435
|
+
if (fmt === null || fmt === undefined || fmt.trim() === "") {
|
|
436
|
+
return { date: false, time: false, elapsed: false, text: false, general: true };
|
|
437
|
+
}
|
|
438
|
+
const first = parseNumberFormat(fmt).sections[0];
|
|
439
|
+
return {
|
|
440
|
+
date: first.hasDate,
|
|
441
|
+
time: first.hasTime,
|
|
442
|
+
elapsed: first.hasElapsed,
|
|
443
|
+
text: first.hasText,
|
|
444
|
+
general: first.hasGeneral
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Whether a cell carrying this format holds a date: its first section names a calendar or clock code
|
|
449
|
+
* outside every literal, and is not a text format.
|
|
450
|
+
*
|
|
451
|
+
* This is the rule every reader applies when deciding whether a stored serial becomes a `Date`.
|
|
452
|
+
*
|
|
453
|
+
* **An elapsed tag on its own does not count.** `[h]` or `[ss]` is a quantity of time, and a `Date` would
|
|
454
|
+
* misstate it — and could not hold it past a few thousand years. `[h]:mm:ss` still counts, through its
|
|
455
|
+
* clock codes, which is the reading these readers have always given both forms.
|
|
456
|
+
*/
|
|
457
|
+
export function isDateFmt(fmt) {
|
|
458
|
+
if (!fmt) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
// Read the cached section directly: the readers ask this once per numeric cell.
|
|
462
|
+
const first = parseNumberFormat(fmt).sections[0];
|
|
463
|
+
return !first.hasText && (first.hasDate || first.hasTime);
|
|
464
|
+
}
|
|
465
|
+
/** Whether a format is `General` — or empty, which Excel treats the same way. */
|
|
466
|
+
export function isGeneralFormat(fmt) {
|
|
467
|
+
if (fmt === null || fmt === undefined || fmt.trim() === "") {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
const { sections } = parseNumberFormat(fmt);
|
|
471
|
+
return (sections.length === 1 &&
|
|
472
|
+
sections[0].hasGeneral &&
|
|
473
|
+
sections[0].tokens.every(t => t.kind === "general" ||
|
|
474
|
+
t.kind === "color" ||
|
|
475
|
+
(t.kind === "locale" && t.symbol === "") ||
|
|
476
|
+
(t.kind === "literal" && t.text.trim() === "")));
|
|
477
|
+
}
|
|
@@ -163,76 +163,9 @@ export function validInt(value) {
|
|
|
163
163
|
const i = typeof value === "number" ? value : parseInt(value, 10);
|
|
164
164
|
return Number.isNaN(i) ? 0 : i;
|
|
165
165
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
* Excel numFmt can have up to 4 sections: `positive ; negative ; zero ; text`.
|
|
170
|
-
* Semicolons inside `"..."` (literal text) or `[...]` (locale/color tags) must NOT
|
|
171
|
-
* be treated as section separators.
|
|
172
|
-
*/
|
|
173
|
-
export function splitFormatSections(fmt) {
|
|
174
|
-
const sections = [];
|
|
175
|
-
let current = "";
|
|
176
|
-
let inQuote = false;
|
|
177
|
-
let inBracket = false;
|
|
178
|
-
for (let i = 0; i < fmt.length; i++) {
|
|
179
|
-
const char = fmt[i];
|
|
180
|
-
if (char === '"' && !inBracket) {
|
|
181
|
-
inQuote = !inQuote;
|
|
182
|
-
current += char;
|
|
183
|
-
}
|
|
184
|
-
else if (char === "[" && !inQuote) {
|
|
185
|
-
inBracket = true;
|
|
186
|
-
current += char;
|
|
187
|
-
}
|
|
188
|
-
else if (char === "]" && !inQuote) {
|
|
189
|
-
inBracket = false;
|
|
190
|
-
current += char;
|
|
191
|
-
}
|
|
192
|
-
else if (char === ";" && !inQuote && !inBracket) {
|
|
193
|
-
sections.push(current);
|
|
194
|
-
current = "";
|
|
195
|
-
}
|
|
196
|
-
else {
|
|
197
|
-
current += char;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
sections.push(current);
|
|
201
|
-
return sections;
|
|
202
|
-
}
|
|
203
|
-
/** Reusable regex — no capture groups, so safe for `test()`. */
|
|
204
|
-
const DATE_FMT_RE = /[ymdhMsb]/;
|
|
205
|
-
/** Strips bracket expressions `[...]` and quoted literals `"..."` from a format string. */
|
|
206
|
-
const STRIP_BRACKETS_QUOTES_RE = /\[[^\]]*\]|"[^"]*"/g;
|
|
207
|
-
/** Cache for isDateFmt results — typically only 5-20 unique formats per workbook,
|
|
208
|
-
* but each may be tested hundreds of thousands of times during reconcile. */
|
|
209
|
-
const _isDateFmtCache = new Map();
|
|
210
|
-
export function isDateFmt(fmt) {
|
|
211
|
-
if (!fmt) {
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
const cached = _isDateFmtCache.get(fmt);
|
|
215
|
-
if (cached !== undefined) {
|
|
216
|
-
return cached;
|
|
217
|
-
}
|
|
218
|
-
// Only the first section (used for positive numbers / dates) determines
|
|
219
|
-
// whether the format represents a date. The "@" text placeholder may
|
|
220
|
-
// legitimately appear in later sections as a text fallback (e.g. "mm/dd/yyyy;@").
|
|
221
|
-
const firstSection = splitFormatSections(fmt)[0];
|
|
222
|
-
// Strip bracket expressions [...] (locale/color tags) and quoted literals "..."
|
|
223
|
-
// before any further checks so that characters inside them are ignored.
|
|
224
|
-
const clean = firstSection.replace(STRIP_BRACKETS_QUOTES_RE, "");
|
|
225
|
-
// "@" in the cleaned section means it's a text format, not a date format.
|
|
226
|
-
let result;
|
|
227
|
-
if (clean.indexOf("@") > -1) {
|
|
228
|
-
result = false;
|
|
229
|
-
}
|
|
230
|
-
else {
|
|
231
|
-
result = DATE_FMT_RE.test(clean);
|
|
232
|
-
}
|
|
233
|
-
_isDateFmtCache.set(fmt, result);
|
|
234
|
-
return result;
|
|
235
|
-
}
|
|
166
|
+
// Number-format reading lives in one place; these names are kept here because every reader imports
|
|
167
|
+
// them from `@utils/utils`.
|
|
168
|
+
export { isDateFmt, splitFormatSections } from "./number-format.js";
|
|
236
169
|
export function parseBoolean(value) {
|
|
237
170
|
return value === true || value === "true" || value === 1 || value === "1";
|
|
238
171
|
}
|