Spaces:
Sleeping
Sleeping
Fix SVG download: use single quotes inside style attr, no duplicate style
Browse files- src/visualization/svg_theme.py +46 -40
src/visualization/svg_theme.py
CHANGED
|
@@ -25,6 +25,10 @@ FONT_FAMILY = (
|
|
| 25 |
'"Helvetica Neue", Arial, sans-serif'
|
| 26 |
)
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
def is_renderable_svg(svg: str) -> bool:
|
| 30 |
"""Cheap structural validity check — does this look like a real SVG with content?"""
|
|
@@ -89,31 +93,57 @@ def _ensure_viewbox(svg: str) -> str:
|
|
| 89 |
|
| 90 |
|
| 91 |
def _ensure_responsive(svg: str) -> str:
|
| 92 |
-
"""Strip explicit width/height so the SVG fills its container responsively.
|
|
|
|
|
|
|
|
|
|
| 93 |
svg = re.sub(r'\s(width|height)="[^"]*"', "", svg, flags=re.IGNORECASE, count=2)
|
|
|
|
| 94 |
if "preserveAspectRatio" not in svg:
|
| 95 |
svg = re.sub(
|
| 96 |
r"<svg",
|
| 97 |
-
'<svg preserveAspectRatio="xMidYMid meet"
|
| 98 |
-
'style="width:100%;height:auto;display:block"',
|
| 99 |
svg,
|
| 100 |
count=1,
|
| 101 |
flags=re.IGNORECASE,
|
| 102 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
return svg
|
| 104 |
|
| 105 |
|
| 106 |
def _normalize_fonts(svg: str) -> str:
|
| 107 |
-
"""Force the system font stack on all text.
|
|
|
|
|
|
|
| 108 |
svg = re.sub(
|
| 109 |
r'font-family\s*=\s*"[^"]*"',
|
| 110 |
f'font-family="{FONT_FAMILY}"',
|
| 111 |
svg,
|
| 112 |
flags=re.IGNORECASE,
|
| 113 |
)
|
|
|
|
|
|
|
| 114 |
svg = re.sub(
|
| 115 |
r"font-family\s*:\s*[^;\"']+",
|
| 116 |
-
f"font-family:{
|
| 117 |
svg,
|
| 118 |
flags=re.IGNORECASE,
|
| 119 |
)
|
|
@@ -145,55 +175,31 @@ def _wrap_with_theme(svg: str) -> str:
|
|
| 145 |
|
| 146 |
|
| 147 |
def to_standalone_svg(svg: str) -> str:
|
| 148 |
-
"""
|
| 149 |
-
-
|
| 150 |
-
-
|
| 151 |
-
-
|
| 152 |
-
|
| 153 |
"""
|
| 154 |
if not svg or "<svg" not in svg:
|
| 155 |
return svg
|
| 156 |
|
| 157 |
-
out = svg
|
| 158 |
|
| 159 |
-
# Add
|
| 160 |
-
if "xmlns=" not in out:
|
| 161 |
out = re.sub(
|
| 162 |
-
r"<svg",
|
| 163 |
'<svg xmlns="http://www.w3.org/2000/svg"',
|
| 164 |
out, count=1, flags=re.IGNORECASE,
|
| 165 |
)
|
| 166 |
-
if "xmlns:xlink=" not in out:
|
| 167 |
out = re.sub(
|
| 168 |
-
r"<svg",
|
| 169 |
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"',
|
| 170 |
out, count=1, flags=re.IGNORECASE,
|
| 171 |
)
|
| 172 |
|
| 173 |
-
# Pull viewBox dims for width/height fallback
|
| 174 |
-
vb = re.search(r'viewBox\s*=\s*"([\d.\s\-]+)"', out)
|
| 175 |
-
if vb:
|
| 176 |
-
parts = vb.group(1).split()
|
| 177 |
-
if len(parts) == 4:
|
| 178 |
-
w, h = parts[2], parts[3]
|
| 179 |
-
# Replace any existing width/height with explicit pixel values
|
| 180 |
-
out = re.sub(r'\s(width|height)="[^"]*"', "", out, flags=re.IGNORECASE)
|
| 181 |
-
out = re.sub(
|
| 182 |
-
r"<svg",
|
| 183 |
-
f'<svg width="{w}" height="{h}"',
|
| 184 |
-
out, count=1, flags=re.IGNORECASE,
|
| 185 |
-
)
|
| 186 |
-
|
| 187 |
-
# Drop the responsive style so file viewers get fixed dims
|
| 188 |
-
out = re.sub(r'\sstyle="width:100%[^"]*"', "", out, flags=re.IGNORECASE)
|
| 189 |
-
|
| 190 |
-
# Add explicit white background rect at start (for opaque export)
|
| 191 |
-
out = re.sub(
|
| 192 |
-
r"(<svg[^>]*>)",
|
| 193 |
-
r'\1<rect width="100%" height="100%" fill="#FAFAF9"/>',
|
| 194 |
-
out, count=1, flags=re.IGNORECASE,
|
| 195 |
-
)
|
| 196 |
-
|
| 197 |
# XML prolog
|
| 198 |
if not out.startswith("<?xml"):
|
| 199 |
out = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + out
|
|
|
|
| 25 |
'"Helvetica Neue", Arial, sans-serif'
|
| 26 |
)
|
| 27 |
|
| 28 |
+
# Variant with single quotes for use INSIDE style="..." attributes — double
|
| 29 |
+
# quotes inside double-quoted attributes break XML parsing on download.
|
| 30 |
+
FONT_FAMILY_SINGLE = FONT_FAMILY.replace('"', "'")
|
| 31 |
+
|
| 32 |
|
| 33 |
def is_renderable_svg(svg: str) -> bool:
|
| 34 |
"""Cheap structural validity check — does this look like a real SVG with content?"""
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
def _ensure_responsive(svg: str) -> str:
|
| 96 |
+
"""Strip explicit width/height so the SVG fills its container responsively.
|
| 97 |
+
Adds preserveAspectRatio and style only if not already present, and merges
|
| 98 |
+
style values to avoid duplicate style attributes (which break XML parse)."""
|
| 99 |
+
# Drop explicit width/height
|
| 100 |
svg = re.sub(r'\s(width|height)="[^"]*"', "", svg, flags=re.IGNORECASE, count=2)
|
| 101 |
+
|
| 102 |
if "preserveAspectRatio" not in svg:
|
| 103 |
svg = re.sub(
|
| 104 |
r"<svg",
|
| 105 |
+
'<svg preserveAspectRatio="xMidYMid meet"',
|
|
|
|
| 106 |
svg,
|
| 107 |
count=1,
|
| 108 |
flags=re.IGNORECASE,
|
| 109 |
)
|
| 110 |
+
|
| 111 |
+
# Merge style: if existing style="" or style="..." present, append; else add
|
| 112 |
+
style_value = "width:100%;height:auto;display:block"
|
| 113 |
+
if re.search(r'<svg[^>]*\sstyle\s*=', svg, re.IGNORECASE):
|
| 114 |
+
# Already has a style attr — merge our values into it
|
| 115 |
+
def _merge(m):
|
| 116 |
+
existing = m.group(2).strip().rstrip(";")
|
| 117 |
+
merged = (existing + ";" if existing else "") + style_value
|
| 118 |
+
return f'{m.group(1)}style="{merged}"'
|
| 119 |
+
svg = re.sub(
|
| 120 |
+
r'(<svg[^>]*\s)style\s*=\s*"([^"]*)"',
|
| 121 |
+
_merge, svg, count=1, flags=re.IGNORECASE,
|
| 122 |
+
)
|
| 123 |
+
else:
|
| 124 |
+
svg = re.sub(
|
| 125 |
+
r"<svg",
|
| 126 |
+
f'<svg style="{style_value}"',
|
| 127 |
+
svg, count=1, flags=re.IGNORECASE,
|
| 128 |
+
)
|
| 129 |
return svg
|
| 130 |
|
| 131 |
|
| 132 |
def _normalize_fonts(svg: str) -> str:
|
| 133 |
+
"""Force the system font stack on all text. Use the single-quote variant
|
| 134 |
+
when injecting INTO a style="..." attribute, double-quote when standalone."""
|
| 135 |
+
# Standalone font-family attribute: font-family="..."
|
| 136 |
svg = re.sub(
|
| 137 |
r'font-family\s*=\s*"[^"]*"',
|
| 138 |
f'font-family="{FONT_FAMILY}"',
|
| 139 |
svg,
|
| 140 |
flags=re.IGNORECASE,
|
| 141 |
)
|
| 142 |
+
# Inline in style attribute: font-family:... — must use single quotes
|
| 143 |
+
# so we don't break the surrounding double-quoted style attribute
|
| 144 |
svg = re.sub(
|
| 145 |
r"font-family\s*:\s*[^;\"']+",
|
| 146 |
+
f"font-family:{FONT_FAMILY_SINGLE}",
|
| 147 |
svg,
|
| 148 |
flags=re.IGNORECASE,
|
| 149 |
)
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
def to_standalone_svg(svg: str) -> str:
|
| 178 |
+
"""Minimal hardening for downloaded SVG:
|
| 179 |
+
- Ensure XML namespace declarations are present
|
| 180 |
+
- Add XML prolog
|
| 181 |
+
- Do NOT touch existing attributes / dimensions / inner content
|
| 182 |
+
(heavier post-processing was breaking the XML for some Plotly outputs)
|
| 183 |
"""
|
| 184 |
if not svg or "<svg" not in svg:
|
| 185 |
return svg
|
| 186 |
|
| 187 |
+
out = svg.strip()
|
| 188 |
|
| 189 |
+
# Add xmlns if missing — this is the only requirement for standalone files
|
| 190 |
+
if "xmlns=" not in out[:200]:
|
| 191 |
out = re.sub(
|
| 192 |
+
r"<svg(?![^>]*xmlns=)",
|
| 193 |
'<svg xmlns="http://www.w3.org/2000/svg"',
|
| 194 |
out, count=1, flags=re.IGNORECASE,
|
| 195 |
)
|
| 196 |
+
if "xmlns:xlink=" not in out[:300]:
|
| 197 |
out = re.sub(
|
| 198 |
+
r"<svg(?![^>]*xmlns:xlink=)",
|
| 199 |
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"',
|
| 200 |
out, count=1, flags=re.IGNORECASE,
|
| 201 |
)
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
# XML prolog
|
| 204 |
if not out.startswith("<?xml"):
|
| 205 |
out = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + out
|