// Copyright 2026 Bayanat Labs // SPDX-License-Identifier: Apache-2.0 // // The arabic-normalize engine: the stages of docs/SPEC.md applied in a fixed order. A port of // src/arabic_normalize/engine.py that gives the same text, offset map and change log (tested on every // conformance fixture and on random strings against the Python package). // // Every stage produces edits on the current code-point array. In detailed mode the edits are composed into // an offset map (output code point -> input span) and a change log; in plain mode only the text is built. // Both modes run the same edit-finding code, so they always give the same text. import { InputRejected } from './errors.js'; import { LIB_VERSION } from './generated/version.js'; import { loadPolicy } from './policy.js'; import { sha256Hex } from './sha256.js'; import * as U from './ucd.js'; import { encodeCodePoints, findInvalidUtf8, utf8Length } from './utf8.js'; export const MAX_TEXT_BYTES = 10 * 1024 * 1024; export const MAX_RECORDS = 10000; export const DEFAULT_POLICY = 'bayanat-norm-v1'; export const LOSSY_RULES = new Set([ 'alef_variants.map', 'alef_maqsura.to_yeh', 'taa_marbuta.to_heh', 'hamza_carriers.fold', 'persian_urdu_letters.map', 'latin_case.lower', 'canonical_order.nfkc', ]); function cps(...items) { const out = new Set(); for (const r of items) { if (Array.isArray(r)) for (let c = r[0]; c <= r[1]; c++) out.add(c); else out.add(r); } return out; } const union = (...sets) => { const out = new Set(); for (const s of sets) for (const c of s) out.add(c); return out; }; const str = (a) => U.fromCodePoints(a); // --- Character sets (docs/SPEC.md section 4) ------------------------------------------------------------- const SURROGATES = cps([0xD800, 0xDFFF]); const ALLOWED_CONTROLS = new Set([...U.CONTROLS].filter((c) => U.WHITE_SPACE.has(c))); const REJECT_CONTROLS = new Set([...U.CONTROLS].filter((c) => !ALLOWED_CONTROLS.has(c))); const REJECT = union(SURROGATES, REJECT_CONTROLS, U.NONCHARACTERS); const FORMAT_CONTROLS = cps(0x00AD, 0x2060, [0x2061, 0x2064], [0x206A, 0x206F], [0xFFF9, 0xFFFB]); const BOM = 0xFEFF; export const HONORIFIC = cps([0x0610, 0x0614], [0xFD40, 0xFD4F], 0xFDCF, [0xFDF0, 0xFDFF], [0x10ED1, 0x10ED8]); export const PF = new Set([...cps([0xFB50, 0xFDFF], [0xFE70, 0xFEFF])] .filter((c) => U.COMPAT_DECOMPOSABLE.has(c) && !HONORIFIC.has(c)).sort((a, b) => a - b)); const PF_MAP = new Map([...PF].map((c) => [c, U.normalizeFormCps([c], 'NFKC')])); const H_EXPAND = new Map([...HONORIFIC].sort((a, b) => a - b) .filter((c) => U.COMPAT_DECOMPOSABLE.has(c)).map((c) => [c, U.normalizeFormCps([c], 'NFKC')])); const H_REMAIN = new Set([...HONORIFIC].filter((c) => !H_EXPAND.has(c))); const ZWSP = 0x200B; const ZWNJ = 0x200C; const ZWJ = 0x200D; const BIDI = U.BIDI_CONTROLS; const BIDI_ZW = union(BIDI, [ZWSP, ZWNJ, ZWJ]); const EMOJI_SKIP = union(U.EMOJI_MODIFIER, [0xFE0E, 0xFE0F], cps([0xE0020, 0xE007F])); const TATWEEL = 0x0640; const HARAKAT = cps([0x064B, 0x0652]); const SHADDA = 0x0651; const QURANIC = cps([0x0615, 0x061A], 0x0653, [0x0656, 0x0658], [0x06D6, 0x06ED], [0x0898, 0x089F], [0x08CA, 0x08E2], [0x08E3, 0x08E9], [0x08F0, 0x08F3], 0x08FF, [0x10EFD, 0x10EFF]); const SUPERSCRIPT_ALEF = 0x0670; const ALEF = 0x0627; const ALEF_VARIANTS = cps(0x0622, 0x0623, 0x0625, 0x0671); const HAMZA_MARKS = cps(0x0653, 0x0654, 0x0655); const HAMZA_ABOVE = 0x0654; const HAMZA_CARRIERS = new Map([[0x0624, 0x0648], [0x0626, 0x064A]]); const HAMZA_BASES = cps(0x0648, 0x064A, 0x0649, 0x0624, 0x0626); const ALEF_MAQSURA = 0x0649; const YEH = 0x064A; const TAA_MARBUTA = 0x0629; const HEH = 0x0647; const PERSIAN_URDU = new Map([[0x06CC, 0x064A], [0x06A9, 0x0643], [0x06C1, 0x0647], [0x06C2, 0x0647], [0x06BE, 0x0647], [0x06D5, 0x0647], [0x06C0, 0x0647]]); const ARABIC_INDIC = cps([0x0660, 0x0669]); const EXT_ARABIC_INDIC = cps([0x06F0, 0x06F9]); const PUNCT_ASCII = new Map([[0x060C, ','], [0x061B, ';'], [0x061F, '?'], [0x066A, '%'], [0x066B, '.'], [0x066C, ','], [0x06D4, '.'], [0x066D, '*'], [0x00AB, '"'], [0x00BB, '"']]); // Fast membership tables for the per-code-point scans. const REJECT_T = new U.CpSet(REJECT); const BIDI_ZW_T = new U.CpSet(BIDI_ZW); const PF_T = new U.CpSet(PF); const HONORIFIC_T = new U.CpSet(HONORIFIC); const H_REMAIN_T = new U.CpSet(H_REMAIN); const ALEF_TRIGGER_T = new U.CpSet(union(ALEF_VARIANTS, HAMZA_MARKS)); const HAMZA_TRIGGER_T = new U.CpSet(union(HAMZA_CARRIERS.keys(), [HAMZA_ABOVE])); const WHITE_SPACE_T = new U.CpSet(U.WHITE_SPACE); const MARKS_T = new U.CpSet(U.MARKS); // --- Per-code-point map stages ------------------------------------------------------------------------ class MapStage { /** mapping: Map cp -> [replacement code points, rule id] */ constructor(stage, mapping) { this.stage = stage; this.mapping = mapping; this.keys = new U.CpSet(mapping.keys()); } run(ctx, s) { const keys = this.keys; let i = keys.find(s); if (i < 0) return s; const m = this.mapping; const edits = []; for (; i >= 0; i = keys.find(s, i + 1)) { const v = m.get(s[i]); edits.push([i, i + 1, v[0], v[1], null]); } return ctx.apply(s, edits, this.stage); } } /** One MapStage equal to applying `a` then `b` (both per-code-point maps); used in plain mode only. */ function composeMaps(a, b) { const mapping = new Map(); for (const c of new Set([...a.mapping.keys(), ...b.mapping.keys()])) { const first = a.mapping.has(c) ? a.mapping.get(c)[0] : [c]; const out = []; for (const x of first) { const v = b.mapping.get(x); if (v === undefined) out.push(x); else for (const y of v[0]) out.push(y); } if (!(out.length === 1 && out[0] === c)) mapping.set(c, [out, 'composed']); } return new MapStage('composed', mapping); } function mapStage(stage, codepoints, rep, rule) { const mapping = new Map(); for (const c of codepoints) { const r = typeof rep === 'function' ? rep(c) : rep; mapping.set(c, [typeof r === 'string' ? U.toCodePoints(r) : r, rule]); } return new MapStage(stage, mapping); } const DIGITS_ASCII_SRC = union(ARABIC_INDIC, EXT_ARABIC_INDIC); const STAGES = new Map([ ['strip_controls/strip', new MapStage('strip_controls', new Map([ ...[...FORMAT_CONTROLS].map((c) => [c, [[], 'strip_controls.format']]), [BOM, [[], 'strip_controls.bom']]]))], ['presentation_forms/map', mapStage('presentation_forms', PF, (c) => PF_MAP.get(c), 'presentation_forms.map')], ['ligatures_honorific/expand', mapStage('ligatures_honorific', H_EXPAND.keys(), (c) => H_EXPAND.get(c), 'ligatures_honorific.expand')], ['tatweel/strip', mapStage('tatweel', [TATWEEL], '', 'tatweel.strip')], ['diacritics/strip', mapStage('diacritics', HARAKAT, '', 'diacritics.strip')], ['diacritics/strip-except-shadda', mapStage('diacritics', [...HARAKAT].filter((c) => c !== SHADDA), '', 'diacritics.strip')], ['quranic_marks/strip', mapStage('quranic_marks', QURANIC, '', 'quranic_marks.strip')], ['superscript_alef/strip', mapStage('superscript_alef', [SUPERSCRIPT_ALEF], '', 'superscript_alef.strip')], ['superscript_alef/to-alef', mapStage('superscript_alef', [SUPERSCRIPT_ALEF], [ALEF], 'superscript_alef.to_alef')], ['alef_maqsura/to-yeh', mapStage('alef_maqsura', [ALEF_MAQSURA], [YEH], 'alef_maqsura.to_yeh')], ['taa_marbuta/to-heh', mapStage('taa_marbuta', [TAA_MARBUTA], [HEH], 'taa_marbuta.to_heh')], ['persian_urdu_letters/map', mapStage('persian_urdu_letters', PERSIAN_URDU.keys(), (c) => [PERSIAN_URDU.get(c)], 'persian_urdu_letters.map')], ['digits/ascii', mapStage('digits', DIGITS_ASCII_SRC, (c) => [0x30 + (c & 0xF)], 'digits.to_ascii')], ['digits/arabic-indic', mapStage('digits', union(cps([0x30, 0x39]), EXT_ARABIC_INDIC), (c) => [0x0660 + (c <= 0x39 ? c - 0x30 : c - 0x06F0)], 'digits.to_arabic_indic')], ['punctuation/strip', mapStage('punctuation', U.PUNCTUATION, ' ', 'punctuation.strip')], ['punctuation/ascii', mapStage('punctuation', PUNCT_ASCII.keys(), (c) => PUNCT_ASCII.get(c), 'punctuation.to_ascii')], ['latin_case/lower', mapStage('latin_case', U.LATIN_LOWER.keys(), (c) => [U.LATIN_LOWER.get(c)], 'latin_case.lower')], ]); // --- Context: offset map, change log, flags -------------------------------------------------------------- class Ctx { constructor(detailed, strict, n) { this.detailed = detailed; this.strict = strict; this.lo = null; this.hi = null; if (detailed) { this.lo = new Array(n); this.hi = new Array(n); for (let i = 0; i < n; i++) { this.lo[i] = i; this.hi[i] = i + 1; } } this.changes = []; this.flags = new Set(); this.rejected = []; } /** Apply sorted, non-overlapping edits [start, end, replacementCps, ruleId, subSpans]. */ apply(s, edits, stage) { if (edits.length === 0) return s; const out = []; let pos = 0; if (!this.detailed) { for (const e of edits) { for (let k = pos; k < e[0]; k++) out.push(s[k]); for (const c of e[2]) out.push(c); pos = e[1]; } for (let k = pos; k < s.length; k++) out.push(s[k]); return out; } const { lo, hi } = this; const nlo = []; const nhi = []; for (const [i, j, rep, rule, sub] of edits) { for (let k = pos; k < i; k++) { out.push(s[k]); nlo.push(lo[k]); nhi.push(hi[k]); } let a = Infinity; let b = -Infinity; for (let k = i; k < j; k++) { if (lo[k] < a) a = lo[k]; if (hi[k] > b) b = hi[k]; } if (sub === null) { for (let k = 0; k < rep.length; k++) { nlo.push(a); nhi.push(b); } } else { for (const [x, y] of sub) { let p = Infinity; let q = -Infinity; for (let k = x; k < y; k++) { if (lo[k] < p) p = lo[k]; if (hi[k] > q) q = hi[k]; } nlo.push(p); nhi.push(q); } } for (const c of rep) out.push(c); this.changes.push({ stage, rule_id: rule, src_start: a, src_end: b, before: U.fromCodePoints(s, i, j), after: str(rep), lossy: LOSSY_RULES.has(rule), }); pos = j; } for (let k = pos; k < s.length; k++) { out.push(s[k]); nlo.push(lo[k]); nhi.push(hi[k]); } this.lo = nlo; this.hi = nhi; return out; } } // --- Context-sensitive stages --------------------------------------------------------------------------- function rejectReason(cp) { if (cp >= 0xD800 && cp <= 0xDFFF) return 'lone_surrogate'; if (REJECT_CONTROLS.has(cp)) return 'control'; return 'noncharacter'; } const hex4 = (cp) => cp.toString(16).toUpperCase().padStart(4, '0'); function stageReject(ctx, s, mode) { const first = REJECT_T.find(s); if (first < 0) return s; if (mode === 'error' || ctx.strict) { const cp = s[first]; throw new InputRejected(rejectReason(cp), `U+${hex4(cp)} at index ${first}`, [first, first + 1]); } const edits = []; const rejected = ctx.rejected; for (let i = first; i >= 0; i = REJECT_T.find(s, i + 1)) { const reason = rejectReason(s[i]); edits.push([i, i + 1, [], `reject_invalid.${reason}`, null]); const last = rejected[rejected.length - 1]; if (last && last.reason === reason && last.end === i) last.end = i + 1; else rejected.push({ start: i, end: i + 1, reason }); } return ctx.apply(s, edits, 'reject_invalid'); } // Code points skipped when looking for the letters around a ZWNJ (docs/SPEC.md 4.5). Policy-independent. const CTX_SKIP_BASE = new Set([...U.JOINING].filter(([, t]) => t === 'T').map(([c]) => c)); for (const c of [ZWSP, ZWNJ, ZWJ, TATWEEL, ...QURANIC]) CTX_SKIP_BASE.add(c); const CTX_SKIP = new Set(CTX_SKIP_BASE); for (const [c, m] of PF_MAP) if (m.every((x) => CTX_SKIP_BASE.has(x))) CTX_SKIP.add(c); // Presentation forms take the joining behaviour of the letters they map to. const JT_PREV = new Map(); const JT_NEXT = new Map(); for (const [c, m] of PF_MAP) { const letters = m.filter((x) => !CTX_SKIP.has(x)); JT_PREV.set(c, letters.length ? U.joiningType(letters[letters.length - 1]) : 'U'); JT_NEXT.set(c, letters.length ? U.joiningType(letters[0]) : 'U'); } const jtPrev = (cp) => (JT_PREV.has(cp) ? JT_PREV.get(cp) : U.joiningType(cp)); const jtNext = (cp) => (JT_NEXT.has(cp) ? JT_NEXT.get(cp) : U.joiningType(cp)); function viramaBefore(s, i) { for (let k = i - 1; k >= 0 && MARKS_T.has(s[k]); k--) if (U.ccc(s[k]) === 9) return true; return false; } /** For each position (sorted), the joining types of the nearest non-skipped code points before and after. */ function context(s, positions) { const before = new Map(); let last = -1; let lastVal = null; for (const i of positions) { let k = i - 1; let val = null; let found = false; while (k > last) { const cp = s[k]; if (!CTX_SKIP.has(cp)) { val = jtPrev(cp); found = true; break; } k -= 1; } if (!found) val = last >= 0 ? lastVal : null; before.set(i, val); last = i; lastVal = val; } const after = new Map(); const n = s.length; last = n; lastVal = null; for (let p = positions.length - 1; p >= 0; p--) { const i = positions[p]; let k = i + 1; let val = null; let found = false; while (k < last) { const cp = s[k]; if (!CTX_SKIP.has(cp)) { val = jtNext(cp); found = true; break; } k += 1; } if (!found) val = last < n ? lastVal : null; after.set(i, val); last = i; lastVal = val; } return [before, after]; } function zwjMeaningful(s, i) { if (viramaBefore(s, i)) return true; let k = i - 1; while (k >= 0 && EMOJI_SKIP.has(s[k])) k -= 1; if (k < 0 || !U.EXT_PICT.has(s[k])) return false; return i + 1 < s.length && U.EXT_PICT.has(s[i + 1]); } const JOIN_PREV_OK = new Set(['D', 'L', 'C']); const JOIN_NEXT_OK = new Set(['D', 'R', 'C']); function stageBidiZw(ctx, s, mode) { if (mode === 'keep') return s; const matches = []; for (let i = BIDI_ZW_T.find(s); i >= 0; i = BIDI_ZW_T.find(s, i + 1)) matches.push(i); if (matches.length === 0) return s; const keepZwnj = mode === 'strip-except-zwnj' || mode === 'strip-except-meaningful'; const keepZwj = mode === 'strip-except-emoji-zwj' || mode === 'strip-except-meaningful'; let before = new Map(); let after = new Map(); if (keepZwnj) { const zwnj = matches.filter((i) => s[i] === ZWNJ); if (zwnj.length) [before, after] = context(s, zwnj); } const edits = []; for (const i of matches) { const c = s[i]; let rule; if (c === ZWNJ) { if (keepZwnj && (viramaBefore(s, i) || (JOIN_PREV_OK.has(before.get(i)) && JOIN_NEXT_OK.has(after.get(i))))) continue; rule = 'bidi_zw.zwnj'; } else if (c === ZWJ) { if (keepZwj && zwjMeaningful(s, i)) continue; rule = 'bidi_zw.zwj'; } else if (c === ZWSP) { rule = 'bidi_zw.zwsp'; } else { rule = 'bidi_zw.bidi_control'; } edits.push([i, i + 1, [], rule, null]); } return ctx.apply(s, edits, 'bidi_zw'); } function sameCps(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function stageCanonical(ctx, s, form, final) { if (form === 'none') return s; const edits = []; for (const [a, b, rep, sub] of U.normalizationEdits(s, form)) { let rule; if (final) rule = 'canonical_order.final'; else if (form === 'NFKC' && !sameCps(U.normalizeFormCps(s.slice(a, b), 'NFC'), rep)) rule = 'canonical_order.nfkc'; else rule = 'canonical_order.nfc'; edits.push([a, b, rep, rule, sub]); } return ctx.apply(s, edits, 'canonical_order'); } /** Base code point (nearest non-mark before) for each mark position; linear over runs of marks. */ function markBases(s, positions) { const bases = new Map(); let prev = -1; let prevBase = null; for (const i of positions) { let k = i - 1; while (k > prev && MARKS_T.has(s[k])) k -= 1; let base; if (k === prev && prev >= 0) base = prevBase; else base = k >= 0 ? s[k] : null; bases.set(i, base); prev = i; prevBase = base; } return bases; } function stageAlef(ctx, s) { const matches = []; for (let i = ALEF_TRIGGER_T.find(s); i >= 0; i = ALEF_TRIGGER_T.find(s, i + 1)) matches.push(i); if (matches.length === 0) return s; const bases = markBases(s, matches.filter((i) => HAMZA_MARKS.has(s[i]))); const edits = []; for (const i of matches) { if (ALEF_VARIANTS.has(s[i])) { edits.push([i, i + 1, [ALEF], 'alef_variants.map', null]); } else { const base = bases.get(i); if (base === ALEF || ALEF_VARIANTS.has(base)) edits.push([i, i + 1, [], 'alef_variants.map', null]); } } return ctx.apply(s, edits, 'alef_variants'); } function stageHamza(ctx, s) { const matches = []; for (let i = HAMZA_TRIGGER_T.find(s); i >= 0; i = HAMZA_TRIGGER_T.find(s, i + 1)) matches.push(i); if (matches.length === 0) return s; const bases = markBases(s, matches.filter((i) => s[i] === HAMZA_ABOVE)); const edits = []; for (const i of matches) { if (HAMZA_CARRIERS.has(s[i])) edits.push([i, i + 1, [HAMZA_CARRIERS.get(s[i])], 'hamza_carriers.fold', null]); else if (HAMZA_BASES.has(bases.get(i))) edits.push([i, i + 1, [], 'hamza_carriers.fold', null]); } return ctx.apply(s, edits, 'hamza_carriers'); } function stageWhitespace(ctx, s) { const edits = []; const n = s.length; let i = WHITE_SPACE_T.find(s); while (i >= 0 && i < n) { let j = i + 1; while (j < n && WHITE_SPACE_T.has(s[j])) j += 1; if (i === 0 || j === n) edits.push([i, j, [], 'whitespace.collapse', null]); else if (!(j - i === 1 && s[i] === 0x20)) edits.push([i, j, [0x20], 'whitespace.collapse', null]); i = j < n ? WHITE_SPACE_T.find(s, j) : -1; } return ctx.apply(s, edits, 'whitespace'); } const FLAG = Symbol('flag'); function flagStep(flag, test) { const step = (ctx, s) => { if (ctx.detailed && test(s)) ctx.flags.add(flag); return s; }; step[FLAG] = true; return step; } const PLANS = new Map(); /** * The ordered steps for a policy (docs/SPEC.md section 3). In plain mode the flag checks are dropped and * adjacent per-code-point maps are merged into one table, as in the Python engine; both plans give the same * text (tested). */ function plan(policy, detailed) { const cacheKey = `${policy.policy_hash}/${detailed ? 'd' : 'p'}`; let cached = PLANS.get(cacheKey); if (cached !== undefined) return cached; const st = policy.stages; let steps = [(ctx, s) => stageReject(ctx, s, st.reject_invalid)]; const add = (key) => { const stage = STAGES.get(key); if (stage) steps.push(stage); }; if (st.strip_controls === 'strip') add('strip_controls/strip'); if (st.presentation_forms === 'map') add('presentation_forms/map'); else if (st.presentation_forms === 'flag') steps.push(flagStep('presentation_forms.flag', (s) => PF_T.find(s) >= 0)); if (st.ligatures_honorific === 'expand') { add('ligatures_honorific/expand'); steps.push(flagStep('ligatures_honorific.flag', (s) => H_REMAIN_T.find(s) >= 0)); } else if (st.ligatures_honorific === 'flag') { steps.push(flagStep('ligatures_honorific.flag', (s) => HONORIFIC_T.find(s) >= 0)); } steps.push((ctx, s) => stageBidiZw(ctx, s, st.bidi_zw)); const form = policy.unicode_form; steps.push((ctx, s) => stageCanonical(ctx, s, form, false)); for (const stage of ['tatweel', 'diacritics', 'quranic_marks', 'superscript_alef']) add(`${stage}/${st[stage]}`); if (st.alef_variants === 'map') steps.push(stageAlef); if (st.alef_maqsura === 'to-yeh') add('alef_maqsura/to-yeh'); else if (st.alef_maqsura === 'flag') steps.push(flagStep('alef_maqsura.flag', (s) => s.includes(ALEF_MAQSURA))); if (st.taa_marbuta === 'to-heh') add('taa_marbuta/to-heh'); else if (st.taa_marbuta === 'flag') steps.push(flagStep('taa_marbuta.flag', (s) => s.includes(TAA_MARBUTA))); if (st.hamza_carriers === 'fold') steps.push(stageHamza); for (const stage of ['persian_urdu_letters', 'digits', 'punctuation']) add(`${stage}/${st[stage]}`); if (st.whitespace === 'collapse') steps.push(stageWhitespace); if (st.latin_case === 'lower') add('latin_case/lower'); if (form !== 'none') steps.push((ctx, s) => stageCanonical(ctx, s, 'NFC', true)); if (!detailed) { const merged = []; for (const step of steps) { if (step[FLAG]) continue; const last = merged[merged.length - 1]; if (step instanceof MapStage && last instanceof MapStage) merged[merged.length - 1] = composeMaps(last, step); else merged.push(step); } steps = merged; } cached = steps.map((step) => (step instanceof MapStage ? (ctx, s) => step.run(ctx, s) : step)); PLANS.set(cacheKey, cached); return cached; } function run(cpsIn, policy, detailed, strict) { const ctx = new Ctx(detailed, strict, cpsIn.length); let s = cpsIn; for (const step of plan(policy, detailed)) s = step(ctx, s); return [s, ctx]; } // --- Public API ------------------------------------------------------------------------------------------ function checkText(text) { if (typeof text !== 'string') { throw new InputRejected('text_not_string', `expected str, got ${text === null ? 'null' : typeof text}`); } const c = U.toCodePoints(text); if (c.length * 4 > MAX_TEXT_BYTES) { const nbytes = utf8Length(c); if (nbytes > MAX_TEXT_BYTES) { throw new InputRejected('over_cap_bytes', `${nbytes} UTF-8 bytes; the cap is ${MAX_TEXT_BYTES}`); } } return c; } /** * Normalise `text` with `policy` (built-in id, policy object or Policy) and return the string. * @param {string} text * @param {string|object} [policy] * @param {{strict?: boolean}} [options] */ export function normalize(text, policy = DEFAULT_POLICY, { strict = false } = {}) { const c = checkText(text); const p = loadPolicy(policy); return U.fromCodePoints(run(c, p, false, strict)[0]); } function countIn(arr, set) { let n = 0; for (const c of arr) if (set.has(c)) n += 1; return n; } function inputStatsCps(c) { const expanded = []; for (const x of c) { const m = PF_MAP.get(x); if (m === undefined) expanded.push(x); else for (const y of m) expanded.push(y); } const letters = countIn(expanded, U.ARABIC_LETTERS); const harakat = countIn(expanded, HARAKAT); let sup = 0; let taa = 0; let maq = 0; for (const x of expanded) { if (x === SUPERSCRIPT_ALEF) sup += 1; else if (x === TAA_MARBUTA) taa += 1; else if (x === ALEF_MAQSURA) maq += 1; } const classes = { arabic_letters: letters, harakat, superscript_alef: sup, taa_marbuta: taa, alef_maqsura: maq, presentation_forms: countIn(c, PF), honorific_ligatures: countIn(c, HONORIFIC), rejected: countIn(c, REJECT), }; const ratio = letters ? (harakat + sup) / letters : 0.0; return [classes, ratio]; } /** Policy-independent input counts and the diacritization ratio (docs/SPEC.md section 5). */ export function inputStats(text) { return inputStatsCps(U.toCodePoints(text)); } const sortStrings = (arr) => [...arr].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); /** * Normalise `text` and return the detailed result: the same object as Python's Result.to_dict() * (schemas/output.schema.json): text_norm, offset_map, changes, lossy_flags, flags, diacritization_ratio, * rejected_spans, stats, policy_id, policy_hash, lib_version, unicode_version, original_hash, text_diac. */ export function normalizeDetailed(text, policy = DEFAULT_POLICY, { strict = false } = {}) { const c = checkText(text); const p = loadPolicy(policy); const [out, ctx] = run(c, p, true, strict); const [classes, ratio] = inputStatsCps(c); const ruleCounts = {}; for (const ch of ctx.changes) ruleCounts[ch.rule_id] = (ruleCounts[ch.rule_id] || 0) + 1; const rules = {}; for (const k of sortStrings(Object.keys(ruleCounts))) rules[k] = ruleCounts[k]; let textDiac = null; if (classes.harakat || classes.superscript_alef) { textDiac = U.fromCodePoints(run(c, loadPolicy('raw-safe-v1'), false, false)[0]); } const offsetMap = ctx.lo.map((lo, i) => [lo, ctx.hi[i]]); return { original_hash: sha256Hex(encodeCodePoints(c)), text_norm: U.fromCodePoints(out), text_diac: textDiac, policy_id: p.policy_id, policy_hash: p.policy_hash, lib_version: LIB_VERSION, unicode_version: U.UNICODE_VERSION, changes: ctx.changes, offset_map: offsetMap, lossy_flags: sortStrings(new Set(ctx.changes.filter((x) => x.lossy).map((x) => x.rule_id))), flags: sortStrings(ctx.flags), diacritization_ratio: ratio, stats: { rules, classes }, rejected_spans: ctx.rejected.map((r) => ({ ...r })), }; } /** Sorted unique rule ids in a detailed result's change log (Python: Result.rules). */ export function ruleIds(result) { return sortStrings(new Set(result.changes.map((x) => x.rule_id))); } /** Strict UTF-8 decoding; throws InputRejected('invalid_utf8') with the byte offset. */ export function decodeUtf8(bytes) { if (!(bytes instanceof Uint8Array)) throw new InputRejected('invalid_request', 'expected a Uint8Array'); if (bytes.length > MAX_TEXT_BYTES) { throw new InputRejected('over_cap_bytes', `${bytes.length} bytes; the cap is ${MAX_TEXT_BYTES}`); } const bad = findInvalidUtf8(bytes); if (bad) throw new InputRejected('invalid_utf8', `byte offset ${bad.start}: ${bad.reason}`, [bad.start, bad.end]); return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } /** Decode strict UTF-8 (a BOM is handled by strip_controls), then normalise. */ export function normalizeBytes(bytes, policy = DEFAULT_POLICY, { strict = false, detailed = false } = {}) { const text = decodeUtf8(bytes); return detailed ? normalizeDetailed(text, policy, { strict }) : normalize(text, policy, { strict }); } /** Parse one JSONL line and return [record, text]. Throws InputRejected with a contract reason. */ export function parseRecord(line, field = 'text') { let rec; try { rec = JSON.parse(line); } catch (e) { throw new InputRejected('invalid_json', String(e.message || e)); } if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) { throw new InputRejected('invalid_json', 'a JSONL record must be a JSON object'); } if (!Object.prototype.hasOwnProperty.call(rec, field)) { throw new InputRejected('missing_text_field', `record has no '${field}' field`); } const text = rec[field]; if (typeof text !== 'string') { throw new InputRejected('text_not_string', `field '${field}' is ${text === null ? 'null' : typeof text}, not a string`); } return [rec, text]; } /** * Apply the input contract (schemas/input.schema.json) to one request and return a list of results. * request: {text} or {texts: [...]}, optional policy (id or inline object), detailed, strict. */ export function normalizeRequest(request) { if (request === null || typeof request !== 'object' || Array.isArray(request)) { throw new InputRejected('invalid_request', 'a request must be a JSON object'); } const unknown = Object.keys(request).filter((k) => !['text', 'texts', 'policy', 'detailed', 'strict'].includes(k)); if (unknown.length) throw new InputRejected('unknown_member', `unknown request member(s): ${sortStrings(unknown).join(', ')}`); const hasText = Object.prototype.hasOwnProperty.call(request, 'text'); const hasTexts = Object.prototype.hasOwnProperty.call(request, 'texts'); if (hasText === hasTexts) throw new InputRejected('invalid_request', "give exactly one of 'text' or 'texts'"); const texts = hasText ? [request.text] : request.texts; if (!Array.isArray(texts)) throw new InputRejected('invalid_request', "'texts' must be a list"); if (texts.length > MAX_RECORDS) { throw new InputRejected('over_cap_records', `${texts.length} records; the cap is ${MAX_RECORDS}`); } const p = loadPolicy(Object.prototype.hasOwnProperty.call(request, 'policy') ? request.policy : DEFAULT_POLICY); const strict = Boolean(request.strict); for (const t of texts) checkText(t); if (request.detailed) return texts.map((t) => normalizeDetailed(t, p, { strict })); return texts.map((t) => normalize(t, p, { strict })); } /** Compare two policies on one text: outputs, rules only one of them applies, and differing source regions. */ export function diffPolicies(text, policyA, policyB) { const ra = normalizeDetailed(text, policyA); const rb = normalizeDetailed(text, policyB); const regions = [...ra.changes, ...rb.changes].map((c) => [c.src_start, c.src_end]); regions.sort((x, y) => x[0] - y[0] || x[1] - y[1]); const merged = []; for (const [lo, hi] of regions) { const last = merged[merged.length - 1]; if (last && lo <= last[1]) last[1] = Math.max(last[1], hi); else merged.push([lo, hi]); } const cpsA = U.toCodePoints(ra.text_norm); const cpsB = U.toCodePoints(rb.text_norm); const piece = (res, c, lo, hi) => { const out = []; for (let i = 0; i < c.length; i++) { const sp = res.offset_map[i]; if (sp && sp[0] < hi && sp[1] > lo) out.push(c[i]); } return U.fromCodePoints(out); }; const src = U.toCodePoints(text); const diffs = []; for (const [lo, hi] of merged) { const a = piece(ra, cpsA, lo, hi); const b = piece(rb, cpsB, lo, hi); if (a !== b) diffs.push({ src_start: lo, src_end: hi, source: U.fromCodePoints(src, lo, hi), a, b }); } const rulesA = new Set(ruleIds(ra)); const rulesB = new Set(ruleIds(rb)); return { policy_a: ra.policy_id, policy_hash_a: ra.policy_hash, text_a: ra.text_norm, policy_b: rb.policy_id, policy_hash_b: rb.policy_hash, text_b: rb.text_norm, identical: ra.text_norm === rb.text_norm, rules_only_a: sortStrings([...rulesA].filter((r) => !rulesB.has(r))), rules_only_b: sortStrings([...rulesB].filter((r) => !rulesA.has(r))), lossy_flags_a: ra.lossy_flags, lossy_flags_b: rb.lossy_flags, regions: diffs, }; } /** The text_diac field: raw-safe-v1 output when the input carries harakat or superscript alef, else null. */ export function textDiac(text) { const [classes] = inputStats(text); if (classes.harakat || classes.superscript_alef) return normalize(text, 'raw-safe-v1'); return null; }