Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a055923fe | ||
|
|
b94639ccd1 | ||
|
|
abb70e8b23 | ||
|
|
44526e7d3b |
@@ -32,6 +32,39 @@ export async function startCamera(videoElement) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmenmasse als Anteil der Bildkanten - einzige Quelle fuer die Lage des
|
||||||
|
* Zielrahmens. scan-view.js uebernimmt daraus den sichtbaren Rahmen,
|
||||||
|
* grabFrameRegion() weiter unten den nativen Ausschnitt - beide koennen so
|
||||||
|
* nicht mehr auseinanderlaufen (frueher stand das, unabhaengig davon, nur
|
||||||
|
* im Stilblatt).
|
||||||
|
*/
|
||||||
|
export const FRAME_INSET = { top: 0.2, right: 0.08, bottom: 0.2, left: 0.08 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet die Rahmenmasse (Anteile der Kantenlaenge) in Bildpunkte um.
|
||||||
|
* Reine Funktion ohne Bezug zu einem konkreten Bild oder einer Browser-API -
|
||||||
|
* damit auch ohne DOM testbar.
|
||||||
|
*
|
||||||
|
* Breite und Hoehe werden als Differenz aus der Gesamtkante und den beiden
|
||||||
|
* gerundeten Raendern gebildet, nicht unabhaengig aus dem Anteil selbst
|
||||||
|
* gerundet - so bleibt der Ausschnitt bei krummen Bildgroessen garantiert
|
||||||
|
* innerhalb des Bildes, auch wenn die Anteile keine ganzzahligen Ergebnisse
|
||||||
|
* liefern.
|
||||||
|
*/
|
||||||
|
export function frameRect(width, height, inset = FRAME_INSET) {
|
||||||
|
const left = Math.round(width * inset.left);
|
||||||
|
const right = Math.round(width * inset.right);
|
||||||
|
const top = Math.round(height * inset.top);
|
||||||
|
const bottom = Math.round(height * inset.bottom);
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
width: Math.max(0, width - left - right),
|
||||||
|
height: Math.max(0, height - top - bottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function drawScaled(source, sourceWidth, sourceHeight, maxEdge) {
|
function drawScaled(source, sourceWidth, sourceHeight, maxEdge) {
|
||||||
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
|
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
|
||||||
const width = Math.round(sourceWidth * scale);
|
const width = Math.round(sourceWidth * scale);
|
||||||
@@ -64,6 +97,30 @@ export function grabFrame(videoElement, maxEdge = 1280) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schneidet den Zielrahmen (siehe FRAME_INSET) aus dem laufenden Kamerabild -
|
||||||
|
* in unveraenderter Aufloesung, ohne jede Verkleinerung. Anders als
|
||||||
|
* grabFrame(): fuer die Texterkennung genuegt ein herunterskaliertes
|
||||||
|
* Vollbild, aber ein Code-128 auf einem kleinen Etikett braucht seine
|
||||||
|
* feinen Striche in voller Schaerfe - beim Skalieren auf die fuer OCR
|
||||||
|
* ausreichende Kantenlaenge verschmieren sie unlesbar.
|
||||||
|
*/
|
||||||
|
export function grabFrameRegion(videoElement, inset = FRAME_INSET) {
|
||||||
|
assertHasSize(videoElement.videoWidth, videoElement.videoHeight);
|
||||||
|
const rect = frameRect(videoElement.videoWidth, videoElement.videoHeight, inset);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = rect.width;
|
||||||
|
canvas.height = rect.height;
|
||||||
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
context.drawImage(
|
||||||
|
videoElement,
|
||||||
|
rect.x, rect.y, rect.width, rect.height,
|
||||||
|
0, 0, rect.width, rect.height,
|
||||||
|
);
|
||||||
|
return context.getImageData(0, 0, rect.width, rect.height);
|
||||||
|
}
|
||||||
|
|
||||||
/** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */
|
/** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */
|
||||||
export async function imageDataFromFile(file, maxEdge = 1280) {
|
export async function imageDataFromFile(file, maxEdge = 1280) {
|
||||||
const bitmap = await createImageBitmap(file);
|
const bitmap = await createImageBitmap(file);
|
||||||
|
|||||||
+147
-9
@@ -1,6 +1,6 @@
|
|||||||
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
|
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
|
||||||
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
|
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
|
||||||
import { startCamera, grabFrame, imageDataFromFile } from './camera.js';
|
import { startCamera, grabFrame, grabFrameRegion, imageDataFromFile } from './camera.js';
|
||||||
import { decodeBarcodes } from './barcode.js';
|
import { decodeBarcodes } from './barcode.js';
|
||||||
import { runOcr, isOcrAvailable } from './ocr.js';
|
import { runOcr, isOcrAvailable } from './ocr.js';
|
||||||
import { recognize } from './pipeline.js';
|
import { recognize } from './pipeline.js';
|
||||||
@@ -112,6 +112,10 @@ async function processCapture(getFrame) {
|
|||||||
candidates: plan.kind === 'ambiguous'
|
candidates: plan.kind === 'ambiguous'
|
||||||
? plan.candidates
|
? plan.candidates
|
||||||
: targetSession.stacks.map((stack) => stack.id),
|
: targetSession.stacks.map((stack) => stack.id),
|
||||||
|
// Nur eine echte Stapel-Mehrdeutigkeit (plan.kind === 'ambiguous')
|
||||||
|
// bedeutet "mehrere passen" - alles andere landet nur wegen roter
|
||||||
|
// Konfidenz hier, also weil nichts Verwertbares erkannt wurde.
|
||||||
|
nothingRecognized: plan.kind !== 'ambiguous',
|
||||||
});
|
});
|
||||||
|
|
||||||
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
|
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
|
||||||
@@ -162,6 +166,134 @@ async function processCapture(getFrame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dauersuche nach Barcodes -----------------------------------------
|
||||||
|
//
|
||||||
|
// Solange die Kamera laeuft, sucht die App fortwaehrend im Zielrahmen nach
|
||||||
|
// Barcodes, statt auf den Knopfdruck zu warten - ein Modul in Freihand vor
|
||||||
|
// die Kamera zu halten und zu treffen, gelingt selten beim ersten Bild.
|
||||||
|
|
||||||
|
// Etwa fuenf Versuche pro Sekunde: haeufig genug, dass ein kurzes Hinhalten
|
||||||
|
// genuegt, aber selten genug, dass ein Handy dabei nicht dauerhaft ausgelastet
|
||||||
|
// ist. Die tatsaechliche Dauer eines Versuchs wird unten gemessen und von
|
||||||
|
// dieser Zielspanne abgezogen - dauert ein Versuch laenger (z. B. weil er
|
||||||
|
// eine Buchung samt kurzer Rueckmeldung ausgeloest hat), startet der naechste
|
||||||
|
// sofort, ohne zusaetzlich zu warten.
|
||||||
|
const SEARCH_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
// Sperrzeit gegen Doppelerfassung: haelt man ein Modul mehrere Sekunden vor
|
||||||
|
// die Kamera, darf es nicht mehrfach gebucht werden. Zwei Sekunden reichen,
|
||||||
|
// um eine kurze Verdeckung oder ein Wackeln zu ueberbruecken, waehrend
|
||||||
|
// derselbe Barcode noch im Bild ist - ein zuegiger Wechsel zu einem anderen
|
||||||
|
// Modul wird davon nicht ausgebremst, denn ein *anderer* Code hebt die Sperre
|
||||||
|
// sofort auf (siehe runSearchAttempt). Bewusst kurz gehalten, damit ein
|
||||||
|
// tatsaechlich zweites Exemplar desselben Teils (z. B. beim Sortieren vieler
|
||||||
|
// gleicher Module) nach dem Wegnehmen ohne spuerbare Wartezeit erneut gezaehlt
|
||||||
|
// werden kann, sobald kein Code mehr im Bild ist.
|
||||||
|
const REBOOK_COOLDOWN_MS = 2000;
|
||||||
|
|
||||||
|
// Zuletzt automatisch (per Dauersuche) gebuchte Codemenge, waehrend die Suche
|
||||||
|
// wegen der Sperrzeit ruht - null, solange keine Sperre aktiv ist.
|
||||||
|
let restingCodes = null;
|
||||||
|
// Zeitpunkt (performance.now()), ab dem die Sperrzeit abgelaufen ist.
|
||||||
|
let restUntil = 0;
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselben Bedingungen, unter denen heute schon die Bedienelemente gesperrt
|
||||||
|
* sind: eine Erkennung laeuft (busy) oder ein Dialog/die Sitzungsliste liegt
|
||||||
|
* als Vollbild-Overlay ueber der Ansicht.
|
||||||
|
*/
|
||||||
|
function searchPaused() {
|
||||||
|
return busy || app.querySelector('.overlay') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Durchlauf der Dauersuche: Ausschnitt in nativer Aufloesung
|
||||||
|
* holen, auf Barcodes pruefen, Rahmen entsprechend hervorheben. Wird ein noch
|
||||||
|
* nicht gesperrter Code gefunden, laeuft ab hier derselbe Weg wie beim
|
||||||
|
* Antippen von "Modul scannen" (siehe processCapture) - der Nutzer muss dafuer
|
||||||
|
* nichts tun.
|
||||||
|
*/
|
||||||
|
async function runSearchAttempt() {
|
||||||
|
if (searchPaused()) {
|
||||||
|
view.setFrameDetected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let region;
|
||||||
|
try {
|
||||||
|
region = grabFrameRegion(view.video);
|
||||||
|
} catch {
|
||||||
|
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf
|
||||||
|
// versucht es erneut, kein Grund die Dauersuche abzubrechen.
|
||||||
|
view.setFrameDetected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let codes;
|
||||||
|
try {
|
||||||
|
codes = await decodeBarcodes(region);
|
||||||
|
} catch {
|
||||||
|
view.setFrameDetected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.setFrameDetected(codes.length > 0);
|
||||||
|
if (codes.length === 0) {
|
||||||
|
// Kein Code mehr im Bild: Die Sperrzeit darf jetzt ablaufen (siehe
|
||||||
|
// REBOOK_COOLDOWN_MS oben), aber nur wenn sie tatsaechlich verstrichen ist.
|
||||||
|
if (restingCodes !== null && performance.now() >= restUntil) {
|
||||||
|
restingCodes = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCodes = codes.map((code) => normalizeToken(code));
|
||||||
|
|
||||||
|
if (restingCodes !== null) {
|
||||||
|
const stillSameModule = normalizedCodes.some((code) => restingCodes.has(code));
|
||||||
|
if (stillSameModule) {
|
||||||
|
// Dasselbe Modul haengt noch im Bild - nicht erneut buchen, egal ob die
|
||||||
|
// Sperrzeit schon verstrichen ist.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ein anderer Code ist erschienen: Modulwechsel, die Sperre faellt sofort.
|
||||||
|
restingCodes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waehrend decodeBarcodes lief, koennte sich der Zustand veraendert haben
|
||||||
|
// (z. B. Knopfdruck des Nutzers) - unmittelbar vor dem Ausloesen erneut pruefen.
|
||||||
|
if (searchPaused()) return;
|
||||||
|
|
||||||
|
restingCodes = new Set(normalizedCodes);
|
||||||
|
restUntil = performance.now() + REBOOK_COOLDOWN_MS;
|
||||||
|
// Derselbe Ausschnitt, der den Code gerade geliefert hat, geht direkt in
|
||||||
|
// denselben Ablauf wie beim Knopfdruck - kein zweiter, zeitversetzter Griff
|
||||||
|
// zur Kamera noetig.
|
||||||
|
await processCapture(() => Promise.resolve(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
|
||||||
|
* der vorherige vollstaendig fertig ist - nie ueberlappend.
|
||||||
|
*/
|
||||||
|
async function continuousSearchLoop() {
|
||||||
|
for (;;) {
|
||||||
|
const started = performance.now();
|
||||||
|
try {
|
||||||
|
await runSearchAttempt();
|
||||||
|
} catch {
|
||||||
|
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
|
||||||
|
// dauerhaft abbrechen.
|
||||||
|
}
|
||||||
|
const elapsed = performance.now() - started;
|
||||||
|
await sleep(Math.max(0, SEARCH_INTERVAL_MS - elapsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
|
/** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */
|
||||||
function closeOverlays() {
|
function closeOverlays() {
|
||||||
app.querySelectorAll('.overlay').forEach((element) => element.remove());
|
app.querySelectorAll('.overlay').forEach((element) => element.remove());
|
||||||
@@ -199,14 +331,20 @@ function openSessionList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startCamera(view.video).catch((error) => {
|
startCamera(view.video)
|
||||||
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
|
.then(() => {
|
||||||
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
|
// Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche
|
||||||
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.
|
// laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss.
|
||||||
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`;
|
continuousSearchLoop();
|
||||||
view.setStatus(cameraUnavailableMessage, true);
|
})
|
||||||
view.setFilePickerVisible(true);
|
.catch((error) => {
|
||||||
});
|
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
|
||||||
|
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
|
||||||
|
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.
|
||||||
|
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`;
|
||||||
|
view.setStatus(cameraUnavailableMessage, true);
|
||||||
|
view.setFilePickerVisible(true);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,
|
* Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,
|
||||||
|
|||||||
+8
-1
@@ -25,13 +25,20 @@ body {
|
|||||||
.camera video { width: 100%; height: 100%; object-fit: cover; }
|
.camera video { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
.frame {
|
.frame {
|
||||||
|
/* Lage (inset) wird von scan-view.js aus FRAME_INSET (camera.js) gesetzt -
|
||||||
|
einzige Quelle, die sich auch der native Ausschnitt teilt. */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 20% 8%;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.7);
|
border: 3px solid rgba(255, 255, 255, 0.7);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dauersuche hat einen Barcode im Ausschnitt gefunden - schlichte
|
||||||
|
Farbaenderung, keine Animation. */
|
||||||
|
.frame.detected {
|
||||||
|
border-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
.stacks {
|
.stacks {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -19,21 +19,42 @@ function describeReading(spec) {
|
|||||||
* Reine Darstellung: kennt weder Stapel-Logik noch Erkennung,
|
* Reine Darstellung: kennt weder Stapel-Logik noch Erkennung,
|
||||||
* bekommt die zur Wahl stehenden Stapel als Liste herein.
|
* bekommt die zur Wahl stehenden Stapel als Liste herein.
|
||||||
*
|
*
|
||||||
|
* Der Dialog deckt zwei grundverschiedene Lagen ab, die main.js nicht
|
||||||
|
* auseinanderhaelt, wenn man nur spec/candidates ansieht: mehrere Stapel
|
||||||
|
* passen (echte Mehrdeutigkeit) oder die Erkennung hat schlicht nichts
|
||||||
|
* Verwertbares geliefert. Da beide Lagen mit leerem spec und/oder nicht-
|
||||||
|
* leeren candidates auftreten koennen (main.js reicht bei "nichts erkannt"
|
||||||
|
* als Rueckfall alle vorhandenen Stapel als candidates durch, damit der
|
||||||
|
* Dialog trotzdem Schaltflaechen hat), laesst sich die Unterscheidung nicht
|
||||||
|
* zweifelsfrei aus spec/candidates ableiten - deshalb der ausdrueckliche
|
||||||
|
* Parameter nothingRecognized.
|
||||||
|
*
|
||||||
* @param {HTMLElement} root
|
* @param {HTMLElement} root
|
||||||
* @param {{spec: object, candidates: string[]}} args
|
* @param {{spec: object, candidates: string[], nothingRecognized?: boolean}} args
|
||||||
|
* nothingRecognized: true, wenn weder Barcode noch Texterkennung etwas
|
||||||
|
* Verwertbares geliefert haben (main.js: confidence 'red' ausserhalb
|
||||||
|
* einer echten Stapel-Mehrdeutigkeit) - dann ist der Dialog eine
|
||||||
|
* Rueckfallebene und kein Angebot mehrerer passender Stapel.
|
||||||
* @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>}
|
* @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>}
|
||||||
*/
|
*/
|
||||||
export function askForStack(root, { spec, candidates }) {
|
export function askForStack(root, { spec, candidates, nothingRecognized = false }) {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'overlay red';
|
overlay.className = 'overlay red';
|
||||||
overlay.setAttribute('role', 'dialog');
|
overlay.setAttribute('role', 'dialog');
|
||||||
overlay.setAttribute('aria-modal', 'true');
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
overlay.setAttribute('aria-labelledby', 'ambiguous-dialog-heading');
|
overlay.setAttribute('aria-labelledby', 'ambiguous-dialog-heading');
|
||||||
|
const heading = nothingRecognized ? 'Nichts erkannt' : 'Nicht eindeutig';
|
||||||
|
const hint = nothingRecognized
|
||||||
|
? 'Etikett näher heranhalten, auf gleichmäßiges Licht achten und Spiegelungen vermeiden. Sonst hier den Stapel selbst wählen.'
|
||||||
|
: 'Mehrere Stapel passen zu diesem Modul – bitte auswählen.';
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="stack-id" id="ambiguous-dialog-heading" style="font-size:34px">Nicht eindeutig</div>
|
<div class="stack-id" id="ambiguous-dialog-heading" style="font-size:34px">${heading}</div>
|
||||||
|
<div class="detail" id="hint"></div>
|
||||||
<div class="detail">Gelesen: <span id="read"></span></div>
|
<div class="detail">Gelesen: <span id="read"></span></div>
|
||||||
<div class="choices" id="choices"></div>
|
<div class="choices" id="choices"></div>
|
||||||
`;
|
`;
|
||||||
|
// Der Hinweistext ist fest vorgegeben (kein Fremdinhalt), textContent genuegt trotzdem als sichere Wahl.
|
||||||
|
overlay.querySelector('#hint').textContent = hint;
|
||||||
// Der gelesene Wert stammt aus dem Etikett (Fremdeingabe) - als Text setzen, nicht als Auszeichnung.
|
// Der gelesene Wert stammt aus dem Etikett (Fremdeingabe) - als Text setzen, nicht als Auszeichnung.
|
||||||
overlay.querySelector('#read').textContent = describeReading(spec);
|
overlay.querySelector('#read').textContent = describeReading(spec);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { FRAME_INSET } from '../camera.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
|
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
|
||||||
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
|
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
|
||||||
@@ -24,6 +26,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
|||||||
const statusEl = root.querySelector('#status');
|
const statusEl = root.querySelector('#status');
|
||||||
const fileEl = root.querySelector('#file');
|
const fileEl = root.querySelector('#file');
|
||||||
const pickFileButtonEl = root.querySelector('#pick-file-button');
|
const pickFileButtonEl = root.querySelector('#pick-file-button');
|
||||||
|
const frameEl = root.querySelector('.frame');
|
||||||
|
|
||||||
|
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
|
||||||
|
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
|
||||||
|
// (grabFrameRegion). Damit koennen beide nicht mehr auseinanderlaufen;
|
||||||
|
// das Stilblatt selbst legt keinen Abstand mehr fest.
|
||||||
|
frameEl.style.inset =
|
||||||
|
`${FRAME_INSET.top * 100}% ${FRAME_INSET.right * 100}% ` +
|
||||||
|
`${FRAME_INSET.bottom * 100}% ${FRAME_INSET.left * 100}%`;
|
||||||
|
|
||||||
root.querySelector('#capture').addEventListener('click', onCapture);
|
root.querySelector('#capture').addEventListener('click', onCapture);
|
||||||
root.querySelector('#undo').addEventListener('click', onUndo);
|
root.querySelector('#undo').addEventListener('click', onUndo);
|
||||||
@@ -82,5 +93,15 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
|
|||||||
setFilePickerVisible(visible) {
|
setFilePickerVisible(visible) {
|
||||||
pickFileButtonEl.hidden = !visible;
|
pickFileButtonEl.hidden = !visible;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hebt den Zielrahmen hervor, solange die Dauersuche einen Barcode im
|
||||||
|
* Ausschnitt erkennt - schlichte Farbaenderung, keine Animation. Der
|
||||||
|
* Nutzer sieht damit beim Hinhalten sofort, ob es klappt.
|
||||||
|
* @param {boolean} detected
|
||||||
|
*/
|
||||||
|
setFrameDetected(detected) {
|
||||||
|
frameEl.classList.toggle('detected', detected);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { frameRect, FRAME_INSET } from '../src/camera.js';
|
||||||
|
|
||||||
|
// frameRect() ist eine reine Funktion (kein window/document noetig) - sie
|
||||||
|
// laesst sich direkt unter Node pruefen, obwohl camera.js an anderer Stelle
|
||||||
|
// Browser-APIs verwendet (die dort nur beim Aufruf, nicht beim Import
|
||||||
|
// ausgewertet werden).
|
||||||
|
|
||||||
|
test('glatte Standardaufloesung: Rahmenmasse exakt aus den Anteilen', () => {
|
||||||
|
const rect = frameRect(1920, 1080, FRAME_INSET);
|
||||||
|
assert.deepEqual(rect, { x: 154, y: 216, width: 1612, height: 648 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('krumme Bildgroesse: Ausschnitt bleibt vollstaendig innerhalb des Bildes', () => {
|
||||||
|
const rect = frameRect(1921, 1079, FRAME_INSET);
|
||||||
|
assert.deepEqual(rect, { x: 154, y: 216, width: 1613, height: 647 });
|
||||||
|
assert.ok(rect.x >= 0 && rect.y >= 0);
|
||||||
|
assert.ok(rect.x + rect.width <= 1921);
|
||||||
|
assert.ok(rect.y + rect.height <= 1079);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nicht-ganzzahliges Zwischenergebnis wird gerundet, nicht abgeschnitten', () => {
|
||||||
|
// 999 * 0.08 = 79.92 -> rundet auf 80, nicht auf 79.
|
||||||
|
const rect = frameRect(999, 333, FRAME_INSET);
|
||||||
|
assert.equal(rect.x, 80);
|
||||||
|
assert.equal(rect.width, 999 - 80 - 80);
|
||||||
|
// 333 * 0.2 = 66.6 -> rundet auf 67.
|
||||||
|
assert.equal(rect.y, 67);
|
||||||
|
assert.equal(rect.height, 333 - 67 - 67);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Breite/Hoehe werden als Differenz gebildet, nicht unabhaengig gerundet', () => {
|
||||||
|
// Wuerde man Breite unabhaengig aus dem Anteil runden, koennte sie um ein
|
||||||
|
// Pixel von "Gesamtbreite minus beide Raender" abweichen. Das darf nicht
|
||||||
|
// passieren - hier ueber viele krumme Groessen hinweg geprueft.
|
||||||
|
for (let width = 1; width <= 200; width++) {
|
||||||
|
for (const inset of [FRAME_INSET, { top: 0.2, right: 0.08, bottom: 0.2, left: 0.08 }]) {
|
||||||
|
const rect = frameRect(width, width, inset);
|
||||||
|
const left = Math.round(width * inset.left);
|
||||||
|
const right = Math.round(width * inset.right);
|
||||||
|
assert.equal(rect.width, width - left - right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eigene Rahmenmasse werden statt der Voreinstellung verwendet', () => {
|
||||||
|
const rect = frameRect(1000, 1000, { top: 0.1, right: 0.1, bottom: 0.1, left: 0.1 });
|
||||||
|
assert.deepEqual(rect, { x: 100, y: 100, width: 800, height: 800 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ueberlappende Raender ergeben nie eine negative Breite/Hoehe', () => {
|
||||||
|
const rect = frameRect(100, 100, { top: 0.6, bottom: 0.6, left: 0.6, right: 0.6 });
|
||||||
|
assert.equal(rect.width, 0);
|
||||||
|
assert.equal(rect.height, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Standard-Rahmenmasse ohne Argument identisch zu FRAME_INSET', () => {
|
||||||
|
assert.deepEqual(frameRect(1280, 720), frameRect(1280, 720, FRAME_INSET));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user