3morrrrr commited on
Commit
3c198d7
·
verified ·
1 Parent(s): ae17bcc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +243 -48
app.py CHANGED
@@ -1,7 +1,7 @@
1
  import os, re, io
2
  import xml.etree.ElementTree as ET
3
  import gradio as gr
4
- from hand import Hand # your model
5
 
6
  # -----------------------------------------------------------------------------
7
  # Setup
@@ -10,14 +10,13 @@ os.makedirs("img", exist_ok=True)
10
  hand = Hand()
11
 
12
  # -----------------------------------------------------------------------------
13
- # Helpers
14
  # -----------------------------------------------------------------------------
15
  def _parse_viewbox(root):
16
  """
17
- Parse SVG viewBox robustly:
18
- - accepts "0 0 600 160" or "0,0,600,160"
19
- - falls back to width/height attributes
20
- - strips 'px'
21
  """
22
  vb = root.get("viewBox")
23
  if vb:
@@ -31,16 +30,16 @@ def _parse_viewbox(root):
31
  return (x, y, w, h)
32
  except ValueError:
33
  pass
34
- # fallback: width/height attributes
35
  def _num(v, default):
36
  if not v:
37
  return float(default)
38
- v = v.strip().lower().replace("px", "")
39
- v = v.replace(",", ".")
40
  try:
41
  return float(v)
42
  except ValueError:
43
  return float(default)
 
44
  w = _num(root.get("width"), 1200.0)
45
  h = _num(root.get("height"), 400.0)
46
  return (0.0, 0.0, w, h)
@@ -63,9 +62,13 @@ def _translate_group(elem, dx, dy):
63
  elem.set("transform", (prev + f" translate({dx},{dy})").strip())
64
 
65
  # -----------------------------------------------------------------------------
66
- # Tokenizer
67
  # -----------------------------------------------------------------------------
68
  def _tokenize_line(line):
 
 
 
 
69
  tokens, i, n = [], 0, len(line)
70
  while i < n:
71
  ch = line[i]
@@ -88,28 +91,36 @@ def _tokenize_line(line):
88
  return tokens
89
 
90
  def _display_text_from_tokens(tokens):
 
91
  return "".join([v if t == "text" else " " for t, v in tokens])
92
 
93
  # -----------------------------------------------------------------------------
94
- # Rasterize and analyze gaps
95
  # -----------------------------------------------------------------------------
96
- def _rasterize_svg(svg_str, scale=2.0):
 
97
  import cairosvg
98
  from PIL import Image
99
  png = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), scale=scale, background_color="none")
100
  return Image.open(io.BytesIO(png)).convert("RGBA")
101
 
102
  def _find_blobs_and_gaps(alpha_img):
 
 
 
 
103
  w, h = alpha_img.size
104
  bbox = alpha_img.getbbox()
105
  if not bbox:
106
  return [], [], (0, 0, w, h)
107
  left, top, right, bottom = bbox
 
108
  def col_has_ink(x):
109
  for y in range(top, bottom):
110
  if alpha_img.getpixel((x, y)) > 0:
111
  return True
112
  return False
 
113
  blobs, gaps = [], []
114
  x = left
115
  in_blob = col_has_ink(x)
@@ -123,13 +134,57 @@ def _find_blobs_and_gaps(alpha_img):
123
  x += 1
124
  if in_blob: blobs.append((start, right))
125
  else: gaps.append((start, right))
 
 
126
  core_gaps = [(blobs[i][1], blobs[i + 1][0]) for i in range(len(blobs) - 1)]
127
  return blobs, core_gaps, (left, top, right, bottom)
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
130
  color, stroke_width, n, pad_px=3, between_px=4):
 
 
 
131
  gap_w = max(0, gap_px[1] - gap_px[0] - 2 * pad_px)
132
- if gap_w <= 6 or n <= 0: return
 
133
  slot = gap_w / n
134
  line_len = max(8, int(slot * 0.85) - between_px)
135
  offset = (slot - line_len) / 2.0
@@ -139,7 +194,34 @@ def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
139
  y_px = baseline_px
140
  x0 = _px_to_svg_x(x0_px, img_w, vb)
141
  x1 = _px_to_svg_x(x1_px, img_w, vb)
142
- y = _px_to_svg_y(y_px, img_h, vb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  p = ET.Element("path")
144
  p.set("d", f"M{x0},{y} L{x1},{y}")
145
  p.set("stroke", color)
@@ -149,11 +231,19 @@ def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
149
  root.append(p)
150
 
151
  # -----------------------------------------------------------------------------
152
- # Line renderer with underscores
153
  # -----------------------------------------------------------------------------
154
  def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
 
 
 
 
 
 
155
  tokens = _tokenize_line(line)
156
  display_line = _display_text_from_tokens(tokens).replace("/", "-").replace("\\", "-")
 
 
157
  hand.write(
158
  filename="img/line.tmp.svg",
159
  lines=[display_line if display_line.strip() else " "],
@@ -162,69 +252,134 @@ def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
162
  )
163
  root = ET.parse("img/line.tmp.svg").getroot()
164
  vb = _parse_viewbox(root)
165
- img = _rasterize_svg(ET.tostring(root, encoding="unicode"))
 
 
166
  alpha = img.split()[-1]
167
  blobs, gaps, content_bbox = _find_blobs_and_gaps(alpha)
168
  img_w, img_h = img.size
169
  left, top, right, bottom = content_bbox
170
  line_h_px = max(20, bottom - top)
171
  baseline_px = bottom - int(0.18 * line_h_px)
 
 
172
  g = ET.Element("g")
173
- for p in _extract_paths(root): g.append(p)
174
- gap_idx = 0
175
- text_run_count = sum(1 for t, v in tokens if t == "text" and len(v) > 0)
176
- max_gaps = max(0, min(len(gaps), text_run_count - 1))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  i = 0
178
  while i < len(tokens):
179
  t, v = tokens[i]
180
  if t == "text" and len(v) > 0:
181
  j = i + 1
182
- underscore_count, saw_sep = 0, False
183
  while j < len(tokens) and tokens[j][0].startswith("sep_"):
184
  saw_sep = True
185
  if tokens[j][0] == "sep_underscore":
186
- underscore_count += len(tokens[j][1])
187
  j += 1
188
- if saw_sep and gap_idx < max_gaps:
189
- if underscore_count > 0:
190
- _draw_underscores_in_gap(
191
- g, gaps[gap_idx], baseline_px, img_w, img_h, vb,
192
- color, stroke_width, underscore_count
193
- )
194
- gap_idx += 1
195
  i = j
196
  else:
197
  i += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  width_estimate = right - left if right > left else img_w // 2
199
  return g, width_estimate
200
 
201
  # -----------------------------------------------------------------------------
202
- # Generate handwriting (multi-line)
203
  # -----------------------------------------------------------------------------
204
  def generate_handwriting(text, style, bias=0.75, color="#000000", stroke_width=2, multiline=True):
205
  try:
206
  lines = text.split("\n") if multiline else [text]
207
  for idx, ln in enumerate(lines):
208
  if len(ln) > 75:
209
- return f"Error: Line {idx+1} too long (max 75 chars)"
210
- svg_root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg","viewBox": "0 0 1200 800"})
 
211
  y0, line_gap, max_right = 80.0, 110.0, 0.0
 
212
  for i, line in enumerate(lines):
213
  g, w = render_line_svg_with_underscores(line, style, bias, color, stroke_width)
214
  _translate_group(g, dx=40, dy=y0 + i * line_gap)
215
  svg_root.append(g)
216
  max_right = max(max_right, 40 + w)
 
217
  height = int(y0 + len(lines) * line_gap + 80)
218
  width = max(300, int(max_right + 40))
219
  svg_root.set("viewBox", f"0 0 {width} {height}")
 
220
  svg_content = ET.tostring(svg_root, encoding="unicode")
221
- with open("img/output.svg", "w", encoding="utf-8") as f: f.write(svg_content)
 
222
  return svg_content
 
223
  except Exception as e:
224
  return f"Error: {str(e)}"
225
 
226
  # -----------------------------------------------------------------------------
227
- # Export to PNG
228
  # -----------------------------------------------------------------------------
229
  def export_to_png(svg_content):
230
  try:
@@ -232,19 +387,26 @@ def export_to_png(svg_content):
232
  from PIL import Image
233
  if not svg_content or svg_content.startswith("Error:"):
234
  return None
 
235
  tmp_svg = "img/temp.svg"
236
- with open(tmp_svg, "w", encoding="utf-8") as f: f.write(svg_content)
 
 
237
  cairosvg.svg2png(url=tmp_svg, write_to="img/output_temp.png", scale=2.0, background_color="none")
238
  img = Image.open("img/output_temp.png")
239
  if img.mode != "RGBA": img = img.convert("RGBA")
 
 
240
  datas = img.getdata()
241
- img.putdata([(255,255,255,0) if r>240 and g>240 and b>240 else (r,g,b,a)
242
- for (r,g,b,a) in datas])
 
243
  out_path = "img/output.png"
244
  img.save(out_path, "PNG")
245
  try: os.remove("img/output_temp.png")
246
  except: pass
247
  return out_path
 
248
  except Exception as e:
249
  print(f"Error converting to PNG: {str(e)}")
250
  return None
@@ -257,27 +419,60 @@ def generate_handwriting_wrapper(text, style, bias, color, stroke_width, multili
257
  png = export_to_png(svg)
258
  return svg, png, "img/output.svg"
259
 
260
- css = ".container {max-width:900px;margin:auto;} .output-container{min-height:300px;}"
 
 
 
261
 
262
  with gr.Blocks(css=css) as demo:
263
  gr.Markdown("# 🖋️ Handwriting Synthesis (Underscore-aware)")
 
 
264
  with gr.Row():
265
  with gr.Column(scale=2):
266
- text_input = gr.Textbox(label="Text Input", placeholder="Try: user_name vs zeb aasba", lines=5)
267
- style_select = gr.Slider(0, 12, step=1, value=9, label="Handwriting Style")
268
- bias_slider = gr.Slider(0.5, 1.0, step=0.05, value=0.75, label="Neatness")
269
- color_picker = gr.ColorPicker(label="Ink Color", value="#000000")
270
- stroke_width = gr.Slider(1, 4, step=0.5, value=2, label="Stroke Width")
 
 
 
 
 
 
271
  generate_btn = gr.Button("Generate Handwriting", variant="primary")
272
  with gr.Column(scale=3):
273
  output_svg = gr.HTML(label="Generated Handwriting (SVG)", elem_classes=["output-container"])
274
  output_png = gr.Image(type="filepath", label="Generated Handwriting (PNG)", elem_classes=["output-container"])
275
  download_svg_file = gr.File(label="Download SVG")
276
  download_png_file = gr.File(label="Download PNG")
277
- generate_btn.click(fn=generate_handwriting_wrapper,
278
- inputs=[text_input, style_select, bias_slider, color_picker, stroke_width],
279
- outputs=[output_svg, output_png, download_svg_file]
280
- ).then(fn=lambda p: p, inputs=[output_png], outputs=[download_png_file])
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  if __name__ == "__main__":
283
- demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os, re, io
2
  import xml.etree.ElementTree as ET
3
  import gradio as gr
4
+ from hand import Hand # your handwriting model wrapper
5
 
6
  # -----------------------------------------------------------------------------
7
  # Setup
 
10
  hand = Hand()
11
 
12
  # -----------------------------------------------------------------------------
13
+ # SVG / coord helpers
14
  # -----------------------------------------------------------------------------
15
  def _parse_viewbox(root):
16
  """
17
+ Robustly parse viewBox:
18
+ - accepts '0 0 600 160' or '0,0,600,160'
19
+ - falls back to width/height attrs (with px)
 
20
  """
21
  vb = root.get("viewBox")
22
  if vb:
 
30
  return (x, y, w, h)
31
  except ValueError:
32
  pass
33
+
34
  def _num(v, default):
35
  if not v:
36
  return float(default)
37
+ v = v.strip().lower().replace("px", "").replace(",", ".")
 
38
  try:
39
  return float(v)
40
  except ValueError:
41
  return float(default)
42
+
43
  w = _num(root.get("width"), 1200.0)
44
  h = _num(root.get("height"), 400.0)
45
  return (0.0, 0.0, w, h)
 
62
  elem.set("transform", (prev + f" translate({dx},{dy})").strip())
63
 
64
  # -----------------------------------------------------------------------------
65
+ # Tokenization
66
  # -----------------------------------------------------------------------------
67
  def _tokenize_line(line):
68
+ """
69
+ Split into [('text'|'sep_space'|'sep_underscore', value), ...]
70
+ Preserves exact underscore counts and spaces.
71
+ """
72
  tokens, i, n = [], 0, len(line)
73
  while i < n:
74
  ch = line[i]
 
91
  return tokens
92
 
93
  def _display_text_from_tokens(tokens):
94
+ """What we actually render with the model (underscores & spaces -> single spaces)."""
95
  return "".join([v if t == "text" else " " for t, v in tokens])
96
 
97
  # -----------------------------------------------------------------------------
98
+ # Rasterization & analysis
99
  # -----------------------------------------------------------------------------
100
+ def _rasterize_svg(svg_str, scale=3.0):
101
+ """Rasterize SVG string to RGBA image (higher scale = finer gap detection)."""
102
  import cairosvg
103
  from PIL import Image
104
  png = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), scale=scale, background_color="none")
105
  return Image.open(io.BytesIO(png)).convert("RGBA")
106
 
107
  def _find_blobs_and_gaps(alpha_img):
108
+ """
109
+ Return (blobs, gaps, content_bbox) using per-column alpha.
110
+ blobs/gaps are lists of x-intervals [start, end) in pixels.
111
+ """
112
  w, h = alpha_img.size
113
  bbox = alpha_img.getbbox()
114
  if not bbox:
115
  return [], [], (0, 0, w, h)
116
  left, top, right, bottom = bbox
117
+
118
  def col_has_ink(x):
119
  for y in range(top, bottom):
120
  if alpha_img.getpixel((x, y)) > 0:
121
  return True
122
  return False
123
+
124
  blobs, gaps = [], []
125
  x = left
126
  in_blob = col_has_ink(x)
 
134
  x += 1
135
  if in_blob: blobs.append((start, right))
136
  else: gaps.append((start, right))
137
+
138
+ # only interior gaps (exclude margins)
139
  core_gaps = [(blobs[i][1], blobs[i + 1][0]) for i in range(len(blobs) - 1)]
140
  return blobs, core_gaps, (left, top, right, bottom)
141
 
142
+ def _column_profile(alpha_img, top, bottom):
143
+ """Ink count per column."""
144
+ w, h = alpha_img.size
145
+ prof = [0] * w
146
+ for x in range(w):
147
+ s = 0
148
+ for y in range(top, bottom):
149
+ if alpha_img.getpixel((x, y)) > 0:
150
+ s += 1
151
+ prof[x] = s
152
+ return prof
153
+
154
+ def _synthesize_gap_near(alpha_img, content_bbox, target_x_px, min_w_px=14, search_pct=0.18):
155
+ """
156
+ Create a gap near target_x_px by finding a local 'valley' (min ink column).
157
+ Ensures at least min_w_px width.
158
+ """
159
+ left, top, right, bottom = content_bbox
160
+ prof = _column_profile(alpha_img, top, bottom)
161
+ half = max(6, int((right - left) * search_pct))
162
+ x0 = max(left + 1, int(target_x_px - half))
163
+ x1 = min(right - 1, int(target_x_px + half))
164
+
165
+ best_x, best_v = x0, prof[x0]
166
+ for x in range(x0, x1):
167
+ v = prof[x]
168
+ if v < best_v:
169
+ best_x, best_v = x, v
170
+
171
+ half_w = max(7, min_w_px // 2)
172
+ g0 = max(left + 1, best_x - half_w)
173
+ g1 = min(right - 1, best_x + half_w)
174
+ if g1 - g0 < min_w_px:
175
+ pad = (min_w_px - (g1 - g0)) // 2 + 1
176
+ g0 = max(left + 1, g0 - pad)
177
+ g1 = min(right - 1, g1 + pad)
178
+ return (g0, g1)
179
+
180
  def _draw_underscores_in_gap(root, gap_px, baseline_px, img_w, img_h, vb,
181
  color, stroke_width, n, pad_px=3, between_px=4):
182
+ """
183
+ Fill the pixel gap with n evenly spaced underscores.
184
+ """
185
  gap_w = max(0, gap_px[1] - gap_px[0] - 2 * pad_px)
186
+ if gap_w <= 6 or n <= 0:
187
+ return
188
  slot = gap_w / n
189
  line_len = max(8, int(slot * 0.85) - between_px)
190
  offset = (slot - line_len) / 2.0
 
194
  y_px = baseline_px
195
  x0 = _px_to_svg_x(x0_px, img_w, vb)
196
  x1 = _px_to_svg_x(x1_px, img_w, vb)
197
+ y = _px_to_svg_y(y_px, img_h, vb)
198
+ p = ET.Element("path")
199
+ p.set("d", f"M{x0},{y} L{x1},{y}")
200
+ p.set("stroke", color)
201
+ p.set("stroke-width", str(max(1, stroke_width)))
202
+ p.set("fill", "none")
203
+ p.set("stroke-linecap", "round")
204
+ root.append(p)
205
+
206
+ def _draw_margin_underscores(root, edge_px, side, baseline_px, img_w, img_h, vb,
207
+ color, stroke_width, n, pad_px=6, between_px=4, max_len_px=28):
208
+ """
209
+ Draw underscores left of the first blob ('left') or right of the last blob ('right')
210
+ for leading/trailing underscores like '__init__'.
211
+ """
212
+ if n <= 0: return
213
+ slot = max_len_px + between_px
214
+ for i in range(n):
215
+ if side == "left":
216
+ x1_px = edge_px - pad_px - i * slot
217
+ x0_px = x1_px - max_len_px
218
+ else:
219
+ x0_px = edge_px + pad_px + i * slot
220
+ x1_px = x0_px + max_len_px
221
+ y_px = baseline_px
222
+ x0 = _px_to_svg_x(x0_px, img_w, vb)
223
+ x1 = _px_to_svg_x(x1_px, img_w, vb)
224
+ y = _px_to_svg_y(y_px, img_h, vb)
225
  p = ET.Element("path")
226
  p.set("d", f"M{x0},{y} L{x1},{y}")
227
  p.set("stroke", color)
 
231
  root.append(p)
232
 
233
  # -----------------------------------------------------------------------------
234
+ # Render a single line with correct underscores
235
  # -----------------------------------------------------------------------------
236
  def render_line_svg_with_underscores(line, style, bias, color, stroke_width):
237
+ """
238
+ Draw the line via the model, then insert underscores:
239
+ - Use real gaps when the model produced multiple blobs.
240
+ - If merged (no gap), synthesize a gap near expected split.
241
+ - Draw leading/trailing underscores at margins.
242
+ """
243
  tokens = _tokenize_line(line)
244
  display_line = _display_text_from_tokens(tokens).replace("/", "-").replace("\\", "-")
245
+
246
+ # Render the (spaces-only) display line once
247
  hand.write(
248
  filename="img/line.tmp.svg",
249
  lines=[display_line if display_line.strip() else " "],
 
252
  )
253
  root = ET.parse("img/line.tmp.svg").getroot()
254
  vb = _parse_viewbox(root)
255
+
256
+ # Rasterize and analyze ink
257
+ img = _rasterize_svg(ET.tostring(root, encoding="unicode"), scale=3.0)
258
  alpha = img.split()[-1]
259
  blobs, gaps, content_bbox = _find_blobs_and_gaps(alpha)
260
  img_w, img_h = img.size
261
  left, top, right, bottom = content_bbox
262
  line_h_px = max(20, bottom - top)
263
  baseline_px = bottom - int(0.18 * line_h_px)
264
+
265
+ # Pack model paths into a <g>
266
  g = ET.Element("g")
267
+ for p in _extract_paths(root):
268
+ g.append(p)
269
+
270
+ # Count leading/trailing underscores (for margins)
271
+ leading_us = 0
272
+ i = 0
273
+ while i < len(tokens) and tokens[i][0] != "text":
274
+ if tokens[i][0] == "sep_underscore":
275
+ leading_us += len(tokens[i][1])
276
+ i += 1
277
+
278
+ trailing_us = 0
279
+ j = len(tokens) - 1
280
+ while j >= 0 and tokens[j][0] != "text":
281
+ if tokens[j][0] == "sep_underscore":
282
+ trailing_us += len(tokens[j][1])
283
+ j -= 1
284
+
285
+ # Between-word positions and underscore counts
286
+ positions = [] # list of underscore_count (can be 0 if just space)
287
  i = 0
288
  while i < len(tokens):
289
  t, v = tokens[i]
290
  if t == "text" and len(v) > 0:
291
  j = i + 1
292
+ u_count, saw_sep = 0, False
293
  while j < len(tokens) and tokens[j][0].startswith("sep_"):
294
  saw_sep = True
295
  if tokens[j][0] == "sep_underscore":
296
+ u_count += len(tokens[j][1])
297
  j += 1
298
+ if saw_sep:
299
+ positions.append(u_count)
 
 
 
 
 
300
  i = j
301
  else:
302
  i += 1
303
+
304
+ words = [v for (t, v) in tokens if t == "text" and len(v) > 0]
305
+ total_chars = sum(len(w) for w in words) or 1
306
+
307
+ used_gap_idx = 0
308
+ seen_chars = 0
309
+ word_idx = 0
310
+ for pos_idx, underscores_here in enumerate(positions):
311
+ if word_idx < len(words):
312
+ seen_chars += len(words[word_idx])
313
+ word_idx += 1
314
+
315
+ if underscores_here <= 0:
316
+ continue # pure space in input -> draw nothing
317
+
318
+ # Prefer detected interior gap, else synthesize near expected split
319
+ if used_gap_idx < len(gaps):
320
+ gap_px = gaps[used_gap_idx]
321
+ used_gap_idx += 1
322
+ else:
323
+ ratio = min(0.95, max(0.05, seen_chars / float(total_chars)))
324
+ target_x_px = left + ratio * (right - left)
325
+ gap_px = _synthesize_gap_near(alpha, content_bbox, target_x_px)
326
+
327
+ _draw_underscores_in_gap(
328
+ g, gap_px, baseline_px, img_w, img_h, vb,
329
+ color, stroke_width, underscores_here
330
+ )
331
+
332
+ # Margin underscores (leading/trailing)
333
+ if blobs:
334
+ first_left = blobs[0][0]
335
+ last_right = blobs[-1][1]
336
+ if leading_us:
337
+ _draw_margin_underscores(
338
+ g, first_left, "left", baseline_px, img_w, img_h, vb,
339
+ color, stroke_width, leading_us
340
+ )
341
+ if trailing_us:
342
+ _draw_margin_underscores(
343
+ g, last_right, "right", baseline_px, img_w, img_h, vb,
344
+ color, stroke_width, trailing_us
345
+ )
346
+
347
  width_estimate = right - left if right > left else img_w // 2
348
  return g, width_estimate
349
 
350
  # -----------------------------------------------------------------------------
351
+ # Generate full SVG (multi-line stacking)
352
  # -----------------------------------------------------------------------------
353
  def generate_handwriting(text, style, bias=0.75, color="#000000", stroke_width=2, multiline=True):
354
  try:
355
  lines = text.split("\n") if multiline else [text]
356
  for idx, ln in enumerate(lines):
357
  if len(ln) > 75:
358
+ return f"Error: Line {idx+1} is too long (max 75 characters)"
359
+
360
+ svg_root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "viewBox": "0 0 1200 800"})
361
  y0, line_gap, max_right = 80.0, 110.0, 0.0
362
+
363
  for i, line in enumerate(lines):
364
  g, w = render_line_svg_with_underscores(line, style, bias, color, stroke_width)
365
  _translate_group(g, dx=40, dy=y0 + i * line_gap)
366
  svg_root.append(g)
367
  max_right = max(max_right, 40 + w)
368
+
369
  height = int(y0 + len(lines) * line_gap + 80)
370
  width = max(300, int(max_right + 40))
371
  svg_root.set("viewBox", f"0 0 {width} {height}")
372
+
373
  svg_content = ET.tostring(svg_root, encoding="unicode")
374
+ with open("img/output.svg", "w", encoding="utf-8") as f:
375
+ f.write(svg_content)
376
  return svg_content
377
+
378
  except Exception as e:
379
  return f"Error: {str(e)}"
380
 
381
  # -----------------------------------------------------------------------------
382
+ # PNG export (transparent)
383
  # -----------------------------------------------------------------------------
384
  def export_to_png(svg_content):
385
  try:
 
387
  from PIL import Image
388
  if not svg_content or svg_content.startswith("Error:"):
389
  return None
390
+
391
  tmp_svg = "img/temp.svg"
392
+ with open(tmp_svg, "w", encoding="utf-8") as f:
393
+ f.write(svg_content)
394
+
395
  cairosvg.svg2png(url=tmp_svg, write_to="img/output_temp.png", scale=2.0, background_color="none")
396
  img = Image.open("img/output_temp.png")
397
  if img.mode != "RGBA": img = img.convert("RGBA")
398
+
399
+ # Make near-white fully transparent (safety)
400
  datas = img.getdata()
401
+ img.putdata([(255, 255, 255, 0) if r > 240 and g > 240 and b > 240 else (r, g, b, a)
402
+ for (r, g, b, a) in datas])
403
+
404
  out_path = "img/output.png"
405
  img.save(out_path, "PNG")
406
  try: os.remove("img/output_temp.png")
407
  except: pass
408
  return out_path
409
+
410
  except Exception as e:
411
  print(f"Error converting to PNG: {str(e)}")
412
  return None
 
419
  png = export_to_png(svg)
420
  return svg, png, "img/output.svg"
421
 
422
+ css = """
423
+ .container {max-width: 900px; margin: auto;}
424
+ .output-container {min-height: 300px;}
425
+ """
426
 
427
  with gr.Blocks(css=css) as demo:
428
  gr.Markdown("# 🖋️ Handwriting Synthesis (Underscore-aware)")
429
+ gr.Markdown("Underscores are inserted only where typed, aligned to real or synthesized gaps.")
430
+
431
  with gr.Row():
432
  with gr.Column(scale=2):
433
+ text_input = gr.Textbox(
434
+ label="Text Input",
435
+ placeholder="Try: user_name, zeb_3asba, long__name, __init__",
436
+ lines=5
437
+ )
438
+ with gr.Row():
439
+ style_select = gr.Slider(0, 12, step=1, value=9, label="Handwriting Style")
440
+ bias_slider = gr.Slider(0.5, 1.0, step=0.05, value=0.75, label="Neatness (Higher = Neater)")
441
+ with gr.Row():
442
+ color_picker = gr.ColorPicker(label="Ink Color", value="#000000")
443
+ stroke_width = gr.Slider(1, 4, step=0.5, value=2, label="Stroke Width")
444
  generate_btn = gr.Button("Generate Handwriting", variant="primary")
445
  with gr.Column(scale=3):
446
  output_svg = gr.HTML(label="Generated Handwriting (SVG)", elem_classes=["output-container"])
447
  output_png = gr.Image(type="filepath", label="Generated Handwriting (PNG)", elem_classes=["output-container"])
448
  download_svg_file = gr.File(label="Download SVG")
449
  download_png_file = gr.File(label="Download PNG")
 
 
 
 
450
 
451
+ generate_btn.click(
452
+ fn=generate_handwriting_wrapper,
453
+ inputs=[text_input, style_select, bias_slider, color_picker, stroke_width],
454
+ outputs=[output_svg, output_png, download_svg_file]
455
+ ).then(
456
+ fn=lambda p: p,
457
+ inputs=[output_png],
458
+ outputs=[download_png_file]
459
+ )
460
+
461
+ # -----------------------------------------------------------------------------
462
+ # Main
463
+ # -----------------------------------------------------------------------------
464
  if __name__ == "__main__":
465
+ # Make sure dependencies for rasterization/export are present
466
+ missing = []
467
+ try:
468
+ import cairosvg # noqa
469
+ except ImportError:
470
+ missing.append("cairosvg")
471
+ try:
472
+ from PIL import Image # noqa
473
+ except ImportError:
474
+ missing.append("pillow")
475
+ if missing:
476
+ print("Install:", " ".join(missing))
477
+ port = int(os.environ.get("PORT", 7860))
478
+ demo.launch(server_name="0.0.0.0", server_port=port)