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([]); + }); +}); 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 b4642101..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); @@ -177,13 +179,40 @@ 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; 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); @@ -290,6 +319,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 +540,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/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'); }); 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. */