UNPKG

tldraw

Version:

A tiny little drawing editor.

867 lines (762 loc) 28.7 kB
import { createShapeId, toRichText } from '@tldraw/editor' import { describe, expect, it, vi } from 'vitest' import { TestEditor } from '../../../test/TestEditor' import { sanitizeSvg } from './sanitizeSvg' function wrap(inner: string, attrs = 'xmlns="http://www.w3.org/2000/svg"'): string { return `<svg ${attrs}>${inner}</svg>` } describe('sanitizeSvg', () => { describe('attack vectors — must strip', () => { it('removes <script> elements', () => { const result = sanitizeSvg(wrap('<script>alert(1)</script><rect width="10" height="10"/>')) expect(result).not.toContain('<script') expect(result).toContain('<rect') }) it('strips onerror from <image>', () => { const result = sanitizeSvg(wrap('<image onerror="alert(1)" href="data:image/png;base64,x"/>')) expect(result).not.toContain('onerror') expect(result).toContain('<image') }) it('removes <img> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><img onerror="alert(1)" src="x"/></foreignObject>' ) ) expect(result).not.toContain('<img') expect(result).toContain('foreignObject') }) it('strips onload from <svg>', () => { const result = sanitizeSvg( '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect width="10" height="10"/></svg>' ) expect(result).not.toContain('onload') expect(result).toContain('<rect') }) it('strips onclick from <rect>', () => { const result = sanitizeSvg(wrap('<rect onclick="alert(1)" width="10" height="10"/>')) expect(result).not.toContain('onclick') }) it('strips onbegin from <animate>', () => { const result = sanitizeSvg( wrap('<animate onbegin="alert(1)" attributeName="x" from="0" to="100" dur="1s"/>') ) expect(result).not.toContain('onbegin') expect(result).toContain('<animate') }) it('strips javascript: href from <a>', () => { const result = sanitizeSvg(wrap('<a href="javascript:alert(1)"><text>click</text></a>')) expect(result).not.toContain('javascript') }) it('strips https: href from <image>', () => { const result = sanitizeSvg( wrap('<image href="https://evil.com/x.png" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips https: xlink:href from <image>', () => { const result = sanitizeSvg( wrap( '<image xlink:href="https://evil.com/x.png" width="10" height="10"/>', 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' ) ) expect(result).not.toContain('evil.com') }) it('strips external href from <use>', () => { const result = sanitizeSvg(wrap('<use href="https://evil.com/x.svg#y"/>')) expect(result).not.toContain('evil.com') }) it('strips data: href from <use>', () => { const result = sanitizeSvg(wrap('<use href="data:image/svg+xml,&lt;svg&gt;&lt;/svg&gt;"/>')) expect(result).not.toContain('data:') }) it('strips external href from <feImage>', () => { const result = sanitizeSvg( wrap('<filter id="f"><feImage href="https://evil.com/x.png"/></filter>') ) expect(result).not.toContain('evil.com') }) it('strips @import in <style>', () => { const result = sanitizeSvg( wrap('<style>@import url("https://evil.com/x.css");</style><rect width="10" height="10"/>') ) expect(result).not.toContain('@import') expect(result).not.toContain('evil.com') }) it('strips external url() in <style>', () => { const result = sanitizeSvg( wrap( '<style>rect { background: url(https://evil.com/x.png) }</style><rect width="10" height="10"/>' ) ) expect(result).not.toContain('evil.com') }) it('strips external url() in @font-face in <style>', () => { const result = sanitizeSvg( wrap( '<style>@font-face { src: url(https://evil.com/font.woff2) }</style><rect width="10" height="10"/>' ) ) expect(result).not.toContain('evil.com') }) it('strips external url() in style attribute', () => { const result = sanitizeSvg( wrap('<rect style="background: url(https://evil.com/x)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips cursor url() in style attribute', () => { const result = sanitizeSvg( wrap('<rect style="cursor: url(https://evil.com/c)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('blocks CSS escape bypass in href', () => { // \6A decodes to 'j', making "javascript:" const result = sanitizeSvg(wrap('<a href="\\6Aavascript:alert(1)"><text>x</text></a>')) // The href might still be there but it shouldn't contain javascript protocol // Since this is an SVG a element, it uses URI sanitization which strips invisible whitespace // and checks protocol expect(result).not.toContain('alert') }) it('blocks null byte bypass in href', () => { const result = sanitizeSvg(wrap('<a href="java\x00script:alert(1)"><text>x</text></a>')) expect(result).not.toContain('alert') }) it('strips mixed-case event handlers', () => { const r1 = sanitizeSvg(wrap('<rect OnError="alert(1)" width="10" height="10"/>')) expect(r1).not.toContain('OnError') const r2 = sanitizeSvg(wrap('<rect ONCLICK="alert(1)" width="10" height="10"/>')) expect(r2).not.toContain('ONCLICK') }) it('removes <iframe> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><iframe src="https://evil.com"></iframe></foreignObject>' ) ) expect(result).not.toContain('<iframe') }) it('removes <embed> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><embed src="https://evil.com"/></foreignObject>' ) ) expect(result).not.toContain('<embed') }) it('removes <object> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><object data="https://evil.com"></object></foreignObject>' ) ) expect(result).not.toContain('<object') }) it('removes <form> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><form action="https://evil.com"><input/></form></foreignObject>' ) ) expect(result).not.toContain('<form') }) it('removes nested <svg> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><svg><script>alert(1)</script></svg></foreignObject>' ) ) // The nested svg should be removed; the foreignObject is preserved expect(result).toContain('foreignObject') expect(result).not.toContain('<script') // Check there's no nested svg — only the outer one const svgCount = (result.match(/<svg/g) || []).length expect(svgCount).toBe(1) }) it('removes <link> inside <foreignObject>', () => { const result = sanitizeSvg( wrap( '<foreignObject width="100" height="100"><link href="https://evil.com/x.css" rel="stylesheet"/></foreignObject>' ) ) expect(result).not.toContain('<link') }) it('strips expression() in CSS', () => { const result = sanitizeSvg( wrap('<rect style="width: expression(alert(1))" width="10" height="10"/>') ) expect(result).not.toContain('expression') expect(result).not.toContain('alert') }) it('strips -moz-binding in CSS', () => { const result = sanitizeSvg( wrap('<rect style="-moz-binding: url(x)" width="10" height="10"/>') ) expect(result).not.toContain('-moz-binding') }) it('strips behavior: in CSS', () => { const result = sanitizeSvg( wrap('<rect style="behavior: url(x.htc)" width="10" height="10"/>') ) expect(result).not.toContain('behavior') }) it('returns empty string for fully malicious SVG', () => { const result = sanitizeSvg(wrap('<script>alert(1)</script>')) expect(result).toBe('') }) it('returns empty string for invalid SVG', () => { const result = sanitizeSvg('this is not svg') expect(result).toBe('') }) it('rejects non-svg root element', () => { const result = sanitizeSvg( '<script xmlns="http://www.w3.org/2000/svg">alert(1)<rect width="10" height="10"/></script>' ) expect(result).toBe('') }) it('removes <animate> targeting href (XSS via animation)', () => { const result = sanitizeSvg( wrap( '<a href="https://safe.com"><animate attributeName="href" values="javascript:alert(1)" dur="1s"/><text>x</text></a>' ) ) expect(result).not.toContain('javascript') expect(result).not.toContain('attributeName="href"') expect(result).toContain('<text') }) it('removes <set> targeting href', () => { const result = sanitizeSvg( wrap( '<a href="https://safe.com"><set attributeName="href" to="javascript:alert(1)"/><text>x</text></a>' ) ) expect(result).not.toContain('javascript') expect(result).not.toContain('attributeName="href"') }) it('removes <animateTransform> targeting href', () => { const result = sanitizeSvg( wrap( '<a href="https://safe.com"><animateTransform attributeName="href" values="javascript:alert(1)"/><text>x</text></a>' ) ) expect(result).not.toContain('attributeName="href"') }) it('removes <animate> targeting xlink:href', () => { const result = sanitizeSvg( wrap( '<a href="https://safe.com"><animate attributeName="xlink:href" values="javascript:alert(1)"/><text>x</text></a>', 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' ) ) expect(result).not.toContain('attributeName="xlink:href"') }) it('removes <animate> targeting on* attributes', () => { const result = sanitizeSvg( wrap( '<rect width="10" height="10"><animate attributeName="onclick" values="alert(1)" dur="1s"/></rect>' ) ) expect(result).not.toContain('attributeName="onclick"') }) it('strips multiline CSS url() values', () => { const result = sanitizeSvg( wrap( '<style>rect { background: url(\nhttps://evil.com/x.png\n) }</style><rect width="10" height="10"/>' ) ) expect(result).not.toContain('evil.com') }) it('blocks fully-malicious data:image/svg+xml on <image>', () => { // inner SVG has no safe content after sanitization, so href is removed const result = sanitizeSvg( wrap( '<image href="data:image/svg+xml;base64,PHN2Zz48c2NyaXB0PmFsZXJ0KDEpPC9zY3JpcHQ+PC9zdmc+" width="10" height="10"/>' ) ) expect(result).not.toContain('data:image/svg+xml') }) it('blocks data:image/svg+xml in CSS url()', () => { const result = sanitizeSvg( wrap( '<style>rect { background: url(data:image/svg+xml;base64,PHN2Zz4=) }</style><rect width="10" height="10"/>' ) ) expect(result).not.toContain('data:image/svg+xml') }) it('strips @import with semicolons inside quoted URL', () => { const result = sanitizeSvg( wrap( '<style>@import url("https://evil.com/foo;bar.css");</style><rect width="10" height="10"/>' ) ) expect(result).not.toContain('evil.com') expect(result).not.toContain('@import') }) it('strips external url() in fill attribute', () => { const result = sanitizeSvg( wrap('<rect fill="url(https://evil.com/track.png)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips external url() in filter attribute', () => { const result = sanitizeSvg( wrap('<rect filter="url(https://evil.com/filter)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips external url() in clip-path attribute', () => { const result = sanitizeSvg( wrap('<rect clip-path="url(https://evil.com/clip)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips external url() in mask attribute', () => { const result = sanitizeSvg( wrap('<rect mask="url(https://evil.com/mask)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips external url() in marker-end attribute', () => { const result = sanitizeSvg( wrap('<line marker-end="url(https://evil.com/m)" x1="0" y1="0" x2="10" y2="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips external url() in stroke attribute', () => { const result = sanitizeSvg( wrap('<rect stroke="url(https://evil.com/s)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('strips uppercase URL() in presentation attributes', () => { const result = sanitizeSvg( wrap('<rect fill="URL(https://evil.com/track)" width="10" height="10"/>') ) expect(result).not.toContain('evil.com') }) it('does not throw on CSS escapes above Unicode max', () => { const result = sanitizeSvg(wrap('<rect style="color: \\999999" width="10" height="10"/>')) expect(result).toContain('<rect') }) }) describe('preservation — must keep intact', () => { it('preserves basic SVG shapes', () => { const svg = wrap( '<path d="M0 0L10 10"/><rect width="10" height="10"/><circle cx="5" cy="5" r="5"/>' + '<ellipse cx="5" cy="5" rx="5" ry="3"/><line x1="0" y1="0" x2="10" y2="10"/>' + '<polyline points="0,0 10,10 20,0"/><polygon points="0,0 10,10 20,0"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('<path') expect(result).toContain('<rect') expect(result).toContain('<circle') expect(result).toContain('<ellipse') expect(result).toContain('<line') expect(result).toContain('<polyline') expect(result).toContain('<polygon') }) it('preserves gradients', () => { const svg = wrap( '<defs><linearGradient id="lg"><stop offset="0" stop-color="red"/></linearGradient>' + '<radialGradient id="rg"><stop offset="0" stop-color="blue"/></radialGradient></defs>' + '<rect fill="url(#lg)" width="10" height="10"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('linearGradient') expect(result).toContain('radialGradient') expect(result).toContain('<stop') // fill="url(#lg)" must survive url-bearing attr sanitization expect(result).toContain('url(#lg)') }) it('preserves filters', () => { const svg = wrap( '<defs><filter id="f"><feGaussianBlur stdDeviation="2"/>' + '<feDropShadow dx="2" dy="2"/><feBlend mode="multiply"/></filter></defs>' + '<rect filter="url(#f)" width="10" height="10"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('feGaussianBlur') expect(result).toContain('feDropShadow') expect(result).toContain('feBlend') // filter="url(#f)" must survive url-bearing attr sanitization expect(result).toContain('url(#f)') }) it('preserves clipPath, mask, pattern, marker', () => { const svg = wrap( '<defs><clipPath id="cp"><rect width="10" height="10"/></clipPath>' + '<mask id="m"><rect width="10" height="10" fill="white"/></mask>' + '<pattern id="p" width="10" height="10"><rect width="5" height="5"/></pattern>' + '<marker id="mk" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5"/></marker></defs>' + '<rect width="10" height="10"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('clipPath') expect(result).toContain('mask') expect(result).toContain('pattern') expect(result).toContain('marker') }) it('preserves data: href on <image>', () => { const svg = wrap('<image href="data:image/png;base64,iVBOR" width="10" height="10"/>') const result = sanitizeSvg(svg) expect(result).toContain('data:image/png;base64,iVBOR') }) it('preserves safe data:image/svg+xml href on <image>', () => { // base64 of <svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg> const innerSvg = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>' const b64 = btoa(innerSvg) const svg = wrap(`<image href="data:image/svg+xml;base64,${b64}" width="10" height="10"/>`) const result = sanitizeSvg(svg) expect(result).toContain('data:image/svg+xml;base64,') expect(result).toContain('<image') }) it('preserves safe content and strips script from embedded SVG data URI on <image>', () => { // SVG with both safe content and a script tag const innerSvg = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/><script>alert(1)</script></svg>' const b64 = btoa(innerSvg) const svg = wrap(`<image href="data:image/svg+xml;base64,${b64}" width="10" height="10"/>`) const result = sanitizeSvg(svg) // Should keep the image with sanitized SVG data URI expect(result).toContain('data:image/svg+xml;base64,') // Decode the embedded SVG to verify script was stripped const match = result.match(/data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)/) expect(match).toBeTruthy() const decoded = atob(match![1]) expect(decoded).toContain('<rect') expect(decoded).not.toContain('<script') }) it('preserves fragment ref on <use>', () => { const svg = wrap('<defs><rect id="r" width="10" height="10"/></defs><use href="#r"/>') const result = sanitizeSvg(svg) expect(result).toContain('href="#r"') }) it('preserves data: font URL in <style>', () => { const svg = wrap( '<style>@font-face { font-family: "Test"; src: url(data:font/woff2;base64,d09GMg) }</style>' + '<rect width="10" height="10"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('data:font/woff2;base64,d09GMg') }) it('preserves foreignObject with safe HTML content', () => { const svg = wrap( '<foreignObject width="100" height="100">' + '<div xmlns="http://www.w3.org/1999/xhtml"><p>Hello <strong>world</strong></p>' + '<span class="test">text</span>' + '<ul><li>item</li></ul>' + '<code>code</code><em>italic</em><b>bold</b>' + '</div></foreignObject>' ) const result = sanitizeSvg(svg) expect(result).toContain('foreignObject') expect(result).toContain('<div') expect(result).toContain('<p') expect(result).toContain('<strong') expect(result).toContain('<span') expect(result).toContain('<ul') expect(result).toContain('<li') expect(result).toContain('<code') expect(result).toContain('<em') expect(result).toContain('<b') expect(result).toContain('Hello') }) it('preserves https link in <a>', () => { const svg = wrap('<a href="https://example.com"><text>link</text></a>') const result = sanitizeSvg(svg) expect(result).toContain('https://example.com') }) it('preserves https link in <a> inside foreignObject', () => { const svg = wrap( '<foreignObject width="100" height="100">' + '<div xmlns="http://www.w3.org/1999/xhtml"><a href="https://example.com">link</a></div>' + '</foreignObject>' ) const result = sanitizeSvg(svg) expect(result).toContain('https://example.com') }) it('preserves safe inline styles', () => { const svg = wrap('<rect style="fill: red; stroke: blue;" width="10" height="10"/>') const result = sanitizeSvg(svg) expect(result).toContain('fill: red') expect(result).toContain('stroke: blue') }) it('preserves transform, viewBox, preserveAspectRatio', () => { const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g transform="translate(10,10)"><rect width="10" height="10"/></g></svg>' const result = sanitizeSvg(svg) expect(result).toContain('viewBox') expect(result).toContain('transform') }) it('preserves data-* and aria-* attributes', () => { const svg = wrap('<rect data-testid="r" aria-label="rectangle" width="10" height="10"/>') const result = sanitizeSvg(svg) expect(result).toContain('data-testid') expect(result).toContain('aria-label') }) it('preserves width/height on svg element', () => { const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="10" height="10"/></svg>' const result = sanitizeSvg(svg) expect(result).toContain('width="200"') expect(result).toContain('height="100"') }) it('preserves animation elements without event handlers', () => { const svg = wrap( '<rect width="10" height="10"><animate attributeName="x" from="0" to="100" dur="1s"/>' + '<set attributeName="fill" to="red"/>' + '<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="1s"/></rect>' ) const result = sanitizeSvg(svg) expect(result).toContain('<animate') expect(result).toContain('<set') expect(result).toContain('animateTransform') }) it('preserves CSS url(#id) fragment references in <style>', () => { const svg = wrap( '<defs><linearGradient id="grad"><stop offset="0" stop-color="red"/><stop offset="1" stop-color="blue"/></linearGradient></defs>' + '<style>rect { fill: url(#grad) }</style>' + '<rect width="10" height="10"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('url(#grad)') }) it('preserves safe <animate> targeting non-URI attributes', () => { const svg = wrap( '<rect width="10" height="10">' + '<animate attributeName="opacity" from="0" to="1" dur="1s"/>' + '<animate attributeName="fill" from="red" to="blue" dur="1s"/>' + '</rect>' ) const result = sanitizeSvg(svg) expect(result).toContain('attributeName="opacity"') expect(result).toContain('attributeName="fill"') }) it('preserves data: image in CSS url()', () => { const svg = wrap( '<style>rect { background-image: url(data:image/png;base64,iVBOR) }</style>' + '<rect width="10" height="10"/>' ) const result = sanitizeSvg(svg) expect(result).toContain('data:image/png;base64,iVBOR') }) }) describe('round-trip — tldraw SVG export survives sanitization', () => { vi.useRealTimers() it('preserves tldraw-exported SVG with text shapes', async () => { const editor = new TestEditor() const geoId = createShapeId('geo') editor.createShapes([ { id: geoId, type: 'geo', x: 0, y: 0, props: { w: 200, h: 100, richText: toRichText('Hello world'), }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const original = exported!.svg const sanitized = sanitizeSvg(original) // Must not be empty expect(sanitized).not.toBe('') // Must still contain the SVG root expect(sanitized).toContain('<svg') // Must preserve text content expect(sanitized).toContain('Hello world') // Must preserve foreignObject (used for text rendering) expect(sanitized).toContain('foreignObject') // Must preserve path elements (shape outlines) expect(sanitized).toContain('<path') // Must preserve style elements (for fonts) if (original.includes('<style')) { expect(sanitized).toContain('<style') } // If original has data: font URLs, they must survive if (original.includes('data:font/') || original.includes('data:application/')) { expect(sanitized).toMatch(/data:(?:font\/|application\/)/) } }) it('preserves tldraw-exported SVG with multiple geo shapes', async () => { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('rect'), type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, geo: 'rectangle' }, }, { id: createShapeId('ellipse'), type: 'geo', x: 150, y: 0, props: { w: 100, h: 100, geo: 'ellipse' }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('<svg') // Parse both and compare child element counts const parser = new DOMParser() const origDoc = parser.parseFromString(exported!.svg, 'image/svg+xml') const sanDoc = parser.parseFromString(sanitized, 'image/svg+xml') const origSvg = origDoc.documentElement const sanSvg = sanDoc.documentElement // Sanitized SVG should have the same number of top-level children expect(sanSvg.children.length).toBe(origSvg.children.length) }) it('preserves geo shape with pattern fill (mask + pattern defs)', async () => { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('patternRect'), type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, fill: 'pattern' }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const original = exported!.svg const sanitized = sanitizeSvg(original) expect(sanitized).not.toBe('') // Pattern fill uses <mask>, <pattern>, <rect> in defs if (original.includes('<mask')) expect(sanitized).toContain('<mask') if (original.includes('<pattern')) expect(sanitized).toContain('<pattern') if (original.includes('<defs')) expect(sanitized).toContain('<defs') }) it('preserves arrow shape with markers and clipPath', async () => { const editor = new TestEditor() const startId = createShapeId('start') const endId = createShapeId('end') editor.createShapes([ { id: startId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 }, }, { id: endId, type: 'geo', x: 300, y: 0, props: { w: 100, h: 100 }, }, ]) editor.setCurrentTool('arrow') editor.pointerDown(50, 50) editor.pointerMove(350, 50) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const original = exported!.svg const sanitized = sanitizeSvg(original) expect(sanitized).not.toBe('') expect(sanitized).toContain('<path') // Arrows use marker and/or clipPath defs if (original.includes('<marker')) expect(sanitized).toContain('<marker') if (original.includes('<clipPath')) expect(sanitized).toContain('<clipPath') }) it('preserves draw shape', async () => { const editor = new TestEditor() editor.setCurrentTool('draw') editor.pointerDown(0, 0) editor.pointerMove(50, 50) editor.pointerMove(100, 0) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('<path') }) it('preserves note shape with text', async () => { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('note'), type: 'note', x: 0, y: 0, props: { richText: toRichText('Note text'), }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('Note text') }) it('preserves text shape', async () => { const editor = new TestEditor() editor.createShapes([ { id: createShapeId('text'), type: 'text', x: 0, y: 0, props: { richText: toRichText('Plain text shape'), autoSize: true, }, }, ]) editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('Plain text shape') expect(sanitized).toContain('foreignObject') }) it('preserves highlight shape', async () => { const editor = new TestEditor() editor.setCurrentTool('highlight') editor.pointerDown(0, 0) editor.pointerMove(50, 50) editor.pointerMove(100, 0) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('<path') }) it('preserves line shape', async () => { const editor = new TestEditor() editor.setCurrentTool('line') editor.pointerDown(0, 0) editor.pointerMove(100, 100) editor.pointerUp() editor.selectAll() const exported = await editor.getSvgString(editor.getSelectedShapeIds()) expect(exported).toBeTruthy() const sanitized = sanitizeSvg(exported!.svg) expect(sanitized).not.toBe('') expect(sanitized).toContain('<path') }) }) })