diff --git a/components/artifacts/svg-image.tsx b/components/artifacts/svg-image.tsx new file mode 100644 index 0000000..6c74cdd --- /dev/null +++ b/components/artifacts/svg-image.tsx @@ -0,0 +1,37 @@ +/** Two display-only repairs for inputs the old HTML embedding accepted. + * Do not rewrite stored artifacts/downloads or attempt general XML repair. + * Comments, CDATA and processing instructions are copied verbatim. */ +export function svgImageSource(svg: string): string { + let foundRoot = false; + return svg.replace( + /|$)||$)|<\?[\s\S]*?(?:\?>|$)|<\/?[a-zA-Z](?:[^<>"']|"[^"]*"|'[^']*')*>| /g, + (token) => { + if (token.startsWith("]]>'); + assert.equal(svgImageSource(source), source); + }); + + it("adds only a missing root namespace, ignoring comments and attribute values", () => { + const source = '\' xmlns:xlink="http://www.w3.org/1999/xlink">'; + const expected = source.replace('Example`; + assert.equal(svgImageSource(source), source); + } + }); + + it("converts nbsp references in text and attributes, but not comments, CDATA or processing instructions", () => { + const source = 'A B &nbsp;  &nbsp;'; + const expected = source.replace('aria-label="A B"', 'aria-label="A B"').replace('A B', 'A B'); + assert.equal(svgImageSource(source), expected); + assert.equal(svgImageSource(expected), expected); + }); +}); + +describe("generated SVG image boundary", () => { + for (const mode of ["inline", "fullscreen"] as const) { + for (const [name, source] of [["reported style rule and fragment IDs", FIGURE], ["active markup", ACTIVE_MARKUP]] as const) { + it(`${mode}: isolates ${name} inside the image URL`, () => { + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assertImageBoundary(holder, source); + assert.ok(holder.classList.contains("bg-white"), "white backing retained"); + }); + } + + for (const [name, original, display] of [ + ["missing namespace", 'Example', 'Example'], + ["HTML entity", 'A B', 'A B'], + ] as const) { + it(`${mode}: repairs ${name} only in the image source`, () => { + const output = { svg: original }; + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assertImageBoundary(holder, display); + assert.equal(output.svg, original, "artifact source was not mutated"); + }); + } + + it(`${mode}: retains inversion on the white backing`, () => { + const html = renderToStaticMarkup(mode === "inline" + ? + : {}} />); + const { document } = parseHTML(html); + const holder = document.querySelector(mode === "inline" ? '[data-testid="visual-svg"]' : "[data-lh-overlay-chart]")!; + assert.match(holder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/); + assert.ok(holder.classList.contains("bg-white")); + assertImageBoundary(holder, FIGURE); + }); + } + + it("inline: retains natural width and only shrinks oversized figures", () => { + const { document } = parseHTML(renderToStaticMarkup()); + const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, FIGURE); + assert.equal(image.getAttribute("width"), "320"); + assert.equal(image.className, "block h-auto max-w-full"); + assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-fit")); + }); + + for (const width of ["", ' width="100%"']) { + it(`inline: keeps the full-width fallback for ${width || "viewBox-only"} SVG`, () => { + const source = `Example`; + const { document } = parseHTML(renderToStaticMarkup()); + const image = assertImageBoundary(document.querySelector('[data-testid="visual-svg"]')!, source); + assert.equal(image.getAttribute("width"), null); + assert.equal(image.className, "block h-auto w-full"); + assert.ok(document.querySelector('[data-testid="visual-card"]')!.classList.contains("w-full")); + }); + } + + it("fullscreen: keeps the natural-size box and zoom controls", () => { + const { document } = parseHTML(renderToStaticMarkup( {}} />)); + const holder = document.querySelector("[data-lh-overlay-chart]")!; + assert.equal(assertImageBoundary(holder, FIGURE).className, "block h-auto w-full"); + assert.match(holder.parentElement!.getAttribute("style")!, /width:320px/); + for (const label of ["Zoom in", "Zoom out", "Fit diagram to view", "Close"]) { + assert.ok(document.querySelector(`button[aria-label="${label}"]`)); + } + }); +}); + +it("the fullscreen button opens the same SVG image and inversion; download keeps original bytes", async () => { + const originalSvg = FIGURE.replace(' xmlns="http://www.w3.org/2000/svg"', '').replace("θ & φ", "θ & φ"); + const displaySvg = FIGURE.replace("θ & φ", "θ & φ"); + const dom = parseHTML(""); + const names = ["window", "document", "Element", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const; + const originals = names.map((name) => Object.getOwnPropertyDescriptor(globalThis, name)); + Object.assign(globalThis, { + window: dom.window, + document: dom.document, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const host = dom.document.createElement("div"); + dom.document.body.append(host); + const root = createRoot(host); + const originalCreateUrl = URL.createObjectURL; + const originalRevokeUrl = URL.revokeObjectURL; + const originalCreateElement = dom.document.createElement; + let downloaded: Blob | undefined; + let revoked: string | undefined; + let clicked: { name: string; href: string } | undefined; + URL.createObjectURL = (blob) => { + assert.ok(blob instanceof Blob); + downloaded = blob; + return "blob:figure-test"; + }; + URL.revokeObjectURL = (url) => { revoked = url; }; + function Harness() { + const [focus, setFocus] = useState<{ svg: string; inverted: boolean } | null>(null); + return ( + {}, openFigure: (svg, inverted) => setFocus({ svg, inverted }) }}> + + {focus && setFocus(null)} />} + + ); + } + try { + await act(async () => root.render()); + await act(async () => { host.querySelector('[data-testid="visual-open"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); + const inline = assertImageBoundary(host.querySelector('[data-testid="visual-svg"]')!, displaySvg); + const overlayHolder = host.querySelector("[data-lh-overlay-chart]")!; + const fullscreen = assertImageBoundary(overlayHolder, displaySvg); + assert.equal(fullscreen.getAttribute("src"), inline.getAttribute("src")); + assert.match(overlayHolder.getAttribute("style")!, /invert\(1\) hue-rotate\(180deg\)/); + + // Intercept only the download anchor; never navigate or write a real file. + dom.document.createElement = ((...args: Parameters) => { + const element = originalCreateElement.apply(dom.document, args); + if (args[0] === "a") { + element.click = () => { clicked = { name: element.getAttribute("download")!, href: element.getAttribute("href")! }; }; + } + return element; + }) as typeof originalCreateElement; + await act(async () => { host.querySelector('[data-testid="visual-download"]')!.dispatchEvent(new dom.window.Event("click", { bubbles: true })); }); + assert.ok(downloaded); + assert.equal(await downloaded.text(), originalSvg); + assert.equal(downloaded.type, "image/svg+xml"); + assert.deepEqual(clicked, { name: "figure.svg", href: "blob:figure-test" }); + assert.equal(revoked, "blob:figure-test"); + } finally { + try { + await act(async () => root.unmount()); + } finally { + URL.createObjectURL = originalCreateUrl; + URL.revokeObjectURL = originalRevokeUrl; + dom.document.createElement = originalCreateElement; + host.remove(); + for (const [i, name] of names.entries()) { + if (originals[i]) Object.defineProperty(globalThis, name, originals[i]!); + else Reflect.deleteProperty(globalThis, name); + } + } + } +});