plan291037's picture
Update layout.py
f701f38
Raw
History Blame Contribute Delete
34.9 kB
"""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