Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebff173443 | ||
|
|
2e619ed64d | ||
|
|
2eb9d1ecf8 | ||
|
|
9ef038ca98 |
@@ -4,6 +4,11 @@ Browser-App, die per Handykamera gebrauchte Server-RAM-Module erkennt und
|
||||
beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die
|
||||
App nennt den Stapel.
|
||||
|
||||
Vor dem Scannen wählt der Nutzer eine von drei Funktionen — Strichcode,
|
||||
QR-Code oder Text erkennen (siehe „Funktionsweise" unten). Jede Funktion nutzt
|
||||
ausschließlich ihre eigene Quelle; anders als früher weicht die App nicht mehr
|
||||
automatisch auf Texterkennung aus, wenn kein Code gefunden wird.
|
||||
|
||||
Zwei Module gelten als zusammengehörig, wenn sie denselben Barcode-Inhalt
|
||||
tragen — ein Barcode ist exakt gelesen, das ist der zuverlässigste Maßstab.
|
||||
Jeder Stapel führt dafür die Menge der Barcode-Zeichenketten, die bei seinen
|
||||
@@ -51,16 +56,57 @@ keine Daten verlassen das Gerät.
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
1. **Barcode zuerst.** Code-128 und DataMatrix werden aus dem Kamerabild
|
||||
dekodiert (`zxing-wasm`). Das ist exakt, im Gegensatz zu Texterkennung.
|
||||
Werden mehrere Barcodes im Bild gefunden, die sich widersprechen (nicht
|
||||
dieselbe Teilenummer), gilt das als mehrdeutig — die App rät nicht,
|
||||
sondern fragt nach. Das gilt nur für Barcodes mit einem bekannten
|
||||
Nummernschema; widersprechen sich mehrere Barcodes mit unbekanntem
|
||||
Nummernschema, wird keine ihrer Teilenummern übernommen.
|
||||
2. **Teilenummer-Decoder.** Aus einer Hersteller-PN wie `M386A8K40BM1-CRC4Y`
|
||||
werden Kapazität, Bauform und Geschwindigkeit tabellengesteuert
|
||||
abgeleitet. Gelingt das, entfällt OCR vollständig, das Ergebnis ist grün.
|
||||
0. **Funktion wählen.** Vor dem ersten Scan (und jederzeit erneut über die
|
||||
antippbare Anzeige in der Scan-Ansicht) wählt der Nutzer eine von drei
|
||||
Funktionen. Kennung, Beschriftung, gelesene Codearten und Verhalten stehen
|
||||
ausschließlich in `src/scan-modes.js` — es gibt keine zweite Stelle, an der
|
||||
das steht:
|
||||
|
||||
| Funktion | Liest | Laufende Suche | Texterkennung |
|
||||
|---|---|---|---|
|
||||
| **Strichcode** | Code128, Code39, Code93, ITF, EAN-13, EAN-8, UPC-A, UPC-E, Codabar | ja | nein |
|
||||
| **QR-Code** | ausschließlich QRCode, MicroQRCode, RMQRCode | ja | nein |
|
||||
| **Text erkennen** | keine Codes | nein | ja, auf Knopfdruck |
|
||||
|
||||
„QR-Code" liest bewusst **kein** DataMatrix — auch wenn die 2D-Codes auf
|
||||
eigenen RAM-Etiketten DataMatrix sind. Der reguläre Arbeitsablauf läuft über
|
||||
„Strichcode", weil dort die Teilenummer im Strichcode steht; „QR-Code" ist
|
||||
für andere Ware gedacht. Diese Beschränkung ist eine ausdrückliche
|
||||
Entscheidung, keine Lücke.
|
||||
|
||||
„Modul scannen" erscheint nur in „Text erkennen" — in „Strichcode" und
|
||||
„QR-Code" sucht die App ohnehin laufend, ein Knopf ohne Texterkennung hätte
|
||||
dort nichts zu tun. Der Zielrahmen hebt sich in „Strichcode" und „QR-Code"
|
||||
hervor, sobald ein passender Code im Bild ist; in „Text erkennen" bleibt er
|
||||
schlicht, weil dort nichts laufend erkannt wird. Ein Wechsel der Funktion
|
||||
während des Sortierens (z. B. bei gemischter Ware) gilt sofort: laufende
|
||||
Suche startet oder stoppt, der Knopf erscheint oder verschwindet.
|
||||
|
||||
Liegt beim Start eine gesicherte Sitzung vor, erscheint zuerst die Frage
|
||||
nach dem Fortsetzen (siehe Punkt 5 unten) und erst danach die Funktionswahl
|
||||
— die Kamera startet in jedem Fall erst, nachdem beides geklärt ist.
|
||||
|
||||
Technisch bekommen „Strichcode" und „QR-Code" eine Texterkennung
|
||||
hineingereicht, die sofort leeren Text liefert, ohne Tesseract anzustoßen;
|
||||
„Text erkennen" bekommt eine Barcode-Dekodierung hineingereicht, die sofort
|
||||
eine leere Liste liefert (`buildRecognitionAdapters` in
|
||||
`src/scan-recognition.js`). Die Erkennungs-Pipeline selbst (`src/pipeline.js`,
|
||||
Punkte 1–4 unten) bleibt dabei unverändert und kennt keine Funktionen — die
|
||||
drei Funktionen unterscheiden sich ausschließlich darin, welche Adapter ihr
|
||||
übergeben werden.
|
||||
1. **Barcode-Dekodierung** (nur in „Strichcode" und „QR-Code"). Die zur
|
||||
Funktion gehörenden Codearten werden laufend aus dem Kamerabild dekodiert
|
||||
(`zxing-wasm`, `decodeBarcodes` in `src/barcode.js` bekommt die Codearten
|
||||
der gewählten Funktion übergeben). Das ist exakt, im Gegensatz zu
|
||||
Texterkennung. Werden mehrere Barcodes im Bild gefunden, die sich
|
||||
widersprechen (nicht dieselbe Teilenummer), gilt das als mehrdeutig — die
|
||||
App rät nicht, sondern fragt nach. Das gilt nur für Barcodes mit einem
|
||||
bekannten Nummernschema; widersprechen sich mehrere Barcodes mit
|
||||
unbekanntem Nummernschema, wird keine ihrer Teilenummern übernommen.
|
||||
2. **Teilenummer-Decoder** (nur relevant in „Strichcode"/„QR-Code" — „Text
|
||||
erkennen" liest nie einen Barcode, siehe Punkt 0). Aus einer Hersteller-PN
|
||||
wie `M386A8K40BM1-CRC4Y` werden Kapazität, Bauform und Geschwindigkeit
|
||||
tabellengesteuert abgeleitet. Gelingt das, entfällt OCR vollständig, das Ergebnis ist grün.
|
||||
Kennt der Decoder das Nummernschema nicht (siehe „Grenzen“ unten), ist das
|
||||
allein noch kein Grund für Rot: Zum Sortieren muss keine Kapazität bekannt
|
||||
sein, eine exakt gelesene Teilenummer genügt, um ein Modul wiederzuerkennen
|
||||
@@ -72,17 +118,23 @@ keine Daten verlassen das Gerät.
|
||||
könnte, das über die Zuordnung entscheidet. Bei einem tatsächlich neuen
|
||||
Modul läuft sie dagegen wie gehabt, denn dort liefert sie die lesbare
|
||||
Beschriftung des neuen Stapels.
|
||||
3. **OCR als Rückfallebene.** Nur wenn kein Barcode eindeutig lesbar war oder
|
||||
sein Nummernschema unbekannt ist (z. B. überklebtes/beschädigtes Etikett
|
||||
oder ein noch nicht in `src/pn-tables.js` hinterlegter Hersteller). Das
|
||||
Kamerabild wird dafür in Graustufen gewandelt und mit einem
|
||||
**Otsu-Schwellwert** in Schwarz/Weiß aufbereitet: Der Schwellwert wird aus
|
||||
der Helligkeitsverteilung des gesamten Bildes bestimmt, nicht aus einer
|
||||
festen Kontrastspreizung zwischen hellstem und dunkelstem Pixel. Das macht
|
||||
die Aufbereitung robust gegen einzelne Lichtreflexe auf glänzenden
|
||||
Metalletiketten, die eine reine Min/Max-Spreizung leicht kippen würden.
|
||||
Das OCR-Ergebnis wird anschließend gegen die bekannten Spec-Werte
|
||||
abgeglichen, was die typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst.
|
||||
3. **Texterkennung** (ausschließlich in „Text erkennen", ausgelöst durch
|
||||
„Modul scannen"). Anders als früher ist das kein automatischer Rückfall
|
||||
vom Barcode-Weg mehr, sondern die einzige Quelle dieser Funktion — sie
|
||||
liest ohnehin nie einen Barcode (siehe Punkt 0). Das Kamerabild wird dafür
|
||||
in Graustufen gewandelt und mit einem **Otsu-Schwellwert** in Schwarz/Weiß
|
||||
aufbereitet: Der Schwellwert wird aus der Helligkeitsverteilung des
|
||||
gesamten Bildes bestimmt, nicht aus einer festen Kontrastspreizung
|
||||
zwischen hellstem und dunkelstem Pixel. Das macht die Aufbereitung robust
|
||||
gegen einzelne Lichtreflexe auf glänzenden Metalletiketten, die eine reine
|
||||
Min/Max-Spreizung leicht kippen würden. Das OCR-Ergebnis wird anschließend
|
||||
gegen die bekannten Spec-Werte abgeglichen, was die typischen
|
||||
Verwechslungen (0/O, 1/I, 8/B, …) auflöst. Da in dieser Funktion nie eine
|
||||
per Barcode gelesene Teilenummer vorliegt, entscheiden hier ausschließlich
|
||||
die aus dem erkannten Text abgeleiteten technischen Angaben (Kapazität,
|
||||
Bauform, Rank, Geschwindigkeit) über die Stapelzuordnung
|
||||
(`specsCompatible` in `src/spec.js`) — anders als in „Strichcode"/
|
||||
„QR-Code", wo meist die exakte Teilenummer trägt.
|
||||
4. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
|
||||
durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt, widersprüchliche
|
||||
Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität)
|
||||
@@ -133,14 +185,19 @@ korrekt funktioniert.
|
||||
npm test
|
||||
```
|
||||
|
||||
124 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
|
||||
143 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
|
||||
die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung
|
||||
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert) und
|
||||
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen
|
||||
und Mehrdeutigkeitsbehandlung, sowie die Sitzungssicherung samt Prüfung eines
|
||||
wiederhergestellten Zustands. Kamera, Barcode-/OCR-Adapter selbst und
|
||||
Oberfläche laufen nur im echten Browser und werden dort manuell geprüft
|
||||
(siehe „Am Gerät noch zu prüfen" unten) — `node --test` kennt kein DOM.
|
||||
und Mehrdeutigkeitsbehandlung, die Sitzungssicherung samt Prüfung eines
|
||||
wiederhergestellten Zustands, die drei Scan-Funktionen (`scan-modes.js` —
|
||||
Codearten je Funktion, kein DataMatrix in „QR-Code") sowie die je Funktion
|
||||
tatsächlich aufgerufenen Adapter (`scan-recognition.js` — Texterkennung läuft
|
||||
nachweislich nie in „Strichcode"/„QR-Code", Barcode-Dekodierung nachweislich
|
||||
nie in „Text erkennen"). Kamera, Barcode-/OCR-Adapter selbst, die
|
||||
Auswahl-Oberfläche und die Verdrahtung in `main.js` laufen nur im echten
|
||||
Browser und werden dort manuell geprüft (siehe „Am Gerät noch zu prüfen"
|
||||
unten) — `node --test` kennt kein DOM.
|
||||
|
||||
## Produktion
|
||||
|
||||
@@ -189,7 +246,9 @@ worauf du bei der Veröffentlichung achten solltest.
|
||||
|
||||
- Vanilla JavaScript (ES Modules), kein Framework
|
||||
- [Vite](https://vite.dev/) als Entwicklungsserver und Bündler
|
||||
- [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm) für Code-128 und DataMatrix
|
||||
- [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm) zur Barcode-Dekodierung;
|
||||
welche Codearten gesucht werden, gibt die gewählte Funktion vor
|
||||
(`src/scan-modes.js`)
|
||||
- [`tesseract.js`](https://tesseract.projectnaptha.com/) für Texterkennung
|
||||
- `node:test` für Tests, ohne zusätzliche Test-Bibliothek
|
||||
|
||||
@@ -202,15 +261,18 @@ worauf du bei der Veröffentlichung achten solltest.
|
||||
| `src/pn-decoder.js` | Teilenummer → Spec-Felder |
|
||||
| `src/ocr-extract.js` | OCR-Rohtext → Spec-Felder |
|
||||
| `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen — inklusive der aus den Einträgen abgeleiteten, je Stapel bekannten Barcode-Inhalte (`stack.codes`), die vor dem Vergleich der technischen Angaben über die Zuordnung entscheiden, sofern diese Angaben nicht widersprechen |
|
||||
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) |
|
||||
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) — unverändert; kennt keine Scan-Funktionen |
|
||||
| `src/scan-modes.js` | Die drei Scan-Funktionen (Strichcode, QR-Code, Text erkennen): Kennung, deutsche Beschriftung, gelesene Codearten, laufende Suche ja/nein, Texterkennung ja/nein — einzige Quelle, sowohl für die Auswahl-Oberfläche als auch für `main.js` |
|
||||
| `src/scan-recognition.js` | Baut die an `recognize()` übergebenen Adapter anhand der gewählten Funktion: in „Strichcode"/„QR-Code" liefert die Texterkennung sofort leeren Text ohne Tesseract anzustoßen, in „Text erkennen" liefert die Barcode-Dekodierung sofort eine leere Liste ohne `zxing-wasm` anzustoßen |
|
||||
| `src/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten) |
|
||||
| `src/camera.js` | Kamerastart, Einzelbildaufnahme, Bilddatei-Ersatzweg |
|
||||
| `src/barcode.js` | Adapter zu `zxing-wasm` |
|
||||
| `src/barcode.js` | Adapter zu `zxing-wasm`; `decodeBarcodes(imageData, formats)` bekommt die Codearten übergeben, ohne Angabe wie bisher (Code128 + DataMatrix) |
|
||||
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js` |
|
||||
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, Scan-Knopf, Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera |
|
||||
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, antippbare Anzeige der gewählten Funktion (Wechsel), Scan-Knopf (nur in „Text erkennen" sichtbar), Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera |
|
||||
| `src/ui/mode-dialog.js` | Vollbild-Auswahl der Scan-Funktion vor dem Start und beim Wechsel: drei große Flächen, eine je Funktion aus `src/scan-modes.js` |
|
||||
| `src/ui/result-overlay.js` | Kurze Treffer-Rückmeldung (grün/gelb), blendet sich nach kurzer Zeit selbst wieder aus |
|
||||
| `src/ui/ambiguous-dialog.js` | Rot-Dialog bei roter Konfidenz oder mehrdeutiger Stapelzuordnung |
|
||||
| `src/ui/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen |
|
||||
| `src/ui/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen — läuft vor der Funktionswahl |
|
||||
| `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden |
|
||||
| `src/ui/describe-spec.js` | Gemeinsame Kurzbeschreibung eines Specs (Kapazität, Rank, Geschwindigkeit, Bauform), wahlweise mit Platzhaltern für fehlende Felder; von Treffer-Rückmeldung, „Zuletzt"-Zeile und Sitzungsliste gemeinsam genutzt |
|
||||
| `src/main.js` | Verdrahtung aller Module zur lauffähigen App |
|
||||
@@ -218,11 +280,11 @@ worauf du bei der Veröffentlichung achten solltest.
|
||||
| `server.py` | Auslieferung im Betrieb: statischer Server für `dist/` mit Passwortabfrage vor jeder Datei, plus passwortfreier Gesundheitspfad `/healthz`. Nur Python-Standardbibliothek, keine zusätzliche Abhängigkeit |
|
||||
| `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung |
|
||||
|
||||
`spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline` und `storage` sind
|
||||
reine Funktionen ohne Browser-Zugriff (kein `window`, `document` oder
|
||||
`localStorage` direkt) und deshalb vollständig mit `node:test` prüfbar.
|
||||
`camera.js`, `barcode.js`, `ocr.js` und `src/ui/` brauchen einen echten
|
||||
Browser und werden nur manuell geprüft.
|
||||
`spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline`, `storage`,
|
||||
`scan-modes` und `scan-recognition` sind reine Funktionen ohne Browser-Zugriff
|
||||
(kein `window`, `document` oder `localStorage` direkt) und deshalb vollständig
|
||||
mit `node:test` prüfbar. `camera.js`, `barcode.js`, `ocr.js` und `src/ui/`
|
||||
brauchen einen echten Browser und werden nur manuell geprüft.
|
||||
|
||||
### Sitzungssicherung im Detail
|
||||
|
||||
@@ -325,40 +387,68 @@ werden:
|
||||
- Datei-Auswahl als Ersatzweg (ohne Kamera) öffnet den nativen Dialog und
|
||||
hinterlässt keine leere Fläche im Layout.
|
||||
|
||||
**Funktionswahl (neu)**
|
||||
- Erster Start ohne gesicherte Sitzung: Auswahl mit drei großen Flächen
|
||||
(„Strichcode", „QR-Code", „Text erkennen") erscheint, *bevor* die Kamera
|
||||
startet — kein Kamerabild sichtbar, solange keine Funktion gewählt ist.
|
||||
- Erster Start *mit* gesicherter Sitzung: erst die Frage „Gesicherte Sitzung
|
||||
gefunden" (fortsetzen/verwerfen), erst danach die Funktionswahl.
|
||||
- „Strichcode" wählen: laufende Suche erkennt Code-128/Code-39/EAN/UPC/…,
|
||||
kein „Modul scannen"-Knopf sichtbar, Zielrahmen hebt sich bei erkanntem
|
||||
Code hervor.
|
||||
- „QR-Code" wählen und ein Etikett mit dem eigenen DataMatrix-Code vor die
|
||||
Kamera halten: **bewusst kein Treffer** (kein DataMatrix in dieser
|
||||
Funktion) — Rahmen bleibt neutral. Einen echten QR-Code vor dieselbe
|
||||
Funktion halten: Rahmen hebt sich hervor, Treffer wird gebucht.
|
||||
- „Text erkennen" wählen: kein automatisches Hervorheben des Rahmens (er
|
||||
bleibt durchgehend schlicht), „Modul scannen"-Knopf sichtbar und löst die
|
||||
Texterkennung aus.
|
||||
- Anzeige der gewählten Funktion in der Scan-Ansicht antippen: Auswahl
|
||||
erscheint erneut, nach der Wahl gilt der Wechsel sofort — laufende Suche
|
||||
startet/stoppt, Knopf erscheint/verschwindet, **ohne** die Seite neu zu
|
||||
laden.
|
||||
- Anzeige der gewählten Funktion aus rund einem Meter Entfernung lesbar.
|
||||
|
||||
**Barcode- und OCR-Erkennung**
|
||||
- Samsung-Referenzmodul scannen: Code-128 *und* DataMatrix werden erkannt,
|
||||
- Samsung-Referenzmodul in Funktion „Strichcode" scannen:
|
||||
`M386A8K40BM1-CRC4Y` wird korrekt als Teilenummer übernommen, grüne
|
||||
Rückmeldung mit `STAPEL A`, `64GB 4DRx4 PC4-2400 LRDIMM`.
|
||||
- Etikett ohne lesbaren Barcode fotografieren: OCR-Weg greift, die
|
||||
Otsu-Aufbereitung liefert auf einem echten, glänzenden Etikettenfoto
|
||||
tatsächlich brauchbaren Text (bisher nur an synthetischen Testbildern
|
||||
geprüft, nie an einem echten Foto).
|
||||
- In Funktion „Text erkennen" ein Etikett fotografieren und „Modul scannen"
|
||||
antippen: die Otsu-Aufbereitung liefert auf einem echten, glänzenden
|
||||
Etikettenfoto tatsächlich brauchbaren Text (bisher nur an synthetischen
|
||||
Testbildern geprüft, nie an einem echten Foto).
|
||||
- Ladezeit des WASM-Barcode-Moduls (`zxing-wasm`) beim allerersten Scan einer
|
||||
Sitzung beobachten — bleibt sie deutlich unter der 10-Sekunden-Zeitgrenze
|
||||
der Pipeline, und braucht ein zweiter, schnell nachfolgender Scan nicht
|
||||
erneut die volle Ladezeit (Beleg, dass die Vorbereitung tatsächlich nur
|
||||
einmal läuft)?
|
||||
Sitzung in Funktion „Strichcode" oder „QR-Code" beobachten — bleibt sie
|
||||
deutlich unter der 10-Sekunden-Zeitgrenze der Pipeline, und braucht ein
|
||||
zweiter, schnell nachfolgender Scan nicht erneut die volle Ladezeit (Beleg,
|
||||
dass die Vorbereitung tatsächlich nur einmal läuft)? In Funktion „Text
|
||||
erkennen" darf dieses Laden dagegen gar nicht erst anlaufen, da dort nie
|
||||
`decodeBarcodes` aufgerufen wird.
|
||||
- Verbindung während des allerersten Scans einer Sitzung unterbrechen (WASM
|
||||
lädt per CDN), danach mit wiederhergestellter Verbindung erneut scannen:
|
||||
Das muss einen echten neuen Ladeversuch auslösen statt dauerhaft mit
|
||||
demselben Fehler zu scheitern.
|
||||
- Ladezeit des Tesseract-Arbeiters beim allerersten Scan einer Sitzung
|
||||
beobachten — bleibt sie auf einem normalen Handy deutlich unter der
|
||||
20-Sekunden-Zeitgrenze der Pipeline?
|
||||
- Ladezeit des Tesseract-Arbeiters beim ersten Antippen von „Modul scannen"
|
||||
in Funktion „Text erkennen" beobachten — bleibt sie auf einem normalen
|
||||
Handy deutlich unter der 20-Sekunden-Zeitgrenze der Pipeline? In den
|
||||
Funktionen „Strichcode"/„QR-Code" darf dieses Laden nie anlaufen, auch
|
||||
nicht bei einem gänzlich unbekannten Etikett, da `runOcr` dort nie
|
||||
aufgerufen wird.
|
||||
- Verfügbarkeitsanzeige der Texterkennung: bleibt nach einem erfolgreichen
|
||||
Ladevorgang dauerhaft "verfügbar", springt nach einem erzwungenen
|
||||
Fehlschlag (z. B. Flugmodus beim ersten Laden) beim nächsten Erfolg wieder
|
||||
darauf zurück?
|
||||
- Stichprobe, ob die feste Zeichen-Whitelist der Texterkennung auf echten
|
||||
Etiketten keine tatsächlich benötigten Zeichen ausschließt.
|
||||
- Bild ohne lesbaren Barcode und ohne verwertbaren OCR-Text: Rot-Dialog
|
||||
erscheint statt Fehler oder Absturz.
|
||||
- Modul mit unbekanntem Nummernschema (z. B. ein Hynix-Modul) scannen: grüne
|
||||
Rückmeldung statt Rot, auch wenn keine Kapazität abgeleitet werden kann.
|
||||
Dasselbe Modul ein zweites Mal scannen: landet auf demselben Stapel — und
|
||||
die Rückmeldung erscheint spürbar schneller als beim ersten Scan, weil die
|
||||
Texterkennung diesmal übersprungen wird (nur am Gerät beobachtbar, da
|
||||
`node --test` keine echte Ladezeit misst).
|
||||
- Funktion „Strichcode"/„QR-Code": kein passender Code im Bild führt zu
|
||||
Rot-Dialog (statt Fehler oder Absturz) — hier ohne jeden OCR-Versuch.
|
||||
Funktion „Text erkennen": kein verwertbarer OCR-Text führt ebenfalls zu
|
||||
Rot, hier ohne jeden Barcode-Versuch.
|
||||
- In Funktion „Strichcode" ein Modul mit unbekanntem Nummernschema (z. B. ein
|
||||
Hynix-Modul) scannen: grüne Rückmeldung statt Rot, auch wenn keine
|
||||
Kapazität abgeleitet werden kann (die Texterkennung ist in dieser Funktion
|
||||
ohnehin stumm geschaltet und liefert nie eine Kapazität nach). Dasselbe
|
||||
Modul ein zweites Mal scannen: landet auf demselben Stapel.
|
||||
- Etikett mit zwei Barcodes (Teile- *und* Seriennummer) mehrfach scannen:
|
||||
beide Scans landen auf demselben Stapel, obwohl die Seriennummer bei realen
|
||||
Modulen nie identisch ist — das belegt, dass die Teilenummer und nicht die
|
||||
|
||||
+11
-3
@@ -25,8 +25,14 @@ async function ensureReady() {
|
||||
await readyPromise;
|
||||
}
|
||||
|
||||
// Bisheriges Verhalten (Code-128 und DataMatrix), falls der Aufrufer keine
|
||||
// eigene Liste von Codearten angibt - main.js reicht seit den Scan-Funktionen
|
||||
// (siehe scan-modes.js) stets eine eigene Liste durch, aber ohne Angabe
|
||||
// bleibt der Adapter unveraendert nutzbar (z. B. fuer kuenftige Aufrufer).
|
||||
const DEFAULT_FORMATS = ['Code128', 'DataMatrix'];
|
||||
|
||||
/**
|
||||
* Dekodiert Code-128 und DataMatrix aus einem Einzelbild.
|
||||
* Dekodiert Barcodes aus einem Einzelbild.
|
||||
* Barcode-Dekodierung ist exakt - deshalb ist sie die bevorzugte Quelle
|
||||
* gegenueber der fehleranfaelligen Texterkennung.
|
||||
*
|
||||
@@ -35,12 +41,14 @@ async function ensureReady() {
|
||||
* Ausgang und kein Fehler.
|
||||
*
|
||||
* @param {ImageData} imageData Einzelbild aus der Kamera.
|
||||
* @param {string[]} [formats] Zu suchende Codearten (zxing-wasm-Formatnamen,
|
||||
* siehe scan-modes.js). Ohne Angabe wie bisher: Code128 und DataMatrix.
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function decodeBarcodes(imageData) {
|
||||
export async function decodeBarcodes(imageData, formats = DEFAULT_FORMATS) {
|
||||
await ensureReady();
|
||||
const results = await readBarcodes(imageData, {
|
||||
formats: ['Code128', 'DataMatrix'],
|
||||
formats,
|
||||
tryHarder: true,
|
||||
maxNumberOfSymbols: 4,
|
||||
});
|
||||
|
||||
+79
-3
@@ -5,6 +5,8 @@ import { decodeBarcodes } from './barcode.js';
|
||||
import { runOcr, isOcrAvailable } from './ocr.js';
|
||||
import { recognize } from './pipeline.js';
|
||||
import { normalizeToken } from './spec.js';
|
||||
import { getScanMode } from './scan-modes.js';
|
||||
import { buildRecognitionAdapters } from './scan-recognition.js';
|
||||
import {
|
||||
createSession, proposeAssignment, commitAssignment,
|
||||
undoLast, moveEntry, removeEntry, nextStackId,
|
||||
@@ -14,6 +16,7 @@ import { renderScanView } from './ui/scan-view.js';
|
||||
import { showResult } from './ui/result-overlay.js';
|
||||
import { askForStack } from './ui/ambiguous-dialog.js';
|
||||
import { askResumeSession } from './ui/resume-dialog.js';
|
||||
import { askForScanMode } from './ui/mode-dialog.js';
|
||||
import { renderSessionList } from './ui/session-list.js';
|
||||
import { describeSpec } from './ui/describe-spec.js';
|
||||
|
||||
@@ -38,6 +41,14 @@ let busy = false;
|
||||
// Scan-Durchlaufs ueberschrieben werden.
|
||||
let cameraUnavailableMessage = null;
|
||||
|
||||
// Vor dem ersten Start der Kamera waehlt der Nutzer eine der drei Scan-
|
||||
// Funktionen (siehe scan-modes.js); erst danach steht diese Kennung fest.
|
||||
// Waehrend applyMode() noch nicht gelaufen ist, darf weder die Dauersuche
|
||||
// noch ein Scan-Durchlauf ausgeloest werden - beide sind bis dahin durch das
|
||||
// Auswahl-Overlay (searchPaused()) bzw. durch busy/den fehlenden Kamerastart
|
||||
// ohnehin unerreichbar.
|
||||
let currentModeId = null;
|
||||
|
||||
const view = renderScanView(app, {
|
||||
onCapture: () => processCapture(() => grabFrame(view.video)),
|
||||
onUndo: () => {
|
||||
@@ -56,8 +67,44 @@ const view = renderScanView(app, {
|
||||
openSessionList();
|
||||
},
|
||||
onPickFile: (file) => processCapture(() => imageDataFromFile(file)),
|
||||
onChangeMode: () => {
|
||||
// Waehrend eine Erkennung laeuft, bleibt auch der Funktionswechsel
|
||||
// unerreichbar - aus demselben Grund wie bei Rueckgaengig/Sitzungsliste.
|
||||
if (busy) return;
|
||||
openModeDialog();
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit von
|
||||
* "Modul scannen" und - bei einem Wechsel weg von laufender Codesuche - der
|
||||
* Zielrahmen wird sofort zurueckgesetzt, statt auf den naechsten Durchlauf
|
||||
* der Dauersuche zu warten.
|
||||
* @param {string} modeId
|
||||
*/
|
||||
function applyMode(modeId) {
|
||||
currentModeId = modeId;
|
||||
const mode = getScanMode(modeId);
|
||||
view.setMode(mode.label);
|
||||
// "Modul scannen" hat nur dort etwas zu tun, wo es keine laufende Suche
|
||||
// gibt (Funktion 3) - siehe Kommentar in scan-view.js/setCaptureVisible.
|
||||
view.setCaptureVisible(!mode.continuousSearch);
|
||||
if (!mode.continuousSearch) {
|
||||
view.setFrameDetected(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Oeffnet die Funktionswahl (auch fuer den Wechsel aus der laufenden
|
||||
* Scan-Ansicht heraus, nicht nur beim Start). Solange das Overlay offen ist,
|
||||
* pausiert die Dauersuche von selbst (siehe searchPaused()).
|
||||
*/
|
||||
async function openModeDialog() {
|
||||
closeOverlays();
|
||||
const modeId = await askForScanMode(app, { current: currentModeId });
|
||||
applyMode(modeId);
|
||||
}
|
||||
|
||||
/** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */
|
||||
function syncView() {
|
||||
view.setStacks(session.stacks);
|
||||
@@ -98,9 +145,14 @@ async function processCapture(getFrame) {
|
||||
(stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized),
|
||||
);
|
||||
};
|
||||
// Welcher Adapter tatsaechlich etwas tut, haengt von der gewaehlten
|
||||
// Scan-Funktion ab (siehe scan-modes.js/scan-recognition.js) - recognize()
|
||||
// selbst (pipeline.js) bleibt unveraendert und kennt keine Funktionen.
|
||||
const { decodeBarcodes: decode, runOcr: recognizeText } =
|
||||
buildRecognitionAdapters(currentModeId, { decodeBarcodes, runOcr });
|
||||
const { spec, source, confidence, codes } = await recognize(
|
||||
frame,
|
||||
{ decodeBarcodes, runOcr, isKnownCode },
|
||||
{ decodeBarcodes: decode, runOcr: recognizeText, isKnownCode },
|
||||
);
|
||||
|
||||
const plan = proposeAssignment(targetSession, spec, codes);
|
||||
@@ -218,6 +270,11 @@ function searchPaused() {
|
||||
* nichts tun.
|
||||
*/
|
||||
async function runSearchAttempt() {
|
||||
// Funktion 3 (Text erkennen) hat keine laufende Suche - siehe scan-modes.js.
|
||||
// Ein Wechsel dorthin setzt den Rahmen bereits in applyMode() zurueck;
|
||||
// hier genuegt es, schlicht nichts zu tun.
|
||||
if (!getScanMode(currentModeId).continuousSearch) return;
|
||||
|
||||
if (searchPaused()) {
|
||||
view.setFrameDetected(false);
|
||||
return;
|
||||
@@ -235,7 +292,7 @@ async function runSearchAttempt() {
|
||||
|
||||
let codes;
|
||||
try {
|
||||
codes = await decodeBarcodes(region);
|
||||
codes = await decodeBarcodes(region, getScanMode(currentModeId).barcodeFormats);
|
||||
} catch {
|
||||
view.setFrameDetected(false);
|
||||
return;
|
||||
@@ -331,10 +388,14 @@ function openSessionList() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Startet die Kamera und, sobald sie brauchbare Bilder liefert, die Dauersuche. */
|
||||
function startCameraAndSearch() {
|
||||
startCamera(view.video)
|
||||
.then(() => {
|
||||
// Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche
|
||||
// laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss.
|
||||
// runSearchAttempt() selbst haelt sich zurueck, wenn die gewaehlte
|
||||
// Funktion keine laufende Suche vorsieht (Funktion 3).
|
||||
continuousSearchLoop();
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -345,6 +406,7 @@ startCamera(view.video)
|
||||
view.setStatus(cameraUnavailableMessage, true);
|
||||
view.setFilePickerVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,
|
||||
@@ -367,4 +429,18 @@ async function initResumeOffer() {
|
||||
syncView();
|
||||
}
|
||||
|
||||
initResumeOffer();
|
||||
/**
|
||||
* Ablauf beim Start: erst - falls vorhanden - die Frage nach dem Fortsetzen
|
||||
* einer gesicherten Sitzung, danach die Wahl der Scan-Funktion, und erst
|
||||
* danach die Kamera. So sieht der Nutzer die Funktionswahl nie, bevor die
|
||||
* Fortsetzen-Frage (falls noetig) beantwortet ist, und die Kamera startet
|
||||
* nie, bevor feststeht, welche Codearten sie ueberhaupt suchen soll.
|
||||
*/
|
||||
async function boot() {
|
||||
await initResumeOffer();
|
||||
const modeId = await askForScanMode(app);
|
||||
applyMode(modeId);
|
||||
startCameraAndSearch();
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Die drei Scan-Funktionen, die der Nutzer vor dem Scannen waehlt. Reines
|
||||
// Modul ohne Browser-Zugriff (kein window/document/navigator) - einzige
|
||||
// Quelle fuer Kennung, Beschriftung, Codearten und Verhalten je Funktion.
|
||||
// Sowohl die Auswahl-Oberflaeche als auch main.js lesen ausschliesslich von
|
||||
// hier - es darf keine zweite Stelle geben, an der Codearten oder
|
||||
// Beschriftungen stehen.
|
||||
//
|
||||
// Ausdrueckliche Entscheidung des Auftraggebers (nicht abaendern): Funktion 2
|
||||
// liest streng nur QR (QRCode, MicroQRCode, RMQRCode) - kein DataMatrix, auch
|
||||
// wenn die 2D-Codes auf den eigenen RAM-Etiketten DataMatrix sind. Der
|
||||
// Arbeitsablauf des Auftraggebers laeuft ueber Funktion 1 (Teilenummer im
|
||||
// Strichcode); Funktion 2 dient anderer Ware.
|
||||
|
||||
/**
|
||||
* @typedef {object} ScanMode
|
||||
* @property {string} id Kennung, intern verwendet (main.js, Speicherung der Wahl).
|
||||
* @property {string} label Deutsche Beschriftung fuer die Oberflaeche.
|
||||
* @property {string[]} barcodeFormats An decodeBarcodes() zu uebergebende
|
||||
* Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine
|
||||
* Barcode-Dekodierung nutzt.
|
||||
* @property {boolean} continuousSearch Ob waehrend die Kamera laeuft
|
||||
* fortwaehrend nach Codes gesucht wird (statt nur auf Knopfdruck).
|
||||
* @property {boolean} useOcr Ob die Texterkennung (Tesseract) genutzt wird.
|
||||
*/
|
||||
|
||||
/** @type {ScanMode[]} */
|
||||
export const SCAN_MODES = [
|
||||
{
|
||||
id: 'barcode',
|
||||
label: 'Strichcode',
|
||||
// Eindimensionale Codes, wie sie auf Teilenummern-Etiketten vorkommen.
|
||||
barcodeFormats: ['Code128', 'Code39', 'Code93', 'ITF', 'EAN13', 'EAN8', 'UPCA', 'UPCE', 'Codabar'],
|
||||
continuousSearch: true,
|
||||
useOcr: false,
|
||||
},
|
||||
{
|
||||
id: 'qrcode',
|
||||
label: 'QR-Code',
|
||||
// Ausschliesslich QR-Varianten - bewusst kein DataMatrix, siehe Kommentar oben.
|
||||
barcodeFormats: ['QRCode', 'MicroQRCode', 'RMQRCode'],
|
||||
continuousSearch: true,
|
||||
useOcr: false,
|
||||
},
|
||||
{
|
||||
id: 'ocr',
|
||||
label: 'Text erkennen',
|
||||
// Keine Codes - allein die Texterkennung, auf Knopfdruck.
|
||||
barcodeFormats: [],
|
||||
continuousSearch: false,
|
||||
useOcr: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Liefert die Funktion zu einer Kennung.
|
||||
* @param {string} id
|
||||
* @returns {ScanMode}
|
||||
* @throws {Error} wenn die Kennung keiner der drei Funktionen entspricht -
|
||||
* ein Programmfehler (main.js darf nie eine fremde Kennung durchreichen),
|
||||
* kein regulaerer Ausgang.
|
||||
*/
|
||||
export function getScanMode(id) {
|
||||
const mode = SCAN_MODES.find((candidate) => candidate.id === id);
|
||||
if (!mode) {
|
||||
throw new Error(`Unbekannte Scan-Funktion: ${id}`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Baut die Adapter fuer recognize() (pipeline.js) anhand der gewaehlten
|
||||
// Scan-Funktion (siehe scan-modes.js). Reines Modul ohne Browser-Zugriff -
|
||||
// direkt unter Node pruefbar. pipeline.js selbst bleibt unveraendert: die
|
||||
// Funktionen unterscheiden sich ausschliesslich darin, welche Adapter
|
||||
// hineingereicht werden.
|
||||
//
|
||||
// Funktionen 1/2 (laufende Codesuche): echte Barcode-Dekodierung mit den zur
|
||||
// Funktion gehoerenden Codearten; die Texterkennung liefert sofort leeren
|
||||
// Text, ohne dass runOcr ueberhaupt aufgerufen wird.
|
||||
// Funktion 3 (Text erkennen): echte Texterkennung; die Barcode-Dekodierung
|
||||
// liefert sofort eine leere Liste, ohne dass decodeBarcodes ueberhaupt
|
||||
// aufgerufen wird.
|
||||
|
||||
import { getScanMode } from './scan-modes.js';
|
||||
|
||||
/**
|
||||
* @param {string} modeId Kennung aus scan-modes.js.
|
||||
* @param {{decodeBarcodes: Function, runOcr: Function}} adapters echte,
|
||||
* browser-gebundene Adapter (aus barcode.js / ocr.js).
|
||||
* @returns {{decodeBarcodes: Function, runOcr: Function}} an recognize()
|
||||
* durchzureichende Adapter, passend zur gewaehlten Funktion.
|
||||
*/
|
||||
export function buildRecognitionAdapters(modeId, adapters) {
|
||||
const mode = getScanMode(modeId);
|
||||
|
||||
return {
|
||||
decodeBarcodes: mode.useOcr
|
||||
? async () => []
|
||||
: (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats),
|
||||
runOcr: mode.useOcr
|
||||
? adapters.runOcr
|
||||
: async () => '',
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,19 @@ body {
|
||||
|
||||
#app { display: flex; flex-direction: column; height: 100dvh; }
|
||||
|
||||
.mode-button {
|
||||
min-height: 56px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #1c2027;
|
||||
color: var(--fg);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.camera { position: relative; flex: 1; overflow: hidden; background: #000; }
|
||||
.camera video { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
@@ -115,5 +128,22 @@ body {
|
||||
.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
|
||||
.choices button { min-height: 64px; font-size: 20px; }
|
||||
|
||||
/* Auswahl der Scan-Funktion vor dem Scannen (mode-dialog.js): drei grosse,
|
||||
gleich gestaltete Flaechen statt schmaler Schaltflaechen. */
|
||||
.mode-tile {
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.mode-tile-label { font-size: 24px; font-weight: 700; }
|
||||
.mode-tile-hint { font-size: 14px; opacity: 0.8; }
|
||||
|
||||
.mode-tile-current { outline: 3px solid var(--fg); outline-offset: -3px; }
|
||||
|
||||
.status { padding: 8px 12px; font-size: 14px; color: var(--muted); }
|
||||
.status.warn { color: #f0b429; }
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { SCAN_MODES } from '../scan-modes.js';
|
||||
|
||||
/**
|
||||
* Kurzer Hinweis je Funktion, direkt aus ihren Merkmalen abgeleitet (siehe
|
||||
* scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion.
|
||||
*/
|
||||
function describeMode(mode) {
|
||||
if (mode.useOcr) return 'Auf Knopfdruck';
|
||||
return 'Läuft von selbst, sobald ein Code im Bild ist';
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollbild-Auswahl der Scan-Funktion: drei grosse Flaechen, eine je Funktion
|
||||
* aus scan-modes.js (einzige Quelle fuer Kennung und Beschriftung). Gebaut
|
||||
* nach demselben Muster wie die anderen Vollbild-Dialoge (ambiguous-dialog.js,
|
||||
* resume-dialog.js): Geschwister waehrend der Anzeige per inert unerreichbar,
|
||||
* Fokus kehrt danach zurueck. Reine Darstellung - kennt weder Kamera noch
|
||||
* Erkennung.
|
||||
*
|
||||
* Wird sowohl vor dem ersten Start der Kamera verwendet als auch fuer den
|
||||
* Wechsel aus der laufenden Scan-Ansicht heraus (siehe main.js).
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @param {{current?: string}} [args] Kennung der aktuell gewaehlten Funktion,
|
||||
* falls es sich um einen Wechsel handelt - hebt die passende Flaeche hervor.
|
||||
* @returns {Promise<string>} Kennung der gewaehlten Funktion (siehe scan-modes.js).
|
||||
*/
|
||||
export function askForScanMode(root, { current } = {}) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'overlay';
|
||||
overlay.style.background = 'var(--bg)';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-labelledby', 'mode-dialog-heading');
|
||||
overlay.innerHTML = `
|
||||
<div class="stack-id" id="mode-dialog-heading" style="font-size:28px">Funktion wählen</div>
|
||||
<div class="choices mode-choices" id="choices"></div>
|
||||
`;
|
||||
|
||||
const choices = overlay.querySelector('#choices');
|
||||
|
||||
// Siehe ambiguous-dialog.js / resume-dialog.js: waehrend der Dialog offen
|
||||
// ist, werden die Geschwisterelemente per inert unerreichbar gemacht und
|
||||
// danach wieder freigegeben.
|
||||
const previouslyFocused = document.activeElement;
|
||||
const madeInert = [];
|
||||
for (const sibling of root.children) {
|
||||
if (!sibling.hasAttribute('inert')) {
|
||||
sibling.setAttribute('inert', '');
|
||||
madeInert.push(sibling);
|
||||
}
|
||||
}
|
||||
|
||||
root.appendChild(overlay);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (modeId) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlay.remove();
|
||||
for (const sibling of madeInert) {
|
||||
sibling.removeAttribute('inert');
|
||||
}
|
||||
if (
|
||||
previouslyFocused &&
|
||||
typeof previouslyFocused.focus === 'function' &&
|
||||
document.contains(previouslyFocused)
|
||||
) {
|
||||
previouslyFocused.focus();
|
||||
} else {
|
||||
root.focus?.();
|
||||
}
|
||||
resolve(modeId);
|
||||
};
|
||||
|
||||
let firstButton = null;
|
||||
for (const mode of SCAN_MODES) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'action mode-tile';
|
||||
if (mode.id === current) button.classList.add('mode-tile-current');
|
||||
button.setAttribute('aria-pressed', String(mode.id === current));
|
||||
button.innerHTML = `
|
||||
<span class="mode-tile-label">${mode.label}</span>
|
||||
<span class="mode-tile-hint"></span>
|
||||
`;
|
||||
button.querySelector('.mode-tile-hint').textContent = describeMode(mode);
|
||||
button.addEventListener('click', () => finish(mode.id));
|
||||
choices.appendChild(button);
|
||||
firstButton = firstButton ?? button;
|
||||
}
|
||||
|
||||
firstButton?.focus();
|
||||
});
|
||||
}
|
||||
+29
-3
@@ -4,14 +4,15 @@ import { FRAME_INSET } from '../camera.js';
|
||||
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
|
||||
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
|
||||
*/
|
||||
export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }) {
|
||||
export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile, onChangeMode }) {
|
||||
root.innerHTML = `
|
||||
<button class="mode-button" id="mode-button" type="button"></button>
|
||||
<div class="camera">
|
||||
<video id="preview" playsinline muted></video>
|
||||
<div class="frame"></div>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
<button class="action capture" id="capture">Modul scannen</button>
|
||||
<button class="action capture" id="capture" hidden>Modul scannen</button>
|
||||
<button class="action secondary" id="pick-file-button" hidden>Bild auswählen</button>
|
||||
<div class="stacks" id="stacks"></div>
|
||||
<div class="last">
|
||||
@@ -27,6 +28,8 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
||||
const fileEl = root.querySelector('#file');
|
||||
const pickFileButtonEl = root.querySelector('#pick-file-button');
|
||||
const frameEl = root.querySelector('.frame');
|
||||
const captureEl = root.querySelector('#capture');
|
||||
const modeButtonEl = root.querySelector('#mode-button');
|
||||
|
||||
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
|
||||
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
|
||||
@@ -36,8 +39,9 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
||||
`${FRAME_INSET.top * 100}% ${FRAME_INSET.right * 100}% ` +
|
||||
`${FRAME_INSET.bottom * 100}% ${FRAME_INSET.left * 100}%`;
|
||||
|
||||
root.querySelector('#capture').addEventListener('click', onCapture);
|
||||
captureEl.addEventListener('click', onCapture);
|
||||
root.querySelector('#undo').addEventListener('click', onUndo);
|
||||
modeButtonEl.addEventListener('click', onChangeMode);
|
||||
// Klick auf die sichtbare Schaltflaeche ist selbst die Nutzergeste, die
|
||||
// die versteckte Dateiauswahl oeffnen darf - anders als ein Aufruf aus dem
|
||||
// Code heraus, den Handy-Browser blockieren.
|
||||
@@ -74,6 +78,28 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
||||
lastEl.textContent = text;
|
||||
},
|
||||
|
||||
/**
|
||||
* Zeigt die gewaehlte Scan-Funktion in der Ansicht an - antippbar, um zu
|
||||
* wechseln (siehe onChangeMode). Beschriftung kommt vom Aufrufer, der sie
|
||||
* seinerseits aus scan-modes.js liest; diese Ansicht kennt die Funktionen
|
||||
* selbst nicht.
|
||||
* @param {string} label
|
||||
*/
|
||||
setMode(label) {
|
||||
modeButtonEl.textContent = `${label} ändern ⌄`;
|
||||
modeButtonEl.setAttribute('aria-label', `Scan-Funktion wechseln (aktuell: ${label})`);
|
||||
},
|
||||
|
||||
/**
|
||||
* "Modul scannen" hat nur in Funktionen mit Texterkennung auf Knopfdruck
|
||||
* etwas zu tun (Funktion 3) - in Funktionen mit laufender Codesuche sucht
|
||||
* die App ohnehin durchgehend, ein Knopf dafuer waere wirkungslos.
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
setCaptureVisible(visible) {
|
||||
captureEl.hidden = !visible;
|
||||
},
|
||||
|
||||
/** @param {string} text @param {boolean} warn */
|
||||
setStatus(text, warn = false) {
|
||||
statusEl.textContent = text;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SCAN_MODES, getScanMode } from '../src/scan-modes.js';
|
||||
|
||||
test('genau drei Funktionen in fester Reihenfolge', () => {
|
||||
assert.equal(SCAN_MODES.length, 3);
|
||||
assert.deepEqual(SCAN_MODES.map((mode) => mode.id), ['barcode', 'qrcode', 'ocr']);
|
||||
});
|
||||
|
||||
test('Funktion 1 (Strichcode): eindimensionale Codearten, laufende Suche, keine Texterkennung', () => {
|
||||
const mode = getScanMode('barcode');
|
||||
assert.equal(mode.label, 'Strichcode');
|
||||
assert.deepEqual(mode.barcodeFormats, [
|
||||
'Code128', 'Code39', 'Code93', 'ITF', 'EAN13', 'EAN8', 'UPCA', 'UPCE', 'Codabar',
|
||||
]);
|
||||
assert.equal(mode.continuousSearch, true);
|
||||
assert.equal(mode.useOcr, false);
|
||||
});
|
||||
|
||||
test('Funktion 2 (QR-Code): ausschliesslich QR-Varianten, kein DataMatrix', () => {
|
||||
const mode = getScanMode('qrcode');
|
||||
assert.equal(mode.label, 'QR-Code');
|
||||
assert.deepEqual(mode.barcodeFormats, ['QRCode', 'MicroQRCode', 'RMQRCode']);
|
||||
assert.ok(!mode.barcodeFormats.includes('DataMatrix'), 'Funktion 2 darf kein DataMatrix lesen');
|
||||
assert.equal(mode.continuousSearch, true);
|
||||
assert.equal(mode.useOcr, false);
|
||||
});
|
||||
|
||||
test('Funktion 3 (Text erkennen): keine Codearten, keine laufende Suche, Texterkennung', () => {
|
||||
const mode = getScanMode('ocr');
|
||||
assert.equal(mode.label, 'Text erkennen');
|
||||
assert.deepEqual(mode.barcodeFormats, []);
|
||||
assert.equal(mode.continuousSearch, false);
|
||||
assert.equal(mode.useOcr, true);
|
||||
});
|
||||
|
||||
test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => {
|
||||
assert.throws(() => getScanMode('unbekannt'));
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildRecognitionAdapters } from '../src/scan-recognition.js';
|
||||
import { recognize } from '../src/pipeline.js';
|
||||
|
||||
test('Funktion 1: Texterkennung wird nicht aufgerufen, auch wenn kein Code gefunden wird', async () => {
|
||||
let ocrCalled = false;
|
||||
const adapters = buildRecognitionAdapters('barcode', {
|
||||
decodeBarcodes: async () => [],
|
||||
runOcr: async () => { ocrCalled = true; return 'irgendein Text'; },
|
||||
});
|
||||
const result = await recognize({}, adapters);
|
||||
assert.equal(ocrCalled, false, 'runOcr darf in Funktion 1 nie aufgerufen werden');
|
||||
assert.equal(result.confidence, 'red');
|
||||
});
|
||||
|
||||
test('Funktion 2: Texterkennung wird nicht aufgerufen, auch wenn kein Code gefunden wird', async () => {
|
||||
let ocrCalled = false;
|
||||
const adapters = buildRecognitionAdapters('qrcode', {
|
||||
decodeBarcodes: async () => [],
|
||||
runOcr: async () => { ocrCalled = true; return 'irgendein Text'; },
|
||||
});
|
||||
const result = await recognize({}, adapters);
|
||||
assert.equal(ocrCalled, false, 'runOcr darf in Funktion 2 nie aufgerufen werden');
|
||||
assert.equal(result.confidence, 'red');
|
||||
});
|
||||
|
||||
test('Funktion 3: Barcode-Dekodierung wird nicht aufgerufen', async () => {
|
||||
let barcodeCalled = false;
|
||||
const adapters = buildRecognitionAdapters('ocr', {
|
||||
decodeBarcodes: async () => { barcodeCalled = true; return []; },
|
||||
runOcr: async () => '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908',
|
||||
});
|
||||
const result = await recognize({}, adapters);
|
||||
assert.equal(barcodeCalled, false, 'decodeBarcodes darf in Funktion 3 nie aufgerufen werden');
|
||||
assert.equal(result.source, 'ocr');
|
||||
assert.equal(result.confidence, 'yellow');
|
||||
});
|
||||
|
||||
test('Funktion 1: ein gelesener Strichcode fuehrt zu Gruen', async () => {
|
||||
const adapters = buildRecognitionAdapters('barcode', {
|
||||
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y'],
|
||||
runOcr: async () => '',
|
||||
});
|
||||
const result = await recognize({}, adapters);
|
||||
assert.equal(result.source, 'barcode');
|
||||
assert.equal(result.confidence, 'green');
|
||||
});
|
||||
|
||||
test('Funktion 3: kein brauchbarer Text fuehrt zu Rot, nicht zu Gruen', async () => {
|
||||
const adapters = buildRecognitionAdapters('ocr', {
|
||||
decodeBarcodes: async () => { throw new Error('darf nicht aufgerufen werden'); },
|
||||
runOcr: async () => 'Made in Philippines',
|
||||
});
|
||||
const result = await recognize({}, adapters);
|
||||
assert.equal(result.source, 'none');
|
||||
assert.equal(result.confidence, 'red');
|
||||
});
|
||||
|
||||
test('decodeBarcodes bekommt die Codearten der jeweiligen Funktion uebergeben', async () => {
|
||||
let receivedFormats = null;
|
||||
const adapters = buildRecognitionAdapters('qrcode', {
|
||||
decodeBarcodes: async (_imageData, formats) => { receivedFormats = formats; return []; },
|
||||
runOcr: async () => '',
|
||||
});
|
||||
await adapters.decodeBarcodes({});
|
||||
assert.deepEqual(receivedFormats, ['QRCode', 'MicroQRCode', 'RMQRCode']);
|
||||
});
|
||||
|
||||
test('Funktion 3 uebergibt keine Codearten, weil decodeBarcodes gar nicht aufgerufen wird', async () => {
|
||||
let called = false;
|
||||
const adapters = buildRecognitionAdapters('ocr', {
|
||||
decodeBarcodes: async () => { called = true; return []; },
|
||||
runOcr: async () => '',
|
||||
});
|
||||
const codes = await adapters.decodeBarcodes({});
|
||||
assert.deepEqual(codes, []);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
Reference in New Issue
Block a user