diff --git a/package.json b/package.json index fbb510e..44aa908 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fieldlens/print", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "scripts": { @@ -16,7 +16,9 @@ "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0", "@tauri-apps/plugin-process": "^2.0.0", - "@tauri-apps/plugin-updater": "^2.0.0" + "@tauri-apps/plugin-updater": "^2.0.0", + "pdfjs-dist": "^4.10.38", + "tesseract.js": "^6.0.1" }, "devDependencies": { "@tauri-apps/cli": "^2.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 378d176..83b3919 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fieldlens-print" -version = "0.1.21" +version = "0.1.22" description = "FieldLens print-stream barcode capture companion" authors = ["Arrowstack"] edition = "2021" @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" +base64 = "0.22" chrono = { version = "0.4", features = ["serde", "clock"] } parking_lot = "0.12" regex = "1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48cf69d..95ade48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,11 +7,14 @@ mod state; mod updates; mod zpl; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use parking_lot::Mutex; +use serde_json::Value; +use std::{fs, path::{Path, PathBuf}, time::SystemTime}; use tauri::{AppHandle, Manager, State}; use capture::CaptureRuntime; -use model::{AppConfig, AuthStatus, CaptureStatus, DebugCheck, DebugSnapshot, JobSummary, LoginPoll, LoginStart, SyncResult}; +use model::{AppConfig, AuthStatus, CaptureStatus, DebugCheck, DebugSnapshot, JobSummary, LoginPoll, LoginStart, RecentCapture, SyncResult}; use printers::{SystemPrinter, VirtualPrinterStatus}; use state::SharedState; @@ -297,6 +300,103 @@ fn sync_pending(state: State<'_, AppState>) -> Result { fieldlens::sync_pending(&state.shared).map_err(|error| error.to_string()) } +#[tauri::command] +fn load_ocr_profile(state: State<'_, AppState>) -> Result, String> { + let path = state.shared.data_dir.join("ocr-profile.json"); + if !path.is_file() { + return Ok(None); + } + let bytes = fs::read(&path).map_err(|error| error.to_string())?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| format!("read OCR profile: {error}")) +} + +#[tauri::command] +fn save_ocr_profile(profile: Value, state: State<'_, AppState>) -> Result { + if profile.get("schemaVersion").and_then(Value::as_u64) != Some(1) { + return Err("unsupported OCR profile schema".into()); + } + let path = state.shared.data_dir.join("ocr-profile.json"); + let temp = state.shared.data_dir.join("ocr-profile.json.tmp"); + let bytes = serde_json::to_vec_pretty(&profile).map_err(|error| error.to_string())?; + fs::write(&temp, bytes).map_err(|error| error.to_string())?; + fs::rename(&temp, &path).map_err(|error| error.to_string())?; + Ok(profile) +} + +fn newest_supported_capture_in(dir: &Path) -> Option<(PathBuf, SystemTime)> { + let entries = fs::read_dir(dir).ok()?; + entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if !path.is_file() { + return None; + } + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + if !matches!(extension.as_str(), "pdf" | "png" | "jpg" | "jpeg" | "webp") { + return None; + } + let modified = entry.metadata().ok()?.modified().ok()?; + Some((path, modified)) + }) + .max_by_key(|(_, modified)| *modified) +} + +#[tauri::command] +fn get_recent_capture(state: State<'_, AppState>) -> Result { + let config = state.shared.config.lock().clone(); + let mut directories = Vec::::new(); + + if !config.spool_dir.trim().is_empty() { + let spool = PathBuf::from(&config.spool_dir); + directories.push(spool.join("processed")); + directories.push(spool); + } + directories.push(state.shared.data_dir.join("capture").join("processed")); + + #[cfg(target_os = "linux")] + directories.push(PathBuf::from("/var/lib/fieldlens-print/capture/processed")); + + let latest = directories + .iter() + .filter_map(|dir| newest_supported_capture_in(dir)) + .max_by_key(|(_, modified)| *modified) + .ok_or_else(|| "no recent PDF or image capture was found".to_string())?; + + let metadata = fs::metadata(&latest.0).map_err(|error| error.to_string())?; + if metadata.len() > 30 * 1024 * 1024 { + return Err("recent capture is larger than the 30 MB refinement limit".into()); + } + + let bytes = fs::read(&latest.0).map_err(|error| error.to_string())?; + let extension = latest + .0 + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let mime = match extension.as_str() { + "pdf" => "application/pdf", + "png" => "image/png", + "webp" => "image/webp", + _ => "image/jpeg", + } + .to_string(); + + Ok(RecentCapture { + name: latest + .0 + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("recent-capture") + .to_string(), + mime, + data_base64: BASE64_STANDARD.encode(bytes), + }) +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -330,7 +430,10 @@ pub fn run() { get_auth_status, logout, install_linux_update, - sync_pending + sync_pending, + load_ocr_profile, + save_ocr_profile, + get_recent_capture ]) .run(tauri::generate_context!()) .expect("error while running FieldLens Print"); diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index 5e4931a..0aa7777 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -192,6 +192,14 @@ pub struct SyncResult { pub pending: usize, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentCapture { + pub name: String, + pub mime: String, + pub data_base64: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c8c9bb6..43ace6f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FieldLens Print", - "version": "0.1.21", + "version": "0.1.22", "identifier": "dev.arrowstack.fieldlens.print", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/main.ts b/src/main.ts index f530afc..9991602 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { listen } from '@tauri-apps/api/event' import { openUrl } from '@tauri-apps/plugin-opener' import { relaunch } from '@tauri-apps/plugin-process' import { check, type Update } from '@tauri-apps/plugin-updater' +import { initOcrRefinement } from './ocrRefinement' import './styles.css' type CaptureMode = 'raw-proxy' | 'spool-folder' | 'both' @@ -141,7 +142,7 @@ let config: AppConfig let jobs: Job[] = [] let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 } let loginTimer: number | undefined -let currentVersion = '0.1.21' +let currentVersion = '0.1.22' let bundleType: BundleType | null = null let availableUpdate: Update | null = null let availableManualUpdate: ManualUpdate | null = null @@ -172,6 +173,7 @@ app.innerHTML = `
Loading
@@ -372,6 +374,10 @@ app.innerHTML = ` + + ` @@ -380,11 +386,13 @@ function el(id: string) { return document.getElementById(id) as T } -function setView(view: 'setup' | 'running') { +function setView(view: 'setup' | 'running' | 'ocr') { el('setup-view').hidden = view !== 'setup' el('running-view').hidden = view !== 'running' + el('ocr-view').hidden = view !== 'ocr' el('setup-tab').classList.toggle('active', view === 'setup') el('running-tab').classList.toggle('active', view === 'running') + el('ocr-tab').classList.toggle('active', view === 'ocr') } function formatDebugReport(snapshot: DebugSnapshot) { @@ -854,6 +862,7 @@ el('debug-copy').addEventListener('click', async () => { el('setup-tab').addEventListener('click', () => setView('setup')) el('running-tab').addEventListener('click', () => setView('running')) +el('ocr-tab').addEventListener('click', () => setView('ocr')) el('running-stop').addEventListener('click', async () => { const state = el('running-action-state') @@ -1123,6 +1132,8 @@ await listen('capture-job', (event) => { void refreshStatus() }) +initOcrRefinement(el('ocr-refinement-root'), invoke) + config = await invoke('get_config') writeForm(config) await refreshPrinters() diff --git a/src/ocrRefinement.ts b/src/ocrRefinement.ts new file mode 100644 index 0000000..f7d8809 --- /dev/null +++ b/src/ocrRefinement.ts @@ -0,0 +1,828 @@ +import * as pdfjsLib from 'pdfjs-dist' +import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url' +import type { Worker } from 'tesseract.js' + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker + +type RegionType = 'ocr' | 'barcode' | 'ignore' +type RegionTarget = + | 'palletCard' + | 'origin' + | 'variety' + | 'brand' + | 'class' + | 'base' + | 'pack' + | 'grower' + | 'packrun' + | 'size' + | 'qty' + | 'pickDate' + | 'packDate' + | 'total' + | 'custom' + +type Box = { x: number; y: number; w: number; h: number } + +type RegionSettings = { + psm: '3' | '6' | '7' | '11' + grayscale: boolean + contrast: number + threshold: number + whitelist: string + barcodeFormats: string[] + firstMatch: boolean +} + +type RefinementRegion = { + id: string + name: string + type: RegionType + target: RegionTarget + enabled: boolean + box: Box + settings: RegionSettings + lastText?: string + confidence?: number +} + +type RefinementProfile = { + schemaVersion: 1 + id: string + name: string + revision: number + savedAt?: string + page: { + rotation: 0 | 90 | 180 | 270 + renderDpi: number + zoom: number + } + regions: RefinementRegion[] +} + +type RecentCapture = { + name: string + mime: string + dataBase64: string +} + +async function loadPdf(data: ArrayBuffer) { + return pdfjsLib.getDocument({ data }).promise +} + +type LoadedPdf = Awaited> + +type DocumentSource = + | { kind: 'pdf'; name: string; document: LoadedPdf } + | { kind: 'image'; name: string; bitmap: ImageBitmap } + +const TARGET_OPTIONS: Array<{ value: RegionTarget; label: string }> = [ + { value: 'palletCard', label: 'Pallet card block' }, + { value: 'origin', label: 'Origin' }, + { value: 'variety', label: 'Variety' }, + { value: 'brand', label: 'Brand' }, + { value: 'class', label: 'Class' }, + { value: 'base', label: 'Base' }, + { value: 'pack', label: 'Pack' }, + { value: 'grower', label: 'Grower' }, + { value: 'packrun', label: 'Packrun' }, + { value: 'size', label: 'Size' }, + { value: 'qty', label: 'Qty' }, + { value: 'pickDate', label: 'Pick date' }, + { value: 'packDate', label: 'Pack date' }, + { value: 'total', label: 'Total' }, + { value: 'custom', label: 'Custom text' }, +] + +const DEFAULT_SETTINGS: RegionSettings = { + psm: '6', + grayscale: true, + contrast: 1.5, + threshold: 0, + whitelist: '', + barcodeFormats: ['CODE_128'], + firstMatch: true, +} + +function defaultProfile(): RefinementProfile { + return { + schemaVersion: 1, + id: 'avocado-pallet-card', + name: 'Avocado pallet card', + revision: 0, + page: { rotation: 0, renderDpi: 200, zoom: 0.82 }, + regions: [], + } +} + +function cloneSettings(): RegionSettings { + return { + ...DEFAULT_SETTINGS, + barcodeFormats: [...DEFAULT_SETTINGS.barcodeFormats], + } +} + +function clamp(value: number, min = 0, max = 1) { + return Math.max(min, Math.min(max, value)) +} + +function decodeBase64(value: string) { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return bytes +} + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!)) +} + +let ocrWorkerPromise: Promise | null = null + +async function getOcrWorker(updateStatus: (message: string) => void) { + if (!ocrWorkerPromise) { + ocrWorkerPromise = (async () => { + const { createWorker, OEM } = await import('tesseract.js') + return createWorker('eng', OEM.LSTM_ONLY, { + logger: (message) => { + if (typeof message.progress === 'number') { + updateStatus((message.status || 'OCR') + ' · ' + Math.round(message.progress * 100) + '%') + } else if (message.status) { + updateStatus(message.status) + } + }, + }) + })().catch((error) => { + ocrWorkerPromise = null + throw error + }) + } + return ocrWorkerPromise +} + +export function initOcrRefinement(root: HTMLElement, invoke: (command: string, args?: Record) => Promise) { + root.innerHTML = [ + '
', + '
', + '
', + '
', + 'OCR REFINEMENT', + '
', + '', + 'v1 · unsaved', + '
', + '
', + '
', + '', + '', + '', + '', + '', + '', + '
', + '
', + '
', + 'No document loaded', + '', + '', + 'Load a PDF or image, then draw target regions.', + '
', + '
', + '
', + '', + '
', + '
', + '
DOCUMENT

Preview & box drawer

', + 'Select “+ OCR area” to draw', + '
', + '
', + '
No document loadedLoad the production PDF, a screenshot, or the most recent captured document.
', + '', + '
', + '
', + '', + '
', + '
', + ].join('') + + const byId = (id: string) => root.querySelector('#' + id)! + const canvas = byId('ocr-document-canvas') + const overlay = byId('ocr-overlay') + const pageFrame = byId('ocr-page-frame') + const previewEmpty = byId('ocr-preview-empty') + const cropCanvas = byId('ocr-region-preview') + const profileNameInput = byId('ocr-profile-name') + const regionTargetSelect = byId('ocr-region-target') + regionTargetSelect.innerHTML = TARGET_OPTIONS.map((option) => '').join('') + + let profile = defaultProfile() + let source: DocumentSource | null = null + let selectedId: string | null = null + let pendingDrawType: RegionType | null = null + let dragState: { + kind: 'draw' | 'move' | 'resize' + regionId: string + startX: number + startY: number + initial: Box + } | null = null + let renderGeneration = 0 + + function selectedRegion() { + return profile.regions.find((region) => region.id === selectedId) ?? null + } + + function setStatus(message: string) { + byId('ocr-status').textContent = message + } + + function normalizeProfile(value: unknown): RefinementProfile { + if (!value || typeof value !== 'object') return defaultProfile() + const candidate = value as Partial + if (candidate.schemaVersion !== 1 || !Array.isArray(candidate.regions)) return defaultProfile() + return { + ...defaultProfile(), + ...candidate, + page: { ...defaultProfile().page, ...candidate.page }, + regions: candidate.regions.map((region) => ({ + ...region, + settings: { + ...cloneSettings(), + ...region.settings, + barcodeFormats: [...(region.settings?.barcodeFormats ?? DEFAULT_SETTINGS.barcodeFormats)], + }, + })), + } as RefinementProfile + } + + function updateProfileHeader() { + profileNameInput.value = profile.name + const version = byId('ocr-profile-version') + version.textContent = profile.revision > 0 + ? 'v1 · revision ' + profile.revision + : 'v1 · unsaved' + byId('ocr-zoom').value = String(profile.page.zoom) + byId('ocr-dpi').value = String(profile.page.renderDpi) + } + + async function renderDocument() { + if (!source) return + const generation = ++renderGeneration + const context = canvas.getContext('2d') + if (!context) return + + if (source.kind === 'pdf') { + const page = await source.document.getPage(1) + if (generation !== renderGeneration) return + const pointsPerInch = 72 + const scale = profile.page.renderDpi / pointsPerInch + const viewport = page.getViewport({ scale, rotation: profile.page.rotation }) + const maxDimension = 1600 + const renderScale = Math.min(1, maxDimension / Math.max(viewport.width, viewport.height)) + const finalViewport = page.getViewport({ + scale: scale * renderScale, + rotation: profile.page.rotation, + }) + canvas.width = Math.max(1, Math.round(finalViewport.width)) + canvas.height = Math.max(1, Math.round(finalViewport.height)) + await page.render({ canvasContext: context, viewport: finalViewport }).promise + } else { + const bitmap = source.bitmap + const rotated = profile.page.rotation === 90 || profile.page.rotation === 270 + const maxDimension = 1600 + const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height)) + const sourceWidth = bitmap.width * scale + const sourceHeight = bitmap.height * scale + canvas.width = Math.max(1, Math.round(rotated ? sourceHeight : sourceWidth)) + canvas.height = Math.max(1, Math.round(rotated ? sourceWidth : sourceHeight)) + context.clearRect(0, 0, canvas.width, canvas.height) + context.save() + if (profile.page.rotation === 90) { + context.translate(canvas.width, 0) + context.rotate(Math.PI / 2) + } else if (profile.page.rotation === 180) { + context.translate(canvas.width, canvas.height) + context.rotate(Math.PI) + } else if (profile.page.rotation === 270) { + context.translate(0, canvas.height) + context.rotate(-Math.PI / 2) + } + context.drawImage(bitmap, 0, 0, sourceWidth, sourceHeight) + context.restore() + } + + pageFrame.hidden = false + previewEmpty.hidden = true + applyZoom() + renderRegions() + updateCropPreview() + } + + function applyZoom() { + if (!canvas.width || !canvas.height) return + const width = canvas.width * profile.page.zoom + const height = canvas.height * profile.page.zoom + canvas.style.width = width + 'px' + canvas.style.height = height + 'px' + overlay.style.width = width + 'px' + overlay.style.height = height + 'px' + pageFrame.style.width = width + 'px' + pageFrame.style.height = height + 'px' + } + + async function loadBlob(blob: Blob, name: string) { + setStatus('Loading ' + name + '…') + selectedId = null + if (source?.kind === 'image') source.bitmap.close() + + if (blob.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf')) { + source = { kind: 'pdf', name, document: await loadPdf(await blob.arrayBuffer()) } + } else if (blob.type.startsWith('image/')) { + source = { kind: 'image', name, bitmap: await createImageBitmap(blob) } + } else { + throw new Error('Use a PDF, PNG, JPEG or WebP document.') + } + + byId('ocr-document-name').textContent = name + setStatus('Document loaded. Draw or adjust target regions.') + await renderDocument() + } + + function overlayPoint(event: PointerEvent) { + const rect = overlay.getBoundingClientRect() + return { + x: clamp((event.clientX - rect.left) / rect.width), + y: clamp((event.clientY - rect.top) / rect.height), + } + } + + function newRegion(type: RegionType, box: Box): RefinementRegion { + const number = profile.regions.filter((region) => region.type === type).length + 1 + return { + id: crypto.randomUUID(), + name: type === 'ocr' ? 'OCR area ' + number : type === 'barcode' ? 'Barcode area ' + number : 'Ignore area ' + number, + type, + target: type === 'ocr' ? 'palletCard' : 'custom', + enabled: true, + box, + settings: cloneSettings(), + } + } + + function beginDraw(type: RegionType) { + if (!source) { + setStatus('Load a document before drawing regions.') + return + } + pendingDrawType = type + byId('ocr-draw-mode').textContent = 'Drawing ' + type + ' region · drag on document' + overlay.classList.add('drawing') + } + + function renderRegions() { + overlay.innerHTML = '' + for (const region of profile.regions) { + if (!region.enabled) continue + const node = document.createElement('div') + node.className = 'ocr-region-box type-' + region.type + (region.id === selectedId ? ' selected' : '') + node.dataset.regionId = region.id + node.style.left = (region.box.x * 100) + '%' + node.style.top = (region.box.y * 100) + '%' + node.style.width = (region.box.w * 100) + '%' + node.style.height = (region.box.h * 100) + '%' + const label = document.createElement('span') + label.textContent = region.name + node.append(label) + const handle = document.createElement('i') + handle.className = 'ocr-resize-handle' + node.append(handle) + + node.addEventListener('pointerdown', (event) => { + if (pendingDrawType) return + event.preventDefault() + event.stopPropagation() + selectedId = region.id + const point = overlayPoint(event) + dragState = { + kind: event.target === handle ? 'resize' : 'move', + regionId: region.id, + startX: point.x, + startY: point.y, + initial: { ...region.box }, + } + overlay.setPointerCapture(event.pointerId) + renderRegions() + renderRegionList() + renderSelectedSettings() + }) + overlay.append(node) + } + } + + function renderRegionList() { + const list = byId('ocr-region-list') + byId('ocr-region-count').textContent = String(profile.regions.length) + if (!profile.regions.length) { + list.innerHTML = '
No regions yet.
' + return + } + + list.innerHTML = profile.regions.map((region) => { + const result = region.lastText + ? '' + Math.round(region.confidence ?? 0) + '%' + : '' + return [ + '', + ].join('') + }).join('') + + list.querySelectorAll('[data-region-select]').forEach((button) => { + button.addEventListener('click', () => { + selectedId = button.dataset.regionSelect ?? null + renderRegions() + renderRegionList() + renderSelectedSettings() + }) + }) + } + + function renderSelectedSettings() { + const region = selectedRegion() + byId('ocr-delete-region').disabled = !region + byId('ocr-no-selection').hidden = Boolean(region) + byId('ocr-selection-settings').hidden = !region + if (!region) { + cropCanvas.width = 1 + cropCanvas.height = 1 + return + } + + byId('ocr-region-name').value = region.name + byId('ocr-region-type').value = region.type + regionTargetSelect.value = region.target + byId('ocr-psm').value = region.settings.psm + byId('ocr-grayscale').checked = region.settings.grayscale + byId('ocr-contrast').value = String(region.settings.contrast) + byId('ocr-threshold').value = String(region.settings.threshold) + byId('ocr-whitelist').value = region.settings.whitelist + byId('ocr-barcode-formats').value = region.settings.barcodeFormats.join(', ') + byId('ocr-first-match').checked = region.settings.firstMatch + byId('ocr-contrast-value').textContent = region.settings.contrast.toFixed(1) + '×' + byId('ocr-threshold-value').textContent = region.settings.threshold > 0 ? String(region.settings.threshold) : 'Off' + byId('ocr-ocr-settings').hidden = region.type !== 'ocr' + byId('ocr-barcode-settings').hidden = region.type !== 'barcode' + regionTargetSelect.closest('label')!.toggleAttribute('hidden', region.type !== 'ocr') + byId('ocr-coordinates').textContent = + 'x ' + (region.box.x * 100).toFixed(1) + '% · y ' + (region.box.y * 100).toFixed(1) + + '% · w ' + (region.box.w * 100).toFixed(1) + '% · h ' + (region.box.h * 100).toFixed(1) + '%' + byId('ocr-confidence').textContent = region.confidence === undefined ? '' : Math.round(region.confidence) + '% confidence' + byId('ocr-raw-output').textContent = region.lastText || '—' + byId('ocr-region-status').textContent = region.lastText ? 'Last result' : 'Not run' + updateCropPreview() + } + + function updateCropPreview() { + const region = selectedRegion() + if (!region || !canvas.width || !canvas.height) return + + const sx = Math.max(0, Math.floor(region.box.x * canvas.width)) + const sy = Math.max(0, Math.floor(region.box.y * canvas.height)) + const sw = Math.max(1, Math.floor(region.box.w * canvas.width)) + const sh = Math.max(1, Math.floor(region.box.h * canvas.height)) + const maxWidth = 520 + const scale = Math.min(1, maxWidth / sw) + cropCanvas.width = Math.max(1, Math.round(sw * scale)) + cropCanvas.height = Math.max(1, Math.round(sh * scale)) + const context = cropCanvas.getContext('2d', { willReadFrequently: true }) + if (!context) return + context.clearRect(0, 0, cropCanvas.width, cropCanvas.height) + context.filter = (region.settings.grayscale ? 'grayscale(1) ' : '') + 'contrast(' + region.settings.contrast + ')' + context.drawImage(canvas, sx, sy, sw, sh, 0, 0, cropCanvas.width, cropCanvas.height) + context.filter = 'none' + + if (region.settings.threshold > 0) { + const image = context.getImageData(0, 0, cropCanvas.width, cropCanvas.height) + for (let index = 0; index < image.data.length; index += 4) { + const luminance = image.data[index] * 0.299 + image.data[index + 1] * 0.587 + image.data[index + 2] * 0.114 + const value = luminance >= region.settings.threshold ? 255 : 0 + image.data[index] = value + image.data[index + 1] = value + image.data[index + 2] = value + } + context.putImageData(image, 0, 0) + } + } + + function updateRegionFromControls() { + const region = selectedRegion() + if (!region) return + region.name = byId('ocr-region-name').value.trim() || region.name + region.type = byId('ocr-region-type').value as RegionType + region.target = regionTargetSelect.value as RegionTarget + region.settings.psm = byId('ocr-psm').value as RegionSettings['psm'] + region.settings.grayscale = byId('ocr-grayscale').checked + region.settings.contrast = Number(byId('ocr-contrast').value) + region.settings.threshold = Number(byId('ocr-threshold').value) + region.settings.whitelist = byId('ocr-whitelist').value + region.settings.barcodeFormats = byId('ocr-barcode-formats').value + .split(',') + .map((value) => value.trim().toUpperCase()) + .filter(Boolean) + region.settings.firstMatch = byId('ocr-first-match').checked + renderRegions() + renderRegionList() + renderSelectedSettings() + } + + async function runRegion(regionId: string) { + const region = profile.regions.find((candidate) => candidate.id === regionId) + if (!region || !region.enabled) return + selectedId = region.id + renderRegions() + renderRegionList() + renderSelectedSettings() + updateCropPreview() + + if (region.type !== 'ocr') { + byId('ocr-region-status').textContent = region.type === 'barcode' + ? 'Preview ready · production barcode decoder handles this region' + : 'Ignored region' + return + } + + const status = byId('ocr-region-status') + status.textContent = 'Preparing OCR…' + try { + const worker = await getOcrWorker((message) => { status.textContent = message }) + const { PSM } = await import('tesseract.js') + const psm = { + '3': PSM.AUTO, + '6': PSM.SINGLE_BLOCK, + '7': PSM.SINGLE_LINE, + '11': PSM.SPARSE_TEXT, + }[region.settings.psm] + await worker.setParameters({ + tessedit_pageseg_mode: psm, + tessedit_char_whitelist: region.settings.whitelist, + preserve_interword_spaces: '1', + }) + const result = await worker.recognize(cropCanvas) + region.lastText = result.data.text.trim() + region.confidence = result.data.confidence + status.textContent = 'OCR complete' + renderRegionList() + renderSelectedSettings() + } catch (error) { + status.textContent = 'OCR failed' + setStatus('OCR failed: ' + String(error)) + } + } + + async function loadSavedProfile() { + try { + const saved = await invoke('load_ocr_profile') + if (saved) profile = normalizeProfile(saved) + } catch { + profile = defaultProfile() + } + updateProfileHeader() + renderRegionList() + renderSelectedSettings() + } + + byId('ocr-load-document').addEventListener('click', () => byId('ocr-file-input').click()) + byId('ocr-file-input').addEventListener('change', (event) => { + const file = (event.currentTarget as HTMLInputElement).files?.[0] + if (!file) return + void loadBlob(file, file.name).catch((error) => setStatus('Document load failed: ' + String(error))) + }) + + byId('ocr-load-recent').addEventListener('click', () => { + setStatus('Loading most recent captured document…') + void invoke('get_recent_capture') + .then((capture) => loadBlob(new Blob([decodeBase64(capture.dataBase64)], { type: capture.mime }), capture.name)) + .catch((error) => setStatus('Recent capture unavailable: ' + String(error))) + }) + + byId('ocr-rotate').addEventListener('click', () => { + profile.page.rotation = ((profile.page.rotation + 90) % 360) as RefinementProfile['page']['rotation'] + void renderDocument() + }) + + byId('ocr-zoom').addEventListener('input', (event) => { + profile.page.zoom = Number((event.currentTarget as HTMLInputElement).value) + applyZoom() + }) + + byId('ocr-dpi').addEventListener('change', (event) => { + profile.page.renderDpi = Number((event.currentTarget as HTMLSelectElement).value) + void renderDocument() + }) + + root.querySelectorAll('[data-add-region]').forEach((button) => { + button.addEventListener('click', () => beginDraw(button.dataset.addRegion as RegionType)) + }) + + overlay.addEventListener('pointerdown', (event) => { + if (!pendingDrawType) { + if (event.target === overlay) { + selectedId = null + renderRegions() + renderRegionList() + renderSelectedSettings() + } + return + } + + event.preventDefault() + event.stopPropagation() + const point = overlayPoint(event) + const region = newRegion(pendingDrawType, { x: point.x, y: point.y, w: 0.001, h: 0.001 }) + profile.regions.push(region) + selectedId = region.id + dragState = { + kind: 'draw', + regionId: region.id, + startX: point.x, + startY: point.y, + initial: { ...region.box }, + } + pendingDrawType = null + overlay.classList.remove('drawing') + byId('ocr-draw-mode').textContent = 'Drag to move · lower-right handle to resize' + overlay.setPointerCapture(event.pointerId) + renderRegions() + renderRegionList() + renderSelectedSettings() + }, true) + + overlay.addEventListener('pointermove', (event) => { + if (!dragState) return + const region = profile.regions.find((candidate) => candidate.id === dragState?.regionId) + if (!region) return + const point = overlayPoint(event) + const dx = point.x - dragState.startX + const dy = point.y - dragState.startY + + if (dragState.kind === 'draw') { + region.box.x = Math.min(dragState.startX, point.x) + region.box.y = Math.min(dragState.startY, point.y) + region.box.w = Math.max(0.005, Math.abs(point.x - dragState.startX)) + region.box.h = Math.max(0.005, Math.abs(point.y - dragState.startY)) + } else if (dragState.kind === 'move') { + region.box.x = clamp(dragState.initial.x + dx, 0, 1 - region.box.w) + region.box.y = clamp(dragState.initial.y + dy, 0, 1 - region.box.h) + } else { + region.box.w = clamp(dragState.initial.w + dx, 0.01, 1 - region.box.x) + region.box.h = clamp(dragState.initial.h + dy, 0.01, 1 - region.box.y) + } + renderRegions() + renderSelectedSettings() + }) + + overlay.addEventListener('pointerup', () => { + dragState = null + updateCropPreview() + }) + overlay.addEventListener('pointercancel', () => { dragState = null }) + + const controlIds = [ + 'ocr-region-name', + 'ocr-region-type', + 'ocr-region-target', + 'ocr-psm', + 'ocr-grayscale', + 'ocr-contrast', + 'ocr-threshold', + 'ocr-whitelist', + 'ocr-barcode-formats', + 'ocr-first-match', + ] + controlIds.forEach((id) => { + byId(id).addEventListener('input', updateRegionFromControls) + byId(id).addEventListener('change', updateRegionFromControls) + }) + + byId('ocr-delete-region').addEventListener('click', () => { + if (!selectedId) return + profile.regions = profile.regions.filter((region) => region.id !== selectedId) + selectedId = null + renderRegions() + renderRegionList() + renderSelectedSettings() + }) + + byId('ocr-copy-region').addEventListener('click', () => { + const region = selectedRegion() + if (!region) return + const copy: RefinementRegion = { + ...region, + id: crypto.randomUUID(), + name: region.name + ' copy', + box: { + x: clamp(region.box.x + 0.02, 0, 1 - region.box.w), + y: clamp(region.box.y + 0.02, 0, 1 - region.box.h), + w: region.box.w, + h: region.box.h, + }, + settings: { ...region.settings, barcodeFormats: [...region.settings.barcodeFormats] }, + lastText: undefined, + confidence: undefined, + } + profile.regions.push(copy) + selectedId = copy.id + renderRegions() + renderRegionList() + renderSelectedSettings() + }) + + byId('ocr-run-region').addEventListener('click', () => { + if (selectedId) void runRegion(selectedId) + }) + + byId('ocr-run-all').addEventListener('click', async () => { + const runnable = profile.regions.filter((region) => region.enabled && region.type === 'ocr') + if (!source) { + setStatus('Load a document before running OCR.') + return + } + if (!runnable.length) { + setStatus('Draw at least one OCR region first.') + return + } + setStatus('Running ' + runnable.length + ' OCR region' + (runnable.length === 1 ? '' : 's') + '…') + for (const region of runnable) await runRegion(region.id) + setStatus('OCR pass complete. Review each region result, then save the profile.') + }) + + byId('ocr-save-profile').addEventListener('click', async () => { + profile.name = profileNameInput.value.trim() || 'OCR profile' + const next = { + ...profile, + revision: profile.revision + 1, + savedAt: new Date().toISOString(), + } + setStatus('Saving OCR profile…') + try { + const saved = await invoke('save_ocr_profile', { profile: next }) + profile = normalizeProfile(saved) + updateProfileHeader() + setStatus('Profile saved. Region coordinates and OCR settings are persisted locally.') + } catch (error) { + setStatus('Profile save failed: ' + String(error)) + } + }) + + void loadSavedProfile() +} diff --git a/src/styles.css b/src/styles.css index 36bbcb8..accb8b1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -734,3 +734,458 @@ select:focus { min-height: 14px; padding: 0 12px 10px; } + + +/* OCR refinement workbench */ +.ocr-workbench { + display: grid; + gap: 16px; +} + +.ocr-toolbar-card { + overflow: visible; +} + +.ocr-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px 10px; +} + +.ocr-profile-title { + min-width: 240px; + display: grid; + gap: 5px; +} + +.ocr-profile-row { + display: flex; + align-items: center; + gap: 8px; +} + +.ocr-profile-row input { + min-width: 0; + padding: 6px 8px; + border: 0; + background: transparent; + font-size: 16px; + font-weight: 800; +} + +.profile-version { + flex: 0 0 auto; + border: 1px solid #2a3441; + border-radius: 999px; + padding: 4px 7px; + color: #7f8b9c; + font-size: 9px; + font-weight: 800; +} + +.ocr-document-meta { + display: flex; + align-items: center; + gap: 14px; + min-height: 40px; + padding: 8px 16px; + border-top: 1px solid #1e2630; + color: #768497; + font-size: 10px; +} + +.ocr-document-meta > span:first-child { + min-width: 140px; + max-width: 260px; + overflow: hidden; + color: #c4ccd7; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ocr-document-meta label { + display: flex; + grid: none; + align-items: center; + gap: 6px; + color: #758295; + font-size: 9px; +} + +.ocr-document-meta input[type="range"] { + width: 110px; + padding: 0; +} + +.ocr-document-meta select { + width: auto; + padding: 5px 7px; +} + +#ocr-status { + margin-left: auto; + text-align: right; +} + +.ocr-layout { + display: grid; + grid-template-columns: 190px minmax(360px, 1fr) 280px; + gap: 12px; + min-height: 620px; +} + +.ocr-regions-card, +.ocr-preview-card, +.ocr-settings-card { + min-width: 0; +} + +.ocr-region-sidebar { + display: grid; + align-content: start; + gap: 12px; + padding: 12px; +} + +.ocr-add-region { + display: grid; + gap: 6px; +} + +.ocr-add-region .btn { + width: 100%; + text-align: left; +} + +.ocr-region-list { + display: grid; + gap: 5px; +} + +.ocr-region-list-empty, +.ocr-region-help, +.ocr-no-selection { + color: #667386; + font-size: 10px; + line-height: 1.45; +} + +.ocr-region-help { + padding-top: 8px; + border-top: 1px solid #202833; +} + +.ocr-region-item { + width: 100%; + display: grid; + grid-template-columns: 9px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border: 1px solid #202833; + border-radius: 8px; + padding: 8px; + background: #0c1117; + color: #cfd7e1; + text-align: left; +} + +.ocr-region-item.active { + border-color: #4ca77b; + background: #102019; +} + +.ocr-region-item > span:nth-child(2) { + min-width: 0; + display: grid; + gap: 2px; +} + +.ocr-region-item strong, +.ocr-region-item small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ocr-region-item strong { + font-size: 10px; +} + +.ocr-region-item small { + color: #667386; + font-size: 8px; +} + +.region-kind { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.region-kind.type-ocr { background: #65d69a; } +.region-kind.type-barcode { background: #77aef2; } +.region-kind.type-ignore { background: #b47d7d; } + +.region-result-ok { + color: #79d9a4; + font-size: 8px; + font-weight: 800; +} + +.ocr-preview-card { + display: grid; + grid-template-rows: auto 1fr; +} + +.ocr-preview-scroll { + position: relative; + min-height: 540px; + overflow: auto; + padding: 22px; + background: + linear-gradient(45deg, #0c1117 25%, transparent 25%) 0 0/20px 20px, + linear-gradient(-45deg, #0c1117 25%, transparent 25%) 0 10px/20px 20px, + linear-gradient(45deg, transparent 75%, #0c1117 75%) 10px -10px/20px 20px, + linear-gradient(-45deg, transparent 75%, #0c1117 75%) -10px 0/20px 20px, + #0f151d; +} + +.ocr-preview-empty { + min-height: 480px; + display: grid; + place-items: center; + align-content: center; + gap: 7px; + color: #647083; + text-align: center; +} + +.ocr-preview-empty strong { + color: #aeb8c6; + font-size: 13px; +} + +.ocr-preview-empty span { + max-width: 360px; + font-size: 10.5px; + line-height: 1.5; +} + +.ocr-page-frame { + position: relative; + margin: 0 auto; + background: #fff; + box-shadow: 0 18px 50px rgba(0,0,0,.45); +} + +#ocr-document-canvas { + position: absolute; + inset: 0; + display: block; + background: white; +} + +.ocr-overlay { + position: absolute; + inset: 0; + touch-action: none; + cursor: crosshair; +} + +.ocr-overlay.drawing { + cursor: crosshair; +} + +.ocr-region-box { + position: absolute; + border: 2px solid #65d69a; + background: rgba(73, 190, 127, .08); + cursor: move; + user-select: none; +} + +.ocr-region-box.type-barcode { + border-color: #77aef2; + background: rgba(87, 141, 218, .08); +} + +.ocr-region-box.type-ignore { + border-color: #c17c7c; + background: rgba(180, 85, 85, .08); +} + +.ocr-region-box.selected { + border-width: 3px; + box-shadow: 0 0 0 1px rgba(255,255,255,.72); +} + +.ocr-region-box > span { + position: absolute; + top: -21px; + left: -2px; + max-width: 180px; + overflow: hidden; + border-radius: 4px 4px 0 0; + padding: 3px 6px; + background: #17271f; + color: #baf0d1; + font-size: 8px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ocr-region-box.type-barcode > span { + background: #17263a; + color: #b7d8fa; +} + +.ocr-region-box.type-ignore > span { + background: #341c1c; + color: #efbbbb; +} + +.ocr-resize-handle { + position: absolute; + right: -6px; + bottom: -6px; + width: 12px; + height: 12px; + border: 2px solid #0d1218; + border-radius: 3px; + background: currentColor; + cursor: nwse-resize; +} + +.ocr-settings-body { + padding: 12px; +} + +.ocr-setting-grid { + display: grid; + grid-template-columns: 1fr 92px; + gap: 9px; +} + +.ocr-setting-grid .full { + grid-column: 1 / -1; +} + +.ocr-setting-section { + display: grid; + gap: 10px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #202833; +} + +.ocr-setting-section label { + font-size: 9.5px; +} + +.ocr-check { + display: flex !important; + align-items: center; + grid-template-columns: none !important; + gap: 7px !important; +} + +.ocr-check input { + width: auto; +} + +.ocr-setting-section input[type="range"] { + padding: 0; +} + +.ocr-coordinates { + margin: 11px 0; + color: #667386; + font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.ocr-crop-panel, +.ocr-output-panel { + margin-top: 12px; + overflow: hidden; + border: 1px solid #202833; + border-radius: 9px; + background: #0a0f15; +} + +.ocr-crop-head { + min-height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + border-bottom: 1px solid #202833; + font-size: 9px; +} + +#ocr-region-preview { + display: block; + width: 100%; + max-height: 210px; + object-fit: contain; + background: white; +} + +#ocr-raw-output { + margin: 0; + min-height: 80px; + max-height: 180px; + overflow: auto; + padding: 9px; + color: #cbd4df; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 9px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +@media (max-width: 1040px) { + .ocr-layout { + grid-template-columns: 160px minmax(340px, 1fr); + } + + .ocr-settings-card { + grid-column: 1 / -1; + } + + .ocr-settings-body { + max-width: 720px; + } +} + +@media (max-width: 760px) { + .ocr-toolbar { + align-items: stretch; + flex-direction: column; + } + + .ocr-document-meta { + align-items: flex-start; + flex-wrap: wrap; + } + + #ocr-status { + width: 100%; + margin-left: 0; + text-align: left; + } + + .ocr-layout { + grid-template-columns: 1fr; + } + + .ocr-settings-card { + grid-column: auto; + } + + .ocr-preview-scroll { + min-height: 420px; + } +}