reamkit 1.32.0 → 1.32.1

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.
@@ -95,8 +95,17 @@ var FORMAT_COLORS = new Map([
95
95
  ["white", "FFFFFF"],
96
96
  ["yellow", "FFFF00"]
97
97
  ]);
98
+ var BRACKET_CODE = /\[([^\]]*)\]/g;
99
+ function bracketCodes(format) {
100
+ return format.slice(0, format.lastIndexOf("]") + 1).matchAll(BRACKET_CODE);
101
+ }
102
+ /** The format with each `[…]` code replaced by what `fn` makes of its inside. */
103
+ function replaceBracketCodes(format, fn) {
104
+ const end = format.lastIndexOf("]") + 1;
105
+ return format.slice(0, end).replace(BRACKET_CODE, (_, body) => fn(body)) + format.slice(end);
106
+ }
98
107
  function colorOfSection(section) {
99
- for (const m of section.matchAll(/\[([^\]]*)\]/g)) {
108
+ for (const m of bracketCodes(section)) {
100
109
  const body = m[1].trim().toLowerCase();
101
110
  const named = FORMAT_COLORS.get(body);
102
111
  if (named !== void 0) return named;
@@ -251,7 +260,7 @@ function hasElapsedToken(code) {
251
260
  return /\[(?:h+|m+|s+)\]/i.test(code.replace(/"[^"]*"/g, ""));
252
261
  }
253
262
  function isDateFormat(code) {
254
- const cleaned = code.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, "");
263
+ const cleaned = replaceBracketCodes(code.replace(/"[^"]*"/g, ""), () => "");
255
264
  return /[dyhs]|m+/i.test(cleaned) && /[dyhs]/i.test(cleaned);
256
265
  }
257
266
  function tokenizeDateFormat(format) {
@@ -442,9 +451,12 @@ function formatExcelDate(rawValue, format, date1904) {
442
451
  case "elapsed-s":
443
452
  out += elapsed(totalSeconds, t.text.length);
444
453
  break;
445
- case "subsec":
446
- out += (totalSeconds - Math.floor(totalSeconds)).toFixed(subsecDigits).substring(1);
454
+ case "subsec": {
455
+ const places = Math.min(subsecDigits, 100);
456
+ const fraction = (totalSeconds - Math.floor(totalSeconds)).toFixed(places);
457
+ out += fraction.substring(1) + "0".repeat(subsecDigits - places);
447
458
  break;
459
+ }
448
460
  case "m":
449
461
  out += t.text.length >= 2 ? pad2(minute) : String(minute);
450
462
  break;
@@ -469,23 +481,28 @@ function excelExponent(rendered) {
469
481
  return `${m[1]}E${m[2] === "-" ? "-" : "+"}${m[3].padStart(2, "0")}`;
470
482
  }
471
483
  /**
472
- * A General number rounded to the decimals that fit `maxChars` characters.
473
- *
474
- * General is not a fixed format: a spreadsheet shows as many decimal places as
475
- * the column has room for and ROUNDS to that, so 4.3900875881221957 in a
476
- * column eight characters wide reads 4.390088. We rendered every stored digit
477
- * and let the cell clip it, which turns the same value into 4.390087 — off by
478
- * one in the last place shown, with nothing to say a digit was cut.
484
+ * The most characters General writes in a cell, its sign apart: however wide
485
+ * the column, 123456789012345 is 1.23457E+14 and 0.123456789012345 is
486
+ * 0.123456789 — Excel's own PDF (2026-10-02).
487
+ */
488
+ var GENERAL_CELL_CHARS = 11;
489
+ /**
490
+ * A General number in its column: the most precise of the ways General may
491
+ * write it ({@link generalRenderings}) that `fits`.
479
492
  *
480
- * The integer part is never dropped: a number too wide even without decimals
481
- * keeps them all and the cell says so its own way (see hashOnOverflow).
493
+ * General is not a fixed format: a spreadsheet shows as many places as the
494
+ * column has room for and ROUNDS to that, so 4.3900875881221957 in a column
495
+ * eight characters wide reads 4.390088. We rendered every stored digit and let
496
+ * the cell clip it, which turns the same value into 4.390087 — off by one in
497
+ * the last place shown, with nothing to say a digit was cut. A number too wide
498
+ * for every way of writing it comes back in its finest, and the cell fills
499
+ * with `#` (CellProperties.hashOnOverflow); one under 1 always fits as `0` or
500
+ * `-0`, which is what Excel prints in a column too narrow for anything else.
482
501
  *
483
- * `fits` decides, and it must measure in the face this will be DRAWN in rather
484
- * than count characters against the column's width in the document's unit.
485
- * Counted that way we produced nine digits for a column that holds nine of
486
- * Excel's and eight of ours, and the layout clipped the ninth — the very
487
- * truncation this exists to prevent. Fewer digits than the reference shows is a
488
- * coarser number; a clipped one is a different number.
502
+ * `fits` decides whether a rendering has room. The print model asks it in the
503
+ * document's own terms — the column's width less the padding it carries, in
504
+ * the font's widest digits — because that is the room Excel gives up places
505
+ * against, whatever face the page is drawn in.
489
506
  *
490
507
  * @param rawValue The cell's stored value.
491
508
  * @param fits Whether a rendering will fit the cell.
@@ -493,36 +510,100 @@ function excelExponent(rendered) {
493
510
  function generalToWidth(rawValue, fits) {
494
511
  const n = Number(rawValue);
495
512
  if (!Number.isFinite(n)) return defaultNumberRender(rawValue);
496
- const full = defaultNumberRender(rawValue);
497
- if (fits(full)) return full;
498
- const dot = full.indexOf(".");
499
- if (dot < 0) return scientificToWidth(n, fits) ?? full;
500
- let shortened = full;
501
- for (let decimals = full.length - dot - 2; decimals >= 0; decimals--) {
502
- const shifted = Number(`${Number(n.toPrecision(15))}e${decimals}`);
503
- const rounded = shifted < 0 ? -Math.round(-shifted) : Math.round(shifted);
504
- const back = Number(`${rounded}e${-decimals}`);
505
- if (!Number.isFinite(back)) break;
506
- shortened = defaultNumberRender(String(back));
507
- if (fits(shortened)) return shortened;
513
+ let widest;
514
+ for (const text of generalRenderings(n, GENERAL_CELL_CHARS)) {
515
+ if (fits(text)) return text;
516
+ widest ??= text;
508
517
  }
509
- return scientificToWidth(n, fits) ?? shortened;
518
+ return widest ?? defaultNumberRender(rawValue);
510
519
  }
511
520
  /**
512
- * `value` in scientific notation with as many mantissa decimals as `fits`
513
- * allows, spelled Excel's way: an upper-case `E`, a signed exponent of at least
514
- * two digits. Undefined when even a bare `1E+09` will not fit — the cell then
515
- * says so with hashes rather than shrinking further.
521
+ * Every way General may write `value` in `chars` characters, the most precise
522
+ * first. One rule, measured in Excel's own PDF on chart axes, which give it
523
+ * nine characters with the sign among them, and in cells, eleven with the
524
+ * sign apart (2026-10-01/02):
525
+ *
526
+ * - the number as it is, its first 15 significant digits, where that fits:
527
+ * 0.000012345, 0.0000003, 12345678901;
528
+ * - else rounded to fewer places or written in scientific notation, whichever
529
+ * keeps the finer last digit — 0.123456789, not 1.23457E-01; 1.23457E-10,
530
+ * not 0; 4E-04, not 0, in a column five characters wide — and of two as
531
+ * fine, the plain one down to 0.0001 and the scientific one below it: 0.0001
532
+ * for 0.000123456 but 1E-05 for 1.00049E-05;
533
+ * - a whole part longer than the room only in scientific notation, and never
534
+ * with the zeros a mantissa or a fraction ends in: 1E+10, 2.5E+12.
535
+ *
536
+ * The digits are the 15 of {@link decimalOf}, rounded half away from zero, so
537
+ * 1.2345E-05 is 1.235E-05, where `toExponential` rounded the binary double
538
+ * down to 1.234E-05.
539
+ *
540
+ * @param value The number.
541
+ * @param chars How many characters General has.
542
+ * @param signCounts Whether a minus sign takes one of them.
543
+ * @returns The renderings, sign included, finest first.
516
544
  */
517
- function scientificToWidth(value, fits) {
518
- for (let decimals = MAX_SCIENTIFIC_DECIMALS; decimals >= 0; decimals--) {
519
- const [mantissa, exp] = value.toExponential(decimals).split("e");
520
- const out = `${mantissa}E${exp.startsWith("-") ? "-" : "+"}${exp.replace(/^[-+]/, "").padStart(2, "0")}`;
521
- if (fits(out)) return out;
545
+ function* generalRenderings(value, chars, signCounts = false) {
546
+ if (value === 0 || !Number.isFinite(value)) {
547
+ yield value === 0 ? "0" : String(value);
548
+ return;
549
+ }
550
+ const sign = value < 0 ? "-" : "";
551
+ const room = chars - (signCounts ? sign.length : 0);
552
+ const { digits, exponent } = decimalOf(value);
553
+ let shown = digits.length;
554
+ while (shown > 1 && digits[shown - 1] === "0") shown--;
555
+ const exactPlaces = Math.max(0, shown - 1 - exponent);
556
+ const exact = placeDigits(digits, exponent, exactPlaces);
557
+ if (exact.length <= room) yield sign + exact;
558
+ const forms = [];
559
+ if (exponent < room) {
560
+ const most = Math.min(exactPlaces, room - Math.max(exponent, 0) - 2);
561
+ for (let places = Math.max(0, most); places >= 0; places--) {
562
+ const text = withoutTrailingZeros(placeDigits(digits, exponent, places));
563
+ if (text.length <= room) forms.push({
564
+ text,
565
+ place: -decimalsIn(text),
566
+ plain: true
567
+ });
568
+ }
569
+ }
570
+ const tail = (e) => `E${e < 0 ? "-" : "+"}${String(Math.abs(e)).padStart(2, "0")}`;
571
+ for (let places = Math.max(0, room - tail(exponent).length - 2); places >= 0; places--) {
572
+ let e = exponent;
573
+ let mantissa = placeDigits(digits, 0, places);
574
+ if (mantissa.length > 1 && mantissa[1] !== ".") {
575
+ e += 1;
576
+ mantissa = placeDigits("1", 0, places);
577
+ }
578
+ const stripped = withoutTrailingZeros(mantissa);
579
+ const text = stripped + tail(e);
580
+ if (text.length <= room) forms.push({
581
+ text,
582
+ place: e - decimalsIn(stripped),
583
+ plain: false
584
+ });
585
+ }
586
+ const plainFirst = exponent >= -4;
587
+ forms.sort((a, b) => a.place - b.place || (a.plain === b.plain ? 0 : a.plain === plainFirst ? -1 : 1));
588
+ const seen = new Set(exact.length <= room ? [exact] : []);
589
+ for (const form of forms) {
590
+ if (seen.has(form.text)) continue;
591
+ seen.add(form.text);
592
+ yield sign + form.text;
522
593
  }
523
594
  }
524
- /** Excel never shows more mantissa decimals than this under General. */
525
- var MAX_SCIENTIFIC_DECIMALS = 10;
595
+ /** A decimal without the zeros that end its fraction, nor a point left bare. */
596
+ function withoutTrailingZeros(fixed) {
597
+ if (!fixed.includes(".")) return fixed;
598
+ let end = fixed.length;
599
+ while (fixed[end - 1] === "0") end--;
600
+ return fixed.slice(0, fixed[end - 1] === "." ? end - 1 : end);
601
+ }
602
+ /** How many places a plain decimal shows after its point. */
603
+ var decimalsIn = (fixed) => {
604
+ const dot = fixed.indexOf(".");
605
+ return dot < 0 ? 0 : fixed.length - dot - 1;
606
+ };
526
607
  function applyFormatString(rawValue, format) {
527
608
  const n = Number(rawValue);
528
609
  const sections = splitSections(format);
@@ -571,6 +652,43 @@ function countDigitPlaceholders(s) {
571
652
  return n;
572
653
  }
573
654
  /**
655
+ * The decimal a spreadsheet takes a double for: its first 15 significant
656
+ * digits, and the power of ten the first of them stands at.
657
+ *
658
+ * Excel shows a number as those 15 digits and zeros after them, however many
659
+ * places its format asks for: 0.1 under thirty places is 0.1 and twenty-nine
660
+ * zeros, 1/3 is fifteen threes and zeros, 1E+21 under `0.00` is a 1, twenty-one
661
+ * zeros and `.00` — its own PDF of each (2026-10-01).
662
+ */
663
+ function decimalOf(value) {
664
+ const [mantissa, exponent] = Math.abs(value).toExponential(14).split("e");
665
+ return {
666
+ digits: mantissa.replace(".", ""),
667
+ exponent: Number(exponent)
668
+ };
669
+ }
670
+ /**
671
+ * Those digits at `decimals` places — the first standing at `exponent` —
672
+ * rounded half away from zero, as plain digits with a point and no sign.
673
+ */
674
+ function placeDigits(digits, exponent, decimals) {
675
+ const kept = exponent + 1 + decimals;
676
+ let units;
677
+ if (kept >= digits.length) units = digits + "0".repeat(kept - digits.length);
678
+ else if (kept < 0) units = "";
679
+ else units = digits[kept] >= "5" ? incremented(digits.slice(0, kept)) : digits.slice(0, kept);
680
+ const padded = units.replace(/^0+/, "").padStart(decimals + 1, "0");
681
+ const whole = padded.slice(0, padded.length - decimals);
682
+ return decimals > 0 ? `${whole}.${padded.slice(whole.length)}` : whole;
683
+ }
684
+ /** A string of decimal digits plus one. */
685
+ function incremented(digits) {
686
+ let at = digits.length - 1;
687
+ while (at >= 0 && digits[at] === "9") at--;
688
+ if (at < 0) return `1${"0".repeat(digits.length)}`;
689
+ return `${digits.slice(0, at)}${String(Number(digits[at]) + 1)}${"0".repeat(digits.length - at - 1)}`;
690
+ }
691
+ /**
574
692
  * `value` rounded to `decimals` places the way a spreadsheet rounds: on the
575
693
  * DECIMAL number, half away from zero.
576
694
  *
@@ -580,21 +698,19 @@ function countDigitPlaceholders(s) {
580
698
  * every other reader show 1.0%. AverageTaxRates.xlsx had eleven of its
581
699
  * percentages a tenth low for exactly this reason.
582
700
  *
583
- * Rounding to 15 significant digits first collapses the binary noise back to
584
- * the decimal the author typed; shifting through a string exponent then keeps
585
- * the scaling exact, so the final round sees 9.5 and not 9.499999999999998.
701
+ * The digits are the 15 of {@link decimalOf} and zeros after them, placed by
702
+ * hand: past seventeen significant digits `toFixed` printed the binary
703
+ * expansion (0.1 under twenty places as 0.10000000000000000555), from 1E+21 on
704
+ * it wrote the exponent (`1e+21.00`), and past a hundred places it threw.
586
705
  */
587
706
  function toFixedDecimal(value, decimals) {
588
- if (!Number.isFinite(value)) return value.toFixed(decimals);
589
- const decimal = Number(value.toPrecision(15));
590
- const shifted = Number(`${decimal}e${decimals}`);
591
- if (!Number.isFinite(shifted)) return value.toFixed(decimals);
592
- const rounded = shifted < 0 ? -Math.round(-shifted) : Math.round(shifted);
593
- const back = Number(`${rounded}e${-decimals}`);
594
- return Number.isFinite(back) ? back.toFixed(decimals) : value.toFixed(decimals);
707
+ if (!Number.isFinite(value)) return String(value);
708
+ const { digits, exponent } = decimalOf(value);
709
+ const placed = placeDigits(digits, exponent, decimals);
710
+ return value < 0 && /[1-9]/.test(placed) ? `-${placed}` : placed;
595
711
  }
596
712
  function formatScientific(value, cleaned, negativeSection) {
597
- const m = /^(.*?)([eE])([+-])(.*)$/.exec(cleaned);
713
+ const m = hasLineEnd(cleaned) ? null : /^(.*?)([eE])([+-])(.*)$/.exec(cleaned);
598
714
  if (!m) return cleaned;
599
715
  const mantissaFmt = m[1];
600
716
  const eChar = m[2];
@@ -604,17 +720,16 @@ function formatScientific(value, cleaned, negativeSection) {
604
720
  const intDigits = Math.max(1, countDigitPlaceholders(dot >= 0 ? mantissaFmt.slice(0, dot) : mantissaFmt));
605
721
  const decimals = dot >= 0 ? countDigitPlaceholders(mantissaFmt.slice(dot + 1)) : 0;
606
722
  let exp = 0;
607
- let mant = Math.abs(value);
608
- if (mant !== 0) {
609
- exp = Math.floor(Math.log10(mant));
610
- exp -= (exp % intDigits + intDigits) % intDigits;
611
- mant = mant / Math.pow(10, exp);
612
- if (Number(mant.toFixed(decimals)) >= Math.pow(10, intDigits)) {
723
+ let mantStr = placeDigits("0", 0, decimals);
724
+ if (value !== 0) {
725
+ const { digits, exponent } = decimalOf(value);
726
+ exp = exponent - (exponent % intDigits + intDigits) % intDigits;
727
+ mantStr = placeDigits(digits, exponent - exp, decimals);
728
+ if (mantStr.split(".")[0].length > intDigits) {
613
729
  exp += intDigits;
614
- mant = mant / Math.pow(10, intDigits);
730
+ mantStr = placeDigits(digits, exponent - exp, decimals);
615
731
  }
616
732
  }
617
- const mantStr = mant.toFixed(decimals);
618
733
  const expDigits = countDigitPlaceholders(expFmt) || 2;
619
734
  const expStr = String(Math.abs(exp)).padStart(expDigits, "0");
620
735
  return `${value < 0 && !negativeSection ? "-" : ""}${mantStr}${eChar}${exp < 0 ? "-" : expSignFmt === "+" ? "+" : ""}${expStr}`;
@@ -629,11 +744,77 @@ function formatScientific(value, cleaned, negativeSection) {
629
744
  * conditions) is decoration here and goes.
630
745
  */
631
746
  function resolveBracketCodes(format) {
632
- return format.replace(/\[([^\]]*)\]/g, (_, body) => {
747
+ return replaceBracketCodes(format, (body) => {
633
748
  const symbol = /^\$([^-]*)(?:-.*)?$/.exec(body)?.[1]?.replace(/"/g, "") ?? "";
634
749
  return symbol.length > 0 ? `"${symbol}"` : "";
635
750
  });
636
751
  }
752
+ var isPlaceholder = (c) => c === "0" || c === "#" || c === "?";
753
+ var isAsciiDigit = (c) => c !== void 0 && c >= "0" && c <= "9";
754
+ var isSpace = (c) => c !== void 0 && /\s/.test(c);
755
+ var isLineEnd = (c) => c === 10 || c === 13 || c === 8232 || c === 8233;
756
+ function hasLineEnd(s) {
757
+ for (let i = 0; i < s.length; i++) if (isLineEnd(s.charCodeAt(i))) return true;
758
+ return false;
759
+ }
760
+ /**
761
+ * A fraction section cut into head, numerator, denominator and tail — what
762
+ * `/^(.*?)([0#?]+)\s*\/\s*([0#?]+|\d+)(.*)$/` makes of it, in one pass. The
763
+ * expression, lazy at the front, took every position of a run of placeholders
764
+ * in turn as the start of the numerator and read the rest of the run from
765
+ * each: quadratic in a long run no slash follows. A start inside a run sees
766
+ * the same rest as the run's first, so only the first is tried. The head
767
+ * cannot cross a line end, nor the tail hold one.
768
+ */
769
+ function fractionParts(s) {
770
+ let firstEnd = s.length;
771
+ let lastEnd = -1;
772
+ for (let i = 0; i < s.length; i++) {
773
+ if (!isLineEnd(s.charCodeAt(i))) continue;
774
+ firstEnd = Math.min(firstEnd, i);
775
+ lastEnd = i;
776
+ }
777
+ for (let start = 0; start < firstEnd;) {
778
+ if (!isPlaceholder(s[start])) {
779
+ start++;
780
+ continue;
781
+ }
782
+ let numEnd = start;
783
+ while (isPlaceholder(s[numEnd])) numEnd++;
784
+ let slash = numEnd;
785
+ while (isSpace(s[slash])) slash++;
786
+ if (s[slash] === "/") {
787
+ let den = slash + 1;
788
+ while (isSpace(s[den])) den++;
789
+ let denEnd = den;
790
+ const inDen = isPlaceholder(s[den]) ? isPlaceholder : isAsciiDigit;
791
+ while (inDen(s[denEnd])) denEnd++;
792
+ if (denEnd > den && lastEnd < denEnd) return {
793
+ head: s.slice(0, start),
794
+ num: s.slice(start, numEnd),
795
+ den: s.slice(den, denEnd),
796
+ tail: s.slice(denEnd)
797
+ };
798
+ }
799
+ start = numEnd;
800
+ }
801
+ }
802
+ /**
803
+ * The last run of placeholders in the head and what follows it — what
804
+ * `/([0#?]+)([^0#?]*)$/` matches, read back from the end: that expression
805
+ * retried every placeholder of an earlier run, and is quadratic in a long one.
806
+ */
807
+ function lastPlaceholderRun(head) {
808
+ let end = head.length;
809
+ while (end > 0 && !isPlaceholder(head[end - 1])) end--;
810
+ if (end === 0) return void 0;
811
+ let at = end - 1;
812
+ while (at > 0 && isPlaceholder(head[at - 1])) at--;
813
+ return {
814
+ at,
815
+ after: head.slice(end)
816
+ };
817
+ }
637
818
  /**
638
819
  * §18.8.31 fraction formats — `# ?/?`, `# ??/??`, `?/?`, `# ?/16`.
639
820
  *
@@ -644,15 +825,13 @@ function resolveBracketCodes(format) {
644
825
  * section is not a fraction, leaving the ordinary grammar to run.
645
826
  */
646
827
  function formatFraction(value, cleaned, negativeSection) {
647
- const m = /^(.*?)([0#?]+)\s*\/\s*([0#?]+|\d+)(.*)$/.exec(cleaned);
828
+ const m = fractionParts(cleaned);
648
829
  if (!m) return void 0;
649
- const head = m[1];
650
- const denFmt = m[3];
651
- const tail = m[4];
652
- const intMatch = /([0#?]+)([^0#?]*)$/.exec(head);
653
- const hasInteger = intMatch !== null;
654
- const literalPrefix = unquoteLiteral(head.substring(0, intMatch ? intMatch.index : head.length));
655
- const separator = intMatch ? unquoteLiteral(intMatch[2]) : " ";
830
+ const { head, den: denFmt, tail } = m;
831
+ const intMatch = lastPlaceholderRun(head);
832
+ const hasInteger = intMatch !== void 0;
833
+ const literalPrefix = unquoteLiteral(head.substring(0, intMatch ? intMatch.at : head.length));
834
+ const separator = intMatch ? unquoteLiteral(intMatch.after) : " ";
656
835
  const magnitude = Math.abs(value);
657
836
  const whole = hasInteger ? Math.floor(magnitude) : 0;
658
837
  const rest = magnitude - whole;
@@ -724,7 +903,7 @@ function applyNumericSection(value, format, negativeSection) {
724
903
  for (let i = 0; i < intPlaceholders.length - significant.length; i++) if (intPlaceholders[i] === "0") zeros += "0";
725
904
  else if (intPlaceholders[i] === "?") spaces += " ";
726
905
  const intDigits = zeros + significant;
727
- let numberPart = spaces + (useThousands ? intDigits.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : intDigits);
906
+ let numberPart = spaces + (useThousands ? groupThousands(intDigits) : intDigits);
728
907
  if (decimals > 0) {
729
908
  let dec = decRaw ?? "".padEnd(decimals, "0");
730
909
  if (dec.length < decimals) dec = dec.padEnd(decimals, "0");
@@ -739,6 +918,34 @@ function applyNumericSection(value, format, negativeSection) {
739
918
  if (value < 0 && !negativeSection) signPrefix = "-";
740
919
  return `${literalPrefix}${signPrefix}${numberPart}${isPercent ? "%" : ""}${literalSuffix}`;
741
920
  }
921
+ /**
922
+ * The digits with a comma before every third from the right of each run — what
923
+ * `/\B(?=(\d{3})+(?!\d))/g` puts one at — in one pass. That expression read
924
+ * ahead over the rest of the run from every position, and the run is as long
925
+ * as the format has zeros, applied in every cell the format shows. A comma
926
+ * goes where the rest of the run is a whole number of threes and the character
927
+ * before is a word character, as `\B` asks: a digit inside the run, a letter
928
+ * at its start.
929
+ */
930
+ function groupThousands(s) {
931
+ const isWord = (i) => i >= 0 && /\w/.test(s[i]);
932
+ let out = "";
933
+ let from = 0;
934
+ for (let start = 0; start < s.length;) {
935
+ if (!isAsciiDigit(s[start])) {
936
+ start++;
937
+ continue;
938
+ }
939
+ let end = start;
940
+ while (isAsciiDigit(s[end])) end++;
941
+ for (let at = start; at < end; at++) if ((end - at) % 3 === 0 && isWord(at - 1)) {
942
+ out += `${s.slice(from, at)},`;
943
+ from = at;
944
+ }
945
+ start = end;
946
+ }
947
+ return out + s.slice(from);
948
+ }
742
949
  function splitNumberFormat(cleaned) {
743
950
  let firstDigit = -1;
744
951
  let lastDigit = -1;
@@ -802,4 +1009,4 @@ function unquoteLiteral(s) {
802
1009
  return out;
803
1010
  }
804
1011
  //#endregion
805
- export { applyNumberFormat, excelSerialFromUtcParts, excelSerialToDate, generalToWidth, numberFormatColorHex };
1012
+ export { applyNumberFormat, excelSerialFromUtcParts, excelSerialToDate, generalRenderings, generalToWidth, numberFormatColorHex };
@@ -0,0 +1,15 @@
1
+ /** Whether the code unit is one `\w` matches. NaN, past the end, is not. */
2
+ export declare function isWordChar(c: number): boolean;
3
+ /**
4
+ * The matches a global search with `re` finds in `xml` from `from`, in order.
5
+ *
6
+ * @param xml The part's text.
7
+ * @param open The tag's opening, `<w:font`, which `re` begins with.
8
+ * @param boundary Whether `re` puts `\b` right after `open`.
9
+ * @param re The expression, sticky (`y`): `open`, the `\b` if any, then
10
+ * `[^>]*` (greedy or lazy) before what it looks for in the tag.
11
+ * @param from Where the search starts.
12
+ */
13
+ export declare function tagMatches(xml: string, open: string, boundary: boolean, re: RegExp, from?: number): Generator<RegExpExecArray>;
14
+ /** The first of {@link tagMatches}, or undefined when there is none. */
15
+ export declare function firstTagMatch(xml: string, open: string, boundary: boolean, re: RegExp, from?: number): RegExpExecArray | undefined;
@@ -0,0 +1,41 @@
1
+ //#region src/core/opc/tag-scan.ts
2
+ /** Whether the code unit is one `\w` matches. NaN, past the end, is not. */
3
+ function isWordChar(c) {
4
+ return c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 95;
5
+ }
6
+ /**
7
+ * The matches a global search with `re` finds in `xml` from `from`, in order.
8
+ *
9
+ * @param xml The part's text.
10
+ * @param open The tag's opening, `<w:font`, which `re` begins with.
11
+ * @param boundary Whether `re` puts `\b` right after `open`.
12
+ * @param re The expression, sticky (`y`): `open`, the `\b` if any, then
13
+ * `[^>]*` (greedy or lazy) before what it looks for in the tag.
14
+ * @param from Where the search starts.
15
+ */
16
+ function* tagMatches(xml, open, boundary, re, from = 0) {
17
+ let at = xml.indexOf(open, from);
18
+ while (at >= 0) {
19
+ if (boundary && isWordChar(xml.charCodeAt(at + open.length))) {
20
+ at = xml.indexOf(open, at + 1);
21
+ continue;
22
+ }
23
+ re.lastIndex = at;
24
+ const found = re.exec(xml);
25
+ if (found) {
26
+ const next = Math.max(re.lastIndex, at + 1);
27
+ yield found;
28
+ at = xml.indexOf(open, next);
29
+ continue;
30
+ }
31
+ const end = xml.indexOf(">", at + open.length);
32
+ if (end < 0) return;
33
+ at = xml.indexOf(open, end + 1);
34
+ }
35
+ }
36
+ /** The first of {@link tagMatches}, or undefined when there is none. */
37
+ function firstTagMatch(xml, open, boundary, re, from = 0) {
38
+ for (const found of tagMatches(xml, open, boundary, re, from)) return found;
39
+ }
40
+ //#endregion
41
+ export { firstTagMatch, isWordChar, tagMatches };
@@ -3,7 +3,7 @@
3
3
  var MAX_EXPANSION = 65536;
4
4
  /** Longest total the substitutions may add to one part. */
5
5
  var MAX_TOTAL = 1048576;
6
- var DOCTYPE = /<!DOCTYPE\s[^[>]*(?:\[[\s\S]*?\]\s*)?>/;
6
+ var DOCTYPE = /<!DOCTYPE\s[^[>]*(?:\[[\s\S]*?\]\s*)?>/y;
7
7
  var ENTITY_DECL = /<!ENTITY\s+([^\s%<>&;]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g;
8
8
  var REFERENCE = /&([^\s;&<>]+);/g;
9
9
  /** The five entities XML predefines; a declaration never overrides them. */
@@ -14,6 +14,43 @@ var PREDEFINED = new Set([
14
14
  "apos",
15
15
  "quot"
16
16
  ]);
17
+ /**
18
+ * The first `<!DOCTYPE …>` of a part — what {@link DOCTYPE} finds searching
19
+ * from the start — tried only where it holds. A search tries every
20
+ * `<!DOCTYPE`, reads on to the first `[` or `>` after it, and from a `[` on to
21
+ * a `]` a `>` follows: a part of `<!DOCTYPE ` written over and over with none
22
+ * of those sent it to the end of the part from each one, before the parser
23
+ * ever saw the part. Every `<!DOCTYPE` before the same first `[` or `>` fares
24
+ * as the one before it, and a `[` that no `]…>` follows fails every one that
25
+ * reaches it, so those are passed over and the expression runs once.
26
+ */
27
+ function findDoctype(xml) {
28
+ let stop = -1;
29
+ let close;
30
+ for (let at = xml.indexOf("<!DOCTYPE"); at >= 0; at = xml.indexOf("<!DOCTYPE", at + 1)) {
31
+ if (!/\s/.test(xml[at + 9] ?? "")) continue;
32
+ if (stop < at + 10) stop = firstBracketOrEnd(xml, at + 10);
33
+ if (stop < 0) return null;
34
+ if (xml[stop] === "[") {
35
+ close ??= lastBracketClose(xml);
36
+ if (close < stop) continue;
37
+ }
38
+ DOCTYPE.lastIndex = at;
39
+ return DOCTYPE.exec(xml);
40
+ }
41
+ return null;
42
+ }
43
+ /** The first `[` or `>` at or after `from`, or -1. */
44
+ function firstBracketOrEnd(xml, from) {
45
+ for (let i = from; i < xml.length; i++) if (xml[i] === "[" || xml[i] === ">") return i;
46
+ return -1;
47
+ }
48
+ /** Where the last `]` stands that, past some whitespace, a `>` follows; or -1. */
49
+ function lastBracketClose(xml) {
50
+ let last = -1;
51
+ for (const m of xml.matchAll(/\]\s*>/g)) last = m.index;
52
+ return last;
53
+ }
17
54
  /** Escape resolved text so it re-enters the document as characters, not markup. */
18
55
  function escape(text) {
19
56
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -74,7 +111,7 @@ function expander(declared) {
74
111
  */
75
112
  function resolveInternalEntities(xml) {
76
113
  if (!xml.includes("<!DOCTYPE")) return xml;
77
- const doctype = DOCTYPE.exec(xml);
114
+ const doctype = findDoctype(xml);
78
115
  if (!doctype) return xml;
79
116
  const declared = /* @__PURE__ */ new Map();
80
117
  for (const decl of doctype[0].matchAll(ENTITY_DECL)) {