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
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { argToNumber, checkError, excelWildcardToRegex } from "./_shared.js";
|
|
5
5
|
import { DEFAULT_FUNCTION_CONTEXT } from "../runtime/function-context.js";
|
|
6
6
|
import { RVKind, ERRORS, isError, isArray, toNumberRV, toStringRV, toBooleanRV, topLeft, rvNumber, rvString, rvBoolean, rvArray } from "../runtime/values.js";
|
|
7
|
-
import {
|
|
7
|
+
import { parseNumberFormat } from "../../../utils/number-format.js";
|
|
8
|
+
import { tryRenderNumberFormat } from "../../../utils/number-format-render.js";
|
|
8
9
|
// ============================================================================
|
|
9
10
|
// Local utility
|
|
10
11
|
// ============================================================================
|
|
@@ -413,643 +414,34 @@ export function fnTEXT(args, context = DEFAULT_FUNCTION_CONTEXT) {
|
|
|
413
414
|
return e1;
|
|
414
415
|
}
|
|
415
416
|
const fmt = toStringRV(topLeft(args[1]));
|
|
416
|
-
//
|
|
417
|
-
|
|
417
|
+
// The format is read and rendered by the same code that produces a cell's display text, so
|
|
418
|
+
// `TEXT(x, f)` and a cell holding `x` formatted with `f` cannot disagree. Where the cell would show
|
|
419
|
+
// hashes — a date past 9999, a negative time — `TEXT` is `#VALUE!`, as in Excel.
|
|
420
|
+
const render = (value) => {
|
|
421
|
+
const text = tryRenderNumberFormat(fmt, value, { date1904: context.date1904 });
|
|
422
|
+
return text === undefined ? ERRORS.VALUE : rvString(text);
|
|
423
|
+
};
|
|
424
|
+
// A logical is shown as itself under any format: `TEXT(TRUE, "0")` is `TRUE`.
|
|
425
|
+
if (rawVal.kind === RVKind.Boolean) {
|
|
418
426
|
return rvString(toStringRV(rawVal));
|
|
419
427
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
return
|
|
428
|
+
if (rawVal.kind === RVKind.String) {
|
|
429
|
+
// A four-section format formats text with its fourth section. Otherwise text that reads as a number is that
|
|
430
|
+
// number, and any other text goes through the text section if there is one — or comes back unchanged, which
|
|
431
|
+
// is what Excel does with `TEXT("abc", "0.00")` rather than failing.
|
|
432
|
+
if (parseNumberFormat(fmt).sections.length < 4) {
|
|
433
|
+
const num = toNumberRV(rawVal);
|
|
434
|
+
if (!isError(num)) {
|
|
435
|
+
return render(num.value);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return render(toStringRV(rawVal));
|
|
431
439
|
}
|
|
432
440
|
const valRV = toNumberRV(rawVal);
|
|
433
441
|
if (isError(valRV)) {
|
|
434
442
|
return valRV;
|
|
435
443
|
}
|
|
436
|
-
|
|
437
|
-
let activeFmt;
|
|
438
|
-
if (sections.length >= 3) {
|
|
439
|
-
activeFmt = val > 0 ? sections[0] : val < 0 ? sections[1] : sections[2];
|
|
440
|
-
}
|
|
441
|
-
else if (sections.length === 2) {
|
|
442
|
-
activeFmt = val >= 0 ? sections[0] : sections[1];
|
|
443
|
-
}
|
|
444
|
-
else {
|
|
445
|
-
activeFmt = sections[0];
|
|
446
|
-
}
|
|
447
|
-
// For negative section, use absolute value (sign is in the format)
|
|
448
|
-
const useVal = sections.length >= 2 && val < 0 ? Math.abs(val) : val;
|
|
449
|
-
return rvString(formatWithCode(useVal, activeFmt, rawVal, context.date1904));
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Split format string on `;` separators, respecting quoted strings.
|
|
453
|
-
*/
|
|
454
|
-
function splitFormatSections(fmt) {
|
|
455
|
-
const sections = [];
|
|
456
|
-
let current = "";
|
|
457
|
-
let inQuotes = false;
|
|
458
|
-
for (let i = 0; i < fmt.length; i++) {
|
|
459
|
-
if (fmt[i] === '"') {
|
|
460
|
-
inQuotes = !inQuotes;
|
|
461
|
-
current += fmt[i];
|
|
462
|
-
}
|
|
463
|
-
else if (fmt[i] === ";" && !inQuotes) {
|
|
464
|
-
sections.push(current);
|
|
465
|
-
current = "";
|
|
466
|
-
}
|
|
467
|
-
else {
|
|
468
|
-
current += fmt[i];
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
sections.push(current);
|
|
472
|
-
return sections;
|
|
473
|
-
}
|
|
474
|
-
/**
|
|
475
|
-
* Format a number using a single format code section.
|
|
476
|
-
*/
|
|
477
|
-
function formatWithCode(val, fmt, rawVal, date1904) {
|
|
478
|
-
const upper = fmt.toUpperCase();
|
|
479
|
-
// Date/time formats — detect by presence of date/time tokens
|
|
480
|
-
if (upper.includes("YYYY") ||
|
|
481
|
-
upper.includes("YY") ||
|
|
482
|
-
upper.includes("MMMM") ||
|
|
483
|
-
upper.includes("MMM") ||
|
|
484
|
-
upper.includes("DDDD") ||
|
|
485
|
-
upper.includes("DDD") ||
|
|
486
|
-
upper.includes("DD") ||
|
|
487
|
-
/(?:^|[^H])MM(?!M)/.test(upper) ||
|
|
488
|
-
/(?:^|[^M])M(?!M)/.test(upper.replace(/MMMM|MMM/g, "")) ||
|
|
489
|
-
upper.includes("HH") ||
|
|
490
|
-
/(?:^|[^H])H(?!H)/.test(upper) ||
|
|
491
|
-
upper.includes("SS") ||
|
|
492
|
-
upper.includes("AM/PM") ||
|
|
493
|
-
upper.includes("A/P")) {
|
|
494
|
-
return formatDate(val, fmt, date1904);
|
|
495
|
-
}
|
|
496
|
-
// Percentage format
|
|
497
|
-
if (fmt.includes("%")) {
|
|
498
|
-
const stripped = fmt.replace(/[^0#.%,]/g, "");
|
|
499
|
-
const dotIdx = stripped.indexOf(".");
|
|
500
|
-
const afterDot = dotIdx >= 0
|
|
501
|
-
? stripped
|
|
502
|
-
.slice(dotIdx + 1)
|
|
503
|
-
.replace(/%/g, "")
|
|
504
|
-
.replace(/,/g, "")
|
|
505
|
-
: "";
|
|
506
|
-
const decimals = afterDot.length;
|
|
507
|
-
const pctVal = val * 100;
|
|
508
|
-
let result = pctVal.toFixed(decimals);
|
|
509
|
-
if (fmt.includes(",")) {
|
|
510
|
-
const parts = result.split(".");
|
|
511
|
-
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
512
|
-
result = parts.join(".");
|
|
513
|
-
}
|
|
514
|
-
return result + "%";
|
|
515
|
-
}
|
|
516
|
-
// Scientific notation: 0.00E+00
|
|
517
|
-
if (/[0#]\.?[0#]*E[+-][0#]+/i.test(fmt)) {
|
|
518
|
-
return formatScientific(val, fmt);
|
|
519
|
-
}
|
|
520
|
-
// Fraction format: # ?/? or # ??/??
|
|
521
|
-
if (/[?]+\/[?]+/.test(fmt)) {
|
|
522
|
-
return formatFraction(val, fmt);
|
|
523
|
-
}
|
|
524
|
-
// Number format with 0 and #
|
|
525
|
-
if (fmt.includes("0") || fmt.includes("#")) {
|
|
526
|
-
return formatNumber(val, fmt);
|
|
527
|
-
}
|
|
528
|
-
// Literal-only section (no numeric placeholders). Excel emits the
|
|
529
|
-
// format string verbatim, substituting any `@` with the stringified
|
|
530
|
-
// value. This matters for 3- and 4-section formats like
|
|
531
|
-
// `"pos;neg;zero"` — the selected section has no `#`/`0` but should
|
|
532
|
-
// still appear in the output.
|
|
533
|
-
return fmt.replace(/@/g, String(val));
|
|
534
|
-
}
|
|
535
|
-
/**
|
|
536
|
-
* Format number using #, 0, comma patterns.
|
|
537
|
-
*/
|
|
538
|
-
/**
|
|
539
|
-
* Format number using #, 0, comma patterns.
|
|
540
|
-
*
|
|
541
|
-
* The implementation tokenises the pattern left-to-right so that *any*
|
|
542
|
-
* literal character (parentheses, `+`/`-`, currency symbols, letters in
|
|
543
|
-
* quoted `"..."` segments, backslash-escaped characters, leading/trailing
|
|
544
|
-
* punctuation) is preserved in its original position. The previous
|
|
545
|
-
* implementation stripped everything that wasn't `0 # . ,` before analysis
|
|
546
|
-
* and tried to re-inject a handful of literals afterwards, which caused
|
|
547
|
-
* formats like `0;(0)` (→ "(5)" for -5) and `#,##0.00;-#,##0.00` to drop
|
|
548
|
-
* their negative-section literal characters entirely.
|
|
549
|
-
*/
|
|
550
|
-
function formatNumber(val, fmt) {
|
|
551
|
-
// Strip `[color]` and `[condition]` tags up front (Excel-style
|
|
552
|
-
// decorations that don't affect the numeric layout in our engine).
|
|
553
|
-
const cleanFmt = fmt.replace(/\[[^\]]*\]/g, "");
|
|
554
|
-
const tokens = [];
|
|
555
|
-
for (let i = 0; i < cleanFmt.length; i++) {
|
|
556
|
-
const ch = cleanFmt[i];
|
|
557
|
-
if (ch === '"') {
|
|
558
|
-
// Quoted literal run: consume until the closing quote.
|
|
559
|
-
let j = i + 1;
|
|
560
|
-
let buf = "";
|
|
561
|
-
while (j < cleanFmt.length && cleanFmt[j] !== '"') {
|
|
562
|
-
buf += cleanFmt[j];
|
|
563
|
-
j++;
|
|
564
|
-
}
|
|
565
|
-
tokens.push({ kind: "literal", text: buf });
|
|
566
|
-
i = j; // skip closing quote
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
if (ch === "\\" && i + 1 < cleanFmt.length) {
|
|
570
|
-
// Backslash escape: next character is a literal.
|
|
571
|
-
tokens.push({ kind: "literal", text: cleanFmt[i + 1] });
|
|
572
|
-
i++;
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
if (ch === "0" || ch === "#") {
|
|
576
|
-
tokens.push({ kind: "digit", char: ch });
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
if (ch === ".") {
|
|
580
|
-
tokens.push({ kind: "dot" });
|
|
581
|
-
continue;
|
|
582
|
-
}
|
|
583
|
-
if (ch === ",") {
|
|
584
|
-
tokens.push({ kind: "comma" });
|
|
585
|
-
continue;
|
|
586
|
-
}
|
|
587
|
-
// Any other character is a literal (space, parentheses, currency
|
|
588
|
-
// symbol, sign, hyphen, etc.).
|
|
589
|
-
tokens.push({ kind: "literal", text: ch });
|
|
590
|
-
}
|
|
591
|
-
// Count integer/fraction digit slots and decide whether to group.
|
|
592
|
-
// Trailing commas after the last integer digit token — before the
|
|
593
|
-
// decimal point or end of pattern — act as a "scale by 1/1000^k"
|
|
594
|
-
// multiplier (Excel's "thousands scaling"), not thousand separators.
|
|
595
|
-
// Example: `#,##0,` → 1,234,567 → "1,235" (divide by 1000).
|
|
596
|
-
// Example: `#,##0,,` → 1,234,567,890 → "1,234" (divide by 1,000,000).
|
|
597
|
-
let sawDot = false;
|
|
598
|
-
const intDigitSlots = [];
|
|
599
|
-
const fracDigitSlots = [];
|
|
600
|
-
let hasGrouping = false;
|
|
601
|
-
// Scan for trailing commas that sit strictly after the last integer
|
|
602
|
-
// digit slot but before any dot or fractional slot. Each such comma
|
|
603
|
-
// divides the value by 1000. Scan from end-of-pattern backward to
|
|
604
|
-
// locate them.
|
|
605
|
-
let scalingFactor = 1;
|
|
606
|
-
{
|
|
607
|
-
// Find the last integer digit-slot index.
|
|
608
|
-
let lastIntDigitIdx = -1;
|
|
609
|
-
let dotIdx = -1;
|
|
610
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
611
|
-
const t = tokens[i];
|
|
612
|
-
if (t.kind === "dot") {
|
|
613
|
-
dotIdx = i;
|
|
614
|
-
break;
|
|
615
|
-
}
|
|
616
|
-
if (t.kind === "digit") {
|
|
617
|
-
lastIntDigitIdx = i;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
// Trailing commas live after `lastIntDigitIdx` and strictly before
|
|
621
|
-
// `dotIdx` (if present). They must be adjacent — only run-of-commas
|
|
622
|
-
// directly abutting the last integer digit count as scaling.
|
|
623
|
-
if (lastIntDigitIdx !== -1) {
|
|
624
|
-
const stopBefore = dotIdx === -1 ? tokens.length : dotIdx;
|
|
625
|
-
let k = lastIntDigitIdx + 1;
|
|
626
|
-
while (k < stopBefore && tokens[k].kind === "comma") {
|
|
627
|
-
scalingFactor *= 1000;
|
|
628
|
-
// Remove this comma so later logic doesn't treat it as a
|
|
629
|
-
// thousand-separator or literal artefact. We mutate the token
|
|
630
|
-
// to a no-op literal with empty text.
|
|
631
|
-
tokens[k] = { kind: "literal", text: "" };
|
|
632
|
-
k++;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
// Apply the scaling before any other formatting work.
|
|
637
|
-
const scaledVal = val / scalingFactor;
|
|
638
|
-
for (const t of tokens) {
|
|
639
|
-
if (t.kind === "dot") {
|
|
640
|
-
sawDot = true;
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
if (t.kind === "digit") {
|
|
644
|
-
if (sawDot) {
|
|
645
|
-
fracDigitSlots.push(t.char);
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
intDigitSlots.push(t.char);
|
|
649
|
-
}
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
if (t.kind === "comma" && !sawDot) {
|
|
653
|
-
// A comma between digit slots means "thousands grouping".
|
|
654
|
-
hasGrouping = true;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
if (intDigitSlots.length === 0 && fracDigitSlots.length === 0) {
|
|
658
|
-
return fmt; // Nothing to format; return the (raw) pattern.
|
|
659
|
-
}
|
|
660
|
-
// Round to the requested fractional precision.
|
|
661
|
-
const rounded = roundHalfAwayFromZeroFmt(scaledVal, fracDigitSlots.length);
|
|
662
|
-
const sign = rounded < 0 ? "-" : "";
|
|
663
|
-
const absStr = Math.abs(rounded).toFixed(fracDigitSlots.length);
|
|
664
|
-
const [intPartRaw, fracPart = ""] = absStr.split(".");
|
|
665
|
-
let intPart = intPartRaw;
|
|
666
|
-
// Pad the integer part to satisfy mandatory `0` slots.
|
|
667
|
-
const mandatoryInt = intDigitSlots.filter(s => s === "0").length;
|
|
668
|
-
if (intPart.length < mandatoryInt) {
|
|
669
|
-
intPart = intPart.padStart(mandatoryInt, "0");
|
|
670
|
-
}
|
|
671
|
-
// Apply thousand grouping if the pattern requested it.
|
|
672
|
-
let groupedInt = intPart;
|
|
673
|
-
if (hasGrouping) {
|
|
674
|
-
groupedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
675
|
-
}
|
|
676
|
-
// Walk the tokens again and materialise output, interleaving the
|
|
677
|
-
// integer run, decimal point, and fractional run with literals.
|
|
678
|
-
let out = "";
|
|
679
|
-
let intCursor = 0; // index into groupedInt (right-to-left on integer digits)
|
|
680
|
-
// We emit integer digits in source order using a trick: scan tokens
|
|
681
|
-
// left-to-right; the first integer digit slot consumes `groupedInt[0]`
|
|
682
|
-
// if integer has more digits than slots all extras come out before the
|
|
683
|
-
// first digit slot (Excel overflow rule).
|
|
684
|
-
const totalIntSlots = intDigitSlots.length;
|
|
685
|
-
// Determine how much "overflow" we need to prepend. Overflow digits
|
|
686
|
-
// appear right before the first digit token we encounter.
|
|
687
|
-
const overflowDigits = Math.max(0, groupedInt.length - totalIntSlots);
|
|
688
|
-
let fracCursor = 0;
|
|
689
|
-
let emittedOverflow = false;
|
|
690
|
-
let firstDigitTokenIdx = -1;
|
|
691
|
-
for (let k = 0; k < tokens.length; k++) {
|
|
692
|
-
if (tokens[k].kind === "digit") {
|
|
693
|
-
// We only care about the first *integer* digit token — one that
|
|
694
|
-
// comes before the dot. If the pattern has no integer slots, we
|
|
695
|
-
// still need to emit any overflow somewhere; pick the first digit
|
|
696
|
-
// token regardless.
|
|
697
|
-
firstDigitTokenIdx = k;
|
|
698
|
-
break;
|
|
699
|
-
}
|
|
700
|
-
if (tokens[k].kind === "dot") {
|
|
701
|
-
break;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
for (let k = 0; k < tokens.length; k++) {
|
|
705
|
-
const t = tokens[k];
|
|
706
|
-
if (t.kind === "literal") {
|
|
707
|
-
out += t.text;
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
710
|
-
if (t.kind === "dot") {
|
|
711
|
-
if (fracDigitSlots.length > 0 || fracPart.length > 0) {
|
|
712
|
-
out += ".";
|
|
713
|
-
}
|
|
714
|
-
continue;
|
|
715
|
-
}
|
|
716
|
-
if (t.kind === "comma") {
|
|
717
|
-
// Grouping commas are consumed by the integer-build step above,
|
|
718
|
-
// so trailing commas here are literal (unusual but valid).
|
|
719
|
-
continue;
|
|
720
|
-
}
|
|
721
|
-
// digit token
|
|
722
|
-
if (sawDotAt(tokens, k)) {
|
|
723
|
-
// fractional slot
|
|
724
|
-
if (fracCursor < fracPart.length) {
|
|
725
|
-
out += fracPart[fracCursor];
|
|
726
|
-
}
|
|
727
|
-
else if (t.char === "0") {
|
|
728
|
-
out += "0";
|
|
729
|
-
}
|
|
730
|
-
fracCursor++;
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
// integer slot
|
|
734
|
-
if (k === firstDigitTokenIdx && !emittedOverflow) {
|
|
735
|
-
if (overflowDigits > 0) {
|
|
736
|
-
out += groupedInt.slice(0, overflowDigits);
|
|
737
|
-
}
|
|
738
|
-
emittedOverflow = true;
|
|
739
|
-
}
|
|
740
|
-
const slotIdx = intCursor;
|
|
741
|
-
// Right-align the integer into the digit slots: leading `#` slots
|
|
742
|
-
// emit nothing when the value is shorter than the pattern, and
|
|
743
|
-
// leading `0` slots pad with a zero. Previously the logic treated
|
|
744
|
-
// every slot as if the integer started at slot 0 (left-align),
|
|
745
|
-
// which made `TEXT(0, "#,##0")` produce "00" instead of "0".
|
|
746
|
-
//
|
|
747
|
-
// The number of "padding" slots that must appear before the first
|
|
748
|
-
// real digit equals `totalIntSlots − groupedInt.length` when that
|
|
749
|
-
// value is positive (overflow path zeroes it out since we already
|
|
750
|
-
// prepended the overflow digits before the first slot).
|
|
751
|
-
const paddingSlots = overflowDigits > 0 ? 0 : Math.max(0, totalIntSlots - groupedInt.length);
|
|
752
|
-
const srcIdx = overflowDigits + slotIdx - paddingSlots;
|
|
753
|
-
if (srcIdx >= 0 && srcIdx < groupedInt.length) {
|
|
754
|
-
out += groupedInt[srcIdx];
|
|
755
|
-
}
|
|
756
|
-
else if (t.char === "0") {
|
|
757
|
-
out += "0";
|
|
758
|
-
}
|
|
759
|
-
intCursor++;
|
|
760
|
-
}
|
|
761
|
-
return sign + out;
|
|
762
|
-
}
|
|
763
|
-
/**
|
|
764
|
-
* Did a dot token appear at any index < k in `tokens`? Used to classify
|
|
765
|
-
* each digit slot as integer vs fractional during the emit pass.
|
|
766
|
-
*/
|
|
767
|
-
function sawDotAt(tokens, k) {
|
|
768
|
-
for (let i = 0; i < k; i++) {
|
|
769
|
-
if (tokens[i].kind === "dot") {
|
|
770
|
-
return true;
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
return false;
|
|
774
|
-
}
|
|
775
|
-
/**
|
|
776
|
-
* Round `val` to `decimals` fractional digits using Excel's half-away-
|
|
777
|
-
* from-zero convention (the same rule `formatNumber`'s caller relies on
|
|
778
|
-
* to keep `-0.5` displaying as `"-1"` instead of `"0"`).
|
|
779
|
-
*/
|
|
780
|
-
function roundHalfAwayFromZeroFmt(val, decimals) {
|
|
781
|
-
if (!isFinite(val) || decimals < 0) {
|
|
782
|
-
return val;
|
|
783
|
-
}
|
|
784
|
-
const factor = Math.pow(10, decimals);
|
|
785
|
-
return ((val < 0 ? -1 : 1) * Math.round(Math.abs(val) * factor)) / factor;
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Format a number in scientific notation: 0.00E+00
|
|
789
|
-
*/
|
|
790
|
-
function formatScientific(val, fmt) {
|
|
791
|
-
const upper = fmt.toUpperCase();
|
|
792
|
-
const eIdx = upper.indexOf("E");
|
|
793
|
-
const beforeE = fmt.slice(0, eIdx);
|
|
794
|
-
const afterE = fmt.slice(eIdx + 1);
|
|
795
|
-
// Count decimal places in mantissa
|
|
796
|
-
const dotIdx = beforeE.indexOf(".");
|
|
797
|
-
const mantissaDecimals = dotIdx >= 0 ? beforeE.slice(dotIdx + 1).replace(/[^0#]/g, "").length : 0;
|
|
798
|
-
// Count exponent digits
|
|
799
|
-
const expSign = afterE[0] === "+" || afterE[0] === "-" ? afterE[0] : "+";
|
|
800
|
-
const expDigits = afterE.replace(/[^0#]/g, "").length;
|
|
801
|
-
const exp = val === 0 ? 0 : Math.floor(Math.log10(Math.abs(val)));
|
|
802
|
-
const mantissa = val / Math.pow(10, exp);
|
|
803
|
-
const expStr = (exp >= 0 && expSign === "+" ? "+" : exp < 0 ? "-" : "") +
|
|
804
|
-
String(Math.abs(exp)).padStart(expDigits, "0");
|
|
805
|
-
return mantissa.toFixed(mantissaDecimals) + "E" + expStr;
|
|
806
|
-
}
|
|
807
|
-
/**
|
|
808
|
-
* Format a number as a fraction: "# ?/?" or "# ??/??"
|
|
809
|
-
*/
|
|
810
|
-
function formatFraction(val, fmt) {
|
|
811
|
-
const whole = Math.floor(Math.abs(val));
|
|
812
|
-
const frac = Math.abs(val) - whole;
|
|
813
|
-
const sign = val < 0 ? "-" : "";
|
|
814
|
-
if (frac === 0) {
|
|
815
|
-
if (fmt.includes("#") || fmt.includes("0")) {
|
|
816
|
-
return sign + whole + " ";
|
|
817
|
-
}
|
|
818
|
-
return sign + whole + " 0/1";
|
|
819
|
-
}
|
|
820
|
-
// Determine max denominator from ? count
|
|
821
|
-
const slashIdx = fmt.indexOf("/");
|
|
822
|
-
const denomPattern = fmt.slice(slashIdx + 1).replace(/[^?0#]/g, "");
|
|
823
|
-
const maxDenom = Math.pow(10, denomPattern.length) - 1;
|
|
824
|
-
// Find best fraction approximation
|
|
825
|
-
let bestNum = 0;
|
|
826
|
-
let bestDen = 1;
|
|
827
|
-
let bestError = Math.abs(frac);
|
|
828
|
-
for (let d = 1; d <= maxDenom; d++) {
|
|
829
|
-
const n = Math.round(frac * d);
|
|
830
|
-
const error = Math.abs(frac - n / d);
|
|
831
|
-
if (error < bestError) {
|
|
832
|
-
bestError = error;
|
|
833
|
-
bestNum = n;
|
|
834
|
-
bestDen = d;
|
|
835
|
-
if (error === 0) {
|
|
836
|
-
break;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
const hasWholePart = fmt.indexOf("?") > 0 && fmt[0] !== "?" && fmt[0] !== "/";
|
|
841
|
-
if (hasWholePart) {
|
|
842
|
-
const numStr = String(bestNum).padStart(denomPattern.length, " ");
|
|
843
|
-
const denStr = String(bestDen).padStart(denomPattern.length, " ");
|
|
844
|
-
return sign + (whole > 0 ? whole + " " : "") + numStr + "/" + denStr;
|
|
845
|
-
}
|
|
846
|
-
return sign + (whole * bestDen + bestNum) + "/" + bestDen;
|
|
847
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* Day name arrays for date formatting.
|
|
850
|
-
*/
|
|
851
|
-
const DAY_NAMES_FULL = [
|
|
852
|
-
"Sunday",
|
|
853
|
-
"Monday",
|
|
854
|
-
"Tuesday",
|
|
855
|
-
"Wednesday",
|
|
856
|
-
"Thursday",
|
|
857
|
-
"Friday",
|
|
858
|
-
"Saturday"
|
|
859
|
-
];
|
|
860
|
-
const DAY_NAMES_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
861
|
-
const MONTH_NAMES_FULL = [
|
|
862
|
-
"January",
|
|
863
|
-
"February",
|
|
864
|
-
"March",
|
|
865
|
-
"April",
|
|
866
|
-
"May",
|
|
867
|
-
"June",
|
|
868
|
-
"July",
|
|
869
|
-
"August",
|
|
870
|
-
"September",
|
|
871
|
-
"October",
|
|
872
|
-
"November",
|
|
873
|
-
"December"
|
|
874
|
-
];
|
|
875
|
-
const MONTH_NAMES_SHORT = [
|
|
876
|
-
"Jan",
|
|
877
|
-
"Feb",
|
|
878
|
-
"Mar",
|
|
879
|
-
"Apr",
|
|
880
|
-
"May",
|
|
881
|
-
"Jun",
|
|
882
|
-
"Jul",
|
|
883
|
-
"Aug",
|
|
884
|
-
"Sep",
|
|
885
|
-
"Oct",
|
|
886
|
-
"Nov",
|
|
887
|
-
"Dec"
|
|
888
|
-
];
|
|
889
|
-
/**
|
|
890
|
-
* Format a numeric Excel serial date with a date/time format code.
|
|
891
|
-
*
|
|
892
|
-
* In the native value system, dates are always numbers (serial values).
|
|
893
|
-
* We always use excelToDate() to convert and then read UTC fields so the
|
|
894
|
-
* output does not drift by a day in timezones west of UTC.
|
|
895
|
-
*/
|
|
896
|
-
/**
|
|
897
|
-
* Format a serial-date value using an Excel-style pattern.
|
|
898
|
-
*
|
|
899
|
-
* Unlike a naive multi-pass regex approach, this implementation tokenises
|
|
900
|
-
* the pattern in a single left-to-right sweep and disambiguates the
|
|
901
|
-
* overloaded `m` / `mm` token by looking at its neighbours:
|
|
902
|
-
*
|
|
903
|
-
* - `mm` after `h:` or before `:ss` is rendered as **minutes**
|
|
904
|
-
* - otherwise `mm` is rendered as **month**
|
|
905
|
-
*
|
|
906
|
-
* Without this, a mixed format like `"yyyy-mm-dd hh:mm:ss"` would render
|
|
907
|
-
* the time's minutes as months (both tokens fire the same regex), giving
|
|
908
|
-
* garbage like `"2023-06-15 14:06:45"` for a timestamp at 14:30:45.
|
|
909
|
-
*/
|
|
910
|
-
function formatDate(val, fmt, date1904) {
|
|
911
|
-
const d = excelToDate(val, date1904);
|
|
912
|
-
const year = d.getUTCFullYear();
|
|
913
|
-
const month0 = d.getUTCMonth();
|
|
914
|
-
const dayN = d.getUTCDate();
|
|
915
|
-
const dow = d.getUTCDay();
|
|
916
|
-
const fracDay = val % 1;
|
|
917
|
-
const totalSeconds = Math.round(Math.abs(fracDay) * 86400);
|
|
918
|
-
const hours = Math.floor(totalSeconds / 3600) % 24;
|
|
919
|
-
const minutes = Math.floor(totalSeconds / 60) % 60;
|
|
920
|
-
const seconds = totalSeconds % 60;
|
|
921
|
-
const hasAmPmToken = /AM\/PM/i.test(fmt) || /A\/P/i.test(fmt);
|
|
922
|
-
const h12 = hours % 12 === 0 ? 12 : hours % 12;
|
|
923
|
-
const ampm = hours < 12 ? "AM" : "PM";
|
|
924
|
-
const ap = hours < 12 ? "A" : "P";
|
|
925
|
-
const runs = [];
|
|
926
|
-
let i = 0;
|
|
927
|
-
while (i < fmt.length) {
|
|
928
|
-
const ch = fmt[i];
|
|
929
|
-
const lo = ch.toLowerCase();
|
|
930
|
-
// Handle two-char literals that must not tokenise as letters
|
|
931
|
-
if (fmt.slice(i, i + 5).toUpperCase() === "AM/PM") {
|
|
932
|
-
runs.push({ kind: "literal", text: ampm });
|
|
933
|
-
i += 5;
|
|
934
|
-
continue;
|
|
935
|
-
}
|
|
936
|
-
if (fmt.slice(i, i + 3).toUpperCase() === "A/P") {
|
|
937
|
-
runs.push({ kind: "literal", text: ap });
|
|
938
|
-
i += 3;
|
|
939
|
-
continue;
|
|
940
|
-
}
|
|
941
|
-
if (lo === "y" || lo === "m" || lo === "d" || lo === "h" || lo === "s") {
|
|
942
|
-
let j = i + 1;
|
|
943
|
-
while (j < fmt.length && fmt[j].toLowerCase() === lo) {
|
|
944
|
-
j++;
|
|
945
|
-
}
|
|
946
|
-
runs.push({ kind: "letters", lower: lo, count: j - i });
|
|
947
|
-
i = j;
|
|
948
|
-
continue;
|
|
949
|
-
}
|
|
950
|
-
// Literal: consume until the next token-letter (to keep literal runs
|
|
951
|
-
// contiguous and reduce array churn).
|
|
952
|
-
let j = i + 1;
|
|
953
|
-
while (j < fmt.length) {
|
|
954
|
-
const nx = fmt[j].toLowerCase();
|
|
955
|
-
if (nx === "y" || nx === "m" || nx === "d" || nx === "h" || nx === "s") {
|
|
956
|
-
break;
|
|
957
|
-
}
|
|
958
|
-
if (fmt.slice(j, j + 5).toUpperCase() === "AM/PM") {
|
|
959
|
-
break;
|
|
960
|
-
}
|
|
961
|
-
if (fmt.slice(j, j + 3).toUpperCase() === "A/P") {
|
|
962
|
-
break;
|
|
963
|
-
}
|
|
964
|
-
j++;
|
|
965
|
-
}
|
|
966
|
-
runs.push({ kind: "literal", text: fmt.slice(i, j) });
|
|
967
|
-
i = j;
|
|
968
|
-
}
|
|
969
|
-
// Phase 2: render each run, using neighbour context to decide whether
|
|
970
|
-
// an `m` / `mm` run means minute (when adjacent to an `h` or `s` run)
|
|
971
|
-
// or month (otherwise).
|
|
972
|
-
let out = "";
|
|
973
|
-
for (let r = 0; r < runs.length; r++) {
|
|
974
|
-
const run = runs[r];
|
|
975
|
-
if (run.kind === "literal") {
|
|
976
|
-
out += run.text;
|
|
977
|
-
continue;
|
|
978
|
-
}
|
|
979
|
-
switch (run.lower) {
|
|
980
|
-
case "y":
|
|
981
|
-
out += run.count >= 4 ? String(year).padStart(4, "0") : String(year).slice(-2);
|
|
982
|
-
break;
|
|
983
|
-
case "d":
|
|
984
|
-
if (run.count >= 4) {
|
|
985
|
-
out += DAY_NAMES_FULL[dow];
|
|
986
|
-
}
|
|
987
|
-
else if (run.count === 3) {
|
|
988
|
-
out += DAY_NAMES_SHORT[dow];
|
|
989
|
-
}
|
|
990
|
-
else if (run.count === 2) {
|
|
991
|
-
out += String(dayN).padStart(2, "0");
|
|
992
|
-
}
|
|
993
|
-
else {
|
|
994
|
-
out += String(dayN);
|
|
995
|
-
}
|
|
996
|
-
break;
|
|
997
|
-
case "h":
|
|
998
|
-
out +=
|
|
999
|
-
run.count >= 2
|
|
1000
|
-
? String(hasAmPmToken ? h12 : hours).padStart(2, "0")
|
|
1001
|
-
: String(hasAmPmToken ? h12 : hours);
|
|
1002
|
-
break;
|
|
1003
|
-
case "s":
|
|
1004
|
-
out += run.count >= 2 ? String(seconds).padStart(2, "0") : String(seconds);
|
|
1005
|
-
break;
|
|
1006
|
-
case "m": {
|
|
1007
|
-
// Month-names render independent of context.
|
|
1008
|
-
if (run.count === 4) {
|
|
1009
|
-
out += MONTH_NAMES_FULL[month0];
|
|
1010
|
-
break;
|
|
1011
|
-
}
|
|
1012
|
-
if (run.count === 3) {
|
|
1013
|
-
out += MONTH_NAMES_SHORT[month0];
|
|
1014
|
-
break;
|
|
1015
|
-
}
|
|
1016
|
-
// Otherwise `m` / `mm` is month-or-minute. Follow Excel's rule:
|
|
1017
|
-
// treat as minute iff an adjacent letter-run is `h` or `s`.
|
|
1018
|
-
const isMinute = isAdjacentTimeContext(runs, r);
|
|
1019
|
-
const value = isMinute ? minutes : month0 + 1;
|
|
1020
|
-
out += run.count >= 2 ? String(value).padStart(2, "0") : String(value);
|
|
1021
|
-
break;
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
return out;
|
|
1026
|
-
}
|
|
1027
|
-
/**
|
|
1028
|
-
* Determine whether the `m` / `mm` run at `runs[idx]` should render as
|
|
1029
|
-
* minutes (true) or months (false). Excel's rule, simplified: minutes
|
|
1030
|
-
* when the immediately preceding or following *letter* run is `h` or
|
|
1031
|
-
* `s`, ignoring intervening literal separators like `:`.
|
|
1032
|
-
*/
|
|
1033
|
-
function isAdjacentTimeContext(runs, idx) {
|
|
1034
|
-
for (let j = idx - 1; j >= 0; j--) {
|
|
1035
|
-
const p = runs[j];
|
|
1036
|
-
if (p.kind === "letters") {
|
|
1037
|
-
if (p.lower === "h" || p.lower === "s") {
|
|
1038
|
-
return true;
|
|
1039
|
-
}
|
|
1040
|
-
break;
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
for (let j = idx + 1; j < runs.length; j++) {
|
|
1044
|
-
const p = runs[j];
|
|
1045
|
-
if (p.kind === "letters") {
|
|
1046
|
-
if (p.lower === "h" || p.lower === "s") {
|
|
1047
|
-
return true;
|
|
1048
|
-
}
|
|
1049
|
-
break;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
return false;
|
|
444
|
+
return render(valRV.value);
|
|
1053
445
|
}
|
|
1054
446
|
// ============================================================================
|
|
1055
447
|
// VALUE / EXACT
|