Ray1ee01 commited on
Commit
0db40c8
·
verified ·
1 Parent(s): 9eba547

Upload folder using huggingface_hub

Browse files
modules/title_styler/__init__.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 信息图表标题生成器
3
+
4
+ 一个用于生成专业信息图表标题的Python模块。
5
+ """
6
+
7
+ from modules.title_styler.infographic_title_generator import (
8
+ InfographicTitleGenerator,
9
+ generate_title
10
+ )
11
+
12
+ from modules.title_styler.templates import (
13
+ TitleTemplate,
14
+ get_all_templates,
15
+ get_templates_by_alignment
16
+ )
17
+
18
+ from modules.title_styler.svg_renderer import SVGRenderer
19
+
20
+ from modules.title_styler.llm_analyzer import LLMAnalyzer
21
+
22
+ from modules.title_styler.config import (
23
+ TITLE_FONTS,
24
+ NEUTRAL_COLORS,
25
+ ALIGNMENT_LEFT,
26
+ ALIGNMENT_CENTER,
27
+ ALIGNMENT_RIGHT
28
+ )
29
+
30
+ __version__ = '1.0.0'
31
+ __author__ = 'Your Name'
32
+
33
+ __all__ = [
34
+ # 主要类
35
+ 'InfographicTitleGenerator',
36
+ 'TitleTemplate',
37
+ 'SVGRenderer',
38
+ 'LLMAnalyzer',
39
+
40
+ # 便捷函数
41
+ 'generate_title',
42
+ 'generate_and_save',
43
+ 'render_title',
44
+ 'analyze_title_structure',
45
+
46
+ # 模板相关
47
+ 'get_all_templates',
48
+ 'get_templates_by_line_count',
49
+ 'get_templates_by_alignment',
50
+
51
+ # 常量
52
+ 'TITLE_FONTS',
53
+ 'NEUTRAL_COLORS',
54
+ 'ALIGNMENT_LEFT',
55
+ 'ALIGNMENT_CENTER',
56
+ 'ALIGNMENT_RIGHT',
57
+ ]
58
+
modules/title_styler/color_utils.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Color utility functions for contrast calculation
3
+ """
4
+
5
+ def hex_to_rgb(hex_color: str) -> tuple:
6
+ """Convert hex color to RGB tuple"""
7
+ hex_color = hex_color.lstrip('#')
8
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
9
+
10
+
11
+ def rgb_to_luminance(r: int, g: int, b: int) -> float:
12
+ """Calculate relative luminance (0-1) from RGB"""
13
+ # Convert to 0-1 range
14
+ r, g, b = r / 255.0, g / 255.0, b / 255.0
15
+
16
+ # Apply gamma correction
17
+ r = r / 12.92 if r <= 0.03928 else ((r + 0.055) / 1.055) ** 2.4
18
+ g = g / 12.92 if g <= 0.03928 else ((g + 0.055) / 1.055) ** 2.4
19
+ b = b / 12.92 if b <= 0.03928 else ((b + 0.055) / 1.055) ** 2.4
20
+
21
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
22
+
23
+
24
+ def contrast_ratio(color1: str, color2: str) -> float:
25
+ """
26
+ Calculate contrast ratio between two colors (1:1 to 21:1)
27
+
28
+ Args:
29
+ color1: Hex color (e.g., '#FFFFFF')
30
+ color2: Hex color (e.g., '#000000')
31
+
32
+ Returns:
33
+ Contrast ratio (1.0 to 21.0)
34
+ """
35
+ rgb1 = hex_to_rgb(color1)
36
+ rgb2 = hex_to_rgb(color2)
37
+
38
+ lum1 = rgb_to_luminance(*rgb1)
39
+ lum2 = rgb_to_luminance(*rgb2)
40
+
41
+ lighter = max(lum1, lum2)
42
+ darker = min(lum1, lum2)
43
+
44
+ return (lighter + 0.05) / (darker + 0.05)
45
+
46
+
47
+ def is_sufficient_contrast(foreground: str, background: str, min_ratio: float = 3.0) -> bool:
48
+ """
49
+ Check if there's sufficient contrast between foreground and background
50
+
51
+ Args:
52
+ foreground: Foreground color (hex)
53
+ background: Background color (hex)
54
+ min_ratio: Minimum acceptable contrast ratio (default 3.0)
55
+
56
+ Returns:
57
+ True if contrast is sufficient
58
+ """
59
+ ratio = contrast_ratio(foreground, background)
60
+ return ratio >= min_ratio
61
+
modules/title_styler/font_metrics.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Font Metrics Tool - Using Pillow for Accurate Text Width Calculation
3
+ """
4
+
5
+ import os
6
+ import subprocess
7
+ from typing import Tuple, Optional
8
+
9
+ from PIL import Image, ImageDraw, ImageFont
10
+
11
+
12
+ # Cache the file path returned by `fc-match <family>` so we don't fork a
13
+ # subprocess on every text measurement. Keyed by (font_family, font_weight,
14
+ # font_style) — same key shape used by `_load_font`.
15
+ _FC_MATCH_CACHE: dict = {}
16
+
17
+ # Map our public font_family names to a CSS generic family ("serif",
18
+ # "sans-serif", "monospace", "cursive"). When `fc-match` is unavailable we
19
+ # fall back to a Linux font of the same *category* so a request for Georgia
20
+ # never silently degrades to LiberationSans (a sans-serif). Anything we
21
+ # don't recognize is treated as sans-serif.
22
+ _FAMILY_GENERIC = {
23
+ 'Georgia': 'serif',
24
+ 'Times': 'serif',
25
+ 'Times New Roman': 'serif',
26
+ 'Cambria': 'serif',
27
+ 'Garamond': 'serif',
28
+ 'Book Antiqua': 'serif',
29
+ 'Palatino': 'serif',
30
+ 'Palatino Linotype': 'serif',
31
+ 'Noto Serif': 'serif',
32
+ 'DejaVu Serif': 'serif',
33
+
34
+ 'Arial': 'sans-serif',
35
+ 'Helvetica': 'sans-serif',
36
+ 'Verdana': 'sans-serif',
37
+ 'Tahoma': 'sans-serif',
38
+ 'Trebuchet MS': 'sans-serif',
39
+ 'Segoe UI': 'sans-serif',
40
+ 'Calibri': 'sans-serif',
41
+ 'Roboto': 'sans-serif',
42
+ 'Open Sans': 'sans-serif',
43
+ 'Montserrat': 'sans-serif',
44
+ 'Oswald': 'sans-serif',
45
+ 'Lato': 'sans-serif',
46
+ 'Source Sans Pro': 'sans-serif',
47
+ 'Noto Sans': 'sans-serif',
48
+ 'DejaVu Sans': 'sans-serif',
49
+ 'Liberation Sans': 'sans-serif',
50
+
51
+ 'Courier': 'monospace',
52
+ 'Courier New': 'monospace',
53
+ 'Consolas': 'monospace',
54
+ 'Monaco': 'monospace',
55
+ 'Menlo': 'monospace',
56
+ 'DejaVu Sans Mono': 'monospace',
57
+ 'Liberation Mono': 'monospace',
58
+
59
+ # display/script: have wildly different metrics. We push these through
60
+ # fc-match (which has explicit rules) instead of category-based fallback
61
+ # so they don't all collapse onto a sans-serif body font.
62
+ 'Impact': 'sans-serif',
63
+ 'Bebas Neue': 'sans-serif',
64
+ 'Comic Sans MS': 'cursive',
65
+ 'Comic Neue': 'cursive',
66
+ 'Brush Script': 'cursive',
67
+ 'Pacifico': 'cursive',
68
+ }
69
+
70
+
71
+ class FontMetrics:
72
+ """Font metrics calculator"""
73
+
74
+ def __init__(self):
75
+ self.font_cache = {}
76
+
77
+ def get_font(self, font_family: str, font_size: int, font_weight: str = 'normal',
78
+ font_style: str = 'normal') -> Optional[ImageFont.FreeTypeFont]:
79
+ """
80
+ Get font object (with caching)
81
+
82
+ Args:
83
+ font_family: Font name
84
+ font_size: Font size
85
+ font_weight: Font weight
86
+ font_style: Font style (normal, italic, oblique)
87
+
88
+ Returns:
89
+ Font object or None (if font is unavailable)
90
+ """
91
+ cache_key = f"{font_family}_{font_size}_{font_weight}_{font_style}"
92
+
93
+ if cache_key in self.font_cache:
94
+ return self.font_cache[cache_key]
95
+
96
+ # Try to load font
97
+ font = self._load_font(font_family, font_size, font_weight, font_style)
98
+ self.font_cache[cache_key] = font
99
+ return font
100
+
101
+ def _load_font(self, font_family: str, font_size: int, font_weight: str,
102
+ font_style: str) -> Optional[ImageFont.FreeTypeFont]:
103
+ """Resolve a font for measurement.
104
+
105
+ Resolution order:
106
+ 1. Hard-coded path table (`_get_font_paths`) — exists for
107
+ developer machines that have the real Microsoft/Apple fonts
108
+ (Georgia, Arial, ...) installed in well-known locations.
109
+ 2. fontconfig (`fc-match <family>:weight=...:slant=...`) — this is
110
+ *exactly* the path Chromium/Chrome uses on Linux to resolve a
111
+ `font-family: Georgia` SVG style, so going through fc-match
112
+ keeps PIL's measurement aligned with how the chart actually
113
+ renders. This is the fix for Bug B: before this call, the code
114
+ always fell through to `_get_generic_fallback_paths()` which
115
+ returned Liberation Sans regardless of whether the request was
116
+ serif, sans-serif, or display, so PIL routinely under-measured
117
+ a 32px Georgia headline by ~40-50% and the resulting title
118
+ SVG width was too small, leading to the rendered text
119
+ overflowing the canvas at compose time.
120
+ 3. Generic-family fallback table (`_get_generic_fallback_paths`)
121
+ — used only when fontconfig is unavailable (no `fc-match`
122
+ binary). Picks a Linux-installed font that matches the
123
+ *category* (serif / sans-serif / monospace / cursive) of the
124
+ requested family.
125
+ 4. Pillow's bitmap default (last-resort).
126
+ """
127
+ font_paths = self._get_font_paths(font_family, font_weight, font_style)
128
+ for path in font_paths:
129
+ if os.path.exists(path):
130
+ try:
131
+ return ImageFont.truetype(path, font_size)
132
+ except Exception:
133
+ continue
134
+
135
+ fc_path = self._fc_match(font_family, font_weight, font_style)
136
+ if fc_path:
137
+ try:
138
+ return ImageFont.truetype(fc_path, font_size)
139
+ except Exception:
140
+ pass
141
+
142
+ for path in self._get_generic_fallback_paths(font_family, font_weight, font_style):
143
+ if os.path.exists(path):
144
+ try:
145
+ return ImageFont.truetype(path, font_size)
146
+ except Exception:
147
+ continue
148
+
149
+ try:
150
+ return ImageFont.load_default()
151
+ except Exception:
152
+ return None
153
+
154
+ def _fc_match(self, font_family: str, font_weight: str, font_style: str) -> Optional[str]:
155
+ """Ask fontconfig which file would be used for this family / weight /
156
+ slant combination. Cached because subprocess.run is ~3ms and we may
157
+ ask thousands of times during a batch run.
158
+ """
159
+ key = (font_family, font_weight, font_style)
160
+ if key in _FC_MATCH_CACHE:
161
+ return _FC_MATCH_CACHE[key]
162
+
163
+ is_bold = font_weight in ['bold', 'bolder', '700', '800', '900']
164
+ is_italic = font_style in ['italic', 'oblique']
165
+ query = font_family
166
+ if is_bold:
167
+ query += ':weight=bold'
168
+ if is_italic:
169
+ query += ':slant=italic'
170
+
171
+ result = subprocess.run(
172
+ ['fc-match', '-f', '%{file}', query],
173
+ capture_output=True, text=True, timeout=2,
174
+ check=False,
175
+ )
176
+ path = result.stdout.strip() if result.returncode == 0 else ''
177
+ resolved = path if path and os.path.exists(path) else None
178
+ _FC_MATCH_CACHE[key] = resolved
179
+ return resolved
180
+
181
+ def _get_generic_fallback_paths(self, font_family: str, font_weight: str,
182
+ font_style: str) -> list:
183
+ """Linux font paths picked by *category* (serif / sans / mono / cursive).
184
+
185
+ Only used when fc-match is unavailable. The category is derived from
186
+ ``_FAMILY_GENERIC``; anything unknown is treated as sans-serif.
187
+ """
188
+ is_bold = font_weight in ['bold', 'bolder', '700', '800', '900']
189
+ is_italic = font_style in ['italic', 'oblique']
190
+ category = _FAMILY_GENERIC.get(font_family, 'sans-serif')
191
+
192
+ def pick(serif, sans, mono, cursive):
193
+ return {'serif': serif, 'sans-serif': sans, 'monospace': mono,
194
+ 'cursive': cursive}[category]
195
+
196
+ if is_bold and is_italic:
197
+ filename = pick(
198
+ 'DejaVuSerif-BoldItalic.ttf',
199
+ 'LiberationSans-BoldItalic.ttf',
200
+ 'DejaVuSansMono-BoldOblique.ttf',
201
+ 'LiberationSans-BoldItalic.ttf',
202
+ )
203
+ elif is_bold:
204
+ filename = pick(
205
+ 'DejaVuSerif-Bold.ttf',
206
+ 'LiberationSans-Bold.ttf',
207
+ 'DejaVuSansMono-Bold.ttf',
208
+ 'LiberationSans-Bold.ttf',
209
+ )
210
+ elif is_italic:
211
+ filename = pick(
212
+ 'DejaVuSerif-Italic.ttf',
213
+ 'LiberationSans-Italic.ttf',
214
+ 'DejaVuSansMono-Oblique.ttf',
215
+ 'LiberationSans-Italic.ttf',
216
+ )
217
+ else:
218
+ filename = pick(
219
+ 'DejaVuSerif.ttf',
220
+ 'LiberationSans-Regular.ttf',
221
+ 'DejaVuSansMono.ttf',
222
+ 'LiberationSans-Regular.ttf',
223
+ )
224
+
225
+ return [
226
+ f'/usr/share/fonts/truetype/dejavu/{filename}',
227
+ f'/usr/share/fonts/truetype/liberation2/{filename}',
228
+ f'/usr/share/fonts/truetype/liberation/{filename}',
229
+ f'/usr/share/fonts/truetype/noto/{filename}',
230
+ ]
231
+
232
+ def _get_font_paths(self, font_family: str, font_weight: str, font_style: str) -> list:
233
+ """Get possible font file paths"""
234
+ paths = []
235
+
236
+ # macOS font paths
237
+ mac_system_fonts = "/System/Library/Fonts"
238
+ mac_supplemental_fonts = "/System/Library/Fonts/Supplemental"
239
+ mac_user_fonts = os.path.expanduser("~/Library/Fonts")
240
+
241
+ # Linux font paths
242
+ linux_fonts = "/usr/share/fonts"
243
+ linux_truetype = "/usr/share/fonts/truetype"
244
+
245
+ # Windows font paths
246
+ windows_fonts = "C:/Windows/Fonts"
247
+
248
+ # Determine style key: 'normal', 'italic', 'bold', 'bold-italic'
249
+ is_bold = font_weight in ['bold', 'bolder', '700', '800', '900']
250
+ is_italic = font_style in ['italic', 'oblique']
251
+
252
+ if is_bold and is_italic:
253
+ style_key = 'bold-italic'
254
+ elif is_bold:
255
+ style_key = 'bold'
256
+ elif is_italic:
257
+ style_key = 'italic'
258
+ else:
259
+ style_key = 'normal'
260
+
261
+ # Font filename mappings
262
+ font_files = {
263
+ 'Arial': {
264
+ 'normal': ['Arial.ttf', 'arial.ttf', 'Arial Unicode.ttf'],
265
+ 'bold': ['Arial Bold.ttf', 'arialbd.ttf', 'Arial-Bold.ttf', 'Arial-BoldMT.ttf', 'Arial_Bold.ttf'],
266
+ 'italic': ['Arial Italic.ttf', 'ariali.ttf', 'Arial-Italic.ttf', 'Arial-ItalicMT.ttf', 'Arial_Italic.ttf'],
267
+ 'bold-italic': ['Arial Bold Italic.ttf', 'arialbi.ttf', 'Arial-BoldItalic.ttf', 'Arial-BoldItalicMT.ttf', 'Arial_Bold_Italic.ttf'],
268
+ },
269
+ 'Comic Sans MS': {
270
+ 'normal': ['Comic Sans MS.ttf', 'comic.ttf', 'ComicSansMS.ttf'],
271
+ 'bold': ['Comic Sans MS Bold.ttf', 'comicbd.ttf', 'ComicSansMS-Bold.ttf'],
272
+ 'italic': ['Comic Sans MS Italic.ttf', 'comici.ttf', 'ComicSansMS-Italic.ttf'],
273
+ 'bold-italic': ['Comic Sans MS Bold Italic.ttf', 'comicbi.ttf', 'ComicSansMS-BoldItalic.ttf'],
274
+ },
275
+ 'Georgia': {
276
+ 'normal': ['Georgia.ttf', 'georgia.ttf'],
277
+ 'bold': ['Georgia Bold.ttf', 'Georgia-Bold.ttf', 'GeorgiaBold.ttf', 'georgiab.ttf'],
278
+ 'italic': ['Georgia Italic.ttf', 'Georgia-Italic.ttf', 'GeorgiaItalic.ttf', 'georgiai.ttf'],
279
+ 'bold-italic': ['Georgia Bold Italic.ttf', 'Georgia-BoldItalic.ttf', 'GeorgiaBoldItalic.ttf', 'georgiaz.ttf'],
280
+ },
281
+ 'Times New Roman': {
282
+ 'normal': ['Times New Roman.ttf', 'times.ttf', 'Times_New_Roman.ttf'],
283
+ 'bold': ['Times New Roman Bold.ttf', 'timesbd.ttf', 'Times-Bold.ttf'],
284
+ 'italic': ['Times New Roman Italic.ttf', 'timesi.ttf', 'Times-Italic.ttf'],
285
+ 'bold-italic': ['Times New Roman Bold Italic.ttf', 'timesbi.ttf', 'Times-BoldItalic.ttf'],
286
+ },
287
+ 'Helvetica': {
288
+ 'normal': ['Helvetica.ttc', 'Helvetica.ttf'],
289
+ 'bold': ['Helvetica-Bold.ttc', 'Helvetica-Bold.ttf'],
290
+ 'italic': ['Helvetica-Oblique.ttc', 'Helvetica-Oblique.ttf'],
291
+ 'bold-italic': ['Helvetica-BoldOblique.ttc', 'Helvetica-BoldOblique.ttf'],
292
+ },
293
+ 'Montserrat': {
294
+ 'normal': ['Montserrat-Regular.ttf'],
295
+ 'bold': ['Montserrat-Bold.ttf'],
296
+ 'italic': ['Montserrat-Italic.ttf'],
297
+ 'bold-italic': ['Montserrat-BoldItalic.ttf'],
298
+ },
299
+ 'Open Sans': {
300
+ 'normal': ['OpenSans-Regular.ttf'],
301
+ 'bold': ['OpenSans-Bold.ttf'],
302
+ 'italic': ['OpenSans-Italic.ttf'],
303
+ 'bold-italic': ['OpenSans-BoldItalic.ttf'],
304
+ },
305
+ 'Roboto': {
306
+ 'normal': ['Roboto-Regular.ttf'],
307
+ 'bold': ['Roboto-Bold.ttf'],
308
+ 'italic': ['Roboto-Italic.ttf'],
309
+ 'bold-italic': ['Roboto-BoldItalic.ttf'],
310
+ },
311
+ 'Impact': {
312
+ 'normal': ['Impact.ttf', 'impact.ttf'],
313
+ 'bold': ['Impact.ttf', 'impact.ttf'],
314
+ 'italic': ['Impact.ttf', 'impact.ttf'],
315
+ 'bold-italic': ['Impact.ttf', 'impact.ttf'],
316
+ },
317
+ 'Oswald': {
318
+ 'normal': ['Oswald-Regular.ttf'],
319
+ 'bold': ['Oswald-Bold.ttf'],
320
+ 'italic': ['Oswald-Italic.ttf'],
321
+ 'bold-italic': ['Oswald-BoldItalic.ttf'],
322
+ },
323
+ }
324
+
325
+ filenames = font_files.get(font_family, {}).get(style_key, [f"{font_family}.ttf"])
326
+
327
+ # Build complete paths
328
+ base_dirs = [
329
+ mac_supplemental_fonts, # Search Supplemental first
330
+ mac_system_fonts,
331
+ mac_user_fonts,
332
+ linux_truetype,
333
+ linux_fonts,
334
+ windows_fonts
335
+ ]
336
+
337
+ # First try direct paths
338
+ for base_dir in base_dirs:
339
+ for filename in filenames:
340
+ paths.append(os.path.join(base_dir, filename))
341
+
342
+ # For Linux, also search in common subdirectories (Ubuntu stores fonts in subdirs)
343
+ linux_subdirs = [
344
+ '/usr/share/fonts/truetype/msttcorefonts', # Microsoft core fonts
345
+ '/usr/share/fonts/truetype/liberation', # Liberation fonts
346
+ '/usr/share/fonts/truetype/dejavu', # DejaVu fonts
347
+ '/usr/share/fonts/truetype/noto', # Noto fonts
348
+ '/usr/share/fonts/truetype/liberation2', # Liberation2 fonts
349
+ ]
350
+
351
+ for subdir in linux_subdirs:
352
+ for filename in filenames:
353
+ paths.append(os.path.join(subdir, filename))
354
+
355
+ return paths
356
+
357
+ def measure_text(self, text: str, font_family: str, font_size: int,
358
+ font_weight: str = 'normal', font_style: str = 'normal',
359
+ letter_spacing: float = 0) -> Tuple[float, float]:
360
+ """
361
+ Measure text dimensions
362
+
363
+ Args:
364
+ text: Text content
365
+ font_family: Font name
366
+ font_size: Font size
367
+ font_weight: Font weight
368
+ font_style: Font style (normal, italic, oblique)
369
+ letter_spacing: Letter spacing (em unit) - will be applied by SVG renderer
370
+
371
+ Returns:
372
+ (width, height) Text width and height
373
+
374
+ Note:
375
+ We measure the base width without letter-spacing here.
376
+ The SVG renderer will apply letter-spacing attribute, which the browser handles.
377
+ This avoids double-counting letter-spacing in both measurement and rendering.
378
+ """
379
+ font = self.get_font(font_family, font_size, font_weight, font_style)
380
+
381
+ if font is None:
382
+ # Fallback to simple estimation
383
+ base_width, height = self._estimate_text_size(text, font_size, font_weight)
384
+ # Add letter spacing for fallback estimation
385
+ if letter_spacing > 0 and len(text) > 1:
386
+ extra_space = font_size * letter_spacing * (len(text) - 1)
387
+ base_width += extra_space
388
+ return base_width, height
389
+
390
+ # Create temporary image for measurement
391
+ img = Image.new('RGB', (1, 1))
392
+ draw = ImageDraw.Draw(img)
393
+
394
+ # Use textbbox to get accurate bounding box
395
+ try:
396
+ bbox = draw.textbbox((0, 0), text, font=font)
397
+ width = bbox[2] - bbox[0]
398
+ height = bbox[3] - bbox[1]
399
+
400
+ # Add letter spacing
401
+ # SVG letter-spacing is applied on top of the base glyph spacing
402
+ # We need to estimate the total width including letter-spacing
403
+ if letter_spacing > 0 and len(text) > 1:
404
+ extra_space = font_size * letter_spacing * (len(text) - 1)
405
+ width += extra_space
406
+
407
+ return width, height
408
+ except AttributeError:
409
+ # Older PIL versions use textsize
410
+ width, height = draw.textsize(text, font=font)
411
+
412
+ if letter_spacing > 0 and len(text) > 1:
413
+ extra_space = font_size * letter_spacing * (len(text) - 1)
414
+ width += extra_space
415
+
416
+ return width, height
417
+
418
+ def _estimate_text_size(self, text: str, font_size: int, font_weight: str) -> Tuple[float, float]:
419
+ """Simple text size estimation (fallback)"""
420
+ char_width_ratio = 0.65 if font_weight in ['bold', 'bolder'] else 0.6
421
+ width = len(text) * font_size * char_width_ratio
422
+ height = font_size
423
+ return width, height
424
+
425
+
426
+ # Global instance
427
+ _metrics_instance = None
428
+
429
+
430
+ def get_font_metrics() -> FontMetrics:
431
+ """Get global font metrics instance"""
432
+ global _metrics_instance
433
+ if _metrics_instance is None:
434
+ _metrics_instance = FontMetrics()
435
+ return _metrics_instance
436
+
437
+
438
+ def measure_text_width(text: str, font_family: str, font_size: int,
439
+ font_weight: str = 'normal', font_style: str = 'normal',
440
+ letter_spacing: float = 0) -> float:
441
+ """
442
+ Convenience function: Measure text width
443
+
444
+ Args:
445
+ text: Text content
446
+ font_family: Font name
447
+ font_size: Font size
448
+ font_weight: Font weight
449
+ font_style: Font style (normal, italic, oblique)
450
+ letter_spacing: Letter spacing
451
+
452
+ Returns:
453
+ Text width
454
+ """
455
+ metrics = get_font_metrics()
456
+ width, _ = metrics.measure_text(text, font_family, font_size, font_weight, font_style, letter_spacing)
457
+ return width
458
+
459
+
460
+ def measure_text_bbox(text: str, font_family: str, font_size: int,
461
+ font_weight: str = 'normal', font_style: str = 'normal',
462
+ letter_spacing: float = 0) -> Tuple[float, float, float, float]:
463
+ """
464
+ Measure text bounding box relative to baseline
465
+
466
+ Args:
467
+ text: Text content
468
+ font_family: Font name
469
+ font_size: Font size
470
+ font_weight: Font weight
471
+ font_style: Font style (normal, italic, oblique)
472
+ letter_spacing: Letter spacing
473
+
474
+ Returns:
475
+ (ascent, descent, width, total_height)
476
+ - ascent: Distance from baseline to top of text
477
+ - descent: Distance from baseline to bottom of text (positive value)
478
+ - width: Text width
479
+ - total_height: Total height (ascent + descent)
480
+ """
481
+ metrics = get_font_metrics()
482
+ font = metrics.get_font(font_family, font_size, font_weight, font_style)
483
+
484
+ if font is None:
485
+ # Fallback estimation
486
+ width, height = metrics._estimate_text_size(text, font_size, font_weight)
487
+ if letter_spacing > 0 and len(text) > 1:
488
+ extra_space = font_size * letter_spacing * (len(text) - 1)
489
+ width += extra_space
490
+ # Estimate: ~75% above baseline, ~25% below
491
+ ascent = height * 0.75
492
+ descent = height * 0.25
493
+ return ascent, descent, width, height
494
+
495
+ # Create temporary image for measurement
496
+ img = Image.new('RGB', (1, 1))
497
+ draw = ImageDraw.Draw(img)
498
+
499
+ # Use textbbox to get accurate bounding box
500
+ try:
501
+ # textbbox with anchor parameter gives us baseline-relative coordinates
502
+ bbox = draw.textbbox((0, 0), text, font=font, anchor='ls') # 'ls' = left-baseline
503
+
504
+ # bbox = (left, top, right, bottom) relative to baseline at y=0
505
+ # top will be negative (above baseline)
506
+ # bottom will be positive (below baseline) for characters with descenders
507
+ ascent = abs(bbox[1]) # Distance from baseline to top
508
+ descent = max(0, bbox[3]) # Distance from baseline to bottom
509
+ width = bbox[2] - bbox[0]
510
+
511
+ # Add letter spacing
512
+ if letter_spacing > 0 and len(text) > 1:
513
+ extra_space = font_size * letter_spacing * (len(text) - 1)
514
+ width += extra_space
515
+
516
+ total_height = ascent + descent
517
+ return ascent, descent, width, total_height
518
+
519
+ except (AttributeError, TypeError):
520
+ # Fallback if anchor parameter not supported
521
+ bbox = draw.textbbox((0, 0), text, font=font)
522
+ width = bbox[2] - bbox[0]
523
+ height = bbox[3] - bbox[1]
524
+
525
+ if letter_spacing > 0 and len(text) > 1:
526
+ extra_space = font_size * letter_spacing * (len(text) - 1)
527
+ width += extra_space
528
+
529
+ # Estimate ascent/descent
530
+ ascent = height * 0.75
531
+ descent = height * 0.25
532
+ return ascent, descent, width, height
533
+
modules/title_styler/infographic_title_generator.py ADDED
@@ -0,0 +1,954 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infographic Title Generator - Main Module
3
+ Integrates all functionality and provides a unified generation interface
4
+ """
5
+
6
+ from typing import Optional, List, Dict
7
+ from modules.title_styler.templates import get_all_templates, get_templates_with_description, get_templates_main_only, get_templates_by_style, TitleTemplate
8
+ from modules.title_styler.svg_renderer import SVGRenderer
9
+ from modules.title_styler.llm_analyzer import LLMAnalyzer
10
+ from modules.title_styler.color_utils import contrast_ratio
11
+ import os
12
+ from datetime import datetime
13
+ from modules.title_styler.config import COLOR_TYPE_PRIMARY, COLOR_TYPE_SECONDARY, COLOR_TYPE_FIXED
14
+
15
+
16
+ class InfographicTitleGenerator:
17
+ """Main class for infographic title generator"""
18
+
19
+ def __init__(self, use_llm=True):
20
+ """
21
+ Initialize generator
22
+
23
+ Args:
24
+ use_llm: Whether to use LLM for title analysis (default True)
25
+ """
26
+ self.renderer = SVGRenderer()
27
+ self.templates = get_all_templates()
28
+ self.use_llm = use_llm
29
+ if use_llm:
30
+ self.analyzer = LLMAnalyzer()
31
+ # Hard cap on total visible rows in the rendered title region
32
+ # (main segments + optional description). Keeps the layout from
33
+ # blowing up to 4+ rows when a multi-segment template is paired
34
+ # with a sub_title — the resulting infographic looks busy and
35
+ # dominates the chart. Set <= 0 to disable.
36
+ self.max_total_rows = 3
37
+
38
+ def _filter_templates_by_row_budget(self, templates, has_description: bool):
39
+ """Drop templates whose ``main_segments + (1 if has_description else 0)``
40
+ would exceed ``self.max_total_rows``. Used as a global guard rail
41
+ regardless of whether the LLM or the heuristic picker is in charge.
42
+ """
43
+ if not self.max_total_rows or self.max_total_rows <= 0:
44
+ return list(templates)
45
+ budget = self.max_total_rows - (1 if has_description else 0)
46
+ kept = []
47
+ for t in templates:
48
+ main_count = sum(1 for l in t.lines if l.role == 'main')
49
+ if main_count <= budget:
50
+ kept.append(t)
51
+ return kept
52
+
53
+ def generate(self,
54
+ title: str,
55
+ description: Optional[str] = None,
56
+ primary_color: str = '#2E7D32',
57
+ secondary_color: Optional[str] = None,
58
+ background_color: str = '#FFFFFF',
59
+ max_width: Optional[int] = None,
60
+ alignment: Optional[str] = None,
61
+ template_name: Optional[str] = None,
62
+ top_k: Optional[int] = None,
63
+ style: Optional[str] = None) -> List[Dict]:
64
+ """
65
+ Generate infographic title
66
+
67
+ Args:
68
+ title: Main title text
69
+ description: Subtitle/description text (optional)
70
+ primary_color: Primary color
71
+ secondary_color: Secondary color (optional)
72
+ background_color: Background color for contrast checking
73
+ max_width: Maximum width limit
74
+ alignment: Alignment ('left', 'center', 'right'), None means no limit
75
+ template_name: Specify template name (optional)
76
+ top_k: Number of templates to try (only works when template_name is None and use_llm=True)
77
+ style: Template style filter ('normal', 'comic', 'simple', 'professional', 'all' or None)
78
+ None means no style filtering, 'all' means use all styles
79
+
80
+ Returns:
81
+ List of results, each containing:
82
+ - template_name: Template name used
83
+ - svg: SVG string
84
+ - width: SVG width
85
+ - height: SVG height
86
+ """
87
+ # Store background_color for later use in rendering
88
+ self.background_color = background_color
89
+
90
+ # Step 1: Determine template and title splitting
91
+ if self.use_llm:
92
+ # Use LLM to analyze title splitting
93
+ if template_name:
94
+ print(f"🤖 Analyzing title with LLM for template: {template_name}")
95
+
96
+ # Get the specified template
97
+ candidate_templates = self._select_templates(
98
+ has_description=(description is not None),
99
+ alignment=alignment,
100
+ template_name=template_name,
101
+ style=style
102
+ )
103
+ if not candidate_templates:
104
+ print(f"⚠️ Specified template '{template_name}' not found")
105
+ return []
106
+
107
+ # Build templates info for LLM (just the one template)
108
+ t = candidate_templates[0]
109
+ segment_count = 0
110
+ if t.lines:
111
+ segment_count = len([l for l in t.lines if l.role == 'main'])
112
+
113
+ templates_info = [{
114
+ 'name': t.name,
115
+ 'description': t.description,
116
+ 'segment_count': segment_count
117
+ }]
118
+
119
+ # Single template with LLM splitting
120
+ recommendation = self.analyzer.recommend_template(
121
+ title,
122
+ has_description=(description is not None),
123
+ templates_info=templates_info
124
+ )
125
+
126
+ if not recommendation:
127
+ print("❌ LLM analysis failed")
128
+ return []
129
+
130
+ title_lines = recommendation['title_split']
131
+ rec_split_method = recommendation.get('split_method') or 'llm'
132
+
133
+ print(f" ✅ LLM Analysis:")
134
+ print(f" Title Splitting: {title_lines}")
135
+ print(f" Reasoning: {recommendation.get('reasoning', '')}")
136
+
137
+ elif top_k and top_k > 1:
138
+ # Top-k template recommendation
139
+ print(f"🤖 Analyzing title with LLM to recommend top-{top_k} templates...")
140
+
141
+ # Step 1: Pre-filter all templates by contrast, style, and alignment
142
+ print(f" 📋 Step 1: Filtering templates by contrast with background {background_color}")
143
+
144
+ # Apply style filter first if specified
145
+ templates_to_check = self.templates
146
+ if style and style != 'all':
147
+ templates_to_check = [t for t in templates_to_check if t.style == style]
148
+ print(f" 🎨 Style filter: {style} ({len(templates_to_check)} templates match)")
149
+
150
+ all_contrast_valid = self._filter_templates_by_contrast(
151
+ templates_to_check, background_color, primary_color
152
+ )
153
+
154
+ if not all_contrast_valid:
155
+ print(" ❌ All templates filtered out by contrast check")
156
+ return []
157
+
158
+ print(f" ✅ {len(all_contrast_valid)}/{len(templates_to_check)} templates passed contrast check")
159
+
160
+ # Hard cap on total visible rows (main segments + optional desc).
161
+ # Without this, a 3-main-segment template + sub_title would
162
+ # produce 4 stacked rows in the title region.
163
+ row_budget_kept = self._filter_templates_by_row_budget(
164
+ all_contrast_valid, has_description=(description is not None)
165
+ )
166
+ if row_budget_kept:
167
+ if len(row_budget_kept) != len(all_contrast_valid):
168
+ print(f" ✂️ Row-budget filter (<= {self.max_total_rows} rows total): "
169
+ f"{len(row_budget_kept)}/{len(all_contrast_valid)} kept")
170
+ all_contrast_valid = row_budget_kept
171
+ else:
172
+ print(" ⚠️ Row-budget filter would drop everything; keeping contrast-valid pool as-is")
173
+
174
+ # Apply alignment filter if specified
175
+ if alignment:
176
+ templates_with_alignment = [t for t in all_contrast_valid if t.alignment == alignment]
177
+ if not templates_with_alignment:
178
+ print(f" ⚠️ No templates found with alignment='{alignment}', falling back to all alignments")
179
+ print(f" 💡 Available templates: {len(all_contrast_valid)} (various alignments)")
180
+ else:
181
+ all_contrast_valid = templates_with_alignment
182
+ print(f" ✅ Alignment filter: {alignment} ({len(all_contrast_valid)} templates match)")
183
+
184
+ # Step 2: Group filtered templates by segment count for diversity
185
+ print(f" 📊 Step 2: Sampling diverse templates from filtered set")
186
+ import random
187
+ templates_by_segments = {}
188
+
189
+ for t in all_contrast_valid:
190
+ # Count main segments
191
+ segments = []
192
+ if t.lines:
193
+ segments = [l for l in t.lines if l.role == 'main']
194
+
195
+ seg_count = len(segments)
196
+ if seg_count not in templates_by_segments:
197
+ templates_by_segments[seg_count] = []
198
+
199
+ templates_by_segments[seg_count].append({
200
+ 'template': t,
201
+ 'segment_count': seg_count
202
+ })
203
+
204
+ # Sample 3 templates from each segment count group
205
+ sampled_templates = []
206
+ for seg_count in sorted(templates_by_segments.keys()):
207
+ group = templates_by_segments[seg_count]
208
+ sample_size = min(3, len(group))
209
+ sampled = random.sample(group, sample_size)
210
+ sampled_templates.extend(sampled)
211
+
212
+ print(f" 📌 Sampled {len(sampled_templates)} diverse templates:")
213
+ for seg_count in sorted(templates_by_segments.keys()):
214
+ group_names = [s['template'].name for s in sampled_templates if s['segment_count'] == seg_count]
215
+ if group_names:
216
+ print(f" {seg_count}-segment: {', '.join(group_names)}")
217
+
218
+ # Step 3: Prepare template info for LLM (only contrast-valid, sampled templates)
219
+ print(f" 🤖 Step 3: Sending {len(sampled_templates)} templates to LLM")
220
+ templates_info = []
221
+ for item in sampled_templates:
222
+ t = item['template']
223
+ info = {
224
+ 'name': t.name,
225
+ 'description': t.description,
226
+ 'segment_roles': getattr(t, 'segment_roles', ''),
227
+ 'segment_count': item['segment_count']
228
+ }
229
+ templates_info.append(info)
230
+
231
+ top_k_result = self.analyzer.recommend_top_k_templates(title, templates_info, top_k)
232
+
233
+ if not top_k_result:
234
+ print(" ❌ LLM analysis failed")
235
+ return []
236
+
237
+ recommendations_list = top_k_result.get('recommendations', [])
238
+
239
+ print(f" ✅ LLM Analysis - Top {len(recommendations_list)} Recommendations:")
240
+ for i, rec in enumerate(recommendations_list, 1):
241
+ print(f" {i}. {rec['template_name']} (confidence: {rec.get('confidence', 0):.2f})")
242
+ if rec.get('paraphrased_title'):
243
+ print(f" Paraphrased: \"{rec['paraphrased_title']}\"")
244
+ print(f" Split: {rec['title_split']}")
245
+ print(f" Reason: {rec.get('reasoning', '')}")
246
+
247
+ # Step 4: Generate SVG for each recommended template
248
+ print(f" 🎨 Step 4: Generating SVG for recommended templates")
249
+ all_results = []
250
+ for rec in recommendations_list:
251
+ rec_template_name = rec['template_name']
252
+ rec_title_split = rec['title_split']
253
+ rec_split_method = rec.get('split_method') or 'llm'
254
+
255
+ # Find template (should already be contrast-valid from Step 1)
256
+ rec_templates = self._select_templates(
257
+ has_description=(description is not None),
258
+ alignment=alignment,
259
+ template_name=rec_template_name,
260
+ style=style
261
+ )
262
+
263
+ if not rec_templates:
264
+ print(f" ⚠️ Template '{rec_template_name}' not found, skipping")
265
+ continue
266
+
267
+ # Generate with this template
268
+ result = self._generate_with_template(
269
+ rec_templates[0], rec_title_split, description,
270
+ primary_color, secondary_color, background_color, max_width
271
+ )
272
+
273
+ if result:
274
+ result['split_method'] = rec_split_method
275
+ all_results.append(result)
276
+ print(f" ✓ Template '{rec_template_name}' generated successfully")
277
+
278
+ return all_results
279
+
280
+ else:
281
+ # Single template recommendation
282
+ print("🤖 Analyzing title with LLM to recommend template...")
283
+
284
+ # Get available templates (with style filter if specified)
285
+ available_templates = self.templates
286
+ if style and style != 'all':
287
+ available_templates = [t for t in available_templates if t.style == style]
288
+ print(f" 🎨 Style filter: {style} ({len(available_templates)} templates available)")
289
+
290
+ # Build templates info for LLM
291
+ templates_info = []
292
+ for t in available_templates:
293
+ # Count segments
294
+ segment_count = 0
295
+ if t.lines:
296
+ segment_count = len([l for l in t.lines if l.role == 'main'])
297
+
298
+ templates_info.append({
299
+ 'name': t.name,
300
+ 'description': t.description,
301
+ 'segment_count': segment_count
302
+ })
303
+
304
+ recommendation = self.analyzer.recommend_template(
305
+ title,
306
+ has_description=(description is not None),
307
+ templates_info=templates_info
308
+ )
309
+
310
+ if not recommendation:
311
+ print("❌ LLM analysis failed")
312
+ return []
313
+
314
+ recommended_template_name = recommendation['recommended_template']
315
+ title_lines = recommendation['title_split']
316
+ reasoning = recommendation.get('reasoning', '')
317
+
318
+ print(f" ✅ LLM Analysis:")
319
+ print(f" Recommended Template: {recommended_template_name}")
320
+ print(f" Title Splitting: {title_lines}")
321
+ print(f" Reasoning: {reasoning}")
322
+
323
+ candidate_templates = []
324
+ for t in self.templates:
325
+ if t.name == recommended_template_name:
326
+ candidate_templates = [t]
327
+ break
328
+
329
+ if not candidate_templates:
330
+ print(f" ❌ Recommended template '{recommended_template_name}' not found")
331
+ return []
332
+
333
+ elif template_name:
334
+ # Use specified template without LLM
335
+ print(f"📋 Using specified template: {template_name}")
336
+ candidate_templates = self._select_templates(
337
+ has_description=(description is not None),
338
+ alignment=alignment,
339
+ template_name=template_name,
340
+ style=style
341
+ )
342
+ if not candidate_templates:
343
+ print(f"⚠️ Specified template '{template_name}' not found")
344
+ return []
345
+ # Split by newline if title contains \n
346
+ if '\n' in title:
347
+ title_lines = title.split('\n')
348
+ else:
349
+ title_lines = [title]
350
+
351
+ else:
352
+ # Use simple standard template (useLLM=False)
353
+ print("📋 Using standard single-line template (LLM disabled)")
354
+ candidate_templates = []
355
+ for t in self.templates:
356
+ if t.name == 'infographic_standard':
357
+ candidate_templates = [t]
358
+ break
359
+
360
+ if not candidate_templates:
361
+ print("❌ Standard template not found")
362
+ return []
363
+
364
+ # Split by newline if title contains \n
365
+ if '\n' in title:
366
+ title_lines = title.split('\n')
367
+ else:
368
+ title_lines = [title]
369
+
370
+ # Step 2: Filter templates by color contrast
371
+ filtered_templates = self._filter_templates_by_contrast(
372
+ candidate_templates, background_color, primary_color
373
+ )
374
+
375
+ if not filtered_templates:
376
+ print("⚠️ All templates filtered out due to insufficient contrast with background")
377
+ return []
378
+
379
+ # Step 3: Generate SVG
380
+ # Single-template / standard / specified branches don't have a real
381
+ # rec; mark the split source as 'literal' (kept the raw title or just
382
+ # split on newlines).
383
+ rec_split_method = locals().get('rec_split_method', 'literal')
384
+ results = []
385
+
386
+ for template in filtered_templates:
387
+ try:
388
+ result = self._generate_with_template(
389
+ template, title_lines, description,
390
+ primary_color, secondary_color, background_color, max_width
391
+ )
392
+
393
+ if result:
394
+ result['title_lines'] = title_lines
395
+ result['split_method'] = rec_split_method
396
+ results.append(result)
397
+ print(f" ✓ Template '{template.name}' generated successfully")
398
+ except Exception as e:
399
+ print(f" ✗ Template '{template.name}' generation failed: {e}")
400
+ continue
401
+
402
+ print(f"\n{'='*60}")
403
+ print(f"🎉 Total generated {len(results)} result(s)")
404
+ print(f"{'='*60}")
405
+
406
+ return results
407
+
408
+ def _select_templates(self,
409
+ has_description: bool,
410
+ alignment: Optional[str],
411
+ template_name: Optional[str],
412
+ style: Optional[str] = None) -> List[TitleTemplate]:
413
+ """Select suitable templates"""
414
+ candidates = []
415
+
416
+ # If template name is specified, use only that template
417
+ if template_name:
418
+ for template in self.templates:
419
+ if template.name == template_name:
420
+ candidates.append(template)
421
+ return candidates
422
+
423
+ # Filter by description availability
424
+ if has_description:
425
+ candidates = get_templates_with_description()
426
+ else:
427
+ candidates = get_templates_main_only()
428
+
429
+ # Filter by alignment
430
+ if alignment:
431
+ candidates = [t for t in candidates if t.alignment == alignment]
432
+
433
+ # Filter by style
434
+ if style and style != 'all':
435
+ candidates = [t for t in candidates if t.style == style]
436
+
437
+ return candidates
438
+
439
+ def _filter_templates_by_contrast(self, templates: List[TitleTemplate],
440
+ background_color: str,
441
+ primary_color: str,
442
+ min_contrast: float = 2.5) -> List[TitleTemplate]:
443
+ """
444
+ Filter templates by color contrast with page background
445
+ Skip templates where text or background rectangles are too similar to page background
446
+
447
+ Args:
448
+ templates: Template list to filter
449
+ background_color: Page background color
450
+ primary_color: Primary color (user-provided, skip checking)
451
+ min_contrast: Minimum acceptable contrast ratio (default 2.5)
452
+
453
+ Returns:
454
+ Filtered template list
455
+ """
456
+ filtered = []
457
+
458
+ for template in templates:
459
+ # Get all lines
460
+ all_lines = template.lines if template.lines else []
461
+
462
+ is_acceptable = True
463
+
464
+ for line in all_lines:
465
+ colors_to_check = []
466
+
467
+ # Rule 1: If line has background rectangle, check rectangle vs page background
468
+ if line.background and isinstance(line.background, dict):
469
+ bg_rect_color = line.background.get('color')
470
+ # Only check fixed colors, skip primary_color/secondary_color
471
+ if bg_rect_color and bg_rect_color not in ['primary_color', 'secondary_color']:
472
+ colors_to_check.append(('background rect', bg_rect_color))
473
+ # Rule 2: If no background rectangle, check text color vs page background
474
+ else:
475
+ # Skip primary_color/secondary_color (user-provided)
476
+ if line.color_type == COLOR_TYPE_FIXED and line.color_value:
477
+ colors_to_check.append(('text', line.color_value))
478
+
479
+ # Calculate contrast
480
+ for color_type, check_color in colors_to_check:
481
+ try:
482
+ contrast = contrast_ratio(check_color, background_color)
483
+ if contrast < min_contrast:
484
+ # print(f" ⚠️ Template '{template.name}' skipped: {color_type} {check_color} vs page bg {background_color} = {contrast:.2f} (< {min_contrast})")
485
+ is_acceptable = False
486
+ break
487
+ except Exception:
488
+ # If contrast calculation fails, accept the template
489
+ pass
490
+
491
+ if not is_acceptable:
492
+ break
493
+
494
+ if is_acceptable:
495
+ filtered.append(template)
496
+
497
+ return filtered
498
+
499
+ def _generate_with_template(self,
500
+ template: TitleTemplate,
501
+ title_lines: List[str],
502
+ description: Optional[str],
503
+ primary_color: str,
504
+ secondary_color: Optional[str],
505
+ background_color: str,
506
+ max_width: Optional[int]) -> Optional[Dict]:
507
+ """Generate SVG with specified template"""
508
+
509
+ # Check if template has inline groups
510
+ inline_groups = getattr(template, 'inline_groups', [])
511
+
512
+ # Single column layout
513
+ lines_data = []
514
+ line_configs = []
515
+
516
+ title_idx = 0
517
+
518
+ for line_idx, line_config in enumerate(template.lines):
519
+ # Determine which text to use based on role
520
+ if line_config.role == 'main':
521
+ # Use title lines split by LLM
522
+ if title_idx < len(title_lines):
523
+ text = title_lines[title_idx]
524
+ title_idx += 1
525
+ else:
526
+ continue
527
+ elif line_config.role == 'description':
528
+ if description:
529
+ text = description
530
+ else:
531
+ continue
532
+ else:
533
+ continue
534
+
535
+ # Convert to dict and apply colors
536
+ config = line_config.to_dict()
537
+
538
+ # Parse color
539
+ if config['color_type'] == COLOR_TYPE_PRIMARY:
540
+ config['final_color'] = primary_color
541
+ elif config['color_type'] == COLOR_TYPE_SECONDARY:
542
+ config['final_color'] = secondary_color or primary_color
543
+ else: # FIXED
544
+ config['final_color'] = config['color_value']
545
+
546
+ # Handle background color (if background exists and color is primary_color)
547
+ if config.get('background') and isinstance(config['background'], dict):
548
+ bg_color = config['background'].get('color')
549
+ if bg_color == 'primary_color':
550
+ config['background']['color'] = primary_color
551
+ elif bg_color == 'secondary_color':
552
+ config['background']['color'] = secondary_color or primary_color
553
+
554
+ lines_data.append(text)
555
+ line_configs.append(config)
556
+
557
+ # Mark inline groups in configs (don't merge yet, let renderer handle it)
558
+ if inline_groups:
559
+ for i, config in enumerate(line_configs):
560
+ # Check if this index is in an inline group
561
+ for group_start, group_end in inline_groups:
562
+ if group_start <= i <= group_end:
563
+ config['inline_group'] = (group_start, group_end)
564
+ config['inline_position'] = i - group_start # Position within group (0, 1, 2...)
565
+ break
566
+
567
+ if not lines_data:
568
+ return None
569
+
570
+ # Render SVG (with inline group info in configs)
571
+ svg_string, (width, height) = self.renderer.render_svg(
572
+ lines_data, line_configs, template.alignment, max_width, background_color
573
+ )
574
+
575
+ # Build a self-contained, render-reproducible record for each segment.
576
+ # We resolve color_type → final hex here so consumers don't need the
577
+ # original primary/secondary palette to recreate the look.
578
+ segments = []
579
+ for txt, cfg in zip(lines_data, line_configs):
580
+ seg = {
581
+ 'text': txt,
582
+ 'role': cfg.get('role'),
583
+ 'importance': cfg.get('importance'),
584
+ 'font_family': cfg.get('font_family'),
585
+ 'font_size': cfg.get('font_size'),
586
+ 'font_weight': cfg.get('font_weight'),
587
+ 'font_style': cfg.get('font_style'),
588
+ 'color': cfg.get('final_color') or cfg.get('color_value'),
589
+ 'text_transform': cfg.get('text_transform', 'none'),
590
+ 'letter_spacing': cfg.get('letter_spacing', 0),
591
+ 'background': cfg.get('background') or None,
592
+ 'underline': cfg.get('underline', False),
593
+ 'strikethrough': cfg.get('strikethrough', False),
594
+ 'outline': cfg.get('outline', False),
595
+ 'shadow': cfg.get('shadow', False),
596
+ 'inline_group': list(cfg['inline_group']) if cfg.get('inline_group') else None,
597
+ }
598
+ segments.append(seg)
599
+
600
+ return {
601
+ 'template_name': template.name,
602
+ 'template_description': template.description,
603
+ 'svg': svg_string,
604
+ 'width': width,
605
+ 'height': height,
606
+ 'alignment': template.alignment,
607
+ 'segments': segments,
608
+ 'primary_color': primary_color,
609
+ 'secondary_color': secondary_color,
610
+ 'background_color': background_color,
611
+ }
612
+
613
+ def analyze_title(self,
614
+ title: str,
615
+ description: Optional[str] = None,
616
+ primary_color: str = '#2E7D32',
617
+ background_color: str = '#FFFFFF',
618
+ alignment: Optional[str] = None,
619
+ top_k: int = 3,
620
+ style: Optional[str] = None) -> Optional[Dict]:
621
+ """
622
+ Analyze title with LLM to get template recommendations and title splitting.
623
+ This method only calls LLM once and returns analysis results for later SVG generation.
624
+
625
+ Args:
626
+ title: Main title text
627
+ description: Subtitle/description text (optional)
628
+ primary_color: Primary color for contrast checking
629
+ background_color: Background color for contrast checking
630
+ alignment: Alignment filter ('left', 'center', 'right')
631
+ top_k: Number of template recommendations to return
632
+ style: Template style filter
633
+
634
+ Returns:
635
+ Dict containing:
636
+ - recommendations: List of template recommendations with title_split
637
+ - has_description: Whether description was provided
638
+ - primary_color: Primary color
639
+ - background_color: Background color
640
+ - alignment: Alignment
641
+ - style: Style filter
642
+ """
643
+ if not self.use_llm:
644
+ print("❌ LLM is disabled, cannot analyze title")
645
+ return None
646
+
647
+ print(f"🤖 Analyzing title with LLM to recommend top-{top_k} templates...")
648
+
649
+ # Step 1: Pre-filter all templates by contrast, style, and alignment
650
+ print(f" 📋 Step 1: Filtering templates by contrast with background {background_color}")
651
+
652
+ # Apply style filter first if specified
653
+ templates_to_check = self.templates
654
+ if style and style != 'all':
655
+ templates_to_check = [t for t in templates_to_check if t.style == style]
656
+ print(f" 🎨 Style filter: {style} ({len(templates_to_check)} templates match)")
657
+
658
+ all_contrast_valid = self._filter_templates_by_contrast(
659
+ templates_to_check, background_color, primary_color
660
+ )
661
+
662
+ if not all_contrast_valid:
663
+ print(" ❌ All templates filtered out by contrast check")
664
+ return None
665
+
666
+ print(f" ✅ {len(all_contrast_valid)}/{len(templates_to_check)} templates passed contrast check")
667
+
668
+ # Hard cap on total visible rows (main segments + optional desc).
669
+ row_budget_kept = self._filter_templates_by_row_budget(
670
+ all_contrast_valid, has_description=(description is not None)
671
+ )
672
+ if row_budget_kept:
673
+ if len(row_budget_kept) != len(all_contrast_valid):
674
+ print(f" ✂️ Row-budget filter (<= {self.max_total_rows} rows total): "
675
+ f"{len(row_budget_kept)}/{len(all_contrast_valid)} kept")
676
+ all_contrast_valid = row_budget_kept
677
+ else:
678
+ print(" ⚠️ Row-budget filter would drop everything; keeping contrast-valid pool as-is")
679
+
680
+ # Apply alignment filter if specified
681
+ if alignment:
682
+ templates_with_alignment = [t for t in all_contrast_valid if t.alignment == alignment]
683
+ if not templates_with_alignment:
684
+ print(f" ⚠️ No templates found with alignment='{alignment}', falling back to all alignments")
685
+ else:
686
+ all_contrast_valid = templates_with_alignment
687
+ print(f" ✅ Alignment filter: {alignment} ({len(all_contrast_valid)} templates match)")
688
+
689
+ # Step 2: Group filtered templates by segment count for diversity
690
+ print(f" 📊 Step 2: Sampling diverse templates from filtered set")
691
+ import random
692
+ templates_by_segments = {}
693
+
694
+ for t in all_contrast_valid:
695
+ segments = []
696
+ if t.lines:
697
+ segments = [l for l in t.lines if l.role == 'main']
698
+
699
+ seg_count = len(segments)
700
+ if seg_count not in templates_by_segments:
701
+ templates_by_segments[seg_count] = []
702
+
703
+ templates_by_segments[seg_count].append({
704
+ 'template': t,
705
+ 'segment_count': seg_count
706
+ })
707
+
708
+ # Sample 3 templates from each segment count group
709
+ sampled_templates = []
710
+ for seg_count in sorted(templates_by_segments.keys()):
711
+ group = templates_by_segments[seg_count]
712
+ sample_size = min(3, len(group))
713
+ sampled = random.sample(group, sample_size)
714
+ sampled_templates.extend(sampled)
715
+
716
+ print(f" 📌 Sampled {len(sampled_templates)} diverse templates")
717
+
718
+ # Step 3: Prepare template info for LLM
719
+ print(f" 🤖 Step 3: Sending {len(sampled_templates)} templates to LLM")
720
+ templates_info = []
721
+ for item in sampled_templates:
722
+ t = item['template']
723
+ info = {
724
+ 'name': t.name,
725
+ 'description': t.description,
726
+ 'segment_roles': getattr(t, 'segment_roles', ''),
727
+ 'segment_count': item['segment_count']
728
+ }
729
+ templates_info.append(info)
730
+
731
+ top_k_result = self.analyzer.recommend_top_k_templates(title, templates_info, top_k)
732
+
733
+ if not top_k_result:
734
+ print(" ❌ LLM analysis failed")
735
+ return None
736
+
737
+ recommendations_list = top_k_result.get('recommendations', [])
738
+
739
+ print(f" ✅ LLM Analysis - Top {len(recommendations_list)} Recommendations:")
740
+ for i, rec in enumerate(recommendations_list, 1):
741
+ print(f" {i}. {rec['template_name']} (confidence: {rec.get('confidence', 0):.2f})")
742
+ if rec.get('paraphrased_title'):
743
+ print(f" Paraphrased: \"{rec['paraphrased_title']}\"")
744
+ print(f" Split: {rec['title_split']}")
745
+
746
+ return {
747
+ 'recommendations': recommendations_list,
748
+ 'has_description': description is not None,
749
+ 'description': description,
750
+ 'primary_color': primary_color,
751
+ 'background_color': background_color,
752
+ 'alignment': alignment,
753
+ 'style': style
754
+ }
755
+
756
+ def generate_with_analysis(self,
757
+ analysis_result: Dict,
758
+ max_widths: List[int],
759
+ secondary_color: Optional[str] = None,
760
+ recommendation_index: int = 0) -> List[Dict]:
761
+ """
762
+ Generate SVGs based on pre-computed LLM analysis with multiple widths.
763
+ This method does not call LLM and generates SVGs for each width.
764
+
765
+ Args:
766
+ analysis_result: Result from analyze_title()
767
+ max_widths: List of max_width values to generate for
768
+ secondary_color: Secondary color (optional)
769
+ recommendation_index: Which recommendation to use (default 0, the first/best one)
770
+
771
+ Returns:
772
+ List of results, one for each max_width, each containing:
773
+ - template_name: Template name used
774
+ - svg: SVG string
775
+ - width: SVG width
776
+ - height: SVG height
777
+ - max_width_requested: The max_width that was requested
778
+ """
779
+ if not analysis_result or 'recommendations' not in analysis_result:
780
+ print("❌ Invalid analysis result")
781
+ return []
782
+
783
+ recommendations = analysis_result['recommendations']
784
+ if not recommendations:
785
+ print("❌ No recommendations in analysis result")
786
+ return []
787
+
788
+ if recommendation_index >= len(recommendations):
789
+ recommendation_index = 0
790
+
791
+ rec = recommendations[recommendation_index]
792
+ template_name = rec['template_name']
793
+ title_split = rec['title_split']
794
+ split_method = rec.get('split_method') or analysis_result.get('split_method') or 'llm'
795
+
796
+ description = analysis_result.get('description')
797
+ primary_color = analysis_result.get('primary_color', '#2E7D32')
798
+ background_color = analysis_result.get('background_color', '#FFFFFF')
799
+ alignment = analysis_result.get('alignment')
800
+ style = analysis_result.get('style')
801
+
802
+ # Find the template
803
+ templates = self._select_templates(
804
+ has_description=analysis_result.get('has_description', False),
805
+ alignment=alignment,
806
+ template_name=template_name,
807
+ style=style
808
+ )
809
+
810
+ if not templates:
811
+ print(f"❌ Template '{template_name}' not found")
812
+ return []
813
+
814
+ template = templates[0]
815
+
816
+ print(f"🎨 Generating SVGs for {len(max_widths)} different widths with template '{template_name}'")
817
+
818
+ results = []
819
+ for max_width in max_widths:
820
+ result = self._generate_with_template(
821
+ template, title_split, description,
822
+ primary_color, secondary_color, background_color, max_width
823
+ )
824
+
825
+ if result:
826
+ result['max_width_requested'] = max_width
827
+ result['split_method'] = split_method
828
+ results.append(result)
829
+ print(f" ✓ Width {max_width}: generated {result['width']}x{result['height']}px")
830
+
831
+ return results
832
+
833
+ def save_result(self, result: Dict, filepath: str):
834
+ """
835
+ Save generation result to file
836
+
837
+ Args:
838
+ result: Generation result dictionary
839
+ filepath: Output file path
840
+ """
841
+ # Ensure directory exists
842
+ os.makedirs(os.path.dirname(filepath) if os.path.dirname(filepath) else '.', exist_ok=True)
843
+
844
+ with open(filepath, 'w', encoding='utf-8') as f:
845
+ f.write(result['svg'])
846
+
847
+ print(f"SVGSaved to: {filepath}")
848
+
849
+ def save_all_results(self, results: List[Dict], session_name: str = None) -> str:
850
+ """
851
+ Save all generation results to output directory (flat structure)
852
+
853
+ Args:
854
+ results: Generation result list
855
+ session_name: Session name or filepath prefix (e.g., "test" or "output/test.svg")
856
+
857
+ Returns:
858
+ Output directory path
859
+ """
860
+ if session_name is None:
861
+ session_name = datetime.now().strftime("%Y%m%d_%H%M%S")
862
+
863
+ # Remove .svg extension if present
864
+ if session_name.endswith('.svg'):
865
+ session_name = session_name[:-4]
866
+
867
+ # Extract directory and base name from session_name
868
+ if '/' in session_name:
869
+ output_dir = os.path.dirname(session_name)
870
+ base_name = os.path.basename(session_name)
871
+ else:
872
+ output_dir = "output"
873
+ base_name = session_name
874
+
875
+ os.makedirs(output_dir, exist_ok=True)
876
+
877
+ print(f"\n📁 Saving to directory: {output_dir}")
878
+ print(f"{'='*60}")
879
+
880
+ for i, result in enumerate(results, 1):
881
+ # Use base_name as filename prefix, include template name in filename
882
+ template_name = result.get('template_name', 'unknown')
883
+ if len(results) > 1:
884
+ filename = f"{base_name}_{i}_{template_name}.svg"
885
+ else:
886
+ filename = f"{base_name}_{template_name}.svg"
887
+ filepath = os.path.join(output_dir, filename)
888
+
889
+ with open(filepath, 'w', encoding='utf-8') as f:
890
+ f.write(result['svg'])
891
+
892
+ print(f" ✓ {filename} ({result['width']}x{result['height']}px)")
893
+
894
+ print(f"{'='*60}")
895
+ print(f"✅ Total saved {len(results)} file(s) to {output_dir}/")
896
+
897
+ return output_dir
898
+
899
+
900
+ # Convenience functions
901
+ def generate_title(title: str,
902
+ description: Optional[str] = None,
903
+ primary_color: str = '#2E7D32',
904
+ secondary_color: Optional[str] = None,
905
+ background_color: str = '#FFFFFF',
906
+ max_width: Optional[int] = None,
907
+ alignment: Optional[str] = None,
908
+ template_name: Optional[str] = None,
909
+ use_llm: bool = True,
910
+ top_k: Optional[int] = None,
911
+ style: Optional[str] = None,
912
+ save_to: Optional[str] = None) -> List[Dict]:
913
+ """
914
+ Convenience function: Generate infographic title
915
+
916
+ Args:
917
+ title: Main title
918
+ description: Subtitle/description
919
+ primary_color: Primary color
920
+ secondary_color: Secondary color
921
+ background_color: Background color
922
+ max_width: Maximum width
923
+ alignment: Alignment (left/center/right)
924
+ template_name: Specify template name
925
+ use_llm: Whether to use LLM to analyze title (default True)
926
+ top_k: Number of templates to try (default None, only when use_llm=True and template_name=None)
927
+ style: Template style filter ('normal', 'comic', 'simple', 'professional', 'all' or None)
928
+ save_to: Save path (optional), if provided, all results will be saved automatically
929
+
930
+ Returns:
931
+ Generation result list
932
+ """
933
+ generator = InfographicTitleGenerator(use_llm=use_llm)
934
+ results = generator.generate(
935
+ title=title,
936
+ description=description,
937
+ primary_color=primary_color,
938
+ secondary_color=secondary_color,
939
+ background_color=background_color,
940
+ max_width=max_width,
941
+ alignment=alignment,
942
+ template_name=template_name,
943
+ top_k=top_k,
944
+ style=style
945
+ )
946
+
947
+ # If save_to is specified, automatically save all results
948
+ if save_to and results:
949
+ generator.save_all_results(results, save_to)
950
+
951
+ return results
952
+
953
+
954
+
modules/title_styler/llm_analyzer.py ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infographic Title Generator - LLM Analyzer
3
+ Use LLM to analyze title structure and recommend suitable templates
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import random
9
+ import re
10
+ import requests
11
+ from typing import Dict, List, Optional
12
+
13
+
14
+ def _split_title_evenly(title: str, n_segments: int) -> List[str]:
15
+ """按词把 title 大致均分成 n 段,作为最后兜底(语义切分也失败时使用)。"""
16
+ if n_segments <= 0:
17
+ n_segments = 1
18
+ text = (title or "").strip()
19
+ if not text:
20
+ return [""] * n_segments
21
+ words = re.split(r"\s+", text)
22
+ if len(words) <= n_segments:
23
+ out = list(words)
24
+ while len(out) < n_segments:
25
+ out.append("")
26
+ return out
27
+ base = len(words) // n_segments
28
+ extra = len(words) % n_segments
29
+ parts: List[str] = []
30
+ idx = 0
31
+ for i in range(n_segments):
32
+ take = base + (1 if i < extra else 0)
33
+ parts.append(" ".join(words[idx:idx + take]))
34
+ idx += take
35
+ return parts
36
+
37
+
38
+ # === Heuristic semantic splitter ===================================
39
+ # When LLM is disabled (default in the prod pipeline) we still want the
40
+ # title to break at natural language boundaries — colons, commas, the
41
+ # word "vs.", before prepositions / conjunctions — instead of a blind
42
+ # even-by-words split. This module-local DP picks N-1 break points to
43
+ # maximise a token-gap score table.
44
+
45
+ _PREPOSITIONS = frozenset({
46
+ "of", "in", "by", "with", "for", "from", "on", "over", "per",
47
+ "to", "at", "into", "across", "between", "after", "before",
48
+ "during", "since", "through", "via", "under", "above",
49
+ })
50
+ _CONJUNCTIONS = frozenset({"and", "or", "but", "vs", "vs.", "versus", "&"})
51
+
52
+
53
+ def _gap_score(left_word: str, right_word: str) -> float:
54
+ """Score the desirability of breaking after ``left_word``.
55
+
56
+ Higher = more natural break point. Negative numbers mean "fairly
57
+ arbitrary place to break" (we still let length-balance decide there).
58
+ """
59
+ lw = (left_word or "").rstrip()
60
+ rw_lower = (right_word or "").lower().lstrip()
61
+ if not lw or not rw_lower:
62
+ return 0.0
63
+
64
+ # Hard punctuation at the end of left word — strongest preference.
65
+ last = lw[-1]
66
+ if last == ":":
67
+ return 1000.0
68
+ if last == ";":
69
+ return 900.0
70
+ if last in {"—", "–"}:
71
+ return 850.0
72
+ if last == "?" or last == "!":
73
+ return 800.0
74
+ if last == "." and len(lw) > 2 and not lw.endswith(("Mr.", "Mrs.", "Dr.", "St.", "vs.")):
75
+ # Likely sentence-ending period (skip common abbreviations and "vs.").
76
+ return 700.0
77
+ if last == ",":
78
+ return 500.0
79
+
80
+ # Soft preference: break BEFORE conjunctions / prepositions.
81
+ rw_clean = rw_lower.rstrip(",.;:")
82
+ if rw_clean in _CONJUNCTIONS:
83
+ return 250.0
84
+ if rw_clean in _PREPOSITIONS:
85
+ return 150.0
86
+
87
+ # Fallback: arbitrary mid-phrase break, no bonus, length balance wins.
88
+ return 0.0
89
+
90
+
91
+ def _split_title_semantic(title: str, n_segments: int) -> List[str]:
92
+ """Split ``title`` into ``n_segments`` segments by maximising a
93
+ semantic-break score subject to length balance.
94
+
95
+ Algorithm:
96
+ 1) Tokenise on whitespace; collect word widths in characters.
97
+ 2) For every gap i (between word[i] and word[i+1]) compute a
98
+ positive bonus from punctuation/POS, then add a length-balance
99
+ term that penalises uneven segment widths.
100
+ 3) Greedy DP: dp[k][i] = best score for splitting the first i words
101
+ into k segments. Pick the top N-1 gaps that maximise the sum.
102
+
103
+ Falls back to even split when title is too short for ``n_segments``.
104
+ """
105
+ if n_segments <= 0:
106
+ n_segments = 1
107
+ text = (title or "").strip()
108
+ if not text:
109
+ return [""] * n_segments
110
+ words = re.split(r"\s+", text)
111
+ W = len(words)
112
+ if n_segments == 1:
113
+ return [" ".join(words)]
114
+ if W <= n_segments:
115
+ out = list(words)
116
+ while len(out) < n_segments:
117
+ out.append("")
118
+ return out
119
+
120
+ # Per-word "width" in characters (proxy for visual width). Spaces between
121
+ # words add 1 char.
122
+ char_lens = [len(w) for w in words]
123
+ # cumulative chars (each word + 1 space sep, except first), len = W+1
124
+ cum = [0] * (W + 1)
125
+ for i, c in enumerate(char_lens):
126
+ cum[i + 1] = cum[i] + c + (1 if i > 0 else 0)
127
+ total_chars = cum[W]
128
+ target_seg = total_chars / n_segments
129
+
130
+ def seg_chars(s: int, e: int) -> int:
131
+ """Chars in words[s..e-1] joined by spaces."""
132
+ if e <= s:
133
+ return 0
134
+ return cum[e] - cum[s] - (1 if s > 0 else 0)
135
+
136
+ # Pre-compute gap bonuses for every internal gap i (break AFTER word i).
137
+ gap_bonus = [0.0] * (W - 1)
138
+ for i in range(W - 1):
139
+ gap_bonus[i] = _gap_score(words[i], words[i + 1])
140
+
141
+ def seg_score(s: int, e: int) -> float:
142
+ """Score of segment words[s..e-1] viewed as one row."""
143
+ if e <= s:
144
+ return -1e9
145
+ c = seg_chars(s, e)
146
+ # Penalise distance from ideal segment length (per char).
147
+ # Coefficient 5 keeps it on the same order as gap bonuses for typical
148
+ # text (10-50 char titles), so a "+150 break before preposition"
149
+ # never beats a 30-char balance issue.
150
+ return -5.0 * abs(c - target_seg)
151
+
152
+ # dp[k][i] = best total score using first i words in exactly k segments.
153
+ # Track parent pointers to reconstruct the breaks.
154
+ NEG_INF = float("-inf")
155
+ dp = [[NEG_INF] * (W + 1) for _ in range(n_segments + 1)]
156
+ parent = [[-1] * (W + 1) for _ in range(n_segments + 1)]
157
+ dp[0][0] = 0.0
158
+ for k in range(1, n_segments + 1):
159
+ # Each segment must have at least 1 word; previous segments need
160
+ # at least k-1 words; final segment must leave at least 1 word.
161
+ for i in range(k, W + 1):
162
+ best = NEG_INF
163
+ best_j = -1
164
+ for j in range(k - 1, i):
165
+ if dp[k - 1][j] == NEG_INF:
166
+ continue
167
+ # Break after word j (i.e. between word j-1 and word j when
168
+ # j > 0); add the gap bonus for that break.
169
+ gap = gap_bonus[j - 1] if j > 0 else 0.0
170
+ cand = dp[k - 1][j] + seg_score(j, i) + gap
171
+ if cand > best:
172
+ best = cand
173
+ best_j = j
174
+ dp[k][i] = best
175
+ parent[k][i] = best_j
176
+
177
+ # Reconstruct boundaries.
178
+ boundaries = []
179
+ i = W
180
+ for k in range(n_segments, 0, -1):
181
+ j = parent[k][i]
182
+ boundaries.append((j, i))
183
+ i = j
184
+ boundaries.reverse()
185
+
186
+ parts = [" ".join(words[s:e]) for s, e in boundaries]
187
+ # Safety net — should never trigger, but keep length contract.
188
+ while len(parts) < n_segments:
189
+ parts.append("")
190
+ return parts[:n_segments]
191
+
192
+
193
+ class LLMAnalyzer:
194
+ """LLM Title Analyzer"""
195
+
196
+ def __init__(self, api_key=None, base_url=None, model=None):
197
+ """
198
+ Initialize LLM analyzer
199
+
200
+ Args:
201
+ api_key: API key (default uses built-in key)
202
+ base_url: API base URL (default uses built-in URL)
203
+ model: Model name (default uses gpt-5-mini)
204
+ """
205
+ # Use provided API configuration(环境变量优先,缺省禁用 LLM 走 fallback)
206
+ self.api_key = api_key or os.environ.get('CHARTPIPELINE_LLM_API_KEY') or ''
207
+ self.base_url = base_url or os.environ.get('CHARTPIPELINE_LLM_BASE_URL') or 'https://aihubmix.com/v1'
208
+ self.model = model or os.environ.get('CHARTPIPELINE_LLM_MODEL') or 'deepseek-v3.2'
209
+ self.llm_disabled = os.environ.get('CHARTPIPELINE_DISABLE_LLM', '1') == '1' or not self.api_key
210
+ # 仅第一次失败时打印 fallback 提示,避免刷屏
211
+ self._fallback_logged = False
212
+
213
+ def _log_fallback_once(self, reason: str):
214
+ if not self._fallback_logged:
215
+ print(f" ⚠️ LLM unavailable ({reason}), using local heuristic fallback for title styling")
216
+ self._fallback_logged = True
217
+
218
+ def _fallback_single(self, title: str, templates_info: Optional[List[Dict]]) -> Dict:
219
+ """LLM 失败时的单模板 fallback。"""
220
+ self._log_fallback_once("single-template")
221
+ if templates_info:
222
+ chosen = random.choice(templates_info)
223
+ name = chosen['name']
224
+ seg = int(chosen.get('segment_count') or len(chosen.get('segments', [])) or 1)
225
+ else:
226
+ name = 'infographic_standard'
227
+ seg = 1
228
+ return {
229
+ 'recommended_template': name,
230
+ 'title_split': _split_title_semantic(title, seg),
231
+ 'split_method': 'semantic_dp',
232
+ 'reasoning': 'fallback: LLM disabled, picked template by random sampling and split title via semantic DP',
233
+ }
234
+
235
+ def _fallback_top_k(self, title: str, all_templates_info: list, top_k: int) -> Dict:
236
+ """LLM 失败时的 top-k fallback。"""
237
+ self._log_fallback_once("top-k")
238
+ if not all_templates_info:
239
+ return {'recommendations': []}
240
+ pool = list(all_templates_info)
241
+ random.shuffle(pool)
242
+ recs = []
243
+ for info in pool[:max(1, top_k)]:
244
+ seg = int(info.get('segment_count') or 1)
245
+ recs.append({
246
+ 'template_name': info['name'],
247
+ 'title_split': _split_title_semantic(title, seg),
248
+ 'split_method': 'semantic_dp',
249
+ 'paraphrased_title': None,
250
+ 'confidence': 0.5,
251
+ 'reasoning': 'fallback: LLM disabled, random template + semantic DP split',
252
+ })
253
+ return {'recommendations': recs}
254
+
255
+ def recommend_template(self, title: str, has_description: bool = True, templates_info: Optional[List[Dict]] = None) -> Dict:
256
+ """
257
+ Analyze title and recommend suitable template with splitting scheme
258
+
259
+ Args:
260
+ title: Main title text
261
+ has_description: Whether description is provided
262
+ templates_info: List of template info dicts (optional, for filtering by style)
263
+
264
+ Returns:
265
+ Dictionary containing:
266
+ {
267
+ 'recommended_template': 'template_name',
268
+ 'title_split': ['line1', 'line2', ...],
269
+ 'reasoning': 'explanation'
270
+ }
271
+ """
272
+ return self._analyze_with_llm(title, has_description, templates_info)
273
+
274
+ def recommend_top_k_templates(self, title: str, all_templates_info: list, top_k: int = 3) -> Dict:
275
+ """
276
+ Analyze title and recommend top-k suitable templates with splitting schemes
277
+
278
+ Args:
279
+ title: Main title text
280
+ all_templates_info: List of template info dicts with name, description, segment_roles, segments
281
+ top_k: Number of templates to recommend (default 3)
282
+
283
+ Returns:
284
+ Dictionary containing:
285
+ {
286
+ 'recommendations': [
287
+ {
288
+ 'template_name': 'template1',
289
+ 'title_split': ['line1', 'line2', ...],
290
+ 'paraphrased_title': 'Restructured title or null',
291
+ 'confidence': 0.95,
292
+ 'reasoning': 'explanation'
293
+ },
294
+ ...
295
+ ]
296
+ }
297
+ """
298
+ return self._analyze_with_llm_top_k(title, all_templates_info, top_k)
299
+
300
+
301
+ def _analyze_with_llm(self, title: str, has_description: bool, templates_info: Optional[List[Dict]] = None) -> Dict:
302
+ """
303
+ Use LLM API to analyze and recommend template
304
+ """
305
+ if self.llm_disabled:
306
+ return self._fallback_single(title, templates_info)
307
+
308
+ # Build prompt
309
+ prompt = self._build_recommendation_prompt(title, has_description, templates_info)
310
+
311
+ # Call LLM API
312
+ try:
313
+ response = self._query_llm(prompt)
314
+
315
+ if response:
316
+ # Parse JSON response
317
+ try:
318
+ # Try to clean possible markdown code blocks
319
+ cleaned_response = response.strip()
320
+ if cleaned_response.startswith('```'):
321
+ lines = cleaned_response.split('\n')
322
+ cleaned_response = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
323
+ cleaned_response = cleaned_response.replace('```json', '').replace('```', '').strip()
324
+
325
+ result = json.loads(cleaned_response)
326
+
327
+ # Validate return format
328
+ if 'recommended_template' in result and 'title_split' in result:
329
+ # Validate template name if templates_info is provided
330
+ if templates_info:
331
+ available_names = [t['name'] for t in templates_info]
332
+ if result['recommended_template'] not in available_names:
333
+ print(f"❌ Unknown template '{result['recommended_template']}'")
334
+ return None
335
+ return result
336
+ else:
337
+ print("❌ LLM response missing required fields")
338
+ return self._fallback_single(title, templates_info)
339
+
340
+ except json.JSONDecodeError as e:
341
+ print(f"❌ LLM response is not valid JSON")
342
+ print(f"Response: {response[:200]}...")
343
+ return self._fallback_single(title, templates_info)
344
+ else:
345
+ return self._fallback_single(title, templates_info)
346
+
347
+ except Exception as e:
348
+ print(f"❌ LLM analysis error: {e}")
349
+ return self._fallback_single(title, templates_info)
350
+
351
+ def _query_llm(self, prompt: str) -> Optional[str]:
352
+ """
353
+ Query LLM API
354
+
355
+ Args:
356
+ prompt: Prompt to send to LLM
357
+
358
+ Returns:
359
+ str: LLM response content
360
+ """
361
+ headers = {
362
+ 'Authorization': f'Bearer {self.api_key}',
363
+ 'Content-Type': 'application/json'
364
+ }
365
+
366
+ data = {
367
+ 'model': self.model,
368
+ 'messages': [
369
+ {
370
+ 'role': 'system',
371
+ 'content': 'You are a professional infographic design assistant, skilled at analyzing title structure and visual hierarchy. Always return valid JSON format only, without any markdown formatting or extra text.'
372
+ },
373
+ {
374
+ 'role': 'user',
375
+ 'content': prompt
376
+ }
377
+ ],
378
+ 'temperature': 0.3
379
+ }
380
+
381
+ try:
382
+ response = requests.post(
383
+ f'{self.base_url}/chat/completions',
384
+ headers=headers,
385
+ json=data,
386
+ timeout=30
387
+ )
388
+ response.raise_for_status()
389
+
390
+ result = response.json()
391
+ return result['choices'][0]['message']['content'].strip()
392
+
393
+ except requests.exceptions.Timeout:
394
+ print("❌ LLM API timeout")
395
+ return None
396
+ except requests.exceptions.HTTPError as e:
397
+ print(f"❌ LLM API HTTP error: {e}")
398
+ if hasattr(e.response, 'text'):
399
+ print(f" Response: {e.response.text[:200]}")
400
+ return None
401
+ except requests.exceptions.RequestException as e:
402
+ print(f"❌ LLM API request error: {e}")
403
+ return None
404
+ except KeyError as e:
405
+ print(f"❌ LLM API response format error: {e}")
406
+ return None
407
+
408
+ def _analyze_with_llm_top_k(self, title: str, all_templates_info: list, top_k: int = 3) -> Dict:
409
+ """
410
+ Use LLM API to recommend top-k templates
411
+
412
+ Args:
413
+ title: Main title text
414
+ all_templates_info: List of template info dicts
415
+ top_k: Number of templates to recommend
416
+
417
+ Returns:
418
+ Dictionary with recommendations list
419
+ """
420
+ if self.llm_disabled:
421
+ return self._fallback_top_k(title, all_templates_info, top_k)
422
+
423
+ # Build prompt for top-k recommendation
424
+ prompt = self._build_top_k_prompt(title, all_templates_info, top_k)
425
+
426
+ # Call LLM API
427
+ try:
428
+ response = self._query_llm(prompt)
429
+
430
+ if response:
431
+ # Parse JSON response
432
+ try:
433
+ cleaned_response = response.strip()
434
+ if cleaned_response.startswith('```'):
435
+ lines = cleaned_response.split('\n')
436
+ cleaned_response = '\n'.join(lines[1:-1] if lines[-1].strip() == '```' else lines[1:])
437
+ cleaned_response = cleaned_response.replace('```json', '').replace('```', '').strip()
438
+
439
+ result = json.loads(cleaned_response)
440
+
441
+ # Validate return format
442
+ if 'recommendations' in result and isinstance(result['recommendations'], list):
443
+ return result
444
+ else:
445
+ print("❌ LLM response missing required 'recommendations' field")
446
+ return self._fallback_top_k(title, all_templates_info, top_k)
447
+
448
+ except json.JSONDecodeError as e:
449
+ print(f"❌ LLM response is not valid JSON")
450
+ return self._fallback_top_k(title, all_templates_info, top_k)
451
+ else:
452
+ return self._fallback_top_k(title, all_templates_info, top_k)
453
+
454
+ except Exception as e:
455
+ print(f"❌ LLM analysis error: {e}")
456
+ return self._fallback_top_k(title, all_templates_info, top_k)
457
+
458
+ def _build_recommendation_prompt(self, title: str, has_description: bool, templates_info: Optional[List[Dict]] = None) -> str:
459
+ """Build LLM recommendation prompt"""
460
+
461
+ # Build default templates info if not provided
462
+ if not templates_info:
463
+ # Use a basic set of templates
464
+ templates_list = [
465
+ {"name": "infographic_standard", "description": "Single line bold title", "segments": 1},
466
+ {"name": "two_line_hierarchy", "description": "Two-line: small prefix + large main title", "segments": 2},
467
+ {"name": "three_line_emphasis", "description": "Three-line: prefix + emphasized middle + suffix", "segments": 3}
468
+ ]
469
+ else:
470
+ templates_list = templates_info
471
+
472
+ templates_desc = "\n".join([
473
+ f"- **{t['name']}**: {t.get('description', 'No description')}, segments: {t.get('segment_count', len(t.get('segments', [])))}"
474
+ for t in templates_list
475
+ ])
476
+
477
+ prompt = f"""
478
+ Analyze this infographic title to recommend the layout template and split strategy.
479
+
480
+ Input Data:
481
+ - Title: "{title}"
482
+ - Has Subtitle/Description: {has_description}
483
+
484
+ ### Task
485
+ 1. Identify the **PRIMARY** focus (The "Hero" of the title) and **SECONDARY** context.
486
+ 2. Recommend the best template (`infographic_standard`, `two_line_hierarchy`, or `three_line_emphasis`).
487
+ 3. Split the title text strictly according to the template rules.
488
+
489
+ ### 1. Semantic Analysis Rules
490
+ **PRIMARY Segment (The "Hero"):**
491
+ - The core metric, main topic, or "What" the chart shows.
492
+ - *Examples:* "GDP Growth", "Carbon Emissions", "Mobile Phones", "Annual Report".
493
+ - **Constraint:** NEVER split a noun phrase inside the Primary segment.
494
+
495
+ **SECONDARY Segment (The "Context"):**
496
+ - Modifiers, Prepositions, Locations, Time, or "Which/Where/When".
497
+ - *Examples:* "The United States of...", "Trends in...", "...of All Time", "Global...", "Top 10...".
498
+ - *Note:* If a title contains both a Metric (e.g., Inflation) and a Location (e.g., in Europe), treat the Metric as PRIMARY and Location as SECONDARY.
499
+
500
+ ### 2. Template Selection Guidelines
501
+ **Option A: infographic_standard**
502
+ - *Criteria:* Short titles (1-5 words) OR titles that are a single cohesive phrase.
503
+ - *Split Strategy:* Return as a single string in the array.
504
+ - *Example:* ["Annual Report 2023"]
505
+
506
+ **Option B: two_line_hierarchy**
507
+ - *Criteria:* Strong prefix (Context) + Main Topic (Hero). Good for "The X of Y" structures.
508
+ - *Split Strategy:* - Line 1: Secondary Context (Prefix)
509
+ - Line 2: Primary Topic (Hero)
510
+ - *Example:* ["The United States of", "Food Inflation"]
511
+
512
+ **Option C: three_line_emphasis**
513
+ - *Criteria:* Long titles (6+ words) with a "Sandwich" structure: Context + Hero + Context.
514
+ - *Split Strategy:*
515
+ - Line 1: Secondary Context (Prefix)
516
+ - Line 2: Primary Topic (Hero - Centerpiece)
517
+ - Line 3: Secondary Context (Suffix)
518
+ - *Example:* ["The Best Selling", "Mobile Phones", "of All Time"]
519
+
520
+ ### Output Format
521
+ Return ONLY valid JSON:
522
+ ```json
523
+ {{
524
+ "recommended_template": "template_name",
525
+ "title_split": ["string1", "string2", ...],
526
+ "reasoning": "Explain why this template was chosen based on the Primary/Secondary analysis."
527
+ }}
528
+ """
529
+
530
+ return prompt
531
+
532
+ def _build_top_k_prompt(self, title: str, all_templates_info: list, top_k: int) -> str:
533
+ """
534
+ Build prompt for top-k template recommendation
535
+
536
+ Args:
537
+ title: Main title text
538
+ all_templates_info: List of template info with segment_roles
539
+ top_k: Number of recommendations
540
+
541
+ Returns:
542
+ Prompt string
543
+ """
544
+ # Build templates list for prompt
545
+ templates_desc = []
546
+ for info in all_templates_info:
547
+ name = info['name']
548
+ segment_roles = info.get('segment_roles', 'No role description available')
549
+ segment_count = info.get('segment_count', 1)
550
+ templates_desc.append(f"- **{name}** ({segment_count} segments): {segment_roles}")
551
+
552
+ templates_text = "\n".join(templates_desc)
553
+
554
+ prompt = f"""You are an expert in infographic title design and linguistic analysis.
555
+
556
+ **Task**: Analyze the given title and recommend the top {top_k} most suitable templates based on grammatical structure and semantic roles.
557
+
558
+ **Input Title**: "{title}"
559
+
560
+ **Available Templates** (with segment role descriptions):
561
+ {templates_text}
562
+
563
+ **Your Responsibilities**:
564
+
565
+ 1. **Grammatical Analysis**: Parse the title to identify its grammatical components (subject, modifier, qualifier, possessive, prepositional phrase, etc.)
566
+
567
+ 2. **Structural Matching**: Match the title's structure against each template's segment_roles based on:
568
+ - Number of natural semantic units in the title
569
+ - Grammatical roles of each unit (modifier, subject, qualifier, etc.)
570
+ - Visual hierarchy implied by the content
571
+
572
+ 3. **Title Adaptation**: If necessary, **paraphrase or restructure** the title to better fit the template:
573
+ - Preserve the core meaning
574
+ - Adjust phrasing to match segment roles
575
+ - Optimize for visual impact
576
+
577
+ 4. **Segmentation**: For each recommended template, split the title (original or paraphrased) into segments that match the template's segment_roles.
578
+
579
+ 5. **Ranking**: Order recommendations by confidence, considering both grammatical fit and visual effectiveness.
580
+
581
+ **Output Format** (return ONLY valid JSON, no markdown):
582
+ {{
583
+ "recommendations": [
584
+ {{
585
+ "template_name": "template_name",
586
+ "title_split": ["segment1", "segment2", ...],
587
+ "paraphrased_title": "Restructured title if modified, or null if unchanged",
588
+ "confidence": 0.95,
589
+ "reasoning": "Brief explanation of grammatical structure match and any paraphrasing done"
590
+ }}
591
+ ]
592
+ }}
593
+
594
+ **Critical Guidelines on Segment Importance**:
595
+ - **PRIMARY importance**: Core subject nouns, key entities, or concrete data/metrics that form the main topic
596
+ * Examples: "Life Expectancy", "Mobile Phones", "Carbon Emissions", "Europe", "GDP Growth"
597
+ * These are the focal subjects - the "what" the infographic is about
598
+ * Will be rendered with larger, bolder, more prominent styling
599
+ * **KEEP PRIMARY INTACT**: Do not split core noun phrases (e.g., keep "Life Expectancy" together)
600
+
601
+ - **SECONDARY importance**: Modifiers, time references, actions, prepositions, and contextual phrases
602
+ * Examples:
603
+ - Time references: "Since 1950", "in 2023", "by 2030"
604
+ - Actions/verbs: "Gains", "Changes", "Growth"
605
+ - Prepositions: "by Country", "via Technology", "of All Time"
606
+ - Quantifiers/modifiers: "Top 10", "Best-Selling", "Average"
607
+ * These provide context but are NOT the main subject
608
+ * Will be rendered with smaller, less prominent styling
609
+
610
+ **Key Principle**: Identify the SUBJECT first (PRIMARY), then treat everything else as decoration (SECONDARY).
611
+ - Example: "Average Life Expectancy Gains Since 1950" → PRIMARY: "Life Expectancy", SECONDARY: "Average...Gains Since 1950"
612
+ - Example: "Europe's Best-Selling Car Brands" → PRIMARY: "Car Brands" (or "Europe"), SECONDARY: "Best-Selling"
613
+
614
+ **Other Guidelines**:
615
+ - Ensure title_split length matches the template's segment count
616
+ - Consider grammatical roles (possessive, modifier, subject, etc.)
617
+ - Paraphrase when it improves fit without changing meaning
618
+ - Prioritize templates with natural grammatical alignment
619
+ - Provide diverse options (avoid similar templates)
620
+ - Order by confidence (highest first)
621
+ - **Identify topic keywords and metrics for PRIMARY segments, context for SECONDARY**
622
+ """
623
+ return prompt
624
+
625
+
626
+
627
+ # Convenience function
628
+
modules/title_styler/svg_renderer.py ADDED
@@ -0,0 +1,1507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infographic Title Generator - SVG Renderer
3
+ Renders title content in SVG format with unified row/column layout
4
+ """
5
+
6
+ import xml.etree.ElementTree as ET
7
+ import os
8
+ from typing import List, Tuple, Dict, Optional
9
+ from modules.title_styler.config import (
10
+ LINE_HEIGHT_RATIO,
11
+ SVG_PADDING, ALIGNMENT_LEFT, ALIGNMENT_CENTER, ALIGNMENT_RIGHT
12
+ )
13
+
14
+ from modules.title_styler.font_metrics import (
15
+ get_font_metrics,
16
+ measure_text_width,
17
+ measure_text_bbox,
18
+ )
19
+
20
+ SINGLE_WEIGHT_FONTS = {'Impact'}
21
+ MEASUREMENT_FALLBACKS_WHEN_UNAVAILABLE = {
22
+ 'Impact': 'Arial',
23
+ 'Bebas Neue': 'Arial',
24
+ 'Comic Sans MS': 'Times New Roman',
25
+ 'Comic Neue': 'Times New Roman',
26
+ 'Brush Script': 'Times New Roman',
27
+ 'Pacifico': 'Times New Roman',
28
+ }
29
+ FONT_FAMILY_FALLBACKS = {
30
+ 'Georgia': 'serif',
31
+ 'Times': 'serif',
32
+ 'Times New Roman': 'serif',
33
+ 'Cambria': 'serif',
34
+ 'Garamond': 'serif',
35
+ 'Book Antiqua': 'serif',
36
+ 'Palatino': 'serif',
37
+ 'Palatino Linotype': 'serif',
38
+ 'Noto Serif': 'serif',
39
+ 'DejaVu Serif': 'serif',
40
+
41
+ 'Arial': 'sans-serif',
42
+ 'Helvetica': 'sans-serif',
43
+ 'Verdana': 'sans-serif',
44
+ 'Tahoma': 'sans-serif',
45
+ 'Trebuchet MS': 'sans-serif',
46
+ 'Segoe UI': 'sans-serif',
47
+ 'Calibri': 'sans-serif',
48
+ 'Roboto': 'sans-serif',
49
+ 'Open Sans': 'sans-serif',
50
+ 'Montserrat': 'sans-serif',
51
+ 'Oswald': 'sans-serif',
52
+ 'Lato': 'sans-serif',
53
+ 'Source Sans Pro': 'sans-serif',
54
+ 'Noto Sans': 'sans-serif',
55
+ 'DejaVu Sans': 'sans-serif',
56
+ 'Liberation Sans': 'sans-serif',
57
+ 'Impact': 'sans-serif',
58
+ 'Bebas Neue': 'sans-serif',
59
+
60
+ 'Courier': 'monospace',
61
+ 'Courier New': 'monospace',
62
+ 'Consolas': 'monospace',
63
+ 'Monaco': 'monospace',
64
+ 'Menlo': 'monospace',
65
+ 'DejaVu Sans Mono': 'monospace',
66
+ 'Liberation Mono': 'monospace',
67
+
68
+ 'Comic Sans MS': 'cursive',
69
+ 'Comic Neue': 'cursive',
70
+ 'Brush Script': 'cursive',
71
+ 'Pacifico': 'cursive',
72
+ }
73
+
74
+
75
+ def _font_family_base(font_family):
76
+ if not font_family:
77
+ return ''
78
+ return str(font_family).split(',')[0].strip().strip("'").strip('"')
79
+
80
+
81
+ def _font_key(text):
82
+ return ''.join(ch for ch in str(text).lower() if ch.isalnum())
83
+
84
+
85
+ def _font_family_for_measurement(font_family, font_weight, font_style):
86
+ """Pick the family whose metrics best match the emitted SVG on this host.
87
+
88
+ Some named display fonts are often missing on Linux servers. Fontconfig may
89
+ report Noto Sans for them, while Chrome's SVG renderer falls through to the
90
+ generic CSS family. In those cases, measuring the requested family makes
91
+ background pills far wider than the rendered text.
92
+ """
93
+ base = _font_family_base(font_family)
94
+ fallback = MEASUREMENT_FALLBACKS_WHEN_UNAVAILABLE.get(base)
95
+ if not fallback:
96
+ return font_family
97
+
98
+ resolved_path = get_font_metrics()._fc_match(base, font_weight, font_style)
99
+ if resolved_path and _font_key(base) in _font_key(os.path.basename(resolved_path)):
100
+ return font_family
101
+ return fallback
102
+
103
+
104
+ def _normalize_weight_for_output(font_family, font_weight):
105
+ """Some display fonts (Impact) ship only a Regular face; emitting font-weight=bold
106
+ causes browsers/Cairo to synthesise fake bold (visibly thicker than intended).
107
+ Strip the weight for those fonts so the rasterised PNG matches the original face.
108
+ """
109
+ base = _font_family_base(font_family)
110
+ if base in SINGLE_WEIGHT_FONTS:
111
+ return 'normal'
112
+ return font_weight
113
+
114
+
115
+ def _font_family_for_output(font_family):
116
+ """Emit an explicit generic fallback so Chrome and PIL land in the same
117
+ font category when a named family (notably Impact) is unavailable.
118
+ """
119
+ if not font_family:
120
+ return 'Arial, sans-serif'
121
+ if ',' in str(font_family):
122
+ return str(font_family)
123
+ base = _font_family_base(font_family)
124
+ fallback = FONT_FAMILY_FALLBACKS.get(base)
125
+ if not fallback:
126
+ return str(font_family)
127
+ return f"{font_family}, {fallback}"
128
+
129
+
130
+ class LayoutElement:
131
+ """Represents a layout element with its bounding box"""
132
+ def __init__(self, text: str, config: dict, width: float, height: float, metrics: dict = None):
133
+ self.text = text
134
+ self.config = config
135
+ self.width = width # Total width (including background)
136
+ self.height = height # Total height (including background)
137
+ self.metrics = metrics or {} # Text metrics (text_width, text_height, text_ascent, text_descent)
138
+
139
+ # Bounding box (will be set during layout)
140
+ self.x1 = 0.0 # Left edge
141
+ self.y1 = 0.0 # Top edge
142
+ self.x2 = 0.0 # Right edge (x1 + width)
143
+ self.y2 = 0.0 # Bottom edge (y1 + height)
144
+
145
+ # Text rendering position
146
+ self.text_baseline_y = 0.0 # Text baseline Y position
147
+ self.text_x = 0.0 # Text X position (left edge of text, not including bg padding)
148
+ self.text_anchor = 'start'
149
+
150
+ # Background rect position (if has background)
151
+ self.bg_rect_x = 0.0
152
+ self.bg_rect_y = 0.0
153
+ self.bg_rect_width = 0.0
154
+ self.bg_rect_height = 0.0
155
+
156
+ def set_position(self, x: float, y: float):
157
+ """Set element position (x, y is top-left corner)"""
158
+ self.x1 = x
159
+ self.y1 = y
160
+ self.x2 = x + self.width
161
+ self.y2 = y + self.height
162
+
163
+
164
+ class SVGRenderer:
165
+ """SVG Renderer class with unified layout system"""
166
+
167
+ def __init__(self):
168
+ self.svg_padding = SVG_PADDING
169
+
170
+ def measure_element_size(self, text: str, config: dict) -> Tuple[float, float, dict]:
171
+ """
172
+ Measure actual element size including background padding
173
+
174
+ Returns:
175
+ (width, height, metrics) tuple
176
+ metrics contains: text_width, text_height, text_ascent, text_descent
177
+ """
178
+ # Apply text transform first (affects measurement)
179
+ text_transform = config.get('text_transform', 'none')
180
+ if text_transform == 'uppercase':
181
+ text = text.upper()
182
+ elif text_transform == 'lowercase':
183
+ text = text.lower()
184
+ elif text_transform == 'capitalize':
185
+ text = text.capitalize()
186
+
187
+ font_size = config.get('font_size', 48)
188
+ font_family = config.get('font_family') or 'Arial'
189
+ font_weight = _normalize_weight_for_output(
190
+ font_family,
191
+ config.get('font_weight') or 'normal',
192
+ )
193
+ font_style = config.get('font_style') or 'normal'
194
+ measurement_family = _font_family_for_measurement(
195
+ font_family,
196
+ font_weight,
197
+ font_style,
198
+ )
199
+
200
+ # Parse letter_spacing
201
+ spacing_value = 0
202
+ ls = config.get('letter_spacing', 0)
203
+ if isinstance(ls, str) and ls.endswith('em'):
204
+ spacing_value = float(ls[:-2])
205
+ elif isinstance(ls, (int, float)):
206
+ spacing_value = ls
207
+
208
+ # Measure text bbox
209
+ ascent, descent, text_width, text_height = measure_text_bbox(
210
+ text,
211
+ measurement_family,
212
+ int(font_size),
213
+ font_weight,
214
+ font_style,
215
+ spacing_value,
216
+ )
217
+
218
+ metrics = {
219
+ 'text_width': text_width,
220
+ 'text_height': text_height,
221
+ 'text_ascent': ascent,
222
+ 'text_descent': descent
223
+ }
224
+
225
+ # Check if element has background padding
226
+ bg_config = config.get('background', False)
227
+ if bg_config and isinstance(bg_config, dict):
228
+ bg_padding = bg_config.get('padding', 10)
229
+ # Background expands the bounding box
230
+ width = text_width + 2 * bg_padding
231
+ height = text_height + 2 * bg_padding
232
+ else:
233
+ width = text_width
234
+ height = text_height
235
+
236
+ return width, height, metrics
237
+
238
+ def layout_row(self, elements: List[LayoutElement], gap: float = 10.0,
239
+ vertical_align: str = 'baseline') -> Tuple[float, float]:
240
+ """
241
+ Layout elements horizontally (row layout)
242
+
243
+ In baseline mode: text baselines align first, then backgrounds adjust around text
244
+
245
+ Args:
246
+ elements: List of LayoutElement objects
247
+ gap: Gap between elements
248
+ vertical_align: Vertical alignment ('baseline', 'center', 'start', 'end')
249
+
250
+ Returns:
251
+ (total_width, max_height) of the row
252
+ """
253
+ if not elements:
254
+ return 0.0, 0.0
255
+
256
+ current_x = 0.0
257
+
258
+ # For baseline alignment: align text baselines, then adjust backgrounds
259
+ if vertical_align == 'baseline':
260
+ # Find the maximum ascent and descent among all elements (text only)
261
+ max_ascent = 0.0
262
+ max_descent = 0.0
263
+ for elem in elements:
264
+ text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
265
+ text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
266
+ max_ascent = max(max_ascent, text_ascent)
267
+ max_descent = max(max_descent, text_descent)
268
+
269
+ # Row height is based on text metrics, but may extend for backgrounds
270
+ row_content_height = max_ascent + max_descent
271
+ max_top_extend = 0.0 # Maximum extension above baseline
272
+ max_bottom_extend = 0.0 # Maximum extension below baseline
273
+
274
+ for elem in elements:
275
+ text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
276
+ text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
277
+ text_height = text_ascent + text_descent
278
+
279
+ # Check if element has background
280
+ bg_config = elem.config.get('background', False)
281
+ if bg_config and isinstance(bg_config, dict):
282
+ bg_padding = bg_config.get('padding', 10)
283
+ # Background extends beyond text
284
+ top_extend = text_ascent + bg_padding
285
+ bottom_extend = text_descent + bg_padding
286
+ else:
287
+ # No background, just text
288
+ top_extend = text_ascent
289
+ bottom_extend = text_descent
290
+
291
+ max_top_extend = max(max_top_extend, top_extend)
292
+ max_bottom_extend = max(max_bottom_extend, bottom_extend)
293
+
294
+ # Total row height includes maximum extensions
295
+ max_height = max_top_extend + max_bottom_extend
296
+
297
+ # Now position each element
298
+ for i, elem in enumerate(elements):
299
+ text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
300
+ text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
301
+ text_width = elem.metrics.get('text_width', elem.width)
302
+ text_height = text_ascent + text_descent
303
+
304
+ # Text baseline is at max_top_extend from row top
305
+ # This ensures all text baselines align
306
+ text_baseline_y = max_top_extend
307
+
308
+ # Check if element has background
309
+ bg_config = elem.config.get('background', False)
310
+ if bg_config and isinstance(bg_config, dict):
311
+ bg_padding = bg_config.get('padding', 10)
312
+
313
+ # Text position (within the element)
314
+ elem.text_x = current_x + bg_padding
315
+ elem.text_baseline_y = text_baseline_y
316
+
317
+ # Background rect: centered vertically around text
318
+ elem.bg_rect_x = current_x
319
+ elem.bg_rect_y = text_baseline_y - text_ascent - bg_padding
320
+ elem.bg_rect_width = text_width + 2 * bg_padding
321
+ elem.bg_rect_height = text_height + 2 * bg_padding
322
+
323
+ # Element bbox is the background rect
324
+ elem.set_position(current_x, elem.bg_rect_y)
325
+
326
+ # Move x for next element (based on background width)
327
+ current_x += elem.bg_rect_width + gap
328
+ else:
329
+ # No background: element bbox is just the text bbox
330
+ elem.text_x = current_x
331
+ elem.text_baseline_y = text_baseline_y
332
+
333
+ # Element bbox
334
+ elem_y1 = text_baseline_y - text_ascent
335
+ elem.set_position(current_x, elem_y1)
336
+
337
+ # Move x for next element (based on text width)
338
+ current_x += text_width + gap
339
+
340
+ # Verify gap (for debugging)
341
+ if i > 0:
342
+ prev_elem = elements[i - 1]
343
+ actual_gap = elem.x1 - prev_elem.x2
344
+ # Gap should be exactly as specified
345
+ assert abs(actual_gap - gap) < 0.01, f"Gap mismatch: expected {gap}, got {actual_gap}"
346
+
347
+ else:
348
+ # Other alignment modes (center, start, end)
349
+ max_height = max(elem.height for elem in elements)
350
+
351
+ for i, elem in enumerate(elements):
352
+ # Calculate y offset based on vertical alignment
353
+ if vertical_align == 'center':
354
+ y_offset = (max_height - elem.height) / 2
355
+ elif vertical_align == 'start':
356
+ y_offset = 0.0
357
+ elif vertical_align == 'end':
358
+ y_offset = max_height - elem.height
359
+ else:
360
+ y_offset = 0.0
361
+
362
+ # Set position
363
+ elem.set_position(current_x, y_offset)
364
+
365
+ # Text position
366
+ bg_config = elem.config.get('background', False)
367
+ if bg_config and isinstance(bg_config, dict):
368
+ bg_padding = bg_config.get('padding', 10)
369
+ text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
370
+ text_height = elem.metrics.get('text_height', elem.height)
371
+
372
+ elem.text_x = elem.x1 + bg_padding
373
+ elem.text_baseline_y = elem.y1 + (elem.height - text_height) / 2 + text_ascent
374
+
375
+ elem.bg_rect_x = elem.x1
376
+ elem.bg_rect_y = elem.y1
377
+ elem.bg_rect_width = elem.width
378
+ elem.bg_rect_height = elem.height
379
+ else:
380
+ text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
381
+ elem.text_x = elem.x1
382
+ elem.text_baseline_y = elem.y1 + text_ascent
383
+
384
+ # Verify gap
385
+ if i > 0:
386
+ prev_elem = elements[i - 1]
387
+ actual_gap = elem.x1 - prev_elem.x2
388
+ assert abs(actual_gap - gap) < 0.01, f"Gap mismatch: expected {gap}, got {actual_gap}"
389
+
390
+ # Move x for next element
391
+ current_x = elem.x2 + gap
392
+
393
+ # Total width is last element's x2 (no gap after last element)
394
+ total_width = elements[-1].x2
395
+
396
+ return total_width, max_height
397
+
398
+ def layout_column(self, elements: List[LayoutElement], gap: float = 0.0) -> Tuple[float, float]:
399
+ """
400
+ Layout elements vertically (column layout)
401
+
402
+ Args:
403
+ elements: List of LayoutElement objects
404
+ gap: Gap between elements
405
+
406
+ Returns:
407
+ (max_width, total_height) of the column
408
+ """
409
+ if not elements:
410
+ return 0.0, 0.0
411
+
412
+ current_y = 0.0
413
+ max_width = max(elem.width for elem in elements)
414
+
415
+ for i, elem in enumerate(elements):
416
+ # Set position: x at 0 (will be adjusted by alignment later), y from current_y
417
+ elem.set_position(0, current_y)
418
+
419
+ # Text baseline is at y1 + ascent (from top of bbox)
420
+ font_size = elem.config.get('font_size', 48)
421
+ elem.baseline_y = elem.y1 + elem.height * 0.75 # Approximate baseline position
422
+
423
+ # Verify gap (for debugging)
424
+ if i > 0:
425
+ prev_elem = elements[i - 1]
426
+ actual_gap = elem.y1 - prev_elem.y2
427
+ # Gap should be exactly as specified
428
+ assert abs(actual_gap - gap) < 0.01, f"Gap mismatch: expected {gap}, got {actual_gap}"
429
+
430
+ # Move y for next element: y2 + gap = next y1
431
+ current_y = elem.y2 + gap
432
+
433
+ # Total height is last element's y2 (no gap after last element)
434
+ total_height = elements[-1].y2
435
+
436
+ return max_width, total_height
437
+
438
+ def render_svg_unified(self, lines_data: List[str], line_configs: List[dict],
439
+ alignment: str, max_width: Optional[int] = None,
440
+ background_color: str = '#FFFFFF') -> Tuple[str, Tuple[int, int]]:
441
+ """
442
+ Unified SVG rendering with proper row/column layout
443
+
444
+ Args:
445
+ lines_data: Text content list
446
+ line_configs: Line configuration list
447
+ alignment: Alignment (left/center/right)
448
+ max_width: Maximum width for text wrapping
449
+ background_color: Background color
450
+
451
+ Returns:
452
+ (svg_string, (width, height))
453
+ """
454
+ # Check for inline groups
455
+ inline_groups = []
456
+ for i, config in enumerate(line_configs):
457
+ if config.get('inline_group'):
458
+ group_start, group_end = config['inline_group']
459
+ if (group_start, group_end) not in inline_groups:
460
+ inline_groups.append((group_start, group_end))
461
+
462
+ # Build layout structure: list of rows, each row is a list of elements
463
+ rows = []
464
+ processed_indices = set()
465
+ wrap_group_seq = 0
466
+
467
+ for i, (text, config) in enumerate(zip(lines_data, line_configs)):
468
+ if i in processed_indices:
469
+ continue
470
+
471
+ inline_group = config.get('inline_group')
472
+ if inline_group:
473
+ # This is part of an inline group - create a row with all group elements
474
+ group_start, group_end = inline_group
475
+ row_elements = []
476
+
477
+ for j in range(group_start, group_end + 1):
478
+ if j < len(lines_data):
479
+ elem_text = lines_data[j]
480
+ elem_config = line_configs[j]
481
+ width, height, metrics = self.measure_element_size(elem_text, elem_config)
482
+ elem = LayoutElement(elem_text, elem_config, width, height, metrics)
483
+ row_elements.append(elem)
484
+ processed_indices.add(j)
485
+
486
+ # Check if inline row needs wrapping
487
+ if max_width and row_elements:
488
+ total_width = sum(elem.width for elem in row_elements) + 10.0 * (len(row_elements) - 1)
489
+ content_max_width = max_width - 2 * self.svg_padding
490
+
491
+ # If row exceeds max_width, check for wrappable elements
492
+ if total_width > content_max_width:
493
+ # Find elements without background (they should wrap)
494
+ # Elements with background should NOT wrap
495
+ wrappable_indices = []
496
+ for idx, elem in enumerate(row_elements):
497
+ has_bg = elem.config.get('background') and isinstance(elem.config.get('background'), dict)
498
+ if not has_bg:
499
+ wrappable_indices.append(idx)
500
+
501
+ if wrappable_indices:
502
+ # For now, wrap the first wrappable element
503
+ # Future: could implement more sophisticated wrapping strategy
504
+ wrap_idx = wrappable_indices[0]
505
+ elem_to_wrap = row_elements[wrap_idx]
506
+ wrap_group_seq += 1
507
+ wrap_group_id = f"inline-{i}-{wrap_group_seq}"
508
+
509
+ # Calculate line height for wrap gap
510
+ font_size = elem_to_wrap.config.get('font_size', 48)
511
+ line_height = elem_to_wrap.metrics.get('text_height', font_size)
512
+ wrap_gap = min(line_height * 0.25, 10.0)
513
+
514
+ # Split the row into multiple rows
515
+ # Row 1: elements before wrap point
516
+ # Row 2: wrapped element on new line
517
+ # Row 3: elements after wrap point (if any)
518
+
519
+ if wrap_idx > 0:
520
+ # Add elements before wrap point as a row
521
+ rows.append(row_elements[:wrap_idx])
522
+
523
+ # Add wrapped element as its own row with special gap
524
+ wrapped_row = [row_elements[wrap_idx]]
525
+ rows.append({
526
+ 'elements': wrapped_row,
527
+ 'is_wrapped': True,
528
+ 'wrap_gap': wrap_gap,
529
+ 'wrap_group_id': wrap_group_id,
530
+ })
531
+
532
+ # Add remaining elements as another row if any
533
+ if wrap_idx < len(row_elements) - 1:
534
+ rows.append(row_elements[wrap_idx + 1:])
535
+ else:
536
+ # No wrappable elements, just add the row as-is (will overflow)
537
+ rows.append(row_elements)
538
+ else:
539
+ # Fits within width, add as normal row
540
+ rows.append(row_elements)
541
+ else:
542
+ # No max_width constraint, add as normal row
543
+ rows.append(row_elements)
544
+ else:
545
+ # Single element row
546
+ width, height, metrics = self.measure_element_size(text, config)
547
+
548
+ # Check if element has background color - elements with background should NOT wrap
549
+ has_background = config.get('background') and isinstance(config.get('background'), dict)
550
+
551
+ # All elements without background should wrap when exceeding max_width
552
+ # Elements with background should NOT wrap (to preserve the background box)
553
+ should_wrap = not has_background and max_width
554
+
555
+ if should_wrap:
556
+ content_max_width = max_width - 2 * self.svg_padding
557
+
558
+ if width > content_max_width:
559
+ # Need to wrap text into multiple lines
560
+ font_size = config.get('font_size', 48)
561
+ font_weight = config.get('font_weight', 'normal')
562
+ font_family = config.get('font_family', 'Arial')
563
+ letter_spacing = config.get('letter_spacing', 0)
564
+
565
+ wrapped_lines = self.wrap_text(
566
+ text, max_width, font_size, font_weight, font_family, letter_spacing
567
+ )
568
+
569
+ if len(wrapped_lines) > 1:
570
+ # Create multiple rows for wrapped text
571
+ # Calculate line height for wrap gap
572
+ line_height = metrics.get('text_height', font_size)
573
+ wrap_gap = min(line_height * 0.25, 10.0)
574
+ wrap_group_seq += 1
575
+ wrap_group_id = f"line-{i}-{wrap_group_seq}"
576
+
577
+ for wrap_idx, line_text in enumerate(wrapped_lines):
578
+ line_width, line_height, line_metrics = self.measure_element_size(line_text, config)
579
+ line_elem = LayoutElement(line_text, config, line_width, line_height, line_metrics)
580
+ rows.append({
581
+ 'elements': [line_elem],
582
+ 'is_wrapped': wrap_idx > 0,
583
+ 'wrap_gap': wrap_gap,
584
+ 'wrap_group_id': wrap_group_id,
585
+ })
586
+
587
+ processed_indices.add(i)
588
+ continue
589
+
590
+ # No wrapping needed or wrapping not applicable
591
+ elem = LayoutElement(text, config, width, height, metrics)
592
+ rows.append([elem])
593
+ processed_indices.add(i)
594
+
595
+ # Layout each row
596
+ row_layouts = []
597
+ for row_data in rows:
598
+ # Handle both list and dict (dict is for wrapped rows with metadata)
599
+ if isinstance(row_data, dict):
600
+ row_elements = row_data['elements']
601
+ is_wrapped = row_data.get('is_wrapped', False)
602
+ wrap_gap = row_data.get('wrap_gap', 0)
603
+ wrap_group_id = row_data.get('wrap_group_id')
604
+ else:
605
+ row_elements = row_data
606
+ is_wrapped = False
607
+ wrap_gap = 0
608
+ wrap_group_id = None
609
+
610
+ if len(row_elements) > 1:
611
+ # Row layout (inline elements) - default to baseline alignment
612
+ row_width, row_height = self.layout_row(row_elements, gap=10.0, vertical_align='baseline')
613
+ else:
614
+ # Single element
615
+ elem = row_elements[0]
616
+ text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
617
+ text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
618
+ text_width = elem.metrics.get('text_width', elem.width)
619
+ text_height = elem.metrics.get('text_height', elem.height)
620
+
621
+ # Set text position
622
+ bg_config = elem.config.get('background', False)
623
+ if bg_config and isinstance(bg_config, dict):
624
+ bg_padding = bg_config.get('padding', 10)
625
+
626
+ # Text position: padding from left, baseline at ascent + padding from top
627
+ elem.text_x = bg_padding
628
+ elem.text_baseline_y = bg_padding + text_ascent
629
+
630
+ # Background rect: wraps around text with padding
631
+ elem.bg_rect_x = 0
632
+ elem.bg_rect_y = 0
633
+ elem.bg_rect_width = text_width + 2 * bg_padding
634
+ elem.bg_rect_height = text_height + 2 * bg_padding
635
+
636
+ # Element bbox is the background rect
637
+ elem.set_position(0, 0)
638
+ row_width, row_height = elem.bg_rect_width, elem.bg_rect_height
639
+ else:
640
+ # No background: just text
641
+ elem.text_x = 0
642
+ elem.text_baseline_y = text_ascent
643
+ elem.set_position(0, 0)
644
+ row_width, row_height = text_width, text_height
645
+
646
+ row_layouts.append({
647
+ 'elements': row_elements,
648
+ 'width': row_width,
649
+ 'height': row_height,
650
+ 'is_wrapped': is_wrapped,
651
+ 'wrap_gap': wrap_gap,
652
+ 'wrap_group_id': wrap_group_id,
653
+ })
654
+
655
+ # Calculate total dimensions
656
+ max_content_width = max(layout['width'] for layout in row_layouts) if row_layouts else 0
657
+
658
+ # Layout rows vertically with proper gap
659
+ current_y = self.svg_padding
660
+ for row_idx, layout in enumerate(row_layouts):
661
+ # Position row based on alignment
662
+ row_x_offset = 0
663
+ if alignment == ALIGNMENT_CENTER:
664
+ row_x_offset = (max_content_width - layout['width']) / 2
665
+ elif alignment == ALIGNMENT_RIGHT:
666
+ row_x_offset = max_content_width - layout['width']
667
+
668
+ # Adjust all elements in this row
669
+ for elem in layout['elements']:
670
+ elem.x1 += self.svg_padding + row_x_offset
671
+ elem.x2 += self.svg_padding + row_x_offset
672
+ elem.y1 += current_y
673
+ elem.y2 += current_y
674
+ elem.text_x += self.svg_padding + row_x_offset
675
+ elem.text_baseline_y += current_y
676
+
677
+ # Adjust background rect if exists
678
+ bg_config = elem.config.get('background', False)
679
+ if bg_config and isinstance(bg_config, dict):
680
+ elem.bg_rect_x += self.svg_padding + row_x_offset
681
+ elem.bg_rect_y += current_y
682
+ elif (
683
+ len(layout['elements']) == 1
684
+ and os.environ.get("TITLE_STYLER_NATIVE_TEXT_ANCHOR", "1") != "0"
685
+ ):
686
+ # For plain single-line rows, use SVG's native anchoring
687
+ # instead of converting measured text width into a left
688
+ # x-coordinate. PIL and Chrome can still disagree by a few
689
+ # pixels for fallback fonts (Comic Sans -> Noto Sans, etc.);
690
+ # with text-anchor="middle"/"end" that residual metric
691
+ # drift no longer turns into visibly misaligned rows.
692
+ if alignment == ALIGNMENT_CENTER:
693
+ elem.text_x = self.svg_padding + max_content_width / 2
694
+ elem.text_anchor = 'middle'
695
+ elif alignment == ALIGNMENT_RIGHT:
696
+ elem.text_x = self.svg_padding + max_content_width
697
+ elem.text_anchor = 'end'
698
+ '''
699
+ # Print row layout info
700
+ if len(layout['elements']) > 1:
701
+ # Inline row (multiple elements)
702
+ print(f"[Row {row_idx}] Inline layout with {len(layout['elements'])} elements:")
703
+ print(f" Row bbox: y1={current_y:.1f}, y2={current_y + layout['height']:.1f}")
704
+ for elem_idx, elem in enumerate(layout['elements']):
705
+ bg_config = elem.config.get('background', False)
706
+ if bg_config:
707
+ print(f" Element {elem_idx} (with bg): bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
708
+ print(f" bg_rect=({elem.bg_rect_x:.1f}, {elem.bg_rect_y:.1f}, w={elem.bg_rect_width:.1f}, h={elem.bg_rect_height:.1f})")
709
+ print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
710
+ else:
711
+ print(f" Element {elem_idx} (no bg): bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
712
+ print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
713
+ else:
714
+ # Single element row
715
+ elem = layout['elements'][0]
716
+ bg_config = elem.config.get('background', False)
717
+ if bg_config:
718
+ print(f"[Row {row_idx}] Single element (with bg):")
719
+ print(f" bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
720
+ print(f" bg_rect=({elem.bg_rect_x:.1f}, {elem.bg_rect_y:.1f}, w={elem.bg_rect_width:.1f}, h={elem.bg_rect_height:.1f})")
721
+ print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
722
+ else:
723
+ print(f"[Row {row_idx}] Single element (no bg):")
724
+ print(f" bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
725
+ print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
726
+ '''
727
+ # Move to next row
728
+ current_y += layout['height']
729
+
730
+ # Calculate row gap
731
+ if row_idx < len(row_layouts) - 1: # Not the last row
732
+ next_layout = row_layouts[row_idx + 1]
733
+ same_wrapped_text = (
734
+ layout.get('wrap_group_id')
735
+ and layout.get('wrap_group_id') == next_layout.get('wrap_group_id')
736
+ )
737
+ # Row gap must respect BOTH:
738
+ # (a) the current row's own line-height (typographically 1.2-1.5 of
739
+ # font_size), and
740
+ # (b) the next row's ascender — when a small eyebrow (e.g. 18px) is
741
+ # followed by a big headline (e.g. 32px) the next row's
742
+ # ascenders must NOT visually crash into this row.
743
+ # The old `min(curr_font*0.5, 15)` ignored (b) entirely and the 15px
744
+ # cap throttled even single-line tall titles, leading to the
745
+ # "eyebrow overlaps main title" Bug A.
746
+ if layout['elements']:
747
+ curr_font = layout['elements'][0].config.get('font_size', 48)
748
+ else:
749
+ curr_font = 16.0
750
+ if next_layout['elements']:
751
+ next_font = max(
752
+ (e.config.get('font_size', 48) for e in next_layout['elements']),
753
+ default=curr_font,
754
+ )
755
+ else:
756
+ next_font = curr_font
757
+ gap = max(curr_font * 0.4, next_font * 0.5, 10.0)
758
+ if same_wrapped_text:
759
+ # Keep wrapped lines visually compact only when the custom
760
+ # wrap gap is still large enough for the rendered font.
761
+ gap = max(gap, layout.get('wrap_gap', 5.0))
762
+ next_role = (
763
+ next_layout['elements'][0].config.get('role')
764
+ if next_layout['elements']
765
+ else None
766
+ )
767
+ if next_role == 'description':
768
+ gap = max(gap, curr_font * 0.55, next_font * 0.8, 14.0)
769
+ current_y += gap
770
+
771
+ total_height = current_y + self.svg_padding
772
+ total_width = max_content_width + 2 * self.svg_padding
773
+
774
+ # Create SVG
775
+ svg = ET.Element('svg')
776
+ svg.set('xmlns', 'http://www.w3.org/2000/svg')
777
+ svg.set('width', str(int(total_width)))
778
+ svg.set('height', str(int(total_height)))
779
+ svg.set('viewBox', f"0 0 {int(total_width)} {int(total_height)}")
780
+
781
+ # Render all elements
782
+ for layout in row_layouts:
783
+ for elem in layout['elements']:
784
+ self._render_element(svg, elem)
785
+
786
+ # Convert to string
787
+ svg_string = ET.tostring(svg, encoding='unicode', method='xml')
788
+
789
+ return svg_string, (int(total_width), int(total_height))
790
+
791
+ def _render_element(self, svg_parent: ET.Element, elem: LayoutElement):
792
+ """Render a single layout element to SVG"""
793
+ text = elem.text
794
+ config = elem.config
795
+
796
+ # Get style properties
797
+ font_size = config.get('font_size', 48)
798
+ font_family = config.get('font_family') or 'Arial'
799
+ font_weight = config.get('font_weight') or 'normal'
800
+ final_color = config.get('final_color') or '#000000'
801
+
802
+ # Text transform
803
+ text_transform = config.get('text_transform', 'none')
804
+ if text_transform == 'uppercase':
805
+ text = text.upper()
806
+ elif text_transform == 'lowercase':
807
+ text = text.lower()
808
+ elif text_transform == 'capitalize':
809
+ text = text.capitalize()
810
+
811
+ # Render background if exists
812
+ bg_config = config.get('background', False)
813
+ if bg_config and isinstance(bg_config, dict):
814
+ bg_color = bg_config.get('color', '#000000')
815
+ bg_radius = bg_config.get('radius', bg_config.get('border_radius', 0))
816
+
817
+ # Use pre-calculated background rect position
818
+ rect_elem = ET.Element('rect')
819
+ rect_elem.set('x', str(elem.bg_rect_x))
820
+ rect_elem.set('y', str(elem.bg_rect_y))
821
+ rect_elem.set('width', str(elem.bg_rect_width))
822
+ rect_elem.set('height', str(elem.bg_rect_height))
823
+ rect_elem.set('fill', bg_color)
824
+ if bg_radius > 0:
825
+ rect_elem.set('rx', str(bg_radius))
826
+ rect_elem.set('ry', str(bg_radius))
827
+
828
+ svg_parent.append(rect_elem)
829
+
830
+ # Create text element using pre-calculated position
831
+ text_elem = ET.Element('text')
832
+ text_elem.set('x', str(elem.text_x))
833
+ text_elem.set('y', str(elem.text_baseline_y))
834
+ text_elem.set('font-family', _font_family_for_output(font_family))
835
+ text_elem.set('font-size', f"{font_size}px")
836
+ text_elem.set('font-weight', _normalize_weight_for_output(font_family, font_weight))
837
+ text_elem.set('text-anchor', elem.text_anchor)
838
+ text_elem.set('fill', final_color)
839
+
840
+ # Font style
841
+ font_style = config.get('font_style')
842
+ if font_style and font_style != 'normal':
843
+ text_elem.set('font-style', font_style)
844
+
845
+ # Letter spacing
846
+ letter_spacing = config.get('letter_spacing')
847
+ if letter_spacing:
848
+ if isinstance(letter_spacing, (int, float)) and letter_spacing != 0:
849
+ text_elem.set('letter-spacing', f"{letter_spacing}em")
850
+ elif isinstance(letter_spacing, str):
851
+ text_elem.set('letter-spacing', letter_spacing)
852
+
853
+ text_elem.text = text
854
+ svg_parent.append(text_elem)
855
+
856
+ def estimate_text_width(self, text, font_size, font_weight='normal',
857
+ font_family='Arial', letter_spacing=0):
858
+ """
859
+ Calculate text width using Pillow for precise measurement
860
+ """
861
+ # Parse letter_spacing (from '0.1em' to number)
862
+ spacing_value = 0
863
+ if isinstance(letter_spacing, str) and letter_spacing.endswith('em'):
864
+ spacing_value = float(letter_spacing[:-2])
865
+ elif isinstance(letter_spacing, (int, float)):
866
+ spacing_value = letter_spacing
867
+
868
+ normalized_weight = _normalize_weight_for_output(font_family, font_weight)
869
+ measurement_family = _font_family_for_measurement(
870
+ font_family,
871
+ normalized_weight,
872
+ 'normal',
873
+ )
874
+ width = measure_text_width(
875
+ text,
876
+ measurement_family,
877
+ int(font_size),
878
+ normalized_weight,
879
+ spacing_value
880
+ )
881
+
882
+ # Comic Sans MS tends to be wider than calculated, add safety margin
883
+ if 'Comic Sans' in font_family:
884
+ width *= 1.08 # Add 8% safety margin for Comic Sans
885
+
886
+ return width
887
+
888
+ def render_line(self, text, line_config, y_position, svg_width, alignment):
889
+ """
890
+ 渲染单行文本
891
+
892
+ Returns:
893
+ text_element: SVG text元素
894
+ line_height: 该行的高度
895
+ """
896
+ font_size = line_config.get('font_size', 48) # 直接使用绝对字号
897
+
898
+ # 计算x位置
899
+ if alignment == ALIGNMENT_LEFT:
900
+ x_position = self.svg_padding
901
+ text_anchor = 'start'
902
+ elif alignment == ALIGNMENT_RIGHT:
903
+ x_position = svg_width - self.svg_padding
904
+ text_anchor = 'end'
905
+ else: # center
906
+ x_position = svg_width / 2
907
+ text_anchor = 'middle'
908
+
909
+ # 文本转换
910
+ if line_config.get('text_transform') == 'uppercase':
911
+ text = text.upper()
912
+ elif line_config.get('text_transform') == 'lowercase':
913
+ text = text.lower()
914
+ elif line_config.get('text_transform') == 'capitalize':
915
+ text = text.capitalize()
916
+
917
+ elements = []
918
+
919
+ # 添加背景矩形
920
+ background_config = line_config.get('background', False)
921
+ if background_config:
922
+ bg_padding = background_config.get('padding', 10)
923
+ bg_color = background_config.get('color', '#000000')
924
+ bg_radius = background_config.get('radius', background_config.get('border_radius', 0))
925
+
926
+ # 测量文本实际bbox(相对于baseline)
927
+ # Parse letter_spacing
928
+ spacing_value = 0
929
+ ls = line_config.get('letter_spacing', 0)
930
+ if isinstance(ls, str) and ls.endswith('em'):
931
+ spacing_value = float(ls[:-2])
932
+ elif isinstance(ls, (int, float)):
933
+ spacing_value = ls
934
+
935
+ font_family = line_config.get('font_family') or 'Arial'
936
+ font_weight = _normalize_weight_for_output(
937
+ font_family,
938
+ line_config.get('font_weight') or 'normal',
939
+ )
940
+ font_style = line_config.get('font_style') or 'normal'
941
+ measurement_family = _font_family_for_measurement(
942
+ font_family,
943
+ font_weight,
944
+ font_style,
945
+ )
946
+ ascent, descent, text_width, text_height = measure_text_bbox(
947
+ text,
948
+ measurement_family,
949
+ int(font_size),
950
+ font_weight,
951
+ font_style,
952
+ spacing_value
953
+ )
954
+
955
+ # 根据对齐方式计算矩形位置
956
+ if alignment == ALIGNMENT_LEFT:
957
+ rect_x = x_position - bg_padding
958
+ elif alignment == ALIGNMENT_RIGHT:
959
+ rect_x = x_position - text_width - bg_padding
960
+ else: # center
961
+ rect_x = x_position - text_width/2 - bg_padding
962
+
963
+ # 矩形顶部 = baseline - ascent - padding
964
+ # 矩形底部 = baseline + descent + padding
965
+ rect_y = y_position - ascent - bg_padding
966
+ rect_width = text_width + 2*bg_padding
967
+ rect_height = text_height + 2*bg_padding
968
+
969
+ # 创建矩形元素
970
+ rect_elem = ET.Element('rect')
971
+ rect_elem.set('x', str(rect_x))
972
+ rect_elem.set('y', str(rect_y))
973
+ rect_elem.set('width', str(rect_width))
974
+ rect_elem.set('height', str(rect_height))
975
+ rect_elem.set('fill', bg_color)
976
+ if bg_radius > 0:
977
+ rect_elem.set('rx', str(bg_radius))
978
+ rect_elem.set('ry', str(bg_radius))
979
+
980
+ elements.append(rect_elem)
981
+
982
+ # 添加阴影效果
983
+ shadow_config = line_config.get('shadow', False)
984
+ if shadow_config:
985
+ shadow_offset = shadow_config.get('offset', (3, 3))
986
+ shadow_blur = shadow_config.get('blur', 4)
987
+ shadow_color = shadow_config.get('color', 'rgba(0,0,0,0.3)')
988
+
989
+ shadow_elem = ET.Element('text')
990
+ shadow_elem.set('x', str(x_position + shadow_offset[0]))
991
+ shadow_elem.set('y', str(y_position + shadow_offset[1]))
992
+ shadow_elem.set(
993
+ 'font-family',
994
+ _font_family_for_output(line_config.get('font_family') or 'Arial'),
995
+ )
996
+ shadow_elem.set('font-size', f"{font_size}px")
997
+ shadow_elem.set('font-weight', _normalize_weight_for_output(
998
+ line_config.get('font_family'),
999
+ line_config.get('font_weight') or 'normal',
1000
+ ))
1001
+ shadow_elem.set('text-anchor', text_anchor)
1002
+ shadow_elem.set('fill', shadow_color)
1003
+
1004
+ if shadow_blur > 0:
1005
+ shadow_elem.set('filter', f'url(#shadow-blur-{shadow_blur})')
1006
+
1007
+ if line_config.get('font_style'):
1008
+ shadow_elem.set('font-style', line_config['font_style'])
1009
+ if line_config.get('letter_spacing'):
1010
+ ls = line_config['letter_spacing']
1011
+ if isinstance(ls, (int, float)) and ls != 0:
1012
+ shadow_elem.set('letter-spacing', f"{ls}em")
1013
+ elif isinstance(ls, str):
1014
+ shadow_elem.set('letter-spacing', ls)
1015
+
1016
+ shadow_elem.text = text
1017
+ elements.append(shadow_elem)
1018
+
1019
+ # 创建主text元素
1020
+ text_elem = ET.Element('text')
1021
+ text_elem.set('x', str(x_position))
1022
+ text_elem.set('y', str(y_position))
1023
+ text_elem.set(
1024
+ 'font-family',
1025
+ _font_family_for_output(line_config.get('font_family') or 'Arial'),
1026
+ )
1027
+ text_elem.set('font-size', f"{font_size}px")
1028
+ text_elem.set('font-weight', _normalize_weight_for_output(
1029
+ line_config.get('font_family'),
1030
+ line_config.get('font_weight') or 'normal',
1031
+ ))
1032
+ text_elem.set('text-anchor', text_anchor)
1033
+
1034
+ # 空心字效果
1035
+ if line_config.get('outline', False):
1036
+ outline_width = line_config.get('outline_width', 2)
1037
+ text_elem.set('fill', 'none')
1038
+ text_elem.set('stroke', line_config.get('final_color') or '#000000')
1039
+ text_elem.set('stroke-width', str(outline_width))
1040
+ else:
1041
+ text_elem.set('fill', line_config.get('final_color') or '#000000')
1042
+
1043
+ # 字体样式
1044
+ if line_config.get('font_style'):
1045
+ text_elem.set('font-style', line_config['font_style'])
1046
+
1047
+ # 字母间距
1048
+ if line_config.get('letter_spacing'):
1049
+ ls = line_config['letter_spacing']
1050
+ if isinstance(ls, (int, float)) and ls != 0:
1051
+ text_elem.set('letter-spacing', f"{ls}em")
1052
+ elif isinstance(ls, str):
1053
+ text_elem.set('letter-spacing', ls)
1054
+
1055
+ # 文本装饰(下划线、删除线)
1056
+ decorations = []
1057
+ if line_config.get('underline', False):
1058
+ decorations.append('underline')
1059
+ if line_config.get('strikethrough', False):
1060
+ decorations.append('line-through')
1061
+
1062
+ if decorations:
1063
+ text_elem.set('text-decoration', ' '.join(decorations))
1064
+
1065
+ text_elem.text = text
1066
+ elements.append(text_elem)
1067
+
1068
+ # 计算line_height:如果有背景,需要加上padding
1069
+ line_height = font_size * LINE_HEIGHT_RATIO
1070
+ if background_config:
1071
+ bg_padding = background_config.get('padding', 10)
1072
+ line_height = max(line_height, font_size + 2*bg_padding)
1073
+
1074
+ return elements, line_height
1075
+
1076
+ def wrap_text(self, text, max_width, font_size, font_weight, font_family, letter_spacing):
1077
+ """
1078
+ 将文本分成多行以适应最大宽度。
1079
+
1080
+ 采用「行数最少 + 行宽平衡」两步法:
1081
+ 1) 贪心算出在 max_width 下需要的最少行数 N;
1082
+ 2) 二分搜索最小的 target_width <= max_width,使得贪心仍能在 N 行内装下;
1083
+ 以这个 target 再做一次贪心打包,让各行宽度尽量接近,避免出现
1084
+ 「长 / 短 / 短」这种典型不平衡(First-Fit 贪心的经典缺陷)。
1085
+
1086
+ Returns:
1087
+ lines: 分割后的行列表
1088
+ """
1089
+ words = text.split()
1090
+ if not words:
1091
+ return [text]
1092
+
1093
+ content_max = max_width - 2 * self.svg_padding
1094
+ if content_max <= 0:
1095
+ return [text]
1096
+
1097
+ # 预测每个 word 的宽度,避免反复调用 Pillow。
1098
+ word_widths = [
1099
+ self.estimate_text_width(w, font_size, font_weight, font_family, letter_spacing)
1100
+ for w in words
1101
+ ]
1102
+ space_width = self.estimate_text_width(
1103
+ ' ', font_size, font_weight, font_family, letter_spacing
1104
+ )
1105
+
1106
+ def greedy_pack(target):
1107
+ """尝试用 First-Fit 把 words 装到行宽 <= target 的若干行。
1108
+ 返回 (行数, [(start_idx, end_idx_exclusive), ...]);若有单 word
1109
+ 超过 target 也算 1 行(与原行为一致:单词过长直接成一行)。"""
1110
+ n_lines = 1
1111
+ cur_w = 0.0
1112
+ cur_start = 0
1113
+ packs = []
1114
+ for i, ww in enumerate(word_widths):
1115
+ extra = ww if cur_w == 0 else (space_width + ww)
1116
+ if cur_w + extra <= target or cur_w == 0:
1117
+ cur_w += extra
1118
+ else:
1119
+ packs.append((cur_start, i))
1120
+ n_lines += 1
1121
+ cur_start = i
1122
+ cur_w = ww
1123
+ packs.append((cur_start, len(words)))
1124
+ return n_lines, packs
1125
+
1126
+ # 第 1 步:用原始 max_width 下的最少行数 N。
1127
+ n_lines, _ = greedy_pack(content_max)
1128
+ if n_lines <= 1:
1129
+ return [' '.join(words)]
1130
+
1131
+ # 第 2 步:二分最小化 target_width,使贪心仍能装进 N 行。
1132
+ # 下界必须能容下最长的单 word,否则会无谓增加行数。
1133
+ lo = max(word_widths)
1134
+ hi = content_max
1135
+ # target 不能小于 lo,否则单词放不下
1136
+ if lo >= hi:
1137
+ target = hi
1138
+ else:
1139
+ # 浮点二分:精度 0.5 px 足够,文本宽度本身也是近似值。
1140
+ for _ in range(40): # log2(content_max / 0.5) 上限充裕
1141
+ if hi - lo < 0.5:
1142
+ break
1143
+ mid = (lo + hi) / 2
1144
+ fit_n, _ = greedy_pack(mid)
1145
+ if fit_n <= n_lines:
1146
+ hi = mid
1147
+ else:
1148
+ lo = mid
1149
+ target = hi
1150
+
1151
+ # 第 3 步:用平衡 target 重新打包。
1152
+ _, packs = greedy_pack(target)
1153
+ lines = [' '.join(words[s:e]) for s, e in packs]
1154
+ return lines if lines else [text]
1155
+
1156
+ def calculate_svg_dimensions(self, lines_data, line_configs, alignment, max_width=None):
1157
+ """
1158
+ 计算SVG总尺寸
1159
+
1160
+ Args:
1161
+ lines_data: 文本内容列表
1162
+ line_configs: 行配置列表
1163
+ alignment: 对齐方式
1164
+ max_width: 最大宽度限制
1165
+
1166
+ Returns:
1167
+ (width, height): SVG尺寸
1168
+ wrapped_lines: 考虑换行后的实际行数据
1169
+ """
1170
+ # 单列布局
1171
+ max_width_content = 0
1172
+ total_height = self.svg_padding
1173
+ wrapped_lines = [] # 存储实际渲染的行(包括换行后的)
1174
+
1175
+ for text, config in zip(lines_data, line_configs):
1176
+ font_size = config.get('font_size', 48)
1177
+
1178
+ # 处理换行
1179
+ if config.get('allow_wrap') and max_width:
1180
+ text_lines = self.wrap_text(
1181
+ text, max_width, font_size,
1182
+ config.get('font_weight', 'normal'),
1183
+ config.get('font_family', 'Arial'),
1184
+ config.get('letter_spacing', 0)
1185
+ )
1186
+ else:
1187
+ text_lines = [text]
1188
+
1189
+ # 为每个实际行添加配置
1190
+ for line_text in text_lines:
1191
+ text_width = self.estimate_text_width(
1192
+ line_text,
1193
+ font_size,
1194
+ config.get('font_weight', 'normal'),
1195
+ config.get('font_family', 'Arial'),
1196
+ config.get('letter_spacing', 0)
1197
+ )
1198
+ max_width_content = max(max_width_content, text_width)
1199
+ total_height += font_size * LINE_HEIGHT_RATIO
1200
+ wrapped_lines.append((line_text, config))
1201
+
1202
+ total_height += self.svg_padding
1203
+ total_width = max_width_content + 2 * self.svg_padding
1204
+
1205
+ # Add extra margin for Comic Sans fonts to prevent truncation
1206
+ has_comic_sans = any(
1207
+ config.get('font_family', '').startswith('Comic Sans')
1208
+ for _, config in wrapped_lines
1209
+ )
1210
+ if has_comic_sans:
1211
+ total_width += 20 # Extra 20px safety margin for Comic Sans
1212
+
1213
+ # Always use actual content width to avoid text truncation
1214
+ # max_width is only used to determine when to wrap text, not to limit SVG size
1215
+
1216
+ return total_width, total_height, wrapped_lines
1217
+
1218
+ def _calculate_element_bbox(self, element):
1219
+ """
1220
+ Calculate bounding box for an SVG element (text, rect, or g)
1221
+
1222
+ Returns:
1223
+ (min_x, min_y, max_x, max_y) or None
1224
+ """
1225
+ tag = element.tag
1226
+
1227
+ if tag == 'rect':
1228
+ # For rect: x, y, width, height are explicit
1229
+ # Skip background rect with percentage values
1230
+ width_str = element.get('width', '0')
1231
+ height_str = element.get('height', '0')
1232
+ if '%' in width_str or '%' in height_str:
1233
+ return None # Skip percentage-based rects (background)
1234
+
1235
+ x = float(element.get('x', 0))
1236
+ y = float(element.get('y', 0))
1237
+ width = float(width_str)
1238
+ height = float(height_str)
1239
+
1240
+ # Consider border-radius (rx/ry) if present - doesn't change bbox
1241
+ return (x, y, x + width, y + height)
1242
+
1243
+ elif tag == 'text':
1244
+ # For text: need to measure text dimensions
1245
+ text_content = element.text or ''
1246
+ if not text_content:
1247
+ return None
1248
+
1249
+ x = float(element.get('x', 0))
1250
+ y = float(element.get('y', 0))
1251
+ font_family = element.get('font-family', 'Arial')
1252
+ font_size = int(element.get('font-size', '16px').replace('px', ''))
1253
+ font_weight = element.get('font-weight', 'normal')
1254
+ text_anchor = element.get('text-anchor', 'start')
1255
+
1256
+ # Parse letter-spacing from SVG element
1257
+ letter_spacing_str = element.get('letter-spacing', '0')
1258
+ letter_spacing = 0
1259
+ if letter_spacing_str and letter_spacing_str != '0':
1260
+ if letter_spacing_str.endswith('em'):
1261
+ letter_spacing = float(letter_spacing_str[:-2])
1262
+
1263
+ # Measure text width and height (including letter-spacing)
1264
+ text_width = self.estimate_text_width(
1265
+ text_content, font_size, font_weight, font_family, letter_spacing
1266
+ )
1267
+
1268
+ # Consider stroke width for outline text
1269
+ stroke_width = 0
1270
+ if element.get('stroke'):
1271
+ stroke_width_str = element.get('stroke-width', '0')
1272
+ try:
1273
+ stroke_width = float(stroke_width_str)
1274
+ except:
1275
+ stroke_width = 0
1276
+
1277
+ # Text height with stroke consideration
1278
+ text_height = font_size + stroke_width
1279
+
1280
+ # Calculate bbox based on text-anchor
1281
+ if text_anchor == 'start':
1282
+ min_x = x - stroke_width / 2
1283
+ max_x = x + text_width + stroke_width / 2
1284
+ elif text_anchor == 'middle':
1285
+ min_x = x - text_width / 2 - stroke_width / 2
1286
+ max_x = x + text_width / 2 + stroke_width / 2
1287
+ elif text_anchor == 'end':
1288
+ min_x = x - text_width - stroke_width / 2
1289
+ max_x = x + stroke_width / 2
1290
+ else:
1291
+ min_x = x - stroke_width / 2
1292
+ max_x = x + text_width + stroke_width / 2
1293
+
1294
+ # Y coordinates: y is baseline, text extends above
1295
+ # Add extra space for ascenders and stroke
1296
+ min_y = y - text_height - stroke_width / 2
1297
+ max_y = y + stroke_width / 2
1298
+
1299
+ return (min_x, min_y, max_x, max_y)
1300
+
1301
+ return None
1302
+
1303
+ def _adjust_svg_dimensions_to_content(self, svg_element):
1304
+ """
1305
+ Adjust SVG width/height to fit all content without clipping, ensuring uniform padding
1306
+
1307
+ Args:
1308
+ svg_element: SVG root element
1309
+
1310
+ Returns:
1311
+ (adjusted_width, adjusted_height)
1312
+ """
1313
+ # Collect all bounding boxes
1314
+ bboxes = []
1315
+
1316
+ for child in svg_element:
1317
+ bbox = self._calculate_element_bbox(child)
1318
+ if bbox:
1319
+ bboxes.append(bbox)
1320
+
1321
+ if not bboxes:
1322
+ # No content, keep original dimensions
1323
+ width = int(svg_element.get('width', 100))
1324
+ height = int(svg_element.get('height', 100))
1325
+ return (width, height)
1326
+
1327
+ # Calculate overall bounding box of all visible content
1328
+ min_x = min(bbox[0] for bbox in bboxes)
1329
+ min_y = min(bbox[1] for bbox in bboxes)
1330
+ max_x = max(bbox[2] for bbox in bboxes)
1331
+ max_y = max(bbox[3] for bbox in bboxes)
1332
+
1333
+ # Target padding
1334
+ padding = self.svg_padding
1335
+
1336
+ # Calculate required dimensions to maintain uniform padding on all sides
1337
+ # Left padding: ensure min_x >= padding (shift if needed)
1338
+ # Right padding: ensure max_x + padding is within bounds
1339
+ # Top padding: ensure min_y >= padding (shift if needed)
1340
+ # Bottom padding: ensure max_y + padding is within bounds
1341
+
1342
+ # Calculate content dimensions
1343
+ content_width = max_x - min_x
1344
+ content_height = max_y - min_y
1345
+
1346
+ # Required SVG dimensions = content + padding on both sides
1347
+ required_width = content_width + 2 * padding
1348
+ required_height = content_height + 2 * padding
1349
+
1350
+ # Get current dimensions (from initial calculation)
1351
+ current_width = int(svg_element.get('width', 100))
1352
+ current_height = int(svg_element.get('height', 100))
1353
+
1354
+ # Use maximum of current and required to ensure all content fits
1355
+ final_width = max(current_width, int(required_width))
1356
+ final_height = max(current_height, int(required_height))
1357
+
1358
+ # Check if content is positioned correctly (starts at padding)
1359
+ # If min_x or min_y is less than padding, we need to shift content or increase dimensions
1360
+ if min_x < padding:
1361
+ # Content extends beyond left padding - increase width
1362
+ extra_width = padding - min_x
1363
+ final_width += int(extra_width)
1364
+
1365
+ if min_y < padding:
1366
+ # Content extends beyond top padding - increase height
1367
+ extra_height = padding - min_y
1368
+ final_height += int(extra_height)
1369
+
1370
+ # Ensure right and bottom padding
1371
+ if max_x + padding > final_width:
1372
+ final_width = int(max_x + padding)
1373
+
1374
+ if max_y + padding > final_height:
1375
+ final_height = int(max_y + padding)
1376
+
1377
+ return (final_width, final_height)
1378
+
1379
+ def render_svg(self, lines_data, line_configs, alignment, max_width=None, background_color='#FFFFFF', inline=False):
1380
+ """
1381
+ 渲染完整的SVG (uses unified layout system)
1382
+
1383
+ Args:
1384
+ lines_data: 文本内容列表
1385
+ line_configs: 行配置列表
1386
+ alignment: 对齐方式
1387
+ max_width: 最大宽度限制(可选)
1388
+ background_color: 背景颜色
1389
+ inline: If True, render segments horizontally on same line instead of stacking
1390
+
1391
+ Returns:
1392
+ svg_string: SVG XML字符串
1393
+ dimensions: (width, height)
1394
+ """
1395
+ # Use unified layout system
1396
+ return self.render_svg_unified(lines_data, line_configs, alignment, max_width, background_color)
1397
+
1398
+ def _render_inline_segment(self, text, line_config, y_position, x_position, alignment):
1399
+ """
1400
+ Render a single segment for inline layout (horizontal positioning)
1401
+
1402
+ Args:
1403
+ text: Text content
1404
+ line_config: Line configuration
1405
+ y_position: Y position (baseline)
1406
+ x_position: X position (left edge)
1407
+ alignment: Alignment (ignored for inline, always uses start)
1408
+
1409
+ Returns:
1410
+ (elements, line_height)
1411
+ """
1412
+ font_size = line_config['font_size']
1413
+ line_height = font_size * LINE_HEIGHT_RATIO
1414
+
1415
+ elements = []
1416
+
1417
+ # Add background rectangle if exists
1418
+ background_config = line_config.get('background', False)
1419
+ if background_config:
1420
+ bg_padding = background_config.get('padding', 10)
1421
+ bg_color = background_config.get('color', '#000000')
1422
+ bg_radius = background_config.get('radius', background_config.get('border_radius', 0))
1423
+
1424
+ # 测量文本实际bbox(相对于baseline)
1425
+ spacing_value = 0
1426
+ ls = line_config.get('letter_spacing', 0)
1427
+ if isinstance(ls, str) and ls.endswith('em'):
1428
+ spacing_value = float(ls[:-2])
1429
+ elif isinstance(ls, (int, float)):
1430
+ spacing_value = ls
1431
+
1432
+ font_family = line_config.get('font_family') or 'Arial'
1433
+ font_weight = _normalize_weight_for_output(
1434
+ font_family,
1435
+ line_config.get('font_weight') or 'normal',
1436
+ )
1437
+ font_style = line_config.get('font_style') or 'normal'
1438
+ measurement_family = _font_family_for_measurement(
1439
+ font_family,
1440
+ font_weight,
1441
+ font_style,
1442
+ )
1443
+ ascent, descent, text_width, text_height = measure_text_bbox(
1444
+ text,
1445
+ measurement_family,
1446
+ int(font_size),
1447
+ font_weight,
1448
+ font_style,
1449
+ spacing_value
1450
+ )
1451
+
1452
+ rect_x = x_position - bg_padding
1453
+ rect_y = y_position - ascent - bg_padding
1454
+ rect_width = text_width + 2*bg_padding
1455
+ rect_height = text_height + 2*bg_padding
1456
+
1457
+ rect_elem = ET.Element('rect')
1458
+ rect_elem.set('x', str(rect_x))
1459
+ rect_elem.set('y', str(rect_y))
1460
+ rect_elem.set('width', str(rect_width))
1461
+ rect_elem.set('height', str(rect_height))
1462
+ rect_elem.set('fill', bg_color)
1463
+ if bg_radius > 0:
1464
+ rect_elem.set('rx', str(bg_radius))
1465
+ rect_elem.set('ry', str(bg_radius))
1466
+
1467
+ elements.append(rect_elem)
1468
+
1469
+ # Create text element
1470
+ text_elem = ET.Element('text')
1471
+ text_elem.set('x', str(x_position))
1472
+ text_elem.set('y', str(y_position))
1473
+ text_elem.set(
1474
+ 'font-family',
1475
+ _font_family_for_output(line_config.get('font_family') or 'Arial'),
1476
+ )
1477
+ text_elem.set('font-size', f"{font_size}px")
1478
+ text_elem.set('font-weight', _normalize_weight_for_output(
1479
+ line_config.get('font_family'),
1480
+ line_config.get('font_weight') or 'normal',
1481
+ ))
1482
+ text_elem.set('text-anchor', 'start') # Always start for inline
1483
+ text_elem.set('fill', line_config.get('final_color') or '#000000')
1484
+
1485
+ # Font style (only set if not None and not 'normal')
1486
+ font_style = line_config.get('font_style')
1487
+ if font_style and font_style != 'normal':
1488
+ text_elem.set('font-style', font_style)
1489
+
1490
+ # Letter spacing
1491
+ if line_config.get('letter_spacing'):
1492
+ ls = line_config['letter_spacing']
1493
+ if isinstance(ls, (int, float)) and ls != 0:
1494
+ text_elem.set('letter-spacing', f"{ls}em")
1495
+ elif isinstance(ls, str):
1496
+ text_elem.set('letter-spacing', ls)
1497
+
1498
+ text_elem.text = text
1499
+ elements.append(text_elem)
1500
+
1501
+ # 计算line_height:如果有背景,需要加上padding
1502
+ line_height = font_size * LINE_HEIGHT_RATIO
1503
+ if background_config:
1504
+ bg_padding = background_config.get('padding', 10)
1505
+ line_height = max(line_height, font_size + 2*bg_padding)
1506
+
1507
+ return elements, line_height
modules/title_styler/template_schema.json ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "InfographicLayout",
4
+ "type": "object",
5
+ "description": "Infographic layout description with theme and node tree",
6
+ "properties": {
7
+ "theme": {
8
+ "type": "object",
9
+ "description": "Global design tokens (colors, typography, etc.)",
10
+ "properties": {
11
+ "colors": {
12
+ "type": "object",
13
+ "description": "Color tokens used across the infographic",
14
+ "patternProperties": {
15
+ "^[a-zA-Z_][a-zA-Z0-9_]*$": {
16
+ "type": "string",
17
+ "description": "Color value (e.g. hex, rgba, or implementation-defined token)"
18
+ }
19
+ },
20
+ "additionalProperties": false
21
+ },
22
+ "typography": {
23
+ "type": "object",
24
+ "description": "Typography style tokens (e.g. H1, H2, Body)",
25
+ "patternProperties": {
26
+ "^[a-zA-Z_][a-zA-Z0-9_]*$": {
27
+ "type": "object",
28
+ "properties": {
29
+ "font": {
30
+ "type": "string",
31
+ "description": "Font family name (and optionally style info, implementation-defined)"
32
+ },
33
+ "size": {
34
+ "type": "number",
35
+ "description": "Font size, typically in px"
36
+ },
37
+ "weight": {
38
+ "type": "string",
39
+ "description": "Font weight (e.g. 'normal', 'bold', '600')"
40
+ }
41
+ },
42
+ "required": ["font", "size"],
43
+ "additionalProperties": true
44
+ }
45
+ },
46
+ "additionalProperties": false
47
+ }
48
+ },
49
+ "additionalProperties": false
50
+ },
51
+
52
+ "root": {
53
+ "$ref": "#/definitions/Node",
54
+ "description": "Root node of the infographic layout tree"
55
+ }
56
+ },
57
+ "required": ["root"],
58
+ "additionalProperties": false,
59
+
60
+ "definitions": {
61
+ "Node": {
62
+ "oneOf": [
63
+ { "$ref": "#/definitions/Group" },
64
+ { "$ref": "#/definitions/Stack" },
65
+ { "$ref": "#/definitions/Text" },
66
+ { "$ref": "#/definitions/Image" },
67
+ { "$ref": "#/definitions/Chart" },
68
+ { "$ref": "#/definitions/Shape" }
69
+ ]
70
+ },
71
+
72
+ "Padding": {
73
+ "oneOf": [
74
+ {
75
+ "type": "number",
76
+ "description": "Uniform padding on all sides (px)"
77
+ },
78
+ {
79
+ "type": "object",
80
+ "description": "Per-side padding (px)",
81
+ "properties": {
82
+ "top": { "type": "number" },
83
+ "right": { "type": "number" },
84
+ "bottom": { "type": "number" },
85
+ "left": { "type": "number" }
86
+ },
87
+ "required": ["top", "right", "bottom", "left"],
88
+ "additionalProperties": false
89
+ }
90
+ ]
91
+ },
92
+
93
+ "OverlapConstraint": {
94
+ "type": "object",
95
+ "description": "Relative overlap constraint between two children in a STACK",
96
+ "properties": {
97
+ "sourceId": {
98
+ "type": "string",
99
+ "description": "ID of the element being constrained"
100
+ },
101
+ "targetId": {
102
+ "type": "string",
103
+ "description": "ID of the reference element"
104
+ },
105
+ "type": {
106
+ "type": "string",
107
+ "enum": ["FULLY_INSIDE", "NON_OVERLAP", "PARTIALLY_OVERLAP"],
108
+ "description": "Overlap constraint type"
109
+ },
110
+ "minDistance": {
111
+ "type": "number",
112
+ "description": "Minimum distance in pixels when type is NON_OVERLAP"
113
+ },
114
+ "overlapPercentage": {
115
+ "type": "number",
116
+ "minimum": 0,
117
+ "maximum": 100,
118
+ "description": "Required overlap percentage (0–100) when type is PARTIALLY_OVERLAP"
119
+ }
120
+ },
121
+ "required": ["sourceId", "targetId", "type"],
122
+ "additionalProperties": false
123
+ },
124
+
125
+ "AlignmentConstraint": {
126
+ "type": "object",
127
+ "description": "Simple pairwise alignment constraint between two children in a STACK",
128
+ "properties": {
129
+ "sourceId": {
130
+ "type": "string",
131
+ "description": "ID of the element being aligned"
132
+ },
133
+ "targetId": {
134
+ "type": "string",
135
+ "description": "ID of the reference element to align to"
136
+ },
137
+ "type": {
138
+ "type": "string",
139
+ "enum": ["TOP", "BOTTOM", "LEFT", "RIGHT"],
140
+ "description": "Which edge of source should align to the same edge of target"
141
+ }
142
+ },
143
+ "required": ["sourceId", "targetId", "type"],
144
+ "additionalProperties": false
145
+ },
146
+
147
+ "Group": {
148
+ "type": "object",
149
+ "description": "Flow layout container (row/column)",
150
+ "properties": {
151
+ "id": {
152
+ "type": "string",
153
+ "description": "Unique identifier for the group element"
154
+ },
155
+ "type": {
156
+ "const": "GROUP"
157
+ },
158
+ "direction": {
159
+ "type": "string",
160
+ "enum": ["ROW", "COLUMN"],
161
+ "description": "Layout direction of children"
162
+ },
163
+ "alignment": {
164
+ "type": "object",
165
+ "description": "Main-axis and cross-axis alignment (simplified)",
166
+ "properties": {
167
+ "main": {
168
+ "type": "string",
169
+ "enum": ["START", "CENTER", "END"],
170
+ "description": "Alignment along the layout direction"
171
+ },
172
+ "cross": {
173
+ "type": "string",
174
+ "enum": ["START", "CENTER", "END", "STRETCH"],
175
+ "description": "Alignment perpendicular to the layout direction"
176
+ }
177
+ },
178
+ "required": ["main", "cross"],
179
+ "additionalProperties": false
180
+ },
181
+ "spacing": {
182
+ "type": "number",
183
+ "description": "Spacing between children (px)"
184
+ },
185
+ "padding": {
186
+ "$ref": "#/definitions/Padding"
187
+ },
188
+ "children": {
189
+ "type": "array",
190
+ "items": { "$ref": "#/definitions/Node" }
191
+ }
192
+ },
193
+ "required": ["id", "type", "direction", "alignment", "children"],
194
+ "additionalProperties": false
195
+ },
196
+
197
+ "Stack": {
198
+ "type": "object",
199
+ "description": "Stacked layout (children layered in z-order, like a Stack/ZStack)",
200
+ "properties": {
201
+ "id": {
202
+ "type": "string",
203
+ "description": "Unique identifier for the stack element"
204
+ },
205
+ "type": {
206
+ "const": "STACK"
207
+ },
208
+ "alignment": {
209
+ "type": "string",
210
+ "enum": [
211
+ "TOP_LEFT", "TOP_CENTER", "TOP_RIGHT",
212
+ "CENTER_LEFT", "CENTER", "CENTER_RIGHT",
213
+ "BOTTOM_LEFT", "BOTTOM_CENTER", "BOTTOM_RIGHT"
214
+ ],
215
+ "description": "Default alignment of children within the stack bounds"
216
+ },
217
+ "children": {
218
+ "type": "array",
219
+ "items": { "$ref": "#/definitions/Node" }
220
+ },
221
+ "overlapConstraints": {
222
+ "type": "array",
223
+ "description": "Overlap constraints between children in this stack",
224
+ "items": { "$ref": "#/definitions/OverlapConstraint" }
225
+ },
226
+ "alignmentConstraints": {
227
+ "type": "array",
228
+ "description": "Pairwise alignment constraints between children in this stack",
229
+ "items": { "$ref": "#/definitions/AlignmentConstraint" }
230
+ }
231
+ },
232
+ "required": ["id", "type", "alignment", "children"],
233
+ "additionalProperties": false
234
+ },
235
+
236
+ "Chart": {
237
+ "type": "object",
238
+ "description": "Chart placeholder node (semantics handled outside this grammar)",
239
+ "properties": {
240
+ "id": {
241
+ "type": "string",
242
+ "description": "Unique identifier for the chart element"
243
+ },
244
+ "type": {
245
+ "const": "CHART"
246
+ },
247
+ "width": {
248
+ "type": "number",
249
+ "description": "Chart width (px)"
250
+ },
251
+ "height": {
252
+ "type": "number",
253
+ "description": "Chart height (px)"
254
+ }
255
+ },
256
+ "required": ["id", "type", "width", "height"],
257
+ "additionalProperties": false
258
+ },
259
+
260
+ "Text": {
261
+ "type": "object",
262
+ "description": "Text node; visual style is driven mainly by theme.typography + role",
263
+ "properties": {
264
+ "id": {
265
+ "type": "string",
266
+ "description": "Unique identifier for the text element"
267
+ },
268
+ "type": {
269
+ "const": "TEXT"
270
+ },
271
+ "content": {
272
+ "type": "string",
273
+ "description": "Text content"
274
+ },
275
+ "role": {
276
+ "type": "string",
277
+ "enum": [
278
+ "TITLE_PRIMARY",
279
+ "TITLE_NUMBER",
280
+ "TITLE_CONTEXT",
281
+ "SUBTITLE",
282
+ "CAPTION"
283
+ ],
284
+ "description": "Semantic role of the text, used to map to typography tokens"
285
+ },
286
+ "color": {
287
+ "type": "string",
288
+ "description": "Optional color override; if omitted, renderer may use theme colors based on role"
289
+ },
290
+ "maxLines": {
291
+ "type": "integer",
292
+ "description": "Maximum number of lines to render (truncation behavior is implementation-defined)"
293
+ }
294
+ },
295
+ "required": ["id", "type", "content", "role"],
296
+ "additionalProperties": false
297
+ },
298
+
299
+ "Image": {
300
+ "type": "object",
301
+ "description": "Image node",
302
+ "properties": {
303
+ "id": {
304
+ "type": "string",
305
+ "description": "Unique identifier for the image element"
306
+ },
307
+ "type": {
308
+ "const": "IMAGE"
309
+ },
310
+ "src": {
311
+ "type": "string",
312
+ "description": "Image source (URL or asset identifier)"
313
+ },
314
+ "width": {
315
+ "type": "number",
316
+ "description": "Image width (px)"
317
+ },
318
+ "height": {
319
+ "type": "number",
320
+ "description": "Image height (px)"
321
+ },
322
+ "fit": {
323
+ "type": "string",
324
+ "enum": ["COVER", "CONTAIN", "FILL"],
325
+ "description": "How the image fits into its bounding box"
326
+ },
327
+ "radius": {
328
+ "type": "number",
329
+ "description": "Corner radius for rounded images (px)"
330
+ }
331
+ },
332
+ "required": ["id", "type", "src"],
333
+ "additionalProperties": false
334
+ },
335
+
336
+ "Shape": {
337
+ "type": "object",
338
+ "description": "Basic geometric or path-based shape with free-form attributes",
339
+ "properties": {
340
+ "id": {
341
+ "type": "string",
342
+ "description": "Unique identifier for the shape element"
343
+ },
344
+ "type": {
345
+ "type": "string",
346
+ "enum": ["RECT", "CIRCLE", "PATH"],
347
+ "description": "Shape type"
348
+ },
349
+ "attrs": {
350
+ "type": "object",
351
+ "description": "Implementation-defined attributes for the shape (e.g. width, height, radius, path, color, strokeColor, strokeWidth, etc.)",
352
+ "additionalProperties": true
353
+ }
354
+ },
355
+ "required": ["id", "type"],
356
+ "additionalProperties": false
357
+ }
358
+ }
359
+ }
modules/title_styler/templates.json ADDED
@@ -0,0 +1,2010 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "templates": [
3
+ {
4
+ "name": "three_line_emphasis",
5
+ "description": "Single-line emphasis: fully inline, bold colored highlight for main phrase",
6
+ "style": "normal",
7
+ "layout_type": "single_column",
8
+ "alignment": "left",
9
+ "parts": {
10
+ "main_title": {
11
+ "segments": [
12
+ {
13
+ "font": "Arial 20px",
14
+ "color": "#000000",
15
+ "importance": "secondary",
16
+ "id": 1,
17
+ "allow_wrap": false
18
+ },
19
+ {
20
+ "font": "Arial 28px bold",
21
+ "color": "primary_color",
22
+ "importance": "primary",
23
+ "effects": {
24
+ "shadow": {
25
+ "blur": 4,
26
+ "offset": [
27
+ 3,
28
+ 3
29
+ ],
30
+ "color": "rgba(0,0,0,0.3)"
31
+ }
32
+ },
33
+ "id": 2,
34
+ "allow_wrap": false
35
+ },
36
+ {
37
+ "font": "Arial 20px",
38
+ "color": "#000000",
39
+ "importance": "secondary",
40
+ "id": 3,
41
+ "allow_wrap": false
42
+ }
43
+ ]
44
+ },
45
+ "description": {
46
+ "font": "Arial 17px italic",
47
+ "color": "#333333",
48
+ "allow_wrap": true
49
+ }
50
+ },
51
+ "segment_roles": "$S1 is an introductory modifier or context setter, $S2 is the focal subject (emphasized and prominent), $S3 is a qualifier or concluding phrase.",
52
+ "color_mode": "monochrome"
53
+ },
54
+ {
55
+ "name": "two_line_hierarchy",
56
+ "description": "Two-line hierarchy: secondary prefix + PRIMARY main title",
57
+ "segment_roles": "$S1 is a categorical prefix or contextual modifier, $S2 is the primary subject or topic.",
58
+ "style": "professional",
59
+ "layout_type": "single_column",
60
+ "alignment": "left",
61
+ "parts": {
62
+ "main_title": {
63
+ "segments": [
64
+ {
65
+ "font": "Arial 19px",
66
+ "color": "#FFFFFF",
67
+ "importance": "secondary",
68
+ "text_transform": "uppercase",
69
+ "letter_spacing": 0.1,
70
+ "id": 1,
71
+ "allow_wrap": true
72
+ },
73
+ {
74
+ "font": "Arial 34px bold",
75
+ "color": "primary_color",
76
+ "importance": "primary",
77
+ "id": 2,
78
+ "allow_wrap": true
79
+ }
80
+ ]
81
+ },
82
+ "description": {
83
+ "font": "Arial 17px",
84
+ "color": "#CCCCCC",
85
+ "allow_wrap": true
86
+ }
87
+ },
88
+ "color_mode": "monochrome"
89
+ },
90
+ {
91
+ "name": "lithium_countries",
92
+ "description": "World's Largest style: elegant italic prefix + bold main title",
93
+ "segment_roles": "$S1 is a superlative or descriptive qualifier, $S2 is the main noun phrase or topic.",
94
+ "style": "professional",
95
+ "layout_type": "single_column",
96
+ "alignment": "left",
97
+ "parts": {
98
+ "main_title": {
99
+ "segments": [
100
+ {
101
+ "font": "Georgia 23px italic",
102
+ "color": "#333333",
103
+ "importance": "secondary",
104
+ "id": 1,
105
+ "allow_wrap": false
106
+ },
107
+ {
108
+ "font": "Arial 28px bold",
109
+ "color": "primary_color",
110
+ "importance": "primary",
111
+ "id": 2,
112
+ "allow_wrap": false
113
+ }
114
+ ]
115
+ },
116
+ "description": {
117
+ "font": "Arial 17px",
118
+ "color": "#666666",
119
+ "allow_wrap": true
120
+ }
121
+ },
122
+ "color_mode": "monochrome"
123
+ },
124
+ {
125
+ "name": "european_adults",
126
+ "description": "Two-line stacked: small serif prefix + large sans-serif main title",
127
+ "style": "professional",
128
+ "layout_type": "single_column",
129
+ "alignment": "center",
130
+ "parts": {
131
+ "main_title": {
132
+ "segments": [
133
+ {
134
+ "font": "Georgia 20px",
135
+ "color": "#8B7355",
136
+ "importance": "secondary",
137
+ "id": 1,
138
+ "allow_wrap": false
139
+ },
140
+ {
141
+ "font": "Arial 27px bold",
142
+ "color": "primary_color",
143
+ "importance": "primary",
144
+ "id": 2,
145
+ "allow_wrap": false
146
+ }
147
+ ]
148
+ },
149
+ "description": {
150
+ "font": "Arial 16px",
151
+ "color": "#666666",
152
+ "allow_wrap": true
153
+ }
154
+ },
155
+ "segment_roles": "$S1 is a demographic or geographic qualifier, $S2 is the subject matter or metric.",
156
+ "color_mode": "monochrome"
157
+ },
158
+ {
159
+ "name": "millionaires_green",
160
+ "description": "Green theme with background: small text on green + large white text on black background",
161
+ "segment_roles": "$S1 is a prepositional phrase or categorical context, $S2 is the emphasized core subject (with background, less than 3 words).",
162
+ "style": "professional",
163
+ "layout_type": "single_column",
164
+ "alignment": "center",
165
+ "parts": {
166
+ "main_title": {
167
+ "segments": [
168
+ {
169
+ "font": "Arial 21px bold",
170
+ "color": "#2C5F2D",
171
+ "importance": "secondary",
172
+ "id": 1,
173
+ "allow_wrap": false
174
+ },
175
+ {
176
+ "font": "Arial 30px bold",
177
+ "color": "#ffffff",
178
+ "importance": "primary",
179
+ "effects": {
180
+ "background": {
181
+ "color": "#1A1A1A",
182
+ "padding": 8,
183
+ "border_radius": 10
184
+ }
185
+ },
186
+ "id": 2,
187
+ "allow_wrap": false
188
+ }
189
+ ]
190
+ },
191
+ "description": {
192
+ "font": "Arial 17px",
193
+ "color": "#555555",
194
+ "allow_wrap": true
195
+ }
196
+ },
197
+ "color_mode": "monochrome"
198
+ },
199
+ {
200
+ "name": "america_valuable",
201
+ "description": "America's Most Valuable style: small prefix + 2 lines of huge text + small suffix",
202
+ "style": "professional",
203
+ "layout_type": "single_column",
204
+ "alignment": "left",
205
+ "parts": {
206
+ "main_title": {
207
+ "segments": [
208
+ {
209
+ "font": "Arial 20px",
210
+ "color": "#999999",
211
+ "importance": "secondary",
212
+ "text_transform": "uppercase",
213
+ "id": 1,
214
+ "allow_wrap": false
215
+ },
216
+ {
217
+ "font": "Arial 34px bold",
218
+ "color": "primary_color",
219
+ "importance": "primary",
220
+ "text_transform": "uppercase",
221
+ "id": 2,
222
+ "allow_wrap": false
223
+ },
224
+ {
225
+ "font": "Arial 34px bold",
226
+ "color": "primary_color",
227
+ "importance": "primary",
228
+ "text_transform": "uppercase",
229
+ "id": 3,
230
+ "allow_wrap": false
231
+ },
232
+ {
233
+ "font": "Arial 19px bold",
234
+ "color": "#000000",
235
+ "importance": "secondary",
236
+ "text_transform": "uppercase",
237
+ "id": 4,
238
+ "allow_wrap": false
239
+ }
240
+ ]
241
+ },
242
+ "description": {
243
+ "font": "Arial 17px",
244
+ "color": "#666666",
245
+ "allow_wrap": true
246
+ }
247
+ },
248
+ "segment_roles": "$S1 is a possessive or geographic prefix, $S2 is the main subject (with visual emphasis through background).",
249
+ "color_mode": "monochrome"
250
+ },
251
+ {
252
+ "name": "dentist_question",
253
+ "description": "Question style: medium setup + huge question + small caption",
254
+ "style": "normal",
255
+ "layout_type": "single_column",
256
+ "alignment": "left",
257
+ "parts": {
258
+ "main_title": {
259
+ "segments": [
260
+ {
261
+ "font": "Arial 24px bold",
262
+ "color": "#2C5F4D",
263
+ "importance": "secondary",
264
+ "text_transform": "uppercase",
265
+ "id": 1,
266
+ "allow_wrap": false
267
+ },
268
+ {
269
+ "font": "Arial 40px bold",
270
+ "color": "primary_color",
271
+ "importance": "primary",
272
+ "text_transform": "uppercase",
273
+ "id": 2,
274
+ "allow_wrap": false
275
+ }
276
+ ]
277
+ },
278
+ "description": {
279
+ "font": "Arial 18px bold",
280
+ "color": "#000000",
281
+ "allow_wrap": true,
282
+ "text_transform": "uppercase"
283
+ }
284
+ },
285
+ "segment_roles": "$S1 is an interrogative phrase or question prefix, $S2 is the specific subject being questioned.",
286
+ "color_mode": "monochrome"
287
+ },
288
+ {
289
+ "name": "infant_mortality",
290
+ "description": "Dark background style: small text with white background + large white text",
291
+ "style": "professional",
292
+ "layout_type": "single_column",
293
+ "alignment": "left",
294
+ "parts": {
295
+ "main_title": {
296
+ "segments": [
297
+ {
298
+ "font": "Arial 21px",
299
+ "color": "#000000",
300
+ "importance": "secondary",
301
+ "effects": {
302
+ "background": {
303
+ "color": "#FFFFFF",
304
+ "padding": 8,
305
+ "border_radius": 0
306
+ }
307
+ },
308
+ "id": 1,
309
+ "allow_wrap": false
310
+ },
311
+ {
312
+ "font": "Arial 31px bold",
313
+ "color": "primary_color",
314
+ "importance": "primary",
315
+ "id": 2,
316
+ "allow_wrap": false
317
+ }
318
+ ]
319
+ },
320
+ "description": {
321
+ "font": "Arial 17px",
322
+ "color": "#CCCCCC",
323
+ "allow_wrap": true
324
+ }
325
+ },
326
+ "segment_roles": "$S1 is a categorical tag (with background, less than 3 words), $S2 is the emphasized main metric or topic.",
327
+ "color_mode": "monochrome"
328
+ },
329
+ {
330
+ "name": "financial_centers",
331
+ "description": "Three-line with colored background: black + primary background + black",
332
+ "style": "professional",
333
+ "layout_type": "single_column",
334
+ "alignment": "left",
335
+ "parts": {
336
+ "main_title": {
337
+ "segments": [
338
+ {
339
+ "font": "Arial 28px bold",
340
+ "color": "#000000",
341
+ "importance": "secondary",
342
+ "id": 1,
343
+ "allow_wrap": false
344
+ },
345
+ {
346
+ "font": "Arial 28px bold",
347
+ "color": "#FFFFFF",
348
+ "importance": "primary",
349
+ "effects": {
350
+ "background": {
351
+ "color": "primary_color",
352
+ "padding": 12,
353
+ "border_radius": 0
354
+ }
355
+ },
356
+ "id": 2,
357
+ "allow_wrap": false
358
+ },
359
+ {
360
+ "font": "Arial 28px bold",
361
+ "color": "#000000",
362
+ "importance": "secondary",
363
+ "id": 3,
364
+ "allow_wrap": false
365
+ }
366
+ ]
367
+ },
368
+ "description": {
369
+ "font": "Arial 17px",
370
+ "color": "#666666",
371
+ "allow_wrap": true
372
+ }
373
+ },
374
+ "segment_roles": "$S1 is a ranking or quantitative modifier, $S2 is the emphasized subject (with background, less than 3 words).",
375
+ "color_mode": "monochrome"
376
+ },
377
+ {
378
+ "name": "innovative_countries",
379
+ "description": "Three-line emphasis with purple background: black + primary background + black",
380
+ "style": "professional",
381
+ "layout_type": "single_column",
382
+ "alignment": "left",
383
+ "parts": {
384
+ "main_title": {
385
+ "segments": [
386
+ {
387
+ "font": "Arial 27px bold",
388
+ "color": "#000000",
389
+ "importance": "secondary",
390
+ "id": 1,
391
+ "allow_wrap": false
392
+ },
393
+ {
394
+ "font": "Arial 32px bold",
395
+ "color": "#FFFFFF",
396
+ "importance": "primary",
397
+ "effects": {
398
+ "background": {
399
+ "color": "primary_color",
400
+ "padding": 14,
401
+ "border_radius": 8
402
+ }
403
+ },
404
+ "id": 2,
405
+ "allow_wrap": false
406
+ },
407
+ {
408
+ "font": "Arial 27px bold",
409
+ "color": "#000000",
410
+ "importance": "secondary",
411
+ "id": 3,
412
+ "allow_wrap": false
413
+ }
414
+ ]
415
+ },
416
+ "description": {
417
+ "font": "Arial 17px",
418
+ "color": "#666666",
419
+ "allow_wrap": true
420
+ }
421
+ },
422
+ "segment_roles": "$S1 is a metric label or index name, $S2 is the main subject (with background, less than 3 words).",
423
+ "color_mode": "monochrome"
424
+ },
425
+ {
426
+ "name": "americas_wealth",
427
+ "description": "Large elegant title + smaller subtitle on dark background (white text)",
428
+ "style": "professional",
429
+ "layout_type": "single_column",
430
+ "alignment": "center",
431
+ "parts": {
432
+ "main_title": {
433
+ "segments": [
434
+ {
435
+ "font": "Georgia 28px bold",
436
+ "color": "#FFFFFF",
437
+ "importance": "primary",
438
+ "id": 1,
439
+ "allow_wrap": true
440
+ }
441
+ ]
442
+ },
443
+ "description": {
444
+ "font": "Arial 17px",
445
+ "color": "#CCCCCC",
446
+ "allow_wrap": true
447
+ }
448
+ },
449
+ "segment_roles": "$S1 is a complete formal title without internal segmentation.",
450
+ "color_mode": "monochrome"
451
+ },
452
+ {
453
+ "name": "china_dominance",
454
+ "description": "Three-line bold title with alternating primary/secondary colors (red-black-red pattern)",
455
+ "style": "professional",
456
+ "layout_type": "single_column",
457
+ "alignment": "left",
458
+ "parts": {
459
+ "main_title": {
460
+ "segments": [
461
+ {
462
+ "font": "Impact 34px bold",
463
+ "color": "primary_color",
464
+ "importance": "primary",
465
+ "letter_spacing": "0.02em",
466
+ "id": 1,
467
+ "allow_wrap": false
468
+ },
469
+ {
470
+ "font": "Arial 23px bold",
471
+ "color": "#1A1A1A",
472
+ "importance": "secondary",
473
+ "id": 2,
474
+ "allow_wrap": false
475
+ },
476
+ {
477
+ "font": "Impact 34px bold",
478
+ "color": "primary_color",
479
+ "importance": "primary",
480
+ "letter_spacing": "0.02em",
481
+ "id": 3,
482
+ "allow_wrap": false
483
+ }
484
+ ]
485
+ },
486
+ "description": {
487
+ "font": "Arial 16px",
488
+ "color": "#555555",
489
+ "allow_wrap": true
490
+ }
491
+ },
492
+ "segment_roles": "$S1 is a possessive or subject identifier, $S2 is a descriptive/categorical modifier, $S3 is an abstract noun or conclusion.",
493
+ "color_mode": "monochrome"
494
+ },
495
+ {
496
+ "name": "magazine_cover",
497
+ "description": "Magazine cover style: small eyebrow + huge bold title + elegant subtitle",
498
+ "style": "professional",
499
+ "layout_type": "single_column",
500
+ "alignment": "center",
501
+ "parts": {
502
+ "main_title": {
503
+ "segments": [
504
+ {
505
+ "font": "Arial 19px bold",
506
+ "color": "primary_color",
507
+ "importance": "secondary",
508
+ "text_transform": "uppercase",
509
+ "letter_spacing": "0.2em",
510
+ "id": 1,
511
+ "allow_wrap": true
512
+ },
513
+ {
514
+ "font": "Georgia 32px bold",
515
+ "color": "#1A1A1A",
516
+ "importance": "primary",
517
+ "id": 2,
518
+ "allow_wrap": true
519
+ },
520
+ {
521
+ "font": "Georgia 19px italic",
522
+ "color": "#666666",
523
+ "importance": "secondary",
524
+ "id": 3,
525
+ "allow_wrap": true
526
+ }
527
+ ]
528
+ },
529
+ "description": {
530
+ "font": "Arial 16px",
531
+ "color": "#888888",
532
+ "allow_wrap": true
533
+ }
534
+ },
535
+ "segment_roles": "$S1 is a meta-label or section tag, $S2 is the main headline, $S3 is a subtitle or elaborative phrase.",
536
+ "color_mode": "monochrome"
537
+ },
538
+ {
539
+ "name": "newspaper_headline",
540
+ "description": "Newspaper headline: all caps bold with strong hierarchy",
541
+ "style": "professional",
542
+ "layout_type": "single_column",
543
+ "alignment": "left",
544
+ "parts": {
545
+ "main_title": {
546
+ "segments": [
547
+ {
548
+ "font": "Arial 28px bold",
549
+ "color": "#000000",
550
+ "importance": "primary",
551
+ "text_transform": "uppercase",
552
+ "letter_spacing": "0.01em",
553
+ "id": 1,
554
+ "allow_wrap": true
555
+ }
556
+ ]
557
+ },
558
+ "description": {
559
+ "font": "Georgia 17px",
560
+ "color": "#333333",
561
+ "allow_wrap": true
562
+ }
563
+ },
564
+ "segment_roles": "$S1 is a complete declarative sentence or statement headline.",
565
+ "color_mode": "monochrome"
566
+ },
567
+ {
568
+ "name": "elegant_serif",
569
+ "description": "Elegant serif: centered thin + thick combination with line spacing",
570
+ "style": "professional",
571
+ "layout_type": "single_column",
572
+ "alignment": "center",
573
+ "parts": {
574
+ "main_title": {
575
+ "segments": [
576
+ {
577
+ "font": "Georgia 20px",
578
+ "color": "#555555",
579
+ "importance": "secondary",
580
+ "letter_spacing": "0.05em",
581
+ "id": 1,
582
+ "allow_wrap": true
583
+ },
584
+ {
585
+ "font": "Georgia 27px bold",
586
+ "color": "primary_color",
587
+ "importance": "primary",
588
+ "id": 2,
589
+ "allow_wrap": true
590
+ }
591
+ ]
592
+ },
593
+ "description": {
594
+ "font": "Georgia 16px italic",
595
+ "color": "#777777",
596
+ "allow_wrap": true
597
+ }
598
+ },
599
+ "segment_roles": "$S1 is an abstract or philosophical prefix, $S2 is the main conceptual subject.",
600
+ "color_mode": "monochrome"
601
+ },
602
+ {
603
+ "name": "data_report",
604
+ "description": "Data report: number emphasis with highlighted background",
605
+ "style": "professional",
606
+ "layout_type": "single_column",
607
+ "alignment": "left",
608
+ "parts": {
609
+ "main_title": {
610
+ "segments": [
611
+ {
612
+ "font": "Arial 20px bold",
613
+ "color": "#444444",
614
+ "importance": "secondary",
615
+ "text_transform": "uppercase",
616
+ "letter_spacing": "0.15em",
617
+ "id": 1,
618
+ "allow_wrap": false
619
+ },
620
+ {
621
+ "font": "Impact 34px bold",
622
+ "color": "#FFFFFF",
623
+ "importance": "primary",
624
+ "background": {
625
+ "color": "primary_color",
626
+ "padding": 12,
627
+ "border_radius": 8
628
+ },
629
+ "id": 2,
630
+ "allow_wrap": false
631
+ }
632
+ ]
633
+ },
634
+ "description": {
635
+ "font": "Arial 16px",
636
+ "color": "#666666",
637
+ "allow_wrap": true
638
+ }
639
+ },
640
+ "segment_roles": "$S1 is a temporal label or reporting period, $S2 is a numeric metric or key finding (with background, less than 3 words).",
641
+ "color_mode": "monochrome"
642
+ },
643
+ {
644
+ "name": "corporate_brand",
645
+ "description": "Corporate brand: wide letter spacing + underline emphasis",
646
+ "style": "professional",
647
+ "layout_type": "single_column",
648
+ "alignment": "center",
649
+ "parts": {
650
+ "main_title": {
651
+ "segments": [
652
+ {
653
+ "font": "Arial 28px bold",
654
+ "color": "primary_color",
655
+ "importance": "primary",
656
+ "text_transform": "uppercase",
657
+ "letter_spacing": "0.08em",
658
+ "id": 1,
659
+ "allow_wrap": true
660
+ }
661
+ ]
662
+ },
663
+ "description": {
664
+ "font": "Arial 17px",
665
+ "color": "#555555",
666
+ "allow_wrap": true
667
+ }
668
+ },
669
+ "segment_roles": "$S1 is a brand statement or corporate message (typically uppercase).",
670
+ "color_mode": "monochrome"
671
+ },
672
+ {
673
+ "name": "academic_paper",
674
+ "description": "Academic paper: formal serif with subtitle line",
675
+ "style": "professional",
676
+ "layout_type": "single_column",
677
+ "alignment": "center",
678
+ "parts": {
679
+ "main_title": {
680
+ "segments": [
681
+ {
682
+ "font": "Georgia 24px bold",
683
+ "color": "#1A1A1A",
684
+ "importance": "primary",
685
+ "id": 1,
686
+ "allow_wrap": true
687
+ },
688
+ {
689
+ "font": "Georgia 20px",
690
+ "color": "#555555",
691
+ "importance": "secondary",
692
+ "id": 2,
693
+ "allow_wrap": true
694
+ }
695
+ ]
696
+ },
697
+ "description": {
698
+ "font": "Georgia 16px",
699
+ "color": "#666666",
700
+ "allow_wrap": true
701
+ }
702
+ },
703
+ "segment_roles": "$S1 is the research topic or phenomenon, $S2 is a methodological or scope qualifier.",
704
+ "color_mode": "monochrome"
705
+ },
706
+ {
707
+ "name": "tech_startup",
708
+ "description": "Tech startup: bold sans-serif + colored accent word",
709
+ "style": "normal",
710
+ "layout_type": "single_column",
711
+ "alignment": "left",
712
+ "parts": {
713
+ "main_title": {
714
+ "segments": [
715
+ {
716
+ "font": "Arial 29px bold",
717
+ "color": "#1A1A1A",
718
+ "importance": "secondary",
719
+ "id": 1,
720
+ "allow_wrap": true
721
+ },
722
+ {
723
+ "font": "Arial 29px bold",
724
+ "color": "primary_color",
725
+ "importance": "primary",
726
+ "id": 2,
727
+ "allow_wrap": true
728
+ }
729
+ ]
730
+ },
731
+ "description": {
732
+ "font": "Arial 17px",
733
+ "color": "#777777",
734
+ "allow_wrap": true
735
+ }
736
+ },
737
+ "segment_roles": "$S1 is the first part of a compound statement, $S2 is the complementary second part (color-emphasized).",
738
+ "color_mode": "monochrome"
739
+ },
740
+ {
741
+ "name": "poster_impact",
742
+ "description": "Poster impact: mixed sizes with strong contrast",
743
+ "style": "normal",
744
+ "layout_type": "single_column",
745
+ "alignment": "left",
746
+ "parts": {
747
+ "main_title": {
748
+ "segments": [
749
+ {
750
+ "font": "Impact 37px bold",
751
+ "color": "primary_color",
752
+ "importance": "primary",
753
+ "letter_spacing": "0.02em",
754
+ "id": 1,
755
+ "allow_wrap": false
756
+ },
757
+ {
758
+ "font": "Arial 19px bold",
759
+ "color": "#2A2A2A",
760
+ "importance": "secondary",
761
+ "text_transform": "uppercase",
762
+ "letter_spacing": "0.1em",
763
+ "id": 2,
764
+ "allow_wrap": false
765
+ }
766
+ ]
767
+ },
768
+ "description": {
769
+ "font": "Arial 16px",
770
+ "color": "#666666",
771
+ "allow_wrap": true
772
+ }
773
+ },
774
+ "segment_roles": "$S1 is a declarative statement or call-to-action (huge impact), $S2 is a supporting phrase or continuation.",
775
+ "color_mode": "monochrome"
776
+ },
777
+ {
778
+ "name": "vintage_style",
779
+ "description": "Vintage style: decorative serif with layered text",
780
+ "style": "normal",
781
+ "layout_type": "single_column",
782
+ "alignment": "center",
783
+ "parts": {
784
+ "main_title": {
785
+ "segments": [
786
+ {
787
+ "font": "Georgia 19px",
788
+ "color": "#8B7355",
789
+ "importance": "secondary",
790
+ "letter_spacing": "0.3em",
791
+ "text_transform": "uppercase",
792
+ "id": 1,
793
+ "allow_wrap": true
794
+ },
795
+ {
796
+ "font": "Georgia 28px bold",
797
+ "color": "#2C1810",
798
+ "importance": "primary",
799
+ "letter_spacing": "0.05em",
800
+ "id": 2,
801
+ "allow_wrap": true
802
+ },
803
+ {
804
+ "font": "Georgia 19px italic",
805
+ "color": "#A0826D",
806
+ "importance": "secondary",
807
+ "letter_spacing": "0.15em",
808
+ "id": 3,
809
+ "allow_wrap": true
810
+ }
811
+ ]
812
+ },
813
+ "description": {
814
+ "font": "Georgia 16px",
815
+ "color": "#6B5D52",
816
+ "allow_wrap": true
817
+ }
818
+ },
819
+ "segment_roles": "$S1 is a temporal marker or establishment tag, $S2 is a proper noun or brand name, $S3 is a descriptor or tagline.",
820
+ "color_mode": "monochrome"
821
+ },
822
+ {
823
+ "name": "europe_brands",
824
+ "description": "Small colored tag + large black title (primary color tag with white text)",
825
+ "style": "normal",
826
+ "layout_type": "single_column",
827
+ "alignment": "left",
828
+ "parts": {
829
+ "main_title": {
830
+ "segments": [
831
+ {
832
+ "font": "Arial 24px bold",
833
+ "color": "#FFFFFF",
834
+ "importance": "secondary",
835
+ "background": {
836
+ "color": "primary_color",
837
+ "padding": 12,
838
+ "border_radius": 12
839
+ },
840
+ "id": 1,
841
+ "allow_wrap": false
842
+ },
843
+ {
844
+ "font": "Arial 32px bold",
845
+ "color": "#000000",
846
+ "importance": "primary",
847
+ "id": 2,
848
+ "allow_wrap": true
849
+ }
850
+ ]
851
+ },
852
+ "description": {
853
+ "font": "Arial 16px",
854
+ "color": "#2A2A2A",
855
+ "allow_wrap": true
856
+ }
857
+ },
858
+ "segment_roles": "$S1 is a geographic or possessive tag (with background, less than 3 words), $S2 is the main headline or topic.",
859
+ "color_mode": "monochrome"
860
+ },
861
+ {
862
+ "name": "international_students",
863
+ "description": "Geographic prefix + multi-line stacked emphasis + temporal subtitle",
864
+ "style": "normal",
865
+ "layout_type": "single_column",
866
+ "alignment": "center",
867
+ "parts": {
868
+ "main_title": {
869
+ "segments": [
870
+ {
871
+ "font": "Arial 20px",
872
+ "color": "#444444",
873
+ "importance": "secondary",
874
+ "text_transform": "uppercase",
875
+ "letter_spacing": 0.08,
876
+ "id": 1,
877
+ "allow_wrap": false
878
+ },
879
+ {
880
+ "font": "Impact 34px bold",
881
+ "color": "#1A1A1A",
882
+ "importance": "primary",
883
+ "text_transform": "uppercase",
884
+ "id": 2,
885
+ "allow_wrap": false
886
+ },
887
+ {
888
+ "font": "Arial 19px",
889
+ "color": "#555555",
890
+ "importance": "secondary",
891
+ "id": 3,
892
+ "allow_wrap": false
893
+ }
894
+ ]
895
+ },
896
+ "description": {
897
+ "font": "Arial 16px",
898
+ "color": "#666666",
899
+ "allow_wrap": true
900
+ }
901
+ },
902
+ "segment_roles": "$S1 is a geographic or possessive identifier, $S2 is the main emphasized subject (multi-line capable), $S3 is a temporal or scope qualifier.",
903
+ "color_mode": "monochrome"
904
+ },
905
+ {
906
+ "name": "kpi_number_pill",
907
+ "description": "KPI pill: small uppercase label + huge number in rounded pill",
908
+ "segment_roles": "$S1 is a category/metric label (SECONDARY), $S2 is the key number or data fact (with background, less than 3 words).",
909
+ "style": "professional",
910
+ "layout_type": "single_column",
911
+ "alignment": "left",
912
+ "parts": {
913
+ "main_title": {
914
+ "segments": [
915
+ {
916
+ "font": "Arial 20px bold",
917
+ "color": "#444444",
918
+ "importance": "secondary",
919
+ "text_transform": "uppercase",
920
+ "letter_spacing": 0.12,
921
+ "id": 1,
922
+ "allow_wrap": false
923
+ },
924
+ {
925
+ "font": "Impact 36px",
926
+ "color": "#FFFFFF",
927
+ "importance": "primary",
928
+ "background": {
929
+ "color": "primary_color",
930
+ "padding": 12,
931
+ "border_radius": 15
932
+ },
933
+ "id": 2,
934
+ "allow_wrap": false
935
+ }
936
+ ]
937
+ },
938
+ "description": {
939
+ "font": "Arial 16px",
940
+ "color": "#666666",
941
+ "allow_wrap": true
942
+ }
943
+ },
944
+ "color_mode": "monochrome"
945
+ },
946
+ {
947
+ "name": "timeline_year_span",
948
+ "description": "Timeline span: small prefix + large year range + small suffix",
949
+ "segment_roles": "$S1 is a contextual lead-in (SECONDARY), $S2 is the year range / time span (PRIMARY), $S3 is a topic noun or qualifier (SECONDARY).",
950
+ "style": "normal",
951
+ "layout_type": "single_column",
952
+ "alignment": "center",
953
+ "parts": {
954
+ "main_title": {
955
+ "segments": [
956
+ {
957
+ "font": "Georgia 18px italic",
958
+ "color": "#555555",
959
+ "importance": "secondary",
960
+ "id": 1,
961
+ "allow_wrap": false
962
+ },
963
+ {
964
+ "font": "Arial 34px bold",
965
+ "color": "primary_color",
966
+ "importance": "primary",
967
+ "id": 2,
968
+ "allow_wrap": false
969
+ },
970
+ {
971
+ "font": "Arial 19px",
972
+ "color": "#333333",
973
+ "importance": "secondary",
974
+ "id": 3,
975
+ "allow_wrap": false
976
+ }
977
+ ]
978
+ },
979
+ "description": {
980
+ "font": "Arial 16px",
981
+ "color": "#777777",
982
+ "allow_wrap": true
983
+ }
984
+ },
985
+ "color_mode": "monochrome"
986
+ },
987
+ {
988
+ "name": "stacked_badge_headline",
989
+ "description": "Badge + headline + tagline: small label in rounded badge, big headline, small italic tagline",
990
+ "segment_roles": "$S1 is a label/tag (with background, less than 3 words), $S2 is the main topic or metric (PRIMARY), $S3 is a short qualifier or context (SECONDARY).",
991
+ "style": "professional",
992
+ "layout_type": "single_column",
993
+ "alignment": "left",
994
+ "parts": {
995
+ "main_title": {
996
+ "segments": [
997
+ {
998
+ "font": "Arial 19px bold",
999
+ "color": "#FFFFFF",
1000
+ "importance": "secondary",
1001
+ "text_transform": "uppercase",
1002
+ "letter_spacing": 0.18,
1003
+ "background": {
1004
+ "color": "primary_color",
1005
+ "padding": 10,
1006
+ "border_radius": 14
1007
+ },
1008
+ "id": 1,
1009
+ "allow_wrap": false
1010
+ },
1011
+ {
1012
+ "font": "Arial 33px bold",
1013
+ "color": "#111111",
1014
+ "importance": "primary",
1015
+ "id": 2,
1016
+ "allow_wrap": true
1017
+ },
1018
+ {
1019
+ "font": "Georgia 18px italic",
1020
+ "color": "#666666",
1021
+ "importance": "secondary",
1022
+ "id": 3,
1023
+ "allow_wrap": true
1024
+ }
1025
+ ]
1026
+ },
1027
+ "description": {
1028
+ "font": "Arial 16px",
1029
+ "color": "#777777",
1030
+ "allow_wrap": true
1031
+ }
1032
+ },
1033
+ "color_mode": "monochrome"
1034
+ },
1035
+ {
1036
+ "name": "right_align_elegant_duo",
1037
+ "description": "Right aligned duo: small serif context + large bold headline",
1038
+ "segment_roles": "$S1 is context or scope (SECONDARY), $S2 is the core subject (PRIMARY).",
1039
+ "style": "professional",
1040
+ "layout_type": "single_column",
1041
+ "alignment": "right",
1042
+ "parts": {
1043
+ "main_title": {
1044
+ "segments": [
1045
+ {
1046
+ "font": "Georgia 19px italic",
1047
+ "color": "#666666",
1048
+ "importance": "secondary",
1049
+ "id": 1,
1050
+ "allow_wrap": true
1051
+ },
1052
+ {
1053
+ "font": "Georgia 31px bold",
1054
+ "color": "primary_color",
1055
+ "importance": "primary",
1056
+ "id": 2,
1057
+ "allow_wrap": true
1058
+ }
1059
+ ]
1060
+ },
1061
+ "description": {
1062
+ "font": "Arial 16px",
1063
+ "color": "#666666",
1064
+ "allow_wrap": true
1065
+ }
1066
+ },
1067
+ "color_mode": "monochrome"
1068
+ },
1069
+ {
1070
+ "name": "inline_contrast_triple",
1071
+ "description": "Inline triple: text + highlighted keyword + trailing qualifier (single line)",
1072
+ "segment_roles": "$S1 is a lead-in phrase (SECONDARY), $S2 is the key noun/metric (with background, less than 3 words), $S3 is a short qualifier (SECONDARY). All three are inline.",
1073
+ "style": "normal",
1074
+ "layout_type": "single_column",
1075
+ "alignment": "left",
1076
+ "parts": {
1077
+ "main_title": {
1078
+ "segments": [
1079
+ [
1080
+ {
1081
+ "font": "Arial 25px bold",
1082
+ "color": "#1A1A1A",
1083
+ "importance": "secondary",
1084
+ "id": 1,
1085
+ "allow_wrap": false
1086
+ },
1087
+ {
1088
+ "font": "Arial 25px bold",
1089
+ "color": "#FFFFFF",
1090
+ "importance": "primary",
1091
+ "background": {
1092
+ "color": "primary_color",
1093
+ "padding": 10,
1094
+ "border_radius": 10
1095
+ },
1096
+ "id": 2,
1097
+ "allow_wrap": false
1098
+ },
1099
+ {
1100
+ "font": "Arial 25px bold",
1101
+ "color": "#1A1A1A",
1102
+ "importance": "secondary",
1103
+ "id": 3,
1104
+ "allow_wrap": false
1105
+ }
1106
+ ]
1107
+ ]
1108
+ },
1109
+ "description": {
1110
+ "font": "Arial 17px",
1111
+ "color": "#555555",
1112
+ "allow_wrap": true
1113
+ }
1114
+ },
1115
+ "color_mode": "monochrome"
1116
+ },
1117
+ {
1118
+ "name": "split_colon_emphasis",
1119
+ "description": "Colon split: label on top + main topic highlighted below",
1120
+ "segment_roles": "$S1 is a label/category (SECONDARY), $S2 is the main topic/metric after the colon (with background, less than 3 words).",
1121
+ "style": "professional",
1122
+ "layout_type": "single_column",
1123
+ "alignment": "left",
1124
+ "parts": {
1125
+ "main_title": {
1126
+ "segments": [
1127
+ {
1128
+ "font": "Arial 18px bold",
1129
+ "color": "#555555",
1130
+ "importance": "secondary",
1131
+ "text_transform": "uppercase",
1132
+ "letter_spacing": 0.12,
1133
+ "id": 1,
1134
+ "allow_wrap": false
1135
+ },
1136
+ {
1137
+ "font": "Arial 32px bold",
1138
+ "color": "#FFFFFF",
1139
+ "importance": "primary",
1140
+ "effects": {
1141
+ "background": {
1142
+ "color": "primary_color",
1143
+ "padding": 14,
1144
+ "border_radius": 8
1145
+ }
1146
+ },
1147
+ "id": 2,
1148
+ "allow_wrap": false
1149
+ }
1150
+ ]
1151
+ },
1152
+ "description": {
1153
+ "font": "Arial 16px",
1154
+ "color": "#666666",
1155
+ "allow_wrap": true
1156
+ }
1157
+ },
1158
+ "color_mode": "monochrome"
1159
+ },
1160
+ {
1161
+ "name": "minimal_right_caption",
1162
+ "description": "Minimal right aligned: clean title + italic caption",
1163
+ "segment_roles": "$S1 is a concise headline (PRIMARY).",
1164
+ "style": "simple",
1165
+ "layout_type": "single_column",
1166
+ "alignment": "right",
1167
+ "parts": {
1168
+ "main_title": {
1169
+ "segments": [
1170
+ {
1171
+ "font": "Arial 28px bold",
1172
+ "color": "#1A1A1A",
1173
+ "importance": "primary",
1174
+ "id": 1,
1175
+ "allow_wrap": true
1176
+ }
1177
+ ]
1178
+ },
1179
+ "description": {
1180
+ "font": "Arial 16px italic",
1181
+ "color": "#666666",
1182
+ "allow_wrap": true
1183
+ }
1184
+ },
1185
+ "color_mode": "monochrome"
1186
+ },
1187
+ {
1188
+ "name": "two_tone_upper",
1189
+ "description": "Two-tone uppercase: secondary color line + primary color line",
1190
+ "segment_roles": "$S1 is context/scope (SECONDARY), $S2 is the main subject (PRIMARY). Both are uppercase for strong hierarchy.",
1191
+ "style": "normal",
1192
+ "layout_type": "single_column",
1193
+ "alignment": "left",
1194
+ "parts": {
1195
+ "main_title": {
1196
+ "segments": [
1197
+ {
1198
+ "font": "Arial 22px bold",
1199
+ "color": "secondary_color",
1200
+ "importance": "secondary",
1201
+ "text_transform": "uppercase",
1202
+ "letter_spacing": 0.1,
1203
+ "id": 1,
1204
+ "allow_wrap": false
1205
+ },
1206
+ {
1207
+ "font": "Arial 29px bold",
1208
+ "color": "primary_color",
1209
+ "importance": "primary",
1210
+ "text_transform": "uppercase",
1211
+ "letter_spacing": 0.04,
1212
+ "id": 2,
1213
+ "allow_wrap": false
1214
+ }
1215
+ ]
1216
+ },
1217
+ "description": {
1218
+ "font": "Arial 16px",
1219
+ "color": "#555555",
1220
+ "allow_wrap": true
1221
+ }
1222
+ },
1223
+ "color_mode": "duotone"
1224
+ },
1225
+ {
1226
+ "name": "rounded_label_big_title",
1227
+ "description": "Rounded label + big title: compact label badge above large headline",
1228
+ "segment_roles": "$S1 is a category label (with background, less than 3 words), $S2 is the primary topic/metric (PRIMARY).",
1229
+ "style": "normal",
1230
+ "layout_type": "single_column",
1231
+ "alignment": "left",
1232
+ "parts": {
1233
+ "main_title": {
1234
+ "segments": [
1235
+ {
1236
+ "font": "Arial 19px bold",
1237
+ "color": "#FFFFFF",
1238
+ "importance": "secondary",
1239
+ "text_transform": "uppercase",
1240
+ "letter_spacing": 0.16,
1241
+ "effects": {
1242
+ "background": {
1243
+ "color": "#1A1A1A",
1244
+ "padding": 10,
1245
+ "border_radius": 15
1246
+ }
1247
+ },
1248
+ "id": 1,
1249
+ "allow_wrap": true
1250
+ },
1251
+ {
1252
+ "font": "Arial 33px bold",
1253
+ "color": "primary_color",
1254
+ "importance": "primary",
1255
+ "id": 2,
1256
+ "allow_wrap": true
1257
+ }
1258
+ ]
1259
+ },
1260
+ "description": {
1261
+ "font": "Arial 16px",
1262
+ "color": "#666666",
1263
+ "allow_wrap": true
1264
+ }
1265
+ },
1266
+ "color_mode": "monochrome"
1267
+ },
1268
+ {
1269
+ "name": "underlined_statement",
1270
+ "description": "Underlined headline: bold title with underline emphasis",
1271
+ "segment_roles": "$S1 is the main statement headline (PRIMARY) with underline emphasis.",
1272
+ "style": "simple",
1273
+ "layout_type": "single_column",
1274
+ "alignment": "left",
1275
+ "parts": {
1276
+ "main_title": {
1277
+ "segments": [
1278
+ {
1279
+ "font": "Arial 30px bold",
1280
+ "color": "#111111",
1281
+ "importance": "primary",
1282
+ "effects": {
1283
+ "underline": {
1284
+ "color": "primary_color",
1285
+ "thickness": 4
1286
+ }
1287
+ },
1288
+ "id": 1,
1289
+ "allow_wrap": true
1290
+ }
1291
+ ]
1292
+ },
1293
+ "description": {
1294
+ "font": "Arial 16px",
1295
+ "color": "#666666",
1296
+ "allow_wrap": true
1297
+ }
1298
+ },
1299
+ "color_mode": "monochrome"
1300
+ },
1301
+ {
1302
+ "name": "soft_shadow_duo",
1303
+ "description": "Soft shadow duo: small context + big headline with gentle drop shadow",
1304
+ "segment_roles": "$S1 is a context qualifier (SECONDARY), $S2 is the main topic/metric (PRIMARY) with soft shadow for depth.",
1305
+ "style": "normal",
1306
+ "layout_type": "single_column",
1307
+ "alignment": "left",
1308
+ "parts": {
1309
+ "main_title": {
1310
+ "segments": [
1311
+ {
1312
+ "font": "Arial 18px",
1313
+ "color": "#666666",
1314
+ "importance": "secondary",
1315
+ "id": 1,
1316
+ "allow_wrap": true
1317
+ },
1318
+ {
1319
+ "font": "Arial 33px bold",
1320
+ "color": "primary_color",
1321
+ "importance": "primary",
1322
+ "effects": {
1323
+ "shadow": {
1324
+ "blur": 10,
1325
+ "offset": [
1326
+ 0,
1327
+ 8
1328
+ ],
1329
+ "color": "rgba(0,0,0,0.22)"
1330
+ }
1331
+ },
1332
+ "id": 2,
1333
+ "allow_wrap": true
1334
+ }
1335
+ ]
1336
+ },
1337
+ "description": {
1338
+ "font": "Arial 16px",
1339
+ "color": "#777777",
1340
+ "allow_wrap": true
1341
+ }
1342
+ },
1343
+ "color_mode": "monochrome"
1344
+ },
1345
+ {
1346
+ "name": "comic_fun_title",
1347
+ "description": "Comic fun title: playful Comic Sans with shadow",
1348
+ "segment_roles": "$S1 is the main fun headline (PRIMARY).",
1349
+ "style": "comic",
1350
+ "layout_type": "single_column",
1351
+ "alignment": "center",
1352
+ "parts": {
1353
+ "main_title": {
1354
+ "segments": [
1355
+ {
1356
+ "font": "Comic Sans MS 28px bold",
1357
+ "color": "primary_color",
1358
+ "importance": "primary",
1359
+ "effects": {
1360
+ "shadow": {
1361
+ "blur": 6,
1362
+ "offset": [
1363
+ 4,
1364
+ 4
1365
+ ],
1366
+ "color": "rgba(0,0,0,0.3)"
1367
+ }
1368
+ },
1369
+ "id": 1,
1370
+ "allow_wrap": true
1371
+ }
1372
+ ]
1373
+ },
1374
+ "description": {
1375
+ "font": "Comic Sans MS 17px",
1376
+ "color": "#555555",
1377
+ "allow_wrap": true
1378
+ }
1379
+ },
1380
+ "color_mode": "monochrome"
1381
+ },
1382
+ {
1383
+ "name": "comic_bubble_speech",
1384
+ "description": "Comic bubble speech: small prefix + big Comic Sans title with rounded background",
1385
+ "segment_roles": "$S1 is a playful prefix (SECONDARY), $S2 is the main fun headline (PRIMARY) with bubble background.",
1386
+ "style": "comic",
1387
+ "layout_type": "single_column",
1388
+ "alignment": "left",
1389
+ "parts": {
1390
+ "main_title": {
1391
+ "segments": [
1392
+ {
1393
+ "font": "Comic Sans MS 19px bold",
1394
+ "color": "#FF6B6B",
1395
+ "importance": "secondary",
1396
+ "id": 1,
1397
+ "allow_wrap": true
1398
+ },
1399
+ {
1400
+ "font": "Comic Sans MS 32px bold",
1401
+ "color": "#FFFFFF",
1402
+ "importance": "primary",
1403
+ "background": {
1404
+ "color": "primary_color",
1405
+ "padding": 15,
1406
+ "border_radius": 20
1407
+ },
1408
+ "id": 2,
1409
+ "allow_wrap": false
1410
+ }
1411
+ ]
1412
+ },
1413
+ "description": {
1414
+ "font": "Comic Sans MS 16px",
1415
+ "color": "#666666",
1416
+ "allow_wrap": true
1417
+ }
1418
+ },
1419
+ "color_mode": "monochrome"
1420
+ },
1421
+ {
1422
+ "name": "comic_colorful_stack",
1423
+ "description": "Comic colorful stack: two-line playful Comic Sans with different colors",
1424
+ "segment_roles": "$S1 is the first part of the fun message (SECONDARY), $S2 is the second part (PRIMARY).",
1425
+ "style": "comic",
1426
+ "layout_type": "single_column",
1427
+ "alignment": "center",
1428
+ "parts": {
1429
+ "main_title": {
1430
+ "segments": [
1431
+ {
1432
+ "font": "Comic Sans MS 25px bold",
1433
+ "color": "#4ECDC4",
1434
+ "importance": "secondary",
1435
+ "id": 1,
1436
+ "allow_wrap": false
1437
+ },
1438
+ {
1439
+ "font": "Comic Sans MS 30px bold",
1440
+ "color": "primary_color",
1441
+ "importance": "primary",
1442
+ "id": 2,
1443
+ "allow_wrap": false
1444
+ }
1445
+ ]
1446
+ },
1447
+ "description": {
1448
+ "font": "Comic Sans MS 17px",
1449
+ "color": "#777777",
1450
+ "allow_wrap": true
1451
+ }
1452
+ },
1453
+ "color_mode": "monochrome"
1454
+ },
1455
+ {
1456
+ "name": "comic_bouncy_emphasis",
1457
+ "description": "Comic bouncy emphasis: inline Comic Sans with highlighted keyword",
1458
+ "segment_roles": "$S1 is the lead-in text (SECONDARY), $S2 is the emphasized fun word (PRIMARY) with rounded background, $S3 is the trailing text (SECONDARY). All inline.",
1459
+ "style": "comic",
1460
+ "layout_type": "single_column",
1461
+ "alignment": "left",
1462
+ "parts": {
1463
+ "main_title": {
1464
+ "segments": [
1465
+ [
1466
+ {
1467
+ "font": "Comic Sans MS 27px bold",
1468
+ "color": "#333333",
1469
+ "importance": "secondary",
1470
+ "id": 1,
1471
+ "allow_wrap": false
1472
+ },
1473
+ {
1474
+ "font": "Comic Sans MS 28px bold",
1475
+ "color": "#FFFFFF",
1476
+ "importance": "primary",
1477
+ "background": {
1478
+ "color": "primary_color",
1479
+ "padding": 12,
1480
+ "border_radius": 18
1481
+ },
1482
+ "id": 2,
1483
+ "allow_wrap": false
1484
+ },
1485
+ {
1486
+ "font": "Comic Sans MS 27px bold",
1487
+ "color": "#333333",
1488
+ "importance": "secondary",
1489
+ "id": 3,
1490
+ "allow_wrap": false
1491
+ }
1492
+ ]
1493
+ ]
1494
+ },
1495
+ "description": {
1496
+ "font": "Comic Sans MS 16px",
1497
+ "color": "#666666",
1498
+ "allow_wrap": true
1499
+ }
1500
+ },
1501
+ "color_mode": "monochrome"
1502
+ },
1503
+ {
1504
+ "name": "comic_playful_label",
1505
+ "description": "Comic playful label: fun rounded badge + big Comic Sans headline",
1506
+ "segment_roles": "$S1 is a playful category label (SECONDARY) in a badge, $S2 is the fun main headline (PRIMARY).",
1507
+ "style": "comic",
1508
+ "layout_type": "single_column",
1509
+ "alignment": "left",
1510
+ "parts": {
1511
+ "main_title": {
1512
+ "segments": [
1513
+ {
1514
+ "font": "Comic Sans MS 20px bold",
1515
+ "color": "#FFFFFF",
1516
+ "importance": "secondary",
1517
+ "text_transform": "uppercase",
1518
+ "effects": {
1519
+ "background": {
1520
+ "color": "#FF6B6B",
1521
+ "padding": 12,
1522
+ "border_radius": 20
1523
+ }
1524
+ },
1525
+ "id": 1,
1526
+ "allow_wrap": true
1527
+ },
1528
+ {
1529
+ "font": "Comic Sans MS 34px bold",
1530
+ "color": "primary_color",
1531
+ "importance": "primary",
1532
+ "id": 2,
1533
+ "allow_wrap": true
1534
+ }
1535
+ ]
1536
+ },
1537
+ "description": {
1538
+ "font": "Comic Sans MS 17px",
1539
+ "color": "#555555",
1540
+ "allow_wrap": true
1541
+ }
1542
+ },
1543
+ "color_mode": "monochrome"
1544
+ },
1545
+ {
1546
+ "name": "comic_question_fun",
1547
+ "description": "Comic question fun: playful question style with exclamation",
1548
+ "segment_roles": "$S1 is the question setup (SECONDARY), $S2 is the big fun question (PRIMARY).",
1549
+ "style": "comic",
1550
+ "layout_type": "single_column",
1551
+ "alignment": "center",
1552
+ "parts": {
1553
+ "main_title": {
1554
+ "segments": [
1555
+ {
1556
+ "font": "Comic Sans MS 21px bold",
1557
+ "color": "#FF6B6B",
1558
+ "importance": "secondary",
1559
+ "id": 1,
1560
+ "allow_wrap": true
1561
+ },
1562
+ {
1563
+ "font": "Comic Sans MS 35px bold",
1564
+ "color": "primary_color",
1565
+ "importance": "primary",
1566
+ "effects": {
1567
+ "shadow": {
1568
+ "blur": 8,
1569
+ "offset": [
1570
+ 5,
1571
+ 5
1572
+ ],
1573
+ "color": "rgba(0,0,0,0.25)"
1574
+ }
1575
+ },
1576
+ "id": 2,
1577
+ "allow_wrap": true
1578
+ }
1579
+ ]
1580
+ },
1581
+ "description": {
1582
+ "font": "Comic Sans MS 17px bold",
1583
+ "color": "#666666",
1584
+ "allow_wrap": true
1585
+ }
1586
+ },
1587
+ "color_mode": "monochrome"
1588
+ },
1589
+ {
1590
+ "name": "comic_bold_announcement",
1591
+ "description": "Comic bold announcement: single huge Comic Sans title with colorful background",
1592
+ "segment_roles": "$S1 is the main announcement (PRIMARY) with bold colorful styling.",
1593
+ "style": "comic",
1594
+ "layout_type": "single_column",
1595
+ "alignment": "center",
1596
+ "parts": {
1597
+ "main_title": {
1598
+ "segments": [
1599
+ {
1600
+ "font": "Comic Sans MS 33px bold",
1601
+ "color": "#FFFFFF",
1602
+ "importance": "primary",
1603
+ "background": {
1604
+ "color": "primary_color",
1605
+ "padding": 18,
1606
+ "border_radius": 25
1607
+ },
1608
+ "effects": {
1609
+ "shadow": {
1610
+ "blur": 10,
1611
+ "offset": [
1612
+ 6,
1613
+ 6
1614
+ ],
1615
+ "color": "rgba(0,0,0,0.3)"
1616
+ }
1617
+ },
1618
+ "id": 1,
1619
+ "allow_wrap": false
1620
+ }
1621
+ ]
1622
+ },
1623
+ "description": {
1624
+ "font": "Comic Sans MS 17px",
1625
+ "color": "#555555",
1626
+ "allow_wrap": true
1627
+ }
1628
+ },
1629
+ "color_mode": "monochrome"
1630
+ },
1631
+ {
1632
+ "name": "comic_wavy_text",
1633
+ "description": "Comic wavy text: playful two-line with different Comic Sans styles",
1634
+ "segment_roles": "$S1 is the first playful line (SECONDARY), $S2 is the second emphasized line (PRIMARY).",
1635
+ "style": "comic",
1636
+ "layout_type": "single_column",
1637
+ "alignment": "left",
1638
+ "parts": {
1639
+ "main_title": {
1640
+ "segments": [
1641
+ {
1642
+ "font": "Comic Sans MS 23px bold",
1643
+ "color": "#FF6B6B",
1644
+ "importance": "secondary",
1645
+ "id": 1,
1646
+ "allow_wrap": true
1647
+ },
1648
+ {
1649
+ "font": "Comic Sans MS 31px bold",
1650
+ "color": "primary_color",
1651
+ "importance": "primary",
1652
+ "id": 2,
1653
+ "allow_wrap": true
1654
+ }
1655
+ ]
1656
+ },
1657
+ "description": {
1658
+ "font": "Comic Sans MS 17px",
1659
+ "color": "#666666",
1660
+ "allow_wrap": true
1661
+ }
1662
+ },
1663
+ "color_mode": "monochrome"
1664
+ },
1665
+ {
1666
+ "name": "comic_mega_title",
1667
+ "description": "Comic mega title: enormous single-line Comic Sans with outline",
1668
+ "segment_roles": "$S1 is the mega headline (PRIMARY) with strong visual impact.",
1669
+ "style": "comic",
1670
+ "layout_type": "single_column",
1671
+ "alignment": "center",
1672
+ "parts": {
1673
+ "main_title": {
1674
+ "segments": [
1675
+ {
1676
+ "font": "Comic Sans MS 40px bold",
1677
+ "color": "primary_color",
1678
+ "importance": "primary",
1679
+ "effects": {
1680
+ "shadow": {
1681
+ "blur": 12,
1682
+ "offset": [
1683
+ 6,
1684
+ 6
1685
+ ],
1686
+ "color": "rgba(0,0,0,0.4)"
1687
+ }
1688
+ },
1689
+ "id": 1,
1690
+ "allow_wrap": true
1691
+ }
1692
+ ]
1693
+ },
1694
+ "description": {
1695
+ "font": "Comic Sans MS 18px",
1696
+ "color": "#555555",
1697
+ "allow_wrap": true
1698
+ }
1699
+ },
1700
+ "color_mode": "monochrome"
1701
+ },
1702
+ {
1703
+ "name": "comic_sticker_style",
1704
+ "description": "Comic sticker style: rounded sticker badge + Comic Sans title",
1705
+ "segment_roles": "$S1 is a sticker-like badge (SECONDARY), $S2 is the main title (PRIMARY).",
1706
+ "style": "comic",
1707
+ "layout_type": "single_column",
1708
+ "alignment": "center",
1709
+ "parts": {
1710
+ "main_title": {
1711
+ "segments": [
1712
+ {
1713
+ "font": "Comic Sans MS 20px bold",
1714
+ "color": "#FFFFFF",
1715
+ "importance": "secondary",
1716
+ "text_transform": "uppercase",
1717
+ "background": {
1718
+ "color": "#FF6B6B",
1719
+ "padding": 14,
1720
+ "border_radius": 25
1721
+ },
1722
+ "effects": {
1723
+ "shadow": {
1724
+ "blur": 6,
1725
+ "offset": [
1726
+ 3,
1727
+ 3
1728
+ ],
1729
+ "color": "rgba(0,0,0,0.3)"
1730
+ }
1731
+ },
1732
+ "id": 1,
1733
+ "allow_wrap": false
1734
+ },
1735
+ {
1736
+ "font": "Comic Sans MS 32px bold",
1737
+ "color": "primary_color",
1738
+ "importance": "primary",
1739
+ "id": 2,
1740
+ "allow_wrap": true
1741
+ }
1742
+ ]
1743
+ },
1744
+ "description": {
1745
+ "font": "Comic Sans MS 17px",
1746
+ "color": "#666666",
1747
+ "allow_wrap": true
1748
+ }
1749
+ },
1750
+ "color_mode": "monochrome"
1751
+ },
1752
+ {
1753
+ "name": "right_bold_statement",
1754
+ "description": "Right aligned bold statement: single bold headline",
1755
+ "segment_roles": "$S1 is a powerful headline (PRIMARY).",
1756
+ "style": "normal",
1757
+ "layout_type": "single_column",
1758
+ "alignment": "right",
1759
+ "parts": {
1760
+ "main_title": {
1761
+ "segments": [
1762
+ {
1763
+ "font": "Arial 30px bold",
1764
+ "color": "primary_color",
1765
+ "importance": "primary",
1766
+ "id": 1,
1767
+ "allow_wrap": true
1768
+ }
1769
+ ]
1770
+ },
1771
+ "description": {
1772
+ "font": "Arial 17px",
1773
+ "color": "#666666",
1774
+ "allow_wrap": true
1775
+ }
1776
+ },
1777
+ "color_mode": "monochrome"
1778
+ },
1779
+ {
1780
+ "name": "right_two_tone",
1781
+ "description": "Right aligned two-tone: colored prefix + bold main",
1782
+ "segment_roles": "$S1 is context or category (SECONDARY), $S2 is the main subject (PRIMARY).",
1783
+ "style": "normal",
1784
+ "layout_type": "single_column",
1785
+ "alignment": "right",
1786
+ "parts": {
1787
+ "main_title": {
1788
+ "segments": [
1789
+ {
1790
+ "font": "Arial 21px",
1791
+ "color": "secondary_color",
1792
+ "importance": "secondary",
1793
+ "text_transform": "uppercase",
1794
+ "letter_spacing": 0.1,
1795
+ "id": 1,
1796
+ "allow_wrap": true
1797
+ },
1798
+ {
1799
+ "font": "Arial 30px bold",
1800
+ "color": "primary_color",
1801
+ "importance": "primary",
1802
+ "id": 2,
1803
+ "allow_wrap": true
1804
+ }
1805
+ ]
1806
+ },
1807
+ "description": {
1808
+ "font": "Arial 16px",
1809
+ "color": "#777777",
1810
+ "allow_wrap": true
1811
+ }
1812
+ },
1813
+ "color_mode": "monochrome"
1814
+ },
1815
+ {
1816
+ "name": "right_serif_classic",
1817
+ "description": "Right aligned classic serif: elegant Georgia headline",
1818
+ "segment_roles": "$S1 is a sophisticated statement (PRIMARY).",
1819
+ "style": "professional",
1820
+ "layout_type": "single_column",
1821
+ "alignment": "right",
1822
+ "parts": {
1823
+ "main_title": {
1824
+ "segments": [
1825
+ {
1826
+ "font": "Georgia 29px bold",
1827
+ "color": "#1A1A1A",
1828
+ "importance": "primary",
1829
+ "id": 1,
1830
+ "allow_wrap": true
1831
+ }
1832
+ ]
1833
+ },
1834
+ "description": {
1835
+ "font": "Georgia 17px italic",
1836
+ "color": "#666666",
1837
+ "allow_wrap": true
1838
+ }
1839
+ },
1840
+ "color_mode": "monochrome"
1841
+ },
1842
+ {
1843
+ "name": "right_modern_duo",
1844
+ "description": "Right aligned modern duo: thin secondary + bold primary",
1845
+ "segment_roles": "$S1 is a light descriptor (SECONDARY), $S2 is the bold subject (PRIMARY).",
1846
+ "style": "simple",
1847
+ "layout_type": "single_column",
1848
+ "alignment": "right",
1849
+ "parts": {
1850
+ "main_title": {
1851
+ "segments": [
1852
+ {
1853
+ "font": "Arial 21px",
1854
+ "color": "#999999",
1855
+ "importance": "secondary",
1856
+ "id": 1,
1857
+ "allow_wrap": true
1858
+ },
1859
+ {
1860
+ "font": "Arial 30px bold",
1861
+ "color": "primary_color",
1862
+ "importance": "primary",
1863
+ "id": 2,
1864
+ "allow_wrap": true
1865
+ }
1866
+ ]
1867
+ },
1868
+ "description": {
1869
+ "font": "Arial 16px",
1870
+ "color": "#888888",
1871
+ "allow_wrap": true
1872
+ }
1873
+ },
1874
+ "color_mode": "monochrome"
1875
+ },
1876
+ {
1877
+ "name": "right_triple_emphasis",
1878
+ "description": "Right aligned triple emphasis: secondary + bold primary + secondary",
1879
+ "segment_roles": "$S1 is context (SECONDARY), $S2 is core subject (PRIMARY), $S3 is closing context (SECONDARY).",
1880
+ "style": "normal",
1881
+ "layout_type": "single_column",
1882
+ "alignment": "right",
1883
+ "parts": {
1884
+ "main_title": {
1885
+ "segments": [
1886
+ {
1887
+ "font": "Arial 20px",
1888
+ "color": "#333333",
1889
+ "importance": "secondary",
1890
+ "id": 1,
1891
+ "allow_wrap": false
1892
+ },
1893
+ {
1894
+ "font": "Arial 28px bold",
1895
+ "color": "primary_color",
1896
+ "importance": "primary",
1897
+ "id": 2,
1898
+ "allow_wrap": false
1899
+ },
1900
+ {
1901
+ "font": "Arial 20px",
1902
+ "color": "#333333",
1903
+ "importance": "secondary",
1904
+ "id": 3,
1905
+ "allow_wrap": false
1906
+ }
1907
+ ]
1908
+ },
1909
+ "description": {
1910
+ "font": "Arial 17px italic",
1911
+ "color": "#555555",
1912
+ "allow_wrap": true
1913
+ }
1914
+ },
1915
+ "color_mode": "monochrome"
1916
+ },
1917
+ {
1918
+ "name": "right_impact_headline",
1919
+ "description": "Right aligned impact: large bold uppercase statement",
1920
+ "segment_roles": "$S1 is a powerful declaration (PRIMARY).",
1921
+ "style": "normal",
1922
+ "layout_type": "single_column",
1923
+ "alignment": "right",
1924
+ "parts": {
1925
+ "main_title": {
1926
+ "segments": [
1927
+ {
1928
+ "font": "Impact 34px bold",
1929
+ "color": "primary_color",
1930
+ "importance": "primary",
1931
+ "text_transform": "uppercase",
1932
+ "letter_spacing": 0.02,
1933
+ "id": 1,
1934
+ "allow_wrap": true
1935
+ }
1936
+ ]
1937
+ },
1938
+ "description": {
1939
+ "font": "Arial 16px",
1940
+ "color": "#666666",
1941
+ "allow_wrap": true
1942
+ }
1943
+ },
1944
+ "color_mode": "monochrome"
1945
+ },
1946
+ {
1947
+ "name": "right_elegant_stack",
1948
+ "description": "Right aligned elegant stack: small caps + large bold",
1949
+ "segment_roles": "$S1 is a category or subtitle (SECONDARY), $S2 is the main headline (PRIMARY).",
1950
+ "style": "professional",
1951
+ "layout_type": "single_column",
1952
+ "alignment": "right",
1953
+ "parts": {
1954
+ "main_title": {
1955
+ "segments": [
1956
+ {
1957
+ "font": "Arial 18px",
1958
+ "color": "#888888",
1959
+ "importance": "secondary",
1960
+ "text_transform": "uppercase",
1961
+ "letter_spacing": 0.15,
1962
+ "id": 1,
1963
+ "allow_wrap": true
1964
+ },
1965
+ {
1966
+ "font": "Georgia 32px bold",
1967
+ "color": "#1A1A1A",
1968
+ "importance": "primary",
1969
+ "id": 2,
1970
+ "allow_wrap": true
1971
+ }
1972
+ ]
1973
+ },
1974
+ "description": {
1975
+ "font": "Arial 17px",
1976
+ "color": "#777777",
1977
+ "allow_wrap": true
1978
+ }
1979
+ },
1980
+ "color_mode": "monochrome"
1981
+ },
1982
+ {
1983
+ "name": "right_minimal_focus",
1984
+ "description": "Right aligned minimal: clean and focused single line",
1985
+ "segment_roles": "$S1 is a clear statement (PRIMARY).",
1986
+ "style": "simple",
1987
+ "layout_type": "single_column",
1988
+ "alignment": "right",
1989
+ "parts": {
1990
+ "main_title": {
1991
+ "segments": [
1992
+ {
1993
+ "font": "Arial 27px",
1994
+ "color": "#2A2A2A",
1995
+ "importance": "primary",
1996
+ "id": 1,
1997
+ "allow_wrap": true
1998
+ }
1999
+ ]
2000
+ },
2001
+ "description": {
2002
+ "font": "Arial 16px",
2003
+ "color": "#999999",
2004
+ "allow_wrap": true
2005
+ }
2006
+ },
2007
+ "color_mode": "monochrome"
2008
+ }
2009
+ ]
2010
+ }
modules/title_styler/templates.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Infographic Title Generator - Template Loader
3
+ Load and parse templates from JSON configuration file
4
+ """
5
+
6
+ import json
7
+ import os
8
+ from typing import List, Dict, Optional
9
+ from modules.title_styler.config import (
10
+ COLOR_TYPE_PRIMARY, COLOR_TYPE_SECONDARY, COLOR_TYPE_FIXED,
11
+ NEUTRAL_COLORS
12
+ )
13
+
14
+
15
+ class LineConfig:
16
+ """Single line configuration"""
17
+
18
+ def __init__(self,
19
+ role, # 'main' or 'description'
20
+ font_family,
21
+ font_size, # Absolute size in pixels
22
+ font_weight='normal',
23
+ color_type=COLOR_TYPE_PRIMARY,
24
+ color_value=None,
25
+ text_transform='none',
26
+ letter_spacing=0,
27
+ font_style='normal',
28
+ allow_wrap=False,
29
+ importance=None, # 'primary' or 'secondary' (for main title only)
30
+ # Simplified style options
31
+ underline=False, # True or {'color': '#000', 'thickness': 1}
32
+ strikethrough=False, # True or {'color': '#000', 'thickness': 1}
33
+ outline=False, # True or {'width': 2, 'color': None}
34
+ shadow=False, # True or {'blur': 4, 'offset': (3, 3), 'color': 'rgba(0,0,0,0.3)'}
35
+ background=False): # True or {'color': '#000', 'padding': 10, 'border_radius': 0}
36
+ """
37
+ Initialize line configuration
38
+
39
+ Args:
40
+ role: 'main' for main title, 'description' for subtitle
41
+ font_family: Font name
42
+ font_size: Font size in pixels (e.g. 48, 32)
43
+ font_weight: Font weight ('normal', 'bold', 'bolder', 'lighter')
44
+ color_type: Color type
45
+ color_value: Fixed color value
46
+ text_transform: Text transform ('none', 'uppercase', 'lowercase', 'capitalize')
47
+ letter_spacing: Letter spacing in em units
48
+ font_style: Font style ('normal', 'italic', 'oblique')
49
+ allow_wrap: Whether to allow text wrapping (when exceeding max_width)
50
+ importance: Importance level (for main title only)
51
+ - 'primary': Most relevant part (e.g. "Mobile Phones"), usually larger and bolder
52
+ - 'secondary': Supporting part (e.g. "The Best Selling"), usually smaller
53
+ - None: Not specified (default)
54
+
55
+ underline: Underline
56
+ - False: Not used
57
+ - True: Use default style
58
+ - dict: {'color': '#000000', 'thickness': 1}
59
+
60
+ strikethrough: Strikethrough
61
+ - False: Not used
62
+ - True: Use default style
63
+ - dict: {'color': '#000000', 'thickness': 1}
64
+
65
+ outline: Outline text
66
+ - False: Not used
67
+ - True: Use default style (2px width)
68
+ - dict: {'width': 2, 'color': None} # None means use primary color
69
+
70
+ shadow: Shadow
71
+ - False: Not used
72
+ - True: Use default style (offset 3,3, blur 4)
73
+ - dict: {'blur': 4, 'offset': (3, 3), 'color': 'rgba(0,0,0,0.3)'}
74
+
75
+ background: Background rectangle
76
+ - False: Not used
77
+ - True: Use default style (black bg, 10px padding)
78
+ - dict: {'color': '#000000', 'padding': 10, 'border_radius': 0}
79
+ """
80
+ self.role = role
81
+ self.importance = importance # Added: importance marker
82
+ self.font_family = font_family
83
+ self.font_size = font_size
84
+ self.font_weight = font_weight
85
+ self.color_type = color_type
86
+ self.color_value = color_value
87
+ self.text_transform = text_transform
88
+ self.letter_spacing = letter_spacing
89
+ self.font_style = font_style
90
+ self.allow_wrap = allow_wrap
91
+
92
+ # Normalize style parameters
93
+ self.underline = self._normalize_style_param(underline, {
94
+ 'color': None, # None means use text color
95
+ 'thickness': 1
96
+ })
97
+
98
+ self.strikethrough = self._normalize_style_param(strikethrough, {
99
+ 'color': None,
100
+ 'thickness': 1
101
+ })
102
+
103
+ self.outline = self._normalize_style_param(outline, {
104
+ 'width': 2,
105
+ 'color': None # None means use text color
106
+ })
107
+
108
+ self.shadow = self._normalize_style_param(shadow, {
109
+ 'blur': 4,
110
+ 'offset': (3, 3),
111
+ 'color': 'rgba(0,0,0,0.3)'
112
+ })
113
+ self.background = self._normalize_style_param(background, {
114
+ 'color': '#000000',
115
+ 'padding': 10,
116
+ 'border_radius': 0
117
+ })
118
+
119
+ def _normalize_style_param(self, param, defaults):
120
+ """
121
+ Normalize style parameter
122
+
123
+ Args:
124
+ param: False / True / dict
125
+ defaults: Default values dictionary
126
+
127
+ Returns:
128
+ False or dictionary with complete parameters
129
+ """
130
+ if param is False or param is None:
131
+ return False
132
+ elif param is True:
133
+ return defaults.copy()
134
+ elif isinstance(param, dict):
135
+ # Merge user parameters and defaults
136
+ result = defaults.copy()
137
+ result.update(param)
138
+ return result
139
+ else:
140
+ # Other cases treated as True
141
+ return defaults.copy()
142
+
143
+ def to_dict(self):
144
+ """Convert to dictionary"""
145
+ return {
146
+ 'role': self.role,
147
+ 'importance': self.importance,
148
+ 'font_family': self.font_family,
149
+ 'font_size': self.font_size,
150
+ 'font_weight': self.font_weight,
151
+ 'color_type': self.color_type,
152
+ 'color_value': self.color_value,
153
+ 'text_transform': self.text_transform,
154
+ 'letter_spacing': self.letter_spacing,
155
+ 'font_style': self.font_style,
156
+ 'allow_wrap': self.allow_wrap,
157
+ 'underline': self.underline,
158
+ 'strikethrough': self.strikethrough,
159
+ 'outline': self.outline,
160
+ 'shadow': self.shadow,
161
+ 'background': self.background,
162
+ }
163
+
164
+ def to_json_dict(self):
165
+ """Convert to JSON format (simplified version, conforms to template_schema.json)"""
166
+ # Build font string: e.g. "Arial 68px bold" or "Arial 24px normal italic"
167
+ font_parts = [self.font_family, f"{self.font_size}px"]
168
+ if self.font_weight and self.font_weight != 'normal':
169
+ font_parts.append(self.font_weight)
170
+ if self.font_style and self.font_style != 'normal':
171
+ font_parts.append(self.font_style)
172
+ font_string = ' '.join(font_parts)
173
+
174
+ # Build color string: either hex color or variable name
175
+ if self.color_type == COLOR_TYPE_PRIMARY:
176
+ color_string = 'primary_color'
177
+ elif self.color_type == COLOR_TYPE_SECONDARY:
178
+ color_string = 'secondary_color'
179
+ else: # FIXED
180
+ color_string = self.color_value
181
+
182
+ # Build effects object (only include non-False effects)
183
+ effects = {}
184
+ if self.shadow:
185
+ effects['shadow'] = self.shadow
186
+ if self.outline:
187
+ effects['outline'] = self.outline
188
+ if self.underline:
189
+ effects['underline'] = self.underline
190
+ if self.strikethrough:
191
+ effects['strikethrough'] = self.strikethrough
192
+
193
+ result = {
194
+ 'font': font_string,
195
+ 'color': color_string,
196
+ }
197
+
198
+ # Only main role has importance
199
+ if self.role == 'main':
200
+ result['importance'] = self.importance or 'primary'
201
+
202
+ # Add optional fields
203
+ if self.text_transform and self.text_transform != 'none':
204
+ result['text_transform'] = self.text_transform
205
+ if self.letter_spacing and self.letter_spacing != 0:
206
+ result['letter_spacing'] = self.letter_spacing
207
+ if effects:
208
+ result['effects'] = effects
209
+
210
+ return result
211
+
212
+
213
+ class TitleTemplate:
214
+ """Title template class"""
215
+
216
+ def __init__(self, name, description, lines=None, alignment='center', style='normal',
217
+ color_mode='monochrome'):
218
+ """
219
+ Initialize template
220
+
221
+ Args:
222
+ name: Template name
223
+ description: Template description
224
+ lines: List of LineConfig objects (single column layout)
225
+ alignment: Alignment (left/center/right)
226
+ style: Template style ('normal', 'comic', 'simple', 'professional')
227
+ color_mode: Color mode ('monochrome' or 'duotone')
228
+ """
229
+ self.name = name
230
+ self.description = description
231
+ self.alignment = alignment
232
+ self.style = style
233
+ self.color_mode = color_mode
234
+
235
+ # Single column layout
236
+ self.lines = lines
237
+ self.columns = None # Single column mode
238
+
239
+ def has_main(self):
240
+ """Check if contains main title line"""
241
+ return any(line.role == 'main' for line in self.lines)
242
+
243
+ def has_description(self):
244
+ """Check if contains description line"""
245
+ return any(line.role == 'description' for line in self.lines)
246
+
247
+ def __repr__(self):
248
+ main_count = sum(1 for l in self.lines if l.role == 'main')
249
+ desc_count = sum(1 for l in self.lines if l.role == 'description')
250
+ return f"<TitleTemplate: {self.name} (main:{main_count}, desc:{desc_count}, {self.color_mode})>"
251
+
252
+
253
+ # Template cache (loaded from JSON)
254
+ _TEMPLATES_CACHE = None
255
+
256
+
257
+ def _parse_font_string(font_string: str) -> Dict:
258
+ """
259
+ Parse font string like "Arial 68px bold" or "Comic Sans MS 72px bold" into components
260
+
261
+ Returns:
262
+ {
263
+ 'font_family': 'Arial' or 'Comic Sans MS',
264
+ 'font_size': 68,
265
+ 'font_weight': 'bold',
266
+ 'font_style': 'normal'
267
+ }
268
+ """
269
+ parts = font_string.split()
270
+
271
+ # Find the part with "px" - that's the font size
272
+ size_index = -1
273
+ font_size = 0
274
+ for i, part in enumerate(parts):
275
+ if 'px' in part:
276
+ size_index = i
277
+ font_size = int(part.replace('px', ''))
278
+ break
279
+
280
+ if size_index == -1:
281
+ raise ValueError(f"Invalid font string: {font_string} - no size found")
282
+
283
+ # Everything before the size is the font family
284
+ font_family = ' '.join(parts[:size_index])
285
+
286
+ # Everything after the size is weight/style
287
+ font_weight = 'normal'
288
+ font_style = 'normal'
289
+
290
+ for part in parts[size_index + 1:]:
291
+ if part in ['bold', 'bolder', 'lighter', 'normal']:
292
+ font_weight = part
293
+ elif part in ['italic', 'oblique']:
294
+ font_style = part
295
+
296
+ return {
297
+ 'font_family': font_family,
298
+ 'font_size': font_size,
299
+ 'font_weight': font_weight,
300
+ 'font_style': font_style
301
+ }
302
+
303
+
304
+ def _parse_color_string(color_string: str) -> tuple:
305
+ """
306
+ Parse color string into (color_type, color_value)
307
+
308
+ Args:
309
+ color_string: "#000000", "primary_color", or "secondary_color"
310
+
311
+ Returns:
312
+ (COLOR_TYPE_*, color_value)
313
+ """
314
+ if color_string == 'primary_color':
315
+ return (COLOR_TYPE_PRIMARY, None)
316
+ elif color_string == 'secondary_color':
317
+ return (COLOR_TYPE_SECONDARY, None)
318
+ else:
319
+ return (COLOR_TYPE_FIXED, color_string)
320
+
321
+
322
+ def _load_templates_from_json(json_file='templates.json') -> List[TitleTemplate]:
323
+ """
324
+ Load templates from JSON configuration file
325
+
326
+ Args:
327
+ json_file: Path to templates JSON file
328
+
329
+ Returns:
330
+ List of TitleTemplate objects
331
+ """
332
+ json_path = os.path.join(os.path.dirname(__file__), json_file)
333
+
334
+ if not os.path.exists(json_path):
335
+ raise FileNotFoundError(f"Templates file not found: {json_path}")
336
+
337
+ with open(json_path, 'r', encoding='utf-8') as f:
338
+ data = json.load(f)
339
+
340
+ templates = []
341
+
342
+ for template_dict in data.get('templates', []):
343
+ name = template_dict['name']
344
+ description = template_dict.get('description', '')
345
+ style = template_dict.get('style', 'normal')
346
+ color_mode = template_dict.get('color_mode', 'monochrome')
347
+ layout_type = template_dict['layout_type']
348
+ alignment = template_dict.get('alignment', 'center')
349
+ parts = template_dict['parts']
350
+
351
+ if layout_type == 'single_column':
352
+ # Single column layout
353
+ lines = []
354
+
355
+ # Parse main_title segments (supports nested arrays for inline groups)
356
+ main_title = parts.get('main_title', {})
357
+ segments_raw = main_title.get('segments', [])
358
+
359
+ # Flatten segments and track inline groups
360
+ # Format: [seg1, [seg2, seg3], seg4] where array means inline
361
+ inline_groups = [] # List of (start_idx, end_idx) for inline groups
362
+ flat_segments = []
363
+ current_idx = 0
364
+
365
+ for item in segments_raw:
366
+ if isinstance(item, list):
367
+ # Inline group
368
+ group_start = current_idx
369
+ for seg in item:
370
+ flat_segments.append(seg)
371
+ current_idx += 1
372
+ inline_groups.append((group_start, current_idx - 1))
373
+ else:
374
+ # Single segment
375
+ flat_segments.append(item)
376
+ current_idx += 1
377
+
378
+ for segment in flat_segments:
379
+ # Regular text segment
380
+ font_info = _parse_font_string(segment['font'])
381
+ color_type, color_value = _parse_color_string(segment['color'])
382
+
383
+ # Get background from either 'background' or 'effects.background'
384
+ background = segment.get('background', False)
385
+ if not background and 'effects' in segment:
386
+ background = segment['effects'].get('background', False)
387
+
388
+ # Determine allow_wrap: if has background, force to False; otherwise use segment's setting
389
+ if background:
390
+ allow_wrap = False
391
+ else:
392
+ allow_wrap = segment.get('allow_wrap', False)
393
+
394
+ line = LineConfig(
395
+ role='main',
396
+ importance=segment.get('importance', 'primary'),
397
+ font_family=font_info['font_family'],
398
+ font_size=font_info['font_size'],
399
+ font_weight=font_info['font_weight'],
400
+ font_style=font_info['font_style'],
401
+ color_type=color_type,
402
+ color_value=color_value,
403
+ text_transform=segment.get('text_transform', 'none'),
404
+ letter_spacing=segment.get('letter_spacing', 0),
405
+ allow_wrap=allow_wrap,
406
+ shadow=segment.get('effects', {}).get('shadow', False) if 'effects' in segment else False,
407
+ outline=segment.get('effects', {}).get('outline', False) if 'effects' in segment else False,
408
+ underline=segment.get('effects', {}).get('underline', False) if 'effects' in segment else False,
409
+ strikethrough=segment.get('effects', {}).get('strikethrough', False) if 'effects' in segment else False,
410
+ background=background,
411
+ )
412
+ lines.append(line)
413
+
414
+ # Parse description
415
+ if 'description' in parts:
416
+ desc = parts['description']
417
+ font_info = _parse_font_string(desc['font'])
418
+ color_type, color_value = _parse_color_string(desc['color'])
419
+
420
+ line = LineConfig(
421
+ role='description',
422
+ font_family=font_info['font_family'],
423
+ font_size=font_info['font_size'],
424
+ font_weight=font_info['font_weight'],
425
+ font_style=font_info['font_style'],
426
+ color_type=color_type,
427
+ color_value=color_value,
428
+ allow_wrap=desc.get('allow_wrap', True),
429
+ text_transform=desc.get('text_transform', 'none'),
430
+ letter_spacing=desc.get('letter_spacing', 0),
431
+ )
432
+ lines.append(line)
433
+
434
+ template = TitleTemplate(
435
+ name=name,
436
+ description=description,
437
+ lines=lines,
438
+ alignment=alignment,
439
+ style=style,
440
+ color_mode=color_mode
441
+ )
442
+ # Store inline groups as an attribute
443
+ template.inline_groups = inline_groups # List of (start_idx, end_idx)
444
+ templates.append(template)
445
+
446
+ return templates
447
+
448
+
449
+ def get_all_templates() -> List[TitleTemplate]:
450
+ """Get all templates (loaded from JSON)"""
451
+ global _TEMPLATES_CACHE
452
+
453
+ if _TEMPLATES_CACHE is None:
454
+ _TEMPLATES_CACHE = _load_templates_from_json()
455
+
456
+ return _TEMPLATES_CACHE
457
+
458
+
459
+ def reload_templates():
460
+ """Reload templates from JSON file"""
461
+ global _TEMPLATES_CACHE
462
+ _TEMPLATES_CACHE = None
463
+ return get_all_templates()
464
+
465
+
466
+ def get_templates_by_alignment(alignment: str) -> List[TitleTemplate]:
467
+ """Get templates by alignment"""
468
+ return [t for t in get_all_templates() if t.alignment == alignment]
469
+
470
+
471
+ def get_templates_with_description() -> List[TitleTemplate]:
472
+ """Get templates with description"""
473
+ return [t for t in get_all_templates() if t.has_description()]
474
+
475
+
476
+ def get_templates_main_only() -> List[TitleTemplate]:
477
+ """Get templates with main title only"""
478
+ return [t for t in get_all_templates() if not t.has_description()]
479
+
480
+
481
+ def get_templates_by_style(style: str) -> List[TitleTemplate]:
482
+ """
483
+ Get templates by style
484
+
485
+ Args:
486
+ style: Template style ('normal', 'comic', 'simple', 'professional', 'all')
487
+ 'all' means return all templates regardless of style
488
+
489
+ Returns:
490
+ List of templates matching the style
491
+ """
492
+ if style == 'all':
493
+ return get_all_templates()
494
+ return [t for t in get_all_templates() if t.style == style]
modules/title_styler/test_cases.py ADDED
@@ -0,0 +1,607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script for specific problematic SVG cases
3
+ Tests the following cases that were reported to have issues:
4
+ 1. tourism_cities_3_financial_centers.svg
5
+ 2. tech_companies_1_rounded_label_big_title.svg
6
+ 3. renewable_power_3_inline_contrast_triple.svg
7
+ 4. life_expectancy_2_comic_bouncy_emphasis.svg
8
+ 5. life_expectancy_1_stacked_badge_headline.svg
9
+ 6. europe_cars_2_comic_bouncy_emphasis.svg
10
+ 7. education_cost_1_comic_bold_announcement.svg
11
+ 8. arctic_ice_decline_1_highlight_stripe.svg
12
+ """
13
+
14
+ from modules.title_styler.infographic_title_generator import generate_title
15
+
16
+ # Problematic cases with their corresponding test data and templates
17
+ # Each case includes manually segmented title text to match template segments
18
+ PROBLEMATIC_CASES = [
19
+ {
20
+ "test_id": "tourism_cities",
21
+ # financial_centers: 3 segments (black + primary bg + black)
22
+ "title_segments": "Top 10 Most\nVisited Cities\nin 2024",
23
+ "description": "Annual tourism statistics reveal which urban destinations attracted the most international visitors last year",
24
+ "primary_color": "#E67E22",
25
+ "background_color": "#FFFFFF",
26
+ "template_name": "financial_centers",
27
+ "output_file": "tourism_cities_3_financial_centers.svg"
28
+ },
29
+ {
30
+ "test_id": "tech_companies",
31
+ # rounded_label_big_title: 2 segments (label badge + headline)
32
+ "title_segments": "TECH\nAmerica's Largest Technology Companies",
33
+ "description": "Market capitalization comparison of leading tech firms headquartered in the United States",
34
+ "primary_color": "#8E44AD",
35
+ "background_color": "#F5F5F5",
36
+ "template_name": "rounded_label_big_title",
37
+ "output_file": "tech_companies_1_rounded_label_big_title.svg"
38
+ },
39
+ {
40
+ "test_id": "renewable_power",
41
+ # inline_contrast_triple: 3 inline segments
42
+ "title_segments": "Renewable Energy\nShare\nof Total Electricity Generation",
43
+ "description": "Tracking the percentage of power generated from solar, wind, hydro, and other renewable sources by country",
44
+ "primary_color": "#16A085",
45
+ "background_color": "#ECF0F1",
46
+ "template_name": "inline_contrast_triple",
47
+ "output_file": "renewable_power_3_inline_contrast_triple.svg"
48
+ },
49
+ {
50
+ "test_id": "life_expectancy",
51
+ # comic_bouncy_emphasis: 3 inline segments
52
+ "title_segments": "Average\nLife Expectancy\nGains Since 1950",
53
+ "description": "How longevity has improved across different regions over seven decades of global development",
54
+ "primary_color": "#9B59B6",
55
+ "background_color": "#FFFFFF",
56
+ "template_name": "comic_bouncy_emphasis",
57
+ "output_file": "life_expectancy_2_comic_bouncy_emphasis.svg"
58
+ },
59
+ {
60
+ "test_id": "life_expectancy",
61
+ # stacked_badge_headline: 3 segments (badge + headline + tagline)
62
+ "title_segments": "LONGEVITY\nLife Expectancy Gains\nSince 1950",
63
+ "description": "How longevity has improved across different regions over seven decades of global development",
64
+ "primary_color": "#9B59B6",
65
+ "background_color": "#FFFFFF",
66
+ "template_name": "stacked_badge_headline",
67
+ "output_file": "life_expectancy_1_stacked_badge_headline.svg"
68
+ },
69
+ {
70
+ "test_id": "europe_cars",
71
+ # comic_bouncy_emphasis: 3 inline segments
72
+ "title_segments": "Europe's\nBest-Selling\nCar Brands in H1 2025",
73
+ "description": "This visualization shows the best-selling car brands in Europe",
74
+ "primary_color": "#1E5BBD",
75
+ "background_color": "#E8E8E8",
76
+ "template_name": "comic_bouncy_emphasis",
77
+ "output_file": "europe_cars_2_comic_bouncy_emphasis.svg"
78
+ },
79
+ {
80
+ "test_id": "education_cost",
81
+ # comic_bold_announcement: 1 segment (single huge title)
82
+ "title_segments": "The Rising Cost of Higher Education",
83
+ "description": "How tuition fees have increased across different types of universities over the past two decades",
84
+ "primary_color": "#C0392B",
85
+ "background_color": "#F9F9F9",
86
+ "template_name": "comic_bold_announcement",
87
+ "output_file": "education_cost_1_comic_bold_announcement.svg"
88
+ },
89
+ {
90
+ "test_id": "arctic_ice_decline",
91
+ # highlight_stripe: 2 segments (lead-in + highlighted core)
92
+ "title_segments": "The Dramatic Decline of\nArctic Sea Ice Coverage Over the Past Four Decades",
93
+ "description": "Satellite imagery and climate data reveal unprecedented melting patterns affecting polar ecosystems and global weather",
94
+ "primary_color": "#2C3E50",
95
+ "background_color": "#FFFFFF",
96
+ "template_name": "highlight_stripe",
97
+ "output_file": "arctic_ice_decline_1_highlight_stripe.svg"
98
+ }
99
+ ]
100
+
101
+
102
+ # Comprehensive test cases for all 48 templates
103
+ FULL_TEMPLATE_TEST_CASES = [
104
+ {
105
+ "test_id": "three_line_emphasis",
106
+ "title_segments": "Average\nLife Expectancy\nGains Since 1950",
107
+ "description": "How longevity has improved across different regions over seven decades",
108
+ "primary_color": "#E67E22",
109
+ "background_color": "#FFFFFF",
110
+ "template_name": "three_line_emphasis",
111
+ "output_file": "three_line_emphasis_test.svg"
112
+ },
113
+ {
114
+ "test_id": "two_line_hierarchy",
115
+ "title_segments": "The Evolution of\nArtificial Intelligence",
116
+ "description": "From early neural networks to modern large language models",
117
+ "primary_color": "#F5F5F5",
118
+ "background_color": "#8E44AD",
119
+ "template_name": "two_line_hierarchy",
120
+ "output_file": "two_line_hierarchy_test.svg"
121
+ },
122
+ {
123
+ "test_id": "lithium_countries",
124
+ "title_segments": "World's Largest\nLithium Producers",
125
+ "description": "Countries leading the global lithium extraction industry",
126
+ "primary_color": "#16A085",
127
+ "background_color": "#ECF0F1",
128
+ "template_name": "lithium_countries",
129
+ "output_file": "lithium_countries_test.svg"
130
+ },
131
+ {
132
+ "test_id": "european_adults",
133
+ "title_segments": "Among European Adults\nSmoking Rates Decline",
134
+ "description": "Public health campaigns and regulations drive behavioral change",
135
+ "primary_color": "#9B59B6",
136
+ "background_color": "#FFFFFF",
137
+ "template_name": "european_adults",
138
+ "output_file": "european_adults_test.svg"
139
+ },
140
+ {
141
+ "test_id": "millionaires_green",
142
+ "title_segments": "Number of\nMillionaires by Country",
143
+ "description": "Wealth distribution across major economies worldwide",
144
+ "primary_color": "#1E5BBD",
145
+ "background_color": "#E8E8E8",
146
+ "template_name": "millionaires_green",
147
+ "output_file": "millionaires_green_test.svg"
148
+ },
149
+ {
150
+ "test_id": "america_valuable",
151
+ "title_segments": "America's\nMost Valuable\nTech\nCompanies",
152
+ "description": "Market capitalization of leading technology firms",
153
+ "primary_color": "#C0392B",
154
+ "background_color": "#F9F9F9",
155
+ "template_name": "america_valuable",
156
+ "output_file": "america_valuable_test.svg"
157
+ },
158
+ {
159
+ "test_id": "dentist_question",
160
+ "title_segments": "How Many People\nVisit the Dentist Annually?",
161
+ "description": "Dental care access and frequency across different demographics",
162
+ "primary_color": "#2C3E50",
163
+ "background_color": "#FFFFFF",
164
+ "template_name": "dentist_question",
165
+ "output_file": "dentist_question_test.svg"
166
+ },
167
+ {
168
+ "test_id": "infant_mortality",
169
+ "title_segments": "HEALTH\nInfant Mortality Rates by Region",
170
+ "description": "Global health indicators tracking child survival rates",
171
+ "primary_color": "#FF00FF",
172
+ "background_color": "#333333",
173
+ "template_name": "infant_mortality",
174
+ "output_file": "infant_mortality_test.svg"
175
+ },
176
+ {
177
+ "test_id": "financial_centers",
178
+ "title_segments": "Top 10\nGlobal Financial Centers\nRanked by GDP",
179
+ "description": "Economic powerhouses driving international finance",
180
+ "primary_color": "#E74C3C",
181
+ "background_color": "#F0F0F0",
182
+ "template_name": "financial_centers",
183
+ "output_file": "financial_centers_test.svg"
184
+ },
185
+ {
186
+ "test_id": "innovative_countries",
187
+ "title_segments": "Innovation Index\nMost Innovative Countries\nand Their Scores",
188
+ "description": "R&D investment and patent output rankings",
189
+ "primary_color": "#3498DB",
190
+ "background_color": "#FFFFFF",
191
+ "template_name": "innovative_countries",
192
+ "output_file": "innovative_countries_test.svg"
193
+ },
194
+ {
195
+ "test_id": "americas_wealth",
196
+ "title_segments": "Total Household Wealth in North America",
197
+ "description": "Combined assets of families across the United States and Canada",
198
+ "primary_color": "#E67E22",
199
+ "background_color": "#2C3E50",
200
+ "template_name": "americas_wealth",
201
+ "output_file": "americas_wealth_test.svg"
202
+ },
203
+ {
204
+ "test_id": "china_dominance",
205
+ "title_segments": "China's\nGrowing\nEconomic Influence",
206
+ "description": "Trade relationships and investment patterns worldwide",
207
+ "primary_color": "#16A085",
208
+ "background_color": "#ECF0F1",
209
+ "template_name": "china_dominance",
210
+ "output_file": "china_dominance_test.svg"
211
+ },
212
+ {
213
+ "test_id": "magazine_cover",
214
+ "title_segments": "FEATURE STORY\nThe Future of Space Exploration\nNew Frontiers Ahead",
215
+ "description": "Innovations in rocket technology and planetary missions",
216
+ "primary_color": "#9B59B6",
217
+ "background_color": "#FFFFFF",
218
+ "template_name": "magazine_cover",
219
+ "output_file": "magazine_cover_test.svg"
220
+ },
221
+ {
222
+ "test_id": "newspaper_headline",
223
+ "title_segments": "Climate Agreement Reached at International Summit",
224
+ "description": "World leaders commit to ambitious carbon reduction targets",
225
+ "primary_color": "#1E5BBD",
226
+ "background_color": "#E8E8E8",
227
+ "template_name": "newspaper_headline",
228
+ "output_file": "newspaper_headline_test.svg"
229
+ },
230
+ {
231
+ "test_id": "elegant_serif",
232
+ "title_segments": "The Philosophy of\nSustainable Development",
233
+ "description": "Balancing economic growth with environmental preservation",
234
+ "primary_color": "#C0392B",
235
+ "background_color": "#F9F9F9",
236
+ "template_name": "elegant_serif",
237
+ "output_file": "elegant_serif_test.svg"
238
+ },
239
+ {
240
+ "test_id": "data_report",
241
+ "title_segments": "Q3 2024\n+18% Revenue Growth",
242
+ "description": "Quarterly financial performance exceeds analyst expectations",
243
+ "primary_color": "#2C3E50",
244
+ "background_color": "#FFFFFF",
245
+ "template_name": "data_report",
246
+ "output_file": "data_report_test.svg"
247
+ },
248
+ {
249
+ "test_id": "corporate_brand",
250
+ "title_segments": "INNOVATION THROUGH COLLABORATION",
251
+ "description": "Our commitment to advancing technology for everyone",
252
+ "primary_color": "#27AE60",
253
+ "background_color": "#FAFAFA",
254
+ "template_name": "corporate_brand",
255
+ "output_file": "corporate_brand_test.svg"
256
+ },
257
+ {
258
+ "test_id": "corporate_brand_desc_gap",
259
+ "title_segments": "Full-Day Kindergarten Outperforms Half-Day on Reading Scores",
260
+ "description": "ReadingScores for Half-Day and Full-Day kindergarten students (from 2015 to 2023) consistently indicate higher performance in Full-Day.",
261
+ "primary_color": "#f8dd3d",
262
+ "background_color": "#E2F1F6",
263
+ "template_name": "corporate_brand",
264
+ "output_file": "corporate_brand_desc_gap_test.svg"
265
+ },
266
+ {
267
+ "test_id": "academic_paper",
268
+ "title_segments": "Machine Learning Applications\nin Climate Science",
269
+ "description": "Improving weather prediction models with AI techniques",
270
+ "primary_color": "#E74C3C",
271
+ "background_color": "#F0F0F0",
272
+ "template_name": "academic_paper",
273
+ "output_file": "academic_paper_test.svg"
274
+ },
275
+ {
276
+ "test_id": "tech_startup",
277
+ "title_segments": "Move Fast\nBreak Barriers",
278
+ "description": "Our mission to disrupt traditional industries",
279
+ "primary_color": "#3498DB",
280
+ "background_color": "#FFFFFF",
281
+ "template_name": "tech_startup",
282
+ "output_file": "tech_startup_test.svg"
283
+ },
284
+ {
285
+ "test_id": "poster_impact",
286
+ "title_segments": "JOIN THE REVOLUTION\nTogether We Rise",
287
+ "description": "Community-driven social change movement",
288
+ "primary_color": "#E67E22",
289
+ "background_color": "#FFFFFF",
290
+ "template_name": "poster_impact",
291
+ "output_file": "poster_impact_test.svg"
292
+ },
293
+ {
294
+ "test_id": "vintage_style",
295
+ "title_segments": "Est. 1887\nHeritage Coffee Co.\nAuthentic Roasters",
296
+ "description": "Traditional craftsmanship meets modern taste",
297
+ "primary_color": "#8E44AD",
298
+ "background_color": "#F5F5F5",
299
+ "template_name": "vintage_style",
300
+ "output_file": "vintage_style_test.svg"
301
+ },
302
+ {
303
+ "test_id": "europe_brands",
304
+ "title_segments": "EUROPE\nBest-Selling Car Brands in 2024",
305
+ "description": "Consumer preferences across major European markets",
306
+ "primary_color": "#16A085",
307
+ "background_color": "#ECF0F1",
308
+ "template_name": "europe_brands",
309
+ "output_file": "europe_brands_test.svg"
310
+ },
311
+ {
312
+ "test_id": "international_students",
313
+ "title_segments": "Asia's\nTop Universities\nfor International Students",
314
+ "description": "Rankings based on diversity and academic excellence",
315
+ "primary_color": "#9B59B6",
316
+ "background_color": "#FFFFFF",
317
+ "template_name": "international_students",
318
+ "output_file": "international_students_test.svg"
319
+ },
320
+ {
321
+ "test_id": "kpi_number_pill",
322
+ "title_segments": "QUARTERLY REVENUE\n$2.4 Billion",
323
+ "description": "Record-breaking sales driven by product innovation",
324
+ "primary_color": "#1E5BBD",
325
+ "background_color": "#E8E8E8",
326
+ "template_name": "kpi_number_pill",
327
+ "output_file": "kpi_number_pill_test.svg"
328
+ },
329
+ {
330
+ "test_id": "timeline_year_span",
331
+ "title_segments": "Looking Back at\n1990-2020\nTechnology Evolution",
332
+ "description": "Three decades of digital transformation",
333
+ "primary_color": "#C0392B",
334
+ "background_color": "#F9F9F9",
335
+ "template_name": "timeline_year_span",
336
+ "output_file": "timeline_year_span_test.svg"
337
+ },
338
+ {
339
+ "test_id": "stacked_badge_headline",
340
+ "title_segments": "SUSTAINABILITY\nCarbon Emissions Reduction\nSince 2010",
341
+ "description": "Progress toward net-zero goals across industries",
342
+ "primary_color": "#2C3E50",
343
+ "background_color": "#FFFFFF",
344
+ "template_name": "stacked_badge_headline",
345
+ "output_file": "stacked_badge_headline_test.svg"
346
+ },
347
+ {
348
+ "test_id": "right_align_elegant_duo",
349
+ "title_segments": "Across Major Cities\nUrban Population Density",
350
+ "description": "Comparing metropolitan areas worldwide",
351
+ "primary_color": "#27AE60",
352
+ "background_color": "#FAFAFA",
353
+ "template_name": "right_align_elegant_duo",
354
+ "output_file": "right_align_elegant_duo_test.svg"
355
+ },
356
+ {
357
+ "test_id": "inline_contrast_triple",
358
+ "title_segments": "The Rise of\nElectric Vehicles\nin Global Markets",
359
+ "description": "Adoption rates accelerate with policy support",
360
+ "primary_color": "#E74C3C",
361
+ "background_color": "#F0F0F0",
362
+ "template_name": "inline_contrast_triple",
363
+ "output_file": "inline_contrast_triple_test.svg"
364
+ },
365
+ {
366
+ "test_id": "split_colon_emphasis",
367
+ "title_segments": "INNOVATION REPORT:\nBreakthrough Technologies of 2024",
368
+ "description": "Scientific advances transforming daily life",
369
+ "primary_color": "#3498DB",
370
+ "background_color": "#FFFFFF",
371
+ "template_name": "split_colon_emphasis",
372
+ "output_file": "split_colon_emphasis_test.svg"
373
+ },
374
+ {
375
+ "test_id": "minimal_right_caption",
376
+ "title_segments": "Global Trade Volumes",
377
+ "description": "International commerce statistics",
378
+ "primary_color": "#E67E22",
379
+ "background_color": "#FFFFFF",
380
+ "template_name": "minimal_right_caption",
381
+ "output_file": "minimal_right_caption_test.svg"
382
+ },
383
+ {
384
+ "test_id": "two_tone_upper",
385
+ "title_segments": "MARKET ANALYSIS\nCRYPTOCURRENCY TRENDS",
386
+ "description": "Digital asset valuations and regulatory developments",
387
+ "primary_color": "#8E44AD",
388
+ "background_color": "#F5F5F5",
389
+ "template_name": "two_tone_upper",
390
+ "output_file": "two_tone_upper_test.svg"
391
+ },
392
+ {
393
+ "test_id": "rounded_label_big_title",
394
+ "title_segments": "TECH\nArtificial Intelligence Market Growth",
395
+ "description": "Industry valuation surpasses $500 billion",
396
+ "primary_color": "#16A085",
397
+ "background_color": "#ECF0F1",
398
+ "template_name": "rounded_label_big_title",
399
+ "output_file": "rounded_label_big_title_test.svg"
400
+ },
401
+ {
402
+ "test_id": "underlined_statement",
403
+ "title_segments": "The Future Belongs to Renewable Energy",
404
+ "description": "Sustainable power generation becomes economically dominant",
405
+ "primary_color": "#9B59B6",
406
+ "background_color": "#FFFFFF",
407
+ "template_name": "underlined_statement",
408
+ "output_file": "underlined_statement_test.svg"
409
+ },
410
+ {
411
+ "test_id": "soft_shadow_duo",
412
+ "title_segments": "Throughout History\nRevolutionary Inventions",
413
+ "description": "Innovations that changed human civilization",
414
+ "primary_color": "#1E5BBD",
415
+ "background_color": "#E8E8E8",
416
+ "template_name": "soft_shadow_duo",
417
+ "output_file": "soft_shadow_duo_test.svg"
418
+ },
419
+ {
420
+ "test_id": "comic_fun_title",
421
+ "title_segments": "Super Amazing Facts About Space!",
422
+ "description": "Mind-blowing discoveries about our universe",
423
+ "primary_color": "#C0392B",
424
+ "background_color": "#F9F9F9",
425
+ "template_name": "comic_fun_title",
426
+ "output_file": "comic_fun_title_test.svg"
427
+ },
428
+ {
429
+ "test_id": "comic_bubble_speech",
430
+ "title_segments": "Guess What?\nPandas Eat 40kg Bamboo Daily!",
431
+ "description": "Incredible animal eating habits revealed",
432
+ "primary_color": "#2C3E50",
433
+ "background_color": "#FFFFFF",
434
+ "template_name": "comic_bubble_speech",
435
+ "output_file": "comic_bubble_speech_test.svg"
436
+ },
437
+ {
438
+ "test_id": "comic_bouncy_emphasis",
439
+ "title_segments": "World's\nCoolest\nGadgets of 2024",
440
+ "description": "The most exciting tech toys released this year",
441
+ "primary_color": "#E74C3C",
442
+ "background_color": "#F0F0F0",
443
+ "template_name": "comic_bouncy_emphasis",
444
+ "output_file": "comic_bouncy_emphasis_test.svg"
445
+ },
446
+ {
447
+ "test_id": "comic_playful_label",
448
+ "title_segments": "FUN FACTS\nOceans Cover 71% of Earth!",
449
+ "description": "Amazing things you never knew about our planet",
450
+ "primary_color": "#3498DB",
451
+ "background_color": "#FFFFFF",
452
+ "template_name": "comic_playful_label",
453
+ "output_file": "comic_playful_label_test.svg"
454
+ },
455
+ {
456
+ "test_id": "comic_question_fun",
457
+ "title_segments": "Did You Know?\nBananas Are Berries!",
458
+ "description": "Surprising botanical classification facts",
459
+ "primary_color": "#E67E22",
460
+ "background_color": "#FFFFFF",
461
+ "template_name": "comic_question_fun",
462
+ "output_file": "comic_question_fun_test.svg"
463
+ },
464
+ {
465
+ "test_id": "comic_bold_announcement",
466
+ "title_segments": "The Most Incredible Animal Superpowers!",
467
+ "description": "Nature's amazing adaptations and abilities",
468
+ "primary_color": "#8E44AD",
469
+ "background_color": "#F5F5F5",
470
+ "template_name": "comic_bold_announcement",
471
+ "output_file": "comic_bold_announcement_test.svg"
472
+ },
473
+ {
474
+ "test_id": "comic_wavy_text",
475
+ "title_segments": "Get Ready for\nAdventure Time!",
476
+ "description": "Exciting outdoor activities for families",
477
+ "primary_color": "#9B59B6",
478
+ "background_color": "#FFFFFF",
479
+ "template_name": "comic_wavy_text",
480
+ "output_file": "comic_wavy_text_test.svg"
481
+ },
482
+ {
483
+ "test_id": "comic_mega_title",
484
+ "title_segments": "Ultimate Gaming Champions Tournament!",
485
+ "description": "Epic esports competition draws global audience",
486
+ "primary_color": "#2C3E50",
487
+ "background_color": "#FFFFFF",
488
+ "template_name": "comic_mega_title",
489
+ "output_file": "comic_mega_title_test.svg"
490
+ },
491
+ {
492
+ "test_id": "comic_sticker_style",
493
+ "title_segments": "AWESOME\nSpace Mission Success!",
494
+ "description": "Satellite deployment achieves perfect orbit",
495
+ "primary_color": "#27AE60",
496
+ "background_color": "#FAFAFA",
497
+ "template_name": "comic_sticker_style",
498
+ "output_file": "comic_sticker_style_test.svg"
499
+ },
500
+ ]
501
+
502
+
503
+ def test_single_case(test_case):
504
+ """
505
+ Test a single problematic case
506
+
507
+ Args:
508
+ test_case: Dictionary containing title_segments, description, colors, template_name, etc.
509
+
510
+ Returns:
511
+ Result of the generation
512
+ """
513
+ print("\n" + "=" * 70)
514
+ print(f"Testing: {test_case['output_file']}")
515
+ print("=" * 70)
516
+ print(f"Title segments: {test_case['title_segments']}")
517
+ if len(test_case['description']) > 60:
518
+ print(f"Description: {test_case['description'][:60]}...")
519
+ else:
520
+ print(f"Description: {test_case['description']}")
521
+ print(f"Template: {test_case['template_name']}")
522
+ print(f"Primary Color: {test_case['primary_color']}")
523
+ print(f"Background Color: {test_case['background_color']}")
524
+ print("=" * 70)
525
+
526
+ try:
527
+ results = generate_title(
528
+ title=test_case['title_segments'], # Pass list of segments
529
+ description=test_case['description'],
530
+ primary_color=test_case['primary_color'],
531
+ background_color=test_case['background_color'],
532
+ template_name=test_case['template_name'],
533
+ max_width=900,
534
+ use_llm=False, # Use specified template directly
535
+ save_to=f"output/{test_case['test_id']}"
536
+ )
537
+
538
+ if results:
539
+ result = results[0]
540
+ print(f"✅ Successfully generated:")
541
+ print(f" Template: {result['template_name']}")
542
+ print(f" Size: {result['width']:.0f}px × {result['height']:.0f}px")
543
+ print(f" Saved as: output/{test_case['output_file']}")
544
+ return True
545
+ else:
546
+ print(f"❌ Failed to generate")
547
+ return False
548
+
549
+ except Exception as e:
550
+ print(f"❌ Error: {e}")
551
+ import traceback
552
+ traceback.print_exc()
553
+ return False
554
+
555
+
556
+ if __name__ == "__main__":
557
+ import sys
558
+
559
+ # Determine which test suite to run
560
+ if len(sys.argv) > 1 and sys.argv[1] == '--all':
561
+ test_suite = FULL_TEMPLATE_TEST_CASES
562
+ suite_name = "ALL TEMPLATES"
563
+ elif len(sys.argv) > 1 and sys.argv[1] == '--problematic':
564
+ test_suite = PROBLEMATIC_CASES
565
+ suite_name = "PROBLEMATIC CASES"
566
+ else:
567
+ # Default: run full template tests
568
+ test_suite = FULL_TEMPLATE_TEST_CASES
569
+ suite_name = "ALL TEMPLATES (DEFAULT)"
570
+ print("💡 Tip: Use '--problematic' to run only problematic cases")
571
+ print("💡 Tip: Use '--all' to explicitly run all template tests")
572
+ print()
573
+
574
+ print("=" * 70)
575
+ print(f"TESTING {suite_name}")
576
+ print(f"Total cases to test: {len(test_suite)}")
577
+ print("=" * 70)
578
+
579
+ success_count = 0
580
+ failed_count = 0
581
+
582
+ for i, test_case in enumerate(test_suite, 1):
583
+ print(f"\n[{i}/{len(test_suite)}]")
584
+
585
+ if test_single_case(test_case):
586
+ success_count += 1
587
+ else:
588
+ failed_count += 1
589
+ print(f"Failed: {test_case['output_file']}")
590
+
591
+ # Summary
592
+ print("\n" + "=" * 70)
593
+ print("TEST SUMMARY")
594
+ print("=" * 70)
595
+ print(f"Total test cases: {len(test_suite)}")
596
+ print(f"✅ Successful: {success_count}")
597
+ print(f"❌ Failed: {failed_count}")
598
+ print("=" * 70)
599
+
600
+ if failed_count > 0:
601
+ print("\n⚠️ Some tests failed. Please review the errors above.")
602
+ else:
603
+ print("\n✅ All tests passed!")
604
+
605
+ print("=" * 70)
606
+
607
+
modules/title_styler/test_new_comics.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 测试新增的Comic模板
4
+ """
5
+
6
+ from templates import get_templates_by_style
7
+
8
+ def test_new_comic_templates():
9
+ """验证新增的comic模板"""
10
+
11
+ print("="*60)
12
+ print("新Comic模板验证")
13
+ print("="*60)
14
+
15
+ # 获取所有comic模板
16
+ comic_templates = get_templates_by_style('comic')
17
+
18
+ print(f"\n总共有 {len(comic_templates)} 个Comic模板\n")
19
+
20
+ # 新增的模板名称
21
+ new_templates = [
22
+ "comic_bold_announcement",
23
+ "comic_three_part_fun",
24
+ "comic_wavy_text",
25
+ "comic_explosion_box",
26
+ "comic_dotted_fun",
27
+ "comic_mega_title",
28
+ "comic_sticker_style"
29
+ ]
30
+
31
+ print("新增的Comic模板:")
32
+ for i, name in enumerate(new_templates, 1):
33
+ # 查找模板
34
+ template = None
35
+ for t in comic_templates:
36
+ if t.name == name:
37
+ template = t
38
+ break
39
+
40
+ if template:
41
+ print(f"\n{i}. ✅ {name}")
42
+ print(f" 描述: {template.description}")
43
+ print(f" 对齐: {template.alignment}")
44
+
45
+ # 统计字体大小
46
+ if template.lines:
47
+ fonts = []
48
+ for line in template.lines:
49
+ if hasattr(line, 'font_size'):
50
+ fonts.append(f"{line.font_size}px")
51
+ if fonts:
52
+ print(f" 字体大小: {', '.join(fonts)}")
53
+ else:
54
+ print(f"\n{i}. ❌ {name} - 未找到!")
55
+
56
+ print("\n" + "="*60)
57
+ print("所有Comic模板列表:")
58
+ print("="*60)
59
+ for i, template in enumerate(comic_templates, 1):
60
+ is_new = "🆕" if template.name in new_templates else " "
61
+ print(f"{is_new} {i:2d}. {template.name}")
62
+
63
+ print("\n" + "="*60)
64
+ print(f"验证完成!共 {len(comic_templates)} 个Comic模板")
65
+ print(f"其中新增 {len([t for t in comic_templates if t.name in new_templates])} 个")
66
+ print("="*60)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ test_new_comic_templates()
71
+
modules/title_styler/test_new_title.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script for process() function
3
+ Tests with various titles to validate the title processor
4
+ """
5
+
6
+ from title_processor import process
7
+
8
+ # Test cases
9
+ TEST_CASES = [
10
+ # Comic style test cases
11
+ {
12
+ "test_id": "comic_discovery",
13
+ "input_data": {
14
+ "title": "Amazing Discovery in Science!",
15
+ "subtitle": "Breakthrough research findings that will change everything",
16
+ "primary_color": "#E74C3C",
17
+ "background_color": "#FFFFFF"
18
+ },
19
+ "style": "Comics",
20
+ "max_width": 800
21
+ },
22
+ {
23
+ "test_id": "comic_fun_facts",
24
+ "input_data": {
25
+ "title": "10 Fun Facts About Space Exploration",
26
+ "subtitle": "Cool things you didn't know about the cosmos",
27
+ "primary_color": "#3498DB",
28
+ "background_color": "#F0F0F0"
29
+ },
30
+ "style": "Comics",
31
+ "max_width": 700
32
+ },
33
+
34
+ # Non-comic style test cases
35
+ {
36
+ "test_id": "professional_economy",
37
+ "input_data": {
38
+ "title": "Global Economic Growth Forecast for 2025",
39
+ "subtitle": "Analysis of GDP trends across major economies",
40
+ "primary_color": "#2E7D32",
41
+ "background_color": "#FFFFFF"
42
+ },
43
+ "style": "professional",
44
+ "max_width": 900
45
+ },
46
+ {
47
+ "test_id": "europe_cars",
48
+ "input_data": {
49
+ "title": "Europe's Best-Selling Car Brands in H1 2025",
50
+ "subtitle": "This visualization shows the best-selling car brands in Europe",
51
+ "primary_color": "#1E5BBD",
52
+ "background_color": "#E8E8E8"
53
+ },
54
+ "style": "normal",
55
+ "max_width": 850
56
+ },
57
+ {
58
+ "test_id": "quality_life",
59
+ "input_data": {
60
+ "title": "How Quality of Life Has Changed in 30 Countries",
61
+ "subtitle": "Citizens' perceptions of changes in their country's quality of life",
62
+ "primary_color": "#E74C3C",
63
+ "background_color": "#F5F5F5"
64
+ },
65
+ "style": "normal",
66
+ "max_width": 900
67
+ },
68
+ {
69
+ "test_id": "carbon_emissions",
70
+ "input_data": {
71
+ "title": "Global Carbon Emissions by Sector",
72
+ "subtitle": "Breaking down CO2 emissions from energy, transportation, industry, and agriculture",
73
+ "primary_color": "#27AE60",
74
+ "background_color": "#FFFFFF"
75
+ },
76
+ "style": "professional",
77
+ "max_width": 800
78
+ },
79
+ {
80
+ "test_id": "tech_companies",
81
+ "input_data": {
82
+ "title": "America's Largest Technology Companies",
83
+ "subtitle": "Market capitalization comparison of leading tech firms",
84
+ "primary_color": "#8E44AD",
85
+ "background_color": "#F5F5F5"
86
+ },
87
+ "style": "normal",
88
+ "max_width": 850
89
+ },
90
+ {
91
+ "test_id": "renewable_energy",
92
+ "input_data": {
93
+ "title": "Renewable Energy Share of Total Electricity Generation",
94
+ "subtitle": "Tracking solar, wind, hydro, and other renewable sources by country",
95
+ "primary_color": "#16A085",
96
+ "background_color": "#ECF0F1"
97
+ },
98
+ "style": "professional",
99
+ "max_width": 900
100
+ },
101
+ ]
102
+
103
+
104
+ def test_single_case(test_case):
105
+ """
106
+ Test process() function for a single case
107
+
108
+ Args:
109
+ test_case: Dictionary containing test_id, input_data, style, max_width
110
+
111
+ Returns:
112
+ True if successful, False otherwise
113
+ """
114
+ test_id = test_case['test_id']
115
+ input_data = test_case['input_data']
116
+ style = test_case['style']
117
+ max_width = test_case['max_width']
118
+ output_file = f"output/{test_id}_process_test.svg"
119
+
120
+ print(f"\n{'='*70}")
121
+ print(f"TEST: {test_id}")
122
+ print(f"{'='*70}")
123
+ print(f"Title: {input_data['title']}")
124
+ print(f"Style: {style}")
125
+ print(f"Max Width: {max_width}px")
126
+ print(f"{'='*70}")
127
+
128
+ try:
129
+ svg_content = process(
130
+ input_data=input_data,
131
+ output=output_file,
132
+ max_width=max_width,
133
+ text_align="center",
134
+ show_sub_title=True,
135
+ style=style
136
+ )
137
+
138
+ if svg_content:
139
+ print(f"✅ Success: SVG generated ({len(svg_content)} chars)")
140
+ return True
141
+ else:
142
+ print(f"❌ Failed: process() returned None")
143
+ return False
144
+
145
+ except Exception as e:
146
+ print(f"❌ Error: {e}")
147
+ import traceback
148
+ traceback.print_exc()
149
+ return False
150
+
151
+
152
+ if __name__ == "__main__":
153
+ print("=" * 70)
154
+ print("PROCESS() FUNCTION TEST")
155
+ print(f"Testing {len(TEST_CASES)} cases")
156
+ print("=" * 70)
157
+
158
+ success_count = 0
159
+ failed_count = 0
160
+
161
+ for i, test_case in enumerate(TEST_CASES, 1):
162
+ print(f"\n[{i}/{len(TEST_CASES)}]")
163
+
164
+ success = test_single_case(test_case)
165
+ if success:
166
+ success_count += 1
167
+ else:
168
+ failed_count += 1
169
+
170
+ # Summary
171
+ print("\n" + "=" * 70)
172
+ print("TEST SUMMARY")
173
+ print("=" * 70)
174
+ print(f"Total test cases: {len(TEST_CASES)}")
175
+ print(f"✅ Successful: {success_count}")
176
+ print(f"❌ Failed: {failed_count}")
177
+ print(f"Success rate: {success_count/len(TEST_CASES)*100:.1f}%")
178
+ print("=" * 70)
179
+
180
+ if success_count == len(TEST_CASES):
181
+ print("\n🎉 All tests passed!")
182
+ elif success_count > 0:
183
+ print(f"\n⚠️ {failed_count} test(s) failed")
184
+ else:
185
+ print("\n❌ All tests failed")
186
+
187
+ print("\n" + "=" * 70)
modules/title_styler/title_styler.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Title Processor - 包装接口
3
+ 为title generation提供统一的处理接口
4
+ """
5
+
6
+ import json
7
+ import re
8
+ import argparse
9
+ from typing import Dict, Union, Optional, List
10
+ from modules.title_styler.infographic_title_generator import InfographicTitleGenerator
11
+ from modules.title_styler.templates import get_all_templates
12
+
13
+
14
+ def _scale_svg_to_fit(svg_content: str, original_width: float, original_height: float,
15
+ max_width: int, template_name: str) -> str:
16
+ """
17
+ Scale SVG content to fit within max_width.
18
+
19
+ Args:
20
+ svg_content: Original SVG string
21
+ original_width: Original width
22
+ original_height: Original height
23
+ max_width: Target max width
24
+ template_name: Template name for logging
25
+
26
+ Returns:
27
+ Scaled SVG string
28
+ """
29
+ # Calculate scale factor to fit within max_width
30
+ scale = max_width / original_width
31
+
32
+ # Find the SVG tag
33
+ svg_pattern = r'<svg[^>]*>'
34
+ svg_match = re.search(svg_pattern, svg_content)
35
+ svg_tag = svg_match.group(0)
36
+
37
+ # Calculate new dimensions
38
+ new_width = original_width * scale
39
+ new_height = original_height * scale
40
+
41
+ # Update width and height in SVG tag
42
+ new_svg_tag = re.sub(r'width="[^"]*"', f'width="{new_width:.0f}"', svg_tag)
43
+ new_svg_tag = re.sub(r'height="[^"]*"', f'height="{new_height:.0f}"', new_svg_tag)
44
+
45
+ # Replace the SVG tag
46
+ svg_content = svg_content.replace(svg_tag, new_svg_tag)
47
+
48
+ # Add scale transform to all content
49
+ svg_tag_end = svg_content.find('>', svg_content.find('<svg')) + 1
50
+ before = svg_content[:svg_tag_end]
51
+ after = svg_content[svg_tag_end:]
52
+
53
+ # Wrap remaining content in scaled group
54
+ closing_svg = '</svg>'
55
+ closing_pos = after.rfind(closing_svg)
56
+ content = after[:closing_pos]
57
+ svg_content = f'{before}<g transform="scale({scale:.4f})">{content}</g>{closing_svg}'
58
+
59
+ print(f"✅ 生成完成 (缩放 {scale:.2f}x): {template_name} ({new_width:.0f}x{new_height:.0f}px)")
60
+
61
+ return svg_content
62
+
63
+
64
+ def _build_meta_from_result(result: Dict, scaled: bool = False, scale: float = 1.0) -> Dict:
65
+ """Extract a JSON-serialisable metadata bundle from a generator result.
66
+ Caller can persist this in info.json so the rendered title is fully
67
+ reproducible without re-running the LLM/styler."""
68
+ return {
69
+ 'template_name': result.get('template_name'),
70
+ 'template_description': result.get('template_description'),
71
+ 'alignment': result.get('alignment'),
72
+ 'width': result.get('width'),
73
+ 'height': result.get('height'),
74
+ 'segments': result.get('segments') or [],
75
+ 'split_method': result.get('split_method'),
76
+ 'primary_color': result.get('primary_color'),
77
+ 'secondary_color': result.get('secondary_color'),
78
+ 'background_color': result.get('background_color'),
79
+ 'scaled_to_fit': bool(scaled),
80
+ 'scale_factor': scale,
81
+ }
82
+
83
+
84
+ def _select_best_result(results: List[Dict], max_width: int, min_scale: float = 0.6,
85
+ return_meta: bool = False):
86
+ """
87
+ Select the best result from a list of results based on max_width constraint.
88
+
89
+ Args:
90
+ results: List of result dicts with 'width', 'height', 'svg', 'template_name'
91
+ max_width: Maximum width constraint
92
+ min_scale: Minimum acceptable scale factor (default 0.6). Results requiring
93
+ smaller scale will be discarded.
94
+ return_meta: When True, return ``(svg_str, meta_dict)`` instead of bare SVG
95
+ string. Backward-compatible default keeps the old contract.
96
+
97
+ Returns:
98
+ - return_meta=False (default): Best SVG content string, or None
99
+ - return_meta=True: ``(svg_str_or_None, meta_dict_or_None)``
100
+ """
101
+ # Filter results that fit within max_width
102
+ valid_results = [r for r in results if r['width'] <= max_width]
103
+
104
+ if not valid_results:
105
+ # Use the result with smallest width and scale it to fit max_width
106
+ best_result = min(results, key=lambda x: x['width'])
107
+ scale = max_width / best_result['width']
108
+
109
+ # Discard if scale is too small
110
+ if scale < min_scale:
111
+ print(f"⚠️ 丢弃结果: {best_result['template_name']} 需要缩放 {scale:.2f}x (< {min_scale})")
112
+ return (None, None) if return_meta else None
113
+
114
+ svg = _scale_svg_to_fit(
115
+ best_result['svg'],
116
+ best_result['width'],
117
+ best_result['height'],
118
+ max_width,
119
+ best_result['template_name']
120
+ )
121
+ if return_meta:
122
+ return svg, _build_meta_from_result(best_result, scaled=True, scale=scale)
123
+ return svg
124
+ else:
125
+ # Select the result with largest width that fits
126
+ best_result = max(valid_results, key=lambda x: x['width'])
127
+ print(f"✅ 生成完成: {best_result['template_name']} ({best_result['width']:.0f}x{best_result['height']:.0f}px)")
128
+ if return_meta:
129
+ return best_result['svg'], _build_meta_from_result(best_result)
130
+ return best_result['svg']
131
+
132
+
133
+ def process(
134
+ input: str = None,
135
+ output: str = None,
136
+ input_data: Dict = None,
137
+ max_width: int = 500,
138
+ text_align: str = "left",
139
+ background_color: str = "#FFFFFF",
140
+ dark: bool = False,
141
+ show_embellishment: bool = True,
142
+ show_sub_title: bool = True,
143
+ font_family: str = None,
144
+ return_meta: bool = False,
145
+ ):
146
+ """
147
+ Process function for generating styled title SVG from input data.
148
+
149
+ Args:
150
+ input (str, optional): Path to the input JSON file.
151
+ output (str, optional): Path to the output SVG file (if provided, will save to file).
152
+ input_data (Dict, optional): Input data dictionary (alternative to file input).
153
+ max_width (int, optional): Maximum width constraint for the title. Defaults to 500.
154
+ text_align (str, optional): Text alignment. Options: "left", "center", "right". Defaults to "left".
155
+ background_color (str, optional): Background color. Defaults to "#FFFFFF".
156
+ dark (bool, optional): Whether to use dark mode. Defaults to False.
157
+ show_embellishment (bool, optional): Whether to show embellishments (currently unused). Defaults to True.
158
+ show_sub_title (bool, optional): Whether to show the subtitle. Defaults to True.
159
+ font_family (str, optional): Font family override (currently unused). Defaults to None.
160
+ style (str, optional): Title style: normal, comic, simple, professional, all. Defaults to "normal".
161
+
162
+ Returns:
163
+ str: Always returns the generated SVG content as a string.
164
+ If output path is provided, also saves to file.
165
+
166
+ Input JSON Format:
167
+ {
168
+ "title": "Main title text",
169
+ "subtitle": "Subtitle text (optional)",
170
+ "primary_color": "#2E7D32",
171
+ "secondary_color": "#4CAF50",
172
+ "background_color": "#FFFFFF"
173
+ }
174
+ """
175
+ try:
176
+ # Load the data object
177
+ if input_data is None:
178
+ if input is None:
179
+ print("❌ Error: Either input file path or input_data must be provided")
180
+ return None
181
+ with open(input, 'r', encoding='utf-8') as f:
182
+ data = json.load(f)
183
+ else:
184
+ data = input_data
185
+
186
+ # Extract data fields
187
+ title = data.get('titles').get('main_title')
188
+ if not title:
189
+ print("❌ Error: 'title' field is required in input data")
190
+ return None
191
+
192
+ subtitle = data.get('titles').get('sub_title') if show_sub_title else None
193
+ if not dark:
194
+ primary_color = data.get('colors').get('other').get('primary')
195
+ secondary_color = data.get('colors').get('other').get('secondary')
196
+ else:
197
+ primary_color = data.get('colors_dark').get('other').get('primary')
198
+ secondary_color = data.get('colors_dark').get('other').get('secondary')
199
+
200
+ # Handle style parameter: map "Comics" to "comic", otherwise filter out comic templates
201
+ if font_family == "Comics":
202
+ filter_mode = "comic_only"
203
+ else:
204
+ filter_mode = "non_comic"
205
+
206
+ # Create generator with custom template filtering
207
+ all_templates = get_all_templates()
208
+
209
+ if filter_mode == "comic_only":
210
+ # Only use comic templates
211
+ filtered_templates = [t for t in all_templates if t.style == 'comic']
212
+ else:
213
+ # Use all non-comic templates
214
+ filtered_templates = [t for t in all_templates if t.style != 'comic']
215
+
216
+ if not filtered_templates:
217
+ print("❌ Error: No templates available after filtering")
218
+ return None
219
+
220
+ # Create generator instance
221
+ generator = InfographicTitleGenerator(use_llm=True)
222
+ # Override templates with filtered ones
223
+ generator.templates = filtered_templates
224
+
225
+ # Generate title with LLM, top_k=1
226
+ results = generator.generate(
227
+ title=title,
228
+ description=subtitle,
229
+ primary_color=primary_color,
230
+ secondary_color=secondary_color,
231
+ background_color=background_color,
232
+ max_width=max_width,
233
+ alignment=text_align,
234
+ top_k=1,
235
+ style="comic" if filter_mode == "comic_only" else None
236
+ )
237
+
238
+ if not results:
239
+ print("❌ Error: No results generated")
240
+ return (None, None) if return_meta else None
241
+
242
+ # Select best result based on max_width constraint
243
+ if return_meta:
244
+ svg_content, meta = _select_best_result(results, max_width, return_meta=True)
245
+ else:
246
+ svg_content = _select_best_result(results, max_width)
247
+ meta = None
248
+
249
+ # Output handling: save to file if output path is provided
250
+ if output and svg_content:
251
+ with open(output, 'w', encoding='utf-8') as f:
252
+ f.write(svg_content)
253
+ print(f" 保存: {output}")
254
+
255
+ if return_meta:
256
+ return svg_content, meta
257
+ return svg_content
258
+
259
+ except FileNotFoundError as e:
260
+ print(f"❌ Error: Input file not found: {e}")
261
+ return (None, None) if return_meta else None
262
+ except json.JSONDecodeError as e:
263
+ print(f"❌ Error: Invalid JSON format: {e}")
264
+ return (None, None) if return_meta else None
265
+ except Exception as e:
266
+ print(f"❌ Error in title styling: {str(e)}")
267
+ import traceback
268
+ traceback.print_exc()
269
+ return (None, None) if return_meta else None
270
+
271
+
272
+ def process_batch(
273
+ input: str = None,
274
+ input_data: Dict = None,
275
+ max_widths: List[int] = None,
276
+ text_align: str = "left",
277
+ background_color: str = "#FFFFFF",
278
+ dark: bool = False,
279
+ show_embellishment: bool = True,
280
+ show_sub_title: bool = True,
281
+ font_family: str = None,
282
+ return_meta: bool = False,
283
+ ):
284
+ """
285
+ Batch process function for generating styled title SVGs with multiple widths.
286
+ This function calls LLM only once and generates SVGs for each width.
287
+
288
+ Args:
289
+ input (str, optional): Path to the input JSON file.
290
+ input_data (Dict, optional): Input data dictionary (alternative to file input).
291
+ max_widths (List[int]): List of maximum width constraints for the titles.
292
+ text_align (str, optional): Text alignment. Options: "left", "center", "right". Defaults to "left".
293
+ background_color (str, optional): Background color. Defaults to "#FFFFFF".
294
+ dark (bool, optional): Whether to use dark mode. Defaults to False.
295
+ show_embellishment (bool, optional): Whether to show embellishments (currently unused). Defaults to True.
296
+ show_sub_title (bool, optional): Whether to show the subtitle. Defaults to True.
297
+ font_family (str, optional): Font family override. Defaults to None.
298
+
299
+ Returns:
300
+ List[str]: List of SVG content strings, one for each max_width in max_widths.
301
+ Order matches the order of max_widths.
302
+ """
303
+ if max_widths is None or len(max_widths) == 0:
304
+ print("❌ Error: max_widths must be provided and non-empty")
305
+ return []
306
+
307
+ try:
308
+ # Load the data object
309
+ if input_data is None:
310
+ if input is None:
311
+ print("❌ Error: Either input file path or input_data must be provided")
312
+ return []
313
+ with open(input, 'r', encoding='utf-8') as f:
314
+ data = json.load(f)
315
+ else:
316
+ data = input_data
317
+
318
+ # Extract data fields
319
+ title = data.get('titles').get('main_title')
320
+ if not title:
321
+ print("❌ Error: 'title' field is required in input data")
322
+ return []
323
+
324
+ subtitle = data.get('titles').get('sub_title') if show_sub_title else None
325
+ if not dark:
326
+ primary_color = data.get('colors').get('other').get('primary')
327
+ secondary_color = data.get('colors').get('other').get('secondary')
328
+ else:
329
+ primary_color = data.get('colors_dark').get('other').get('primary')
330
+ secondary_color = data.get('colors_dark').get('other').get('secondary')
331
+
332
+ # Handle style parameter: map "Comics" to "comic", otherwise filter out comic templates
333
+ if font_family == "Comics":
334
+ filter_mode = "comic_only"
335
+ else:
336
+ filter_mode = "non_comic"
337
+
338
+ # Create generator with custom template filtering
339
+ all_templates = get_all_templates()
340
+
341
+ if filter_mode == "comic_only":
342
+ filtered_templates = [t for t in all_templates if t.style == 'comic']
343
+ else:
344
+ filtered_templates = [t for t in all_templates if t.style != 'comic']
345
+
346
+ if not filtered_templates:
347
+ print("❌ Error: No templates available after filtering")
348
+ return []
349
+
350
+ # Create generator instance
351
+ generator = InfographicTitleGenerator(use_llm=True)
352
+ generator.templates = filtered_templates
353
+
354
+ # Step 1: Analyze title with LLM (only once)
355
+ analysis_result = generator.analyze_title(
356
+ title=title,
357
+ description=subtitle,
358
+ primary_color=primary_color,
359
+ background_color=background_color,
360
+ alignment=text_align,
361
+ top_k=1,
362
+ style="comic" if filter_mode == "comic_only" else None
363
+ )
364
+
365
+ if not analysis_result:
366
+ print("❌ Error: Title analysis failed")
367
+ return []
368
+
369
+ # Step 2: Generate SVGs for each width (no LLM calls)
370
+ results = generator.generate_with_analysis(
371
+ analysis_result=analysis_result,
372
+ max_widths=max_widths,
373
+ secondary_color=secondary_color
374
+ )
375
+
376
+ if not results:
377
+ print("❌ Error: No results generated")
378
+ return []
379
+
380
+ # Step 3: Post-process results - match each max_width to best result
381
+ # Results are ordered by max_widths, so we can process each
382
+ svg_contents = []
383
+ metas = []
384
+
385
+ # Create a mapping from requested max_width to results
386
+ results_by_max_width = {}
387
+ for result in results:
388
+ req_width = result.get('max_width_requested')
389
+ if req_width not in results_by_max_width:
390
+ results_by_max_width[req_width] = []
391
+ results_by_max_width[req_width].append(result)
392
+
393
+ for max_width in max_widths:
394
+ if max_width in results_by_max_width:
395
+ width_results = results_by_max_width[max_width]
396
+ if return_meta:
397
+ svg_content, meta = _select_best_result(width_results, max_width, return_meta=True)
398
+ else:
399
+ svg_content = _select_best_result(width_results, max_width)
400
+ meta = None
401
+ else:
402
+ # No result for this width — use closest fit + scale.
403
+ all_results_list = [r for r in results]
404
+ if all_results_list:
405
+ if return_meta:
406
+ svg_content, meta = _select_best_result(all_results_list, max_width, return_meta=True)
407
+ else:
408
+ svg_content = _select_best_result(all_results_list, max_width)
409
+ meta = None
410
+ else:
411
+ svg_content = None
412
+ meta = None
413
+ svg_contents.append(svg_content)
414
+ metas.append(meta)
415
+
416
+ if return_meta:
417
+ return list(zip(svg_contents, metas))
418
+ return svg_contents
419
+
420
+ except FileNotFoundError as e:
421
+ print(f"❌ Error: Input file not found: {e}")
422
+ return []
423
+ except json.JSONDecodeError as e:
424
+ print(f"❌ Error: Invalid JSON format: {e}")
425
+ return []
426
+ except Exception as e:
427
+ print(f"❌ Error in title styling: {str(e)}")
428
+ import traceback
429
+ traceback.print_exc()
430
+ return []
431
+
432
+
433
+ def main():
434
+ """命令行接口"""
435
+ parser = argparse.ArgumentParser(
436
+ description='Generate styled title SVG for a chart',
437
+ formatter_class=argparse.RawDescriptionHelpFormatter,
438
+ epilog="""
439
+ Examples:
440
+ # Generate with default settings
441
+ python title_processor.py -i input.json -o output.svg
442
+
443
+ # Generate with specific style and width
444
+ python title_processor.py -i input.json -o output.svg --style comic --max-width 600
445
+
446
+ # Generate without subtitle
447
+ python title_processor.py -i input.json -o output.svg --no-subtitle
448
+
449
+ # Generate with center alignment
450
+ python title_processor.py -i input.json -o output.svg --text-align center
451
+
452
+ Input JSON format:
453
+ {
454
+ "title": "Your Title Here",
455
+ "subtitle": "Optional subtitle",
456
+ "primary_color": "#2E7D32",
457
+ "secondary_color": "#4CAF50",
458
+ "background_color": "#FFFFFF"
459
+ }
460
+ """
461
+ )
462
+
463
+ parser.add_argument('--input', '-i', type=str, required=True,
464
+ help='Input JSON file path')
465
+ parser.add_argument('--output', '-o', type=str,
466
+ help='Output SVG file path')
467
+ parser.add_argument('--max-width', '-w', type=int, default=500,
468
+ help='Maximum width constraint for the title (default: 500)')
469
+ parser.add_argument('--text-align', '-a', type=str, default='left',
470
+ choices=['left', 'center', 'right'],
471
+ help='Text alignment: left, center, or right (default: left)')
472
+ parser.add_argument('--no-subtitle', action='store_true',
473
+ help='Hide the subtitle')
474
+ parser.add_argument('--style', '-s', type=str, default='normal',
475
+ choices=['normal', 'comic', 'simple', 'professional', 'all'],
476
+ help='Title style (default: normal)')
477
+
478
+ args = parser.parse_args()
479
+
480
+ svg_content = process(
481
+ input=args.input,
482
+ output=args.output,
483
+ max_width=args.max_width,
484
+ text_align=args.text_align,
485
+ show_sub_title=not args.no_subtitle,
486
+ style=args.style
487
+ )
488
+
489
+ if svg_content:
490
+ if args.output:
491
+ print("\n✅ Title styling completed successfully.")
492
+ else:
493
+ # If no output file, print SVG to stdout
494
+ print(svg_content)
495
+ else:
496
+ print("\n❌ Title styling failed.")
497
+ exit(1)
498
+
499
+
500
+ if __name__ == '__main__':
501
+ main()
502
+
modules/title_styler/title_styler_basic.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Union
2
+ import json
3
+ from PIL import Image, ImageDraw, ImageFont
4
+ import argparse
5
+
6
+ def merge_bounding_boxes(bounding_boxes):
7
+ """合并所有有实际文字内容的boundingbox"""
8
+ min_x = min(box['x'] for box in bounding_boxes)
9
+ min_y = min(box['y'] for box in bounding_boxes)
10
+ max_x = max(box['x'] + box['width'] for box in bounding_boxes)
11
+ max_y = max(box['y'] + box['height'] for box in bounding_boxes)
12
+ ascent = max(box['ascent'] for box in bounding_boxes)
13
+ descent = min(box['descent'] for box in bounding_boxes)
14
+ return {
15
+ 'x': min_x,
16
+ 'y': min_y,
17
+ 'width': max_x - min_x,
18
+ 'height': max_y - min_y,
19
+ 'ascent': ascent,
20
+ 'descent': descent
21
+ }
22
+
23
+ def measure_text_bounds(text, font_family, font_size, font_weight="normal"):
24
+ """测量文本的边界框尺寸"""
25
+ # 创建临时图像用于测量文本
26
+ img = Image.new('RGB', (1, 1), color=(255, 255, 255))
27
+ draw = ImageDraw.Draw(img)
28
+
29
+ # 获取字体
30
+ font = get_font(font_family, font_size, font_weight)
31
+
32
+ # 获取文本尺寸
33
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
34
+ width = right - left
35
+ height = bottom - top
36
+
37
+ result = {
38
+ 'width': width,
39
+ 'height': height,
40
+ 'min_x': left,
41
+ 'min_y': top,
42
+ 'max_x': right,
43
+ 'max_y': bottom
44
+ }
45
+
46
+ return result
47
+
48
+ def get_font(font_family, font_size, font_weight="normal"):
49
+ """获取字体对象,处理各种字体格式和降级情况"""
50
+ # 处理特殊字体名称
51
+ if font_family and font_family.lower() == 'comics':
52
+ font_family = 'Comic Sans MS, cursive'
53
+
54
+ # 从字体大小中提取数字部分
55
+ if isinstance(font_size, str):
56
+ font_size = int(font_size.replace('px', ''))
57
+
58
+ try:
59
+ # 首先尝试加载系统字体
60
+ if font_weight == "bold":
61
+ font = ImageFont.truetype(font_family, size=font_size, weight="bold")
62
+ else:
63
+ font = ImageFont.truetype(font_family, size=font_size)
64
+ except (OSError, IOError):
65
+ try:
66
+ # 如果直接加载失败,尝试一些常见的系统字体
67
+ system_fonts = {
68
+ 'Arial': '/usr/share/fonts/truetype/msttcorefonts/Arial.ttf',
69
+ 'Times': '/usr/share/fonts/truetype/msttcorefonts/Times_New_Roman.ttf',
70
+ 'Courier': '/usr/share/fonts/truetype/msttcorefonts/Courier_New.ttf',
71
+ 'Verdana': '/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf',
72
+ 'Comic': '/usr/share/fonts/truetype/msttcorefonts/Comic_Sans_MS.ttf',
73
+ 'Default': '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'
74
+ }
75
+
76
+ # 尝试匹配字体名称
77
+ for name, path in system_fonts.items():
78
+ if name.lower() in font_family.lower():
79
+ font = ImageFont.truetype(path, size=font_size)
80
+ return font
81
+
82
+ # 如果没有匹配,使用默认字体
83
+ font = ImageFont.truetype(system_fonts['Default'], size=font_size)
84
+ except (OSError, IOError):
85
+ # 如果所有尝试都失败,使用PIL的默认字体并尝试调整大小
86
+ default_font = ImageFont.load_default()
87
+
88
+ # PIL的默认字体不支持调整大小,所以我们必须警告用户
89
+ print(f"警告: 无法加载指定字体和大小 '{font_family}', {font_size}px。使用默认字体。")
90
+ font = default_font
91
+
92
+ return font
93
+
94
+ def split_text_into_lines(text, max_width, font_family="Arial", font_size=16, font_weight="normal"):
95
+ """将文本按照给定的宽度限制拆分成多行"""
96
+ # 创建临时图像用于测量文本宽度
97
+ img = Image.new('RGB', (1, 1), color=(255, 255, 255))
98
+ draw = ImageDraw.Draw(img)
99
+
100
+ # 获取字体
101
+ font = get_font(font_family, font_size, font_weight)
102
+
103
+ lines = []
104
+
105
+ # 检测是否包含中文字符
106
+ has_chinese = any('\u4e00' <= char <= '\u9fff' for char in text)
107
+
108
+ if has_chinese:
109
+ # 中文文本按字符切分
110
+ current_line = ("", 0)
111
+ for char in text:
112
+ test_line = current_line[0] + char
113
+ # 获取文本宽度
114
+ text_width = draw.textlength(test_line, font=font)
115
+
116
+ if text_width <= max_width:
117
+ current_line = (test_line, text_width)
118
+ else:
119
+ lines.append(current_line)
120
+ current_line = (char, draw.textlength(char, font=font))
121
+
122
+ # 添加最后一行
123
+ if current_line:
124
+ lines.append(current_line)
125
+ else:
126
+ # 英文文本按单词切分
127
+ words = text.split()
128
+ current_line = ("", 0)
129
+
130
+ for word in words:
131
+ # 测试添加这个单词后是否超出宽度
132
+ test_line = current_line[0] + (" " if current_line[0] else "") + word
133
+ text_width = draw.textlength(test_line, font=font)
134
+ if text_width <= max_width:
135
+ current_line = (test_line, text_width)
136
+ else:
137
+ if current_line:
138
+ lines.append(current_line)
139
+ current_line = (word, draw.textlength(word, font=font))
140
+
141
+ # 检查单个单词是否超过最大宽度
142
+ if draw.textlength(word, font=font) > max_width:
143
+ # 如果单个单词就超过宽度,则需要逐字分割
144
+ word_line = ""
145
+ for char in word:
146
+ test_word_line = word_line + char
147
+ if draw.textlength(test_word_line, font=font) <= max_width:
148
+ word_line = test_word_line
149
+ else:
150
+ word_line = word_line + "-"
151
+ lines.append((word_line, draw.textlength(word_line, font=font)))
152
+ word_line = char
153
+
154
+ if word_line:
155
+ current_line = (word_line, draw.textlength(word_line, font=font))
156
+ if current_line not in lines:
157
+ lines.append(current_line)
158
+ current_line = ("", 0)
159
+
160
+ # 添加最后一行
161
+ if current_line:
162
+ lines.append(current_line)
163
+
164
+ # 确保至少有一行
165
+ if not lines:
166
+ lines = [(text, draw.textlength(text, font=font))]
167
+
168
+ return lines
169
+
170
+ class TitleGenerator:
171
+ def __init__(self, json_data: Dict, max_width = 0, text_align = "left", show_embellishment = True, show_sub_title = True, font_family = None):
172
+ self.json_data = json_data
173
+ self.max_width = max_width
174
+ self.text_align = text_align # 保留接口,但内部只实现左对齐
175
+ self.show_embellishment = show_embellishment
176
+ self.show_sub_title = show_sub_title
177
+ self.font_family = font_family
178
+
179
+ def generate(self):
180
+ self.main_title_svg, self.main_title_bounding_box = self.generate_main_title()
181
+ if self.show_sub_title:
182
+ self.description_svg, self.description_bounding_box = self.generate_description()
183
+ else:
184
+ self.description_svg = ""
185
+ self.description_bounding_box = {
186
+ 'width': 0, 'height': 0,
187
+ 'min_x': 0, 'min_y': 0,
188
+ 'max_x': 0, 'max_y': 0
189
+ }
190
+ primary_color = self.json_data['colors']['other']['primary']
191
+
192
+ if self.show_embellishment:
193
+ self.embellishment_svg, self.embellishment_bounding_box = self.generate_embellishment(primary_color)
194
+ else:
195
+ # 创建空的装饰块,不会显示在最终结果中
196
+ self.embellishment_svg = ""
197
+ self.embellishment_bounding_box = {
198
+ 'width': 0, 'height': 0,
199
+ 'min_x': 0, 'min_y': 0,
200
+ 'max_x': 0, 'max_y': 0
201
+ }
202
+
203
+ return self.composite()
204
+
205
+ def composite(self):
206
+ if self.show_sub_title:
207
+ description_shift_y = self.main_title_bounding_box['max_y'] + 15 - self.main_title_bounding_box['min_y']
208
+ description_shift_x = self.description_bounding_box['min_x'] - self.main_title_bounding_box['min_x']
209
+
210
+ if self.text_align == "right":
211
+ title_width = self.main_title_bounding_box['max_x'] - self.main_title_bounding_box['min_x']
212
+ description_width = self.description_bounding_box['max_x'] - self.description_bounding_box['min_x']
213
+ description_shift_x = title_width - description_width
214
+
215
+ description_transform = f'translate({0}, {description_shift_y})'
216
+ self.description_svg = self.description_svg.replace('transform="', f'transform="{description_transform} ')
217
+ self.description_bounding_box['min_x'] += 0
218
+ self.description_bounding_box['min_y'] += description_shift_y
219
+ self.description_bounding_box['max_x'] += 0
220
+ self.description_bounding_box['max_y'] += description_shift_y
221
+
222
+ # 如果显示装饰块,调整其位置和大小
223
+ if self.show_embellishment:
224
+ new_height = self.description_bounding_box['max_y'] - self.main_title_bounding_box['min_y']
225
+ old_height = self.embellishment_bounding_box['height']
226
+ old_width = self.embellishment_bounding_box['width']
227
+ scale = new_height / old_height
228
+ new_width = old_width * scale
229
+
230
+ # 装饰块在左边
231
+ embellishment_shift_x = self.main_title_bounding_box['min_x'] - self.embellishment_bounding_box['min_x'] - new_width - 15
232
+ embellishment_shift_y = self.main_title_bounding_box['min_y'] - self.embellishment_bounding_box['min_y']
233
+
234
+ # 通过添加transform属性,调整embellishment_svg的���置
235
+ embellishment_transform = f'translate({embellishment_shift_x}, {embellishment_shift_y})'
236
+ self.embellishment_svg = self.embellishment_svg.replace('transform="', f'transform="{embellishment_transform} ')
237
+ self.embellishment_bounding_box['min_x'] += embellishment_shift_x
238
+ self.embellishment_bounding_box['min_y'] += embellishment_shift_y
239
+ self.embellishment_bounding_box['max_x'] += embellishment_shift_x
240
+ self.embellishment_bounding_box['max_y'] += embellishment_shift_y
241
+
242
+ old_width_text = self.embellishment_svg.split('width="')[1].split('"')[0]
243
+ old_height_text = self.embellishment_svg.split('height="')[1].split('"')[0]
244
+ new_width_text = str(int(float(old_width_text) * scale))
245
+ new_height_text = str(int(float(old_height_text) * scale))
246
+
247
+ # 通过修改width和height,调整embellishment_svg的大小
248
+ self.embellishment_svg = self.embellishment_svg.replace(old_width_text, new_width_text)
249
+ self.embellishment_svg = self.embellishment_svg.replace(old_height_text, new_height_text)
250
+
251
+ # Update embellishment bounding box with new dimensions after scaling
252
+ self.embellishment_bounding_box['width'] = float(new_width_text)
253
+ self.embellishment_bounding_box['height'] = float(new_height_text)
254
+ self.embellishment_bounding_box['max_x'] = self.embellishment_bounding_box['min_x'] + float(new_width_text)
255
+ self.embellishment_bounding_box['max_y'] = self.embellishment_bounding_box['min_y'] + float(new_height_text)
256
+
257
+ # 计算整体边界框
258
+ min_x = min(
259
+ self.main_title_bounding_box['min_x'],
260
+ self.description_bounding_box['min_x'] if self.show_sub_title else float('inf'),
261
+ self.embellishment_bounding_box['min_x'] if self.show_embellishment else float('inf')
262
+ )
263
+ min_y = min(
264
+ self.main_title_bounding_box['min_y'],
265
+ self.description_bounding_box['min_y'] if self.show_sub_title else float('inf'),
266
+ self.embellishment_bounding_box['min_y'] if self.show_embellishment else float('inf')
267
+ )
268
+ max_x = max(
269
+ self.main_title_bounding_box['max_x'],
270
+ self.description_bounding_box['max_x'] if self.show_sub_title else float('-inf'),
271
+ self.embellishment_bounding_box['max_x'] if self.show_embellishment else float('-inf')
272
+ )
273
+ max_y = max(
274
+ self.main_title_bounding_box['max_y'],
275
+ self.description_bounding_box['max_y'] if self.show_sub_title else float('-inf'),
276
+ self.embellishment_bounding_box['max_y'] if self.show_embellishment else float('-inf')
277
+ )
278
+
279
+ group_left = f'<g class="title" transform="translate({-min_x}, {-min_y})">'
280
+ group_right = '</g>'
281
+ svg_left = f'<svg xmlns="http://www.w3.org/2000/svg" width="{max_x - min_x}" height="{max_y - min_y}" viewBox="0 0 {max_x - min_x} {max_y - min_y}">'
282
+ svg_right = '</svg>'
283
+ svg_content = svg_left + group_left + self.embellishment_svg + self.main_title_svg + self.description_svg + group_right + svg_right
284
+
285
+ final_bounding_box = {
286
+ 'width': max_x - min_x,
287
+ 'height': max_y - min_y,
288
+ 'min_x': 0,
289
+ 'min_y': 0,
290
+ 'max_x': max_x - min_x,
291
+ 'max_y': max_y - min_y
292
+ }
293
+
294
+ return svg_content, final_bounding_box
295
+
296
+ def generate_text_element(self, text: str, typography: Dict, max_width: int = 0, text_align: str = "left"):
297
+ """生成文本元素,包括SVG和边界框"""
298
+ text_svg = self.generate_one_line_text(typography, text, max_width, text_align)
299
+
300
+ # 使用PIL直接测量文本尺寸
301
+ font_family = typography.get('font_family', 'Arial')
302
+ if self.font_family: # 如果全局字体被设置,优先使用全局字体
303
+ font_family = self.font_family
304
+ # 如果字体是comics,自动转换为Comic Sans MS, cursive
305
+ if font_family and font_family.lower() == 'comics':
306
+ font_family = 'Comic Sans MS, cursive'
307
+ font_size = typography.get('font_size', '16px')
308
+ font_weight = typography.get('font_weight', 'normal')
309
+
310
+ bounding_box = measure_text_bounds(text, font_family, font_size, font_weight)
311
+
312
+ # 检查是否超出最大宽度,并且生成多行文本
313
+ if max_width > 0 and bounding_box['width'] > max_width:
314
+ text_svg, bounding_box = self.generate_multi_line_text(typography, text, max_width, text_align)
315
+
316
+ return text_svg, bounding_box
317
+
318
+ def generate_main_title(self):
319
+ """生成主标题"""
320
+ main_title_text = self.json_data['titles']['main_title']
321
+ typography = self.json_data['typography']['title']
322
+ return self.generate_text_element(main_title_text, typography, self.max_width, self.text_align)
323
+
324
+ def generate_description(self):
325
+ """生成描述文本"""
326
+ description_text = self.json_data['titles']['sub_title']
327
+ typography = self.json_data['typography']['description']
328
+ return self.generate_text_element(description_text, typography, self.max_width, self.text_align)
329
+
330
+ def generate_embellishment(self, color = '#000000'):
331
+ rect = f'<rect x="0" y="0" width="15" height="150" fill="{color}" transform="translate(0, 0)"></rect>'
332
+ bounding_box = {
333
+ 'width': 15,
334
+ 'height': 150,
335
+ 'min_x': 0,
336
+ 'min_y': 0,
337
+ 'max_x': 15,
338
+ 'max_y': 150
339
+ }
340
+ return rect, bounding_box
341
+
342
+ def generate_one_line_text(self, typography: Dict, text: str, max_width: int = 0, text_align: str = "left"):
343
+ font_family = typography.get('font_family', 'Arial')
344
+ if self.font_family: # 如果全局字体被设置,优先使用全局字体
345
+ font_family = self.font_family
346
+ # 如果字体是comics,自动转换为Comic Sans MS, cursive
347
+ if font_family and font_family.lower() == 'comics':
348
+ font_family = 'Comic Sans MS, cursive'
349
+ font_size = typography.get('font_size', '16px')
350
+ font_weight = typography.get('font_weight', 'normal')
351
+
352
+ text_anchor = "start"
353
+ x = 0
354
+ if text_align == "center":
355
+ text_anchor = "middle"
356
+ x = max_width / 2
357
+ elif text_align == "right":
358
+ text_anchor = "end"
359
+ x = max_width
360
+ text_left = f'<text dominant-baseline="hanging" text-anchor="{text_anchor}" style="font-family: {font_family}; font-size: {font_size}; font-weight: {font_weight};" \
361
+ transform="translate({x}, 0)">'
362
+ text_right = '</text>'
363
+ return text_left + text + text_right
364
+
365
+ def generate_multi_line_text(self, typography: Dict, text: str, max_width: int, text_align: str = "left"):
366
+ """生成多行文本,确保每行不超过最大宽度"""
367
+ font_family = typography.get('font_family', 'Arial')
368
+ if self.font_family: # 如果全局字体被设置,优先使用全局字体
369
+ font_family = self.font_family
370
+ # 如果字体是comics,自动转换为Comic Sans MS, cursive
371
+ if font_family and font_family.lower() == 'comics':
372
+ font_family = 'Comic Sans MS, cursive'
373
+ font_size = typography.get('font_size', '16px')
374
+ font_weight = typography.get('font_weight', 'normal')
375
+
376
+ # 使用拆分文本函数获取多行
377
+ lines = split_text_into_lines(text, max_width, font_family, font_size, font_weight)
378
+
379
+ # 生成多行SVG
380
+ if isinstance(font_size, str):
381
+ font_size_px = int(font_size.replace('px', ''))
382
+ else:
383
+ font_size_px = font_size
384
+
385
+ line_height = font_size_px * 1.2 # 行高约为字体大小的1.2倍
386
+ g_left = '<g>'
387
+ text_content = ""
388
+
389
+ text_anchor = "start"
390
+ if text_align == "center":
391
+ text_anchor = "middle"
392
+ elif text_align == "right":
393
+ text_anchor = "end"
394
+
395
+ for i, (line, line_width) in enumerate(lines):
396
+ y = i * line_height
397
+ x = 0
398
+ if text_align == "right":
399
+ x = max_width
400
+ elif text_align == "center":
401
+ x = max_width / 2
402
+ text_style = f'style="font-family: {font_family}; font-size: {font_size}; font-weight: {font_weight};"'
403
+ text_content += f'<text dominant-baseline="hanging" text-anchor="{text_anchor}" {text_style} transform="translate({x}, {y})">{line}</text>'
404
+
405
+ g_right = '</g>'
406
+ text_svg = g_left + text_content + g_right
407
+
408
+ # 计算整体边界框
409
+ if len(lines) == 1:
410
+ bounding_box = measure_text_bounds(lines[0][0], font_family, font_size, font_weight)
411
+ else:
412
+ # 对于多行文本,计算整体边界框
413
+ max_line_width = 0
414
+ for (line, line_width) in lines:
415
+ max_line_width = max(max_line_width, line_width)
416
+
417
+ total_height = line_height * (len(lines) - 1) + font_size_px
418
+
419
+ bounding_box = {
420
+ 'width': max_line_width,
421
+ 'height': total_height,
422
+ 'min_x': 0,
423
+ 'min_y': 0,
424
+ 'max_x': max_line_width,
425
+ 'max_y': total_height
426
+ }
427
+
428
+ return text_svg, bounding_box
429
+
430
+
431
+ def process(
432
+ input: str = None,
433
+ output: str = None,
434
+ input_data: Dict = None,
435
+ max_width: int = 500,
436
+ text_align: str = "left",
437
+ show_embellishment: bool = True,
438
+ show_sub_title: bool = True,
439
+ font_family: str = None
440
+ ) -> Union[bool, str]:
441
+ """
442
+ Process function for generating styled title SVG from input data.
443
+
444
+ Args:
445
+ input (str, optional): Path to the input JSON file.
446
+ output (str, optional): Path to the output SVG file.
447
+ input_data (Dict, optional): Input data dictionary (alternative to file input).
448
+ max_width (int, optional): Maximum width constraint for the title. Defaults to 500.
449
+ text_align (str, optional): Text alignment. Options: "left", "center", "right". Defaults to "left".
450
+ show_embellishment (bool, optional): Whether to show the decoration element. Defaults to True.
451
+ show_sub_title (bool, optional): Whether to show the subtitle. Defaults to True.
452
+ font_family (str, optional): Font family to use for all text. Defaults to None (use from typography).
453
+
454
+ Returns:
455
+ Union[bool, str]:
456
+ - If output is provided, returns True/False indicating success/failure.
457
+ - Otherwise, returns the generated SVG content as a string.
458
+ """
459
+ try:
460
+ # Load the data object
461
+ if input_data is None:
462
+ if input is None:
463
+ return False
464
+ with open(input, 'r', encoding='utf-8') as f:
465
+ data = json.load(f)
466
+ else:
467
+ data = input_data
468
+
469
+ # Generate the title SVG
470
+ title_generator = TitleGenerator(data, max_width=max_width,
471
+ text_align=text_align,
472
+ show_embellishment=show_embellishment,
473
+ show_sub_title=show_sub_title,
474
+ font_family=font_family)
475
+ svg_content, bounding_box = title_generator.generate()
476
+
477
+ if output:
478
+ with open(output, 'w', encoding='utf-8') as f:
479
+ f.write(svg_content)
480
+ return True
481
+
482
+ return svg_content
483
+
484
+ except Exception as e:
485
+ print(f"Error in title styling: {str(e)}")
486
+ return False
487
+
488
+
489
+ def main():
490
+ parser = argparse.ArgumentParser(description='Generate styled title SVG for a chart')
491
+ parser.add_argument('--input', '-i', type=str, required=True, help='Input JSON file path')
492
+ parser.add_argument('--output', '-o', type=str, help='Output SVG file path')
493
+ parser.add_argument('--max-width', '-w', type=int, default=500, help='Maximum width constraint for the title')
494
+ parser.add_argument('--text-align', '-a', type=str, default='left', choices=['left', 'center', 'right'],
495
+ help='Text alignment: left, center, or right')
496
+ parser.add_argument('--no-embellishment', action='store_true', help='Hide the decoration element')
497
+ parser.add_argument('--no-subtitle', action='store_true', help='Hide the subtitle')
498
+ parser.add_argument('--font', type=str, help='Font family to use for all text (e.g. Arial, Comic, Times)')
499
+
500
+ args = parser.parse_args()
501
+
502
+ # 处理font参数中的comics
503
+ if args.font and args.font.lower() == 'comics':
504
+ args.font = 'Comic Sans MS, cursive'
505
+
506
+ success = process(
507
+ input=args.input,
508
+ output=args.output,
509
+ max_width=args.max_width,
510
+ text_align=args.text_align,
511
+ show_embellishment=not args.no_embellishment,
512
+ show_sub_title=not args.no_subtitle,
513
+ font_family=args.font
514
+ )
515
+
516
+ if success:
517
+ print("Title styling completed successfully.")
518
+ else:
519
+ print("Title styling failed.")
520
+
521
+
522
+ if __name__ == '__main__':
523
+ main()
modules/title_styler/utils/svg_to_mask.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import io
4
+ import argparse
5
+ import math
6
+ from PIL import Image as PILImage, ImageDraw
7
+ import cairosvg
8
+ import numpy as np
9
+ from lxml import etree
10
+ import base64
11
+
12
+ def parse_svg_dimensions(svg_content):
13
+ """解析SVG中的尺寸信息"""
14
+ # print('svg_content: ', svg_content)
15
+ # for debug: 把svg_content写入文件
16
+ svg_content = svg_content.replace('&', '')
17
+ temp_dir = os.environ.get('TEMP_DIR', '.')
18
+ with open(os.path.join(temp_dir, "svg_content.svg"), "w") as f:
19
+ f.write(svg_content)
20
+ # 将&符号替换为&amp;以避免XML解析错误
21
+ root = etree.fromstring(svg_content)
22
+
23
+
24
+
25
+ # 默认值
26
+ dimensions = {
27
+ 'viewBox': [0, 0, 300, 150],
28
+ 'width': 300,
29
+ 'height': 150
30
+ }
31
+
32
+ # 解析viewBox
33
+ viewbox = root.get('viewBox')
34
+ if viewbox:
35
+ dimensions['viewBox'] = [float(x) for x in viewbox.replace(',', ' ').split()]
36
+
37
+ # 解析width和height
38
+ width = root.get('width')
39
+ height = root.get('height')
40
+
41
+ if width and height:
42
+ # 去掉单位
43
+ dimensions['width'] = float(''.join(c for c in width if c.isdigit() or c == '.'))
44
+ dimensions['height'] = float(''.join(c for c in height if c.isdigit() or c == '.'))
45
+ elif width:
46
+ dimensions['width'] = float(''.join(c for c in width if c.isdigit() or c == '.'))
47
+ # 保持比例
48
+ dimensions['height'] = dimensions['width'] * (dimensions['viewBox'][3] / dimensions['viewBox'][2])
49
+ elif height:
50
+ dimensions['height'] = float(''.join(c for c in height if c.isdigit() or c == '.'))
51
+ # 保持比例
52
+ dimensions['width'] = dimensions['height'] * (dimensions['viewBox'][2] / dimensions['viewBox'][3])
53
+ else:
54
+ # 使用viewBox
55
+ dimensions['width'] = dimensions['viewBox'][2]
56
+ dimensions['height'] = dimensions['viewBox'][3]
57
+
58
+ return dimensions
59
+
60
+
61
+ def svg_to_mask(svg_content, grid_size=10, content_threshold=0.05, sample_density=36, scale=2):
62
+ """
63
+ 将SVG转换为基于网格的mask
64
+
65
+ 参数:
66
+ svg_content - SVG内容字符串
67
+ grid_size - 网格大小
68
+ content_threshold - 内容阈值,当内容占比超过此值时标记为有内容
69
+ sample_density - 采样密度,每个网格单元的采样点数量
70
+ scale - 比例因子,用于提高渲染精度
71
+
72
+ 返回:
73
+ tuple (原始图像, mask图像, mask网格, 网格信息)
74
+ """
75
+ svg_content = svg_content.replace('&', '')
76
+ # 解析SVG尺寸
77
+ dimensions = parse_svg_dimensions(svg_content)
78
+ vbx, vby, vbw, vbh = dimensions['viewBox']
79
+
80
+ # 计算网格尺寸
81
+ cols = math.ceil(vbw / grid_size)
82
+ rows = math.ceil(vbh / grid_size)
83
+
84
+ # 渲染SVG到PNG
85
+ canvas_width = int(cols * grid_size * scale)
86
+ canvas_height = int(rows * grid_size * scale)
87
+
88
+ png_data = cairosvg.svg2png(
89
+ bytestring=svg_content.encode('utf-8'),
90
+ output_width=canvas_width,
91
+ output_height=canvas_height
92
+ )
93
+
94
+ # 加载图像
95
+ image = PILImage.open(io.BytesIO(png_data))
96
+ image_data = np.array(image)
97
+
98
+ # 创建mask矩阵和图像
99
+ mask_grid = np.zeros((rows, cols), dtype=bool)
100
+ mask_image = PILImage.new('RGBA', (canvas_width, canvas_height), (0, 0, 0, 0))
101
+ draw = ImageDraw.Draw(mask_image)
102
+
103
+ # 用于调试的画布
104
+ debug_image = image.copy()
105
+ debug_draw = ImageDraw.Draw(debug_image)
106
+
107
+ # 计算采样步长
108
+ samples_per_row = int(math.sqrt(sample_density))
109
+ step_size = (grid_size * scale) / samples_per_row
110
+
111
+ grid_info = {
112
+ 'total_cells': rows * cols,
113
+ 'filled_cells': 0,
114
+ 'dimensions': dimensions,
115
+ 'grid_size': grid_size,
116
+ 'rows': rows,
117
+ 'cols': cols
118
+ }
119
+
120
+ # 生成采样网格坐标
121
+ x_offsets = np.arange(samples_per_row) * step_size
122
+ y_offsets = np.arange(samples_per_row) * step_size
123
+ X, Y = np.meshgrid(x_offsets, y_offsets)
124
+ sample_offsets = np.stack([X.flatten(), Y.flatten()], axis=1)
125
+
126
+ # 遍历每个网格单元
127
+ for y in range(rows):
128
+ for x in range(cols):
129
+ # 网格在图像上的位置
130
+ grid_x = int(x * grid_size * scale)
131
+ grid_y = int(y * grid_size * scale)
132
+
133
+ # 绘制网格线(调试用)
134
+ debug_draw.rectangle(
135
+ [(grid_x, grid_y), (grid_x + grid_size * scale, grid_y + grid_size * scale)],
136
+ outline=(200, 200, 200, 128),
137
+ width=1,
138
+ fill=None
139
+ )
140
+
141
+ # 计算所有采样点坐标
142
+ sample_points = sample_offsets + [grid_x, grid_y]
143
+ sample_points = sample_points.astype(int)
144
+
145
+ # 过滤掉超出范围的点
146
+ valid_points = (sample_points[:, 0] >= 0) & (sample_points[:, 0] < canvas_width) & \
147
+ (sample_points[:, 1] >= 0) & (sample_points[:, 1] < canvas_height)
148
+ valid_samples = sample_points[valid_points]
149
+
150
+ # 获取所有有效采样点的颜色和alpha值
151
+ pixels = image_data[valid_samples[:, 1], valid_samples[:, 0]]
152
+ rgb_values = pixels[:, :3]
153
+ alpha_values = pixels[:, 3]
154
+
155
+ # 判断有效点:alpha不为0且RGB不都超过250
156
+ is_white = np.all(rgb_values > 250, axis=1)
157
+ is_visible = (alpha_values > 0) & (~is_white)
158
+ content_points = np.sum(is_visible)
159
+
160
+ # 计算内容比例
161
+ content_ratio = content_points / sample_density
162
+
163
+ # 在调试图像上显示比例
164
+ text_x = grid_x + (grid_size * scale) // 2
165
+ text_y = grid_y + (grid_size * scale) // 2
166
+ text_color = (255, 0, 0, 255) if content_ratio >= content_threshold else (0, 0, 0, 255)
167
+ debug_draw.text((text_x, text_y), f"{int(content_ratio*100)}%", fill=text_color)
168
+
169
+ # 如果内容比例超过阈值,则标记为有内容
170
+ if content_ratio >= content_threshold:
171
+ mask_grid[y, x] = True
172
+ grid_info['filled_cells'] += 1
173
+
174
+ # 在mask图像上绘制矩形
175
+ draw.rectangle(
176
+ [(grid_x, grid_y), (grid_x + grid_size * scale, grid_y + grid_size * scale)],
177
+ fill=(0, 0, 255, 128)
178
+ )
179
+
180
+ # 在调试图像上高亮有内容的网格
181
+ debug_draw.rectangle(
182
+ [(grid_x, grid_y), (grid_x + grid_size * scale, grid_y + grid_size * scale)],
183
+ outline=(255, 0, 0, 255),
184
+ width=2,
185
+ fill=None
186
+ )
187
+
188
+ return debug_image, mask_image, mask_grid, grid_info
189
+
190
+ def main():
191
+ parser = argparse.ArgumentParser(description='SVG到网格Mask转换工具')
192
+ parser.add_argument('svg_file', help='输入SVG文件路径')
193
+ parser.add_argument('--output', '-o', help='输出目录', default='.')
194
+ parser.add_argument('--grid-size', '-g', type=int, default=10, help='网格大小 (默认: 10)')
195
+ parser.add_argument('--threshold', '-t', type=float, default=0.05, help='内容阈值 (0-1, 默认: 0.05)')
196
+ parser.add_argument('--sample', '-s', type=int, default=36, help='采样密度 (默认: 36 = 6x6)')
197
+ parser.add_argument('--scale', type=float, default=2, help='渲染比例因子 (默认: 2)')
198
+
199
+ args = parser.parse_args()
200
+
201
+ # 读取SVG文件
202
+ try:
203
+ with open(args.svg_file, 'r', encoding='utf-8') as f:
204
+ svg_content = f.read()
205
+ except Exception as e:
206
+ print(f"无法读取SVG文件: {e}")
207
+ return
208
+
209
+ # 创建输出目录
210
+ if not os.path.exists(args.output):
211
+ os.makedirs(args.output)
212
+
213
+ # 生成文件名
214
+ base_name = os.path.splitext(os.path.basename(args.svg_file))[0]
215
+ debug_file = os.path.join(args.output, f"{base_name}_debug.png")
216
+ mask_file = os.path.join(args.output, f"{base_name}_mask.png")
217
+ info_file = os.path.join(args.output, f"{base_name}_info.txt")
218
+
219
+ # 生成mask
220
+ print(f"处理SVG文件: {args.svg_file}")
221
+ print(f"网格大小: {args.grid_size}, 内容阈值: {args.threshold*100}%, 采样密度: {args.sample}")
222
+
223
+ debug_image, mask_image, mask_grid, grid_info = svg_to_mask(
224
+ svg_content,
225
+ grid_size=args.grid_size,
226
+ content_threshold=args.threshold,
227
+ sample_density=args.sample,
228
+ scale=args.scale
229
+ )
230
+
231
+ # 保存结果
232
+ debug_image.save(debug_file)
233
+ mask_image.save(mask_file)
234
+ print(mask_grid)
235
+
236
+ # 生成信息文件
237
+ dimensions = grid_info['dimensions']
238
+ with open(info_file, 'w', encoding='utf-8') as f:
239
+ f.write(f"SVG尺寸: {dimensions['width']}x{dimensions['height']}\n")
240
+ f.write(f"ViewBox: {dimensions['viewBox']}\n")
241
+ f.write(f"网格: {grid_info['cols']}×{grid_info['rows']}, 共{grid_info['filled_cells']}/{grid_info['total_cells']}个单元格被标记 ")
242
+ f.write(f"({grid_info['filled_cells']/grid_info['total_cells']*100:.1f}%)\n")
243
+ f.write(f"阈值: {args.threshold*100}%\n")
244
+ f.write(f"白色阈值: 250\n")
245
+
246
+ print(f"处理完成。调试图像已保存到: {debug_file}")
247
+ print(f"Mask图像已保存到: {mask_file}")
248
+ print(f"信息已保存到: {info_file}")
249
+
250
+ if __name__ == "__main__":
251
+ main()