diff --git a/e2e-tests/plot.spec.ts b/e2e-tests/plot.spec.ts index 7e3895041..b1229ec38 100644 --- a/e2e-tests/plot.spec.ts +++ b/e2e-tests/plot.spec.ts @@ -4,6 +4,103 @@ import { beforeTest } from './common'; test.beforeEach(beforeTest); +test('SVG download includes foreignObject content and preserves hidden bookmark styling', async ({ + page, +}) => { + await page.goto( + 'http://localhost:3000/?workspace=Upset+Examples&table=simpsons&sessionId=193', + ); + + await page.evaluate(() => { + const svgDownloadState = window as typeof window & { + __capturedSvgBlob?: Blob; + __originalCreateObjectURL?: typeof URL.createObjectURL; + __originalAnchorClick?: typeof HTMLAnchorElement.prototype.click; + }; + + svgDownloadState.__originalCreateObjectURL = URL.createObjectURL; + svgDownloadState.__originalAnchorClick = HTMLAnchorElement.prototype.click; + + URL.createObjectURL = ((object: Blob | MediaSource) => { + if (object instanceof Blob && object.type === 'image/svg+xml') { + svgDownloadState.__capturedSvgBlob = object; + } + + return 'blob:captured-svg'; + }) as typeof URL.createObjectURL; + + HTMLAnchorElement.prototype.click = () => {}; + }); + + await page.getByLabel('Additional options menu').click(); + await page.getByRole('menuitem', { name: 'SVG Download of this upset plot' }).click(); + + const exportDetails = await page.evaluate(async () => { + const svgDownloadState = window as typeof window & { + __capturedSvgBlob?: Blob; + __originalCreateObjectURL?: typeof URL.createObjectURL; + __originalAnchorClick?: typeof HTMLAnchorElement.prototype.click; + }; + + const svgText = await svgDownloadState.__capturedSvgBlob?.text(); + + if (svgDownloadState.__originalCreateObjectURL) { + URL.createObjectURL = svgDownloadState.__originalCreateObjectURL; + } + + if (svgDownloadState.__originalAnchorClick) { + HTMLAnchorElement.prototype.click = svgDownloadState.__originalAnchorClick; + } + + if (!svgText) { + return { + bloatedSvgNodeCount: 0, + foreignObjectCount: 0, + xhtmlNodeCount: 0, + hiddenMuiIconCount: 0, + }; + } + + const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); + const hiddenMuiIconCount = Array.from( + doc.querySelectorAll('svg.MuiSvgIcon-root'), + ).filter((icon) => + /(?:opacity|fill-opacity):\s*0(?:;|$)/.test(icon.getAttribute('style') ?? ''), + ).length; + const bloatedSvgNodeCount = Array.from(doc.querySelectorAll('[style]')).filter( + (element) => { + if ( + element.closest('foreignObject') || + element.classList.contains('MuiSvgIcon-root') + ) { + return false; + } + + const styleDeclarationCount = (element.getAttribute('style') ?? '') + .split(';') + .map((declaration) => declaration.trim()) + .filter(Boolean).length; + + return styleDeclarationCount > 10; + }, + ).length; + + return { + bloatedSvgNodeCount, + foreignObjectCount: doc.querySelectorAll('foreignObject').length, + xhtmlNodeCount: Array.from(doc.querySelectorAll('foreignObject *')).filter( + (element) => element.getAttribute('xmlns') === 'http://www.w3.org/1999/xhtml', + ).length, + hiddenMuiIconCount, + }; + }); + + expect(exportDetails.foreignObjectCount).toBeGreaterThan(0); + expect(exportDetails.xhtmlNodeCount).toBeGreaterThan(0); + expect(exportDetails.hiddenMuiIconCount).toBeGreaterThan(0); + expect(exportDetails.bloatedSvgNodeCount).toBe(0); +}); + /** * Toggles the advanced scale slider. Must be awaited * @param page page provided to test function diff --git a/packages/upset/src/components/Upset.tsx b/packages/upset/src/components/Upset.tsx index a77ff874f..769780b7e 100644 --- a/packages/upset/src/components/Upset.tsx +++ b/packages/upset/src/components/Upset.tsx @@ -1,14 +1,12 @@ import { Box, ThemeProvider } from '@mui/material'; -import { FC, useMemo } from 'react'; +import { ComponentType, FC, PropsWithChildren, useMemo } from 'react'; import { RecoilRoot } from 'recoil'; import defaultTheme from '../utils/theme'; import { Root } from './Root'; import { UpsetProps } from '../types'; import { processRawData } from '../utils/data'; -const RecoilRootCompat = RecoilRoot as unknown as React.ComponentType< - React.PropsWithChildren ->; +const RecoilRootCompat = RecoilRoot as unknown as ComponentType; /** * Renders the Upset component. diff --git a/packages/upset/src/utils/downloads.ts b/packages/upset/src/utils/downloads.ts index 587f8c9f0..c3da9a60d 100644 --- a/packages/upset/src/utils/downloads.ts +++ b/packages/upset/src/utils/downloads.ts @@ -1,3 +1,139 @@ +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; +const XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; +const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink'; + +const FOREIGN_OBJECT_HTML_STYLE_PROPERTIES = [ + 'display', + 'visibility', + 'opacity', + 'overflow', + 'overflow-x', + 'overflow-y', + 'width', + 'height', + 'max-width', + 'max-height', + 'min-width', + 'min-height', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'color', + 'font-family', + 'font-size', + 'font-weight', + 'font-style', + 'line-height', + 'letter-spacing', + 'text-align', + 'text-overflow', + 'text-wrap', + 'white-space', + 'word-break', + 'box-sizing', + 'position', +] as const; + +const FOREIGN_OBJECT_SVG_STYLE_PROPERTIES = [ + 'display', + 'visibility', + 'opacity', + 'overflow', + 'width', + 'height', + 'max-width', + 'max-height', + 'position', +] as const; + +const SVG_ICON_STYLE_PROPERTIES = [ + 'display', + 'visibility', + 'opacity', + 'overflow', + 'color', + 'fill', + 'fill-opacity', +] as const; + +const isMuiSvgIcon = (element: Element) => element.classList.contains('MuiSvgIcon-root'); + +const inlineSelectedStyles = ( + sourceElement: Element, + clonedElement: Element, + properties: readonly string[], +) => { + const computedStyles = window.getComputedStyle(sourceElement); + const styledElement = clonedElement as HTMLElement | SVGElement; + + properties.forEach((property) => { + if (styledElement.style.getPropertyValue(property)) { + return; + } + + const value = computedStyles.getPropertyValue(property); + + if (!value) { + return; + } + + styledElement.style.setProperty( + property, + value, + computedStyles.getPropertyPriority(property), + ); + }); +}; + +const serializeExportSubtree = ( + sourceElement: Element, + clonedElement: Element, + inForeignObject = false, +) => { + const isInsideForeignObject = + inForeignObject || sourceElement.localName === 'foreignObject'; + + if (sourceElement.namespaceURI === XHTML_NAMESPACE) { + clonedElement.setAttribute('xmlns', XHTML_NAMESPACE); + } + + if (isMuiSvgIcon(sourceElement)) { + inlineSelectedStyles(sourceElement, clonedElement, SVG_ICON_STYLE_PROPERTIES); + } else if (isInsideForeignObject) { + const properties = + sourceElement.namespaceURI === XHTML_NAMESPACE + ? FOREIGN_OBJECT_HTML_STYLE_PROPERTIES + : FOREIGN_OBJECT_SVG_STYLE_PROPERTIES; + + inlineSelectedStyles(sourceElement, clonedElement, properties); + } + + Array.from(sourceElement.children).forEach((child, index) => { + const clonedChild = clonedElement.children.item(index); + + if (clonedChild) { + serializeExportSubtree(child, clonedChild, isInsideForeignObject); + } + }); +}; + +export const serializeSVGForDownload = (svg: SVGSVGElement) => { + const clone = svg.cloneNode(true) as SVGSVGElement; + + clone.setAttribute('xmlns', SVG_NAMESPACE); + clone.setAttribute('xmlns:xlink', XLINK_NAMESPACE); + serializeExportSubtree(svg, clone); + + return new XMLSerializer().serializeToString(clone); +}; + /** * Downloads an SVG file of the current UpSet plot. * @param filename - The name of the downloaded file. Defaults to "upset-plot-[current date]". @@ -5,12 +141,12 @@ export function downloadSVG(filename = `upset-plot-${new Date().toJSON().slice(0, 10)}`) { const svg = document.getElementById('upset-svg'); - if (!svg) { + if (!(svg instanceof SVGSVGElement)) { console.error("Couldn't find SVG element"); return; } - const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' }); + const blob = new Blob([serializeSVGForDownload(svg)], { type: 'image/svg+xml' }); const href = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = href;