ChevalierJoseph commited on
Commit
b2e9d6e
·
verified ·
1 Parent(s): 554bd9d

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +635 -0
  2. packages.txt +3 -0
  3. requirements.txt +13 -0
app.py ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import os, io, re, random, string, tempfile
3
+ import spaces
4
+ import gradio as gr
5
+ from PIL import Image
6
+
7
+ # --- CONSTANTES ---
8
+ POTRACE_BIN = 'potrace'
9
+ UPM = 1000
10
+ CROP = 8
11
+ SIDEBEARING = 20
12
+ GLYPH_SCALE = 0.112
13
+ PRECISION = 1
14
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
15
+ DEFAULT_IMG_PROMPT = "Design a custom typeface that takes direct inspiration on the attached control image. The font should faithfully replicate the unique style of the reference."
16
+
17
+ # --- MAPPINGS 6x6 ---
18
+ MAP_UC = [['A','B','C','D','E','F'],['G','H','I','J','K','L'],['M','N','O','P','Q','R'],['S','T','U','V','W','X'],['Y','Z','Æ','Œ','Ø','ß'],['Ç','.',',',';','?','!']]
19
+ MAP_LC = [['a','b','c','d','e','f'],['g','h','i','j','k','l'],['m','n','o','p','q','r'],['s','t','u','v','w','x'],['y','z','æ','œ','ø','Ð'],['ç',':',"'",'"','«','<']]
20
+ MAP_PUNC = [['1','2','3','4','5','6'],['7','8','9','0','#','%'],['(','[','{','$','€','£'],['&','@','_','+','-','='],['*','/','^','°','→','—'],['`','´','ˆ','¨','˜','•']]
21
+
22
+ # --- RÉFÉRENCES DE BASELINE PAR GRILLE ---
23
+ BASELINE_REFS = {
24
+ 0: ['H', 'I', 'E', 'A', 'B', 'D', 'F', 'L', 'M', 'N', 'P', 'R', 'T', 'U', 'V', 'X', 'Y', 'Z'],
25
+ 1: ['n', 'm', 'u', 'x', 'h', 'i', 'l', 'k', 'r', 'v', 'w', 'z', 'a', 'e', 'o'],
26
+ 2: ['1', '0', '2', '4', '7', '8', '9', '#', '%', '$', '£', '&', '@', '+', '='],
27
+ }
28
+
29
+ # --- TRAITEMENT SVG ---
30
+ def simplify_svg_path(d):
31
+ from fontTools.pens.recordingPen import RecordingPen
32
+ from fontTools.pens.svgPathPen import SVGPathPen
33
+ from fontTools.svgLib.path import parse_path
34
+ rec = RecordingPen()
35
+ parse_path(d, rec)
36
+ svg = SVGPathPen(None)
37
+ rec.replay(svg)
38
+ return re.sub(r"(\d+\.\d+)", lambda m: f"{float(m.group(1)):.{PRECISION}f}", svg.getCommands())
39
+
40
+ def get_char_path_rdp(pil_img, upscale=2):
41
+ import tempfile, subprocess
42
+ import cv2, numpy as np
43
+ rgb = np.array(pil_img.convert("RGB"))
44
+ black_mask = (
45
+ (rgb[:,:,0] < 80) &
46
+ (rgb[:,:,1] < 80) &
47
+ (rgb[:,:,2] < 80)
48
+ ).astype(np.uint8) * 255
49
+ h, w = black_mask.shape
50
+ up = cv2.resize(black_mask, (w*upscale, h*upscale), interpolation=cv2.INTER_NEAREST)
51
+ with tempfile.NamedTemporaryFile(suffix='.pbm', delete=False) as f:
52
+ pbm = f.name
53
+ with tempfile.NamedTemporaryFile(suffix='.svg', delete=False) as f:
54
+ svg = f.name
55
+ Image.fromarray(cv2.flip(255-up, 0)).convert('1').save(pbm)
56
+ try:
57
+ subprocess.run([POTRACE_BIN, pbm, '-s', '-o', svg, '--alphamax', '0.9', '--opttolerance', '0.5', '--turdsize', '30'], check=True, capture_output=True)
58
+ except:
59
+ return ""
60
+ finally:
61
+ if os.path.exists(pbm):
62
+ os.unlink(pbm)
63
+ if not os.path.exists(svg):
64
+ return ""
65
+ with open(svg) as f:
66
+ content = f.read()
67
+ os.unlink(svg)
68
+ paths = re.findall(r'd="([^"]+)"', content)
69
+ return ' '.join(simplify_svg_path(p) for p in paths).strip() if paths else ""
70
+
71
+ # --- CONSTRUCTION OTF ---
72
+ MIRROR_MAP = {')': '(', ']': '[', '}': '{'}
73
+
74
+ ACCENT_MAP = {
75
+ 'à':('a','`'),'è':('e','`'),'ì':('i','`'),'ò':('o','`'),'ù':('u','`'),
76
+ 'À':('A','`'),'È':('E','`'),'Ì':('I','`'),'Ò':('O','`'),'Ù':('U','`'),
77
+ 'á':('a','´'),'é':('e','´'),'í':('i','´'),'ó':('o','´'),'ú':('u','´'),
78
+ 'Á':('A','´'),'É':('E','´'),'Í':('I','´'),'Ó':('O','´'),'Ú':('U','´'),
79
+ 'â':('a','ˆ'),'ê':('e','ˆ'),'î':('i','ˆ'),'ô':('o','ˆ'),'û':('u','ˆ'),
80
+ 'Â':('A','ˆ'),'Ê':('E','ˆ'),'Î':('I','ˆ'),'Ô':('O','ˆ'),'Û':('U','ˆ'),
81
+ 'ä':('a','¨'),'ë':('e','¨'),'ï':('i','¨'),'ö':('o','¨'),'ü':('u','¨'),
82
+ 'Ä':('A','¨'),'Ë':('E','¨'),'Ï':('I','¨'),'Ö':('O','¨'),'Ü':('U','¨'),
83
+ 'ã':('a','˜'),'õ':('o','˜'),'ñ':('n','˜'),
84
+ 'Ã':('A','˜'),'Õ':('O','˜'),'Ñ':('N','˜'),
85
+ }
86
+
87
+ def build_otf(images, font_name):
88
+ from fontTools.fontBuilder import FontBuilder
89
+ from fontTools.pens.t2CharStringPen import T2CharStringPen
90
+ from fontTools.pens.boundsPen import BoundsPen
91
+ from fontTools.pens.transformPen import TransformPen
92
+ from fontTools.pens.recordingPen import RecordingPen
93
+ from fontTools.misc.transform import Identity
94
+ from fontTools.svgLib.path import parse_path
95
+ from fontTools.cffLib import PrivateDict
96
+ from fontTools.ttLib import newTable
97
+ from fontTools.ttLib.tables import _k_e_r_n as _kern
98
+ import tempfile, subprocess, cv2, numpy as np
99
+ from PIL import Image, ImageDraw
100
+
101
+ all_maps = [MAP_UC, MAP_LC, MAP_PUNC]
102
+ glyph_data = {}
103
+
104
+ for idx, img in enumerate(images):
105
+ w, h = img.size
106
+ arr = np.array(img.convert("RGB"))
107
+ arr_up = cv2.resize(arr, (w*2, h*2), interpolation=cv2.INTER_CUBIC)
108
+ img_up = Image.fromarray(arr_up)
109
+ w2, h2 = img_up.size
110
+ cw, ch = w2//6, h2//6
111
+ scale = 800/ch
112
+ current_map = all_maps[idx]
113
+ for r in range(6):
114
+ for c in range(6):
115
+ char = current_map[r][c]
116
+ box = (c*cw, r*ch, (c+1)*cw, (r+1)*ch)
117
+ d = get_char_path_rdp(img_up.crop(box), upscale=1)
118
+ if not d:
119
+ continue
120
+ bp = BoundsPen(None)
121
+ try:
122
+ parse_path(d, bp)
123
+ except:
124
+ continue
125
+ if bp.bounds:
126
+ glyph_data[char] = {'d': d, 'b': bp.bounds, 'scale': scale, 'grid': idx}
127
+
128
+ # Baseline calculée
129
+ baseline_per_grid = {}
130
+ for grid_idx in range(len(all_maps)):
131
+ chosen_ref = None
132
+ for ref_char in BASELINE_REFS.get(grid_idx, []):
133
+ if ref_char in glyph_data and glyph_data[ref_char].get('grid') == grid_idx:
134
+ gd = glyph_data[ref_char]
135
+ baseline_per_grid[grid_idx] = gd['b'][3] * gd['scale'] * GLYPH_SCALE
136
+ chosen_ref = ref_char
137
+ break
138
+ if chosen_ref is None:
139
+ baseline_per_grid[grid_idx] = baseline_per_grid.get(0, 0)
140
+ print(f"⚠️ Grille {grid_idx} : aucun caractère de référence trouvé, fallback = {baseline_per_grid[grid_idx]:.1f}")
141
+ else:
142
+ print(f"📐 Grille {grid_idx} : baseline = {baseline_per_grid[grid_idx]:.1f} (réf '{chosen_ref}')")
143
+
144
+ def get_baseline(char):
145
+ if char in glyph_data:
146
+ return baseline_per_grid.get(glyph_data[char]['grid'], baseline_per_grid.get(0, 0))
147
+ return baseline_per_grid.get(0, 0)
148
+
149
+ dummy = PrivateDict()
150
+ dummy.nominalWidthX = 0
151
+ cs = {".notdef": T2CharStringPen(600, None).getCharString(private=dummy)}
152
+ metrics = {".notdef": (600, 0)}
153
+ cmap, order = {}, [".notdef"]
154
+ cs["space"] = T2CharStringPen(300, None).getCharString(private=dummy)
155
+ metrics["space"] = (300, 0)
156
+ order.append("space")
157
+ cmap[32] = "space"
158
+
159
+ font_recordings = {}
160
+ font_bounds = {}
161
+ font_widths = {}
162
+ font_centers = {}
163
+
164
+ for char, data in glyph_data.items():
165
+ if len(char) != 1:
166
+ continue
167
+ b, s = data['b'], data['scale']
168
+ width = round((b[2]-b[0]) * s * GLYPH_SCALE + SIDEBEARING * 2)
169
+ tx = SIDEBEARING - b[0] * s * GLYPH_SCALE
170
+ ty = get_baseline(char)
171
+ transform = Identity.translate(tx, ty).scale(s * GLYPH_SCALE, -s * GLYPH_SCALE)
172
+ rec = RecordingPen()
173
+ parse_path(data['d'], TransformPen(rec, transform))
174
+ font_recordings[char] = rec.value
175
+ font_widths[char] = width
176
+ bp2 = BoundsPen(None)
177
+ rec2 = RecordingPen()
178
+ rec2.value = rec.value
179
+ rec2.replay(bp2)
180
+ bounds = bp2.bounds or (0, 0, width, 800)
181
+ font_bounds[char] = bounds
182
+ font_centers[char] = ((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2)
183
+ pen = T2CharStringPen(width, None)
184
+ rec3 = RecordingPen()
185
+ rec3.value = rec.value
186
+ rec3.replay(pen)
187
+ glyph_name = f"glyph{ord(char)}"
188
+ cs[glyph_name] = pen.getCharString(private=dummy)
189
+ order.append(glyph_name)
190
+ cmap[ord(char)] = glyph_name
191
+ metrics[glyph_name] = (width, 0)
192
+
193
+ # Miroirs
194
+ for dst_char, src_char in MIRROR_MAP.items():
195
+ if src_char not in font_recordings:
196
+ continue
197
+ w = font_widths[src_char]
198
+ pen = T2CharStringPen(w, None)
199
+ flip = TransformPen(pen, (-1, 0, 0, 1, w, 0))
200
+ rec = RecordingPen()
201
+ rec.value = font_recordings[src_char]
202
+ rec.replay(flip)
203
+ gn = f"glyph{ord(dst_char)}"
204
+ cs[gn] = pen.getCharString(private=dummy)
205
+ order.append(gn)
206
+ cmap[ord(dst_char)] = gn
207
+ metrics[gn] = (w, 0)
208
+ if src_char in font_centers:
209
+ cx, cy = font_centers[src_char]
210
+ font_centers[dst_char] = (w - cx, cy)
211
+
212
+ # Accentués
213
+ for dst_char, (base_char, accent_char) in ACCENT_MAP.items():
214
+ if base_char not in font_recordings or accent_char not in font_recordings:
215
+ continue
216
+ w = font_widths[base_char]
217
+ bb = font_bounds[base_char]
218
+ ab = font_bounds[accent_char]
219
+ dx = ((bb[0]+bb[2]) - (ab[0]+ab[2])) / 2
220
+ dy = bb[3] - ab[1] + 40
221
+ pen = T2CharStringPen(w, None)
222
+ rec1 = RecordingPen()
223
+ rec1.value = font_recordings[base_char]
224
+ rec1.replay(pen)
225
+ rec2 = RecordingPen()
226
+ rec2.value = font_recordings[accent_char]
227
+ rec2.replay(TransformPen(pen, (1, 0, 0, 1, dx, dy)))
228
+ gn = f"glyph{ord(dst_char)}"
229
+ cs[gn] = pen.getCharString(private=dummy)
230
+ order.append(gn)
231
+ cmap[ord(dst_char)] = gn
232
+ metrics[gn] = (w, 0)
233
+ if base_char in font_centers and accent_char in font_centers:
234
+ bx, by = font_centers[base_char]
235
+ ax, ay = font_centers[accent_char]
236
+ font_centers[dst_char] = (bx + dx, by + dy)
237
+
238
+ # Miroirs bracket
239
+ MIRROR_PAIRS = {'(': ')', '[': ']', '{': '}'}
240
+ for src_char, dst_char in MIRROR_PAIRS.items():
241
+ if src_char not in glyph_data:
242
+ continue
243
+ data = glyph_data[src_char]
244
+ b, s = data['b'], data['scale']
245
+ width = round((b[2]-b[0]) * s * GLYPH_SCALE + SIDEBEARING * 2)
246
+ pen = T2CharStringPen(width, None)
247
+ tx = SIDEBEARING + b[2] * s * GLYPH_SCALE
248
+ ty = get_baseline(src_char)
249
+ transform = Identity.translate(tx, ty).scale(-s * GLYPH_SCALE, -s * GLYPH_SCALE)
250
+ parse_path(data['d'], TransformPen(pen, transform))
251
+ glyph_name = f"glyph{ord(dst_char)}"
252
+ if glyph_name not in cs:
253
+ cs[glyph_name] = pen.getCharString(private=dummy)
254
+ order.append(glyph_name)
255
+ cmap[ord(dst_char)] = glyph_name
256
+ metrics[glyph_name] = (width, 0)
257
+ if src_char in font_centers:
258
+ cx, cy = font_centers[src_char]
259
+ font_centers[dst_char] = (width - cx, cy)
260
+
261
+ # Alias accents combinants
262
+ ACCENT_ALIASES = {
263
+ '`': [0x0060, 0x0300], '´': [0x00B4, 0x0301],
264
+ 'ˆ': [0x02C6, 0x0302], '¨': [0x00A8, 0x0308], '˜': [0x02DC, 0x0303],
265
+ }
266
+ for base_char, codepoints in ACCENT_ALIASES.items():
267
+ if base_char not in glyph_data:
268
+ continue
269
+ existing_glyph_name = f"glyph{ord(base_char)}"
270
+ if existing_glyph_name in cs:
271
+ for cp in codepoints[1:]:
272
+ if cp not in cmap:
273
+ cmap[cp] = existing_glyph_name
274
+
275
+ fb = FontBuilder(UPM, isTTF=False)
276
+ fb.setupGlyphOrder(order)
277
+ fb.setupCharacterMap(cmap)
278
+ fb.setupCFF(
279
+ font_name,
280
+ {"FullName": font_name, "FamilyName": font_name, "Weight": "Regular"},
281
+ cs,
282
+ {font_name: dummy}
283
+ )
284
+ fb.setupHorizontalMetrics(metrics)
285
+ fb.setupHorizontalHeader(ascent=REF["asc"], descent=REF["dsc"])
286
+ fb.setupNameTable({
287
+ "familyName": font_name,
288
+ "styleName": "Regular",
289
+ "uniqueFontIdentifier": f"{font_name}:Version 1.000",
290
+ "fullName": font_name,
291
+ "version": "Version 1.000",
292
+ "psName": font_name
293
+ })
294
+ fb.setupOS2(
295
+ sTypoAscender=REF["tAsc"],
296
+ sTypoDescender=REF["tDsc"],
297
+ sTypoLineGap=REF["tGap"],
298
+ usWinAscent=REF["wAsc"],
299
+ usWinDescent=REF["wDsc"],
300
+ sxHeight=REF["xH"],
301
+ sCapHeight=REF["cH"],
302
+ fsType=4,
303
+ fsSelection=64
304
+ )
305
+ fb.setupPost(italicAngle=0, underlinePosition=REF["ulP"], underlineThickness=REF["ulT"])
306
+
307
+ # -------------------------------------------------------------------------
308
+ # BUBBLE KERNING — vectoriel pur, écrit en GPOS (pas de limite de paires)
309
+ # -------------------------------------------------------------------------
310
+ try:
311
+ import numpy as np
312
+ from fontTools.otlLib.builder import buildValue, buildPairPosGlyphs
313
+ from fontTools.ttLib import newTable
314
+ from fontTools.ttLib.tables import otTables
315
+
316
+ # --- Paramètres ---
317
+ BUBBLE_RADIUS = 10 # rayon de la bulle en UPM, doit être < SIDEBEARING (20)
318
+ N_SAMPLES = 300 # points échantillonnés par contour
319
+ KERN_THRESHOLD = -2 # paires dont kern ≥ seuil ignorées (bruit sub-UPM)
320
+ KERN_CAP = -280 # plancher anti-collision
321
+
322
+ def sample_contour(recording, n=N_SAMPLES):
323
+ pts = []
324
+
325
+ def add_line(p0, p1):
326
+ d = ((p1[0]-p0[0])**2 + (p1[1]-p0[1])**2) ** 0.5
327
+ steps = max(1, int(d / 8))
328
+ for i in range(steps + 1):
329
+ t = i / steps
330
+ pts.append((p0[0] + t*(p1[0]-p0[0]),
331
+ p0[1] + t*(p1[1]-p0[1])))
332
+
333
+ def add_cubic(p0, p1, p2, p3):
334
+ d = ((p3[0]-p0[0])**2 + (p3[1]-p0[1])**2) ** 0.5
335
+ steps = max(4, int(d * 1.5 / 8))
336
+ for i in range(steps + 1):
337
+ t = i / steps
338
+ mt = 1.0 - t
339
+ bx = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0]
340
+ by = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1]
341
+ pts.append((bx, by))
342
+
343
+ cur = (0.0, 0.0)
344
+ start = (0.0, 0.0)
345
+ for op, args in recording:
346
+ if op == 'moveTo':
347
+ cur = args[0]; start = cur
348
+ elif op == 'lineTo':
349
+ add_line(cur, args[0]); cur = args[0]
350
+ elif op == 'curveTo':
351
+ add_cubic(cur, args[0], args[1], args[2]); cur = args[2]
352
+ elif op in ('endPath', 'closePath'):
353
+ if cur != start:
354
+ add_line(cur, start)
355
+ cur = start
356
+
357
+ if not pts:
358
+ return None
359
+ arr = np.array(pts, dtype=np.float32)
360
+ if len(arr) > n:
361
+ idx = np.round(np.linspace(0, len(arr)-1, n)).astype(int)
362
+ arr = arr[idx]
363
+ return arr
364
+
365
+ # Échantillonnage
366
+ chars_available = [c for c in font_recordings if len(c) == 1]
367
+ contour_points = {}
368
+ for char in chars_available:
369
+ pts = sample_contour(font_recordings[char])
370
+ if pts is not None and len(pts) >= 2:
371
+ contour_points[char] = pts
372
+
373
+ print(f"📐 Contours échantillonnés : {len(contour_points)} glyphes")
374
+
375
+ # Calcul all-to-all
376
+ diameter = BUBBLE_RADIUS * 2.0
377
+ gpos_pairs = {} # (glyph_name_L, glyph_name_R) → kern_upm
378
+
379
+ for l_char in contour_points:
380
+ pts_L = contour_points[l_char]
381
+ adv_L = float(font_widths[l_char])
382
+ lg = f"glyph{ord(l_char)}"
383
+ if lg not in metrics:
384
+ continue
385
+
386
+ for r_char in contour_points:
387
+ rg = f"glyph{ord(r_char)}"
388
+ if rg not in metrics:
389
+ continue
390
+
391
+ pts_R_shifted = contour_points[r_char] + np.array([adv_L, 0.0], dtype=np.float32)
392
+ diff = pts_L[:, np.newaxis, :] - pts_R_shifted[np.newaxis, :, :]
393
+ min_dist = float(np.sqrt((diff**2).sum(axis=2)).min())
394
+ kern_upm = int(round(diameter - min_dist))
395
+
396
+ if l_char in ('A','V','W') and r_char in ('A','V','W'):
397
+ print(f" {l_char}→{r_char} : min_dist={min_dist:.1f} kern={kern_upm}")
398
+
399
+ if kern_upm < KERN_THRESHOLD:
400
+ gpos_pairs[(lg, rg)] = max(kern_upm, KERN_CAP)
401
+
402
+ print(f"✅ Bubble Kerning vectoriel : {len(gpos_pairs)} paires")
403
+
404
+ if gpos_pairs:
405
+ val0 = buildValue({})
406
+ pairs_for_builder = {
407
+ (lg, rg): (buildValue({"XAdvance": kern}), val0)
408
+ for (lg, rg), kern in gpos_pairs.items()
409
+ }
410
+
411
+ glyph_map = fb.font.getReverseGlyphMap()
412
+ subtables = buildPairPosGlyphs(pairs_for_builder, glyph_map)
413
+
414
+ lookup = otTables.Lookup()
415
+ lookup.LookupType = 2
416
+ lookup.LookupFlag = 0
417
+ lookup.SubTableCount = len(subtables)
418
+ lookup.SubTable = subtables
419
+
420
+ lookup_list = otTables.LookupList()
421
+ lookup_list.Lookup = [lookup]
422
+ lookup_list.LookupCount = 1
423
+
424
+ feature_record = otTables.FeatureRecord()
425
+ feature_record.FeatureTag = "kern"
426
+ feature = otTables.Feature()
427
+ feature.LookupListIndex = [0]
428
+ feature.LookupCount = 1
429
+ feature_record.Feature = feature
430
+
431
+ lang_sys = otTables.DefaultLangSys()
432
+ lang_sys.ReqFeatureIndex = 0xFFFF
433
+ lang_sys.FeatureIndex = [0]
434
+ lang_sys.FeatureCount = 1
435
+ lang_sys.LookupOrderOffset = 0
436
+
437
+ script = otTables.Script()
438
+ script.DefaultLangSys = lang_sys
439
+ script.LangSysCount = 0
440
+ script.LangSysRecord = []
441
+
442
+ script_record = otTables.ScriptRecord()
443
+ script_record.ScriptTag = "DFLT"
444
+ script_record.Script = script
445
+
446
+ script_list = otTables.ScriptList()
447
+ script_list.ScriptRecord = [script_record]
448
+ script_list.ScriptCount = 1
449
+
450
+ feature_list = otTables.FeatureList()
451
+ feature_list.FeatureRecord = [feature_record]
452
+ feature_list.FeatureCount = 1
453
+
454
+ gpos_table = otTables.GPOS()
455
+ gpos_table.Version = 0x00010000
456
+ gpos_table.ScriptList = script_list
457
+ gpos_table.FeatureList = feature_list
458
+ gpos_table.LookupList = lookup_list
459
+
460
+ gpos = newTable("GPOS")
461
+ gpos.table = gpos_table
462
+ fb.font["GPOS"] = gpos
463
+ print(f"✅ GPOS écrit : {len(gpos_pairs)} paires kern")
464
+
465
+ except Exception as e:
466
+ print(f"⚠️ Bubble Kerning failed: {e}")
467
+ import traceback
468
+ traceback.print_exc()
469
+
470
+ # --- Sauvegarde finale en OTF ---
471
+ tmp_otf = f"/tmp/{font_name}.otf"
472
+ fb.save(tmp_otf)
473
+ with open(tmp_otf, "rb") as f:
474
+ data = f.read()
475
+ os.remove(tmp_otf)
476
+ return data
477
+
478
+ # --- MÉTRIQUES DE RÉFÉRENCE (Helvetica LT Std Regular) ---
479
+ REF = {
480
+ "asc": 718, "dsc": -282, "tAsc": 718, "tDsc": -282, "tGap": 200,
481
+ "wAsc": 931, "wDsc": 225, "xH": 524, "cH": 718, "ulP": -75, "ulT": 50
482
+ }
483
+
484
+ # =============================================================================
485
+ # CHARGEMENT DU MODÈLE (au scope module, une seule fois au démarrage du Space)
486
+ # ZeroGPU : .to("cuda") ici est OK, l'init CUDA réelle est différée par `spaces`.
487
+ # =============================================================================
488
+ import torch
489
+ from diffusers import Flux2KleinPipeline
490
+ from huggingface_hub import login
491
+
492
+ if HF_TOKEN:
493
+ login(token=HF_TOKEN)
494
+
495
+ print("🚀 Loading Flux.2-klein + LoRAs ...")
496
+ pipe = Flux2KleinPipeline.from_pretrained(
497
+ "black-forest-labs/FLUX.2-klein-base-4B",
498
+ torch_dtype=torch.bfloat16,
499
+ )
500
+ pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaMAJ.safetensors", adapter_name="uc")
501
+ pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaMIN.safetensors", adapter_name="lc")
502
+ pipe.load_lora_weights("ChevalierJoseph/TYPOTOPIA_APP", weight_name="typotopiaPONCT.safetensors", adapter_name="punc")
503
+ pipe.to("cuda")
504
+ print("✅ Modèle prêt !")
505
+
506
+
507
+ # =============================================================================
508
+ # INFÉRENCE GPU — uniquement les 3 passes FLUX sous @spaces.GPU
509
+ # duration = temps max d'allocation GPU (compte sur le quota ZeroGPU).
510
+ # =============================================================================
511
+ @spaces.GPU(duration=180)
512
+ def run_pipeline(enhanced, input_img, seed):
513
+ gen = torch.Generator("cuda").manual_seed(int(seed))
514
+
515
+ pipe.set_adapters("uc")
516
+ img1 = pipe(
517
+ prompt=f"[typotopiaMAJ], {enhanced}",
518
+ image=input_img,
519
+ num_inference_steps=15,
520
+ guidance_scale=7 if input_img else 3.5,
521
+ height=1536, width=1536,
522
+ generator=gen,
523
+ ).images[0]
524
+
525
+ pipe.set_adapters("lc")
526
+ img2 = pipe(
527
+ prompt="[typotopiaMIN], Design a custom lowercase typeface inspired by the attached control image.",
528
+ image=img1,
529
+ num_inference_steps=15,
530
+ guidance_scale=7,
531
+ height=1536, width=1536,
532
+ generator=gen,
533
+ ).images[0]
534
+
535
+ pipe.set_adapters("punc")
536
+ img3 = pipe(
537
+ prompt="[typotopiaPONCT], Design a custom punctuation typeface inspired by the attached control image.",
538
+ image=img1,
539
+ num_inference_steps=15,
540
+ guidance_scale=7,
541
+ height=1536, width=1536,
542
+ generator=gen,
543
+ ).images[0]
544
+
545
+ return img1, img2, img3
546
+
547
+
548
+ # =============================================================================
549
+ # ORCHESTRATEUR + UI GRADIO
550
+ # =============================================================================
551
+ def _render_preview(otf_path, font_name):
552
+ try:
553
+ from PIL import Image, ImageDraw, ImageFont
554
+ sample = f"{font_name}\nAa Bb Cc 123\nThe quick brown fox"
555
+ img = Image.new("RGB", (1100, 520), "white")
556
+ draw = ImageDraw.Draw(img)
557
+ font = ImageFont.truetype(otf_path, 90)
558
+ draw.multiline_text((40, 40), sample, font=font, fill="black", spacing=24)
559
+ return img
560
+ except Exception as e:
561
+ print(f"⚠️ Aperçu impossible: {e}")
562
+ return None
563
+
564
+
565
+ def generate(prompt, control_image, seed, random_seed, progress=gr.Progress(track_tqdm=True)):
566
+ if random_seed or seed is None:
567
+ seed = random.randint(0, 2**32 - 1)
568
+ seed = int(seed)
569
+
570
+ input_img = None
571
+ prefixes = ["Vex","Aur","Kyr","Lux","Nox","Arc","Sol","Vel","Fen","Zor","Cal","Dex","Ora","Pax","Rux"]
572
+ suffixes = ["ra","is","on","us","ia","el","an","ix","em","or","al","en","ax","um","yr"]
573
+ font_name = random.choice(prefixes) + random.choice(suffixes)
574
+
575
+ if control_image is not None:
576
+ input_img = control_image.convert("RGB").resize((1536, 1536))
577
+ enhanced = DEFAULT_IMG_PROMPT
578
+ else:
579
+ if not (prompt and prompt.strip()):
580
+ raise gr.Error("Donne un prompt OU une image de contrôle.")
581
+ enhanced = prompt # input utilisateur brut, aucun raffinement
582
+
583
+ # --- GPU : 3 passes FLUX ---
584
+ img1, img2, img3 = run_pipeline(enhanced, input_img, seed)
585
+
586
+ if not font_name:
587
+ font_name = "Font" + "".join(random.choices(string.ascii_uppercase, k=3))
588
+
589
+ # --- CPU : vectorisation potrace + build OTF ---
590
+ otf_bytes = build_otf([img1, img2, img3], font_name)
591
+
592
+ out_dir = tempfile.mkdtemp()
593
+ otf_path = os.path.join(out_dir, f"{font_name}.otf")
594
+ with open(otf_path, "wb") as f:
595
+ f.write(otf_bytes)
596
+
597
+ gallery = [(img1, "Majuscules"), (img2, "Minuscules"), (img3, "Ponctuation")]
598
+ preview = _render_preview(otf_path, font_name)
599
+ status = f"✅ **{font_name}** — seed `{seed}`"
600
+ return gallery, preview, otf_path, status
601
+
602
+
603
+ with gr.Blocks(title="Typotopia", theme=gr.themes.Soft()) as demo:
604
+ gr.Markdown(
605
+ "# 🔤 Typotopia\n"
606
+ "Génère une police OpenType complète (.otf) depuis un **prompt** ou une "
607
+ "**image de contrôle**. FLUX.2-klein + LoRAs (ZeroGPU) → vectorisation potrace "
608
+ "→ fontTools avec kerning vectoriel."
609
+ )
610
+ with gr.Row():
611
+ with gr.Column(scale=1):
612
+ prompt = gr.Textbox(
613
+ label="Prompt",
614
+ placeholder="ex. a bold geometric sans inspired by brutalist concrete signage",
615
+ lines=3,
616
+ )
617
+ control_image = gr.Image(label="Image de contrôle (optionnelle)", type="pil")
618
+ with gr.Row():
619
+ seed = gr.Number(label="Seed", value=0, precision=0)
620
+ random_seed = gr.Checkbox(label="Seed aléatoire", value=True)
621
+ btn = gr.Button("Générer la police", variant="primary")
622
+ with gr.Column(scale=1):
623
+ status = gr.Markdown()
624
+ preview = gr.Image(label="Aperçu", type="pil")
625
+ gallery = gr.Gallery(label="Grilles générées", columns=3, height="auto")
626
+ otf_file = gr.File(label="Télécharger le .otf")
627
+
628
+ btn.click(
629
+ generate,
630
+ inputs=[prompt, control_image, seed, random_seed],
631
+ outputs=[gallery, preview, otf_file, status],
632
+ )
633
+
634
+ if __name__ == "__main__":
635
+ demo.queue().launch()
packages.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ potrace
2
+ libgl1
3
+ libglib2.0-0
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ spaces
2
+ gradio
3
+ torch
4
+ diffusers
5
+ transformers
6
+ accelerate
7
+ sentencepiece
8
+ huggingface_hub
9
+ peft
10
+ opencv-python-headless
11
+ numpy
12
+ Pillow
13
+ fonttools