fix: print PDF from actual editor theme CSS and shared Markdown rendering
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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('<Title>')
|
||||
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('','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()
|
||||
})
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>') }
|
||||
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>`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user