Spaces:
Running
Running
| """Text layout: tokenising, line-wrapping, font-fitting and span placement. | |
| The renderer's job is to pour translated text back into the *boxes* of the | |
| original OCR items. This module owns that pour: | |
| - :func:`tokens_with_spaces` — split text into word / space tokens, with a | |
| per-language strategy: Latin keeps real whitespace words, Thai uses BudouX | |
| phrase-level chunks, and JA/ZH/KO break at every character so scriptio- | |
| continua scripts always have a break point available. | |
| - :func:`wrap_tokens_to_lines` — greedily wrap tokens into per-item lines, | |
| respecting each item's pixel width "cap". | |
| - :func:`fit_para_size_and_lines` — shrink the font until every line fits its | |
| item's height. | |
| - :func:`distribute_to_template` — mirror Lens's own per-item word | |
| distribution by pouring AI text into the template proportionally. | |
| - :func:`apply_line_to_item` — given a line of tokens, write ``spans`` (with | |
| pixel boxes) back onto an item. | |
| A *line token* is a ``(kind, text, width_px)`` tuple where ``kind`` is | |
| ``"word"`` or ``"space"``. | |
| The per-language token strategy here is inspired by manga-image-translator's | |
| ``calc_horizontal`` (see ``manga_translator/rendering/text_render.py``): for | |
| languages with a hyphenator (Latin) we keep whole words and only split when | |
| they overflow; for languages without one (JA/ZH/KO) we pre-split into single | |
| characters so the wrapper always has a legal break point. Thai sits in | |
| between — BudouX gives us phrase-level chunks that are far better than naive | |
| character splits, but the chunks are short enough that wrapping never gets | |
| stuck. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| from typing import Any, Final | |
| from PIL import Image, ImageDraw | |
| from backend.render.fonts import is_truetype, pick_font | |
| from backend.render.geometry import ensure_box_fields | |
| from backend.render.text_metrics import baseline_offset_px, line_metrics_px | |
| from backend.render.text_utils import contains_thai, sanitize_draw_text | |
| from backend.utils.text import ZWSP | |
| # A reusable scratch draw context for width measurement. | |
| _SCRATCH = ImageDraw.Draw(Image.new("RGBA", (10, 10), (0, 0, 0, 0))) | |
| # Token tuples. | |
| RawToken = tuple[str, str] # (kind, text) | |
| LineToken = tuple[str, str, float] # (kind, text, width_px) | |
| # Languages whose orthography has no inter-word spaces. When the target | |
| # language belongs to this set we strip stray spaces that sit between two | |
| # adjacent same-script characters — this happens often in AI output ("ไม่ มี" | |
| # instead of "ไม่มี") and Lens HTML never has them. Spaces touching Latin | |
| # letters or digits are preserved (e.g. "อันดับ 10"). | |
| _NO_SPACE_SCRIPTS = frozenset( | |
| {"th", "ja", "ko", "zh", "zh-cn", "zh-tw", "zh-hans", "zh-hant"} | |
| ) | |
| # Unicode ranges covered by ``_NO_SPACE_SCRIPTS``: | |
| # - Thai (0E00-0E7F) | |
| # - CJK Unified Ideographs (4E00-9FFF) + CJK Symbols and Punctuation (3000-303F) | |
| # - Hiragana (3040-309F), Katakana (30A0-30FF) | |
| # - Hangul Syllables (AC00-D7A3) | |
| _NO_SPACE_CHARS = ( | |
| r"- -〿-ゟ゠-ヿ一-鿿가-힣" | |
| ) | |
| _INTRA_SCRIPT_SPACE_RE = re.compile( | |
| rf"([{_NO_SPACE_CHARS}])\s+(?=[{_NO_SPACE_CHARS}])" | |
| ) | |
| # Latin / European scripts whose orthography uses inter-word spaces. When | |
| # the target is one of these we keep BudouX out of the picture entirely and | |
| # treat each whitespace-delimited run as a single "word" token — exactly how | |
| # manga-image-translator's ``calc_horizontal`` handles ``language='en_US'``. | |
| _LATIN_LANGS = frozenset( | |
| { | |
| "en", "id", "ms", "vi", "tl", "fil", | |
| "es", "pt", "pt-br", "pt-pt", | |
| "fr", "de", "it", "nl", "sv", "no", "nb", "da", "fi", "is", | |
| "pl", "cs", "sk", "hu", "ro", "tr", | |
| } | |
| ) | |
| # Scriptio-continua scripts that benefit from single-character tokens. We | |
| # already collapse intra-script whitespace via :data:`_INTRA_SCRIPT_SPACE_RE`; | |
| # in addition, when no BudouX parser is available (or for ZH/KO where BudouX | |
| # doesn't help much) we hand the wrapper one token per character so a line | |
| # break can always land exactly where it has to. | |
| _CHAR_LEVEL_LANGS = frozenset( | |
| {"ja", "ko", "zh", "zh-cn", "zh-tw", "zh-hans", "zh-hant"} | |
| ) | |
| # Half-width small kana (Japanese): visually narrower than the average | |
| # character, so when we compute "how much text mass" an item should hold we | |
| # count them as half a unit. This mirrors manga-image-translator's | |
| # ``count_text_length`` (``rendering/__init__.py``) and keeps the AI | |
| # distribution from over-weighting items whose template text is mostly small | |
| # kana — common in Japanese onomatopoeia. | |
| _HALF_WIDTH_CHARS: Final[frozenset[str]] = frozenset("っッぁぃぅぇぉゃゅょャュョ") | |
| def count_text_length(text: str) -> float: | |
| """Visual text length, with half-width chars counted as 0.5. | |
| Used by :func:`_item_weight` so the AI distribution mirrors Lens's split | |
| proportionally to *visible* text mass rather than raw codepoint count. | |
| """ | |
| if not text: | |
| return 0.0 | |
| total = 0.0 | |
| for ch in text: | |
| if ch.isspace(): | |
| continue | |
| total += 0.5 if ch in _HALF_WIDTH_CHARS else 1.0 | |
| return total | |
| def font_size_minimum_for_image(img_w: int, img_h: int) -> int: | |
| """Readability floor for the AI fit pass. | |
| manga-image-translator uses ``(img.shape[0] + img.shape[1]) / 200`` as its | |
| minimum font size when no explicit fixed size is given. That heuristic | |
| assumes a page-shaped image; on a long manhwa/webtoon STRIP (e.g. | |
| 800 x 12000 px) the height term explodes the floor to 60+ px and every AI | |
| paragraph renders huge. Glyph size should follow the *reading* dimension | |
| (the width), so the longer side's contribution is capped at 2x the shorter | |
| side — identical to the original behaviour for normal page shapes, sane | |
| for strips. The minimum is 8px so tiny thumbnails still produce legible | |
| spans. | |
| """ | |
| w = max(0, int(img_w)) | |
| h = max(0, int(img_h)) | |
| short, long_ = (w, h) if w <= h else (h, w) | |
| side_sum = short + min(long_, 2 * short) | |
| return max(8, int(round(side_sum / 200.0))) | |
| def _normalise_lang(lang: str) -> str: | |
| """Lowercase + ``_``→``-`` so language comparisons are robust.""" | |
| return (lang or "").strip().lower().replace("_", "-") | |
| def collapse_intra_script_spaces(text: str, lang: str) -> str: | |
| """Remove spaces between adjacent CJK / Thai characters. | |
| Thai, Japanese, Chinese and Korean don't put spaces between words. AI | |
| output (and sometimes the source itself) sneaks them in — left alone they | |
| show up as visible gaps once each token becomes its own ``.tp-span`` div. | |
| Spaces between *different* scripts (Thai + Latin, CJK + digits, …) are | |
| kept so things like "อันดับ 10" still render correctly. | |
| """ | |
| if not text: | |
| return "" | |
| if _normalise_lang(lang) not in _NO_SPACE_SCRIPTS: | |
| return text | |
| # The regex eats *only* the whitespace between two same-script chars, in | |
| # one left-to-right sweep that handles arbitrarily long Thai/CJK runs. | |
| return _INTRA_SCRIPT_SPACE_RE.sub(r"\1", text) | |
| def _split_word_for_lang(word: str, parser, code: str) -> list[str]: | |
| """Per-language strategy that turns a single non-whitespace run into one | |
| or more "word" segments. | |
| - Latin / European: return the whole run as a single segment — the natural | |
| break points already came from whitespace. | |
| - Thai: hand the run to BudouX when available, falling back to whole-run. | |
| - JA/ZH/KO (scriptio continua): break at every character so the wrapper | |
| always has somewhere to break, matching what | |
| ``manga-image-translator``'s ``calc_horizontal`` does when no hyphenator | |
| exists for the language. | |
| - Anything else: BudouX if available, else whole-run. | |
| """ | |
| if not word: | |
| return [] | |
| if code in _LATIN_LANGS: | |
| return [word] | |
| if code in _CHAR_LEVEL_LANGS: | |
| # One char per token; punctuation stays attached to the previous | |
| # character so it never starts a new visual line. | |
| out: list[str] = [] | |
| for ch in word: | |
| if out and not ch.isalnum() and ord(ch) < 0x3000: | |
| # ASCII / Latin-1 punctuation glues to the previous segment. | |
| out[-1] = out[-1] + ch | |
| else: | |
| out.append(ch) | |
| return out | |
| if parser is not None: | |
| try: | |
| segs = [seg for seg in parser.parse(word) if seg] | |
| return segs or [word] | |
| except Exception: | |
| return [word] | |
| return [word] | |
| def tokens_with_spaces(text: str, parser, lang: str) -> list[RawToken]: | |
| """Split ``text`` into ``(kind, text)`` tokens. | |
| Whitespace runs become ``("space", ...)`` tokens; everything else is sent | |
| through :func:`_split_word_for_lang`, which picks a per-language strategy: | |
| Latin keeps whole words, JA/ZH/KO splits per character, Thai uses BudouX. | |
| For no-space scripts the input is normalised first via | |
| :func:`collapse_intra_script_spaces` so a stray space between two Thai or | |
| CJK characters doesn't sneak through as a token. | |
| """ | |
| t = collapse_intra_script_spaces(text or "", lang) | |
| if not t: | |
| return [] | |
| code = _normalise_lang(lang) | |
| out: list[RawToken] = [] | |
| for part in re.findall(r"\s+|\S+", t): | |
| if not part: | |
| continue | |
| if part.isspace(): | |
| out.append(("space", part)) | |
| continue | |
| out.extend(("word", seg) for seg in _split_word_for_lang(part, parser, code)) | |
| return out | |
| def _measure_width(font, text: str) -> float: | |
| """Pixel advance width of ``text`` in ``font`` (robust to old Pillow).""" | |
| try: | |
| return float(font.getlength(text)) | |
| except Exception: | |
| try: | |
| bb = _SCRATCH.textbbox((0, 0), text, font=font, anchor="ls") | |
| return float(bb[2] - bb[0]) | |
| except Exception: | |
| w, _ = _SCRATCH.textsize(text, font=font) # type: ignore[attr-defined] | |
| return float(w) | |
| def _line_cap_px(item: dict, img_w: int, img_h: int) -> float: | |
| """Maximum line width (px) for an item — its baseline length, or box width.""" | |
| p1 = item.get("baseline_p1") or {} | |
| p2 = item.get("baseline_p2") or {} | |
| dx = (float(p2.get("x") or 0.0) - float(p1.get("x") or 0.0)) * img_w | |
| dy = (float(p2.get("y") or 0.0) - float(p1.get("y") or 0.0)) * img_h | |
| cap = math.hypot(dx, dy) | |
| if cap > 1e-6: | |
| return cap | |
| box = ensure_box_fields(item.get("box") or {}) | |
| return float(box.get("width") or 0.0) * img_w | |
| def wrap_tokens_to_lines( | |
| tokens: list[RawToken], | |
| items: list[dict], | |
| img_w: int, | |
| img_h: int, | |
| thai_font: str, | |
| latin_font: str, | |
| font_size: int, | |
| min_lines: int, | |
| ) -> list[list[LineToken]]: | |
| """Greedily wrap ``tokens`` into at most ``len(items)`` lines. | |
| Each line's width budget is the matching item's :func:`_line_cap_px`. A | |
| "soft cap" (90%) is applied to early lines when more than one line is | |
| desired, so text distributes more evenly instead of cramming line one. | |
| Leading/trailing space tokens are trimmed from every line. | |
| """ | |
| max_lines = len(items) | |
| if max_lines <= 0: | |
| return [] | |
| caps = [_line_cap_px(it, img_w, img_h) for it in items] | |
| desired = max(1, min(int(min_lines), max_lines)) | |
| soft_factor = 0.90 if desired > 1 else 1.0 | |
| lines: list[list[LineToken]] = [[]] | |
| cur_w = 0.0 | |
| li = 0 | |
| last_word_hint = "" | |
| pending_space = "" | |
| def cap_for_line(idx: int) -> float: | |
| return float(caps[min(idx, max_lines - 1)]) | |
| for kind, s in tokens or []: | |
| if kind == "space": | |
| if lines[-1]: # ignore leading spaces | |
| pending_space += str(s) | |
| continue | |
| if kind != "word": | |
| continue | |
| txt = str(s) | |
| if not txt: | |
| continue | |
| word_w = _measure_width(pick_font(txt, thai_font, latin_font, int(font_size)), txt) | |
| space_w = 0.0 | |
| if pending_space: | |
| hint = last_word_hint or txt | |
| space_w = _measure_width( | |
| pick_font(hint, thai_font, latin_font, int(font_size)), pending_space | |
| ) | |
| cap = cap_for_line(li) | |
| soft_cap = cap * soft_factor if (li < desired and cap > 0.0) else cap | |
| need_w = cur_w + space_w + word_w | |
| # Break to the next line if this word overflows the (soft) cap. | |
| if lines[-1] and li < max_lines - 1: | |
| if (cap > 0.0 and need_w > cap) or (soft_cap > 0.0 and need_w > soft_cap): | |
| lines.append([]) | |
| li += 1 | |
| cur_w = 0.0 | |
| pending_space = "" | |
| space_w = 0.0 | |
| if pending_space and lines[-1]: | |
| lines[-1].append(("space", pending_space, space_w)) | |
| cur_w += space_w | |
| pending_space = "" | |
| lines[-1].append(("word", txt, word_w)) | |
| cur_w += word_w | |
| last_word_hint = txt | |
| # Overflow lines get merged into the last allowed line. | |
| if len(lines) > max_lines: | |
| head = lines[: max_lines - 1] | |
| tail: list[LineToken] = [] | |
| for seg in lines[max_lines - 1 :]: | |
| tail.extend(seg) | |
| lines = head + [tail] | |
| # Trim leading/trailing spaces per line. | |
| for i, line in enumerate(lines): | |
| while line and line[0][0] == "space": | |
| line = line[1:] | |
| while line and line[-1][0] == "space": | |
| line = line[:-1] | |
| lines[i] = line | |
| return lines | |
| def ensure_min_lines_by_split( | |
| lines: list[list[LineToken]], min_lines: int, max_lines: int | |
| ) -> list[list[LineToken]]: | |
| """Split the wordiest lines until ``min_lines`` (capped at ``max_lines``).""" | |
| if not lines: | |
| return [] | |
| min_lines = int(min_lines) | |
| max_lines = int(max_lines) | |
| if min_lines <= 1: | |
| return lines | |
| target = min(min_lines, max_lines) | |
| lines = [list(seg) for seg in lines] | |
| def trim(seg: list[LineToken]) -> list[LineToken]: | |
| while seg and seg[0][0] == "space": | |
| seg.pop(0) | |
| while seg and seg[-1][0] == "space": | |
| seg.pop() | |
| return seg | |
| while len(lines) < target: | |
| # Find the line with the most splittable words. | |
| idx = None | |
| best = 0 | |
| for i, seg in enumerate(lines): | |
| n_words = sum(1 for k, s, _ in seg if k == "word" and s != ZWSP) | |
| if n_words > best and n_words > 1: | |
| best = n_words | |
| idx = i | |
| if idx is None: | |
| break | |
| seg = lines[idx] | |
| word_positions = [i for i, (k, s, _) in enumerate(seg) if k == "word" and s != ZWSP] | |
| if len(word_positions) <= 1: | |
| break | |
| cut_pos = word_positions[len(word_positions) // 2] | |
| lines[idx] = trim(seg[:cut_pos]) | |
| lines.insert(idx + 1, trim(seg[cut_pos:])) | |
| if len(lines) >= max_lines: | |
| break | |
| return lines | |
| def fit_para_size_and_lines( | |
| ptext: str, | |
| parser, | |
| items: list[dict], | |
| img_w: int, | |
| img_h: int, | |
| thai_font: str, | |
| latin_font: str, | |
| base_size: int, | |
| min_lines: int, | |
| lang: str, | |
| ) -> tuple[int, list[list[LineToken]]]: | |
| """Find the largest font size at which ``ptext`` fits the item boxes. | |
| Returns ``(font_size, wrapped_lines)``. Tries ``base_size`` down to 10, | |
| accepting the first size where every line's measured height fits its | |
| item's box height. Falls back to size 10 if nothing fits. | |
| """ | |
| tokens = tokens_with_spaces(ptext, parser, lang) | |
| if not tokens or not items: | |
| return int(base_size), [[] for _ in items] | |
| max_lines = len(items) | |
| n_words = sum(1 for k, s in tokens if k == "word" and str(s)) | |
| desired_lines = max(1, min(max_lines, n_words)) | |
| heights = [ | |
| float(ensure_box_fields(it.get("box") or {}).get("height") or 0.0) * img_h | |
| for it in items | |
| ] | |
| size = max(10, int(base_size)) | |
| while size >= 10: | |
| lines = wrap_tokens_to_lines( | |
| tokens, items, img_w, img_h, thai_font, latin_font, size, min_lines=desired_lines | |
| ) | |
| lines = ensure_min_lines_by_split(lines, desired_lines, max_lines) | |
| if len(lines) <= max_lines: | |
| fits = True | |
| for ii, seg in enumerate(lines): | |
| words = [s for k, s, _ in seg if k == "word" and s != ZWSP] | |
| if not words: | |
| continue | |
| metrics = line_metrics_px("".join(words), thai_font, latin_font, size) | |
| if metrics is None: | |
| continue | |
| _w, line_h, _c = metrics | |
| if ii < len(heights) and heights[ii] > 0.0 and line_h > heights[ii] * 1.01: | |
| fits = False | |
| break | |
| if fits: | |
| return size, lines | |
| size -= 1 | |
| lines10 = wrap_tokens_to_lines( | |
| tokens, items, img_w, img_h, thai_font, latin_font, 10, min_lines=desired_lines | |
| ) | |
| lines10 = ensure_min_lines_by_split(lines10, desired_lines, max_lines) | |
| return 10, lines10 | |
| def pad_lines(lines: list[list[LineToken]], max_lines: int) -> list[list[LineToken]]: | |
| """Truncate or pad ``lines`` to exactly ``max_lines`` entries.""" | |
| max_lines = int(max_lines) | |
| if max_lines <= 0: | |
| return [] | |
| lines = list(lines or []) | |
| if len(lines) > max_lines: | |
| return lines[:max_lines] | |
| if len(lines) < max_lines: | |
| lines.extend([[] for _ in range(max_lines - len(lines))]) | |
| return lines | |
| # --- Template-mirrored distribution --------------------------------------- | |
| # Instead of greedily wrapping AI text to *fit* the item boxes (which often | |
| # crams one line and empties another), we mirror how Lens itself split the | |
| # paragraph: each template item already carries a slice of the Lens | |
| # translation, and the length of that slice tells us the share of text that | |
| # line should hold. Distributing the AI text by those same proportions | |
| # reproduces Lens's line breaks — which were chosen to suit the speech bubble. | |
| def _item_weight(item: dict, img_w: int, img_h: int) -> float: | |
| """How much of the paragraph this template item should hold. | |
| Primary signal: the visual text length of the item's own (Lens) text — | |
| small kana count as 0.5 (see :func:`count_text_length`), so an item full | |
| of Japanese sokuon doesn't disproportionately attract AI characters. | |
| Fallback when the item has no text: its baseline length in pixels. | |
| """ | |
| text = str(item.get("text") or "").strip() | |
| if text: | |
| return max(1.0, count_text_length(text)) | |
| cap = _line_cap_px(item, img_w, img_h) | |
| return cap if cap > 1e-6 else 1.0 | |
| def distribute_to_template( | |
| para_text: str, | |
| template_items: list[dict], | |
| parser, | |
| lang: str, | |
| img_w: int, | |
| img_h: int, | |
| ) -> list[list[LineToken]]: | |
| """Split ``para_text`` into one line per template item, mirroring how Lens | |
| distributed the same paragraph across those items. | |
| Returns exactly ``len(template_items)`` token lines. Words are never split; | |
| the cut between two items happens at the word boundary closest to the | |
| target character offset (weighted by :func:`_item_weight`). | |
| """ | |
| n = len(template_items) | |
| if n == 0: | |
| return [] | |
| tokens = tokens_with_spaces(para_text, parser, lang) | |
| words = [(k, s) for k, s in tokens] # keep spaces for natural rendering | |
| if n == 1: | |
| return [[(k, s, 0.0) for k, s in words]] | |
| # Per-item targets as a cumulative fraction of the total word-character | |
| # mass. Both sides of the ratio use :func:`count_text_length` so a | |
| # template item full of small kana isn't asked to absorb a disproportionate | |
| # share of the AI text. | |
| weights = [_item_weight(it, img_w, img_h) for it in template_items] | |
| total_w = sum(weights) or 1.0 | |
| total_mass = sum(count_text_length(s) for k, s in words if k == "word") or 1.0 | |
| cumulative = 0.0 | |
| targets: list[float] = [] | |
| for w in weights: | |
| cumulative += w | |
| targets.append(total_mass * cumulative / total_w) | |
| lines: list[list[LineToken]] = [[] for _ in range(n)] | |
| li = 0 | |
| mass_so_far = 0.0 | |
| for kind, s in words: | |
| if kind == "space": | |
| if lines[li]: # never start a line with a space | |
| lines[li].append((kind, s, 0.0)) | |
| continue | |
| # Move to the next item once we've passed this item's target — using | |
| # the word's *midpoint* so a word straddling the boundary lands on | |
| # whichever side it is mostly on. | |
| w_mass = count_text_length(s) | |
| if li < n - 1 and lines[li] and (mass_so_far + w_mass / 2.0) > targets[li]: | |
| li += 1 | |
| while lines[li - 1] and lines[li - 1][-1][0] == "space": | |
| lines[li - 1].pop() | |
| lines[li].append((kind, s, 0.0)) | |
| mass_so_far += w_mass | |
| # Trim leading/trailing spaces from every line. | |
| for line in lines: | |
| while line and line[0][0] == "space": | |
| line.pop(0) | |
| while line and line[-1][0] == "space": | |
| line.pop() | |
| return lines | |
| def fit_font_size_for_lines( | |
| lines: list[list[LineToken]], | |
| items: list[dict], | |
| img_w: int, | |
| img_h: int, | |
| thai_font: str, | |
| latin_font: str, | |
| base_size: int, | |
| lang: str, | |
| min_size_px: int | None = None, | |
| ) -> int: | |
| """Largest font size (>=floor) at which every fixed line fits its item height. | |
| The line distribution is already decided (see :func:`distribute_to_template`) | |
| so this only searches for a size — it never re-wraps. Per-item width fitting | |
| is handled later by ``backend.render.tp_html.fit_tree_font_sizes``. | |
| ``min_size_px`` is the readability floor; defaults to | |
| :func:`font_size_minimum_for_image` for the supplied ``img_w``/``img_h``. | |
| The smallest value ever returned is 8 so tiny thumbnails still produce | |
| legible spans even when the box height is microscopic. | |
| """ | |
| heights = [ | |
| float(ensure_box_fields(it.get("box") or {}).get("height") or 0.0) * img_h | |
| for it in items | |
| ] | |
| floor = int(min_size_px) if min_size_px is not None else font_size_minimum_for_image(img_w, img_h) | |
| floor = max(8, floor) | |
| size = max(floor, int(base_size)) | |
| while size >= floor: | |
| fits = True | |
| for ii, seg in enumerate(lines): | |
| words = [s for k, s, _ in seg if k == "word" and s != ZWSP] | |
| if not words: | |
| continue | |
| metrics = line_metrics_px("".join(words), thai_font, latin_font, size) | |
| if metrics is None: | |
| continue | |
| _w, line_h, _c = metrics | |
| if ii < len(heights) and heights[ii] > 0.0 and line_h > heights[ii] * 1.01: | |
| fits = False | |
| break | |
| if fits: | |
| return size | |
| size -= 1 | |
| return floor | |
| def apply_line_to_item( | |
| item: dict, | |
| line_tokens: list[LineToken], | |
| para_index: int, | |
| item_index: int, | |
| abs_line_start_raw: int, | |
| W: int, | |
| H: int, | |
| thai_path: str, | |
| latin_path: str, | |
| forced_size_px: int | None, | |
| apply_baseline_shift: bool = True, | |
| kerning_adjust: bool = False, | |
| ) -> None: | |
| """Write ``spans`` (and updated ``box`` / ``font_size_px``) onto ``item``. | |
| Places each token along the item's baseline, scaling the font so the line | |
| fits the box. When ``forced_size_px`` is given the font size is fixed and | |
| only horizontal scaling is applied. Mutates ``item`` in place. | |
| """ | |
| # Normalise raw tuples to (kind, text, width) triples. | |
| tokens: list[LineToken] = [] | |
| for t in line_tokens or []: | |
| if not isinstance(t, (list, tuple)) or len(t) < 2: | |
| continue | |
| w = float(t[2]) if len(t) > 2 and isinstance(t[2], (int, float)) else 0.0 | |
| tokens.append((str(t[0]), str(t[1]), w)) | |
| words = [s for k, s, _ in tokens if k == "word" and s != ZWSP] | |
| item_text = "".join(s for _, s, _ in tokens if s != ZWSP).strip() | |
| item["text"] = item_text | |
| item["valid_text"] = bool(item_text) | |
| box = ensure_box_fields(item.get("box") or {}) | |
| item["box"] = box | |
| base_left = float(box.get("left") or 0.0) | |
| base_top = float(box.get("top") or 0.0) | |
| base_w = float(box.get("width") or 0.0) | |
| base_h = float(box.get("height") or 0.0) | |
| if not words or base_w <= 0.0 or base_h <= 0.0 or W <= 0 or H <= 0: | |
| item["spans"] = [] | |
| return | |
| # Baseline endpoints in pixels. | |
| p1 = item.get("baseline_p1") or {} | |
| p2 = item.get("baseline_p2") or {} | |
| x1 = float(p1.get("x") or 0.0) * W | |
| y1 = float(p1.get("y") or 0.0) * H | |
| x2 = float(p2.get("x") or 0.0) * W | |
| y2 = float(p2.get("y") or 0.0) * H | |
| dx, dy = x2 - x1, y2 - y1 | |
| length = math.hypot(dx, dy) | |
| if length <= 1e-9: | |
| item["spans"] = [] | |
| return | |
| ux, uy = dx / length, dy / length | |
| nx, ny = -uy, ux | |
| if ny < 0: | |
| nx, ny = -nx, -ny | |
| base_w_px = length | |
| base_h_px = base_h * H | |
| base_size = 96 # measure at a fixed reference size, then scale | |
| # If a real TTF/OTF is unavailable Pillow falls back to a bitmap default | |
| # whose ``textbbox`` ignores the requested size — every measurement | |
| # becomes the same tiny number, which makes ``scale_line`` (and the | |
| # downstream fit-size) explode. Detect that here and use a height-based | |
| # heuristic + equal proportional shares per word: visually similar to | |
| # what Lens emits, and recoverable once the font finally loads. | |
| if not is_truetype(pick_font(item_text or "a", thai_path, latin_path, base_size)): | |
| final_size = int(forced_size_px) if forced_size_px else max(10, int(base_h_px * 0.85)) | |
| item["font_size_px"] = final_size | |
| word_count = sum(1 for k, _s, _w in tokens if k == "word") | |
| if word_count <= 0: | |
| item["spans"] = [] | |
| return | |
| equal_share = 1.0 / word_count | |
| spans: list[dict[str, Any]] = [] | |
| raw_pos = 0 | |
| span_i = 0 | |
| cum_t = 0.0 | |
| for kind, s, _ in tokens: | |
| if s == ZWSP: | |
| continue | |
| start_raw = abs_line_start_raw + raw_pos | |
| raw_pos += len(s) | |
| end_raw = abs_line_start_raw + raw_pos | |
| if kind != "word": | |
| continue | |
| t0 = cum_t | |
| cum_t += equal_share | |
| t1 = cum_t | |
| seg_start_px = t0 * base_w_px | |
| seg_end_px = t1 * base_w_px | |
| e1x = x1 + ux * seg_start_px | |
| e1y = y1 + uy * seg_start_px | |
| e2x = x1 + ux * seg_end_px | |
| e2y = y1 + uy * seg_end_px | |
| span_cx = (e1x + e2x) / 2.0 | |
| span_cy = (e1y + e2y) / 2.0 | |
| span_w_px = (t1 - t0) * base_w_px | |
| span_box = ensure_box_fields( | |
| { | |
| "left": (span_cx - span_w_px / 2.0) / W, | |
| "top": (span_cy - base_h_px / 2.0) / H, | |
| "width": span_w_px / W, | |
| "height": base_h_px / H, | |
| "rotation_deg": float(box.get("rotation_deg") or 0.0), | |
| "rotation_deg_css": float(box.get("rotation_deg_css") or 0.0), | |
| "center": {"x": span_cx / W, "y": span_cy / H}, | |
| } | |
| ) | |
| spans.append( | |
| { | |
| "side": "Ai", | |
| "para_index": para_index, | |
| "item_index": item_index, | |
| "span_index": span_i, | |
| "text": s, | |
| "valid_text": True, | |
| "start_raw": start_raw, | |
| "end_raw": end_raw, | |
| "t0_raw": t0, | |
| "t1_raw": t1, | |
| "box": span_box, | |
| "height_raw": item.get("height_raw"), | |
| "baseline_p1": item.get("baseline_p1"), | |
| "baseline_p2": item.get("baseline_p2"), | |
| "font_size_px": final_size, | |
| } | |
| ) | |
| span_i += 1 | |
| item["spans"] = spans | |
| return | |
| # --- Build layout units and measure each at the reference size -------- | |
| layout_units: list[tuple[str, str]] = [] | |
| for k, s, _ in tokens: | |
| if s == ZWSP: | |
| continue | |
| if k in ("space", "word"): | |
| layout_units.append((k, sanitize_draw_text(s))) | |
| widths_px: list[float] = [] | |
| max_ascent = 0 | |
| max_descent = 0 | |
| for i, (kind, text) in enumerate(layout_units): | |
| if kind == "space": | |
| # A space's width depends on the font of an adjacent word. | |
| hint = "" | |
| for j in range(i - 1, -1, -1): | |
| if layout_units[j][0] == "word": | |
| hint = layout_units[j][1] | |
| break | |
| if not hint: | |
| for j in range(i + 1, len(layout_units)): | |
| if layout_units[j][0] == "word": | |
| hint = layout_units[j][1] | |
| break | |
| font = pick_font(hint or "a", thai_path, latin_path, base_size) | |
| widths_px.append(max(0.0, _measure_width(font, text))) | |
| continue | |
| font = pick_font(text, thai_path, latin_path, base_size) | |
| try: | |
| ascent, descent = font.getmetrics() | |
| except Exception: | |
| ascent, descent = base_size, int(base_size * 0.25) | |
| max_ascent = max(max_ascent, ascent) | |
| max_descent = max(max_descent, descent) | |
| # Optional kerning: measure word+next-char minus next-char so adjacent | |
| # words of the same script tuck together naturally. | |
| if ( | |
| kerning_adjust | |
| and (i + 1) < len(layout_units) | |
| and layout_units[i + 1][0] == "word" | |
| ): | |
| nxt = layout_units[i + 1][1] | |
| nxt1 = nxt[:1] if nxt else "" | |
| if nxt1 and contains_thai(text) == contains_thai(nxt1): | |
| width = _measure_width(font, text + nxt1) - _measure_width(font, nxt1) | |
| else: | |
| width = _measure_width(font, text) | |
| else: | |
| width = _measure_width(font, text) | |
| widths_px.append(max(0.0, width)) | |
| line_tw = sum(widths_px) | |
| bo_base = baseline_offset_px(item_text, thai_path, latin_path, base_size) | |
| line_th = float(bo_base[1]) if bo_base is not None else float(max_ascent + max_descent) | |
| if line_tw <= 1e-9 or line_th <= 1e-9: | |
| item["spans"] = [] | |
| return | |
| # --- Decide the final font size --------------------------------------- | |
| if forced_size_px is None: | |
| scale_line = min((base_w_px * 1.0) / line_tw, (base_h_px * 0.995) / line_th) | |
| if scale_line <= 0.0: | |
| item["spans"] = [] | |
| return | |
| final_size = max(10, int(base_size * scale_line)) | |
| else: | |
| final_size = int(max(10, forced_size_px)) | |
| scale_line = final_size / base_size | |
| item["font_size_px"] = final_size | |
| # Lens-style span tiling: each span's box covers a slice of the item | |
| # baseline proportional to that token's natural width, so all spans | |
| # together fill the full item width (no leftover "margin" pockets like | |
| # the previous centre-and-pad layout produced). Within each span the | |
| # CSS flex centring renders the text at its natural size — visually | |
| # matching how Google Lens lays out its own overlays. | |
| w_scaled = [w * scale_line for w in widths_px] | |
| _proportion_total = sum(widths_px) or 1.0 | |
| _shares = [w / _proportion_total for w in widths_px] | |
| # --- Optionally shift the baseline so text is vertically centred ------ | |
| bo = baseline_offset_px(item_text, thai_path, latin_path, final_size) | |
| if apply_baseline_shift and bo is not None: | |
| baseline_offset, _ = bo | |
| cx = (base_left + base_w / 2.0) * W | |
| cy = (base_top + base_h / 2.0) * H | |
| target = (cx + baseline_offset * nx, cy + baseline_offset * ny) | |
| shift = ((target[0] - x1) * nx) + ((target[1] - y1) * ny) | |
| x1 += nx * shift | |
| y1 += ny * shift | |
| x2 += nx * shift | |
| y2 += ny * shift | |
| item["baseline_p1"] = {"x": x1 / W, "y": y1 / H} | |
| item["baseline_p2"] = {"x": x2 / W, "y": y2 / H} | |
| # --- Emit spans ------------------------------------------------------- | |
| # t0/t1 are proportional positions along the item baseline (0..1). | |
| # Together every token's slice — words *and* spaces — sums to 1, so the | |
| # spans tile the item width exactly the way Lens's own decoded spans do. | |
| spans: list[dict[str, Any]] = [] | |
| raw_pos = 0 | |
| span_i = 0 | |
| unit_i = 0 | |
| cum_t = 0.0 | |
| for kind, s, _ in tokens: | |
| if s == ZWSP: | |
| continue | |
| start_raw = abs_line_start_raw + raw_pos | |
| raw_pos += len(s) | |
| end_raw = abs_line_start_raw + raw_pos | |
| if unit_i >= len(_shares): | |
| break | |
| t0 = cum_t | |
| cum_t += _shares[unit_i] | |
| t1 = cum_t | |
| unit_i += 1 | |
| if kind == "space": | |
| continue | |
| # Compute the span's image-space box from its position along the | |
| # (possibly rotated, possibly baseline-shifted) baseline — the same | |
| # way ``backend.lens.tree.decode_tree`` builds Lens-native spans. | |
| # Using ``base_left + base_w * t0`` (the previous behaviour) is only | |
| # correct for axis-aligned items; for rotated items it places the | |
| # span at the wrong image-space coordinates, which becomes visible | |
| # the moment the renderer emits per-span absolute positions. | |
| seg_start_px = t0 * base_w_px | |
| seg_end_px = t1 * base_w_px | |
| e1x = x1 + ux * seg_start_px | |
| e1y = y1 + uy * seg_start_px | |
| e2x = x1 + ux * seg_end_px | |
| e2y = y1 + uy * seg_end_px | |
| span_cx = (e1x + e2x) / 2.0 | |
| span_cy = (e1y + e2y) / 2.0 | |
| span_w_px = abs(t1 - t0) * base_w_px | |
| span_left_px = span_cx - span_w_px / 2.0 | |
| span_top_px = span_cy - base_h_px / 2.0 | |
| span_box = ensure_box_fields( | |
| { | |
| "left": span_left_px / W, | |
| "top": span_top_px / H, | |
| "width": span_w_px / W, | |
| "height": base_h_px / H, | |
| "rotation_deg": float(box.get("rotation_deg") or 0.0), | |
| "rotation_deg_css": float(box.get("rotation_deg_css") or 0.0), | |
| "center": {"x": span_cx / W, "y": span_cy / H}, | |
| } | |
| ) | |
| spans.append( | |
| { | |
| "side": "Ai", | |
| "para_index": para_index, | |
| "item_index": item_index, | |
| "span_index": span_i, | |
| "text": s, | |
| "valid_text": True, | |
| "start_raw": start_raw, | |
| "end_raw": end_raw, | |
| "t0_raw": t0, | |
| "t1_raw": t1, | |
| "box": span_box, | |
| "height_raw": item.get("height_raw"), | |
| "baseline_p1": item.get("baseline_p1"), | |
| "baseline_p2": item.get("baseline_p2"), | |
| "font_size_px": final_size, | |
| } | |
| ) | |
| span_i += 1 | |
| item["spans"] = spans | |