fix(pos): reset stateful printer attributes per-block instead of relying on order

Root cause of the oversized/wrapping text on the printed slip: ePOS-
Print XML's <text> attributes (width, height, em) are stateful on the
printer — they persist across subsequent <text> 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 <text> 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.
This commit is contained in:
Dave 2026-09-04 15:58:08 +02:00
parent ec711760a3
commit fc361edfc8
2 changed files with 59 additions and 45 deletions

View file

@ -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 <text> style attributes (width, height, em, ul,
// reverse) are STATEFUL — the printer keeps whatever a previous
// <text> 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(`<text align="center">${xmlEscape(s.letterhead.name)}&#10;</text>`);
if (s.letterhead && s.letterhead.name) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(s.letterhead.name)}&#10;</text>`);
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(`<text align="center">${addrLines.join('&#10;')}&#10;</text>`);
if (addrLines.length) parts.push(`<text align="center" ${NORMAL}>${addrLines.join('&#10;')}&#10;</text>`);
return parts.join('');
},
date_slip: function () {
const d = new Date(s.createdAt);
return '<text align="left">--------------------------------&#10;</text>'
+ `<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="left" ${NORMAL}>Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`;
},
customer: function () {
return s.customer ? `<text align="left">Customer: ${xmlEscape(s.customer.name)}&#10;</text>` : '';
return s.customer ? `<text align="left" ${NORMAL}>Customer: ${xmlEscape(s.customer.name)}&#10;</text>` : '';
},
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)}&#10;${xmlEscape(priceLine)}&#10;`;
}).join('');
return `<text align="left">--------------------------------&#10;${itemLines}</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;${itemLines}</text>`;
},
totals: function () {
const parts = ['<text align="left">--------------------------------&#10;</text>'];
const parts = [`<text align="left" ${NORMAL}>--------------------------------&#10;</text>`];
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(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" ${NORMAL}>Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" width="1" height="1">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" width="1" height="1" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
return parts.join('');
},
payment: function () {
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
let out = `<text align="left">Paid: ${xmlEscape(paidLabel)}&#10;</text>`;
if (s.payment === 'cash') out += `<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`;
let out = `<text align="left" ${NORMAL}>Paid: ${xmlEscape(paidLabel)}&#10;</text>`;
if (s.payment === 'cash') out += `<text align="left" ${NORMAL}>Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`;
return out;
},
footer: function (block) {
const text = block.text || (s.letterhead && s.letterhead.footer_text);
let out = '<text align="left">--------------------------------&#10;</text>';
if (text) out += `<text align="center">${xmlEscape(text)}&#10;</text>`;
let out = `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`;
if (text) out += `<text align="center" ${NORMAL}>${xmlEscape(text)}&#10;</text>`;
return out;
},
};
@ -176,17 +186,17 @@ window.EposPrint = (function () {
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return `<text align="center" width="1" height="2">${xmlEscape(title)}&#10;</text><text align="center">Delivery Note&#10;</text>`;
return `<text align="center" width="1" height="2">${xmlEscape(title)}&#10;</text><text align="center" ${NORMAL}>Delivery Note&#10;</text>`;
},
customer: function () {
let out = '';
if (s.customer && s.customer.company) out += `<text align="left">Company: ${xmlEscape(s.customer.company)}&#10;</text>`;
out += `<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')}&#10;</text>`;
if (s.customer && s.customer.company) out += `<text align="left" ${NORMAL}>Company: ${xmlEscape(s.customer.company)}&#10;</text>`;
out += `<text align="left" ${NORMAL}>Customer: ${xmlEscape((s.customer && s.customer.name) || '—')}&#10;</text>`;
return out;
},
date: function () {
const d = new Date(s.createdAt);
return `<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))}&#10;</text>`;
return `<text align="left" ${NORMAL}>Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))}&#10;</text>`;
},
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)}&#10;${xmlEscape(qtyLine + priceSuffix)}&#10;`;
}).join('');
return `<text align="left">--------------------------------&#10;${itemLines}</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;${itemLines}</text>`;
},
totals: function () {
let out = '<text align="left">--------------------------------&#10;</text>';
if (showPricing) out += `<text align="left">TOTAL: R ${s.total.toFixed(2)}&#10;</text>`;
let out = `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`;
if (showPricing) out += `<text align="left" ${NORMAL}>TOTAL: R ${s.total.toFixed(2)}&#10;</text>`;
return out;
},
signature: function () {
return '<text align="left">Received by: __________ Driver: __________&#10;</text>';
return `<text align="left" ${NORMAL}>Received by: __________ Driver: __________&#10;</text>`;
},
};
}
@ -249,16 +259,16 @@ window.EposPrint = (function () {
},
business_info: function () {
const parts = [];
if (lh.display_name) parts.push(`<text align="center">${xmlEscape(lh.display_name)}&#10;</text>`);
if (lh.address) parts.push(`<text align="center">${xmlEscape(lh.address.split('\n')[0])}&#10;</text>`);
if (lh.phone) parts.push(`<text align="center">Tel: ${xmlEscape(lh.phone)}&#10;</text>`);
if (lh.vat_number) parts.push(`<text align="center">VAT: ${xmlEscape(lh.vat_number)}&#10;</text>`);
if (lh.display_name) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(lh.display_name)}&#10;</text>`);
if (lh.address) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(lh.address.split('\n')[0])}&#10;</text>`);
if (lh.phone) parts.push(`<text align="center" ${NORMAL}>Tel: ${xmlEscape(lh.phone)}&#10;</text>`);
if (lh.vat_number) parts.push(`<text align="center" ${NORMAL}>VAT: ${xmlEscape(lh.vat_number)}&#10;</text>`);
return parts.join('');
},
shift_date: function () {
let out = '<text align="left">--------------------------------&#10;</text>';
if (r.shiftLabel || r.dateLabel) out += `<text align="left">Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')}&#10;</text>`;
if (r.counts) out += `<text align="left">Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account}&#10;</text>`;
let out = `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`;
if (r.shiftLabel || r.dateLabel) out += `<text align="left" ${NORMAL}>Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')}&#10;</text>`;
if (r.counts) out += `<text align="left" ${NORMAL}>Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account}&#10;</text>`;
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 '<text align="left">--------------------------------&#10;</text>'
+ '<text align="left">PAYMENT METHOD BREAKDOWN&#10;</text>'
+ `<text align="left">${lines.join('&#10;')}&#10;</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="left" ${NORMAL}>PAYMENT METHOD BREAKDOWN&#10;</text>`
+ `<text align="left" ${NORMAL}>${lines.join('&#10;')}&#10;</text>`;
},
grand_total: function () {
return '<text align="left">--------------------------------&#10;</text>'
+ `<text align="left" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)}&#10;</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="left" width="1" height="1" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)}&#10;</text>`;
},
cash_reconciliation: function () {
return '<text align="left">--------------------------------&#10;</text>'
+ '<text align="left">CASH RECONCILIATION&#10;</text>'
+ `<text align="left">Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)}&#10;</text>`
+ `<text align="left">Cash counted: R ${fmtMoney(r.cashCounted || 0)}&#10;</text>`
+ `<text align="left" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)}&#10;</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="left" ${NORMAL}>CASH RECONCILIATION&#10;</text>`
+ `<text align="left" ${NORMAL}>Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)}&#10;</text>`
+ `<text align="left" ${NORMAL}>Cash counted: R ${fmtMoney(r.cashCounted || 0)}&#10;</text>`
+ `<text align="left" width="1" height="1" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)}&#10;</text>`;
},
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 '<text align="left">--------------------------------&#10;</text>'
+ '<text align="left">DENOMINATIONS COUNTED&#10;</text>'
+ `<text align="left">${dlines.join('&#10;')}&#10;</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="left" ${NORMAL}>DENOMINATIONS COUNTED&#10;</text>`
+ `<text align="left" ${NORMAL}>${dlines.join('&#10;')}&#10;</text>`;
},
footer: function (block) {
const text = block.text || lh.footer_text;
if (!text) return '';
return '<text align="left">--------------------------------&#10;</text>'
+ `<text align="center">${xmlEscape(text)}&#10;</text>`;
return `<text align="left" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="center" ${NORMAL}>${xmlEscape(text)}&#10;</text>`;
},
};
}

View file

@ -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: () => '',