diff --git a/package.json b/package.json index a824984845..6b974bb8a2 100644 --- a/package.json +++ b/package.json @@ -243,7 +243,9 @@ "reflect-metadata": "0.2.2", "rehype-katex": "^7.0.1", "rehype-mathjax": "^7.1.0", + "rehype-parse": "^9.0.1", "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", "remark-cjk-friendly": "^1.2.0", "remark-gfm": "^4.0.1", "remark-github-blockquote-alert": "^2.0.0", diff --git a/src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts b/src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts new file mode 100644 index 0000000000..c74e69d5f0 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/__tests__/rehypeScalableSvg.test.ts @@ -0,0 +1,337 @@ +import rehypeParse from 'rehype-parse' +import rehypeStringify from 'rehype-stringify' +import { unified } from 'unified' +import { describe, expect, it } from 'vitest' + +import rehypeScalableSvg from '../rehypeScalableSvg' + +const processHtml = (html: string): string => { + return unified() + .use(rehypeParse, { fragment: true }) + .use(rehypeScalableSvg) + .use(rehypeStringify) + .processSync(html) + .toString() +} + +const createSvgHtml = (attributes: Record): string => { + const attrs = Object.entries(attributes) + .map(([key, value]) => `${key}="${value}"`) + .join(' ') + return `` +} + +describe('rehypeScalableSvg', () => { + describe('simple SVG cases', () => { + it('should add viewBox when missing numeric width and height', () => { + const html = createSvgHtml({ width: '100', height: '50' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100%"') + expect(result).not.toContain('height=') + expect(result).toContain('max-width: 100') + }) + + it('should preserve existing viewBox and original dimensions', () => { + const html = createSvgHtml({ width: '100', height: '50', viewBox: '0 0 100 50' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100"') + expect(result).toContain('height="50"') + expect(result).toContain('max-width: 100') + }) + + it('should handle different viewBox values and preserve original dimensions', () => { + const html = createSvgHtml({ width: '200', height: '100', viewBox: '10 20 180 80' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="10 20 180 80"') + expect(result).toContain('width="200"') + expect(result).toContain('height="100"') + expect(result).toContain('max-width: 200') + }) + + it('should handle numeric width and height as strings', () => { + const html = createSvgHtml({ width: '300', height: '150' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 300 150"') + expect(result).toContain('width="100%"') + expect(result).not.toContain('height=') + expect(result).toContain('max-width: 300') + }) + + it('should handle decimal numeric values', () => { + const html = createSvgHtml({ width: '100.5', height: '50.25' }) + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100.5 50.25"') + expect(result).toContain('width="100%"') + expect(result).not.toContain('height=') + expect(result).toContain('max-width: 100.5') + }) + }) + + describe('complex SVG cases', () => { + it('should flag SVGs with units for runtime measurement', () => { + const html = createSvgHtml({ width: '100px', height: '50px' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('height="50px"') + expect(result).toContain('max-width: 100px') + expect(result).not.toContain('viewBox=') + }) + + it('should handle various CSS units', () => { + const units = ['px', 'pt', 'em', 'rem', '%', 'cm', 'mm'] + + units.forEach((unit) => { + const html = createSvgHtml({ width: `100${unit}`, height: `50${unit}` }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain(`width="100${unit}"`) + expect(result).toContain(`height="50${unit}"`) + expect(result).toContain(`max-width: 100${unit}`) + expect(result).not.toContain('viewBox=') + }) + }) + + it('should handle mixed unit types', () => { + const html = createSvgHtml({ width: '100px', height: '2em' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('height="2em"') + expect(result).toContain('max-width: 100px') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVGs with only width (no height)', () => { + const html = createSvgHtml({ width: '100px' }) + const result = processHtml(html) + + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('max-width: 100px') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVGs with only height (no width)', () => { + const html = createSvgHtml({ height: '50px' }) + const result = processHtml(html) + + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).toContain('height="50px"') + expect(result).not.toContain('max-width:') + expect(result).not.toContain('viewBox=') + }) + }) + + describe('edge cases', () => { + it('should handle SVG with no properties object', () => { + // Create HTML that will result in an SVG element with no properties + const html = '' + const result = processHtml(html) + + // The plugin should handle undefined properties gracefully + expect(result).toBe('') + }) + + it('should handle SVG with no dimensions', () => { + const html = '' + const result = processHtml(html) + + expect(result).not.toContain('width="') + expect(result).not.toContain('height=') + expect(result).not.toContain('viewBox=') + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).not.toContain('max-width:') + }) + + it('should handle SVG with whitespace-only dimensions', () => { + const html = createSvgHtml({ width: ' ', height: ' ' }) + const result = processHtml(html) + + expect(result).not.toContain('data-needs-measurement="true"') + expect(result).toContain('width=" "') + expect(result).toContain('height=" "') + expect(result).not.toContain('max-width:') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVG with non-numeric strings', () => { + const html = createSvgHtml({ width: 'auto', height: 'inherit' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="auto"') + expect(result).toContain('height="inherit"') + expect(result).toContain('max-width: auto') + expect(result).not.toContain('viewBox=') + }) + + it('should handle SVG with mixed numeric and non-numeric values', () => { + const html = createSvgHtml({ width: '100', height: 'auto' }) + const result = processHtml(html) + + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100"') + expect(result).toContain('height="auto"') + expect(result).toContain('max-width: 100') + expect(result).not.toContain('viewBox=') + }) + }) + + describe('style handling', () => { + it('should append to existing style attribute for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: 'fill: red; stroke: blue' + }) + const result = processHtml(html) + + expect(result).toContain('style="fill: red; stroke: blue; max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100%"') + }) + + it('should handle style attribute with trailing semicolon for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: 'fill: red;' + }) + const result = processHtml(html) + + expect(result).toContain('style="fill: red; max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + }) + + it('should handle empty style attribute for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: '' + }) + const result = processHtml(html) + + expect(result).toContain('style="max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + }) + + it('should handle style with only whitespace for simple SVG', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + style: ' ' + }) + const result = processHtml(html) + + expect(result).toContain('style="max-width: 100"') + expect(result).toContain('viewBox="0 0 100 50"') + }) + + it('should preserve complex style attributes for complex SVG', () => { + const html = createSvgHtml({ + width: '100px', + height: '50px', + style: 'fill: url(#gradient); stroke: #333; stroke-width: 2;' + }) + const result = processHtml(html) + + expect(result).toContain('style="fill: url(#gradient); stroke: #333; stroke-width: 2; max-width: 100px"') + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('width="100px"') + expect(result).toContain('height="50px"') + }) + }) + + describe('HTML structure handling', () => { + it('should only process SVG elements', () => { + const html = '
' + const result = processHtml(html) + + expect(result).toBe('
') + }) + + it('should process multiple SVG elements in one document', () => { + const html = ` + + + + ` + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('data-needs-measurement="true"') + expect(result).toContain('viewBox="0 0 300 150"') + }) + + it('should handle nested SVG elements', () => { + const html = ` + + + + ` + const result = processHtml(html) + + expect(result).toContain('viewBox="0 0 200 200"') + expect(result).toContain('viewBox="0 0 100 100"') + }) + + it('should handle SVG with other attributes', () => { + const html = createSvgHtml({ + width: '100', + height: '50', + id: 'test-svg', + class: 'svg-class', + 'data-custom': 'value' + }) + const result = processHtml(html) + + expect(result).toContain('id="test-svg"') + expect(result).toContain('class="svg-class"') + expect(result).toContain('data-custom="value"') + expect(result).toContain('viewBox="0 0 100 50"') + expect(result).toContain('width="100%"') + }) + }) + + describe('numeric validation', () => { + it('should correctly identify numeric strings', () => { + const testCases = [ + { value: '100', expected: true }, + { value: '0', expected: true }, + { value: '-50', expected: true }, + { value: '3.14', expected: true }, + { value: '100px', expected: false }, + { value: 'auto', expected: false }, + { value: '', expected: false }, + { value: ' ', expected: false }, + { value: '100 ', expected: true }, + { value: ' 100', expected: true }, + { value: ' 100 ', expected: true } + ] + + testCases.forEach(({ value, expected }) => { + const html = createSvgHtml({ width: value, height: '50' }) + const result = processHtml(html) + + if (expected && value.trim() !== '') { + expect(result).toContain('viewBox="0 0 ' + value.trim() + ' 50"') + } else if (value.trim() === '') { + expect(result).not.toContain('viewBox=') + } else { + expect(result).toContain('data-needs-measurement="true"') + } + }) + }) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts index 535075c4dd..8d9d3e9332 100644 --- a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts @@ -30,10 +30,10 @@ function rehypeScalableSvg() { return (tree: Root) => { visit(tree, 'element', (node: Element) => { if (node.tagName === 'svg') { - const properties = node.properties || {} + const properties = node.properties const hasViewBox = 'viewBox' in properties - const width = properties.width as string | undefined - const height = properties.height as string | undefined + const width = (properties.width as string)?.trim() + const height = (properties.height as string)?.trim() // 1. Universally set max-width from the width attribute if it exists. // This is safe for both simple and complex cases. @@ -46,16 +46,15 @@ function rehypeScalableSvg() { // 2. Handle viewBox creation for simple, numeric cases. if (!hasViewBox && isNumeric(width) && isNumeric(height)) { properties.viewBox = `0 0 ${width} ${height}` + // Reset or clean up attributes. + properties.width = '100%' + delete properties.height } // 3. Flag complex cases for runtime measurement. else if (!hasViewBox && width && height) { properties['data-needs-measurement'] = 'true' } - // 4. Reset or clean up attributes. - properties.width = '100%' - delete properties.height - node.properties = properties } }) diff --git a/yarn.lock b/yarn.lock index 276a523034..1682a91c84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8621,7 +8621,9 @@ __metadata: reflect-metadata: "npm:0.2.2" rehype-katex: "npm:^7.0.1" rehype-mathjax: "npm:^7.1.0" + rehype-parse: "npm:^9.0.1" rehype-raw: "npm:^7.0.0" + rehype-stringify: "npm:^10.0.1" remark-cjk-friendly: "npm:^1.2.0" remark-gfm: "npm:^4.0.1" remark-github-blockquote-alert: "npm:^2.0.0" @@ -13728,7 +13730,7 @@ __metadata: languageName: node linkType: hard -"hast-util-to-html@npm:^9.0.5": +"hast-util-to-html@npm:^9.0.0, hast-util-to-html@npm:^9.0.5": version: 9.0.5 resolution: "hast-util-to-html@npm:9.0.5" dependencies: @@ -19509,6 +19511,17 @@ __metadata: languageName: node linkType: hard +"rehype-parse@npm:^9.0.1": + version: 9.0.1 + resolution: "rehype-parse@npm:9.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-from-html: "npm:^2.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/efa9ca17673fe70e2d322a1d262796bbed5f6a89382f8f8393352bbd6f6bbf1d4d1d050984b86ff9cb6c0fa2535175ab0829e53c94b1e38fc3c158e6c0ad90bc + languageName: node + linkType: hard + "rehype-raw@npm:^7.0.0": version: 7.0.0 resolution: "rehype-raw@npm:7.0.0" @@ -19520,6 +19533,17 @@ __metadata: languageName: node linkType: hard +"rehype-stringify@npm:^10.0.1": + version: 10.0.1 + resolution: "rehype-stringify@npm:10.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-to-html: "npm:^9.0.0" + unified: "npm:^11.0.0" + checksum: 10c0/c643ae3a4862465033e0f1e9f664433767279b4ee9296570746970a79940417ec1fb1997a513659aab97063cf971c5d97e0af8129f590719f01628c8aa480765 + languageName: node + linkType: hard + "remark-cjk-friendly@npm:^1.2.0": version: 1.2.0 resolution: "remark-cjk-friendly@npm:1.2.0"