Spaces:
Paused
Paused
File size: 11,283 Bytes
844c9ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 | """Pour an AI translation into a template tree.
``patch`` takes the marker-encoded AI text plus a *template tree* (normally
the Lens **Translated** tree, because it already carries target-language
geometry: the right number of lines per bubble, each line's free-angle
baseline, the polyline that approximates a curved bubble).
Each paragraph of AI text is distributed across the template paragraph's
items by :func:`backend.render.layout.distribute_to_template` — which
mirrors how Lens itself split the same paragraph (one line per item). The
result is an ``Ai`` tree with the same geometry as the template but the AI's
(better) wording.
Per-item *font sizes* are picked by
:func:`backend.render.tp_html.fit_item_font_size`, a closed-form formula
that doesn't need Pillow. The renderer (`render_tree_overlay`) emits one
``<div class="tp-line">`` per item with that font size — no per-word span
tiling, no PIL fonts, no fragile measurement.
"""
from __future__ import annotations
import copy
from typing import Any
from backend.ai import markers
from backend.lens.languages import normalize as normalize_lang
from backend.render.fonts import budoux_parser
from backend.render.layout import (
distribute_to_template,
font_size_minimum_for_image,
pad_lines,
)
from backend.render.tp_html import fit_item_font_size
from backend.utils.text import ZWSP
def _patch_groups(
ai_text_full: str,
out_tree: dict,
paragraphs: list[dict],
lang_norm: str,
parser: Any,
min_size_px: int,
img_w: int,
img_h: int,
group_map: list[list[int]],
) -> dict[str, Any]:
"""Group-level variant of :func:`patch`.
``ai_text_full`` carries one ``<<TP_Pn>>`` marker per *bubble group* (not
per Lens paragraph). Each group's translated text is distributed across
the combined items of ALL paragraphs in that group via
:func:`distribute_to_template`, then the item texts are written back to
their individual paragraphs so that the downstream
:func:`group_paragraphs_into_bubbles` re-groups them correctly.
``group_map[i]`` is the sorted list of ``para_index`` values that belong
to group ``i``.
"""
n_groups = len(group_map)
extracted = markers.extract_paragraphs(ai_text_full, n_groups)
if extracted is not None:
ai_group_texts, ai_text_full_clean = extracted
else:
ai_group_texts = (ai_text_full or "").split("\n\n")
if len(ai_group_texts) < n_groups:
ai_group_texts += [""] * (n_groups - len(ai_group_texts))
ai_text_full_clean = "\n\n".join(ai_group_texts[:n_groups])
# Build para_index → para dict once.
para_by_idx: dict[int, Any] = {}
for p in paragraphs:
pi = int(p.get("para_index", 0))
para_by_idx[pi] = p
# Separator for joining item texts within a paragraph: scriptio-continua
# languages (Thai / CJK) don't use inter-word spaces.
sep_char = "" if parser is not None else " "
raw_cursor = 0
for gi, para_indices in enumerate(group_map):
group_ai_text = ai_group_texts[gi] if gi < len(ai_group_texts) else ""
# Collect all items from every paragraph in this group, in para order.
all_items: list[Any] = []
para_item_ranges: list[tuple[int, int, int]] = [] # (pi, start, end)
for pi in para_indices:
para = para_by_idx.get(pi)
if para is None:
continue
items = para.get("items") or []
start = len(all_items)
all_items.extend(items)
end = len(all_items)
if items:
para_item_ranges.append((pi, start, end))
if not all_items:
continue
max_lines = len(all_items)
lines = distribute_to_template(
group_ai_text, all_items, parser, lang_norm, img_w, img_h
)
lines = pad_lines(lines, max_lines)
# Assign line texts and font sizes to every item.
all_sizes: list[int] = []
for li, item in enumerate(all_items):
line_tokens = lines[li] if li < len(lines) else []
line_text = _line_text(line_tokens)
item["side"] = "Ai"
item["text"] = line_text
item["valid_text"] = bool(line_text)
item["start_raw"] = raw_cursor
item["end_raw"] = raw_cursor + len(line_text)
item["spans"] = []
box = item.get("box") or {}
width_pct = float(
box.get("width_pct") or (float(box.get("width") or 0.0) * 100.0)
)
height_pct = float(
box.get("height_pct") or (float(box.get("height") or 0.0) * 100.0)
)
fs = fit_item_font_size(
width_pct, height_pct, line_text or "ก", img_w, img_h
)
fs = max(min_size_px, fs)
item["font_size_px"] = int(fs)
all_sizes.append(fs)
raw_cursor = item["end_raw"] + 1
# Write back item-index, para-index, and para-level summary fields.
for pi, start, end in para_item_ranges:
para = para_by_idx.get(pi)
if para is None:
continue
slice_items = all_items[start:end]
for ii, item in enumerate(slice_items):
item["para_index"] = pi
item["item_index"] = ii
para["side"] = "Ai"
para["para_index"] = pi
slice_texts = [str(it.get("text") or "") for it in slice_items]
para["text"] = sep_char.join(t for t in slice_texts if t).strip()
para["valid_text"] = bool(para["text"])
if all_sizes[start:end]:
sz_slice = sorted(all_sizes[start:end])
para["para_font_size_px"] = sz_slice[len(sz_slice) // 2]
# Approximate raw offsets (informational only).
if start < len(all_items):
para["start_raw"] = all_items[start].get("start_raw", raw_cursor)
if end - 1 < len(all_items):
para["end_raw"] = all_items[end - 1].get("end_raw", raw_cursor)
raw_cursor += 2 # paragraph separator
return {"aiTextFull": ai_text_full_clean, "aiTree": out_tree}
def _line_text(tokens: list[tuple[str, str, float]]) -> str:
"""Reassemble a distributed line's token list back into a flat string.
``distribute_to_template`` returns one line per template item as a list
of ``(kind, text, _)`` tuples (kind = ``"word"`` or ``"space"``). For
item-level rendering we just concatenate them, drop the zero-width
sentinel and trim outer whitespace.
"""
return "".join(
s for _kind, s, _w in (tokens or []) if s and s != ZWSP
).strip()
def patch(
ai_text_full: str,
template_tree: dict,
img_w: int,
img_h: int,
_thai_font: str,
_latin_font: str,
lang: str,
group_map: list[list[int]] | None = None,
) -> dict[str, Any]:
"""Build the ``Ai`` tree from ``ai_text_full`` + ``template_tree``.
Returns ``{"aiTextFull": <clean text>, "aiTree": <tree>}``. Mutates a
deep copy of the template — original/translated trees aren't touched.
"""
if not isinstance(template_tree, dict):
raise ValueError("template_tree must be a dict")
lang_norm = normalize_lang(lang)
parser = budoux_parser(lang_norm)
out_tree = copy.deepcopy(template_tree)
out_tree["side"] = "Ai"
paragraphs = out_tree.get("paragraphs") or []
# Readability floor scales with image resolution
# (font_size_minimum = (W + H) / 200, à la manga-image-translator).
min_size_px = font_size_minimum_for_image(img_w, img_h)
# When a group_map is provided the AI text has one marker per bubble group,
# not one per paragraph. Delegate to the group-aware distributor.
if group_map is not None:
return _patch_groups(
ai_text_full, out_tree, paragraphs,
lang_norm, parser, min_size_px,
img_w, img_h, group_map,
)
# Split the AI text into one string per paragraph, marker-aware.
extracted = markers.extract_paragraphs(ai_text_full, len(paragraphs))
if extracted is not None:
ai_paras, ai_text_full_clean = extracted
else:
ai_paras = ai_text_full.split("\n\n") if ai_text_full else []
if len(ai_paras) < len(paragraphs):
ai_paras += [""] * (len(paragraphs) - len(ai_paras))
elif len(ai_paras) > len(paragraphs):
ai_paras = ai_paras[: len(paragraphs)]
ai_text_full_clean = "\n\n".join(ai_paras)
raw_cursor = 0
for pi, (para, ptext) in enumerate(zip(paragraphs, ai_paras)):
para["side"] = "Ai"
para["para_index"] = int(para.get("para_index", pi))
items = para.get("items") or []
max_lines = len(items)
if max_lines <= 0:
continue
# Distribute the AI paragraph across the template's items, mirroring
# how Lens split the same paragraph (one line per template item).
lines = distribute_to_template(ptext, items, parser, lang_norm, img_w, img_h)
lines = pad_lines(lines, max_lines)
para["text"] = ptext
para["valid_text"] = bool(ptext)
para["start_raw"] = raw_cursor
para["end_raw"] = raw_cursor + len(ptext)
# Walk each template item, install its slice of the AI text, and
# pick a font size with the closed-form heuristic. No spans, no PIL.
para_sizes: list[int] = []
line_start = raw_cursor
for ii in range(max_lines):
item = items[ii]
item["side"] = "Ai"
item["para_index"] = pi
item["item_index"] = ii
line_tokens = lines[ii] if ii < len(lines) else []
line_text = _line_text(line_tokens)
item["text"] = line_text
item["valid_text"] = bool(line_text)
item["start_raw"] = line_start
item["end_raw"] = line_start + len(line_text)
# Per-item geometry → per-item font size.
box = item.get("box") or {}
width_pct = float(box.get("width_pct") or (float(box.get("width") or 0.0) * 100.0))
height_pct = float(box.get("height_pct") or (float(box.get("height") or 0.0) * 100.0))
fs = fit_item_font_size(width_pct, height_pct, line_text or "ก", img_w, img_h)
fs = max(min_size_px, fs)
item["font_size_px"] = int(fs)
# We no longer maintain per-word spans for the AI layer — the
# renderer reads ``item.text`` directly. Keep the field as an
# empty list so any consumer that iterates it stays happy.
item["spans"] = []
para_sizes.append(int(fs))
line_start = item["end_raw"]
# Paragraph gets a shared "reference" size (median) so callers that
# need one value per paragraph have it. The renderer still picks
# per-item sizes — this is just informational.
if para_sizes:
para["para_font_size_px"] = sorted(para_sizes)[len(para_sizes) // 2]
raw_cursor = para["end_raw"] + 2 # +2 for the "\n\n" between paragraphs
return {"aiTextFull": ai_text_full_clean, "aiTree": out_tree}
|