/** * 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 };