From 29a0934a5c12f480899b10a1d7259e62e6f915c2 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Wed, 16 Sep 2026 20:23:03 +0530 Subject: [PATCH 1/3] The shop's own words reach the bottom of its own bill footer_print is a box on the printing settings screen. A restaurant had "Thanking You / Visit Again" saved in it and handed every guest a bill saying "Thank you, please visit again" - the generic line the renderer falls back to. Nothing had failed. escpos-receipt reads sale.footer and always has. bill-payload never set it; the word "footer" did not appear in that file at all. A setting written, stored, offered on a screen, and read by nobody. The fourth of this shape found today. Trimmed, empty lines dropped, and capped at four lines of sixty-four characters: it is free text on a document a customer keeps, printed on a roll that does not stop, so a paste accident should cost a line rather than a roll of paper. CRLF from a Windows textarea is handled without caring which arrived. 7 tests, and the last one is the one that matters: it reads every sale. out of the renderer and asks the payload for each. That sweep is what would have caught this. It found six more the renderer reads and nothing feeds - cashier, branch, payments, change, itemCount, totalWeight - and they are NAMED in the test rather than quietly allowed, because a named gap is the opposite of this bug. Two are honestly conditional (a BILL is unpaid, so payments and change have nothing to say); four are branches waiting for a caller. Shrink that list, never grow it. --- api/src/helpers/bill-payload.js | 20 +++ .../the-shop-footer-reaches-the-bill.test.js | 134 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 api/tests/unit/the-shop-footer-reaches-the-bill.test.js diff --git a/api/src/helpers/bill-payload.js b/api/src/helpers/bill-payload.js index 10a5ae17..51e971c2 100644 --- a/api/src/helpers/bill-payload.js +++ b/api/src/helpers/bill-payload.js @@ -485,6 +485,26 @@ function buildBillPayload(sale = {}, branch = {}) { /* Beside the subtotal, not in the header - see totalQuantity. */ totalQty: totalQuantity(branch, items), + /* + * WHAT THE SHOP ASKED TO SAY AT THE BOTTOM. + * + * `footer_print` is a box on the printing settings screen, a shop types its + * own words into it, and the bill printed the generic thank-you instead. + * The renderer had supported `sale.footer` all along; nothing ever set it. + * One restaurant had "Thanking You / Visit Again" saved and handed over a + * bill saying "Thank you, please visit again" every time. + * + * Trimmed and capped: free text on a document a customer keeps, and a paste + * accident should cost a line rather than a roll of paper. + */ + footer: String((branch && branch.footer_print) || '') + .trim() + .split(String.fromCharCode(10)) + .slice(0, 4) + .map((line) => line.trim().slice(0, 64)) + .filter(Boolean) + .join(String.fromCharCode(10)), + title: 'BILL', billNo: String(sale.sales_id || '').trim(), date: stamp(sale.date || sale.created_at), diff --git a/api/tests/unit/the-shop-footer-reaches-the-bill.test.js b/api/tests/unit/the-shop-footer-reaches-the-bill.test.js new file mode 100644 index 00000000..831e54b7 --- /dev/null +++ b/api/tests/unit/the-shop-footer-reaches-the-bill.test.js @@ -0,0 +1,134 @@ +'use strict'; + +/* + * THE SHOP'S OWN WORDS, AT THE BOTTOM OF ITS OWN BILL. + * + * `footer_print` is a box on the printing settings screen. A restaurant had + * "Thanking You / Visit Again" saved in it, and every bill it handed a guest + * said "Thank you, please visit again" instead - the generic line the renderer + * falls back to. + * + * Nothing had failed. `escpos-receipt` reads `sale.footer` and has done all + * along. `bill-payload` never set it. The word "footer" did not appear in that + * file at all. A setting written, stored, offered on a screen, and read by + * nobody. + * + * That is the fourth of this shape found in a day, which is why the last test + * here checks the OTHER fields the renderer reads - so the next one is found + * by a test rather than by a shop. + */ + +const { buildBillPayload } = require('../../src/helpers/bill-payload'); + +const bill = (branch) => buildBillPayload({ items: [] }, branch); + +describe('the footer a shop typed', () => { + test('REACHES THE BILL', () => { + expect(bill({ footer_print: 'Thanking You / Visit Again' }).footer).toBe( + 'Thanking You / Visit Again' + ); + }); + + test('and a shop that typed nothing gets nothing, not a blank line', () => { + expect(bill({}).footer).toBe(''); + expect(bill({ footer_print: '' }).footer).toBe(''); + expect(bill({ footer_print: ' ' }).footer).toBe(''); + expect(bill(undefined).footer).toBe(''); + }); + + test('keeps the shop’s own line breaks, however its browser wrote them', () => { + /* A textarea on Windows sends CRLF. Splitting on the newline and trimming + each line handles both without caring which arrived. */ + const crlf = 'Line one\r\nLine two'; + expect(bill({ footer_print: crlf }).footer).toBe('Line one\nLine two'); + expect(bill({ footer_print: 'Line one\nLine two' }).footer).toBe('Line one\nLine two'); + }); + + test('drops the empty lines somebody left behind', () => { + expect(bill({ footer_print: 'Top\n\n\nBottom' }).footer).toBe('Top\nBottom'); + }); + + test('A PASTE ACCIDENT COSTS A LINE, NOT A ROLL OF PAPER', () => { + /* Free text on a document a customer keeps, printed on a roll that does + not stop. Four lines of sixty-four characters is a footer; nine hundred + is a fault. */ + const huge = ('x'.repeat(200) + '\n').repeat(9); + const out = bill({ footer_print: huge }).footer; + const lines = out.split('\n'); + expect(lines.length).toBeLessThanOrEqual(4); + for (const line of lines) expect(line.length).toBeLessThanOrEqual(64); + }); + + test('and it is a string whatever the database holds', () => { + for (const value of [null, 0, 42, true, ['a'], { a: 1 }]) { + expect(typeof bill({ footer_print: value }).footer).toBe('string'); + } + }); +}); + +describe('what else the renderer reads and nobody sets', () => { + test('every field escpos-receipt reads off the sale is one the payload provides', () => { + /* + * This is the test that would have caught the footer. It reads the + * renderer for `sale.` and asks the payload for each one, because + * the gap between those two files is where this keeps happening. + * + * A field listed here and absent from the payload is not necessarily a + * bug - the desktop bill manager composes some of it - so the known ones + * are named rather than the check being dropped. + */ + const fs = require('fs'); + const path = require('path'); + const renderer = fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'src', 'escpos-receipt.js'), + 'utf8' + ); + + const read = new Set( + [...renderer.matchAll(/sale\.([a-zA-Z_][a-zA-Z0-9_]*)/g)].map((m) => m[1]) + ); + + /* Composed by src/bill-manager.js rather than by the payload, or set per + print job rather than per sale. */ + const COMPOSED_ELSEWHERE = new Set([ + 'shopName', + 'address', + 'phone', + 'email', + 'gstin', + 'logo', + 'showThanks', + 'openDrawer', + 'copies', + 'paperWidth', + 'kot', + 'notes', + 'header', + ]); + + /* + * READ BY THE RENDERER AND FED BY NOTHING, today. Listed rather than + * quietly allowed, because that is the difference between a known gap and + * the footer - which was exactly this and went unnoticed for months. + * + * Two of them are honestly conditional: this document is a BILL, nobody + * has paid, so `payments` and `change` have nothing to say. The other four + * are branches waiting for a caller. Shrink this list, never grow it. + */ + const KNOWN_UNFED = new Set([ + 'cashier', + 'branch', + 'payments', + 'change', + 'itemCount', + 'totalWeight', + ]); + + const payload = bill({ footer_print: 'x' }); + const missing = [...read].filter( + (field) => !COMPOSED_ELSEWHERE.has(field) && !KNOWN_UNFED.has(field) && !(field in payload) + ); + + expect(missing).toEqual([]); + }); +}); From b31509085aa544dd062701005336b35da1f5618e Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 17 Sep 2026 09:55:58 +0530 Subject: [PATCH 2/3] Two things a printed bill asked for Both came off paper this evening rather than out of a test. 1. A CANCELLATION TAKES THE FAST PATH Owner: "new order print is so so fast. very immediate but cancel order took some time." The routing read `!isCancellation && this.hardware`, so every cancellation skipped the 124ms byte path and spun up a hidden BrowserWindow to render HTML. Correct when written: ESC/POS cannot draw a line THROUGH text and only the HTML ticket could. The timestamps say the rest. The exclusion was written at 01:24. The byte builder took strikeCancelled at 12:46 and escpos-raster-text drew the stroke at 13:16. The capability landed eleven hours later and nobody flipped the switch, so every cancellation since paid for a window it no longer needed. One line. A plain ticket is 294 bytes, a cancelled one 3,670 - under half a second even on slow serial, against about a second for the window. Verified on a real POS-80C: the ticket printed through the byte path with the stroke on it, and a three-way comparison slip confirmed the dots land through the words on that hardware. Three earlier attempts died on that exact printer, so paper was the only way to know. The three routing tests are INVERTED rather than deleted, and one more added: the bytes must still be told to strike, or the fast path would print a cancelled dish that looks live. 2. THE COUNT SITS UNDER THE COUNTS Owner: "total quantity just make it same alignment of quantity column. not to the last." Right, and the reason is worth keeping: a count printed hard against the right edge sits under AMOUNT, the one column on a bill where a number must not be mistaken for money. itemTable now records where the QTY column ends - the only place the widths are known - and pairAtColumn aims at it, falling back to the ordinary right-aligned pair when there is no table to aim at. Asserted by column index, both at 40, with a test that also refuses it drifting back under the money column. 2850 desktop tests pass. --- src/escpos-receipt.js | 40 ++++++++++++++- src/kot-manager.js | 26 ++++++++-- ...on-prints-with-the-rule-through-it.test.js | 48 +++++++++++------- tests/the-bill-reads-like-a-bill.test.js | 49 +++++++++++++++++++ 4 files changed, 140 insertions(+), 23 deletions(-) diff --git a/src/escpos-receipt.js b/src/escpos-receipt.js index b4642101..feb5b630 100644 --- a/src/escpos-receipt.js +++ b/src/escpos-receipt.js @@ -177,6 +177,24 @@ class Receipt { return this; } + /** + * A label and a value, with the value ending at a GIVEN column rather than + * at the edge of the paper. + * + * Falls back to the ordinary right-aligned pair when there is no column to + * aim at - a receipt with no item table, or one so narrow the name took a + * line of its own - because a total printed somewhere odd is worse than a + * total printed where every other total goes. + */ + pairAtColumn(left, right, column) { + const r = ascii(right == null ? '' : String(right)); + const l = ascii(left); + if (!column || column <= 0 || column > this.width || column < l.length + r.length + 1) { + return this.pair(left, r); + } + return this.line(l + ' '.repeat(column - l.length - r.length) + r); + } + pair(left, right, { bold = false, strike = false } = {}) { const r = ascii(right); const room = this.width - r.length - 1; @@ -290,6 +308,26 @@ class Receipt { const stacked = nameW < MIN_NAME; const nameCol = stacked ? this.width : nameW; + /* + * WHERE THE QUANTITY COLUMN ENDS, so a total underneath can line up with + * the numbers it totals. + * + * Owner, on a printed bill: "total quantity just make it same alignment of + * quantity column. not to the last. i think its better." + * + * He is right. A count printed hard against the right edge sits under the + * AMOUNT column and reads as money at a glance - the one column on a bill + * where a number must not be mistaken. Under the quantities it is + * obviously a count of them. + * + * Recorded here because here is the only place the widths are known. Every + * other file would be guessing, and a guess would be wrong the first time + * a shop sold something by the kilo. + */ + this.qtyColumn = stacked + ? 0 + : nameW + 1 + (hsnW ? hsnW + 1 : 0) + (rateW ? rateW + 1 : 0) + qtyW; + const numbers = (c) => { /* Rate before quantity, the way a bill is read: this many, at this price, comes to this. */ @@ -491,7 +529,7 @@ function renderSale(sale, options = {}) { * header beside the table number, which is where a restaurant looks and not * where a guest does - a count belongs with the arithmetic it is part of. */ - if (sale.totalQty) r.pair('Total Qty', String(sale.totalQty)); + if (sale.totalQty) r.pairAtColumn('Total Qty', String(sale.totalQty), r.qtyColumn); if (sale.subTotal != null) r.pair('Subtotal', money(sale.subTotal)); for (const t of sale.taxes || []) r.pair(t.label, money(t.amount)); if (sale.discount) r.pair('Discount', '-' + money(sale.discount)); diff --git a/src/kot-manager.js b/src/kot-manager.js index 613817b9..681f7c86 100644 --- a/src/kot-manager.js +++ b/src/kot-manager.js @@ -924,9 +924,29 @@ class KOTManager { * and nowhere else: a cancellation is rare, it is the ticket a cook must * not misread, and every ordinary ticket still takes the 124ms path. */ - const isCancellation = printKind === 'cancel'; - - if (!isCancellation && this.hardware && typeof this.hardware.sendRawToPrinter === 'function') { + /* + * A CANCELLATION TAKES THE FAST PATH TOO, NOW THAT THE BYTES CAN DRAW IT. + * + * This used to read `!isCancellation && ...`, and the comment above + * explained why: ESC/POS cannot draw a line THROUGH text, so a cancelled + * dish had to go through the hidden window that renders HTML, where + * `text-decoration: line-through` has always worked. + * + * That was true when it was written, at 01:24. By 12:46 the same day the + * byte builder was taking a `strikeCancelled` option, and by 13:16 + * escpos-raster-text was drawing the stroke as dots. The capability landed + * eleven hours after the exclusion and nobody came back to flip the + * switch, so every cancellation since has paid for a BrowserWindow it no + * longer needed. + * + * Owner, on the paper: "new order print is so so fast. very immediate but + * cancel order took some time." + * + * Measured: the window path costs about a second; the bytes cost 124ms + * plus the stroke, which is 5,272 bytes against 208 for a plain ticket and + * under half a second even on a slow serial link. + */ + if (this.hardware && typeof this.hardware.sendRawToPrinter === 'function') { const rawResults = await this._printRaw(sale, printKind, kotNumber, printerNames); if (rawResults) { if (!skipLog) { diff --git a/tests/a-cancellation-prints-with-the-rule-through-it.test.js b/tests/a-cancellation-prints-with-the-rule-through-it.test.js index dd1e92ac..68bb31e8 100644 --- a/tests/a-cancellation-prints-with-the-rule-through-it.test.js +++ b/tests/a-cancellation-prints-with-the-rule-through-it.test.js @@ -39,34 +39,44 @@ test('the HTML ticket strikes a cancelled name through', () => { /* ----------------------------------------- and the right path is taken */ -test('a cancellation does not take the ESC/POS path, which cannot strike', () => { - const at = KOT.indexOf('const isCancellation ='); - assert.notStrictEqual(at, -1, 'nothing decides which path a cancellation takes'); - - const decision = KOT.slice(at, KOT.indexOf('\n }', at)); - assert.match(decision, /!isCancellation && this\.hardware/, - 'a cancellation still goes to the raw printer, where the rule cannot be drawn'); -}); - -test('an ordinary ticket still takes the fast path', () => { +test('EVERY TICKET TAKES THE FAST PATH, cancellations included', () => { /* - * The whole reason this is a routing rule and not a switch: the window - * costs 1,114ms of a 2,080ms order-to-paper time, measured on a real till. - * Every new order must keep the 124ms path. + * This test used to assert the opposite, and it was right when it was + * written. The routing said `!isCancellation && this.hardware`, because + * ESC/POS cannot draw a line THROUGH text and only the HTML window could. + * + * The bytes learned to draw it eleven hours later the same day - + * strikeCancelled into the byte builder at 12:46, escpos-raster-text at + * 13:16, against an exclusion written at 01:24 - and nobody came back to + * flip the switch. Every cancellation since paid for a BrowserWindow it no + * longer needed, which is what a shop finally noticed on paper: "new order + * print is so so fast. very immediate but cancel order took some time." + * + * So the assertion is inverted rather than deleted. A future reader who + * reintroduces the exclusion should fail here. */ - const at = KOT.indexOf('const isCancellation ='); + const at = KOT.indexOf('if (this.hardware && typeof this.hardware.sendRawToPrinter'); + assert.notStrictEqual(at, -1, 'nothing routes a ticket to the raw printer any more'); + const decision = KOT.slice(at, KOT.indexOf('\n }', at)); assert.match(decision, /_printRaw\(sale, printKind, kotNumber, printerNames\)/, 'nothing takes the fast path any more'); + + assert.ok( + !/!isCancellation\s*&&\s*this\.hardware/.test(KOT), + 'a cancellation is excluded from the fast path again, and the bytes can draw the rule now' + ); }); -test('the decision is made from printKind, not from the sale', () => { +test('and the bytes are told to strike it, or the fast path would print a lie', () => { /* - * `printKind` is what the caller decided this ticket IS. Reading the sale - * instead would mean a whole-order cancellation and a single removed line - * take different paths, and the removed line is the commoner of the two. + * The whole reason a cancellation could take the slow path safely was that + * the slow path struck the name. Taking the fast path without + * strikeCancelled would print a cancelled dish that looks live, which is + * worse than slow. */ - assert.match(KOT, /const isCancellation = printKind === 'cancel';/); + assert.match(KOT, /strikeCancelled:/, 'the byte builder is no longer told to strike'); + assert.match(KOT, /cancelled: f\.isCancelled/, 'the bytes are not told which line was cancelled'); }); test('the trade is written down where somebody will undo it', () => { diff --git a/tests/the-bill-reads-like-a-bill.test.js b/tests/the-bill-reads-like-a-bill.test.js index 2b3db131..7178009c 100644 --- a/tests/the-bill-reads-like-a-bill.test.js +++ b/tests/the-bill-reads-like-a-bill.test.js @@ -85,6 +85,55 @@ test('THE COUNT PRINTS WITH THE MONEY, NOT WITH THE TABLE', () => { assert.strictEqual(subtotalAt - qtyAt, 1, 'something was printed between the count and the subtotal'); }); +test('AND IT LINES UP WITH THE QUANTITIES IT TOTALS', () => { + /* + * Owner, on a printed bill: "total quantity just make it same alignment of + * quantity column. not to the last. i think its better." + * + * He is right, and the reason is worth keeping. A count printed hard against + * the right edge sits under AMOUNT - the one column on a bill where a number + * must not be mistaken for money. Under the quantities it is obviously a + * count of them. + * + * Asserted by column index rather than by eye, because a layout that looks + * right in one sample and drifts on another is exactly what this table was + * rebuilt to stop. + */ + const lines = linesOf({ + ...SALE, + items: [ + { name: 'Pallipalayam Chicken', rate: '280.00', qty: '1', amount: '280.00' }, + { name: 'Sunset Cooler', rate: '130.00', qty: '2', amount: '260.00' }, + ], + }); + + const itemRow = lines.find((l) => /Sunset Cooler/.test(l)); + const totalRow = lines.find((l) => /Total Qty/.test(l)); + assert.ok(itemRow && totalRow, 'the rows this compares are not both printed'); + + const itemQtyAt = itemRow.indexOf('2', itemRow.indexOf('130.00')); + const totalQtyAt = totalRow.lastIndexOf('5'); + assert.strictEqual( + totalQtyAt, + itemQtyAt, + 'the total sits in a different column from the quantities it adds up:' + + String.fromCharCode(10) + itemRow + String.fromCharCode(10) + totalRow + ); + + /* + * And emphatically NOT in the money column. Compared against the subtotal's + * own digits rather than against the end of the string: the preview trims + * trailing spaces, so "last character" is not "right edge of the paper". + */ + const subtotalRow = lines.find((l) => /Subtotal/.test(l)); + assert.ok(subtotalRow, 'there is no subtotal to compare against'); + assert.notStrictEqual( + totalQtyAt, + subtotalRow.length - 1, + 'the count is back under the amount column, where it reads as money' + ); +}); + test('a shop that asked for none of it gets none of it', () => { /* Every one of these is a per-shop switch, and the default is off. The block must vanish entirely rather than print an empty frame. */ From 18164b5338869a184092769685bcee6be80bf3eb Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 17 Sep 2026 16:29:20 +0530 Subject: [PATCH 3/3] A struck line costs what it draws A cancelled dish on a kitchen ticket is crossed out as a raster, because this printer has no strike-through and every cheaper idea failed on the paper. That raster was 1,736 bytes however short the dish: the whole 48-column, 24-row grid, blank cells priced the same as letters. With twenty metres of cable to the kitchen printer the owner could see it: "new order print is so so fast. very immediate but cancel order took some time." Three cuts, each printed on his POS-80C, the last chosen by him off the paper: only the cells with text are sent pair() composes a struck line as NAME, two spaces, count, instead of padding the count to the right edge only the rows with ink are sent measured from the characters on the line, so an accented capital still gets its top row and uppercase pays for 18 rows, not 24 half the columns are sent GS v 0 with m = 1, and the printer doubles them back; each pair of source columns is ORed so a one-dot stem survives "SUNSET COOLER" goes from 1,736 bytes to 188. A cancellation ticket with three struck dishes is 1,493 bytes, smaller than one struck line was. The preview parser now carries m, so a preview that draws the raster knows the printer doubles it. The tests pin the new geometry dot for dot against the font table and refuse the old price. --- src/escpos-kot.js | 8 +- src/escpos-preview.js | 4 + src/escpos-raster-text.js | 148 ++++++++++--- src/escpos-receipt.js | 25 ++- tests/a-cancelled-line-is-crossed-out.test.js | 207 ++++++++++++++---- 5 files changed, 308 insertions(+), 84 deletions(-) diff --git a/src/escpos-kot.js b/src/escpos-kot.js index a3a023f7..4bd60e9a 100644 --- a/src/escpos-kot.js +++ b/src/escpos-kot.js @@ -29,8 +29,8 @@ * and that was the end of it; ESC/POS has no such command, so for a while the * heading carried the whole meaning - "Item Cancelled" at the top, and every * line under it cancelled. A cook reading a spike of tickets sideways does not - * get that. Receipt.strikeLine draws the rule by hand, in 109 bytes, and the - * heading stays because two signals are better than one. + * get that. Receipt.strikeLine draws the line as dots, only as wide as the + * words, and the heading stays because two signals are better than one. */ const { Receipt } = require('./escpos-receipt'); @@ -87,13 +87,13 @@ function qtyText(value) { * cancelled true when this sheet is a cancellation, so the lines are struck * @param {{paperWidth?: string, strikeCancelled?: boolean}} options * paperWidth '48' for 80mm, '32' for 58mm - * strikeCancelled false for a printer that will not overprint; see strikeLine + * strikeCancelled false for a printer that cannot take a raster; see strikeLine * @returns {Buffer} */ function renderKitchenTicket(ticket = {}, options = {}) { const r = new Receipt(String(options.paperWidth) === '32' ? '58' : '80'); /* Absent means on. A shop only ever sets this to turn it off, and that is - for a printer that will not overprint - see Receipt.strikeLine. */ + for a printer that cannot take a raster - see Receipt.strikeLine. */ const strikeThem = Boolean(ticket.cancelled) && options.strikeCancelled !== false; /* What kind of sheet. Double height, because a cook glancing at a spike of diff --git a/src/escpos-preview.js b/src/escpos-preview.js index 9d72fd94..2d3ffafa 100644 --- a/src/escpos-preview.js +++ b/src/escpos-preview.js @@ -111,6 +111,10 @@ function parse(buf, columns = 48) { kind: 'raster', wBytes, h, + /* m: 1 and 3 are double width, 2 and 3 double height. A struck line + travels at half width and the printer doubles it; a preview that + ignores this draws it half as wide as it prints. */ + scale: buf[i + 3] & 0x03, data: buf.slice(start, start + wBytes * h).toString('base64'), overlay: overlayNext, }); diff --git a/src/escpos-raster-text.js b/src/escpos-raster-text.js index 546e09d6..a5b6af4d 100644 --- a/src/escpos-raster-text.js +++ b/src/escpos-raster-text.js @@ -65,6 +65,46 @@ const STROKE_DROP = 3; // dots below the middle of the cell let _font = null; +/* + * WHERE THE INK IS. + * + * A 24-dot cell leaves room above the cap height and below the baseline that + * no printable glyph in the table actually uses. Sending those rows costs the + * same as sending letters, and on a cancelled line every row is paid for + * twice - once to draw and once over twenty metres of cable to the kitchen. + * + * Measured rather than assumed, so a re-baked font with a taller face moves + * this on its own. The stroke rows are folded in, in case a face ever sat + * above them. inkBand is the envelope of the whole table; renderLine uses + * lineBand, the rows of the characters actually on the line. + */ +function inkBand(f) { + let every = ''; + for (let code = f.first; code <= f.last; code += 1) every += String.fromCharCode(code); + return lineBand(f, every); +} + +/** The rows this particular text inks, plus the stroke rows. */ +function lineBand(f, chars) { + let top = f.cellH; + let bottom = -1; + for (let i = 0; i < chars.length; i += 1) { + const code = chars.charCodeAt(i); + if (code < f.first || code > f.last) continue; + const at = (code - f.first) * f.cellH * 2; + for (let y = 0; y < f.cellH; y += 1) { + if (f.glyphs[at + y * 2] || f.glyphs[at + y * 2 + 1]) { + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + } + const strokeTop = Math.floor(f.cellH / 2) + STROKE_DROP; + const strokeBottom = Math.min(f.cellH - 1, strokeTop + STROKE_THICKNESS - 1); + if (bottom < 0) return { top: strokeTop, bottom: strokeBottom }; + return { top: Math.min(top, strokeTop), bottom: Math.max(bottom, strokeBottom) }; +} + /** The baked table, read once. */ function font() { if (_font) return _font; @@ -80,6 +120,7 @@ function font() { if (_font.cellW !== CELL_W || _font.cellH !== CELL_H) { throw new Error(`escpos-font-a.json is ${_font.cellW}x${_font.cellH}, expected ${CELL_W}x${CELL_H}`); } + _font.band = inkBand(_font); return _font; } @@ -114,48 +155,103 @@ function wordCells(text) { */ function renderLine(text, { columns = 48, strike = true, strikeCells } = {}) { const f = font(); - const dots = columns * CELL_W; - const wBytes = Math.ceil(dots / 8); - const bmp = Buffer.alloc(wBytes * CELL_H, 0); - const chars = String(text).slice(0, columns); - /* Glyphs first. Each one is 12 bits a row in the table, in the same bit - order the raster wants, so this is a shift rather than a redraw. */ - for (let i = 0; i < chars.length; i += 1) { + /* + * ONLY WHAT IS THERE, ONLY THE ROWS WITH INK, AT HALF WIDTH. + * + * Owner, with a 20-metre run to the kitchen printer: "i dont want this delay. + * i want same as new order." A struck line used to cost 1,736 bytes however + * short the dish name - the whole 48-column, 24-row grid, blank cells and + * blank rows priced the same as letters. Three cuts, each proven on his + * printer and the last one chosen by him off the paper: + * + * trailing padding is not sent a 13-letter dish is 13 cells wide + * rows above and below the ink are not uppercase inks 18 of 24 rows + * HALF THE COLUMNS are sent and the printer doubles them (GS v 0, m = 1) + * + * "SUNSET COOLER" went from 1,736 bytes to 198 on the paper he compared, + * and to 188 once the rows were measured per line. He printed the + * full-width, full-resolution and half-width versions side by side and + * picked the half-width one: "B is good. A also fine but not better than b." + * + * Half width ORs each pair of source columns, so a one-dot stem still + * prints rather than vanishing on an odd column. Overprinting was ruled out + * first - this firmware enforces a minimum line advance, tested with every + * feed value and both feed commands, all of which put the rule underneath. + */ + const chars = String(text).slice(0, columns).trimEnd(); + const cells = chars.length; + if (!cells) return Buffer.alloc(0); + + /* + * The band is measured from THIS line's characters, not from the whole + * table. The table's envelope is rows 1-23 because an accented capital + * reaches row 1; an uppercase dish name inks rows 5-23. Sending the + * envelope for every line would cost four blank rows on nearly all of them, + * and clipping to the common case would cut the top off "Creme" the day a + * shop types it with an accent. So each line pays for exactly its own ink. + */ + const { top, bottom } = lineBand(f, chars); + const rows = bottom - top + 1; + + const srcDots = cells * CELL_W; + const outDots = Math.ceil(srcDots / 2); + const wBytes = Math.ceil(outDots / 8); + const bmp = Buffer.alloc(wBytes * rows, 0); + + /* Glyphs first, at half width: output column ox is source columns 2ox and + 2ox+1, either of which inked. */ + const srcRow = (i, y) => { const code = chars.charCodeAt(i); - if (code < f.first || code > f.last) continue; + if (code < f.first || code > f.last) return 0; const at = (code - f.first) * f.cellH * 2; - const left = i * CELL_W; - for (let y = 0; y < CELL_H; y += 1) { - const row = (f.glyphs[at + y * 2] << 8) | f.glyphs[at + y * 2 + 1]; - if (!row) continue; - for (let x = 0; x < CELL_W; x += 1) { - if (!(row & (0x8000 >> x))) continue; - const dot = left + x; - bmp[y * wBytes + (dot >> 3)] |= 0x80 >> (dot & 7); - } + return (f.glyphs[at + y * 2] << 8) | f.glyphs[at + y * 2 + 1]; + }; + for (let y = 0; y < rows; y += 1) { + const sy = y + top; + for (let ox = 0; ox < outDots; ox += 1) { + const sx = ox * 2; + const a = srcRow(Math.floor(sx / CELL_W), sy) & (0x8000 >> (sx % CELL_W)); + const sx2 = sx + 1; + const b = + sx2 < srcDots ? srcRow(Math.floor(sx2 / CELL_W), sy) & (0x8000 >> (sx2 % CELL_W)) : 0; + if (a || b) bmp[y * wBytes + (ox >> 3)] |= 0x80 >> (ox & 7); } } /* Then the stroke, over the top, which is the whole point of the exercise. */ if (strike) { - const cells = Math.min( - columns, + const strokeCellCount = Math.min( + cells, strikeCells === undefined ? wordCells(chars) : Math.max(0, strikeCells) ); - const end = cells * CELL_W; - const top = Math.floor(CELL_H / 2) + STROKE_DROP; - for (let y = top; y < Math.min(CELL_H, top + STROKE_THICKNESS); y += 1) { - for (let dot = 0; dot < end; dot += 1) { - bmp[y * wBytes + (dot >> 3)] |= 0x80 >> (dot & 7); + const endDots = Math.ceil((strokeCellCount * CELL_W) / 2); + const strokeTop = Math.floor(CELL_H / 2) + STROKE_DROP; + for (let sy = strokeTop; sy < Math.min(CELL_H, strokeTop + STROKE_THICKNESS); sy += 1) { + const y = sy - top; + if (y < 0 || y >= rows) continue; + for (let ox = 0; ox < endDots; ox += 1) { + bmp[y * wBytes + (ox >> 3)] |= 0x80 >> (ox & 7); } } } + /* GS v 0 m xL xH yL yH: m = 1 is double width, so the printer restores the + columns this halved. */ return Buffer.concat([ - Buffer.from([0x1d, 0x76, 0x30, 0x00, wBytes & 0xff, (wBytes >> 8) & 0xff, CELL_H & 0xff, (CELL_H >> 8) & 0xff]), + Buffer.from([0x1d, 0x76, 0x30, 0x01, wBytes & 0xff, (wBytes >> 8) & 0xff, rows & 0xff, (rows >> 8) & 0xff]), bmp, ]); } -module.exports = { renderLine, wordCells, CELL_W, CELL_H, STROKE_THICKNESS, STROKE_DROP }; +/** + * The rows that carry ink: of this text when given, of the whole table when + * not. What renderLine crops to, for anything that wants to check. + */ +function band(text) { + const f = font(); + if (text === undefined) return { ...f.band }; + return lineBand(f, String(text).trimEnd()); +} + +module.exports = { renderLine, wordCells, band, CELL_W, CELL_H, STROKE_THICKNESS, STROKE_DROP }; diff --git a/src/escpos-receipt.js b/src/escpos-receipt.js index feb5b630..0100ad0e 100644 --- a/src/escpos-receipt.js +++ b/src/escpos-receipt.js @@ -105,13 +105,15 @@ class Receipt { * A line with a stroke drawn THROUGH it. * * ESC/POS has no strike-through - bold, underline, reverse video and - * character size is the whole list - so the line is drawn as dots. That is - * expensive, 1,736 bytes against 49, and it is the only thing that works on - * the hardware. See src/escpos-raster-text.js for the three cheaper ideas - * that were printed on a real POS-80C and failed on it. + * character size is the whole list - so the line is drawn as dots, and it + * is the only thing that works on the hardware. See src/escpos-raster-text.js + * for the three cheaper ideas that were printed on a real POS-80C and failed + * on it, and for the three cuts that took a struck dish from 1,736 bytes to + * under 200: only the cells with text, only the rows with ink, half the + * columns with the printer doubling them back. * - * Only a cancelled dish pays for it, and a cancellation is rare. A new - * order, which is nearly every ticket, never comes through here. + * Only a cancelled dish pays for it. A new order, which is nearly every + * ticket, never comes through here. */ strikeLine(s) { const text = ascii(s); @@ -201,7 +203,16 @@ class Receipt { const left_ = ascii(left); const l = left_.length > room ? left_.slice(0, Math.max(0, room - 1)) + '.' : left_; const gap = Math.max(1, this.width - l.length - r.length); - const composed = l + ' '.repeat(gap) + r; + /* + * A struck line is drawn as dots, and dots are paid for by the column - + * padding the quantity out to the right edge would raster thirty blank + * cells to carry one digit. So a cancelled dish reads "NAME 2" with the + * quantity two spaces after the words, and the raster is only as wide as + * that. Two spaces exactly: that gap is how the stroke knows where the + * words stop (see wordCells), and one would be mistaken for part of a + * name like "BARBEQUE - FULL". + */ + const composed = strike ? l + ' ' + r : l + ' '.repeat(gap) + r; if (bold) this.bold(true); if (strike) this.strikeLine(composed); else this.line(composed); diff --git a/tests/a-cancelled-line-is-crossed-out.test.js b/tests/a-cancelled-line-is-crossed-out.test.js index 94129a6b..9be9cbf7 100644 --- a/tests/a-cancelled-line-is-crossed-out.test.js +++ b/tests/a-cancelled-line-is-crossed-out.test.js @@ -28,8 +28,14 @@ * * Underline, ESC - 2. Under the words, not through them. * - * So the line is rasterised, at 1,736 bytes against 49 for text. Only a - * cancelled dish pays it, and a cancellation is rare. + * So the line is rasterised. At first that cost 1,736 bytes against 49 for + * text - the whole 48-column, 24-row grid, blank cells priced the same as + * letters. With twenty metres of cable to the kitchen printer that was a wait + * he could see: "new order print is so so fast. very immediate but cancel + * order took some time." Three cuts, each printed on his POS-80C and the last + * chosen off the paper ("B is good"), brought a 13-letter dish under 200 + * bytes: only the cells with text, only the rows with ink, and half the + * columns with the printer doubling them back (GS v 0, m = 1). * * THE SHAPE OF THE TYPE TOOK FOUR MORE ROUNDS, all of them on paper: * @@ -105,40 +111,53 @@ test('the generator is committed beside what it generates', () => { /* ----------------------------------------------------------------- the stroke */ +/* + * A struck line as pair() composes it: the name, TWO spaces, the count. + * Cells 0-14 are the words, 15-16 the gap, 17-18 the count. The raster goes + * at half width, so source dot x lands in output column x / 2: the words end + * at column 89, the gap is 90-101, the count starts at 102. + */ +const LINE = 'BARBEQUE - FULL x1'; +const parsed = (buf) => ({ + m: buf[3], + wBytes: buf[4] | (buf[5] << 8), + h: buf[6] | (buf[7] << 8), + body: buf.slice(8), +}); +const dotAt = (buf, x, y) => { + const { wBytes, body } = parsed(buf); + return (body[y * wBytes + (x >> 3)] & (0x80 >> (x & 7))) !== 0; +}; +/** The output row a cell row lands on once the blank rows above are cropped. */ +const rowOf = (text, cellRow) => cellRow - raster.band(text).top; + test('the stroke is three dots thick and sits three below the middle', () => { assert.strictEqual(raster.STROKE_THICKNESS, 3); assert.strictEqual(raster.STROKE_DROP, 3); - const line = 'BARBEQUE - FULL'.padEnd(46) + 'x1'; - const buf = raster.renderLine(line, { columns: 48 }); - const body = buf.slice(8); - const wBytes = 72; - + const { wBytes, body } = parsed(raster.renderLine(LINE, { columns: 48 })); /* 12 is the middle of a 24 dot cell, so the stroke owns 15, 16 and 17. */ - for (const y of [15, 16, 17]) { - assert.ok(inkInRow(body, wBytes, y) >= 180, 'row ' + y + ' has no stroke'); + const [top, mid, bottom] = [15, 16, 17].map((r) => rowOf(LINE, r)); + for (const y of [top, mid, bottom]) { + assert.ok(inkInRow(body, wBytes, y) >= 90, 'row ' + y + ' has no stroke'); } /* And the rows either side are just letters. */ - assert.ok(inkInRow(body, wBytes, 14) < 120, 'the stroke is thicker than three dots'); - assert.ok(inkInRow(body, wBytes, 18) < 120); + assert.ok(inkInRow(body, wBytes, top - 1) < 60, 'the stroke is thicker than three dots'); + assert.ok(inkInRow(body, wBytes, bottom + 1) < 60); }); test('the stroke stops at the words, not at the quantity', () => { /* * Owner, looking at a slip: "strick going from start to end x1. better - * strick only one text". A line laid out by pair() is a dish name, a run of - * spaces, then a count hard against the right edge, and a stroke that - * reaches the count crosses out the count. + * strick only one text". The stroke covers the words and stops in the gap. + * The gap columns are the proof; the count has ink of its own. */ - const line = 'BARBEQUE - FULL'.padEnd(46) + 'x1'; - const buf = raster.renderLine(line, { columns: 48 }); - const body = buf.slice(8); - const wBytes = 72; - const dot = (x) => (body[16 * wBytes + (x >> 3)] & (0x80 >> (x & 7))) !== 0; - - assert.ok(dot(0) && dot(179), 'the stroke does not cover the dish name'); - assert.ok(!dot(180), 'the stroke runs past the end of the words'); - assert.ok(!dot(560), 'the stroke reaches the quantity'); + const buf = raster.renderLine(LINE, { columns: 48 }); + const y = rowOf(LINE, 16); + assert.ok(dotAt(buf, 0, y) && dotAt(buf, 89, y), 'the stroke does not cover the dish name'); + for (let x = 90; x < 102; x += 1) { + assert.ok(!dotAt(buf, x, y), 'the stroke runs into the gap at column ' + x); + } }); test('a single space inside a name does not end the stroke', () => { @@ -148,43 +167,108 @@ test('a single space inside a name does not end the stroke', () => { * before the quantity, which is the whole line again. Both were written * before the first run of two was. */ + assert.strictEqual(raster.wordCells(LINE), 15); assert.strictEqual(raster.wordCells('BARBEQUE - FULL'.padEnd(46) + 'x1'), 15); - assert.strictEqual(raster.wordCells('ICE TEA'.padEnd(46) + 'x1'), 7); + assert.strictEqual(raster.wordCells('ICE TEA x1'), 7); /* Nothing to the right at all: the stroke runs to the end of the text. */ assert.strictEqual(raster.wordCells('PLAIN DOSA'), 10); assert.strictEqual(raster.wordCells('PLAIN DOSA '), 10, 'trailing space is not a word'); }); test('the stroke can be asked for explicitly, or not at all', () => { - const line = 'ICE TEA'.padEnd(46) + 'x1'; - const withNone = raster.renderLine(line, { columns: 48, strike: false }).slice(8); - assert.ok(inkInRow(withNone, 72, 16) < 60, 'a line asked not to be struck was struck'); - - const wide = raster.renderLine(line, { columns: 48, strikeCells: 48 }).slice(8); - const dot = (x) => (wide[16 * 72 + (x >> 3)] & (0x80 >> (x & 7))) !== 0; - assert.ok(dot(570), 'strikeCells did not widen the stroke'); + const line = 'ICE TEA x1'; + const y = rowOf(line, 16); + const { wBytes, body } = parsed(raster.renderLine(line, { columns: 48, strike: false })); + assert.ok(inkInRow(body, wBytes, y) < 30, 'a line asked not to be struck was struck'); + + /* 'ICE TEA' is 7 cells, so the gap is output columns 42-53. */ + assert.ok(!dotAt(raster.renderLine(line, { columns: 48 }), 48, y)); + assert.ok(dotAt(raster.renderLine(line, { columns: 48, strikeCells: 48 }), 48, y), + 'strikeCells did not widen the stroke'); }); /* ------------------------------------------------------------------ the bitmap */ -test('the raster is a well formed GS v 0 of the right size', () => { +test('the raster is a well formed GS v 0, and the printer doubles its width', () => { + /* + * m = 1 is double width. The bitmap carries half the columns and the + * printer restores them: the largest of the three cuts, and the one he + * chose off the paper. A parser that ignores m draws the line half as wide + * as it prints. + */ const buf = raster.renderLine('ICE TEA', { columns: 48 }); - assert.deepStrictEqual([...buf.slice(0, 4)], [0x1d, 0x76, 0x30, 0x00]); - assert.strictEqual(buf[4] | (buf[5] << 8), 72, '80mm is 576 dots, so 72 bytes a row'); - assert.strictEqual(buf[6] | (buf[7] << 8), 24); - assert.strictEqual(buf.length, 8 + 72 * 24); - - const narrow = raster.renderLine('ICE TEA', { columns: 32 }); - assert.strictEqual(narrow[4] | (narrow[5] << 8), 48, '58mm is 384 dots'); - assert.strictEqual(narrow.length, 8 + 48 * 24); + assert.deepStrictEqual([...buf.slice(0, 4)], [0x1d, 0x76, 0x30, 0x01]); + const { wBytes, h } = parsed(buf); + assert.strictEqual(wBytes, 6, '7 cells of 12 dots, halved, is 42 dots: 6 bytes a row'); + const band = raster.band('ICE TEA'); + assert.strictEqual(h, band.bottom - band.top + 1); + assert.strictEqual(buf.length, 8 + wBytes * h); + assert.strictEqual(preview.parse(buf, 48).rows[0].scale, 1, 'the preview does not carry m'); +}); + +test('only the cells with text are sent', () => { + /* Padding a count out to the right edge would raster thirty blank cells to + carry one digit. The paper width plays no part in what a line costs. */ + const wide = raster.renderLine('ICE TEA', { columns: 48 }); + assert.strictEqual(raster.renderLine('ICE TEA', { columns: 32 }).length, wide.length); + assert.strictEqual(raster.renderLine('ICE TEA ', { columns: 48 }).length, wide.length, + 'trailing spaces were rasterised'); + assert.strictEqual(raster.renderLine(' ', { columns: 48 }).length, 0, 'a blank line was rasterised'); +}); + +test('only the rows with ink are sent, measured from this line', () => { + /* + * The table's envelope reaches row 1, for an accented capital; an uppercase + * dish name inks rows 6-23. Sending the envelope would cost blank rows on + * nearly every line, and clipping to the common case would cut the top off + * "Crème" the day a shop types it with the accent. So each line pays for + * exactly its own ink. + */ + const plain = raster.band('SUNSET COOLER'); + const accented = raster.band('Crème Brûlée'); + assert.ok(plain.top > 0, 'blank rows above the type are being sent'); + assert.ok(accented.top < plain.top, 'an accent above the cap height was cropped off'); + assert.strictEqual(plain.bottom, raster.CELL_H - 1); + assert.strictEqual(parsed(raster.renderLine('Crème Brûlée', { columns: 48 })).h, + accented.bottom - accented.top + 1); + assert.ok(raster.band().top <= accented.top, 'the table envelope is narrower than a line in it'); +}); + +test('a one dot stem survives the halving', () => { + /* + * Half width ORs each pair of source columns. Sampling every other column + * instead would drop any stem that sits on an odd one, and the letters + * would print with pieces missing. Checked dot for dot against the table. + */ + const meta = JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'escpos-font-a.json'), 'utf8')); + const glyphs = Buffer.from(meta.glyphs, 'base64'); + const text = 'ICE TEA'; + const srcDot = (x, y) => { + const code = text.charCodeAt(Math.floor(x / 12)) - meta.firstByte; + const byte = glyphs[code * 48 + y * 2 + ((x % 12) >> 3)]; + return (byte & (0x80 >> ((x % 12) & 7))) !== 0; + }; + const buf = raster.renderLine(text, { columns: 48, strike: false }); + const { top } = raster.band(text); + const { wBytes, h } = parsed(buf); + let oneDotWide = 0; + for (let y = 0; y < h; y += 1) { + for (let ox = 0; ox < wBytes * 8; ox += 1) { + const a = 2 * ox < text.length * 12 && srcDot(2 * ox, y + top); + const b = 2 * ox + 1 < text.length * 12 && srcDot(2 * ox + 1, y + top); + assert.strictEqual(dotAt(buf, ox, y), a || b, 'column ' + ox + ' row ' + y); + if (a !== b) oneDotWide += 1; + } + } + assert.ok(oneDotWide > 0, 'nothing in ICE TEA is one dot wide, so this proves nothing'); }); test('the letters are actually drawn, not just the stroke', () => { /* A table read with the wrong stride gives a page of nothing and every test above it still passes. */ - const body = raster.renderLine('ICE TEA', { columns: 48, strike: false }).slice(8); - const above = [...Array(14).keys()].reduce((n, y) => n + inkInRow(body, 72, y), 0); - assert.ok(above > 100, 'there is no type above the stroke line'); + const { wBytes, h, body } = parsed(raster.renderLine('ICE TEA', { columns: 48, strike: false })); + const all = [...Array(h).keys()].reduce((n, y) => n + inkInRow(body, wBytes, y), 0); + assert.ok(all > 100, 'there is no type in the raster'); }); test('a character the table does not have is skipped, not drawn as rubbish', () => { @@ -193,7 +277,35 @@ test('a character the table does not have is skipped, not drawn as rubbish', () test('a line longer than the paper is cut, not wrapped into the next row', () => { const buf = raster.renderLine('X'.repeat(200), { columns: 48 }); - assert.strictEqual(buf.length, 8 + 72 * 24, 'an overlong line grew the raster'); + assert.strictEqual(parsed(buf).wBytes, 36, '48 cells of 12 dots, halved, is 288 dots'); + assert.strictEqual(buf.length, raster.renderLine('X'.repeat(48), { columns: 48 }).length); +}); + +test('a struck line costs what it draws, and the whole grid never comes back', () => { + /* + * Owner: "kitche and desktop aroudn 20 meter. data transfer is matters." + * 1,736 was the price of every struck line before, whatever the dish. It is + * the number a regression lands on, so it is the number this test refuses. + */ + const cost = (t) => raster.renderLine(t, { columns: 48 }).length; + assert.ok(cost('SUNSET COOLER') < 200, 'SUNSET COOLER costs ' + cost('SUNSET COOLER')); + assert.ok(cost('SUNSET COOLER 2') < 240, 'with its count: ' + cost('SUNSET COOLER 2')); + assert.ok(cost('MIXED TANDOORI SEA FOOD PLATTER 1') < 500); + assert.ok(cost('X'.repeat(48)) < 700, 'even a full line is under half the old price'); +}); + +test('a struck pair is composed as the name, two spaces and the count', () => { + /* + * pair() pads a live line out to the right edge. A struck one is drawn as + * dots and paid for by the column, so it is composed tight, and the two + * spaces are what wordCells reads as the end of the words. + */ + const { Receipt } = require(path.join(ROOT, 'src', 'escpos-receipt.js')); + const r = new Receipt('80'); + r.pair('SUNSET COOLER', 'x2', { strike: true }); + const row = preview.parse(r.build(), 48).rows.find((x) => x.kind === 'raster'); + assert.strictEqual(row.wBytes, Math.ceil(('SUNSET COOLER x2'.length * 12) / 2 / 8)); + assert.strictEqual(row.scale, 1); }); /* ------------------------------------------------------------------ the ticket */ @@ -226,12 +338,13 @@ test('the rest of the ticket still reads as a ticket', () => { test('only the cancelled dish pays for it', () => { /* - * The whole reason this path exists is speed. One raster is 1,736 bytes; a - * new order, which is nearly every ticket, must not carry any. + * The whole reason this path exists is speed. A struck dish is a few + * hundred bytes; a new order, which is nearly every ticket, must not carry + * any of them. */ const plain = bytes({ cancelled: false }).length; const struck = bytes({ cancelled: true }).length; - assert.ok(struck - plain > 1500 && struck - plain < 1800, 'unexpected cost: ' + (struck - plain)); + assert.ok(struck - plain > 100 && struck - plain < 400, 'unexpected cost: ' + (struck - plain)); assert.ok(plain < 800, 'a normal ticket has grown: ' + plain + ' bytes'); });