""" Smoke Signal — Font Detection + Font-Aware OCR Helpers ====================================================== Lightweight utilities for: 1) identifying page fonts via MixFont Lens API 2) maintaining per-font OCR preference metadata 3) selecting per-font custom Tesseract models when available 4) exposing font-specific punctuation-map paths This module is intentionally defensive: - if MixFont key is missing, it degrades gracefully - if no custom .traineddata exists, it falls back to "eng" """ from __future__ import annotations import json import os import urllib.parse import urllib.request from pathlib import Path from typing import Optional SS_ROOT = Path(os.environ.get("SS_DATA_ROOT", os.environ.get("SS_ROOT", "/tmp/smoke_signal"))) CALIBRATION_DIR = SS_ROOT / "calibration" FONTS_DIR = SS_ROOT / "fonts" TESSDATA_DIR = SS_ROOT / "tessdata" REPO_TESSDATA_DIR = Path(__file__).resolve().parents[1] / "tessdata" FONT_REGISTRY_PATH = CALIBRATION_DIR / "font_registry.json" for _dir in (CALIBRATION_DIR, FONTS_DIR, TESSDATA_DIR, REPO_TESSDATA_DIR): _dir.mkdir(parents=True, exist_ok=True) MIXFONT_API_KEY = os.environ.get("MIXFONT_API_KEY", "").strip() # MixFont Lens API docs endpoint. MIXFONT_API_URL = os.environ.get("MIXFONT_API_URL", "https://www.mixfont.com/v1/api/lens").strip() try: MIXFONT_TIMEOUT_SEC = max(1.0, float(os.environ.get("MIXFONT_TIMEOUT_SEC", "4"))) except Exception: MIXFONT_TIMEOUT_SEC = 4.0 def _slug_font_name(font_name: str) -> str: cleaned = "".join(ch.lower() if ch.isalnum() else "_" for ch in (font_name or "").strip()) while "__" in cleaned: cleaned = cleaned.replace("__", "_") return cleaned.strip("_") def _load_registry() -> dict: if not FONT_REGISTRY_PATH.exists(): return {} try: return json.loads(FONT_REGISTRY_PATH.read_text(encoding="utf-8")) except Exception: return {} def _save_registry(registry: dict) -> None: FONT_REGISTRY_PATH.write_text( json.dumps(registry, indent=2, ensure_ascii=False), encoding="utf-8", ) def _ensure_registry_entry(font_name: str) -> dict: registry = _load_registry() entry = registry.get(font_name, {}) if not entry: entry = { "identified_count": 0, "status": "unknown", # unknown | available | missing | specimen_only "preferred_engine": "surya", # surya | tesseract "tesseract_model": None, "avg_confidence_surya": None, "avg_confidence_tess": None, "last_identified_at": None, } registry[font_name] = entry _save_registry(registry) return entry def _build_public_image_url(local_image_path: str) -> Optional[str]: """ Construct a publicly reachable URL for a local rendered page image. Priority: 1) MIXFONT_IMAGE_URL_TEMPLATE with {path} placeholder 2) MIXFONT_IMAGE_BASE_URL + /gradio_api/file= 3) SPACE_HOST + /gradio_api/file= 4) HF_SPACE_URL + /gradio_api/file= """ raw_path = str(Path(local_image_path).resolve()) encoded_path = urllib.parse.quote(raw_path, safe="") template = os.environ.get("MIXFONT_IMAGE_URL_TEMPLATE", "").strip() if template: if "{path}" in template: return template.replace("{path}", encoded_path) return template base = ( os.environ.get("MIXFONT_IMAGE_BASE_URL", "").strip() or os.environ.get("HF_SPACE_URL", "").strip() or "" ) if not base: space_id = os.environ.get("SPACE_ID", "").strip() if space_id: base = f"https://huggingface.co/spaces/{space_id}" if not base: space_host = os.environ.get("SPACE_HOST", "").strip() if space_host: if space_host.startswith("http://") or space_host.startswith("https://"): base = space_host else: base = f"https://{space_host}" if not base: return None return f"{base.rstrip('/')}/gradio_api/file={encoded_path}" def mixfont_preflight() -> dict: """ Return runtime readiness checks for MixFont integration. """ template = os.environ.get("MIXFONT_IMAGE_URL_TEMPLATE", "").strip() base = ( os.environ.get("MIXFONT_IMAGE_BASE_URL", "").strip() or os.environ.get("HF_SPACE_URL", "").strip() or "" ) if not base: space_id = os.environ.get("SPACE_ID", "").strip() if space_id: base = f"https://huggingface.co/spaces/{space_id}" space_host = os.environ.get("SPACE_HOST", "").strip() has_public_source = bool(template or base or space_host) return { "api_key_set": bool(MIXFONT_API_KEY), "api_url": MIXFONT_API_URL, "image_url_template_set": bool(template), "image_base_set": bool(base), "space_host_set": bool(space_host), "public_image_url_source_available": has_public_source, } def identify_page_font(image_path: str, image_url: Optional[str] = None) -> dict: """ Identify font via MixFont Lens API. Returns: { "font_name": str|None, "confidence": float, "alternatives": list[str], "image_url": str|None, "error": str|None } """ if not MIXFONT_API_KEY: return { "font_name": None, "confidence": 0.0, "alternatives": [], "image_url": image_url, "error": "MIXFONT_API_KEY not set", } path_obj = Path(image_path) if not path_obj.exists(): return { "font_name": None, "confidence": 0.0, "alternatives": [], "image_url": image_url, "error": f"image not found: {image_path}", } resolved_url = image_url or _build_public_image_url(str(path_obj)) if not resolved_url: return { "font_name": None, "confidence": 0.0, "alternatives": [], "image_url": None, "error": "no public image URL (set MIXFONT_IMAGE_BASE_URL or MIXFONT_IMAGE_URL_TEMPLATE)", } payload = json.dumps({"image_url": resolved_url}).encode("utf-8") request = urllib.request.Request( MIXFONT_API_URL, data=payload, method="POST", headers={ "Content-Type": "application/json", "x-api-key": MIXFONT_API_KEY, }, ) try: with urllib.request.urlopen(request, timeout=MIXFONT_TIMEOUT_SEC) as response: body = response.read().decode("utf-8", errors="replace") data = json.loads(body) except Exception as e: return { "font_name": None, "confidence": 0.0, "alternatives": [], "image_url": resolved_url, "error": str(e), } matches = data.get("font_matches", []) if not isinstance(matches, list): matches = [] top = matches[0] if matches else {} if not isinstance(top, dict): top = {} font_name = str(top.get("name") or "").strip() or None try: confidence = float(top.get("confidence", 0.0)) except Exception: confidence = 0.0 alternatives = [] for match in matches[1:4]: if isinstance(match, dict): name = str(match.get("name") or "").strip() if name: alternatives.append(name) if font_name: registry = _load_registry() entry = registry.get(font_name, _ensure_registry_entry(font_name)) entry["identified_count"] = int(entry.get("identified_count", 0)) + 1 entry["last_identified_at"] = str(data.get("datetime") or "") registry[font_name] = entry _save_registry(registry) return { "font_name": font_name, "confidence": round(confidence, 4), "alternatives": alternatives, "image_url": resolved_url, "error": None, } def register_font_model( font_name: str, model_name: Optional[str] = None, preferred_engine: str = "tesseract", status: str = "available", ) -> dict: """ Manually register a font->tesseract model mapping. Use this for bespoke fonts where only a specimen-based traineddata exists. """ if not font_name: return {"ok": False, "error": "font_name required"} model = (model_name or _slug_font_name(font_name) or "eng").strip() registry = _load_registry() entry = registry.get(font_name, _ensure_registry_entry(font_name)) entry["tesseract_model"] = model entry["preferred_engine"] = preferred_engine if preferred_engine in {"surya", "tesseract"} else "tesseract" entry["status"] = status registry[font_name] = entry _save_registry(registry) return {"ok": True, "font_name": font_name, "tesseract_model": model} # Static font name → model name mapping. # Handles cases where the slug doesn't match the traineddata filename. FONT_MODEL_MAP: dict[str, str] = { "gill sans infant": "gill_sans_infant", "gill sans": "gill_sans_infant", # MixFont may return either name "gill sans mt": "gill_sans_infant", "gillsans": "gill_sans_infant", } def resolve_tesseract_lang(font_name: Optional[str]) -> str: """ Return best tesseract language/model to use for this font. Defaults to "eng" if no custom traineddata is available. """ if not font_name: return "eng" # Check static map first mapped = FONT_MODEL_MAP.get(font_name.lower().strip()) if mapped: tess_dirs = [TESSDATA_DIR, REPO_TESSDATA_DIR] if any((d / f"{mapped}.traineddata").exists() for d in tess_dirs): return mapped registry = _load_registry() entry = registry.get(font_name, {}) candidates = [] explicit_model = str(entry.get("tesseract_model") or "").strip() if explicit_model: candidates.append(explicit_model) slug = _slug_font_name(font_name) if slug: candidates.append(slug) candidates.append(slug.replace("_", "")) # unique keep order seen = set() deduped = [] for c in candidates: if c and c not in seen: deduped.append(c) seen.add(c) tess_dirs = [TESSDATA_DIR, REPO_TESSDATA_DIR] for model_name in deduped: if any((tess_dir / f"{model_name}.traineddata").exists() for tess_dir in tess_dirs): if entry: entry["status"] = "available" entry["preferred_engine"] = "tesseract" entry["tesseract_model"] = model_name registry[font_name] = entry _save_registry(registry) return model_name # Common misplacement: .traineddata.otf (font file, not Tesseract model) otf_like_candidates = [] for model_name in deduped: otf_like_candidates.extend( [ REPO_TESSDATA_DIR / f"{model_name}.traineddata.otf", TESSDATA_DIR / f"{model_name}.traineddata.otf", REPO_TESSDATA_DIR / f"{model_name}.otf", TESSDATA_DIR / f"{model_name}.otf", ] ) if any(path.exists() for path in otf_like_candidates): if entry: entry["status"] = "font_file_only" entry["preferred_engine"] = "surya" registry[font_name] = entry _save_registry(registry) # If a specimen exists but no model compiled yet, make that explicit in registry. if (FONTS_DIR / "gruffalo_specimen.png").exists(): if entry: entry["status"] = entry.get("status") or "specimen_only" if entry["status"] == "unknown": entry["status"] = "specimen_only" registry[font_name] = entry _save_registry(registry) return "eng" def resolve_tessdata_dir(model_name: Optional[str]) -> Optional[str]: if not model_name: return None model = str(model_name).strip() if not model: return None for tess_dir in (TESSDATA_DIR, REPO_TESSDATA_DIR): if (tess_dir / f"{model}.traineddata").exists(): return str(tess_dir) return None def update_font_engine_stats(font_name: Optional[str], surya_conf: float, tess_conf: float) -> None: if not font_name: return registry = _load_registry() entry = registry.get(font_name, _ensure_registry_entry(font_name)) alpha = 0.3 prev_surya = entry.get("avg_confidence_surya") prev_tess = entry.get("avg_confidence_tess") try: prev_surya_val = float(prev_surya) if prev_surya is not None else None except Exception: prev_surya_val = None try: prev_tess_val = float(prev_tess) if prev_tess is not None else None except Exception: prev_tess_val = None surya_new = float(surya_conf or 0.0) tess_new = float(tess_conf or 0.0) surya_avg = surya_new if prev_surya_val is None else round(alpha * surya_new + (1 - alpha) * prev_surya_val, 4) tess_avg = tess_new if prev_tess_val is None else round(alpha * tess_new + (1 - alpha) * prev_tess_val, 4) entry["avg_confidence_surya"] = surya_avg entry["avg_confidence_tess"] = tess_avg entry["preferred_engine"] = "tesseract" if tess_avg > surya_avg else "surya" if entry.get("status") == "unknown": entry["status"] = "available" registry[font_name] = entry _save_registry(registry) def font_correction_map_path(font_name: Optional[str]) -> Path: """ Font-specific punctuation learning map path. """ if not font_name: return CALIBRATION_DIR / "punct_correction_map.json" safe = _slug_font_name(font_name) or "unknown" return CALIBRATION_DIR / f"punct_correction_map_{safe}.json" def font_registry_summary() -> list[dict]: registry = _load_registry() rows = [] for name, row in sorted( registry.items(), key=lambda x: int(x[1].get("identified_count", 0)), reverse=True, ): rows.append( { "font_name": name, "pages_seen": int(row.get("identified_count", 0)), "status": str(row.get("status", "unknown")), "preferred_engine": str(row.get("preferred_engine", "surya")), "tesseract_model": row.get("tesseract_model"), "surya_avg": row.get("avg_confidence_surya"), "tess_avg": row.get("avg_confidence_tess"), } ) return rows