agent-sanitizer 2.61.0 → 2.61.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/src/invisible.mjs CHANGED
@@ -10,8 +10,20 @@
10
10
  * actual cursive-join semantics rather than a hand-rolled script guess.
11
11
  */
12
12
  import { joiningType, isVirama, isBrahmicConsonant } from "./joining-type.mjs";
13
- import { isStandardizedVariant } from "./standardized-variants.mjs";
13
+ import {
14
+ STANDARDIZED_VARIANTS,
15
+ isStandardizedVariant,
16
+ } from "./standardized-variants.mjs";
14
17
  import { CF_CODEPOINTS } from "./cf-charset.mjs";
18
+ import {
19
+ EMOJI_MODIFIER,
20
+ EMOJI_TEXT_DEFAULT,
21
+ EXTENDED_PICTOGRAPHIC,
22
+ SCRIPT_BRAILLE,
23
+ SCRIPT_HANGUL,
24
+ UNIFIED_IDEOGRAPH,
25
+ inSpans,
26
+ } from "./unicode-properties.mjs";
15
27
  import { scanAnsi, TOKEN_KIND } from "./ansi.mjs";
16
28
 
17
29
  // Unicode's Variation_Selector property, whole: the FE00 run, the Mongolian
@@ -332,13 +344,11 @@ export const TOTAL_PRESERVED_JOINER_BUDGET = 16;
332
344
  // residual channel to a fixed fraction of the cover text.
333
345
  export const PRESERVED_JOINER_PER_VISIBLE = 8;
334
346
 
335
- // Absolute ceiling on the document-wide preserve allowance, regardless of how
336
- // much visible cover text an attacker supplies. Without it, maxPreserved grows
337
- // linearly with visible length (visibleLen / PRESERVED_JOINER_PER_VISIBLE), so a
338
- // large benign-looking body lets the residual joiner/selector channel scale with
339
- // attacker input — preserved chars do not count toward the scatter floor, so
340
- // nothing else bounds it. This caps the whole channel at a fixed width no cover
341
- // text can widen.
347
+ // Ceiling on the preserve allowance cover text can buy, so a large ASCII body
348
+ // cannot scale the residual channel. Joiners alone may also grow at
349
+ // PRESERVED_JOINER_PER_VISIBLE of the joining letters (see joiningLetterCount):
350
+ // in a joining script they are orthography, so a long Persian document keeps
351
+ // every ZWNJ while selectors and tags stay under this cap.
342
352
  export const PRESERVE_HARD_CAP = 64;
343
353
 
344
354
  // Floor on the document-wide allowance for PRESERVED blank fillers (the
@@ -385,35 +395,34 @@ export const LINGUISTIC_SCRIPTS = [
385
395
  "Malayalam",
386
396
  "Sinhala",
387
397
  ];
398
+ // Every Unicode property below comes from the pinned-UCD tables, never a live
399
+ // `\p{…}`, so the runtime's ICU cannot decide what is preserved.
388
400
  // Left side of an emoji joiner: a pictograph or a skin-tone modifier (a base
389
401
  // emoji may carry a modifier before the joiner, e.g. a health-worker sequence).
390
- const EMOJI_LEFT = /[\p{Extended_Pictographic}\p{Emoji_Modifier}]/u;
402
+ /** @param {number} cp @returns {boolean} */
403
+ const isEmojiLeft = (cp) => isExtendedPictographic(cp) || isEmojiModifierCp(cp);
391
404
  // The three keycap bases (digit, `#`, `*`) Unicode's keycap grammar allows
392
405
  // before VS16 + U+20E3 COMBINING ENCLOSING KEYCAP (1️⃣ #️⃣ *️⃣ … 9️⃣). None is
393
- // Extended_Pictographic or Emoji_Modifier, so EMOJI_LEFT misses them; the
406
+ // Extended_Pictographic or Emoji_Modifier, so isEmojiLeft misses them; the
394
407
  // presentation-selector check accepts one ONLY when the required U+20E3 keycap
395
408
  // actually follows the selector (see isEmojiPresentationSelector). A bare
396
409
  // digit/`#`/`*` + VS with no keycap is NOT a glyph — it is a hidden presentation
397
410
  // selector spliced after ordinary text, the top low-effort VS-smuggling shape —
398
411
  // so it fails closed and is stripped. The base set is confined to the keycap
399
- // check (never folded into EMOJI_LEFT) so it cannot loosen the ZWJ carve-out.
412
+ // check (never folded into isEmojiLeft) so it cannot loosen the ZWJ carve-out.
400
413
  const KEYCAP_BASE = /[0-9#*]/u;
401
414
  // U+20E3 COMBINING ENCLOSING KEYCAP — the mandatory terminator of a keycap
402
415
  // sequence, the one signal that a digit/`#`/`*` + VS16 is a real glyph.
403
416
  const COMBINING_KEYCAP = 0x20e3;
404
417
  // Right side of an emoji joiner is always the next component's base pictograph.
405
- const EMOJI_BASE = /\p{Extended_Pictographic}/u;
418
+ /** @param {number} cp @returns {boolean} */
419
+ const isEmojiBase = (cp) => isExtendedPictographic(cp);
406
420
  // A variation selector legitimately sits between a base pictograph and a
407
421
  // following ZWJ (🏳️‍🌈 = flag base, VS16, ZWJ, rainbow; 👁️‍🗨️), so the joiner's real
408
422
  // left neighbor for the emoji test is the pictograph, not the selector.
409
423
  const VARIATION_SELECTOR = new RegExp(`[${VS}]`, "u");
410
- // Code-point twins of the four single-character classes above, memoized: the
411
- // carve analysis asks these per invisible (and per neighbour), and a Unicode
412
- // property class costs far more than a table hit. See memoizedCpPredicate.
413
- const isEmojiLeft = memoizedCpPredicate(EMOJI_LEFT);
414
- const isEmojiBase = memoizedCpPredicate(EMOJI_BASE);
424
+ // Code-point twin of KEYCAP_BASE (see memoizedCpPredicate).
415
425
  const isKeycapBase = memoizedCpPredicate(KEYCAP_BASE);
416
- const isVariationSelectorCp = memoizedCpPredicate(VARIATION_SELECTOR);
417
426
  // U+FE0F (VS16) forces emoji presentation, U+FE0E (VS15) forces text
418
427
  // presentation (☺︎ vs ☺); either one directly after a pictograph is part of a
419
428
  // visible glyph, not a hidden variation-selector run.
@@ -442,6 +451,68 @@ const REGISTERED_TAG_PAYLOADS = new Set(["gbeng", "gbsct", "gbwls"]);
442
451
  // a malformed-but-registered-prefix run can never carry a long ASCII rider.
443
452
  const MAX_TAG_SPEC_CHARS = 6;
444
453
 
454
+ // ─── Emoji glyph spelling ─────────────────────────────────────────────────────
455
+ // Some preserved invisibles are part of a glyph's spelling: an RGI subdivision
456
+ // flag's tags, the VS16 a text-default emoji (Emoji but not Emoji_Presentation:
457
+ // ⚠ ✔ 🏳 ❤, keycaps) needs, and a ZWJ between two pictographs (👨‍👩‍👧, 🏳️‍🌈).
458
+ // They are KIND_GLYPH: preserved whenever the carve-out is on and charged to no
459
+ // budget, since charging them made ten Scotland flags or fifty ⚠️ lines read as
460
+ // a covert channel. A VS16 on an Emoji_Presentation base (😀️) and VS15 change
461
+ // nothing that renders and keep their budget.
462
+ //
463
+ // The ZWJ rule is UAX #29's GB11, not the RGI list, so an unregistered pair
464
+ // (😀‍😀) is exempt too: one bit per pair of adjacent pictographs, the accepted
465
+ // residual.
466
+ const VS16 = 0xfe0f;
467
+
468
+ /** Whether `cp` is in an emoji property, read off its cpProps `bit` below
469
+ * CP_PROPS_END and off `spans` above it; -1 is in none.
470
+ * @param {number} cp @param {number} bit
471
+ * @param {ReadonlyArray<readonly [number, number]>} spans
472
+ * @returns {boolean} */
473
+ function hasEmojiProp(cp, bit, spans) {
474
+ if (cp < 0) return false;
475
+ if (cp >= CP_PROPS_END) return inSpans(spans, cp);
476
+ return (cpProps(cp) & bit) !== 0;
477
+ }
478
+
479
+ /** Emoji but not Emoji_Presentation. @param {number} cp @returns {boolean} */
480
+ const isTextDefaultEmoji = (cp) =>
481
+ hasEmojiProp(cp, PROP_EMOJI_TEXT_DEFAULT, EMOJI_TEXT_DEFAULT);
482
+ /** A skin tone. @param {number} cp @returns {boolean} */
483
+ const isEmojiModifierCp = (cp) =>
484
+ hasEmojiProp(cp, PROP_EMOJI_MODIFIER, EMOJI_MODIFIER);
485
+ /** Extended_Pictographic. @param {number} cp @returns {boolean} */
486
+ const isExtendedPictographic = (cp) =>
487
+ hasEmojiProp(cp, PROP_EXTENDED_PICTOGRAPHIC, EXTENDED_PICTOGRAPHIC);
488
+
489
+ /**
490
+ * Upgrade to KIND_GLYPH each preserved VS16 on a text-default emoji, and each
491
+ * preserved ZWJ GB11 keeps inside one grapheme (`ExtPict Extend* ZWJ ×
492
+ * ExtPict`, Extend read as selectors and skin tones). Each walk stops at a ZWJ,
493
+ * which is not Extend, so the pass is linear.
494
+ * @param {Int32Array} cps @param {Int32Array} invisible ascending indices
495
+ * @param {Uint8Array} kind updated in place
496
+ */
497
+ function markRegisteredGlyphs(cps, invisible, kind) {
498
+ for (const i of invisible) {
499
+ if (cps[i] === VS16) {
500
+ if (kind[i] === KIND_EMOJIVS && i > 0 && isTextDefaultEmoji(cps[i - 1]))
501
+ kind[i] = KIND_GLYPH;
502
+ continue;
503
+ }
504
+ if (kind[i] !== KIND_JOINER || cps[i] !== ZWJ) continue;
505
+ if (!isExtendedPictographic(i + 1 < cps.length ? cps[i + 1] : -1)) continue;
506
+ let p = i - 1;
507
+ while (
508
+ p >= 0 &&
509
+ (isVariationSelectorCp(cps[p]) || isEmojiModifierCp(cps[p]))
510
+ )
511
+ p--;
512
+ if (p >= 0 && isExtendedPictographic(cps[p])) kind[i] = KIND_GLYPH;
513
+ }
514
+ }
515
+
445
516
  // ─── Ideographic variation selectors (VS17–VS256, U+E0100–U+E01EF) ────────────
446
517
  // An ideographic variation sequence is a CJK ideograph followed by a selector in
447
518
  // U+E0100–U+E01EF. Preserved ONLY when the immediately preceding code point is a
@@ -450,8 +521,8 @@ const MAX_TAG_SPEC_CHARS = 6;
450
521
  //
451
522
  // Derived from Unicode's own data rather than hand-transcribed block spans: the
452
523
  // Unified_Ideograph property IS the set of unified ideographs (URO + every
453
- // extension), versioned with the runtime's ICU, so a new extension arrives with
454
- // the Node upgrade instead of waiting for someone to notice. A hand-written
524
+ // extension), generated from the pinned UCD, so a new extension arrives with
525
+ // the next UCD refresh instead of waiting for someone to notice. A hand-written
455
526
  // table went stale exactly this way — it stopped at Extension H and so denied
456
527
  // the 4,298 Extension J ideographs (U+323B0–U+33479) a legitimate IVS base.
457
528
  //
@@ -462,42 +533,47 @@ const MAX_TAG_SPEC_CHARS = 6;
462
533
  // full stop anchor a variation selector.
463
534
  //
464
535
  // The two Compatibility Ideograph BLOCKS stay literal: they are not
465
- // Unified_Ideograph, and JS RegExp exposes no \p{Block=…}. Block boundaries are
536
+ // Unified_Ideograph, and a block is not a property. Block boundaries are
466
537
  // immutable by Unicode's stability policy, so a literal span stays correct
467
538
  // forever; the contract test in test/invisible-unicode-tables.test.mjs pins
468
539
  // that every ASSIGNED code point inside them is a Script=Han letter.
469
540
  const IVS_MIN = 0xe0100;
470
541
  const IVS_MAX = 0xe01ef;
471
- const CJK_IDEOGRAPH_RE =
472
- /[\p{Unified_Ideograph}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]/u;
542
+ /** @type {ReadonlyArray<readonly [number, number]>} */
543
+ const CJK_COMPATIBILITY_IDEOGRAPH_BLOCKS = [
544
+ [0xf900, 0xfaff],
545
+ [0x2f800, 0x2fa1f],
546
+ ];
473
547
 
474
- /** True when `cp` is a CJK ideograph (the only base an ideographic variation
475
- * selector legitimately follows). -1 (past a string boundary) is not.
476
- * @type {(cp: number) => boolean} */
477
- const isCjkIdeograph = memoizedCpPredicate(CJK_IDEOGRAPH_RE);
548
+ /** A CJK ideograph (the only legitimate IVS base); -1 is not.
549
+ * @param {number} cp @returns {boolean} */
550
+ const inCjkIdeographTables = (cp) =>
551
+ inSpans(UNIFIED_IDEOGRAPH, cp) ||
552
+ inSpans(CJK_COMPATIBILITY_IDEOGRAPH_BLOCKS, cp);
553
+
554
+ /** {@link inCjkIdeographTables} via cpProps. @param {number} cp @returns {boolean} */
555
+ function isCjkIdeograph(cp) {
556
+ if (cp < 0 || cp >= CP_PROPS_END) return inCjkIdeographTables(cp);
557
+ return (cpProps(cp) & PROP_CJK_IDEOGRAPH) !== 0;
558
+ }
478
559
 
479
560
  // Brahmic consonants: the only base a virama does half-form/conjunct work on.
480
561
  // A bare or base-less halant + ZWJ carries no rendering and is a smuggling
481
562
  // channel, so the Indic joiner is preserved only over one of these.
482
563
  //
483
- // The spans are GENERATED from the UCD (Indic_Syllabic_Category=Consonant,
484
- // restricted by Script) and live in ./joining-type.mjs alongside the virama
485
- // table they are read against — see scripts/gen-joining-type.mjs. They used to
486
- // be hand-typed per-block KA–HA approximations here, which swept up the holes
487
- // between the real consonants; ECMAScript exposes no
488
- // \p{Indic_Syllabic_Category=…} escape, and \p{Script=Devanagari} is the wrong
489
- // shape on its own (it also holds the independent vowels U+0904–U+0914, which a
490
- // virama never attaches to), so a generated table is the only drift-proof
491
- // answer. Re-exported here because it was part of this module's surface before
492
- // it moved, and because test/invisible-unicode-tables.test.mjs checks each span
493
- // against the script it claims.
564
+ // The spans are GENERATED from the UCD (Indic_Syllabic_Category=Consonant in
565
+ // every script that owns a virama) by scripts/gen-joining-type.mjs, since
566
+ // \p{Script=…} also holds independent vowels a virama never attaches to.
567
+ // Re-exported as part of this module's published surface.
494
568
  export { BRAHMIC_CONSONANT_RANGES } from "./joining-type.mjs";
495
569
 
496
570
  /** True when the code point `cp` is a Brahmic consonant; -1 (past a string
497
571
  * boundary) is not. A thin sign-guard over ./joining-type.mjs's table lookup.
498
572
  * @param {number} cp @returns {boolean} */
499
573
  function isBrahmicConsonantCp(cp) {
500
- return cp >= 0 && isBrahmicConsonant(cp);
574
+ if (cp < 0) return false;
575
+ if (cp >= CP_PROPS_END) return isBrahmicConsonant(cp);
576
+ return (cpProps(cp) & PROP_BRAHMIC_CONSONANT) !== 0;
501
577
  }
502
578
 
503
579
  // ─── Blank-filler carve-out (Braille / archaic Hangul) ───────────────────────
@@ -528,11 +604,9 @@ const HANGUL_FILLERS = new Set([0x115f, 0x1160, 0x3164, 0xffa0]);
528
604
  // literal is invisible to the eye and a correctness landmine for the next editor.
529
605
  const GATED_BLANK_RE = new RegExp("[\\u115F\\u1160\\u2800\\u3164\\uFFA0]", "u");
530
606
 
531
- // Script=Braille, from the runtime's Unicode data (identical to
532
- // Script_Extensions=Braille — no character is shared with another script).
533
- const BRAILLE_RE = /\p{Script=Braille}/u;
534
-
535
- const isBrailleScript = memoizedCpPredicate(BRAILLE_RE);
607
+ // Script=Braille (identical to Script_Extensions=Braille).
608
+ /** @param {number} cp @returns {boolean} */
609
+ const isBrailleScript = (cp) => inSpans(SCRIPT_BRAILLE, cp);
536
610
 
537
611
  /** A real (non-blank) Braille cell — the anchoring neighbour for a U+2800 blank.
538
612
  * @param {number} cp @returns {boolean} */
@@ -540,14 +614,11 @@ function isBrailleCell(cp) {
540
614
  return cp !== BRAILLE_BLANK && isBrailleScript(cp);
541
615
  }
542
616
 
543
- // Script=Hangul, straight from the runtime's Unicode data. NOT
544
- // Script_Extensions=Hangul: that set also holds the CJK punctuation Korean text
545
- // shares with Chinese and Japanese (U+3001 IDEOGRAPHIC COMMA, U+30FB KATAKANA
546
- // MIDDLE DOT, U+FF61–U+FF65 …), so a Japanese middle dot would anchor — and
547
- // thereby preserve — a Hangul filler in text with no Hangul in it at all.
548
- const HANGUL_RE = /\p{Script=Hangul}/u;
549
-
550
- const isHangulScript = memoizedCpPredicate(HANGUL_RE);
617
+ // Script=Hangul. NOT Script_Extensions=Hangul: that also holds CJK punctuation
618
+ // (U+3001, U+30FB, U+FF61–U+FF65 …), so a Japanese middle dot would anchor a
619
+ // Hangul filler in text with no Hangul in it at all.
620
+ /** @param {number} cp @returns {boolean} */
621
+ const isHangulScript = (cp) => inSpans(SCRIPT_HANGUL, cp);
551
622
 
552
623
  /** A Hangul jamo/syllable (NOT itself one of the fillers) — the anchoring
553
624
  * neighbour for a Hangul filler. @param {number} cp @returns {boolean} */
@@ -575,7 +646,8 @@ const CODE_BLANK = 3;
575
646
  const KIND_NONE = 0;
576
647
  const KIND_JOINER = 1;
577
648
  const KIND_EMOJIVS = 2;
578
- const KIND_TAG = 3;
649
+ // Part of an emoji glyph's spelling: outside every budget.
650
+ const KIND_GLYPH = 3;
579
651
  const KIND_STDVS = 4;
580
652
  const KIND_IVS = 5;
581
653
  const KIND_BLANK = 6;
@@ -606,9 +678,12 @@ for (const [code, codepoints] of /** @type {[number, Iterable<number>][]} */ ([
606
678
  for (const cp of codepoints)
607
679
  if (!TRACKED_INVISIBLE.has(cp)) TRACKED_INVISIBLE.set(cp, code);
608
680
 
609
- // No tracked code point sits below this, so ASCII and Latin-1 text answers the
610
- // classifier without touching the map at all.
611
- const MIN_TRACKED_CP = Math.min(...TRACKED_INVISIBLE.keys());
681
+ // The same classes for the BMP and SMP by array index (128 KB): every visible
682
+ // character asks, and a hash lookup per character was a measurable cost.
683
+ const TRACKED_TABLE_END = 0x20000;
684
+ const TRACKED_LOW = new Uint8Array(TRACKED_TABLE_END);
685
+ for (const [cp, code] of TRACKED_INVISIBLE)
686
+ if (cp < TRACKED_TABLE_END) TRACKED_LOW[cp] = code;
612
687
 
613
688
  /**
614
689
  * The CODE_* class a code point belongs to, or CODE_VISIBLE when it is not
@@ -617,10 +692,26 @@ const MIN_TRACKED_CP = Math.min(...TRACKED_INVISIBLE.keys());
617
692
  * @returns {number}
618
693
  */
619
694
  function classifyCp(cp) {
620
- if (cp < MIN_TRACKED_CP) return CODE_VISIBLE;
695
+ if (cp < TRACKED_TABLE_END) return TRACKED_LOW[cp];
621
696
  return TRACKED_INVISIBLE.get(cp) ?? CODE_VISIBLE;
622
697
  }
623
698
 
699
+ /** One of the {@link VS} selectors, read off the class table; the check below
700
+ * fails loudly if a VS code point were ever classified otherwise.
701
+ * @param {number} cp @returns {boolean} */
702
+ function isVariationSelectorCp(cp) {
703
+ return cp >= 0 && classifyCp(cp) === CODE_VS;
704
+ }
705
+ for (const ch of VS)
706
+ if (!isVariationSelectorCp(/** @type {number} */ (ch.codePointAt(0))))
707
+ /* c8 ignore start -- an import-time consistency check of two committed
708
+ tables; it fires only if a regeneration broke one, which no test can
709
+ stage without a broken table to import. */
710
+ throw new Error(
711
+ `variation selector U+${ch.codePointAt(0)?.toString(16)} is not classified CODE_VS`,
712
+ );
713
+ /* c8 ignore stop */
714
+
624
715
  // Distinct code points a memoizedCpPredicate will remember. Comfortably above
625
716
  // the character repertoire of any real document (and of every test corpus here)
626
717
  // while bounding what a single hostile paste can make one predicate retain.
@@ -632,10 +723,9 @@ const CP_PREDICATE_MEMO_CAP = 4096;
632
723
  *
633
724
  * The carve analysis asks these of the same handful of code points over and
634
725
  * over — a document is a few hundred distinct characters repeated — and each
635
- * ask otherwise costs a `String.fromCodePoint` allocation plus a Unicode
636
- * property-class match. The memo is a pure accelerator: it answers exactly what
637
- * the RegExp answers, for the same input, forever (a code point's Unicode
638
- * properties do not change inside a process).
726
+ * ask otherwise costs a `String.fromCodePoint` allocation plus a regex match.
727
+ * The memo is a pure accelerator: it answers exactly what the RegExp answers,
728
+ * for the same input, forever.
639
729
  *
640
730
  * The entry cap is a MEMORY policy, not a scan bound: past it the predicate
641
731
  * still answers every code point, just without recording new ones, so no input
@@ -729,9 +819,64 @@ function codePointLength(text) {
729
819
  * @param {string} jt @returns {boolean} */
730
820
  const isCursiveLetter = (jt) => jt === "D" || jt === "R" || jt === "L";
731
821
 
822
+ // Every per-code-point Unicode table the carve reads, cached below CP_PROPS_END
823
+ // (BMP + SMP) by array index: a binary search per ask was the analysis' largest
824
+ // cost on Persian prose and ZWJ-dense pages. An entry packs the Joining_Type's
825
+ // index into JOINING_TYPES (never 0, so 0 means "not yet asked") with one bit
826
+ // per yes/no table; above the end the tables are searched directly.
827
+ const CP_PROPS_END = 0x20000;
828
+ const JOINING_TYPES = ["", "C", "D", "R", "L", "T", "U"];
829
+ const PROP_TYPE_MASK = 0b111;
830
+ const PROP_VIRAMA = 0b1000;
831
+ const PROP_BRAHMIC_CONSONANT = 0b10000;
832
+ const PROP_EMOJI_TEXT_DEFAULT = 0b100000;
833
+ const PROP_EMOJI_MODIFIER = 0b1000000;
834
+ const PROP_CJK_IDEOGRAPH = 0b10000000;
835
+ const PROP_EXTENDED_PICTOGRAPHIC = 0b100000000;
836
+ const PROP_JOINING_LETTER = 0b1000000000;
837
+ /** @type {Uint16Array | null} */
838
+ let cpPropsTable = null;
839
+ const propsTable = () => (cpPropsTable ??= new Uint16Array(CP_PROPS_END));
840
+
841
+ /** The packed entry for `cp` (0 <= cp < CP_PROPS_END). @param {number} cp @returns {number} */
842
+ function cpProps(cp) {
843
+ const table = propsTable();
844
+ const known = table[cp];
845
+ if (known !== 0) return known;
846
+ const type = JOINING_TYPES.indexOf(joiningType(cp));
847
+ /* c8 ignore start -- the generated table holds only the types listed in
848
+ JOINING_TYPES; this fails loudly if a regeneration ever adds another. */
849
+ if (type < 1)
850
+ throw new Error(`unknown Joining_Type for U+${cp.toString(16)}`);
851
+ /* c8 ignore stop */
852
+ const props =
853
+ type |
854
+ (isCursiveLetter(JOINING_TYPES[type]) || isBrahmicConsonant(cp)
855
+ ? PROP_JOINING_LETTER
856
+ : 0) |
857
+ (isVirama(cp) ? PROP_VIRAMA : 0) |
858
+ (isBrahmicConsonant(cp) ? PROP_BRAHMIC_CONSONANT : 0) |
859
+ (inSpans(EMOJI_TEXT_DEFAULT, cp) ? PROP_EMOJI_TEXT_DEFAULT : 0) |
860
+ (inSpans(EMOJI_MODIFIER, cp) ? PROP_EMOJI_MODIFIER : 0) |
861
+ (inCjkIdeographTables(cp) ? PROP_CJK_IDEOGRAPH : 0) |
862
+ (inSpans(EXTENDED_PICTOGRAPHIC, cp) ? PROP_EXTENDED_PICTOGRAPHIC : 0);
863
+ table[cp] = props;
864
+ return props;
865
+ }
866
+
732
867
  /** The Joining_Type of a code point, or "U" for -1 (past a string boundary).
733
868
  * @param {number} cp @returns {string} */
734
- const jtOf = (cp) => (cp < 0 ? "U" : joiningType(cp));
869
+ function jtOf(cp) {
870
+ if (cp < 0) return "U";
871
+ if (cp >= CP_PROPS_END) return joiningType(cp);
872
+ return JOINING_TYPES[cpProps(cp) & PROP_TYPE_MASK];
873
+ }
874
+
875
+ /** @param {number} cp @returns {boolean} */
876
+ function isViramaCp(cp) {
877
+ if (cp >= CP_PROPS_END) return isVirama(cp);
878
+ return (cpProps(cp) & PROP_VIRAMA) !== 0;
879
+ }
735
880
 
736
881
  /** True when `cp` is itself a ZWNJ/ZWJ (used to reject joiner runs).
737
882
  * @param {number} cp @returns {boolean} */
@@ -782,16 +927,64 @@ function followsBrahmicConjunct(cps, i) {
782
927
  classifyCp(cps[j]) !== CODE_VISIBLE
783
928
  )
784
929
  j--;
785
- if (j < 0 || !isVirama(cps[j])) return false;
930
+ if (j < 0 || !isViramaCp(cps[j])) return false;
786
931
  return isBrahmicConsonantCp(effectiveNeighbor(cps, j, -1));
787
932
  }
788
933
 
934
+ /** The visible joining letters: the cover that funds joiners past
935
+ * PRESERVE_HARD_CAP. @param {Int32Array} cps @param {Uint8Array} codes
936
+ * @returns {number} */
937
+ function joiningLetterCount(cps, codes) {
938
+ const table = propsTable();
939
+ let letters = 0;
940
+ for (let i = 0; i < cps.length; i++) {
941
+ const cp = cps[i];
942
+ if (codes[i] !== CODE_VISIBLE) continue;
943
+ // The cache read in place: this visits every code point of a joiner-dense
944
+ // document, and a call per code point was most of what it cost.
945
+ if (
946
+ cp < CP_PROPS_END
947
+ ? ((table[cp] || cpProps(cp)) & PROP_JOINING_LETTER) !== 0
948
+ : isJoiningLetter(cp)
949
+ )
950
+ letters++;
951
+ }
952
+ return letters;
953
+ }
954
+
955
+ /** A cursive letter or Brahmic consonant. @param {number} cp @returns {boolean} */
956
+ function isJoiningLetter(cp) {
957
+ return cp >= 0x80 && (isCursiveLetter(jtOf(cp)) || isBrahmicConsonantCp(cp));
958
+ }
959
+
960
+ /**
961
+ * True when the ZWJ at `cps[i]` sits between a Brahmic consonant and the virama
962
+ * after it — Unicode's spelling for the Bengali RA with ya-phala, the
963
+ * Devanagari eyelash RA and the Sinhala touching letters. The mirror of
964
+ * {@link followsBrahmicConjunct}: it steps right over removable invisibles.
965
+ * @param {Int32Array} cps @param {number} i
966
+ * @returns {boolean}
967
+ */
968
+ function precedesBrahmicVirama(cps, i) {
969
+ if (cps[i] !== ZWJ) return false;
970
+ let j = i + 1;
971
+ while (
972
+ j < cps.length &&
973
+ !isJoinControl(cps[j]) &&
974
+ classifyCp(cps[j]) !== CODE_VISIBLE
975
+ )
976
+ j++;
977
+ if (j >= cps.length || !isViramaCp(cps[j])) return false;
978
+ return isBrahmicConsonantCp(effectiveNeighbor(cps, i, -1));
979
+ }
980
+
789
981
  /**
790
982
  * True when the joiner at `cps[i]` does real rendering work and so must be
791
983
  * preserved rather than stripped, decided from the neighbours' Joining_Type:
792
984
  * - emoji ZWJ: between two emoji components (its left pictograph may sit behind
793
985
  * a VS16, so step over selectors — see leftNonSelector);
794
- * - Indic joiner: after a virama that sits on a real Brahmic consonant;
986
+ * - Indic joiner: after a virama that sits on a real Brahmic consonant, or a
987
+ * ZWJ between a consonant and the virama that follows it;
795
988
  * - Arabic-family joiner: between two cursive letters (ZWNJ needs both, ZWJ at
796
989
  * least one — it forces a connected form). A joiner whose effective neighbour
797
990
  * is ANOTHER joiner is a run (a zero-width payload channel) and is rejected.
@@ -812,8 +1005,9 @@ function isPreservedJoiner(cps, i) {
812
1005
  isEmojiBase(rightNonSelector(cps, i))
813
1006
  )
814
1007
  return true;
815
- // Indic: meaningful only after a virama sitting on a real consonant base.
816
- if (followsBrahmicConjunct(cps, i)) return true;
1008
+ // Indic: meaningful only beside a virama that sits on a real consonant base.
1009
+ if (followsBrahmicConjunct(cps, i) || precedesBrahmicVirama(cps, i))
1010
+ return true;
817
1011
  // Arabic-family cursive joining, on the nearest non-Transparent neighbours.
818
1012
  const left = effectiveNeighbor(cps, i, -1);
819
1013
  const right = effectiveNeighbor(cps, i, 1);
@@ -853,7 +1047,7 @@ function isEmojiPresentationSelector(cps, i) {
853
1047
  * floor counts only that, so meaningful joiners/selectors never push honest
854
1048
  * prose over the threshold.
855
1049
  * @param {Int32Array} cps
856
- * @returns {{ codes: Uint8Array, kind: Uint8Array, payloadInvis: number, visibleLen: number }}
1050
+ * @returns {{ codes: Uint8Array, kind: Uint8Array, invisible: Int32Array, payloadInvis: number, visibleLen: number }}
857
1051
  */
858
1052
  function analyzeCarve(cps) {
859
1053
  const codes = new Uint8Array(cps.length);
@@ -885,13 +1079,14 @@ function analyzeCarve(cps) {
885
1079
  const tagKeep = hasTagBase ? markTagSequences(cps) : null;
886
1080
  const kind = new Uint8Array(cps.length);
887
1081
  for (const i of invisible) {
888
- if (tagKeep !== null && tagKeep[i]) kind[i] = KIND_TAG;
1082
+ if (tagKeep !== null && tagKeep[i]) kind[i] = KIND_GLYPH;
889
1083
  else if (isPreservedJoiner(cps, i)) kind[i] = KIND_JOINER;
890
1084
  else if (isEmojiPresentationSelector(cps, i)) kind[i] = KIND_EMOJIVS;
891
1085
  else if (isStandardizedVariationSelector(cps, i)) kind[i] = KIND_STDVS;
892
1086
  else if (isIdeographicVariationSelector(cps, i)) kind[i] = KIND_IVS;
893
1087
  else if (isPreservedBlankFiller(cps, i)) kind[i] = KIND_BLANK;
894
1088
  }
1089
+ markRegisteredGlyphs(cps, invisible, kind);
895
1090
  // Blank fillers are budgeted here, document-wide and all-or-nothing, against
896
1091
  // the visible anchor-script text rather than against the joiner/selector
897
1092
  // counter in carveStrip (see PRESERVED_BLANK_PER_ANCHOR for why the two
@@ -928,7 +1123,7 @@ function analyzeCarve(cps) {
928
1123
  }
929
1124
  let payloadInvis = 0;
930
1125
  for (const i of invisible) if (kind[i] === KIND_NONE) payloadInvis++;
931
- return { codes, kind, payloadInvis, visibleLen };
1126
+ return { codes, kind, invisible, payloadInvis, visibleLen };
932
1127
  }
933
1128
 
934
1129
  /**
@@ -947,12 +1142,13 @@ export function countPayloadInvisible(text) {
947
1142
  // every code point — it is a necessary condition checked in full, not a scan
948
1143
  // bound an attacker could paste past.
949
1144
  if (!hasInvisibleCodePoint(text)) return 0;
950
- return analyzeCarve(codePointArray(text)).payloadInvis;
1145
+ return carveOf(text).analysis.payloadInvis;
951
1146
  }
952
1147
 
953
1148
  /**
954
1149
  * Bulk strip (the common path: {@link needsCarveOut} found nothing the
955
- * carve-out could apply to). A single regex pass removes every payload-
1150
+ * carve-out could apply to; also the carve-free fallback a non-converging
1151
+ * composition asks for). A single regex pass removes every payload-
956
1152
  * capable char; `found` names the category codes present via `.search`
957
1153
  * (which ignores the `g` lastIndex).
958
1154
  * @param {string} body
@@ -1035,18 +1231,27 @@ function markTagSequences(cps) {
1035
1231
  return keep;
1036
1232
  }
1037
1233
 
1234
+ // The range the registered sequences' selectors span: a cheap pre-filter.
1235
+ const STANDARDIZED_SELECTOR_MIN = Math.min(
1236
+ ...STANDARDIZED_VARIANTS.map(([, selector]) => selector),
1237
+ );
1238
+ const STANDARDIZED_SELECTOR_MAX = Math.max(
1239
+ ...STANDARDIZED_VARIANTS.map(([, selector]) => selector),
1240
+ );
1241
+
1038
1242
  /**
1039
- * True when `cps[i]` is a standardized variation selector (U+FE00–U+FE0D) whose
1040
- * immediately preceding code point forms a REGISTERED standardized variation
1041
- * sequence (per the generated UCD table). Every unregistered FE00–FE0D selector
1042
- * stays payload.
1243
+ * True when `cps[i]` is a variation selector whose immediately preceding code
1244
+ * point forms a REGISTERED standardized variation sequence with it (per the
1245
+ * generated UCD table: VS1–VS14 and the Mongolian free variation selectors).
1246
+ * Every unregistered pairing stays payload.
1043
1247
  * @param {Int32Array} cps @param {number} i
1044
1248
  * @returns {boolean}
1045
1249
  */
1046
1250
  function isStandardizedVariationSelector(cps, i) {
1047
1251
  const cp = cps[i];
1048
- if (cp < 0xfe00 || cp > 0xfe0d) return false;
1049
- return i > 0 ? isStandardizedVariant(cps[i - 1], cp) : false;
1252
+ if (cp < STANDARDIZED_SELECTOR_MIN || cp > STANDARDIZED_SELECTOR_MAX)
1253
+ return false;
1254
+ return i > 0 && isStandardizedVariant(cps[i - 1], cp);
1050
1255
  }
1051
1256
 
1052
1257
  /**
@@ -1136,6 +1341,75 @@ function cpIndexOf(offsets, u16) {
1136
1341
  return lo;
1137
1342
  }
1138
1343
 
1344
+ /**
1345
+ * True when a code point can only meet its left neighbour under a UAX #29 rule
1346
+ * that reads those two code points alone. Only GB9c, GB11 and GB12/13 read
1347
+ * further left, and each needs an Indic consonant, an Extended_Pictographic or
1348
+ * a regional indicator on the RIGHT — which nothing below U+0900 but U+00A9 and
1349
+ * U+00AE is, nor are the joiners, selectors, tags or CJK ideographs, in any
1350
+ * Unicode version. Lone surrogates are left out: the segmenter reads UTF-16.
1351
+ * @param {number} cp @returns {boolean}
1352
+ */
1353
+ function contextFreeOnLeft(cp) {
1354
+ if (cp < 0x900) return cp !== 0xa9 && cp !== 0xae;
1355
+ if (cp === ZWNJ || cp === ZWJ) return true;
1356
+ if (cp >= 0xfe00 && cp <= 0xfe0f) return true; // VS1–VS16
1357
+ if (cp >= 0xe0020 && cp <= 0xe007f) return true; // tag characters
1358
+ if (cp >= 0xe0100 && cp <= 0xe01ef) return true; // VS17–VS256
1359
+ return isCjkIdeograph(cp);
1360
+ }
1361
+
1362
+ // Distinct (left, right) pairs pairBreak remembers. Past the cap it declines,
1363
+ // and the caller asks the segmenter about the whole cluster instead.
1364
+ const PAIR_BREAK_MEMO_CAP = 4096;
1365
+ /** @type {Map<number, boolean>} */
1366
+ const PAIR_BREAK_MEMO = new Map();
1367
+
1368
+ /**
1369
+ * The segmenter's own verdict on a boundary between `left` and `right`, memoized
1370
+ * per pair — or null when it could depend on further context (see
1371
+ * {@link contextFreeOnLeft}) or the memo is full.
1372
+ * @param {number} left @param {number} right
1373
+ * @returns {boolean | null}
1374
+ */
1375
+ function pairBreak(left, right) {
1376
+ if (!contextFreeOnLeft(right) || (left >= 0xd800 && left <= 0xdfff))
1377
+ return null;
1378
+ const key = left * 0x110000 + right;
1379
+ const hit = PAIR_BREAK_MEMO.get(key);
1380
+ if (hit !== undefined) return hit;
1381
+ if (PAIR_BREAK_MEMO.size >= PAIR_BREAK_MEMO_CAP) return null;
1382
+ const pair = String.fromCodePoint(left, right);
1383
+ const first = /** @type {{ segment: string }} */ (
1384
+ graphemeSegmenter().segment(pair).containing(0)
1385
+ );
1386
+ const breaks = first.segment.length < pair.length;
1387
+ PAIR_BREAK_MEMO.set(key, breaks);
1388
+ return breaks;
1389
+ }
1390
+
1391
+ /**
1392
+ * The grapheme cluster containing code point `at`, read off {@link pairBreak}
1393
+ * outward from it, or null where a verdict is missing.
1394
+ * @param {Int32Array} cps @param {number} at
1395
+ * @returns {{ start: number, end: number } | null}
1396
+ */
1397
+ function localCluster(cps, at) {
1398
+ let start = at;
1399
+ for (; start > 0; start--) {
1400
+ const breaks = pairBreak(cps[start - 1], cps[start]);
1401
+ if (breaks === null) return null;
1402
+ if (breaks) break;
1403
+ }
1404
+ let end = at + 1;
1405
+ for (; end < cps.length; end++) {
1406
+ const breaks = pairBreak(cps[end - 1], cps[end]);
1407
+ if (breaks === null) return null;
1408
+ if (breaks) break;
1409
+ }
1410
+ return { start, end };
1411
+ }
1412
+
1139
1413
  // Above this share of the code points being carve-out CANDIDATES, resolving each
1140
1414
  // candidate's cluster with `Segments.containing` costs more than segmenting the
1141
1415
  // document once from the start (a `containing` call is a few times dearer than
@@ -1153,14 +1427,26 @@ const CONTAINING_CANDIDATE_SHARE = 4;
1153
1427
  * the clusters are resolved on demand around the candidates rather than by
1154
1428
  * segmenting the whole document — the difference between reading one emoji at
1155
1429
  * the end of an 8 MB log and segmenting the 8 MB.
1156
- * @param {string} body @param {Int32Array} offsets @param {number} candidates
1430
+ *
1431
+ * Each candidate is first resolved by {@link localCluster}, falling back to the
1432
+ * segmenter only where that has no verdict: a `containing` call per joiner was
1433
+ * most of what carving a long Persian document cost.
1434
+ * @param {string} body @param {Int32Array} cps
1435
+ * @param {() => Int32Array} offsetsOf {@link u16Offsets} of `cps`, asked for
1436
+ * only once the segmenter is
1437
+ * @param {number} candidates
1157
1438
  * @returns {(cp: number) => { start: number, end: number }}
1158
1439
  */
1159
- function clusterResolver(body, offsets, candidates) {
1160
- const cpCount = offsets.length - 1;
1440
+ function clusterResolver(body, cps, offsetsOf, candidates) {
1441
+ const cpCount = cps.length;
1161
1442
  if (candidates * CONTAINING_CANDIDATE_SHARE < cpCount) {
1162
- const segments = graphemeSegmenter().segment(body);
1443
+ /** @type {Intl.Segments | null} */
1444
+ let segments = null;
1163
1445
  return (cp) => {
1446
+ const local = localCluster(cps, cp);
1447
+ if (local !== null) return local;
1448
+ segments ??= graphemeSegmenter().segment(body);
1449
+ const offsets = offsetsOf();
1164
1450
  // `offsets[cp]` is inside the string for every code-point index the walk
1165
1451
  // asks about, so the segment always exists.
1166
1452
  const found = /** @type {{ index: number, segment: string }} */ (
@@ -1173,13 +1459,17 @@ function clusterResolver(body, offsets, candidates) {
1173
1459
  };
1174
1460
  }
1175
1461
  // Dense candidates: walk the segmenter forward instead, keeping a code-point
1176
- // cursor in step with it so no boundary needs searching for. The iterator is
1177
- // abandoned wherever the last candidate leaves it, so a document whose
1178
- // preserve budget is spent in its first line is never segmented past it.
1179
- const iterator = graphemeSegmenter().segment(body)[Symbol.iterator]();
1462
+ // cursor in step with it. Candidates arrive in increasing order, so this is
1463
+ // one pass, abandoned wherever the last candidate leaves it.
1464
+ /** @type {Iterator<Intl.SegmentData> | null} */
1465
+ let iterator = null;
1180
1466
  let start = 0;
1181
1467
  let end = 0;
1182
1468
  return (cp) => {
1469
+ const local = localCluster(cps, cp);
1470
+ if (local !== null) return local;
1471
+ iterator ??= graphemeSegmenter().segment(body)[Symbol.iterator]();
1472
+ const offsets = offsetsOf();
1183
1473
  while (end <= cp) {
1184
1474
  // ECMA-402 guarantees the segments partition the input, so every code
1185
1475
  // point inside it has a cluster and the iterator cannot run out first; if
@@ -1220,49 +1510,114 @@ function clusterResolver(body, offsets, candidates) {
1220
1510
  * makes the caller claim a strip that did not happen, and a stuffed channel
1221
1511
  * surfaces as its category once it overruns the budget.
1222
1512
  * @param {string} body
1223
- * @param {Int32Array} [cps] `body`'s code points, when the caller already has them
1224
- * @param {ReturnType<typeof analyzeCarve>} [analysis] likewise the analysis
1225
- * @returns {{ cleaned: string, found: string[] }}
1513
+ * @param {Int32Array} cps `body`'s code points
1514
+ * @param {ReturnType<typeof analyzeCarve>} analysis the analysis of `cps`
1515
+ * @returns {{ cleaned: string, found: string[], removed: number }}
1226
1516
  */
1227
- function carveStrip(
1228
- body,
1229
- cps = codePointArray(body),
1230
- analysis = analyzeCarve(cps),
1231
- ) {
1517
+ function carveStrip(body, cps, analysis) {
1518
+ const walk = carveWalk(body, cps, analysis, true);
1519
+ const kept = /** @type {string[]} */ (walk.kept);
1520
+ const found = CHECKS.filter(([code]) => walk.foundCodes.has(code)).map(
1521
+ ([code]) => code,
1522
+ );
1523
+ return { cleaned: kept.join(""), found, removed: walk.removed };
1524
+ }
1525
+
1526
+ // A low surrogate that does not complete a pair.
1527
+ const LONE_LOW_SURROGATE_RE = /(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
1528
+
1529
+ /** @param {number} cp @returns {boolean} */
1530
+ const isHighSurrogate = (cp) => cp >= 0xd800 && cp <= 0xdbff;
1531
+ /** @param {number} cp @returns {boolean} */
1532
+ const isLowSurrogate = (cp) => cp >= 0xdc00 && cp <= 0xdfff;
1533
+
1534
+ /**
1535
+ * How many code points {@link carveStrip} removes from `body`, without
1536
+ * building the cleaned text — all a verdict needs.
1537
+ * @param {string} body @param {Int32Array} cps
1538
+ * @param {ReturnType<typeof analyzeCarve>} analysis
1539
+ * @returns {number}
1540
+ */
1541
+ function carveRemoved(body, cps, analysis) {
1542
+ return carveWalk(body, cps, analysis, false).removed;
1543
+ }
1544
+
1545
+ /**
1546
+ * The walk both of the above read (see {@link carveStrip}); `kept` is null
1547
+ * unless `build`, and `foundCodes` is read only off a built walk.
1548
+ * @param {string} body @param {Int32Array} cps
1549
+ * @param {ReturnType<typeof analyzeCarve>} analysis @param {boolean} build
1550
+ * @returns {{ kept: string[] | null, foundCodes: Set<string | null>, removed: number }}
1551
+ */
1552
+ function carveWalk(body, cps, analysis, build) {
1232
1553
  // Only PAYLOAD invisibles count toward the scatter floor, so a
1233
1554
  // meaningful-joiner-dense text (formal Persian, a long Devanagari conjunct
1234
1555
  // run) stays under it.
1235
- const { codes, kind, payloadInvis, visibleLen } = analysis;
1556
+ const { codes, kind, invisible, payloadInvis, visibleLen } = analysis;
1236
1557
  // SCATTERED_THRESHOLD is the floor on payload invisibles: past it the document
1237
1558
  // is drowning in hidden bytes, so the carve-out is off and even a meaningful
1238
1559
  // joiner is stripped (threshold-evasion catch — over-strip beats under).
1239
1560
  const allowCarveOut = payloadInvis < SCATTERED_THRESHOLD;
1561
+ const n = cps.length;
1562
+ // Every code point that draws on the joiner/selector budget, in order. Blank
1563
+ // fillers (budgeted in analyzeCarve) and glyph spelling are exempt.
1564
+ const candidateBuffer = new Int32Array(allowCarveOut ? invisible.length : 0);
1565
+ let candidateCount = 0;
1566
+ // The exempt invisibles, every one of them preserved under allowCarveOut.
1567
+ let exemptCount = 0;
1568
+ if (allowCarveOut)
1569
+ for (const k of invisible)
1570
+ if (kind[k] === KIND_BLANK || kind[k] === KIND_GLYPH) exemptCount++;
1571
+ else if (kind[k] !== KIND_NONE) candidateBuffer[candidateCount++] = k;
1572
+ const candidates = candidateBuffer.subarray(0, candidateCount);
1573
+ // A count-only walk needs no per-code-point pass where nothing but the
1574
+ // exempt kinds survives: what it strips is every invisible not preserved,
1575
+ // totalled at the end, and the run counters only reset there. A lone low
1576
+ // surrogate is the exception — a strip can close a lone high one against it
1577
+ // into a pair (see `removed`), which only the full loop counts.
1578
+ const tally = !build && !LONE_LOW_SURROGATE_RE.test(body);
1579
+
1240
1580
  // Document-wide preserve allowance, proportional to visible text but never
1241
1581
  // below the floor (see TOTAL_PRESERVED_JOINER_BUDGET / PRESERVED_JOINER_PER_VISIBLE)
1242
- // and never above PRESERVE_HARD_CAP — the absolute ceiling stops the channel
1243
- // scaling with attacker-supplied cover text.
1244
- const maxPreserved = Math.min(
1582
+ // and never above PRESERVE_HARD_CAP. Every preserved kind draws on it.
1583
+ const coverAllowance = Math.min(
1245
1584
  PRESERVE_HARD_CAP,
1246
1585
  Math.max(
1247
1586
  TOTAL_PRESERVED_JOINER_BUDGET,
1248
1587
  Math.ceil(visibleLen / PRESERVED_JOINER_PER_VISIBLE),
1249
1588
  ),
1250
1589
  );
1251
-
1252
- const n = cps.length;
1253
- // Every code point that draws on the joiner/selector budget, in order. Blank
1254
- // fillers are exempt (their allowance is already spent in analyzeCarve), so a
1255
- // cluster holding only those needs no boundary of its own.
1256
- /** @type {number[]} */
1257
- const candidates = [];
1258
- if (allowCarveOut)
1259
- for (let k = 0; k < n; k++)
1260
- if (kind[k] !== KIND_NONE && kind[k] !== KIND_BLANK) candidates.push(k);
1261
-
1590
+ // The allowance joining letters buy past the cap, for JOINERS alone (a
1591
+ // selector beside Persian does no orthographic work). The total of every kind
1592
+ // stays under it; non-joiners also stay under coverAllowance. Counted only
1593
+ // when it could bind: the candidates outrun the cap and include a joiner.
1594
+ const joinerAllowance =
1595
+ candidates.length <= coverAllowance ||
1596
+ !candidates.some((c) => kind[c] === KIND_JOINER)
1597
+ ? coverAllowance
1598
+ : Math.max(
1599
+ coverAllowance,
1600
+ Math.ceil(
1601
+ joiningLetterCount(cps, codes) / PRESERVED_JOINER_PER_VISIBLE,
1602
+ ),
1603
+ );
1604
+
1605
+ /** @type {Set<string | null>} */
1262
1606
  const foundCodes = new Set();
1263
1607
  /** @type {string[]} */
1264
1608
  const kept = [];
1265
- const offsets = u16Offsets(cps);
1609
+ // Code points the cleaned text has fewer of than `body`: one per stripped
1610
+ // code point, plus one where the strip closes a lone high surrogate against a
1611
+ // lone low one into a pair — so it equals the `[...cleaned].length` gap.
1612
+ let removed = 0;
1613
+ // Whether the last code point was stripped, and the one kept before it.
1614
+ let stripOpen = false;
1615
+ let mergeLeft = -1;
1616
+ // Only the built text and the segmenter speak UTF-16, so a count-only walk
1617
+ // whose clusters all settle on pair verdicts never pays for the offsets.
1618
+ /** @type {Int32Array | null} */
1619
+ let offsets = null;
1620
+ const offsetsOf = () => (offsets ??= u16Offsets(cps));
1266
1621
  // Start of the kept run currently being accumulated, in UTF-16 units. Output
1267
1622
  // is assembled from slices of `body` — one per uninterrupted kept run — rather
1268
1623
  // than a character at a time, so a document with nothing to strip costs one
@@ -1280,47 +1635,83 @@ function carveStrip(
1280
1635
  // surplus is stripped, closing the high-bit-rate variation-selector channel.
1281
1636
  let selectorRun = 0;
1282
1637
  // Preserved chars across the WHOLE string — never reset at a gap, so it bounds
1283
- // the document-wide channel joinerRun cannot. Past maxPreserved a preserved
1638
+ // the document-wide channel joinerRun cannot. Past joinerAllowance a preserved
1284
1639
  // char is stripped and its category reported.
1285
1640
  let preservedTotal = 0;
1641
+ // The non-joiner share of preservedTotal, bounded by coverAllowance.
1642
+ let preservedOther = 0;
1286
1643
  let prevVisible = false;
1287
1644
 
1645
+ // The first entry of `invisible` the emit loop has not passed.
1646
+ let nextInvisible = 0;
1288
1647
  /** Emit `[from, to)` under a settled `fits`, updating every counter.
1289
1648
  * @param {number} from @param {number} to @param {boolean} fits */
1290
1649
  const emit = (from, to, fits) => {
1650
+ if (tally && !fits) {
1651
+ if (
1652
+ (prevVisible && codes[from] === CODE_VISIBLE) ||
1653
+ gapFrom(from + 1) < to
1654
+ ) {
1655
+ joinerRun = 0;
1656
+ selectorRun = 0;
1657
+ }
1658
+ prevVisible = codes[to - 1] === CODE_VISIBLE;
1659
+ return;
1660
+ }
1291
1661
  for (let k = from; k < to; k++) {
1292
1662
  const code = codes[k];
1293
1663
  if (code === CODE_VISIBLE) {
1294
- // A visible char following another visible char is a real word/segment
1295
- // boundary, not a join — the joined cluster (if any) ended here.
1296
- if (prevVisible) {
1664
+ // A stretch of visible code points at once, up to the next invisible:
1665
+ // they stay inside the kept run, only the first can close a strip, and
1666
+ // a visible char following another visible char is a real word/segment
1667
+ // boundary, not a join — the joined cluster (if any) ended there.
1668
+ while (nextInvisible < invisible.length && invisible[nextInvisible] < k)
1669
+ nextInvisible++;
1670
+ const stop =
1671
+ nextInvisible < invisible.length
1672
+ ? Math.min(to, invisible[nextInvisible])
1673
+ : to;
1674
+ if (stripOpen) {
1675
+ stripOpen = false;
1676
+ if (isHighSurrogate(mergeLeft) && isLowSurrogate(cps[k])) removed++;
1677
+ }
1678
+ if (prevVisible || stop - k > 1) {
1297
1679
  joinerRun = 0;
1298
1680
  selectorRun = 0;
1299
1681
  }
1300
1682
  prevVisible = true;
1301
- continue; // ordinary visible character: stays inside the kept run
1683
+ k = stop - 1;
1684
+ continue;
1302
1685
  }
1303
- // A blank filler rides on allowCarveOut alone (its own allowance is
1304
- // already spent in analyzeCarve); everything else rides on `fits`.
1305
- if (
1306
- kind[k] === KIND_BLANK ? allowCarveOut : fits && kind[k] !== KIND_NONE
1307
- ) {
1686
+ // A blank filler and a glyph's spelling ride on allowCarveOut alone;
1687
+ // everything else rides on `fits`.
1688
+ const exempt = kind[k] === KIND_BLANK || kind[k] === KIND_GLYPH;
1689
+ if (exempt ? allowCarveOut : fits && kind[k] !== KIND_NONE) {
1308
1690
  if (kind[k] === KIND_JOINER) joinerRun++;
1309
1691
  if (kind[k] === KIND_IVS || kind[k] === KIND_STDVS) selectorRun++;
1310
- if (kind[k] !== KIND_BLANK) preservedTotal++;
1692
+ if (!exempt) preservedTotal++;
1693
+ if (!exempt && kind[k] !== KIND_JOINER) preservedOther++;
1311
1694
  prevVisible = false; // a joiner/selector/tag/blank keeps the cluster open
1695
+ stripOpen = false;
1312
1696
  continue;
1313
1697
  }
1314
1698
  foundCodes.add(CODE_CATEGORY[code]);
1699
+ removed++;
1700
+ if (!stripOpen) {
1701
+ stripOpen = true;
1702
+ mergeLeft = k > 0 ? cps[k - 1] : -1;
1703
+ }
1315
1704
  prevVisible = false; // a stripped invisible neither opens nor closes a gap
1316
- if (offsets[k] > runStart) kept.push(body.slice(runStart, offsets[k]));
1317
- runStart = offsets[k + 1];
1705
+ if (!build) continue;
1706
+ const at = offsetsOf();
1707
+ if (at[k] > runStart) kept.push(body.slice(runStart, at[k]));
1708
+ runStart = at[k + 1];
1318
1709
  }
1319
1710
  };
1320
1711
 
1321
1712
  const clusterOf =
1322
1713
  candidates.length > 0
1323
- ? clusterResolver(body, offsets, candidates.length)
1714
+ ? clusterResolver(body, cps, offsetsOf, candidates.length)
1324
1715
  : null;
1325
1716
  // The last index at which the emit loop resets the run counters — a visible
1326
1717
  // code point directly after another visible one. The condition reads only
@@ -1348,19 +1739,68 @@ function carveStrip(
1348
1739
 
1349
1740
  /** True when no cluster from here on can be preserved, so the rest of the
1350
1741
  * document is settled and needs no boundaries. Every term is monotone:
1351
- * `preservedTotal` only grows, and past `lastGap` the run counters only grow
1352
- * too, so a limit that is spent here is spent for good.
1742
+ * `preservedTotal` and `preservedOther` only grow, and past `lastGap` the
1743
+ * run counters only grow too, so a limit that is spent here is spent for good.
1353
1744
  * @param {number} at */
1354
1745
  const nothingLeftFits = (at) => {
1355
- if (preservedTotal >= maxPreserved) return true;
1746
+ if (preservedTotal >= joinerAllowance) return true;
1747
+ // Every non-joiner candidate needs room under coverAllowance.
1748
+ const othersSpent = preservedOther >= coverAllowance;
1749
+ if (othersSpent && leftJoiner === 0) return true;
1356
1750
  if (at <= lastGap) return false; // a reset ahead can reopen a spent run
1357
- if (leftOther > 0) return false; // a tag/presentation selector draws on neither run
1751
+ if (!othersSpent && leftOther > 0) return false; // a presentation selector draws on neither run
1358
1752
  if (leftJoiner > 0 && joinerRun < CONSECUTIVE_JOINER_CAP) return false;
1359
- if (leftSelector > 0 && selectorRun < CONSECUTIVE_SELECTOR_CAP)
1753
+ if (
1754
+ !othersSpent &&
1755
+ leftSelector > 0 &&
1756
+ selectorRun < CONSECUTIVE_SELECTOR_CAP
1757
+ )
1360
1758
  return false;
1361
1759
  return true;
1362
1760
  };
1363
1761
 
1762
+ /** Take the candidates from index `from` up to code point `end` off the
1763
+ * remaining counts. @param {number} from @param {number} end */
1764
+ const settle = (from, end) => {
1765
+ for (let j = from; j < candidates.length && candidates[j] < end; j++) {
1766
+ if (kind[candidates[j]] === KIND_JOINER) leftJoiner--;
1767
+ else if (
1768
+ kind[candidates[j]] === KIND_IVS ||
1769
+ kind[candidates[j]] === KIND_STDVS
1770
+ )
1771
+ leftSelector--;
1772
+ else leftOther--;
1773
+ }
1774
+ };
1775
+
1776
+ // The first gap at or after a code-point index (n when none) — the next place
1777
+ // the emit loop can reset a run counter. Asked in increasing order, so the
1778
+ // cursor makes every reading together one pass.
1779
+ let gap = -1;
1780
+ /** @param {number} at */
1781
+ const gapFrom = (at) => {
1782
+ if (gap >= at) return gap;
1783
+ gap = Math.max(at, 1);
1784
+ while (
1785
+ gap < n &&
1786
+ !(codes[gap] === CODE_VISIBLE && codes[gap - 1] === CODE_VISIBLE)
1787
+ )
1788
+ gap++;
1789
+ return gap;
1790
+ };
1791
+
1792
+ /** True when candidate `c`, reached before the next gap, cannot be preserved:
1793
+ * a limit it draws on is already spent, and nothing short of that gap
1794
+ * restores one. Its whole cluster is then stripped, wherever it begins.
1795
+ * @param {number} c */
1796
+ const spent = (c) =>
1797
+ preservedTotal >= joinerAllowance ||
1798
+ (kind[c] === KIND_JOINER
1799
+ ? joinerRun >= CONSECUTIVE_JOINER_CAP
1800
+ : preservedOther >= coverAllowance ||
1801
+ ((kind[c] === KIND_IVS || kind[c] === KIND_STDVS) &&
1802
+ selectorRun >= CONSECUTIVE_SELECTOR_CAP));
1803
+
1364
1804
  let k = 0;
1365
1805
  let next = 0;
1366
1806
  while (k < n) {
@@ -1373,6 +1813,24 @@ function carveStrip(
1373
1813
  emit(k, n, false);
1374
1814
  break;
1375
1815
  }
1816
+ // Strip a stretch of spent candidates in one emit, resolving only the
1817
+ // cluster of the last: an ideograph-selector chain past its cap would
1818
+ // otherwise pay a cluster lookup per selector for a verdict already known.
1819
+ let last = next;
1820
+ const reset = gapFrom(k);
1821
+ while (
1822
+ last < candidates.length &&
1823
+ candidates[last] < reset &&
1824
+ spent(candidates[last])
1825
+ )
1826
+ last++;
1827
+ if (last > next) {
1828
+ const { end } = clusterOf(candidates[last - 1]);
1829
+ emit(k, end, false);
1830
+ settle(next, end);
1831
+ k = end;
1832
+ continue;
1833
+ }
1376
1834
  const { start, end } = clusterOf(candidates[next]);
1377
1835
  if (start > k) emit(k, start, false);
1378
1836
  // Charge the WHOLE cluster's preservables against every limit at once: if
@@ -1382,7 +1840,12 @@ function carveStrip(
1382
1840
  let joiners = 0;
1383
1841
  let selectors = 0;
1384
1842
  for (let j = start; j < end; j++) {
1385
- if (kind[j] === KIND_NONE || kind[j] === KIND_BLANK) continue;
1843
+ if (
1844
+ kind[j] === KIND_NONE ||
1845
+ kind[j] === KIND_BLANK ||
1846
+ kind[j] === KIND_GLYPH
1847
+ )
1848
+ continue;
1386
1849
  need++;
1387
1850
  if (kind[j] === KIND_JOINER) joiners++;
1388
1851
  if (kind[j] === KIND_IVS || kind[j] === KIND_STDVS) selectors++;
@@ -1406,29 +1869,51 @@ function carveStrip(
1406
1869
  }
1407
1870
  seenVisible = codes[j] === CODE_VISIBLE;
1408
1871
  }
1409
- for (let j = next; j < candidates.length && candidates[j] < end; j++) {
1410
- if (kind[candidates[j]] === KIND_JOINER) leftJoiner--;
1411
- else if (
1412
- kind[candidates[j]] === KIND_IVS ||
1413
- kind[candidates[j]] === KIND_STDVS
1414
- )
1415
- leftSelector--;
1416
- else leftOther--;
1417
- }
1872
+ settle(next, end);
1418
1873
  emit(
1419
1874
  start,
1420
1875
  end,
1421
- preservedTotal + need <= maxPreserved &&
1876
+ preservedTotal + need <= joinerAllowance &&
1877
+ preservedOther + need - joiners <= coverAllowance &&
1422
1878
  runJoiner + joiners <= CONSECUTIVE_JOINER_CAP &&
1423
1879
  runSelector + selectors <= CONSECUTIVE_SELECTOR_CAP,
1424
1880
  );
1425
1881
  k = end;
1426
1882
  }
1883
+ if (tally)
1884
+ removed =
1885
+ invisible.length - (allowCarveOut ? exemptCount : 0) - preservedTotal;
1886
+ if (!build) return { kept: null, foundCodes, removed };
1427
1887
  kept.push(body.slice(runStart));
1428
- const found = CHECKS.filter(([code]) => foundCodes.has(code)).map(
1429
- ([code]) => code,
1430
- );
1431
- return { cleaned: kept.join(""), found };
1888
+ return { kept, foundCodes, removed };
1889
+ }
1890
+
1891
+ /**
1892
+ * @typedef {{ body: string, cps: Int32Array,
1893
+ * analysis: ReturnType<typeof analyzeCarve>,
1894
+ * strip: { cleaned: string, found: string[], removed: number } | null,
1895
+ * removed: number | null }} CarveEntry
1896
+ */
1897
+
1898
+ /** @type {CarveEntry | null} */
1899
+ let lastCarve = null;
1900
+
1901
+ /**
1902
+ * The carve analysis of `body`, with the most recent one remembered — and its
1903
+ * strip, or removal count, once asked for. One hook call often carves one
1904
+ * document several times (the payload view, the effective count, then
1905
+ * {@link isIncidentalInvisible}); every reading is a pure function of `body`,
1906
+ * so the cache changes what a verdict costs, never the verdict. One entry,
1907
+ * recorded only after the analysis returns.
1908
+ * @param {string} body
1909
+ * @returns {CarveEntry}
1910
+ */
1911
+ function carveOf(body) {
1912
+ if (lastCarve !== null && lastCarve.body === body) return lastCarve;
1913
+ const cps = codePointArray(body);
1914
+ const analysis = analyzeCarve(cps);
1915
+ lastCarve = { body, cps, analysis, strip: null, removed: null };
1916
+ return lastCarve;
1432
1917
  }
1433
1918
 
1434
1919
  // Any tag char (the whole U+E0000–U+E007F block is category Cf).
@@ -1456,17 +1941,21 @@ function needsCarveOut(body) {
1456
1941
  }
1457
1942
 
1458
1943
  /**
1459
- * The `text` with every carve-out-PRESERVABLE invisible (joiners/selectors/tags/
1460
- * blank fillers doing real rendering work) replaced by a space, leaving only the
1461
- * PAYLOAD invisibles in place. The LONG_RUN injection probe runs over this so a
1462
- * legitimate emoji/flag/variation sequence never trips the "possible injection
1463
- * payload" marker (alert fatigue), while a genuine hidden run still surfaces.
1944
+ * The `text` with every visible character and every carve-out-PRESERVABLE
1945
+ * invisible (joiners/selectors/tags/blank fillers doing real rendering work)
1946
+ * replaced by spaces — one per UTF-16 unit, so the view is index-aligned with
1947
+ * `text` — leaving only the PAYLOAD invisibles in place. The LONG_RUN injection
1948
+ * probe runs over this so a legitimate emoji/flag/variation sequence never trips
1949
+ * the "possible injection payload" marker (alert fatigue), while a genuine
1950
+ * hidden run still surfaces.
1464
1951
  * @param {string} text
1465
1952
  * @returns {string}
1466
1953
  */
1467
1954
  export function payloadInvisibleView(text) {
1468
- const cps = codePointArray(text);
1469
- const { codes, kind } = analyzeCarve(cps);
1955
+ const {
1956
+ cps,
1957
+ analysis: { codes, kind },
1958
+ } = carveOf(text);
1470
1959
  const offsets = u16Offsets(cps);
1471
1960
  /** @param {number} i */
1472
1961
  const isPayload = (i) => codes[i] !== CODE_VISIBLE && kind[i] === KIND_NONE;
@@ -1479,9 +1968,8 @@ export function payloadInvisibleView(text) {
1479
1968
  while (i < cps.length) {
1480
1969
  const maskFrom = i;
1481
1970
  while (i < cps.length && !isPayload(i)) i++;
1482
- // One space per masked CODE POINT, so the view stays code-point-aligned with
1483
- // `text` even where an astral character is two UTF-16 units wide.
1484
- if (i > maskFrom) parts.push(" ".repeat(i - maskFrom));
1971
+ // One space per masked UTF-16 UNIT, so view indices are `text` indices.
1972
+ if (i > maskFrom) parts.push(" ".repeat(offsets[i] - offsets[maskFrom]));
1485
1973
  const keepFrom = i;
1486
1974
  while (i < cps.length && isPayload(i)) i++;
1487
1975
  if (i > keepFrom) parts.push(text.slice(offsets[keepFrom], offsets[i]));
@@ -1512,13 +2000,36 @@ export function payloadInvisibleView(text) {
1512
2000
  * @returns {string | null}
1513
2001
  */
1514
2002
  export function payloadLongRunSample(text) {
1515
- // The view is code-point-for-code-point with `text` and only ever REPLACES an
1516
- // invisible with a space, so a run in the view is a run in `text`: no long run
1517
- // in the raw text means none in the view. This hides no payload — the bulk
1518
- // scan reads the whole text, and a run it finds still goes through the full
1519
- // carve analysis below to decide what of it is really payload.
1520
- if (!hasLongRun(text)) return null;
1521
- return findLongRuns(payloadInvisibleView(text)).next().value?.text ?? null;
2003
+ return payloadLongRuns(text).next().value?.text ?? null;
2004
+ }
2005
+
2006
+ /**
2007
+ * Every payload-invisible LONG RUN in `text`, as {@link findLongRuns} reports
2008
+ * them; `index` is valid in `text` (the view is index-aligned).
2009
+ * @param {string} text
2010
+ * @returns {Generator<{ index: number, text: string, charCount: number }>}
2011
+ */
2012
+ function* payloadLongRuns(text) {
2013
+ // The view only REPLACES invisibles with spaces, so no long run in `text`
2014
+ // means none in the view. With the analysis in hand the question is answered
2015
+ // off it exactly, cheaper than the regex on text dense with short runs.
2016
+ const known = lastCarve?.body === text ? lastCarve.analysis : null;
2017
+ if (known === null ? !hasLongRun(text) : !hasPayloadRun(known)) return;
2018
+ yield* findLongRuns(payloadInvisibleView(text));
2019
+ }
2020
+
2021
+ /** Whether the analysis holds {@link LONG_RUN_THRESHOLD} consecutive payload
2022
+ * code points — exactly when {@link payloadInvisibleView} holds a long run.
2023
+ * @param {ReturnType<typeof analyzeCarve>} analysis @returns {boolean} */
2024
+ function hasPayloadRun({ invisible, kind }) {
2025
+ let run = 0; // payload code points in a row, ending at the previous invisible
2026
+ let prev = -2;
2027
+ for (const i of invisible) {
2028
+ run = kind[i] !== KIND_NONE ? 0 : i === prev + 1 ? run + 1 : 1;
2029
+ if (run >= LONG_RUN_THRESHOLD) return true;
2030
+ prev = i;
2031
+ }
2032
+ return false;
1522
2033
  }
1523
2034
 
1524
2035
  /**
@@ -1551,12 +2062,20 @@ export function countEffectiveInvisible(text) {
1551
2062
  // the two would read different strings; that case pays for both separately.
1552
2063
  if (text.charCodeAt(0) === 0xfeff)
1553
2064
  return surplusOver(countPayloadInvisible(text), text, stripInvisible(text));
1554
- // Otherwise both terms are readings of ONE carve analysis of ONE string, so
1555
- // the analysis is done once and handed to the strip.
1556
- const cps = codePointArray(text);
1557
- const analysis = analyzeCarve(cps);
1558
- const { cleaned } = stripBody(text, cps, analysis);
1559
- return surplusOver(analysis.payloadInvis, text, cleaned, cps.length);
2065
+ // Otherwise both terms read ONE carve analysis (see carveOf).
2066
+ const entry = carveOf(text);
2067
+ const payload = entry.analysis.payloadInvis;
2068
+ if (!needsCarveOut(text))
2069
+ return surplusOver(
2070
+ payload,
2071
+ text,
2072
+ bulkStrip(text).cleaned,
2073
+ entry.cps.length,
2074
+ );
2075
+ // Only the count is needed, so the walk need not assemble the text.
2076
+ entry.removed ??=
2077
+ entry.strip?.removed ?? carveRemoved(text, entry.cps, entry.analysis);
2078
+ return payload + Math.max(0, entry.removed - payload);
1560
2079
  }
1561
2080
 
1562
2081
  /**
@@ -1607,6 +2126,27 @@ export function isIncidentalInvisible(text) {
1607
2126
  );
1608
2127
  }
1609
2128
 
2129
+ /**
2130
+ * THE decision whether `text` carries a payload-capable invisible channel: a
2131
+ * payload LONG RUN, or {@link SCATTERED_THRESHOLD} or more effective payload
2132
+ * invisibles. Every gate reads this one verdict, so a Persian paragraph cannot
2133
+ * be a payload to one and clean to another. It stops at the first run, so an
2134
+ * 8 MB field of short runs costs one record.
2135
+ * @param {string} text the text a consumer acts on, ANSI-stripped where its
2136
+ * surface can carry escapes (a sequence can hide invisibles)
2137
+ * @returns {{ firstRun: { index: number, text: string, charCount: number } | null, invisibleCount: number, payloadCapable: boolean }}
2138
+ */
2139
+ export function payloadVerdict(text) {
2140
+ // The count first: it carves the text, which the run search then reads.
2141
+ const invisibleCount = countEffectiveInvisible(text);
2142
+ const firstRun = payloadLongRuns(text).next().value ?? null;
2143
+ return {
2144
+ firstRun,
2145
+ invisibleCount,
2146
+ payloadCapable: firstRun !== null || invisibleCount >= SCATTERED_THRESHOLD,
2147
+ };
2148
+ }
2149
+
1610
2150
  /**
1611
2151
  * Strip payload-capable invisible chars and report which categories were
1612
2152
  * removed. A single leading U+FEFF (BOM) is preserved as a legitimate marker;
@@ -1619,34 +2159,41 @@ export function isIncidentalInvisible(text) {
1619
2159
  * decide whether a leading BOM is genuinely leading: an interior BOM that an
1620
2160
  * ANSI-strip left at index 0 of `text` (e.g. `ESC[m + interior U+FEFF`) must NOT be treated as a
1621
2161
  * legitimate leading marker. Defaults to `text` for the common single-arg call.
2162
+ *
2163
+ * `carveOut: false` strips EVERY payload-capable invisible, preserved ones
2164
+ * included — the monotone fallback for a composition that fails to converge.
1622
2165
  * @param {string} text
1623
2166
  * @param {string} [originalText]
2167
+ * @param {{ carveOut?: boolean }} [options]
1624
2168
  * @returns {{ cleaned: string, found: string[] }}
1625
2169
  */
1626
- export function stripInvisibleWithReport(text, originalText = text) {
2170
+ export function stripInvisibleWithReport(
2171
+ text,
2172
+ originalText = text,
2173
+ { carveOut = true } = {},
2174
+ ) {
1627
2175
  // Leading only when the BOM leads the ORIGINAL text (not merely `text` after an
1628
2176
  // ANSI strip shifted an interior BOM to index 0). The `text` guard keeps the
1629
2177
  // slice sound when the two disagree.
1630
2178
  const hasLeadingBom =
1631
2179
  originalText.charCodeAt(0) === 0xfeff && text.charCodeAt(0) === 0xfeff;
1632
2180
  const body = hasLeadingBom ? text.slice(1) : text;
1633
- const { cleaned, found } = stripBody(body);
2181
+ const { cleaned, found } = carveOut ? stripBody(body) : bulkStrip(body);
1634
2182
  return { cleaned: hasLeadingBom ? BOM + cleaned : cleaned, found };
1635
2183
  }
1636
2184
 
1637
2185
  /**
1638
2186
  * The strip of a BOM-resolved body: the carve-out walk when anything in it could
1639
- * be preserved, the single bulk regex pass otherwise. `cps` and `analysis` let a
1640
- * caller that has already analyzed `body` hand the work over instead of paying
1641
- * for it twice.
2187
+ * be preserved, the single bulk regex pass otherwise.
1642
2188
  * @param {string} body
1643
- * @param {Int32Array} [cps]
1644
- * @param {ReturnType<typeof analyzeCarve>} [analysis]
1645
2189
  * @returns {{ cleaned: string, found: string[] }}
1646
2190
  */
1647
- function stripBody(body, cps, analysis) {
2191
+ function stripBody(body) {
1648
2192
  if (!needsCarveOut(body)) return bulkStrip(body);
1649
- return carveStrip(body, cps, analysis);
2193
+ const entry = carveOf(body);
2194
+ entry.strip ??= carveStrip(body, entry.cps, entry.analysis);
2195
+ // A copy, so a caller that edits its `found` cannot edit the cached one.
2196
+ return { cleaned: entry.strip.cleaned, found: [...entry.strip.found] };
1650
2197
  }
1651
2198
 
1652
2199
  /**