Spaces:
Sleeping
Sleeping
| """ | |
| Infographic Title Generator - Template Loader | |
| Load and parse templates from JSON configuration file | |
| """ | |
| import json | |
| import os | |
| from typing import List, Dict, Optional | |
| from modules.title_styler.config import ( | |
| COLOR_TYPE_PRIMARY, COLOR_TYPE_SECONDARY, COLOR_TYPE_FIXED, | |
| NEUTRAL_COLORS | |
| ) | |
| class LineConfig: | |
| """Single line configuration""" | |
| def __init__(self, | |
| role, # 'main' or 'description' | |
| font_family, | |
| font_size, # Absolute size in pixels | |
| font_weight='normal', | |
| color_type=COLOR_TYPE_PRIMARY, | |
| color_value=None, | |
| text_transform='none', | |
| letter_spacing=0, | |
| font_style='normal', | |
| allow_wrap=False, | |
| importance=None, # 'primary' or 'secondary' (for main title only) | |
| # Simplified style options | |
| underline=False, # True or {'color': '#000', 'thickness': 1} | |
| strikethrough=False, # True or {'color': '#000', 'thickness': 1} | |
| outline=False, # True or {'width': 2, 'color': None} | |
| shadow=False, # True or {'blur': 4, 'offset': (3, 3), 'color': 'rgba(0,0,0,0.3)'} | |
| background=False): # True or {'color': '#000', 'padding': 10, 'border_radius': 0} | |
| """ | |
| Initialize line configuration | |
| Args: | |
| role: 'main' for main title, 'description' for subtitle | |
| font_family: Font name | |
| font_size: Font size in pixels (e.g. 48, 32) | |
| font_weight: Font weight ('normal', 'bold', 'bolder', 'lighter') | |
| color_type: Color type | |
| color_value: Fixed color value | |
| text_transform: Text transform ('none', 'uppercase', 'lowercase', 'capitalize') | |
| letter_spacing: Letter spacing in em units | |
| font_style: Font style ('normal', 'italic', 'oblique') | |
| allow_wrap: Whether to allow text wrapping (when exceeding max_width) | |
| importance: Importance level (for main title only) | |
| - 'primary': Most relevant part (e.g. "Mobile Phones"), usually larger and bolder | |
| - 'secondary': Supporting part (e.g. "The Best Selling"), usually smaller | |
| - None: Not specified (default) | |
| underline: Underline | |
| - False: Not used | |
| - True: Use default style | |
| - dict: {'color': '#000000', 'thickness': 1} | |
| strikethrough: Strikethrough | |
| - False: Not used | |
| - True: Use default style | |
| - dict: {'color': '#000000', 'thickness': 1} | |
| outline: Outline text | |
| - False: Not used | |
| - True: Use default style (2px width) | |
| - dict: {'width': 2, 'color': None} # None means use primary color | |
| shadow: Shadow | |
| - False: Not used | |
| - True: Use default style (offset 3,3, blur 4) | |
| - dict: {'blur': 4, 'offset': (3, 3), 'color': 'rgba(0,0,0,0.3)'} | |
| background: Background rectangle | |
| - False: Not used | |
| - True: Use default style (black bg, 10px padding) | |
| - dict: {'color': '#000000', 'padding': 10, 'border_radius': 0} | |
| """ | |
| self.role = role | |
| self.importance = importance # Added: importance marker | |
| self.font_family = font_family | |
| self.font_size = font_size | |
| self.font_weight = font_weight | |
| self.color_type = color_type | |
| self.color_value = color_value | |
| self.text_transform = text_transform | |
| self.letter_spacing = letter_spacing | |
| self.font_style = font_style | |
| self.allow_wrap = allow_wrap | |
| # Normalize style parameters | |
| self.underline = self._normalize_style_param(underline, { | |
| 'color': None, # None means use text color | |
| 'thickness': 1 | |
| }) | |
| self.strikethrough = self._normalize_style_param(strikethrough, { | |
| 'color': None, | |
| 'thickness': 1 | |
| }) | |
| self.outline = self._normalize_style_param(outline, { | |
| 'width': 2, | |
| 'color': None # None means use text color | |
| }) | |
| self.shadow = self._normalize_style_param(shadow, { | |
| 'blur': 4, | |
| 'offset': (3, 3), | |
| 'color': 'rgba(0,0,0,0.3)' | |
| }) | |
| self.background = self._normalize_style_param(background, { | |
| 'color': '#000000', | |
| 'padding': 10, | |
| 'border_radius': 0 | |
| }) | |
| def _normalize_style_param(self, param, defaults): | |
| """ | |
| Normalize style parameter | |
| Args: | |
| param: False / True / dict | |
| defaults: Default values dictionary | |
| Returns: | |
| False or dictionary with complete parameters | |
| """ | |
| if param is False or param is None: | |
| return False | |
| elif param is True: | |
| return defaults.copy() | |
| elif isinstance(param, dict): | |
| # Merge user parameters and defaults | |
| result = defaults.copy() | |
| result.update(param) | |
| return result | |
| else: | |
| # Other cases treated as True | |
| return defaults.copy() | |
| def to_dict(self): | |
| """Convert to dictionary""" | |
| return { | |
| 'role': self.role, | |
| 'importance': self.importance, | |
| 'font_family': self.font_family, | |
| 'font_size': self.font_size, | |
| 'font_weight': self.font_weight, | |
| 'color_type': self.color_type, | |
| 'color_value': self.color_value, | |
| 'text_transform': self.text_transform, | |
| 'letter_spacing': self.letter_spacing, | |
| 'font_style': self.font_style, | |
| 'allow_wrap': self.allow_wrap, | |
| 'underline': self.underline, | |
| 'strikethrough': self.strikethrough, | |
| 'outline': self.outline, | |
| 'shadow': self.shadow, | |
| 'background': self.background, | |
| } | |
| def to_json_dict(self): | |
| """Convert to JSON format (simplified version, conforms to template_schema.json)""" | |
| # Build font string: e.g. "Arial 68px bold" or "Arial 24px normal italic" | |
| font_parts = [self.font_family, f"{self.font_size}px"] | |
| if self.font_weight and self.font_weight != 'normal': | |
| font_parts.append(self.font_weight) | |
| if self.font_style and self.font_style != 'normal': | |
| font_parts.append(self.font_style) | |
| font_string = ' '.join(font_parts) | |
| # Build color string: either hex color or variable name | |
| if self.color_type == COLOR_TYPE_PRIMARY: | |
| color_string = 'primary_color' | |
| elif self.color_type == COLOR_TYPE_SECONDARY: | |
| color_string = 'secondary_color' | |
| else: # FIXED | |
| color_string = self.color_value | |
| # Build effects object (only include non-False effects) | |
| effects = {} | |
| if self.shadow: | |
| effects['shadow'] = self.shadow | |
| if self.outline: | |
| effects['outline'] = self.outline | |
| if self.underline: | |
| effects['underline'] = self.underline | |
| if self.strikethrough: | |
| effects['strikethrough'] = self.strikethrough | |
| result = { | |
| 'font': font_string, | |
| 'color': color_string, | |
| } | |
| # Only main role has importance | |
| if self.role == 'main': | |
| result['importance'] = self.importance or 'primary' | |
| # Add optional fields | |
| if self.text_transform and self.text_transform != 'none': | |
| result['text_transform'] = self.text_transform | |
| if self.letter_spacing and self.letter_spacing != 0: | |
| result['letter_spacing'] = self.letter_spacing | |
| if effects: | |
| result['effects'] = effects | |
| return result | |
| class TitleTemplate: | |
| """Title template class""" | |
| def __init__(self, name, description, lines=None, alignment='center', style='normal', | |
| color_mode='monochrome'): | |
| """ | |
| Initialize template | |
| Args: | |
| name: Template name | |
| description: Template description | |
| lines: List of LineConfig objects (single column layout) | |
| alignment: Alignment (left/center/right) | |
| style: Template style ('normal', 'comic', 'simple', 'professional') | |
| color_mode: Color mode ('monochrome' or 'duotone') | |
| """ | |
| self.name = name | |
| self.description = description | |
| self.alignment = alignment | |
| self.style = style | |
| self.color_mode = color_mode | |
| # Single column layout | |
| self.lines = lines | |
| self.columns = None # Single column mode | |
| def has_main(self): | |
| """Check if contains main title line""" | |
| return any(line.role == 'main' for line in self.lines) | |
| def has_description(self): | |
| """Check if contains description line""" | |
| return any(line.role == 'description' for line in self.lines) | |
| def __repr__(self): | |
| main_count = sum(1 for l in self.lines if l.role == 'main') | |
| desc_count = sum(1 for l in self.lines if l.role == 'description') | |
| return f"<TitleTemplate: {self.name} (main:{main_count}, desc:{desc_count}, {self.color_mode})>" | |
| # Template cache (loaded from JSON) | |
| _TEMPLATES_CACHE = None | |
| def _parse_font_string(font_string: str) -> Dict: | |
| """ | |
| Parse font string like "Arial 68px bold" or "Comic Sans MS 72px bold" into components | |
| Returns: | |
| { | |
| 'font_family': 'Arial' or 'Comic Sans MS', | |
| 'font_size': 68, | |
| 'font_weight': 'bold', | |
| 'font_style': 'normal' | |
| } | |
| """ | |
| parts = font_string.split() | |
| # Find the part with "px" - that's the font size | |
| size_index = -1 | |
| font_size = 0 | |
| for i, part in enumerate(parts): | |
| if 'px' in part: | |
| size_index = i | |
| font_size = int(part.replace('px', '')) | |
| break | |
| if size_index == -1: | |
| raise ValueError(f"Invalid font string: {font_string} - no size found") | |
| # Everything before the size is the font family | |
| font_family = ' '.join(parts[:size_index]) | |
| # Everything after the size is weight/style | |
| font_weight = 'normal' | |
| font_style = 'normal' | |
| for part in parts[size_index + 1:]: | |
| if part in ['bold', 'bolder', 'lighter', 'normal']: | |
| font_weight = part | |
| elif part in ['italic', 'oblique']: | |
| font_style = part | |
| return { | |
| 'font_family': font_family, | |
| 'font_size': font_size, | |
| 'font_weight': font_weight, | |
| 'font_style': font_style | |
| } | |
| def _parse_color_string(color_string: str) -> tuple: | |
| """ | |
| Parse color string into (color_type, color_value) | |
| Args: | |
| color_string: "#000000", "primary_color", or "secondary_color" | |
| Returns: | |
| (COLOR_TYPE_*, color_value) | |
| """ | |
| if color_string == 'primary_color': | |
| return (COLOR_TYPE_PRIMARY, None) | |
| elif color_string == 'secondary_color': | |
| return (COLOR_TYPE_SECONDARY, None) | |
| else: | |
| return (COLOR_TYPE_FIXED, color_string) | |
| def _load_templates_from_json(json_file='templates.json') -> List[TitleTemplate]: | |
| """ | |
| Load templates from JSON configuration file | |
| Args: | |
| json_file: Path to templates JSON file | |
| Returns: | |
| List of TitleTemplate objects | |
| """ | |
| json_path = os.path.join(os.path.dirname(__file__), json_file) | |
| if not os.path.exists(json_path): | |
| raise FileNotFoundError(f"Templates file not found: {json_path}") | |
| with open(json_path, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| templates = [] | |
| for template_dict in data.get('templates', []): | |
| name = template_dict['name'] | |
| description = template_dict.get('description', '') | |
| style = template_dict.get('style', 'normal') | |
| color_mode = template_dict.get('color_mode', 'monochrome') | |
| layout_type = template_dict['layout_type'] | |
| alignment = template_dict.get('alignment', 'center') | |
| parts = template_dict['parts'] | |
| if layout_type == 'single_column': | |
| # Single column layout | |
| lines = [] | |
| # Parse main_title segments (supports nested arrays for inline groups) | |
| main_title = parts.get('main_title', {}) | |
| segments_raw = main_title.get('segments', []) | |
| # Flatten segments and track inline groups | |
| # Format: [seg1, [seg2, seg3], seg4] where array means inline | |
| inline_groups = [] # List of (start_idx, end_idx) for inline groups | |
| flat_segments = [] | |
| current_idx = 0 | |
| for item in segments_raw: | |
| if isinstance(item, list): | |
| # Inline group | |
| group_start = current_idx | |
| for seg in item: | |
| flat_segments.append(seg) | |
| current_idx += 1 | |
| inline_groups.append((group_start, current_idx - 1)) | |
| else: | |
| # Single segment | |
| flat_segments.append(item) | |
| current_idx += 1 | |
| for segment in flat_segments: | |
| # Regular text segment | |
| font_info = _parse_font_string(segment['font']) | |
| color_type, color_value = _parse_color_string(segment['color']) | |
| # Get background from either 'background' or 'effects.background' | |
| background = segment.get('background', False) | |
| if not background and 'effects' in segment: | |
| background = segment['effects'].get('background', False) | |
| # Determine allow_wrap: if has background, force to False; otherwise use segment's setting | |
| if background: | |
| allow_wrap = False | |
| else: | |
| allow_wrap = segment.get('allow_wrap', False) | |
| line = LineConfig( | |
| role='main', | |
| importance=segment.get('importance', 'primary'), | |
| font_family=font_info['font_family'], | |
| font_size=font_info['font_size'], | |
| font_weight=font_info['font_weight'], | |
| font_style=font_info['font_style'], | |
| color_type=color_type, | |
| color_value=color_value, | |
| text_transform=segment.get('text_transform', 'none'), | |
| letter_spacing=segment.get('letter_spacing', 0), | |
| allow_wrap=allow_wrap, | |
| shadow=segment.get('effects', {}).get('shadow', False) if 'effects' in segment else False, | |
| outline=segment.get('effects', {}).get('outline', False) if 'effects' in segment else False, | |
| underline=segment.get('effects', {}).get('underline', False) if 'effects' in segment else False, | |
| strikethrough=segment.get('effects', {}).get('strikethrough', False) if 'effects' in segment else False, | |
| background=background, | |
| ) | |
| lines.append(line) | |
| # Parse description | |
| if 'description' in parts: | |
| desc = parts['description'] | |
| font_info = _parse_font_string(desc['font']) | |
| color_type, color_value = _parse_color_string(desc['color']) | |
| line = LineConfig( | |
| role='description', | |
| font_family=font_info['font_family'], | |
| font_size=font_info['font_size'], | |
| font_weight=font_info['font_weight'], | |
| font_style=font_info['font_style'], | |
| color_type=color_type, | |
| color_value=color_value, | |
| allow_wrap=desc.get('allow_wrap', True), | |
| text_transform=desc.get('text_transform', 'none'), | |
| letter_spacing=desc.get('letter_spacing', 0), | |
| ) | |
| lines.append(line) | |
| template = TitleTemplate( | |
| name=name, | |
| description=description, | |
| lines=lines, | |
| alignment=alignment, | |
| style=style, | |
| color_mode=color_mode | |
| ) | |
| # Store inline groups as an attribute | |
| template.inline_groups = inline_groups # List of (start_idx, end_idx) | |
| templates.append(template) | |
| return templates | |
| def get_all_templates() -> List[TitleTemplate]: | |
| """Get all templates (loaded from JSON)""" | |
| global _TEMPLATES_CACHE | |
| if _TEMPLATES_CACHE is None: | |
| _TEMPLATES_CACHE = _load_templates_from_json() | |
| return _TEMPLATES_CACHE | |
| def reload_templates(): | |
| """Reload templates from JSON file""" | |
| global _TEMPLATES_CACHE | |
| _TEMPLATES_CACHE = None | |
| return get_all_templates() | |
| def get_templates_by_alignment(alignment: str) -> List[TitleTemplate]: | |
| """Get templates by alignment""" | |
| return [t for t in get_all_templates() if t.alignment == alignment] | |
| def get_templates_with_description() -> List[TitleTemplate]: | |
| """Get templates with description""" | |
| return [t for t in get_all_templates() if t.has_description()] | |
| def get_templates_main_only() -> List[TitleTemplate]: | |
| """Get templates with main title only""" | |
| return [t for t in get_all_templates() if not t.has_description()] | |
| def get_templates_by_style(style: str) -> List[TitleTemplate]: | |
| """ | |
| Get templates by style | |
| Args: | |
| style: Template style ('normal', 'comic', 'simple', 'professional', 'all') | |
| 'all' means return all templates regardless of style | |
| Returns: | |
| List of templates matching the style | |
| """ | |
| if style == 'all': | |
| return get_all_templates() | |
| return [t for t in get_all_templates() if t.style == style] | |