refactor(SvgPreview,Markdown): make svg size adaptive (#9232)

* refactor(Svg): make svg preview scalable

* feat: make svg in markdown scalable

* refactor: add measureElementSize

* refactor: improve rehypeScalableSvg, add MarkdownSvgRenderer

* fix: svg namespace

* perf: improve namespace correction

* refactor: rename makeSvgScalable to makeSvgSizeAdaptive

* test: fix tests for renderSvgInShadowHost

* refactor: improve MarkdownSvgRenderer re-render

* feat: sanitize svg before rendering

* feat: make MarkdownSvgRenderer clickable

* test: fix

* Revert "feat: make MarkdownSvgRenderer clickable"

This reverts commit 73af8fbb8c.

* refactor: use context menu in MarkdownSvgRenderer

* refactor: remove preserveAspectRatio from svg
This commit is contained in:
one 2025-08-16 23:19:47 +08:00 committed by GitHub
parent 62a6a0a8be
commit 72d0fea3a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 408 additions and 44 deletions

View File

@ -179,6 +179,7 @@
"dexie-react-hooks": "^1.1.7",
"diff": "^7.0.0",
"docx": "^9.0.2",
"dompurify": "^3.2.6",
"dotenv-cli": "^7.4.2",
"electron": "37.2.3",
"electron-builder": "26.0.15",

View File

@ -10,29 +10,39 @@ describe('renderSvgInShadowHost', () => {
// Mock attachShadow
Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
const shadowRoot = document.createElement('div')
// Check if a shadow root already exists to prevent re-creating it.
if (this.shadowRoot) {
return this.shadowRoot
}
// Create a container that acts as the shadow root.
const shadowRootContainer = document.createElement('div')
shadowRootContainer.dataset.testid = 'shadow-root'
Object.defineProperty(this, 'shadowRoot', {
value: shadowRoot,
value: shadowRootContainer,
writable: true,
configurable: true
})
// Simple innerHTML copy for test verification
Object.defineProperty(shadowRoot, 'innerHTML', {
set(value) {
shadowRoot.textContent = value // A simplified mock
// Mock essential methods like appendChild and innerHTML.
// JSDOM doesn't fully implement shadow DOM, so we simulate its behavior.
const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')
Object.defineProperty(shadowRootContainer, 'innerHTML', {
set(value: string) {
// Clear existing content and parse the new HTML.
originalInnerHTMLDescriptor?.set?.call(this, '')
const template = document.createElement('template')
template.innerHTML = value
shadowRootContainer.append(...Array.from(template.content.childNodes))
},
get() {
return shadowRoot.textContent || ''
return originalInnerHTMLDescriptor?.get?.call(this) ?? ''
},
configurable: true
})
shadowRoot.appendChild = vi.fn(<T extends Node>(node: T): T => {
shadowRoot.append(node)
return node
})
return shadowRoot as unknown as ShadowRoot
return shadowRootContainer as unknown as ShadowRoot
})
})
@ -57,7 +67,7 @@ describe('renderSvgInShadowHost', () => {
expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
// Verify it works with the existing shadow root
expect(existingShadowRoot.appendChild).toHaveBeenCalled()
expect(existingShadowRoot.innerHTML).toContain('<svg')
})
it('should inject styles and valid SVG content into the shadow DOM', () => {
@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => {
expect(shadowRoot?.querySelector('rect')).not.toBeNull()
})
it('should add the xmlns attribute if it is missing', () => {
const svgWithoutXmlns = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
renderSvgInShadowHost(svgWithoutXmlns, hostElement)
const svgElement = hostElement.shadowRoot?.querySelector('svg')
expect(svgElement).not.toBeNull()
expect(svgElement?.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
})
it('should throw an error if the host element is not available', () => {
expect(() => renderSvgInShadowHost('<svg></svg>', null as any)).toThrow(
'Host element for SVG rendering is not available.'
)
})
it('should throw an error for invalid SVG content', () => {
const invalidSvg = '<svg><rect></svg>' // Malformed
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
it('should not throw an error for malformed SVG content due to HTML parser fallback', () => {
const invalidSvg = '<svg><rect></svg>' // Malformed, but fixable by the browser's HTML parser
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).not.toThrow()
// Also, assert that it successfully rendered something.
expect(hostElement.shadowRoot?.querySelector('svg')).not.toBeNull()
})
it('should throw an error for non-SVG content', () => {
const nonSvg = '<div>this is not svg</div>'
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow()
})
it('should not throw an error for empty or whitespace content', () => {

View File

@ -1,3 +1,6 @@
import { makeSvgSizeAdaptive } from '@renderer/utils'
import DOMPurify from 'dompurify'
/**
* Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
* This function handles creating the shadow root, injecting base styles for the host,
@ -12,15 +15,22 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
throw new Error('Host element for SVG rendering is not available.')
}
// Sanitize the SVG content
const sanitizedContent = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
RETURN_DOM_FRAGMENT: false,
RETURN_DOM: false
})
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
// Base styles for the host element
// Base styles for the host element and the inner SVG
const style = document.createElement('style')
style.textContent = `
:host {
padding: 1em;
background-color: white;
overflow: auto;
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
border: 0.5px solid var(--color-code-background);
border-radius: 8px;
display: block;
@ -28,34 +38,51 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
width: 100%;
height: 100%;
}
svg {
max-width: 100%;
height: auto;
}
`
// Clear previous content and append new style and SVG
// Clear previous content and append new style
shadowRoot.innerHTML = ''
shadowRoot.appendChild(style)
// Parse and append the SVG using DOMParser to prevent script execution and check for errors
if (svgContent.trim() === '') {
if (sanitizedContent.trim() === '') {
return
}
const parser = new DOMParser()
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
const parser = new DOMParser()
const doc = parser.parseFromString(sanitizedContent, 'image/svg+xml')
const parserError = doc.querySelector('parsererror')
if (parserError) {
// Throw a specific error that can be caught by the calling component
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
let svgElement: Element = doc.documentElement
// If parsing fails or the namespace is incorrect, fall back to the more lenient HTML parser.
if (parserError || svgElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
const tempDiv = document.createElement('div')
tempDiv.innerHTML = sanitizedContent
const svgFromHtml = tempDiv.querySelector('svg')
if (svgFromHtml) {
// Directly use the DOM node created by the HTML parser.
svgElement = svgFromHtml
// Ensure the xmlns attribute is present.
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
} else {
// If both parsing methods fail, the SVG content is genuinely invalid.
if (parserError) {
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
}
throw new Error('Invalid SVG content: The provided string does not contain a valid SVG element.')
}
}
const svgElement = doc.documentElement
if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
shadowRoot.appendChild(svgElement.cloneNode(true))
} else if (svgContent.trim() !== '') {
// Do not throw error for empty content
// Type guard
if (svgElement instanceof SVGSVGElement) {
// Standardize the SVG element for proper scaling
makeSvgSizeAdaptive(svgElement)
// Append the SVG element to the shadow root
shadowRoot.appendChild(svgElement)
} else {
// This path is taken if the content is valid XML but not a valid SVG document
// (e.g., root element is not <svg>), or if the fallback parser fails.
throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
}
}

View File

@ -29,7 +29,9 @@ import { Pluggable } from 'unified'
import CodeBlock from './CodeBlock'
import Link from './Link'
import MarkdownSvgRenderer from './MarkdownSvgRenderer'
import rehypeHeadingIds from './plugins/rehypeHeadingIds'
import rehypeScalableSvg from './plugins/rehypeScalableSvg'
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
import Table from './Table'
@ -113,7 +115,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
const rehypePlugins = useMemo(() => {
const plugins: Pluggable[] = []
if (ALLOWED_ELEMENTS.test(messageContent)) {
plugins.push(rehypeRaw)
plugins.push(rehypeRaw, rehypeScalableSvg)
}
plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }])
if (mathEngine === 'KaTeX') {
@ -148,7 +150,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
if (hasImage) return <div {...props} />
return <p {...props} />
}
},
svg: MarkdownSvgRenderer
} as Partial<Components>
}, [onSaveCodeBlock, block.id])

View File

@ -0,0 +1,76 @@
import { ImagePreviewService } from '@renderer/services/ImagePreviewService'
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
import { Dropdown } from 'antd'
import { Eye } from 'lucide-react'
import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
interface SvgProps extends React.SVGProps<SVGSVGElement> {
'data-needs-measurement'?: 'true'
}
/**
* A smart SVG renderer for Markdown content.
*
* This component handles two types of SVGs passed from `react-markdown`:
*
* 1. **Pre-processed SVGs**: Simple SVGs that were already handled by the
* `rehypeScalableSvg` plugin. These are rendered directly.
*
* 2. **SVGs needing measurement**: Complex SVGs are flagged with
* `data-needs-measurement`. This component performs a one-time DOM
* mutation upon mounting to make them scalable. To prevent React from
* reverting these changes during subsequent renders, it stops passing
* the original `width` and `height` props after the mutation is complete.
*/
const MarkdownSvgRenderer: FC<SvgProps> = (props) => {
const { 'data-needs-measurement': needsMeasurement, ...restProps } = props
const svgRef = useRef<SVGSVGElement>(null)
const isMeasuredRef = useRef(false)
const { t } = useTranslation()
useEffect(() => {
if (needsMeasurement && svgRef.current && !isMeasuredRef.current) {
// Directly mutate the DOM element to make it adaptive.
makeSvgSizeAdaptive(svgRef.current)
// Set flag to prevent re-measuring. This does not trigger a re-render.
isMeasuredRef.current = true
}
}, [needsMeasurement])
const onPreview = useCallback(() => {
if (!svgRef.current) return
ImagePreviewService.show(svgRef.current, { format: 'svg' })
}, [])
const contextMenuItems = useMemo(
() => [
{
key: 'preview',
label: t('common.preview'),
icon: <Eye size="1rem" />,
onClick: onPreview
}
],
[onPreview, t]
)
// Create a mutable copy of props to potentially modify.
const finalProps = { ...restProps }
// If the SVG has been measured and mutated, we prevent React from
// re-applying the original width and height attributes on subsequent renders.
// This preserves the changes made by `makeSvgSizeAdaptive`.
if (isMeasuredRef.current) {
delete finalProps.width
delete finalProps.height
}
return (
<Dropdown menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
<svg ref={svgRef} {...finalProps} />
</Dropdown>
)
}
export default MarkdownSvgRenderer

View File

@ -68,9 +68,9 @@ vi.mock('../CodeBlock', () => ({
)
}))
vi.mock('../ImagePreview', () => ({
vi.mock('@renderer/components/ImageViewer', () => ({
__esModule: true,
default: (props: any) => <img data-testid="image-preview" {...props} />
default: (props: any) => <img data-testid="image-viewer" {...props} />
}))
vi.mock('../Link', () => ({
@ -94,12 +94,18 @@ vi.mock('../Table', () => ({
)
}))
vi.mock('../MarkdownSvgRenderer', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="svg-renderer">{children}</div>
}))
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
__esModule: true,
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
}))
// Mock plugins
vi.mock('remark-alert', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() }))
vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() }))
@ -113,6 +119,16 @@ vi.mock('../plugins/remarkDisableConstructs', () => ({
default: vi.fn()
}))
vi.mock('../plugins/rehypeHeadingIds', () => ({
__esModule: true,
default: vi.fn()
}))
vi.mock('../plugins/rehypeScalableSvg', () => ({
__esModule: true,
default: vi.fn()
}))
// Mock ReactMarkdown with realistic rendering
vi.mock('react-markdown', () => ({
__esModule: true,
@ -331,7 +347,7 @@ describe('Markdown', () => {
expect(tableComponent).toHaveAttribute('data-block-id', 'test-block-456')
})
it('should integrate ImagePreview component', () => {
it('should integrate ImageViewer component', () => {
render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()

View File

@ -0,0 +1,65 @@
import type { Element, Root } from 'hast'
import { visit } from 'unist-util-visit'
const isNumeric = (value: unknown): boolean => {
if (typeof value === 'string' && value.trim() !== '') {
return String(parseFloat(value)) === value.trim()
}
return false
}
/**
* A Rehype plugin that prepares SVG elements for scalable rendering.
*
* This plugin classifies SVGs into two categories:
*
* 1. **Simple SVGs**: Those that already have a `viewBox` or have unitless
* numeric `width` and `height` attributes. These are processed directly
* in the HAST tree for maximum performance. A `viewBox` is added if
* missing, and fixed dimensions are removed.
*
* 2. **Complex SVGs**: Those without a `viewBox` and with dimensions that
* have units (e.g., "100pt", "10em"). These cannot be safely processed
* at the data layer. The plugin adds a `data-needs-measurement="true"`
* attribute to them, flagging them for runtime processing by a
* specialized React component.
*
* @returns A unified transformer function.
*/
function rehypeScalableSvg() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'svg') {
const properties = node.properties || {}
const hasViewBox = 'viewBox' in properties
const width = properties.width as string | undefined
const height = properties.height as string | undefined
// 1. Universally set max-width from the width attribute if it exists.
// This is safe for both simple and complex cases.
if (width) {
const existingStyle = properties.style ? String(properties.style).trim().replace(/;$/, '') : ''
const maxWidth = `max-width: ${width}`
properties.style = existingStyle ? `${existingStyle}; ${maxWidth}` : maxWidth
}
// 2. Handle viewBox creation for simple, numeric cases.
if (!hasViewBox && isNumeric(width) && isNumeric(height)) {
properties.viewBox = `0 0 ${width} ${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
}
})
}
}
export default rehypeScalableSvg

View File

@ -6,7 +6,8 @@ import {
captureScrollableDivAsBlob,
captureScrollableDivAsDataURL,
compressImage,
convertToBase64
convertToBase64,
makeSvgSizeAdaptive
} from '../image'
// mock 依赖
@ -125,4 +126,79 @@ describe('utils/image', () => {
expect(func).not.toHaveBeenCalled()
})
})
describe('makeSvgSizeAdaptive', () => {
const createSvgElement = (svgString: string): SVGElement => {
const div = document.createElement('div')
div.innerHTML = svgString
const svgElement = div.querySelector<SVGElement>('svg')
if (!svgElement) {
throw new Error(`Test setup error: No <svg> element found in string: "${svgString}"`)
}
return svgElement
}
// Mock document.body.appendChild to avoid errors in jsdom
beforeEach(() => {
vi.spyOn(document.body, 'appendChild').mockImplementation(() => ({}) as Node)
vi.spyOn(document.body, 'removeChild').mockImplementation(() => ({}) as Node)
})
it('should measure and add viewBox/max-width when viewBox is missing', () => {
const svgElement = createSvgElement('<svg width="100pt" height="80pt"></svg>')
// Mock the measurement result on the prototype
const spy = vi
.spyOn(SVGElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 133, height: 106 } as DOMRect)
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
expect(spy).toHaveBeenCalled()
expect(result.getAttribute('viewBox')).toBe('0 0 133 106')
expect(result.style.maxWidth).toBe('133px')
expect(result.getAttribute('width')).toBe('100%')
expect(result.hasAttribute('height')).toBe(false)
spy.mockRestore() // Clean up the prototype spy
})
it('should use width attribute for max-width when viewBox is present', () => {
const svgElement = createSvgElement('<svg viewBox="0 0 50 50" width="100pt" height="80pt"></svg>')
const spy = vi.spyOn(SVGElement.prototype, 'getBoundingClientRect') // Spy to ensure it's NOT called
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
expect(spy).not.toHaveBeenCalled()
expect(result.getAttribute('viewBox')).toBe('0 0 50 50')
expect(result.style.maxWidth).toBe('100pt')
expect(result.getAttribute('width')).toBe('100%')
expect(result.hasAttribute('height')).toBe(false)
spy.mockRestore()
})
it('should handle measurement failure gracefully', () => {
const svgElement = createSvgElement('<svg width="100pt" height="80pt"></svg>')
// Mock a failed measurement
const spy = vi
.spyOn(SVGElement.prototype, 'getBoundingClientRect')
.mockReturnValue({ width: 0, height: 0 } as DOMRect)
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
expect(result.hasAttribute('viewBox')).toBe(false)
expect(result.style.maxWidth).toBe('100pt') // Falls back to width attribute
expect(result.getAttribute('width')).toBe('100%')
spy.mockRestore()
})
it('should return the element unchanged if it is not an SVGElement', () => {
const divElement = document.createElement('div')
const originalOuterHTML = divElement.outerHTML
const result = makeSvgSizeAdaptive(divElement)
expect(result.outerHTML).toBe(originalOuterHTML)
})
})
})

View File

@ -270,3 +270,81 @@ export const svgToSvgBlob = (svgElement: SVGElement): Blob => {
const svgData = new XMLSerializer().serializeToString(svgElement)
return new Blob([svgData], { type: 'image/svg+xml' })
}
/**
* 使 DOM
* @param element
* @returns
*/
function measureElementSize(element: Element): { width: number; height: number } {
const clone = element.cloneNode(true) as Element
// 检查元素类型并重置样式
if (clone instanceof HTMLElement || clone instanceof SVGElement) {
clone.style.width = ''
clone.style.height = ''
clone.style.position = ''
clone.style.visibility = ''
}
// 创建一个离屏容器
const container = document.createElement('div')
container.style.position = 'absolute'
container.style.top = '-9999px'
container.style.left = '-9999px'
container.style.visibility = 'hidden'
container.appendChild(clone)
document.body.appendChild(container)
// 测量并清理
const rect = clone.getBoundingClientRect()
document.body.removeChild(container)
return { width: rect.width, height: rect.height }
}
/**
* SVG
* - viewBox
* - max-width style
* - width 100%
* - height
*/
export const makeSvgSizeAdaptive = (element: Element): Element => {
// type guard
if (!(element instanceof SVGElement)) {
return element
}
const hasViewBox = element.hasAttribute('viewBox')
const widthStr = element.getAttribute('width')
let measuredWidth: number | undefined
// 如果缺少 viewBox 属性,测量元素尺寸来创建
if (!hasViewBox) {
const renderedSize = measureElementSize(element)
if (renderedSize.width > 0 && renderedSize.height > 0) {
measuredWidth = renderedSize.width
element.setAttribute('viewBox', `0 0 ${renderedSize.width} ${renderedSize.height}`)
}
}
// 设置 max-width
// 优先使用测量得到的宽度值,否则回退到 width 属性值
if (measuredWidth !== undefined) {
element.style.setProperty('max-width', `${measuredWidth}px`)
} else if (widthStr) {
element.style.setProperty('max-width', widthStr)
}
// 调整 width 和 height
element.setAttribute('width', '100%')
element.removeAttribute('height')
// FIXME: 移除 preserveAspectRatio 来避免某些图无法正常预览
element.removeAttribute('preserveAspectRatio')
return element
}

View File

@ -8552,6 +8552,7 @@ __metadata:
dexie-react-hooks: "npm:^1.1.7"
diff: "npm:^7.0.0"
docx: "npm:^9.0.2"
dompurify: "npm:^3.2.6"
dotenv-cli: "npm:^7.4.2"
electron: "npm:37.2.3"
electron-builder: "npm:26.0.15"
@ -11445,7 +11446,7 @@ __metadata:
languageName: node
linkType: hard
"dompurify@npm:^3.2.5":
"dompurify@npm:^3.2.5, dompurify@npm:^3.2.6":
version: 3.2.6
resolution: "dompurify@npm:3.2.6"
dependencies: