- لماذا لا تصلح جداول الرموز (code pages) للعربية في أغلب الطابعات
- رسم الإيصال على canvas بعرض الورق الصحيح بالنقاط
- تحويل الصورة إلى أوامر GS v 0 مقسومة إلى شرائح
- الإرسال إلى طابعة شبكية على المنفذ 9100، وما تفعله على ويندوز وأندرويد
لماذا تخرج العربية مقطّعة؟
الطابعة الحرارية في وضع النص لا تعرف «حروفاً»، بل جدول رموز (code page) يحوّل كل بايت إلى شكل محفوظ في ذاكرتها. والعربية تحتاج أمرين لا يقدّمهما هذا الجدول في أغلب الطابعات: تشكيل الحروف، أي اختيار شكل الحرف حسب موقعه في الكلمة، واتجاه النص من اليمين إلى اليسار مع أرقام تُقرأ من اليسار.
بعض الطابعات تدعم جدولاً عربياً مثل CP864 أو Windows-1256، لكن الدعم يختلف من طراز لآخر، بل من نسخة برنامج داخلي (firmware) لأخرى في الطراز نفسه. وحين تبني نظاماً لمطاعم تشتري طابعاتها من السوق، لن تعرف مسبقاً أي طابعة ستقابل.
الحل: اطبع صورة لا نصاً
المتصفح ونظام التشغيل يجيدان رسم العربية: التشكيل والاتجاه والخطوط كلها محلولة هناك. فنرسم الإيصال على canvas كما نريده بالضبط، ثم نحوّل الصورة إلى نقاط سوداء وبيضاء، ونرسلها بأمر الطباعة النقطية GS v 0 الذي تفهمه الطابعات المتوافقة مع ESC/POS. والنتيجة واحدة على كل الطرازات، لأن الطابعة لم تعد تعرف أن ما تطبعه عربي.
أول ما تحتاجه هو عرض الورق بالنقاط. الطابعات الشائعة بدقة 203 نقطة في البوصة: ورق 80 مم يعطي عادةً 576 نقطة، وورق 58 مم يعطي 384 نقطة. وبعض طرازات 80 مم تطبع 512 نقطة فقط، فتأكد من صفحة الاختبار الذاتي للطابعة قبل أن تثبّت الرقم.
الخطوة الأولى: ارسم الإيصال على canvas
نصف الإيصال قائمةَ أسطر، ونحسب ارتفاع الصورة منها قبل الرسم، فلا يُقطع إيصال طويل. الاسم يُرسم من اليمين، والمبلغ في عمود مستقل على اليسار، وبهذا تتجنب اختلاط الأرقام بالنص العربي في سطر واحد.
type Line =
| { kind: 'center'; text: string; font: string }
| { kind: 'row'; right: string; left: string; font: string }
| { kind: 'rule' };
const LINE_H = 34; // ارتفاع السطر بالنقاط
const PAD = 12; // الهامش الداخلي
export async function renderReceipt(lines: Line[], widthDots: 384 | 576) {
await document.fonts.ready; // لا ترسم قبل تحميل الخط
const canvas = document.createElement('canvas');
canvas.width = widthDots; // يجب أن يقبل القسمة على ثمانية
canvas.height = PAD * 2 + lines.length * LINE_H;
const ctx = canvas.getContext('2d')!;
// خلفية بيضاء صريحة: البكسل الشفاف يتحول إلى أسود عند التحويل
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.direction = 'rtl';
ctx.textBaseline = 'middle';
let y = PAD + LINE_H / 2;
for (const l of lines) {
if (l.kind === 'rule') {
ctx.fillRect(PAD, y, widthDots - PAD * 2, 2);
} else if (l.kind === 'center') {
ctx.font = l.font;
ctx.textAlign = 'center';
ctx.fillText(l.text, widthDots / 2, y);
} else {
ctx.font = l.font;
ctx.textAlign = 'right'; // الاسم يبدأ من اليمين
ctx.fillText(l.right, widthDots - PAD, y);
ctx.textAlign = 'left'; // المبلغ في عمود مستقل على اليسار
ctx.fillText(l.left, PAD, y);
}
y += LINE_H;
}
return canvas;
}
// مثال
const canvas = await renderReceipt([
{ kind: 'center', text: 'مطعم الشاطئ', font: 'bold 32px Cairo' },
{ kind: 'row', right: 'رقم الطلب', left: '1042', font: '26px Cairo' },
{ kind: 'rule' },
{ kind: 'row', right: 'كباب لحم ×2', left: '24,000', font: '26px Cairo' },
{ kind: 'row', right: 'بيبسي ×2', left: '2,000', font: '26px Cairo' },
{ kind: 'rule' },
{ kind: 'row', right: 'الإجمالي', left: '26,000 د.ع', font: 'bold 30px Cairo' },
], 576);سطران في هذا الكود يوفّران عليك ساعات. الأول document.fonts.ready: الرسم قبل تحميل الخط يطبع بخط بديل أو بمربعات. والثاني الخلفية البيضاء الصريحة: canvas شفاف افتراضياً، والبكسل الشفاف قيمه (0, 0, 0, 0)، فيُقرأ أسود عند التحويل وتخرج لك ورقة سوداء كاملة.
الخطوة الثانية: من الصورة إلى أوامر GS v 0
كل 8 نقاط أفقية تصبح بايتاً واحداً، والبت الأعلى هو النقطة الأولى من اليسار. نحسب إضاءة كل بكسل، وما كان أغمق من الحد (160 هنا) يصبح نقطة سوداء. وبدل إرسال الإيصال صورةً واحدة نقسمه إلى شرائح لا تتجاوز 255 سطراً للأمر الواحد، لأن بعض الطابعات تضيق ذاكرتها بالصورة الطويلة فتطبع نصفها.
// تحويل الصورة إلى أوامر طباعة نقطية مقسومة إلى شرائح
export function canvasToEscpos(canvas: HTMLCanvasElement, threshold = 160): Uint8Array {
const { width: w, height: h } = canvas;
const px = canvas.getContext('2d')!.getImageData(0, 0, w, h).data;
const bytesPerRow = Math.ceil(w / 8);
const BAND = 255; // أقصى عدد أسطر في الأمر الواحد
const out: number[] = [0x1b, 0x40]; // ESC @ تهيئة الطابعة
for (let top = 0; top < h; top += BAND) {
const rows = Math.min(BAND, h - top);
// GS v 0 m xL xH yL yH
out.push(0x1d, 0x76, 0x30, 0x00,
bytesPerRow & 0xff, bytesPerRow >> 8, rows & 0xff, rows >> 8);
for (let y = top; y < top + rows; y++) {
for (let xb = 0; xb < bytesPerRow; xb++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const x = xb * 8 + bit;
if (x >= w) break;
const i = (y * w + x) * 4;
const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
if (lum < threshold) byte |= 0x80 >> bit; // نقطة سوداء
}
out.push(byte);
}
}
}
out.push(0x1b, 0x64, 0x04); // ESC d 4 تقديم الورق
out.push(0x1d, 0x56, 0x42, 0x00); // GS V 66 0 قص جزئي
return Uint8Array.from(out);
}
الخطوة الثالثة: أرسل البايتات إلى الطابعة
أغلب الطابعات الشبكية تستقبل البيانات الخام على المنفذ 9100. يكفي أن تفتح اتصال TCP وترسل البايتات وتغلقه:
import net from 'node:net';
// إرسال البايتات خاماً إلى طابعة شبكية عبر المنفذ ٩١٠٠
export function printRaw(host, bytes, { port = 9100, timeoutMs = 5000 } = {}) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port });
socket.setTimeout(timeoutMs);
socket.once('connect', () => socket.end(Buffer.from(bytes)));
socket.once('timeout', () => socket.destroy(new Error('printer timeout')));
socket.once('error', reject);
socket.once('close', (hadError) => { if (!hadError) resolve(); });
});
}
// await printRaw('192.168.1.50', bytes);المتصفح لا يفتح اتصال TCP مباشرة، فالإرسال يحتاج طرفاً وسيطاً: تطبيق سطح مكتب (Tauri أو Electron)، أو خادماً صغيراً داخل شبكة المطعم، أو إضافة أصلية في تطبيق Android للطابعات بالبلوتوث أو USB. وعلى Windows مع طابعة USB أرسل البايتات خاماً (RAW) إلى مخزن الطباعة، لا عبر نافذة الطباعة العادية التي تعيد تحجيم الصورة.
أخطاء رأيناها في الواقع
- ورقة سوداء: نسيت الخلفية البيضاء.
- حروف باهتة أو متقطعة: خط رفيع. استعمل خطاً عريضاً بحجم 24 نقطة فأكثر، وجرّب حدّاً بين 140 و180.
- الإيصال مقصوص من الطرف: عرض الصورة أكبر من عرض رأس الطباعة.
- تتوقف في منتصف إيصال طويل: أرسلت صورة واحدة كبيرة. قسّمها شرائح.
- الطابعة لا ترد صباحاً: تغيّر عنوانها بعد انقطاع الكهرباء. ثبّت عنوانها في إعدادات الراوتر.
وطريقة الصورة لها ثمن: الإيصال يصبح عشرات الكيلوبايتات بدل مئات البايتات، وهذا لا يكاد يُلاحظ على شبكة محلية. في المقابل تحصل على عربية صحيحة وشعار وأي تصميم تريده، على أي طابعة.