Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |