Files
maestro/src/engine/tools/text-decode.ts
T
oss-sync b1292e34b2
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

288 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Encoding-aware text layer on top of the pure byte sniffer in binary-detect.ts.
*
* binary-detect.ts answers a binary yes/no and recognizes UTF-8 / BOM-tagged
* UTF-16 only — every other text encoding (Shift_JIS/CP932, EUC-JP, ISO-2022-JP,
* windows-1252/Latin-1, …) trips its strict-UTF-8 gate and is reported as
* `utf8-decode-fail`, i.e. "binary". That is wrong for the common case of a
* Japanese .txt / CSV saved on Windows (CP932).
*
* This module recovers those: when the sniffer's only complaint is
* `utf8-decode-fail`, it trial-decodes a short ladder of candidate encodings
* with iconv-lite and picks the one that produces the least mojibake. Callers
* (Read/Grep/Edit) then transcode the whole file to UTF-8 for the LLM while
* keeping the raw bytes so edits round-trip back to the original encoding.
*
* iconv-lite is pure JS (no ICU dependency), so Shift_JIS/EUC-JP decoding works
* the same on small-icu Node builds where the native TextDecoder lacks those
* labels. Only UTF-8 and BOM-tagged UTF-16 stay on the built-in decoder.
*/
import iconv from 'iconv-lite';
import {
looksLikeBinaryBytes,
controlCharRatio,
CONTROL_CHAR_RATIO_THRESHOLD,
SNIFF_HEAD_BYTES,
stripLeadingBom,
} from './binary-detect.js';
const UTF16LE_BOM = Buffer.from([0xff, 0xfe]);
const UTF16BE_BOM = Buffer.from([0xfe, 0xff]);
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
// Internal encoding label for UTF-8 files that carry a leading BOM. TextDecoder
// strips the BOM on read, so without tracking it separately an Edit/Write would
// silently drop it and break Windows tools that expect it. Treated as UTF-8
// everywhere except that the BOM is re-added on encode.
export const UTF8_BOM_LABEL = 'utf-8-bom';
function hasUtf8Bom(buf: Buffer): boolean {
return buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
}
/**
* Strict UTF-8 decode of a COMPLETE buffer. Unlike binary-detect's streaming
* `decodeText` (used for 8 KB head sniffing, where a multibyte char cut at the
* sample boundary must be tolerated), this flushes at EOF, so a genuinely
* truncated trailing sequence is fatal instead of being silently dropped.
*/
function strictUtf8Whole(buf: Buffer): string | null {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
} catch {
return null;
}
}
export type TextEncodingVerdict =
| { binary: true; reason: string }
| { binary: false; encoding: string };
export type DecodedFile =
| { binary: true; reason: string }
| { binary: false; encoding: string; text: string };
/**
* Candidate encodings tried, in priority order, when a buffer is not valid
* UTF-8 but also shows no hard binary signal. Japanese-first because that is
* the dominant real case (Windows CP932). windows-1252 is last: it decodes
* almost any byte, so it must only win when the multibyte candidates all fail
* (strict `<` tie-break below keeps the earliest candidate on equal scores).
*/
// Japanese-first, and deliberately Japanese-only among the multibyte codecs:
// GBK/Big5 (Chinese) would grab ambiguous kana/halfwidth Japanese and mojibake
// it. windows-1252 last as the single-byte Western fallback (see the tie-break).
const CANDIDATE_ENCODINGS = ['Shift_JIS', 'EUC-JP', 'ISO-2022-JP', 'windows-1252'];
/** Accept a trial decode only when at most this fraction of chars are garbage. */
const MOJIBAKE_ACCEPT_RATIO = 0.02;
/**
* A near-zero mojibake score is not enough on its own: windows-1252 maps almost
* every byte and multibyte CJK encodings (GBK/Big5) accept many random byte
* pairs, so high-entropy binary can decode "cleanly" into a soup of symbols and
* rare ideographs. Require that the decoded chars are also *predominantly*
* ordinary text — ASCII, Japanese kana/kanji, CJK punctuation, fullwidth forms,
* or Latin accented letters. Real text scores ~1.0; binary-as-cp1252/GBK scores
* well below this because it is dominated by symbol/rare-block code points.
*/
const PLAUSIBLE_TEXT_MIN = 0.8;
const REPLACEMENT_CHAR = 0xfffd;
/** Fraction of decoded chars that are U+FFFD or non-whitespace C0/C1 controls. */
function mojibakeRatio(decoded: string): number {
if (decoded.length === 0) return 1;
let bad = 0;
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code === REPLACEMENT_CHAR) {
bad++;
} else if (
(code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) ||
code === 0x7f || (code >= 0x80 && code <= 0x9f))
) {
bad++;
}
}
return bad / decoded.length;
}
/** True for code points that appear in ordinary human text (see PLAUSIBLE_TEXT_MIN). */
function isPlausibleTextChar(code: number): boolean {
return (
code === 0x09 || code === 0x0a || code === 0x0d || // tab / newlines
(code >= 0x20 && code <= 0x7e) || // ASCII printable
(code >= 0x00c0 && code <= 0x00ff) || // Latin-1 accented letters
(code >= 0x3000 && code <= 0x303f) || // CJK symbols & punctuation
(code >= 0x3040 && code <= 0x309f) || // Hiragana
(code >= 0x30a0 && code <= 0x30ff) || // Katakana
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
(code >= 0xff00 && code <= 0xffef) // Fullwidth / halfwidth forms
);
}
/** Fraction of decoded chars that are ordinary text (not stray symbols/rare blocks). */
function plausibleTextRatio(decoded: string): number {
if (decoded.length === 0) return 0;
let ok = 0;
for (let i = 0; i < decoded.length; i++) {
if (isPlausibleTextChar(decoded.charCodeAt(i))) ok++;
}
return ok / decoded.length;
}
/**
* Fraction of chars that are *body-text* Japanese/Latin — like isPlausibleTextChar
* but EXCLUDING halfwidth katakana (U+FF61–FF9F) and Latin-1 symbols. Used only to
* break ties between candidates: EUC-JP kana decoded (wrongly) as Shift_JIS lands
* in halfwidth katakana, so the candidate whose decode is richer in fullwidth
* kana/kanji is the right one. Two encodings can both hit zero mojibake, so this
* secondary key is what stops EUC-JP text from being mislabeled Shift_JIS.
*/
function preferredTextRatio(decoded: string): number {
if (decoded.length === 0) return 0;
let ok = 0;
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (
code === 0x09 || code === 0x0a || code === 0x0d ||
(code >= 0x20 && code <= 0x7e) || // ASCII printable
(code >= 0x00c0 && code <= 0x00ff) || // Latin-1 accented letters
(code >= 0x3000 && code <= 0x303f) || // CJK symbols & punctuation
(code >= 0x3040 && code <= 0x309f) || // Hiragana
(code >= 0x30a0 && code <= 0x30ff) || // full Katakana
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
(code >= 0xff01 && code <= 0xff60) // Fullwidth forms (not halfwidth)
) {
ok++;
}
}
return ok / decoded.length;
}
/**
* Extend the pure binary sniffer with non-UTF-8 text recovery.
* Returns a concrete encoding label callers pass to decodeToUtf8.
*/
export function detectTextEncoding(head: Buffer): TextEncodingVerdict {
const base = looksLikeBinaryBytes(head);
if (!base.binary) return { binary: false, encoding: base.encoding };
// Only a strict-UTF-8 failure is potentially recoverable text. A magic-byte
// signature, an embedded NUL, or a high control-char ratio is a hard binary
// signal — leave those rejected exactly as before.
if (base.reason !== 'utf8-decode-fail') return base;
// utf8-decode-fail returns before binary-detect's own control-ratio gate, so
// re-check it here: high control density means binary, not exotic-encoded text.
if (controlCharRatio(head) > CONTROL_CHAR_RATIO_THRESHOLD) {
return { binary: true, reason: base.reason };
}
let best: string | null = null;
let bestScore = Infinity;
let bestMojibake = 1;
let bestPlausible = 0;
for (const enc of CANDIDATE_ENCODINGS) {
if (!iconv.encodingExists(enc)) continue;
const decoded = iconv.decode(head, enc);
const mojibake = mojibakeRatio(decoded);
// Primary key: mojibake (×10 so it dominates). Secondary: prefer the decode
// richer in body-text Japanese/Latin, which separates true EUC-JP from the
// same bytes decoded as Shift_JIS halfwidth katakana.
const score = mojibake * 10 + (1 - preferredTextRatio(decoded));
if (score < bestScore) {
bestScore = score;
best = enc;
bestMojibake = mojibake;
bestPlausible = plausibleTextRatio(decoded);
}
}
if (best && bestMojibake <= MOJIBAKE_ACCEPT_RATIO && bestPlausible >= PLAUSIBLE_TEXT_MIN) {
return { binary: false, encoding: best.toLowerCase() };
}
return { binary: true, reason: base.reason };
}
/** Decode raw file bytes of a known encoding into a UTF-8 JS string. */
export function decodeToUtf8(buf: Buffer, encoding: string): string {
if (encoding === 'utf-8') return buf.toString('utf-8');
if (encoding === UTF8_BOM_LABEL) return stripLeadingBom(buf.toString('utf-8'));
return iconv.decode(buf, encoding);
}
/**
* Encode an (edited) UTF-8 string back into bytes of the original encoding.
*
* For UTF-16 the BOM is re-added: the encoding label is only produced for
* BOM-tagged files (iconv strips the BOM on decode and does not re-add it on
* encode), and a BOMless UTF-16 file would be rejected as binary on the next
* Read (leading NUL). Re-adding it keeps the round-trip readable.
*/
export function encodeFromUtf8(text: string, encoding: string): Buffer {
if (encoding === 'utf-8') return Buffer.from(text, 'utf-8');
if (encoding === UTF8_BOM_LABEL) return Buffer.concat([UTF8_BOM, Buffer.from(text, 'utf-8')]);
const body = iconv.encode(text, encoding);
if (encoding === 'utf-16le') return Buffer.concat([UTF16LE_BOM, body]);
if (encoding === 'utf-16be') return Buffer.concat([UTF16BE_BOM, body]);
return body;
}
/**
* Decode a WHOLE file buffer to a UTF-8 string, choosing the encoding.
*
* Detection runs over the entire buffer, not just the head: a file whose first
* 8 KiB is pure ASCII (a long English header/comment) but whose tail is
* Shift_JIS must not be mistaken for UTF-8 and mangled to U+FFFD. The fast path
* is a single whole-file strict UTF-8 decode; only when that fails do we run the
* candidate ladder.
*/
export function decodeFileText(full: Buffer): DecodedFile {
// Hard binary signals (magic byte / NUL / high control ratio) MUST be checked
// before any UTF-8 fast path: a PDF/ZIP/GIF header can be valid UTF-8, and NUL
// is itself valid UTF-8, so trusting the fast path would let Grep leak binary.
const head = full.length > SNIFF_HEAD_BYTES ? full.subarray(0, SNIFF_HEAD_BYTES) : full;
const headVerdict = detectTextEncoding(head);
if (headVerdict.binary) return headVerdict;
// BOM-tagged UTF-16 legitimately contains NUL bytes; decode via its encoding
// and skip the NUL gate below (which would otherwise reject it as binary).
if (headVerdict.encoding === 'utf-16le' || headVerdict.encoding === 'utf-16be') {
return { binary: false, encoding: headVerdict.encoding, text: decodeToUtf8(full, headVerdict.encoding) };
}
// Whole-file hard-binary check: an ASCII head can hide NUL / high control ratio
// in the tail (the head-only verdict above cannot see it).
if (full.includes(0)) return { binary: true, reason: 'nul-byte' };
if (controlCharRatio(full) > CONTROL_CHAR_RATIO_THRESHOLD) {
return { binary: true, reason: `control-ratio:${controlCharRatio(full).toFixed(2)}` };
}
// Whole-file UTF-8 validation (an ASCII prefix must not hide a Shift_JIS tail;
// a truncated trailing multibyte must not be silently dropped either). Track a
// leading BOM so Edit/Write can re-add it.
const utf8 = strictUtf8Whole(full);
if (utf8 !== null) {
return { binary: false, encoding: hasUtf8Bom(full) ? UTF8_BOM_LABEL : 'utf-8', text: utf8 };
}
const verdict = detectTextEncoding(full);
if (verdict.binary) return verdict;
return { binary: false, encoding: verdict.encoding, text: decodeToUtf8(full, verdict.encoding) };
}
/**
* Encode a UTF-8 string back to bytes of `encoding`, but only if it round-trips.
* iconv-lite silently substitutes unrepresentable chars (e.g. an emoji written
* into a Shift_JIS file) with '?', which would corrupt the save. Returns null so
* the caller can refuse rather than write bytes that differ from the request.
*/
export function encodeFileText(text: string, encoding: string): Buffer | null {
const bytes = encodeFromUtf8(text, encoding);
if (encoding === 'utf-8') return bytes;
return decodeToUtf8(bytes, encoding) === text ? bytes : null;
}