pos_bluetooth_thermal_printer/static/src/js/escpos_generator.js

429 lines
20 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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;