Ray1ee01's picture
Upload folder using huggingface_hub
0db40c8 verified
Raw
History Blame Contribute Delete
18.4 kB
"""
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]