DanielRegaladoCardoso commited on
Commit
964b628
·
verified ·
1 Parent(s): 180e51f

Fix SVG download: use single quotes inside style attr, no duplicate style

Browse files
Files changed (1) hide show
  1. 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:{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
- """Make the SVG self-contained for download:
149
- - Add XML namespace declarations
150
- - Ensure xmlns:xlink for href attributes
151
- - Add explicit white background for visibility outside the app
152
- - Add explicit width/height for file viewers that ignore viewBox
153
  """
154
  if not svg or "<svg" not in svg:
155
  return svg
156
 
157
- out = svg
158
 
159
- # Add XML namespace if missing
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