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("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
    )


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 {}