refactor: include state code in receipt address and optimize ESC/POS generator codebase

This commit is contained in:
Suherdy Yacob 2026-05-29 10:36:44 +07:00
parent 458404ba7a
commit d3a3633359
2 changed files with 232 additions and 302 deletions

View File

@ -5,13 +5,14 @@
*
* 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;
const CR = 0x0D;
// Initialization
const INIT = [ESC, 0x40];
@ -25,387 +26,309 @@ const ALIGN_RIGHT = [ESC, 0x61, 0x02];
const BOLD_ON = [ESC, 0x45, 0x01];
const BOLD_OFF = [ESC, 0x45, 0x00];
const UNDERLINE_ON = [ESC, 0x2D, 0x01];
const UNDERLINE_OFF = [ESC, 0x2D, 0x00];
const UNDERLINE_OFF= [ESC, 0x2D, 0x00];
// Paper control
const FEED_LINE = [LF];
const CUT_PAPER = [GS, 0x56, 0x00];
// Character sets
const CHARSET_USA = [ESC, 0x52, 0x00];
const CHARSET_FRANCE = [ESC, 0x52, 0x01];
const CHARSET_GERMANY = [ESC, 0x52, 0x02];
const CHARSET_UK = [ESC, 0x52, 0x03];
const CHARSET_DENMARK = [ESC, 0x52, 0x04];
const CHARSET_SWEDEN = [ESC, 0x52, 0x05];
const CHARSET_ITALY = [ESC, 0x52, 0x06];
const CHARSET_SPAIN = [ESC, 0x52, 0x07];
export class EscPosGenerator {
constructor() {
this.commands = [];
this.characterSet = 'CP437'; // Default character set
this.colWidth = 32; // 58 mm paper = 32 chars @ 12 cpi
}
/**
* Initialize printer with default settings
* @returns {Uint8Array} Initialization command sequence
*/
initialize() {
return new Uint8Array(INIT);
}
// ─── Low-level ESC/POS helpers ──────────────────────────────────────────
initialize() { return new Uint8Array(INIT); }
/**
* Set text alignment
* @param {string} alignment - 'left', 'center', or 'right'
* @returns {Uint8Array} Alignment command sequence
*/
setAlignment(alignment) {
switch (alignment.toLowerCase()) {
case 'left':
return new Uint8Array(ALIGN_LEFT);
case 'center':
return new Uint8Array(ALIGN_CENTER);
case 'right':
return new Uint8Array(ALIGN_RIGHT);
default:
return new Uint8Array(ALIGN_LEFT);
switch ((alignment || '').toLowerCase()) {
case 'center': return new Uint8Array(ALIGN_CENTER);
case 'right': return new Uint8Array(ALIGN_RIGHT);
default: return new Uint8Array(ALIGN_LEFT);
}
}
/**
* Set text size
* @param {number} width - Width multiplier (1-8)
* @param {number} height - Height multiplier (1-8)
* @returns {Uint8Array} Text size command sequence
*/
setTextSize(width = 1, height = 1) {
// Validate input ranges
width = Math.max(1, Math.min(8, width));
height = Math.max(1, Math.min(8, height));
// ESC/POS uses 0-7 for size (0 = normal, 7 = 8x)
const widthValue = width - 1;
const heightValue = height - 1;
// Combine width and height into single byte
// High nibble = width, low nibble = height
const sizeValue = (widthValue << 4) | heightValue;
const sizeValue = ((width - 1) << 4) | (height - 1);
return new Uint8Array([GS, 0x21, sizeValue]);
}
/**
* Set text emphasis (bold and underline)
* @param {boolean} bold - Enable bold
* @param {boolean} underline - Enable underline
* @returns {Uint8Array} Emphasis command sequence
*/
setEmphasis(bold = false, underline = false) {
const commands = [];
if (bold) {
commands.push(...BOLD_ON);
} else {
commands.push(...BOLD_OFF);
const cmds = [];
cmds.push(...(bold ? BOLD_ON : BOLD_OFF));
cmds.push(...(underline ? UNDERLINE_ON : UNDERLINE_OFF));
return new Uint8Array(cmds);
}
if (underline) {
commands.push(...UNDERLINE_ON);
} else {
commands.push(...UNDERLINE_OFF);
}
return new Uint8Array(commands);
}
/**
* Feed paper and cut
* @param {number} lines - Number of lines to feed before cutting (default: 3)
* @returns {Uint8Array} Feed and cut command sequence
*/
feedAndCut(lines = 3) {
const commands = [];
// Feed lines
for (let i = 0; i < lines; i++) {
commands.push(...FEED_LINE);
}
// Cut paper
commands.push(...CUT_PAPER);
return new Uint8Array(commands);
const cmds = [];
for (let i = 0; i < lines; i++) cmds.push(...FEED_LINE);
cmds.push(...CUT_PAPER);
return new Uint8Array(cmds);
}
/**
* Encode text to bytes using the configured character set
* @param {string} text - Text to encode
* @returns {Uint8Array} Encoded text bytes
* Encode text to bytes using the configured character set.
*/
encodeText(text) {
if (!text) {
return new Uint8Array(0);
}
if (!text) return new Uint8Array(0);
const result = new Uint8Array(text.length);
const charSet = (this.characterSet || 'CP437').toUpperCase();
// CP437 mapping for common characters above 127
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, '₧': 0x9E, 'ƒ': 0x9F,
'á': 0xA0, 'í': 0xA1, 'ó': 0xA2, 'ú': 0xA3, 'ñ': 0xA4, 'Ñ': 0xA5, 'ª': 0xA6, 'º': 0xA7, '¿': 0xA8, '⌐': 0xA9, '¬': 0xAA, '½': 0xAB, '¼': 0xAC, '¡': 0xAD, '«': 0xAE, '»': 0xAF,
'ß': 0xE1, 'Γ': 0xE2, 'π': 0xE3, 'Σ': 0xE4, 'σ': 0xE5, 'µ': 0xE6, 'τ': 0xE7, 'Φ': 0xE8, 'Θ': 0xE9, 'Ω': 0xEA, 'δ': 0xEB, '∞': 0xEC, 'φ': 0xED, 'ε': 0xEE, '∩': 0xEF,
'≡': 0xF0, '±': 0xF1, '≥': 0xF2, '≤': 0xF3, '⌠': 0xF4, '⌡': 0xF5, '÷': 0xF6, '≈': 0xF7, '°': 0xF8, '∙': 0xF9, '·': 0xFA, '√': 0xFB, 'ⁿ': 0xFC, '²': 0xFD, '■': 0xFE,
'€': 0xEE // Mapped to approximate char or standard CP437 fallback
'ü':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,
};
// CP850/CP858 mapping for common characters
const cp850Map = {
'ü': 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, '×': 0x9E, 'ƒ': 0x9F,
'á': 0xA0, 'í': 0xA1, 'ó': 0xA2, 'ú': 0xA3, 'ñ': 0xA4, 'Ñ': 0xA5, 'ª': 0xA6, 'º': 0xA7, '¿': 0xA8, '®': 0xA9, '¬': 0xAA, '½': 0xAB, '¼': 0xAC, '¡': 0xAD, '«': 0xAE, '»': 0xAF,
'ß': 0xE1, 'µ': 0xE6, '±': 0xF1, '÷': 0xF6, '°': 0xF8, '²': 0xFD,
'€': 0xD5 // CP858/CP850 Euro symbol or placeholder
...cp437Map, 'ø':0x9B,'Ø':0x9D,'×':0x9E,'®':0xA9,'€':0xD5,
};
// Select active lookup map
let activeMap = cp437Map;
if (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852') {
activeMap = cp850Map;
}
const activeMap = (charSet === 'CP850' || charSet === 'CP858' || charSet === 'CP852')
? cp850Map : cp437Map;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const code = char.charCodeAt(0);
const ch = text[i];
const code = ch.charCodeAt(0);
if (code <= 127) {
result[i] = code;
} else if (activeMap[char] !== undefined) {
result[i] = activeMap[char];
} else if (activeMap[ch] !== undefined) {
result[i] = activeMap[ch];
} else {
// Normalize to strip accents as a general fallback
const normalized = char.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
if (normalized.length > 0 && normalized.charCodeAt(0) <= 127) {
result[i] = normalized.charCodeAt(0);
} else {
result[i] = 0x3F; // '?' in ASCII
const norm = ch.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
result[i] = (norm.length > 0 && norm.charCodeAt(0) <= 127)
? norm.charCodeAt(0) : 0x3F; // '?'
}
}
}
return result;
}
/**
* Add a line of text with optional formatting
* @param {string} text - Text to add
* @param {Object} options - Formatting options
* @returns {Uint8Array} Formatted text command sequence
* Emit a single line of text (with optional formatting) followed by LF.
*/
addLine(text, options = {}) {
const commands = [];
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));
// Apply alignment
if (options.align) {
commands.push(...this.setAlignment(options.align));
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);
}
// Apply text size
if (options.width || options.height) {
commands.push(...this.setTextSize(options.width || 1, options.height || 1));
// ─── 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, '.');
}
// Apply emphasis
if (options.bold !== undefined || options.underline !== undefined) {
commands.push(...this.setEmphasis(options.bold || false, options.underline || false));
}
// Add text
commands.push(...this.encodeText(text));
// Add line feed
commands.push(...FEED_LINE);
// Reset formatting
commands.push(...this.setTextSize(1, 1));
commands.push(...this.setEmphasis(false, false));
return new Uint8Array(commands);
// 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}`;
}
/**
* Generate complete receipt from Odoo receipt data
* @param {Object} receiptData - Odoo receipt structure
* @returns {Uint8Array} Complete ESC/POS command sequence
* 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 commands = [];
const cmds = [];
const W = this.colWidth;
// Initialize printer
commands.push(...this.initialize());
// ── Initialize ────────────────────────────────────────────────────
cmds.push(...this.initialize());
// Header section
// ── Header ────────────────────────────────────────────────────────
if (receiptData.headerData) {
const header = receiptData.headerData;
if (header.companyName) {
commands.push(...this.addLine(header.companyName, {
align: 'center',
width: 2,
height: 2,
bold: true
}));
const h = receiptData.headerData;
if (h.companyName) {
cmds.push(...this.addLine(h.companyName, { align: 'center', bold: true, width: 2, height: 2 }));
}
if (h.address) {
// Long addresses should wrap — print as-is (printer wraps automatically)
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()));
}
if (header.address) {
commands.push(...this.addLine(header.address, { align: 'center' }));
}
if (header.phone) {
commands.push(...this.addLine(header.phone, { align: 'center' }));
}
if (header.taxId) {
commands.push(...this.addLine(`Tax ID: ${header.taxId}`, { align: 'center' }));
}
// Separator line
commands.push(...this.addLine(''.padEnd(48, '-'), { align: 'center' }));
}
// Order information
// ── Order info ────────────────────────────────────────────────────
if (receiptData.orderData) {
const order = receiptData.orderData;
if (order.orderName) {
commands.push(...this.addLine(`Order: ${order.orderName}`, { bold: true }));
const o = receiptData.orderData;
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.customer) cmds.push(...this.addLine(`Customer: ${o.customer}`));
cmds.push(...this.addLine(this.divider()));
}
if (order.date) {
commands.push(...this.addLine(`Date: ${order.date}`));
}
if (order.cashier) {
commands.push(...this.addLine(`Cashier: ${order.cashier}`));
}
if (order.customer) {
commands.push(...this.addLine(`Customer: ${order.customer}`));
}
commands.push(...this.addLine(''.padEnd(48, '-')));
}
// Line items
// ── Line items ────────────────────────────────────────────────────
if (receiptData.lines && receiptData.lines.length > 0) {
// Header for items
commands.push(...this.addLine('Item Qty Price Total', { bold: true }));
receiptData.lines.forEach(line => {
// Product name (truncate if too long)
let productName = line.productName || '';
if (productName.length > 24) {
productName = productName.substring(0, 21) + '...';
const name = String(line.productName || '');
const qty = line.quantity || 0;
const unitPrice = line.price || 0;
const total = line.total || 0;
// Line 1: product name (truncate to full width)
const displayName = name.length > W ? name.substring(0, W - 1) + '.' : name;
cmds.push(...this.addLine(displayName, { bold: false }));
// Line 2: qty x unitPrice = total (right-aligned)
const priceStr = this.formatAmount(unitPrice);
const totalStr = this.formatAmount(total);
const qtyStr = `${qty % 1 === 0 ? qty.toFixed(0) : qty.toFixed(2)}x`;
// Build: " 2x 14.985.000 29.970.000"
const middle = `${qtyStr} ${priceStr}`;
let itemLine = middle + totalStr.padStart(W - middle.length);
if (itemLine.length > W) {
// fallback: just right-align total
itemLine = totalStr.padStart(W);
}
// Format line with proper spacing
const qty = (line.quantity || 0).toFixed(2).padStart(6);
const price = (line.price || 0).toFixed(2).padStart(8);
const total = (line.total || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(productName.padEnd(24)));
commands.push(...this.addLine(`${' '.repeat(24)}${qty}${price}${total}`));
cmds.push(...this.addLine(itemLine));
});
commands.push(...this.addLine(''.padEnd(48, '-')));
cmds.push(...this.addLine(this.divider()));
}
// Totals section
// ── Totals ────────────────────────────────────────────────────────
if (receiptData.totals) {
const totals = receiptData.totals;
const t = receiptData.totals;
if (totals.subtotal !== undefined) {
const subtotalLine = `Subtotal:`.padEnd(40) + (totals.subtotal || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(subtotalLine));
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()));
}
if (totals.discount !== undefined && totals.discount > 0) {
const discountLine = `Discount:`.padEnd(40) + (totals.discount || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(discountLine));
}
if (totals.tax !== undefined) {
const taxLine = `Tax:`.padEnd(40) + (totals.tax || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(taxLine));
}
if (totals.total !== undefined) {
const totalLine = `TOTAL:`.padEnd(40) + (totals.total || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(totalLine, { bold: true, width: 2, height: 2 }));
}
commands.push(...this.addLine(''.padEnd(48, '-')));
}
// Payment information
// ── Payment ───────────────────────────────────────────────────────
if (receiptData.paymentData) {
const payment = receiptData.paymentData;
if (payment.method) {
commands.push(...this.addLine(`Payment Method: ${payment.method}`));
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()));
}
if (payment.amount !== undefined) {
const amountLine = `Amount Paid:`.padEnd(40) + (payment.amount || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(amountLine));
}
if (payment.change !== undefined && payment.change > 0) {
const changeLine = `Change:`.padEnd(40) + (payment.change || 0).toFixed(2).padStart(8);
commands.push(...this.addLine(changeLine, { bold: true }));
}
commands.push(...this.addLine(''.padEnd(48, '-')));
}
// Footer section
// ── Footer ────────────────────────────────────────────────────────
if (receiptData.footerData) {
const footer = receiptData.footerData;
if (footer.message) {
commands.push(...this.addLine(footer.message, { align: 'center' }));
const f = receiptData.footerData;
if (f.message) {
cmds.push(...this.addLine(f.message, { align: 'center' }));
}
if (footer.barcode) {
// Note: Barcode printing would require additional ESC/POS commands
// For now, just print the barcode value as text
commands.push(...this.addLine(footer.barcode, { align: 'center' }));
if (f.barcode) {
cmds.push(...this.addLine(f.barcode, { align: 'center' }));
}
}
// Feed and cut
commands.push(...this.feedAndCut(4));
cmds.push(...this.feedAndCut(4));
return new Uint8Array(commands);
return new Uint8Array(cmds);
}
/**
* Helper method to combine multiple Uint8Arrays
* @param {Array<Uint8Array>} arrays - Arrays to combine
* @returns {Uint8Array} Combined array
* Helper: combine multiple Uint8Arrays into one.
*/
static combineArrays(arrays) {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
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;
}
for (const arr of arrays) { result.set(arr, offset); offset += arr.length; }
return result;
}
}

View File

@ -636,9 +636,16 @@ patch(PosPrinterService.prototype, {
const config = order.config || pos?.config || {};
// ── Header ─────────────────────────────────────────────────────────────
// Build address the same way the Odoo receipt template does
const addrParts = [
company.street,
company.city,
company.state_id?.code,
company.zip,
].filter(Boolean);
const headerData = {
companyName: company.name || config.name || 'Receipt',
address: [company.street, company.city, company.zip].filter(Boolean).join(', '),
address: addrParts.join(', '),
phone: company.phone || '',
taxId: company.vat || ''
};