"""Render an OCR tree as the HTML overlay the extension injects on a page. Layout rules (per *item*): - **Original / Translated layers** — render every Lens item as it came back from Lens: one ``
`` per item, sized to its box, and rotated with ``transform: rotate()`` so the line follows its baseline. No re-orientation, no script-based magic. These layers are *Lens-direct*; Lens already laid the text out correctly for each language. - **AI layer** — same default as Original / Translated, with one CJK exception inspired by ``manga-image-translator``'s ``calc_vertical``: when a *paragraph's* translated text is dominantly CJK (Japanese / Chinese / Korean) the whole paragraph collapses into one ``
`` covering the bubble (the union ``bounds_px``). The div uses ``writing-mode: vertical-rl`` + ``text-orientation: upright`` so the translation reads as a single vertical block — characters upright, top-to-bottom, columns flowing right-to-left — like a hand-set Japanese / Chinese speech bubble. Per-paragraph (not per-item) avoids shattering a multi-line bubble into several tiny vertical strips. The browser handles fonts, kerning and shaping with whatever Thai / CJK / Arabic / Devanagari / Hebrew / … font is installed. No Pillow involvement. A single :func:`overlay_css` stylesheet covers all three layers. """ from __future__ import annotations import math from typing import Any, Final from backend.lens.tree import iter_paragraphs from backend.render.region import ( compute_region_geometry, fit_render_box, is_rtl, resolve_text_direction, ) from backend.render.text_utils import contains_rtl # Per-script average glyph width as a fraction of font-size — eyeballed # averages used by the horizontal font-size fit. _GLYPH_W_RATIO_CJK: Final[float] = 0.95 _GLYPH_W_RATIO_THAI: Final[float] = 0.55 _GLYPH_W_RATIO_LATIN: Final[float] = 0.55 # Minimum legible font size — below this the line becomes a dot. _MIN_FONT_PX: Final[int] = 9 # Unicode ranges that count as "CJK" for the vertical-text decision. # Covers every block Lens may return for Japanese / Chinese / Korean — # punctuation, kana, kanji, hangul, halfwidth/fullwidth forms, and every # CJK Extension block. _CJK_RANGES: Final[tuple[tuple[int, int], ...]] = ( (0x2E80, 0x2EFF), # CJK Radicals Supplement (0x2F00, 0x2FDF), # Kangxi Radicals (0x3000, 0x303F), # CJK Symbols & Punctuation (0x3040, 0x309F), # Hiragana (0x30A0, 0x30FF), # Katakana (0x3100, 0x312F), # Bopomofo (0x3130, 0x318F), # Hangul Compatibility Jamo (0x3190, 0x319F), # Kanbun (0x31A0, 0x31BF), # Bopomofo Extended (0x31C0, 0x31EF), # CJK Strokes (0x31F0, 0x31FF), # Katakana Phonetic Extensions (0x3200, 0x32FF), # Enclosed CJK Letters & Months (0x3300, 0x33FF), # CJK Compatibility (0x3400, 0x4DBF), # CJK Extension A (0x4E00, 0x9FFF), # CJK Unified Ideographs (0xA000, 0xA48F), # Yi Syllables (0xAC00, 0xD7AF), # Hangul Syllables (0xF900, 0xFAFF), # CJK Compatibility Ideographs (0xFE30, 0xFE4F), # CJK Compatibility Forms (0xFF00, 0xFFEF), # Halfwidth & Fullwidth Forms (0x20000, 0x2A6DF), # CJK Extension B (0x2A700, 0x2B73F), # CJK Extension C (0x2B740, 0x2B81F), # CJK Extension D (0x2B820, 0x2CEAF), # CJK Extension E (0x2F800, 0x2FA1F), # CJK Compatibility Ideographs Supplement ) def overlay_css() -> str: """Return the single stylesheet used by all three overlay layers. Visual style: classic scanlation — near-black text with a soft white halo + thin dark drop-shadow, so it reads cleanly on any background. A separate rule (``.tp-line.vert``) flips the same element to a vertical-rl block for AI items whose text is CJK-dominant. The ``font-family`` stack lists Noto Sans variants for every script Google Lens may return, then platform-specific fallbacks (PingFang, Microsoft YaHei, Hiragino, …), then ``system-ui`` so something always renders no matter which OS the extension runs on. """ return ( # The extension's popup +/- buttons set ``--tp-font-scale`` on the # ancestor ``.tp-ol-scope``; every ``.tp-line`` reads it through # ``calc(var(--tp-font-scale,1) * Npx)``. CRITICAL: ``.tp-draw-root`` # must NOT declare ``--tp-font-scale`` itself — doing so would create a # local custom property that SHADOWS the ancestor's value, freezing the # scale at 1 and making the +/- buttons do nothing. The ``,1`` fallback # in every ``calc()`` already supplies the default when the variable is # unset (e.g. the standalone file preview, which has no extension). ".tp-draw-root{position:absolute;inset:0;pointer-events:none;}" ".tp-draw-scope{position:absolute;inset:0;width:100%;height:100%;" "transform-origin:0 0;}" # Shared text style — horizontal layout is the default. ".tp-line{" "position:absolute;" "display:flex;" "align-items:center;" "justify-content:center;" "white-space:nowrap;" "overflow:visible;" "box-sizing:border-box;" "transform-origin:center center;" "pointer-events:none;" "user-select:none;" "padding:0 .15em;" # Broad font stack covering every script Lens can return. "font-family:" "\"Noto Sans CJK JP\",\"Noto Sans CJK SC\"," "\"Noto Sans CJK TC\",\"Noto Sans CJK KR\"," "\"Noto Sans JP\",\"Noto Sans SC\",\"Noto Sans TC\",\"Noto Sans KR\"," "\"Noto Sans Thai\",\"Noto Sans Thai UI\"," "\"Noto Sans Arabic\",\"Noto Sans Hebrew\"," "\"Noto Sans Devanagari\",\"Noto Sans Bengali\"," "\"Noto Sans Tamil\",\"Noto Sans Telugu\"," "\"Noto Sans Khmer\",\"Noto Sans Lao\",\"Noto Sans Myanmar\"," "\"Noto Sans Georgian\",\"Noto Sans Armenian\"," "\"Noto Sans Ethiopic\",\"Noto Sans\"," "\"Hiragino Sans\",\"Hiragino Kaku Gothic ProN\",\"Yu Gothic\"," "\"Microsoft YaHei\",\"Microsoft JhengHei\",\"Malgun Gothic\"," "\"Apple SD Gothic Neo\",\"PingFang SC\",\"PingFang TC\"," "system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\"," "Roboto,Arial,sans-serif;" "font-weight:600;" "font-style:normal;" "letter-spacing:0;" # Ink + halo go through CSS variables so a paragraph sitting on a DARK # panel can flip to white-text-dark-halo via the .tp-on-dark wrapper # (custom properties inherit through the static wrapper div). "color:var(--tp-ink,rgba(15,15,15,.98));" "text-shadow:var(--tp-halo," "0 0 2px rgba(255,255,255,.95)," "0 0 2px rgba(255,255,255,.95)," "0 0 3px rgba(255,255,255,.85)," "0 1px 1px rgba(0,0,0,.35));" "text-rendering:geometricPrecision;" "}" # Dark-background variant: white text with a dark halo. Applied per # paragraph by the renderer when the sampled background is dark. ".tp-on-dark{" "--tp-ink:rgba(248,248,248,.98);" "--tp-halo:0 0 2px rgba(0,0,0,.95)," "0 0 2px rgba(0,0,0,.95)," "0 0 3px rgba(0,0,0,.85)," "0 1px 1px rgba(255,255,255,.25);" "}" # Vertical CJK variant — top-to-bottom columns reading right-to-left. # ``writing-mode: vertical-rl`` rotates the block-flow axis so text # naturally fills a portrait box this way; ``text-orientation: # upright`` keeps every glyph standing up (no sideways characters). # Flex centring still works because the writing-mode flip swaps # which axis ``align-items`` / ``justify-content`` refer to. ".tp-line.vert{" "writing-mode:vertical-rl;" "text-orientation:upright;" "white-space:normal;" "padding:.15em 0;" "letter-spacing:0;" "}" # Bubble variant — used ONLY when the source items are vertical # (Japanese vertical, etc.) AND the target is a horizontal script # (Thai / Latin / Cyrillic / Arabic / …). Per-item rotation would # leave the translation lying sideways like the source, so the # whole paragraph collapses into one axis-aligned div in the # bubble's AABB and the text wraps inside it. # # Padding is intentionally narrow (.1em) on the horizontal axis: # .3em at a small font size (e.g. 14 px) can consume most of the # box width, forcing ``overflow-wrap`` to kick in and split Thai # grapheme clusters mid-syllable ("ไ ม่", "ปั ง", "จ้อ ง"). # Callers insert ZWSP at BudouX word boundaries so the browser # wraps on correct Thai word edges rather than at arbitrary code # points. ``word-break:normal`` + ``overflow-wrap:break-word`` # respects those ZWSP opportunities and falls back to mid-word only # as a last resort (not after every grapheme cluster). ".tp-line.bubble{" "white-space:normal;" "word-break:normal;" "overflow-wrap:break-word;" "text-align:center;" "padding:.2em .1em;" "}" # Right-to-left variant — Arabic / Hebrew / Persian / Urdu targets. # ``direction:rtl`` makes the browser apply the Unicode bidi algorithm # so the glyphs (already shaped/joined by the font) order correctly; # ``unicode-bidi:isolate`` keeps any embedded LTR run (numbers, latin # brand names) from leaking out and reordering the whole line. ".tp-line.rtl{" "direction:rtl;" "unicode-bidi:isolate;" "}" ) # --- Small helpers -------------------------------------------------------- def _num(x: Any, default: float = 0.0) -> float: """Coerce ``x`` to a finite float, returning ``default`` on failure.""" try: n = float(x) except (TypeError, ValueError): return float(default) if n != n or n in (float("inf"), float("-inf")): return float(default) return n def _escape_text(s: str) -> str: """HTML-escape an item's text content (strip ``\\r``, keep newlines).""" if not s: return "" return ( s.replace("\r", "") .replace("&", "&") .replace("<", "<") .replace(">", ">") ) def _is_cjk_char(ch: str) -> bool: """True when ``ch`` falls inside a CJK Unicode block.""" if not ch: return False o = ord(ch) for lo, hi in _CJK_RANGES: if lo <= o <= hi: return True return False def _classify_text(text: str) -> str: """Cheap script bucket used to pick a glyph-width ratio.""" if not text: return "latin" cjk = thai = other = 0 for ch in text: if _is_cjk_char(ch): cjk += 1 elif 0x0E00 <= ord(ch) <= 0x0E7F: thai += 1 elif ch.isalnum(): other += 1 if cjk and cjk >= max(thai, other): return "cjk" if thai and thai >= other: return "thai" return "latin" def _is_cjk_dominant(text: str, threshold: float = 0.45) -> bool: """True when at least ``threshold`` fraction of visible chars is CJK. Used by the AI renderer to decide per item whether to switch to vertical layout — matching ``manga-image-translator``'s per-region horizontal-vs-vertical decision. """ if not text: return False visible = 0 cjk = 0 for ch in text: if ch.isspace(): continue visible += 1 if _is_cjk_char(ch): cjk += 1 return visible > 0 and (cjk / visible) >= threshold def _glyph_width_ratio(text: str) -> float: """Average glyph width (as fraction of font-size) for ``text``.""" kind = _classify_text(text) if kind == "cjk": return _GLYPH_W_RATIO_CJK if kind == "thai": return _GLYPH_W_RATIO_THAI return _GLYPH_W_RATIO_LATIN def _visible_char_count(text: str) -> int: """Length excluding whitespace — what width fit actually pays for.""" if not text: return 0 return sum(1 for ch in text if not ch.isspace()) def fit_item_font_size( box_width_pct: float, box_height_pct: float, text: str, img_w: int, img_h: int, ) -> int: """Pure-math font-size pick for a *horizontal* Lens item. Height is the hard ceiling (text taller than the line looks cramped); width is the soft ceiling (shrink so the line fits the box). """ w_px = max(0.0, box_width_pct) / 100.0 * max(1, int(img_w)) h_px = max(0.0, box_height_pct) / 100.0 * max(1, int(img_h)) if h_px <= 0: return _MIN_FONT_PX fs_height = h_px * 0.85 n = _visible_char_count(text) if n <= 0: return max(_MIN_FONT_PX, int(round(fs_height))) ratio = _glyph_width_ratio(text) fs_width = w_px / max(1.0, (n + 0.5) * ratio) fs = min(fs_height, fs_width) return max(_MIN_FONT_PX, int(round(fs))) def fit_paragraph_font_size_horizontal( box_w_px: float, box_h_px: float, text: str, ) -> int: """Closed-form font size for *wrapped* horizontal text in a bubble AABB. Each glyph occupies roughly ``ratio × fs`` wide and ``fs`` tall (ratio 0.55 for Thai / Latin / Cyrillic, 0.95 for CJK). ``n`` chars wrapped across width ``w`` fill ``ceil(n·ratio·fs / w)`` lines that must fit height ``h``: fs² ≤ w · h / (n · ratio) A 0.85 safety factor + a hard height ceiling keep text comfortably inside the bubble after CSS adds its em-based padding. Used only by the source-vertical → target-horizontal path (e.g. Japanese vertical bubble translated to Thai); horizontal source bubbles keep the per-item fit so they aren't affected by this size formula. """ if box_w_px <= 0 or box_h_px <= 0: return _MIN_FONT_PX n = _visible_char_count(text) if n <= 0: return max(_MIN_FONT_PX, int(round(min(box_w_px, box_h_px) * 0.85))) ratio = _glyph_width_ratio(text) fs_area = math.sqrt(box_w_px * box_h_px / max(1.0, n * ratio)) * 0.85 # Single-line ceiling — text never grows taller than ~80% of the box. fs_one_line = box_h_px * 0.80 fs = min(fs_area, fs_one_line) return max(_MIN_FONT_PX, int(round(fs))) def fit_item_font_size_vertical( box_w_px: float, box_h_px: float, text: str, ) -> int: """Closed-form font size for *vertical* CJK text in an axis-aligned box. Each CJK glyph occupies roughly ``fs × fs`` pixels. ``n`` chars laid out as vertical columns inside a ``w × h`` AABB fit when total glyph area is no greater than the AABB area: n · fs² ≤ w · h => fs ≤ sqrt(w · h / n) Extra ceilings: - One column must fit horizontally: ``fs ≤ w * 0.95``. - A single column shouldn't exceed the height: ``fs ≤ h * 0.95``. A 0.9 safety factor keeps the text comfortably inside the box. """ if box_w_px <= 0 or box_h_px <= 0: return _MIN_FONT_PX n = _visible_char_count(text) if n <= 0: return max(_MIN_FONT_PX, int(round(min(box_w_px, box_h_px) * 0.85))) fs_area = math.sqrt(box_w_px * box_h_px / n) * 0.9 fs_col_w = box_w_px * 0.95 fs_one = box_h_px * 0.95 fs = min(fs_area, fs_col_w, fs_one) return max(_MIN_FONT_PX, int(round(fs))) # --- Geometry helpers ---------------------------------------------------- def _item_rotated_aabb_px( item: dict, img_w: int, img_h: int ) -> tuple[float, float, float, float] | None: """Axis-aligned bounding box (in image pixels) of an item's rotated rect. The item's box is stored as ``(left, top, width, height)`` of the unrotated rectangle plus a ``rotation_deg``. Lens applies that rotation around the box's *center*, so the rectangle's four corners need to be rotated and then unioned to get the visual axis-aligned footprint. Returns ``(left, top, width, height)`` in image pixels, or ``None`` when the item lacks valid geometry. """ box = item.get("box") or {} w_n = _num(box.get("width")) h_n = _num(box.get("height")) if w_n <= 0 or h_n <= 0: return None center = box.get("center") or {} cx_n = _num(center.get("x"), _num(box.get("left")) + w_n / 2.0) cy_n = _num(center.get("y"), _num(box.get("top")) + h_n / 2.0) rot_deg = _num(box.get("rotation_deg_css"), _num(box.get("rotation_deg"))) cx = cx_n * img_w cy = cy_n * img_h half_w = w_n * img_w / 2.0 half_h = h_n * img_h / 2.0 rad = math.radians(rot_deg) cos_a = math.cos(rad) sin_a = math.sin(rad) xs: list[float] = [] ys: list[float] = [] for ox, oy in ((-half_w, -half_h), (half_w, -half_h), (half_w, half_h), (-half_w, half_h)): xs.append(cx + ox * cos_a - oy * sin_a) ys.append(cy + ox * sin_a + oy * cos_a) left = min(xs) top = min(ys) width = max(xs) - left height = max(ys) - top if width <= 0 or height <= 0: return None return left, top, width, height def _font_size_for_item(item: dict, img_w: int, img_h: int) -> int: """Pick (or read) the per-item font size for horizontal rendering.""" fs_existing = _num(item.get("font_size_px")) if fs_existing >= _MIN_FONT_PX: return int(round(fs_existing)) box = item.get("box") or {} width_pct = _num(box.get("width_pct"), _num(box.get("width")) * 100.0) height_pct = _num(box.get("height_pct"), _num(box.get("height")) * 100.0) text = str(item.get("text") or "") return fit_item_font_size(width_pct, height_pct, text, img_w, img_h) def _shared_vertical_font_size(para: dict, img_w: int, img_h: int) -> int | None: """One font size shared by every VERTICAL item in this paragraph. Analog of :func:`_shared_horizontal_font_size` for paragraphs whose source items are vertical CJK columns: each item already carries its real pixel footprint in ``bounds_px``, so the per-item fit uses :func:`fit_item_font_size_vertical` (which derives the glyph size from the column's width × height area). Averaging those gives one consistent size for every column in the paragraph — and, combined with the bubble-shared step the renderer applies on top, one size for every paragraph that lives inside the same speech bubble. """ sizes: list[int] = [] for item in para.get("items") or []: text = str(item.get("text") or "").strip() if not text: continue fs_existing = _num(item.get("font_size_px")) if fs_existing >= _MIN_FONT_PX: sizes.append(int(round(fs_existing))) continue aabb = _item_rotated_aabb_px(item, img_w, img_h) if aabb is None: continue _, _, w_px, h_px = aabb sizes.append(fit_item_font_size_vertical(w_px, h_px, text)) if not sizes: return None return max(_MIN_FONT_PX, int(round(sum(sizes) / len(sizes)))) def _shared_horizontal_font_size(para: dict, img_w: int, img_h: int) -> int | None: """One font size shared by every horizontal item in this paragraph. Each Lens *item* in a paragraph (= one bubble) has its own bounding box, and the per-item fit picks a different size for each — so the same speech bubble can end up with one line at 18px and the next at 32px purely because Lens detected the heights slightly differently. Averaging the per-item fits and applying the result to every item in the paragraph keeps the bubble visually consistent. Items rely on ``overflow: visible`` (already in :func:`overlay_css`) when the shared size is slightly larger than the smallest box: glyphs spill a few px past the box rather than reflowing. """ sizes: list[int] = [] for item in para.get("items") or []: text = str(item.get("text") or "").strip() if not text: continue fs_existing = _num(item.get("font_size_px")) if fs_existing >= _MIN_FONT_PX: sizes.append(int(round(fs_existing))) continue box = item.get("box") or {} width_pct = _num(box.get("width_pct"), _num(box.get("width")) * 100.0) height_pct = _num(box.get("height_pct"), _num(box.get("height")) * 100.0) sizes.append(fit_item_font_size(width_pct, height_pct, text, img_w, img_h)) if not sizes: return None avg = sum(sizes) / len(sizes) return max(_MIN_FONT_PX, int(round(avg))) # --- Main renderer -------------------------------------------------------- def _bubble_group_key(para: dict) -> tuple[float, ...] | None: """Hashable key for grouping paragraphs that share the same bubble. Two Lens paragraphs that map to the same detected bubble (e.g. ``"โธ่"``, ``"อีกนาน"``, ``"ไหมเนี่ย?"`` inside one speech balloon) get the same key. Returns ``None`` for paragraphs without a bubble bounds — those form their own one-paragraph group. """ bb = para.get("bubble_bounds_px") if not isinstance(bb, (list, tuple)) or len(bb) != 4: return None return tuple(round(float(x), 1) for x in bb) def _para_rotation(para: dict) -> float: """Representative baseline rotation (degrees) for a paragraph. The mean of its items' ``rotation_deg``. Note: for *near-vertical* text the sign of this value is unstable — a column tilting a hair left decodes as ``-90°`` and a hair right as ``+90°`` even inside one bubble — so rotation is used only to pick the *perpendicular axis* for :func:`_perpendicular_gap` (which is sign-insensitive), never as a grouping signal on its own. """ rots: list[float] = [] for it in para.get("items") or []: if not str(it.get("text") or "").strip(): continue box = it.get("box") or {} rots.append(float(box.get("rotation_deg") or box.get("rotation_deg_css") or 0.0)) return sum(rots) / len(rots) if rots else 0.0 def _para_centroid(para: dict, img_w: int, img_h: int) -> tuple[float, float] | None: """Mean of a paragraph's item centres, in image pixels.""" xs: list[float] = [] ys: list[float] = [] for it in para.get("items") or []: if not str(it.get("text") or "").strip(): continue box = it.get("box") or {} center = box.get("center") or {} cx = center.get("x") cy = center.get("y") if cx is None: cx = float(box.get("left") or 0.0) + float(box.get("width") or 0.0) / 2.0 if cy is None: cy = float(box.get("top") or 0.0) + float(box.get("height") or 0.0) / 2.0 xs.append(float(cx) * img_w) ys.append(float(cy) * img_h) if not xs: return None return (sum(xs) / len(xs), sum(ys) / len(ys)) def _para_font_px(para: dict, img_h: int) -> float: """Median item text-height of a paragraph, in pixels — the glyph scale. ``box.height`` is the text height perpendicular to the baseline for both horizontal and vertical items, so it is the glyph size in either orientation. Used as the natural length unit when deciding whether two paragraphs are adjacent columns or separate bubbles. """ hs: list[float] = [] for it in para.get("items") or []: if not str(it.get("text") or "").strip(): continue box = it.get("box") or {} h = float(box.get("height") or 0.0) * img_h if h > 1.0: hs.append(h) if not hs: return 0.0 hs.sort() return hs[len(hs) // 2] def _perpendicular_gap( c_a: tuple[float, float], c_b: tuple[float, float], rot_deg: float ) -> float: """Distance between two centroids measured *across* the text direction. For vertical text this is the inter-column gap (Δx); for horizontal text the inter-line gap (Δy). Measuring across-axis isolates the bubble's column/line spacing from variation in column/line *length* — a plain Euclidean distance would be inflated when neighbouring columns simply have different lengths. Sign-insensitive, so the unstable ±90° rotation sign doesn't matter. """ r = math.radians(rot_deg) # The text direction is (cos r, sin r); its perpendicular is # (-sin r, cos r). Project the centroid delta onto that. px, py = -math.sin(r), math.cos(r) return abs((c_b[0] - c_a[0]) * px + (c_b[1] - c_a[1]) * py) def _insert_thai_word_breaks(text: str, target_lang: str) -> str: """Insert ZWSP (U+200B) between BudouX word boundaries in Thai text. Thai orthography has no inter-word spaces. Without explicit break opportunities ``overflow-wrap`` falls back to splitting at any code point, which can separate a leading vowel from its base consonant (ไ | ม่) or split mid-syllable (ปั | ง, จ้อ | ง). With ZWSP inserted the browser has correct break points and ``overflow-wrap: break-word`` only ever breaks at those positions, keeping each Thai grapheme cluster intact. Single-word tokens (BudouX returns one chunk) get no ZWSP, so a short word like "จ้อง" or "ไม่" never acquires a spurious break opportunity. Returns ``text`` unchanged when the target language is not Thai or when the BudouX parser is unavailable. """ import re as _re from backend.lens.languages import normalize as _normalise from backend.render.fonts import budoux_parser as _get_parser if _normalise(target_lang) != "th": return text parser = _get_parser("th") if parser is None: return text # Process each non-whitespace run through BudouX; preserve spaces. out: list[str] = [] for part in _re.split(r"(\s+)", text): if not part or part.isspace(): out.append(part) continue try: chunks = [c for c in parser.parse(part) if c] out.append("​".join(chunks) if len(chunks) > 1 else part) except Exception: out.append(part) return "".join(out) def _render_ai_region( items: list[dict], text: str, target_lang: str, img_w: int, img_h: int, ) -> str: """Render one AI bubble as a single ``
`` — deterministic, no fallback. The geometry comes entirely from :mod:`backend.render.region`: - reading direction is the *target language's* direction (a fixed lookup table — Thai/Latin/Cyrillic → horizontal, CJK → vertical); - the render box is sized by a closed-form formula from the glyph count and the source font size; - the only rotation applied is the region's *residual tilt* (the part of the Lens rotation that isn't the 0°/90° base orientation), so a vertical Japanese column doesn't get a spurious 90° CSS rotation. Every bubble follows this exact path — there is no branch that silently swaps in a different algorithm, so a test result always traces back here. """ region = compute_region_geometry(items, img_w, img_h) if region is None: return "" direction = resolve_text_direction(target_lang, text) # Override: if the language preset says "v" (or resolved to "v" via # auto/CJK text detection) but the *source items were actually horizontal* # (rotation ≈ 0°), keep horizontal — some Japanese/Chinese panels use # horizontal layout and we must not rotate what the author wrote upright. if direction == "v" and not region.source_vertical: direction = "h" left, top, width, height, font_px = fit_render_box( region, text, direction, img_w, img_h ) if width <= 1.0 or height <= 1.0: return "" fs = max(_MIN_FONT_PX, int(round(font_px))) # Horizontal text wants a little leading; vertical columns want the # line box equal to the glyph cell so columns sit flush. lh = int(round(fs * 1.15)) if direction == "h" else fs left_pct = left / max(1, img_w) * 100.0 top_pct = top / max(1, img_h) * 100.0 width_pct = width / max(1, img_w) * 100.0 height_pct = height / max(1, img_h) * 100.0 style_parts = [ f"left:{left_pct:.4f}%", f"top:{top_pct:.4f}%", f"width:{width_pct:.4f}%", f"height:{height_pct:.4f}%", f"font-size:calc(var(--tp-font-scale,1) * {fs}px)", f"line-height:calc(var(--tp-font-scale,1) * {lh}px)", ] # Apply only the residual tilt — never the 0°/90° base orientation, # which is expressed through the writing-mode instead. if abs(region.tilt_deg) > 0.5: style_parts.append(f"transform:rotate({region.tilt_deg:.3f}deg)") style = ";".join(style_parts) + ";" cls = "tp-line vert" if direction == "v" else "tp-line bubble" if direction != "v" and is_rtl(target_lang): cls += " rtl" # For horizontal Thai output insert ZWSP at BudouX word boundaries so # the browser wraps on correct syllable/word edges rather than at # arbitrary Unicode code points (which causes "ไ ม่", "ปั ง", etc.). display_text = ( _insert_thai_word_breaks(text, target_lang) if direction == "h" else text ) return f'
{_escape_text(display_text)}
' def _render_ai_paragraph( para: dict, text: str, target_lang: str, img_w: int, img_h: int, override_fs: int | None = None, ) -> str: """Render one AI paragraph as a single ``
`` using pre-computed geometry. This is the correct path for trees built by :mod:`backend.render.build_ai_tree`. It reads ``para_font_size_px`` directly — already computed by :func:`backend.render.tp_html.fit_item_font_size` — rather than deriving the font size from synthetic item ``box.height`` values (which store *slot* heights, not glyph heights, and would give wildly wrong sizes via :func:`compute_region_geometry`). Position comes from ``bubble_bounds_px`` (the OpenCV-detected speech-bubble outline) via :func:`_para_bounds_px`. Text direction is read from the item rotation angles that :mod:`backend.render.build_ai_tree` stamped: 0° = horizontal target language, 90° = vertical CJK target. """ # Use bounds_px set by build_ai_tree — it's the group's unique canvas # (item AABB when bubble_bounds_px is shared, full bounds otherwise). # Reading it directly avoids _para_bounds_px which would try # bubble_bounds_px first and return the shared overlapping blob. bp = para.get("bounds_px") if isinstance(bp, (list, tuple)) and len(bp) == 4: x1, y1, x2, y2 = (_num(v) for v in bp) if x2 <= x1 or y2 <= y1: return "" left_px, top_px = x1, y1 width_px, height_px = x2 - x1, y2 - y1 else: # Fallback for AI trees not built by build_ai_tree. bounds = _para_bounds_px(para, img_w, img_h) if bounds is None: return "" left_px, top_px, width_px, height_px = bounds if width_px <= 0 or height_px <= 0: return "" # Direction: read from item rotations set by build_ai_tree (0° = h, 90° = v). is_vert = _paragraph_source_is_vertical(para) # Font size: use the pre-computed median from build_ai_tree. # Falls back to the formula-based fit only for trees not built that way. if override_fs is not None and override_fs >= _MIN_FONT_PX: # Spec \u00a717: every paragraph in the SAME real speech bubble renders at # one shared size (computed by the caller). Overrides the per-paragraph # fit so siblings in a bubble don't read at different sizes. fs = int(override_fs) else: fs = int(para.get("para_font_size_px") or 0) if fs < _MIN_FONT_PX: if is_vert: fs = fit_item_font_size_vertical(width_px, height_px, text) else: fs = fit_paragraph_font_size_horizontal(width_px, height_px, text) fs = max(_MIN_FONT_PX, fs) lh = int(round(fs * 1.15)) if not is_vert else fs left_pct = left_px / max(1, img_w) * 100.0 top_pct = top_px / max(1, img_h) * 100.0 width_pct = width_px / max(1, img_w) * 100.0 height_pct = height_px / max(1, img_h) * 100.0 style = ( f"left:{left_pct:.4f}%;" f"top:{top_pct:.4f}%;" f"width:{width_pct:.4f}%;" f"height:{height_pct:.4f}%;" f"font-size:calc(var(--tp-font-scale,1) * {fs}px);" f"line-height:calc(var(--tp-font-scale,1) * {lh}px);" ) cls = "tp-line vert" if is_vert else "tp-line bubble" if not is_vert and is_rtl(target_lang): cls += " rtl" display_text = _insert_thai_word_breaks(text, target_lang) if not is_vert else text pi = int(para.get("para_index", 0)) return ( f'
{_escape_text(display_text)}
' ) def render_tree_overlay( tree: dict | None, img_w: int, img_h: int, target_lang: str = "", ) -> str: """Render every paragraph/item in ``tree`` as ``
``s. Two layout modes: - **Original / Translated** (``tree.side`` in {"original", "translated"}): every item is rendered horizontally, rotated to match its source baseline. Lens-direct — the layout Lens chose is preserved as-is. - **AI** (``tree.side == "Ai"``): paragraphs that share a bubble are merged, and each bubble renders as ONE deterministic block via :func:`_render_ai_region` — reading direction from the target language, box size from the glyph count, rotation from the residual tilt only. This is the manga-image-translator model: direction is a language property, geometry is closed-form. ``target_lang`` is required for the AI layer's direction lookup; for Original/Translated it is ignored. Returns ``""`` when ``tree`` has no renderable text. """ if not isinstance(tree, dict): return "" is_ai_layer = str(tree.get("side") or "").lower() == "ai" parts: list[str] = ['
'] has_any = False def _wrap_on_dark(chunk: str, para: dict) -> str: """Wrap a rendered chunk so dark-background paragraphs flip colour. The wrapper is position:static, so the absolutely-positioned ``.tp-line`` children keep ``.tp-draw-scope`` as their containing block — only the inherited CSS variables change. """ if chunk and para.get("text_light"): return '
' + chunk + "
" return chunk if is_ai_layer: # AI layer: two rendering paths based on canvas geometry. # # 1. **Flat paragraphs** (canvas_rotation_deg ≈ 0°) — speech bubbles # whose text is roughly horizontal. A single block div with # word-wrap keeps the translation readable even when it is longer # than the original. Uses _render_ai_paragraph. # # 2. **Tilted paragraphs** (|canvas_rotation_deg| > 1°) — diagonal # manga labels, sound effects, status-screen text drawn at an angle. # Rendered per-item via _render_item_horizontal, which applies # transform:rotate() from item.box.rotation_deg. This also # naturally supports *curved* text: each item can carry a slightly # different rotation angle, letting the line of items follow a curve # exactly as Lens detected it in the original art. ai_paras = [p for _, p in iter_paragraphs(tree) if str(p.get("text") or "").strip()] # Spec \u00a717 (font consistency inside one bubble) for the AI layer. # Flat paragraphs that genuinely share one detected speech bubble must # render at ONE shared size. We bucket flat paragraphs by # ``bubble_bounds_px`` and average their pre-computed fonts. A bucket # of size 1 is left untouched; blob-collisions where build_ai_tree gave # paragraphs distinct ``bounds_px`` outside the shared blob are NOT # forced equal (their bounds don't fall inside the common bubble), so a # caption/watermark wrongly sharing a blob keeps its own size. shared_ai_fs: dict[int, int] = {} flat_bucket: dict[tuple[float, ...], list[dict]] = {} for p in ai_paras: if abs(float(p.get("canvas_rotation_deg") or 0.0)) > 1.0: continue # tilted/per-item path is not bubble-bucketed bb = p.get("bubble_bounds_px") if not (isinstance(bb, (list, tuple)) and len(bb) == 4): continue flat_bucket.setdefault(tuple(round(float(x), 1) for x in bb), []).append(p) for bb_key, members in flat_bucket.items(): if len(members) < 2: continue bx1, by1, bx2, by2 = bb_key # keep only members whose own bounds_px fall (mostly) inside the # shared bubble — filters out blob-collision strangers. inside = [] for p in members: pb = p.get("bounds_px") if not (isinstance(pb, (list, tuple)) and len(pb) == 4): continue px1, py1, px2, py2 = (float(v) for v in pb) ix = max(0.0, min(px2, bx2) - max(px1, bx1)) iy = max(0.0, min(py2, by2) - max(py1, by1)) inter = ix * iy area = max(1.0, (px2 - px1) * (py2 - py1)) if inter / area >= 0.6: inside.append(p) if len(inside) < 2: continue sizes = [int(p.get("para_font_size_px") or 0) for p in inside] sizes = [s for s in sizes if s >= _MIN_FONT_PX] if not sizes: continue shared = max(_MIN_FONT_PX, int(round(sum(sizes) / len(sizes)))) for p in inside: shared_ai_fs[id(p)] = shared for para in ai_paras: para_text = str(para.get("text") or "").strip() para_rot = float(para.get("canvas_rotation_deg") or 0.0) if abs(para_rot) > 1.0: # Per-item path: each AI item already has the correct # rotation_deg set by build_ai_tree._make_item_box, so # _render_item_horizontal applies transform:rotate() for free. for item in para.get("items") or []: item_text = str(item.get("text") or "").strip() if not item_text: continue chunk = _render_item_horizontal( item, item_text, para, img_w, img_h ) if chunk: parts.append(_wrap_on_dark(chunk, para)) has_any = True else: # Single-block path: word-wrap inside the bubble canvas. chunk = _render_ai_paragraph( para, para_text, target_lang, img_w, img_h, override_fs=shared_ai_fs.get(id(para)), ) if chunk: parts.append(_wrap_on_dark(chunk, para)) has_any = True parts.append("
") return "".join(parts) if has_any else "" # Non-AI layers (Original / Translated) — Lens-direct rendering. # Lens already chose the correct layout (rotation, line boxes) for each # item; we render every item exactly as received, one
per item. # # Vertical and horizontal source paragraphs go down SEPARATE paths: # # • horizontal paragraphs (rotation ≈ 0°) use the horizontal per-item # fit (``_shared_horizontal_font_size``) — height is the ceiling, # width is the soft constraint; # • vertical paragraphs (rotation ≈ ±90°) use the vertical fit # (``_shared_vertical_font_size``) — every glyph is square so font # size is derived from the column's actual area. # # Bubble-shared font then bucketises by (bubble_key, orientation), so a # vertical column inside a bubble shares its size with the OTHER # vertical columns in the same bubble (not with a horizontal caption # that happens to fall inside the same blob). paragraphs_in_order = [p for _, p in iter_paragraphs(tree) if _para_full_text(p)] para_vertical: dict[int, bool] = { id(p): _paragraph_source_is_vertical(p) for p in paragraphs_in_order } para_fonts: dict[int, int] = {} bucket: dict[tuple[Any, str], list[int]] = {} for para in paragraphs_in_order: is_v = para_vertical[id(para)] fs = ( _shared_vertical_font_size(para, img_w, img_h) if is_v else _shared_horizontal_font_size(para, img_w, img_h) ) if fs is None: continue para_fonts[id(para)] = fs key = (_bubble_group_key(para), "v" if is_v else "h") bucket.setdefault(key, []).append(fs) # Bubble-shared font (spec \u00a717): the representative size for a bubble is # the MEDIAN of its paragraphs' own fits, not the mean \u2014 so one tiny # ruby (furigana) paragraph cannot drag the size up or down. def _median(vals: list[int]) -> int: s = sorted(vals) return s[len(s) // 2] bubble_font: dict[tuple[Any, str], int] = { k: max(_MIN_FONT_PX, _median(v)) for k, v in bucket.items() if v } def _font_for_para(para: dict) -> int | None: is_v = para_vertical[id(para)] bk = _bubble_group_key(para) key = (bk, "v" if is_v else "h") own = para_fonts.get(id(para)) if bk is not None and key in bubble_font and own is not None: shared = bubble_font[key] # Sibling dialogue lines snap to ONE shared size for consistency # (spec \u00a717). But a paragraph whose own fit is an OUTLIER vs the # bubble median \u2014 either much smaller (ruby / furigana) or much # larger (the base kanji column when ruby dominate the median) \u2014 # keeps its OWN size. The band is relative (0.67\u20131.5\u00d7), so it is # size-driven and general, never tuned to one image. if 0.67 * shared <= own <= 1.5 * shared: return shared return own return own # Rendering is **Lens-direct**: every item keeps the rotation, # position and writing-mode Lens chose. The only change versus the # raw Lens layout is the font size — shared across every item in the # same speech bubble (spec §17), and vertical paragraphs get a # vertical-specific fit so a column of CJK glyphs is sized by its # actual area rather than by horizontal width × height. for para in paragraphs_in_order: shared_fs = _font_for_para(para) for item in para.get("items") or []: text = str(item.get("text") or "").strip() if not text: continue chunk = _render_item_horizontal(item, text, para, img_w, img_h, override_fs=shared_fs) if chunk: parts.append(_wrap_on_dark(chunk, para)) has_any = True parts.append("
") return "".join(parts) if has_any else "" def _render_item_upright_vertical( item: dict, text: str, para: dict, img_w: int, img_h: int, override_fs: int | None, ) -> str: """Render a vertical-source CJK item with *upright* characters. Used by the per-item dispatcher when the source line was written vertically (Japanese / Chinese / Korean) — those items have a Lens rotation near ±90°. CSS-rotating a horizontal text run by 89° keeps every glyph tilted sideways (unreadable), so we instead lay the item's rotated AABB and switch the inner text to ``writing-mode: vertical-rl`` + ``text-orientation: upright``. That stacks characters top-to-bottom in their natural form, matching how the source page actually reads — exactly what the user expects from the Original layer for vertical Japanese text. """ aabb = _item_rotated_aabb_px(item, img_w, img_h) if aabb is None: return "" left_px, top_px, width_px, height_px = aabb if width_px <= 0 or height_px <= 0: return "" if override_fs is not None and override_fs >= _MIN_FONT_PX: fs = int(override_fs) else: fs = fit_item_font_size_vertical(width_px, height_px, text) left_pct = left_px / max(1, img_w) * 100.0 top_pct = top_px / max(1, img_h) * 100.0 width_pct = width_px / max(1, img_w) * 100.0 height_pct = height_px / max(1, img_h) * 100.0 pi = int(para.get("para_index", 0)) ii = int(item.get("item_index", 0)) style = ( f"left:{left_pct:.4f}%;" f"top:{top_pct:.4f}%;" f"width:{width_pct:.4f}%;" f"height:{height_pct:.4f}%;" # No transform: rotate — the AABB already covers the visual # footprint, and writing-mode does the orientation. f"font-size:calc(var(--tp-font-scale,1) * {fs}px);" f"line-height:calc(var(--tp-font-scale,1) * {fs}px);" ) return ( f'
{_escape_text(text)}
' ) def _render_paragraph_vertical_position_based( para: dict, img_w: int, img_h: int, override_fs: int | None = None, ) -> str: """Render a vertical-source paragraph in Lens's per-span position style. Lens itself renders vertical Japanese text by emitting **one positioned div per word**, each at rotation 0°, with vertical reading emerging from shared ``left%`` values and increasing ``top%`` — never from CSS rotate. This function mirrors that strategy: * one ``
`` per span in each vertical item; * each span's visual rect is derived from the item's visual ``bounds_px`` and the span's ``(t0_raw, t1_raw)`` fractional offset along the reading axis; * font size is the paragraph-shared vertical fit, applied at rotation 0° so the glyphs sit upright (no CSS rotate, no writing-mode flip). Items without spans fall back to :func:`_render_item_upright_vertical`. """ parts: list[str] = [] fs = int(override_fs) if (override_fs and override_fs >= _MIN_FONT_PX) else _MIN_FONT_PX pi = int(para.get("para_index", 0)) for item in para.get("items") or []: item_text = str(item.get("text") or "").strip() if not item_text: continue bp = item.get("bounds_px") if isinstance(bp, (list, tuple)) and len(bp) == 4: ix1, iy1, ix2, iy2 = (float(v) for v in bp) iw, ih = ix2 - ix1, iy2 - iy1 else: aabb = _item_rotated_aabb_px(item, img_w, img_h) if aabb is None: continue ix1, iy1, iw, ih = aabb ix2, iy2 = ix1 + iw, iy1 + ih if iw <= 0 or ih <= 0: continue spans = item.get("spans") or [] if not spans: chunk = _render_item_upright_vertical(item, item_text, para, img_w, img_h, override_fs) if chunk: parts.append(chunk) continue ii = int(item.get("item_index", 0)) for si, span in enumerate(spans): span_text = str(span.get("text") or "").strip() if not span_text: continue t0 = float(span.get("t0_raw") or 0.0) t1 = float(span.get("t1_raw") or 1.0) span_y1 = iy1 + t0 * ih span_y2 = iy1 + t1 * ih sp_w = iw sp_h = max(span_y2 - span_y1, fs * 1.05) left_pct = ix1 / max(1, img_w) * 100.0 top_pct = span_y1 / max(1, img_h) * 100.0 width_pct = sp_w / max(1, img_w) * 100.0 height_pct = sp_h / max(1, img_h) * 100.0 style = ( f"left:{left_pct:.4f}%;" f"top:{top_pct:.4f}%;" f"width:{width_pct:.4f}%;" f"height:{height_pct:.4f}%;" f"font-size:calc(var(--tp-font-scale,1) * {fs}px);" f"line-height:calc(var(--tp-font-scale,1) * {fs}px);" ) parts.append( f'
{_escape_text(span_text)}
' ) return "".join(parts) def _render_item_horizontal( item: dict, text: str, para: dict, img_w: int, img_h: int, override_fs: int | None = None, ) -> str: """Per-item dispatcher used by every layer. - Vertical-source items (rotation near ±90°) that carry CJK text get the upright-vertical path so characters stay readable — Japanese vertical bubbles render exactly like the source page, instead of a horizontal text run rotated 89° (which leaves every glyph lying on its side). - Everything else uses the original CSS-rotate path: a horizontal text run rotated to match the item's baseline. Translated layers where Lens MT puts non-CJK text in a vertical-source box stay on this path, so the Translated overlay remains Lens-direct. ``override_fs`` is the paragraph-shared font size from :func:`_shared_horizontal_font_size`; when given, it replaces the item's own per-fit size so every line in the bubble matches. """ box = item.get("box") or {} rot = _num(box.get("rotation_deg_css"), _num(box.get("rotation_deg"))) # Vertical-source + CJK text → upright vertical-rl rendering. # Only NEAR-vertical text (|rot| ≈ 90°) is a real vertical column; a # steeply-tilted horizontal label (e.g. 68° status text on a slanted game # screen) must keep its tilt via CSS-rotate, not be snapped upright — so # the cut-off is 78°, not 60°. if abs(rot) > 78.0 and _is_cjk_dominant(text): return _render_item_upright_vertical( item, text, para, img_w, img_h, override_fs ) left_pct = _num(box.get("left_pct"), _num(box.get("left")) * 100.0) top_pct = _num(box.get("top_pct"), _num(box.get("top")) * 100.0) width_pct = _num(box.get("width_pct"), _num(box.get("width")) * 100.0) height_pct = _num(box.get("height_pct"), _num(box.get("height")) * 100.0) if width_pct <= 0 or height_pct <= 0: return "" fs = ( int(override_fs) if (override_fs is not None and override_fs >= _MIN_FONT_PX) else _font_size_for_item(item, img_w, img_h) ) lh = int(round(fs * 1.05)) pi = int(para.get("para_index", 0)) ii = int(item.get("item_index", 0)) # The font-size and line-height are wrapped in calc(var(--tp-font-scale, # 1) * Npx) so the extension's +/- buttons can scale every line by # flipping a single CSS variable on .tp-ol-scope. When the variable is # absent (or = 1) the size is exactly what the API picked. style = ( f"left:{left_pct:.4f}%;" f"top:{top_pct:.4f}%;" f"width:{width_pct:.4f}%;" f"height:{height_pct:.4f}%;" f"transform:rotate({rot:.4f}deg);" f"font-size:calc(var(--tp-font-scale,1) * {fs}px);" f"line-height:calc(var(--tp-font-scale,1) * {lh}px);" ) cls = "tp-line rtl" if contains_rtl(text) else "tp-line" return ( f'
{_escape_text(text)}
' ) def _para_full_text(para: dict) -> str: """Reassemble a paragraph's combined text from its items. Prefers the precomputed ``para.text`` (set by ``decode_tree`` / patch); falls back to joining item texts when that field is empty. Whitespace is trimmed at the edges but kept between items so words from adjacent horizontal lines don't merge. """ text = str(para.get("text") or "").strip() if text: return text items = para.get("items") or [] return "".join(str(it.get("text") or "") for it in items).strip() def _para_bounds_px( para: dict, img_w: int, img_h: int ) -> tuple[float, float, float, float] | None: """The bubble's union bounding box in image pixels. Resolution order: 1. ``para.bubble_bounds_px`` — the *real* speech-bubble outline detected by :mod:`backend.render.bubble` (YOLO segmentation when enabled, OpenCV connected components otherwise). This is what the renderer wants whenever it's available, because it covers the whole bubble canvas rather than just the text region inside it. 2. ``para.bounds_px`` — the union of every span's rotated quad, set by :func:`backend.lens.tree.decode_tree`. Tight to the text only. 3. Compute from the items' rotated AABBs (last-resort recovery). Returns ``(left, top, width, height)`` or ``None`` when no signal is available. """ bbp = para.get("bubble_bounds_px") if isinstance(bbp, (list, tuple)) and len(bbp) == 4: l, t, r, b = (_num(x) for x in bbp) if r > l and b > t: return (l, t, r - l, b - t) bp = para.get("bounds_px") if isinstance(bp, (list, tuple)) and len(bp) == 4: l, t, r, b = (_num(x) for x in bp) if r > l and b > t: return (l, t, r - l, b - t) lefts: list[float] = [] tops: list[float] = [] rights: list[float] = [] bottoms: list[float] = [] for it in para.get("items") or []: aabb = _item_rotated_aabb_px(it, img_w, img_h) if aabb is None: continue l, t, w, h = aabb lefts.append(l) tops.append(t) rights.append(l + w) bottoms.append(t + h) if not lefts: return None left = min(lefts) top = min(tops) width = max(rights) - left height = max(bottoms) - top if width <= 0 or height <= 0: return None return (left, top, width, height) def _paragraph_source_is_vertical(para: dict) -> bool: """True when most of the paragraph's items have a vertical baseline. Lens decodes each line of vertical Japanese / Chinese text as an item whose ``rotation_deg`` is near ±90°. When we translate such a bubble into a *horizontal* target (Thai / Latin / Cyrillic / …), rendering per-item with the same rotation would leave the translation lying sideways like the source — unreadable. This predicate gates the "bubble-block" path that re-orients the AI output into the bubble's axis-aligned footprint. A paragraph is considered vertical when at least half of its text-bearing items have ``|rotation_deg| > 78°`` (a true vertical column); steeply-tilted horizontal labels (≈ 60–78°) stay horizontal and keep their tilt. """ items = para.get("items") or [] if not items: return False n_total = n_vert = 0 for it in items: text = str(it.get("text") or "").strip() if not text: continue n_total += 1 rot = _num((it.get("box") or {}).get("rotation_deg"), 0.0) if abs(rot) > 78.0: n_vert += 1 return n_total > 0 and (n_vert / n_total) >= 0.5 def _render_paragraph_horizontal_block( para: dict, para_text: str, img_w: int, img_h: int ) -> str: """One horizontal ``
`` covering the whole bubble. Used only when the source items were vertical and the AI target is a horizontal script. The paragraph's union ``bounds_px`` (already rotated-quad-aware in :func:`backend.lens.tree.decode_tree`) is the canvas; CSS lets the text wrap naturally inside it (``white-space: normal; word-break: break-word``). No CSS rotation is applied — characters stay upright while the source bubble's portrait AABB hosts the wrapped translation. """ bounds = _para_bounds_px(para, img_w, img_h) if bounds is None: return "" left_px, top_px, width_px, height_px = bounds if width_px <= 0 or height_px <= 0: return "" fs = fit_paragraph_font_size_horizontal(width_px, height_px, para_text) left_pct = left_px / max(1, img_w) * 100.0 top_pct = top_px / max(1, img_h) * 100.0 width_pct = width_px / max(1, img_w) * 100.0 height_pct = height_px / max(1, img_h) * 100.0 pi = int(para.get("para_index", 0)) style = ( f"left:{left_pct:.4f}%;" f"top:{top_pct:.4f}%;" f"width:{width_pct:.4f}%;" f"height:{height_pct:.4f}%;" # No transform: rotate — text reads horizontally inside the AABB. f"font-size:calc(var(--tp-font-scale,1) * {fs}px);" f"line-height:calc(var(--tp-font-scale,1) * {int(round(fs * 1.15))}px);" ) return ( f'
{_escape_text(para_text)}
' ) def _render_paragraph_vertical( para: dict, para_text: str, img_w: int, img_h: int ) -> str: """One vertical ``
`` covering the whole bubble. Uses ``para.bounds_px`` (the union of every item's footprint in the paragraph) as the canvas, so all the lines from a multi-line source bubble collapse into a single vertical text block — matching how manga-image-translator renders a translated bubble. No CSS rotation is applied; ``writing-mode: vertical-rl`` + ``text-orientation: upright`` keep glyphs standing top-to-bottom in columns that flow right-to-left. """ bounds = _para_bounds_px(para, img_w, img_h) if bounds is None: return "" left_px, top_px, width_px, height_px = bounds if width_px <= 0 or height_px <= 0: return "" fs = fit_item_font_size_vertical(width_px, height_px, para_text) left_pct = left_px / max(1, img_w) * 100.0 top_pct = top_px / max(1, img_h) * 100.0 width_pct = width_px / max(1, img_w) * 100.0 height_pct = height_px / max(1, img_h) * 100.0 pi = int(para.get("para_index", 0)) style = ( f"left:{left_pct:.4f}%;" f"top:{top_pct:.4f}%;" f"width:{width_pct:.4f}%;" f"height:{height_pct:.4f}%;" # No transform: rotate — the bubble's AABB is axis-aligned and the # CSS class flips writing-mode so columns stack right-to-left # with upright characters. font-size goes through the same # --tp-font-scale CSS variable that horizontal items use, so the # extension's +/- buttons scale both layouts together. f"font-size:calc(var(--tp-font-scale,1) * {fs}px);" f"line-height:calc(var(--tp-font-scale,1) * {fs}px);" ) return ( f'
{_escape_text(para_text)}
' ) # --- Compatibility shims -------------------------------------------------- # Older callers may still import these names; route them all into the # unified renderer / single CSS payload. def ai_tree_to_tp_html( tree: dict | None, img_w: int, img_h: int, _thai_font: str = "", _latin_font: str = "", ) -> str: """Shim: AI layer uses the unified renderer.""" return render_tree_overlay(tree, img_w, img_h) def lens_tree_to_lens_html(tree: dict | None) -> str: """Shim: Original/Translated also share the unified renderer.""" return render_tree_overlay(tree, 1000, 1000) def tp_overlay_css() -> str: """Shim: single CSS payload.""" return overlay_css() def lens_overlay_css(*_args: Any, **_kwargs: Any) -> str: """Shim: single CSS payload.""" return overlay_css() def fit_tree_font_sizes( tree: dict | None, _thai_path: str, _latin_path: str, img_w: int, img_h: int, ) -> dict[int, int]: """Attach a starting font_size_px to every item using the heuristic. Useful when callers want a paragraph-size map; the renderer also picks sizes itself if missing. """ if not isinstance(tree, dict): return {} sizes: dict[int, int] = {} for _, p in iter_paragraphs(tree): per_item: list[int] = [] for it in p.get("items") or []: text = str(it.get("text") or "").strip() if not text: continue box = it.get("box") or {} width_pct = _num(box.get("width_pct"), _num(box.get("width")) * 100.0) height_pct = _num(box.get("height_pct"), _num(box.get("height")) * 100.0) fs = fit_item_font_size(width_pct, height_pct, text, img_w, img_h) it["font_size_px"] = int(fs) per_item.append(int(fs)) if per_item: shared = sorted(per_item)[len(per_item) // 2] p["para_font_size_px"] = int(shared) sizes[int(p.get("para_index", 0))] = int(shared) return sizes def apply_para_font_size( _tree: dict | None, _para_sizes: dict[int, int], ) -> None: """No-op: kept for backward compatibility.""" return None def compute_shared_para_sizes( _tree: dict | None, _thai_path: str, _latin_path: str, _img_w: int, _img_h: int, ) -> dict[int, int]: """No-op: returns an empty map; renderer picks sizes.""" return {}