fix: print PDF from actual editor theme CSS and shared Markdown rendering

This commit is contained in:
2026-09-07 14:33:19 +08:00
parent 9f097ea629
commit d1cbc10fc4
20 changed files with 530 additions and 43 deletions
@@ -37,7 +37,7 @@ onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
<label>纸张 <select v-model="page"><option>A4</option><option>Letter</option></select></label>
<label><input v-model="title" type="checkbox">包含标题</label>
<p v-if="format === 'docx'">DOCX 使用浅色打印样式</p>
<p v-if="format === 'pdf'">PDF 使用当前主题配色</p>
<p v-if="format === 'pdf'">PDF 使用当前笔记主题与排版样式</p>
<button class="button-primary" :disabled="preparing || !editor.content.trim()" @click="start">{{ preparing ? '准备图表' : '开始导出' }}</button>
<button v-if="preparing" @click="controller?.abort()">取消准备</button>
<p v-if="error" role="alert">{{ error }}</p>
+7 -12
View File
@@ -3,6 +3,8 @@ import {webcrypto} from 'node:crypto'
import {describe,it,expect,vi,afterEach} from 'vitest'
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
vi.mock('./pdfSnapshotService',()=>({preparePdfSnapshot:vi.fn().mockResolvedValue('<html>theme snapshot</html>')}))
import {preparePdfSnapshot} from './pdfSnapshotService'
import {renderMermaid} from './mermaidService'
import {apiClient} from './apiClient'
import {exportService,captureExportPalette} from './exportService'
@@ -63,20 +65,13 @@ it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset f
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[expect.objectContaining({kind:'mermaid',png_base64:'YWJj',source_hash:expect.stringMatching(/^[a-f0-9]{64}$/)})]}))
})
it('PDF prepares more than 16 Mermaid assets with the frozen palette',async()=>{
vi.stubGlobal('crypto',webcrypto)
vi.stubGlobal('Image',class {src='';decode(){return Promise.resolve()}})
vi.spyOn(HTMLCanvasElement.prototype,'getContext').mockReturnValue({fillStyle:'',fillRect:vi.fn(),drawImage:vi.fn()} as never)
vi.spyOn(HTMLCanvasElement.prototype,'toDataURL').mockReturnValue('data:image/png;base64,YWJj')
vi.mocked(renderMermaid).mockResolvedValue({svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"></svg>',warnings:[]} as never)
it('PDF submits the shared browser snapshot instead of raster assets',async()=>{
vi.mocked(apiClient.post).mockResolvedValue(queued)
const palette={page:'#010409',surface:'#161b22',text:'#e6edf3',muted:'#b1bac4',code:'#21262d',border:'#57606a',accent:'#79c0ff'}
const markdown=Array.from({length:17},(_,i)=>'```mermaid\nflowchart LR\n A'+i+'-->B\n```').join('\n\n')
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark',palette})
expect(renderMermaid).toHaveBeenCalledTimes(17)
expect(renderMermaid).toHaveBeenCalledWith(expect.any(String),{mode:'raster',theme:'dark',unlimited:true,palette})
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:expect.arrayContaining(Array.from({length:17},()=>expect.anything())),options:expect.objectContaining({palette})}))
await expect(exportService.create(markdown,'many','html',reviewOptions)).rejects.toThrow('最多 16')
await exportService.create(markdown,'many','pdf',{...reviewOptions,theme_id:'dark'})
expect(preparePdfSnapshot).toHaveBeenCalledWith(markdown,'many',expect.objectContaining({theme_id:'dark'}),undefined,undefined)
expect(renderMermaid).not.toHaveBeenCalled()
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.objectContaining({assets:[],print_html:'<html>theme snapshot</html>'}))
})
it('captures custom theme CSS as a portable palette',()=>{
const values={'background-primary':'#010409','surface-primary':'rgb(22, 27, 34)','text-primary':'#e6edf3','text-secondary':'#b1bac4','background-secondary':'#21262d','border-default':'#57606a','accent-primary':'#79c0ff'}
+7 -2
View File
@@ -68,9 +68,14 @@ export async function hashSource(source: string) {
}
export const exportService = {
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string; palette?: ExportPalette }, signal?: AbortSignal, filePath?: string) {
let printHtml: string | undefined
if (format === 'pdf') {
const { preparePdfSnapshot } = await import('./pdfSnapshotService')
printHtml = await preparePdfSnapshot(markdown,title,options,signal,filePath)
}
const blocks: string[] = []
const parser = new Marked()
parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
if (format !== 'pdf') parser.walkTokens(parser.lexer(markdown), token => { if (token.type === 'code' && token.lang?.trim().split(/\s+/)[0]?.toLowerCase() === 'mermaid') blocks.push(token.text) })
const assets = []
for (const source of [...new Set(blocks)]) {
signal?.throwIfAborted()
@@ -83,7 +88,7 @@ export const exportService = {
signal?.throwIfAborted()
// Keep the response handle when cancellation arrives during submission:
// aborting HTTP alone could leave an undiscoverable running server job.
const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets }))
const job = mapJob(await apiClient.post<JobWire>('/api/exports', { source: { type: 'markdown', markdown, file_path: filePath }, title, format, options, assets, ...(printHtml ? { print_html:printHtml } : {}) }))
if (signal?.aborted) {
await apiClient.post(`/api/exports/${encodeURIComponent(job.id)}/cancel`)
const current = await apiClient.get<JobWire>(`/api/exports/${encodeURIComponent(job.id)}`)
+6 -6
View File
@@ -29,13 +29,13 @@ export function mermaidThemeVariables(dark: boolean, useDocument = true) {
}
}
async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record<string,string>, unlimited = false) {
async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record<string,string>, unlimited = false, frozenVariables?: ReturnType<typeof mermaidThemeVariables>) {
const mermaid = await loadMermaid()
const dark = palette ? [1,3,5].reduce((sum,index,i) => sum + parseInt(palette.surface!.slice(index,index+2),16) * [0.2126,0.7152,0.0722][i]!,0) < 128 : theme === 'dark'
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: palette ? {
themeVariables: frozenVariables ?? (palette ? {
...mermaidThemeVariables(dark, false), background: palette.surface,
primaryColor: palette.code, primaryTextColor: palette.text, primaryBorderColor: palette.border,
secondaryColor: palette.code, secondaryTextColor: palette.text, secondaryBorderColor: palette.border,
@@ -46,7 +46,7 @@ async function ensureInitialized(theme: 'light' | 'dark', raster = false, palett
signalColor: palette.muted, signalTextColor: palette.text, labelBoxBkgColor: palette.surface,
labelBoxBorderColor: palette.border, labelTextColor: palette.text, noteBkgColor: palette.code,
noteTextColor: palette.text, noteBorderColor: palette.border, activationBkgColor: palette.code, activationBorderColor: palette.border,
} : mermaidThemeVariables(theme === 'dark', !raster),
} : mermaidThemeVariables(theme === 'dark', !raster)),
...(unlimited ? { maxTextSize: Number.MAX_SAFE_INTEGER, maxEdges: Number.MAX_SAFE_INTEGER } : {}),
securityLevel: 'strict',
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
@@ -78,18 +78,18 @@ export interface MermaidParseError {
let renderCounter = 0
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}): Promise<MermaidRenderResult> {
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean; themeVariables?: ReturnType<typeof mermaidThemeVariables> } = {}): Promise<MermaidRenderResult> {
return serialized(() => renderMermaidNow(source, options))
}
async function renderMermaidNow(
source: string,
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean; themeVariables?: ReturnType<typeof mermaidThemeVariables> } = {}
): Promise<MermaidRenderResult> {
const theme = options.theme ?? 'light'
const id = `mermaid-${Date.now()}-${++renderCounter}`
try {
const mermaid = await ensureInitialized(theme, options.mode === 'raster', options.palette, options.unlimited)
const mermaid = await ensureInitialized(theme, options.mode === 'raster', options.palette, options.unlimited, options.themeVariables)
const result = await mermaid.render(id, source)
const parser = new DOMParser()
const doc = parser.parseFromString(result.svg, 'image/svg+xml')
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
import {it,expect,vi,afterEach} from 'vitest'
vi.mock('@/utils/markdown',()=>({renderMarkdown:vi.fn().mockResolvedValue('<h1>Heading</h1><details><summary>Tip</summary><p>Body</p></details><div class="markdown-code-toolbar"><button>Copy</button><span>python</span></div>')}))
vi.mock('./apiClient',()=>({apiClient:{post:vi.fn().mockResolvedValue({images:[],plots:[]})}}))
vi.mock('./mermaidService',()=>({mermaidThemeVariables:()=>({primaryColor:'#fff'})}))
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({isDark:false})}))
vi.mock('@/stores/markdownPreferences',()=>({useMarkdownPreferencesStore:()=>({normalized:{wrapCode:true,lineNumbers:true,indent:4}})}))
vi.mock('@/stores/headingAppearance',()=>({useHeadingAppearanceStore:()=>({cssVariables:{'--heading-1-size':'37px'},preferences:{custom:true}})}))
vi.mock('@/components/common/MarkdownContent.vue',()=>({default:{}}))
vi.mock('@/features/editor/VisualMarkdownEditor.vue',()=>({default:{__scopeId:'data-v-editor'}}))
import {preparePdfSnapshot} from './pdfSnapshotService'
import {apiClient} from './apiClient'
import {renderMarkdown} from '@/utils/markdown'
afterEach(()=>vi.clearAllMocks())
it('preserves actual theme CSS, pseudo elements, root attributes and heading preferences',async()=>{
const style=document.createElement('style');style.textContent='[data-theme="paper"] .ProseMirror::before { content:"tape"; transform:rotate(-3deg) }';document.head.append(style)
document.documentElement.dataset.theme='paper'
try {
const html=await preparePdfSnapshot('# Heading','<Title>',{theme_id:'paper',include_title:true,page_size:'A4'})
expect(html).toContain('transform: rotate(-3deg)')
expect(html).toContain('data-theme="paper"')
expect(html).toContain('data-v-editor')
expect(html).toContain('data-heading-style="custom"')
expect(html).toContain('--heading-1-size:37px')
expect(html).toContain('&lt;Title&gt;')
expect(html).toContain('<details open="">')
expect(html).not.toContain('<button>Copy')
expect(html).toContain('<span>python</span>')
expect(renderMarkdown).toHaveBeenCalledWith('# Heading',expect.objectContaining({pdf:expect.anything()}))
} finally {style.remove();delete document.documentElement.dataset.theme}
})
it('rejects a missing image instead of silently producing an incomplete PDF',async()=>{
vi.mocked(renderMarkdown).mockResolvedValueOnce('<img src="missing.png">')
await expect(preparePdfSnapshot('![image](missing.png)','note',{theme_id:'light',include_title:false,page_size:'A4'})).rejects.toThrow('PDF 图片无法读取')
})
it('an aborted snapshot never requests backend resources',async()=>{
const controller=new AbortController();controller.abort()
await expect(preparePdfSnapshot('text','note',{theme_id:'light',include_title:false,page_size:'A4'},controller.signal)).rejects.toMatchObject({name:'AbortError'})
expect(apiClient.post).not.toHaveBeenCalled()
})
+106
View File
@@ -0,0 +1,106 @@
import { apiClient } from './apiClient'
import { renderMarkdown } from '@/utils/markdown'
import { mermaidThemeVariables } from './mermaidService'
import { useThemeStore } from '@/stores/theme'
import { useMarkdownPreferencesStore } from '@/stores/markdownPreferences'
import { useHeadingAppearanceStore } from '@/stores/headingAppearance'
// Load the same CSS, including Vue's scoped editor rules, without mounting an editor.
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import VisualMarkdownEditor from '@/features/editor/VisualMarkdownEditor.vue'
void MarkdownContent; void VisualMarkdownEditor
interface Resources { images: {source:string; data:string|null; warnings:string[]}[]; plots: {source:string; svg:string; warnings:string[]}[] }
interface Options { theme_id:string; include_title:boolean; page_size:string }
const printRules = `
@page { margin: 0; }
html, body { margin:0 !important; padding:0 !important; width:auto !important; height:auto !important; min-height:0 !important; overflow:visible !important; display:block !important; }
* { -webkit-print-color-adjust:exact !important; print-color-adjust:exact !important; animation:none !important; transition:none !important; }
.pdf-document, .pdf-document .milkdown-host, .pdf-document .milkdown { display:block !important; height:auto !important; min-height:0 !important; overflow:visible !important; }
.pdf-document .ProseMirror { min-height:0 !important; overflow:visible !important; box-decoration-break:clone; -webkit-box-decoration-break:clone; }
.pdf-document :is(h1,h2,h3,h4,h5,h6) { break-after:avoid; }
.pdf-document img { max-width:100%; }
.pdf-document .markdown-mermaid > svg { width:100% !important; min-width:0 !important; max-width:100% !important; height:auto !important; max-height:250mm; }
.pdf-document :is(.markdown-mermaid,.markdown-math,table) { break-inside:avoid; }
.pdf-document :is(pre,.shiki) { overflow:visible !important; white-space:pre-wrap; overflow-wrap:anywhere; }
.pdf-document .markdown-code-toolbar button, .pdf-document .diagram-controls { display:none !important; }
`
function attrs(element: Element): string {
return [...element.attributes].filter(a => a.name==='class' || a.name==='style' || a.name.startsWith('data-')).map(a=>` ${a.name}="${escape(a.value)}"`).join('')
}
function escape(text: string) { return text.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
function scopeAttributes(component: unknown) { const id=(component as {__scopeId?:string}).__scopeId; return id ? ` ${id}` : '' }
async function dataUrl(url: string, signal?:AbortSignal):Promise<string> {
const response=await fetch(url,{signal}); if(!response.ok) throw Error(`PDF 资源读取失败:${url}`)
const blob=await response.blob()
return await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(String(reader.result));reader.onerror=reject;reader.readAsDataURL(blob)})
}
async function embedCss(css: string, base: string, signal?:AbortSignal) {
const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)]
for(const match of matches) {
const url=match[2]!
if(url.startsWith('data:')||url.startsWith('#'))continue
const absolute=new URL(url,base)
if(absolute.origin!==location.origin)throw Error(`PDF 主题资源必须来自应用:${absolute.href}`)
css=css.replace(match[0],`url("${await dataUrl(absolute.href,signal)}")`)
}
return css
}
function stylesheetSnapshot(): {css:string;base:string}[] {
const sheets: {css:string;base:string}[]=[]
function visit(sheet:CSSStyleSheet) {
for(const rule of [...sheet.cssRules]) {
if(rule instanceof CSSImportRule && rule.styleSheet)visit(rule.styleSheet)
else sheets.push({css:rule.cssText,base:sheet.href || document.baseURI})
}
}
for(const sheet of [...document.styleSheets])visit(sheet)
return sheets
}
export async function preparePdfSnapshot(markdown:string,title:string,options:Options,signal?:AbortSignal,filePath?:string):Promise<string> {
signal?.throwIfAborted()
const theme=useThemeStore(), preferences={...useMarkdownPreferencesStore().normalized}, heading=useHeadingAppearanceStore()
if(theme.currentThemeId && theme.currentThemeId!==options.theme_id)throw Error('主题在导出准备期间发生变化,请重新导出。')
const htmlAttrs=attrs(document.documentElement), bodyAttrs=attrs(document.body)
const variables=getComputedStyle(document.documentElement)
const rootVariables=[...variables].filter(name=>name.startsWith('--')).map(name=>`${name}:${variables.getPropertyValue(name)};`).join('')
const styles=stylesheetSnapshot()
const diagramVariables=mermaidThemeVariables(theme.isDark)
const headingStyle=Object.entries(heading.cssVariables).map(([key,value])=>`${key}:${value}`).join(';')
const customHeading=heading.preferences.custom
const dark=theme.isDark
const resources=await apiClient.post<Resources>('/api/exports/preview-resources',{format:'pdf',source:{type:'markdown',markdown,file_path:filePath},options})
signal?.throwIfAborted()
const rendered=await renderMarkdown(markdown,{themeId:options.theme_id,theme:dark?'dark':'light',preferences,pdf:{mermaidVariables:diagramVariables,plot:async source=>{
const plot=resources.plots.find(p=>p.source.trim()===source.trim()); if(!plot?.svg)throw Error(plot?.warnings.join('; ')||'函数图像无法导出');return plot
}}})
const fragment=new DOMParser().parseFromString(rendered,'text/html')
for(const image of fragment.querySelectorAll('img')) {
const source=image.getAttribute('src')||''
if(source.startsWith('data:'))continue
const resource=resources.images.find(item=>item.source===source)
if(!resource?.data)throw Error(resource?.warnings.join('; ')||`PDF 图片无法读取:${source}`)
image.src=resource.data
}
// Print all callout content and remove only interactive tools, not decoration.
fragment.querySelectorAll('details').forEach(d=>d.open=true)
// The workspace uses blockquotes for callouts. Preserve that DOM contract so
// editor-specific theme selectors apply, including spacing and decoration.
fragment.querySelectorAll('.markdown-callout:not(blockquote)').forEach(details=>{
const block=fragment.createElement('blockquote')
for(const attribute of [...details.attributes])if(attribute.name!=='open')block.setAttribute(attribute.name,attribute.value)
block.innerHTML=details.innerHTML
const summary=block.querySelector('summary')
if(summary){const title=fragment.createElement('div');title.className=summary.className;title.innerHTML=summary.innerHTML;summary.replaceWith(title)}
details.replaceWith(block)
})
const error=fragment.querySelector('.mermaid-error')
if(error)throw Error(error.textContent||'PDF 图表渲染失败')
fragment.querySelectorAll('.markdown-code-toolbar button,.diagram-controls').forEach(e=>e.remove())
const css=(await Promise.all(styles.map(s=>embedCss(s.css,s.base,signal)))).join('\n')
signal?.throwIfAborted()
const scope=scopeAttributes(VisualMarkdownEditor)
return `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><title>${escape(title)}</title><style>${css.replace(/<\/style/gi,'<\\/style')}\n:root{${rootVariables}}\n${printRules}</style></head><body${bodyAttrs}><div class="visual-editor pdf-document"${scope} ${customHeading?'data-heading-style="custom"':''} style="${escape(headingStyle)}"><div class="milkdown-host"${scope}><div class="milkdown"><article class="ProseMirror markdown-content" data-code-wrap="${preferences.wrapCode}" data-line-numbers="${preferences.lineNumbers}" style="--markdown-code-indent:${preferences.indent}">${options.include_title?`<h1>${escape(title)}</h1>`:''}${fragment.body.innerHTML}</article></div></div></div></body></html>`
}
+8 -7
View File
@@ -7,7 +7,7 @@ import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import { bundledLanguagesInfo } from 'shiki/langs'
import githubDark from '@shikijs/themes/github-dark'
import githubLight from '@shikijs/themes/github-light'
import { renderMermaid } from '@/services/mermaidService'
import { renderMermaid, type mermaidThemeVariables } from '@/services/mermaidService'
import { appendDiagramControls } from './diagramControls'
import katex from 'katex'
import 'katex/dist/katex.min.css'
@@ -126,7 +126,7 @@ export async function getCodeTokenizer(theme: 'github-light' | 'github-dark', re
}
}
export async function renderMarkdown(source: string, options?: { themeId?: string; theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> {
export async function renderMarkdown(source: string, options?: { themeId?: string; theme?: 'light' | 'dark'; preferences?: MarkdownPreferences; pdf?: { plot: (source: string) => Promise<{svg: string; warnings: string[]}>; mermaidVariables: ReturnType<typeof mermaidThemeVariables> }; citationNumbers?: number[]; citationAliases?: Record<string, number> }): Promise<string> {
const preferences = options?.preferences ?? defaultMarkdownPreferences
const marked = createMarkdownParser(preferences)
const citations = new Set(options?.citationNumbers ?? [])
@@ -146,8 +146,9 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
for (const code of documentNode.querySelectorAll('pre > code')) {
const requestedLanguage = [...code.classList].find((name) => name.startsWith('language-'))?.slice(9) || 'text'
if (['mermaid', 'function-plot'].includes(requestedLanguage) && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: requestedLanguage })
const diagramKind = requestedLanguage.toLowerCase().split(/\s+/)[0]!.replace('function_plot','function-plot')
if (['mermaid', 'function-plot'].includes(diagramKind) && preferences.diagrams) {
mermaidBlocks.push({ pre: code.parentElement!, source: code.textContent ?? '', kind: diagramKind })
continue
}
if (requestedLanguage.toLowerCase() === 'latex' && preferences.math) {
@@ -172,9 +173,9 @@ export async function renderMarkdown(source: string, options?: { themeId?: strin
let plotCount = 0, plotNodes = 0
for (const { pre, source, kind } of mermaidBlocks) {
try {
if (kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16')
const result = kind === 'function-plot' ? await renderFunctionPlot(source, options?.themeId) : await renderMermaid(source, { theme: options?.theme, mode: 'static' })
if ('nodeCount' in result && (plotNodes += result.nodeCount) > 8000) throw new Error('函数图像累计复杂度超过 8000')
if (!options?.pdf && kind === 'function-plot' && ++plotCount > 16) throw new Error('函数图像数量超过 16')
const result = kind === 'function-plot' ? (options?.pdf ? await options.pdf.plot(source) : await renderFunctionPlot(source, options?.themeId)) : await renderMermaid(source, { theme: options?.theme, mode: 'static', ...(options?.pdf ? { unlimited:true, themeVariables:options.pdf.mermaidVariables } : {}) })
if (!options?.pdf && 'nodeCount' in result && (plotNodes += Number(result.nodeCount)) > 8000) throw new Error('函数图像累计复杂度超过 8000')
const container = document.createElement('div')
container.className = 'markdown-mermaid' + (kind === 'function-plot' ? ' markdown-function-plot' : '')
container.innerHTML = result.svg