text-sanctifier 1.3.0 → 1.3.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/README.md +86 -19
- package/bin/text-sanctifier.js +261 -27
- package/dist/text-sanctifier.min.js +15 -14
- package/dist/types/categories.generated.d.ts +27 -0
- package/dist/types/hiddenText.d.ts +50 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/inspectText.d.ts +41 -22
- package/dist/types/sanctifyText.d.ts +3 -29
- package/package.json +76 -74
- package/src/categories.generated.js +223 -0
- package/src/hiddenText.js +119 -0
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -2
- package/src/inspectText.js +21 -64
- package/src/sanctifyText.js +11 -16
|
@@ -0,0 +1,119 @@
|
|
|
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 and invisible operators. Tag runs are decoded
|
|
5
|
+
// to the ASCII they spell. Used by `text-sanctifier check --ai-config`.
|
|
6
|
+
//
|
|
7
|
+
// Node and command-line only: src/index.js does not import this file, so it never
|
|
8
|
+
// reaches the browser bundle.
|
|
9
|
+
|
|
10
|
+
/** @typedef {'tag' | 'variation_selector' | 'invisible_operator'} HiddenRunKind */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} HiddenRun
|
|
14
|
+
* @property {HiddenRunKind} kind
|
|
15
|
+
* @property {number} offset - UTF-16 index of the first character
|
|
16
|
+
* @property {number} end - UTF-16 index just past the last character
|
|
17
|
+
* @property {number} count - number of code points in the run
|
|
18
|
+
* @property {string} [decoded] - tag runs only: the ASCII the tags spell (see decodeTagText)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} HiddenTextScan
|
|
23
|
+
* @property {HiddenRun[]} runs - runs that may hide text, in order
|
|
24
|
+
* @property {Array<[number, number]>} benign - [offset, end) spans of legitimate
|
|
25
|
+
* sequences (subdivision flags, a single emoji or text presentation selector)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const TAG_FIRST = 0xE0000;
|
|
29
|
+
const TAG_LAST = 0xE007F;
|
|
30
|
+
const CANCEL_TAG = 0xE007F;
|
|
31
|
+
const BLACK_FLAG = 0x1F3F4;
|
|
32
|
+
|
|
33
|
+
// RGI subdivision flags: U+1F3F4, the tag letters, U+E007F
|
|
34
|
+
const SUBDIVISION_FLAGS = new Set(['gbeng', 'gbsct', 'gbwls']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {number} cp
|
|
38
|
+
* @returns {HiddenRunKind | null}
|
|
39
|
+
*/
|
|
40
|
+
function kindOf(cp) {
|
|
41
|
+
if (cp >= TAG_FIRST && cp <= TAG_LAST) return 'tag';
|
|
42
|
+
if ((cp >= 0xFE00 && cp <= 0xFE0F) || (cp >= 0xE0100 && cp <= 0xE01EF) ||
|
|
43
|
+
(cp >= 0x180B && cp <= 0x180D) || cp === 0x180F) return 'variation_selector';
|
|
44
|
+
if (cp >= 0x2061 && cp <= 0x2064) return 'invisible_operator';
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {number} cp */
|
|
49
|
+
const hex = (cp) => 'U+' + cp.toString(16).toUpperCase().padStart(4, '0');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decodes tag characters to the ASCII they shadow: U+E0020–U+E007E become
|
|
53
|
+
* U+0020–U+007E. Anything else (U+E0001 LANGUAGE TAG, U+E007F CANCEL TAG, unassigned
|
|
54
|
+
* tags, other characters) is written as `<U+XXXX>`, so the result is printable.
|
|
55
|
+
* @param {string} text
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function decodeTagText(text) {
|
|
59
|
+
let out = '';
|
|
60
|
+
for (const ch of text) {
|
|
61
|
+
const cp = /** @type {number} */ (ch.codePointAt(0));
|
|
62
|
+
out += cp >= 0xE0020 && cp <= 0xE007E ? String.fromCharCode(cp - TAG_FIRST) : `<${hex(cp)}>`;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Finds runs of tag characters, variation selectors and invisible operators.
|
|
69
|
+
* Consecutive characters of the same kind form one run. Two kinds of sequence are
|
|
70
|
+
* legitimate and listed under `benign` instead of `runs`:
|
|
71
|
+
* - the subdivision flags of England, Scotland and Wales: U+1F3F4 followed by the
|
|
72
|
+
* tags for "gbeng", "gbsct" or "gbwls" and U+E007F
|
|
73
|
+
* - a single U+FE0E or U+FE0F (text or emoji presentation) after another character
|
|
74
|
+
* @param {string} text
|
|
75
|
+
* @returns {HiddenTextScan}
|
|
76
|
+
*/
|
|
77
|
+
export function findHiddenRuns(text) {
|
|
78
|
+
/** @type {HiddenRun[]} */
|
|
79
|
+
const runs = [];
|
|
80
|
+
/** @type {Array<[number, number]>} */
|
|
81
|
+
const benign = [];
|
|
82
|
+
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < text.length) {
|
|
85
|
+
const cp = /** @type {number} */ (text.codePointAt(i));
|
|
86
|
+
const kind = kindOf(cp);
|
|
87
|
+
if (kind === null) { i += cp > 0xFFFF ? 2 : 1; continue; }
|
|
88
|
+
|
|
89
|
+
const offset = i;
|
|
90
|
+
let count = 0;
|
|
91
|
+
let last = cp;
|
|
92
|
+
while (i < text.length) {
|
|
93
|
+
const c = /** @type {number} */ (text.codePointAt(i));
|
|
94
|
+
if (kindOf(c) !== kind) break;
|
|
95
|
+
last = c;
|
|
96
|
+
count++;
|
|
97
|
+
i += c > 0xFFFF ? 2 : 1;
|
|
98
|
+
}
|
|
99
|
+
const end = i;
|
|
100
|
+
|
|
101
|
+
if (kind === 'tag') {
|
|
102
|
+
const body = text.slice(offset, end);
|
|
103
|
+
const afterFlag = offset >= 2 && text.codePointAt(offset - 2) === BLACK_FLAG;
|
|
104
|
+
if (afterFlag && last === CANCEL_TAG && SUBDIVISION_FLAGS.has(decodeTagText(body).slice(0, -'<U+E007F>'.length))) {
|
|
105
|
+
benign.push([offset, end]);
|
|
106
|
+
} else {
|
|
107
|
+
runs.push({ kind, offset, end, count, decoded: decodeTagText(body) });
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (kind === 'variation_selector' && count === 1 && (cp === 0xFE0E || cp === 0xFE0F) &&
|
|
112
|
+
offset > 0 && !/\s/.test(text[offset - 1])) {
|
|
113
|
+
benign.push([offset, end]);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
runs.push({ kind, offset, end, count });
|
|
117
|
+
}
|
|
118
|
+
return { runs, benign };
|
|
119
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { inspectText } from './inspectText.js';
|
|
2
2
|
import { inspectTextDetailed } from './inspectText.js';
|
|
3
|
+
import { getNewlineStats } from './inspectText.js';
|
|
3
4
|
import { getRecommendedSanctifierOptions } from './inspectText.js';
|
|
4
5
|
import { summonSanctifier } from './sanctifyText.js';
|
|
5
|
-
export { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions, summonSanctifier };
|
|
6
|
+
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions, summonSanctifier };
|
|
6
7
|
export type UnicodeTrashReport = import('./inspectText.js').UnicodeTrashReport;
|
|
7
8
|
export type TrashCategory = import('./inspectText.js').TrashCategory;
|
|
8
9
|
export type TrashFinding = import('./inspectText.js').TrashFinding;
|
|
9
10
|
export type TrashCounts = import('./inspectText.js').TrashCounts;
|
|
10
11
|
export type DetailedTrashReport = import('./inspectText.js').DetailedTrashReport;
|
|
11
12
|
export type InspectDetailedOptions = import('./inspectText.js').InspectDetailedOptions;
|
|
13
|
+
export type NewlineStats = import('./inspectText.js').NewlineStats;
|
|
12
14
|
export type SanctifyOptions = import('./sanctifyText.js').SanctifyOptions;
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/index.js
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
import { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
|
-
export { inspectText, inspectTextDetailed, getRecommendedSanctifierOptions };
|
|
4
|
+
import { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions } from './inspectText.js';
|
|
5
|
+
export { inspectText, inspectTextDetailed, getNewlineStats, getRecommendedSanctifierOptions };
|
|
6
6
|
|
|
7
7
|
import { summonSanctifier } from './sanctifyText.js';
|
|
8
8
|
export { summonSanctifier };
|
|
@@ -14,6 +14,7 @@ export { summonSanctifier };
|
|
|
14
14
|
/** @typedef {import('./inspectText.js').TrashCounts} TrashCounts */
|
|
15
15
|
/** @typedef {import('./inspectText.js').DetailedTrashReport} DetailedTrashReport */
|
|
16
16
|
/** @typedef {import('./inspectText.js').InspectDetailedOptions} InspectDetailedOptions */
|
|
17
|
+
/** @typedef {import('./inspectText.js').NewlineStats} NewlineStats */
|
|
17
18
|
/** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
|
|
18
19
|
|
|
19
20
|
|
package/src/inspectText.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
ASCII_KEYBOARD_SAFE_REGEX,
|
|
8
8
|
normalizeTypographicJank,
|
|
9
9
|
} from './sanctifyText.js';
|
|
10
|
+
import { classifyCodeUnit, classifyAstral } from './categories.generated.js';
|
|
10
11
|
|
|
11
12
|
/** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
|
|
12
13
|
|
|
@@ -167,73 +168,26 @@ export function inspectText(text) {
|
|
|
167
168
|
/**
|
|
168
169
|
* @typedef {Object} InspectDetailedOptions
|
|
169
170
|
* @property {number} [maxFindings=100] - Cap on listed findings (non-negative integer or Infinity).
|
|
171
|
+
* Each listed finding is an object, so memory grows with the number listed; 0 builds none (counts only).
|
|
170
172
|
*/
|
|
171
173
|
|
|
172
174
|
const DEFAULT_MAX_FINDINGS = 100;
|
|
173
175
|
|
|
174
|
-
/**
|
|
175
|
-
* Category of one BMP code point (one UTF-16 code unit), or null when it is not trash.
|
|
176
|
-
* Tab, LF and CR are whitespace, not trash. DEL (U+007F) is counted with the C0 controls.
|
|
177
|
-
* Surrogates return null; the caller pairs them and uses classifyAstral.
|
|
178
|
-
* @param {number} c
|
|
179
|
-
* @returns {TrashCategory|null}
|
|
180
|
-
*/
|
|
181
|
-
function classifyCodeUnit(c) {
|
|
182
|
-
if (c < 0x80) {
|
|
183
|
-
if (c === 0) return 'nul';
|
|
184
|
-
if (c < 0x20) return c === 0x09 || c === 0x0A || c === 0x0D ? null : 'c0_control';
|
|
185
|
-
return c === 0x7F ? 'c0_control' : null;
|
|
186
|
-
}
|
|
187
|
-
if (c <= 0x9F) return 'c1_control';
|
|
188
|
-
if (c === 0xA0) return 'nonbreaking_space';
|
|
189
|
-
if (c === 0xAD || c === 0x034F || c === 0x180E) return 'invisible_format';
|
|
190
|
-
if (c === 0x061C) return 'bidi_control';
|
|
191
|
-
if (c === 0x115F || c === 0x1160) return 'hangul_filler';
|
|
192
|
-
if (c < 0x2000) return null;
|
|
193
|
-
if (c <= 0x200A) return 'nonbreaking_space';
|
|
194
|
-
if (c <= 0x200D) return 'zero_width';
|
|
195
|
-
if (c <= 0x200F) return 'bidi_control';
|
|
196
|
-
if (c >= 0x202A && c <= 0x202E) return 'bidi_control';
|
|
197
|
-
if (c === 0x202F) return 'nonbreaking_space';
|
|
198
|
-
if (c === 0x2060) return 'zero_width';
|
|
199
|
-
if (c >= 0x2061 && c <= 0x2064) return 'invisible_format';
|
|
200
|
-
if (c >= 0x2066 && c <= 0x2069) return 'bidi_control';
|
|
201
|
-
if (c === 0x3000) return 'nonbreaking_space';
|
|
202
|
-
if (c === 0x3164 || c === 0xFFA0) return 'hangul_filler';
|
|
203
|
-
if (c >= 0xFE00 && c <= 0xFE0F) return 'variation_selector';
|
|
204
|
-
if (c === 0xFEFF) return 'bom';
|
|
205
|
-
return null;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Category of a code point above U+FFFF, or null when it is not trash.
|
|
210
|
-
* @param {number} cp
|
|
211
|
-
* @returns {TrashCategory|null}
|
|
212
|
-
*/
|
|
213
|
-
function classifyAstral(cp) {
|
|
214
|
-
if (cp >= 0xE0000 && cp <= 0xE007F) return 'tag';
|
|
215
|
-
if (cp >= 0xE0100 && cp <= 0xE01EF) return 'variation_selector';
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** High surrogate of U+E0000–U+E03FF, the only astral block with trash (tags, VS17–VS256). */
|
|
220
|
-
const TRASH_HIGH_SURROGATE = 0xDB40;
|
|
221
|
-
|
|
222
176
|
/**
|
|
223
177
|
* Locates invisible and control characters, one finding per character.
|
|
224
178
|
*
|
|
225
|
-
* Categories:
|
|
179
|
+
* Categories (generated from scripts/categories.js by `npm run gen`):
|
|
226
180
|
* - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
|
|
227
181
|
* - `zero_width`: U+200B–U+200D, U+2060
|
|
228
|
-
* - `nonbreaking_space`:
|
|
229
|
-
* - `bom`: U+FEFF (`atStart`
|
|
182
|
+
* - `nonbreaking_space`: U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000 (non-breaking and typographic spaces)
|
|
183
|
+
* - `bom`: U+FEFF (`atStart` is true at offset 0, where a BOM is legitimate)
|
|
230
184
|
* - `nul`: U+0000
|
|
231
|
-
* - `c0_control`: U+0001–U+001F
|
|
185
|
+
* - `c0_control`: U+0001–U+0008, U+000B, U+000C, U+000E–U+001F, U+007F (tab, LF and CR are whitespace, not trash)
|
|
232
186
|
* - `c1_control`: U+0080–U+009F
|
|
233
|
-
* - `invisible_format`: soft hyphen U+
|
|
187
|
+
* - `invisible_format`: U+00AD (soft hyphen), U+034F (combining grapheme joiner), U+17B4, U+17B5 (Khmer inherent vowels), U+180E (Mongolian vowel separator), U+2061–U+2064 (invisible operators), U+2065 (reserved), U+206A–U+206F (deprecated format characters), U+FFF0–U+FFF8 (reserved), U+1BCA0–U+1BCA3 (shorthand format controls), U+1D173–U+1D17A (musical formatting), U+E0080–U+E00FF (reserved), U+E01F0–U+E0FFF (reserved) (every other Default_Ignorable_Code_Point, which renderers draw as nothing)
|
|
234
188
|
* - `hangul_filler`: U+115F, U+1160, U+3164, U+FFA0 (letters that render blank)
|
|
235
|
-
* - `variation_selector`: U+
|
|
236
|
-
* - `tag`: U+E0000–U+E007F (tag characters, used for "ASCII smuggling")
|
|
189
|
+
* - `variation_selector`: U+180B–U+180D (Mongolian FVS1-FVS3), U+180F (Mongolian FVS4), U+FE00–U+FE0F (VS1-VS16, including VS16 in emoji), U+E0100–U+E01EF (VS17-VS256)
|
|
190
|
+
* - `tag`: U+E0000–U+E007F (tag characters, used for "ASCII smuggling" of hidden instructions)
|
|
237
191
|
*
|
|
238
192
|
* `codePoint` is the full code point; `offset` and `column` count UTF-16 units.
|
|
239
193
|
* Every occurrence is counted; only the first `maxFindings` are listed.
|
|
@@ -297,7 +251,7 @@ export function inspectTextDetailed(text, options = {}) {
|
|
|
297
251
|
const offset = i;
|
|
298
252
|
let codePoint = c;
|
|
299
253
|
let category;
|
|
300
|
-
if (c
|
|
254
|
+
if (c >= 0xD800 && c <= 0xDBFF) {
|
|
301
255
|
const low = text.charCodeAt(i + 1);
|
|
302
256
|
if (!(low >= 0xDC00 && low <= 0xDFFF)) continue; // lone surrogate
|
|
303
257
|
codePoint = 0x10000 + ((c - 0xD800) << 10) + (low - 0xDC00);
|
|
@@ -322,15 +276,18 @@ export function inspectTextDetailed(text, options = {}) {
|
|
|
322
276
|
}
|
|
323
277
|
|
|
324
278
|
/**
|
|
325
|
-
*
|
|
279
|
+
* @typedef {Object} NewlineStats
|
|
280
|
+
* @property {number} crlf - CR LF pairs.
|
|
281
|
+
* @property {number} cr - CRs not followed by LF.
|
|
282
|
+
* @property {number} lf - LFs not preceded by CR.
|
|
283
|
+
* @property {('CRLF'|'CR'|'LF')[]} types - Styles present, in the order CRLF, CR, LF.
|
|
284
|
+
* @property {boolean} mixed - True when more than one style is present.
|
|
285
|
+
*/
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Counts each newline style in one pass, with no regex and no copy.
|
|
326
289
|
* @param {string} text
|
|
327
|
-
* @returns {
|
|
328
|
-
* crlf: number,
|
|
329
|
-
* cr: number,
|
|
330
|
-
* lf: number,
|
|
331
|
-
* types: string[],
|
|
332
|
-
* mixed: boolean
|
|
333
|
-
* }}
|
|
290
|
+
* @returns {NewlineStats}
|
|
334
291
|
*/
|
|
335
292
|
export function getNewlineStats(text) {
|
|
336
293
|
if (typeof text !== 'string') {
|
package/src/sanctifyText.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// src/sanctifyText.js
|
|
2
2
|
|
|
3
|
+
import { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX } from './categories.generated.js';
|
|
4
|
+
export { CONTROL_CHARS_REGEX, INVISIBLE_TRASH_REGEX };
|
|
5
|
+
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* @typedef {Object} SanctifyOptions
|
|
@@ -272,21 +275,14 @@ export function sanctifyText(
|
|
|
272
275
|
|
|
273
276
|
|
|
274
277
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* - Zero-width spaces and miscellaneous Unicode spaces ( –, , , , )
|
|
280
|
-
* - Bidi controls: marks, embeddings/overrides and isolates (, , , –, –)
|
|
281
|
-
* - Invisible format characters: soft hyphen, CGJ, Mongolian vowel separator, invisible operators (, ͏, , –)
|
|
282
|
-
* - Hangul fillers (ᅟ, ᅠ, ㅤ, ᅠ)
|
|
283
|
-
* - Variation selectors (︀–️, \u{E0100}–\u{E01EF}), including VS16 in emoji
|
|
284
|
-
* - Tag characters (\u{E0000}–\u{E007F}), used for "ASCII smuggling"
|
|
278
|
+
* Removes the invisible Unicode characters in INVISIBLE_TRASH_REGEX: non-breaking and
|
|
279
|
+
* typographic spaces, zero-width characters, bidi controls, invisible format characters,
|
|
280
|
+
* Hangul fillers, variation selectors (including VS16 in emoji), the BOM and tag characters.
|
|
281
|
+
* The code points come from the table in scripts/categories.js.
|
|
285
282
|
*
|
|
286
283
|
* @param {string} text
|
|
287
284
|
* @returns {string}
|
|
288
285
|
*/
|
|
289
|
-
export const INVISIBLE_TRASH_REGEX = /[ ͏ᅟᅠ -- -- ㅤ︀-️ᅠ\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]+/gu;
|
|
290
286
|
function purgeInvisibleTrash(text) {
|
|
291
287
|
return text.replace(INVISIBLE_TRASH_REGEX, '');
|
|
292
288
|
}
|
|
@@ -497,15 +493,14 @@ function collapseExtraSpaces(text) {
|
|
|
497
493
|
* Nukes hidden control characters that are invisible and often dangerous.
|
|
498
494
|
* (Excludes necessary whitespace like \n and \t.)
|
|
499
495
|
*
|
|
500
|
-
* Control characters nuked:
|
|
501
|
-
* -
|
|
502
|
-
* -
|
|
503
|
-
* - Bidi controls
|
|
496
|
+
* Control characters nuked (the code points come from the table in scripts/categories.js):
|
|
497
|
+
* - NUL and the C0 controls except tab, LF and CR; DEL (U+007F)
|
|
498
|
+
* - C1 controls (U+0080-U+009F)
|
|
499
|
+
* - Bidi controls
|
|
504
500
|
*
|
|
505
501
|
* @param {string} text
|
|
506
502
|
* @returns {string}
|
|
507
503
|
*/
|
|
508
|
-
export const CONTROL_CHARS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+/g;
|
|
509
504
|
function purgeControlCharacters(text) {
|
|
510
505
|
return text.replace(CONTROL_CHARS_REGEX, '');
|
|
511
506
|
}
|