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
@@ -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 { excelToDate } from "../../../utils/utils.base.js";
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
- // "@" format = return text as-is
417
- if (fmt === "@") {
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
- // Conditional sections: positive;negative;zero;text (up to 4 parts).
421
- // Text input — when the value is a String/Boolean that doesn't parse
422
- // as a number — is routed to the 4th section if present. Without this
423
- // early dispatch, the later `toNumberRV(rawVal)` would #VALUE! and
424
- // `TEXT("hi", "0;-0;0;@")` could never reach the 4th section.
425
- const sections = splitFormatSections(fmt);
426
- const isTextInput = rawVal.kind === RVKind.String;
427
- if (isTextInput && sections.length >= 4) {
428
- // The 4th section formats the text value — `@` is a placeholder
429
- // that re-emits the source string (like Excel's `@` metacharacter).
430
- return rvString(sections[3].replace(/@/g, toStringRV(rawVal)));
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
- const val = valRV.value;
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