riazmo commited on
Commit
d2eee40
·
verified ·
1 Parent(s): 4ab81ad

Upload preview_generator.py

Browse files
Files changed (1) hide show
  1. core/preview_generator.py +548 -0
core/preview_generator.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Preview Generator for Typography and Color Ramps
3
+
4
+ Generates HTML previews for:
5
+ 1. Typography - Actual font rendering with detected styles
6
+ 2. Color Ramps - 11 shades (50-950) with AA compliance indicators
7
+ """
8
+
9
+ from typing import Optional
10
+ import colorsys
11
+ import re
12
+
13
+
14
+ # =============================================================================
15
+ # TYPOGRAPHY PREVIEW
16
+ # =============================================================================
17
+
18
+ def generate_typography_preview_html(
19
+ typography_tokens: dict,
20
+ font_family: str = "Open Sans",
21
+ background: str = "#FAFAFA",
22
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
23
+ ) -> str:
24
+ """
25
+ Generate HTML preview for typography tokens.
26
+
27
+ Args:
28
+ typography_tokens: Dict of typography styles {name: {font_size, font_weight, line_height, letter_spacing}}
29
+ font_family: Primary font family detected
30
+ background: Background color (neutral)
31
+ sample_text: Text to render for preview
32
+
33
+ Returns:
34
+ HTML string for Gradio HTML component
35
+ """
36
+
37
+ # Sort tokens by font size (largest first)
38
+ sorted_tokens = []
39
+ for name, token in typography_tokens.items():
40
+ size_str = str(token.get("font_size", "16px"))
41
+ size_num = float(re.sub(r'[^0-9.]', '', size_str) or 16)
42
+ sorted_tokens.append((name, token, size_num))
43
+
44
+ sorted_tokens.sort(key=lambda x: -x[2]) # Descending by size
45
+
46
+ # Generate rows
47
+ rows_html = ""
48
+ for name, token, size_num in sorted_tokens[:15]: # Limit to 15 styles
49
+ font_size = token.get("font_size", "16px")
50
+ font_weight = token.get("font_weight", "400")
51
+ line_height = token.get("line_height", "1.5")
52
+ letter_spacing = token.get("letter_spacing", "0")
53
+
54
+ # Convert weight names to numbers
55
+ weight_map = {
56
+ "thin": 100, "extralight": 200, "light": 300, "regular": 400,
57
+ "medium": 500, "semibold": 600, "bold": 700, "extrabold": 800, "black": 900
58
+ }
59
+ if isinstance(font_weight, str) and font_weight.lower() in weight_map:
60
+ font_weight = weight_map[font_weight.lower()]
61
+
62
+ # Weight label
63
+ weight_labels = {
64
+ 100: "Thin", 200: "ExtraLight", 300: "Light", 400: "Regular",
65
+ 500: "Medium", 600: "SemiBold", 700: "Bold", 800: "ExtraBold", 900: "Black"
66
+ }
67
+ weight_label = weight_labels.get(int(font_weight) if str(font_weight).isdigit() else 400, "Regular")
68
+
69
+ # Clean up name for display
70
+ display_name = name.replace("_", " ").replace("-", " ").title()
71
+ if len(display_name) > 15:
72
+ display_name = display_name[:15] + "..."
73
+
74
+ # Truncate sample text for large sizes
75
+ display_text = sample_text
76
+ if size_num > 48:
77
+ display_text = sample_text[:30] + "..."
78
+ elif size_num > 32:
79
+ display_text = sample_text[:40] + "..."
80
+
81
+ rows_html += f'''
82
+ <tr>
83
+ <td class="scale-name">
84
+ <div class="scale-label">{display_name}</div>
85
+ </td>
86
+ <td class="meta">{font_family}</td>
87
+ <td class="meta">{weight_label}</td>
88
+ <td class="meta">{int(size_num)}</td>
89
+ <td class="meta">Sentence</td>
90
+ <td class="meta">{letter_spacing}</td>
91
+ </tr>
92
+ <tr>
93
+ <td colspan="6" class="preview-cell">
94
+ <div class="preview-text" style="
95
+ font-family: '{font_family}', sans-serif;
96
+ font-size: {font_size};
97
+ font-weight: {font_weight};
98
+ line-height: {line_height};
99
+ letter-spacing: {letter_spacing}px;
100
+ ">{display_text}</div>
101
+ </td>
102
+ </tr>
103
+ '''
104
+
105
+ html = f'''
106
+ <style>
107
+ @import url('https://fonts.googleapis.com/css2?family={font_family.replace(" ", "+")}:wght@100;200;300;400;500;600;700;800;900&display=swap');
108
+
109
+ .typography-preview {{
110
+ font-family: system-ui, -apple-system, sans-serif;
111
+ background: {background};
112
+ border-radius: 12px;
113
+ padding: 20px;
114
+ overflow-x: auto;
115
+ }}
116
+
117
+ .typography-preview table {{
118
+ width: 100%;
119
+ border-collapse: collapse;
120
+ }}
121
+
122
+ .typography-preview th {{
123
+ text-align: left;
124
+ padding: 12px 16px;
125
+ font-size: 12px;
126
+ font-weight: 600;
127
+ color: #666;
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.5px;
130
+ border-bottom: 2px solid #E0E0E0;
131
+ }}
132
+
133
+ .typography-preview td {{
134
+ padding: 8px 16px;
135
+ vertical-align: middle;
136
+ }}
137
+
138
+ .typography-preview .scale-name {{
139
+ font-weight: 600;
140
+ color: #333;
141
+ min-width: 100px;
142
+ }}
143
+
144
+ .typography-preview .scale-label {{
145
+ font-size: 14px;
146
+ }}
147
+
148
+ .typography-preview .meta {{
149
+ font-size: 13px;
150
+ color: #666;
151
+ white-space: nowrap;
152
+ }}
153
+
154
+ .typography-preview .preview-cell {{
155
+ padding: 16px;
156
+ background: #FFFFFF;
157
+ border-bottom: 1px solid #E8E8E8;
158
+ }}
159
+
160
+ .typography-preview .preview-text {{
161
+ color: #1A1A1A;
162
+ margin: 0;
163
+ word-break: break-word;
164
+ }}
165
+
166
+ .typography-preview tr:hover .preview-cell {{
167
+ background: #F5F5F5;
168
+ }}
169
+ </style>
170
+
171
+ <div class="typography-preview">
172
+ <table>
173
+ <thead>
174
+ <tr>
175
+ <th>Scale Category</th>
176
+ <th>Typeface</th>
177
+ <th>Weight</th>
178
+ <th>Size</th>
179
+ <th>Case</th>
180
+ <th>Letter Spacing</th>
181
+ </tr>
182
+ </thead>
183
+ <tbody>
184
+ {rows_html}
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ '''
189
+
190
+ return html
191
+
192
+
193
+ # =============================================================================
194
+ # COLOR RAMP PREVIEW
195
+ # =============================================================================
196
+
197
+ def hex_to_rgb(hex_color: str) -> tuple:
198
+ """Convert hex color to RGB tuple."""
199
+ hex_color = hex_color.lstrip('#')
200
+ if len(hex_color) == 3:
201
+ hex_color = ''.join([c*2 for c in hex_color])
202
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
203
+
204
+
205
+ def rgb_to_hex(rgb: tuple) -> str:
206
+ """Convert RGB tuple to hex string."""
207
+ return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
208
+
209
+
210
+ def get_luminance(rgb: tuple) -> float:
211
+ """Calculate relative luminance for contrast ratio."""
212
+ def adjust(c):
213
+ c = c / 255
214
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
215
+
216
+ r, g, b = rgb
217
+ return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
218
+
219
+
220
+ def get_contrast_ratio(color1: tuple, color2: tuple) -> float:
221
+ """Calculate contrast ratio between two colors."""
222
+ l1 = get_luminance(color1)
223
+ l2 = get_luminance(color2)
224
+ lighter = max(l1, l2)
225
+ darker = min(l1, l2)
226
+ return (lighter + 0.05) / (darker + 0.05)
227
+
228
+
229
+ def generate_color_ramp(base_hex: str) -> list[dict]:
230
+ """
231
+ Generate 11 shades (50-950) from a base color.
232
+
233
+ Uses OKLCH-like approach for perceptually uniform steps.
234
+ """
235
+ try:
236
+ rgb = hex_to_rgb(base_hex)
237
+ except:
238
+ return []
239
+
240
+ # Convert to HLS for easier manipulation
241
+ r, g, b = [x / 255 for x in rgb]
242
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
243
+
244
+ # Define lightness levels for each shade
245
+ # 50 = very light (0.95), 500 = base, 950 = very dark (0.05)
246
+ shade_lightness = {
247
+ 50: 0.95,
248
+ 100: 0.90,
249
+ 200: 0.80,
250
+ 300: 0.70,
251
+ 400: 0.60,
252
+ 500: l, # Keep original lightness for 500
253
+ 600: 0.45,
254
+ 700: 0.35,
255
+ 800: 0.25,
256
+ 900: 0.15,
257
+ 950: 0.08,
258
+ }
259
+
260
+ # Adjust saturation for light/dark shades
261
+ ramp = []
262
+ for shade, target_l in shade_lightness.items():
263
+ # Reduce saturation for very light colors
264
+ if target_l > 0.8:
265
+ adjusted_s = s * 0.6
266
+ elif target_l < 0.2:
267
+ adjusted_s = s * 0.8
268
+ else:
269
+ adjusted_s = s
270
+
271
+ # Generate new RGB
272
+ new_r, new_g, new_b = colorsys.hls_to_rgb(h, target_l, adjusted_s)
273
+ new_rgb = (int(new_r * 255), int(new_g * 255), int(new_b * 255))
274
+ new_hex = rgb_to_hex(new_rgb)
275
+
276
+ # Check AA compliance
277
+ white = (255, 255, 255)
278
+ black = (0, 0, 0)
279
+ contrast_white = get_contrast_ratio(new_rgb, white)
280
+ contrast_black = get_contrast_ratio(new_rgb, black)
281
+
282
+ # AA requires 4.5:1 for normal text
283
+ aa_on_white = contrast_white >= 4.5
284
+ aa_on_black = contrast_black >= 4.5
285
+
286
+ ramp.append({
287
+ "shade": shade,
288
+ "hex": new_hex,
289
+ "rgb": new_rgb,
290
+ "contrast_white": round(contrast_white, 2),
291
+ "contrast_black": round(contrast_black, 2),
292
+ "aa_on_white": aa_on_white,
293
+ "aa_on_black": aa_on_black,
294
+ })
295
+
296
+ return ramp
297
+
298
+
299
+ def generate_color_ramps_preview_html(
300
+ color_tokens: dict,
301
+ background: str = "#FAFAFA"
302
+ ) -> str:
303
+ """
304
+ Generate HTML preview for color ramps.
305
+
306
+ Args:
307
+ color_tokens: Dict of colors {name: {value: "#hex", ...}}
308
+ background: Background color
309
+
310
+ Returns:
311
+ HTML string for Gradio HTML component
312
+ """
313
+
314
+ rows_html = ""
315
+
316
+ for name, token in list(color_tokens.items())[:10]: # Limit to 10 colors
317
+ # Get hex value
318
+ if isinstance(token, dict):
319
+ hex_val = token.get("value", "#888888")
320
+ else:
321
+ hex_val = str(token)
322
+
323
+ # Clean up hex
324
+ if not hex_val.startswith("#"):
325
+ hex_val = f"#{hex_val}"
326
+
327
+ # Generate ramp
328
+ ramp = generate_color_ramp(hex_val)
329
+ if not ramp:
330
+ continue
331
+
332
+ # Clean name
333
+ display_name = name.replace("_", " ").replace("-", " ").title()
334
+ if len(display_name) > 15:
335
+ display_name = display_name[:12] + "..."
336
+
337
+ # Generate shade cells
338
+ shades_html = ""
339
+ for shade_info in ramp:
340
+ shade = shade_info["shade"]
341
+ hex_color = shade_info["hex"]
342
+ aa_white = shade_info["aa_on_white"]
343
+ aa_black = shade_info["aa_on_black"]
344
+
345
+ # Determine text color for label
346
+ text_color = "#000" if shade < 500 else "#FFF"
347
+
348
+ # AA indicator
349
+ if aa_white or aa_black:
350
+ aa_indicator = "✓"
351
+ aa_class = "aa-pass"
352
+ else:
353
+ aa_indicator = "✗"
354
+ aa_class = "aa-fail"
355
+
356
+ shades_html += f'''
357
+ <div class="shade-cell" style="background-color: {hex_color};" title="{hex_color} | AA: {'Pass' if aa_white or aa_black else 'Fail'}">
358
+ <span class="shade-label" style="color: {text_color};">{shade}</span>
359
+ <span class="aa-badge {aa_class}">{aa_indicator}</span>
360
+ </div>
361
+ '''
362
+
363
+ rows_html += f'''
364
+ <div class="color-row">
365
+ <div class="color-info">
366
+ <div class="color-swatch" style="background-color: {hex_val};"></div>
367
+ <div class="color-meta">
368
+ <div class="color-name">{display_name}</div>
369
+ <div class="color-hex">{hex_val}</div>
370
+ </div>
371
+ </div>
372
+ <div class="color-ramp">
373
+ {shades_html}
374
+ </div>
375
+ </div>
376
+ '''
377
+
378
+ html = f'''
379
+ <style>
380
+ .color-ramps-preview {{
381
+ font-family: system-ui, -apple-system, sans-serif;
382
+ background: {background};
383
+ border-radius: 12px;
384
+ padding: 20px;
385
+ overflow-x: auto;
386
+ }}
387
+
388
+ .color-row {{
389
+ display: flex;
390
+ align-items: center;
391
+ margin-bottom: 16px;
392
+ padding-bottom: 16px;
393
+ border-bottom: 1px solid #E8E8E8;
394
+ }}
395
+
396
+ .color-row:last-child {{
397
+ border-bottom: none;
398
+ margin-bottom: 0;
399
+ padding-bottom: 0;
400
+ }}
401
+
402
+ .color-info {{
403
+ display: flex;
404
+ align-items: center;
405
+ min-width: 140px;
406
+ margin-right: 20px;
407
+ }}
408
+
409
+ .color-swatch {{
410
+ width: 40px;
411
+ height: 40px;
412
+ border-radius: 50%;
413
+ border: 2px solid rgba(0,0,0,0.1);
414
+ margin-right: 12px;
415
+ flex-shrink: 0;
416
+ }}
417
+
418
+ .color-meta {{
419
+ flex: 1;
420
+ }}
421
+
422
+ .color-name {{
423
+ font-weight: 600;
424
+ font-size: 14px;
425
+ color: #333;
426
+ margin-bottom: 2px;
427
+ }}
428
+
429
+ .color-hex {{
430
+ font-size: 12px;
431
+ color: #666;
432
+ font-family: monospace;
433
+ }}
434
+
435
+ .color-ramp {{
436
+ display: flex;
437
+ gap: 4px;
438
+ flex: 1;
439
+ }}
440
+
441
+ .shade-cell {{
442
+ width: 48px;
443
+ height: 48px;
444
+ border-radius: 6px;
445
+ display: flex;
446
+ flex-direction: column;
447
+ align-items: center;
448
+ justify-content: center;
449
+ position: relative;
450
+ cursor: pointer;
451
+ transition: transform 0.15s;
452
+ }}
453
+
454
+ .shade-cell:hover {{
455
+ transform: scale(1.1);
456
+ z-index: 10;
457
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
458
+ }}
459
+
460
+ .shade-label {{
461
+ font-size: 10px;
462
+ font-weight: 600;
463
+ opacity: 0.9;
464
+ }}
465
+
466
+ .aa-badge {{
467
+ font-size: 9px;
468
+ margin-top: 2px;
469
+ }}
470
+
471
+ .aa-pass {{
472
+ color: #22C55E;
473
+ }}
474
+
475
+ .aa-fail {{
476
+ color: #EF4444;
477
+ }}
478
+
479
+ .shade-cell:hover .shade-label,
480
+ .shade-cell:hover .aa-badge {{
481
+ opacity: 1;
482
+ }}
483
+
484
+ /* Header row */
485
+ .ramp-header {{
486
+ display: flex;
487
+ margin-bottom: 12px;
488
+ padding-left: 160px;
489
+ }}
490
+
491
+ .ramp-header-label {{
492
+ width: 48px;
493
+ text-align: center;
494
+ font-size: 11px;
495
+ font-weight: 600;
496
+ color: #666;
497
+ margin-right: 4px;
498
+ }}
499
+ </style>
500
+
501
+ <div class="color-ramps-preview">
502
+ <div class="ramp-header">
503
+ <span class="ramp-header-label">50</span>
504
+ <span class="ramp-header-label">100</span>
505
+ <span class="ramp-header-label">200</span>
506
+ <span class="ramp-header-label">300</span>
507
+ <span class="ramp-header-label">400</span>
508
+ <span class="ramp-header-label">500</span>
509
+ <span class="ramp-header-label">600</span>
510
+ <span class="ramp-header-label">700</span>
511
+ <span class="ramp-header-label">800</span>
512
+ <span class="ramp-header-label">900</span>
513
+ <span class="ramp-header-label">950</span>
514
+ </div>
515
+ {rows_html}
516
+ </div>
517
+ '''
518
+
519
+ return html
520
+
521
+
522
+ # =============================================================================
523
+ # COMBINED PREVIEW
524
+ # =============================================================================
525
+
526
+ def generate_design_system_preview_html(
527
+ typography_tokens: dict,
528
+ color_tokens: dict,
529
+ font_family: str = "Open Sans",
530
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
531
+ ) -> tuple[str, str]:
532
+ """
533
+ Generate both typography and color ramp previews.
534
+
535
+ Returns:
536
+ Tuple of (typography_html, color_ramps_html)
537
+ """
538
+ typography_html = generate_typography_preview_html(
539
+ typography_tokens=typography_tokens,
540
+ font_family=font_family,
541
+ sample_text=sample_text,
542
+ )
543
+
544
+ color_ramps_html = generate_color_ramps_preview_html(
545
+ color_tokens=color_tokens,
546
+ )
547
+
548
+ return typography_html, color_ramps_html