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

This commit is contained in:
Admin 2026-09-25 21:21:24 +00:00
parent 554fde4447
commit d39c66eb82
8 changed files with 1416 additions and 8 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@fieldlens/print", "name": "@fieldlens/print",
"version": "0.1.21", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -16,7 +16,9 @@
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0",
"@tauri-apps/plugin-process": "^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": { "devDependencies": {
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "fieldlens-print" name = "fieldlens-print"
version = "0.1.21" version = "0.1.22"
description = "FieldLens print-stream barcode capture companion" description = "FieldLens print-stream barcode capture companion"
authors = ["Arrowstack"] authors = ["Arrowstack"]
edition = "2021" edition = "2021"
@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
parking_lot = "0.12" parking_lot = "0.12"
regex = "1" regex = "1"

View File

@ -7,11 +7,14 @@ mod state;
mod updates; mod updates;
mod zpl; mod zpl;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde_json::Value;
use std::{fs, path::{Path, PathBuf}, time::SystemTime};
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
use capture::CaptureRuntime; 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 printers::{SystemPrinter, VirtualPrinterStatus};
use state::SharedState; 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()) 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() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
@ -330,7 +430,10 @@ pub fn run() {
get_auth_status, get_auth_status,
logout, logout,
install_linux_update, install_linux_update,
sync_pending sync_pending,
load_ocr_profile,
save_ocr_profile,
get_recent_capture
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running FieldLens Print"); .expect("error while running FieldLens Print");

View File

@ -192,6 +192,14 @@ pub struct SyncResult {
pub pending: usize, 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)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "FieldLens Print", "productName": "FieldLens Print",
"version": "0.1.21", "version": "0.1.22",
"identifier": "dev.arrowstack.fieldlens.print", "identifier": "dev.arrowstack.fieldlens.print",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@ -4,6 +4,7 @@ import { listen } from '@tauri-apps/api/event'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process' import { relaunch } from '@tauri-apps/plugin-process'
import { check, type Update } from '@tauri-apps/plugin-updater' import { check, type Update } from '@tauri-apps/plugin-updater'
import { initOcrRefinement } from './ocrRefinement'
import './styles.css' import './styles.css'
type CaptureMode = 'raw-proxy' | 'spool-folder' | 'both' type CaptureMode = 'raw-proxy' | 'spool-folder' | 'both'
@ -141,7 +142,7 @@ let config: AppConfig
let jobs: Job[] = [] let jobs: Job[] = []
let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 } let status: Status = { running: false, mode: 'raw-proxy', pending: 0, jobs: 0 }
let loginTimer: number | undefined let loginTimer: number | undefined
let currentVersion = '0.1.21' let currentVersion = '0.1.22'
let bundleType: BundleType | null = null let bundleType: BundleType | null = null
let availableUpdate: Update | null = null let availableUpdate: Update | null = null
let availableManualUpdate: ManualUpdate | null = null let availableManualUpdate: ManualUpdate | null = null
@ -172,6 +173,7 @@ app.innerHTML = `
<nav class="view-tabs" aria-label="FieldLens Print view"> <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 active" id="setup-tab" type="button">Setup</button>
<button class="view-tab" id="running-tab" type="button">Running</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> </nav>
<div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div> <div id="status-pill" class="status"><span class="dot"></span><span>Loading</span></div>
</div> </div>
@ -372,6 +374,10 @@ app.innerHTML = `
</section> </section>
</div> </div>
</section> </section>
<section id="ocr-view" class="app-view" hidden>
<div id="ocr-refinement-root"></div>
</section>
</main> </main>
</div> </div>
` `
@ -380,11 +386,13 @@ function el<T extends HTMLElement>(id: string) {
return document.getElementById(id) as T return document.getElementById(id) as T
} }
function setView(view: 'setup' | 'running') { function setView(view: 'setup' | 'running' | 'ocr') {
el('setup-view').hidden = view !== 'setup' el('setup-view').hidden = view !== 'setup'
el('running-view').hidden = view !== 'running' el('running-view').hidden = view !== 'running'
el('ocr-view').hidden = view !== 'ocr'
el('setup-tab').classList.toggle('active', view === 'setup') el('setup-tab').classList.toggle('active', view === 'setup')
el('running-tab').classList.toggle('active', view === 'running') el('running-tab').classList.toggle('active', view === 'running')
el('ocr-tab').classList.toggle('active', view === 'ocr')
} }
function formatDebugReport(snapshot: DebugSnapshot) { function formatDebugReport(snapshot: DebugSnapshot) {
@ -854,6 +862,7 @@ el('debug-copy').addEventListener('click', async () => {
el('setup-tab').addEventListener('click', () => setView('setup')) el('setup-tab').addEventListener('click', () => setView('setup'))
el('running-tab').addEventListener('click', () => setView('running')) el('running-tab').addEventListener('click', () => setView('running'))
el('ocr-tab').addEventListener('click', () => setView('ocr'))
el('running-stop').addEventListener('click', async () => { el('running-stop').addEventListener('click', async () => {
const state = el('running-action-state') const state = el('running-action-state')
@ -1123,6 +1132,8 @@ await listen<Job>('capture-job', (event) => {
void refreshStatus() void refreshStatus()
}) })
initOcrRefinement(el('ocr-refinement-root'), invoke)
config = await invoke<AppConfig>('get_config') config = await invoke<AppConfig>('get_config')
writeForm(config) writeForm(config)
await refreshPrinters() await refreshPrinters()

828
src/ocrRefinement.ts Normal file
View 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[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()
}

View File

@ -734,3 +734,458 @@ select:focus {
min-height: 14px; min-height: 14px;
padding: 0 12px 10px; 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;
}
}