oilking99/TextPhantom_OCR_API2
0
1import base64, copy, hashlib, json, math, os, re, struct, time, unicodedata, cv2, httpx, numpy as np, budoux2 3from urllib.parse import parse_qs, urlencode, urlparse4from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont5 6IMAGE_PATH = "33.jpg"7OUT_JSON = "output.json"8LANG = "th"9 10AI_API_KEY = os.getenv("AI_API_KEY", "").strip()11 12FIREBASE_URL = "https://cookie-6e1cd-default-rtdb.asia-southeast1.firebasedatabase.app/lens/cookie.json"13 14WRITE_OUT_JSON = True15 16DECODE_IMAGEURL_TO_DATAURI = True17 18DO_ORIGINAL = True19DO_TRANSLATED = True20DO_ORIGINAL_HTML = True21DO_TRANSLATED_HTML = True22DO_AI_HTML = True23HTML_INCLUDE_CSS = True24 25DRAW_OVERLAY_ORIGINAL = False26DRAW_OVERLAY_TRANSLATED = False27OVERLAY_ORIGINAL_PATH = "overlay_original.png"28OVERLAY_TRANSLATED_PATH = "overlay_translated.png"29 30TRANSLATED_OVERLAY_FONT_SCALE = 1.031TRANSLATED_OVERLAY_FIT_TO_BOX = True32 33AI_OVERLAY_FONT_SCALE = 1.534AI_OVERLAY_FIT_TO_BOX = True35 36DO_AI = True37DO_AI_JSON = False38DO_AI_OVERLAY = False39AI_CACHE = False40AI_CACHE_PATH = "ai_cache.json"41AI_PATH_OVERLAY = "overlay_ai.png"42AI_PROVIDER = "auto"43AI_MODEL = "auto"44AI_BASE_URL = "auto"45AI_TEMPERATURE = 0.246 47AI_MAX_TOKENS = 120048AI_TIMEOUT_SEC = 12049 50DRAW_BOX_OUTLINE = True51AUTO_TEXT_COLOR = True52TEXT_COLOR = (0, 0, 0, 255)53TEXT_COLOR_DARK = (0, 0, 0, 255)54TEXT_COLOR_LIGHT = (255, 255, 255, 255)55BOX_OUTLINE = (0, 255, 0, 255)56BOX_OUTLINE_WIDTH = 257 58DRAW_OUTLINE_PARA = False59DRAW_OUTLINE_ITEM = False60DRAW_OUTLINE_SPAN = False61PARA_OUTLINE = (0, 0, 255, 255)62ITEM_OUTLINE = (255, 0, 0, 255)63SPAN_OUTLINE = BOX_OUTLINE64PARA_OUTLINE_WIDTH = 365ITEM_OUTLINE_WIDTH = 266SPAN_OUTLINE_WIDTH = BOX_OUTLINE_WIDTH67 68ERASE_OLD_TEXT_WITH_ORIGINAL_BOXES = True69ERASE_PADDING_PX = 270ERASE_SAMPLE_MARGIN_PX = 671ERASE_MODE = "inpaint"72ERASE_MOSAIC_BLOCK_PX = 1073ERASE_CLONE_GAP_PX = 474ERASE_CLONE_BORDER_PX = 675ERASE_CLONE_FEATHER_PX = 376 77ERASE_BLEND_GAP_PX = 378ERASE_BLEND_FEATHER_PX = 479 80INPAINT_RADIUS = 381INPAINT_METHOD = "telea"82INPAINT_DILATE_PX = 183 84BG_SAMPLE_BORDER_PX = 385 86BASELINE_SHIFT = True87BASELINE_SHIFT_FACTOR = 0.4088 89FONT_DOWNLOD = True90FONT_THAI_PATH = "NotoSansThai-Regular.ttf"91FONT_LATIN_PATH = "NotoSans-Regular.ttf"92 93FONT_THAI_URLS = [94 "https://github.com/google/fonts/raw/main/ofl/notosansthai/NotoSansThai-Regular.ttf",95 "https://github.com/google/fonts/raw/main/ofl/notosansthaiui/NotoSansThaiUI-Regular.ttf",96]97FONT_LATIN_URLS = [98 "https://github.com/google/fonts/raw/main/ofl/notosans/NotoSans-Regular.ttf",99]100FONT_JA_PATH = "NotoSansCJKjp-Regular.otf"101FONT_JA_URLS = [102 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf",103 "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf",104]105FONT_ZH_SC_PATH = "NotoSansCJKsc-Regular.otf"106FONT_ZH_SC_URLS = [107 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf",108 "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf",109]110FONT_ZH_TC_PATH = "NotoSansCJKtc-Regular.otf"111FONT_ZH_TC_URLS = [112 "https://raw.githubusercontent.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf",113 "https://github.com/googlefonts/noto-cjk/raw/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf",114]115 116UI_LANGUAGES = [117 {"code": "en", "name": "English"},118 {"code": "th", "name": "Thai"},119 {"code": "ja", "name": "Japanese"},120 {"code": "ko", "name": "Korean"},121 {"code": "zh-CN", "name": "Chinese (Simplified)"},122 {"code": "vi", "name": "Vietnamese"},123 {"code": "es", "name": "Spanish"},124 {"code": "de", "name": "German"},125 {"code": "fr", "name": "French"},126]127 128AI_PROVIDER_DEFAULTS = {129 "gemini": {130 "model": "gemini-2.5-flash",131 "base_url": "",132 },133 "openai": {134 "model": "gpt-4o-mini",135 "base_url": "https://api.openai.com/v1",136 },137 "openrouter": {138 "model": "openai/o4-mini",139 "base_url": "https://openrouter.ai/api/v1",140 },141 "huggingface": {142 "model": "google/gemma-2-2b-it",143 "base_url": "https://router.huggingface.co/v1",144 },145 "featherless": {146 "model": "Qwen/Qwen2.5-7B-Instruct",147 "base_url": "https://api.featherless.ai/v1",148 },149 "groq": {150 "model": "openai/gpt-oss-20b",151 "base_url": "https://api.groq.com/openai/v1",152 },153 "together": {154 "model": "openai/gpt-oss-20b",155 "base_url": "https://api.together.xyz/v1",156 },157 "deepseek": {158 "model": "deepseek-chat",159 "base_url": "https://api.deepseek.com/v1",160 },161 "anthropic": {162 "model": "claude-sonnet-4-20250514",163 "base_url": "https://api.anthropic.com",164 },165}166 167AI_PROVIDER_ALIASES = {168 "hf": "huggingface",169 "huggingface_router": "huggingface",170 "hf_router": "huggingface",171 "openai_compat": "openai",172 "openai-compatible": "openai",173 "gemini3": "gemini",174 "gemini-3": "gemini",175 "google": "gemini",176}177 178AI_MODEL_ALIASES = {179 "gemini": {180 "flash-lite": "gemini-2.5-flash-lite",181 "flash": "gemini-2.5-flash",182 "pro": "gemini-2.5-pro",183 "3-flash": "gemini-3-flash-preview",184 "3-pro": "gemini-3-pro-preview",185 "3-pro-image": "gemini-3-pro-image-preview",186 "flash-image": "gemini-2.5-flash-image",187 }188}189 190AI_PROMPT_SYSTEM_BASE = (191 "You are a professional manga translator and dialogue localizer.\n"192 "Rewrite each paragraph as natural dialogue in the target language while preserving meaning, tone, intent, and character voice.\n"193 "Keep lines concise for speech bubbles. Do not add new information. Do not omit meaning. Do not explain.\n"194 "Preserve emphasis (… ! ?). Avoid excessive punctuation.\n"195 "If the input is already in the target language, improve it (dialogue polish) without changing meaning."196)197 198AI_LANG_STYLE = {199 "th": (200 "Target language: Thai\\n"201 "Write Thai manga dialogue that reads like a high-quality Thai scanlation: natural, concise, and in-character.\\n"202 "Keep lines short for speech bubbles; avoid stiff, literal phrasing.\\n"203 "Default: omit pronouns and omit gendered polite sentence-final particles unless the source line clearly requires them.\\n"204 "Never use the word 'ฉัน'. Prefer omitting the subject.\\n"205 "Never use a male-coded second-person pronoun. When addressing someone by name, do not add a second-person pronoun after the name; prefer NAME + clause.\\n"206 "If a second-person reference is unavoidable, use a neutral/casual form appropriate to tone, but keep it gender-neutral and consistent with the line.\\n"207 "Use particles/interjections sparingly to match tone; do not overuse.\\n"208 "Keep names/terms consistent; transliterate when appropriate.\\n"209 "Output only the translated text."210 ),211 "en": (212 "Target language: English\n"213 "Write natural English manga dialogue: concise, conversational, with contractions where natural.\n"214 "Localize tone and character voice; keep emotion and emphasis.\n"215 "Keep proper nouns consistent; do not over-explain."216 ),217 "ja": (218 "Target language: Japanese\n"219 "Write natural Japanese manga dialogue: concise, spoken.\n"220 "Choose 丁寧語/タメ口 to match context; keep emotion and emphasis.\n"221 "Keep proper nouns consistent; keep SFX natural in Japanese."222 ),223 "default": (224 "Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."225 ),226}227 228 229AI_PROMPT_RESPONSE_CONTRACT_JSON = (230 "Return ONLY valid JSON (no markdown, no extra text).\n"231 "Output JSON MUST have exactly one key: \"aiTextFull\".\n"232 "\"aiTextFull\" MUST be a single JSON string WITHOUT raw newlines.\n"233 "Use literal \\n and \\n\\n to represent line breaks.\n"234 "You MUST preserve paragraph boundaries and order. Paragraphs are separated by a blank line (\\n\\n).\n"235 "Do NOT add extra paragraphs. Do NOT remove paragraphs.\n"236 "Never include code fences or XML/HTML tags.\n"237 "All string values MUST NOT contain raw newlines."238)239 240AI_PROMPT_RESPONSE_CONTRACT_TEXT = (241 "Return ONLY the translated text (no JSON, no markdown, no commentary).\n"242 "You MUST preserve paragraph boundaries and order. Paragraphs are separated by a blank line.\n"243 "Use actual newlines for line breaks.\n"244 "Do NOT add extra paragraphs. Do NOT remove paragraphs.\n"245 "Never include code fences or XML/HTML tags."246)247AI_PROMPT_DATA_TEMPLATE = (248 "Input JSON:\n{input_json}\n\n"249 "Output JSON schema (MUST match exactly):\n{output_schema}"250)251 252AI_PROMPT_DATA_TEMPLATE_TEXT = (253 "Input JSON:\n{input_json}\n\n"254 "Return the translation as plain text only."255)256 257FIREBASE_COOKIE_TTL_SEC = int(os.getenv("FIREBASE_COOKIE_TTL_SEC", "900"))258_FIREBASE_COOKIE_CACHE = {"ts": 0.0, "url": "", "data": None}259_FONT_RESOLVE_CACHE = {}260_HF_MODELS_CACHE = {}261_FONT_PAIR_CACHE = {}262_TP_HTML_EPS_PX = 0.0263ZWSP = "\u200b"264 265 266def _active_ai_contract() -> str:267 return AI_PROMPT_RESPONSE_CONTRACT_JSON if DO_AI_JSON else AI_PROMPT_RESPONSE_CONTRACT_TEXT268 269def _active_ai_data_template() -> str:270 return AI_PROMPT_DATA_TEMPLATE if DO_AI_JSON else AI_PROMPT_DATA_TEMPLATE_TEXT271 272def _canonical_provider(provider: str) -> str:273 p = (provider or "").strip().lower()274 return AI_PROVIDER_ALIASES.get(p, p)275 276def _resolve_model(provider: str, model: str) -> str:277 m = (model or "").strip()278 if not m or m.lower() == "auto":279 d = AI_PROVIDER_DEFAULTS.get(provider) or {}280 return (d.get("model") or "").strip() or AI_PROVIDER_DEFAULTS["openai"]["model"]281 key = m.lower()282 aliases = AI_MODEL_ALIASES.get(provider) or {}283 return aliases.get(key) or m284 285def _normalize_lang(lang: str) -> str:286 t = (lang or "").strip().lower()287 if t in ("jp", "jpn", "japanese"):288 return "ja"289 if t in ("thai",):290 return "th"291 if t in ("eng", "english"):292 return "en"293 if t.startswith("zh"):294 return t295 if len(t) >= 2:296 return t[:2]297 return t298 299def _sha1(s: str) -> str:300 return hashlib.sha1(s.encode("utf-8")).hexdigest()301 302def _hf_router_available_models(api_key: str, base_url: str) -> list[str]:303 if not api_key or not base_url:304 return []305 key = _sha1(f"{_sha1(api_key)}|{base_url}")306 now = time.time()307 cached = _HF_MODELS_CACHE.get(key) or {}308 if cached.get("ts") and now - float(cached["ts"]) < 3600 and isinstance(cached.get("models"), list):309 return cached["models"]310 311 url = base_url.rstrip("/") + "/models"312 headers = {"Authorization": f"Bearer {api_key}"}313 try:314 with httpx.Client(timeout=float(AI_TIMEOUT_SEC)) as client:315 r = client.get(url, headers=headers)316 r.raise_for_status()317 data = r.json()318 except Exception:319 return []320 321 models = []322 for m in (data.get("data") or []):323 mid = (m.get("id") if isinstance(m, dict) else None)324 if isinstance(mid, str) and mid.strip():325 models.append(mid.strip())326 _HF_MODELS_CACHE[key] = {"ts": now, "models": models}327 return models328 329def _pick_hf_fallback_model(models: list[str]) -> str:330 if not models:331 return ""332 priority_substrings = (333 "gemma-3",334 "gemma-2",335 "llama-3.1",336 "llama-3",337 "mistral",338 "qwen",339 "glm",340 )341 lowered = [(m, m.lower()) for m in models]342 for sub in priority_substrings:343 for m, ml in lowered:344 if sub in ml and ("instruct" in ml or ml.endswith("-it") or ":" in ml):345 return m346 for m, ml in lowered:347 if "instruct" in ml or ml.endswith("-it") or ":" in ml:348 return m349 return models[0]350 351def _load_ai_cache(path: str):352 if not path:353 return {}354 if not os.path.exists(path):355 return {}356 try:357 with open(path, "r", encoding="utf-8") as f:358 d = json.load(f)359 return d if isinstance(d, dict) else {}360 except Exception:361 return {}362 363def _save_ai_cache(path: str, cache: dict):364 if not path:365 return366 tmp = path + ".tmp"367 with open(tmp, "w", encoding="utf-8") as f:368 json.dump(cache, f, ensure_ascii=False)369 os.replace(tmp, path)370 371def _build_ai_prompt_packet(target_lang: str, original_text_full: str):372 lang = _normalize_lang(target_lang)373 input_json = json.dumps(374 {"target_lang": lang, "originalTextFull": original_text_full}, ensure_ascii=False)375 output_schema = json.dumps({"aiTextFull": "..."}, ensure_ascii=False)376 data_template = _active_ai_data_template()377 if DO_AI_JSON:378 data_text = data_template.format(379 input_json=input_json, output_schema=output_schema)380 else:381 data_text = data_template.format(input_json=input_json)382 383 style = AI_LANG_STYLE.get(lang) or AI_LANG_STYLE.get("default") or ""384 385 system_parts = [AI_PROMPT_SYSTEM_BASE]386 if style:387 system_parts.append(style)388 system_parts.append(_active_ai_contract())389 system_text = "\n\n".join([p for p in system_parts if p])390 391 user_parts = []392 user_parts.append(data_text)393 return system_text, user_parts394 395def _gemini_generate_json(api_key: str, model: str, system_text: str, user_parts: list[str]):396 url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"397 parts = [{"text": p} for p in user_parts if (p or "").strip()]398 payload = {399 "systemInstruction": {"parts": [{"text": system_text}]},400 "contents": [{"role": "user", "parts": parts}],401 "generationConfig": {402 "temperature": float(AI_TEMPERATURE),403 "maxOutputTokens": int(AI_MAX_TOKENS),404 "responseMimeType": "text/plain",405 },406 }407 with httpx.Client(timeout=float(AI_TIMEOUT_SEC)) as client:408 r = client.post(url, json=payload)409 try:410 r.raise_for_status()411 except httpx.HTTPStatusError as e:412 raise Exception(f"Gemini HTTP {r.status_code}: {r.text}") from e413 data = r.json()414 candidates = data.get("candidates") or []415 if not candidates:416 raise Exception("Gemini returned no candidates")417 c = (candidates[0].get("content") or {})418 out_parts = c.get("parts") or []419 if not out_parts:420 raise Exception("Gemini returned empty content parts")421 txt = "".join([str(p.get("text") or "") for p in out_parts]).strip()422 if not txt:423 raise Exception("Gemini returned empty text")424 return txt425 426def _read_first_env(*names: str) -> str:427 for n in names:428 v = (os.environ.get(n) or "").strip()429 if v:430 return v431 return ""432 433def _detect_ai_provider_from_key(api_key: str) -> str:434 k = (api_key or "").strip()435 if k.startswith("AIza"):436 return "gemini"437 if k.startswith("hf_"):438 return "huggingface"439 if k.startswith("sk-or-"):440 return "openrouter"441 if k.startswith("sk-ant-"):442 return "anthropic"443 if k.startswith("gsk_"):444 return "groq"445 return "openai"446 447def _resolve_ai_config():448 api_key = (AI_API_KEY or _read_first_env(449 "AI_API_KEY",450 "OPENAI_API_KEY",451 "HF_TOKEN",452 "HUGGINGFACEHUB_API_TOKEN",453 "GEMINI_API_KEY",454 "OPENROUTER_API_KEY",455 "FEATHERLESS_API_KEY",456 "GROQ_API_KEY",457 "TOGETHER_API_KEY",458 "DEEPSEEK_API_KEY",459 "ANTHROPIC_API_KEY",460 )).strip()461 462 provider = _canonical_provider((AI_PROVIDER or "auto"))463 model = (AI_MODEL or "auto").strip()464 base_url = (AI_BASE_URL or "auto").strip()465 466 if provider in ("", "auto"):467 provider = _canonical_provider(_detect_ai_provider_from_key(api_key))468 469 preset = AI_PROVIDER_DEFAULTS.get(provider) or {}470 471 model = _resolve_model(provider, model)472 473 if base_url in ("", "auto"):474 base_url = (preset.get("base_url") or "").strip()475 476 if provider not in ("gemini", "anthropic"):477 if not base_url:478 base_url = (AI_PROVIDER_DEFAULTS.get("openai") or {}).get(479 "base_url") or "https://api.openai.com/v1"480 481 return provider, api_key, model, base_url482 483def _openai_compat_generate_json(api_key: str, base_url: str, model: str, system_text: str, user_parts: list[str]):484 url = (base_url.rstrip("/") + "/chat/completions")485 messages = [{"role": "system", "content": system_text}]486 for p in user_parts:487 if (p or "").strip():488 messages.append({"role": "user", "content": p})489 payload = {490 "model": model,491 "messages": messages,492 "temperature": float(AI_TEMPERATURE),493 "max_tokens": int(AI_MAX_TOKENS),494 }495 headers = {496 "Authorization": f"Bearer {api_key}",497 "Content-Type": "application/json",498 }499 used_model = model500 with httpx.Client(timeout=float(AI_TIMEOUT_SEC)) as client:501 r = client.post(url, json=payload, headers=headers)502 try:503 r.raise_for_status()504 data = r.json()505 except httpx.HTTPStatusError as e:506 if (507 r.status_code == 400508 and "router.huggingface.co" in (base_url or "")509 and ((AI_MODEL or "").strip().lower() in ("", "auto") or model == (AI_PROVIDER_DEFAULTS.get("huggingface") or {}).get("model"))510 ):511 try:512 err = r.json().get("error") or {}513 except Exception:514 err = {}515 if (err.get("code") or "") == "model_not_supported":516 models = _hf_router_available_models(api_key, base_url)517 fallback = _pick_hf_fallback_model(models)518 if fallback and fallback != model:519 payload["model"] = fallback520 used_model = fallback521 r2 = client.post(url, json=payload, headers=headers)522 try:523 r2.raise_for_status()524 except httpx.HTTPStatusError as e2:525 raise Exception(526 f"AI HTTP {r2.status_code}: {r2.text}") from e2527 data = r2.json()528 else:529 preview = ", ".join(models[:8])530 hint = f"\nAvailable models (first 8): {preview}" if preview else ""531 raise Exception(532 f"AI HTTP {r.status_code}: {r.text}{hint}") from e533 else:534 raise Exception(535 f"AI HTTP {r.status_code}: {r.text}") from e536 else:537 raise Exception(f"AI HTTP {r.status_code}: {r.text}") from e538 choices = data.get("choices") or []539 if not choices:540 raise Exception("AI returned no choices")541 msg = (choices[0].get("message") or {})542 txt = (msg.get("content") or "").strip()543 if not txt:544 raise Exception("AI returned empty text")545 return txt, used_model546 547def _anthropic_generate_json(api_key: str, model: str, system_text: str, user_parts: list[str]):548 url = "https://api.anthropic.com/v1/messages"549 messages = []550 for p in user_parts:551 if (p or "").strip():552 messages.append({"role": "user", "content": p})553 payload = {554 "model": model,555 "max_tokens": int(AI_MAX_TOKENS),556 "temperature": float(AI_TEMPERATURE),557 "system": system_text,558 "messages": messages,559 }560 headers = {561 "x-api-key": api_key,562 "content-type": "application/json",563 }564 with httpx.Client(timeout=float(AI_TIMEOUT_SEC)) as client:565 r = client.post(url, json=payload, headers=headers)566 try:567 r.raise_for_status()568 except httpx.HTTPStatusError as e:569 raise Exception(f"Anthropic HTTP {r.status_code}: {r.text}") from e570 data = r.json()571 content = data.get("content") or []572 txt = "".join([(c.get("text") or "") for c in content if isinstance(573 c, dict) and c.get("type") == "text"]).strip()574 if not txt:575 raise Exception("Anthropic returned empty text")576 return txt577 578def _strip_wrappers(s: str) -> str:579 t = (s or "").strip()580 if not t:581 return ""582 t = t.replace("\r\n", "\n").replace("\r", "\n")583 if "```" in t:584 t = re.sub(r"```[a-zA-Z0-9_-]*", "", t)585 t = t.replace("```", "")586 t = re.sub(r"</?AiTextFull>", "", t, flags=re.IGNORECASE).strip()587 return t588 589def _sanitize_json_like_text(raw: str) -> str:590 t = _strip_wrappers(raw)591 if not t:592 return ""593 out = []594 in_str = False595 esc = False596 run_ch = ""597 run_len = 0598 599 def _flush_run():600 nonlocal run_ch, run_len601 if run_len:602 out.append(run_ch * min(run_len, 3))603 run_ch = ""604 run_len = 0605 606 for ch in t:607 if in_str:608 if esc:609 _flush_run()610 out.append(ch)611 esc = False612 continue613 if ch == "\\":614 _flush_run()615 out.append(ch)616 esc = True617 continue618 if ch == '"':619 _flush_run()620 out.append(ch)621 in_str = False622 continue623 if ch == "\n":624 _flush_run()625 out.append("\\n")626 continue627 if ch == "\t":628 _flush_run()629 out.append("\\t")630 continue631 if ch == run_ch:632 run_len += 1633 continue634 _flush_run()635 run_ch = ch636 run_len = 1637 continue638 639 _flush_run()640 if ch == '"':641 out.append(ch)642 in_str = True643 esc = False644 continue645 out.append(ch)646 647 _flush_run()648 return "".join(out)649 650def _extract_first_json(raw: str):651 t = _sanitize_json_like_text(raw)652 if not t:653 raise Exception("AI returned empty text")654 start = t.find("{")655 if start < 0:656 raise Exception("AI returned no JSON object")657 658 in_str = False659 esc = False660 depth = 0661 json_start = None662 663 for i in range(start, len(t)):664 ch = t[i]665 if in_str:666 if esc:667 esc = False668 elif ch == "\\":669 esc = True670 elif ch == '"':671 in_str = False672 continue673 674 if ch == '"':675 in_str = True676 continue677 if ch == "{":678 if depth == 0:679 json_start = i680 depth += 1681 continue682 if ch == "}":683 if depth > 0:684 depth -= 1685 if depth == 0 and json_start is not None:686 cand = t[json_start: i + 1]687 return json.loads(cand)688 689 raise Exception("Failed to parse AI JSON")690 691def _parse_ai_textfull_only(raw: str) -> str:692 obj = _extract_first_json(raw)693 if not isinstance(obj, dict):694 raise Exception("AI JSON is not an object")695 txt = obj.get("aiTextFull")696 if txt is None:697 txt = obj.get("textFull")698 if txt is None:699 raise Exception("AI JSON missing aiTextFull")700 t = str(txt)701 if "\\n" in t and "\n" not in t:702 t = t.replace("\\n", "\n")703 t = t.replace("\r\n", "\n").replace("\r", "\n").strip()704 return t705 706def _parse_ai_textfull_text_only(raw: str) -> str:707 t = _strip_wrappers(raw)708 if not t:709 raise Exception("AI returned empty text")710 if t.lstrip().startswith("{"):711 return _parse_ai_textfull_only(t)712 if "\\n" in t and "\n" not in t:713 t = t.replace("\\n", "\n")714 t = re.sub(r"^aiTextFull\s*[:=]\s*", "", t, flags=re.IGNORECASE).strip()715 return t716 717def _budoux_parser_for_lang(lang: str):718 lang = _normalize_lang(lang)719 if not budoux:720 return None721 if lang == "th":722 return budoux.load_default_thai_parser()723 if lang == "ja":724 return budoux.load_default_japanese_parser()725 if lang in ("zh", "zh-hans", "zh_cn", "zh-cn", "zh_hans"):726 return budoux.load_default_simplified_chinese_parser()727 if lang in ("zh-hant", "zh_tw", "zh-tw", "zh_hant"):728 return budoux.load_default_traditional_chinese_parser()729 model_path = os.environ.get("BUDOUX_MODEL_PATH")730 if not model_path:731 return None732 with open(model_path, "r", encoding="utf-8") as f:733 model = json.load(f)734 return budoux.Parser(model)735 736def _ensure_box_fields(box: dict):737 if not isinstance(box, dict):738 return {}739 b = copy.deepcopy(box)740 if "rotation_deg" not in b:741 b["rotation_deg"] = 0.0742 if "rotation_deg_css" not in b:743 b["rotation_deg_css"] = 0.0744 if "center" not in b and all(k in b for k in ("left", "top", "width", "height")):745 b["center"] = {"x": b["left"] + b["width"] /746 2.0, "y": b["top"] + b["height"]/2.0}747 if all(k in b for k in ("left", "top", "width", "height")):748 if "left_pct" not in b:749 b["left_pct"] = b["left"] * 100.0750 if "top_pct" not in b:751 b["top_pct"] = b["top"] * 100.0752 if "width_pct" not in b:753 b["width_pct"] = b["width"] * 100.0754 if "height_pct" not in b:755 b["height_pct"] = b["height"] * 100.0756 return b757 758def _tokens_with_spaces(text: str, parser, lang: str):759 t = (text or "")760 if not t:761 return []762 out = []763 parts = re.findall(r"\s+|\S+", t)764 for part in parts:765 if not part:766 continue767 if part.isspace():768 out.append(("space", part))769 continue770 segs = parser.parse(part) if parser else [part]771 for seg in segs:772 if seg:773 out.append(("word", seg))774 return out775 776def _line_cap_px_for_item(item: dict, img_w: int, img_h: int) -> float:777 p1 = item.get("baseline_p1") or {}778 p2 = item.get("baseline_p2") or {}779 dx = (float(p2.get("x") or 0.0) - float(p1.get("x") or 0.0)) * float(img_w)780 dy = (float(p2.get("y") or 0.0) - float(p1.get("y") or 0.0)) * float(img_h)781 cap = float(math.hypot(dx, dy))782 if cap > 1e-6:783 return cap784 b = _ensure_box_fields(item.get("box") or {})785 return float(b.get("width") or 0.0) * float(img_w)786 787def _wrap_tokens_to_lines_px(tokens, items, img_w: int, img_h: int, thai_font: str, latin_font: str, font_size: int, min_lines: int):788 max_lines = len(items)789 if max_lines <= 0:790 return []791 792 caps = [_line_cap_px_for_item(it, img_w, img_h) for it in items]793 desired = max(1, min(int(min_lines), max_lines))794 soft_factor = 0.90 if desired > 1 else 1.0795 796 lines = [[]]797 cur_w = 0.0798 li = 0799 800 last_word_hint = ""801 pending_space = ""802 803 tmp = Image.new("RGBA", (10, 10), (0, 0, 0, 0))804 dtmp = ImageDraw.Draw(tmp)805 806 def _measure_w(font, txt: str) -> float:807 try:808 return float(font.getlength(txt))809 except Exception:810 try:811 bb = dtmp.textbbox((0, 0), txt, font=font, anchor="ls")812 return float(bb[2] - bb[0])813 except Exception:814 w, _ = dtmp.textsize(txt, font=font)815 return float(w)816 817 def _cap_for_line(idx: int) -> float:818 return float(caps[min(idx, max_lines - 1)])819 820 for k, s in (tokens or []):821 if k == "space":822 if not lines[-1]:823 continue824 pending_space += str(s)825 continue826 827 if k != "word":828 continue829 830 txt = str(s)831 if not txt:832 continue833 834 font = pick_font(txt, thai_font, latin_font, int(font_size))835 w = _measure_w(font, txt)836 837 sw = 0.0838 if pending_space:839 hint = last_word_hint or txt840 font_s = pick_font(hint, thai_font, latin_font, int(font_size))841 sw = _measure_w(font_s, pending_space)842 843 cap = _cap_for_line(li)844 soft_cap = cap * soft_factor if (li < desired and cap > 0.0) else cap845 846 need_w = cur_w + sw + w847 if lines[-1] and li < max_lines - 1:848 if cap > 0.0 and need_w > cap:849 lines.append([])850 li += 1851 cur_w = 0.0852 pending_space = ""853 sw = 0.0854 elif soft_cap > 0.0 and need_w > soft_cap:855 lines.append([])856 li += 1857 cur_w = 0.0858 pending_space = ""859 sw = 0.0860 861 if pending_space and lines[-1]:862 lines[-1].append(("space", pending_space, sw))863 cur_w += sw864 pending_space = ""865 866 lines[-1].append(("word", txt, w))867 cur_w += w868 last_word_hint = txt869 870 if len(lines) > max_lines:871 head = lines[: max_lines - 1]872 tail = []873 for seg in lines[max_lines - 1:]:874 tail.extend(seg)875 lines = head + [tail]876 877 for i in range(len(lines)):878 while lines[i] and lines[i][0][0] == "space":879 lines[i] = lines[i][1:]880 while lines[i] and lines[i][-1][0] == "space":881 lines[i] = lines[i][:-1]882 883 return lines884 885def _ensure_min_lines_by_split(lines, min_lines: int, max_lines: int):886 if not lines:887 return []888 min_lines = int(min_lines)889 max_lines = int(max_lines)890 if min_lines <= 1:891 return lines892 893 target = min(min_lines, max_lines)894 lines = [list(seg) for seg in (lines or [])]895 896 def _trim(seg):897 while seg and seg[0][0] == "space":898 seg.pop(0)899 while seg and seg[-1][0] == "space":900 seg.pop()901 return seg902 903 while len(lines) < target:904 idx = None905 best = 0906 for i, seg in enumerate(lines):907 n_words = sum(1 for k, s, _ in seg if k == "word" and s != ZWSP)908 if n_words > best and n_words > 1:909 best = n_words910 idx = i911 if idx is None:912 break913 914 seg = lines[idx]915 word_pos = [i for i, (k, s, _) in enumerate(seg)916 if k == "word" and s != ZWSP]917 if len(word_pos) <= 1:918 break919 cut_word = len(word_pos) // 2920 cut_pos = word_pos[cut_word]921 922 left = _trim(seg[:cut_pos])923 right = _trim(seg[cut_pos:])924 925 lines[idx] = left926 lines.insert(idx + 1, right)927 if len(lines) >= max_lines:928 break929 930 return lines931 932def _fit_para_size_and_lines(ptext: str, parser, items, img_w: int, img_h: int, thai_font: str, latin_font: str, base_size: int, min_lines: int, lang: str):933 tokens2 = _tokens_with_spaces(ptext, parser, lang)934 if not tokens2 or not items:935 return int(base_size), [[] for _ in range(len(items))]936 937 max_lines = len(items)938 n_words = 0939 for k, s in tokens2:940 if k == "word" and str(s):941 n_words += 1942 desired_lines = max(1, min(max_lines, n_words))943 size = max(10, int(base_size))944 945 heights = []946 for it in items:947 b = _ensure_box_fields(it.get("box") or {})948 heights.append(float(b.get("height") or 0.0) * float(img_h))949 950 while size >= 10:951 lines = _wrap_tokens_to_lines_px(952 tokens2, items, img_w, img_h, thai_font, latin_font, size, min_lines=desired_lines)953 lines = _ensure_min_lines_by_split(954 lines, min_lines=desired_lines, max_lines=max_lines)955 956 if len(lines) <= max_lines:957 ok = True958 for ii, seg in enumerate(lines):959 words = [s for k, s, _ in seg if k == "word" and s != ZWSP]960 if not words:961 continue962 line_text = "".join(words)963 mline = _line_metrics_px(964 line_text, thai_font, latin_font, size)965 if mline is None:966 continue967 _, th, _ = mline968 if ii < len(heights) and heights[ii] > 0.0 and th > heights[ii] * 1.01:969 ok = False970 break971 if ok:972 return size, lines973 974 size -= 1975 976 lines10 = _wrap_tokens_to_lines_px(977 tokens2, items, img_w, img_h, thai_font, latin_font, 10, min_lines=desired_lines)978 lines10 = _ensure_min_lines_by_split(979 lines10, min_lines=desired_lines, max_lines=max_lines)980 return 10, lines10981 982def _pad_lines(lines, max_lines: int):983 max_lines = int(max_lines)984 if max_lines <= 0:985 return []986 lines = list(lines or [])987 if len(lines) > max_lines:988 return lines[:max_lines]989 if len(lines) < max_lines:990 lines.extend([[] for _ in range(max_lines - len(lines))])991 return lines992 993def _contains_thai(text: str) -> bool:994 for ch in (text or ""):995 if _is_thai_char(ch):996 return True997 return False998 999def _apply_line_to_item(1000 item: dict,1001 line_tokens,1002 para_index: int,1003 item_index: int,1004 abs_line_start_raw: int,1005 W: int,1006 H: int,1007 thai_path: str,1008 latin_path: str,1009 forced_size_px: int | None,1010 apply_baseline_shift: bool = True,1011 kerning_adjust: bool = False,1012):1013 tokens = []1014 for t in (line_tokens or []):1015 if not isinstance(t, (list, tuple)) or len(t) < 2:1016 continue1017 k = str(t[0])1018 s = str(t[1])1019 w = float(t[2]) if len(t) > 2 and isinstance(1020 t[2], (int, float)) else 0.01021 tokens.append((k, s, w))1022 1023 words = [s for k, s, _ in tokens if k == "word" and s != ZWSP]1024 item_text = "".join(s for _, s, _ in tokens if s != ZWSP).strip()1025 item["text"] = item_text1026 item["valid_text"] = bool(item_text)1027 1028 b = _ensure_box_fields(item.get("box") or {})1029 item["box"] = b1030 base_left = float(b.get("left") or 0.0)1031 base_top = float(b.get("top") or 0.0)1032 base_w = float(b.get("width") or 0.0)1033 base_h = float(b.get("height") or 0.0)1034 1035 if not words or base_w <= 0.0 or base_h <= 0.0 or W <= 0 or H <= 0:1036 item["spans"] = []1037 return1038 1039 p1 = item.get("baseline_p1") or {}1040 p2 = item.get("baseline_p2") or {}1041 x1 = float(p1.get("x") or 0.0) * float(W)1042 y1 = float(p1.get("y") or 0.0) * float(H)1043 x2 = float(p2.get("x") or 0.0) * float(W)1044 y2 = float(p2.get("y") or 0.0) * float(H)1045 1046 dx = x2 - x11047 dy = y2 - y11048 L = float(math.hypot(dx, dy))1049 if L <= 1e-9:1050 item["spans"] = []1051 return1052 1053 ux = dx / L1054 uy = dy / L1055 nx = -uy1056 ny = ux1057 if ny < 0:1058 nx, ny = -nx, -ny1059 1060 base_w_px = L1061 base_h_px = base_h * float(H)1062 1063 base_size = 961064 1065 widths_px = []1066 max_ascent = 01067 max_descent = 01068 1069 layout_units = []1070 for k, s, _ in tokens:1071 if s == ZWSP:1072 continue1073 if k == "space":1074 layout_units.append(("space", _sanitize_draw_text(s)))1075 elif k == "word":1076 layout_units.append(("word", _sanitize_draw_text(s)))1077 1078 def _measure_len_px(font, text: str) -> float:1079 try:1080 return float(font.getlength(text))1081 except Exception:1082 tmp = Image.new("RGBA", (10, 10), (0, 0, 0, 0))1083 dtmp = ImageDraw.Draw(tmp)1084 try:1085 bb = dtmp.textbbox((0, 0), text, font=font, anchor="ls")1086 return float(bb[2] - bb[0])1087 except Exception:1088 w, _ = dtmp.textsize(text, font=font)1089 return float(w)1090 1091 for i, (k, t) in enumerate(layout_units):1092 if k == "space":1093 hint = ""1094 for j in range(i - 1, -1, -1):1095 if layout_units[j][0] == "word":1096 hint = layout_units[j][1]1097 break1098 if not hint:1099 for j in range(i + 1, len(layout_units)):1100 if layout_units[j][0] == "word":1101 hint = layout_units[j][1]1102 break1103 font0 = pick_font(hint or "a", thai_path, latin_path, base_size)1104 widths_px.append(max(0.0, _measure_len_px(font0, t)))1105 continue1106 1107 font0 = pick_font(t, thai_path, latin_path, base_size)1108 try:1109 ascent, descent = font0.getmetrics()1110 except Exception:1111 ascent, descent = base_size, int(base_size * 0.25)1112 if ascent > max_ascent:1113 max_ascent = ascent1114 if descent > max_descent:1115 max_descent = descent1116 1117 if kerning_adjust and (i + 1) < len(layout_units) and layout_units[i + 1][0] == "word":1118 nxt = layout_units[i + 1][1]1119 nxt1 = nxt[:1] if nxt else ""1120 if nxt1 and (_contains_thai(t) == _contains_thai(nxt1)):1121 tw = _measure_len_px(font0, t + nxt1) - \1122 _measure_len_px(font0, nxt1)1123 else:1124 tw = _measure_len_px(font0, t)1125 else:1126 tw = _measure_len_px(font0, t)1127 1128 widths_px.append(max(0.0, tw))1129 1130 line_tw = sum(widths_px)1131 bo_base = _baseline_offset_px_for_text(1132 item_text, thai_path, latin_path, base_size)1133 if bo_base is not None:1134 _, total_h_base = bo_base1135 line_th = float(total_h_base)1136 else:1137 line_th = float(max_ascent + max_descent)1138 1139 if line_tw <= 1e-9 or line_th <= 1e-9:1140 item["spans"] = []1141 return1142 1143 if forced_size_px is None:1144 scale_line = min((base_w_px * 1.0) / line_tw,1145 (base_h_px * 0.995) / line_th)1146 if scale_line <= 0.0:1147 item["spans"] = []1148 return1149 final_size = max(10, int(base_size * scale_line))1150 else:1151 final_size = int(max(10, forced_size_px))1152 scale_line = float(final_size) / float(base_size)1153 1154 item["font_size_px"] = final_size1155 1156 w_scaled = [w * scale_line for w in widths_px]1157 total_scaled = sum(w_scaled)1158 margin_px = (base_w_px - total_scaled) / \1159 2.0 if total_scaled < base_w_px else 0.01160 1161 bo = _baseline_offset_px_for_text(1162 item_text, thai_path, latin_path, final_size)1163 if apply_baseline_shift and bo is not None:1164 baseline_offset_px, _ = bo1165 cx = (base_left + (base_w / 2.0)) * float(W)1166 cy = (base_top + (base_h / 2.0)) * float(H)1167 target = (cx + (baseline_offset_px * nx),1168 cy + (baseline_offset_px * ny))1169 s = ((target[0] - x1) * nx) + ((target[1] - y1) * ny)1170 x1 += nx * s1171 y1 += ny * s1172 x2 += nx * s1173 y2 += ny * s1174 1175 item["baseline_p1"] = {"x": x1 / float(W), "y": y1 / float(H)}1176 item["baseline_p2"] = {"x": x2 / float(W), "y": y2 / float(H)}1177 1178 raw_pos = 01179 span_i = 01180 unit_i = 01181 cum_px = 0.01182 spans = []1183 1184 for kind, s, _ in tokens:1185 if s == ZWSP:1186 continue1187 1188 start_raw = abs_line_start_raw + raw_pos1189 raw_pos += len(s)1190 end_raw = abs_line_start_raw + raw_pos1191 1192 if unit_i >= len(w_scaled):1193 break1194 1195 wpx = w_scaled[unit_i]1196 t0 = (margin_px + cum_px) / base_w_px1197 cum_px += wpx1198 t1 = (margin_px + cum_px) / base_w_px1199 1200 if kind == "space":