| 1 | function ensureSvgDimensions(svg: SVGSVGElement, container: Element) { |
| 2 | const rect = container.getBoundingClientRect(); |
| 3 | const width = Math.max(1, Math.round(rect.width)); |
| 4 | const height = Math.max(1, Math.round(rect.height)); |
| 5 | |
| 6 | if (!svg.getAttribute("width")) { |
| 7 | svg.setAttribute("width", String(width)); |
| 8 | } |
| 9 | if (!svg.getAttribute("height")) { |
| 10 | svg.setAttribute("height", String(height)); |
| 11 | } |
| 12 | if (!svg.getAttribute("viewBox")) { |
| 13 | svg.setAttribute("viewBox", `0 0 ${width} ${height}`); |
| 14 | } |
| 15 | svg.setAttribute("xmlns", "http://www.w3.org/2000/svg"); |
| 16 | } |
| 17 | |
| 18 | const SVG_PRESENTATION_ATTRIBUTES = [ |
| 19 | "color", |
| 20 | "fill", |
| 21 | "flood-color", |
| 22 | "font-family", |
| 23 | "stroke", |
| 24 | "stop-color", |
| 25 | ] as const; |
| 26 | |
| 27 | function shouldResolveSvgValue(value: string): boolean { |
| 28 | const normalized = value.trim(); |
| 29 | |
| 30 | return ( |
| 31 | normalized === "currentColor" || |
| 32 | normalized === "inherit" || |
| 33 | normalized.includes("var(") |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | function getResolvedStyleValue( |
| 38 | computedStyle: CSSStyleDeclaration, |
| 39 | property: string, |
| 40 | ): string { |
| 41 | return property === "color" |
| 42 | ? computedStyle.color |
| 43 | : computedStyle.getPropertyValue(property); |
| 44 | } |
| 45 | |
| 46 | function inlineResolvedAttribute( |
| 47 | sourceElement: Element, |
| 48 | clonedElement: Element, |
| 49 | computedStyle: CSSStyleDeclaration, |
| 50 | attribute: (typeof SVG_PRESENTATION_ATTRIBUTES)[number], |
| 51 | ): void { |
| 52 | const value = clonedElement.getAttribute(attribute); |
| 53 | if (!value || !shouldResolveSvgValue(value)) return; |
| 54 | |
| 55 | const resolvedValue = getResolvedStyleValue(computedStyle, attribute).trim(); |
| 56 | if (!resolvedValue) return; |
| 57 | |
| 58 | clonedElement.setAttribute(attribute, resolvedValue); |
| 59 | |
| 60 | if (attribute === "color") { |
| 61 | return; |
| 62 | } |
| 63 | |
| 64 | const sourceColor = getResolvedStyleValue( |
| 65 | getComputedStyle(sourceElement), |
| 66 | "color", |
| 67 | ).trim(); |
| 68 | if (value.trim() === "currentColor" && sourceColor) { |
| 69 | clonedElement.setAttribute(attribute, sourceColor); |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | function inlineResolvedStyleProperties( |
| 74 | clonedElement: Element, |
| 75 | computedStyle: CSSStyleDeclaration, |
| 76 | ): void { |
| 77 | if (!(clonedElement instanceof SVGElement)) return; |
| 78 | |
| 79 | for (const attribute of SVG_PRESENTATION_ATTRIBUTES) { |
| 80 | const value = clonedElement.style.getPropertyValue(attribute); |
| 81 | if (!value || !shouldResolveSvgValue(value)) continue; |
| 82 | |
| 83 | const resolvedValue = getResolvedStyleValue( |
| 84 | computedStyle, |
| 85 | attribute, |
| 86 | ).trim(); |
| 87 | if (!resolvedValue) continue; |
| 88 | |
| 89 | clonedElement.style.setProperty(attribute, resolvedValue); |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | function inlineComputedSvgPresentationStyles( |
| 94 | sourceSvg: SVGSVGElement, |
| 95 | clonedSvg: SVGSVGElement, |
| 96 | ): void { |
| 97 | const sourceElements = [ |
| 98 | sourceSvg, |
| 99 | ...Array.from(sourceSvg.querySelectorAll("*")), |
| 100 | ]; |
| 101 | const clonedElements = [ |
| 102 | clonedSvg, |
| 103 | ...Array.from(clonedSvg.querySelectorAll("*")), |
| 104 | ]; |
| 105 | |
| 106 | sourceElements.forEach((sourceElement, index) => { |
| 107 | const clonedElement = clonedElements[index]; |
| 108 | if (!clonedElement) return; |
| 109 | |
| 110 | const computedStyle = getComputedStyle(sourceElement); |
| 111 | |
| 112 | for (const attribute of SVG_PRESENTATION_ATTRIBUTES) { |
| 113 | inlineResolvedAttribute( |
| 114 | sourceElement, |
| 115 | clonedElement, |
| 116 | computedStyle, |
| 117 | attribute, |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | inlineResolvedStyleProperties(clonedElement, computedStyle); |
| 122 | }); |
| 123 | } |
| 124 | |
| 125 | function svgSourceToBase64DataUrl(source: string): string { |
| 126 | const bytes = new TextEncoder().encode(source); |
| 127 | let binary = ""; |
| 128 | for (const byte of bytes) { |
| 129 | binary += String.fromCharCode(byte); |
| 130 | } |
| 131 | |
| 132 | return `data:image/svg+xml;base64,${btoa(binary)}`; |
| 133 | } |
| 134 | |
| 135 | export function getEChartSvgDataUrl(element: Element): string | null { |
| 136 | const chartRoot = element.matches("[data-echart='true']") |
| 137 | ? element |
| 138 | : element.querySelector("[data-echart='true']"); |
| 139 | if (!chartRoot) return null; |
| 140 | |
| 141 | const svg = chartRoot.querySelector("svg"); |
| 142 | if (!(svg instanceof SVGSVGElement)) return null; |
| 143 | |
| 144 | const clonedSvg = svg.cloneNode(true); |
| 145 | if (!(clonedSvg instanceof SVGSVGElement)) return null; |
| 146 | |
| 147 | ensureSvgDimensions(clonedSvg, chartRoot); |
| 148 | inlineComputedSvgPresentationStyles(svg, clonedSvg); |
| 149 | |
| 150 | const source = new XMLSerializer().serializeToString(clonedSvg); |
| 151 | return svgSourceToBase64DataUrl(source); |
| 152 | } |
| 153 |