تفتح ملف 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، وبلا أي صفحة رموز من عصر DOS إطلاقاً. أي أن الترميزين اللذين يقفان وراء أقدم الأرشيفات — وهي الأكثر عرضة للتلف من الأساس — هما بالضبط ما لن يلمسه TextDecoder. في هذه الحالات تحتاج جدولاً خاصاً بك من 256 مدخلاً، تستخرجه من بايثون (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 يتوقف عند 0.77 تقريباً، فلا تستطيع استخدام عتبة نظيفة أبداً. الامتدادات والأرقام محايدة لغوياً — استبعدها فتصل النتائج الحقيقية إلى 1.0.
ولهذا أيضاً لا يجدي نطاق «فحسب جرّب الترميز الأكثر شيوعاً». أي جدول هو الصحيح يعتمد على اللغة داخل الاسم، وهي تحديداً ما لم تكن قادراً على قراءته قبل لحظة.
الكود كاملاً، جاهزاً للصق
مجمّعاً، مع تخزين مؤقت وما يصل إلى ثلاث تمريرات للأسماء مزدوجة وثلاثية الترميز. الصقه في وحدة تحكم 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 │
└─────────┴───────────────┴─────────────┴───────┘
ملاحظتان للإنتاج إن شحّنت هذا. أولاً: خزّن الجداول العكسية مؤقتاً — إعادة بناء ثلاثة جداول من 256 مدخلاً لكل اسم ملف هدرٌ حين يكون لديك أربعمئة اسم. ثانياً: احتفظ دائماً بصف unchanged، فالاسم السليم أصلاً لا شيء ليعكسه، وإرجاع نتيجة فارغة يجعل أداة تعمل تبدو معطوبة.
ولماذا نهتم أصلاً
إن كان مستخدموك في الخليج أو إيران أو تركيا فليست هذه حالة هامشية. تحدث كلما عبر أرشيف جهاز ويندوز، وكلما لمس شيء قديم اسماً حديثاً، وكلما نجا الاسم من عملية نقل ولم ينجُ إعلان ترميزه. والفشل هنا صامت: لا أحد يفتح بلاغاً عن ملف اسمه قبيح فحسب. يعيدون تسميته، أو يتعايشون معه.
الإصلاح حتمي، يستغرق أجزاء من الثانية، ويعمل بالكامل على جهاز المستخدم. قراءة اسم الملف ليست كقراءة الملف نفسه، ومعظم الناس يفضّلون ألا تقرأ الملف.
كيف كُتب هذا المقال
أنشر هذا وفق سياسة DEV للذكاء الاصطناعي، وهذه هي الرواية الصادقة: كتب المسودة وكيل ذكاء اصطناعي، انطلاقاً من مشكلة اخترتها أنا، وتحت قاعدة واحدة أصررت عليها — لا يدخل المقال شيء قبل تشغيله. كل مقطع برمجي في الأعلى نُفّذ فعلاً، والجداول ملصوقة من مخرجات حقيقية لا من الذاكرة.
هذه القاعدة أنجزت معظم العمل. ادّعاءان بدأت بهما سقطا بهدوء عند أول اختبار:
-
TextDecoderفي Node 22 يرمي استثناءً معx-mac-arabicوibm864. افترضت أنهما سيعملان، لأن قائمة ترميزات WHATWG تبدو شاملة — وليست كذلك. فهي تحملx-mac-cyrillicولا شيء غيره من عائلة Mac Arabic، ولا أي صفحة رموز من DOS إطلاقاً. - أعادت النسخة الأولى من المقطع البرمجي جدولاً فارغاً لاسم ملف لم يكن معطوباً أصلاً. أداة صحيحة، تُبلّغ عن فشل، أمام أكثر مُدخل ستواجهه على الإطلاق.
لا شيء منهما كان لينجو لو بقي مجرد وصف. كلاهما سقط من تشغيل الكود. وهذه هي الحجة كاملة لتشغيل أمثلتك — ولاعتبار عبارة «النموذج يقول ذلك» فرضية، حتى حين يكون النموذج نفسه هو من يكتب مقالك.
Top comments (0)