text-sanctifier 1.3.8 → 1.3.10
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/ADDITIONAL_TERMS.md +122 -122
- package/LICENSE +694 -694
- package/README.md +440 -437
- package/bin/text-sanctifier.js +122 -42
- package/dist/text-sanctifier.min.js +1 -1
- package/dist/types/hiddenScan.d.ts +30 -0
- package/dist/types/hiddenText.d.ts +44 -1
- package/dist/types/sanctifyText.d.ts +1 -1
- package/dist/types/version.generated.d.ts +1 -1
- package/package.json +89 -83
- package/robots.txt +79 -79
- package/src/hiddenScan.js +171 -0
- package/src/hiddenText.js +96 -233
- package/src/index.d.ts +22 -22
- package/src/index.js +29 -29
- package/src/sanctifyText.js +4 -4
- package/src/version.generated.js +5 -5
package/robots.txt
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
# WATT3D — AI scraping policy
|
|
2
|
-
#
|
|
3
|
-
# This file enumerates known AI / LLM training and inference crawlers
|
|
4
|
-
# that the WATT3D AI Training Rider does not consent to. See
|
|
5
|
-
# ./ADDITIONAL_TERMS.md for the legal scope and definitions.
|
|
6
|
-
#
|
|
7
|
-
# Honoring robots.txt is voluntary. The rider applies whether or not
|
|
8
|
-
# a bot respects this file. The enumeration is non-exhaustive — bots
|
|
9
|
-
# not listed here are still bound by the rider if they perform
|
|
10
|
-
# Training Use as defined in ADDITIONAL_TERMS.md §1.
|
|
11
|
-
|
|
12
|
-
User-agent: GPTBot
|
|
13
|
-
Disallow: /
|
|
14
|
-
|
|
15
|
-
User-agent: ChatGPT-User
|
|
16
|
-
Disallow: /
|
|
17
|
-
|
|
18
|
-
User-agent: OAI-SearchBot
|
|
19
|
-
Disallow: /
|
|
20
|
-
|
|
21
|
-
User-agent: anthropic-ai
|
|
22
|
-
Disallow: /
|
|
23
|
-
|
|
24
|
-
User-agent: ClaudeBot
|
|
25
|
-
Disallow: /
|
|
26
|
-
|
|
27
|
-
User-agent: Claude-Web
|
|
28
|
-
Disallow: /
|
|
29
|
-
|
|
30
|
-
User-agent: Claude-SearchBot
|
|
31
|
-
Disallow: /
|
|
32
|
-
|
|
33
|
-
User-agent: Google-Extended
|
|
34
|
-
Disallow: /
|
|
35
|
-
|
|
36
|
-
User-agent: Applebot-Extended
|
|
37
|
-
Disallow: /
|
|
38
|
-
|
|
39
|
-
User-agent: Meta-ExternalAgent
|
|
40
|
-
Disallow: /
|
|
41
|
-
|
|
42
|
-
User-agent: Meta-ExternalFetcher
|
|
43
|
-
Disallow: /
|
|
44
|
-
|
|
45
|
-
User-agent: FacebookBot
|
|
46
|
-
Disallow: /
|
|
47
|
-
|
|
48
|
-
User-agent: CCBot
|
|
49
|
-
Disallow: /
|
|
50
|
-
|
|
51
|
-
User-agent: PerplexityBot
|
|
52
|
-
Disallow: /
|
|
53
|
-
|
|
54
|
-
User-agent: Perplexity-User
|
|
55
|
-
Disallow: /
|
|
56
|
-
|
|
57
|
-
User-agent: cohere-ai
|
|
58
|
-
Disallow: /
|
|
59
|
-
|
|
60
|
-
User-agent: Bytespider
|
|
61
|
-
Disallow: /
|
|
62
|
-
|
|
63
|
-
User-agent: Amazonbot
|
|
64
|
-
Disallow: /
|
|
65
|
-
|
|
66
|
-
User-agent: Omgilibot
|
|
67
|
-
Disallow: /
|
|
68
|
-
|
|
69
|
-
User-agent: AI2Bot
|
|
70
|
-
Disallow: /
|
|
71
|
-
|
|
72
|
-
User-agent: Diffbot
|
|
73
|
-
Disallow: /
|
|
74
|
-
|
|
75
|
-
# Everyone else: full access. Search engines and human-driven crawlers
|
|
76
|
-
# remain welcome.
|
|
77
|
-
|
|
78
|
-
User-agent: *
|
|
79
|
-
Allow: /
|
|
1
|
+
# WATT3D — AI scraping policy
|
|
2
|
+
#
|
|
3
|
+
# This file enumerates known AI / LLM training and inference crawlers
|
|
4
|
+
# that the WATT3D AI Training Rider does not consent to. See
|
|
5
|
+
# ./ADDITIONAL_TERMS.md for the legal scope and definitions.
|
|
6
|
+
#
|
|
7
|
+
# Honoring robots.txt is voluntary. The rider applies whether or not
|
|
8
|
+
# a bot respects this file. The enumeration is non-exhaustive — bots
|
|
9
|
+
# not listed here are still bound by the rider if they perform
|
|
10
|
+
# Training Use as defined in ADDITIONAL_TERMS.md §1.
|
|
11
|
+
|
|
12
|
+
User-agent: GPTBot
|
|
13
|
+
Disallow: /
|
|
14
|
+
|
|
15
|
+
User-agent: ChatGPT-User
|
|
16
|
+
Disallow: /
|
|
17
|
+
|
|
18
|
+
User-agent: OAI-SearchBot
|
|
19
|
+
Disallow: /
|
|
20
|
+
|
|
21
|
+
User-agent: anthropic-ai
|
|
22
|
+
Disallow: /
|
|
23
|
+
|
|
24
|
+
User-agent: ClaudeBot
|
|
25
|
+
Disallow: /
|
|
26
|
+
|
|
27
|
+
User-agent: Claude-Web
|
|
28
|
+
Disallow: /
|
|
29
|
+
|
|
30
|
+
User-agent: Claude-SearchBot
|
|
31
|
+
Disallow: /
|
|
32
|
+
|
|
33
|
+
User-agent: Google-Extended
|
|
34
|
+
Disallow: /
|
|
35
|
+
|
|
36
|
+
User-agent: Applebot-Extended
|
|
37
|
+
Disallow: /
|
|
38
|
+
|
|
39
|
+
User-agent: Meta-ExternalAgent
|
|
40
|
+
Disallow: /
|
|
41
|
+
|
|
42
|
+
User-agent: Meta-ExternalFetcher
|
|
43
|
+
Disallow: /
|
|
44
|
+
|
|
45
|
+
User-agent: FacebookBot
|
|
46
|
+
Disallow: /
|
|
47
|
+
|
|
48
|
+
User-agent: CCBot
|
|
49
|
+
Disallow: /
|
|
50
|
+
|
|
51
|
+
User-agent: PerplexityBot
|
|
52
|
+
Disallow: /
|
|
53
|
+
|
|
54
|
+
User-agent: Perplexity-User
|
|
55
|
+
Disallow: /
|
|
56
|
+
|
|
57
|
+
User-agent: cohere-ai
|
|
58
|
+
Disallow: /
|
|
59
|
+
|
|
60
|
+
User-agent: Bytespider
|
|
61
|
+
Disallow: /
|
|
62
|
+
|
|
63
|
+
User-agent: Amazonbot
|
|
64
|
+
Disallow: /
|
|
65
|
+
|
|
66
|
+
User-agent: Omgilibot
|
|
67
|
+
Disallow: /
|
|
68
|
+
|
|
69
|
+
User-agent: AI2Bot
|
|
70
|
+
Disallow: /
|
|
71
|
+
|
|
72
|
+
User-agent: Diffbot
|
|
73
|
+
Disallow: /
|
|
74
|
+
|
|
75
|
+
# Everyone else: full access. Search engines and human-driven crawlers
|
|
76
|
+
# remain welcome.
|
|
77
|
+
|
|
78
|
+
User-agent: *
|
|
79
|
+
Allow: /
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Internal hidden-span scanner; not a package entry point.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('./hiddenText.js').HiddenRunKind} HiddenRunKind
|
|
5
|
+
* @typedef {import('./hiddenText.js').HiddenRun} HiddenRun
|
|
6
|
+
* @typedef {import('./hiddenText.js').HiddenRunCounts} HiddenRunCounts
|
|
7
|
+
* @typedef {import('./hiddenText.js').HiddenTextScan} HiddenTextScan
|
|
8
|
+
* @typedef {import('./hiddenText.js').HiddenScanOptions} HiddenScanOptions
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const TAG_FIRST = 0xE0000;
|
|
12
|
+
const TAG_LAST = 0xE007F;
|
|
13
|
+
const CANCEL_TAG = 0xE007F;
|
|
14
|
+
const BLACK_FLAG = 0x1F3F4;
|
|
15
|
+
|
|
16
|
+
// RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
|
|
17
|
+
const SUBDIVISION_FLAGS = new Set(['gbeng', 'gbsct', 'gbwls']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {number} cp
|
|
21
|
+
* @returns {HiddenRunKind | null}
|
|
22
|
+
*/
|
|
23
|
+
function kindOf(cp) {
|
|
24
|
+
if (cp >= TAG_FIRST && cp <= TAG_LAST) return 'tag';
|
|
25
|
+
if ((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF) ||
|
|
26
|
+
(cp >= 0x180B && cp <= 0x180D) || cp === 0x180F) return 'variation_selector';
|
|
27
|
+
if (cp >= 0x2061 && cp <= 0x2064) return 'invisible_operator';
|
|
28
|
+
// Unassigned and default ignorable: U+E0080-U+E00FF and U+E01F0-U+E0FFF
|
|
29
|
+
if (cp >= 0xE0080 && cp <= 0xE0FFF) return 'reserved_tag_plane';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {number} cp */
|
|
34
|
+
const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decodes text[start, end) as decodeTagText does, stopping before the output would
|
|
38
|
+
* pass `limit` UTF-16 units. A `<U+XXXX>` marker is written whole or not at all.
|
|
39
|
+
* @param {string} text
|
|
40
|
+
* @param {number} start
|
|
41
|
+
* @param {number} end
|
|
42
|
+
* @param {number} limit
|
|
43
|
+
* @returns {{ decoded: string, truncated: boolean }}
|
|
44
|
+
*/
|
|
45
|
+
export function decodeRange(text, start, end, limit) {
|
|
46
|
+
let out = '';
|
|
47
|
+
let i = start;
|
|
48
|
+
while (i < end) {
|
|
49
|
+
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
50
|
+
const piece = cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
|
|
51
|
+
if (out.length + piece.length > limit) return { decoded: out, truncated: true };
|
|
52
|
+
out += piece;
|
|
53
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
54
|
+
}
|
|
55
|
+
return { decoded: out, truncated: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Tested at an index of the text itself, so the check allocates nothing.
|
|
59
|
+
const PICTOGRAPHIC_AT = /\p{Extended_Pictographic}/yu;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True when a presentation selector at `index` follows an emoji-capable character:
|
|
63
|
+
* an Extended_Pictographic code point, or a keycap base (#, * or a digit) with U+20E3 after.
|
|
64
|
+
* @param {string} text
|
|
65
|
+
* @param {number} index
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function isPresentationBase(text, index) {
|
|
69
|
+
if (index === 0) return false;
|
|
70
|
+
const low = text.charCodeAt(index - 1);
|
|
71
|
+
const p = low >= 0xDC00 && low <= 0xDFFF && index >= 2 &&
|
|
72
|
+
(text.charCodeAt(index - 2) & 0xFC00) === 0xD800 ? index - 2 : index - 1;
|
|
73
|
+
const before = text.charCodeAt(p);
|
|
74
|
+
if ((before === 0x23 || before === 0x2A || (before >= 0x30 && before <= 0x39)) && text.charCodeAt(index + 1) === 0x20E3) return true;
|
|
75
|
+
PICTOGRAPHIC_AT.lastIndex = p;
|
|
76
|
+
return PICTOGRAPHIC_AT.test(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A cap option: a non-negative integer or Infinity; anything else means no cap.
|
|
81
|
+
* @param {unknown} value
|
|
82
|
+
* @returns {number}
|
|
83
|
+
*/
|
|
84
|
+
function cap(value) {
|
|
85
|
+
return typeof value === 'number' && (value === Infinity || (Number.isInteger(value) && value >= 0))
|
|
86
|
+
? value
|
|
87
|
+
: Infinity;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} text
|
|
92
|
+
* @param {HiddenScanOptions} [options]
|
|
93
|
+
* @param {(span: {offset: number, end: number, benign: boolean, kind: HiddenRunKind}) => void} [onSpan] - internal streaming visitor
|
|
94
|
+
* @returns {HiddenTextScan}
|
|
95
|
+
*/
|
|
96
|
+
export function scanHiddenSpans(text, options = {}, onSpan) {
|
|
97
|
+
if (typeof text !== 'string') throw new TypeError('findHiddenRuns expects a string input.');
|
|
98
|
+
const maxRuns = cap(options && options.maxRuns);
|
|
99
|
+
const maxDecodedLength = cap(options && options.maxDecodedLength);
|
|
100
|
+
|
|
101
|
+
/** @type {HiddenRun[]} */
|
|
102
|
+
const runs = [];
|
|
103
|
+
/** @type {Array<[number, number]>} */
|
|
104
|
+
const benign = [];
|
|
105
|
+
/** @type {HiddenRunCounts} */
|
|
106
|
+
const counts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
|
|
107
|
+
/** @type {HiddenRunCounts} */
|
|
108
|
+
const codePointCounts = { tag: 0, variation_selector: 0, invisible_operator: 0, reserved_tag_plane: 0 };
|
|
109
|
+
let supplementarySelectorCount = 0;
|
|
110
|
+
let total = 0;
|
|
111
|
+
let benignTotal = 0;
|
|
112
|
+
let line = 1;
|
|
113
|
+
let lineStart = 0;
|
|
114
|
+
|
|
115
|
+
let i = 0;
|
|
116
|
+
while (i < text.length) {
|
|
117
|
+
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
118
|
+
const kind = kindOf(cp);
|
|
119
|
+
if (kind === null) {
|
|
120
|
+
if (cp === 0x0A || (cp === 0x0D && text.charCodeAt(i + 1) !== 0x0A)) { // CRLF ends at its LF
|
|
121
|
+
line++;
|
|
122
|
+
lineStart = i + 1;
|
|
123
|
+
}
|
|
124
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const offset = i;
|
|
129
|
+
let count = 0;
|
|
130
|
+
let supplementary = 0;
|
|
131
|
+
let last = cp;
|
|
132
|
+
while (i < text.length) {
|
|
133
|
+
const c = /** @type {number} */ (text.codePointAt(i));
|
|
134
|
+
if (kindOf(c) !== kind) break;
|
|
135
|
+
last = c;
|
|
136
|
+
count++;
|
|
137
|
+
if (c >= 0xE0100 && c <= 0xE01EF) supplementary++;
|
|
138
|
+
i += c > 0xFFFF ? 2 : 1;
|
|
139
|
+
}
|
|
140
|
+
const end = i;
|
|
141
|
+
|
|
142
|
+
// A subdivision flag is exactly five tag letters and CANCEL TAG after U+1F3F4.
|
|
143
|
+
const isBenign = kind === 'tag'
|
|
144
|
+
? count === 6 && last === CANCEL_TAG && offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG &&
|
|
145
|
+
SUBDIVISION_FLAGS.has(decodeRange(text, offset, end - 2, Infinity).decoded)
|
|
146
|
+
: kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
|
|
147
|
+
isPresentationBase(text, offset);
|
|
148
|
+
if (onSpan) onSpan({ offset, end, benign: isBenign, kind });
|
|
149
|
+
if (isBenign) {
|
|
150
|
+
benignTotal++;
|
|
151
|
+
if (benign.length < maxRuns) benign.push([offset, end]);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
counts[kind]++;
|
|
156
|
+
codePointCounts[kind] += count;
|
|
157
|
+
supplementarySelectorCount += supplementary;
|
|
158
|
+
total++;
|
|
159
|
+
if (runs.length < maxRuns) {
|
|
160
|
+
/** @type {HiddenRun} */
|
|
161
|
+
const run = { kind, offset, end, line, column: offset - lineStart + 1, count };
|
|
162
|
+
if (kind === 'tag') {
|
|
163
|
+
const { decoded, truncated } = decodeRange(text, offset, end, maxDecodedLength);
|
|
164
|
+
run.decoded = decoded;
|
|
165
|
+
run.decodedTruncated = truncated;
|
|
166
|
+
}
|
|
167
|
+
runs.push(run);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { runs, benign, counts, codePointCounts, supplementarySelectorCount, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
|
|
171
|
+
}
|
package/src/hiddenText.js
CHANGED
|
@@ -1,233 +1,96 @@
|
|
|
1
|
-
// src/hiddenText.js
|
|
2
|
-
//
|
|
3
|
-
// Finds runs of the characters used to hide instructions in text a model reads:
|
|
4
|
-
// tag characters, variation selectors, invisible operators and the reserved code points
|
|
5
|
-
// of the tag plane. Tag runs are decoded
|
|
6
|
-
// to the ASCII they spell. Used by `text-sanctifier check --ai-config`, and exported
|
|
7
|
-
// as `text-sanctifier/hidden`.
|
|
8
|
-
//
|
|
9
|
-
// Plain JavaScript with no Node APIs, so it also runs in a browser. src/index.js does
|
|
10
|
-
// not import this file, so it stays out of the browser bundle and its size budget.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* @
|
|
20
|
-
* @property {
|
|
21
|
-
* @property {number}
|
|
22
|
-
* @property {number}
|
|
23
|
-
* @property {number}
|
|
24
|
-
* @property {
|
|
25
|
-
*
|
|
26
|
-
* @property {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* @
|
|
33
|
-
* @property {number}
|
|
34
|
-
* @property {number}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* @
|
|
41
|
-
*
|
|
42
|
-
* @property {
|
|
43
|
-
*
|
|
44
|
-
* @property {
|
|
45
|
-
*
|
|
46
|
-
* @property {number}
|
|
47
|
-
*
|
|
48
|
-
* @property {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*
|
|
55
|
-
* @property {number} [
|
|
56
|
-
*
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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) {
|
|
105
|
-
let out = '';
|
|
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;
|
|
113
|
-
}
|
|
114
|
-
return { decoded: out, truncated: false };
|
|
115
|
-
}
|
|
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
|
-
|
|
121
|
-
/**
|
|
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).
|
|
145
|
-
* Consecutive characters of the same kind form one run. Two kinds of sequence are
|
|
146
|
-
* legitimate and listed under `benign` instead of `runs`:
|
|
147
|
-
* - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
|
|
148
|
-
* tags for "gbeng", "gbsct" or "gbwls" and U+E007F
|
|
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
|
-
*
|
|
156
|
-
* @param {string} text
|
|
157
|
-
* @param {HiddenScanOptions} [options]
|
|
158
|
-
* @returns {HiddenTextScan}
|
|
159
|
-
*/
|
|
160
|
-
export function findHiddenRuns(text, options = {}) {
|
|
161
|
-
const maxRuns = cap(options && options.maxRuns);
|
|
162
|
-
const maxDecodedLength = cap(options && options.maxDecodedLength);
|
|
163
|
-
|
|
164
|
-
/** @type {HiddenRun[]} */
|
|
165
|
-
const runs = [];
|
|
166
|
-
/** @type {Array<[number, number]>} */
|
|
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;
|
|
177
|
-
|
|
178
|
-
let i = 0;
|
|
179
|
-
while (i < text.length) {
|
|
180
|
-
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
181
|
-
const kind = kindOf(cp);
|
|
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
|
-
}
|
|
190
|
-
|
|
191
|
-
const offset = i;
|
|
192
|
-
let count = 0;
|
|
193
|
-
let supplementary = 0;
|
|
194
|
-
let last = cp;
|
|
195
|
-
while (i < text.length) {
|
|
196
|
-
const c = /** @type {number} */ (text.codePointAt(i));
|
|
197
|
-
if (kindOf(c) !== kind) break;
|
|
198
|
-
last = c;
|
|
199
|
-
count++;
|
|
200
|
-
if (c >= 0xE0100 && c <= 0xE01EF) supplementary++;
|
|
201
|
-
i += c > 0xFFFF ? 2 : 1;
|
|
202
|
-
}
|
|
203
|
-
const end = i;
|
|
204
|
-
|
|
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]);
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
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);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return { runs, benign, counts, codePointCounts, supplementarySelectorCount, total, benignTotal, truncated: total > runs.length || benignTotal > benign.length };
|
|
233
|
-
}
|
|
1
|
+
// src/hiddenText.js
|
|
2
|
+
//
|
|
3
|
+
// Finds runs of the characters used to hide instructions in text a model reads:
|
|
4
|
+
// tag characters, variation selectors, invisible operators and the reserved code points
|
|
5
|
+
// of the tag plane. Tag runs are decoded
|
|
6
|
+
// to the ASCII they spell. Used by `text-sanctifier check --ai-config`, and exported
|
|
7
|
+
// as `text-sanctifier/hidden`.
|
|
8
|
+
//
|
|
9
|
+
// Plain JavaScript with no Node APIs, so it also runs in a browser. src/index.js does
|
|
10
|
+
// not import this file, so it stays out of the browser bundle and its size budget.
|
|
11
|
+
|
|
12
|
+
import { scanHiddenSpans, decodeRange } from './hiddenScan.js';
|
|
13
|
+
|
|
14
|
+
export { VERSION } from './version.generated.js';
|
|
15
|
+
|
|
16
|
+
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator' | 'reserved_tag_plane'} HiddenRunKind */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} HiddenRun
|
|
20
|
+
* @property {HiddenRunKind} kind
|
|
21
|
+
* @property {number} offset - UTF-16 index of the first character
|
|
22
|
+
* @property {number} end - UTF-16 index just past the last character
|
|
23
|
+
* @property {number} count - number of code points in the run
|
|
24
|
+
* @property {number} line - 1-based line of the first character (as in inspectTextDetailed)
|
|
25
|
+
* @property {number} column - 1-based column of the first character, in UTF-16 units
|
|
26
|
+
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText),
|
|
27
|
+
* at most `maxDecodedLength` UTF-16 units
|
|
28
|
+
* @property {boolean} [decodedTruncated] - tag runs only: true when `decoded` stops short of the run
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} HiddenRunCounts
|
|
33
|
+
* @property {number} tag
|
|
34
|
+
* @property {number} variation_selector
|
|
35
|
+
* @property {number} invisible_operator
|
|
36
|
+
* @property {number} reserved_tag_plane
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} HiddenTextScan
|
|
41
|
+
* @property {HiddenRun[]} runs - runs that may hide text, in order, at most `maxRuns` long
|
|
42
|
+
* @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
|
|
43
|
+
* sequences (subdivision flags, a single emoji or text presentation selector), at most `maxRuns` long
|
|
44
|
+
* @property {HiddenRunCounts} counts - every run per kind, listed or not
|
|
45
|
+
* @property {HiddenRunCounts} codePointCounts - code points in those runs per kind, listed or not
|
|
46
|
+
* @property {number} supplementarySelectorCount - code points U+E0100–U+E01EF in those runs, which
|
|
47
|
+
* (unlike the emoji selectors U+FE00–U+FE0F) can carry data; included in codePointCounts.variation_selector
|
|
48
|
+
* @property {number} total - sum of `counts`
|
|
49
|
+
* @property {number} benignTotal - every benign span, listed or not
|
|
50
|
+
* @property {boolean} truncated - true when runs or benign spans were left out of the lists
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @typedef {Object} HiddenScanOptions
|
|
55
|
+
* @property {number} [maxRuns=Infinity] - cap on listed runs, and separately on listed benign spans
|
|
56
|
+
* (non-negative integer or Infinity). Counts stay exact.
|
|
57
|
+
* @property {number} [maxDecodedLength=Infinity] - cap on each run's `decoded` text, in UTF-16 units.
|
|
58
|
+
* For both, any other value (negative, fractional, NaN, a string) means the default, no cap.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
|
|
63
|
+
* U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
|
|
64
|
+
* tags, other characters) is written as `<U+XXXX>`, so the result is printable.
|
|
65
|
+
* @param {string} text
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
export function decodeTagText(text) {
|
|
69
|
+
if (typeof text !== 'string') throw new TypeError('decodeTagText expects a string input.');
|
|
70
|
+
return decodeRange(text, 0, text.length, Infinity).decoded;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Finds runs of tag characters, variation selectors, invisible operators and reserved
|
|
75
|
+
* tag-plane code points (U+E0080–U+E00FF, U+E01F0–U+E0FFF).
|
|
76
|
+
* Consecutive characters of the same kind form one run. Two kinds of sequence are
|
|
77
|
+
* legitimate and listed under `benign` instead of `runs`:
|
|
78
|
+
* - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
|
|
79
|
+
* tags for "gbeng", "gbsct" or "gbwls" and U+E007F
|
|
80
|
+
* - a single U+FE0E or U+FE0F (text or emoji presentation) after an
|
|
81
|
+
* Extended_Pictographic character, or between #, * or a digit and U+20E3 (a keycap),
|
|
82
|
+
* the same rule as text-sanctifier/context. After a letter it is a run: one selector
|
|
83
|
+
* per letter can carry a bit each.
|
|
84
|
+
*
|
|
85
|
+
* `line` and `column` are counted as in inspectTextDetailed. Every run and benign
|
|
86
|
+
* span is counted; only the first `maxRuns` of each are listed. With the caps set,
|
|
87
|
+
* memory stays flat however large the input is. One pass over the string, no copy.
|
|
88
|
+
* Set both caps for untrusted input: without them, results grow with the input.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} text
|
|
91
|
+
* @param {HiddenScanOptions} [options]
|
|
92
|
+
* @returns {HiddenTextScan}
|
|
93
|
+
*/
|
|
94
|
+
export function findHiddenRuns(text, options = {}) {
|
|
95
|
+
return scanHiddenSpans(text, options);
|
|
96
|
+
}
|