riazmo commited on
Commit
10a2a39
·
verified ·
1 Parent(s): 4341c2f

Upload preview_generator.py

Browse files
Files changed (1) hide show
  1. core/preview_generator.py +568 -0
core/preview_generator.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 class="meta-row">
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: #333;
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.5px;
130
+ border-bottom: 2px solid #E0E0E0;
131
+ background: #F5F5F5;
132
+ }}
133
+
134
+ .typography-preview td {{
135
+ padding: 8px 16px;
136
+ vertical-align: middle;
137
+ }}
138
+
139
+ .typography-preview .meta-row {{
140
+ background: #F8F8F8;
141
+ border-top: 1px solid #E8E8E8;
142
+ }}
143
+
144
+ .typography-preview .scale-name {{
145
+ font-weight: 700;
146
+ color: #1A1A1A;
147
+ min-width: 120px;
148
+ }}
149
+
150
+ .typography-preview .scale-label {{
151
+ font-size: 13px;
152
+ font-weight: 600;
153
+ color: #1A1A1A;
154
+ background: #E8E8E8;
155
+ padding: 4px 8px;
156
+ border-radius: 4px;
157
+ display: inline-block;
158
+ }}
159
+
160
+ .typography-preview .meta {{
161
+ font-size: 13px;
162
+ color: #444;
163
+ white-space: nowrap;
164
+ }}
165
+
166
+ .typography-preview .preview-cell {{
167
+ padding: 16px;
168
+ background: #FFFFFF;
169
+ border-bottom: 1px solid #E8E8E8;
170
+ }}
171
+
172
+ .typography-preview .preview-text {{
173
+ color: #1A1A1A;
174
+ margin: 0;
175
+ word-break: break-word;
176
+ }}
177
+
178
+ .typography-preview tr:hover .preview-cell {{
179
+ background: #F5F5F5;
180
+ }}
181
+ </style>
182
+
183
+ <div class="typography-preview">
184
+ <table>
185
+ <thead>
186
+ <tr>
187
+ <th>Scale Category</th>
188
+ <th>Typeface</th>
189
+ <th>Weight</th>
190
+ <th>Size</th>
191
+ <th>Case</th>
192
+ <th>Letter Spacing</th>
193
+ </tr>
194
+ </thead>
195
+ <tbody>
196
+ {rows_html}
197
+ </tbody>
198
+ </table>
199
+ </div>
200
+ '''
201
+
202
+ return html
203
+
204
+
205
+ # =============================================================================
206
+ # COLOR RAMP PREVIEW
207
+ # =============================================================================
208
+
209
+ def hex_to_rgb(hex_color: str) -> tuple:
210
+ """Convert hex color to RGB tuple."""
211
+ hex_color = hex_color.lstrip('#')
212
+ if len(hex_color) == 3:
213
+ hex_color = ''.join([c*2 for c in hex_color])
214
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
215
+
216
+
217
+ def rgb_to_hex(rgb: tuple) -> str:
218
+ """Convert RGB tuple to hex string."""
219
+ return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))
220
+
221
+
222
+ def get_luminance(rgb: tuple) -> float:
223
+ """Calculate relative luminance for contrast ratio."""
224
+ def adjust(c):
225
+ c = c / 255
226
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
227
+
228
+ r, g, b = rgb
229
+ return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b)
230
+
231
+
232
+ def get_contrast_ratio(color1: tuple, color2: tuple) -> float:
233
+ """Calculate contrast ratio between two colors."""
234
+ l1 = get_luminance(color1)
235
+ l2 = get_luminance(color2)
236
+ lighter = max(l1, l2)
237
+ darker = min(l1, l2)
238
+ return (lighter + 0.05) / (darker + 0.05)
239
+
240
+
241
+ def generate_color_ramp(base_hex: str) -> list[dict]:
242
+ """
243
+ Generate 11 shades (50-950) from a base color.
244
+
245
+ Uses OKLCH-like approach for perceptually uniform steps.
246
+ """
247
+ try:
248
+ rgb = hex_to_rgb(base_hex)
249
+ except:
250
+ return []
251
+
252
+ # Convert to HLS for easier manipulation
253
+ r, g, b = [x / 255 for x in rgb]
254
+ h, l, s = colorsys.rgb_to_hls(r, g, b)
255
+
256
+ # Define lightness levels for each shade
257
+ # 50 = very light (0.95), 500 = base, 950 = very dark (0.05)
258
+ shade_lightness = {
259
+ 50: 0.95,
260
+ 100: 0.90,
261
+ 200: 0.80,
262
+ 300: 0.70,
263
+ 400: 0.60,
264
+ 500: l, # Keep original lightness for 500
265
+ 600: 0.45,
266
+ 700: 0.35,
267
+ 800: 0.25,
268
+ 900: 0.15,
269
+ 950: 0.08,
270
+ }
271
+
272
+ # Adjust saturation for light/dark shades
273
+ ramp = []
274
+ for shade, target_l in shade_lightness.items():
275
+ # Reduce saturation for very light colors
276
+ if target_l > 0.8:
277
+ adjusted_s = s * 0.6
278
+ elif target_l < 0.2:
279
+ adjusted_s = s * 0.8
280
+ else:
281
+ adjusted_s = s
282
+
283
+ # Generate new RGB
284
+ new_r, new_g, new_b = colorsys.hls_to_rgb(h, target_l, adjusted_s)
285
+ new_rgb = (int(new_r * 255), int(new_g * 255), int(new_b * 255))
286
+ new_hex = rgb_to_hex(new_rgb)
287
+
288
+ # Check AA compliance
289
+ white = (255, 255, 255)
290
+ black = (0, 0, 0)
291
+ contrast_white = get_contrast_ratio(new_rgb, white)
292
+ contrast_black = get_contrast_ratio(new_rgb, black)
293
+
294
+ # AA requires 4.5:1 for normal text
295
+ aa_on_white = contrast_white >= 4.5
296
+ aa_on_black = contrast_black >= 4.5
297
+
298
+ ramp.append({
299
+ "shade": shade,
300
+ "hex": new_hex,
301
+ "rgb": new_rgb,
302
+ "contrast_white": round(contrast_white, 2),
303
+ "contrast_black": round(contrast_black, 2),
304
+ "aa_on_white": aa_on_white,
305
+ "aa_on_black": aa_on_black,
306
+ })
307
+
308
+ return ramp
309
+
310
+
311
+ def generate_color_ramps_preview_html(
312
+ color_tokens: dict,
313
+ background: str = "#FAFAFA"
314
+ ) -> str:
315
+ """
316
+ Generate HTML preview for color ramps.
317
+
318
+ Args:
319
+ color_tokens: Dict of colors {name: {value: "#hex", ...}}
320
+ background: Background color
321
+
322
+ Returns:
323
+ HTML string for Gradio HTML component
324
+ """
325
+
326
+ rows_html = ""
327
+
328
+ for name, token in list(color_tokens.items())[:10]: # Limit to 10 colors
329
+ # Get hex value
330
+ if isinstance(token, dict):
331
+ hex_val = token.get("value", "#888888")
332
+ else:
333
+ hex_val = str(token)
334
+
335
+ # Clean up hex
336
+ if not hex_val.startswith("#"):
337
+ hex_val = f"#{hex_val}"
338
+
339
+ # Generate ramp
340
+ ramp = generate_color_ramp(hex_val)
341
+ if not ramp:
342
+ continue
343
+
344
+ # Clean name
345
+ display_name = name.replace("_", " ").replace("-", " ").title()
346
+ if len(display_name) > 15:
347
+ display_name = display_name[:12] + "..."
348
+
349
+ # Generate shade cells
350
+ shades_html = ""
351
+ for shade_info in ramp:
352
+ shade = shade_info["shade"]
353
+ hex_color = shade_info["hex"]
354
+ aa_white = shade_info["aa_on_white"]
355
+ aa_black = shade_info["aa_on_black"]
356
+
357
+ # Determine text color for label
358
+ text_color = "#000" if shade < 500 else "#FFF"
359
+
360
+ # AA indicator
361
+ if aa_white or aa_black:
362
+ aa_indicator = "✓"
363
+ aa_class = "aa-pass"
364
+ else:
365
+ aa_indicator = "✗"
366
+ aa_class = "aa-fail"
367
+
368
+ shades_html += f'''
369
+ <div class="shade-cell" style="background-color: {hex_color};" title="{hex_color} | AA: {'Pass' if aa_white or aa_black else 'Fail'}">
370
+ <span class="shade-label" style="color: {text_color};">{shade}</span>
371
+ <span class="aa-badge {aa_class}">{aa_indicator}</span>
372
+ </div>
373
+ '''
374
+
375
+ rows_html += f'''
376
+ <div class="color-row">
377
+ <div class="color-info">
378
+ <div class="color-swatch" style="background-color: {hex_val};"></div>
379
+ <div class="color-meta">
380
+ <div class="color-name">{display_name}</div>
381
+ <div class="color-hex">{hex_val}</div>
382
+ </div>
383
+ </div>
384
+ <div class="color-ramp">
385
+ {shades_html}
386
+ </div>
387
+ </div>
388
+ '''
389
+
390
+ html = f'''
391
+ <style>
392
+ .color-ramps-preview {{
393
+ font-family: system-ui, -apple-system, sans-serif;
394
+ background: {background};
395
+ border-radius: 12px;
396
+ padding: 20px;
397
+ overflow-x: auto;
398
+ }}
399
+
400
+ .color-row {{
401
+ display: flex;
402
+ align-items: center;
403
+ margin-bottom: 16px;
404
+ padding-bottom: 16px;
405
+ border-bottom: 1px solid #E8E8E8;
406
+ }}
407
+
408
+ .color-row:last-child {{
409
+ border-bottom: none;
410
+ margin-bottom: 0;
411
+ padding-bottom: 0;
412
+ }}
413
+
414
+ .color-info {{
415
+ display: flex;
416
+ align-items: center;
417
+ min-width: 140px;
418
+ margin-right: 20px;
419
+ }}
420
+
421
+ .color-swatch {{
422
+ width: 40px;
423
+ height: 40px;
424
+ border-radius: 8px;
425
+ border: 2px solid rgba(0,0,0,0.15);
426
+ margin-right: 12px;
427
+ flex-shrink: 0;
428
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
429
+ }}
430
+
431
+ .color-meta {{
432
+ flex: 1;
433
+ min-width: 100px;
434
+ }}
435
+
436
+ .color-name {{
437
+ font-weight: 700;
438
+ font-size: 13px;
439
+ color: #1A1A1A;
440
+ margin-bottom: 2px;
441
+ background: #E8E8E8;
442
+ padding: 3px 8px;
443
+ border-radius: 4px;
444
+ display: inline-block;
445
+ }}
446
+
447
+ .color-hex {{
448
+ font-size: 11px;
449
+ color: #444;
450
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
451
+ margin-top: 4px;
452
+ }}
453
+
454
+ .color-ramp {{
455
+ display: flex;
456
+ gap: 4px;
457
+ flex: 1;
458
+ }}
459
+
460
+ .shade-cell {{
461
+ width: 48px;
462
+ height: 48px;
463
+ border-radius: 6px;
464
+ display: flex;
465
+ flex-direction: column;
466
+ align-items: center;
467
+ justify-content: center;
468
+ position: relative;
469
+ cursor: pointer;
470
+ transition: transform 0.15s;
471
+ border: 1px solid rgba(0,0,0,0.1);
472
+ }}
473
+
474
+ .shade-cell:hover {{
475
+ transform: scale(1.1);
476
+ z-index: 10;
477
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
478
+ }}
479
+
480
+ .shade-label {{
481
+ font-size: 10px;
482
+ font-weight: 700;
483
+ }}
484
+
485
+ .aa-badge {{
486
+ font-size: 12px;
487
+ margin-top: 2px;
488
+ font-weight: 700;
489
+ }}
490
+
491
+ .aa-pass {{
492
+ color: #16A34A;
493
+ }}
494
+
495
+ .aa-fail {{
496
+ color: #DC2626;
497
+ }}
498
+
499
+ .shade-cell:hover .shade-label,
500
+ .shade-cell:hover .aa-badge {{
501
+ opacity: 1;
502
+ }}
503
+
504
+ /* Header row */
505
+ .ramp-header {{
506
+ display: flex;
507
+ margin-bottom: 12px;
508
+ padding-left: 160px;
509
+ }}
510
+
511
+ .ramp-header-label {{
512
+ width: 48px;
513
+ text-align: center;
514
+ font-size: 11px;
515
+ font-weight: 600;
516
+ color: #666;
517
+ margin-right: 4px;
518
+ }}
519
+ </style>
520
+
521
+ <div class="color-ramps-preview">
522
+ <div class="ramp-header">
523
+ <span class="ramp-header-label">50</span>
524
+ <span class="ramp-header-label">100</span>
525
+ <span class="ramp-header-label">200</span>
526
+ <span class="ramp-header-label">300</span>
527
+ <span class="ramp-header-label">400</span>
528
+ <span class="ramp-header-label">500</span>
529
+ <span class="ramp-header-label">600</span>
530
+ <span class="ramp-header-label">700</span>
531
+ <span class="ramp-header-label">800</span>
532
+ <span class="ramp-header-label">900</span>
533
+ <span class="ramp-header-label">950</span>
534
+ </div>
535
+ {rows_html}
536
+ </div>
537
+ '''
538
+
539
+ return html
540
+
541
+
542
+ # =============================================================================
543
+ # COMBINED PREVIEW
544
+ # =============================================================================
545
+
546
+ def generate_design_system_preview_html(
547
+ typography_tokens: dict,
548
+ color_tokens: dict,
549
+ font_family: str = "Open Sans",
550
+ sample_text: str = "The quick brown fox jumps over the lazy dog"
551
+ ) -> tuple[str, str]:
552
+ """
553
+ Generate both typography and color ramp previews.
554
+
555
+ Returns:
556
+ Tuple of (typography_html, color_ramps_html)
557
+ """
558
+ typography_html = generate_typography_preview_html(
559
+ typography_tokens=typography_tokens,
560
+ font_family=font_family,
561
+ sample_text=sample_text,
562
+ )
563
+
564
+ color_ramps_html = generate_color_ramps_preview_html(
565
+ color_tokens=color_tokens,
566
+ )
567
+
568
+ return typography_html, color_ramps_html