""" SVG post-processor: enforce a consistent Apple/Claude aesthetic on any SVG (whether produced by the trained model or the Plotly fallback). The output should: - Be responsive (viewBox + 100% width) - Use a single warm accent (#C96442) plus monochrome ink (#0E0E0E / #5A5A5A) - Use SF Pro / system font stack - Use thin strokes (1.25-1.5px) and no chrome - Include light grid lines instead of axes lines """ import re from xml.etree import ElementTree as ET # Theme constants — keep in sync with app CSS ACCENT = "#C96442" INK = "#0E0E0E" INK_MUTED = "#5A5A5A" INK_FAINT = "#E5E5E5" SURFACE = "#FAFAF9" FONT_FAMILY = ( '-apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", ' '"Helvetica Neue", Arial, sans-serif' ) # Variant with single quotes for use INSIDE style="..." attributes — double # quotes inside double-quoted attributes break XML parsing on download. FONT_FAMILY_SINGLE = FONT_FAMILY.replace('"', "'") def is_renderable_svg(svg: str) -> bool: """Cheap structural validity check — does this look like a real SVG with content?""" if not svg or "" not in svg.lower(): return False # Require at least a few drawing primitives primitives = sum(svg.lower().count(f"<{tag}") for tag in ("rect", "path", "line", "circle", "polygon", "polyline", "text", "g ")) return primitives >= 3 def apply_theme(svg: str) -> str: """Normalize an SVG to the project's visual language.""" if not svg or " str: """Remove Plotly's modebar, watermark and toolbar artifacts.""" # Plotly draws a modebar group — strip it svg = re.sub( r']*class="[^"]*modebar[^"]*"[^>]*>.*?', "", svg, flags=re.DOTALL, ) # Remove explicit white backgrounds that Plotly adds svg = re.sub( r'fill="(?:rgb\(255,\s*255,\s*255\)|#fff(?:fff)?|white)"', 'fill="transparent"', svg, flags=re.IGNORECASE, ) return svg def _ensure_viewbox(svg: str) -> str: """If width/height are present but viewBox is missing, derive it.""" if re.search(r"viewBox\s*=", svg, re.IGNORECASE): return svg w = _attr(svg, "width") or "600" h = _attr(svg, "height") or "400" w_num = re.sub(r"[^\d.]", "", w) or "600" h_num = re.sub(r"[^\d.]", "", h) or "400" return re.sub( r" str: """Strip explicit width/height so the SVG fills its container responsively. Adds preserveAspectRatio and style only if not already present, and merges style values to avoid duplicate style attributes (which break XML parse).""" # Drop explicit width/height svg = re.sub(r'\s(width|height)="[^"]*"', "", svg, flags=re.IGNORECASE, count=2) if "preserveAspectRatio" not in svg: svg = re.sub( r"]*\sstyle\s*=', svg, re.IGNORECASE): # Already has a style attr — merge our values into it def _merge(m): existing = m.group(2).strip().rstrip(";") merged = (existing + ";" if existing else "") + style_value return f'{m.group(1)}style="{merged}"' svg = re.sub( r'(]*\s)style\s*=\s*"([^"]*)"', _merge, svg, count=1, flags=re.IGNORECASE, ) else: svg = re.sub( r" str: """Force the system font stack on all text. Use the single-quote variant when injecting INTO a style="..." attribute, double-quote when standalone.""" # Standalone font-family attribute: font-family="..." svg = re.sub( r'font-family\s*=\s*"[^"]*"', f'font-family="{FONT_FAMILY}"', svg, flags=re.IGNORECASE, ) # Inline in style attribute: font-family:... — must use single quotes # so we don't break the surrounding double-quoted style attribute svg = re.sub( r"font-family\s*:\s*[^;\"']+", f"font-family:{FONT_FAMILY_SINGLE}", svg, flags=re.IGNORECASE, ) return svg def _normalize_strokes(svg: str) -> str: """Make all strokes thin and consistent.""" svg = re.sub( r'stroke-width\s*=\s*"[^"]*"', 'stroke-width="1.25"', svg, flags=re.IGNORECASE, ) return svg def _wrap_with_theme(svg: str) -> str: """Inject a """ return re.sub(r"(]*>)", r"\1" + style, svg, count=1, flags=re.IGNORECASE) def to_standalone_svg(svg: str) -> str: """Minimal hardening for downloaded SVG: - Ensure XML namespace declarations are present - Add XML prolog - Do NOT touch existing attributes / dimensions / inner content (heavier post-processing was breaking the XML for some Plotly outputs) """ if not svg or "]*xmlns=)", ']*xmlns:xlink=)", ' str | None: m = re.search(rf'\s{name}\s*=\s*"([^"]+)"', svg, re.IGNORECASE) return m.group(1) if m else None