ChartPipeline / modules /title_styler /svg_renderer.py
Ray1ee01's picture
Upload folder using huggingface_hub
0db40c8 verified
Raw
History Blame Contribute Delete
64.6 kB
"""
Infographic Title Generator - SVG Renderer
Renders title content in SVG format with unified row/column layout
"""
import xml.etree.ElementTree as ET
import os
from typing import List, Tuple, Dict, Optional
from modules.title_styler.config import (
LINE_HEIGHT_RATIO,
SVG_PADDING, ALIGNMENT_LEFT, ALIGNMENT_CENTER, ALIGNMENT_RIGHT
)
from modules.title_styler.font_metrics import (
get_font_metrics,
measure_text_width,
measure_text_bbox,
)
SINGLE_WEIGHT_FONTS = {'Impact'}
MEASUREMENT_FALLBACKS_WHEN_UNAVAILABLE = {
'Impact': 'Arial',
'Bebas Neue': 'Arial',
'Comic Sans MS': 'Times New Roman',
'Comic Neue': 'Times New Roman',
'Brush Script': 'Times New Roman',
'Pacifico': 'Times New Roman',
}
FONT_FAMILY_FALLBACKS = {
'Georgia': 'serif',
'Times': 'serif',
'Times New Roman': 'serif',
'Cambria': 'serif',
'Garamond': 'serif',
'Book Antiqua': 'serif',
'Palatino': 'serif',
'Palatino Linotype': 'serif',
'Noto Serif': 'serif',
'DejaVu Serif': 'serif',
'Arial': 'sans-serif',
'Helvetica': 'sans-serif',
'Verdana': 'sans-serif',
'Tahoma': 'sans-serif',
'Trebuchet MS': 'sans-serif',
'Segoe UI': 'sans-serif',
'Calibri': 'sans-serif',
'Roboto': 'sans-serif',
'Open Sans': 'sans-serif',
'Montserrat': 'sans-serif',
'Oswald': 'sans-serif',
'Lato': 'sans-serif',
'Source Sans Pro': 'sans-serif',
'Noto Sans': 'sans-serif',
'DejaVu Sans': 'sans-serif',
'Liberation Sans': 'sans-serif',
'Impact': 'sans-serif',
'Bebas Neue': 'sans-serif',
'Courier': 'monospace',
'Courier New': 'monospace',
'Consolas': 'monospace',
'Monaco': 'monospace',
'Menlo': 'monospace',
'DejaVu Sans Mono': 'monospace',
'Liberation Mono': 'monospace',
'Comic Sans MS': 'cursive',
'Comic Neue': 'cursive',
'Brush Script': 'cursive',
'Pacifico': 'cursive',
}
def _font_family_base(font_family):
if not font_family:
return ''
return str(font_family).split(',')[0].strip().strip("'").strip('"')
def _font_key(text):
return ''.join(ch for ch in str(text).lower() if ch.isalnum())
def _font_family_for_measurement(font_family, font_weight, font_style):
"""Pick the family whose metrics best match the emitted SVG on this host.
Some named display fonts are often missing on Linux servers. Fontconfig may
report Noto Sans for them, while Chrome's SVG renderer falls through to the
generic CSS family. In those cases, measuring the requested family makes
background pills far wider than the rendered text.
"""
base = _font_family_base(font_family)
fallback = MEASUREMENT_FALLBACKS_WHEN_UNAVAILABLE.get(base)
if not fallback:
return font_family
resolved_path = get_font_metrics()._fc_match(base, font_weight, font_style)
if resolved_path and _font_key(base) in _font_key(os.path.basename(resolved_path)):
return font_family
return fallback
def _normalize_weight_for_output(font_family, font_weight):
"""Some display fonts (Impact) ship only a Regular face; emitting font-weight=bold
causes browsers/Cairo to synthesise fake bold (visibly thicker than intended).
Strip the weight for those fonts so the rasterised PNG matches the original face.
"""
base = _font_family_base(font_family)
if base in SINGLE_WEIGHT_FONTS:
return 'normal'
return font_weight
def _font_family_for_output(font_family):
"""Emit an explicit generic fallback so Chrome and PIL land in the same
font category when a named family (notably Impact) is unavailable.
"""
if not font_family:
return 'Arial, sans-serif'
if ',' in str(font_family):
return str(font_family)
base = _font_family_base(font_family)
fallback = FONT_FAMILY_FALLBACKS.get(base)
if not fallback:
return str(font_family)
return f"{font_family}, {fallback}"
class LayoutElement:
"""Represents a layout element with its bounding box"""
def __init__(self, text: str, config: dict, width: float, height: float, metrics: dict = None):
self.text = text
self.config = config
self.width = width # Total width (including background)
self.height = height # Total height (including background)
self.metrics = metrics or {} # Text metrics (text_width, text_height, text_ascent, text_descent)
# Bounding box (will be set during layout)
self.x1 = 0.0 # Left edge
self.y1 = 0.0 # Top edge
self.x2 = 0.0 # Right edge (x1 + width)
self.y2 = 0.0 # Bottom edge (y1 + height)
# Text rendering position
self.text_baseline_y = 0.0 # Text baseline Y position
self.text_x = 0.0 # Text X position (left edge of text, not including bg padding)
self.text_anchor = 'start'
# Background rect position (if has background)
self.bg_rect_x = 0.0
self.bg_rect_y = 0.0
self.bg_rect_width = 0.0
self.bg_rect_height = 0.0
def set_position(self, x: float, y: float):
"""Set element position (x, y is top-left corner)"""
self.x1 = x
self.y1 = y
self.x2 = x + self.width
self.y2 = y + self.height
class SVGRenderer:
"""SVG Renderer class with unified layout system"""
def __init__(self):
self.svg_padding = SVG_PADDING
def measure_element_size(self, text: str, config: dict) -> Tuple[float, float, dict]:
"""
Measure actual element size including background padding
Returns:
(width, height, metrics) tuple
metrics contains: text_width, text_height, text_ascent, text_descent
"""
# Apply text transform first (affects measurement)
text_transform = config.get('text_transform', 'none')
if text_transform == 'uppercase':
text = text.upper()
elif text_transform == 'lowercase':
text = text.lower()
elif text_transform == 'capitalize':
text = text.capitalize()
font_size = config.get('font_size', 48)
font_family = config.get('font_family') or 'Arial'
font_weight = _normalize_weight_for_output(
font_family,
config.get('font_weight') or 'normal',
)
font_style = config.get('font_style') or 'normal'
measurement_family = _font_family_for_measurement(
font_family,
font_weight,
font_style,
)
# Parse letter_spacing
spacing_value = 0
ls = config.get('letter_spacing', 0)
if isinstance(ls, str) and ls.endswith('em'):
spacing_value = float(ls[:-2])
elif isinstance(ls, (int, float)):
spacing_value = ls
# Measure text bbox
ascent, descent, text_width, text_height = measure_text_bbox(
text,
measurement_family,
int(font_size),
font_weight,
font_style,
spacing_value,
)
metrics = {
'text_width': text_width,
'text_height': text_height,
'text_ascent': ascent,
'text_descent': descent
}
# Check if element has background padding
bg_config = config.get('background', False)
if bg_config and isinstance(bg_config, dict):
bg_padding = bg_config.get('padding', 10)
# Background expands the bounding box
width = text_width + 2 * bg_padding
height = text_height + 2 * bg_padding
else:
width = text_width
height = text_height
return width, height, metrics
def layout_row(self, elements: List[LayoutElement], gap: float = 10.0,
vertical_align: str = 'baseline') -> Tuple[float, float]:
"""
Layout elements horizontally (row layout)
In baseline mode: text baselines align first, then backgrounds adjust around text
Args:
elements: List of LayoutElement objects
gap: Gap between elements
vertical_align: Vertical alignment ('baseline', 'center', 'start', 'end')
Returns:
(total_width, max_height) of the row
"""
if not elements:
return 0.0, 0.0
current_x = 0.0
# For baseline alignment: align text baselines, then adjust backgrounds
if vertical_align == 'baseline':
# Find the maximum ascent and descent among all elements (text only)
max_ascent = 0.0
max_descent = 0.0
for elem in elements:
text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
max_ascent = max(max_ascent, text_ascent)
max_descent = max(max_descent, text_descent)
# Row height is based on text metrics, but may extend for backgrounds
row_content_height = max_ascent + max_descent
max_top_extend = 0.0 # Maximum extension above baseline
max_bottom_extend = 0.0 # Maximum extension below baseline
for elem in elements:
text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
text_height = text_ascent + text_descent
# Check if element has background
bg_config = elem.config.get('background', False)
if bg_config and isinstance(bg_config, dict):
bg_padding = bg_config.get('padding', 10)
# Background extends beyond text
top_extend = text_ascent + bg_padding
bottom_extend = text_descent + bg_padding
else:
# No background, just text
top_extend = text_ascent
bottom_extend = text_descent
max_top_extend = max(max_top_extend, top_extend)
max_bottom_extend = max(max_bottom_extend, bottom_extend)
# Total row height includes maximum extensions
max_height = max_top_extend + max_bottom_extend
# Now position each element
for i, elem in enumerate(elements):
text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
text_width = elem.metrics.get('text_width', elem.width)
text_height = text_ascent + text_descent
# Text baseline is at max_top_extend from row top
# This ensures all text baselines align
text_baseline_y = max_top_extend
# Check if element has background
bg_config = elem.config.get('background', False)
if bg_config and isinstance(bg_config, dict):
bg_padding = bg_config.get('padding', 10)
# Text position (within the element)
elem.text_x = current_x + bg_padding
elem.text_baseline_y = text_baseline_y
# Background rect: centered vertically around text
elem.bg_rect_x = current_x
elem.bg_rect_y = text_baseline_y - text_ascent - bg_padding
elem.bg_rect_width = text_width + 2 * bg_padding
elem.bg_rect_height = text_height + 2 * bg_padding
# Element bbox is the background rect
elem.set_position(current_x, elem.bg_rect_y)
# Move x for next element (based on background width)
current_x += elem.bg_rect_width + gap
else:
# No background: element bbox is just the text bbox
elem.text_x = current_x
elem.text_baseline_y = text_baseline_y
# Element bbox
elem_y1 = text_baseline_y - text_ascent
elem.set_position(current_x, elem_y1)
# Move x for next element (based on text width)
current_x += text_width + gap
# Verify gap (for debugging)
if i > 0:
prev_elem = elements[i - 1]
actual_gap = elem.x1 - prev_elem.x2
# Gap should be exactly as specified
assert abs(actual_gap - gap) < 0.01, f"Gap mismatch: expected {gap}, got {actual_gap}"
else:
# Other alignment modes (center, start, end)
max_height = max(elem.height for elem in elements)
for i, elem in enumerate(elements):
# Calculate y offset based on vertical alignment
if vertical_align == 'center':
y_offset = (max_height - elem.height) / 2
elif vertical_align == 'start':
y_offset = 0.0
elif vertical_align == 'end':
y_offset = max_height - elem.height
else:
y_offset = 0.0
# Set position
elem.set_position(current_x, y_offset)
# Text position
bg_config = elem.config.get('background', False)
if bg_config and isinstance(bg_config, dict):
bg_padding = bg_config.get('padding', 10)
text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
text_height = elem.metrics.get('text_height', elem.height)
elem.text_x = elem.x1 + bg_padding
elem.text_baseline_y = elem.y1 + (elem.height - text_height) / 2 + text_ascent
elem.bg_rect_x = elem.x1
elem.bg_rect_y = elem.y1
elem.bg_rect_width = elem.width
elem.bg_rect_height = elem.height
else:
text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
elem.text_x = elem.x1
elem.text_baseline_y = elem.y1 + text_ascent
# Verify gap
if i > 0:
prev_elem = elements[i - 1]
actual_gap = elem.x1 - prev_elem.x2
assert abs(actual_gap - gap) < 0.01, f"Gap mismatch: expected {gap}, got {actual_gap}"
# Move x for next element
current_x = elem.x2 + gap
# Total width is last element's x2 (no gap after last element)
total_width = elements[-1].x2
return total_width, max_height
def layout_column(self, elements: List[LayoutElement], gap: float = 0.0) -> Tuple[float, float]:
"""
Layout elements vertically (column layout)
Args:
elements: List of LayoutElement objects
gap: Gap between elements
Returns:
(max_width, total_height) of the column
"""
if not elements:
return 0.0, 0.0
current_y = 0.0
max_width = max(elem.width for elem in elements)
for i, elem in enumerate(elements):
# Set position: x at 0 (will be adjusted by alignment later), y from current_y
elem.set_position(0, current_y)
# Text baseline is at y1 + ascent (from top of bbox)
font_size = elem.config.get('font_size', 48)
elem.baseline_y = elem.y1 + elem.height * 0.75 # Approximate baseline position
# Verify gap (for debugging)
if i > 0:
prev_elem = elements[i - 1]
actual_gap = elem.y1 - prev_elem.y2
# Gap should be exactly as specified
assert abs(actual_gap - gap) < 0.01, f"Gap mismatch: expected {gap}, got {actual_gap}"
# Move y for next element: y2 + gap = next y1
current_y = elem.y2 + gap
# Total height is last element's y2 (no gap after last element)
total_height = elements[-1].y2
return max_width, total_height
def render_svg_unified(self, lines_data: List[str], line_configs: List[dict],
alignment: str, max_width: Optional[int] = None,
background_color: str = '#FFFFFF') -> Tuple[str, Tuple[int, int]]:
"""
Unified SVG rendering with proper row/column layout
Args:
lines_data: Text content list
line_configs: Line configuration list
alignment: Alignment (left/center/right)
max_width: Maximum width for text wrapping
background_color: Background color
Returns:
(svg_string, (width, height))
"""
# Check for inline groups
inline_groups = []
for i, config in enumerate(line_configs):
if config.get('inline_group'):
group_start, group_end = config['inline_group']
if (group_start, group_end) not in inline_groups:
inline_groups.append((group_start, group_end))
# Build layout structure: list of rows, each row is a list of elements
rows = []
processed_indices = set()
wrap_group_seq = 0
for i, (text, config) in enumerate(zip(lines_data, line_configs)):
if i in processed_indices:
continue
inline_group = config.get('inline_group')
if inline_group:
# This is part of an inline group - create a row with all group elements
group_start, group_end = inline_group
row_elements = []
for j in range(group_start, group_end + 1):
if j < len(lines_data):
elem_text = lines_data[j]
elem_config = line_configs[j]
width, height, metrics = self.measure_element_size(elem_text, elem_config)
elem = LayoutElement(elem_text, elem_config, width, height, metrics)
row_elements.append(elem)
processed_indices.add(j)
# Check if inline row needs wrapping
if max_width and row_elements:
total_width = sum(elem.width for elem in row_elements) + 10.0 * (len(row_elements) - 1)
content_max_width = max_width - 2 * self.svg_padding
# If row exceeds max_width, check for wrappable elements
if total_width > content_max_width:
# Find elements without background (they should wrap)
# Elements with background should NOT wrap
wrappable_indices = []
for idx, elem in enumerate(row_elements):
has_bg = elem.config.get('background') and isinstance(elem.config.get('background'), dict)
if not has_bg:
wrappable_indices.append(idx)
if wrappable_indices:
# For now, wrap the first wrappable element
# Future: could implement more sophisticated wrapping strategy
wrap_idx = wrappable_indices[0]
elem_to_wrap = row_elements[wrap_idx]
wrap_group_seq += 1
wrap_group_id = f"inline-{i}-{wrap_group_seq}"
# Calculate line height for wrap gap
font_size = elem_to_wrap.config.get('font_size', 48)
line_height = elem_to_wrap.metrics.get('text_height', font_size)
wrap_gap = min(line_height * 0.25, 10.0)
# Split the row into multiple rows
# Row 1: elements before wrap point
# Row 2: wrapped element on new line
# Row 3: elements after wrap point (if any)
if wrap_idx > 0:
# Add elements before wrap point as a row
rows.append(row_elements[:wrap_idx])
# Add wrapped element as its own row with special gap
wrapped_row = [row_elements[wrap_idx]]
rows.append({
'elements': wrapped_row,
'is_wrapped': True,
'wrap_gap': wrap_gap,
'wrap_group_id': wrap_group_id,
})
# Add remaining elements as another row if any
if wrap_idx < len(row_elements) - 1:
rows.append(row_elements[wrap_idx + 1:])
else:
# No wrappable elements, just add the row as-is (will overflow)
rows.append(row_elements)
else:
# Fits within width, add as normal row
rows.append(row_elements)
else:
# No max_width constraint, add as normal row
rows.append(row_elements)
else:
# Single element row
width, height, metrics = self.measure_element_size(text, config)
# Check if element has background color - elements with background should NOT wrap
has_background = config.get('background') and isinstance(config.get('background'), dict)
# All elements without background should wrap when exceeding max_width
# Elements with background should NOT wrap (to preserve the background box)
should_wrap = not has_background and max_width
if should_wrap:
content_max_width = max_width - 2 * self.svg_padding
if width > content_max_width:
# Need to wrap text into multiple lines
font_size = config.get('font_size', 48)
font_weight = config.get('font_weight', 'normal')
font_family = config.get('font_family', 'Arial')
letter_spacing = config.get('letter_spacing', 0)
wrapped_lines = self.wrap_text(
text, max_width, font_size, font_weight, font_family, letter_spacing
)
if len(wrapped_lines) > 1:
# Create multiple rows for wrapped text
# Calculate line height for wrap gap
line_height = metrics.get('text_height', font_size)
wrap_gap = min(line_height * 0.25, 10.0)
wrap_group_seq += 1
wrap_group_id = f"line-{i}-{wrap_group_seq}"
for wrap_idx, line_text in enumerate(wrapped_lines):
line_width, line_height, line_metrics = self.measure_element_size(line_text, config)
line_elem = LayoutElement(line_text, config, line_width, line_height, line_metrics)
rows.append({
'elements': [line_elem],
'is_wrapped': wrap_idx > 0,
'wrap_gap': wrap_gap,
'wrap_group_id': wrap_group_id,
})
processed_indices.add(i)
continue
# No wrapping needed or wrapping not applicable
elem = LayoutElement(text, config, width, height, metrics)
rows.append([elem])
processed_indices.add(i)
# Layout each row
row_layouts = []
for row_data in rows:
# Handle both list and dict (dict is for wrapped rows with metadata)
if isinstance(row_data, dict):
row_elements = row_data['elements']
is_wrapped = row_data.get('is_wrapped', False)
wrap_gap = row_data.get('wrap_gap', 0)
wrap_group_id = row_data.get('wrap_group_id')
else:
row_elements = row_data
is_wrapped = False
wrap_gap = 0
wrap_group_id = None
if len(row_elements) > 1:
# Row layout (inline elements) - default to baseline alignment
row_width, row_height = self.layout_row(row_elements, gap=10.0, vertical_align='baseline')
else:
# Single element
elem = row_elements[0]
text_ascent = elem.metrics.get('text_ascent', elem.height * 0.75)
text_descent = elem.metrics.get('text_descent', elem.height * 0.25)
text_width = elem.metrics.get('text_width', elem.width)
text_height = elem.metrics.get('text_height', elem.height)
# Set text position
bg_config = elem.config.get('background', False)
if bg_config and isinstance(bg_config, dict):
bg_padding = bg_config.get('padding', 10)
# Text position: padding from left, baseline at ascent + padding from top
elem.text_x = bg_padding
elem.text_baseline_y = bg_padding + text_ascent
# Background rect: wraps around text with padding
elem.bg_rect_x = 0
elem.bg_rect_y = 0
elem.bg_rect_width = text_width + 2 * bg_padding
elem.bg_rect_height = text_height + 2 * bg_padding
# Element bbox is the background rect
elem.set_position(0, 0)
row_width, row_height = elem.bg_rect_width, elem.bg_rect_height
else:
# No background: just text
elem.text_x = 0
elem.text_baseline_y = text_ascent
elem.set_position(0, 0)
row_width, row_height = text_width, text_height
row_layouts.append({
'elements': row_elements,
'width': row_width,
'height': row_height,
'is_wrapped': is_wrapped,
'wrap_gap': wrap_gap,
'wrap_group_id': wrap_group_id,
})
# Calculate total dimensions
max_content_width = max(layout['width'] for layout in row_layouts) if row_layouts else 0
# Layout rows vertically with proper gap
current_y = self.svg_padding
for row_idx, layout in enumerate(row_layouts):
# Position row based on alignment
row_x_offset = 0
if alignment == ALIGNMENT_CENTER:
row_x_offset = (max_content_width - layout['width']) / 2
elif alignment == ALIGNMENT_RIGHT:
row_x_offset = max_content_width - layout['width']
# Adjust all elements in this row
for elem in layout['elements']:
elem.x1 += self.svg_padding + row_x_offset
elem.x2 += self.svg_padding + row_x_offset
elem.y1 += current_y
elem.y2 += current_y
elem.text_x += self.svg_padding + row_x_offset
elem.text_baseline_y += current_y
# Adjust background rect if exists
bg_config = elem.config.get('background', False)
if bg_config and isinstance(bg_config, dict):
elem.bg_rect_x += self.svg_padding + row_x_offset
elem.bg_rect_y += current_y
elif (
len(layout['elements']) == 1
and os.environ.get("TITLE_STYLER_NATIVE_TEXT_ANCHOR", "1") != "0"
):
# For plain single-line rows, use SVG's native anchoring
# instead of converting measured text width into a left
# x-coordinate. PIL and Chrome can still disagree by a few
# pixels for fallback fonts (Comic Sans -> Noto Sans, etc.);
# with text-anchor="middle"/"end" that residual metric
# drift no longer turns into visibly misaligned rows.
if alignment == ALIGNMENT_CENTER:
elem.text_x = self.svg_padding + max_content_width / 2
elem.text_anchor = 'middle'
elif alignment == ALIGNMENT_RIGHT:
elem.text_x = self.svg_padding + max_content_width
elem.text_anchor = 'end'
'''
# Print row layout info
if len(layout['elements']) > 1:
# Inline row (multiple elements)
print(f"[Row {row_idx}] Inline layout with {len(layout['elements'])} elements:")
print(f" Row bbox: y1={current_y:.1f}, y2={current_y + layout['height']:.1f}")
for elem_idx, elem in enumerate(layout['elements']):
bg_config = elem.config.get('background', False)
if bg_config:
print(f" Element {elem_idx} (with bg): bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
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})")
print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
else:
print(f" Element {elem_idx} (no bg): bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
else:
# Single element row
elem = layout['elements'][0]
bg_config = elem.config.get('background', False)
if bg_config:
print(f"[Row {row_idx}] Single element (with bg):")
print(f" bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
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})")
print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
else:
print(f"[Row {row_idx}] Single element (no bg):")
print(f" bbox=({elem.x1:.1f}, {elem.y1:.1f}, {elem.x2:.1f}, {elem.y2:.1f})")
print(f" text=({elem.text_x:.1f}, baseline={elem.text_baseline_y:.1f})")
'''
# Move to next row
current_y += layout['height']
# Calculate row gap
if row_idx < len(row_layouts) - 1: # Not the last row
next_layout = row_layouts[row_idx + 1]
same_wrapped_text = (
layout.get('wrap_group_id')
and layout.get('wrap_group_id') == next_layout.get('wrap_group_id')
)
# Row gap must respect BOTH:
# (a) the current row's own line-height (typographically 1.2-1.5 of
# font_size), and
# (b) the next row's ascender — when a small eyebrow (e.g. 18px) is
# followed by a big headline (e.g. 32px) the next row's
# ascenders must NOT visually crash into this row.
# The old `min(curr_font*0.5, 15)` ignored (b) entirely and the 15px
# cap throttled even single-line tall titles, leading to the
# "eyebrow overlaps main title" Bug A.
if layout['elements']:
curr_font = layout['elements'][0].config.get('font_size', 48)
else:
curr_font = 16.0
if next_layout['elements']:
next_font = max(
(e.config.get('font_size', 48) for e in next_layout['elements']),
default=curr_font,
)
else:
next_font = curr_font
gap = max(curr_font * 0.4, next_font * 0.5, 10.0)
if same_wrapped_text:
# Keep wrapped lines visually compact only when the custom
# wrap gap is still large enough for the rendered font.
gap = max(gap, layout.get('wrap_gap', 5.0))
next_role = (
next_layout['elements'][0].config.get('role')
if next_layout['elements']
else None
)
if next_role == 'description':
gap = max(gap, curr_font * 0.55, next_font * 0.8, 14.0)
current_y += gap
total_height = current_y + self.svg_padding
total_width = max_content_width + 2 * self.svg_padding
# Create SVG
svg = ET.Element('svg')
svg.set('xmlns', 'http://www.w3.org/2000/svg')
svg.set('width', str(int(total_width)))
svg.set('height', str(int(total_height)))
svg.set('viewBox', f"0 0 {int(total_width)} {int(total_height)}")
# Render all elements
for layout in row_layouts:
for elem in layout['elements']:
self._render_element(svg, elem)
# Convert to string
svg_string = ET.tostring(svg, encoding='unicode', method='xml')
return svg_string, (int(total_width), int(total_height))
def _render_element(self, svg_parent: ET.Element, elem: LayoutElement):
"""Render a single layout element to SVG"""
text = elem.text
config = elem.config
# Get style properties
font_size = config.get('font_size', 48)
font_family = config.get('font_family') or 'Arial'
font_weight = config.get('font_weight') or 'normal'
final_color = config.get('final_color') or '#000000'
# Text transform
text_transform = config.get('text_transform', 'none')
if text_transform == 'uppercase':
text = text.upper()
elif text_transform == 'lowercase':
text = text.lower()
elif text_transform == 'capitalize':
text = text.capitalize()
# Render background if exists
bg_config = config.get('background', False)
if bg_config and isinstance(bg_config, dict):
bg_color = bg_config.get('color', '#000000')
bg_radius = bg_config.get('radius', bg_config.get('border_radius', 0))
# Use pre-calculated background rect position
rect_elem = ET.Element('rect')
rect_elem.set('x', str(elem.bg_rect_x))
rect_elem.set('y', str(elem.bg_rect_y))
rect_elem.set('width', str(elem.bg_rect_width))
rect_elem.set('height', str(elem.bg_rect_height))
rect_elem.set('fill', bg_color)
if bg_radius > 0:
rect_elem.set('rx', str(bg_radius))
rect_elem.set('ry', str(bg_radius))
svg_parent.append(rect_elem)
# Create text element using pre-calculated position
text_elem = ET.Element('text')
text_elem.set('x', str(elem.text_x))
text_elem.set('y', str(elem.text_baseline_y))
text_elem.set('font-family', _font_family_for_output(font_family))
text_elem.set('font-size', f"{font_size}px")
text_elem.set('font-weight', _normalize_weight_for_output(font_family, font_weight))
text_elem.set('text-anchor', elem.text_anchor)
text_elem.set('fill', final_color)
# Font style
font_style = config.get('font_style')
if font_style and font_style != 'normal':
text_elem.set('font-style', font_style)
# Letter spacing
letter_spacing = config.get('letter_spacing')
if letter_spacing:
if isinstance(letter_spacing, (int, float)) and letter_spacing != 0:
text_elem.set('letter-spacing', f"{letter_spacing}em")
elif isinstance(letter_spacing, str):
text_elem.set('letter-spacing', letter_spacing)
text_elem.text = text
svg_parent.append(text_elem)
def estimate_text_width(self, text, font_size, font_weight='normal',
font_family='Arial', letter_spacing=0):
"""
Calculate text width using Pillow for precise measurement
"""
# Parse letter_spacing (from '0.1em' to number)
spacing_value = 0
if isinstance(letter_spacing, str) and letter_spacing.endswith('em'):
spacing_value = float(letter_spacing[:-2])
elif isinstance(letter_spacing, (int, float)):
spacing_value = letter_spacing
normalized_weight = _normalize_weight_for_output(font_family, font_weight)
measurement_family = _font_family_for_measurement(
font_family,
normalized_weight,
'normal',
)
width = measure_text_width(
text,
measurement_family,
int(font_size),
normalized_weight,
spacing_value
)
# Comic Sans MS tends to be wider than calculated, add safety margin
if 'Comic Sans' in font_family:
width *= 1.08 # Add 8% safety margin for Comic Sans
return width
def render_line(self, text, line_config, y_position, svg_width, alignment):
"""
渲染单行文本
Returns:
text_element: SVG text元素
line_height: 该行的高度
"""
font_size = line_config.get('font_size', 48) # 直接使用绝对字号
# 计算x位置
if alignment == ALIGNMENT_LEFT:
x_position = self.svg_padding
text_anchor = 'start'
elif alignment == ALIGNMENT_RIGHT:
x_position = svg_width - self.svg_padding
text_anchor = 'end'
else: # center
x_position = svg_width / 2
text_anchor = 'middle'
# 文本转换
if line_config.get('text_transform') == 'uppercase':
text = text.upper()
elif line_config.get('text_transform') == 'lowercase':
text = text.lower()
elif line_config.get('text_transform') == 'capitalize':
text = text.capitalize()
elements = []
# 添加背景矩形
background_config = line_config.get('background', False)
if background_config:
bg_padding = background_config.get('padding', 10)
bg_color = background_config.get('color', '#000000')
bg_radius = background_config.get('radius', background_config.get('border_radius', 0))
# 测量文本实际bbox(相对于baseline)
# Parse letter_spacing
spacing_value = 0
ls = line_config.get('letter_spacing', 0)
if isinstance(ls, str) and ls.endswith('em'):
spacing_value = float(ls[:-2])
elif isinstance(ls, (int, float)):
spacing_value = ls
font_family = line_config.get('font_family') or 'Arial'
font_weight = _normalize_weight_for_output(
font_family,
line_config.get('font_weight') or 'normal',
)
font_style = line_config.get('font_style') or 'normal'
measurement_family = _font_family_for_measurement(
font_family,
font_weight,
font_style,
)
ascent, descent, text_width, text_height = measure_text_bbox(
text,
measurement_family,
int(font_size),
font_weight,
font_style,
spacing_value
)
# 根据对齐方式计算矩形位置
if alignment == ALIGNMENT_LEFT:
rect_x = x_position - bg_padding
elif alignment == ALIGNMENT_RIGHT:
rect_x = x_position - text_width - bg_padding
else: # center
rect_x = x_position - text_width/2 - bg_padding
# 矩形顶部 = baseline - ascent - padding
# 矩形底部 = baseline + descent + padding
rect_y = y_position - ascent - bg_padding
rect_width = text_width + 2*bg_padding
rect_height = text_height + 2*bg_padding
# 创建矩形元素
rect_elem = ET.Element('rect')
rect_elem.set('x', str(rect_x))
rect_elem.set('y', str(rect_y))
rect_elem.set('width', str(rect_width))
rect_elem.set('height', str(rect_height))
rect_elem.set('fill', bg_color)
if bg_radius > 0:
rect_elem.set('rx', str(bg_radius))
rect_elem.set('ry', str(bg_radius))
elements.append(rect_elem)
# 添加阴影效果
shadow_config = line_config.get('shadow', False)
if shadow_config:
shadow_offset = shadow_config.get('offset', (3, 3))
shadow_blur = shadow_config.get('blur', 4)
shadow_color = shadow_config.get('color', 'rgba(0,0,0,0.3)')
shadow_elem = ET.Element('text')
shadow_elem.set('x', str(x_position + shadow_offset[0]))
shadow_elem.set('y', str(y_position + shadow_offset[1]))
shadow_elem.set(
'font-family',
_font_family_for_output(line_config.get('font_family') or 'Arial'),
)
shadow_elem.set('font-size', f"{font_size}px")
shadow_elem.set('font-weight', _normalize_weight_for_output(
line_config.get('font_family'),
line_config.get('font_weight') or 'normal',
))
shadow_elem.set('text-anchor', text_anchor)
shadow_elem.set('fill', shadow_color)
if shadow_blur > 0:
shadow_elem.set('filter', f'url(#shadow-blur-{shadow_blur})')
if line_config.get('font_style'):
shadow_elem.set('font-style', line_config['font_style'])
if line_config.get('letter_spacing'):
ls = line_config['letter_spacing']
if isinstance(ls, (int, float)) and ls != 0:
shadow_elem.set('letter-spacing', f"{ls}em")
elif isinstance(ls, str):
shadow_elem.set('letter-spacing', ls)
shadow_elem.text = text
elements.append(shadow_elem)
# 创建主text元素
text_elem = ET.Element('text')
text_elem.set('x', str(x_position))
text_elem.set('y', str(y_position))
text_elem.set(
'font-family',
_font_family_for_output(line_config.get('font_family') or 'Arial'),
)
text_elem.set('font-size', f"{font_size}px")
text_elem.set('font-weight', _normalize_weight_for_output(
line_config.get('font_family'),
line_config.get('font_weight') or 'normal',
))
text_elem.set('text-anchor', text_anchor)
# 空心字效果
if line_config.get('outline', False):
outline_width = line_config.get('outline_width', 2)
text_elem.set('fill', 'none')
text_elem.set('stroke', line_config.get('final_color') or '#000000')
text_elem.set('stroke-width', str(outline_width))
else:
text_elem.set('fill', line_config.get('final_color') or '#000000')
# 字体样式
if line_config.get('font_style'):
text_elem.set('font-style', line_config['font_style'])
# 字母间距
if line_config.get('letter_spacing'):
ls = line_config['letter_spacing']
if isinstance(ls, (int, float)) and ls != 0:
text_elem.set('letter-spacing', f"{ls}em")
elif isinstance(ls, str):
text_elem.set('letter-spacing', ls)
# 文本装饰(下划线、删除线)
decorations = []
if line_config.get('underline', False):
decorations.append('underline')
if line_config.get('strikethrough', False):
decorations.append('line-through')
if decorations:
text_elem.set('text-decoration', ' '.join(decorations))
text_elem.text = text
elements.append(text_elem)
# 计算line_height:如果有背景,需要加上padding
line_height = font_size * LINE_HEIGHT_RATIO
if background_config:
bg_padding = background_config.get('padding', 10)
line_height = max(line_height, font_size + 2*bg_padding)
return elements, line_height
def wrap_text(self, text, max_width, font_size, font_weight, font_family, letter_spacing):
"""
将文本分成多行以适应最大宽度。
采用「行数最少 + 行宽平衡」两步法:
1) 贪心算出在 max_width 下需要的最少行数 N;
2) 二分搜索最小的 target_width <= max_width,使得贪心仍能在 N 行内装下;
以这个 target 再做一次贪心打包,让各行宽度尽量接近,避免出现
「长 / 短 / 短」这种典型不平衡(First-Fit 贪心的经典缺陷)。
Returns:
lines: 分割后的行列表
"""
words = text.split()
if not words:
return [text]
content_max = max_width - 2 * self.svg_padding
if content_max <= 0:
return [text]
# 预测每个 word 的宽度,避免反复调用 Pillow。
word_widths = [
self.estimate_text_width(w, font_size, font_weight, font_family, letter_spacing)
for w in words
]
space_width = self.estimate_text_width(
' ', font_size, font_weight, font_family, letter_spacing
)
def greedy_pack(target):
"""尝试用 First-Fit 把 words 装到行宽 <= target 的若干行。
返回 (行数, [(start_idx, end_idx_exclusive), ...]);若有单 word
超过 target 也算 1 行(与原行为一致:单词过长直接成一行)。"""
n_lines = 1
cur_w = 0.0
cur_start = 0
packs = []
for i, ww in enumerate(word_widths):
extra = ww if cur_w == 0 else (space_width + ww)
if cur_w + extra <= target or cur_w == 0:
cur_w += extra
else:
packs.append((cur_start, i))
n_lines += 1
cur_start = i
cur_w = ww
packs.append((cur_start, len(words)))
return n_lines, packs
# 第 1 步:用原始 max_width 下的最少行数 N。
n_lines, _ = greedy_pack(content_max)
if n_lines <= 1:
return [' '.join(words)]
# 第 2 步:二分最小化 target_width,使贪心仍能装进 N 行。
# 下界必须能容下最长的单 word,否则会无谓增加行数。
lo = max(word_widths)
hi = content_max
# target 不能小于 lo,否则单词放不下
if lo >= hi:
target = hi
else:
# 浮点二分:精度 0.5 px 足够,文本宽度本身也是近似值。
for _ in range(40): # log2(content_max / 0.5) 上限充裕
if hi - lo < 0.5:
break
mid = (lo + hi) / 2
fit_n, _ = greedy_pack(mid)
if fit_n <= n_lines:
hi = mid
else:
lo = mid
target = hi
# 第 3 步:用平衡 target 重新打包。
_, packs = greedy_pack(target)
lines = [' '.join(words[s:e]) for s, e in packs]
return lines if lines else [text]
def calculate_svg_dimensions(self, lines_data, line_configs, alignment, max_width=None):
"""
计算SVG总尺寸
Args:
lines_data: 文本内容列表
line_configs: 行配置列表
alignment: 对齐方式
max_width: 最大宽度限制
Returns:
(width, height): SVG尺寸
wrapped_lines: 考虑换行后的实际行数据
"""
# 单列布局
max_width_content = 0
total_height = self.svg_padding
wrapped_lines = [] # 存储实际渲染的行(包括换行后的)
for text, config in zip(lines_data, line_configs):
font_size = config.get('font_size', 48)
# 处理换行
if config.get('allow_wrap') and max_width:
text_lines = self.wrap_text(
text, max_width, font_size,
config.get('font_weight', 'normal'),
config.get('font_family', 'Arial'),
config.get('letter_spacing', 0)
)
else:
text_lines = [text]
# 为每个实际行添加配置
for line_text in text_lines:
text_width = self.estimate_text_width(
line_text,
font_size,
config.get('font_weight', 'normal'),
config.get('font_family', 'Arial'),
config.get('letter_spacing', 0)
)
max_width_content = max(max_width_content, text_width)
total_height += font_size * LINE_HEIGHT_RATIO
wrapped_lines.append((line_text, config))
total_height += self.svg_padding
total_width = max_width_content + 2 * self.svg_padding
# Add extra margin for Comic Sans fonts to prevent truncation
has_comic_sans = any(
config.get('font_family', '').startswith('Comic Sans')
for _, config in wrapped_lines
)
if has_comic_sans:
total_width += 20 # Extra 20px safety margin for Comic Sans
# Always use actual content width to avoid text truncation
# max_width is only used to determine when to wrap text, not to limit SVG size
return total_width, total_height, wrapped_lines
def _calculate_element_bbox(self, element):
"""
Calculate bounding box for an SVG element (text, rect, or g)
Returns:
(min_x, min_y, max_x, max_y) or None
"""
tag = element.tag
if tag == 'rect':
# For rect: x, y, width, height are explicit
# Skip background rect with percentage values
width_str = element.get('width', '0')
height_str = element.get('height', '0')
if '%' in width_str or '%' in height_str:
return None # Skip percentage-based rects (background)
x = float(element.get('x', 0))
y = float(element.get('y', 0))
width = float(width_str)
height = float(height_str)
# Consider border-radius (rx/ry) if present - doesn't change bbox
return (x, y, x + width, y + height)
elif tag == 'text':
# For text: need to measure text dimensions
text_content = element.text or ''
if not text_content:
return None
x = float(element.get('x', 0))
y = float(element.get('y', 0))
font_family = element.get('font-family', 'Arial')
font_size = int(element.get('font-size', '16px').replace('px', ''))
font_weight = element.get('font-weight', 'normal')
text_anchor = element.get('text-anchor', 'start')
# Parse letter-spacing from SVG element
letter_spacing_str = element.get('letter-spacing', '0')
letter_spacing = 0
if letter_spacing_str and letter_spacing_str != '0':
if letter_spacing_str.endswith('em'):
letter_spacing = float(letter_spacing_str[:-2])
# Measure text width and height (including letter-spacing)
text_width = self.estimate_text_width(
text_content, font_size, font_weight, font_family, letter_spacing
)
# Consider stroke width for outline text
stroke_width = 0
if element.get('stroke'):
stroke_width_str = element.get('stroke-width', '0')
try:
stroke_width = float(stroke_width_str)
except:
stroke_width = 0
# Text height with stroke consideration
text_height = font_size + stroke_width
# Calculate bbox based on text-anchor
if text_anchor == 'start':
min_x = x - stroke_width / 2
max_x = x + text_width + stroke_width / 2
elif text_anchor == 'middle':
min_x = x - text_width / 2 - stroke_width / 2
max_x = x + text_width / 2 + stroke_width / 2
elif text_anchor == 'end':
min_x = x - text_width - stroke_width / 2
max_x = x + stroke_width / 2
else:
min_x = x - stroke_width / 2
max_x = x + text_width + stroke_width / 2
# Y coordinates: y is baseline, text extends above
# Add extra space for ascenders and stroke
min_y = y - text_height - stroke_width / 2
max_y = y + stroke_width / 2
return (min_x, min_y, max_x, max_y)
return None
def _adjust_svg_dimensions_to_content(self, svg_element):
"""
Adjust SVG width/height to fit all content without clipping, ensuring uniform padding
Args:
svg_element: SVG root element
Returns:
(adjusted_width, adjusted_height)
"""
# Collect all bounding boxes
bboxes = []
for child in svg_element:
bbox = self._calculate_element_bbox(child)
if bbox:
bboxes.append(bbox)
if not bboxes:
# No content, keep original dimensions
width = int(svg_element.get('width', 100))
height = int(svg_element.get('height', 100))
return (width, height)
# Calculate overall bounding box of all visible content
min_x = min(bbox[0] for bbox in bboxes)
min_y = min(bbox[1] for bbox in bboxes)
max_x = max(bbox[2] for bbox in bboxes)
max_y = max(bbox[3] for bbox in bboxes)
# Target padding
padding = self.svg_padding
# Calculate required dimensions to maintain uniform padding on all sides
# Left padding: ensure min_x >= padding (shift if needed)
# Right padding: ensure max_x + padding is within bounds
# Top padding: ensure min_y >= padding (shift if needed)
# Bottom padding: ensure max_y + padding is within bounds
# Calculate content dimensions
content_width = max_x - min_x
content_height = max_y - min_y
# Required SVG dimensions = content + padding on both sides
required_width = content_width + 2 * padding
required_height = content_height + 2 * padding
# Get current dimensions (from initial calculation)
current_width = int(svg_element.get('width', 100))
current_height = int(svg_element.get('height', 100))
# Use maximum of current and required to ensure all content fits
final_width = max(current_width, int(required_width))
final_height = max(current_height, int(required_height))
# Check if content is positioned correctly (starts at padding)
# If min_x or min_y is less than padding, we need to shift content or increase dimensions
if min_x < padding:
# Content extends beyond left padding - increase width
extra_width = padding - min_x
final_width += int(extra_width)
if min_y < padding:
# Content extends beyond top padding - increase height
extra_height = padding - min_y
final_height += int(extra_height)
# Ensure right and bottom padding
if max_x + padding > final_width:
final_width = int(max_x + padding)
if max_y + padding > final_height:
final_height = int(max_y + padding)
return (final_width, final_height)
def render_svg(self, lines_data, line_configs, alignment, max_width=None, background_color='#FFFFFF', inline=False):
"""
渲染完整的SVG (uses unified layout system)
Args:
lines_data: 文本内容列表
line_configs: 行配置列表
alignment: 对齐方式
max_width: 最大宽度限制(可选)
background_color: 背景颜色
inline: If True, render segments horizontally on same line instead of stacking
Returns:
svg_string: SVG XML字符串
dimensions: (width, height)
"""
# Use unified layout system
return self.render_svg_unified(lines_data, line_configs, alignment, max_width, background_color)
def _render_inline_segment(self, text, line_config, y_position, x_position, alignment):
"""
Render a single segment for inline layout (horizontal positioning)
Args:
text: Text content
line_config: Line configuration
y_position: Y position (baseline)
x_position: X position (left edge)
alignment: Alignment (ignored for inline, always uses start)
Returns:
(elements, line_height)
"""
font_size = line_config['font_size']
line_height = font_size * LINE_HEIGHT_RATIO
elements = []
# Add background rectangle if exists
background_config = line_config.get('background', False)
if background_config:
bg_padding = background_config.get('padding', 10)
bg_color = background_config.get('color', '#000000')
bg_radius = background_config.get('radius', background_config.get('border_radius', 0))
# 测量文本实际bbox(相对于baseline)
spacing_value = 0
ls = line_config.get('letter_spacing', 0)
if isinstance(ls, str) and ls.endswith('em'):
spacing_value = float(ls[:-2])
elif isinstance(ls, (int, float)):
spacing_value = ls
font_family = line_config.get('font_family') or 'Arial'
font_weight = _normalize_weight_for_output(
font_family,
line_config.get('font_weight') or 'normal',
)
font_style = line_config.get('font_style') or 'normal'
measurement_family = _font_family_for_measurement(
font_family,
font_weight,
font_style,
)
ascent, descent, text_width, text_height = measure_text_bbox(
text,
measurement_family,
int(font_size),
font_weight,
font_style,
spacing_value
)
rect_x = x_position - bg_padding
rect_y = y_position - ascent - bg_padding
rect_width = text_width + 2*bg_padding
rect_height = text_height + 2*bg_padding
rect_elem = ET.Element('rect')
rect_elem.set('x', str(rect_x))
rect_elem.set('y', str(rect_y))
rect_elem.set('width', str(rect_width))
rect_elem.set('height', str(rect_height))
rect_elem.set('fill', bg_color)
if bg_radius > 0:
rect_elem.set('rx', str(bg_radius))
rect_elem.set('ry', str(bg_radius))
elements.append(rect_elem)
# Create text element
text_elem = ET.Element('text')
text_elem.set('x', str(x_position))
text_elem.set('y', str(y_position))
text_elem.set(
'font-family',
_font_family_for_output(line_config.get('font_family') or 'Arial'),
)
text_elem.set('font-size', f"{font_size}px")
text_elem.set('font-weight', _normalize_weight_for_output(
line_config.get('font_family'),
line_config.get('font_weight') or 'normal',
))
text_elem.set('text-anchor', 'start') # Always start for inline
text_elem.set('fill', line_config.get('final_color') or '#000000')
# Font style (only set if not None and not 'normal')
font_style = line_config.get('font_style')
if font_style and font_style != 'normal':
text_elem.set('font-style', font_style)
# Letter spacing
if line_config.get('letter_spacing'):
ls = line_config['letter_spacing']
if isinstance(ls, (int, float)) and ls != 0:
text_elem.set('letter-spacing', f"{ls}em")
elif isinstance(ls, str):
text_elem.set('letter-spacing', ls)
text_elem.text = text
elements.append(text_elem)
# 计算line_height:如果有背景,需要加上padding
line_height = font_size * LINE_HEIGHT_RATIO
if background_config:
bg_padding = background_config.get('padding', 10)
line_height = max(line_height, font_size + 2*bg_padding)
return elements, line_height