plan291037 commited on
Commit
3bdc698
·
verified ·
1 Parent(s): a90044f

Update backend/lens_core.py

Browse files
Files changed (1) hide show
  1. backend/lens_core.py +444 -10
backend/lens_core.py CHANGED
@@ -119,12 +119,226 @@ UI_LANGUAGES = [
119
  {"code": "ja", "name": "Japanese"},
120
  {"code": "ko", "name": "Korean"},
121
  {"code": "zh-CN", "name": "Chinese (Simplified)"},
 
122
  {"code": "vi", "name": "Vietnamese"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  {"code": "es", "name": "Spanish"},
124
- {"code": "de", "name": "German"},
125
  {"code": "fr", "name": "French"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  ]
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  AI_PROVIDER_DEFAULTS = {
129
  "gemini": {
130
  "model": "gemini-2.5-flash",
@@ -220,6 +434,13 @@ AI_LANG_STYLE = {
220
  "Choose 丁寧語/タメ口 to match context; keep emotion and emphasis.\n"
221
  "Keep proper nouns consistent; keep SFX natural in Japanese."
222
  ),
 
 
 
 
 
 
 
223
  "default": (
224
  "Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."
225
  ),
@@ -284,14 +505,31 @@ def _resolve_model(provider: str, model: str) -> str:
284
 
285
  def _normalize_lang(lang: str) -> str:
286
  t = (lang or "").strip().lower()
287
- if t in ("jp", "jpn", "japanese"):
288
- return "ja"
289
- if t in ("thai",):
290
- return "th"
291
- if t in ("eng", "english"):
292
- return "en"
293
- if t.startswith("zh"):
294
- return t
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  if len(t) >= 2:
296
  return t[:2]
297
  return t
@@ -2798,6 +3036,194 @@ def _line_metrics_px(text: str, thai_path: str, latin_path: str, size: int):
2798
  baseline_to_center = -((min_t + max_b) / 2.0)
2799
  return width, total_h, baseline_to_center
2800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2801
  def _item_avail_w_px(item: dict, W: int, H: int) -> float:
2802
  b = item.get("box") or {}
2803
  w_box = float(b.get("width") or 0.0) * float(W)
@@ -3439,6 +3865,7 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
3439
  base_rgb = img.convert("RGB")
3440
  overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
3441
  draw = ImageDraw.Draw(overlay)
 
3442
 
3443
  for ol in (level_outlines or []):
3444
  q = ol.get("quad")
@@ -3580,7 +4007,8 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
3580
  except Exception:
3581
  tw2, th2 = d2.textsize(text, font=font)
3582
 
3583
- side = int(max(tw2, th2, avail_h, avail_w) * 2.2 + 40)
 
3584
  side = min(side, int(max(W, H) * 4))
3585
  if side < 128:
3586
  side = 128
@@ -3669,6 +4097,9 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
3669
 
3670
  _draw_text_baseline_fallback(
3671
  dc, origin, text, thai_path, latin_path, final_size, fill)
 
 
 
3672
  rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
3673
  expand=False, center=origin)
3674
  paste_x = int(round(bx - origin[0]))
@@ -3677,6 +4108,9 @@ def draw_overlay(img, tokens, out_path, thai_path, latin_path, level_outlines=No
3677
  else:
3678
  _draw_text_centered_fallback(
3679
  dc, origin, text, thai_path, latin_path, final_size, fill)
 
 
 
3680
  rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
3681
  expand=False, center=origin)
3682
  paste_x = int(round(box_cx - origin[0]))
 
119
  {"code": "ja", "name": "Japanese"},
120
  {"code": "ko", "name": "Korean"},
121
  {"code": "zh-CN", "name": "Chinese (Simplified)"},
122
+ {"code": "zh-TW", "name": "Chinese (Traditional)"},
123
  {"code": "vi", "name": "Vietnamese"},
124
+ {"code": "id", "name": "Indonesian"},
125
+ {"code": "ms", "name": "Malay"},
126
+ {"code": "tl", "name": "Tagalog"},
127
+ {"code": "fil", "name": "Filipino"},
128
+ {"code": "hi", "name": "Hindi"},
129
+ {"code": "bn", "name": "Bengali"},
130
+ {"code": "ur", "name": "Urdu"},
131
+ {"code": "ta", "name": "Tamil"},
132
+ {"code": "te", "name": "Telugu"},
133
+ {"code": "ml", "name": "Malayalam"},
134
+ {"code": "mr", "name": "Marathi"},
135
+ {"code": "gu", "name": "Gujarati"},
136
+ {"code": "kn", "name": "Kannada"},
137
+ {"code": "pa", "name": "Punjabi"},
138
+ {"code": "ne", "name": "Nepali"},
139
+ {"code": "si", "name": "Sinhala"},
140
+ {"code": "my", "name": "Myanmar (Burmese)"},
141
+ {"code": "km", "name": "Khmer"},
142
+ {"code": "lo", "name": "Lao"},
143
+ {"code": "jv", "name": "Javanese"},
144
+ {"code": "su", "name": "Sundanese"},
145
  {"code": "es", "name": "Spanish"},
 
146
  {"code": "fr", "name": "French"},
147
+ {"code": "de", "name": "German"},
148
+ {"code": "it", "name": "Italian"},
149
+ {"code": "pt", "name": "Portuguese"},
150
+ {"code": "nl", "name": "Dutch"},
151
+ {"code": "pl", "name": "Polish"},
152
+ {"code": "ro", "name": "Romanian"},
153
+ {"code": "ru", "name": "Russian"},
154
+ {"code": "uk", "name": "Ukrainian"},
155
+ {"code": "cs", "name": "Czech"},
156
+ {"code": "sk", "name": "Slovak"},
157
+ {"code": "sl", "name": "Slovenian"},
158
+ {"code": "hr", "name": "Croatian"},
159
+ {"code": "sr", "name": "Serbian"},
160
+ {"code": "bs", "name": "Bosnian"},
161
+ {"code": "bg", "name": "Bulgarian"},
162
+ {"code": "mk", "name": "Macedonian"},
163
+ {"code": "el", "name": "Greek"},
164
+ {"code": "tr", "name": "Turkish"},
165
+ {"code": "hu", "name": "Hungarian"},
166
+ {"code": "fi", "name": "Finnish"},
167
+ {"code": "sv", "name": "Swedish"},
168
+ {"code": "da", "name": "Danish"},
169
+ {"code": "no", "name": "Norwegian"},
170
+ {"code": "et", "name": "Estonian"},
171
+ {"code": "lv", "name": "Latvian"},
172
+ {"code": "lt", "name": "Lithuanian"},
173
+ {"code": "is", "name": "Icelandic"},
174
+ {"code": "ga", "name": "Irish"},
175
+ {"code": "cy", "name": "Welsh"},
176
+ {"code": "mt", "name": "Maltese"},
177
+ {"code": "sq", "name": "Albanian"},
178
+ {"code": "hy", "name": "Armenian"},
179
+ {"code": "ka", "name": "Georgian"},
180
+ {"code": "az", "name": "Azerbaijani"},
181
+ {"code": "kk", "name": "Kazakh"},
182
+ {"code": "ky", "name": "Kyrgyz"},
183
+ {"code": "tg", "name": "Tajik"},
184
+ {"code": "uz", "name": "Uzbek"},
185
+ {"code": "tk", "name": "Turkmen"},
186
+ {"code": "mn", "name": "Mongolian"},
187
+ {"code": "ar", "name": "Arabic"},
188
+ {"code": "fa", "name": "Persian"},
189
+ {"code": "iw", "name": "Hebrew"},
190
+ {"code": "ps", "name": "Pashto"},
191
+ {"code": "ug", "name": "Uyghur"},
192
+ {"code": "ku", "name": "Kurdish (Kurmanji)"},
193
+ {"code": "sw", "name": "Swahili"},
194
+ {"code": "am", "name": "Amharic"},
195
+ {"code": "ha", "name": "Hausa"},
196
+ {"code": "ig", "name": "Igbo"},
197
+ {"code": "yo", "name": "Yoruba"},
198
+ {"code": "zu", "name": "Zulu"},
199
+ {"code": "xh", "name": "Xhosa"},
200
+ {"code": "so", "name": "Somali"},
201
+ {"code": "rw", "name": "Kinyarwanda"},
202
+ {"code": "mg", "name": "Malagasy"},
203
+ {"code": "af", "name": "Afrikaans"},
204
+ {"code": "ca", "name": "Catalan"},
205
+ {"code": "eu", "name": "Basque"},
206
+ {"code": "gl", "name": "Galician"},
207
+ {"code": "eo", "name": "Esperanto"},
208
+ {"code": "be", "name": "Belarusian"},
209
+ {"code": "ceb", "name": "Cebuano"},
210
+ {"code": "co", "name": "Corsican"},
211
+ {"code": "fy", "name": "Frisian"},
212
+ {"code": "haw", "name": "Hawaiian"},
213
+ {"code": "hmn", "name": "Hmong"},
214
+ {"code": "ht", "name": "Haitian Creole"},
215
+ {"code": "lb", "name": "Luxembourgish"},
216
+ {"code": "la", "name": "Latin"},
217
+ {"code": "mi", "name": "Maori"},
218
+ {"code": "or", "name": "Odia (Oriya)"},
219
+ {"code": "gd", "name": "Scots Gaelic"},
220
+ {"code": "sm", "name": "Samoan"},
221
+ {"code": "sn", "name": "Shona"},
222
+ {"code": "st", "name": "Sesotho"},
223
+ {"code": "sd", "name": "Sindhi"},
224
+ {"code": "tt", "name": "Tatar"},
225
+ {"code": "yi", "name": "Yiddish"},
226
+ {"code": "ny", "name": "Chichewa"}
227
  ]
228
 
229
+ UI_LANGUAGE_CODE_MAP = {
230
+ "en": "en",
231
+ "th": "th",
232
+ "ja": "ja",
233
+ "ko": "ko",
234
+ "zh-cn": "zh-CN",
235
+ "zh-tw": "zh-TW",
236
+ "vi": "vi",
237
+ "id": "id",
238
+ "ms": "ms",
239
+ "tl": "tl",
240
+ "fil": "fil",
241
+ "hi": "hi",
242
+ "bn": "bn",
243
+ "ur": "ur",
244
+ "ta": "ta",
245
+ "te": "te",
246
+ "ml": "ml",
247
+ "mr": "mr",
248
+ "gu": "gu",
249
+ "kn": "kn",
250
+ "pa": "pa",
251
+ "ne": "ne",
252
+ "si": "si",
253
+ "my": "my",
254
+ "km": "km",
255
+ "lo": "lo",
256
+ "jv": "jv",
257
+ "su": "su",
258
+ "es": "es",
259
+ "fr": "fr",
260
+ "de": "de",
261
+ "it": "it",
262
+ "pt": "pt",
263
+ "nl": "nl",
264
+ "pl": "pl",
265
+ "ro": "ro",
266
+ "ru": "ru",
267
+ "uk": "uk",
268
+ "cs": "cs",
269
+ "sk": "sk",
270
+ "sl": "sl",
271
+ "hr": "hr",
272
+ "sr": "sr",
273
+ "bs": "bs",
274
+ "bg": "bg",
275
+ "mk": "mk",
276
+ "el": "el",
277
+ "tr": "tr",
278
+ "hu": "hu",
279
+ "fi": "fi",
280
+ "sv": "sv",
281
+ "da": "da",
282
+ "no": "no",
283
+ "et": "et",
284
+ "lv": "lv",
285
+ "lt": "lt",
286
+ "is": "is",
287
+ "ga": "ga",
288
+ "cy": "cy",
289
+ "mt": "mt",
290
+ "sq": "sq",
291
+ "hy": "hy",
292
+ "ka": "ka",
293
+ "az": "az",
294
+ "kk": "kk",
295
+ "ky": "ky",
296
+ "tg": "tg",
297
+ "uz": "uz",
298
+ "tk": "tk",
299
+ "mn": "mn",
300
+ "ar": "ar",
301
+ "fa": "fa",
302
+ "iw": "iw",
303
+ "ps": "ps",
304
+ "ug": "ug",
305
+ "ku": "ku",
306
+ "sw": "sw",
307
+ "am": "am",
308
+ "ha": "ha",
309
+ "ig": "ig",
310
+ "yo": "yo",
311
+ "zu": "zu",
312
+ "xh": "xh",
313
+ "so": "so",
314
+ "rw": "rw",
315
+ "mg": "mg",
316
+ "af": "af",
317
+ "ca": "ca",
318
+ "eu": "eu",
319
+ "gl": "gl",
320
+ "eo": "eo",
321
+ "be": "be",
322
+ "ceb": "ceb",
323
+ "co": "co",
324
+ "fy": "fy",
325
+ "haw": "haw",
326
+ "hmn": "hmn",
327
+ "ht": "ht",
328
+ "lb": "lb",
329
+ "la": "la",
330
+ "mi": "mi",
331
+ "or": "or",
332
+ "gd": "gd",
333
+ "sm": "sm",
334
+ "sn": "sn",
335
+ "st": "st",
336
+ "sd": "sd",
337
+ "tt": "tt",
338
+ "yi": "yi",
339
+ "ny": "ny"
340
+ }
341
+
342
  AI_PROVIDER_DEFAULTS = {
343
  "gemini": {
344
  "model": "gemini-2.5-flash",
 
434
  "Choose 丁寧語/タメ口 to match context; keep emotion and emphasis.\n"
435
  "Keep proper nouns consistent; keep SFX natural in Japanese."
436
  ),
437
+ "id": (
438
+ "Target language: Indonesian\n"
439
+ "Write natural Indonesian manga dialogue: concise, conversational, and easy to read in speech bubbles.\n"
440
+ "Keep tone, emotion, and character voice intact; avoid stiff literal phrasing.\n"
441
+ "Use everyday Indonesian unless the source clearly needs a formal register.\n"
442
+ "Keep names and recurring terms consistent; avoid over-explaining."
443
+ ),
444
  "default": (
445
  "Write natural manga dialogue in the target language: concise, spoken, faithful to meaning and tone."
446
  ),
 
505
 
506
  def _normalize_lang(lang: str) -> str:
507
  t = (lang or "").strip().lower()
508
+ alias_map = {
509
+ "jp": "ja",
510
+ "jpn": "ja",
511
+ "japanese": "ja",
512
+ "thai": "th",
513
+ "eng": "en",
514
+ "english": "en",
515
+ "indonesian": "id",
516
+ "bahasa indonesia": "id",
517
+ "bahasa_indonesia": "id",
518
+ "indo": "id",
519
+ "he": "iw",
520
+ "hebrew": "iw",
521
+ "tagalog": "tl",
522
+ "filipino": "fil",
523
+ "burmese": "my",
524
+ }
525
+ if t in alias_map:
526
+ t = alias_map[t]
527
+ if t in UI_LANGUAGE_CODE_MAP:
528
+ return UI_LANGUAGE_CODE_MAP[t]
529
+ if t in ("zh", "zh-hans", "zh_cn", "zh-cn", "zh_hans"):
530
+ return "zh-CN"
531
+ if t in ("zh-hant", "zh_tw", "zh-tw", "zh_hant"):
532
+ return "zh-TW"
533
  if len(t) >= 2:
534
  return t[:2]
535
  return t
 
3036
  baseline_to_center = -((min_t + max_b) / 2.0)
3037
  return width, total_h, baseline_to_center
3038
 
3039
+ def _angle_diff_deg(a: float, b: float) -> float:
3040
+ d = float(a) - float(b)
3041
+ while d <= -180.0:
3042
+ d += 360.0
3043
+ while d > 180.0:
3044
+ d -= 360.0
3045
+ return d
3046
+
3047
+
3048
+ def _token_center_px(t: dict, W: int, H: int) -> tuple[float, float]:
3049
+ b = t.get("box") or {}
3050
+ c = b.get("center") or {}
3051
+ if ("x" in c) and ("y" in c):
3052
+ return float(c.get("x") or 0.0) * float(W), float(c.get("y") or 0.0) * float(H)
3053
+ left = float(b.get("left") or 0.0) * float(W)
3054
+ top = float(b.get("top") or 0.0) * float(H)
3055
+ width = float(b.get("width") or 0.0) * float(W)
3056
+ height = float(b.get("height") or 0.0) * float(H)
3057
+ return left + (width / 2.0), top + (height / 2.0)
3058
+
3059
+
3060
+ def _token_tangent_normal_px(t: dict, W: int, H: int):
3061
+ p1 = t.get("baseline_p1") or {}
3062
+ p2 = t.get("baseline_p2") or {}
3063
+ if ("x" in p1 and "y" in p1 and "x" in p2 and "y" in p2):
3064
+ x1 = float(p1.get("x") or 0.0) * float(W)
3065
+ y1 = float(p1.get("y") or 0.0) * float(H)
3066
+ x2 = float(p2.get("x") or 0.0) * float(W)
3067
+ y2 = float(p2.get("y") or 0.0) * float(H)
3068
+ dx = x2 - x1
3069
+ dy = y2 - y1
3070
+ L = float(math.hypot(dx, dy))
3071
+ if L > 1e-6:
3072
+ ux = dx / L
3073
+ uy = dy / L
3074
+ return ux, uy, -uy, ux
3075
+ angle_deg = float((t.get("box") or {}).get("rotation_deg") or 0.0)
3076
+ rad = math.radians(angle_deg)
3077
+ ux = math.cos(rad)
3078
+ uy = math.sin(rad)
3079
+ return ux, uy, -uy, ux
3080
+
3081
+
3082
+ def _build_curve_context(tokens: list, W: int, H: int) -> dict:
3083
+ para_items = {}
3084
+ for t in tokens or []:
3085
+ pi = t.get("para_index")
3086
+ ii = t.get("item_index")
3087
+ if pi is None or ii is None:
3088
+ continue
3089
+ key = (int(pi), int(ii))
3090
+ if key in para_items:
3091
+ continue
3092
+ cx, cy = _token_center_px(t, W, H)
3093
+ ux, uy, nx, ny = _token_tangent_normal_px(t, W, H)
3094
+ b = t.get("box") or {}
3095
+ para_items[key] = {
3096
+ "cx": float(cx),
3097
+ "cy": float(cy),
3098
+ "ux": float(ux),
3099
+ "uy": float(uy),
3100
+ "nx": float(nx),
3101
+ "ny": float(ny),
3102
+ "w": max(1.0, float(b.get("width") or 0.0) * float(W)),
3103
+ "h": max(1.0, float(b.get("height") or 0.0) * float(H)),
3104
+ "angle": float(b.get("rotation_deg") or 0.0),
3105
+ }
3106
+
3107
+ grouped = {}
3108
+ for (pi, ii), data in para_items.items():
3109
+ grouped.setdefault(int(pi), []).append((int(ii), data))
3110
+
3111
+ out = {}
3112
+ for pi, entries in grouped.items():
3113
+ entries.sort(key=lambda it: it[0])
3114
+ n = len(entries)
3115
+ for idx, (ii, cur) in enumerate(entries):
3116
+ prev_data = entries[idx - 1][1] if idx > 0 else None
3117
+ next_data = entries[idx + 1][1] if (idx + 1) < n else None
3118
+ curve_px = 0.0
3119
+ if prev_data and next_data:
3120
+ ax = prev_data["cx"]
3121
+ ay = prev_data["cy"]
3122
+ bx = next_data["cx"]
3123
+ by = next_data["cy"]
3124
+ vx = bx - ax
3125
+ vy = by - ay
3126
+ chord = float(math.hypot(vx, vy))
3127
+ if chord > 1e-6:
3128
+ wx = cur["cx"] - ax
3129
+ wy = cur["cy"] - ay
3130
+ signed = ((vx * wy) - (vy * wx)) / chord
3131
+ turn1 = math.degrees(math.atan2(cur["cy"] - ay, cur["cx"] - ax))
3132
+ turn2 = math.degrees(math.atan2(by - cur["cy"], bx - cur["cx"]))
3133
+ bend_deg = _angle_diff_deg(turn2, turn1)
3134
+ bend_sign = 1.0 if bend_deg >= 0.0 else -1.0
3135
+ if signed != 0.0 and bend_deg != 0.0 and (1.0 if signed >= 0.0 else -1.0) != bend_sign:
3136
+ signed = -signed
3137
+ chord_span = max(cur["w"], chord * 0.5)
3138
+ angle_mag = abs(float(bend_deg))
3139
+ angle_bonus = min(cur["h"] * 0.32, chord_span * 0.1, angle_mag * 0.18)
3140
+ curve_px = signed + (bend_sign * angle_bonus)
3141
+ elif prev_data or next_data:
3142
+ near = prev_data or next_data
3143
+ ref_angle = math.degrees(math.atan2(cur["cy"] - near["cy"], cur["cx"] - near["cx"]))
3144
+ bend_deg = _angle_diff_deg(cur["angle"], ref_angle)
3145
+ if abs(bend_deg) >= 8.0:
3146
+ sign = 1.0 if bend_deg >= 0.0 else -1.0
3147
+ curve_px = sign * min(cur["h"] * 0.2, cur["w"] * 0.06, abs(bend_deg) * 0.2)
3148
+ if curve_px:
3149
+ limit = min(cur["h"] * 0.72, cur["w"] * 0.18, 42.0)
3150
+ if abs(curve_px) < max(2.0, cur["h"] * 0.12):
3151
+ curve_px = 0.0
3152
+ else:
3153
+ curve_px = max(-limit, min(limit, curve_px))
3154
+ out[(pi, ii)] = float(curve_px)
3155
+ return out
3156
+
3157
+
3158
+ def _estimate_curve_px(token: dict, curve_map: dict, avail_w: float, avail_h: float, font_size: int, text_w: float, text_h: float) -> float:
3159
+ pi = token.get("para_index")
3160
+ ii = token.get("item_index")
3161
+ curve_px = 0.0
3162
+ if pi is not None and ii is not None:
3163
+ curve_px = float(curve_map.get((int(pi), int(ii))) or 0.0)
3164
+
3165
+ if not curve_px:
3166
+ b = token.get("box") or {}
3167
+ cx = float((b.get("center") or {}).get("x") or (float(b.get("left") or 0.0) + (float(b.get("width") or 0.0) / 2.0)))
3168
+ cy = float((b.get("center") or {}).get("y") or (float(b.get("top") or 0.0) + (float(b.get("height") or 0.0) / 2.0)))
3169
+ p1 = token.get("baseline_p1") or {}
3170
+ p2 = token.get("baseline_p2") or {}
3171
+ if ("x" in p1 and "y" in p1 and "x" in p2 and "y" in p2):
3172
+ mx = (float(p1.get("x") or 0.0) + float(p2.get("x") or 0.0)) / 2.0
3173
+ my = (float(p1.get("y") or 0.0) + float(p2.get("y") or 0.0)) / 2.0
3174
+ ux, uy, nx, ny = _token_tangent_normal_px(token, 1, 1)
3175
+ off = ((cx - mx) * nx) + ((cy - my) * ny)
3176
+ curve_px = float(off) * min(avail_w, avail_h)
3177
+
3178
+ if not curve_px:
3179
+ return 0.0
3180
+
3181
+ cap_h = max(text_h * 0.55, avail_h * 0.3)
3182
+ cap = min(max(4.0, cap_h), max(4.0, avail_h * 0.82), max(4.0, avail_w * 0.18), max(4.0, font_size * 0.95))
3183
+ curve_px = max(-cap, min(cap, curve_px))
3184
+ if abs(curve_px) < 2.0:
3185
+ return 0.0
3186
+ return float(curve_px)
3187
+
3188
+
3189
+ def _curve_height_extra_px(curve_px: float) -> float:
3190
+ return abs(float(curve_px)) * 0.9
3191
+
3192
+
3193
+ def _warp_canvas_arc(canvas: Image.Image, curve_px: float) -> Image.Image:
3194
+ curve = float(curve_px or 0.0)
3195
+ if abs(curve) < 1.0:
3196
+ return canvas
3197
+ arr = np.array(canvas, dtype=np.uint8)
3198
+ if arr.ndim != 3 or arr.shape[2] != 4:
3199
+ return canvas
3200
+ h, w, _ = arr.shape
3201
+ if h <= 0 or w <= 1:
3202
+ return canvas
3203
+ pad = int(math.ceil(abs(curve))) + 4
3204
+ out = np.zeros((h + (pad * 2), w, 4), dtype=np.uint8)
3205
+ denom = float(max(1, w - 1))
3206
+ for x in range(w):
3207
+ xn = (2.0 * float(x) / denom) - 1.0
3208
+ bow = 1.0 - (xn * xn)
3209
+ shift = int(round(curve * bow))
3210
+ y0 = pad + shift
3211
+ y1 = y0 + h
3212
+ if y0 < 0:
3213
+ src_top = -y0
3214
+ y0 = 0
3215
+ else:
3216
+ src_top = 0
3217
+ if y1 > out.shape[0]:
3218
+ src_bottom = h - (y1 - out.shape[0])
3219
+ y1 = out.shape[0]
3220
+ else:
3221
+ src_bottom = h
3222
+ if y1 <= y0 or src_bottom <= src_top:
3223
+ continue
3224
+ out[y0:y1, x, :] = arr[src_top:src_bottom, x, :]
3225
+ return Image.fromarray(out, mode="RGBA")
3226
+
3227
  def _item_avail_w_px(item: dict, W: int, H: int) -> float:
3228
  b = item.get("box") or {}
3229
  w_box = float(b.get("width") or 0.0) * float(W)
 
3865
  base_rgb = img.convert("RGB")
3866
  overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
3867
  draw = ImageDraw.Draw(overlay)
3868
+ curve_map = _build_curve_context(tokens or [], base.size[0], base.size[1])
3869
 
3870
  for ol in (level_outlines or []):
3871
  q = ol.get("quad")
 
4007
  except Exception:
4008
  tw2, th2 = d2.textsize(text, font=font)
4009
 
4010
+ curve_px = _estimate_curve_px(t, curve_map, avail_w, avail_h, final_size, tw2, th2)
4011
+ side = int(max(tw2, th2 + _curve_height_extra_px(curve_px), avail_h, avail_w) * 2.35 + 40)
4012
  side = min(side, int(max(W, H) * 4))
4013
  if side < 128:
4014
  side = 128
 
4097
 
4098
  _draw_text_baseline_fallback(
4099
  dc, origin, text, thai_path, latin_path, final_size, fill)
4100
+ if curve_px:
4101
+ canvas = _warp_canvas_arc(canvas, curve_px)
4102
+ origin = (canvas.size[0] // 2, canvas.size[1] // 2)
4103
  rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
4104
  expand=False, center=origin)
4105
  paste_x = int(round(bx - origin[0]))
 
4108
  else:
4109
  _draw_text_centered_fallback(
4110
  dc, origin, text, thai_path, latin_path, final_size, fill)
4111
+ if curve_px:
4112
+ canvas = _warp_canvas_arc(canvas, curve_px)
4113
+ origin = (canvas.size[0] // 2, canvas.size[1] // 2)
4114
  rotated = canvas.rotate(-angle_deg, resample=Image.BICUBIC,
4115
  expand=False, center=origin)
4116
  paste_x = int(round(box_cx - origin[0]))