Der Kassierer musste den Artikel bisher von Hand in der Kasse suchen. Jetzt steht er als Strichcode unter dem Betrag und wird gescannt. CODE128 in Zeichensatz B, nicht das sonst uebliche CODE39: dort sind nur Grossbuchstaben erlaubt, ein Artikel namens "techniker" waere damit nicht darstellbar. Die Modulbreite waehlt escpos.js so gross, wie die Papierbreite zulaesst - breitere Balken liest ein Handscanner zuverlaessiger, gerade auf Thermopapier, das einen Tag in einer Hosentasche hinter sich hat. Daraus ergibt sich die Laengengrenze von 17 Zeichen bei 80 mm Papier. Artikelname und An/Aus liegen in den Einstellungen, nicht im Code: der Name haengt am Artikelstamm der Kasse und kann sich aendern, ohne dass jemand an die App muss. Zwei Faelle, die sonst still danebengingen, melden sich jetzt: - Umlaute und Sonderzeichen weist das Speichern ab, statt sie zu verschlucken und einen Code zu drucken, der einen anderen Artikel bedeutet. - Passt der Name nicht auf die Papierbreite, druckt receipt.js ihn nur in Klarschrift. Ein abgeschnittener Code wird von keinem Scanner gelesen. Geprueft auf dem Epson TM-T88IV: Zwischenbeleg und Endbeleg, ausgeschalteter Strichcode, Laengengrenze, geschweifte Klammer im Namen (wird verdoppelt, sonst liest der Drucker sie als Umschaltbefehl). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
7.8 KiB
JavaScript
217 lines
7.8 KiB
JavaScript
/**
|
||
* ESC/POS - Bausteine fuer den Bondrucker
|
||
*
|
||
* Erzeugt die Bytefolge, die ein Thermo-Bondrucker versteht (getestet mit einem
|
||
* Epson TM-T88IV). Bewusst ohne Fremdpaket: gebraucht werden ein Dutzend
|
||
* Steuersequenzen, und der Drucker ist das Letzte, was wegen eines
|
||
* Paket-Updates ploetzlich Buchstabensalat ausspucken soll.
|
||
*
|
||
* Zeichensatz: Der Drucker kennt kein UTF-8. Umgestellt wird auf Codepage 16
|
||
* (WPC1252) - die deckt die deutschen Umlaute ab und ist ab Werk in praktisch
|
||
* jedem Epson-Bondrucker vorhanden. Alles, was dort fehlt, wird lesbar
|
||
* ersetzt statt als Muell gedruckt.
|
||
*/
|
||
|
||
const ESC = 0x1b;
|
||
const GS = 0x1d;
|
||
|
||
// Codepage 16 = WPC1252. Die Zeichen decken sich dort mit Latin-1, mit
|
||
// Ausnahme des Euro-Zeichens (0x80). Deshalb reicht die Latin-1-Kodierung
|
||
// von Node plus diese eine Sonderbehandlung.
|
||
const CODEPAGE_WPC1252 = 16;
|
||
|
||
// Breite eines Zeichens in Schriftart A, in Druckpunkten. Daraus ergibt sich,
|
||
// wie viel Platz ein Strichcode quer ueber das Papier hat.
|
||
const ZEICHENBREITE = 12;
|
||
|
||
// Ruhezone links und rechts vom Strichcode, in Modulen. Ohne diesen Abstand
|
||
// findet ein Scanner den Anfang des Codes nicht zuverlaessig.
|
||
const RUHEZONE = 10;
|
||
|
||
/**
|
||
* Wie viele Zeichen ein CODE128-Strichcode auf dieser Papierbreite noch
|
||
* verkraeftet. Bei der schmalsten noch lesbaren Modulbreite (2 Punkte)
|
||
* gerechnet, also der guenstigste Fall.
|
||
*/
|
||
function barcodeMaxLength(width = 42) {
|
||
const module = Math.floor((width * ZEICHENBREITE) / 2) - 35 - 2 * RUHEZONE;
|
||
return Math.max(0, Math.floor(module / 11));
|
||
}
|
||
|
||
/** Zeichen, die WPC1252 nicht kennt, auf etwas Druckbares abbilden. */
|
||
const ERSATZ = {
|
||
'€': '\x80',
|
||
'„': '"', '“': '"', '”': '"', '‚': "'", '‘': "'", '’': "'",
|
||
'–': '-', '—': '-', '…': '...', ' ': ' ',
|
||
'\t': ' '
|
||
};
|
||
|
||
function encodeText(text) {
|
||
let s = String(text == null ? '' : text);
|
||
for (const [von, nach] of Object.entries(ERSATZ)) {
|
||
s = s.split(von).join(nach);
|
||
}
|
||
// Was danach ausserhalb von Latin-1 liegt (Emoji, kyrillisch, ...), wuerde
|
||
// als zufaelliges Zeichen gedruckt. Lieber ein sichtbares Fragezeichen.
|
||
s = s.replace(/[^\x00-\xff]/g, '?');
|
||
return Buffer.from(s, 'latin1');
|
||
}
|
||
|
||
class Bon {
|
||
/** @param {number} width Zeichen je Zeile (Font A: 42 bei 80 mm, 32 bei 58 mm) */
|
||
constructor(width = 42) {
|
||
this.width = width;
|
||
this.parts = [];
|
||
}
|
||
|
||
raw(...bytes) {
|
||
this.parts.push(Buffer.from(bytes));
|
||
return this;
|
||
}
|
||
|
||
/** Grundzustand herstellen - der Drucker merkt sich Einstellungen sonst. */
|
||
init() {
|
||
return this.raw(ESC, 0x40) // ESC @ Zuruecksetzen
|
||
.raw(ESC, 0x74, CODEPAGE_WPC1252); // ESC t Zeichensatz
|
||
}
|
||
|
||
/** @param {'left'|'center'|'right'} how */
|
||
align(how) {
|
||
const n = how === 'center' ? 1 : how === 'right' ? 2 : 0;
|
||
return this.raw(ESC, 0x61, n);
|
||
}
|
||
|
||
bold(on) {
|
||
return this.raw(ESC, 0x45, on ? 1 : 0);
|
||
}
|
||
|
||
underline(on) {
|
||
return this.raw(ESC, 0x2d, on ? 1 : 0);
|
||
}
|
||
|
||
/** Zeichengroesse als Vielfaches, 1..4 je Richtung. */
|
||
size(w = 1, h = 1) {
|
||
const clamp = v => Math.max(1, Math.min(4, parseInt(v) || 1)) - 1;
|
||
return this.raw(GS, 0x21, (clamp(w) << 4) | clamp(h));
|
||
}
|
||
|
||
text(s) {
|
||
this.parts.push(encodeText(s));
|
||
return this;
|
||
}
|
||
|
||
line(s = '') {
|
||
return this.text(s + '\n');
|
||
}
|
||
|
||
/** Zeile mit linkem und rechtem Block - die Trennlinie fuer Betraege. */
|
||
columns(left, right, width = this.width) {
|
||
const l = String(left == null ? '' : left);
|
||
const r = String(right == null ? '' : right);
|
||
const luecke = width - l.length - r.length;
|
||
if (luecke < 1) {
|
||
// Passt nicht nebeneinander: rechts buendig in die naechste Zeile.
|
||
return this.line(l).line(r.padStart(width));
|
||
}
|
||
return this.line(l + ' '.repeat(luecke) + r);
|
||
}
|
||
|
||
/** Text mittig, ohne den Drucker auf zentriert umzustellen. */
|
||
center(s) {
|
||
const t = String(s == null ? '' : s);
|
||
if (t.length >= this.width) return this.line(t);
|
||
return this.line(' '.repeat(Math.floor((this.width - t.length) / 2)) + t);
|
||
}
|
||
|
||
hr(zeichen = '-') {
|
||
return this.line(zeichen.repeat(this.width));
|
||
}
|
||
|
||
/** Langen Text auf die Zeilenbreite umbrechen. */
|
||
wrap(s, width = this.width) {
|
||
const worte = String(s == null ? '' : s).split(/\s+/).filter(Boolean);
|
||
let zeile = '';
|
||
for (const wort of worte) {
|
||
if (!zeile) {
|
||
zeile = wort;
|
||
} else if ((zeile + ' ' + wort).length <= width) {
|
||
zeile += ' ' + wort;
|
||
} else {
|
||
this.line(zeile);
|
||
zeile = wort;
|
||
}
|
||
while (zeile.length > width) {
|
||
this.line(zeile.slice(0, width));
|
||
zeile = zeile.slice(width);
|
||
}
|
||
}
|
||
if (zeile) this.line(zeile);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Strichcode in CODE128, Zeichensatz B.
|
||
*
|
||
* CODE128 statt des sonst ueblichen CODE39, weil CODE39 nur
|
||
* Grossbuchstaben kennt - ein Artikel namens "techniker" liesse sich damit
|
||
* nicht abbilden. Zeichensatz B deckt den druckbaren ASCII-Bereich ab und
|
||
* wird von jedem Handscanner gelesen.
|
||
*
|
||
* Die Modulbreite - die Breite des schmalsten Balkens - wird so gross
|
||
* gewaehlt, wie das Papier es zulaesst. Breitere Balken liest ein Scanner
|
||
* zuverlaessiger, gerade auf Thermopapier, das schon einen Tag in einer
|
||
* Hosentasche hinter sich hat.
|
||
*
|
||
* @param {string} text Nutzdaten, druckbares ASCII
|
||
* @param {object} [o]
|
||
* @param {number} [o.hoehe] Hoehe in Druckpunkten (8 Punkte = 1 mm)
|
||
* @param {boolean} [o.klarschrift] Nutzdaten zusaetzlich lesbar darunter
|
||
*/
|
||
barcode(text, { hoehe = 64, klarschrift = true } = {}) {
|
||
// Was der Zeichensatz nicht kann, wuerde der Drucker stillschweigend
|
||
// verschlucken und einen Code ausgeben, der etwas anderes bedeutet.
|
||
const daten = String(text == null ? '' : text).replace(/[^\x20-\x7e]/g, '');
|
||
if (!daten) return this;
|
||
|
||
// Ein CODE128-Zeichen belegt 11 Module, dazu kommen Start, Pruefzeichen
|
||
// und Stopp (zusammen 35) sowie die Ruhezone auf beiden Seiten.
|
||
const module = 11 * daten.length + 35 + 2 * RUHEZONE;
|
||
const modul = Math.max(2, Math.min(4, Math.floor((this.width * ZEICHENBREITE) / module)));
|
||
|
||
this.raw(GS, 0x68, Math.max(1, Math.min(255, hoehe))); // GS h Hoehe
|
||
this.raw(GS, 0x77, modul); // GS w Modulbreite
|
||
this.raw(GS, 0x66, 0); // GS f Schrift der Klarschrift
|
||
this.raw(GS, 0x48, klarschrift ? 2 : 0); // GS H Klarschrift darunter
|
||
|
||
// "{B" waehlt den Zeichensatz. Eine geschweifte Klammer in den Daten
|
||
// muss deshalb verdoppelt werden, sonst liest der Drucker sie als
|
||
// Umschaltbefehl und der Code enthaelt am Ende etwas anderes.
|
||
const nutz = Buffer.from('{B' + daten.split('{').join('{{'), 'latin1');
|
||
|
||
// Form 2 von GS k: die Laenge steht als Byte davor, statt die Daten mit
|
||
// einem Nullbyte abzuschliessen. Nur so darf jedes Zeichen vorkommen.
|
||
this.raw(GS, 0x6b, 73, nutz.length);
|
||
this.parts.push(nutz);
|
||
return this;
|
||
}
|
||
|
||
feed(n = 1) {
|
||
return this.raw(ESC, 0x64, Math.max(0, Math.min(255, n)));
|
||
}
|
||
|
||
/**
|
||
* Papier abschneiden. Vorher wird gefuettert: der Schneidekopf sitzt
|
||
* einige Millimeter hinter dem Druckkopf, ohne Vorschub landet der Schnitt
|
||
* mitten im Text.
|
||
*/
|
||
cut() {
|
||
return this.feed(4).raw(GS, 0x56, 0x42, 0x00);
|
||
}
|
||
|
||
build() {
|
||
return Buffer.concat(this.parts);
|
||
}
|
||
}
|
||
|
||
module.exports = { Bon, encodeText, barcodeMaxLength };
|