Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions api/src/helpers/bill-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
134 changes: 134 additions & 0 deletions api/tests/unit/the-shop-footer-reaches-the-bill.test.js
Original file line number Diff line number Diff line change
@@ -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.<field>` 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([]);
});
});
8 changes: 4 additions & 4 deletions src/escpos-kot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/escpos-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
148 changes: 122 additions & 26 deletions src/escpos-raster-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 };
Loading
Loading