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/THREAT-MODEL.md +43 -14
- package/claude-hooks/lib/authored-content.mjs +60 -43
- package/package.json +4 -1
- package/python/agent_sanitizer/data/invisible-charset.json +2 -2
- package/src/ansi.mjs +105 -26
- package/src/cf-charset.mjs +6 -6
- package/src/index.mjs +2 -0
- package/src/instructions.mjs +20 -25
- package/src/invisible.mjs +722 -175
- package/src/joining-type.mjs +57 -10
- package/src/layer1.mjs +134 -31
- package/src/prompt.mjs +20 -22
- package/src/standardized-variants.mjs +69 -9
- package/src/unicode-properties.mjs +365 -0
- package/types/ansi.d.mts +45 -16
- package/types/cf-charset.d.mts +5 -5
- package/types/index.d.mts +1 -1
- package/types/instructions.d.mts +6 -2
- package/types/invisible.d.mts +61 -6
- package/types/joining-type.d.mts +5 -2
- package/types/layer1.d.mts +39 -9
- package/types/src/ansi.d.mts +45 -16
- package/types/src/cf-charset.d.mts +5 -5
- package/types/src/instructions.d.mts +6 -2
- package/types/src/invisible.d.mts +61 -6
- package/types/src/joining-type.d.mts +5 -2
- package/types/src/standardized-variants.d.mts +8 -8
- package/types/src/unicode-properties.d.mts +29 -0
- package/types/standardized-variants.d.mts +8 -8
- package/types/unicode-properties.d.mts +29 -0
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 {
|
|
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
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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),
|
|
454
|
-
// the
|
|
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
|
|
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
|
-
|
|
472
|
-
|
|
542
|
+
/** @type {ReadonlyArray<readonly [number, number]>} */
|
|
543
|
+
const CJK_COMPATIBILITY_IDEOGRAPH_BLOCKS = [
|
|
544
|
+
[0xf900, 0xfaff],
|
|
545
|
+
[0x2f800, 0x2fa1f],
|
|
546
|
+
];
|
|
473
547
|
|
|
474
|
-
/**
|
|
475
|
-
*
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
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
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
const
|
|
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
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
610
|
-
//
|
|
611
|
-
const
|
|
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 <
|
|
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
|
|
636
|
-
*
|
|
637
|
-
*
|
|
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
|
-
|
|
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 || !
|
|
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
|
|
816
|
-
if (followsBrahmicConjunct(cps, i)
|
|
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] =
|
|
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
|
|
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
|
|
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
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
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 <
|
|
1049
|
-
|
|
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
|
-
*
|
|
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,
|
|
1160
|
-
const cpCount =
|
|
1440
|
+
function clusterResolver(body, cps, offsetsOf, candidates) {
|
|
1441
|
+
const cpCount = cps.length;
|
|
1161
1442
|
if (candidates * CONTAINING_CANDIDATE_SHARE < cpCount) {
|
|
1162
|
-
|
|
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
|
|
1177
|
-
// abandoned wherever the last candidate leaves it
|
|
1178
|
-
|
|
1179
|
-
|
|
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}
|
|
1224
|
-
* @param {ReturnType<typeof analyzeCarve>}
|
|
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
|
-
|
|
1230
|
-
|
|
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
|
|
1243
|
-
|
|
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
|
-
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1295
|
-
//
|
|
1296
|
-
|
|
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
|
-
|
|
1683
|
+
k = stop - 1;
|
|
1684
|
+
continue;
|
|
1302
1685
|
}
|
|
1303
|
-
// A blank filler
|
|
1304
|
-
//
|
|
1305
|
-
|
|
1306
|
-
|
|
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 (
|
|
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 (
|
|
1317
|
-
|
|
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,
|
|
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
|
|
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 >=
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 <=
|
|
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
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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
|
|
1460
|
-
* blank fillers doing real rendering work)
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
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
|
|
1469
|
-
|
|
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
|
|
1483
|
-
|
|
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
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
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
|
|
1555
|
-
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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(
|
|
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.
|
|
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
|
|
2191
|
+
function stripBody(body) {
|
|
1648
2192
|
if (!needsCarveOut(body)) return bulkStrip(body);
|
|
1649
|
-
|
|
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
|
/**
|