text-sanctifier 1.1.1 → 1.3.1

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.
@@ -1,342 +1,391 @@
1
- // ./src/inspectText.js
2
-
3
- import {
4
- CONTROL_CHARS_REGEX,
5
- INVISIBLE_TRASH_REGEX,
6
- EMOJI_REGEX,
7
- ASCII_KEYBOARD_SAFE_REGEX,
8
- normalizeTypographicJank,
9
- } from './sanctifyText.js';
10
-
11
- /** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
12
-
13
-
14
- /**
15
- * @typedef {Object} UnicodeTrashReport
16
- * @property {boolean} hasControlChars
17
- * @property {boolean} hasInvisibleChars
18
- * @property {boolean} hasMixedNewlines
19
- * @property {'LF'|'CRLF'|'CR'|'Mixed'|null} newlineStyle
20
- * @property {boolean} hasEmojis
21
- * @property {boolean} hasNonKeyboardChars
22
- * @property {string[]} summary
23
- */
24
-
25
-
26
- /**
27
- * Safe `.test()` for global/sticky regexes.
28
- * Global regexes mutate `lastIndex`, which makes `.test()` unreliable across calls.
29
- * @param {RegExp} re
30
- * @param {string} s
31
- */
32
- function stableTest(re, s) {
33
- re.lastIndex = 0;
34
- return re.test(s);
35
- }
36
-
37
- /**
38
- * Returns true if text contains any non-keyboard characters (excluding emojis),
39
- * after typographic normalization.
40
- *
41
- * "Keyboard" here means:
42
- * - Printable ASCII (0x20–0x7E)
43
- * - CR/LF
44
- * - Emojis (per EMOJI_REGEX)
45
- *
46
- * @param {string} text
47
- * @returns {boolean}
48
- */
49
- function hasNonKeyboardCharsExcludingEmoji(text) {
50
- const normalized = normalizeTypographicJank(text);
51
-
52
- // Fast path: no non-ascii runs => no non-keyboard chars
53
- if (!stableTest(ASCII_KEYBOARD_SAFE_REGEX, normalized)) return false;
54
-
55
- // Walk code points so we can distinguish emoji from other non-ascii safely
56
- for (const ch of normalized) {
57
- // allowed: ASCII printable + newlines
58
- if (ch === '\n' || ch === '\r' || (ch >= ' ' && ch <= '~')) continue;
59
-
60
- // allowed: emoji
61
- if (stableTest(EMOJI_REGEX, ch)) continue;
62
-
63
- // anything else non-ascii is "non-keyboard"
64
- return true;
65
- }
66
-
67
- return false;
68
- }
69
-
70
- /**
71
- * Detects textual "trash" or anomalies in a given string.
72
- * @param {string} text @returns {UnicodeTrashReport}
73
- */
74
- export function inspectText(text) {
75
- if (typeof text !== 'string') {
76
- throw new TypeError('inspectText expects a string input.');
77
- }
78
-
79
- const summary = [];
80
- const report = {
81
- hasControlChars: false,
82
- hasInvisibleChars: false,
83
- hasMixedNewlines: false,
84
- newlineStyle: null,
85
- hasEmojis: false,
86
- hasNonKeyboardChars: false,
87
- summary,
88
- };
89
-
90
- const flag = (condition, key, message) => {
91
- if (condition) {
92
- report[key] = true;
93
- summary.push(message);
94
- }
95
- };
96
-
97
- // === Pattern Checks ===
98
- flag(stableTest(CONTROL_CHARS_REGEX, text), 'hasControlChars', 'Control characters detected.');
99
- flag(stableTest(INVISIBLE_TRASH_REGEX, text), 'hasInvisibleChars', 'Invisible Unicode characters detected.');
100
- flag(stableTest(EMOJI_REGEX, text), 'hasEmojis', 'Emojis detected.');
101
-
102
- // === Newline Analysis ===
103
- const { mixed, types } = getNewlineStats(text);
104
- report.hasMixedNewlines = mixed;
105
- report.newlineStyle = mixed ? 'Mixed' : types[0] || null;
106
-
107
- if (report.newlineStyle) {
108
- summary.push(
109
- mixed ? 'Mixed newline styles detected.' : `Consistent newline style: ${report.newlineStyle}`
110
- );
111
- }
112
-
113
- // === Non-keyboard characters (excluding emojis) ===
114
- flag(
115
- hasNonKeyboardCharsExcludingEmoji(text),
116
- 'hasNonKeyboardChars',
117
- 'Non-keyboard characters detected.'
118
- );
119
-
120
- return report;
121
- }
122
-
123
- /**
124
- * @typedef {'bidi_control'|'zero_width'|'nonbreaking_space'|'bom'|'nul'|'c0_control'|'c1_control'} TrashCategory
125
- */
126
-
127
- /**
128
- * One located character.
129
- * - `offset`: 0-based UTF-16 index into the input (a JS string index).
130
- * - `line`: 1-based; lines end at LF, CR, or CRLF (counted once).
131
- * - `column`: 1-based, in UTF-16 code units from the start of the line.
132
- * - `atStart`: present on `bom` findings only; true at offset 0, where a BOM is legitimate.
133
- *
134
- * @typedef {Object} TrashFinding
135
- * @property {number} offset
136
- * @property {number} line
137
- * @property {number} column
138
- * @property {number} codePoint
139
- * @property {TrashCategory} category
140
- * @property {boolean} [atStart]
141
- */
142
-
143
- /**
144
- * @typedef {Object} TrashCounts
145
- * @property {number} bidi_control
146
- * @property {number} zero_width
147
- * @property {number} nonbreaking_space
148
- * @property {number} bom
149
- * @property {number} nul
150
- * @property {number} c0_control
151
- * @property {number} c1_control
152
- */
153
-
154
- /**
155
- * @typedef {Object} DetailedTrashReport
156
- * @property {TrashFinding[]} findings - In offset order, at most `maxFindings` long.
157
- * @property {TrashCounts} counts - Every occurrence per category, listed or not.
158
- * @property {number} total - Sum of `counts`.
159
- * @property {boolean} truncated - True when `total` exceeds `findings.length`.
160
- */
161
-
162
- /**
163
- * @typedef {Object} InspectDetailedOptions
164
- * @property {number} [maxFindings=100] - Cap on listed findings (non-negative integer or Infinity).
165
- */
166
-
167
- const DEFAULT_MAX_FINDINGS = 100;
168
-
169
- /**
170
- * Category of one UTF-16 code unit, or null when it is not trash.
171
- * Tab, LF and CR are whitespace, not trash. DEL (U+007F) is counted with the C0 controls.
172
- * @param {number} c
173
- * @returns {TrashCategory|null}
174
- */
175
- function classifyCodeUnit(c) {
176
- if (c < 0x80) {
177
- if (c === 0) return 'nul';
178
- if (c < 0x20) return c === 0x09 || c === 0x0A || c === 0x0D ? null : 'c0_control';
179
- return c === 0x7F ? 'c0_control' : null;
180
- }
181
- if (c <= 0x9F) return 'c1_control';
182
- if (c === 0xA0) return 'nonbreaking_space';
183
- if (c === 0x061C) return 'bidi_control';
184
- if (c < 0x2000) return null;
185
- if (c <= 0x200A) return 'nonbreaking_space';
186
- if (c <= 0x200D) return 'zero_width';
187
- if (c <= 0x200F) return 'bidi_control';
188
- if (c >= 0x202A && c <= 0x202E) return 'bidi_control';
189
- if (c === 0x202F) return 'nonbreaking_space';
190
- if (c === 0x2060) return 'zero_width';
191
- if (c >= 0x2066 && c <= 0x2069) return 'bidi_control';
192
- if (c === 0x3000) return 'nonbreaking_space';
193
- if (c === 0xFEFF) return 'bom';
194
- return null;
195
- }
196
-
197
- /**
198
- * Locates invisible and control characters, one finding per character.
199
- *
200
- * Categories:
201
- * - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
202
- * - `zero_width`: U+200B–U+200D, U+2060
203
- * - `nonbreaking_space`: non-breaking and typographic spaces U+00A0, U+2000–U+200A, U+202F, U+3000
204
- * - `bom`: U+FEFF (`atStart` says whether it sits at offset 0)
205
- * - `nul`: U+0000
206
- * - `c0_control`: U+0001–U+001F except tab, LF and CR; and U+007F
207
- * - `c1_control`: U+0080–U+009F
208
- *
209
- * Every occurrence is counted; only the first `maxFindings` are listed.
210
- * One pass over the string, no regex, no copy.
211
- *
212
- * @param {string} text
213
- * @param {InspectDetailedOptions} [options]
214
- * @returns {DetailedTrashReport}
215
- */
216
- export function inspectTextDetailed(text, options = {}) {
217
- if (typeof text !== 'string') {
218
- throw new TypeError('inspectTextDetailed expects a string input.');
219
- }
220
-
221
- const requested = options && options.maxFindings;
222
- const maxFindings =
223
- requested === Infinity || (Number.isInteger(requested) && requested >= 0)
224
- ? requested
225
- : DEFAULT_MAX_FINDINGS;
226
-
227
- /** @type {TrashCounts} */
228
- const counts = {
229
- bidi_control: 0,
230
- zero_width: 0,
231
- nonbreaking_space: 0,
232
- bom: 0,
233
- nul: 0,
234
- c0_control: 0,
235
- c1_control: 0,
236
- };
237
- /** @type {TrashFinding[]} */
238
- const findings = [];
239
- let total = 0;
240
- let line = 1;
241
- let lineStart = 0;
242
-
243
- for (let i = 0, n = text.length; i < n; i++) {
244
- const c = text.charCodeAt(i);
245
-
246
- // Printable ASCII: the common case
247
- if (c >= 0x20 && c < 0x7F) continue;
248
-
249
- if (c === 0x0A) {
250
- line++;
251
- lineStart = i + 1;
252
- continue;
253
- }
254
- if (c === 0x0D) {
255
- // CRLF ends the line at its LF
256
- if (text.charCodeAt(i + 1) !== 0x0A) {
257
- line++;
258
- lineStart = i + 1;
259
- }
260
- continue;
261
- }
262
-
263
- const category = classifyCodeUnit(c);
264
- if (category === null) continue;
265
-
266
- counts[category]++;
267
- total++;
268
- if (findings.length < maxFindings) {
269
- /** @type {TrashFinding} */
270
- const finding = { offset: i, line, column: i - lineStart + 1, codePoint: c, category };
271
- if (category === 'bom') finding.atStart = i === 0;
272
- findings.push(finding);
273
- }
274
- }
275
-
276
- return { findings, counts, total, truncated: total > findings.length };
277
- }
278
-
279
- /**
280
- * Counts the number of different newline types in a string.
281
- * @param {string} text
282
- * @returns {{
283
- * crlf: number,
284
- * cr: number,
285
- * lf: number,
286
- * types: string[],
287
- * mixed: boolean
288
- * }}
289
- */
290
- export function getNewlineStats(text) {
291
- if (typeof text !== 'string') {
292
- throw new TypeError('getNewlineStats expects a string input.');
293
- }
294
-
295
- // One counting pass: a CR followed by LF is one CRLF; any other CR or LF stands alone.
296
- let crlf = 0;
297
- let cr = 0;
298
- let lf = 0;
299
-
300
- for (let i = 0, n = text.length; i < n; i++) {
301
- const c = text.charCodeAt(i);
302
- if (c === 0x0D) {
303
- if (text.charCodeAt(i + 1) === 0x0A) {
304
- crlf++;
305
- i++;
306
- } else {
307
- cr++;
308
- }
309
- } else if (c === 0x0A) {
310
- lf++;
311
- }
312
- }
313
-
314
- const count = { crlf, cr, lf };
315
-
316
- const types = [];
317
- if (count.crlf > 0) types.push('CRLF');
318
- if (count.cr > 0) types.push('CR');
319
- if (count.lf > 0) types.push('LF');
320
-
321
- return {
322
- ...count,
323
- types,
324
- mixed: types.length > 1,
325
- };
326
- }
327
-
328
- /**
329
- * Creates defaultOptions for summonSanctifier based on inspectText result
330
- * @param {!UnicodeTrashReport} report
331
- * @return {!SanctifyOptions}
332
- */
333
- export function getRecommendedSanctifierOptions(report) {
334
- return {
335
- purgeInvisibleChars: report.hasInvisibleChars,
336
- purgeEmojis: report.hasEmojis,
337
- nukeControls: report.hasControlChars,
338
- keyboardOnlyFilter: report.hasNonKeyboardChars,
339
- normalizeNewlines:
340
- report.hasMixedNewlines || report.newlineStyle === 'CRLF' || report.newlineStyle === 'CR',
341
- };
1
+ // ./src/inspectText.js
2
+
3
+ import {
4
+ CONTROL_CHARS_REGEX,
5
+ INVISIBLE_TRASH_REGEX,
6
+ EMOJI_REGEX,
7
+ ASCII_KEYBOARD_SAFE_REGEX,
8
+ normalizeTypographicJank,
9
+ } from './sanctifyText.js';
10
+
11
+ /** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
12
+
13
+
14
+ /**
15
+ * @typedef {Object} UnicodeTrashReport
16
+ * @property {boolean} hasControlChars
17
+ * @property {boolean} hasInvisibleChars
18
+ * @property {boolean} hasMixedNewlines
19
+ * @property {'LF'|'CRLF'|'CR'|'Mixed'|null} newlineStyle
20
+ * @property {boolean} hasEmojis
21
+ * @property {boolean} hasNonKeyboardChars
22
+ * @property {string[]} summary
23
+ */
24
+
25
+
26
+ /**
27
+ * Safe `.test()` for global/sticky regexes.
28
+ * Global regexes mutate `lastIndex`, which makes `.test()` unreliable across calls.
29
+ * @param {RegExp} re
30
+ * @param {string} s
31
+ */
32
+ function stableTest(re, s) {
33
+ re.lastIndex = 0;
34
+ return re.test(s);
35
+ }
36
+
37
+ /**
38
+ * Returns true if text contains any non-keyboard characters (excluding emojis),
39
+ * after typographic normalization.
40
+ *
41
+ * "Keyboard" here means:
42
+ * - Printable ASCII (0x20–0x7E)
43
+ * - CR/LF
44
+ * - Emojis (per EMOJI_REGEX)
45
+ *
46
+ * @param {string} text
47
+ * @returns {boolean}
48
+ */
49
+ function hasNonKeyboardCharsExcludingEmoji(text) {
50
+ const normalized = normalizeTypographicJank(text);
51
+
52
+ // Fast path: no non-ascii runs => no non-keyboard chars
53
+ if (!stableTest(ASCII_KEYBOARD_SAFE_REGEX, normalized)) return false;
54
+
55
+ // Walk code points so we can distinguish emoji from other non-ascii safely
56
+ for (const ch of normalized) {
57
+ // allowed: ASCII printable + newlines
58
+ if (ch === '\n' || ch === '\r' || (ch >= ' ' && ch <= '~')) continue;
59
+
60
+ // allowed: emoji
61
+ if (stableTest(EMOJI_REGEX, ch)) continue;
62
+
63
+ // anything else non-ascii is "non-keyboard"
64
+ return true;
65
+ }
66
+
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Detects textual "trash" or anomalies in a given string.
72
+ * @param {string} text @returns {UnicodeTrashReport}
73
+ */
74
+ export function inspectText(text) {
75
+ if (typeof text !== 'string') {
76
+ throw new TypeError('inspectText expects a string input.');
77
+ }
78
+
79
+ const summary = [];
80
+ const report = {
81
+ hasControlChars: false,
82
+ hasInvisibleChars: false,
83
+ hasMixedNewlines: false,
84
+ newlineStyle: null,
85
+ hasEmojis: false,
86
+ hasNonKeyboardChars: false,
87
+ summary,
88
+ };
89
+
90
+ const flag = (condition, key, message) => {
91
+ if (condition) {
92
+ report[key] = true;
93
+ summary.push(message);
94
+ }
95
+ };
96
+
97
+ // === Pattern Checks ===
98
+ flag(stableTest(CONTROL_CHARS_REGEX, text), 'hasControlChars', 'Control characters detected.');
99
+ flag(stableTest(INVISIBLE_TRASH_REGEX, text), 'hasInvisibleChars', 'Invisible Unicode characters detected.');
100
+ flag(stableTest(EMOJI_REGEX, text), 'hasEmojis', 'Emojis detected.');
101
+
102
+ // === Newline Analysis ===
103
+ const { mixed, types } = getNewlineStats(text);
104
+ report.hasMixedNewlines = mixed;
105
+ report.newlineStyle = mixed ? 'Mixed' : types[0] || null;
106
+
107
+ if (report.newlineStyle) {
108
+ summary.push(
109
+ mixed ? 'Mixed newline styles detected.' : `Consistent newline style: ${report.newlineStyle}`
110
+ );
111
+ }
112
+
113
+ // === Non-keyboard characters (excluding emojis) ===
114
+ flag(
115
+ hasNonKeyboardCharsExcludingEmoji(text),
116
+ 'hasNonKeyboardChars',
117
+ 'Non-keyboard characters detected.'
118
+ );
119
+
120
+ return report;
121
+ }
122
+
123
+ /**
124
+ * @typedef {'bidi_control'|'zero_width'|'nonbreaking_space'|'bom'|'nul'|'c0_control'|'c1_control'|'invisible_format'|'hangul_filler'|'variation_selector'|'tag'} TrashCategory
125
+ */
126
+
127
+ /**
128
+ * One located character.
129
+ * - `offset`: 0-based UTF-16 index into the input (a JS string index); for a code point
130
+ * above U+FFFF, the index of its high surrogate.
131
+ * - `line`: 1-based; lines end at LF, CR, or CRLF (counted once).
132
+ * - `column`: 1-based, in UTF-16 code units from the start of the line.
133
+ * - `atStart`: present on `bom` findings only; true at offset 0, where a BOM is legitimate.
134
+ *
135
+ * @typedef {Object} TrashFinding
136
+ * @property {number} offset
137
+ * @property {number} line
138
+ * @property {number} column
139
+ * @property {number} codePoint
140
+ * @property {TrashCategory} category
141
+ * @property {boolean} [atStart]
142
+ */
143
+
144
+ /**
145
+ * @typedef {Object} TrashCounts
146
+ * @property {number} bidi_control
147
+ * @property {number} zero_width
148
+ * @property {number} nonbreaking_space
149
+ * @property {number} bom
150
+ * @property {number} nul
151
+ * @property {number} c0_control
152
+ * @property {number} c1_control
153
+ * @property {number} invisible_format
154
+ * @property {number} hangul_filler
155
+ * @property {number} variation_selector
156
+ * @property {number} tag
157
+ */
158
+
159
+ /**
160
+ * @typedef {Object} DetailedTrashReport
161
+ * @property {TrashFinding[]} findings - In offset order, at most `maxFindings` long.
162
+ * @property {TrashCounts} counts - Every occurrence per category, listed or not.
163
+ * @property {number} total - Sum of `counts`.
164
+ * @property {boolean} truncated - True when `total` exceeds `findings.length`.
165
+ */
166
+
167
+ /**
168
+ * @typedef {Object} InspectDetailedOptions
169
+ * @property {number} [maxFindings=100] - Cap on listed findings (non-negative integer or Infinity).
170
+ * Each listed finding is an object, so memory grows with the number listed; 0 builds none (counts only).
171
+ */
172
+
173
+ const DEFAULT_MAX_FINDINGS = 100;
174
+
175
+ /**
176
+ * Category of one BMP code point (one UTF-16 code unit), or null when it is not trash.
177
+ * Tab, LF and CR are whitespace, not trash. DEL (U+007F) is counted with the C0 controls.
178
+ * Surrogates return null; the caller pairs them and uses classifyAstral.
179
+ * @param {number} c
180
+ * @returns {TrashCategory|null}
181
+ */
182
+ function classifyCodeUnit(c) {
183
+ if (c < 0x80) {
184
+ if (c === 0) return 'nul';
185
+ if (c < 0x20) return c === 0x09 || c === 0x0A || c === 0x0D ? null : 'c0_control';
186
+ return c === 0x7F ? 'c0_control' : null;
187
+ }
188
+ if (c <= 0x9F) return 'c1_control';
189
+ if (c === 0xA0) return 'nonbreaking_space';
190
+ if (c === 0xAD || c === 0x034F || c === 0x180E) return 'invisible_format';
191
+ if (c === 0x061C) return 'bidi_control';
192
+ if (c === 0x115F || c === 0x1160) return 'hangul_filler';
193
+ if (c < 0x2000) return null;
194
+ if (c <= 0x200A) return 'nonbreaking_space';
195
+ if (c <= 0x200D) return 'zero_width';
196
+ if (c <= 0x200F) return 'bidi_control';
197
+ if (c >= 0x202A && c <= 0x202E) return 'bidi_control';
198
+ if (c === 0x202F || c === 0x205F) return 'nonbreaking_space';
199
+ if (c === 0x2060) return 'zero_width';
200
+ if (c >= 0x2061 && c <= 0x2064) return 'invisible_format';
201
+ if (c >= 0x2066 && c <= 0x2069) return 'bidi_control';
202
+ if (c === 0x3000) return 'nonbreaking_space';
203
+ if (c === 0x3164 || c === 0xFFA0) return 'hangul_filler';
204
+ if (c >= 0xFE00 && c <= 0xFE0F) return 'variation_selector';
205
+ if (c === 0xFEFF) return 'bom';
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Category of a code point above U+FFFF, or null when it is not trash.
211
+ * @param {number} cp
212
+ * @returns {TrashCategory|null}
213
+ */
214
+ function classifyAstral(cp) {
215
+ if (cp >= 0xE0000 && cp <= 0xE007F) return 'tag';
216
+ if (cp >= 0xE0100 && cp <= 0xE01EF) return 'variation_selector';
217
+ return null;
218
+ }
219
+
220
+ /** High surrogate of U+E0000–U+E03FF, the only astral block with trash (tags, VS17–VS256). */
221
+ const TRASH_HIGH_SURROGATE = 0xDB40;
222
+
223
+ /**
224
+ * Locates invisible and control characters, one finding per character.
225
+ *
226
+ * Categories:
227
+ * - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
228
+ * - `zero_width`: U+200B–U+200D, U+2060
229
+ * - `nonbreaking_space`: non-breaking and typographic spaces U+00A0, U+2000–U+200A, U+202F, U+205F, U+3000
230
+ * - `bom`: U+FEFF (`atStart` says whether it sits at offset 0)
231
+ * - `nul`: U+0000
232
+ * - `c0_control`: U+0001–U+001F except tab, LF and CR; and U+007F
233
+ * - `c1_control`: U+0080–U+009F
234
+ * - `invisible_format`: soft hyphen U+00AD, U+034F, U+180E, invisible operators U+2061–U+2064
235
+ * - `hangul_filler`: U+115F, U+1160, U+3164, U+FFA0 (letters that render blank)
236
+ * - `variation_selector`: U+FE00–U+FE0F, U+E0100–U+E01EF (including VS16 in emoji)
237
+ * - `tag`: U+E0000–U+E007F (tag characters, used for "ASCII smuggling")
238
+ *
239
+ * `codePoint` is the full code point; `offset` and `column` count UTF-16 units.
240
+ * Every occurrence is counted; only the first `maxFindings` are listed.
241
+ * One pass over the string, no regex, no copy.
242
+ *
243
+ * @param {string} text
244
+ * @param {InspectDetailedOptions} [options]
245
+ * @returns {DetailedTrashReport}
246
+ */
247
+ export function inspectTextDetailed(text, options = {}) {
248
+ if (typeof text !== 'string') {
249
+ throw new TypeError('inspectTextDetailed expects a string input.');
250
+ }
251
+
252
+ const requested = options && options.maxFindings;
253
+ const maxFindings =
254
+ requested === Infinity || (Number.isInteger(requested) && requested >= 0)
255
+ ? requested
256
+ : DEFAULT_MAX_FINDINGS;
257
+
258
+ /** @type {TrashCounts} */
259
+ const counts = {
260
+ bidi_control: 0,
261
+ zero_width: 0,
262
+ nonbreaking_space: 0,
263
+ bom: 0,
264
+ nul: 0,
265
+ c0_control: 0,
266
+ c1_control: 0,
267
+ invisible_format: 0,
268
+ hangul_filler: 0,
269
+ variation_selector: 0,
270
+ tag: 0,
271
+ };
272
+ /** @type {TrashFinding[]} */
273
+ const findings = [];
274
+ let total = 0;
275
+ let line = 1;
276
+ let lineStart = 0;
277
+
278
+ for (let i = 0, n = text.length; i < n; i++) {
279
+ const c = text.charCodeAt(i);
280
+
281
+ // Printable ASCII: the common case
282
+ if (c >= 0x20 && c < 0x7F) continue;
283
+
284
+ if (c === 0x0A) {
285
+ line++;
286
+ lineStart = i + 1;
287
+ continue;
288
+ }
289
+ if (c === 0x0D) {
290
+ // CRLF ends the line at its LF
291
+ if (text.charCodeAt(i + 1) !== 0x0A) {
292
+ line++;
293
+ lineStart = i + 1;
294
+ }
295
+ continue;
296
+ }
297
+
298
+ const offset = i;
299
+ let codePoint = c;
300
+ let category;
301
+ if (c === TRASH_HIGH_SURROGATE) {
302
+ const low = text.charCodeAt(i + 1);
303
+ if (!(low >= 0xDC00 && low <= 0xDFFF)) continue; // lone surrogate
304
+ codePoint = 0x10000 + ((c - 0xD800) << 10) + (low - 0xDC00);
305
+ category = classifyAstral(codePoint);
306
+ i++; // the low surrogate is part of this code point
307
+ } else {
308
+ category = classifyCodeUnit(c);
309
+ }
310
+ if (category === null) continue;
311
+
312
+ counts[category]++;
313
+ total++;
314
+ if (findings.length < maxFindings) {
315
+ /** @type {TrashFinding} */
316
+ const finding = { offset, line, column: offset - lineStart + 1, codePoint, category };
317
+ if (category === 'bom') finding.atStart = offset === 0;
318
+ findings.push(finding);
319
+ }
320
+ }
321
+
322
+ return { findings, counts, total, truncated: total > findings.length };
323
+ }
324
+
325
+ /**
326
+ * @typedef {Object} NewlineStats
327
+ * @property {number} crlf - CR LF pairs.
328
+ * @property {number} cr - CRs not followed by LF.
329
+ * @property {number} lf - LFs not preceded by CR.
330
+ * @property {('CRLF'|'CR'|'LF')[]} types - Styles present, in the order CRLF, CR, LF.
331
+ * @property {boolean} mixed - True when more than one style is present.
332
+ */
333
+
334
+ /**
335
+ * Counts each newline style in one pass, with no regex and no copy.
336
+ * @param {string} text
337
+ * @returns {NewlineStats}
338
+ */
339
+ export function getNewlineStats(text) {
340
+ if (typeof text !== 'string') {
341
+ throw new TypeError('getNewlineStats expects a string input.');
342
+ }
343
+
344
+ // One counting pass: a CR followed by LF is one CRLF; any other CR or LF stands alone.
345
+ let crlf = 0;
346
+ let cr = 0;
347
+ let lf = 0;
348
+
349
+ for (let i = 0, n = text.length; i < n; i++) {
350
+ const c = text.charCodeAt(i);
351
+ if (c === 0x0D) {
352
+ if (text.charCodeAt(i + 1) === 0x0A) {
353
+ crlf++;
354
+ i++;
355
+ } else {
356
+ cr++;
357
+ }
358
+ } else if (c === 0x0A) {
359
+ lf++;
360
+ }
361
+ }
362
+
363
+ const count = { crlf, cr, lf };
364
+
365
+ const types = [];
366
+ if (count.crlf > 0) types.push('CRLF');
367
+ if (count.cr > 0) types.push('CR');
368
+ if (count.lf > 0) types.push('LF');
369
+
370
+ return {
371
+ ...count,
372
+ types,
373
+ mixed: types.length > 1,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Creates defaultOptions for summonSanctifier based on inspectText result
379
+ * @param {!UnicodeTrashReport} report
380
+ * @return {!SanctifyOptions}
381
+ */
382
+ export function getRecommendedSanctifierOptions(report) {
383
+ return {
384
+ purgeInvisibleChars: report.hasInvisibleChars,
385
+ purgeEmojis: report.hasEmojis,
386
+ nukeControls: report.hasControlChars,
387
+ keyboardOnlyFilter: report.hasNonKeyboardChars,
388
+ normalizeNewlines:
389
+ report.hasMixedNewlines || report.newlineStyle === 'CRLF' || report.newlineStyle === 'CR',
390
+ };
342
391
  }