text-sanctifier 1.3.5 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -7
- package/bin/text-sanctifier.js +1 -1
- package/dist/text-sanctifier.min.js +5 -5
- package/dist/types/context.d.ts +56 -0
- package/dist/types/hiddenText.d.ts +71 -6
- package/dist/types/index.d.ts +7 -1
- package/dist/types/sanctifyText.d.ts +141 -110
- package/dist/types/textPatterns.d.ts +15 -0
- package/dist/types/version.generated.d.ts +2 -0
- package/package.json +8 -4
- package/src/context.js +177 -0
- package/src/hiddenText.js +139 -26
- package/src/index.d.ts +7 -1
- package/src/index.js +5 -2
- package/src/inspectText.js +2 -8
- package/src/sanctifyText.js +138 -141
- package/src/textPatterns.js +78 -0
- package/src/version.generated.js +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "text-sanctifier",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A brutal text normalizer and invisible trash scrubber for modern web projects.",
|
|
6
6
|
"engines": {
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"types": "./dist/types/hiddenText.d.ts",
|
|
32
32
|
"default": "./src/hiddenText.js"
|
|
33
33
|
},
|
|
34
|
+
"./context": {
|
|
35
|
+
"types": "./dist/types/context.d.ts",
|
|
36
|
+
"default": "./src/context.js"
|
|
37
|
+
},
|
|
34
38
|
"./browser": {
|
|
35
39
|
"types": "./dist/types/index.d.ts",
|
|
36
40
|
"default": "./dist/text-sanctifier.min.js"
|
|
@@ -39,12 +43,12 @@
|
|
|
39
43
|
"types": "./dist/types/index.d.ts",
|
|
40
44
|
"sideEffects": false,
|
|
41
45
|
"scripts": {
|
|
42
|
-
"gen": "node scripts/gen-categories.js",
|
|
43
|
-
"gen:check": "node scripts/gen-categories.js --check",
|
|
46
|
+
"gen": "node scripts/gen-categories.js && node scripts/gen-version.js",
|
|
47
|
+
"gen:check": "node scripts/gen-categories.js --check && node scripts/gen-version.js --check",
|
|
44
48
|
"build": "node scripts/build.js",
|
|
45
49
|
"build:types": "tsc -p tsconfig.types.json",
|
|
46
50
|
"test:types": "tsc -p tests/types/tsconfig.json",
|
|
47
|
-
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node tests/cli.test.js",
|
|
51
|
+
"test": "node tests/sanctifyText.test.js && node tests/inspectTextDetailed.test.js && node --expose-gc tests/hiddenText.test.js && node --expose-gc tests/context.test.js && node tests/cli.test.js",
|
|
48
52
|
"test-min": "node tests/sanctifyText.test.min.js",
|
|
49
53
|
"ci": "npm run gen:check && npm run test && npm run build && npm run size && npm run build:types && npm run test:types && npm run test-min",
|
|
50
54
|
"size": "node scripts/check-size.js"
|
package/src/context.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// src/context.js
|
|
2
|
+
//
|
|
3
|
+
// inspectTextInContext: inspectTextDetailed plus which findings belong to legitimate
|
|
4
|
+
// sequences (emoji joiners and selectors, ideographic variation sequences, Mongolian
|
|
5
|
+
// selectors, subdivision flags), counted exactly per category. The categories are
|
|
6
|
+
// unchanged; see "Proposal: context-aware findings" in DESIGN.md.
|
|
7
|
+
//
|
|
8
|
+
// Exported as `text-sanctifier/context`. It needs Unicode property escapes in regexes,
|
|
9
|
+
// so it lives apart from the core and the browser file and has its own size budget.
|
|
10
|
+
|
|
11
|
+
import { inspectTextDetailed } from './inspectText.js';
|
|
12
|
+
|
|
13
|
+
/** @typedef {import('./inspectText.js').TrashFinding} TrashFinding */
|
|
14
|
+
/** @typedef {import('./inspectText.js').TrashCounts} TrashCounts */
|
|
15
|
+
/** @typedef {import('./inspectText.js').InspectDetailedOptions} InspectDetailedOptions */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {TrashFinding & { benign: boolean }} ContextFinding
|
|
19
|
+
* A finding with `benign`: true when the character is part of a legitimate sequence.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} ContextReport
|
|
24
|
+
* @property {ContextFinding[]} findings - As in inspectTextDetailed, each with `benign`.
|
|
25
|
+
* @property {TrashCounts} counts - As in inspectTextDetailed: every occurrence, benign or not.
|
|
26
|
+
* @property {number} total - Sum of `counts`.
|
|
27
|
+
* @property {boolean} truncated - True when `total` exceeds `findings.length`.
|
|
28
|
+
* @property {TrashCounts} benignCounts - Benign occurrences per category, listed or not.
|
|
29
|
+
* `counts[c] - benignCounts[c]` is the number of suspicious characters in category `c`.
|
|
30
|
+
* @property {number} benignTotal - Sum of `benignCounts`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Tested at an index of the text itself, so nothing is allocated per character.
|
|
34
|
+
const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
|
|
35
|
+
const IDEOGRAPHIC_AT = /\p{Ideographic}/yu;
|
|
36
|
+
|
|
37
|
+
// RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
|
|
38
|
+
const SUBDIVISION_FLAGS = ['gbeng', 'gbsct', 'gbwls'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {RegExp} re - sticky
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @param {number} index
|
|
44
|
+
*/
|
|
45
|
+
function matchesAt(re, text, index) {
|
|
46
|
+
re.lastIndex = index;
|
|
47
|
+
return re.test(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Index where the code point that ends just before `index` starts, or -1.
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @param {number} index
|
|
54
|
+
*/
|
|
55
|
+
function previousStart(text, index) {
|
|
56
|
+
if (index <= 0) return -1;
|
|
57
|
+
const low = text.charCodeAt(index - 1);
|
|
58
|
+
if (low >= 0xDC00 && low <= 0xDFFF && index >= 2) {
|
|
59
|
+
const high = text.charCodeAt(index - 2);
|
|
60
|
+
if (high >= 0xD800 && high <= 0xDBFF) return index - 2;
|
|
61
|
+
}
|
|
62
|
+
return index - 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {number} c */
|
|
66
|
+
const isMongolianLetter = (c) =>
|
|
67
|
+
(c >= 0x1820 && c <= 0x1878) || (c >= 0x1880 && c <= 0x1884) || (c >= 0x1887 && c <= 0x18A8) || c === 0x18AA;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A U+200D is benign between two Extended_Pictographic code points, allowing a
|
|
71
|
+
* skin-tone modifier or VS16 after the first.
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @param {number} i - index of the U+200D
|
|
74
|
+
*/
|
|
75
|
+
function isEmojiJoiner(text, i) {
|
|
76
|
+
let p = previousStart(text, i);
|
|
77
|
+
if (p < 0) return false;
|
|
78
|
+
const before = /** @type {number} */ (text.codePointAt(p));
|
|
79
|
+
if (before === 0xFE0F || (before >= 0x1F3FB && before <= 0x1F3FF)) p = previousStart(text, p);
|
|
80
|
+
return p >= 0 && matchesAt(PICTOGRAPHIC_AT, text, p) && matchesAt(PICTOGRAPHIC_AT, text, i + 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Length in UTF-16 units of the subdivision-flag tags starting at `i` (just after a
|
|
85
|
+
* U+1F3F4), or 0 when they are not exactly one of the RGI flags, as findHiddenRuns decides.
|
|
86
|
+
* @param {string} text
|
|
87
|
+
* @param {number} i
|
|
88
|
+
*/
|
|
89
|
+
function flagTagsAt(text, i) {
|
|
90
|
+
// five tag letters and CANCEL TAG, each a surrogate pair
|
|
91
|
+
if (text.charCodeAt(i + 10) !== 0xDB40 || text.charCodeAt(i + 11) !== 0xDC7F) return 0;
|
|
92
|
+
// more tags after CANCEL TAG make it one longer tag run, which is not a flag
|
|
93
|
+
const after = text.charCodeAt(i + 13);
|
|
94
|
+
if (text.charCodeAt(i + 12) === 0xDB40 && after >= 0xDC00 && after <= 0xDC7F) return 0;
|
|
95
|
+
let letters = '';
|
|
96
|
+
for (let k = 0; k < 10; k += 2) {
|
|
97
|
+
const low = text.charCodeAt(i + k + 1);
|
|
98
|
+
if (text.charCodeAt(i + k) !== 0xDB40 || low < 0xDC61 || low > 0xDC7A) return 0; // tag a-z
|
|
99
|
+
letters += String.fromCharCode(low - 0xDC00);
|
|
100
|
+
}
|
|
101
|
+
return SUBDIVISION_FLAGS.includes(letters) ? 12 : 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Locates invisible and control characters as inspectTextDetailed does, and marks
|
|
106
|
+
* the ones that belong to legitimate sequences as benign:
|
|
107
|
+
* - U+200D between two emoji (Extended_Pictographic), allowing a skin-tone modifier or
|
|
108
|
+
* VS16 after the first: an emoji ZWJ sequence
|
|
109
|
+
* - U+FE0E or U+FE0F directly after an emoji, or between 0-9, # or * and U+20E3 (keycap)
|
|
110
|
+
* - one of U+E0100–U+E01EF directly after an ideograph: an ideographic variation sequence
|
|
111
|
+
* - U+180B–U+180D or U+180F directly after a Mongolian letter
|
|
112
|
+
* - the tags of the England, Scotland and Wales flags after U+1F3F4
|
|
113
|
+
* Nothing else is benign: not U+200B, U+2060, U+FEFF, bidi controls or other tags.
|
|
114
|
+
*
|
|
115
|
+
* `counts`, `total` and `truncated` are exactly those of inspectTextDetailed;
|
|
116
|
+
* `benignCounts` counts every benign character, listed or not. Two passes over the
|
|
117
|
+
* string, no copy; memory is flat with `maxFindings` set.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} text
|
|
120
|
+
* @param {InspectDetailedOptions} [options]
|
|
121
|
+
* @returns {ContextReport}
|
|
122
|
+
*/
|
|
123
|
+
export function inspectTextInContext(text, options = {}) {
|
|
124
|
+
if (typeof text !== 'string') {
|
|
125
|
+
throw new TypeError('inspectTextInContext expects a string input.');
|
|
126
|
+
}
|
|
127
|
+
const report = inspectTextDetailed(text, options);
|
|
128
|
+
const findings = /** @type {ContextFinding[]} */ (report.findings);
|
|
129
|
+
for (const f of findings) f.benign = false;
|
|
130
|
+
|
|
131
|
+
/** @type {TrashCounts} */
|
|
132
|
+
const benignCounts = /** @type {TrashCounts} */ ({});
|
|
133
|
+
for (const key of Object.keys(report.counts)) benignCounts[/** @type {keyof TrashCounts} */ (key)] = 0;
|
|
134
|
+
let benignTotal = 0;
|
|
135
|
+
let next = 0; // first listed finding not yet passed
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {number} offset
|
|
139
|
+
* @param {keyof TrashCounts} category
|
|
140
|
+
*/
|
|
141
|
+
const markBenign = (offset, category) => {
|
|
142
|
+
benignCounts[category]++;
|
|
143
|
+
benignTotal++;
|
|
144
|
+
while (next < findings.length && findings[next].offset < offset) next++;
|
|
145
|
+
if (next < findings.length && findings[next].offset === offset) findings[next].benign = true;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (let i = 0, n = text.length; i < n; i++) {
|
|
149
|
+
const c = text.charCodeAt(i);
|
|
150
|
+
if (c < 0x180B) continue; // the common case: nothing below can be benign
|
|
151
|
+
|
|
152
|
+
if (c === 0x200D) {
|
|
153
|
+
if (isEmojiJoiner(text, i)) markBenign(i, 'zero_width');
|
|
154
|
+
} else if (c === 0xFE0E || c === 0xFE0F) {
|
|
155
|
+
const p = previousStart(text, i);
|
|
156
|
+
const before = p < 0 ? -1 : text.charCodeAt(p);
|
|
157
|
+
const keycap = (before === 0x23 || before === 0x2A || (before >= 0x30 && before <= 0x39)) &&
|
|
158
|
+
text.charCodeAt(i + 1) === 0x20E3;
|
|
159
|
+
if (keycap || (p >= 0 && matchesAt(PICTOGRAPHIC_AT, text, p))) markBenign(i, 'variation_selector');
|
|
160
|
+
} else if ((c >= 0x180B && c <= 0x180D) || c === 0x180F) {
|
|
161
|
+
if (isMongolianLetter(text.charCodeAt(i - 1))) markBenign(i, 'variation_selector');
|
|
162
|
+
} else if (c === 0xD83C && text.charCodeAt(i + 1) === 0xDFF4) { // U+1F3F4
|
|
163
|
+
const length = flagTagsAt(text, i + 2);
|
|
164
|
+
for (let k = 0; k < length; k += 2) markBenign(i + 2 + k, 'tag');
|
|
165
|
+
i += 1 + length;
|
|
166
|
+
} else if (c === 0xDB40) { // plane 14: U+E0000-U+E03FF
|
|
167
|
+
const low = text.charCodeAt(i + 1);
|
|
168
|
+
if (low >= 0xDD00 && low <= 0xDDEF) { // U+E0100-U+E01EF
|
|
169
|
+
const p = previousStart(text, i);
|
|
170
|
+
if (p >= 0 && matchesAt(IDEOGRAPHIC_AT, text, p)) markBenign(i, 'variation_selector');
|
|
171
|
+
}
|
|
172
|
+
if (low >= 0xDC00 && low <= 0xDFFF) i++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { findings, counts: report.counts, total: report.total, truncated: report.truncated, benignCounts, benignTotal };
|
|
177
|
+
}
|
package/src/hiddenText.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// src/hiddenText.js
|
|
2
2
|
//
|
|
3
3
|
// Finds runs of the characters used to hide instructions in text a model reads:
|
|
4
|
-
// tag characters, variation selectors
|
|
4
|
+
// tag characters, variation selectors, invisible operators and the reserved code points
|
|
5
|
+
// of the tag plane. Tag runs are decoded
|
|
5
6
|
// to the ASCII they spell. Used by `text-sanctifier check --ai-config`, and exported
|
|
6
7
|
// as `text-sanctifier/hidden`.
|
|
7
8
|
//
|
|
8
9
|
// Plain JavaScript with no Node APIs, so it also runs in a browser. src/index.js does
|
|
9
10
|
// not import this file, so it stays out of the browser bundle and its size budget.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
export { VERSION } from './version.generated.js';
|
|
13
|
+
|
|
14
|
+
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* @typedef {Object} HiddenRun
|
|
@@ -16,14 +19,41 @@
|
|
|
16
19
|
* @property {number} offset - UTF-16 index of the first character
|
|
17
20
|
* @property {number} end - UTF-16 index just past the last character
|
|
18
21
|
* @property {number} count - number of code points in the run
|
|
19
|
-
* @property {
|
|
22
|
+
* @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
|
|
23
|
+
* @property {number} column - 1-based column of the first character, in UTF-16 units
|
|
24
|
+
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
|
|
25
|
+
* at most `maxDecodedLength` UTF-16 units
|
|
26
|
+
* @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} HiddenRunCounts
|
|
31
|
+
* @property {number} tag
|
|
32
|
+
* @property {number} variation_selector
|
|
33
|
+
* @property {number} invisible_operator
|
|
34
|
+
* @property {number} reserved_tag_plane
|
|
20
35
|
*/
|
|
21
36
|
|
|
22
37
|
/**
|
|
23
38
|
* @typedef {Object} HiddenTextScan
|
|
24
|
-
* @property {HiddenRun[]} runs - runs that may hide text, in order
|
|
39
|
+
* @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
|
|
25
40
|
* @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
|
|
26
|
-
* sequences (subdivision flags, a single emoji or text presentation selector)
|
|
41
|
+
* sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
|
|
42
|
+
* @property {HiddenRunCounts} counts - every run per kind, listed or not
|
|
43
|
+
* @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
|
|
44
|
+
* @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
|
|
45
|
+
* (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
|
|
46
|
+
* @property {number} total - sum of `counts`
|
|
47
|
+
* @property {number} benignTotal - every benign span, listed or not
|
|
48
|
+
* @property {boolean} truncated - true when runs or benign spans were left out of the lists
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} HiddenScanOptions
|
|
53
|
+
* @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
|
|
54
|
+
* (non-negative integer or Infinity). Counts stay exact.
|
|
55
|
+
* @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
|
|
56
|
+
* For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
|
|
27
57
|
*/
|
|
28
58
|
|
|
29
59
|
const TAG_FIRST = 0xE0000;
|
|
@@ -43,6 +73,8 @@ function kindOf(cp) {
|
|
|
43
73
|
if ((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF) ||
|
|
44
74
|
(cp >= 0x180B && cp <= 0x180D) || cp === 0x180F) return 'variation_selector';
|
|
45
75
|
if (cp >= 0x2061 && cp <= 0x2064) return 'invisible_operator';
|
|
76
|
+
// Unassigned and default ignorable: U+E0080-U+E00FF and U+E01F0-U+E0FFF
|
|
77
|
+
if (cp >= 0xE0080 && cp <= 0xE0FFF) return 'reserved_tag_plane';
|
|
46
78
|
return null;
|
|
47
79
|
}
|
|
48
80
|
|
|
@@ -57,64 +89,145 @@ const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
|
|
|
57
89
|
* @returns {string}
|
|
58
90
|
*/
|
|
59
91
|
export function decodeTagText(text) {
|
|
92
|
+
return decodeRange(text, 0, text.length, Infinity).decoded;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Decodes text[start, end) as decodeTagText does, stopping before the output would
|
|
97
|
+
* pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @param {number} start
|
|
100
|
+
* @param {number} end
|
|
101
|
+
* @param {number} limit
|
|
102
|
+
* @returns {{ decoded: string, truncated: boolean }}
|
|
103
|
+
*/
|
|
104
|
+
function decodeRange(text, start, end, limit) {
|
|
60
105
|
let out = '';
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
let i = start;
|
|
107
|
+
while (i < end) {
|
|
108
|
+
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
109
|
+
const piece = cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
|
|
110
|
+
if (out.length + piece.length > limit) return { decoded: out, truncated: true };
|
|
111
|
+
out += piece;
|
|
112
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
64
113
|
}
|
|
65
|
-
return out;
|
|
114
|
+
return { decoded: out, truncated: false };
|
|
66
115
|
}
|
|
67
116
|
|
|
117
|
+
// Whitespace as JavaScript's \s defines it, tested at an index of the text itself,
|
|
118
|
+
// so the check allocates nothing.
|
|
119
|
+
const SPACE_AT = /\s/y;
|
|
120
|
+
|
|
68
121
|
/**
|
|
69
|
-
*
|
|
122
|
+
* @param {string} text
|
|
123
|
+
* @param {number} index
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
function isSpaceAt(text, index) {
|
|
127
|
+
SPACE_AT.lastIndex = index;
|
|
128
|
+
return SPACE_AT.test(text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A cap option: a non-negative integer or Infinity; anything else means no cap.
|
|
133
|
+
* @param {unknown} value
|
|
134
|
+
* @returns {number}
|
|
135
|
+
*/
|
|
136
|
+
function cap(value) {
|
|
137
|
+
return typeof value === 'number' && (value === Infinity || (Number.isInteger(value) && value >= 0))
|
|
138
|
+
? value
|
|
139
|
+
: Infinity;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Finds runs of tag characters, variation selectors, invisible operators and reserved
|
|
144
|
+
* tag-plane code points (U+E0080–U+E00FF, U+E01F0–U+E0FFF).
|
|
70
145
|
* Consecutive characters of the same kind form one run. Two kinds of sequence are
|
|
71
146
|
* legitimate and listed under `benign` instead of `runs`:
|
|
72
147
|
* - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
|
|
73
148
|
* tags for "gbeng", "gbsct" or "gbwls" and U+E007F
|
|
74
149
|
* - a single U+FE0E or U+FE0F (text or emoji presentation) after another character
|
|
150
|
+
*
|
|
151
|
+
* `line` and `column` are counted as in inspectTextDetailed. Every run and benign
|
|
152
|
+
* span is counted; only the first `maxRuns` of each are listed. With the caps set,
|
|
153
|
+
* memory stays flat however large the input is. One pass over the string, no copy.
|
|
154
|
+
* Set both caps for untrusted input: without them, results grow with the input.
|
|
155
|
+
*
|
|
75
156
|
* @param {string} text
|
|
157
|
+
* @param {HiddenScanOptions} [options]
|
|
76
158
|
* @returns {HiddenTextScan}
|
|
77
159
|
*/
|
|
78
|
-
export function findHiddenRuns(text) {
|
|
160
|
+
export function findHiddenRuns(text, options = {}) {
|
|
161
|
+
const maxRuns = cap(options && options.maxRuns);
|
|
162
|
+
const maxDecodedLength = cap(options && options.maxDecodedLength);
|
|
163
|
+
|
|
79
164
|
/** @type {HiddenRun[]} */
|
|
80
165
|
const runs = [];
|
|
81
166
|
/** @type {Array<[number, number]>} */
|
|
82
167
|
const benign = [];
|
|
168
|
+
/** @type {HiddenRunCounts} */
|
|
169
|
+
const counts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
|
|
170
|
+
/** @type {HiddenRunCounts} */
|
|
171
|
+
const codePointCounts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
|
|
172
|
+
let supplementarySelectorCount = 0;
|
|
173
|
+
let total = 0;
|
|
174
|
+
let benignTotal = 0;
|
|
175
|
+
let line = 1;
|
|
176
|
+
let lineStart = 0;
|
|
83
177
|
|
|
84
178
|
let i = 0;
|
|
85
179
|
while (i < text.length) {
|
|
86
180
|
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
87
181
|
const kind = kindOf(cp);
|
|
88
|
-
if (kind === null) {
|
|
182
|
+
if (kind === null) {
|
|
183
|
+
if (cp === 0x0A || (cp === 0x0D && text.charCodeAt(i + 1) !== 0x0A)) { // CRLF ends at its LF
|
|
184
|
+
line++;
|
|
185
|
+
lineStart = i + 1;
|
|
186
|
+
}
|
|
187
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
89
190
|
|
|
90
191
|
const offset = i;
|
|
91
192
|
let count = 0;
|
|
193
|
+
let supplementary = 0;
|
|
92
194
|
let last = cp;
|
|
93
195
|
while (i < text.length) {
|
|
94
196
|
const c = /** @type {number} */ (text.codePointAt(i));
|
|
95
197
|
if (kindOf(c) !== kind) break;
|
|
96
198
|
last = c;
|
|
97
199
|
count++;
|
|
200
|
+
if (c >= 0xE0100 && c <= 0xE01EF) supplementary++;
|
|
98
201
|
i += c > 0xFFFF ? 2 : 1;
|
|
99
202
|
}
|
|
100
203
|
const end = i;
|
|
101
204
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
205
|
+
// A subdivision flag is exactly five tag letters and CANCEL TAG after U+1F3F4.
|
|
206
|
+
const isBenign = kind === 'tag'
|
|
207
|
+
? count === 6 && last === CANCEL_TAG && offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG &&
|
|
208
|
+
SUBDIVISION_FLAGS.has(decodeRange(text, offset, end - 2, Infinity).decoded)
|
|
209
|
+
: kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
|
|
210
|
+
offset > 0 && !isSpaceAt(text, offset - 1);
|
|
211
|
+
if (isBenign) {
|
|
212
|
+
benignTotal++;
|
|
213
|
+
if (benign.length < maxRuns) benign.push([offset, end]);
|
|
110
214
|
continue;
|
|
111
215
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
216
|
+
|
|
217
|
+
counts[kind]++;
|
|
218
|
+
codePointCounts[kind] += count;
|
|
219
|
+
supplementarySelectorCount += supplementary;
|
|
220
|
+
total++;
|
|
221
|
+
if (runs.length < maxRuns) {
|
|
222
|
+
/** @type {HiddenRun} */
|
|
223
|
+
const run = { kind, offset, end, line, column: offset - lineStart + 1, count };
|
|
224
|
+
if (kind === 'tag') {
|
|
225
|
+
const { decoded, truncated } = decodeRange(text, offset, end, maxDecodedLength);
|
|
226
|
+
run.decoded = decoded;
|
|
227
|
+
run.decodedTruncated = truncated;
|
|
228
|
+
}
|
|
229
|
+
runs.push(run);
|
|
116
230
|
}
|
|
117
|
-
runs.push({ kind, offset, end, count });
|
|
118
231
|
}
|
|
119
|
-
return { runs, benign };
|
|
232
|
+
return { runs, benign, counts, codePointCounts, supplementarySelectorCount, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
|
|
120
233
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -3,8 +3,14 @@ import { inspectTextDetailed } from './inspectText.js';
|
|
|
3
3
|
import { getNewlineStats } from './inspectText.js';
|
|
4
4
|
import { getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
5
|
import { summonSanctifier } from './sanctifyText.js';
|
|
6
|
+
import { sanctifyStrict } from './sanctifyText.js';
|
|
7
|
+
import { sanctifyLoose } from './sanctifyText.js';
|
|
8
|
+
import { sanctifyKeyboardOnlyEmoji } from './sanctifyText.js';
|
|
9
|
+
import { sanctifyKeyboardOnly } from './sanctifyText.js';
|
|
10
|
+
import { sanctifyPasted } from './sanctifyText.js';
|
|
6
11
|
import { revealInvisible } from './reveal.js';
|
|
7
|
-
|
|
12
|
+
import { VERSION } from './version.generated.js';
|
|
13
|
+
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted, revealInvisible, VERSION };
|
|
8
14
|
export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
|
|
9
15
|
export type TrashCategory = import('./inspectText.js').TrashCategory;
|
|
10
16
|
export type TrashFinding = import('./inspectText.js').TrashFinding;
|
package/src/index.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
import { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
5
|
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions };
|
|
6
6
|
|
|
7
|
-
import { summonSanctifier } from './sanctifyText.js';
|
|
8
|
-
export { summonSanctifier };
|
|
7
|
+
import { summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted } from './sanctifyText.js';
|
|
8
|
+
export { summonSanctifier, sanctifyStrict, sanctifyLoose, sanctifyKeyboardOnlyEmoji, sanctifyKeyboardOnly, sanctifyPasted };
|
|
9
9
|
|
|
10
10
|
import { revealInvisible } from './reveal.js';
|
|
11
11
|
export { revealInvisible };
|
|
12
12
|
|
|
13
|
+
import { VERSION } from './version.generated.js';
|
|
14
|
+
export { VERSION };
|
|
15
|
+
|
|
13
16
|
// Type-only re-exports so TypeScript consumers can name these from the package root.
|
|
14
17
|
/** @typedef {import('./inspectText.js').UnicodeTrashReport} UnicodeTrashReport */
|
|
15
18
|
/** @typedef {import('./inspectText.js').TrashCategory} TrashCategory */
|
package/src/inspectText.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
// ./src/inspectText.js
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
INVISIBLE_TRASH_REGEX,
|
|
6
|
-
EMOJI_REGEX,
|
|
7
|
-
ASCII_KEYBOARD_SAFE_REGEX,
|
|
8
|
-
normalizeTypographicJank,
|
|
9
|
-
} from './sanctifyText.js';
|
|
10
|
-
import { classifyCodeUnit, classifyAstral } from './categories.generated.js';
|
|
3
|
+
import { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX, classifyCodeUnit, classifyAstral } from './categories.generated.js';
|
|
4
|
+
import { EMOJI_REGEX, ASCII_KEYBOARD_SAFE_REGEX, normalizeTypographicJank } from './textPatterns.js';
|
|
11
5
|
|
|
12
6
|
/** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
|
|
13
7
|
|