From d1a5d0cc9b8d33bf8487ac9c3f9f475ebd78af1f Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Thu, 7 May 2026 18:17:59 -0400 Subject: [PATCH] Print POS receipts from rendered HTML only --- addons/dine360_qz_printer/__manifest__.py | 2 +- .../static/src/js/qz_wrapper.js | 351 +++--------------- 2 files changed, 55 insertions(+), 298 deletions(-) diff --git a/addons/dine360_qz_printer/__manifest__.py b/addons/dine360_qz_printer/__manifest__.py index a5e1ffd..75fed44 100644 --- a/addons/dine360_qz_printer/__manifest__.py +++ b/addons/dine360_qz_printer/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Dine360 QZ Tray Printer', - 'version': '17.0.1.3', + 'version': '17.0.1.4', 'category': 'Point of Sale', 'summary': 'Integrate Odoo POS with Star/Epson Printers via QZ Tray.', 'depends': ['point_of_sale'], diff --git a/addons/dine360_qz_printer/static/src/js/qz_wrapper.js b/addons/dine360_qz_printer/static/src/js/qz_wrapper.js index 1bdb1d1..fc236ec 100644 --- a/addons/dine360_qz_printer/static/src/js/qz_wrapper.js +++ b/addons/dine360_qz_printer/static/src/js/qz_wrapper.js @@ -38,18 +38,6 @@ function cleanText(value) { .trim(); } -function formatMoney(value, currency) { - const amount = Number(value || 0).toFixed(2); - const symbol = cleanText(currency?.symbol || "$"); - return currency?.position === "after" ? `${amount} ${symbol}` : `${symbol}${amount}`; -} - -function center(text, width) { - const value = cleanText(text); - const padding = Math.max(0, Math.floor((width - value.length) / 2)); - return `${" ".repeat(padding)}${value}`; -} - function leftRight(left, right, width) { const lhs = cleanText(left); const rhs = cleanText(right); @@ -57,55 +45,10 @@ function leftRight(left, right, width) { return `${lhs}${" ".repeat(space)}${rhs}`; } -function safeUpper(value) { - return cleanText(value).toUpperCase(); -} - -function formatPhone(value) { - const clean = cleanText(value); - const digits = clean.replace(/\D/g, ""); - const local = digits.length === 11 && digits.startsWith("1") ? digits.slice(1) : digits; - if (local.length === 10) { - return `${local.slice(0, 3)}-${local.slice(3, 6)}-${local.slice(6)}`; - } - return clean; -} - -function getCompanyAddress(company) { - const parts = [ - company.street, - company.street2, - [company.city, company.state_id?.[1] || company.state_id?.name, company.zip].filter(Boolean).join(" "), - ].map(cleanText).filter(Boolean); - return parts.join(", "); -} - -function formatPlacedDate(value) { - const date = value ? new Date(value) : new Date(); - if (Number.isNaN(date.getTime())) { - return cleanText(value); - } - - const weekday = date.toLocaleDateString("en-CA", { weekday: "short" }); - const month = date.toLocaleDateString("en-CA", { month: "short" }); - const day = date.getDate(); - const suffix = day % 10 === 1 && day !== 11 ? "st" : day % 10 === 2 && day !== 12 ? "nd" : day % 10 === 3 && day !== 13 ? "rd" : "th"; - const time = date.toLocaleTimeString("en-CA", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }).replace(/\s/g, ""); - return `${weekday}, ${month} ${day}${suffix}, ${time}`; -} - function getOrderTable(order) { return order?.getTable?.() || order?.table; } -function getOrderDisplayName(receiptData, order) { - return cleanText(receiptData?.name || order?.name || ""); -} - function wrapText(text, width) { const clean = cleanText(text); if (clean.length <= width) { @@ -154,13 +97,49 @@ function collectPrintableStyles() { .join("\n"); } -function buildHtmlReceipt(receiptElement, pos) { +async function imageToDataUrl(src) { + const url = absoluteUrl(src); + if (!url || url.startsWith("data:") || url.startsWith("blob:")) { + return url; + } + + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) { + throw new Error(`Unable to load receipt image: ${url}`); + } + + const blob = await response.blob(); + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +async function inlineReceiptImages(receiptElement) { + const images = Array.from(receiptElement.querySelectorAll("img[src]")); + await Promise.all(images.map(async (node) => { + try { + node.setAttribute("src", await imageToDataUrl(node.getAttribute("src"))); + } catch (err) { + console.warn("Could not inline receipt image for QZ print:", err); + node.removeAttribute("src"); + } + })); +} + +function findReceiptElement(screen) { + return screen.el?.querySelector?.(".pos-receipt") + || document.querySelector(".receipt-screen .pos-receipt") + || document.querySelector(".pos-receipt"); +} + +async function buildHtmlReceipt(receiptElement, pos) { const config = pos?.config || {}; const widthMm = paperWidthMm(config); const clone = receiptElement.cloneNode(true); - for (const node of clone.querySelectorAll("[src]")) { - node.setAttribute("src", absoluteUrl(node.getAttribute("src"))); - } + await inlineReceiptImages(clone); for (const node of clone.querySelectorAll("[href]")) { node.setAttribute("href", absoluteUrl(node.getAttribute("href"))); } @@ -274,61 +253,6 @@ class EscPosBuilder { } } -async function getLogoRasterBytes(pos, columns) { - if (pos?.config?.qz_print_logo === false || !pos?.company_logo_base64) { - return []; - } - - const image = await new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = pos.company_logo_base64; - }); - - const maxWidth = Math.min(columns * 8, 384); - const maxHeight = 120; - const ratio = Math.min(maxWidth / image.width, maxHeight / image.height, 1); - const width = Math.max(8, Math.floor((image.width * ratio) / 8) * 8); - const height = Math.max(1, Math.floor(image.height * ratio)); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d", { willReadFrequently: true }); - ctx.fillStyle = "#fff"; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(image, 0, 0, width, height); - - const pixels = ctx.getImageData(0, 0, width, height).data; - const widthBytes = Math.ceil(width / 8); - const data = []; - for (let y = 0; y < height; y++) { - for (let xb = 0; xb < widthBytes; xb++) { - let byte = 0; - for (let bit = 0; bit < 8; bit++) { - const x = xb * 8 + bit; - const index = (y * width + x) * 4; - const alpha = pixels[index + 3]; - const luminance = pixels[index] * 0.299 + pixels[index + 1] * 0.587 + pixels[index + 2] * 0.114; - if (alpha > 32 && luminance < 170) { - byte |= 0x80 >> bit; - } - } - data.push(byte); - } - } - - return [ - ESC, 0x61, 0x01, - GS, 0x76, 0x30, 0x00, - widthBytes & 0xff, (widthBytes >> 8) & 0xff, - height & 0xff, (height >> 8) & 0xff, - ...data, - CR, LF, - ESC, 0x61, 0x00, - ]; -} - function addWrappedLine(builder, left, right = "", indent = 0) { const width = builder.columns; const rightText = cleanText(right); @@ -346,157 +270,6 @@ function addWrappedLine(builder, left, right = "", indent = 0) { } } -function addOrderLines(builder, order, currency) { - builder.bold(true); - builder.line(leftRight("Item", "Price", builder.columns)); - builder.bold(false); - - for (const line of order?.get_orderlines?.() || []) { - const product = line.get_product?.(); - const name = product?.display_name || product?.name || line.get_full_product_name?.() || ""; - const qty = Number(line.get_quantity?.() || 0); - const qtyText = Number.isInteger(qty) ? String(qty) : qty.toFixed(2).replace(/\.?0+$/, ""); - const total = formatMoney(line.get_price_with_tax?.() || 0, currency); - addWrappedLine(builder, `${qtyText} x ${name}`, total); - - const unitPrice = line.get_unit_display_price?.(); - if (qty !== 1 && unitPrice !== undefined) { - builder.line(` @ ${formatMoney(unitPrice, currency)} each`); - } - const note = line.get_customer_note?.(); - if (note) { - addWrappedLine(builder, `Note: ${note}`, "", 2); - } - } -} - -function addTaxSummary(builder, receiptData, currency) { - const taxes = receiptData?.tax_details || []; - if (!taxes.length) { - return; - } - for (const tax of taxes) { - const label = tax?.tax?.name || (tax?.tax?.amount ? `${tax.tax.amount}%` : "Tax"); - builder.line(leftRight(label, formatMoney(tax.amount || 0, currency), builder.columns)); - } -} - -function addPaymentLines(builder, receiptData, currency) { - const paymentLines = receiptData?.paymentlines || []; - if (!paymentLines.length) { - return; - } - builder.separator("-"); - for (const payment of paymentLines) { - builder.line(leftRight(payment.name, formatMoney(payment.amount, currency), builder.columns)); - } -} - -async function buildEscPosReceipt(order, pos) { - const config = pos?.config || {}; - const columns = columnsFromConfig(config); - const currency = pos?.currency; - const company = pos?.company || {}; - const receiptData = order?.export_for_printing?.() || {}; - const builder = new EscPosBuilder(columns); - const table = getOrderTable(order); - const companyName = cleanText(company.name || "Chennora"); - const fulfilmentLabel = safeUpper(receiptData.fulfilment_type_label || receiptData.fulfilment_type || "DINE-IN"); - const orderName = getOrderDisplayName(receiptData, order); - const qrValue = orderName || `${companyName} ${formatMoney(order?.get_total_with_tax?.(), currency)}`; - - builder.init(); - builder.raw(...(await getLogoRasterBytes(pos, columns))); - builder.align("center"); - builder.bold(true); - builder.line(companyName); - builder.bold(false); - builder.line(""); - - const address = getCompanyAddress(company); - if (address) { - builder.lines(wrapText(`Address: ${address}`, columns).map((line) => center(line, columns))); - } - if (company.email) { - builder.line(center(`Email: ${company.email}`, columns)); - } - if (company.phone) { - builder.line(center(`Phone: ${formatPhone(company.phone)}`, columns)); - } - - builder.line(""); - builder.bold(true); - builder.line(order?._printed ? "*** Duplicate Bill (1) ***" : "*** Bill ***"); - if (orderName) { - builder.line(`Invoice ${orderName}`); - } - builder.line(fulfilmentLabel); - builder.bold(false); - builder.line(""); - - builder.align("left"); - builder.separator("-"); - builder.line(leftRight("Placed", formatPlacedDate(receiptData.date), columns)); - builder.separator("-"); - builder.line(leftRight("Cashier", companyName, columns)); - if (order?.customer_count) { - builder.line(leftRight("Guests Served", order.customer_count, columns)); - } - if (table?.name) { - builder.line(leftRight("Table", table.name, columns)); - } - - builder.separator("="); - addOrderLines(builder, order, currency); - builder.separator("="); - builder.line(leftRight("Price", formatMoney(order?.get_total_without_tax?.(), currency), columns)); - const tax = order?.get_total_tax?.() || 0; - if (tax) { - const firstTax = receiptData?.tax_details?.[0]?.tax; - const taxLabel = firstTax?.name || (firstTax?.amount ? `HST (${firstTax.amount}%)` : "Tax"); - builder.line(leftRight(taxLabel, formatMoney(tax, currency), columns)); - } - if (receiptData.total_discount) { - builder.line(leftRight("Discounts", formatMoney(receiptData.total_discount, currency), columns)); - } - builder.bold(true); - builder.line(leftRight("Grand Total", formatMoney(order?.get_total_with_tax?.(), currency), columns)); - builder.bold(false); - - addPaymentLines(builder, receiptData, currency); - const change = receiptData.change || order?.get_change?.() || 0; - if (change > 0) { - builder.bold(true); - builder.line(leftRight("Change", formatMoney(change, currency), columns)); - builder.bold(false); - } - addTaxSummary(builder, receiptData, currency); - - if (config.receipt_footer) { - builder.separator("-"); - builder.align("center"); - builder.lines(wrapText(config.receipt_footer, columns).map((line) => center(line, columns))); - builder.align("left"); - } - - builder.separator("-"); - builder.align("center"); - builder.bold(true); - builder.line("* PAID *"); - builder.bold(false); - builder.line(""); - builder.qrCode(qrValue); - builder.line(""); - builder.line("Powered by dind360"); - builder.align("left"); - builder.feed(END_FEED_LINES); - if (config.qz_enable_cutter !== false) { - builder.cut(config.qz_cut_mode || "partial"); - } - builder.feed(1); - return bytesToBase64(builder.bytes); -} - patch(ReceiptScreen.prototype, { async printReceipt() { const printerName = getBillingPrinterName(this.pos.config); @@ -511,42 +284,26 @@ patch(ReceiptScreen.prototype, { await qz.websocket.connect({ retries: 2, delay: 1 }); } - const receiptElement = this.el?.querySelector?.(".pos-receipt"); - if (receiptElement) { - const widthMm = paperWidthMm(this.pos.config); - const config = qz.configs.create(printerName, { - copies: 1, - margins: 0, - units: "mm", - size: { width: widthMm }, - rasterize: true, - scaleContent: true, - spool: { size: 1 }, - }); - await qz.print(config, [{ - type: "pixel", - format: "html", - flavor: "plain", - data: buildHtmlReceipt(receiptElement, this.pos), - }]); - - if (this.currentOrder) { - this.currentOrder._printed = true; - } - return true; + const receiptElement = findReceiptElement(this); + if (!receiptElement) { + throw new Error("Could not find the rendered Odoo POS receipt preview."); } + const widthMm = paperWidthMm(this.pos.config); const config = qz.configs.create(printerName, { - encoding: "CP437", copies: 1, + margins: 0, + units: "mm", + size: { width: widthMm }, + rasterize: true, + scaleContent: true, spool: { size: 1 }, }); - const payload = await buildEscPosReceipt(this.currentOrder, this.pos); await qz.print(config, [{ - type: "raw", - format: "command", - flavor: "base64", - data: payload, + type: "pixel", + format: "html", + flavor: "plain", + data: await buildHtmlReceipt(receiptElement, this.pos), }]); if (this.currentOrder) { @@ -557,7 +314,7 @@ patch(ReceiptScreen.prototype, { console.error("QZ Tray Print Error:", err); this.env.services.popup.add(ErrorPopup, { title: "QZ Tray Printer Error", - body: "Failed to print through QZ Tray. Check that QZ Tray is running, the printer name is correct, and the printer supports ESC/POS raw commands.", + body: "Failed to print the rendered Odoo receipt through QZ Tray. Check that QZ Tray is running and the printer name is correct.", }); return false; }