Spaces:
Running on Zero
Running on Zero
File size: 6,973 Bytes
a067ada 964b628 a067ada a172c0c a067ada a172c0c a067ada 964b628 a067ada 964b628 a067ada 964b628 a067ada 964b628 a067ada 964b628 a067ada 964b628 a067ada 964b628 a067ada 89360e0 a067ada 89360e0 964b628 89360e0 964b628 89360e0 964b628 89360e0 964b628 89360e0 964b628 89360e0 964b628 89360e0 a067ada | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | """
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 "<svg" not in svg.lower():
return False
if "</svg>" 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 "<svg" not in svg:
return svg
svg = _strip_plotly_chrome(svg)
svg = _ensure_viewbox(svg)
svg = _ensure_responsive(svg)
svg = _normalize_fonts(svg)
svg = _normalize_strokes(svg)
svg = _wrap_with_theme(svg)
return svg
def _strip_plotly_chrome(svg: str) -> str:
"""Remove Plotly's modebar, watermark and toolbar artifacts."""
# Plotly draws a modebar group — strip it
svg = re.sub(
r'<g[^>]*class="[^"]*modebar[^"]*"[^>]*>.*?</g>',
"",
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"<svg",
f'<svg viewBox="0 0 {w_num} {h_num}"',
svg,
count=1,
flags=re.IGNORECASE,
)
def _ensure_responsive(svg: str) -> 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"<svg",
'<svg preserveAspectRatio="xMidYMid meet"',
svg,
count=1,
flags=re.IGNORECASE,
)
# Merge style: if existing style="" or style="..." present, append; else add
style_value = "width:100%;height:auto;display:block"
if re.search(r'<svg[^>]*\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'(<svg[^>]*\s)style\s*=\s*"([^"]*)"',
_merge, svg, count=1, flags=re.IGNORECASE,
)
else:
svg = re.sub(
r"<svg",
f'<svg style="{style_value}"',
svg, count=1, flags=re.IGNORECASE,
)
return svg
def _normalize_fonts(svg: str) -> 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 <style> block scoped to the SVG with explicit hex colors.
These work both in-app and as a standalone downloaded file."""
style = f"""<style>
.chart-bg {{ fill: transparent; }}
.chart-ink, text {{ fill: {INK}; font-family: {FONT_FAMILY}; }}
.chart-muted {{ fill: {INK_MUTED}; }}
.chart-grid {{ stroke: {INK_FAINT}; stroke-width: 0.75; }}
.chart-accent {{ fill: {ACCENT}; stroke: {ACCENT}; }}
</style>"""
return re.sub(r"(<svg[^>]*>)", 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 "<svg" not in svg:
return svg
out = svg.strip()
# Add xmlns if missing — this is the only requirement for standalone files
if "xmlns=" not in out[:200]:
out = re.sub(
r"<svg(?![^>]*xmlns=)",
'<svg xmlns="http://www.w3.org/2000/svg"',
out, count=1, flags=re.IGNORECASE,
)
if "xmlns:xlink=" not in out[:300]:
out = re.sub(
r"<svg(?![^>]*xmlns:xlink=)",
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"',
out, count=1, flags=re.IGNORECASE,
)
# XML prolog
if not out.startswith("<?xml"):
out = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + out
return out
def _attr(svg: str, name: str) -> str | None:
m = re.search(rf'\s{name}\s*=\s*"([^"]+)"', svg, re.IGNORECASE)
return m.group(1) if m else None
|