Files
einkaufsapp/web/html/js/barcode.js
2026-08-08 20:31:58 +02:00

266 lines
8.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}