tane/apps/app_seeds/lib/services/ocr/ocr_language.dart
vjrj 6809dc6143 feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)
Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
2026-07-09 21:23:46 +02:00

110 lines
3.6 KiB
Dart

/// Locale-aware selection of Tesseract OCR language packs.
///
/// Tane is international by design: the OCR must read seed packets in whatever
/// language the user runs the app, not a fixed `eng`/`spa`. This maps the app's
/// preferred locales to the traineddata packs we actually bundle and degrades
/// gracefully — an unbundled locale simply falls back to the Latin baseline
/// instead of failing. As more packs are bundled (incl. RTL/CJK scripts) they
/// light up automatically with no code change here.
library;
import 'dart:convert';
const _trainedDataSuffix = '.traineddata';
/// ISO 639-1 (locale) → Tesseract language code (mostly ISO 639-2/T). Covers the
/// scripts we plausibly bundle; extend as new packs are added. Kept
/// intentionally broad — international first, not one region.
const Map<String, String> isoToTesseract = {
// Latin-script European
'en': 'eng',
'es': 'spa',
'ca': 'cat',
'gl': 'glg',
'eu': 'eus',
'pt': 'por',
'fr': 'fra',
'de': 'deu',
'it': 'ita',
'nl': 'nld',
'pl': 'pol',
'cs': 'ces',
'ro': 'ron',
'sv': 'swe',
'da': 'dan',
'fi': 'fin',
'tr': 'tur',
// Cyrillic / Greek
'ru': 'rus',
'uk': 'ukr',
'el': 'ell',
// RTL scripts
'ar': 'ara',
'he': 'heb',
'fa': 'fas',
'ur': 'urd',
// CJK & other non-Latin
'zh': 'chi_sim',
'ja': 'jpn',
'ko': 'kor',
'hi': 'hin',
'th': 'tha',
};
/// Parses the bundled OCR config (`assets/tessdata_config.json`) into the set of
/// available Tesseract language codes (basenames, e.g. `eng`, `spa`). The config
/// is the single source of truth for what is shipped, so this stays in sync with
/// the assets automatically.
Set<String> parseAvailableOcrLanguages(String configJson) {
final data = jsonDecode(configJson) as Map<String, dynamic>;
final files = (data['files'] as List?)?.cast<String>() ?? const [];
return files
.map(
(f) => f.endsWith(_trainedDataSuffix)
? f.substring(0, f.length - _trainedDataSuffix.length)
: f,
)
.where((code) => code.isNotEmpty)
.toSet();
}
/// Resolves the `+`-joined Tesseract language string for [preferredLocales]
/// (ordered most-preferred first; each an ISO 639-1 code or a fuller tag like
/// `es_ES`/`zh-Hans`), limited to the [available] bundled packs.
///
/// Rules, in order:
/// 1. Each preferred locale that maps to an available pack is included, in
/// order, de-duplicated.
/// 2. The Latin [baseline] (`eng`) is appended if bundled — Latin text is
/// common on packets even in non-Latin locales, and it's a safe default.
/// 3. If nothing matched and the baseline is unavailable, fall back to every
/// available pack (sorted) so OCR still runs with whatever we shipped.
///
/// Returns an empty string only when [available] is empty.
String resolveOcrLanguages({
required List<String> preferredLocales,
required Set<String> available,
String baseline = 'eng',
}) {
final ordered = <String>[];
for (final locale in preferredLocales) {
final code = tesseractCodeForLocale(locale);
if (code != null && available.contains(code) && !ordered.contains(code)) {
ordered.add(code);
}
}
if (available.contains(baseline) && !ordered.contains(baseline)) {
ordered.add(baseline);
}
if (ordered.isEmpty) {
ordered.addAll(available.toList()..sort());
}
return ordered.join('+');
}
/// Maps a locale tag to its Tesseract code, or null if unmapped. Accepts full
/// tags (`es_ES`, `zh-Hans-CN`) and uses the primary language subtag.
String? tesseractCodeForLocale(String localeTag) {
final primary = localeTag.split(RegExp('[_-]')).first.toLowerCase();
return isoToTesseract[primary];
}