Spaces:
Paused
Paused
File size: 61,621 Bytes
844c9ea 20fe41d 844c9ea 20fe41d 844c9ea 833067a 844c9ea 833067a 844c9ea 833067a 844c9ea 20fe41d 844c9ea 20fe41d 844c9ea 20fe41d 844c9ea 833067a 844c9ea 833067a 844c9ea 833067a 844c9ea 833067a 844c9ea 20fe41d 844c9ea 20fe41d 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 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 | """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 ``<div class="tp-line">`` 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
``<div class="tp-line vert">`` 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 ``<div>`` — 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'<div class="{cls}" style="{style}">{_escape_text(display_text)}</div>'
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 ``<div>`` 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'<div class="{cls}" data-pi="{pi}" '
f'data-fs="{fs}" style="{style}">{_escape_text(display_text)}</div>'
)
def render_tree_overlay(
tree: dict | None,
img_w: int,
img_h: int,
target_lang: str = "",
) -> str:
"""Render every paragraph/item in ``tree`` as ``<div class="tp-line">``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] = ['<div class="tp-draw-root"><div class="tp-draw-scope">']
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 '<div class="tp-on-dark">' + chunk + "</div>"
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("</div></div>")
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 <div> 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("</div></div>")
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'<div class="tp-line vert" data-pi="{pi}" data-ii="{ii}" '
f'data-fs="{fs}" '
f'style="{style}">{_escape_text(text)}</div>'
)
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 ``<div class="tp-line">`` 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'<div class="tp-line" data-pi="{pi}" data-ii="{ii}" '
f'data-si="{si}" data-fs="{fs}" data-lens="v-pos" '
f'style="{style}">{_escape_text(span_text)}</div>'
)
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'<div class="{cls}" data-pi="{pi}" data-ii="{ii}" '
f'data-fs="{fs}" '
f'style="{style}">{_escape_text(text)}</div>'
)
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 ``<div class="tp-line bubble">`` 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'<div class="tp-line bubble" data-pi="{pi}" '
f'data-fs="{fs}" '
f'style="{style}">{_escape_text(para_text)}</div>'
)
def _render_paragraph_vertical(
para: dict, para_text: str, img_w: int, img_h: int
) -> str:
"""One vertical ``<div class="tp-line vert">`` 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'<div class="tp-line vert" data-pi="{pi}" '
f'data-fs="{fs}" '
f'style="{style}">{_escape_text(para_text)}</div>'
)
# --- 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 {}
|