text-sanctifier 1.3.3 → 1.3.5

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,344 +1,344 @@
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
- import { classifyCodeUnit, classifyAstral } from './categories.generated.js';
11
-
12
- /** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
13
-
14
-
15
- /**
16
- * @typedef {Object} UnicodeTrashReport
17
- * @property {boolean} hasControlChars
18
- * @property {boolean} hasInvisibleChars
19
- * @property {boolean} hasMixedNewlines
20
- * @property {'LF'|'CRLF'|'CR'|'Mixed'|null} newlineStyle
21
- * @property {boolean} hasEmojis
22
- * @property {boolean} hasNonKeyboardChars
23
- * @property {string[]} summary
24
- */
25
-
26
-
27
- /**
28
- * Safe `.test()` for global/sticky regexes.
29
- * Global regexes mutate `lastIndex`, which makes `.test()` unreliable across calls.
30
- * @param {RegExp} re
31
- * @param {string} s
32
- */
33
- function stableTest(re, s) {
34
- re.lastIndex = 0;
35
- return re.test(s);
36
- }
37
-
38
- /**
39
- * Returns true if text contains any non-keyboard characters (excluding emojis),
40
- * after typographic normalization.
41
- *
42
- * "Keyboard" here means:
43
- * - Printable ASCII (0x20–0x7E)
44
- * - CR/LF
45
- * - Emojis (per EMOJI_REGEX)
46
- *
47
- * @param {string} text
48
- * @returns {boolean}
49
- */
50
- function hasNonKeyboardCharsExcludingEmoji(text) {
51
- const normalized = normalizeTypographicJank(text);
52
-
53
- // Fast path: no non-ascii runs => no non-keyboard chars
54
- if (!stableTest(ASCII_KEYBOARD_SAFE_REGEX, normalized)) return false;
55
-
56
- // Walk code points so we can distinguish emoji from other non-ascii safely
57
- for (const ch of normalized) {
58
- // allowed: ASCII printable + newlines
59
- if (ch === '\n' || ch === '\r' || (ch >= ' ' && ch <= '~')) continue;
60
-
61
- // allowed: emoji
62
- if (stableTest(EMOJI_REGEX, ch)) continue;
63
-
64
- // anything else non-ascii is "non-keyboard"
65
- return true;
66
- }
67
-
68
- return false;
69
- }
70
-
71
- /**
72
- * Detects textual "trash" or anomalies in a given string.
73
- * @param {string} text @returns {UnicodeTrashReport}
74
- */
75
- export function inspectText(text) {
76
- if (typeof text !== 'string') {
77
- throw new TypeError('inspectText expects a string input.');
78
- }
79
-
80
- const summary = [];
81
- const report = {
82
- hasControlChars: false,
83
- hasInvisibleChars: false,
84
- hasMixedNewlines: false,
85
- newlineStyle: null,
86
- hasEmojis: false,
87
- hasNonKeyboardChars: false,
88
- summary,
89
- };
90
-
91
- const flag = (condition, key, message) => {
92
- if (condition) {
93
- report[key] = true;
94
- summary.push(message);
95
- }
96
- };
97
-
98
- // === Pattern Checks ===
99
- flag(stableTest(CONTROL_CHARS_REGEX, text), 'hasControlChars', 'Control characters detected.');
100
- flag(stableTest(INVISIBLE_TRASH_REGEX, text), 'hasInvisibleChars', 'Invisible Unicode characters detected.');
101
- flag(stableTest(EMOJI_REGEX, text), 'hasEmojis', 'Emojis detected.');
102
-
103
- // === Newline Analysis ===
104
- const { mixed, types } = getNewlineStats(text);
105
- report.hasMixedNewlines = mixed;
106
- report.newlineStyle = mixed ? 'Mixed' : types[0] || null;
107
-
108
- if (report.newlineStyle) {
109
- summary.push(
110
- mixed ? 'Mixed newline styles detected.' : `Consistent newline style: ${report.newlineStyle}`
111
- );
112
- }
113
-
114
- // === Non-keyboard characters (excluding emojis) ===
115
- flag(
116
- hasNonKeyboardCharsExcludingEmoji(text),
117
- 'hasNonKeyboardChars',
118
- 'Non-keyboard characters detected.'
119
- );
120
-
121
- return report;
122
- }
123
-
124
- /**
125
- * @typedef {'bidi_control'|'zero_width'|'nonbreaking_space'|'bom'|'nul'|'c0_control'|'c1_control'|'invisible_format'|'hangul_filler'|'variation_selector'|'tag'} TrashCategory
126
- */
127
-
128
- /**
129
- * One located character.
130
- * - `offset`: 0-based UTF-16 index into the input (a JS string index); for a code point
131
- * above U+FFFF, the index of its high surrogate.
132
- * - `line`: 1-based; lines end at LF, CR, or CRLF (counted once).
133
- * - `column`: 1-based, in UTF-16 code units from the start of the line.
134
- * - `atStart`: present on `bom` findings only; true at offset 0, where a BOM is legitimate.
135
- *
136
- * @typedef {Object} TrashFinding
137
- * @property {number} offset
138
- * @property {number} line
139
- * @property {number} column
140
- * @property {number} codePoint
141
- * @property {TrashCategory} category
142
- * @property {boolean} [atStart]
143
- */
144
-
145
- /**
146
- * @typedef {Object} TrashCounts
147
- * @property {number} bidi_control
148
- * @property {number} zero_width
149
- * @property {number} nonbreaking_space
150
- * @property {number} bom
151
- * @property {number} nul
152
- * @property {number} c0_control
153
- * @property {number} c1_control
154
- * @property {number} invisible_format
155
- * @property {number} hangul_filler
156
- * @property {number} variation_selector
157
- * @property {number} tag
158
- */
159
-
160
- /**
161
- * @typedef {Object} DetailedTrashReport
162
- * @property {TrashFinding[]} findings - In offset order, at most `maxFindings` long.
163
- * @property {TrashCounts} counts - Every occurrence per category, listed or not.
164
- * @property {number} total - Sum of `counts`.
165
- * @property {boolean} truncated - True when `total` exceeds `findings.length`.
166
- */
167
-
168
- /**
169
- * @typedef {Object} InspectDetailedOptions
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).
172
- */
173
-
174
- const DEFAULT_MAX_FINDINGS = 100;
175
-
176
- /**
177
- * Locates invisible and control characters, one finding per character.
178
- *
179
- * Categories (generated from scripts/categories.js by `npm run gen`):
180
- * - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
181
- * - `zero_width`: U+200B–U+200D, U+2060
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)
184
- * - `nul`: U+0000
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)
186
- * - `c1_control`: U+0080–U+009F
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)
188
- * - `hangul_filler`: U+115F, U+1160, U+3164, U+FFA0 (letters that render blank)
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)
191
- *
192
- * `codePoint` is the full code point; `offset` and `column` count UTF-16 units.
193
- * Every occurrence is counted; only the first `maxFindings` are listed.
194
- * One pass over the string, no regex, no copy.
195
- *
196
- * @param {string} text
197
- * @param {InspectDetailedOptions} [options]
198
- * @returns {DetailedTrashReport}
199
- */
200
- export function inspectTextDetailed(text, options = {}) {
201
- if (typeof text !== 'string') {
202
- throw new TypeError('inspectTextDetailed expects a string input.');
203
- }
204
-
205
- const requested = options && options.maxFindings;
206
- const maxFindings =
207
- requested === Infinity || (Number.isInteger(requested) && requested >= 0)
208
- ? requested
209
- : DEFAULT_MAX_FINDINGS;
210
-
211
- /** @type {TrashCounts} */
212
- const counts = {
213
- bidi_control: 0,
214
- zero_width: 0,
215
- nonbreaking_space: 0,
216
- bom: 0,
217
- nul: 0,
218
- c0_control: 0,
219
- c1_control: 0,
220
- invisible_format: 0,
221
- hangul_filler: 0,
222
- variation_selector: 0,
223
- tag: 0,
224
- };
225
- /** @type {TrashFinding[]} */
226
- const findings = [];
227
- let total = 0;
228
- let line = 1;
229
- let lineStart = 0;
230
-
231
- for (let i = 0, n = text.length; i < n; i++) {
232
- const c = text.charCodeAt(i);
233
-
234
- // Printable ASCII: the common case
235
- if (c >= 0x20 && c < 0x7F) continue;
236
-
237
- if (c === 0x0A) {
238
- line++;
239
- lineStart = i + 1;
240
- continue;
241
- }
242
- if (c === 0x0D) {
243
- // CRLF ends the line at its LF
244
- if (text.charCodeAt(i + 1) !== 0x0A) {
245
- line++;
246
- lineStart = i + 1;
247
- }
248
- continue;
249
- }
250
-
251
- const offset = i;
252
- let codePoint = c;
253
- let category;
254
- if (c >= 0xD800 && c <= 0xDBFF) {
255
- const low = text.charCodeAt(i + 1);
256
- if (!(low >= 0xDC00 && low <= 0xDFFF)) continue; // lone surrogate
257
- codePoint = 0x10000 + ((c - 0xD800) << 10) + (low - 0xDC00);
258
- category = classifyAstral(codePoint);
259
- i++; // the low surrogate is part of this code point
260
- } else {
261
- category = classifyCodeUnit(c);
262
- }
263
- if (category === null) continue;
264
-
265
- counts[category]++;
266
- total++;
267
- if (findings.length < maxFindings) {
268
- /** @type {TrashFinding} */
269
- const finding = { offset, line, column: offset - lineStart + 1, codePoint, category };
270
- if (category === 'bom') finding.atStart = offset === 0;
271
- findings.push(finding);
272
- }
273
- }
274
-
275
- return { findings, counts, total, truncated: total > findings.length };
276
- }
277
-
278
- /**
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.
289
- * @param {string} text
290
- * @returns {NewlineStats}
291
- */
292
- export function getNewlineStats(text) {
293
- if (typeof text !== 'string') {
294
- throw new TypeError('getNewlineStats expects a string input.');
295
- }
296
-
297
- // One counting pass: a CR followed by LF is one CRLF; any other CR or LF stands alone.
298
- let crlf = 0;
299
- let cr = 0;
300
- let lf = 0;
301
-
302
- for (let i = 0, n = text.length; i < n; i++) {
303
- const c = text.charCodeAt(i);
304
- if (c === 0x0D) {
305
- if (text.charCodeAt(i + 1) === 0x0A) {
306
- crlf++;
307
- i++;
308
- } else {
309
- cr++;
310
- }
311
- } else if (c === 0x0A) {
312
- lf++;
313
- }
314
- }
315
-
316
- const count = { crlf, cr, lf };
317
-
318
- const types = [];
319
- if (count.crlf > 0) types.push('CRLF');
320
- if (count.cr > 0) types.push('CR');
321
- if (count.lf > 0) types.push('LF');
322
-
323
- return {
324
- ...count,
325
- types,
326
- mixed: types.length > 1,
327
- };
328
- }
329
-
330
- /**
331
- * Creates defaultOptions for summonSanctifier based on inspectText result
332
- * @param {!UnicodeTrashReport} report
333
- * @return {!SanctifyOptions}
334
- */
335
- export function getRecommendedSanctifierOptions(report) {
336
- return {
337
- purgeInvisibleChars: report.hasInvisibleChars,
338
- purgeEmojis: report.hasEmojis,
339
- nukeControls: report.hasControlChars,
340
- keyboardOnlyFilter: report.hasNonKeyboardChars,
341
- normalizeNewlines:
342
- report.hasMixedNewlines || report.newlineStyle === 'CRLF' || report.newlineStyle === 'CR',
343
- };
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
+ import { classifyCodeUnit, classifyAstral } from './categories.generated.js';
11
+
12
+ /** @typedef {import('./sanctifyText.js').SanctifyOptions} SanctifyOptions */
13
+
14
+
15
+ /**
16
+ * @typedef {Object} UnicodeTrashReport
17
+ * @property {boolean} hasControlChars
18
+ * @property {boolean} hasInvisibleChars
19
+ * @property {boolean} hasMixedNewlines
20
+ * @property {'LF'|'CRLF'|'CR'|'Mixed'|null} newlineStyle
21
+ * @property {boolean} hasEmojis
22
+ * @property {boolean} hasNonKeyboardChars
23
+ * @property {string[]} summary
24
+ */
25
+
26
+
27
+ /**
28
+ * Safe `.test()` for global/sticky regexes.
29
+ * Global regexes mutate `lastIndex`, which makes `.test()` unreliable across calls.
30
+ * @param {RegExp} re
31
+ * @param {string} s
32
+ */
33
+ function stableTest(re, s) {
34
+ re.lastIndex = 0;
35
+ return re.test(s);
36
+ }
37
+
38
+ /**
39
+ * Returns true if text contains any non-keyboard characters (excluding emojis),
40
+ * after typographic normalization.
41
+ *
42
+ * "Keyboard" here means:
43
+ * - Printable ASCII (0x20–0x7E)
44
+ * - CR/LF
45
+ * - Emojis (per EMOJI_REGEX)
46
+ *
47
+ * @param {string} text
48
+ * @returns {boolean}
49
+ */
50
+ function hasNonKeyboardCharsExcludingEmoji(text) {
51
+ const normalized = normalizeTypographicJank(text);
52
+
53
+ // Fast path: no non-ascii runs => no non-keyboard chars
54
+ if (!stableTest(ASCII_KEYBOARD_SAFE_REGEX, normalized)) return false;
55
+
56
+ // Walk code points so we can distinguish emoji from other non-ascii safely
57
+ for (const ch of normalized) {
58
+ // allowed: ASCII printable + newlines
59
+ if (ch === '\n' || ch === '\r' || (ch >= ' ' && ch <= '~')) continue;
60
+
61
+ // allowed: emoji
62
+ if (stableTest(EMOJI_REGEX, ch)) continue;
63
+
64
+ // anything else non-ascii is "non-keyboard"
65
+ return true;
66
+ }
67
+
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Detects textual "trash" or anomalies in a given string.
73
+ * @param {string} text @returns {UnicodeTrashReport}
74
+ */
75
+ export function inspectText(text) {
76
+ if (typeof text !== 'string') {
77
+ throw new TypeError('inspectText expects a string input.');
78
+ }
79
+
80
+ const summary = [];
81
+ const report = {
82
+ hasControlChars: false,
83
+ hasInvisibleChars: false,
84
+ hasMixedNewlines: false,
85
+ newlineStyle: null,
86
+ hasEmojis: false,
87
+ hasNonKeyboardChars: false,
88
+ summary,
89
+ };
90
+
91
+ const flag = (condition, key, message) => {
92
+ if (condition) {
93
+ report[key] = true;
94
+ summary.push(message);
95
+ }
96
+ };
97
+
98
+ // === Pattern Checks ===
99
+ flag(stableTest(CONTROL_CHARS_REGEX, text), 'hasControlChars', 'Control characters detected.');
100
+ flag(stableTest(INVISIBLE_TRASH_REGEX, text), 'hasInvisibleChars', 'Invisible Unicode characters detected.');
101
+ flag(stableTest(EMOJI_REGEX, text), 'hasEmojis', 'Emojis detected.');
102
+
103
+ // === Newline Analysis ===
104
+ const { mixed, types } = getNewlineStats(text);
105
+ report.hasMixedNewlines = mixed;
106
+ report.newlineStyle = mixed ? 'Mixed' : types[0] || null;
107
+
108
+ if (report.newlineStyle) {
109
+ summary.push(
110
+ mixed ? 'Mixed newline styles detected.' : `Consistent newline style: ${report.newlineStyle}`
111
+ );
112
+ }
113
+
114
+ // === Non-keyboard characters (excluding emojis) ===
115
+ flag(
116
+ hasNonKeyboardCharsExcludingEmoji(text),
117
+ 'hasNonKeyboardChars',
118
+ 'Non-keyboard characters detected.'
119
+ );
120
+
121
+ return report;
122
+ }
123
+
124
+ /**
125
+ * @typedef {'bidi_control'|'zero_width'|'nonbreaking_space'|'bom'|'nul'|'c0_control'|'c1_control'|'invisible_format'|'hangul_filler'|'variation_selector'|'tag'} TrashCategory
126
+ */
127
+
128
+ /**
129
+ * One located character.
130
+ * - `offset`: 0-based UTF-16 index into the input (a JS string index); for a code point
131
+ * above U+FFFF, the index of its high surrogate.
132
+ * - `line`: 1-based; lines end at LF, CR, or CRLF (counted once).
133
+ * - `column`: 1-based, in UTF-16 code units from the start of the line.
134
+ * - `atStart`: present on `bom` findings only; true at offset 0, where a BOM is legitimate.
135
+ *
136
+ * @typedef {Object} TrashFinding
137
+ * @property {number} offset
138
+ * @property {number} line
139
+ * @property {number} column
140
+ * @property {number} codePoint
141
+ * @property {TrashCategory} category
142
+ * @property {boolean} [atStart]
143
+ */
144
+
145
+ /**
146
+ * @typedef {Object} TrashCounts
147
+ * @property {number} bidi_control
148
+ * @property {number} zero_width
149
+ * @property {number} nonbreaking_space
150
+ * @property {number} bom
151
+ * @property {number} nul
152
+ * @property {number} c0_control
153
+ * @property {number} c1_control
154
+ * @property {number} invisible_format
155
+ * @property {number} hangul_filler
156
+ * @property {number} variation_selector
157
+ * @property {number} tag
158
+ */
159
+
160
+ /**
161
+ * @typedef {Object} DetailedTrashReport
162
+ * @property {TrashFinding[]} findings - In offset order, at most `maxFindings` long.
163
+ * @property {TrashCounts} counts - Every occurrence per category, listed or not.
164
+ * @property {number} total - Sum of `counts`.
165
+ * @property {boolean} truncated - True when `total` exceeds `findings.length`.
166
+ */
167
+
168
+ /**
169
+ * @typedef {Object} InspectDetailedOptions
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).
172
+ */
173
+
174
+ const DEFAULT_MAX_FINDINGS = 100;
175
+
176
+ /**
177
+ * Locates invisible and control characters, one finding per character.
178
+ *
179
+ * Categories (generated from scripts/categories.js by `npm run gen`):
180
+ * - `bidi_control`: U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069
181
+ * - `zero_width`: U+200B–U+200D, U+2060
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)
184
+ * - `nul`: U+0000
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)
186
+ * - `c1_control`: U+0080–U+009F
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)
188
+ * - `hangul_filler`: U+115F, U+1160, U+3164, U+FFA0 (letters that render blank)
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)
191
+ *
192
+ * `codePoint` is the full code point; `offset` and `column` count UTF-16 units.
193
+ * Every occurrence is counted; only the first `maxFindings` are listed.
194
+ * One pass over the string, no regex, no copy.
195
+ *
196
+ * @param {string} text
197
+ * @param {InspectDetailedOptions} [options]
198
+ * @returns {DetailedTrashReport}
199
+ */
200
+ export function inspectTextDetailed(text, options = {}) {
201
+ if (typeof text !== 'string') {
202
+ throw new TypeError('inspectTextDetailed expects a string input.');
203
+ }
204
+
205
+ const requested = options && options.maxFindings;
206
+ const maxFindings =
207
+ requested === Infinity || (Number.isInteger(requested) && requested >= 0)
208
+ ? requested
209
+ : DEFAULT_MAX_FINDINGS;
210
+
211
+ /** @type {TrashCounts} */
212
+ const counts = {
213
+ bidi_control: 0,
214
+ zero_width: 0,
215
+ nonbreaking_space: 0,
216
+ bom: 0,
217
+ nul: 0,
218
+ c0_control: 0,
219
+ c1_control: 0,
220
+ invisible_format: 0,
221
+ hangul_filler: 0,
222
+ variation_selector: 0,
223
+ tag: 0,
224
+ };
225
+ /** @type {TrashFinding[]} */
226
+ const findings = [];
227
+ let total = 0;
228
+ let line = 1;
229
+ let lineStart = 0;
230
+
231
+ for (let i = 0, n = text.length; i < n; i++) {
232
+ const c = text.charCodeAt(i);
233
+
234
+ // Printable ASCII: the common case
235
+ if (c >= 0x20 && c < 0x7F) continue;
236
+
237
+ if (c === 0x0A) {
238
+ line++;
239
+ lineStart = i + 1;
240
+ continue;
241
+ }
242
+ if (c === 0x0D) {
243
+ // CRLF ends the line at its LF
244
+ if (text.charCodeAt(i + 1) !== 0x0A) {
245
+ line++;
246
+ lineStart = i + 1;
247
+ }
248
+ continue;
249
+ }
250
+
251
+ const offset = i;
252
+ let codePoint = c;
253
+ let category;
254
+ if (c >= 0xD800 && c <= 0xDBFF) {
255
+ const low = text.charCodeAt(i + 1);
256
+ if (!(low >= 0xDC00 && low <= 0xDFFF)) continue; // lone surrogate
257
+ codePoint = 0x10000 + ((c - 0xD800) << 10) + (low - 0xDC00);
258
+ category = classifyAstral(codePoint);
259
+ i++; // the low surrogate is part of this code point
260
+ } else {
261
+ category = classifyCodeUnit(c);
262
+ }
263
+ if (category === null) continue;
264
+
265
+ counts[category]++;
266
+ total++;
267
+ if (findings.length < maxFindings) {
268
+ /** @type {TrashFinding} */
269
+ const finding = { offset, line, column: offset - lineStart + 1, codePoint, category };
270
+ if (category === 'bom') finding.atStart = offset === 0;
271
+ findings.push(finding);
272
+ }
273
+ }
274
+
275
+ return { findings, counts, total, truncated: total > findings.length };
276
+ }
277
+
278
+ /**
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.
289
+ * @param {string} text
290
+ * @returns {NewlineStats}
291
+ */
292
+ export function getNewlineStats(text) {
293
+ if (typeof text !== 'string') {
294
+ throw new TypeError('getNewlineStats expects a string input.');
295
+ }
296
+
297
+ // One counting pass: a CR followed by LF is one CRLF; any other CR or LF stands alone.
298
+ let crlf = 0;
299
+ let cr = 0;
300
+ let lf = 0;
301
+
302
+ for (let i = 0, n = text.length; i < n; i++) {
303
+ const c = text.charCodeAt(i);
304
+ if (c === 0x0D) {
305
+ if (text.charCodeAt(i + 1) === 0x0A) {
306
+ crlf++;
307
+ i++;
308
+ } else {
309
+ cr++;
310
+ }
311
+ } else if (c === 0x0A) {
312
+ lf++;
313
+ }
314
+ }
315
+
316
+ const count = { crlf, cr, lf };
317
+
318
+ const types = [];
319
+ if (count.crlf > 0) types.push('CRLF');
320
+ if (count.cr > 0) types.push('CR');
321
+ if (count.lf > 0) types.push('LF');
322
+
323
+ return {
324
+ ...count,
325
+ types,
326
+ mixed: types.length > 1,
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Creates defaultOptions for summonSanctifier based on inspectText result
332
+ * @param {!UnicodeTrashReport} report
333
+ * @return {!SanctifyOptions}
334
+ */
335
+ export function getRecommendedSanctifierOptions(report) {
336
+ return {
337
+ purgeInvisibleChars: report.hasInvisibleChars,
338
+ purgeEmojis: report.hasEmojis,
339
+ nukeControls: report.hasControlChars,
340
+ keyboardOnlyFilter: report.hasNonKeyboardChars,
341
+ normalizeNewlines:
342
+ report.hasMixedNewlines || report.newlineStyle === 'CRLF' || report.newlineStyle === 'CR',
343
+ };
344
344
  }