نام فایلهای شما خراب نیست — فقط با الفبای اشتباهی خوانده میشود
یک فایل ZIP را که همکارتان فرستاده باز میکنید و داخلش این است:
الصورة.jpg
تقرير نهائي.pdf
Işık.txt
خودِ فایلها بیمشکل باز میشوند: عکس سالم است، PDF رندر میشود. فقط نامها ناخوانایند، و وقتی چهارصد تا باشند، چهارصد نام ناخوانا یک مشکل واقعی است — نه میتوانید مرتبشان کنید، نه در آنها جستوجو کنید، نه به کسی که درخواست داده پس بدهید.
اگر بیشتر با نامهای انگلیسی کار کرده باشید، این صحنه شبیه خرابی به نظر میرسد. اما نیست. هیچ بلایی سر بایتها نیامده است. هر کدام از این نامها با کدگذاریِ اشتباهی خوانده شدهاند، و این وضعیتِ خیلی بهتری است، چون درست کردنش حساب و کتاب است نه حدس و گمان.
واقعاً چه اتفاقی افتاده است
الصورة شش حرف عربی است. در UTF-8 هر کدام دو بایت میگیرند، پس نام دوازده بایت میشود:
D8 A7 D9 84 D8 B5 D9 88 D8 B1 D8 A9
ا ل ص و ر ة
حالا همین دوازده بایت را یکییکی بخوانید، طوری که انگار هر بایت یک حرفِ کامل در یک صفحه کدِ تکبایتی است — مثلاً Windows-1252:
D8 A7 D9 84 D8 B5 D9 88 D8 B1 D8 A9
Ø § Ù „ Ø µ Ù ˆ Ø ± Ø ©
کنار هم که بگذاریدشان میشود الصورة. بایتها کاملاً سالم از راه رسیدهاند؛ فقط با جدولِ اشتباهی رمزگشایی شدهاند.
این است تمامِ ماجرا. اسمش mojibake است و با اختلافِ زیاد، شایعترین دلیلی است که یک نام فایلِ «خراب» در واقع سالم است.
شکلِ بههمریختگی به شما میگوید کدام جدول استفاده شده است
| خواندهشده بهعنوان | شکل ظاهری | منشأ |
|---|---|---|
| Windows-1256 (عربی) | ط§ظ„طµظˆط±ط© — هنوز حروف عربی است اما بیمعنا | یک ویندوز عربی که بایتهای UTF-8 را میخواند |
| Windows-1252 / Latin-1 | الصورة — لاتینِ اعرابدار و علامتهای سرگردان | آرشیوهای ZIP، FTP، کپی بین سیستمها |
| Windows-1254 (ترکی) | Işık بهجای Işık | ویندوز ترکی، آرشیوهای قدیمی |
| Mac Arabic | ظ\'عÑ — عربیِ قاطیِ علامتهای ASCII | آرشیوهای ساختهشده روی مکهای قدیمی |
| DOS Arabic (cp864) | ﻅ§ﻋ▒ — خطوطِ کادر و حروفِ پُرکننده | آرشیوهای خیلی قدیمیِ دوران DOS |
| دو بار اعمال شده | ال — بههمریختگیای با بههمریختگی درونش | نامی که از قبل غلط بوده و دوباره تبدیل شده |
ردیف آخر مهمتر از چیزی است که به نظر میرسد. نامهای دوبار کدگذاریشده رایجاند: کسی بههمریختگی را میبیند، آن را از یک مبدّل رد میکند تا «درستش» کند، و بههمریختگیِ عمیقتری درست میکند. عملیات در جهتِ معکوس قابلِ برگشت است، پس درست کردنش یعنی دوبار اعمال کردنش.
انجامش در مرورگر
رمزگشایی رایگان است. TextDecoder استاندارد کدگذاری WHATWG را پیاده میکند:
const bytes = new Uint8Array([0xd8, 0xa7, 0xd9, 0x84]);
new TextDecoder('windows-1252').decode(bytes); // 'ال'
new TextDecoder('windows-1256').decode(bytes); // 'ط§ظ„'
اما قبل از اینکه روی این چیزی بسازید، یک محدودیت هست که باید بدانید. تمام برچسبهای مربوط به عربی را در Node 22 امتحان کردم:
windows-1256 OK
windows-1252 OK
windows-1254 OK
x-mac-arabic The "x-mac-arabic" encoding is not supported
ibm864 The "ibm864" encoding is not supported
سه تا از پنج تا. فهرستِ WHATWG شامل x-mac-cyrillic است اما x-mac-arabic را ندارد، و اصلاً هیچ صفحه کدِ داسی هم ندارد. یعنی دو کدگذاریای که پشت قدیمیترین آرشیوها هستند — همانهایی که از اول بیشتر در معرض بههمریختگیاند — دقیقاً همانهاییاند که TextDecoder به آنها دست نمیزند. برای آنها باید جدولِ ۲۵۶تاییِ خودتان را داشته باشید، که از پایتون (bytes.decode('mac_arabic')) یا از فایلهای نگاشتِ کنسرسیوم یونیکد استخراجش کنید و بهشکل JSON همراهش بفرستید.
بعد میرسیم به جایی که همه در آن زمین میخورند: رفتن به عقب یعنی حرف ← بایت، و مرورگر این کار را برای شما نمیکند.
new TextEncoder().encode('Ø'); // [0xc3, 0x98] ← UTF-8, always
TextEncoder روی UTF-8 هاردکد شده است. چیزی به نام new TextEncoder('windows-1252') وجود ندارد و نخواهد داشت. پس جدول معکوس را خودتان میسازید، که ده خط بیشتر نمیخواهد:
function buildEncoder(label) {
const dec = new TextDecoder(label);
const table = new Map();
for (let b = 0; b < 256; b++) {
const ch = dec.decode(new Uint8Array([b]));
if (ch.length !== 1) continue;
if (ch === '\uFFFD') continue; // undefined slot — several bytes land here
if (!table.has(ch)) table.set(ch, b);
}
return table;
}
محافظِ '\uFFFD' تزیینی نیست. صفحههای کدِ قدیمی روزنه دارند — Windows-1252 مقدارهای 0x81 و 0x8D و 0x8F و 0x90 و 0x9D را تعریفنشده گذاشته است — و رمزگشا همهشان را به U+FFFD نگاشت میکند. اگر این بررسی را حذف کنید، پنج بایتِ متفاوت روی یک حرف در جدول معکوسِ شما جمع میشوند و در مسیرِ برگشت، داده را بدون اینکه خطایی ببینید خراب میکنید.
حالا خودِ تعمیر. یک پاس، با جزئیاتی که بیشتر پیادهسازیها از قلم میاندازند:
function pass(s, label) {
const t = buildEncoder(label);
const bytes = [];
for (const ch of s) {
const cp = ch.codePointAt(0);
if (cp < 0x80) { bytes.push(cp); continue; } // ASCII survived the trip
const b = t.get(ch);
if (b === undefined) return null; // not reversible in this page
bytes.push(b);
}
try {
// fatal:true is the filter most implementations forget. A wrong code page
// usually yields bytes that aren't valid UTF-8 — this rejects them for free.
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes));
} catch { return null; }
}
این fatal: true کار واقعی انجام میدهد. بدون آن، TextDecoder برای دنبالههای نامعتبر U+FFFD میگذارد و رشتهای به شما برمیگرداند که شبیه نتیجه است. با آن، کاندیداهایِ غلط استثناء میاندازند و کنار گذاشته میشوند — یک چیز کمتر که رتبهبندیِ شما بعداً باید از آن عبور کند.
وقتی واقعاً نمیشود درستش کرد
دو حالت قابل بازیابی نیستند، و ابزاری که وانمود کند غیر از این است، از بیفایده هم بدتر است.
نام شامل حروفِ جایگزین است. اگر الص�رة را دیدید — آن لوزی، یا یک ? ساده بهجای یک حرف — یعنی در جایی یک رمزگشا به بایتهایی برخورده که نمیتوانسته نشانشان بدهد و جایگزینشان کرده است. مقدار بایتهای اصلی رفته است. هیچ جدولی آنها را برنمیگرداند.
نام بریده شده است. موجیبیکه طول را حفظ میکند. اگر تبدیل 8.3 یا محدودیتِ فایلسیستم نام را بریده باشد، چیزی برای رمزگشایی وجود ندارد.
گفتنِ «این یکی رفته» بهتر از برگرداندنِ یک حدسِ شبیهبهواقعیت است، چون نامِ غلط روی فایل میچسبد و از آن لحظه به بعد دیگر هیچکس نمیتواند بفهمد که اصلاً روزی غلط بوده است.
انتخابِ کاندیدای درست
قسمتِ دردسرساز اینجاست: چند صفحه کد، موجیبیکهٔ یکسان تولید میکنند. بایتهای عربی که از Windows-1252 و Windows-1254 خوانده شوند یکسان از آب درمیآیند، چون این دو صفحه در محدودهای که بایتهای UTF-8 عربی میافتند مشترکاند. مرتب چند رمزگشاییِ ساختاراً معتبر میگیرید و باید بینشان انتخاب کنید.
بر اساس جایگاه حرفها در یونیکد به آنها امتیاز بدهید. نیازی به واژهنامه نیست:
const BLOCKS = {
arabic: [[0x0600, 0x06ff], [0x0750, 0x077f], [0xfb50, 0xfdff], [0xfe70, 0xfeff]],
latin: [[0x0041, 0x007a], [0x00c0, 0x017f], [0x0100, 0x017f]],
cyrillic: [[0x0400, 0x04ff]],
};
function score(s) {
let counted = 0;
const hits = {};
for (const ch of s) {
const c = ch.codePointAt(0);
if (c < 0x80) continue; // ASCII is neutral: extensions, digits, hyphens
counted++;
for (const [name, ranges] of Object.entries(BLOCKS)) {
if (ranges.some(([lo, hi]) => c >= lo && c <= hi)) hits[name] = (hits[name] || 0) + 1;
}
}
if (!counted) return 0;
return Math.max(0, ...Object.values(hits)) / counted;
}
ASCII را از مخرج بیرون نگه دارید. این را در دور اول غلط انجام دادم: حساب کردنِ .jpg و -01 بهعنوان حرف باعث میشود هر اسمی مثل تقرير نهائي.pdf در حدود ۰٫۷۷ متوقف شود، پس هیچوقت نمیتوانید یک آستانهٔ تمیز داشته باشید. پسوندها و ارقام از نظر زبانی خنثیاند — آنها را حذف کنید تا نتایج واقعی روی ۱٫۰ بنشینند.
این دلیلِ دیگری هم هست که چرا «فقط رایجترین کدگذاری را امتحان کن» جواب نمیدهد. اینکه کدام صفحه درست است به زبانِ درونِ نام بستگی دارد، که دقیقاً همان چیزی است که یک لحظه پیش نمیتوانستید بخوانید.
کلِ چیز، آمادهٔ پیست کردن
سرهمشده، با کش و تا سه پاس برای نامهای دوبار و سهبار کدگذاریشده. آن را در کنسول DevTools پیست کنید و fix('الصورة') را صدا بزنید:
const fix = (() => {
const LABELS = ['windows-1256', 'windows-1252', 'windows-1254'];
const cache = new Map();
function encoder(label) {
if (!cache.has(label)) {
const dec = new TextDecoder(label);
const t = new Map();
for (let b = 0; b < 256; b++) {
const ch = dec.decode(new Uint8Array([b]));
if (ch.length === 1 && ch !== '\uFFFD' && !t.has(ch)) t.set(ch, b);
}
cache.set(label, t);
}
return cache.get(label);
}
function pass(s, label) {
const t = encoder(label);
const bytes = [];
for (const ch of s) {
const cp = ch.codePointAt(0);
if (cp < 0x80) { bytes.push(cp); continue; }
const b = t.get(ch);
if (b === undefined) return null;
bytes.push(b);
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes));
} catch { return null; }
}
const BLOCKS = {
arabic: [[0x0600, 0x06ff], [0x0750, 0x077f], [0xfb50, 0xfdff], [0xfe70, 0xfeff]],
latin: [[0x0041, 0x007a], [0x00c0, 0x017f], [0x0100, 0x017f]],
cyrillic: [[0x0400, 0x04ff]],
};
function score(s) {
let counted = 0;
const hits = {};
for (const ch of s) {
const c = ch.codePointAt(0);
if (c < 0x80) continue;
counted++;
for (const [name, ranges] of Object.entries(BLOCKS)) {
if (ranges.some(([lo, hi]) => c >= lo && c <= hi)) hits[name] = (hits[name] || 0) + 1;
}
}
if (!counted) return 0;
return +(Math.max(0, ...Object.values(hits)) / counted).toFixed(3);
}
return function fix(mangled, maxPasses = 3) {
const seen = new Map();
seen.set(mangled, { via: 'unchanged', score: score(mangled) });
for (const label of LABELS) {
let cur = mangled;
for (let i = 1; i <= maxPasses; i++) {
const next = pass(cur, label);
if (next === null || next === cur) break;
cur = next;
if (!seen.has(cur)) seen.set(cur, { via: `${label} x${i}`, score: score(cur) });
}
}
return [...seen.entries()]
.map(([text, m]) => ({ text, via: m.via, score: m.score }))
.sort((a, b) => b.score - a.score);
};
})();
fix('الصورة') — حالتِ دوبار کدگذاریشده — برمیگرداند:
┌─────────┬─────────────────────────────┬───────────────────┬───────┐
│ (index) │ text │ via │ score │
├─────────┼─────────────────────────────┼───────────────────┼───────┤
│ 0 │ 'الصورة' │ 'windows-1252 x2' │ 1 │
│ 1 │ 'الصورة' │ 'unchanged' │ 0.52 │
│ 2 │ 'الصورة' │ 'windows-1252 x1' │ 0.5 │
└─────────┴─────────────────────────────┴───────────────────┴───────┘
و نامی که از قبل U+FFFD دارد، چیزِ بهدردبخوری برنمیگرداند — نتیجهٔ صادقانه همین است:
┌─────────┬───────────────┬─────────────┬───────┐
│ (index) │ text │ via │ score │
├─────────┼───────────────┼─────────────┼───────┤
│ 0 │ 'الص�رة' │ 'unchanged' │ 0.455 │
└─────────┴───────────────┴─────────────┴───────┘
دو نکتهٔ عملیاتی اگر این را عرضه میکنید. اول: جدولهای معکوس را کش کنید — ساختنِ دوبارهٔ سه نقشهٔ ۲۵۶تایی برای هر نام فایل، وقتی چهارصد تا دارید، اسراف است. دوم: همیشه ردیفِ unchanged را نگه دارید؛ نامی که از قبل درست بوده چیزی برای معکوس کردن ندارد، و برگرداندنِ نتیجهٔ خالی یک ابزارِ سالم را خراب نشان میدهد.
اصلاً چرا به خودمان زحمت بدهیم
اگر کاربرانتان در حوزهٔ خلیج، ایران یا ترکیه باشند، این یک مورد حاشیهای نیست. هر بار که یک آرشیو از یک دستگاه ویندوز رد میشود، هر بار که چیزی قدیمی به یک نام فایلِ مدرن دست میزند، هر بار که نامی از یک انتقال جانِ سالم به در میبرد اما اعلامیهٔ کدگذاریاش نه — این اتفاق میافتد. و خرابیاش بیصداست: هیچکس برای فایلی که فقط اسمش زشت است، باگ ثبت نمیکند. اسمش را عوض میکنند، یا با همان کنار میآیند.
تعمیر قطعی است، چند میلیثانیه طول میکشد، و کاملاً روی دستگاهِ کاربر اجرا میشود. خواندنِ نام فایل با خواندنِ خودِ فایل یکی نیست، و بیشترِ مردم ترجیح میدهند شما فایل را نخوانید.
این متن چطور نوشته شد
شفافسازی دربارهٔ نقش هوش مصنوعی: پیشنویسِ این نوشته را یک عامل هوش مصنوعی نوشته است، بر اساس مسئلهای که خودم انتخاب کردم و زیر یک قانون که روی آن پافشاری داشتم — هیچ چیزی وارد متن نمیشود مگر اینکه اجرا شده باشد. هر قطعه کدِ بالا اجرا شده، و جدولها از خروجی واقعی پیست شدهاند نه از حافظه.
این قانون بیشترِ کار را انجام داد. دو ادعایی که با آنها شروع کردم، در اولین برخورد بیسر و صدا شکست خوردند:
TextDecoderدر Node 22 رویx-mac-arabicوibm864استثناء میاندازد. فکر میکردم کار میکنند، چون فهرستِ کدگذاریهای WHATWG جامع به نظر میرسد — اما نیست.x-mac-cyrillicرا دارد و هیچ چیزِ دیگری از خانوادهٔ Mac Arabic را، و اصلاً هیچ صفحه کدِ داسی.- نسخهٔ اولِ قطعه کد، برای نام فایلی که اصلاً خراب نبود، یک جدول خالی برمیگرداند. یک ابزارِ درست، در حالِ گزارشِ شکست، برای رایجترین ورودیای که هرگز میدید.
هیچکدام اگر فقط در حدِ توصیف میماندند زنده نمیماندند. هر دو از دلِ اجرای کد بیرون افتادند. و این همان کلِ استدلال برای اجرا کردنِ مثالهایتان است — و برای اینکه «مدل این را میگوید» را یک فرضیه ببینید، حتی وقتی خودِ مدل است که پستِ وبلاگِ شما را مینویسد.