// Text taken apart into characters, code points and UTF-8 bytes, and bytes back to text; Node runs all above ui() // utf8 encoder · HEX a byte's two hex digits · hex hex digits of a number · point "U+XXXX" · lone a surrogate // NUM num en-US number · plural "3 bytes" · octets bytes as a Uint8Array · spot the four offsets of one place // split the grapheme segmenter, or null · pieces characters, or code points without a segmenter // encode units a code point's UTF-8 bytes and UTF-16 units · INVISIBLE MARK CATEGORIES SCRIPTS patterns and names // known script name → pattern · script a string's script · memo describe() by code point · trail a continuation byte // offset the four offsets up to a UTF-16 offset · fail unit()'s refusal: LEN and WHY set, -1 · no throw a HexError // lost a code point no HTML reference writes · tag a tag character · strict a fatal UTF-8 decoder · ui the page // SPECIAL WORD LETTER LOOKALIKE FOUR EMOJI SPACE SELECTOR FLAG what findings() looks for // byte the byte a variation selector carries, or -1 · back where the code point before i starts // maps encoding → character → byte · undo one round of mojibake export {UNITS, graphemes, counts, countCharacters, utf8Length, utf8Bits, invisible, describe, page, LEN, WHY, unit, decode, windowEnd, pageBytes, locate, HexError, parseHex, toHex, sniff, escape, findings, MARK_RUN, nfc, repair}; import {nameOf, aliasOf, abbreviationOf} from "./names.js"; const UNITS = ["character", "codePoint", "utf16", "utf8"]; const utf8 = new TextEncoder(), HEX = Array.from({length: 2 ** 8}, (_, i) => i.toString(16).padStart(2, "0")); const hex = c => c.toString(16).toUpperCase(), point = c => "U+" + hex(c).padStart(4, "0"); // cost: toLocaleString builds a formatter per call, 21 ms of the load at 4x CPU; one formatter is shared const NUM = new Intl.NumberFormat("en-US"), num = n => NUM.format(n), plural = (n, s) => `${num(n)} ${n === 1 ? s : s + "s"}`; const lone = c => c >= 0xd800 && c <= 0xdfff; const octets = b => b instanceof Uint8Array ? b : Uint8Array.from(b); const spot = (character, codePoint, utf16, utf8) => ({character, codePoint, utf16, utf8}); const back = (s, i) => i - ((s.charCodeAt(i - 1) & 0xfc00) === 0xdc00 && i > 1 ? 2 : 1); const split = globalThis.Intl?.Segmenter ? new Intl.Segmenter(undefined, {granularity: "grapheme"}) : null; function graphemes() { return split } function* pieces(s, by) { if (by) for (const x of by.segment(s)) yield x.segment; else yield* s } function counts(text, {segmenter = graphemes()} = {}) { let codePoint = 0, n = 0, astral = 0, wellFormed = true; for (let i = 0; i < text.length; i++, codePoint++) { const c = text.charCodeAt(i); if (c < 0x80) n++; else if (c < 0x800) n += 2; else if (c < 0xdc00 && c >= 0xd800 && (text.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { n += 4; astral++; i++ } else { n += 3; wellFormed &&= !lone(c) } } let character = segmenter ? 0 : null; if (segmenter) for (const _ of segmenter.segment(text)) character++; return {character, codePoint, utf16: text.length, utf8: n, astral, wellFormed} } function* countCharacters(text, segmenter, step = 2e4) { let n = 0; for (const _ of segmenter.segment(text)) if (++n % step === 0) yield n; return n } const utf8Length = cp => cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; const encode = c => lone(c) ? [0xef, 0xbf, 0xbd] : [...utf8.encode(String.fromCodePoint(c))]; const units = c => c > 0xffff ? [0xd7c0 + (c >> 10), 0xdc00 | c&0x3ff] : [c]; function utf8Bits(cp) { if (lone(cp)) return null; const n = utf8Length(cp); if (n === 1) return [{prefix: "0", payload: cp.toString(2).padStart(7, "0")}]; const s = cp.toString(2).padStart(5*n + 1, "0"), o = [{prefix: "1".repeat(n) + "0", payload: s.slice(0, 7 - n)}]; for (let i = 7 - n; i < s.length; i += 6) o.push({prefix: "10", payload: s.slice(i, i + 6)}); return o } const INVISIBLE = /[\p{Default_Ignorable_Code_Point}\p{Zs}\u2028\u2029\p{Cc}\u2800]/u, MARK = /^\p{M}/u; const invisible = cp => cp !== 32 && cp !== 9 && cp !== 10 && cp !== 13 && INVISIBLE.test(String.fromCodePoint(cp)); const CATEGORIES = ("Lu Uppercase Letter,Ll Lowercase Letter,Lt Titlecase Letter,Lm Modifier Letter,Lo Other Letter,Mn Nonspacing Mark,Mc Spacing Mark," + "Me Enclosing Mark,Nd Decimal Number,Nl Letter Number,No Other Number,Pc Connector Punctuation,Pd Dash Punctuation,Ps Open Punctuation," + "Pe Close Punctuation,Pi Initial Punctuation,Pf Final Punctuation,Po Other Punctuation,Sm Math Symbol,Sc Currency Symbol,Sk Modifier Symbol," + "So Other Symbol,Zs Space Separator,Zl Line Separator,Zp Paragraph Separator,Cc Control,Cf Format,Cs Surrogate,Co Private Use,Cn Unassigned").split(","); // Unicode 17: script names, the common ones first; a name this engine doesn't know is skipped const SCRIPTS = ("Latin Common Inherited Cyrillic Greek Armenian Han Hiragana Katakana Hangul Arabic Hebrew Devanagari Thai Bengali Tamil " + "Telugu Kannada Malayalam Gujarati Gurmukhi Oriya Sinhala Georgian Ethiopic Khmer Lao Myanmar Tibetan Mongolian Syriac Thaana Nko " + "Adlam Ahom Anatolian_Hieroglyphs Avestan Balinese Bamum Bassa_Vah Batak Beria_Erfe Bhaiksuki Bopomofo Brahmi Braille Buginese Buhid " + "Canadian_Aboriginal Carian Caucasian_Albanian Chakma Cham Cherokee Chisoi Chorasmian Coptic Cuneiform Cypriot Cypro_Minoan Deseret " + "Dives_Akuru Dogra Duployan Egyptian_Hieroglyphs Elbasan Elymaic Garay Glagolitic Gothic Grantha Gunjala_Gondi Gurung_Khema " + "Hanifi_Rohingya Hanunoo Hatran Imperial_Aramaic Inscriptional_Pahlavi Inscriptional_Parthian Javanese Kaithi Kawi Kayah_Li Kharoshthi " + "Khitan_Small_Script Khojki Khudawadi Kirat_Rai Lepcha Limbu Linear_A Linear_B Lisu Lycian Lydian Mahajani Makasar Mandaic Manichaean " + "Marchen Masaram_Gondi Medefaidrin Meetei_Mayek Mende_Kikakui Meroitic_Cursive Meroitic_Hieroglyphs Miao Modi Mro Multani Nabataean " + "Nag_Mundari Nandinagari New_Tai_Lue Newa Nushu Nyiakeng_Puachue_Hmong Ogham Ol_Chiki Ol_Onal Old_Hungarian Old_Italic Old_North_Arabian " + "Old_Permic Old_Persian Old_Sogdian Old_South_Arabian Old_Turkic Old_Uyghur Osage Osmanya Pahawh_Hmong Palmyrene Pau_Cin_Hau Phags_Pa " + "Phoenician Psalter_Pahlavi Rejang Runic Samaritan Saurashtra Sharada Shavian Siddham Sidetic SignWriting Sogdian Sora_Sompeng Soyombo " + "Sundanese Sunuwar Syloti_Nagri Tagalog Tagbanwa Tai_Le Tai_Tham Tai_Viet Tai_Yo Takri Tangsa Tangut Tifinagh Tirhuta Todhri " + "Tolong_Siki Toto Tulu_Tigalari Ugaritic Vai Vithkuqi Wancho Warang_Citi Yezidi Yi Zanabazar_Square Unknown").split(" "); const known = new Map(); function script(s) { for (const k of SCRIPTS) { if (!known.has(k)) try { known.set(k, new RegExp(`\\p{Script=${k}}`, "u")) } catch { known.set(k, null) } if (known.get(k)?.test(s)) return k.replaceAll("_", " ") } return null } const memo = new Map(); function describe(cp) { if (memo.has(cp)) return memo.get(cp); const s = String.fromCodePoint(cp), nfd = s.normalize("NFD"), nfkc = s.normalize("NFKC"); const kind = CATEGORIES.find(x => new RegExp(`^\\p{gc=${x.slice(0, 2)}}$`, "u").test(s)); const o = { cp, label: point(cp), name: nameOf(cp), alias: aliasOf(cp), abbreviation: abbreviationOf(cp), category: kind.slice(0, 2), categoryName: kind.slice(3), script: lone(cp) ? null : script(s), invisible: invisible(cp), encodable: !lone(cp), utf8: encode(cp), bits: utf8Bits(cp), utf16: units(cp), surrogate: cp > 0xffff ? {offset: cp - 0x10000, high: units(cp)[0], low: units(cp)[1]} : null, nfd: nfd === s ? null : nfd, nfkc: nfkc === s ? null : nfkc}; memo.set(cp, o); return o } function offset(text, from, by) { const r = counts(text.slice(0, from), {segmenter: by}); return spot(r.character ?? r.codePoint, r.codePoint, from, r.utf8) } function page(text, {from = 0, at = null, size = 1e3, perCharacter = 32, segmenter = graphemes()} = {}) { const o = at ? {...at} : offset(text, from, segmenter), characters = []; let listed = 0, next = null; for (const s of pieces(from ? text.slice(from) : text, segmenter)) { if (listed >= size) { next = o.utf16; break } const r = {text: s, at: {...o}, codePoints: [], more: 0, invisible: true}; for (const c of s) { const x = c.codePointAt(0), b = encode(x), q = invisible(x); r.invisible &&= q; if (r.codePoints.length < perCharacter) r.codePoints.push({cp: x, label: hex(x).padStart(2, "0"), at: {...o}, utf16: units(x), utf8: b, error: null, lone: lone(x), invisible: q}); else r.more++; o.codePoint++; o.utf16 += c.length; o.utf8 += b.length } o.character++; characters.push(r); listed += r.codePoints.length } return {characters, from, next, end: o} } // WHATWG Encoding §4.4.1: one step of the UTF-8 decoder at b[i], the code point or -1, with the unit's length in LEN let LEN = 1, WHY = null; const trail = x => x >= 0x80 && x <= 0xbf; const fail = (s, n = 1) => { LEN = n; WHY = s; return -1 }; function unit(b, i) { const x = b[i]; LEN = 1; WHY = null; if (x < 0x80) return x; let n, c, lo = 0x80, hi = 0xbf; if (x < 0xc0) return fail("stray continuation byte"); if (x < 0xc2) return fail("overlong form"); if (x < 0xe0) { n = 1; c = x & 0x1f } else if (x < 0xf0) { n = 2; c = x & 0xf; if (x === 0xe0) lo = 0xa0; else if (x === 0xed) hi = 0x9f } else if (x < 0xf5) { n = 3; c = x & 7; if (x === 0xf0) lo = 0x90; else if (x === 0xf4) hi = 0x8f } else return fail(x < 0xf8 ? "above U+10FFFF" : "byte never used in UTF-8"); for (let k = 1; k <= n; k++) { const y = b[i+k]; if (!trail(y)) return fail("sequence cut short", k); if (k === 1 && (y < lo || y > hi)) return fail(x === 0xed ? "surrogate" : x === 0xf4 ? "above U+10FFFF" : "overlong form"); c = c<<6 | y&0x3f } LEN = n + 1; return c } function decode(bytes, {maxErrors = 1e3} = {}) { const b = octets(bytes), out = new Uint16Array(b.length), errors = []; let j = 0, errorCount = 0, errorBytes = 0, line = 1, text = ""; for (let i = 0; i < b.length; i += LEN) { const c = unit(b, i); if (c < 0) { if (errorCount++ < maxErrors) errors.push({offset: i, length: LEN, bytes: [...b.subarray(i, i + LEN)], reason: WHY, line}); errorBytes += LEN; out[j++] = 0xfffd } else if (c > 0xffff) { out[j++] = 0xd7c0 + (c >> 10); out[j++] = 0xdc00 | c&0x3ff } else { line += c === 10; out[j++] = c } } for (let i = 0; i < j; i += 2 ** 13) text += String.fromCharCode.apply(null, out.subarray(i, Math.min(j, i + 2**13))); return {text, errors, errorCount, errorBytes} } // inv: a window ends after the unit crossing from + size and stray continuation bytes; the next begins outside 80–BF function windowEnd(b, from, size) { let e = from + size, p = e; if (e >= b.length) return b.length; while (p > from && p > e - 3 && trail(b[p])) p--; if (p === from || !trail(b[p])) { while (p < e) { unit(b, p); p += LEN } e = p } while (e < b.length && trail(b[e]) && e - from < 2 * size) e++; return e } function pageBytes(bytes, {from = 0, size = 2 ** 12, perCharacter = 32, segmenter = graphemes()} = {}) { const b = octets(bytes), end = windowEnd(b, from, size), a = [], characters = []; const at = n => spot(null, null, null, n); let text = ""; for (let i = from; i < end; i += LEN) { const c = unit(b, i); a.push({cp: c < 0 ? null : c, i, bytes: [...b.subarray(i, i + LEN)], error: WHY}); text += c < 0 ? "\ufffd" : String.fromCodePoint(c) } let k = 0; for (const s of pieces(text, segmenter)) { const r = {text: s, at: at(a[k].i), codePoints: [], more: 0, invisible: true}; for (const _ of s) { const x = a[k++], q = x.cp !== null && invisible(x.cp); r.invisible &&= q; if (r.codePoints.length < perCharacter) r.codePoints.push({cp: x.cp, label: x.cp === null ? "×" : hex(x.cp).padStart(2, "0"), at: at(x.i), utf16: x.cp === null ? [] : units(x.cp), utf8: x.bytes, error: x.error, lone: false, invisible: q}); else r.more++ } characters.push(r) } return {characters, from, next: end < b.length ? end : null, end: at(end)} } function locate(text, n, unit, {segmenter = graphemes()} = {}) { const size = s => unit === "character" ? 1 : unit === "codePoint" ? [...s].length : unit === "utf16" ? s.length : utf8.encode(s).length; const o = spot(0, 0, 0, 0); const done = (inside, at, i) => ({unit, n, beyond: false, inside, at, safe: text.slice(0, o.utf16), safeLength: o[unit], naive: unit === "utf8" ? decode(utf8.encode(text.slice(0, i)).subarray(0, n)).text : unit === "utf16" ? text.slice(0, n) : text.slice(0, i)}); if (unit === "character" || n < text.length || n < counts(text, {segmenter: null})[unit]) { for (const s of pieces(text, segmenter)) { if (o[unit] === n) return done(null, {...o}, o.utf16); if (o[unit] + size(s) > n) { const at = {...o}; for (const c of s) { if (at[unit] === n) return done("character", at, at.utf16); if (at[unit] + size(c) > n) return done("codePoint", {...at, [unit]: n}, at.utf16 + c.length); at.codePoint++; at.utf16 += c.length; at.utf8 += utf8.encode(c).length } } o.character++; o.codePoint += [...s].length; o.utf16 += s.length; o.utf8 += utf8.encode(s).length } } else Object.assign(o, offset(text, text.length, null), {character: null}); return {unit, n, beyond: true, inside: null, naive: text, safe: text, safeLength: o[unit], at: {...o}} } class HexError extends Error { constructor(message, index, kind) { super(message); Object.assign(this, {name: "HexError", index, kind}) } } const no = (s, i, k, o) => { throw Object.assign(new HexError(s, i, k), o) }; function parseHex(input) { const bytes = [], at = []; let start = 0, count = 0, odd = -1; const shown = i => { const c = input.codePointAt(i); return invisible(c) || lone(c) || MARK.test(String.fromCodePoint(c)) ? point(c) : `'${String.fromCodePoint(c)}'` }; const close = () => { if (count % 2) odd = start; count = 0 }; for (let i = 0; i < input.length; i++) { const c = input[i]; if (!count && c === "0" && (input[i+1] === "x" || input[i+1] === "X")) i++; else if (/[0-9a-f]/i.test(c)) { if (!count) { odd >= 0 && no(`${shown(odd)} at position ${odd + 1} is half a byte.`, odd, "half-byte"); start = i } if (++count % 2 === 0) { bytes.push(parseInt(input.slice(i - 1, i + 1), 16)); at.push(i - 1) } } else { close(); if (c === "\\" && (input[i+1] === "x" || input[i+1] === "X")) i++; else c !== "%" && !/[\s,;:-]/.test(c) && no(`${shown(i)} at position ${i + 1} isn't hex.`, i, "not-hex") } } close(); const o = {bytes: Uint8Array.from(bytes), at: Uint32Array.from(at)}; odd >= 0 && no("The last hex digit is on its own.", odd, "trailing-half", o); return o } function toHex(bytes, perLine = 16) { let s = ""; for (let i = 0; i < bytes.length; i++) s += (i === 0 ? "" : i % perLine ? " " : "\n") + HEX[bytes[i]]; return s } function sniff(bytes) { const b = octets(bytes); if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) return "utf-8-bom"; if (b[0] === 0xff && b[1] === 0xfe) return "utf-16le"; if (b[0] === 0xfe && b[1] === 0xff) return "utf-16be"; if (b.length < 4 || b.length % 2) return null; const n = Math.min(b.length, 512), zero = [0, 0]; for (let i = 0; i < n; i++) if (b[i] === 0) zero[i%2]++; const [even, odd] = zero.map(x => x / (n / 2)); return odd >= 0.9 && even < 0.1 ? "utf-16le-no-bom" : even >= 0.9 && odd < 0.1 ? "utf-16be-no-bom" : null } // HTML: a parser reads � as U+FFFD, and 27 of €–Ÿ as the characters Windows-1252 has at those bytes const lost = c => c === 0 || (c >= 0x80 && c <= 0x9f && ![0x81, 0x8d, 0x8f, 0x90, 0x9d].includes(c)); function escape(text, format) { if (format === "hex") return toHex(utf8.encode(text), Infinity); if (format === "url") try { return encodeURIComponent(text) } catch { return null } const html = format === "html", ENTITY = {"&": "&", "<": "<", ">": ">", "\"": """}; let o = ""; for (const c of text) { const x = c.codePointAt(0), s = x.toString(16), q = x < 0x20 || (x >= 0x7f && x <= 0x9f); if (html) { if (lone(x) || lost(x)) return null; o += ENTITY[c] ?? (x < 0x7f && (!q || x === 9 || x === 10) ? c : `&#x${s};`) } else if (c === "\"" || c === "\\") o += "\\" + c; else if (x >= 0x20 && x < 0x7f) o += c; else if (x === 9 || x === 10 || x === 13) o += "\\" + "tn r"[x-9]; else if (format !== "json" && (q || (format === "python" && x <= 0xff))) o += "\\x" + s.padStart(2, "0"); else if (x <= 0xffff) o += "\\u" + s.padStart(4, "0"); else if (format === "js") o += `\\u{${s}}`; else if (format === "python") o += "\\U" + s.padStart(8, "0"); else o += units(x).map(y => "\\u" + y.toString(16)).join("") } return html ? o : `"${o}"` } const SPECIAL = /(?![\t\n\r ])[\p{Cc}\p{Zs}\u2028\u2029\u2800\ufffd\p{Default_Ignorable_Code_Point}\p{Cs}]/gu; const WORD = /[\p{L}\p{M}]+/gu, LETTER = /[\p{L}\p{M}]/u, LOOKALIKE = /[\p{Script=Cyrillic}\p{Script=Greek}\p{Script=Armenian}]/u; const FOUR = ["Latin", "Cyrillic", "Greek", "Armenian"].map(s => [s, new RegExp(`\\p{Script=${s}}`, "u")]); const EMOJI = /[\p{Extended_Pictographic}\u{1f3fb}-\u{1f3ff}\ufe0f]/u, SPACE = /[\p{Zs}\u2028\u2029]/u, SELECTOR = /\p{Variation_Selector}/u; const tag = c => c >= 0xe0020 && c <= 0xe007f; // CLDR: a subdivision flag is U+1F3F4, a code in tag letters (gbsct, Scotland), CANCEL TAG; here U+007F once shifted const FLAG = /^(?:[a-z]{2}|[0-9]{3})[a-z0-9]{1,4}\x7f$/; const byte = c => c >= 0xfe00 && c <= 0xfe0f ? c - 0xfe00 : c >= 0xe0100 && c <= 0xe01ef ? c - 0xe00f0 : -1; const strict = new TextDecoder("utf-8", {fatal: true, ignoreBOM: true}); function findings(text, {from = 0, to = text.length} = {}) { const o = [], tally = {}; const add = r => { if ((tally[r.kind] = (tally[r.kind] ?? 0) + 1) <= 1e3) o.push(r) }; const before = i => i > 0 ? text.codePointAt(back(text, i)) : -1; const is = (v, c) => c >= 0 && v.test(String.fromCodePoint(c)); const other = x => is(LETTER, x) && !FOUR.some(([, v]) => is(v, x)); const carry = (i, f) => i === from && i > 0 && f(before(i)); SPECIAL.lastIndex = from; for (let m; (m = SPECIAL.exec(text)) && m.index < to;) { const at = m.index, c = m[0].codePointAt(0), length = m[0].length, next = text.codePointAt(at + length) ?? -1; const report = kind => { add({kind, at, length, cp: c, name: nameOf(c) ?? aliasOf(c)}) }; if (tag(c) || byte(c) >= 0) { const q = tag(c), f = q ? tag : x => byte(x) >= 0, a = []; let end = at; for (let y; end < text.length && f(y = text.codePointAt(end)); end += y > 0xffff ? 2 : 1) a.push(y); SPECIAL.lastIndex = end; if (carry(at, f)) continue; const hidden = {kind: "hidden-text", at, length: end - at, count: a.length}; if (q && !(before(at) === 0x1f3f4 && FLAG.test(String.fromCharCode(...a.slice(0, 9).map(x => x - 0xe0000))))) { let payload = ""; for (const x of a) if (x < 0xe007f) payload += String.fromCharCode(x - 0xe0000); add({...hidden, carrier: "tags", payload}) } else if (!q && a.length > 1) { let payload = null; try { payload = strict.decode(Uint8Array.from(a, byte)) } catch {} add({...hidden, carrier: "selectors", payload}) } } else if (length === 1 && lone(c)) add({kind: "lone-surrogate", at, length, cp: c}); else if (c === 0xfffd) add({kind: "replacement", at, length, cp: c}); else if ((c >= 0x202a && c <= 0x202e) || (c >= 0x2066 && c <= 0x2069)) report("bidi"); else if (c === 0x200e || c === 0x200f || c === 0x61c || is(SELECTOR, c)) continue; else if (c < 0x20 || (c >= 0x7f && c <= 0x9f)) report("control"); else if (is(SPACE, c)) report("space"); else if (c === 0x200d && (is(EMOJI, before(at)) || is(EMOJI, next))) continue; else if ((c === 0x200c || c === 0x200d) && [before(at), next].some(other)) continue; else report("invisible") } WORD.lastIndex = from; for (let m; (m = WORD.exec(text)) && m.index < to;) { if (carry(m.index, x => is(LETTER, x)) || !LOOKALIKE.test(m[0])) continue; const scripts = []; for (const c of m[0]) for (const [k, v] of FOUR) if (!scripts.includes(k) && v.test(c)) scripts.push(k); if (scripts.length > 1) add({kind: "mixed-script", at: m.index, length: m[0].length, scripts}) } return o.sort((x, y) => x.at - y.at) } // ICU: normalizing orders each run of marks in time that grows with its length squared; past MARK_RUN, same is null const MARK_RUN = 100; function nfc(text) { for (const m of text.matchAll(/\p{M}+/gu)) { const marks = m[0].length > MARK_RUN && [...m[0]].length; if (marks > MARK_RUN) return {same: null, text: null, first: m.index && back(text, m.index), marks} } const s = text.normalize("NFC"); if (s === text) return {same: true, text, first: null}; let i = 0; while (text[i] === s[i]) i++; if (i > 0 && (text.charCodeAt(i) & 0xfc00) === 0xdc00) i--; while (i > 0 && /\p{M}/u.test(String.fromCodePoint(text.codePointAt(i)))) i -= (text.charCodeAt(i - 1) & 0xfc00) === 0xdc00 ? 2 : 1; return {same: false, text: s, first: i} } const maps = {}; function undo(text, from) { const map = (maps[from] ??= new Map( [...new TextDecoder(from).decode(Uint8Array.from({length: 0x80}, (_, i) => i + 0x80))].map((c, i) => [c, i + 0x80]))); const a = []; for (const c of text) { const x = c < "\x80" ? c.charCodeAt(0) : map.get(c); if (x === undefined) return null; a.push(x) } try { const s = strict.decode(Uint8Array.from(a)); return s === text ? null : s } catch { return null } } function repair(text) { if (!/[^\0-\x7f]/.test(text)) return null; for (const from of ["windows-1252", "windows-1251"]) { const once = undo(text, from); if (once === null) continue; const twice = undo(once, from); return twice === null ? {text: once, from, rounds: 1} : {text: twice, from, rounds: 2} } return null } function ui() { // now holds mode · text bytes dec counts found norm fix odd notice, the box's content · where, each byte's hex place // page index starts done, the view's pages · flat k, the code points shown and the selected one · cut away marked // file before memo src was · held last recall busy said, the box's undo and what was spoken // turn the race counter · due a frame is due · down a pointer held down · idle the typing timer · wait the job's timer // room the view's width · past value → mode, for undo · UNIT MODE DEMO HINT fixed tables · area the box of the view // pane #inspector · wide 58rem and up · p a code point's row · c its character's row · b a button // the page's words · $ h add mute knob dump flaw hash raw off gap say aria tell fade plan draw trim near clip copy // sync job seek sift sum list act link snip part jump lay dim read pin seat nav move lead · info grid why cut rule // here home put pick swap load shut half rest drag · a b in rule(), the rects either side of the cut const $ = id => document.getElementById(id); function h(s, k, v) { const x = document.createElement(s); if (k) x.className = k; if (v != null) x.textContent = v; return x } const add = (x, ...a) => { x.append(...a); return x }, mute = x => { x.setAttribute("aria-hidden", "true"); return x }; const knob = (k, s, f) => { const b = h("button", k, s); b.type = "button"; b.onclick = f; return b }; const dump = a => a.map(x => HEX[x]).join(" "), flaw = n => `${plural(n, "byte")} ${n === 1 ? "isn't" : "aren't"} valid UTF-8`; // FNV-1a: 32 bits and the length; two values that share both are too unlikely to plan for function hash(s) { let x = 0x811c9dc5; for (let i = 0; i < s.length; i++) x = Math.imul(x ^ s.charCodeAt(i), 0x01000193); return s.length + ":" + (x >>> 0) } // inv: below 58rem the panel sits in #view, and redrawing the view detaches it, where $ can't find it const wide = matchMedia("(min-width: 58rem)"), area = $("view").parentNode, pane = $("inspector"); const UNIT = { character: {one: "character", count: "count-character", here: "here-character"}, codePoint: {one: "code point", count: "count-codePoint", here: "here-codePoint"}, utf16: {one: "UTF-16 unit", count: "count-utf16", here: "here-utf16"}, utf8: {one: "UTF-8 byte", count: "count-utf8", here: "here-utf8"}}; const MODE = {text: {radio: "mode-text", label: "Text"}, bytes: {radio: "mode-bytes", label: "Bytes as hex"}}; const DEMO = [ ["text", "\u00e9 e\u0301"], ["text", "\u{1f469}\u{1f3fd}\u200d\u{1f52c}"], ["text", "ex\u0430mple.com"], ["text", "café"], ["text", "hello" + [..."plain ASCII"].map(c => String.fromCodePoint(0xe0000 + c.charCodeAt(0))).join("")], ["bytes", "c0 af"]]; const HINT = { "utf-8-bom": "Starts with ef bb bf, the UTF-8 byte order mark.", "utf-16le": "Starts with ff fe, the byte order mark of UTF-16LE, so this isn't UTF-8.", "utf-16be": "Starts with fe ff, the byte order mark of UTF-16BE, so this isn't UTF-8.", "utf-16le-no-bom": "Every second byte is 00, which is how ASCII looks in UTF-16LE.", "utf-16be-no-bom": "Every byte pair starts with 00, which is how ASCII looks in UTF-16BE."}; const now = {mode: "text", starts: [], flat: [], k: -1}; const raw = () => now.mode === "bytes", off = x => raw() ? x : x.utf16, gap = c => [9, 10, 13, 32].includes(c); let turn = 0, due = false, down = false, idle, wait, room = 0; const past = new Map(); const say = s => { $("status").textContent = ""; setTimeout(() => { $("status").textContent = s }, 50) }; const aria = (id, k, q) => { if (q) $(id).setAttribute(k, "true"); else $(id).removeAttribute(k) }; function tell(s = "", k = "note") { $("input-error").textContent = s; $("input-error").hidden = !s; now.notice = s && k; aria("input", "aria-invalid", k === "hex" && s) } function fade(q) { // a11y: dimmed results still read and copy as current; inert ones can't be reached at all for (const x of [$("counts"), $("findings"), $("toolbar"), area]) { x.classList.toggle("stale", q); x.inert = q } } const plan = () => { if (!due) { due = true; requestAnimationFrame(() => { due = false; sync() }) } }; // guard: invisibles become labeled boxes, all isolated; a character past 4 marks or 32 code points is clipped function draw(s, q = false) { const o = h("bdi"); for (const x of pieces(s, split)) { const a = []; let n = 0; for (const c of x) if (a.length < 32) a.push(c.codePointAt(0)); else n++; if (!a.every(c => invisible(c) || lone(c) || gap(c))) { const v = (q && MARK.test(x) ? "◌" : "") + (n ? String.fromCodePoint(...a) : x); o.append(a.filter(c => MARK.test(String.fromCodePoint(c))).length > 4 || n ? h("span", "g", v) : v); if (n) o.append(" ", h("span", "ord", "+" + num(n)), " ") } else if (x === " " && !q) o.append(" "); else { for (const c of a) o.append(h("span", gap(c) ? "ord" : "abbr", abbreviationOf(c) ?? hex(c)), " "); if (n) o.append(h("span", "ord", "+" + num(n)), " ") } } if (o.lastChild?.nodeValue === " " && o.lastChild.previousSibling?.className) o.lastChild.remove(); return o } function trim(s, q) { const a = [...pieces(q ? s.slice(-256) : s.slice(0, 256), split)]; return a.length > 32 || s.length > 256 ? (q ? "…" + a.slice(-32).join("") : a.slice(0, 32).join("") + "…") : s } const near = (i, j) => [...pieces(now.text.slice(Math.max(0, i), j), split)]; async function clip(s, f, g) { try { if (s == null) throw 0; await navigator.clipboard.writeText(s); f() } catch { g() } } function copy(s, f, x) { const b = knob("quiet copy", null, () => { clip(f(), () => { b.classList.add("done"); setTimeout(() => { b.classList.remove("done") }, 2e3); say("Copied.") }, () => { getSelection().selectAllChildren(x); say("Couldn't copy, so the text is selected instead.") }) }); b.setAttribute("aria-label", "copy " + s); return add(b, h("span", null, "copy"), h("span", null, "copied")) } function sync(i) { const me = ++turn; now.odd = false; if (now.file) now.bytes = now.file.bytes; else if (raw()) { let r; try { r = parseHex($("input").value) } catch (w) { if (w.kind !== "trailing-half") { tell(w.message, "hex"); return fade(true) } r = w } now.odd = r instanceof HexError; now.bytes = r.bytes; now.where = r.at } if (now.notice === "hex") tell(); fade(false); now.dec = raw() ? decode(now.bytes) : null; now.text = now.dec ? now.dec.text : $("input").value; const x = raw() ? now.bytes : now.text; let n = -1; if (now.was === now.mode && typeof now.src === typeof x) for (n = 0; n < now.src.length && now.src[n] === x[n];) n++; const k = now.starts.findIndex(y => off(y) + 64 > n); now.starts = k > 0 ? now.starts.slice(0, k) : [raw() ? 0 : spot(0, 0, 0, 0)]; now.src = x; now.was = now.mode; now.done = false; now.page = null; const r = (now.counts = counts(now.text, {segmenter: null})), q = !!now.file || r.utf8 > 2e5; if (raw()) r.utf8 = now.bytes.length; r.character = !split ? null : q ? undefined : 0; if (split && !q) for (const _ of split.segment(now.text)) r.character++; now.found = q ? null : sift(findings(now.text)); if (q || raw()) now.norm = now.fix = null; else { now.norm = nfc(now.text); now.fix = repair(now.text) } clearTimeout(wait); if (q) wait = setTimeout(() => { job(me) }, 300); sum(); list(); $("empty").hidden = !!now.text; $("toolbar").hidden = area.hidden = !now.text; if (!now.text) { $("view").replaceChildren(); now.flat = []; return } if (i === "error") i = now.dec.errors[0]?.offset; else if (i === undefined && document.activeElement === $("input")) i = here(); jump(i) } // cost: findings on text over 200 kB, and the character count, run in slices of about 8 ms function job(me) { const s = now.text, a = []; let g = split && countCharacters(s, split), i = 0; function f() { if (me !== turn) return; for (const t = performance.now() + 8; performance.now() < t;) { if (g) { const r = g.next(); if (r.done) { now.counts.character = r.value; g = null; sum() } } else if (i < s.length) { const x = /\s/g; x.lastIndex = i + 2**16; const j = x.exec(s)?.index ?? s.length; a.push(...findings(s, {from: i, to: j})); i = j } else { const o = {}; now.found = sift(a.filter(x => (o[x.kind] = (o[x.kind] ?? 0) + 1) <= 1e3)); if (!raw()) { now.norm = nfc(s); now.fix = repair(s) } return list() } } setTimeout(f, 0) } f() } function seek(a) { const b = now.bytes, o = new Map(); a = [...a].sort((x, y) => x - y); for (let j = 0, i = 0, k = 0; k < a.length;) { if (a[k] <= j || i >= b.length) o.set(a[k++], i); else { j += unit(b, i) > 0xffff ? 2 : 1; i += LEN } } return o } // inv: in bytes mode a U+FFFD is worth reporting only where the bytes held one function sift(a) { if (!raw()) return a; if (sniff(now.bytes ?? []) === "utf-8-bom") a = a.filter(x => x.at || x.cp !== 0xfeff); const b = now.bytes, m = seek(a.filter(x => x.kind === "replacement").map(x => x.at)); return a.filter(x => x.kind !== "replacement" || (b[m.get(x.at)] === 0xef && b[m.get(x.at)+1] === 0xbf)) } function sum() { const r = now.counts; $("counts").classList.toggle("zero", !r.utf16 && !r.utf8); for (const k of UNITS) $(UNIT[k].count).textContent = $(UNIT[k].here).textContent = r[k] === null ? "—" : r[k] === undefined ? "counting…" : num(r[k]); $("astral").hidden = !r.astral; $("astral").textContent = r.astral === 1 ? "1 of these code points needs 4 bytes, and a MySQL utf8 (utf8mb3) column can't store it." : `${num(r.astral)} of these code points need 4 bytes, and a MySQL utf8 (utf8mb3) column can't store them.` } function list() { const o = [], a = now.found, li = (...x) => { o.push(add(h("li", "notice"), ...x)) }; const few = (x, q) => x.slice(0, 4).flatMap((r, i) => [i ? ", " : "", snip(r, q)]) .concat(x.length > 4 ? ` and ${num(x.length - 4)} more` : []); const pool = x => [...x.reduce((m, r) => m.set(r.cp, [...(m.get(r.cp) ?? []), r]), new Map()).values()]; const kind = k => a.filter(x => x.kind === k), noun = r => (r.name ?? point(r.cp)).toLowerCase(); const many = x => ` \u00d7${num(x.length)}${kind(x[0].kind).length >= 1e3 ? "+" : ""}: `; if (!split) li("This browser can't group code points into characters, so each code point gets its own column and the first count is missing."); if (now.dec) { const r = now.dec, x = h("ul"), q = now.bytes.includes(10); for (const y of r.errors.slice(0, 20)) x.append(add(h("li"), link(`offset ${num(y.offset)}${q ? ` (line ${num(y.line)})` : ""}: ${dump(y.bytes)}, ${y.reason}`, y.offset))); if (r.errorCount > 20) x.append(h("li", null, `and ${num(r.errorCount - 20)} more`)); if (r.errorCount) li(flaw(r.errorBytes) + ":", x); if (sniff(now.bytes)) li(HINT[sniff(now.bytes)]) } if (!a) li("Checking…"); else { for (const r of kind("hidden-text")) { if (r.carrier === "tags") { const s = near(r.at - 64, r.at).slice(-8).join("").trimEnd().split(/\s/).pop(); li(...(s ? ["After “", draw(s), "”, "] : ["", "", "At the start, "]), link(plural(r.count, "tag character"), r), " spell out “", draw(trim(r.payload)), "”.") } else li(link(plural(r.count, "variation selector"), r), ` follow${r.count === 1 ? "s" : ""} “`, draw(near(r.at - 64, r.at).at(-1) ?? ""), "”. Read as bytes, they ", ...(r.payload === null ? ["aren't UTF-8."] : ["say “", draw(trim(r.payload)), "”."])) } for (const x of pool(kind("bidi"))) li(noun(x[0]) + many(x), ...few(x), ". It changes the order text is shown in; the stored order stays the same."); const m = kind("mixed-script"); if (m.length === 1) li("“", snip(m[0], true), `” mixes ${m[0].scripts.slice(0, -1).join(", ")} and ${m[0].scripts.at(-1)} letters.`); else if (m.length) li(`${num(m.length)} words mix scripts: `, ...few(m, true), "."); for (const x of pool(a.filter(y => ["invisible", "space", "control"].includes(y.kind)))) li(noun(x[0]) + many(x), ...few(x)); for (const [x, s, v] of [[kind("replacement"), "U+FFFD", ". Something before this page already failed to decode here."], [kind("lone-surrogate"), "half a surrogate pair", ". UTF-8 can't store it, so an encoder writes U+FFFD."]]) if (x.length) li(s + many(x), ...few(x), v); if (now.fix) li(`This reads like UTF-8 that went through ${now.fix.from.replace("w", "W")}${now.fix.rounds > 1 ? " twice" : ""}. Read properly: “`, draw(trim(now.fix.text)), "”. ", act("use this", now.fix.text)); if (now.norm && !now.norm.same) { const i = now.norm.first, q = now.norm.same === false, x = snip({at: i, length: near(i, i + 64)[0].length}, !q); if (q) li("Not in NFC: normalizing would change it, first at ", x, ". ", act("convert", now.norm.text)); else li("Not checked for NFC, because “", x, `” has ${num(now.norm.marks)} combining marks in a row, and this page stops at ${MARK_RUN}.`) } } $("findings").replaceChildren(...o); $("findings").hidden = !o.length } // a11y: the button goes once the text changes; keyboard focus moves to the box, a click's is left to the browser const act = (s, v) => knob("quiet", s, e => { put(v); if (!e.detail) $("input").focus() }); const link = (x, r) => add(knob("quiet snip", null, () => { jump(typeof r === "number" ? r : raw() ? seek([r.at]).get(r.at) : r.at, true) }), x); function snip(r, q) { const s = now.text; const x = draw(q ? trim(s.slice(r.at, r.at + Math.min(r.length, 257))) : s.slice(r.at, r.at + r.length)); if (q) return link(x, r); const y = h("bdi"); x.className = "hit"; y.append(draw(near(r.at - 64, r.at).slice(-8).join("")), h("span", "vh", " (marked: "), x, h("span", "vh", ")"), draw(near(r.at + r.length, r.at + r.length + 64).slice(0, 8).join(""))); return link(y, r) } function part(i) { const a = now.starts; if (raw()) { while (!now.done && a.at(-1) <= i) { const x = windowEnd(now.bytes, a.at(-1), 2 ** 12); if (x < now.bytes.length) a.push(x); else now.done = true } } else if (!now.done && a.at(-1).utf16 <= i) { const o = {...a.at(-1)}; let n = 0; now.done = true; for (const s of pieces(now.text.slice(o.utf16), split)) { if (n >= 1e3) { a.push({...o}); n = 0; if (o.utf16 > i) { now.done = false; break } } let k = 0; for (const c of s) { k++; o.codePoint++; o.utf16 += c.length; o.utf8 += utf8Length(c.codePointAt(0)) } o.character++; n += Math.min(k, 32) } } let j = a.length - 1; while (j > 0 && off(a[j]) > i) j--; return j } function jump(i, q = false) { const j = i == null ? 0 : part(i), y = j !== now.index || !now.page; if (y) lay(j); pin(Math.max(i == null ? lead() : now.flat.findLastIndex(({p}) => (raw() ? p.at.utf8 : p.at.utf16) <= i), 0), q); if (y) cut() } function lay(i) { const at = now.starts[i]; now.index = i; now.page = raw() ? pageBytes(now.bytes, {from: at, segmenter: split}) : page(now.text, {from: at.utf16, at, segmenter: split}); const r = now.page, a = r.characters; const o = [mute(add(h("div", "key"), ...["character", "code point", "UTF-8"].map(s => h("span", null, s))))]; if (r.next !== null && now.starts.length === i + 1) now.starts.push(raw() ? r.next : r.end); now.flat = []; now.k = -1; a.forEach((c, j) => { const box = h("div", "ch"), y = h("span", "cps"), n = c.codePoints.length + c.more; const size = (a[j+1]?.at ?? r.end).utf8 - c.at.utf8, [p] = c.codePoints; const s = p.error ? "not valid UTF-8" : c.invisible || c.codePoints.every(x => x.invisible || gap(x.cp)) ? (nameOf(p.cp) ?? aliasOf(p.cp) ?? "U+" + p.label).toLowerCase() : c.text; box.setAttribute("role", "group"); box.setAttribute("aria-label", `${s}, ${plural(n, "code point")}, ${plural(size, "byte")}`); box.append(mute(add(h("span", "glyph"), p.error || p.lone ? "\ufffd" : draw(c.text, true))), dim(n), y); for (const x of c.codePoints) { const b = knob("cp" + (x.error || x.lone ? " err" : "") + (x.invisible ? " inv" : "")); b.tabIndex = -1; b.dataset.k = now.flat.length; b.setAttribute("aria-label", read(x)); // a11y: spaces a flex box doesn't draw keep the label and each byte separate words of the visible text y.append(add(b, h("span", null, x.label), " ", dim(x.utf8.length), add(h("span", "bytes"), ...x.utf8.map(v => h("span", null, HEX[v])).flatMap((v, i) => i ? [" ", v] : v)))); now.flat.push({p: x, c, b, box, size}) } if (c.more) y.append(h("span", "more", "+" + num(c.more))); o.push(box) }); $("view").replaceChildren(...o); nav() } const dim = n => mute(h("span", n > 1 ? "dim on" : "dim")); // WCAG 2.5.3: a name starts with the text the button shows function read(p) { const v = `${p.label} ${dump(p.utf8)}, `; if (p.error) return `${v}${p.utf8.length > 1 ? "bytes" : "byte"} ${dump(p.utf8)}, not valid UTF-8: ${p.error}`; const s = nameOf(p.cp) ?? aliasOf(p.cp); return `${v}U+${p.label.padStart(4, "0")}${s ? " " + s : ""}${p.lone ? ", half a surrogate pair" : ""}, bytes ${dump(p.utf8)}${p.invisible ? ", invisible" : ""}` } function pin(k, q) { const x = now.flat[k], y = now.flat[now.k]; if (y) { y.b.removeAttribute("aria-current"); y.b.tabIndex = -1 } now.k = k; if (!x) return; x.b.setAttribute("aria-current", "true"); x.b.tabIndex = 0; info(); seat(); if (q) { x.b.focus({preventScroll: true}); x.b.scrollIntoView({block: "nearest", inline: "nearest"}) } } function seat() { const r = now.flat[now.k], y = pane.contains(document.activeElement) && document.activeElement; if (wide.matches || !r) area.insertBefore(pane, $("pager")); else { pane.remove(); let x = r.box; while (x.nextElementSibling?.offsetTop === r.box.offsetTop) x = x.nextElementSibling; x.after(pane) } if (y) y.focus({preventScroll: true}) } function nav() { const r = now.page; $("pager").hidden = now.index === 0 && r.next === null; $("pager-text").textContent = raw() ? `bytes ${num(r.from)}–${num(r.end.utf8 - 1)} of ${num(now.bytes.length)}` : `code points ${num(r.characters[0].at.codePoint)}–${num(r.end.codePoint - 1)} of ${num(now.counts.codePoint)}`; $("pager-prev").setAttribute("aria-disabled", now.index === 0); $("pager-next").setAttribute("aria-disabled", r.next === null) } function move(n) { if (n < 0 ? now.index === 0 : now.page.next === null) return; lay(now.index + n); pin(Math.max(0, lead()), false); cut(); const r = now.page; say(raw() ? `Bytes ${num(r.from)} to ${num(r.end.utf8 - 1)}.` : `Code points ${num(r.characters[0].at.codePoint)} to ${num(r.end.codePoint - 1)}.`) } const lead = () => now.flat.findIndex(({p}) => p.error || p.lone || p.cp > 0x7f); function info() { const {p, c, size} = now.flat[now.k], o = h("dl"); const r = p.error ? null : describe(p.cp), s = r && String.fromCodePoint(p.cp); const a = [mute(add(h("div", "big"), r ? draw(s, true) : "\ufffd")), Object.assign(h("h2", null, r ? `${r.label} ${r.name ?? (r.alias ? r.alias + " (alias)" : "")}`.trim() : `${dump(p.utf8)}: ${p.error}`), {id: "inspector-title", tabIndex: -1})]; const row = (k, ...v) => { o.append(h("dt", null, k)); return o.appendChild(add(h("dd"), ...v)) }; if (!r) { row("offset", "byte " + num(p.at.utf8)); row("bytes", grid(p.utf8, p.utf8.map(v => { const x = v.toString(2).padStart(8, "0"), i = x.indexOf("0") + 1 || 8; return {prefix: x.slice(0, i), payload: x.slice(i)} }), true)); return pane.replaceChildren(...a, o, h("p", null, why(p))) } row("offset", ...(raw() ? [["byte", p.at.utf8]] : [["character", p.at.character], ["code point", p.at.codePoint], ["UTF-16", p.at.utf16], ["byte", p.at.utf8]]) .map(([k, v]) => h("span", null, `${k} ${num(v)}`))).className = "pairs"; const n = c.codePoints.length + c.more; if (n > 1) row("part of", draw(c.text, true), `, ${plural(n, "code point")}, ${plural(size, "byte")}`); row("UTF-8", ...(r.bits ? [grid(r.utf8, r.bits), h("div", null, `${r.bits.map(v => v.payload).join(" ")} = ${hex(p.cp)}`)] : [h("span", "bad", dump(r.utf8)), h("div", "pre", "UTF-8 can't store half a surrogate pair; encoders write U+FFFD.")])); const i = r.surrogate?.offset; row("UTF-16", r.utf16.map(v => hex(v).padStart(4, "0")).join(" "), ...(r.surrogate ? [h("div", "pre", `${hex(p.cp)} − 10000 = ${hex(i)}; D800 + ${hex(i >> 10)}, DC00 + ${hex(i & 0x3ff)}`)] : [])); for (const k of ["nfd", "nfkc"]) if (r[k]) row(k.toUpperCase(), draw(r[k], true), h("span", "pre", " " + [...r[k]].map(v => point(v.codePointAt(0))).join(" "))); for (const k of ["JS", "JSON", "Python", "HTML", "URL"]) { const v = escape(s, k.toLowerCase()), x = h("span", null, v ?? "can't be written this way"), y = row(k, x); if (v !== null && /^\s$/.test(v)) x.replaceChildren(h("span", "vh", v), Object.assign(draw(v, true), {className: "stand-in"})); y.className = "esc"; if (v !== null) y.append(copy(k, () => v, x)); else x.className = "pre" } pane.replaceChildren(...a, h("div", "pre", [r.categoryName, r.script].filter(Boolean).join(" · ")), o) } const grid = (b, a, q) => add(h("div", "cells"), ...b.map((x, i) => add(h("span"), h("span", q ? "bad" : null, HEX[x] + "\n"), h("span", "pre", a[i].prefix), (a[i].payload ? "·" : "") + a[i].payload))); function why(p) { const [x] = p.utf8, i = p.at.utf8, y = now.bytes[i+p.utf8.length], s = dump([x, now.bytes[i+1]]), v = ` at offset ${num(i)} `; return { "stray continuation byte": HEX[x] + v + "is a continuation byte (10xxxxxx) with no lead byte before it. A decoder writes U+FFFD here.", "sequence cut short": dump(p.utf8) + v + `starts a ${x < 0xe0 ? 2 : x < 0xf0 ? 3 : 4}-byte sequence, and ` + (y === undefined ? "the input ends there." : `the next byte, ${HEX[y]}, doesn't continue it.`), "overlong form": x < 0xc2 ? HEX[x] + v + "could only start a 2-byte form of a code point below 80, which fits in one byte." : s + v + (x === 0xe0 ? "would write a code point below 800 in 3 bytes, where 2 are enough." : "would write a code point below 10000 in 4 bytes, where 3 are enough."), "surrogate": s + v + "would encode a UTF-16 surrogate, which UTF-8 never contains.", "above U+10FFFF": (x === 0xf4 ? s : HEX[x]) + v + "would encode a code point above 10FFFF, the last one Unicode has."}[p.error] ?? HEX[x] + v + "never appears in UTF-8." } function cut(e) { const q = !raw() && now.text !== "", s = $("cut-at").value.trim(), n = Number(s); const k = s !== "" && !(Number.isInteger(n) && n >= 0); $("cut").hidden = !q; $("cut-error").textContent = "Enter a whole number."; // a11y: the error appears while focus stays in the field, where its description isn't read again if (e && q && k && $("cut-error").hidden) say($("cut-error").textContent); $("cut-error").hidden = !q || !k; aria("cut-at", "aria-invalid", k); now.cut = q && s !== "" && !k ? locate(now.text, n, $("cut-unit").value, {segmenter: split}) : null; $("cut-results").hidden = !now.cut; rule(); if (!now.cut) return; const r = now.cut, v = r.unit === "utf8" ? "byte" : UNIT[r.unit].one; if (r.beyond) return $("cut-results").replaceChildren(h("dd", "note", `The text is ${plural(r.safeLength, v)} long, so nothing gets cut.`)); const x = draw(trim(r.safe, true)); $("cut-results").replaceChildren( h("dt", null, `first ${plural(n, UNIT[r.unit].one)}`), add(h("dd"), draw(trim(r.naive, true)), r.inside ? h("span", "muted", `ends inside a ${r.inside === "codePoint" ? "code point" : "character"}`) : ""), h("dt", null, "without splitting a character"), add(h("dd"), x, h("span", "muted", plural(r.safeLength, v)), copy("the cut text", () => r.safe, x))); if (now.away) $("cut-results").append(add(h("dd"), now.away)) } function rule() { $("view").querySelector(".cutline")?.remove(); if (now.marked) { now.marked.b.setAttribute("aria-label", read(now.marked.p)); now.marked = null } now.away = null; const r = now.cut; if (!r || r.beyond || !now.page) return; const i = r.at.codePoint, x = now.flat.find(({p}) => p.at.codePoint === i); const y = now.flat.find(({p}) => p.at.codePoint === i - 1); const cols = v => [...v.b.querySelectorAll(".bytes span")], rect = v => v.getBoundingClientRect(); const n = rect(now.flat[0].b.querySelector(".bytes span")).width * 0.625; let o; if (x && r.inside === "codePoint") { const a = cols(x), k = r.at.utf8 - x.p.at.utf8; o = {row: x, top: rect(x.b).top, bottom: rect(a[0]).bottom, x: r.unit === "utf8" ? (rect(a[k-1]).right + rect(a[k]).left) / 2 : (rect(a[0]).left + rect(a.at(-1)).right) / 2}; x.b.setAttribute("aria-label", read(x.p) + ", the cut falls inside it"); now.marked = x } else if (r.inside !== "codePoint" && (x || y)) { const a = y && rect(cols(y).at(-1)), b = x && rect(cols(x)[0]), q = b && (!a || Math.abs(a.top - b.top) < 2); o = {row: q ? x : y, x: !q ? a.right + n : a ? (a.right + b.left) / 2 : b.left - n, bottom: (q ? b : a).bottom}; o.top = rect(o.row.b).top } else { // inv: a character lists 32 code points and counts the rest as +N; a cut among those goes through the +N or past it const j = r.inside ? i : i - 1, row = now.flat.find(({c}) => { const k = c.at.codePoint + c.codePoints.length; return c.more && j >= k && j < k + c.more }); if (!row) { now.away = add(h("span"), "The cut is on another page. ", knob("quiet", "show it", () => { jump(r.at.utf16, true) })); return } const v = rect(row.box.querySelector(".more")); o = {row, x: r.inside ? (v.left + v.right) / 2 : v.right + n, top: v.top, bottom: v.top + rect(row.b).height} } const box = o.row.box, rim = rect(box), view = rect($("view")), bar = mute(h("div", "cutline")); o.x = Math.min(Math.max(o.x, view.left + 1), view.right - 1); if (o.top - rect(box.querySelector(".cp")).top <= 4) o.top = rim.top; bar.style.left = o.x - rim.left - 1 + "px"; bar.style.top = o.top - rim.top + "px"; bar.style.height = o.bottom - o.top + 16 + "px"; bar.append(h("span", null, num(r.n))); box.append(bar); if (rect(bar.firstChild).right > view.right) bar.classList.add("flip") } function here() { const i = $("input").selectionEnd, a = now.where; if (!raw()) return i && back($("input").value, i); let j = 0, k = a.length - 1; while (j < k) { const n = j+k+1 >> 1; if (a[n] <= i - 1) j = n; else k = n - 1 } return j } function home(p) { if (now.file) return; const [i, j] = raw() ? [now.where[p.at.utf8], now.where[p.at.utf8+p.utf8.length-1] + 2] : [p.at.utf16, p.at.utf16 + p.utf16.length]; // Safari: a box that takes focus isn't scrolled into view $("input").focus({preventScroll: true}); $("input").setSelectionRange(i, j); $("input").scrollIntoView({block: "nearest"}) } // cost: the browsers' undoable insert grows with the square of the line count; a long value is assigned instead function put(s, k = now.mode) { const x = document.activeElement, y = $("input").value, long = v => v.length > 1e5 || v.split("\n", 201).length > 200; now.busy = true; $("input").focus(); $("input").select(); if (!long(s) && !long(y) && document.execCommand("insertText", false, s) && $("input").value === s) { past.set(y, k).set(s, now.mode); while (past.size > 50) past.delete(past.keys().next().value) } else { // inv: assigning can leave undo history that splices old text into new; input reverts an undo to a value never held $("input").value = s; past.clear(); now.held = new Set([hash(s)]); now.last = s } now.busy = false; // Safari: a click focuses no button; a focused box selects the code point at its caret and opens a phone's keyboard if (!x || x === document.body) $("input").blur(); else if (x !== $("input") && x.isConnected && !x.closest("[hidden]")) x.focus(); plan() } function pick(k) { now.mode = k; $(MODE[k].radio).checked = true; $("input").setAttribute("aria-label", MODE[k].label) } function swap(k) { let v, s = "", b; const y = now.before ?? now.mode; if (k === "bytes") { v = now.memo?.text === $("input").value ? now.memo.hex : toHex(utf8.encode($("input").value)); if (v.length > 2 ** 20) { pick("text"); tell("That's too long to show as hex in the box."); return say($("input-error").textContent) } now.memo = {text: $("input").value, hex: v} } else if (now.file) b = now.file.bytes; else if (now.memo?.hex === $("input").value) v = now.memo.text; else if (past.get($("input").value) === "text") v = $("input").value; else try { b = parseHex($("input").value).bytes } catch (w) { if (w.kind === "trailing-half") b = w.bytes; else if (now.recall) v = $("input").value; else { pick("bytes"); return say(w.message) } } if (b) { const r = decode(b); v = r.text; if (r.errorBytes) s = `${plural(r.errorBytes, "byte")} ${r.errorBytes === 1 ? "wasn't" : "weren't"} valid UTF-8 and became U+FFFD.`; if (!now.file) now.memo = {text: v, hex: $("input").value} } if (now.file) shut(false); pick(k); if (v !== $("input").value) put(v, y); else plan(); tell(s); if (s) say(s) } async function load(x) { if (x.size > 16e6) { let v, n = 1; do v = (x.size / 1e6).toLocaleString("en-US", {maximumFractionDigits: n++}); while (v === "16" && n <= 6); tell(`That file is ${v} MB; this page reads up to 16 MB.`); return say($("input-error").textContent) } const b = new Uint8Array(await x.arrayBuffer()); now.before ??= now.mode; now.file = {name: x.name, bytes: b}; pick("bytes"); tell(); $("input").hidden = true; $("file-line").hidden = false; const y = draw(x.name); $("file-name").replaceChildren(y, ` · ${plural(b.length, "byte")}`); $("file-close").focus(); sync("error"); // a11y: the name is read out as drawn; a control in it is spoken by its abbreviation, as a word of its own, never raw const s = [...y.childNodes].map(v => v.nodeType === 1 && v.className !== "g" ? ` ${v.textContent} ` : v.textContent); say(`Read ${s.join("").replace(/ +/g, " ").trim()}, ${plural(b.length, "byte")}${now.dec.errorBytes ? `, ${num(now.dec.errorBytes)} not valid UTF-8` : ""}.`) } function shut(q = true) { now.file = null; $("file-line").hidden = true; $("input").hidden = false; if (q) { pick(now.before); $("input").focus(); sync() } now.before = null } function half(q = false) { if (now.odd && raw() && !down && now.notice !== "hex" && (q || document.activeElement !== $("input"))) tell("The last hex digit is on its own.", "hex") } function rest() { clearTimeout(idle); idle = setTimeout(() => { half(true); if (now.notice === "hex") return say($("input-error").textContent); const r = now.counts; const s = UNITS.filter(k => typeof r[k] === "number").map(k => plural(r[k], UNIT[k].one)).join(", ") + "." + (now.dec?.errorBytes ? ` ${flaw(now.dec.errorBytes)}.` : ""); if (s !== now.said) { now.said = s; say(s) } }, 1e3) } $("input").addEventListener("input", e => { const q = !!e.inputType?.startsWith("history"); if (now.held) { const k = hash($("input").value); if (q && !now.held.has(k)) { $("input").value = now.last; return } now.held.add(k); now.last = $("input").value } now.recall = q; const v = q && past.get($("input").value); if (v && v !== now.mode) pick(v); if (!now.busy && now.notice === "note") tell(); plan(); rest() }); // Chrome, Safari: a press blurs the box first, and a note shown on blur moves what the click was aimed at addEventListener("pointerdown", () => { down = true }, true); for (const k of ["pointerup", "pointercancel"]) addEventListener(k, () => { if (down) { down = false; setTimeout(half) } }, true); $("input").addEventListener("blur", () => { half() }); document.addEventListener("selectionchange", () => { if (document.activeElement === $("input") && !now.busy && !due && now.page && !(raw() && $("input").getAttribute("aria-invalid"))) jump(here()) }); for (const x of document.getElementsByName("mode")) x.addEventListener("change", () => { swap(x.value) }); // Safari: focus moved by arrow keys between radios is not :focus-visible; data-keys stands in $("mode").addEventListener("keydown", () => { $("mode").dataset.keys = "" }); $("mode").addEventListener("pointerdown", () => { delete $("mode").dataset.keys }); for (const b of document.querySelectorAll(".example")) b.onclick = () => { const [k, v] = DEMO[b.dataset.i], y = now.before ?? now.mode; if (now.file) shut(false); tell(); pick(k); put(v, y) }; $("file").addEventListener("change", e => { const x = e.target.files[0]; e.target.value = ""; if (x) load(x) }); const drag = q => { $("input").classList.toggle("drag", q); $("input").placeholder = q ? "drop to read it" : "" }; for (const x of [document.querySelector(".source-row"), $("input"), $("file-line")]) { x.addEventListener("dragover", e => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); drag(true) } }); x.addEventListener("dragleave", () => { drag(false) }); x.addEventListener("drop", e => { drag(false); if (e.dataTransfer.files[0]) { e.preventDefault(); load(e.dataTransfer.files[0]) } }) } $("file-close").onclick = () => { shut() }; for (const id of ["cut-at", "cut-unit"]) $(id).addEventListener("input", cut); for (const b of document.querySelectorAll("[data-format]")) b.onclick = () => { const k = b.dataset.format, s = k === "hex" && now.bytes && raw() ? toHex(now.bytes, Infinity) : escape(now.text, k); const f = (x, y) => { $("copy-as-note").textContent = x; clearTimeout($("copy-as-note").timer); $("copy-as-note").timer = setTimeout(() => { $("copy-as-note").textContent = "" }, 2e3); say(y) }; if (s === null) return f("can't be written this way", `This text can't be written as ${b.textContent}.`); clip(s, () => f("copied", `Copied as ${b.textContent}.`), () => f("couldn't copy", "Couldn't copy.")) }; for (const [id, n] of [["pager-prev", -1], ["pager-next", 1]]) $(id).onclick = () => { move(n) }; $("view").addEventListener("click", e => { const b = e.target.closest(".cp") ?? e.target.closest(".ch")?.querySelector(".cp"); if (b && e.detail) pin(+b.dataset.k, true) }); $("view").addEventListener("keydown", e => { const b = e.target.closest(".cp"); if (!b) return; const k = +b.dataset.k, q = e.ctrlKey || e.metaKey; function line(n) { const y = now.flat[k].b.getBoundingClientRect().top; let j = k; while (now.flat[j+n] && Math.abs(now.flat[j+n].b.getBoundingClientRect().top - y) < 2) j += n; return j } function over(n) { const a = now.flat.map(r => r.b.getBoundingClientRect()), x = a[k].left + a[k].width/2; const v = a.filter(r => n * (r.top - a[k].top) > 2).map(r => r.top); if (!v.length) return k; const y = n < 0 ? Math.max(...v) : Math.min(...v); const f = r => Math.abs(r.top - y) < 2 ? Math.abs(r.left + r.width/2 - x) : Infinity; return a.reduce((i, r, j) => f(r) < f(a[i]) ? j : i, 0) } const v = {ArrowLeft: k - 1, ArrowRight: k + 1, ArrowUp: () => over(-1), ArrowDown: () => over(1), Home: () => q ? 0 : line(-1), End: () => q ? now.flat.length - 1 : line(1)}[e.key]; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (k === now.k) $("inspector-title").focus(); else pin(k, true) } else if (e.key === "Escape") home(now.flat[k].p); else if (v !== undefined) { e.preventDefault(); const j = typeof v === "function" ? v() : v; if (now.flat[j]) pin(j, true) } }); pane.addEventListener("keydown", e => { if (e.key === "Escape") now.flat[now.k]?.b.focus() }); new ResizeObserver(() => { if ($("view").clientWidth !== room) { room = $("view").clientWidth; seat(); rule() } }).observe($("view")); wide.addEventListener("change", () => { seat(); rule() }); pick("text"); $("input").value = "Cafe\u0301 \u{1f44d}\u{1f3fd}"; sync() } if (typeof document === "object") ui();