WFDASH-WEBAPP/public/pos/epos-print.js
Dave 6bedf27617 fix(pos): print the letterhead's own logo, and match delivery note to the sale slip
Logo — three separate faults, all of which had to be fixed for the
letterhead logo to print at all:

1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it
   printed the app's bundled mark (the solid black W block) and never
   looked at the letterhead at all.
2. The paths that DID use the letterhead (printTest, cashup) passed
   PocketBase's fully-qualified cross-origin URL into imageToRaster(),
   which draws to a <canvas> — getImageData() taints and throws on a
   cross-origin image, so the .catch(() => null) silently dropped the
   logo. pos-letterhead-service now returns a same-origin
   /api/v1/files/... URL (per AGENTS.md's file-URL rule).
3. letterhead_settings wasn't whitelisted in that file proxy, so the
   same-origin URL would have 403'd.

Verified against the live WESTSCOTT letterhead: its SVG rasterizes to
384x137 at ~15% ink coverage (line art), no tainted canvas.

Also fixes the company name never printing: every builder read
`letterhead.name`, but the POS letterhead record exposes
`display_name` — so the name line silently rendered empty on every
slip, on screen and on paper.

Delivery note now matches the sale slip, per request: gains the logo
and business_info blocks and the same 2x-size title, instead of being
a stripped-down variant. This also reconciles the transport
divergence flagged earlier (Bluetooth baked business info into its
header; the network builder omitted it entirely) — it is now one
reorderable business_info block on both.

resolveBlocks() now slots a newly-shipped block in at its intended
position rather than appending it to the end. Without this, any
letterhead with an already-saved layout would have printed the new
logo/business_info blocks BELOW the signature line.
2026-09-06 23:03:24 +02:00

425 lines
22 KiB
JavaScript

// Network (ePOS-Print) thermal printing for Epson TM-series Ethernet
// printers. Runs alongside the existing Web Bluetooth path in
// receipt.html — the till picks a transport in Printer settings.
//
// The printer's ePOS-Print endpoint is served over HTTPS with a
// self-signed cert and open CORS (`Access-Control-Allow-Origin: *`),
// confirmed by direct testing against the shop's TM-T20II. Each till
// device must visit `https://<printer-ip>` once in its browser and
// accept the certificate warning before fetch() calls here succeed.
window.EposPrint = (function () {
'use strict';
const SETTINGS_KEY = 'pos.printerSettings';
function getSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { type: 'bluetooth', ip: '' };
} catch (e) {
return { type: 'bluetooth', ip: '' };
}
}
function setSettings(settings) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
// Rasterize a same-origin image into the packed 1bpp bitmap the
// ePOS-Print <image> tag expects. targetWidth must be a multiple of
// 8; 384 is the width verified against the shop's TM-T20II.
function imageToRaster(url, targetWidth) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const targetHeight = Math.round(img.height * targetWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
let imageData;
try {
imageData = ctx.getImageData(0, 0, targetWidth, targetHeight);
} catch (e) {
reject(new Error('Could not read logo pixels (cross-origin image?): ' + e.message));
return;
}
const data = imageData.data;
const rowBytes = Math.ceil(targetWidth / 8);
const packed = new Uint8Array(rowBytes * targetHeight);
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const i = (y * targetWidth + x) * 4;
const luminance = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
const alpha = data[i + 3];
if (alpha > 128 && luminance < 128) {
packed[y * rowBytes + (x >> 3)] |= 0x80 >> (x % 8);
}
}
}
let binary = '';
for (let i = 0; i < packed.length; i++) binary += String.fromCharCode(packed[i]);
resolve({ width: targetWidth, height: targetHeight, base64: btoa(binary) });
};
img.onerror = () => reject(new Error('Could not load logo image: ' + url));
img.src = url;
});
}
// Rasterize the ACTIVE LETTERHEAD's logo for printing — not the
// app's bundled logo. `letterhead.logo_url` is served same-origin
// through /api/v1/files/ (see pos-letterhead-service.ts); a raw
// PocketBase URL would taint the canvas in imageToRaster() and
// silently print no logo at all.
//
// Returns null when the letterhead has no logo uploaded — we
// deliberately do NOT fall back to the bundled WFDASH logo, since
// printing one company's mark on another company's slip is worse
// than printing none.
async function letterheadLogoRaster(letterhead, width) {
const url = letterhead && letterhead.logo_url;
if (!url) return null;
return imageToRaster(url, width || 384).catch(function () { return null; });
}
function xmlEscape(s) {
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
// Per-block XML fragment renderers for the sale slip. Each function
// returns the exact XML this block contributes; `block` is the
// resolved block ({id, enabled, text}) so header_title/footer can
// 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);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return '<feed line="1"/><text align="center" width="2" height="2">' + xmlEscape(title) + '&#10;</text>';
},
logo: function () {
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
},
business_info: function () {
const parts = [];
// The POS letterhead record exposes `display_name`, not `name`
// — reading `.name` printed nothing at all on every slip.
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
if (bizName) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(bizName)}&#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" ${NORMAL}>${addrLines.join('&#10;')}&#10;</text>`);
return parts.join('');
},
date_slip: function () {
const d = new Date(s.createdAt);
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" ${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" ${NORMAL}>--------------------------------&#10;${itemLines}</text>`;
},
totals: function () {
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" ${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" 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" ${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" ${NORMAL}>--------------------------------&#10;</text>`;
if (text) out += `<text align="center" ${NORMAL}>${xmlEscape(text)}&#10;</text>`;
return out;
},
};
}
// `blocks` is the resolved block list from PosSlipLayout.resolveBlocks('sale', ...)
// (or getBlocksForPrint). Renders each enabled block in order, then
// the fixed closing feed+cut every sale slip ends with.
function buildEposXml(s, logo, blocks) {
const renderers = saleBlockRenderers(s, logo);
const body = blocks
.filter(function (b) { return b.enabled; })
.map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; })
.join('');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
async function printViaNetwork(ip, xml) {
const res = await fetch(`https://${ip}/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000`, {
method: 'POST',
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '""' },
body: xml
});
if (!res.ok) throw new Error('Printer HTTP error: ' + res.status);
const text = await res.text();
const doc = new DOMParser().parseFromString(text, 'text/xml');
const response = doc.getElementsByTagName('response')[0];
const success = response && response.getAttribute('success') === 'true';
if (!success) {
const code = response ? response.getAttribute('code') : null;
throw new Error('Printer rejected job' + (code ? ' (code: ' + code + ')' : ''));
}
}
function deliveryBlockRenderers(s, showPricing, logo) {
return {
// Matches the sale slip's header exactly (same logo, same 2x2
// title, same business-info block) — a delivery note is handed
// to the same customer and should look like the same company's
// paperwork, not a stripped-down variant.
logo: function () {
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
},
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return '<feed line="1"/>'
+ `<text align="center" width="2" height="2">${xmlEscape(title)}&#10;</text>`
+ `<text align="center" ${NORMAL}>Delivery Note&#10;</text>`;
},
business_info: function () {
const parts = [];
// The POS letterhead record exposes `display_name`, not `name`
// — reading `.name` printed nothing at all on every slip.
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
if (bizName) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(bizName)}&#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" ${NORMAL}>${addrLines.join('&#10;')}&#10;</text>`);
return parts.join('');
},
customer: function () {
let out = '';
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" ${NORMAL}>Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))}&#10;</text>`;
},
items: function () {
const itemLines = s.items.map(function (it) {
const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`;
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" ${NORMAL}>--------------------------------&#10;${itemLines}</text>`;
},
totals: function () {
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" ${NORMAL}>Received by: __________ Driver: __________&#10;</text>`;
},
};
}
// `blocks` is the resolved block list for 'delivery'. `showPricing`
// is the combined "Show pricing" setting — it gates the per-item
// price and the totals block together (see slip-layout.js).
// `logo` is a pre-rasterized {width,height,base64} or null.
function buildEposDeliveryXml(s, blocks, showPricing, logo) {
const renderers = deliveryBlockRenderers(s, showPricing, logo || null);
const body = blocks
.filter(function (b) { return b.enabled; })
.map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; })
.join('');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
// Mirrors the cashup page's "Print cash-up" button. Renders an
// ePOS-Print XML slip with the day's takings broken down by
// payment method + the operator's cash-counted variance. The
// cashup.html page collects the data and calls this — we just
// format the XML.
//
// `report` shape:
// {
// shiftLabel: "Today" | "Yesterday" | string,
// dateLabel: "04 Sep 2026",
// counts: { cash, card, eft, account },
// totals: { cash, card, eft, account },
// grand: number,
// cashCounted: number,
// variance: number,
// denoms: [{ label: "R200", value: 200, count: 2 }, ...],
// }
function cashupBlockRenderers(lh, r, logo) {
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return '<feed line="1"/>'
+ `<text align="center" width="2" height="2">${xmlEscape(title)}&#10;</text>`
+ '<text align="center" width="1" height="1">CASH-UP REPORT&#10;</text>';
},
logo: function () {
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
},
business_info: function () {
const parts = [];
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" ${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 () {
const lines = [
`Cash ${pad((r.counts && r.counts.cash) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.cash) || 0)}`,
`Card ${pad((r.counts && r.counts.card) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.card) || 0)}`,
`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" ${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" ${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" ${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 '';
const dlines = r.denoms
.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" ${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" ${NORMAL}>--------------------------------&#10;</text>`
+ `<text align="center" ${NORMAL}>${xmlEscape(text)}&#10;</text>`;
},
};
}
// `blocks` is the resolved block list for 'cashup'. `logo` is a
// pre-rasterized {width,height,base64} (see imageToRaster) or null —
// rasterizing is async, so the caller does it before calling this.
function buildEposCashupXml(letterhead, report, blocks, logo) {
const lh = letterhead || {};
const r = report || {};
const renderers = cashupBlockRenderers(lh, r, logo || null);
const body = blocks
.filter(function (b) { return b.enabled; })
.map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; })
.join('');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
// Small helper for padded numbers in the cashup slip.
function pad(n, w) {
const s = String(n == null ? 0 : n);
return s.length >= w ? s : (' '.repeat(w - s.length) + s);
}
function fmtMoney(n) {
return (Number(n) || 0).toFixed(2);
}
// Test print — used by the Settings page's "Test print" button to
// verify the printer is reachable + the letterhead renders. Builds
// a tiny "Sample slip" with the active letterhead and pushes it
// through whichever transport the till is currently configured to
// use. Throws on failure (caller surfaces the error).
async function printTest(letterhead) {
const settings = getSettings();
const lh = letterhead || {};
const now = new Date();
const sale = {
createdAt: now.toISOString(),
localId: 'TEST-' + now.getTime().toString(36).toUpperCase(),
payment: 'cash',
cashGiven: 0,
change: 0,
gross: 0,
discount: { kind: 'none', input: 0, amount: 0 },
sub: 0,
vat: 0,
total: 0,
items: [],
customer: null,
letterhead: lh,
};
// Render the regular sale XML — it'll show the letterhead +
// payment line + footer, which is what the operator wants to
// eyeball when they tap "Test print". We don't need to swap in
// a custom test layout.
const logo = await letterheadLogoRaster(lh, 384);
const blocks = window.PosSlipLayout.getBlocksForPrint(lh.id, 'sale');
const xml = buildEposXml(sale, logo, blocks);
if (settings.type === 'network') {
if (!settings.ip) {
throw new Error('Set the printer IP address in Printer settings first.');
}
await printViaNetwork(settings.ip, xml);
return 'Printed · ' + settings.ip;
}
// Bluetooth — the existing epos-print.js doesn't speak BT,
// only the receipt page does. Surface a clear message rather
// than pretending.
throw new Error('Test print on Bluetooth is not supported here — use the till\'s Print slip button.');
}
return { getSettings, setSettings, imageToRaster, letterheadLogoRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml, buildEposCashupXml, printTest };
})();