Skip to content

Commit 91c56b3

Browse files
authored
Remove SVG export option from visual designer (#19302)
The html-to-image library relies on `foreignObject` for SVG export, which largely defeats the purpose of using SVG. This PR removes SVG export support altogether ## Changes - Remove `svg` from `ExportFormat` type (now `png | jpeg`) - Remove SVG from the format dropdown in `ExportToolbar` - Remove `toSvg` import and the `svg` capture case - Remove ~270 lines of SVG post-processing helpers - Remove `svg` entries from `FORMAT_MIME` / `FORMAT_DESC`
1 parent 6a55467 commit 91c56b3

File tree

3 files changed

+4
-277
lines changed

3 files changed

+4
-277
lines changed

src/vscode-bicep-ui/apps/visual-designer/src/features/export/ExportToolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ const $PaddingInput = styled.input`
213213
/* Constants */
214214
/* ------------------------------------------------------------------ */
215215

216-
const FORMATS: ExportFormat[] = ["svg", "png", "jpeg"];
216+
const FORMATS: ExportFormat[] = ["png", "jpeg"];
217217

218218
const THEME_OPTIONS: { label: string; value: DefaultTheme["name"] | null }[] = [
219219
{ label: "Current", value: null },

src/vscode-bicep-ui/apps/visual-designer/src/features/export/capture-element.ts

Lines changed: 2 additions & 275 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import type { ExportFormat } from "./types";
55

6-
import { toJpeg, toPng, toSvg } from "html-to-image";
6+
import { toJpeg, toPng } from "html-to-image";
77
import { getDefaultStore } from "jotai";
88
import { nodesByIdAtom } from "@/lib/graph";
99

@@ -65,272 +65,6 @@ function findTransformedElement(root: HTMLElement): HTMLElement | null {
6565
return null;
6666
}
6767

68-
/**
69-
* Snapshot the computed style of a blank detached <div> to learn
70-
* every default property value.
71-
*/
72-
function computeCssDefaults(): Map<string, string> {
73-
const el = document.createElement("div");
74-
document.body.appendChild(el);
75-
const cs = getComputedStyle(el);
76-
const defaults = new Map<string, string>();
77-
for (let i = 0; i < cs.length; i++) {
78-
const prop = cs[i]!;
79-
defaults.set(prop, cs.getPropertyValue(prop));
80-
}
81-
document.body.removeChild(el);
82-
return defaults;
83-
}
84-
85-
/**
86-
* Properties that never affect the visual output of a static SVG export.
87-
*/
88-
const NON_VISUAL_PROPS = new Set([
89-
"cursor",
90-
"caret-color",
91-
"pointer-events",
92-
"user-select",
93-
"-webkit-user-select",
94-
"touch-action",
95-
"resize",
96-
"outline",
97-
"outline-color",
98-
"outline-offset",
99-
"outline-style",
100-
"outline-width",
101-
"orphans",
102-
"widows",
103-
"page",
104-
"page-break-after",
105-
"page-break-before",
106-
"page-break-inside",
107-
"break-after",
108-
"break-before",
109-
"break-inside",
110-
"accent-color",
111-
"appearance",
112-
"backface-visibility",
113-
"buffered-rendering",
114-
"contain",
115-
"container",
116-
"container-name",
117-
"container-type",
118-
"content-visibility",
119-
"forced-color-adjust",
120-
"image-orientation",
121-
"image-rendering",
122-
"interpolate-size",
123-
"isolation",
124-
"math-depth",
125-
"math-shift",
126-
"math-style",
127-
"mix-blend-mode",
128-
"object-fit",
129-
"object-position",
130-
"object-view-box",
131-
"perspective",
132-
"perspective-origin",
133-
"print-color-adjust",
134-
"ruby-align",
135-
"ruby-position",
136-
"shape-image-threshold",
137-
"shape-margin",
138-
"shape-outside",
139-
"speak",
140-
"table-layout",
141-
"text-combine-upright",
142-
"text-orientation",
143-
"text-size-adjust",
144-
"timeline-scope",
145-
"unicode-bidi",
146-
"will-change",
147-
"writing-mode",
148-
"counter-increment",
149-
"counter-reset",
150-
"counter-set",
151-
"content",
152-
]);
153-
154-
/** Prefix families that are entirely non-visual for a static export. */
155-
const NON_VISUAL_PREFIXES = [
156-
"animation",
157-
"transition",
158-
"scroll-",
159-
"scrollbar-",
160-
"overscroll-",
161-
"contain-intrinsic-",
162-
"view-transition-",
163-
"view-timeline-",
164-
"scroll-timeline-",
165-
"anchor-",
166-
"app-region",
167-
];
168-
169-
function isNonVisualProperty(prop: string): boolean {
170-
if (prop.startsWith("--")) return true;
171-
if (NON_VISUAL_PROPS.has(prop)) return true;
172-
return NON_VISUAL_PREFIXES.some((pfx) => prop.startsWith(pfx));
173-
}
174-
175-
/**
176-
* Inheritable properties must be preserved even when they match the
177-
* browser default, because a standalone SVG has no parent to inherit from.
178-
*/
179-
const INHERITABLE_PROPS = new Set([
180-
"color",
181-
"direction",
182-
"font",
183-
"font-family",
184-
"font-size",
185-
"font-style",
186-
"font-variant",
187-
"font-weight",
188-
"font-stretch",
189-
"font-size-adjust",
190-
"letter-spacing",
191-
"line-height",
192-
"text-align",
193-
"text-indent",
194-
"text-transform",
195-
"white-space-collapse",
196-
"word-spacing",
197-
"word-break",
198-
"visibility",
199-
"cursor",
200-
"-webkit-text-fill-color",
201-
"-webkit-text-stroke",
202-
"fill",
203-
"fill-opacity",
204-
"fill-rule",
205-
"stroke",
206-
"stroke-dasharray",
207-
"stroke-dashoffset",
208-
"stroke-linecap",
209-
"stroke-linejoin",
210-
"stroke-miterlimit",
211-
"stroke-opacity",
212-
"stroke-width",
213-
]);
214-
215-
/**
216-
* Remove custom properties, known non-visual declarations,
217-
* and declarations whose values match the browser default.
218-
*/
219-
function stripBloatedDeclarations(style: string, cssDefaults: Map<string, string>): string {
220-
return style
221-
.split(";")
222-
.filter((decl) => {
223-
const trimmed = decl.trim();
224-
if (!trimmed) return false;
225-
const colonIdx = trimmed.indexOf(":");
226-
if (colonIdx === -1) return false;
227-
const prop = trimmed.substring(0, colonIdx).trim().toLowerCase();
228-
if (isNonVisualProperty(prop)) return false;
229-
if (INHERITABLE_PROPS.has(prop)) return true;
230-
const val = trimmed.substring(colonIdx + 1).trim();
231-
const defaultVal = cssDefaults.get(prop);
232-
if (defaultVal !== undefined && val === defaultVal) return false;
233-
return true;
234-
})
235-
.map((d) => d.trim())
236-
.join("; ");
237-
}
238-
239-
/**
240-
* Strip bloat from the SVG data URL produced by html-to-image.
241-
*/
242-
function cleanSvgDataUrl(dataUrl: string, cssDefaults: Map<string, string>): string {
243-
const prefix = "data:image/svg+xml;charset=utf-8,";
244-
if (!dataUrl.startsWith(prefix)) return dataUrl;
245-
246-
try {
247-
const svgText = decodeURIComponent(dataUrl.slice(prefix.length));
248-
const doc = new DOMParser().parseFromString(svgText, "image/svg+xml");
249-
250-
if (doc.querySelector("parsererror")) {
251-
console.warn("SVG parse error, returning original");
252-
return dataUrl;
253-
}
254-
255-
const root = doc.documentElement;
256-
const elements: Element[] = [root];
257-
const tw = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
258-
let n: Node | null;
259-
while ((n = tw.nextNode())) elements.push(n as Element);
260-
261-
for (const el of elements) {
262-
if (el.localName === "style") {
263-
el.parentNode?.removeChild(el);
264-
continue;
265-
}
266-
267-
el.removeAttribute("class");
268-
el.removeAttribute("data-testid");
269-
270-
const raw = el.getAttribute("style");
271-
if (raw) {
272-
const cleaned = stripBloatedDeclarations(raw, cssDefaults);
273-
if (cleaned) {
274-
el.setAttribute("style", cleaned);
275-
} else {
276-
el.removeAttribute("style");
277-
}
278-
}
279-
}
280-
281-
// --- Pass 2: deduplicate inline styles into shared CSS classes ---
282-
const remaining: Element[] = [];
283-
const tw2 = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
284-
let n2: Node | null = root;
285-
while (n2) {
286-
remaining.push(n2 as Element);
287-
n2 = tw2.nextNode();
288-
}
289-
290-
const styleCounts = new Map<string, number>();
291-
for (const el of remaining) {
292-
const s = el.getAttribute("style");
293-
if (s) styleCounts.set(s, (styleCounts.get(s) ?? 0) + 1);
294-
}
295-
296-
const styleToClass = new Map<string, string>();
297-
let classIdx = 0;
298-
for (const [style, count] of styleCounts) {
299-
if (count >= 2) {
300-
styleToClass.set(style, `s${classIdx++}`);
301-
}
302-
}
303-
304-
if (styleToClass.size > 0) {
305-
for (const el of remaining) {
306-
const s = el.getAttribute("style");
307-
if (s && styleToClass.has(s)) {
308-
el.removeAttribute("style");
309-
el.setAttribute("class", styleToClass.get(s)!);
310-
}
311-
}
312-
313-
let css = "";
314-
for (const [style, cls] of styleToClass) {
315-
css += `.${cls} { ${style} }\n`;
316-
}
317-
318-
const defs =
319-
root.querySelector("defs") ??
320-
root.insertBefore(doc.createElementNS("http://www.w3.org/2000/svg", "defs"), root.firstChild);
321-
const styleEl = doc.createElementNS("http://www.w3.org/2000/svg", "style");
322-
styleEl.textContent = css;
323-
defs.appendChild(styleEl);
324-
}
325-
326-
const out = new XMLSerializer().serializeToString(root);
327-
return prefix + encodeURIComponent(out);
328-
} catch (error) {
329-
console.warn("SVG cleanup failed, returning original:", error);
330-
return dataUrl;
331-
}
332-
}
333-
33468
/**
33569
* Capture the graph by cloning the canvas into an off-screen element.
33670
*
@@ -407,12 +141,7 @@ export async function captureGraphElement(
407141
},
408142
};
409143

410-
// Snapshot CSS defaults while we have live DOM access.
411-
const cssDefaults = computeCssDefaults();
412-
413144
switch (format) {
414-
case "svg":
415-
return cleanSvgDataUrl(await toSvg(clone, options), cssDefaults);
416145
case "png":
417146
return await toPng(clone, { ...options, pixelRatio: 2 });
418147
case "jpeg":
@@ -429,14 +158,12 @@ export async function captureGraphElement(
429158

430159
/** MIME types for each export format. */
431160
const FORMAT_MIME: Record<ExportFormat, string> = {
432-
svg: "image/svg+xml",
433161
png: "image/png",
434162
jpeg: "image/jpeg",
435163
};
436164

437165
/** File extension descriptions for the Save dialog. */
438166
const FORMAT_DESC: Record<ExportFormat, string> = {
439-
svg: "SVG Image",
440167
png: "PNG Image",
441168
jpeg: "JPEG Image",
442169
};
@@ -454,7 +181,7 @@ function dataUrlToBlob(dataUrl: string): Blob {
454181
for (let i = 0; i < bytes.length; i++) buf[i] = bytes.charCodeAt(i);
455182
return new Blob([buf], { type: mime });
456183
}
457-
// charset=utf-8, URI-encoded (SVG path)
184+
// Fallback: URI-encoded data URL.
458185
const commaIdx = dataUrl.indexOf(",");
459186
const meta = dataUrl.substring(0, commaIdx);
460187
const mime = meta.split(":")[1]?.split(";")[0] ?? "application/octet-stream";
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
export type ExportFormat = "svg" | "png" | "jpeg";
4+
export type ExportFormat = "png" | "jpeg";

0 commit comments

Comments
 (0)