/** @odoo-module **/ /** * ESC/POS Command Generator * * Generates ESC/POS command sequences for thermal printers. * Supports text formatting, alignment, sizing, and receipt generation. * * Column width: 32 chars (58 mm paper) — standard for small Bluetooth thermal printers. */ // ESC/POS Command Constants const ESC = 0x1B; const GS = 0x1D; const LF = 0x0A; // Initialization const INIT = [ESC, 0x40]; // Alignment commands const ALIGN_LEFT = [ESC, 0x61, 0x00]; const ALIGN_CENTER = [ESC, 0x61, 0x01]; const ALIGN_RIGHT = [ESC, 0x61, 0x02]; // Text emphasis const BOLD_ON = [ESC, 0x45, 0x01]; const BOLD_OFF = [ESC, 0x45, 0x00]; const UNDERLINE_ON = [ESC, 0x2D, 0x01]; const UNDERLINE_OFF= [ESC, 0x2D, 0x00]; // Paper control const FEED_LINE = [LF]; const CUT_PAPER = [GS, 0x56, 0x00]; export class EscPosGenerator { constructor() { this.commands = []; this.characterSet = 'CP437'; // Default character set this.colWidth = 32; // 58 mm paper = 32 chars @ 12 cpi } // ─── Low-level ESC/POS helpers ────────────────────────────────────────── initialize() { return new Uint8Array(INIT); } setAlignment(alignment) { switch ((alignment || '').toLowerCase()) { case 'center': return new Uint8Array(ALIGN_CENTER); case 'right': return new Uint8Array(ALIGN_RIGHT); default: return new Uint8Array(ALIGN_LEFT); } } setTextSize(width = 1, height = 1) { width = Math.max(1, Math.min(8, width)); height = Math.max(1, Math.min(8, height)); const sizeValue = ((width - 1) << 4) | (height - 1); return new Uint8Array([GS, 0x21, sizeValue]); } setEmphasis(bold = false, underline = false) { const cmds = []; cmds.push(...(bold ? BOLD_ON : BOLD_OFF)); cmds.push(...(underline ? UNDERLINE_ON : UNDERLINE_OFF)); return new Uint8Array(cmds); } feedAndCut(lines = 3) { const cmds = []; for (let i = 0; i < lines; i++) cmds.push(...FEED_LINE); cmds.push(...CUT_PAPER); cmds.push(...INIT); // Force printer buffer flush and reset return new Uint8Array(cmds); } /** * Encode text to bytes using the configured character set. */ encodeText(text) { if (!text) return new Uint8Array(0); const result = new Uint8Array(text.length); const charSet = (this.characterSet || 'CP437').toUpperCase(); const cp437Map = { 'ü':0x81,'é':0x82,'â':0x83,'ä':0x84,'à':0x85,'å':0x86,'ç':0x87,'ê':0x88, 'ë':0x89,'è':0x8A,'ï':0x8B,'î':0x8C,'ì':0x8D,'Ä':0x8E,'Å':0x8F,'É':0x90, 'æ':0x91,'Æ':0x92,'ô':0x93,'ö':0x94,'ò':0x95,'û':0x96,'ù':0x97,'ÿ':0x98, 'Ö':0x99,'Ü':0x9A,'¢':0x9B,'£':0x9C,'¥':0x9D,'á':0xA0,'í':0xA1,'ó':0xA2, 'ú':0xA3,'ñ':0xA4,'Ñ':0xA5,'ß':0xE1,'µ':0xE6,'±':0xF1,'°':0xF8,'€':0xEE, }; const cp850Map = { ...cp437Map, 'ø':0x9B,'Ø':0x9D,'×':0x9E,'®':0xA9,'€':0xD5, }; const activeMap = (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852') ? cp850Map : cp437Map; for (let i = 0; i < text.length; i++) { const ch = text[i]; const code = ch.charCodeAt(0); if (code <= 127) { result[i] = code; } else if (activeMap[ch] !== undefined) { result[i] = activeMap[ch]; } else { const norm = ch.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); result[i] = (norm.length > 0 && norm.charCodeAt(0) <= 127) ? norm.charCodeAt(0) : 0x3F; // '?' } } return result; } /** * Emit a single line of text (with optional formatting) followed by LF. */ addLine(text, options = {}) { const cmds = []; if (options.align) cmds.push(...this.setAlignment(options.align)); if (options.width || options.height) cmds.push(...this.setTextSize(options.width || 1, options.height || 1)); if (options.bold !== undefined || options.underline !== undefined) cmds.push(...this.setEmphasis(options.bold || false, options.underline || false)); cmds.push(...this.encodeText(text)); cmds.push(...FEED_LINE); // Reset formatting after every line cmds.push(...this.setTextSize(1, 1)); cmds.push(...this.setEmphasis(false, false)); return new Uint8Array(cmds); } // ─── Number formatting ─────────────────────────────────────────────────── /** * Format a raw number for thermal receipt printing. * * Rules: * - Use thousands separators (dot for ID locale style) * - Strip trailing ".00" — currencies like IDR have 0 decimal places * and a value like 29970000.00 should print as "29.970.000" * - Keep up to 2 decimal places if non-zero * * @param {number} value * @returns {string} */ formatAmount(value) { if (typeof value !== 'number' || isNaN(value)) return '0'; const rounded = Math.round(value * 100) / 100; // Detect if the value is a whole number (IDR-style) const isWhole = (rounded % 1 === 0); if (isWhole) { // Format as integer with thousands dots: 29970000 → "29.970.000" return rounded.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.'); } // Format with 2 decimals and thousands dots const [intPart, decPart] = rounded.toFixed(2).split('.'); const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); return `${formattedInt},${decPart}`; } /** * Build a right-justified two-column line (label left, value right). * Total width = this.colWidth. * * @param {string} label * @param {string} value * @returns {string} */ twoCol(label, value) { const w = this.colWidth; const v = String(value); const l = String(label); if (l.length + v.length >= w) { // Not enough room — put value on next implicit line via truncation return l.substring(0, w - v.length - 1).padEnd(w - v.length) + v; } return l + v.padStart(w - l.length); } /** * Divider line (full width). */ divider() { return ''.padEnd(this.colWidth, '-'); } // ─── Main receipt generator ────────────────────────────────────────────── /** * Generate complete receipt from structured data. * * Expected receiptData shape: * { * headerData: { companyName, address, phone, taxId } * orderData: { orderName, date, cashier, customer } * lines: [{ productName, quantity, price, total }, ...] * totals: { subtotal, tax, discount, total } * paymentData: { method, amount, change } * footerData: { message, barcode } * } */ generateReceipt(receiptData) { const cmds = []; const W = this.colWidth; // ── Initialize ──────────────────────────────────────────────────── cmds.push(...this.initialize()); // ── Header ──────────────────────────────────────────────────────── // On basic receipt (table checker): skip company header, print only minimal info if (!receiptData.isBasicReceipt && receiptData.headerData) { const h = receiptData.headerData; if (h.companyName) { cmds.push(...this.addLine(h.companyName, { align: 'center', bold: true, width: 2, height: 2 })); } if (h.address) { cmds.push(...this.addLine(h.address, { align: 'center' })); } if (h.phone) { cmds.push(...this.addLine(h.phone, { align: 'center' })); } if (h.taxId) { cmds.push(...this.addLine(`NPWP: ${h.taxId}`, { align: 'center' })); } cmds.push(...this.addLine(this.divider())); } // ── Order info ──────────────────────────────────────────────────── if (receiptData.orderData) { const o = receiptData.orderData; if (receiptData.isBasicReceipt) { // Basic receipt (table checker): all info at double height for readability if (o.orderName) cmds.push(...this.addLine(`Order: ${o.orderName}`, { height: 2, bold: true })); if (o.date) cmds.push(...this.addLine(o.date, { height: 2 })); if (o.cashier) cmds.push(...this.addLine(`By: ${o.cashier}`, { height: 2 })); if (o.tableName) cmds.push(...this.addLine(o.tableName, { bold: true, height: 2, align: 'center' })); if (o.customer) cmds.push(...this.addLine(`Cust: ${o.customer}`, { height: 2 })); } else { if (o.orderName) cmds.push(...this.addLine(`Order: ${o.orderName}`)); if (o.date) cmds.push(...this.addLine(`Date: ${o.date}`)); if (o.cashier) cmds.push(...this.addLine(`Cashier: ${o.cashier}`)); if (o.tableName) cmds.push(...this.addLine(o.tableName, { bold: true, align: 'center' })); if (o.customer) cmds.push(...this.addLine(`Customer: ${o.customer}`)); } cmds.push(...this.addLine(this.divider())); } // ── Line items ──────────────────────────────────────────────────── if (receiptData.lines && receiptData.lines.length > 0) { receiptData.lines.forEach(line => { const name = String(line.productName || ''); const qty = line.quantity || 0; const qtyStr = qty % 1 === 0 ? qty.toFixed(0) : qty.toFixed(2); if (receiptData.isBasicReceipt) { // ── Basic receipt / table checker ────────────────────────── // Show only qty + product name (no price/total), at double-width // double-height for maximum readability on 58mm paper. // At width=2, each char is 2x wide, so effective cols = W/2 = 16 const halfW = Math.floor(W / 2); const qtyLabel = qtyStr.padEnd(2) + ' '; const nameMaxLen = halfW - qtyLabel.length; const displayName = name.length > nameMaxLen ? name.substring(0, nameMaxLen - 1) + '.' : name; cmds.push(...this.addLine(qtyLabel + displayName, { bold: true, width: 2, height: 2 })); } else { // ── Full receipt ─────────────────────────────────────────── // Line 1: product name const displayName = name.length > W ? name.substring(0, W - 1) + '.' : name; cmds.push(...this.addLine(displayName, { bold: true, height: 2 })); // Line 2: qty x unitPrice = total (right-aligned) const priceStr = this.formatAmount(line.price || 0); const totalStr = this.formatAmount(line.total || 0); const middle = `${qtyStr}x ${priceStr}`; let itemLine = middle + totalStr.padStart(W - middle.length); if (itemLine.length > W) itemLine = totalStr.padStart(W); cmds.push(...this.addLine(itemLine, { bold: true, height: 2 })); } // Combo sub-lines detail printing (shown on both basic/checker and full receipt modes) if (line.comboLines && line.comboLines.length > 0) { line.comboLines.forEach(sub => { const subName = String(sub.productName || ''); const subQty = sub.quantity || 0; const subQtyStr = subQty % 1 === 0 ? subQty.toFixed(0) : subQty.toFixed(2); if (receiptData.isBasicReceipt) { // Double-height sub-lines on basic receipt const halfW = Math.floor(W / 2); const prefix = ` ${subQtyStr}x `; const maxSubLen = halfW - prefix.length; const displaySub = subName.length > maxSubLen ? subName.substring(0, maxSubLen - 1) + '.' : subName; cmds.push(...this.addLine(prefix + displaySub, { height: 2 })); } else { // Format: " - 1x Mie Goreng" const prefix = ` - ${subQtyStr}x `; const maxSubLen = W - prefix.length; const displaySub = subName.length > maxSubLen ? subName.substring(0, maxSubLen - 1) + '.' : subName; cmds.push(...this.addLine(prefix + displaySub, { bold: true })); } }); } // Note line (customer note / kitchen note) — word-wrapped to prevent truncation if (line.note) { const rawNote = String(line.note); const rawLines = rawNote.split('\n'); const wrappedLines = []; const maxNoteWidth = W - 2; rawLines.forEach(rawLine => { const words = rawLine.split(/\s+/); let currentLine = ''; for (const word of words) { if (!word) continue; if ((currentLine + (currentLine ? ' ' : '') + word).length <= maxNoteWidth) { currentLine += (currentLine ? ' ' : '') + word; } else { if (currentLine) { wrappedLines.push(currentLine); } let tempWord = word; while (tempWord.length > maxNoteWidth) { wrappedLines.push(tempWord.substring(0, maxNoteWidth)); tempWord = tempWord.substring(maxNoteWidth); } currentLine = tempWord; } } if (currentLine) { wrappedLines.push(currentLine); } }); wrappedLines.forEach((l, i) => { const prefix = i === 0 ? '* ' : ' '; if (receiptData.isBasicReceipt) { cmds.push(...this.addLine(prefix + l, { bold: true, height: 2 })); } else { cmds.push(...this.addLine(prefix + l, { bold: true })); } }); } }); cmds.push(...this.addLine(this.divider())); } // ── Totals ──────────────────────────────────────────────────────── // Hidden on basic receipt — matches t-if="!props.basic_receipt" in order_receipt.xml if (!receiptData.isBasicReceipt && receiptData.totals) { const t = receiptData.totals; if (t.subtotal !== undefined) { cmds.push(...this.addLine(this.twoCol('Subtotal:', this.formatAmount(t.subtotal)))); } if (t.discount !== undefined && t.discount > 0) { cmds.push(...this.addLine(this.twoCol('Discount:', this.formatAmount(t.discount)))); } if (t.tax !== undefined && t.tax !== 0) { cmds.push(...this.addLine(this.twoCol('Tax:', this.formatAmount(t.tax)))); } if (t.total !== undefined) { // Double-width/height for the grand total line const totalLabel = 'TOTAL:'; const totalValue = this.formatAmount(t.total); // At 2x width the printer uses half the chars per line (W/2 = 16) const halfW = Math.floor(W / 2); const totalLine = totalLabel + totalValue.padStart(halfW - totalLabel.length); cmds.push(...this.addLine(totalLine, { bold: true, width: 2, height: 2 })); } cmds.push(...this.addLine(this.divider())); } // ── Payment ─────────────────────────────────────────────────────── // Hidden on basic receipt — matches t-if="!props.basic_receipt" in order_receipt.xml if (!receiptData.isBasicReceipt && receiptData.paymentData) { const p = receiptData.paymentData; if (p.method) { cmds.push(...this.addLine(`Payment: ${p.method}`)); } if (p.amount !== undefined) { cmds.push(...this.addLine(this.twoCol('Paid:', this.formatAmount(p.amount)))); } if (p.change !== undefined && p.change > 0) { cmds.push(...this.addLine(this.twoCol('Change:', this.formatAmount(p.change)), { bold: true })); } cmds.push(...this.addLine(this.divider())); } // ── Footer ──────────────────────────────────────────────────────── // Skip footer on basic receipt (table checker) — matches pos_custom_receipt behaviour if (!receiptData.isBasicReceipt && receiptData.footerData) { const f = receiptData.footerData; if (f.message) { cmds.push(...this.addLine(f.message, { align: 'center' })); } if (f.barcode) { cmds.push(...this.addLine(f.barcode, { align: 'center' })); } } // Feed and cut (set feed to 4 lines to ensure the footer clears the tear bar without excess blank space) cmds.push(...this.feedAndCut(4)); return new Uint8Array(cmds); } /** * Helper: combine multiple Uint8Arrays into one. */ static combineArrays(arrays) { const total = arrays.reduce((s, a) => s + a.length, 0); const result = new Uint8Array(total); let offset = 0; for (const arr of arrays) { result.set(arr, offset); offset += arr.length; } return result; } } export default EscPosGenerator;