v9.1: OCR-Feature Bug-Fix - zusammenhängende Zahlenblöcke

extractDigitSequences() Fix:
- VORHER: Alle Ziffern zusammengefasst → Datensalat bei Subsequenzen
- JETZT: Nur isolierte N-stellige Zahlenblöcke (filter auf length === N)
- Beispiel: "Artikel 1234567 Datum 20250311" → nur "1234567" (nicht "2025031" etc.)

Artikelnummer-Suche:
- findproduct.php sucht in: barcode, ref_fourn (Lieferanten-Artikelnummer!), ref
- OCR-erkannte Nummern funktionieren wenn ref_fourn in Dolibarr gepflegt ist

Dokumentation:
- README.md aktualisiert: ZXing-JS, OCR-Feature, Versionierung
- Barcode-Unterstützung erweitert: Code 128, QR, DataMatrix, OCR
- Bibliotheken: ZXing-JS v0.21.3, Tesseract.js v5.1.0

Versionen synchron erhöht:
- pwa.php: ?v=91 (CSS + JS)
- sw.js: scanner-v9.1
- modHandyBarcodeScanner.class.php: 9.1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-11 20:20:16 +01:00
parent 8586b568e8
commit c97540e6f6
6 changed files with 843 additions and 115 deletions

View file

@ -50,18 +50,32 @@ Das Modul bietet drei Modi für die mobile Barcode-Erfassung:
## Barcode-Unterstützung
Das Modul sucht Barcodes in folgender Reihenfolge:
Das Modul sucht Barcodes/Artikelnummern in folgender Reihenfolge:
1. Produkt-Barcode (`llx_product.barcode`)
2. Lieferanten-Barcode (`llx_product_fournisseur_price.barcode`)
3. Produkt-Referenz (`llx_product.ref`)
3. **Lieferanten-Artikelnummer** (`llx_product_fournisseur_price.ref_fourn`) ← **NEU ab v9.0**
4. Produkt-Referenz (`llx_product.ref`)
Unterstützte Barcode-Formate (Quagga2):
- **Code 128** (empfohlen für alphanumerische Codes, höchste Priorität)
- **Code 39** (alphanumerisch, zweite Priorität)
- **EAN-13** (nur Ziffern, dritte Priorität)
- **EAN-8** (nur Ziffern, niedrigste Priorität)
### Barcode-Formate (ZXing-JS, ab v8.3)
**WICHTIG:** Reader-Reihenfolge optimiert für alphanumerische Codes! CODE128/CODE39 haben Vorrang vor EAN-Readern, um Fehlerkennungen bei Herstellercodes wie "P20260030" zu vermeiden.
**Unterstützte Formate - wählbar per Dropdown:**
- **Code 128** - alphanumerisch (1D-Barcode)
- **QR-Code** - 2D-Matrix-Code
- **DataMatrix** - 2D-Matrix-Code
**Typ-Umschaltung ohne Kamera-Neustart:**
Der Barcode-Typ kann während des Scannens gewechselt werden. Die Kamera bleibt aktiv, nur der Decoder wird neu initialisiert. Die Auswahl wird persistent in localStorage gespeichert.
### OCR-Modus für Artikelnummern (ab v8.5, optimiert in v9.1)
**Für Etiketten OHNE Barcode:**
- Foto-basierte Texterkennung mit Tesseract.js
- Variable Ziffern-Anzahl (5-12 stellig) per Dropdown wählbar
- Erkennt **zusammenhängende** N-stellige Zahlen (z.B. 7-stellige Artikelnummer)
- Mehrere Treffer → Auswahl-Dialog
- Keine Treffer → Fallback auf manuelle Produktsuche
**Wichtig:** OCR erkennt nur **isolierte** Zahlenblöcke mit exakt N Ziffern. Keine Subsequenzen oder Zusammensetzungen aus längeren Zahlen!
## Installation
@ -186,8 +200,9 @@ handybarcodescanner/
```
### Verwendete Bibliotheken
- [Quagga2](https://github.com/ericblade/quagga2) - Browser-basierte Barcode-Erkennung
- [JsBarcode](https://github.com/lindell/JsBarcode) - Barcode-Generierung für Druck
- [ZXing-JS](https://github.com/zxing-js/library) v0.21.3 - Browser-basierte Barcode-Erkennung (1D + 2D)
- [Tesseract.js](https://github.com/naptha/tesseract.js) v5.1.0 - OCR (Optical Character Recognition) für Artikelnummern
- [JsBarcode](https://github.com/lindell/JsBarcode) v3.11.6 - Barcode-Generierung für Druck
- [ZXing](https://github.com/zxing/zxing) - Barcode-Generierung in Android App
### PWA (Progressive Web App)
@ -203,7 +218,15 @@ Das Modul ist als PWA konzipiert mit:
- Firefox auf Android: Nur Shortcuts, kein echter Standalone-Modus ⚠️
- Safari auf iOS: PWA-Unterstützung mit Einschränkungen
**Versionsverwaltung:** Bei Updates CACHE_NAME in `sw.js` + `?v=` in `pwa.php` synchron erhöhen!
**Versionsverwaltung:** Bei Updates IMMER synchron erhöhen:
- `CACHE_NAME` in `sw.js` (z.B. `scanner-v9.1`)
- `?v=` Parameter in `pwa.php` für CSS und JS (z.B. `?v=91`)
- `$this->version` in `modHandyBarcodeScanner.class.php` (z.B. `9.1`)
**Aktuelle Version: 9.1**
- v9.1: OCR-Feature Bug-Fix (extractDigitSequences: nur zusammenhängende Zahlenblöcke)
- v9.0: OCR-Feature für Artikelnummern-Erkennung
- v8.3: ZXing-JS Integration (Code 128, QR, DataMatrix)
### Brother PT-E560BT Android App

Binary file not shown.

View file

@ -76,7 +76,7 @@ class modHandyBarcodeScanner extends DolibarrModules
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@handybarcodescanner'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '8.2';
$this->version = '9.1';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';

View file

@ -44,9 +44,18 @@
let currentProduct = null;
let selectedSupplier = null;
let allSuppliers = [];
let quaggaInitialized = false;
let scannerInitialized = false;
let openDialogCount = 0; // Zaehlt offene Dialoge
// ZXing Scanner State
let currentBarcodeType = 'CODE_128'; // Aktuell gewahlter Barcode-Typ
let currentScanner = null; // ZXing BrowserMultiFormatReader instance
// OCR State (Tesseract.js)
let ocrWorker = null; // Singleton Worker
let ocrInitialized = false;
let currentDigitCount = 8; // Anzahl Ziffern für OCR-Erkennung
// Persistente Einstellungen - eine zentrale Config statt einzelner Keys
const STORAGE_KEY = 'hbs_config';
function loadConfig() {
@ -133,6 +142,49 @@
window._showProductSearchModal = showProductSearchModal;
window._showFreetextModal = showFreetextModal;
window._showOrdersPanel = showOrdersPanel;
// History-Button Event-Handler
const historyBtn = document.getElementById('ctrl-history');
if (historyBtn) {
historyBtn.addEventListener('click', showBarcodeHistory);
}
// Barcode-Typ-Dropdown Event-Handler
const typeSelect = document.getElementById('barcode-type-select');
if (typeSelect) {
// Gespeicherten Typ laden
const savedType = loadConfig().barcodeType || 'CODE_128';
typeSelect.value = savedType;
currentBarcodeType = savedType;
// Initiale Scan-Region-Farbe setzen
updateScanRegionColor(savedType);
// OCR-Digit-Selector initial ein/ausblenden
const digitSelector = document.getElementById('ocr-digit-selector');
if (digitSelector) {
digitSelector.classList.toggle('hidden', savedType !== 'OCR');
}
// Event-Listener fuer Typ-Wechsel
typeSelect.addEventListener('change', function() {
switchBarcodeType(this.value);
});
}
// OCR Digit-Count Dropdown Handler
const digitSelect = document.getElementById('ocr-digit-count');
if (digitSelect) {
const savedCount = loadConfig().ocrDigitCount || 8;
digitSelect.value = savedCount;
currentDigitCount = savedCount;
digitSelect.addEventListener('change', function() {
currentDigitCount = parseInt(this.value);
saveConfig('ocrDigitCount', currentDigitCount);
showToast('OCR: ' + currentDigitCount + ' Ziffern', 'success');
});
}
}
// Check camera support - just log warnings, never disable button
@ -143,9 +195,9 @@
console.warn('HandyBarcodeScanner: HTTPS empfohlen fuer Kamerazugriff');
}
// Check if Quagga is loaded
if (typeof Quagga === 'undefined') {
console.error('HandyBarcodeScanner: Quagga nicht geladen');
// Check if ZXing is loaded
if (typeof ZXing === 'undefined') {
console.error('HandyBarcodeScanner: ZXing nicht geladen');
}
// Check mediaDevices
@ -224,10 +276,17 @@
return;
}
// Check if Quagga is available
if (typeof Quagga === 'undefined') {
showToast('Quagga Bibliothek nicht geladen', 'error');
return;
// Check if libraries are available
if (currentBarcodeType === 'OCR') {
if (typeof Tesseract === 'undefined') {
showToast('Tesseract.js Bibliothek nicht geladen', 'error');
return;
}
} else {
if (typeof ZXing === 'undefined') {
showToast('ZXing Bibliothek nicht geladen', 'error');
return;
}
}
// Disable button while initializing
@ -240,8 +299,14 @@
// Permission granted - stop the test stream
stream.getTracks().forEach(track => track.stop());
// Now start Quagga
initQuagga();
// OCR-Modus oder Barcode-Modus?
if (currentBarcodeType === 'OCR') {
// OCR-Modus: Kamera-Preview + "Foto aufnehmen" Button
startCameraPreview();
} else {
// Barcode-Modus: ZXing Video-Stream
currentScanner = initZXing(currentBarcodeType);
}
})
.catch(function(err) {
console.error('Camera permission error:', err);
@ -260,68 +325,63 @@
});
}
function initQuagga() {
Quagga.init({
inputStream: {
name: "Live",
type: "LiveStream",
target: elements.videoContainer,
constraints: {
facingMode: "environment",
width: { min: 640, ideal: 1280, max: 1920 },
height: { min: 480, ideal: 720, max: 1080 }
},
area: { // Scan-Bereich: groesserer Bereich fuer kleine Barcodes
top: "10%",
right: "5%",
left: "5%",
bottom: "10%"
function getBarcodeFormat(type) {
const formats = {
'CODE_128': ZXing.BarcodeFormat.CODE_128,
'QR_CODE': ZXing.BarcodeFormat.QR_CODE,
'DATA_MATRIX': ZXing.BarcodeFormat.DATA_MATRIX,
'CODE_39': ZXing.BarcodeFormat.CODE_39,
'EAN_13': ZXing.BarcodeFormat.EAN_13,
'EAN_8': ZXing.BarcodeFormat.EAN_8
};
return formats[type] || ZXing.BarcodeFormat.CODE_128;
}
function initZXing(format) {
format = format || currentBarcodeType;
try {
const hints = new Map();
const possibleFormats = [getBarcodeFormat(format)];
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, possibleFormats);
const codeReader = new ZXing.BrowserMultiFormatReader(hints);
codeReader.decodeFromVideoDevice(
undefined, // Auto-select Kamera
elements.video,
(result, err) => {
if (result) {
onBarcodeDetected(result);
}
// Errors werden ignoriert (passiert bei jedem Frame ohne Code)
}
},
decoder: {
readers: [
"code_128_reader", // Best for alphanumeric codes
"code_39_reader", // Also good for alphanumeric (e.g. P20260030)
"ean_reader", // Numeric only - lower priority
"ean_8_reader" // Numeric only - lower priority
],
multiple: false
},
locate: true,
locator: {
patchSize: "medium", // medium = besser fuer kleine Barcodes
halfSample: false // volle Aufloesung = bessere Genauigkeit
},
numOfWorkers: navigator.hardwareConcurrency || 4,
frequency: 20 // haeufiger scannen fuer bessere Erkennung
}, function(err) {
if (err) {
console.error('Quagga init error:', err);
).then(() => {
// Scanner erfolgreich gestartet
scannerInitialized = true;
isScanning = true;
elements.startBtn.disabled = false;
elements.startBtn.classList.add('hidden');
elements.stopBtn.classList.remove('hidden');
elements.videoContainer.classList.add('scanning');
// Timeout-Timer starten
startScanTimeout();
}).catch((err) => {
console.error('ZXing init error:', err);
elements.startBtn.disabled = false;
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
showToast(CONFIG.lang.cameraError || 'Kamerafehler', 'error');
return;
}
});
// Success - start scanning
Quagga.start();
quaggaInitialized = true;
isScanning = true;
return codeReader;
} catch (e) {
console.error('ZXing initialization error:', e);
elements.startBtn.disabled = false;
elements.startBtn.classList.add('hidden');
elements.stopBtn.classList.remove('hidden');
elements.videoContainer.classList.add('scanning');
// Quagga-Canvas darf keine Klicks abfangen
const canvases = elements.videoContainer.querySelectorAll('canvas');
canvases.forEach(function(c) { c.style.pointerEvents = 'none'; });
// Register detection handler
Quagga.onDetected(onBarcodeDetected);
// Timeout-Timer starten
startScanTimeout();
});
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scan starten';
showToast('Scanner konnte nicht initialisiert werden', 'error');
return null;
}
}
// Timeout-Hinweis wenn nichts erkannt wird
@ -339,18 +399,29 @@
function stopScanner() {
clearTimeout(scanTimeoutTimer);
if (quaggaInitialized) {
// OCR-Modus: Video-Stream manuell stoppen
if (currentBarcodeType === 'OCR') {
const video = elements.video;
if (video && video.srcObject) {
video.srcObject.getTracks().forEach(track => track.stop());
video.srcObject = null;
}
// Button-Click-Handler zurücksetzen
elements.startBtn.onclick = null;
}
if (currentScanner && scannerInitialized) {
try {
Quagga.offDetected(onBarcodeDetected);
Quagga.stop();
currentScanner.reset();
} catch (e) {
console.log('Quagga stop error:', e);
console.log('Scanner stop error:', e);
}
}
isScanning = false;
isPaused = false;
quaggaInitialized = false;
scannerInitialized = false;
currentScanner = null;
elements.startBtn.classList.remove('hidden');
elements.startBtn.disabled = false;
elements.startBtn.textContent = CONFIG.lang.startScan || 'Scannen';
@ -359,19 +430,12 @@
}
// Scanner pausieren (bei Dialog-Oeffnung)
// ZXing hat kein natives pause() - wir nutzen ein Flag und ignorieren Detections
function pauseScanner() {
if (!isScanning || isPaused) return;
openDialogCount++;
if (quaggaInitialized) {
try {
Quagga.pause();
isPaused = true;
console.log('Scanner paused, dialogs:', openDialogCount);
} catch (e) {
console.log('Quagga pause error:', e);
}
}
isPaused = true;
console.log('Scanner paused, dialogs:', openDialogCount);
}
// Scanner fortsetzen (wenn alle Dialoge geschlossen)
@ -385,29 +449,376 @@
if (!isScanning || !isPaused) return;
if (quaggaInitialized) {
try {
Quagga.start();
isPaused = false;
console.log('Scanner resumed');
startScanTimeout();
} catch (e) {
console.log('Quagga resume error:', e);
}
}
isPaused = false;
console.log('Scanner resumed');
startScanTimeout();
}
// Globale Funktionen fuer PWA
window._pauseScanner = pauseScanner;
window._resumeScanner = resumeScanner;
// ===== OCR-Funktionen (Tesseract.js) =====
// Kamera-Preview starten (ohne Barcode-Detection)
function startCameraPreview() {
const video = elements.video;
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
})
.then(function(stream) {
video.srcObject = stream;
video.play();
isScanning = true;
scannerInitialized = true;
// OCR: Start-Button NICHT verstecken, sondern als "Foto aufnehmen" nutzen
elements.startBtn.classList.remove('hidden');
elements.startBtn.textContent = 'Foto aufnehmen';
elements.stopBtn.classList.remove('hidden');
elements.videoContainer.classList.add('scanning');
elements.startBtn.disabled = false;
// Button-Click auf Snapshot-Capture umstellen
elements.startBtn.onclick = captureOCRSnapshot;
console.log('OCR Kamera-Preview gestartet');
})
.catch(function(err) {
console.error('OCR Camera error:', err);
showToast('Kamerafehler: ' + err.message, 'error');
elements.startBtn.disabled = false;
elements.startBtn.textContent = 'Scannen';
});
}
// Foto vom Video-Stream aufnehmen und OCR durchführen
function captureOCRSnapshot() {
const video = elements.video;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// Canvas-Größe = Video-Größe
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Video-Frame auf Canvas zeichnen
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// Canvas zu Blob konvertieren
canvas.toBlob(function(blob) {
if (blob) {
performOCR(blob);
} else {
showToast('Fehler beim Erstellen des Fotos', 'error');
}
}, 'image/jpeg', 0.9);
}
// Tesseract Worker initialisieren (Singleton, Lazy)
async function initOCRWorker() {
if (ocrInitialized && ocrWorker) {
return ocrWorker;
}
try {
console.log('Initialisiere Tesseract Worker...');
ocrWorker = await Tesseract.createWorker('eng');
// Nur Ziffern erkennen, einfache Einstellungen
await ocrWorker.setParameters({
tessedit_char_whitelist: '0123456789'
// Keine PSM/OEM - Tesseract-Defaults verwenden
});
ocrInitialized = true;
console.log('Tesseract Worker bereit');
return ocrWorker;
} catch (e) {
console.error('OCR Worker Init Error:', e);
showToast('OCR-Fehler: ' + e.message, 'error');
throw e;
}
}
// OCR durchführen mit Timeout
async function performOCR(imageBlob) {
// Loading-Overlay anzeigen
const overlay = document.createElement('div');
overlay.className = 'ocr-loading-overlay';
overlay.innerHTML = '<div class="ocr-loading-spinner"></div><div class="ocr-loading-text">Erkenne Text...</div>';
document.body.appendChild(overlay);
try {
// Worker initialisieren (falls noch nicht geschehen)
const worker = await initOCRWorker();
// Bild vorverarbeiten für bessere Erkennung
const processedBlob = await preprocessImage(imageBlob);
// OCR mit 10s Timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('OCR Timeout')), 10000)
);
const ocrPromise = worker.recognize(processedBlob);
const result = await Promise.race([ocrPromise, timeoutPromise]);
overlay.remove();
const recognizedText = result.data.text;
console.log('OCR Result:', recognizedText);
console.log('OCR Confidence:', result.data.confidence);
// DEBUG: Erkannten Text anzeigen
showToast('OCR erkannte: "' + recognizedText.trim().substring(0, 50) + '"', 'info', 5000);
// Ziffern extrahieren
const matches = extractDigitSequences(recognizedText, currentDigitCount);
console.log('Gefundene ' + currentDigitCount + '-stellige Zahlen:', matches);
if (matches.length === 0) {
// Keine Treffer
showToast('Keine ' + currentDigitCount + '-stellige Zahl gefunden', 'error');
playErrorBeep();
} else if (matches.length === 1) {
// Ein Treffer - direkt verwenden
onOCRDetected(matches[0]);
} else {
// Mehrere Treffer - Auswahl anzeigen
showOCRSelectionModal(matches);
}
} catch (e) {
overlay.remove();
console.error('OCR Error:', e);
if (e.message === 'OCR Timeout') {
showToast('OCR-Timeout - bitte erneut versuchen', 'error');
} else {
showToast('OCR-Fehler: ' + e.message, 'error');
}
}
}
// Bild vorverarbeiten: MINIMAL - nur Grayscale
async function preprocessImage(blob) {
return new Promise((resolve) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
img.onload = function() {
// Original-Größe beibehalten - keine Skalierung
canvas.width = img.width;
canvas.height = img.height;
// Bild zeichnen
ctx.drawImage(img, 0, 0);
// NUR Grayscale - kein Kontrast, kein Binary!
// Tesseract macht das selbst besser
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Grayscale
const gray = 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
data[i] = data[i+1] = data[i+2] = gray;
}
ctx.putImageData(imageData, 0, 0);
// Zurück zu Blob
canvas.toBlob(function(processedBlob) {
resolve(processedBlob);
}, 'image/jpeg', 0.95);
};
img.src = URL.createObjectURL(blob);
});
}
// N-stellige Zahlensequenzen extrahieren - NUR zusammenhängende Zahlenblöcke mit EXAKT N Ziffern!
function extractDigitSequences(text, digitCount) {
// Alle zusammenhängenden Zahlenblöcke finden
const numberBlocks = text.match(/\d+/g) || [];
// Nur Blöcke mit EXAKT digitCount Ziffern (keine Subsequenzen, keine Zusammensetzungen!)
const matches = numberBlocks.filter(function(block) {
return block.length === digitCount;
});
// Duplikate entfernen
return [...new Set(matches)];
}
// Auswahl-Modal bei mehreren OCR-Treffern
function showOCRSelectionModal(matches) {
pauseScanner();
const modal = document.createElement('div');
modal.className = 'ocr-select-modal';
modal.innerHTML = '<div class="ocr-select-content">' +
'<div class="ocr-select-header">' +
'<h3 style="margin:0;font-size:16px;color:var(--colortext);">Mehrere Nummern gefunden</h3>' +
'<button type="button" class="ocr-select-close">&times;</button>' +
'</div>' +
'<div class="ocr-select-list">' +
matches.map(function(code) {
return '<div class="ocr-select-item" data-code="' + code + '">' + code + '</div>';
}).join('') +
'</div>' +
'</div>';
document.body.appendChild(modal);
function closeModal() {
modal.remove();
resumeScanner();
}
// Close button
modal.querySelector('.ocr-select-close').addEventListener('click', closeModal);
// Item selection
modal.querySelectorAll('.ocr-select-item').forEach(function(item) {
item.addEventListener('click', function() {
const code = this.getAttribute('data-code');
modal.remove();
onOCRDetected(code);
resumeScanner();
});
});
// Backdrop click
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
}
// OCR-Ergebnis verarbeiten (analog zu onBarcodeDetected)
function onOCRDetected(code) {
// Ignore detections wenn Scanner pausiert ist
if (isPaused) return;
const format = 'OCR';
// Duplicate Check
if (lastScannedCode === code) {
showToast('Code bereits gescannt: ' + code, 'info');
return;
}
lastScannedCode = code;
console.log('OCR detected:', code);
// Success Vibration
if ('vibrate' in navigator) {
navigator.vibrate([100, 50, 100]);
}
// In Historie speichern
addToHistory(format, code);
// Last Scan anzeigen
const formatLabels = {
'OCR': 'OCR'
};
const label = formatLabels[format] || format;
elements.lastScanCode.innerHTML = '<span class="scan-format-badge">' + label + '</span>' +
'<span class="scan-code-value">' + escapeHtml(code) + '</span>';
elements.lastScanArea.classList.remove('hidden');
// Toast
showToast('OCR: ' + code, 'success');
// Produktsuche starten
searchProduct(code);
}
// Barcode-Typ wechseln (ohne Kamera-Neustart)
function switchBarcodeType(newType) {
if (newType === currentBarcodeType) return;
console.log('Switching barcode type from', currentBarcodeType, 'to', newType);
// OCR-Digit-Selector ein/ausblenden
const digitSelector = document.getElementById('ocr-digit-selector');
if (digitSelector) {
digitSelector.classList.toggle('hidden', newType !== 'OCR');
}
// 1. Aktuellen Scanner stoppen (NICHT Kamera-Stream!)
if (currentScanner && isScanning) {
try {
currentScanner.reset();
console.log('Scanner reset for type switch');
// Stream bleibt aktiv!
} catch (e) {
console.log('Scanner reset error:', e);
}
}
// 2. Format wechseln
currentBarcodeType = newType;
saveConfig('barcodeType', newType);
// 3. Scanner mit neuem Format neu starten (falls er lief)
if (isScanning && newType !== 'OCR') {
// Barcode-Modus: ZXing neu starten, onclick zurücksetzen
elements.startBtn.onclick = null;
currentScanner = initZXing(newType);
} else if (isScanning && newType === 'OCR') {
// OCR-Modus: Stream läuft bereits, nur Button umstellen
elements.startBtn.classList.remove('hidden');
elements.startBtn.textContent = 'Foto aufnehmen';
elements.startBtn.disabled = false;
elements.startBtn.onclick = captureOCRSnapshot;
console.log('OCR-Modus aktiviert (Stream läuft bereits)');
}
// 4. UI-Feedback
const formatNames = {
'CODE_128': 'Code 128',
'QR_CODE': 'QR-Code',
'DATA_MATRIX': 'DataMatrix',
'OCR': 'OCR'
};
showToast('Barcode-Typ: ' + (formatNames[newType] || newType), 'success');
// Scan-Region-Farbe andern
updateScanRegionColor(newType);
}
// Scan-Region-Farbe je nach Barcode-Typ andern
function updateScanRegionColor(type) {
const colors = {
'CODE_128': 'var(--primary)', // Blau (Standard)
'QR_CODE': '#25a580', // Grun
'DATA_MATRIX': '#bc9526', // Orange/Gelb
'OCR': '#e74c3c' // Rot
};
const region = document.querySelector('.scan-region-highlight');
if (region) {
region.style.borderColor = colors[type] || colors['CODE_128'];
}
}
function onBarcodeDetected(result) {
let code = result.codeResult.code;
const format = result.codeResult.format;
// Ignore detections wenn Scanner pausiert ist
if (isPaused) return;
// ZXing Result-Format
let code = result.getText();
let formatObj = result.getBarcodeFormat();
// Format normalisieren: CODE_128, QR_CODE, DATA_MATRIX, EAN_13, EAN_8
const format = formatObj ? formatObj.toString().toUpperCase() : 'UNKNOWN';
// EAN-13 detected but might be internal code with added check digit
// If it's 13 digits and doesn't validate, try removing the last digit
if (format === 'ean_13' || (code.length === 13 && /^\d{13}$/.test(code))) {
if (format.includes('EAN') && format.includes('13') || (code.length === 13 && /^\d{13}$/.test(code))) {
if (!validateEAN13(code)) {
// Try without last digit (might be internal 12-digit code)
const shortened = code.substring(0, 12);
@ -417,7 +828,7 @@
}
// Validate EAN8 checksum
if (format === 'ean_8' && code.length === 8 && /^\d{8}$/.test(code)) {
if (format.includes('EAN') && format.includes('8') && code.length === 8 && /^\d{8}$/.test(code)) {
if (!validateEAN8(code)) {
// Try without last digit
code = code.substring(0, 7);
@ -455,8 +866,27 @@
}
playBeep();
// Format-Label fuer Anzeige
const formatLabels = {
'CODE_128': 'Code 128',
'CODE_39': 'Code 39',
'QR_CODE': 'QR',
'DATA_MATRIX': 'DataMatrix',
'EAN_13': 'EAN-13',
'EAN_8': 'EAN-8'
};
const label = formatLabels[format] || format;
showToast('Barcode: ' + code, 'success');
elements.lastScanCode.textContent = code;
// Last Scan mit Format-Badge anzeigen
elements.lastScanCode.innerHTML = `
<span class="scan-format-badge">${escapeHtml(label)}</span>
<span class="scan-code-value">${escapeHtml(code)}</span>
`;
// In Historie speichern
addToHistory(format, code);
// Search product
searchProduct(code);
@ -467,6 +897,100 @@
}, 3000);
}
// Barcode-Scan in Historie speichern (max. 10 Einträge)
function addToHistory(format, code) {
const cfg = loadConfig();
let history = cfg.barcodeHistory || [];
// Neuen Eintrag hinzufügen
history.unshift({
type: format,
code: code,
timestamp: Math.floor(Date.now() / 1000)
});
// Maximal 10 Einträge behalten
if (history.length > 10) {
history = history.slice(0, 10);
}
saveConfig('barcodeHistory', history);
}
// Scan-Historie-Modal anzeigen
function showBarcodeHistory() {
pauseScanner();
const cfg = loadConfig();
const history = cfg.barcodeHistory || [];
if (history.length === 0) {
showToast('Noch keine Scans vorhanden', 'error');
resumeScanner();
return;
}
const formatLabels = {
'CODE_128': 'Code 128',
'CODE_39': 'Code 39',
'QR_CODE': 'QR',
'DATA_MATRIX': 'DataMatrix',
'EAN_13': 'EAN-13',
'EAN_8': 'EAN-8'
};
const modal = document.createElement('div');
modal.className = 'search-modal'; // Bestehende Modal-Styles wiederverwenden
modal.id = 'history-modal';
modal.innerHTML = `
<div class="search-modal-content">
<div class="search-modal-header">
<h3 style="margin:0;font-size:16px;color:var(--colortext);">Scan-Historie (${history.length})</h3>
<button type="button" class="search-close-btn" id="history-close">&times;</button>
</div>
<div class="search-results" style="max-height:60vh;">
${history.map((item, i) => {
const relativeTime = getRelativeTime(item.timestamp);
const label = formatLabels[item.type] || item.type;
return `
<div class="search-result-item" style="cursor:default;">
<div class="search-result-label">
<span class="scan-format-badge" style="display:inline-block;margin-right:8px;padding:2px 6px;font-size:10px;background:var(--butactionbg);color:#fff;border-radius:3px;">${escapeHtml(label)}</span>
${escapeHtml(item.code)}
</div>
<div class="search-result-info">
<span>${relativeTime}</span>
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
document.body.appendChild(modal);
function closeModal() {
modal.remove();
resumeScanner();
}
document.getElementById('history-close').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
}
// Relative Zeit-Anzeige (vor X Sekunden/Minuten/Stunden/Tagen)
function getRelativeTime(timestamp) {
const now = Math.floor(Date.now() / 1000);
const diff = now - timestamp;
if (diff < 60) return 'vor ' + diff + ' Sek';
if (diff < 3600) return 'vor ' + Math.floor(diff / 60) + ' Min';
if (diff < 86400) return 'vor ' + Math.floor(diff / 3600) + ' Std';
return 'vor ' + Math.floor(diff / 86400) + ' Tagen';
}
// Validate EAN13 checksum
function validateEAN13(code) {
if (!/^\d{13}$/.test(code)) return false;

190
pwa.php
View file

@ -53,7 +53,7 @@ $colormain = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#0077b3');
<title>Barcode Scanner</title>
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="img/icon-192.png">
<link rel="stylesheet" href="css/scanner.css?v=81">
<link rel="stylesheet" href="css/scanner.css?v=91">
<style>
* {
box-sizing: border-box;
@ -409,8 +409,158 @@ $colormain = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#0077b3');
.scan-tool-btn:active { transform: scale(0.95); opacity: 0.8; }
.scan-tool-btn svg { width: 22px; height: 22px; }
.scan-tool-btn.search-btn { order: 1; }
.scan-tool-btn.freetext-btn { order: 3; }
.scan-buttons-group { order: 2; display: flex; gap: 8px; }
.barcode-type-selector { order: 2; padding: 5px 10px; width: 100%; }
.scan-buttons-group { order: 3; display: flex; gap: 8px; }
.scan-tool-btn.freetext-btn { order: 4; }
.scan-tool-btn.history-btn { order: 5; }
.barcode-type-dropdown {
width: 100%;
padding: 12px;
font-size: 15px;
background: var(--colorbackinput);
color: var(--colortext);
border: 1px solid var(--colorborder);
border-radius: 6px;
cursor: pointer;
}
.barcode-type-dropdown:focus {
outline: none;
border-color: var(--primary);
}
/* OCR Digit Selector */
.ocr-digit-selector {
padding: 5px 10px 10px 10px;
background: #444;
}
.ocr-digit-selector.hidden {
display: none;
}
.ocr-digit-dropdown {
font-size: 18px;
font-weight: 600;
text-align: center;
padding: 14px 20px;
}
/* OCR Loading Overlay */
.ocr-loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
}
.ocr-loading-spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255,255,255,0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ocr-loading-text {
color: #fff;
margin-top: 16px;
font-size: 14px;
}
/* OCR Selection Modal */
.ocr-select-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 20px;
}
.ocr-select-content {
background: var(--colorbackbody);
border-radius: 8px;
max-width: 400px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ocr-select-header {
padding: 16px;
border-bottom: 1px solid var(--colorborder);
display: flex;
justify-content: space-between;
align-items: center;
}
.ocr-select-close {
background: none;
border: none;
font-size: 24px;
color: var(--colortext);
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.ocr-select-list {
overflow-y: auto;
padding: 8px;
}
.ocr-select-item {
padding: 16px;
background: var(--colorbackinput);
border: 1px solid var(--colorborder);
border-radius: 6px;
margin-bottom: 8px;
cursor: pointer;
font-family: monospace;
font-size: 18px;
font-weight: 600;
text-align: center;
transition: all 0.2s;
}
.ocr-select-item:hover {
background: var(--butactionbg);
color: var(--textbutaction);
border-color: var(--butactionbg);
}
.ocr-select-item:active {
transform: scale(0.98);
}
/* Format Badge (im Last Scan) */
.scan-format-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
background: var(--butactionbg);
color: var(--textbutaction);
margin-right: 8px;
}
.scan-code-value {
font-family: monospace;
font-size: 16px;
font-weight: 600;
}
/* Swipe hint */
.swipe-hint {
@ -786,6 +936,28 @@ $colormain = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#0077b3');
<button type="button" class="scan-tool-btn search-btn" id="ctrl-search" style="display:none;" title="Produktsuche">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</button>
<!-- Barcode Type Selector -->
<div class="barcode-type-selector">
<select id="barcode-type-select" class="barcode-type-dropdown">
<option value="CODE_128">Code 128</option>
<option value="QR_CODE">QR-Code</option>
<option value="DATA_MATRIX">DataMatrix</option>
<option value="OCR">OCR</option>
</select>
</div>
<!-- OCR Digit Count Selector (nur sichtbar bei OCR-Modus) -->
<div class="ocr-digit-selector hidden" id="ocr-digit-selector">
<select id="ocr-digit-count" class="barcode-type-dropdown ocr-digit-dropdown">
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8" selected>8</option>
<option value="9">9</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
</select>
</div>
<!-- Scan buttons (center) -->
<div class="scan-buttons-group">
<button type="button" id="start-scan-btn" class="scan-btn start">Scannen</button>
@ -795,6 +967,13 @@ $colormain = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#0077b3');
<button type="button" class="scan-tool-btn freetext-btn" id="ctrl-freetext" style="display:none;" title="Freitext">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>
</button>
<!-- History button -->
<button type="button" class="scan-tool-btn history-btn" id="ctrl-history" title="Scan-Historie">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</button>
</div>
</div>
@ -1312,8 +1491,9 @@ $colormain = getDolGlobalString('THEME_ELDY_TOPMENU_BACK1', '#0077b3');
// App starten
initApp();
</script>
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5.1.0/dist/tesseract.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>
<script src="<?php echo dol_buildpath('/handybarcodescanner/js/scanner.js', 1); ?>?v=81"></script>
<script src="<?php echo dol_buildpath('/handybarcodescanner/js/scanner.js', 1); ?>?v=91"></script>
</body>
</html>

5
sw.js
View file

@ -1,12 +1,13 @@
// Service Worker for HandyBarcodeScanner PWA
const CACHE_NAME = 'scanner-v8.1';
const CACHE_NAME = 'scanner-v9.1';
const ASSETS = [
'pwa.php',
'css/scanner.css',
'js/scanner.js',
'img/icon-192.png',
'img/icon-512.png',
'https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js',
'https://cdn.jsdelivr.net/npm/@zxing/library@0.21.3/umd/index.min.js',
'https://cdn.jsdelivr.net/npm/tesseract.js@5.1.0/dist/tesseract.min.js',
'https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js'
];