""" 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"" # 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]