Strichcode mit dem Kassenartikel auf den Bon drucken

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>
This commit is contained in:
Kerim (portable/claudecode)andClaude Opus 5 committed 2026-09-12 13:19:39 +02:00
1 parent b1c7d89a7d
commit 6ba0cdd07f
7 files changed
+229 -9

No files matched your search

+65 -1
View File
@@ -20,6 +20,24 @@ const GS = 0x1d;
// 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',
@@ -131,6 +149,52 @@ class Bon {
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)));
}
@@ -149,4 +213,4 @@ class Bon {
}
}
module.exports = { Bon, encodeText };
module.exports = { Bon, encodeText, barcodeMaxLength };
+54 -2
View File
@@ -10,7 +10,7 @@
* dem Zielhost gebaut wurde.
*/
const { Bon } = require('./escpos');
const { Bon, barcodeMaxLength } = require('./escpos');
const p2 = n => String(n).padStart(2, '0');
@@ -59,6 +59,7 @@ function unitCount(ms, rateInterval) {
* @param {boolean} data.stillRunning
* @param {string} data.header Kopfzeilen, durch \n getrennt
* @param {string} data.footer Fusszeilen, durch \n getrennt
* @param {string} data.barcode Artikelname fuer den Strichcode; leer = keiner
* @param {number} data.width Zeichen je Zeile
* @returns {Buffer}
*/
@@ -117,6 +118,29 @@ function buildReceipt(data) {
bon.line();
bon.hr();
// --- Strichcode fuer die Kasse ------------------------------------------
// Der Kassierer soll den Artikel nicht in einer Liste suchen muessen: der
// Code enthaelt genau den Namen, unter dem die Kasse die Leistung fuehrt.
// Die Klarschrift darunter druckt der Drucker selbst mit - sie rettet den
// Vorgang, wenn der Scanner streikt oder das Papier geknickt ist.
if (data.barcode) {
bon.line();
bon.align('center');
bon.line('Artikel für die Kasse');
if (data.barcode.length <= barcodeMaxLength(width)) {
bon.barcode(data.barcode);
} else {
// Breiter als das Papier. Ein abgeschnittener Code wird von keinem
// Scanner gelesen, also lieber gar keiner: der Name allein reicht
// zum Eintippen. Passieren kann das nur, wenn die Papierbreite
// nachtraeglich kleiner gestellt wurde.
bon.bold(true).line(data.barcode).bold(false);
}
bon.align('left');
bon.line();
bon.hr();
}
// --- Fuss ---------------------------------------------------------------
bon.align('center');
if (interim) {
@@ -140,12 +164,40 @@ function buildReceipt(data) {
return bon.build();
}
/**
* Strichcodes aus der Bytefolge herausloesen und durch ihren Inhalt ersetzen.
*
* Eigene Schleife statt eines Ausdrucks, weil bei "GS k" die Laenge der Daten
* in einem Byte MITTEN in der Sequenz steht - wie weit zu springen ist, weiss
* man also erst beim Lesen. Genau das kann ein regulaerer Ausdruck nicht.
*/
function stripBarcode(s) {
let aus = '';
let i = 0;
while (i < s.length) {
if (s.charCodeAt(i) === 0x1d && s[i + 1] === 'k' &&
s.charCodeAt(i + 2) >= 65 && s.charCodeAt(i + 2) <= 73) {
const laenge = s.charCodeAt(i + 3);
const daten = s.substr(i + 4, laenge)
.replace(/^\{[ABC]/, '') // Wahl des Zeichensatzes
.split('{{').join('{'); // verdoppelte Klammer zuruecknehmen
aus += `[Strichcode: ${daten}]\n`;
i += 4 + laenge;
continue;
}
aus += s[i];
i++;
}
return aus;
}
/** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */
function toPlainText(buffer) {
return buffer.toString('latin1')
return stripBarcode(buffer.toString('latin1'))
.replace(/\x1b@/g, '') // Zuruecksetzen
.replace(/\x1b[tadEG!-][\s\S]/g, '') // ESC + Kennbuchstabe + 1 Byte
.replace(/\x1d![\s\S]/g, '') // GS ! Zeichengroesse
.replace(/\x1d[hwHf][\s\S]/g, '') // GS h/w/H/f Masse des Strichcodes
.replace(/\x1dV[\s\S]{2}/g, '') // GS V Schnitt
.replace(/\x80/g, 'EUR')
.replace(/\n{3,}/g, '\n\n')
+38 -3
View File
@@ -18,6 +18,7 @@ require('dotenv').config();
const oidc = require('./oidc');
const printer = require('./printer');
const { buildReceipt } = require('./receipt');
const { barcodeMaxLength } = require('./escpos');
const { provision } = require('./provision');
const app = express();
@@ -152,6 +153,12 @@ async function initDatabase() {
['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`);
// Strichcode fuer die Kasse: der Kassierer soll den Artikel scannen
// koennen, statt ihn zu suchen. Der Name steht hier und nicht im Code,
// weil er sich mit dem Artikelstamm der Kasse aendern kann.
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_enabled','1')`);
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_article','techniker')`);
// Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die
// Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi
// einrichten kann, ohne dass jemand auf den Server muss.
@@ -213,15 +220,19 @@ async function refreshPrinterConfig() {
return cfg;
}
/** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */
/** Bon-Einstellungen: Kopf-/Fusstext, Strichcode und der Druck beim Beenden. */
async function getPrintSettings() {
const rows = await dbAll(
`SELECT key, value FROM settings WHERE key IN ('receipt_header','receipt_footer','print_auto_on_stop')`);
`SELECT key, value FROM settings WHERE key IN
('receipt_header','receipt_footer','print_auto_on_stop',
'receipt_barcode_enabled','receipt_barcode_article')`);
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
return {
receiptHeader: map.receipt_header || '',
receiptFooter: map.receipt_footer || '',
printAutoOnStop: map.print_auto_on_stop !== '0'
printAutoOnStop: map.print_auto_on_stop !== '0',
receiptBarcodeEnabled: map.receipt_barcode_enabled !== '0',
receiptBarcodeArticle: map.receipt_barcode_article || ''
};
}
@@ -277,6 +288,7 @@ async function printReceipt(vorgang) {
...vorgang,
header: texte.receiptHeader,
footer: texte.receiptFooter,
barcode: texte.receiptBarcodeEnabled ? texte.receiptBarcodeArticle : '',
width: printer.width()
});
const res = await printer.print(daten);
@@ -673,6 +685,29 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
if (typeof b.printAutoOnStop === 'boolean') {
await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0');
}
if (typeof b.receiptBarcodeEnabled === 'boolean') {
await setSetting('receipt_barcode_enabled', b.receiptBarcodeEnabled ? '1' : '0');
}
if (typeof b.receiptBarcodeArticle === 'string') {
const artikel = b.receiptBarcodeArticle.trim();
// Ein Strichcode kann nur den druckbaren ASCII-Bereich darstellen. Ein
// Umlaut wuerde stillschweigend verschwinden - dann scannt die Kasse
// spaeter einen Artikel, den es so nicht gibt. Lieber hier sagen, warum.
if (/[^\x20-\x7e]/.test(artikel)) {
return res.status(400).json({
error: 'Der Artikelname darf keine Umlaute oder Sonderzeichen enthalten - mehr kann ein Strichcode nicht abbilden.'
});
}
// Zu lang heisst: der Code waere breiter als das Papier und damit
// abgeschnitten. Ein abgeschnittener Code wird gar nicht erst gelesen.
const max = barcodeMaxLength(printer.width());
if (artikel.length > max) {
return res.status(400).json({
error: `Der Artikelname passt nicht auf die Papierbreite: höchstens ${max} Zeichen.`
});
}
await setSetting('receipt_barcode_article', artikel);
}
// Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die
// Einrichtung in dieser Oberflaeche aufgesetzt wurde.