// Decoder für EAN-13, EAN-8 und UPC-A. Ohne Fremdbibliothek. // // Warum selbst geschrieben: Die native BarcodeDetector-API gibt es nur in // Chrome und auf Android. Die übliche Alternative wäre ZXing - eine // 250-KB-Datei aus dem npm-Ökosystem. Für drei Strichcode-Formate, die // alle nach demselben simplen Schema arbeiten, ist das unverhältnismäßig. // // Verfahren: Aus einem Kamerabild werden mehrere waagerechte Linien // abgetastet, in Hell-Dunkel-Folgen zerlegt und gegen die Zifferntabellen // gehalten. Ein Ergebnis gilt erst als sicher, wenn es zweimal // unabhängig herauskommt und die Prüfziffer stimmt. "use strict"; // Jede Ziffer besteht aus 7 Modulen in 4 Streifen. Die Tabelle enthält // die Streifenbreiten der L-Kodierung; daraus lassen sich die beiden // anderen ableiten: // L Streifenbreiten, beginnend mit hell (linke Hälfte) // G dieselben Breiten rückwärts, hell (linke Hälfte) // R dieselben Breiten, beginnend mit dunkel (rechte Hälfte) const L_WIDTHS = [ [3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2], [1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2], ]; // Welche Abfolge von L und G in der linken Hälfte steht, verrät die // erste Ziffer - sie ist selbst nicht als Streifen kodiert. const PARITY = [ "LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG", "LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL", ]; const MAX_DEVIATION = 0.42; // erlaubte Abweichung je Streifen, in Modulen /** Vergleicht vier gemessene Streifen mit einem Sollmuster. * Verglichen werden Verhältnisse, keine absoluten Breiten - dadurch * spielt es keine Rolle, wie weit die Kamera vom Code entfernt ist. */ function patternDistance(counters, pattern) { const total = counters[0] + counters[1] + counters[2] + counters[3]; if (total <= 0) return Infinity; const unit = total / 7; // Zu schmale Streifen deuten auf Rauschen statt auf einen Strichcode. if (unit < 0.6) return Infinity; let sum = 0; for (let i = 0; i < 4; i++) { const deviation = Math.abs(counters[i] / unit - pattern[i]); if (deviation > MAX_DEVIATION * 2) return Infinity; sum += deviation; } return sum; } /** @returns {{digit: number, code: "L"|"G"|"R"}|null} */ function matchDigit(counters, half) { let best = null; let bestDistance = MAX_DEVIATION * 4; for (let digit = 0; digit < 10; digit++) { if (half === "left") { const dL = patternDistance(counters, L_WIDTHS[digit]); if (dL < bestDistance) { bestDistance = dL; best = { digit, code: "L" }; } const reversed = [...L_WIDTHS[digit]].reverse(); const dG = patternDistance(counters, reversed); if (dG < bestDistance) { bestDistance = dG; best = { digit, code: "G" }; } } else { const dR = patternDistance(counters, L_WIDTHS[digit]); if (dR < bestDistance) { bestDistance = dR; best = { digit, code: "R" }; } } } return best; } /** Prüfziffer nach dem Modulo-10-Verfahren. Gilt für EAN-13, EAN-8 * und UPC-A gleichermaßen - nur die Gewichtung beginnt je nach Länge * bei 1 oder 3. */ export function checksumValid(code) { const digits = [...code].map(Number); if (digits.some(Number.isNaN)) return false; const check = digits.pop(); let sum = 0; // Von rechts nach links abwechselnd ×3 und ×1. for (let i = digits.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) { sum += digits[i] * weight; } return (10 - (sum % 10)) % 10 === check; } /** Zerlegt eine Helligkeitszeile in Streifenlängen. * Der Schwellwert wird je Zeile aus deren eigenem Hell-Dunkel-Umfang * gebildet - so stört ein Schatten über dem halben Bild nicht. */ function toRuns(line) { let min = 255; let max = 0; for (const value of line) { if (value < min) min = value; if (value > max) max = value; } // Zu wenig Kontrast: da ist kein Strichcode. if (max - min < 40) return null; const threshold = (min + max) / 2; const runs = []; let dark = line[0] < threshold; let length = 0; for (const value of line) { const isDark = value < threshold; if (isDark === dark) { length++; } else { runs.push({ dark, length }); dark = isDark; length = 1; } } runs.push({ dark, length }); return runs; } // Vor dem Startzeichen verlangt die Norm eine helle Ruhezone von elf // Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer: // In zufälligem Rauschen gibt es keine so breiten hellen Flächen, in // einem echten Kamerabild dagegen immer. Konservativ angesetzt, weil // der Rand bei knapper Bildausschnittwahl auch mal kürzer ausfällt. const MIN_QUIET_ZONE = 3.5; /** Versucht ab einem dunklen Streifen einen vollständigen Code zu lesen. */ function decodeAt(runs, start, digitCount) { // digitCount: 13 -> je 6 Ziffern pro Hälfte, 8 -> je 4 const perHalf = digitCount === 13 ? 6 : 4; // Startzeichen: drei Streifen von je einem Modul. const guard = [runs[start], runs[start + 1], runs[start + 2]]; if (guard.some((r) => r === undefined)) return null; const unit = (guard[0].length + guard[1].length + guard[2].length) / 3; if (unit < 0.7) return null; for (const r of guard) { if (Math.abs(r.length / unit - 1) > MAX_DEVIATION) return null; } // Ruhezone davor const before = runs[start - 1]; if (start > 0 && (before.dark || before.length < unit * MIN_QUIET_ZONE)) { return null; } let index = start + 3; const digits = []; let parity = ""; for (let i = 0; i < perHalf; i++) { const counters = [ runs[index]?.length, runs[index + 1]?.length, runs[index + 2]?.length, runs[index + 3]?.length, ]; if (counters.some((c) => c === undefined)) return null; const match = matchDigit(counters, "left"); if (!match) return null; digits.push(match.digit); parity += match.code === "R" ? "L" : match.code; index += 4; } // Trennzeichen in der Mitte: fünf Streifen von je einem Modul. for (let i = 0; i < 5; i++) { const run = runs[index + i]; if (!run) return null; if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null; } index += 5; for (let i = 0; i < perHalf; i++) { const counters = [ runs[index]?.length, runs[index + 1]?.length, runs[index + 2]?.length, runs[index + 3]?.length, ]; if (counters.some((c) => c === undefined)) return null; const match = matchDigit(counters, "right"); if (!match) return null; digits.push(match.digit); index += 4; } // Schlusszeichen for (let i = 0; i < 3; i++) { const run = runs[index + i]; if (!run) return null; if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null; } // Ruhezone dahinter - oder das Ende der abgetasteten Zeile. const after = runs[index + 3]; if (after && (after.dark || after.length < unit * MIN_QUIET_ZONE)) return null; let code; if (digitCount === 13) { const first = PARITY.indexOf(parity); if (first < 0) return null; code = String(first) + digits.join(""); } else { code = digits.join(""); } return checksumValid(code) ? code : null; } /** Liest eine einzelne Helligkeitszeile. */ export function decodeLine(line) { const runs = toRuns(line); if (!runs || runs.length < 30) return null; for (const direction of ["forward", "backward"]) { // Ein auf dem Kopf stehender Code liest sich rückwärts genauso. const sequence = direction === "forward" ? runs : [...runs].reverse(); for (let i = 0; i < sequence.length - 20; i++) { if (!sequence[i].dark) continue; for (const digitCount of [13, 8]) { const code = decodeAt(sequence, i, digitCount); if (code) return code; } } } return null; } /** * Sucht in einem Bild nach einem Strichcode. * * @param {ImageData} imageData * @param {number} lines Anzahl waagerechter Abtastlinien * @returns {string|null} */ export function decodeImage(imageData, lines = 21) { const { width, height, data } = imageData; const results = new Map(); for (let n = 0; n < lines; n++) { // Linien über die mittleren zwei Drittel verteilen: Dort liegt der // Code, wenn der Nutzer ihn im Sucherrahmen hält. const y = Math.round(height * (1 / 6 + (2 / 3) * (n / (lines - 1 || 1)))); const row = new Uint8Array(width); const offset = y * width * 4; for (let x = 0; x < width; x++) { const p = offset + x * 4; // Grauwert nach Wahrnehmungsgewichtung. row[x] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8; } const code = decodeLine(row); if (!code) continue; const count = (results.get(code) || 0) + 1; // Erst wenn zwei Linien dasselbe ergeben, gilt es als sicher. Ein // Einzeltreffer kann von Rauschen kommen. if (count >= 2) return code; results.set(code, count); } return null; }