From fc361edfc860636b9cb84c6a65bb36d304321b82 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 4 Sep 2026 15:58:08 +0200 Subject: [PATCH] fix(pos): reset stateful printer attributes per-block instead of relying on order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the oversized/wrapping text on the printed slip: ePOS- Print XML's attributes (width, height, em) are stateful on the printer — they persist across subsequent tags until explicitly changed back, not per-tag like CSS. The sale slip's header_title block sets width="2" height="2" for the big title and nothing after it ever reset back to width="1" height="1", so the entire rest of the receipt (business info, items, totals, payment, footer) inherited double-size text — exactly the wrapping/misalignment seen in the photo. Same issue existed in the delivery note (header's height="2" leaking into every line after it) and cash-up (GRAND TOTAL's em="true" leaking into CASH RECONCILIATION's non-bold lines). Now every tag in all three XML builders explicitly declares width/height/em rather than relying on inheritance from whatever tag ran before it. This isn't just a display fix — since blocks are now user-reorderable/toggleable, no block can safely assume what state a neighboring block left behind; every block must be correct in isolation regardless of order. Same class of bug existed on the Bluetooth ESC/POS side: ESC/POS's alignment command is equally persistent, and the sale slip's header_title centered the title but relied on the next block (business_info) to reset it back to left — broken the moment business_info is disabled or reordered. Fixed the same way: each block resets what it changed before returning. --- public/pos/epos-print.js | 98 ++++++++++++++++++++++------------------ public/pos/receipt.html | 6 ++- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/public/pos/epos-print.js b/public/pos/epos-print.js index cd686f5..2838e89 100644 --- a/public/pos/epos-print.js +++ b/public/pos/epos-print.js @@ -78,6 +78,16 @@ window.EposPrint = (function () { // read their custom text. Dividers are owned by whichever block // came after them in the original fixed sequence — reordering a // block takes its leading divider with it. + // ePOS-Print's style attributes (width, height, em, ul, + // reverse) are STATEFUL — the printer keeps whatever a previous + // tag set until a later tag explicitly changes it back; they + // don't reset per-tag the way a browser resets inline styles. Since + // blocks are user-reorderable/toggleable, no block can assume what + // state a neighboring block left behind — every plain-style tag + // here explicitly declares width="1" height="1" em="false" so it's + // correct regardless of what printed immediately before it. + const NORMAL = 'width="1" height="1" em="false"'; + function saleBlockRenderers(s, logo) { const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0)); const vat = s.vat != null ? s.vat : (s.total * 15 / 115); @@ -93,51 +103,51 @@ window.EposPrint = (function () { }, business_info: function () { const parts = []; - if (s.letterhead && s.letterhead.name) parts.push(`${xmlEscape(s.letterhead.name)} `); + if (s.letterhead && s.letterhead.name) parts.push(`${xmlEscape(s.letterhead.name)} `); const addrLines = []; if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0])); if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone)); if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number)); - if (addrLines.length) parts.push(`${addrLines.join(' ')} `); + if (addrLines.length) parts.push(`${addrLines.join(' ')} `); return parts.join(''); }, date_slip: function () { const d = new Date(s.createdAt); - return '-------------------------------- ' - + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} `; + return `-------------------------------- ` + + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} `; }, customer: function () { - return s.customer ? `Customer: ${xmlEscape(s.customer.name)} ` : ''; + return s.customer ? `Customer: ${xmlEscape(s.customer.name)} ` : ''; }, items: function () { const itemLines = s.items.map(function (it) { const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`; return `${xmlEscape(it.description || it.sku)} ${xmlEscape(priceLine)} `; }).join(''); - return `-------------------------------- ${itemLines}`; + return `-------------------------------- ${itemLines}`; }, totals: function () { - const parts = ['-------------------------------- ']; + const parts = [`-------------------------------- `]; if (hasDiscount) { const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0); const grossSub = gross / 1.15; const grossVat = gross - grossSub; - parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} `); + parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} `); } - parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} `); - parts.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} `); + parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} `); + parts.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} `); return parts.join(''); }, payment: function () { const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase(); - let out = `Paid: ${xmlEscape(paidLabel)} `; - if (s.payment === 'cash') out += `Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} `; + let out = `Paid: ${xmlEscape(paidLabel)} `; + if (s.payment === 'cash') out += `Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} `; return out; }, footer: function (block) { const text = block.text || (s.letterhead && s.letterhead.footer_text); - let out = '-------------------------------- '; - if (text) out += `${xmlEscape(text)} `; + let out = `-------------------------------- `; + if (text) out += `${xmlEscape(text)} `; return out; }, }; @@ -176,17 +186,17 @@ window.EposPrint = (function () { return { header_title: function (block) { const title = block.text || 'WFSALES POS'; - return `${xmlEscape(title)} Delivery Note `; + return `${xmlEscape(title)} Delivery Note `; }, customer: function () { let out = ''; - if (s.customer && s.customer.company) out += `Company: ${xmlEscape(s.customer.company)} `; - out += `Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} `; + if (s.customer && s.customer.company) out += `Company: ${xmlEscape(s.customer.company)} `; + out += `Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} `; return out; }, date: function () { const d = new Date(s.createdAt); - return `Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} `; + return `Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} `; }, items: function () { const itemLines = s.items.map(function (it) { @@ -194,15 +204,15 @@ window.EposPrint = (function () { const priceSuffix = showPricing ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : ''; return `${xmlEscape(it.description || it.sku)} ${xmlEscape(qtyLine + priceSuffix)} `; }).join(''); - return `-------------------------------- ${itemLines}`; + return `-------------------------------- ${itemLines}`; }, totals: function () { - let out = '-------------------------------- '; - if (showPricing) out += `TOTAL: R ${s.total.toFixed(2)} `; + let out = `-------------------------------- `; + if (showPricing) out += `TOTAL: R ${s.total.toFixed(2)} `; return out; }, signature: function () { - return 'Received by: __________ Driver: __________ '; + return `Received by: __________ Driver: __________ `; }, }; } @@ -249,16 +259,16 @@ window.EposPrint = (function () { }, business_info: function () { const parts = []; - if (lh.display_name) parts.push(`${xmlEscape(lh.display_name)} `); - if (lh.address) parts.push(`${xmlEscape(lh.address.split('\n')[0])} `); - if (lh.phone) parts.push(`Tel: ${xmlEscape(lh.phone)} `); - if (lh.vat_number) parts.push(`VAT: ${xmlEscape(lh.vat_number)} `); + if (lh.display_name) parts.push(`${xmlEscape(lh.display_name)} `); + if (lh.address) parts.push(`${xmlEscape(lh.address.split('\n')[0])} `); + if (lh.phone) parts.push(`Tel: ${xmlEscape(lh.phone)} `); + if (lh.vat_number) parts.push(`VAT: ${xmlEscape(lh.vat_number)} `); return parts.join(''); }, shift_date: function () { - let out = '-------------------------------- '; - if (r.shiftLabel || r.dateLabel) out += `Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} `; - if (r.counts) out += `Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} `; + let out = `-------------------------------- `; + if (r.shiftLabel || r.dateLabel) out += `Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} `; + if (r.counts) out += `Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} `; return out; }, payment_breakdown: function () { @@ -268,20 +278,20 @@ window.EposPrint = (function () { `EFT ${pad((r.counts && r.counts.eft) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.eft) || 0)}`, `Acct ${pad((r.counts && r.counts.account) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.account) || 0)}`, ]; - return '-------------------------------- ' - + 'PAYMENT METHOD BREAKDOWN ' - + `${lines.join(' ')} `; + return `-------------------------------- ` + + `PAYMENT METHOD BREAKDOWN ` + + `${lines.join(' ')} `; }, grand_total: function () { - return '-------------------------------- ' - + `GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} `; + return `-------------------------------- ` + + `GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} `; }, cash_reconciliation: function () { - return '-------------------------------- ' - + 'CASH RECONCILIATION ' - + `Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} ` - + `Cash counted: R ${fmtMoney(r.cashCounted || 0)} ` - + `Variance: R ${fmtMoney(r.variance || 0)} `; + return `-------------------------------- ` + + `CASH RECONCILIATION ` + + `Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} ` + + `Cash counted: R ${fmtMoney(r.cashCounted || 0)} ` + + `Variance: R ${fmtMoney(r.variance || 0)} `; }, denominations: function () { if (!Array.isArray(r.denoms) || !r.denoms.length) return ''; @@ -289,15 +299,15 @@ window.EposPrint = (function () { .filter(function (d) { return (d.count || 0) > 0; }) .map(function (d) { return `${d.label} x ${pad(d.count || 0, 3)} = R ${fmtMoney((d.value || 0) * (d.count || 0))}`; }); if (!dlines.length) return ''; - return '-------------------------------- ' - + 'DENOMINATIONS COUNTED ' - + `${dlines.join(' ')} `; + return `-------------------------------- ` + + `DENOMINATIONS COUNTED ` + + `${dlines.join(' ')} `; }, footer: function (block) { const text = block.text || lh.footer_text; if (!text) return ''; - return '-------------------------------- ' - + `${xmlEscape(text)} `; + return `-------------------------------- ` + + `${xmlEscape(text)} `; }, }; } diff --git a/public/pos/receipt.html b/public/pos/receipt.html index b286228..f306e93 100644 --- a/public/pos/receipt.html +++ b/public/pos/receipt.html @@ -621,7 +621,11 @@ function saleEscPosRenderers(s){ const discount = s.discount || { kind: 'none', input: 0, amount: 0 }; const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0; return { - header_title: (block) => ESC+'a'+E(1) + ESC+'!'+E(0x30) + (block.text||'WFSALES POS') + '\n' + ESC+'!'+E(0x00), + // ESC/POS alignment (ESC a) and print-mode (ESC !) are persistent + // printer state, not per-line attributes — since blocks are + // reorderable, this must reset both back to normal itself rather + // than relying on the next block (business_info) to do it. + header_title: (block) => ESC+'a'+E(1) + ESC+'!'+E(0x30) + (block.text||'WFSALES POS') + '\n' + ESC+'!'+E(0x00) + ESC+'a'+E(0), // Web Bluetooth GATT printing has no raster-image support in this // app — this block intentionally prints nothing on this transport. logo: () => '',