3morrrrr commited on
Commit
fec890e
·
verified ·
1 Parent(s): f26d58f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +59 -101
app.py CHANGED
@@ -1,21 +1,15 @@
1
- # app.py
2
  import os, re, io
3
  import xml.etree.ElementTree as ET
4
  import gradio as gr
5
 
6
  from hand import Hand # your handwriting model wrapper
7
 
8
- # -----------------------------------------------------------------------------
9
- # Setup
10
- # -----------------------------------------------------------------------------
11
  os.makedirs("img", exist_ok=True)
12
  hand = Hand()
13
 
14
- # -----------------------------------------------------------------------------
15
- # SVG / coord helpers
16
- # -----------------------------------------------------------------------------
17
  def _parse_viewbox(root):
18
- """Robust viewBox parser: handles commas/spaces, falls back to width/height."""
19
  vb = root.get("viewBox")
20
  if vb:
21
  s = re.sub(r"[,\s]+", " ", vb.strip())
@@ -28,11 +22,11 @@ def _parse_viewbox(root):
28
  return (x, y, w, h)
29
  except ValueError:
30
  pass
31
- def _num(v, default):
32
- if not v: return float(default)
33
  v = v.strip().lower().replace("px", "").replace(",", ".")
34
  try: return float(v)
35
- except ValueError: return float(default)
36
  w = _num(root.get("width"), 1200.0)
37
  h = _num(root.get("height"), 400.0)
38
  return (0.0, 0.0, w, h)
@@ -54,14 +48,8 @@ def _translate_group(elem, dx, dy):
54
  prev = elem.get("transform", "")
55
  elem.set("transform", (prev + f" translate({dx},{dy})").strip())
56
 
57
- # -----------------------------------------------------------------------------
58
- # Tokenization
59
- # -----------------------------------------------------------------------------
60
  def _tokenize_line(line):
61
- """
62
- Split into [('text'|'sep_space'|'sep_underscore', value), ...]
63
- Preserves exact underscore counts and spaces.
64
- """
65
  tokens, i, n = [], 0, len(line)
66
  while i < n:
67
  ch = line[i]
@@ -84,24 +72,16 @@ def _tokenize_line(line):
84
  return tokens
85
 
86
  def _display_text_from_tokens(tokens):
87
- """What we actually render with the model (underscores & spaces -> single spaces)."""
88
  return "".join([v if t == "text" else " " for t, v in tokens])
89
 
90
- # -----------------------------------------------------------------------------
91
- # Rasterization & analysis
92
- # -----------------------------------------------------------------------------
93
  def _rasterize_svg(svg_str, scale=3.0):
94
- """Rasterize SVG string to RGBA image (higher scale = finer gap detection)."""
95
  import cairosvg
96
  from PIL import Image
97
  png = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), scale=scale, background_color="none")
98
  return Image.open(io.BytesIO(png)).convert("RGBA")
99
 
100
  def _find_blobs_and_gaps(alpha_img):
101
- """
102
- Return (blobs, gaps, content_bbox) using per-column alpha.
103
- blobs/gaps are lists of x-intervals [start, end) in pixels.
104
- """
105
  w, h = alpha_img.size
106
  bbox = alpha_img.getbbox()
107
  if not bbox:
@@ -128,12 +108,10 @@ def _find_blobs_and_gaps(alpha_img):
128
  if in_blob: blobs.append((start, right))
129
  else: gaps.append((start, right))
130
 
131
- # Only interior gaps (exclude margins)
132
  core_gaps = [(blobs[i][1], blobs[i + 1][0]) for i in range(len(blobs) - 1)]
133
  return blobs, core_gaps, (left, top, right, bottom)
134
 
135
  def _column_profile(alpha_img, top, bottom):
136
- """Ink count per column."""
137
  w, h = alpha_img.size
138
  prof = [0] * w
139
  for x in range(w):
@@ -145,22 +123,16 @@ def _column_profile(alpha_img, top, bottom):
145
  return prof
146
 
147
  def _synthesize_gap_near(alpha_img, content_bbox, target_x_px, min_w_px=14, search_pct=0.18):
148
- """
149
- Create a gap near target_x_px by finding a local 'valley' (min ink column).
150
- Ensures at least min_w_px width.
151
- """
152
  left, top, right, bottom = content_bbox
153
  prof = _column_profile(alpha_img, top, bottom)
154
  half = max(6, int((right - left) * search_pct))
155
  x0 = max(left + 1, int(target_x_px - half))
156
  x1 = min(right - 1, int(target_x_px + half))
157
-
158
  best_x, best_v = x0, prof[x0]
159
  for x in range(x0, x1):
160
  v = prof[x]
161
  if v < best_v:
162
  best_x, best_v = x, v
163
-
164
  half_w = max(7, min_w_px // 2)
165
  g0 = max(left + 1, best_x - half_w)
166
  g1 = min(right - 1, best_x + half_w)
@@ -170,7 +142,6 @@ def _synthesize_gap_near(alpha_img, content_bbox, target_x_px, min_w_px=14, sear
170
  g1 = min(right - 1, g1 + pad)
171
  return (g0, g1)
172
 
173
- # Draw N underscores into a specific gap (pixel coords → SVG coords)
174
  def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
175
  color, stroke_width, n, between_px=3.0,
176
  min_len=10.0, max_len=48.0, frac_of_gap=0.60):
@@ -183,8 +154,7 @@ def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
183
  scale = gap_w / max(1.0, total_needed)
184
  u_len *= scale
185
  block_w = n * u_len + (n - 1) * between_px
186
- x0_px = gap_px[0] + (gap_w - block_w) / 2.0 # centered in gap
187
-
188
  for i in range(n):
189
  xs = x0_px + i * (u_len + between_px)
190
  xe = xs + u_len
@@ -201,7 +171,6 @@ def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
201
 
202
  def _draw_margin_underscores(root, edge_px, side, baseline_px, img_w, img_h, vb,
203
  color, stroke_width, n, between_px=4.0, len_px=22.0, pad_px=6.0):
204
- """Leading/trailing underscores: draw left of first blob or right of last blob."""
205
  if n <= 0: return
206
  for i in range(n):
207
  if side == "left":
@@ -221,18 +190,14 @@ def _draw_margin_underscores(root, edge_px, side, baseline_px, img_w, img_h, vb,
221
  p.set("stroke-linecap", "round")
222
  root.append(p)
223
 
224
- # -----------------------------------------------------------------------------
225
- # Core: render one line with underscores drawn *in the model's real gaps*
226
- # -----------------------------------------------------------------------------
227
- def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
228
- """
229
- Keep the model's spacing. Draw underscores inside the actual gaps.
230
- No added padding or reflow.
231
- """
232
  tokens = _tokenize_line(line)
233
  display_line = _display_text_from_tokens(tokens).replace("/", "-").replace("\\", "-")
234
 
235
- # Render the line once (spacing from model remains)
236
  hand.write(
237
  filename="img/line.tmp.svg",
238
  lines=[display_line if display_line.strip() else " "],
@@ -242,7 +207,7 @@ def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
242
  root = ET.parse("img/line.tmp.svg").getroot()
243
  vb = _parse_viewbox(root)
244
 
245
- # Put model paths into a group we will augment
246
  g = ET.Element("g")
247
  for p in _extract_paths(root):
248
  g.append(p)
@@ -256,7 +221,7 @@ def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
256
  line_h_px = max(20, bottom - top)
257
  baseline_px = bottom - int(0.18 * line_h_px)
258
 
259
- # Count leading/trailing underscores (for margins)
260
  leading_us = 0
261
  i = 0
262
  while i < len(tokens) and tokens[i][0] != "text":
@@ -270,7 +235,7 @@ def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
270
  trailing_us += len(tokens[j][1])
271
  j -= 1
272
 
273
- # Build clusters (positions between text runs) with underscore counts
274
  clusters = []
275
  i = 0
276
  while i < len(tokens):
@@ -289,46 +254,44 @@ def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
289
  else:
290
  i += 1
291
 
292
- # Choose gaps: exact-order when available, else reuse widest remaining, else synthesize
293
  gaps_sorted_by_width = sorted(gaps, key=lambda ab: (ab[1] - ab[0]), reverse=True)
294
  used_gaps = [False] * len(gaps)
295
 
296
- def _take_gap_for_idx(idx, words, left, right):
297
- # same-index gap if free
298
  if 0 <= idx < len(gaps) and not used_gaps[idx]:
299
- used_gaps[idx] = True
300
- return gaps[idx]
301
- # else widest unused
302
  for gp in gaps_sorted_by_width:
303
  try:
304
  real_idx = gaps.index(gp)
305
  except ValueError:
306
  continue
307
  if not used_gaps[real_idx]:
308
- used_gaps[real_idx] = True
309
- return gp
310
- # else synthesize near expected split
311
  seen_chars = sum(len(w) for w in words[:idx + 1]) if words else 1
312
  total_chars = sum(len(w) for w in words) or 1
313
  ratio = min(0.95, max(0.05, seen_chars / float(total_chars)))
314
  target_x_px = left + ratio * (right - left)
315
  return _synthesize_gap_near(alpha, content_bbox, target_x_px)
316
 
317
- words = [v for (t, v) in tokens if t == "text" and len(v) > 0]
318
-
319
- # Draw interior underscores cluster-by-cluster
320
  for idx, cluster in enumerate(clusters):
321
  n = int(cluster["underscores"])
322
  if n <= 0:
323
- continue # spaces only → draw nothing
324
- gap_px = _take_gap_for_idx(idx, words, left, right)
325
  _draw_underscores_in_gap(
326
  g, gap_px, baseline_px, img_w, img_h, vb,
327
  color, stroke_width, n,
328
  between_px=3.0, min_len=10.0, max_len=48.0, frac_of_gap=0.60
329
  )
 
330
 
331
- # Draw leading/trailing underscores at margins
332
  if blobs:
333
  first_left = blobs[0][0]
334
  last_right = blobs[-1][1]
@@ -337,19 +300,29 @@ def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
337
  g, first_left, "left", baseline_px, img_w, img_h, vb,
338
  color, stroke_width, leading_us
339
  )
 
340
  if trailing_us:
341
  _draw_margin_underscores(
342
  g, last_right, "right", baseline_px, img_w, img_h, vb,
343
  color, stroke_width, trailing_us
344
  )
 
 
 
 
 
 
 
 
 
 
345
 
346
  width_estimate = right - left if right > left else img_w // 2
347
  return g, width_estimate
348
 
349
- # -----------------------------------------------------------------------------
350
- # Public: Generate SVG (multi-line, keep model spacing)
351
- # -----------------------------------------------------------------------------
352
- def generate_handwriting(text, style, bias=0.75, color="#000000", stroke_width=2, multiline=True):
353
  try:
354
  lines = text.split("\n") if multiline else [text]
355
  for idx, ln in enumerate(lines):
@@ -362,9 +335,10 @@ def generate_handwriting(text, style, bias=0.75, color="#000000", stroke_width=2
362
  for i, line in enumerate(lines):
363
  g, w = render_line_svg_with_underscores(
364
  line.replace("/", "-").replace("\\", "-"),
365
- style, bias, color, stroke_width
 
366
  )
367
- _translate_group(g, dx=40, dy=y0 + i * line_gap) # vertical stacking only
368
  svg_root.append(g)
369
  max_right = max(max_right, 40 + w)
370
 
@@ -373,36 +347,25 @@ def generate_handwriting(text, style, bias=0.75, color="#000000", stroke_width=2
373
  svg_root.set("viewBox", f"0 0 {width} {height}")
374
 
375
  svg_content = ET.tostring(svg_root, encoding="unicode")
376
- with open("img/output.svg", "w", encoding="utf-8") as f:
377
- f.write(svg_content)
378
  return svg_content
379
-
380
  except Exception as e:
381
  return f"Error: {str(e)}"
382
 
383
- # -----------------------------------------------------------------------------
384
- # PNG export (transparent)
385
- # -----------------------------------------------------------------------------
386
  def export_to_png(svg_content):
387
  try:
388
  import cairosvg
389
  from PIL import Image
390
  if not svg_content or svg_content.startswith("Error:"):
391
  return None
392
-
393
  tmp_svg = "img/temp.svg"
394
- with open(tmp_svg, "w", encoding="utf-8") as f:
395
- f.write(svg_content)
396
-
397
  cairosvg.svg2png(url=tmp_svg, write_to="img/output_temp.png", scale=2.2, background_color="none")
398
  img = Image.open("img/output_temp.png")
399
  if img.mode != "RGBA": img = img.convert("RGBA")
400
-
401
- # Make near-white fully transparent (safety)
402
  data = img.getdata()
403
- img.putdata([(255, 255, 255, 0) if r > 240 and g > 240 and b > 240 else (r, g, b, a)
404
- for (r, g, b, a) in data])
405
-
406
  out_path = "img/output.png"
407
  img.save(out_path, "PNG")
408
  try: os.remove("img/output_temp.png")
@@ -412,11 +375,9 @@ def export_to_png(svg_content):
412
  print(f"Error converting to PNG: {str(e)}")
413
  return None
414
 
415
- # -----------------------------------------------------------------------------
416
- # Gradio UI
417
- # -----------------------------------------------------------------------------
418
- def generate_handwriting_wrapper(text, style, bias, color, stroke_width, multiline=True):
419
- svg = generate_handwriting(text, style, bias, color, stroke_width, multiline)
420
  png = export_to_png(svg)
421
  return svg, png, "img/output.svg"
422
 
@@ -426,8 +387,8 @@ css = """
426
  """
427
 
428
  with gr.Blocks(css=css) as demo:
429
- gr.Markdown("# 🖋️ Handwriting Synthesis (Underscore-in-gaps)")
430
- gr.Markdown("Keeps model spacing. Paints `_` exactly in the real gaps (no padding). Handles merged words and __edges__.")
431
 
432
  with gr.Row():
433
  with gr.Column(scale=2):
@@ -442,6 +403,7 @@ with gr.Blocks(css=css) as demo:
442
  with gr.Row():
443
  color_picker = gr.ColorPicker(label="Ink Color", value="#000000")
444
  stroke_width = gr.Slider(1, 4, step=0.5, value=2, label="Stroke Width")
 
445
  generate_btn = gr.Button("Generate Handwriting", variant="primary")
446
  with gr.Column(scale=3):
447
  output_svg = gr.HTML(label="Generated Handwriting (SVG)", elem_classes=["output-container"])
@@ -451,7 +413,7 @@ with gr.Blocks(css=css) as demo:
451
 
452
  generate_btn.click(
453
  fn=generate_handwriting_wrapper,
454
- inputs=[text_input, style_select, bias_slider, color_picker, stroke_width],
455
  outputs=[output_svg, output_png, download_svg_file]
456
  ).then(
457
  fn=lambda p: p,
@@ -459,11 +421,8 @@ with gr.Blocks(css=css) as demo:
459
  outputs=[download_png_file]
460
  )
461
 
462
- # -----------------------------------------------------------------------------
463
- # Main
464
- # -----------------------------------------------------------------------------
465
  if __name__ == "__main__":
466
- # Ensure deps for rasterization/export are available
467
  missing = []
468
  try:
469
  import cairosvg # noqa
@@ -475,5 +434,4 @@ if __name__ == "__main__":
475
  missing.append("pillow")
476
  if missing:
477
  print("Install:", " ".join(missing))
478
- port = int(os.environ.get("PORT", 7860))
479
- demo.launch(server_name="0.0.0.0", server_port=port)
 
 
1
  import os, re, io
2
  import xml.etree.ElementTree as ET
3
  import gradio as gr
4
 
5
  from hand import Hand # your handwriting model wrapper
6
 
7
+ # ---------------------------------- Setup ------------------------------------
 
 
8
  os.makedirs("img", exist_ok=True)
9
  hand = Hand()
10
 
11
+ # ------------------------------ SVG / coords ---------------------------------
 
 
12
  def _parse_viewbox(root):
 
13
  vb = root.get("viewBox")
14
  if vb:
15
  s = re.sub(r"[,\s]+", " ", vb.strip())
 
22
  return (x, y, w, h)
23
  except ValueError:
24
  pass
25
+ def _num(v, d):
26
+ if not v: return float(d)
27
  v = v.strip().lower().replace("px", "").replace(",", ".")
28
  try: return float(v)
29
+ except: return float(d)
30
  w = _num(root.get("width"), 1200.0)
31
  h = _num(root.get("height"), 400.0)
32
  return (0.0, 0.0, w, h)
 
48
  prev = elem.get("transform", "")
49
  elem.set("transform", (prev + f" translate({dx},{dy})").strip())
50
 
51
+ # ----------------------------- Tokenization ----------------------------------
 
 
52
  def _tokenize_line(line):
 
 
 
 
53
  tokens, i, n = [], 0, len(line)
54
  while i < n:
55
  ch = line[i]
 
72
  return tokens
73
 
74
  def _display_text_from_tokens(tokens):
 
75
  return "".join([v if t == "text" else " " for t, v in tokens])
76
 
77
+ # ------------------------ Rasterization & analysis ---------------------------
 
 
78
  def _rasterize_svg(svg_str, scale=3.0):
 
79
  import cairosvg
80
  from PIL import Image
81
  png = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), scale=scale, background_color="none")
82
  return Image.open(io.BytesIO(png)).convert("RGBA")
83
 
84
  def _find_blobs_and_gaps(alpha_img):
 
 
 
 
85
  w, h = alpha_img.size
86
  bbox = alpha_img.getbbox()
87
  if not bbox:
 
108
  if in_blob: blobs.append((start, right))
109
  else: gaps.append((start, right))
110
 
 
111
  core_gaps = [(blobs[i][1], blobs[i + 1][0]) for i in range(len(blobs) - 1)]
112
  return blobs, core_gaps, (left, top, right, bottom)
113
 
114
  def _column_profile(alpha_img, top, bottom):
 
115
  w, h = alpha_img.size
116
  prof = [0] * w
117
  for x in range(w):
 
123
  return prof
124
 
125
  def _synthesize_gap_near(alpha_img, content_bbox, target_x_px, min_w_px=14, search_pct=0.18):
 
 
 
 
126
  left, top, right, bottom = content_bbox
127
  prof = _column_profile(alpha_img, top, bottom)
128
  half = max(6, int((right - left) * search_pct))
129
  x0 = max(left + 1, int(target_x_px - half))
130
  x1 = min(right - 1, int(target_x_px + half))
 
131
  best_x, best_v = x0, prof[x0]
132
  for x in range(x0, x1):
133
  v = prof[x]
134
  if v < best_v:
135
  best_x, best_v = x, v
 
136
  half_w = max(7, min_w_px // 2)
137
  g0 = max(left + 1, best_x - half_w)
138
  g1 = min(right - 1, best_x + half_w)
 
142
  g1 = min(right - 1, g1 + pad)
143
  return (g0, g1)
144
 
 
145
  def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
146
  color, stroke_width, n, between_px=3.0,
147
  min_len=10.0, max_len=48.0, frac_of_gap=0.60):
 
154
  scale = gap_w / max(1.0, total_needed)
155
  u_len *= scale
156
  block_w = n * u_len + (n - 1) * between_px
157
+ x0_px = gap_px[0] + (gap_w - block_w) / 2.0 # center in gap
 
158
  for i in range(n):
159
  xs = x0_px + i * (u_len + between_px)
160
  xe = xs + u_len
 
171
 
172
  def _draw_margin_underscores(root, edge_px, side, baseline_px, img_w, img_h, vb,
173
  color, stroke_width, n, between_px=4.0, len_px=22.0, pad_px=6.0):
 
174
  if n <= 0: return
175
  for i in range(n):
176
  if side == "left":
 
190
  p.set("stroke-linecap", "round")
191
  root.append(p)
192
 
193
+ # ----------------------- Core: one line w/ underscores -----------------------
194
+ def render_line_svg_with_underscores(line, style, bias, color, stroke_width,
195
+ force_in_largest_gap=False):
196
+ # Tokenize original; render with underscores->spaces
 
 
 
 
197
  tokens = _tokenize_line(line)
198
  display_line = _display_text_from_tokens(tokens).replace("/", "-").replace("\\", "-")
199
 
200
+ # Render the full line once (keeps model spacing intact)
201
  hand.write(
202
  filename="img/line.tmp.svg",
203
  lines=[display_line if display_line.strip() else " "],
 
207
  root = ET.parse("img/line.tmp.svg").getroot()
208
  vb = _parse_viewbox(root)
209
 
210
+ # Put model paths into a group we'll augment
211
  g = ET.Element("g")
212
  for p in _extract_paths(root):
213
  g.append(p)
 
221
  line_h_px = max(20, bottom - top)
222
  baseline_px = bottom - int(0.18 * line_h_px)
223
 
224
+ # Count leading/trailing underscores
225
  leading_us = 0
226
  i = 0
227
  while i < len(tokens) and tokens[i][0] != "text":
 
235
  trailing_us += len(tokens[j][1])
236
  j -= 1
237
 
238
+ # Build clusters (positions between text runs) + underscore counts
239
  clusters = []
240
  i = 0
241
  while i < len(tokens):
 
254
  else:
255
  i += 1
256
 
257
+ words = [v for (t, v) in tokens if t == "text" and len(v) > 0]
258
  gaps_sorted_by_width = sorted(gaps, key=lambda ab: (ab[1] - ab[0]), reverse=True)
259
  used_gaps = [False] * len(gaps)
260
 
261
+ def _take_gap_for_idx(idx):
262
+ # 1) same index gap if available
263
  if 0 <= idx < len(gaps) and not used_gaps[idx]:
264
+ used_gaps[idx] = True; return gaps[idx]
265
+ # 2) widest unused real gap
 
266
  for gp in gaps_sorted_by_width:
267
  try:
268
  real_idx = gaps.index(gp)
269
  except ValueError:
270
  continue
271
  if not used_gaps[real_idx]:
272
+ used_gaps[real_idx] = True; return gp
273
+ # 3) synthesize near expected split
 
274
  seen_chars = sum(len(w) for w in words[:idx + 1]) if words else 1
275
  total_chars = sum(len(w) for w in words) or 1
276
  ratio = min(0.95, max(0.05, seen_chars / float(total_chars)))
277
  target_x_px = left + ratio * (right - left)
278
  return _synthesize_gap_near(alpha, content_bbox, target_x_px)
279
 
280
+ # Draw interior underscores
281
+ drew_any = False
 
282
  for idx, cluster in enumerate(clusters):
283
  n = int(cluster["underscores"])
284
  if n <= 0:
285
+ continue
286
+ gap_px = _take_gap_for_idx(idx)
287
  _draw_underscores_in_gap(
288
  g, gap_px, baseline_px, img_w, img_h, vb,
289
  color, stroke_width, n,
290
  between_px=3.0, min_len=10.0, max_len=48.0, frac_of_gap=0.60
291
  )
292
+ drew_any = True
293
 
294
+ # Leading/trailing
295
  if blobs:
296
  first_left = blobs[0][0]
297
  last_right = blobs[-1][1]
 
300
  g, first_left, "left", baseline_px, img_w, img_h, vb,
301
  color, stroke_width, leading_us
302
  )
303
+ drew_any = True
304
  if trailing_us:
305
  _draw_margin_underscores(
306
  g, last_right, "right", baseline_px, img_w, img_h, vb,
307
  color, stroke_width, trailing_us
308
  )
309
+ drew_any = True
310
+
311
+ # Optional: force-draw one underscore in widest gap if user asked and none drawn
312
+ if force_in_largest_gap and not drew_any and gaps:
313
+ widest = max(gaps, key=lambda ab: (ab[1] - ab[0]))
314
+ _draw_underscores_in_gap(
315
+ g, widest, baseline_px, img_w, img_h, vb,
316
+ color, stroke_width, n=1,
317
+ between_px=0.0, min_len=12.0, max_len=48.0, frac_of_gap=0.70
318
+ )
319
 
320
  width_estimate = right - left if right > left else img_w // 2
321
  return g, width_estimate
322
 
323
+ # -------------------------- Multi-line compositor ----------------------------
324
+ def generate_handwriting(text, style, bias=0.75, color="#000000", stroke_width=2,
325
+ multiline=True, force=False):
 
326
  try:
327
  lines = text.split("\n") if multiline else [text]
328
  for idx, ln in enumerate(lines):
 
335
  for i, line in enumerate(lines):
336
  g, w = render_line_svg_with_underscores(
337
  line.replace("/", "-").replace("\\", "-"),
338
+ style, bias, color, stroke_width,
339
+ force_in_largest_gap=force
340
  )
341
+ _translate_group(g, dx=40, dy=y0 + i * line_gap)
342
  svg_root.append(g)
343
  max_right = max(max_right, 40 + w)
344
 
 
347
  svg_root.set("viewBox", f"0 0 {width} {height}")
348
 
349
  svg_content = ET.tostring(svg_root, encoding="unicode")
350
+ with open("img/output.svg", "w", encoding="utf-8") as f: f.write(svg_content)
 
351
  return svg_content
 
352
  except Exception as e:
353
  return f"Error: {str(e)}"
354
 
355
+ # ------------------------------- PNG export ----------------------------------
 
 
356
  def export_to_png(svg_content):
357
  try:
358
  import cairosvg
359
  from PIL import Image
360
  if not svg_content or svg_content.startswith("Error:"):
361
  return None
 
362
  tmp_svg = "img/temp.svg"
363
+ with open(tmp_svg, "w", encoding="utf-8") as f: f.write(svg_content)
 
 
364
  cairosvg.svg2png(url=tmp_svg, write_to="img/output_temp.png", scale=2.2, background_color="none")
365
  img = Image.open("img/output_temp.png")
366
  if img.mode != "RGBA": img = img.convert("RGBA")
 
 
367
  data = img.getdata()
368
+ img.putdata([(255,255,255,0) if r>240 and g>240 and b>240 else (r,g,b,a) for (r,g,b,a) in data])
 
 
369
  out_path = "img/output.png"
370
  img.save(out_path, "PNG")
371
  try: os.remove("img/output_temp.png")
 
375
  print(f"Error converting to PNG: {str(e)}")
376
  return None
377
 
378
+ # --------------------------------- UI ----------------------------------------
379
+ def generate_handwriting_wrapper(text, style, bias, color, stroke_width, force):
380
+ svg = generate_handwriting(text, style, bias, color, stroke_width, multiline=True, force=force)
 
 
381
  png = export_to_png(svg)
382
  return svg, png, "img/output.svg"
383
 
 
387
  """
388
 
389
  with gr.Blocks(css=css) as demo:
390
+ gr.Markdown("# 🖋️ Handwriting Synthesis — Underscores in Real Gaps")
391
+ gr.Markdown("Spacing is the model’s own. We paint `_` *inside* the actual gaps. If needed, toggle force to drop one underscore into the widest gap.")
392
 
393
  with gr.Row():
394
  with gr.Column(scale=2):
 
403
  with gr.Row():
404
  color_picker = gr.ColorPicker(label="Ink Color", value="#000000")
405
  stroke_width = gr.Slider(1, 4, step=0.5, value=2, label="Stroke Width")
406
+ force_toggle = gr.Checkbox(label="Force underscore in largest gap if none drawn", value=False)
407
  generate_btn = gr.Button("Generate Handwriting", variant="primary")
408
  with gr.Column(scale=3):
409
  output_svg = gr.HTML(label="Generated Handwriting (SVG)", elem_classes=["output-container"])
 
413
 
414
  generate_btn.click(
415
  fn=generate_handwriting_wrapper,
416
+ inputs=[text_input, style_select, bias_slider, color_picker, stroke_width, force_toggle],
417
  outputs=[output_svg, output_png, download_svg_file]
418
  ).then(
419
  fn=lambda p: p,
 
421
  outputs=[download_png_file]
422
  )
423
 
424
+ # -------------------------------- main ---------------------------------------
 
 
425
  if __name__ == "__main__":
 
426
  missing = []
427
  try:
428
  import cairosvg # noqa
 
434
  missing.append("pillow")
435
  if missing:
436
  print("Install:", " ".join(missing))
437
+ demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))