plan291037's picture
Add files via upload
789ea68
Raw
History Blame Contribute Delete
11.3 kB
"""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}