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