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.
Files changed (32) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/dist/esm/modules/excel/core/cell.js +5 -6
  4. package/dist/esm/modules/excel/core/pivot-table.js +17 -9
  5. package/dist/esm/modules/excel/stream/xlsb-worksheet-reader.js +4 -6
  6. package/dist/esm/modules/excel/utils/cell-format-parse.js +112 -174
  7. package/dist/esm/modules/excel/utils/cell-format.js +42 -946
  8. package/dist/esm/modules/formula/functions/text.js +23 -631
  9. package/dist/esm/modules/pdf/excel-bridge.js +5 -5
  10. package/dist/esm/utils/number-format-render.js +814 -0
  11. package/dist/esm/utils/number-format.js +477 -0
  12. package/dist/esm/utils/utils.base.js +3 -70
  13. package/dist/iife/documonster.archive.iife.min.js +1 -1
  14. package/dist/iife/documonster.csv.iife.min.js +1 -1
  15. package/dist/iife/documonster.draw.iife.min.js +1 -1
  16. package/dist/iife/documonster.excel.iife.min.js +50 -50
  17. package/dist/iife/documonster.formula.iife.min.js +1 -1
  18. package/dist/iife/documonster.markdown.iife.min.js +1 -1
  19. package/dist/iife/documonster.mermaid.iife.min.js +1 -1
  20. package/dist/iife/documonster.pdf.iife.min.js +18 -18
  21. package/dist/iife/documonster.stream.iife.min.js +1 -1
  22. package/dist/iife/documonster.word.iife.min.js +60 -60
  23. package/dist/iife/documonster.xml.iife.min.js +1 -1
  24. package/dist/types/modules/excel/core/cell.d.ts +11 -0
  25. package/dist/types/modules/excel/core/pivot-table.d.ts +7 -0
  26. package/dist/types/modules/excel/utils/cell-format-parse.d.ts +13 -9
  27. package/dist/types/modules/excel/utils/cell-format.d.ts +21 -33
  28. package/dist/types/modules/excel/utils/text-metrics.d.ts +1 -0
  29. package/dist/types/utils/number-format-render.d.ts +47 -0
  30. package/dist/types/utils/number-format.d.ts +215 -0
  31. package/dist/types/utils/utils.base.d.ts +1 -9
  32. package/package.json +1 -1
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Render a value through an Excel number format.
3
+ *
4
+ * **One renderer for every caller.** A cell's display text and the `TEXT` worksheet function
5
+ * format a number the same way in Excel, but this library used to carry two implementations:
6
+ * `excel/utils/cell-format.ts` for display and a second copy inside the formula engine. They had
7
+ * different rounding, different fraction search, different month/minute rules and different ideas
8
+ * of what a literal is — `TEXT` read `0" hours"` as a date and dropped every literal from a
9
+ * percentage, while the display path placed the minus sign after a currency symbol. The formula
10
+ * engine sits below the Excel module and could not import the display renderer, so the shared one
11
+ * lives here, at Layer 0, where both can reach it.
12
+ *
13
+ * It renders from the tokens of `./number-format`, so the question "is this character a code or a
14
+ * literal" has one answer for rendering, for date detection on load, for date-kind classification
15
+ * and for input parsing.
16
+ */
17
+ import { serialToParts } from "./excel-serial.js";
18
+ import { DAY_NAMES, MONTH_NAMES, parseNumberFormat } from "./number-format.js";
19
+ /** What Excel shows for a value its format cannot display: a date past 9999 or a number past `double`. */
20
+ const NOT_DISPLAYABLE = "########";
21
+ /** Serial of 10000-01-01 under the 1900 epoch; a date at or past it has no display. */
22
+ const FIRST_SERIAL_PAST_9999 = 2958466;
23
+ /** Days between the epochs: the same calendar date is this much lower under the 1904 epoch. */
24
+ const EPOCH_1904_OFFSET = 1462;
25
+ /** Excel accepts at most three decimal places of a second (`ss.000`); more is an invalid format. */
26
+ const MAX_SECOND_DECIMALS = 3;
27
+ /**
28
+ * Format `value` as Excel would display it under `fmt`.
29
+ *
30
+ * Text is shown through the format's text section (the fourth, or a trailing section carrying
31
+ * `@`), or as is when there is none. Booleans are `TRUE`/`FALSE`. An empty format is `General`.
32
+ * A value the format cannot show — a date past 9999 or before 1900, a negative time, a conditional
33
+ * format no section of which matches — is shown as hashes, as Excel shows it in a cell.
34
+ */
35
+ export function renderNumberFormat(fmt, value, options = {}) {
36
+ return tryRenderNumberFormat(fmt, value, options) ?? NOT_DISPLAYABLE;
37
+ }
38
+ /**
39
+ * {@link renderNumberFormat}, but `undefined` for a value the format cannot show — which is where
40
+ * Excel's `TEXT()` returns `#VALUE!` and a cell shows hashes.
41
+ */
42
+ export function tryRenderNumberFormat(fmt, value, options = {}) {
43
+ if (typeof value === "boolean") {
44
+ return value ? "TRUE" : "FALSE";
45
+ }
46
+ const parsed = parseNumberFormat(fmt.trim() === "" ? "General" : fmt);
47
+ if (typeof value === "string") {
48
+ return parsed.textSection ? renderTextSection(parsed.textSection.tokens, value) : value;
49
+ }
50
+ if (!Number.isFinite(value)) {
51
+ return String(value);
52
+ }
53
+ const sections = parsed.numberSections;
54
+ if (sections.length === 0) {
55
+ // A text-only format such as `"n="@` says nothing about numbers, which are shown as General.
56
+ return formatGeneral(value);
57
+ }
58
+ const choice = chooseSection(sections, value);
59
+ if (choice === undefined) {
60
+ return undefined;
61
+ }
62
+ const { section, signed } = choice;
63
+ const shown = signed ? value : Math.abs(value);
64
+ if (section.hasGeneral) {
65
+ return renderGeneralSection(section.tokens, shown);
66
+ }
67
+ if (section.hasDate || section.hasTime || section.hasElapsed) {
68
+ // A negative value has no date or clock reading, whichever section it reached. A lone elapsed count
69
+ // (`[h]`, `[ss]`) is the exception: it is a plain signed number of units.
70
+ if (value < 0 && !isLoneElapsed(section.tokens)) {
71
+ return undefined;
72
+ }
73
+ return renderDateSection(section.tokens, shown, options.date1904 === true);
74
+ }
75
+ return renderNumberSection(section.tokens, shown);
76
+ }
77
+ function isLoneElapsed(tokens) {
78
+ return (tokens.filter(t => t.kind === "elapsed").length === 1 &&
79
+ !tokens.some(t => t.kind === "date" || t.kind === "ampm"));
80
+ }
81
+ // =============================================================================
82
+ // Section choice
83
+ // =============================================================================
84
+ function compare(value, op, threshold) {
85
+ switch (op) {
86
+ case "=":
87
+ return value === threshold;
88
+ case "<>":
89
+ return value !== threshold;
90
+ case "<":
91
+ return value < threshold;
92
+ case "<=":
93
+ return value <= threshold;
94
+ case ">":
95
+ return value > threshold;
96
+ case ">=":
97
+ return value >= threshold;
98
+ }
99
+ }
100
+ /** A condition no non-negative number can meet: `[<0]`, `[<-5]`, `[<=-1]`, `[=-5]`. */
101
+ function negativeOnly(c) {
102
+ return (c.op === "<" && c.value <= 0) || ((c.op === "<=" || c.op === "=") && c.value < 0);
103
+ }
104
+ /**
105
+ * Pick the section for a number, and say whether it keeps its sign; `undefined` when no section applies.
106
+ *
107
+ * Without conditions the roles are positional: a negative number takes the second section, which prints
108
+ * its own sign, and zero the third. With a condition, the rules below reproduce Excel exactly over 160
109
+ * measured combinations of condition, section count and value:
110
+ *
111
+ * - A conditioned section that matches is used. It drops the sign only when its condition admits no
112
+ * non-negative number (`[<0]`, `[<=-5]`); `[<100]` and `[<=0]` keep it.
113
+ * - A bare first section beside a conditioned second means `[>0]`.
114
+ * - With three or more sections a bare second one means `[<0]` and the third takes everything else.
115
+ * - With two sections a bare second one takes everything else. A negative number there drops its sign when
116
+ * the first condition covered every positive number or only negatives (`[>0]`, `[>=-5]`, `[<-10]`), and
117
+ * keeps it otherwise (`[>100]`, `[=-5]`).
118
+ * - Two conditions and no third section: a number matching neither has no display.
119
+ */
120
+ function chooseSection(sections, value) {
121
+ const [first, second, third] = sections;
122
+ if (first.condition === undefined && second?.condition === undefined) {
123
+ if (sections.length === 1) {
124
+ return { section: first, signed: true };
125
+ }
126
+ if (value < 0) {
127
+ return { section: second, signed: false };
128
+ }
129
+ return { section: value === 0 && third ? third : first, signed: true };
130
+ }
131
+ const c1 = first.condition ?? { op: ">", value: 0 };
132
+ if (compare(value, c1.op, c1.value)) {
133
+ return { section: first, signed: !negativeOnly(c1) };
134
+ }
135
+ if (second === undefined) {
136
+ return { section: first, signed: true };
137
+ }
138
+ const c2 = second.condition;
139
+ if (c2) {
140
+ if (compare(value, c2.op, c2.value)) {
141
+ return { section: second, signed: !negativeOnly(c2) };
142
+ }
143
+ return third ? { section: third, signed: true } : undefined;
144
+ }
145
+ if (third) {
146
+ return value < 0 ? { section: second, signed: false } : { section: third, signed: true };
147
+ }
148
+ const coversPositives = (c1.op === ">" || c1.op === ">=") && c1.value <= 0;
149
+ const dropsSign = value < 0 && (coversPositives || (c1.op !== "=" && negativeOnly(c1)));
150
+ return { section: second, signed: !dropsSign };
151
+ }
152
+ // =============================================================================
153
+ // Shared pieces
154
+ // =============================================================================
155
+ /** What a token shows when it is not doing its job as a code — or is a pure layout token. */
156
+ function plainText(token) {
157
+ switch (token.kind) {
158
+ case "literal":
159
+ case "numeral":
160
+ return token.text;
161
+ case "pad":
162
+ return " ";
163
+ case "locale":
164
+ return token.symbol;
165
+ case "digit":
166
+ return token.char === "0" ? "0" : token.char === "?" ? " " : "";
167
+ case "decimal":
168
+ return ".";
169
+ case "thousands":
170
+ return ",";
171
+ case "percent":
172
+ return "%";
173
+ case "slash":
174
+ return "/";
175
+ case "exponent":
176
+ return (token.upper ? "E" : "e") + token.sign;
177
+ default:
178
+ // fill, colour, condition, other brackets, and codes with no meaning here.
179
+ return "";
180
+ }
181
+ }
182
+ /**
183
+ * Render text through a text section. The caller decides whether there is one; an *empty* text section
184
+ * (`0;0;0;`) is a real section that shows nothing, not an absent one that shows the text as is.
185
+ */
186
+ function renderTextSection(tokens, text) {
187
+ let out = "";
188
+ for (const token of tokens) {
189
+ out += token.kind === "text" ? text : plainText(token);
190
+ }
191
+ return out;
192
+ }
193
+ /** Trailing zeros of a decimal fraction, and the point itself when nothing follows it. */
194
+ function stripDecimal(s) {
195
+ return s.includes(".") ? s.replace(/(?:\.0*|(\.\d*[1-9])0+)$/, "$1") : s;
196
+ }
197
+ /**
198
+ * Excel's `General`: at most eleven characters, not counting a minus sign, switching to scientific
199
+ * notation with six significant digits when a number cannot fit — `0.333333333`, `1.23457E+11`,
200
+ * `1234567891` for 1234567890.5. The shape of the algorithm is SheetJS's, which its authors calibrated
201
+ * against Excel; the rounding is decimal, at fifteen significant digits, so a half like -501059677.45 rounds
202
+ * away from zero as Excel does rather than as its nearest double would. It agreed with Excel on all 146
203
+ * values measured.
204
+ */
205
+ export function formatGeneral(value) {
206
+ if (value === 0) {
207
+ return "0";
208
+ }
209
+ const sign = value < 0 ? "-" : "";
210
+ const x = Math.abs(value);
211
+ const fixed = (decimals) => {
212
+ const { int, frac } = toFixedDigits(x, decimals);
213
+ return (int || "0") + (decimals > 0 ? "." + frac : "");
214
+ };
215
+ const magnitude = Math.floor(Math.log10(x));
216
+ let s;
217
+ if (magnitude >= -4 && magnitude <= -1) {
218
+ s = fixed(9);
219
+ }
220
+ else if (Math.abs(magnitude) <= 9) {
221
+ s = stripDecimal(fixed(12));
222
+ if (s.length > 11) {
223
+ s = fixed(9 - magnitude);
224
+ if (s.split(".")[0].length > Math.max(magnitude, 0) + 1) {
225
+ // Rounding carried into the next magnitude: 9999999999.9 is 10000000000.
226
+ return sign + formatGeneral(Number(s));
227
+ }
228
+ if (stripDecimal(s).length > 11) {
229
+ s = x.toExponential(5);
230
+ }
231
+ }
232
+ }
233
+ else if (magnitude === 10) {
234
+ // Eleven integer digits fill the width: round to a whole number, which may carry to twelve digits.
235
+ const rounded = Number(toFixedDigits(x, 0).int);
236
+ return sign + (rounded >= 1e11 ? formatGeneral(rounded) : String(rounded));
237
+ }
238
+ else {
239
+ s = stripDecimal(fixed(11));
240
+ if (s.length > 11 || s === "0") {
241
+ s = x.toExponential(5);
242
+ }
243
+ }
244
+ s = s.toUpperCase();
245
+ if (s.includes("E")) {
246
+ // At least two exponent digits, and no trailing zeros in the mantissa: `1.5E+15`, `1E-12`.
247
+ s = s.replace(/(?:\.0*|(\.\d*[1-9])0+)E/, "$1E").replace(/E([+-])(\d)$/, "E$10$2");
248
+ }
249
+ return sign + stripDecimal(s);
250
+ }
251
+ function renderGeneralSection(tokens, value) {
252
+ let out = "";
253
+ for (const token of tokens) {
254
+ out += token.kind === "general" ? formatGeneral(value) : plainText(token);
255
+ }
256
+ return out;
257
+ }
258
+ /**
259
+ * A non-negative number as decimal digit strings, rounded half away from zero at `decimals`
260
+ * places, and taken to fifteen significant digits first as Excel does — so `1.005` rounds to
261
+ * `1.01` rather than to the `1.00` its binary value would give.
262
+ *
263
+ * `int` has no leading zeros and is empty for a value below one. With `shift`, the value is first divided
264
+ * by `10^shift` exactly, by moving the decimal point.
265
+ */
266
+ function toFixedDigits(x, decimals, shift = 0) {
267
+ if (x === 0) {
268
+ return { int: "", frac: "0".repeat(decimals) };
269
+ }
270
+ const [mantissa, exponent] = x.toExponential(14).split("e");
271
+ let digits = mantissa.replace(".", "");
272
+ // `shift` divides by 10^shift by moving the point in the digit string — never by dividing a `double`,
273
+ // which underflows to 0 for the smallest values and turned 5e-324 into `0.InE-324`.
274
+ let point = Number(exponent) + 1 - shift;
275
+ const keep = point + decimals;
276
+ if (keep < 0) {
277
+ return { int: "", frac: "0".repeat(decimals) };
278
+ }
279
+ if (keep < digits.length) {
280
+ const roundUp = digits.charCodeAt(keep) >= 53;
281
+ const kept = digits.slice(0, keep).split("");
282
+ if (roundUp) {
283
+ let i = kept.length - 1;
284
+ while (i >= 0 && kept[i] === "9") {
285
+ kept[i] = "0";
286
+ i--;
287
+ }
288
+ if (i >= 0) {
289
+ kept[i] = String(Number(kept[i]) + 1);
290
+ }
291
+ else {
292
+ kept.unshift("1");
293
+ point++;
294
+ }
295
+ }
296
+ digits = kept.join("");
297
+ }
298
+ const full = point <= 0 ? "0".repeat(-point) + digits : digits;
299
+ const at = Math.max(point, 0);
300
+ return {
301
+ int: full.slice(0, at).padEnd(at, "0").replace(/^0+/, ""),
302
+ frac: full.slice(at).padEnd(decimals, "0").slice(0, decimals)
303
+ };
304
+ }
305
+ function isDigitChar(s) {
306
+ return s !== undefined && s >= "0" && s <= "9";
307
+ }
308
+ /**
309
+ * Lay an integer's digits into a run of placeholders, right-aligned.
310
+ *
311
+ * Returns one string per placeholder. A position with no digit shows `0` for `0`, a space for `?`
312
+ * and nothing for `#`; digits beyond the placeholders go in front of the first one, which is how
313
+ * `0` displays `12345`. With `grouping`, a comma follows every third digit from the right.
314
+ */
315
+ function layInteger(digits, slots, grouping) {
316
+ const count = slots.length;
317
+ const charAt = (p) => {
318
+ if (p < digits.length) {
319
+ return digits[digits.length - 1 - p];
320
+ }
321
+ if (p >= count) {
322
+ return "";
323
+ }
324
+ const slot = slots[count - 1 - p];
325
+ return slot === "0" ? "0" : slot === "?" ? " " : "";
326
+ };
327
+ const withComma = (p) => {
328
+ const ch = charAt(p);
329
+ return grouping && p > 0 && p % 3 === 0 && isDigitChar(ch) && isDigitChar(charAt(p - 1))
330
+ ? ch + ","
331
+ : ch;
332
+ };
333
+ const parts = [];
334
+ for (let j = 0; j < count; j++) {
335
+ let part = "";
336
+ if (j === 0) {
337
+ for (let p = digits.length - 1; p >= count; p--) {
338
+ part += withComma(p);
339
+ }
340
+ }
341
+ part += withComma(count - 1 - j);
342
+ parts.push(part);
343
+ }
344
+ return parts;
345
+ }
346
+ /**
347
+ * Lay fraction digits into placeholders, left-aligned. Trailing zeros under `#` vanish and under
348
+ * `?` become spaces; a `0` placeholder, or any non-zero digit, stops the trimming.
349
+ */
350
+ function layFraction(digits, slots) {
351
+ const parts = slots.map((_, k) => digits[k] ?? "0");
352
+ for (let k = slots.length - 1; k >= 0; k--) {
353
+ if (parts[k] !== "0" || slots[k] === "0") {
354
+ break;
355
+ }
356
+ parts[k] = slots[k] === "?" ? " " : "";
357
+ }
358
+ return parts;
359
+ }
360
+ function slotOf(token) {
361
+ return token.kind === "digit" ? token.char : "0";
362
+ }
363
+ // =============================================================================
364
+ // Numbers
365
+ // =============================================================================
366
+ function renderNumberSection(tokens, value) {
367
+ const negative = value < 0;
368
+ let x = Math.abs(value);
369
+ const expIndex = tokens.findIndex(t => t.kind === "exponent");
370
+ const fraction = expIndex === -1 ? findFraction(tokens) : undefined;
371
+ const numberEnd = expIndex === -1 ? tokens.length : expIndex;
372
+ let decimalIndex = -1;
373
+ for (let k = 0; k < numberEnd; k++) {
374
+ if (tokens[k].kind === "decimal") {
375
+ decimalIndex = k;
376
+ break;
377
+ }
378
+ }
379
+ const intEnd = decimalIndex === -1 ? numberEnd : decimalIndex;
380
+ const lastDigitBefore = (end, start) => {
381
+ for (let k = end - 1; k >= start; k--) {
382
+ if (tokens[k].kind === "digit") {
383
+ return k;
384
+ }
385
+ }
386
+ return -1;
387
+ };
388
+ const lastIntDigit = lastDigitBefore(intEnd, 0);
389
+ const lastFracDigit = decimalIndex === -1 ? -1 : lastDigitBefore(numberEnd, decimalIndex + 1);
390
+ // A comma that ends a run of digit placeholders divides by a thousand — at the end of the integer
391
+ // part (`0,.0`) or of the fraction (`0.0,,"M"`). One with integer placeholders after it groups.
392
+ let grouping = false;
393
+ let scale = 0;
394
+ for (let k = 0; k < numberEnd; k++) {
395
+ if (tokens[k].kind !== "thousands") {
396
+ continue;
397
+ }
398
+ if (fraction) {
399
+ // Only the whole-number part of a fraction groups (`#,##0 ?/?`); a fraction does not scale.
400
+ grouping || (grouping = fraction.integer.length > 0 && k < fraction.numerator[0]);
401
+ continue;
402
+ }
403
+ const partEnd = k < intEnd ? lastIntDigit : lastFracDigit;
404
+ if (partEnd !== -1 && k > partEnd) {
405
+ scale++;
406
+ }
407
+ else if (k < intEnd) {
408
+ grouping = true;
409
+ }
410
+ }
411
+ // Each `%` multiplies by 100 and each scaling comma divides by 1,000. Zero stays zero, however many.
412
+ if (x !== 0) {
413
+ x =
414
+ (x * Math.pow(100, tokens.filter(t => t.kind === "percent").length)) / Math.pow(1000, scale);
415
+ }
416
+ if (!Number.isFinite(x)) {
417
+ return undefined;
418
+ }
419
+ if (fraction) {
420
+ return renderFraction(tokens, fraction, x, negative, grouping);
421
+ }
422
+ if (expIndex !== -1) {
423
+ return renderScientific(tokens, expIndex, decimalIndex, x, negative);
424
+ }
425
+ const intSlots = [];
426
+ const fracSlots = [];
427
+ tokens.forEach((t, k) => {
428
+ if (t.kind === "digit") {
429
+ (decimalIndex !== -1 && k > decimalIndex ? fracSlots : intSlots).push(t.char);
430
+ }
431
+ });
432
+ const fixed = toFixedDigits(x, fracSlots.length);
433
+ // A value that rounds to zero shows no sign. A section with no digit placeholder rounds nothing, so it
434
+ // keeps the sign: `"x"` shows -5 as `-x`, as Excel does.
435
+ const isZero = (intSlots.length > 0 || fracSlots.length > 0) && fixed.int === "" && !/[1-9]/.test(fixed.frac);
436
+ const intParts = layInteger(fixed.int, intSlots, grouping);
437
+ const fracParts = layFraction(fixed.frac, fracSlots);
438
+ let out = negative && !isZero ? "-" : "";
439
+ let intCursor = 0;
440
+ let fracCursor = 0;
441
+ tokens.forEach((token, k) => {
442
+ switch (token.kind) {
443
+ case "digit":
444
+ out +=
445
+ decimalIndex !== -1 && k > decimalIndex ? fracParts[fracCursor++] : intParts[intCursor++];
446
+ break;
447
+ case "decimal":
448
+ if (k === decimalIndex && intSlots.length === 0) {
449
+ // `.00` still shows the integer part of 12.5: there is nowhere else to put it.
450
+ out += grouping ? fixed.int.replace(/\B(?=(\d{3})+$)/g, ",") : fixed.int;
451
+ }
452
+ out += ".";
453
+ break;
454
+ case "thousands":
455
+ break;
456
+ case "text":
457
+ out += formatGeneral(Math.abs(value));
458
+ break;
459
+ default:
460
+ out += plainText(token);
461
+ }
462
+ });
463
+ return out;
464
+ }
465
+ function renderScientific(tokens, expIndex, decimalIndex, x, negative) {
466
+ const intSlots = [];
467
+ const fracSlots = [];
468
+ const expSlots = [];
469
+ tokens.forEach((t, k) => {
470
+ if (t.kind !== "digit") {
471
+ return;
472
+ }
473
+ if (k > expIndex) {
474
+ expSlots.push(t.char);
475
+ }
476
+ else if (decimalIndex !== -1 && decimalIndex < expIndex && k > decimalIndex) {
477
+ fracSlots.push(t.char);
478
+ }
479
+ else {
480
+ intSlots.push(t.char);
481
+ }
482
+ });
483
+ // With several integer placeholders the exponent is a multiple of their count: `##0.0E+0`
484
+ // is engineering notation.
485
+ const step = Math.max(intSlots.length, 1);
486
+ let exp = 0;
487
+ let mantissa = { int: "", frac: "0".repeat(fracSlots.length) };
488
+ if (x !== 0) {
489
+ const magnitude = Number(x.toExponential(14).split("e")[1]);
490
+ exp = intSlots.length === 0 ? magnitude + 1 : Math.floor(magnitude / step) * step;
491
+ mantissa = toFixedDigits(x, fracSlots.length, exp);
492
+ if (mantissa.int.length > (intSlots.length === 0 ? 0 : step)) {
493
+ // Rounding carried into a new digit (9.999 → 10.00): move up one exponent step.
494
+ exp += intSlots.length === 0 ? 1 : step;
495
+ mantissa = toFixedDigits(x, fracSlots.length, exp);
496
+ }
497
+ }
498
+ const isZero = mantissa.int === "" && !/[1-9]/.test(mantissa.frac);
499
+ const intParts = layInteger(mantissa.int, intSlots, false);
500
+ const fracParts = layFraction(mantissa.frac, fracSlots);
501
+ const expParts = layInteger(String(Math.abs(exp)).replace(/^0$/, ""), expSlots, false);
502
+ let out = negative && !isZero ? "-" : "";
503
+ let intCursor = 0;
504
+ let fracCursor = 0;
505
+ let expCursor = 0;
506
+ tokens.forEach((token, k) => {
507
+ switch (token.kind) {
508
+ case "digit":
509
+ if (k > expIndex) {
510
+ out += expParts[expCursor++];
511
+ }
512
+ else if (decimalIndex !== -1 && decimalIndex < expIndex && k > decimalIndex) {
513
+ out += fracParts[fracCursor++];
514
+ }
515
+ else {
516
+ out += intParts[intCursor++];
517
+ }
518
+ break;
519
+ case "exponent":
520
+ if (k === expIndex) {
521
+ out += (token.upper ? "E" : "e") + (exp < 0 ? "-" : token.sign === "+" ? "+" : "");
522
+ }
523
+ else {
524
+ out += plainText(token);
525
+ }
526
+ break;
527
+ case "thousands":
528
+ break;
529
+ default:
530
+ out += plainText(token);
531
+ }
532
+ });
533
+ return out;
534
+ }
535
+ /** A `?/?`, `# ??/??` or `# ?/8` layout, if the section has one. */
536
+ function findFraction(tokens) {
537
+ for (let slash = 1; slash < tokens.length; slash++) {
538
+ if (tokens[slash].kind !== "slash" || tokens[slash - 1].kind !== "digit") {
539
+ continue;
540
+ }
541
+ let numStart = slash - 1;
542
+ while (numStart > 0 && tokens[numStart - 1].kind === "digit") {
543
+ numStart--;
544
+ }
545
+ const numerator = [];
546
+ for (let k = numStart; k < slash; k++) {
547
+ numerator.push(k);
548
+ }
549
+ const denominator = [];
550
+ let fixedDenominator;
551
+ if (tokens[slash + 1]?.kind === "numeral") {
552
+ // A fixed denominator is written as bare digits (`?/16`); a quoted or escaped digit is text.
553
+ let text = "";
554
+ for (let k = slash + 1; k < tokens.length; k++) {
555
+ const t = tokens[k];
556
+ if (t.kind === "numeral") {
557
+ text += t.text;
558
+ }
559
+ else if (t.kind === "digit" && t.char === "0") {
560
+ text += "0";
561
+ }
562
+ else {
563
+ break;
564
+ }
565
+ denominator.push(k);
566
+ }
567
+ fixedDenominator = Number(text);
568
+ }
569
+ else {
570
+ for (let k = slash + 1; k < tokens.length && tokens[k].kind === "digit"; k++) {
571
+ denominator.push(k);
572
+ }
573
+ }
574
+ if (denominator.length === 0) {
575
+ continue;
576
+ }
577
+ const integer = [];
578
+ for (let k = 0; k < numStart; k++) {
579
+ if (tokens[k].kind === "digit") {
580
+ integer.push(k);
581
+ }
582
+ }
583
+ return { integer, numerator, slash, denominator, fixedDenominator };
584
+ }
585
+ return undefined;
586
+ }
587
+ /**
588
+ * The fraction Excel shows for `x ≥ 0` with `d` no larger than `maxDenominator`: the last continued-fraction
589
+ * convergent within the bound. It is not always the closest fraction — Excel shows 0.14159 as 1/7, not
590
+ * 14/99 — and it matched Excel on all 150 values measured. A few dozen steps for any bound, where trying every
591
+ * denominator cost a billion iterations per cell for `?/?????????`.
592
+ */
593
+ function closestFraction(x, maxDenominator) {
594
+ let p0 = 0;
595
+ let q0 = 1;
596
+ let p1 = 1;
597
+ let q1 = 0;
598
+ let r = x;
599
+ for (let step = 0; step < 64; step++) {
600
+ const a = Math.floor(r);
601
+ const q2 = q0 + a * q1;
602
+ if (q2 > maxDenominator) {
603
+ break;
604
+ }
605
+ const p2 = p0 + a * p1;
606
+ p0 = p1;
607
+ q0 = q1;
608
+ p1 = p2;
609
+ q1 = q2;
610
+ const rest = r - a;
611
+ if (rest === 0 || p1 / q1 === x) {
612
+ break;
613
+ }
614
+ r = 1 / rest;
615
+ }
616
+ return { n: p1, d: q1 };
617
+ }
618
+ /** A non-negative integer's decimal digits, without exponent notation however large it is. */
619
+ function integerDigits(n) {
620
+ return n === 0 ? "0" : toFixedDigits(n, 0).int;
621
+ }
622
+ function renderFraction(tokens, layout, x, negative, grouping) {
623
+ const mixed = layout.integer.length > 0;
624
+ let whole = mixed ? Math.floor(x) : 0;
625
+ const part = mixed ? x - whole : x;
626
+ let n;
627
+ let d;
628
+ if (layout.fixedDenominator !== undefined) {
629
+ d = layout.fixedDenominator;
630
+ n = Math.round(part * d);
631
+ }
632
+ else {
633
+ ({ n, d } = closestFraction(part, Math.pow(10, layout.denominator.length) - 1));
634
+ }
635
+ if (mixed && n === d) {
636
+ whole++;
637
+ n = 0;
638
+ }
639
+ const isZero = whole === 0 && n === 0;
640
+ // A whole number shows no fraction: Excel blanks it to the width it would have taken.
641
+ const blank = n === 0 && mixed;
642
+ const wholeDigits = whole > 0 ? integerDigits(whole) : n === 0 ? "0" : "";
643
+ const intParts = layInteger(wholeDigits, layout.integer.map(k => slotOf(tokens[k])), grouping);
644
+ const numParts = layInteger(integerDigits(n), layout.numerator.map(k => slotOf(tokens[k])), false);
645
+ // The first denominator placeholder decides how unused places are filled: `0` pads zeros in front
646
+ // (`1/02`), `?` pads spaces behind so fractions line up (`1/2 `), `#` pads nothing. Padding never goes
647
+ // after the digits as zeros, which would change the value: 1/2 is not 1/20.
648
+ const denSlots = layout.denominator.map(k => slotOf(tokens[k]));
649
+ let denText = integerDigits(d);
650
+ if (layout.fixedDenominator === undefined && denText.length < denSlots.length) {
651
+ if (denSlots[0] === "0") {
652
+ denText = denText.padStart(denSlots.length, "0");
653
+ }
654
+ else if (denSlots[0] === "?") {
655
+ denText = denText.padEnd(denSlots.length, " ");
656
+ }
657
+ }
658
+ const numStart = layout.numerator[0];
659
+ const denStart = layout.denominator[0];
660
+ const denEnd = layout.denominator[layout.denominator.length - 1];
661
+ let out = negative && !isZero ? "-" : "";
662
+ let intCursor = 0;
663
+ let numCursor = 0;
664
+ tokens.forEach((token, k) => {
665
+ if (k >= numStart && k < layout.slash) {
666
+ out += blank ? " ".repeat(Math.max(numParts[numCursor].length, 1)) : numParts[numCursor];
667
+ numCursor++;
668
+ return;
669
+ }
670
+ if (k === layout.slash) {
671
+ out += blank ? " " : "/";
672
+ return;
673
+ }
674
+ if (k >= denStart && k <= denEnd) {
675
+ if (k === denStart) {
676
+ out += blank ? " ".repeat(Math.max(denText.length, layout.denominator.length)) : denText;
677
+ }
678
+ return;
679
+ }
680
+ if (layout.integer.includes(k)) {
681
+ out += intParts[intCursor++];
682
+ return;
683
+ }
684
+ if (token.kind === "thousands") {
685
+ return;
686
+ }
687
+ out += plainText(token);
688
+ });
689
+ return out;
690
+ }
691
+ // =============================================================================
692
+ // Dates and times
693
+ // =============================================================================
694
+ function pad2(n) {
695
+ return String(n).padStart(2, "0");
696
+ }
697
+ function renderDateSection(tokens, value, date1904) {
698
+ if (value < 0) {
699
+ // Only a lone elapsed count reaches here negative (see `tryRenderNumberFormat`).
700
+ const magnitude = renderDateSection(tokens, -value, date1904);
701
+ return magnitude === undefined ? undefined : "-" + magnitude;
702
+ }
703
+ let precision = 0;
704
+ for (const t of tokens) {
705
+ if (t.kind === "date" || t.kind === "elapsed") {
706
+ if (t.fraction > MAX_SECOND_DECIMALS) {
707
+ return undefined;
708
+ }
709
+ precision = Math.max(precision, t.fraction);
710
+ }
711
+ }
712
+ // The day and the time of day are separated before scaling, so precision multiplies only a value below
713
+ // 86400: scaling the whole serial overflowed a safe integer, and a valid year-2447 date showed hashes at
714
+ // six decimals.
715
+ const scale = Math.pow(10, precision);
716
+ const unitsPerDay = 86400 * scale;
717
+ let day = Math.floor(value);
718
+ // Rounded to the shown precision of a second even when no time is shown, as Excel does: 23:59:59.95
719
+ // is the next day under `yyyy-mm-dd`.
720
+ let units = Math.round((value - day) * unitsPerDay);
721
+ if (units >= unitsPerDay) {
722
+ day++;
723
+ units -= unitsPerDay;
724
+ }
725
+ const subSecond = String(units % scale).padStart(precision, "0");
726
+ const secondOfDay = Math.floor(units / scale);
727
+ const totalSeconds = day * 86400 + secondOfDay;
728
+ // Checked after rounding, so 9999-12-31 23:59:59.9 is past the end too, and against the workbook's own
729
+ // epoch: the last displayable serial is 1462 lower under 1904. A lone elapsed count (`[ss]`) is a plain
730
+ // number and has no such bound; beside a clock or calendar field it does, as in Excel.
731
+ if (!isLoneElapsed(tokens) &&
732
+ day >= FIRST_SERIAL_PAST_9999 - (date1904 ? EPOCH_1904_OFFSET : 0)) {
733
+ return undefined;
734
+ }
735
+ // Serial 0 of the 1900 system is Excel's 1900-01-00, a Saturday.
736
+ const parts = day === 0 && !date1904 ? { year: 1900, month: 1, day: 0 } : serialToParts(day, date1904);
737
+ // From the serial, not the calendar date: Excel's 1900 calendar counts a 1900-02-29 that never existed,
738
+ // so serial 1 is a Sunday and every day before March 1900 is a weekday earlier than the real one.
739
+ // Serial 0 is a Saturday under 1900 and a Friday under 1904.
740
+ const weekday = (day + (date1904 ? 5 : 6)) % 7;
741
+ const hour = Math.floor(secondOfDay / 3600);
742
+ const minute = Math.floor((secondOfDay % 3600) / 60);
743
+ const second = secondOfDay % 60;
744
+ const twelveHour = tokens.some(t => t.kind === "ampm");
745
+ const shownHour = twelveHour ? hour % 12 || 12 : hour;
746
+ const fractionOf = (places) => places === 0 ? "" : "." + subSecond.padEnd(places, "0").slice(0, places);
747
+ let out = "";
748
+ // An elapsed count after a year, month or day field counts only the whole hours of that day, in its own
749
+ // unit: `dd [h]` shows 1.5 as `01 12`, `yyyy [mm]` shows 12:15 as 720 — where `[h] dd` shows `36 01`.
750
+ let calendarShown = false;
751
+ for (const token of tokens) {
752
+ switch (token.kind) {
753
+ case "date":
754
+ calendarShown || (calendarShown = token.part !== "hour" && token.part !== "minute" && token.part !== "second");
755
+ out += datePart(token.part, token.width, parts.year, parts.month, parts.day, weekday, shownHour, minute, second);
756
+ out += token.part === "second" ? fractionOf(token.fraction) : "";
757
+ break;
758
+ case "elapsed": {
759
+ const seconds = calendarShown ? hour * 3600 : totalSeconds;
760
+ const total = token.unit === "h"
761
+ ? Math.floor(seconds / 3600)
762
+ : token.unit === "m"
763
+ ? Math.floor(seconds / 60)
764
+ : seconds;
765
+ out += String(total).padStart(token.width, "0");
766
+ out += token.unit === "s" ? fractionOf(token.fraction) : "";
767
+ break;
768
+ }
769
+ case "ampm": {
770
+ const pm = hour >= 12;
771
+ const text = token.short ? (pm ? "P" : "A") : pm ? "PM" : "AM";
772
+ out += token.lower ? text.toLowerCase() : text;
773
+ break;
774
+ }
775
+ default:
776
+ out += plainText(token);
777
+ }
778
+ }
779
+ return out;
780
+ }
781
+ function datePart(part, width, year, month, day, weekday, hour, minute, second) {
782
+ switch (part) {
783
+ case "year":
784
+ case "buddhistYear": {
785
+ const y = part === "year" ? year : year + 543;
786
+ return width <= 2 ? pad2(((y % 100) + 100) % 100) : String(y).padStart(4, "0");
787
+ }
788
+ case "month":
789
+ if (width === 1) {
790
+ return String(month);
791
+ }
792
+ if (width === 2) {
793
+ return pad2(month);
794
+ }
795
+ if (width === 3) {
796
+ return MONTH_NAMES[month - 1].slice(0, 3);
797
+ }
798
+ return width === 5 ? MONTH_NAMES[month - 1][0] : MONTH_NAMES[month - 1];
799
+ case "day":
800
+ if (width === 1) {
801
+ return String(day);
802
+ }
803
+ if (width === 2) {
804
+ return pad2(day);
805
+ }
806
+ return width === 3 ? DAY_NAMES[weekday].slice(0, 3) : DAY_NAMES[weekday];
807
+ case "hour":
808
+ return width === 1 ? String(hour) : pad2(hour);
809
+ case "minute":
810
+ return width === 1 ? String(minute) : pad2(minute);
811
+ default:
812
+ return width === 1 ? String(second) : pad2(second);
813
+ }
814
+ }