From 13f3cff8c3b39b0748fa87bb8408924b2901cb76 Mon Sep 17 00:00:00 2001 From: Alexey Morozov Date: Fri, 20 Jun 2025 21:56:17 +0300 Subject: [PATCH] Make `toSvg()` export images referenced in CSS stylesheets or inline styles from `mask` (`mask-image`) or `background` (`background-image`) properties: * Use React v18 in dev dependencies for `vitest-browser-react`; * Add snapshot tests for `toSvg()` image-exporting behavior; * Expose `contentPadding` option for `CanvasApi.exportSvg()` and `CanvasApi.exportRaster()` methods; --- package-lock.json | 83 +++-- package.json | 9 +- src/diagram/canvasApi.ts | 6 + src/diagram/paperArea.tsx | 9 +- src/diagram/toSvg.ts | 240 +++++++++++--- test/diagram/toSvg.expected.empty.svg | 4 + test/diagram/toSvg.expected.withImages.svg | 8 + .../diagram/toSvg.expected.withoutRemoved.svg | 4 + test/diagram/toSvg.inline.svg | 4 + test/diagram/toSvg.module.css | 25 ++ test/diagram/toSvg.resource.svg | 4 + test/diagram/toSvg.test.tsx | 296 ++++++++++++++++++ tsconfig.json | 1 + typings/typings.d.ts | 15 + vitest.config.mts | 31 +- 15 files changed, 664 insertions(+), 75 deletions(-) create mode 100644 test/diagram/toSvg.expected.empty.svg create mode 100644 test/diagram/toSvg.expected.withImages.svg create mode 100644 test/diagram/toSvg.expected.withoutRemoved.svg create mode 100644 test/diagram/toSvg.inline.svg create mode 100644 test/diagram/toSvg.module.css create mode 100644 test/diagram/toSvg.resource.svg create mode 100644 test/diagram/toSvg.test.tsx diff --git a/package-lock.json b/package-lock.json index 3f9a8abd..6aa55667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@reactodia/workspace", - "version": "0.30.0-next", + "version": "0.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reactodia/workspace", - "version": "0.30.0-next", + "version": "0.30.0", "license": "LGPL-2.1-or-later", "dependencies": { "@reactodia/hashmap": "^0.1.0", @@ -22,8 +22,8 @@ "@types/d3-color": "^3.1.3", "@types/file-saver": "^2.0.7", "@types/n3": "^1.21.1", - "@types/react": "^18.2.33", - "@types/react-dom": "^18.2.14", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.25.0", "@vitest/browser": "^3.0.7", @@ -37,8 +37,8 @@ "html-webpack-plugin": "^5.6.3", "http-proxy-middleware": "^3.0.3", "playwright": "^1.50.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "rimraf": "^6.0.1", "sass": "^1.85.1", "sass-loader": "^16.0.5", @@ -50,6 +50,7 @@ "typescript-eslint": "^8.25.0", "use-sync-external-store": "^1.4.0", "vitest": "^3.0.9", + "vitest-browser-react": "^0.3.0", "webcola": "~3.3.8", "webpack": "^5.98.0", "webpack-cli": "^6.0.1", @@ -2016,9 +2017,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.20", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.20.tgz", - "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", "dev": true, "license": "MIT", "dependencies": { @@ -2027,9 +2028,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", - "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7508,32 +7509,30 @@ } }, "node_modules/react": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", - "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", - "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "scheduler": "^0.20.2" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "17.0.2" + "react": "^18.3.1" } }, "node_modules/react-is": { @@ -8040,14 +8039,13 @@ } }, "node_modules/scheduler": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", - "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dev": true, "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/schema-utils": { @@ -9410,6 +9408,35 @@ } } }, + "node_modules/vitest-browser-react": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-0.3.0.tgz", + "integrity": "sha512-dk8T3GHhwrmCJLqDer1A2NB8MlIGsUb9Wr4/iMpbrwz4sBCh1PjV0SkZ9BYmoBrJ4j/Tx8qxzCXFXnbE2IbWzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "@vitest/browser": "^2.1.0 || ^3.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "vitest": "^2.1.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", diff --git a/package.json b/package.json index 83aa17d3..7c583e4b 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "@types/d3-color": "^3.1.3", "@types/file-saver": "^2.0.7", "@types/n3": "^1.21.1", - "@types/react": "^18.2.33", - "@types/react-dom": "^18.2.14", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.25.0", "@vitest/browser": "^3.0.7", @@ -70,8 +70,8 @@ "html-webpack-plugin": "^5.6.3", "http-proxy-middleware": "^3.0.3", "playwright": "^1.50.1", - "react": "^17.0.2", - "react-dom": "^17.0.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", "rimraf": "^6.0.1", "sass": "^1.85.1", "sass-loader": "^16.0.5", @@ -83,6 +83,7 @@ "typescript-eslint": "^8.25.0", "use-sync-external-store": "^1.4.0", "vitest": "^3.0.9", + "vitest-browser-react": "^0.3.0", "webcola": "~3.3.8", "webpack": "^5.98.0", "webpack-cli": "^6.0.1", diff --git a/src/diagram/canvasApi.ts b/src/diagram/canvasApi.ts index 8d492032..e738b4f2 100644 --- a/src/diagram/canvasApi.ts +++ b/src/diagram/canvasApi.ts @@ -517,6 +517,12 @@ export interface ZoomOptions { * @see {@link CanvasApi.exportSvg} */ export interface ExportSvgOptions { + /** + * Padding size (in pixels) around the content for the exported diagram. + * + * @default {x: 100, y: 100} + */ + contentPadding?: Vector; /** * CSS selectors to exclude specific DOM elements from the exported diagram. * diff --git a/src/diagram/paperArea.tsx b/src/diagram/paperArea.tsx index 7ee90ab2..e3005f4a 100644 --- a/src/diagram/paperArea.tsx +++ b/src/diagram/paperArea.tsx @@ -902,6 +902,7 @@ export class PaperArea extends React.Component implements private makeToSVGOptions(baseOptions: ExportSvgOptions): ToSVGOptions { const {colorSchemeApi} = this.props; const { + contentPadding = {x: 100, y: 100}, removeByCssSelectors = [], } = baseOptions; const linkLayer = this.linkLayerRef.current; @@ -910,10 +911,16 @@ export class PaperArea extends React.Component implements if (!(linkLayer && labelLayer && elementLayer)) { throw new Error('Cannot find element, link or label layers to export'); } + const box = this.getContentFittingBox(); return { colorSchemeApi, styleRoot: linkLayer, - contentBox: this.getContentFittingBox(), + contentBox: { + x: box.x - contentPadding.x, + y: box.y - contentPadding.y, + width: box.width + contentPadding.x * 2, + height: box.height + contentPadding.y * 2, + }, layers: [ linkLayer, labelLayer, diff --git a/src/diagram/toSvg.ts b/src/diagram/toSvg.ts index 07001cf7..e3dfc188 100644 --- a/src/diagram/toSvg.ts +++ b/src/diagram/toSvg.ts @@ -7,12 +7,13 @@ export interface ToSVGOptions { styleRoot: HTMLElement | SVGElement; layers: ReadonlyArray; contentBox: Rect; + /** @default false */ preserveDimensions?: boolean; + /** @default false */ convertImagesToDataUris?: boolean; + /** @default [] */ removeByCssSelectors?: ReadonlyArray; watermarkSvg?: string; - /** @default {x: 100, y: 100} */ - borderPadding?: Vector; /** @default false */ addXmlHeader?: boolean; } @@ -23,7 +24,6 @@ interface Bounds { } const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; -const DEFAULT_BORDER_PADDING: Vector = {x: 100, y: 100}; const XML_ENCODING_HEADER = ''; /** @@ -41,19 +41,13 @@ export function toSVG(options: ToSVGOptions): Promise { async function exportSVG(options: ToSVGOptions): Promise { const { colorSchemeApi, - contentBox: bbox, + contentBox: viewBox, watermarkSvg, + preserveDimensions, + convertImagesToDataUris, removeByCssSelectors = [], - borderPadding = DEFAULT_BORDER_PADDING, } = options; - const viewBox: Rect = { - x: bbox.x - borderPadding.x, - y: bbox.y - borderPadding.y, - width: bbox.width + 2 * borderPadding.x, - height: bbox.height + 2 * borderPadding.y, - }; - let cssPropertyValues!: ReturnType; let clonedPaperSvg!: ReturnType; colorSchemeApi.actInColorScheme('light', () => { @@ -69,9 +63,16 @@ async function exportSVG(options: ToSVGOptions): Promise { } // Workaround to include only library-related stylesheets - const exportedCssText = extractCSSFromDocument(composedSvg); + const appliedCssRules = collectAppliedCssFromDocument(composedSvg); - if (options.preserveDimensions) { + const imageUrls = new Map(); + if (convertImagesToDataUris) { + collectImageUrlsFromElements(composedSvg, imageUrls); + collectImageUrlsFromCssRules(appliedCssRules, imageUrls); + collectImageUrlsFromInlineStyles(composedSvg, imageUrls); + } + + if (preserveDimensions) { composedSvg.setAttribute('width', String(viewBox.width)); composedSvg.setAttribute('height', String(viewBox.height)); } else { @@ -79,40 +80,33 @@ async function exportSVG(options: ToSVGOptions): Promise { composedSvg.setAttribute('height', '100%'); } - composedSvg.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`); if (watermarkSvg) { addWatermark(composedSvg, viewBox, watermarkSvg); } - const images = Array.from(composedSvg.querySelectorAll('img')); - await Promise.all(images.map(img => { + await fetchImages(imageUrls); + + for (const img of composedSvg.querySelectorAll('img')) { const exportKey = img.getAttribute('export-key'); img.removeAttribute('export-key'); if (exportKey) { const {width, height} = imageBounds[exportKey]; img.setAttribute('width', width.toString()); img.setAttribute('height', height.toString()); - if (!options.convertImagesToDataUris) { - return Promise.resolve(); + const exportedUrl = imageUrls.get(img.src); + if (exportedUrl) { + img.src = exportedUrl; } - return exportAsDataUri(img).then(dataUri => { - if (dataUri && dataUri !== 'data:image/svg+xml,') { - img.src = dataUri; - } - }).catch(err => { - console.warn('Reactodia: Failed to export image: ' + img.src, err); - }); - } else { - return Promise.resolve(); } - })); + } + embedImageUrlsToInlineStyles(composedSvg, imageUrls); const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); defs.innerHTML = ( `\n` + - `` + `` ); composedSvg.insertBefore(defs, composedSvg.firstChild); @@ -140,10 +134,9 @@ function addWatermark(svg: SVGElement, viewBox: Rect, watermarkSvg: string) { svg.insertBefore(image, svg.firstChild); } -function extractCSSFromDocument(targetSubtree: Element): string { - const exportedParts: string[] = []; - +function collectAppliedCssFromDocument(targetSubtree: Element): CSSStyleRule[] { const visitedRules = new Set(); + const appliedRules: CSSStyleRule[] = []; const visitRule = (rule: CSSRule): void => { if (visitedRules.has(rule)) { return; @@ -152,7 +145,7 @@ function extractCSSFromDocument(targetSubtree: Element): string { if (rule instanceof CSSStyleRule) { const selectorWithoutPseudo = rule.selectorText.replace(/::[a-zA-Z-]+$/, ''); if (targetSubtree.querySelector(selectorWithoutPseudo)) { - exportedParts.push(rule.cssText); + appliedRules.push(rule); } } else if (rule instanceof CSSLayerBlockRule) { for (const subRule of rule.cssRules) { @@ -178,7 +171,171 @@ function extractCSSFromDocument(targetSubtree: Element): string { } } - return exportedParts.join('\n'); + return appliedRules; +} + +function collectImageUrlsFromElements( + target: Element, + imageUrls: Map +) { + for (const img of target.querySelectorAll('img')) { + if (img.src && !imageUrls.has(img.src)) { + imageUrls.set(img.src, null); + } + } +} + +function collectImageUrlsFromCssRules( + rules: readonly CSSStyleRule[], + imageUrls: Map +): void { + for (const rule of rules) { + const maskUrl = parseCssImageUrl(rule.style.getPropertyValue('mask-image')); + if (maskUrl && !imageUrls.has(maskUrl)) { + imageUrls.set(maskUrl, null); + } + + const backgroundUrl = parseCssImageUrl(rule.style.getPropertyValue('background-image')); + if (backgroundUrl && !imageUrls.has(backgroundUrl)) { + imageUrls.set(backgroundUrl, null); + } + } +} + +function collectImageUrlsFromInlineStyles( + target: Element, + imageUrls: Map +): void { + const visited = new Set(); + const visit = (element: Element): void => { + if (visited.has(element)) { + return; + } + visited.add(element); + if (element instanceof HTMLElement) { + const maskUrl = parseCssImageUrl(element.style.maskImage); + if (maskUrl && !imageUrls.has(maskUrl)) { + imageUrls.set(maskUrl, null); + } + + const backgroundUrl = parseCssImageUrl(element.style.backgroundImage); + if (backgroundUrl && !imageUrls.has(backgroundUrl)) { + imageUrls.set(backgroundUrl, null); + } + } + for (const child of element.children) { + visit(child); + } + }; + visit(target); +} + +function embedImageUrlsToInlineStyles( + target: Element, + imageUrls: Map +): void { + const visited = new Set(); + const visit = (element: Element): void => { + if (visited.has(element)) { + return; + } + visited.add(element); + if (element instanceof HTMLElement) { + const maskUrl = parseCssImageUrl(element.style.maskImage); + const exportedMaskUrl = maskUrl ? imageUrls.get(maskUrl) : undefined; + if (exportedMaskUrl) { + element.style.maskImage = serializeCssImageUrl(exportedMaskUrl); + } + + const backgroundUrl = parseCssImageUrl(element.style.backgroundImage); + const exportedBackgroundUrl = backgroundUrl ? imageUrls.get(backgroundUrl) : undefined; + if (exportedBackgroundUrl) { + element.style.backgroundImage = serializeCssImageUrl(exportedBackgroundUrl); + } + } + for (const child of element.children) { + visit(child); + } + }; + visit(target); +} + +async function fetchImages(imageUrls: Map): Promise { + await Promise.all(Array.from(imageUrls.keys(), async sourceUrl => { + if (sourceUrl.startsWith('data:')) { + return; + } + try { + const dataUri = await exportImageAsDataUri(sourceUrl); + if (dataUri && dataUri !== 'data:image/svg+xml,') { + imageUrls.set(sourceUrl, dataUri); + } + } catch (err) { + console.warn('Reactodia: Failed to export image: ' + sourceUrl, err); + } + })); +} + +function exportCssRules( + rules: readonly CSSStyleRule[], + imageUrls: ReadonlyMap +): string { + const cssTexts = rules.map(rule => rule.cssText); + + const styleElement = document.createElement('style'); + document.body.appendChild(styleElement); + const sheet = styleElement.sheet; + + try { + for (let i = 0; i < rules.length; i++) { + const rule = rules[i]; + + const maskUrl = parseCssImageUrl(rule.style.getPropertyValue('mask-image')); + const exportedMaskUrl = maskUrl ? imageUrls.get(maskUrl) : undefined; + + const backgroundUrl = parseCssImageUrl(rule.style.getPropertyValue('background-image')); + const exportedBackgroundUrl = backgroundUrl ? imageUrls.get(backgroundUrl) : undefined; + + if (sheet && (exportedMaskUrl || exportedBackgroundUrl)) { + sheet.insertRule(rule.cssText); + const ruleCopy = sheet.cssRules[0]; + if (ruleCopy instanceof CSSStyleRule) { + if (exportedMaskUrl) { + ruleCopy.style.setProperty('mask-image', serializeCssImageUrl(exportedMaskUrl)); + } + if (exportedBackgroundUrl) { + ruleCopy.style.setProperty('background-image', serializeCssImageUrl(exportedBackgroundUrl)); + } + cssTexts[i] = ruleCopy.cssText; + } + sheet.deleteRule(0); + } + } + } finally { + document.body.removeChild(styleElement); + } + return cssTexts.join('\n'); +} + +function parseCssImageUrl(imageValue: string | undefined): string | undefined { + if (imageValue) { + const trimmedValue = imageValue.trim(); + + let match = /^url\(\s*'(.*)'\s*\)$/i.exec(trimmedValue); + if (match) { + return match[1]; + } + + match = /^url\(\s*"(.*)"\s*\)$/i.exec(trimmedValue); + if (match) { + return match[1]; + } + } + return undefined; +} + +function serializeCssImageUrl(dataUri: string): string { + return `url("${dataUri}")`; } function composeExportedSvg(layers: ToSVGOptions['layers'], viewBox: Rect): { @@ -242,8 +399,7 @@ function findSvgLayerViewport(layer: SVGSVGElement): SVGGElement | undefined { return undefined; } -async function exportAsDataUri(original: HTMLImageElement): Promise { - const url = original.src; +async function exportImageAsDataUri(url: string): Promise { if (!url || url.startsWith('data:')) { return url; } @@ -255,16 +411,18 @@ async function exportAsDataUri(original: HTMLImageElement): Promise { if (extension === 'svg') { try { const response = await fetch(url); - const svgText = await response.text(); - if (svgText.length > 0) { - return 'data:image/svg+xml,' + encodeURIComponent(svgText); + if (response.ok) { + const svgText = await response.text(); + if (svgText.length > 0) { + return 'data:image/svg+xml,' + encodeURIComponent(svgText.replace(/\r\n/g, '\n')); + } } } catch (err) { /* Failed to fetch image as SVG */ } } - const image = await loadCrossOriginImage(original.src); + const image = await loadCrossOriginImage(url); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; diff --git a/test/diagram/toSvg.expected.empty.svg b/test/diagram/toSvg.expected.empty.svg new file mode 100644 index 00000000..b5a42404 --- /dev/null +++ b/test/diagram/toSvg.expected.empty.svg @@ -0,0 +1,4 @@ + +
\ No newline at end of file diff --git a/test/diagram/toSvg.expected.withImages.svg b/test/diagram/toSvg.expected.withImages.svg new file mode 100644 index 00000000..c4d16e45 --- /dev/null +++ b/test/diagram/toSvg.expected.withImages.svg @@ -0,0 +1,8 @@ + +
\ No newline at end of file diff --git a/test/diagram/toSvg.expected.withoutRemoved.svg b/test/diagram/toSvg.expected.withoutRemoved.svg new file mode 100644 index 00000000..f8622680 --- /dev/null +++ b/test/diagram/toSvg.expected.withoutRemoved.svg @@ -0,0 +1,4 @@ + +
\ No newline at end of file diff --git a/test/diagram/toSvg.inline.svg b/test/diagram/toSvg.inline.svg new file mode 100644 index 00000000..38d04094 --- /dev/null +++ b/test/diagram/toSvg.inline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/diagram/toSvg.module.css b/test/diagram/toSvg.module.css new file mode 100644 index 00000000..2d13f59e --- /dev/null +++ b/test/diagram/toSvg.module.css @@ -0,0 +1,25 @@ +.icon { + position: absolute; + content: ''; + display: block; + height: 36px; + width: 36px; +} + +.inlineMask { + mask: url("./toSvg.inline.svg") 0px 0px / contain no-repeat; + background-color: lightgreen; +} + +.resourceMask { + mask: url("./toSvg.resource.svg") 0px 0px / contain no-repeat; + background-color: green; +} + +.inlineBackground { + background: url("./toSvg.inline.svg") 0px 0px / contain no-repeat; +} + +.resourceBackground { + background: url("./toSvg.resource.svg") 0px 0px / contain no-repeat; +} diff --git a/test/diagram/toSvg.resource.svg b/test/diagram/toSvg.resource.svg new file mode 100644 index 00000000..38d04094 --- /dev/null +++ b/test/diagram/toSvg.resource.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/diagram/toSvg.test.tsx b/test/diagram/toSvg.test.tsx new file mode 100644 index 00000000..fe3e4d74 --- /dev/null +++ b/test/diagram/toSvg.test.tsx @@ -0,0 +1,296 @@ +import cx from 'clsx'; +import * as React from 'react'; +import { expect, describe, it } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import type { ColorSchemeApi } from '../../src/coreUtils/colorScheme'; +import { HtmlPaperLayer, SvgPaperLayer, type PaperTransform } from '../../src/diagram/paper'; +import { toSVG } from '../../src/diagram/toSvg'; + +import IconResource from './toSvg.resource.svg'; +import IconInline from './toSvg.inline.svg'; +import styles from './toSvg.module.css'; + +describe('toSvg()', () => { + it('exports an empty paper', async () => { + const svgLayerRef = React.createRef(); + const htmlLayerRef = React.createRef(); + const paperTransform: PaperTransform = { + width: 400, + height: 400, + originX: 0, + originY: 0, + paddingX: 0, + paddingY: 0, + scale: 1, + }; + render( +
+ + {/* empty */} + + + {/* empty */} + +
+ ); + + expect(svgLayerRef.current).toBeTruthy(); + expect(htmlLayerRef.current).toBeTruthy(); + + const exportedSvgString = await toSVG({ + colorSchemeApi: DUMMY_COLOR_SCHEME_API, + styleRoot: svgLayerRef.current!, + contentBox: {x: 0, y: 0, width: paperTransform.width, height: paperTransform.height}, + layers: [ + svgLayerRef.current!, + htmlLayerRef.current!, + ], + preserveDimensions: true, + convertImagesToDataUris: true, + }); + + await expect(exportedSvgString).toMatchFileSnapshot('toSvg.expected.empty.svg'); + }); + + it('exports paper with images via , stylesheets and inline styles', async () => { + expect(IconInline).to.match(/^data:/); + expect(IconResource).to.match(/^\//); + + const svgLayerRef = React.createRef(); + const htmlLayerRef = React.createRef(); + const paperTransform: PaperTransform = { + width: 400, + height: 400, + originX: 0, + originY: 0, + paddingX: 0, + paddingY: 0, + scale: 1, + }; + const commonIconStyle: React.CSSProperties = { + height: '36px', + width: '36px', + position: 'absolute', + }; + const iconVariants = [ + styles.inlineMask, + styles.resourceMask, + styles.inlineBackground, + styles.resourceBackground, + ]; + render ( +
+ + {/* empty */} + + +
+ {/* Images referenced from inline styles */} +
+
+
+
+ {/* Images referenced from CSS stylesheets */} + {iconVariants.map((className, i) => ( +
+ ))} + {/* Images references directly from */} + + + +
+ ); + + expect(svgLayerRef.current).toBeTruthy(); + expect(htmlLayerRef.current).toBeTruthy(); + + const exportedSvgString = await toSVG({ + colorSchemeApi: DUMMY_COLOR_SCHEME_API, + styleRoot: svgLayerRef.current!, + contentBox: {x: 0, y: 0, width: paperTransform.width, height: paperTransform.height}, + layers: [ + svgLayerRef.current!, + htmlLayerRef.current!, + ], + preserveDimensions: true, + convertImagesToDataUris: true, + }); + + await expect(exportedSvgString).toMatchFileSnapshot('toSvg.expected.withImages.svg'); + }); + + it('exports paper with removed by CSS selectors parts', async () => { + const svgLayerRef = React.createRef(); + const htmlLayerRef = React.createRef(); + const lastLayerRef = React.createRef(); + const paperTransform: PaperTransform = { + width: 400, + height: 400, + originX: 0, + originY: 0, + paddingX: 0, + paddingY: 0, + scale: 1, + }; + render( +
+ + + + + + + +
+
+ + {/* The second SVG layer on top of the HTML layer */} + + + + {/* Layer is not exported due to not being explicitly included */} + +
+ + {/* Layer is not exported due to not being explicitly included */} + + + +
+ ); + + expect(svgLayerRef.current).toBeTruthy(); + expect(htmlLayerRef.current).toBeTruthy(); + + const exportedSvgString = await toSVG({ + colorSchemeApi: DUMMY_COLOR_SCHEME_API, + styleRoot: svgLayerRef.current!, + contentBox: {x: 0, y: 0, width: paperTransform.width, height: paperTransform.height}, + layers: [ + svgLayerRef.current!, + htmlLayerRef.current!, + lastLayerRef.current!, + ], + preserveDimensions: true, + removeByCssSelectors: [ + 'rect[fill="red"]', + '.to-remove-from-exported-svg', + ] + }); + + await expect(exportedSvgString).toMatchFileSnapshot('toSvg.expected.withoutRemoved.svg'); + }); +}); + +const DUMMY_COLOR_SCHEME_API: ColorSchemeApi = { + actInColorScheme: (_scheme, action) => action(), +}; diff --git a/tsconfig.json b/tsconfig.json index 25bfade6..f81a431d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "./src/**/*.ts", "./src/**/*.tsx", "./test/**/*.ts", + "./test/**/*.tsx", "./typings/typings.d.ts" ] } diff --git a/typings/typings.d.ts b/typings/typings.d.ts index b3672eac..5e06e441 100644 --- a/typings/typings.d.ts +++ b/typings/typings.d.ts @@ -2,3 +2,18 @@ declare module 'd3-dispatch'; declare module 'd3-timer'; declare module 'd3-drag'; + +declare module '*.resource.svg' { + const imageUrl: string; + export default imageUrl; +} + +declare module '*.inline.svg' { + const imageUrl: string; + export default imageUrl; +} + +declare module '*.module.css' { + const classes: Record; + export default classes; +} diff --git a/vitest.config.mts b/vitest.config.mts index 3a08f789..a708ec0f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,7 +1,29 @@ /// +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; + +const rootDirectory = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + css: { + modules: { + generateScopedName: "[name]__[local]", + } + }, + build: { + assetsInlineLimit: (path, _content) => ( + /.resource.svg$/.test(path) ? false : + /.inline.svg$/.test(path) ? true : + undefined + ), + }, + resolve: { + alias: { + '@images': path.resolve(rootDirectory, 'images'), + '@codicons': '@vscode/codicons/src/icons/', + } + }, test: { browser: { provider: 'playwright', @@ -11,5 +33,12 @@ export default defineConfig({ {browser: 'chromium'}, ] }, - } + }, + // To avoid warning about reloading due to + // "new dependencies optimized: react/jsx-dev-runtime": + optimizeDeps: { + include: [ + "react/jsx-dev-runtime", + ] + }, });