Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

265
web/html/js/barcode.js Normal file
View File

@@ -0,0 +1,265 @@
// 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;
}