feat: add OCR refinement workbench
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
Some checks failed
Desktop Release / Bootstrap updater signing key (push) Has been cancelled
Desktop Release / Windows x64 NSIS (push) Has been cancelled
Desktop Release / Linux x64 DEB + AppImage (push) Has been cancelled
Desktop Release / Publish public desktop release (push) Has been cancelled
This commit is contained in:
parent
554fde4447
commit
d39c66eb82
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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<SyncResult, String> {
|
||||
fieldlens::sync_pending(&state.shared).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_ocr_profile(state: State<'_, AppState>) -> Result<Option<Value>, 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<Value, String> {
|
||||
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<RecentCapture, String> {
|
||||
let config = state.shared.config.lock().clone();
|
||||
let mut directories = Vec::<PathBuf>::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");
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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",
|
||||
|
||||
15
src/main.ts
15
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 = `
|
||||
<nav class="view-tabs" aria-label="FieldLens Print view">
|
||||
<button class="view-tab active" id="setup-tab" type="button">Setup</button>
|
||||
<button class="view-tab" id="running-tab" type="button">Running</button>
|
||||
<button class="view-tab" id="ocr-tab" type="button">OCR Refinement</button>
|
||||
</nav>
|
||||
<div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
|
||||
</div>
|
||||
@ -372,6 +374,10 @@ app.innerHTML = `
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ocr-view" class="app-view" hidden>
|
||||
<div id="ocr-refinement-root"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
`
|
||||
@ -380,11 +386,13 @@ function el<T extends HTMLElement>(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<Job>('capture-job', (event) => {
|
||||
void refreshStatus()
|
||||
})
|
||||
|
||||
initOcrRefinement(el('ocr-refinement-root'), invoke)
|
||||
|
||||
config = await invoke<AppConfig>('get_config')
|
||||
writeForm(config)
|
||||
await refreshPrinters()
|
||||
|
||||
828
src/ocrRefinement.ts
Normal file
828
src/ocrRefinement.ts
Normal file
@ -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<ReturnType<typeof loadPdf>>
|
||||
|
||||
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<Worker> | 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: <T>(command: string, args?: Record<string, unknown>) => Promise<T>) {
|
||||
root.innerHTML = [
|
||||
'<div class="ocr-workbench">',
|
||||
'<section class="card ocr-toolbar-card">',
|
||||
'<div class="ocr-toolbar">',
|
||||
'<div class="ocr-profile-title">',
|
||||
'<span class="eyebrow">OCR REFINEMENT</span>',
|
||||
'<div class="ocr-profile-row">',
|
||||
'<input id="ocr-profile-name" value="Avocado pallet card" aria-label="OCR profile name" />',
|
||||
'<span id="ocr-profile-version" class="profile-version">v1 · unsaved</span>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="inline-actions">',
|
||||
'<input id="ocr-file-input" type="file" accept="application/pdf,image/png,image/jpeg,image/webp" hidden />',
|
||||
'<button class="btn ghost" id="ocr-load-document" type="button">Load document</button>',
|
||||
'<button class="btn ghost" id="ocr-load-recent" type="button">Use recent capture</button>',
|
||||
'<button class="btn ghost" id="ocr-rotate" type="button">Rotate 90°</button>',
|
||||
'<button class="btn primary" id="ocr-save-profile" type="button">Save profile</button>',
|
||||
'<button class="btn blue" id="ocr-run-all" type="button">Run all</button>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="ocr-document-meta">',
|
||||
'<span id="ocr-document-name">No document loaded</span>',
|
||||
'<label>Zoom <input id="ocr-zoom" type="range" min="0.35" max="1.5" step="0.05" value="0.82" /></label>',
|
||||
'<label>DPI <select id="ocr-dpi"><option>150</option><option selected>200</option><option>250</option><option>300</option></select></label>',
|
||||
'<span id="ocr-status">Load a PDF or image, then draw target regions.</span>',
|
||||
'</div>',
|
||||
'</section>',
|
||||
'<div class="ocr-layout">',
|
||||
'<aside class="card ocr-regions-card">',
|
||||
'<div class="card-head"><div><span class="eyebrow">REGIONS</span><h2>Target areas</h2></div><span id="ocr-region-count" class="muted">0</span></div>',
|
||||
'<div class="card-body ocr-region-sidebar">',
|
||||
'<div class="ocr-add-region">',
|
||||
'<button class="btn ghost" data-add-region="ocr" type="button">+ OCR area</button>',
|
||||
'<button class="btn ghost" data-add-region="barcode" type="button">+ Barcode</button>',
|
||||
'<button class="btn ghost" data-add-region="ignore" type="button">+ Ignore</button>',
|
||||
'</div>',
|
||||
'<div id="ocr-region-list" class="ocr-region-list"></div>',
|
||||
'<div class="ocr-region-help">Choose a type, then drag a box over the document. Coordinates are stored relative to the whole page.</div>',
|
||||
'</div>',
|
||||
'</aside>',
|
||||
'<section class="card ocr-preview-card">',
|
||||
'<div class="card-head">',
|
||||
'<div><span class="eyebrow">DOCUMENT</span><h2>Preview & box drawer</h2></div>',
|
||||
'<span id="ocr-draw-mode" class="muted">Select “+ OCR area” to draw</span>',
|
||||
'</div>',
|
||||
'<div class="ocr-preview-scroll" id="ocr-preview-scroll">',
|
||||
'<div class="ocr-preview-empty" id="ocr-preview-empty"><strong>No document loaded</strong><span>Load the production PDF, a screenshot, or the most recent captured document.</span></div>',
|
||||
'<div class="ocr-page-frame" id="ocr-page-frame" hidden>',
|
||||
'<canvas id="ocr-document-canvas"></canvas>',
|
||||
'<div id="ocr-overlay" class="ocr-overlay"></div>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</section>',
|
||||
'<aside class="card ocr-settings-card">',
|
||||
'<div class="card-head"><div><span class="eyebrow">SELECTION</span><h2>Region settings</h2></div><button id="ocr-delete-region" class="btn ghost danger" type="button" disabled>Delete</button></div>',
|
||||
'<div class="card-body ocr-settings-body">',
|
||||
'<div id="ocr-no-selection" class="ocr-no-selection">Select or draw a region to tune its settings.</div>',
|
||||
'<div id="ocr-selection-settings" hidden>',
|
||||
'<div class="ocr-setting-grid">',
|
||||
'<label>Name<input id="ocr-region-name" /></label>',
|
||||
'<label>Type<select id="ocr-region-type"><option value="ocr">OCR</option><option value="barcode">Barcode</option><option value="ignore">Ignore</option></select></label>',
|
||||
'<label class="full">Target<select id="ocr-region-target"></select></label>',
|
||||
'</div>',
|
||||
'<div id="ocr-ocr-settings" class="ocr-setting-section">',
|
||||
'<label>Page segmentation<select id="ocr-psm"><option value="3">Auto</option><option value="6">Single block</option><option value="7">Single line</option><option value="11">Sparse text</option></select></label>',
|
||||
'<label class="ocr-check"><input id="ocr-grayscale" type="checkbox" /> Grayscale</label>',
|
||||
'<label>Contrast <span id="ocr-contrast-value">1.5×</span><input id="ocr-contrast" type="range" min="0.8" max="2.5" step="0.1" /></label>',
|
||||
'<label>Threshold <span id="ocr-threshold-value">Off</span><input id="ocr-threshold" type="range" min="0" max="240" step="5" /></label>',
|
||||
'<label>Character whitelist<input id="ocr-whitelist" placeholder="Optional, e.g. 0123456789/" /></label>',
|
||||
'</div>',
|
||||
'<div id="ocr-barcode-settings" class="ocr-setting-section" hidden>',
|
||||
'<label>Formats<input id="ocr-barcode-formats" value="CODE_128" /></label>',
|
||||
'<label class="ocr-check"><input id="ocr-first-match" type="checkbox" /> First valid barcode is enough</label>',
|
||||
'</div>',
|
||||
'<div class="ocr-coordinates" id="ocr-coordinates"></div>',
|
||||
'<div class="actions"><button class="btn blue" id="ocr-run-region" type="button">Run region</button><button class="btn ghost" id="ocr-copy-region" type="button">Duplicate</button></div>',
|
||||
'<div class="ocr-crop-panel">',
|
||||
'<div class="ocr-crop-head"><strong>Processed crop</strong><span id="ocr-confidence" class="muted"></span></div>',
|
||||
'<canvas id="ocr-region-preview"></canvas>',
|
||||
'</div>',
|
||||
'<div class="ocr-output-panel">',
|
||||
'<div class="ocr-crop-head"><strong>Raw OCR</strong><span id="ocr-region-status" class="muted">Not run</span></div>',
|
||||
'<pre id="ocr-raw-output">—</pre>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</aside>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
].join('')
|
||||
|
||||
const byId = <T extends HTMLElement>(id: string) => root.querySelector<T>('#' + id)!
|
||||
const canvas = byId<HTMLCanvasElement>('ocr-document-canvas')
|
||||
const overlay = byId<HTMLDivElement>('ocr-overlay')
|
||||
const pageFrame = byId<HTMLDivElement>('ocr-page-frame')
|
||||
const previewEmpty = byId<HTMLDivElement>('ocr-preview-empty')
|
||||
const cropCanvas = byId<HTMLCanvasElement>('ocr-region-preview')
|
||||
const profileNameInput = byId<HTMLInputElement>('ocr-profile-name')
|
||||
const regionTargetSelect = byId<HTMLSelectElement>('ocr-region-target')
|
||||
regionTargetSelect.innerHTML = TARGET_OPTIONS.map((option) => '<option value="' + option.value + '">' + option.label + '</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<RefinementProfile>
|
||||
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<HTMLInputElement>('ocr-zoom').value = String(profile.page.zoom)
|
||||
byId<HTMLSelectElement>('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 = '<div class="ocr-region-list-empty">No regions yet.</div>'
|
||||
return
|
||||
}
|
||||
|
||||
list.innerHTML = profile.regions.map((region) => {
|
||||
const result = region.lastText
|
||||
? '<span class="region-result-ok">' + Math.round(region.confidence ?? 0) + '%</span>'
|
||||
: ''
|
||||
return [
|
||||
'<button type="button" class="ocr-region-item ' + (region.id === selectedId ? 'active' : '') + '" data-region-select="' + region.id + '">',
|
||||
'<span class="region-kind type-' + region.type + '"></span>',
|
||||
'<span><strong>' + escapeHtml(region.name) + '</strong><small>' + escapeHtml(region.type === 'ocr' ? region.target : region.type) + '</small></span>',
|
||||
result,
|
||||
'</button>',
|
||||
].join('')
|
||||
}).join('')
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>('[data-region-select]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
selectedId = button.dataset.regionSelect ?? null
|
||||
renderRegions()
|
||||
renderRegionList()
|
||||
renderSelectedSettings()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function renderSelectedSettings() {
|
||||
const region = selectedRegion()
|
||||
byId<HTMLButtonElement>('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<HTMLInputElement>('ocr-region-name').value = region.name
|
||||
byId<HTMLSelectElement>('ocr-region-type').value = region.type
|
||||
regionTargetSelect.value = region.target
|
||||
byId<HTMLSelectElement>('ocr-psm').value = region.settings.psm
|
||||
byId<HTMLInputElement>('ocr-grayscale').checked = region.settings.grayscale
|
||||
byId<HTMLInputElement>('ocr-contrast').value = String(region.settings.contrast)
|
||||
byId<HTMLInputElement>('ocr-threshold').value = String(region.settings.threshold)
|
||||
byId<HTMLInputElement>('ocr-whitelist').value = region.settings.whitelist
|
||||
byId<HTMLInputElement>('ocr-barcode-formats').value = region.settings.barcodeFormats.join(', ')
|
||||
byId<HTMLInputElement>('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<HTMLInputElement>('ocr-region-name').value.trim() || region.name
|
||||
region.type = byId<HTMLSelectElement>('ocr-region-type').value as RegionType
|
||||
region.target = regionTargetSelect.value as RegionTarget
|
||||
region.settings.psm = byId<HTMLSelectElement>('ocr-psm').value as RegionSettings['psm']
|
||||
region.settings.grayscale = byId<HTMLInputElement>('ocr-grayscale').checked
|
||||
region.settings.contrast = Number(byId<HTMLInputElement>('ocr-contrast').value)
|
||||
region.settings.threshold = Number(byId<HTMLInputElement>('ocr-threshold').value)
|
||||
region.settings.whitelist = byId<HTMLInputElement>('ocr-whitelist').value
|
||||
region.settings.barcodeFormats = byId<HTMLInputElement>('ocr-barcode-formats').value
|
||||
.split(',')
|
||||
.map((value) => value.trim().toUpperCase())
|
||||
.filter(Boolean)
|
||||
region.settings.firstMatch = byId<HTMLInputElement>('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<unknown>('load_ocr_profile')
|
||||
if (saved) profile = normalizeProfile(saved)
|
||||
} catch {
|
||||
profile = defaultProfile()
|
||||
}
|
||||
updateProfileHeader()
|
||||
renderRegionList()
|
||||
renderSelectedSettings()
|
||||
}
|
||||
|
||||
byId<HTMLButtonElement>('ocr-load-document').addEventListener('click', () => byId<HTMLInputElement>('ocr-file-input').click())
|
||||
byId<HTMLInputElement>('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<HTMLButtonElement>('ocr-load-recent').addEventListener('click', () => {
|
||||
setStatus('Loading most recent captured document…')
|
||||
void invoke<RecentCapture>('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<HTMLButtonElement>('ocr-rotate').addEventListener('click', () => {
|
||||
profile.page.rotation = ((profile.page.rotation + 90) % 360) as RefinementProfile['page']['rotation']
|
||||
void renderDocument()
|
||||
})
|
||||
|
||||
byId<HTMLInputElement>('ocr-zoom').addEventListener('input', (event) => {
|
||||
profile.page.zoom = Number((event.currentTarget as HTMLInputElement).value)
|
||||
applyZoom()
|
||||
})
|
||||
|
||||
byId<HTMLSelectElement>('ocr-dpi').addEventListener('change', (event) => {
|
||||
profile.page.renderDpi = Number((event.currentTarget as HTMLSelectElement).value)
|
||||
void renderDocument()
|
||||
})
|
||||
|
||||
root.querySelectorAll<HTMLButtonElement>('[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<HTMLButtonElement>('ocr-delete-region').addEventListener('click', () => {
|
||||
if (!selectedId) return
|
||||
profile.regions = profile.regions.filter((region) => region.id !== selectedId)
|
||||
selectedId = null
|
||||
renderRegions()
|
||||
renderRegionList()
|
||||
renderSelectedSettings()
|
||||
})
|
||||
|
||||
byId<HTMLButtonElement>('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<HTMLButtonElement>('ocr-run-region').addEventListener('click', () => {
|
||||
if (selectedId) void runRegion(selectedId)
|
||||
})
|
||||
|
||||
byId<HTMLButtonElement>('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<HTMLButtonElement>('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<RefinementProfile>('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()
|
||||
}
|
||||
455
src/styles.css
455
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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user