Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- modules/title_styler/__init__.py +58 -0
- modules/title_styler/color_utils.py +61 -0
- modules/title_styler/font_metrics.py +533 -0
- modules/title_styler/infographic_title_generator.py +954 -0
- modules/title_styler/llm_analyzer.py +628 -0
- modules/title_styler/svg_renderer.py +1507 -0
- modules/title_styler/template_schema.json +359 -0
- modules/title_styler/templates.json +2010 -0
- modules/title_styler/templates.py +494 -0
- modules/title_styler/test_cases.py +607 -0
- modules/title_styler/test_new_comics.py +71 -0
- modules/title_styler/test_new_title.py +187 -0
- modules/title_styler/title_styler.py +502 -0
- modules/title_styler/title_styler_basic.py +523 -0
- modules/title_styler/utils/svg_to_mask.py +251 -0
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 |
+
# 将&符号替换为&以避免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()
|