CoolFace
Apppublic

oilking99/TextPhantom_OCR_API2

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
lens_core.py3984 linesDownload Raw Back to backend
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":

Showing the first 1,200 of 3984 lines. Download the file for the rest.