fix: preserve exports on close and support themed PDF without export quotas

This commit is contained in:
2026-09-07 14:04:03 +08:00
parent 47c53b6f38
commit 9f097ea629
20 changed files with 485 additions and 123 deletions
@@ -0,0 +1,22 @@
// @vitest-environment jsdom
import {mount,flushPromises} from '@vue/test-utils'
import {it,expect,vi} from 'vitest'
import ExportDialog from './ExportDialog.vue'
import {apiClient} from '@/services/apiClient'
vi.mock('@/stores/editor',()=>({useEditorStore:()=>({content:'# snapshot',currentFilePath:'note.md'})}))
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({currentThemeId:'light'})}))
vi.mock('@/services/mermaidService',()=>({renderMermaid:vi.fn()}))
vi.mock('@/services/apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
it('closing the dialog after submitting preserves the background export',async()=>{
let finish!:(value:unknown)=>void
vi.mocked(apiClient.get).mockImplementation(async(path:string)=>path==='/api/exports'?{items:[]}:{job_id:'closing-job',status:'cancelled',warnings:[],error:null,file:null})
vi.mocked(apiClient.post).mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve}) as never).mockResolvedValue({status:'completed'})
const wrapper=mount(ExportDialog,{global:{stubs:{AppDialog:{template:'<div><slot /></div>'}}}})
await flushPromises()
await wrapper.findAll('button').find(b=>b.text()==='开始导出')!.trigger('click')
expect(apiClient.post).toHaveBeenCalledWith('/api/exports',expect.anything())
wrapper.unmount()
finish({job_id:'closing-job',status:'queued',warnings:[],error:null,file:null})
await flushPromises()
expect(apiClient.post).not.toHaveBeenCalledWith('/api/exports/closing-job/cancel')
})
@@ -3,7 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import AppDialog from '@/components/common/AppDialog.vue'
import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme'
import { exportService, type ExportFormat, type ExportJob } from '@/services/exportService'
import { exportService, captureExportPalette, type ExportFormat, type ExportJob } from '@/services/exportService'
const emit = defineEmits<{ close: [] }>()
const editor = useEditorStore(), theme = useThemeStore()
const format = ref<ExportFormat>('html'), page = ref('A4'), title = ref(true)
@@ -19,7 +19,7 @@ async function start() {
preparing.value = true; error.value = ''; controller = new AbortController()
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
try {
const job = await exportService.create(snapshot, name, format.value, { theme_id: theme.currentThemeId, include_title: title.value, page_size: page.value }, controller.signal, editor.currentFilePath ?? undefined)
const job = await exportService.create(snapshot, name, format.value, { theme_id: theme.currentThemeId, include_title: title.value, page_size: page.value, palette: captureExportPalette() }, controller.signal, editor.currentFilePath ?? undefined)
if (!disposed) jobs.value.unshift(job)
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
finally { preparing.value = false }
@@ -28,7 +28,7 @@ async function action(job: ExportJob, download = false) {
try { if (download) await exportService.download(job); else await exportService.cancel(job.id) } catch (e) { error.value = String(e) }
}
onMounted(refresh)
onBeforeUnmount(() => { disposed = true; clearTimeout(timer); controller?.abort() })
onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
</script>
<template>
<AppDialog label="导出笔记" @close="emit('close')"><section class="modal export-modal">
@@ -36,7 +36,8 @@ onBeforeUnmount(() => { disposed = true; clearTimeout(timer); controller?.abort(
<label for="export-format">格式</label><select id="export-format" v-model="format"><option value="html">HTML</option><option value="pdf">PDF</option><option value="docx">DOCX</option></select>
<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 !== 'html'">PDF / DOCX 使用浅色打印样式</p>
<p v-if="format === 'docx'">DOCX 使用浅色打印样式</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>
+23 -1
View File
@@ -5,7 +5,7 @@ vi.mock('./apiClient',()=>({apiClient:{post:vi.fn(),get:vi.fn()}}))
vi.mock('./mermaidService',()=>({renderMermaid:vi.fn()}))
import {renderMermaid} from './mermaidService'
import {apiClient} from './apiClient'
import {exportService} from './exportService'
import {exportService,captureExportPalette} from './exportService'
describe('export snapshot contract',()=>{
it('submits the unsaved Markdown snapshot and maps warnings and filename',async()=>{
vi.mocked(apiClient.post).mockResolvedValue({job_id:'job',status:'queued',warnings:['print palette'],file:{file_name:'note.pdf'},error:null})
@@ -62,3 +62,25 @@ it.each(['mermaid','Mermaid','mermaid title="Flow"'])('prepares a static asset f
expect(renderMermaid).toHaveBeenCalledWith('flowchart LR\n A-->B',{mode:'raster',theme:'light'})
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)
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')
})
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'}
for(const [key,value] of Object.entries(values))document.documentElement.style.setProperty('--color-'+key,value)
try { expect(captureExportPalette()).toMatchObject({surface:'#161b22',text:'#e6edf3',accent:'#79c0ff'}) }
finally { for(const key of Object.keys(values))document.documentElement.style.removeProperty('--color-'+key) }
})
+35 -8
View File
@@ -10,13 +10,39 @@ interface JobWire {
}
export interface ExportJob { id: string; status: JobWire['status']; warnings: string[]; error: string | null; fileName?: string }
const mapJob = (w: JobWire): ExportJob => ({ id: w.job_id, status: w.status, warnings: w.warnings, error: w.error, fileName: w.file?.file_name })
export async function rasterize(svg: string, signal?: AbortSignal): Promise<string> {
export type ExportPalette = Record<'page' | 'surface' | 'text' | 'muted' | 'code' | 'border' | 'accent', string>
export function captureExportPalette(): ExportPalette | undefined {
const style = getComputedStyle(document.documentElement)
const tokens = { page:'background-primary', surface:'surface-primary', text:'text-primary', muted:'text-secondary', code:'background-secondary', border:'border-default', accent:'accent-primary' }
const entries = Object.entries(tokens).map(([key, token]) => {
const value = style.getPropertyValue(`--color-${token}`).trim()
if (/^#[0-9a-f]{6}$/i.test(value)) return [key,value]
if (/^#[0-9a-f]{3}$/i.test(value)) return [key, '#' + [...value.slice(1)].map(c => c+c).join('')]
const rgb = value.match(/^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/)
if (rgb) return [key, '#' + rgb.slice(1,4).map(v => Number(v).toString(16).padStart(2,'0')).join('')]
// Resolve named colors, color-mix/OKLCH and alpha through the browser's
// color implementation before freezing a portable RGB palette.
if (typeof CSS !== 'undefined' && CSS.supports('color', value)) {
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 1
const context = canvas.getContext('2d')
if (context) {
context.fillStyle = '#ffffff'; context.fillRect(0,0,1,1)
context.fillStyle = value; context.fillRect(0,0,1,1)
const pixel = context.getImageData(0,0,1,1).data
return [key, '#' + [...pixel.slice(0,3)].map(v => v.toString(16).padStart(2,'0')).join('')]
}
}
return [key, '']
})
return entries.every(([,value]) => value) ? Object.fromEntries(entries) as ExportPalette : undefined
}
export async function rasterize(svg: string, signal?: AbortSignal, unlimited = false, background = '#ffffff'): Promise<string> {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
const root = doc.documentElement
const box = root.getAttribute('viewBox')?.split(/[ ,]+/).map(Number)
const width = box?.[2] || 800, height = box?.[3] || 600
if (!Number.isFinite(width + height) || width <= 0 || height <= 0) throw new Error('图表尺寸无效')
const scale = Math.min(4, Math.max(2, 1200 / width), Math.sqrt(4_000_000 / (width * height)))
const scale = Math.min(4, Math.max(2, 1200 / width), unlimited ? Infinity : Math.sqrt(4_000_000 / (width * height)))
root.setAttribute('width', String(Math.floor(width * scale))); root.setAttribute('height', String(Math.floor(height * scale)))
root.style.maxWidth = 'none'
const data = new XMLSerializer().serializeToString(root)
@@ -24,7 +50,7 @@ export async function rasterize(svg: string, signal?: AbortSignal): Promise<stri
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(data)}`
await new Promise<void>((resolve, reject) => {
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
const timer = setTimeout(() => reject(new Error('图表图片解码超时')), 15000)
const timer = unlimited ? undefined : setTimeout(() => reject(new Error('图表图片解码超时')), 15000)
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort) }
if (signal?.aborted) { cleanup(); abort(); return }
signal?.addEventListener('abort', abort, { once: true })
@@ -33,7 +59,7 @@ export async function rasterize(svg: string, signal?: AbortSignal): Promise<stri
const canvas = document.createElement('canvas')
canvas.width = Math.floor(width * scale); canvas.height = Math.floor(height * scale)
const context = canvas.getContext('2d')!
context.fillStyle = '#ffffff'; context.fillRect(0, 0, canvas.width, canvas.height)
context.fillStyle = background; context.fillRect(0, 0, canvas.width, canvas.height)
context.drawImage(image, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png').split(',')[1]!
}
@@ -41,17 +67,18 @@ export async function hashSource(source: string) {
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source.trim())))].map(v => v.toString(16).padStart(2, '0')).join('')
}
export const exportService = {
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string }, signal?: AbortSignal, filePath?: string) {
async create(markdown: string, title: string, format: ExportFormat, options: { theme_id: string; include_title: boolean; page_size: string; palette?: ExportPalette }, signal?: AbortSignal, filePath?: string) {
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) })
const assets = []
for (const source of [...new Set(blocks)]) {
signal?.throwIfAborted()
if (assets.length >= 16) throw new Error('每次导出最多 16 个 Mermaid 图表')
const result = await renderMermaid(source, { mode: 'raster', theme: 'light' })
if (format !== 'pdf' && assets.length >= 16) throw new Error('每次导出最多 16 个 Mermaid 图表')
const pdf = format === 'pdf'
const result = await renderMermaid(source, pdf ? { mode: 'raster', theme: ['dark','midnight-purple'].includes(options.theme_id) ? 'dark' : 'light', palette: options.palette, unlimited: true } : { mode: 'raster', theme: 'light' })
if (result.warnings.length) throw new Error(`Mermaid 无法导出:${result.warnings.join('; ')}`)
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal) })
assets.push({ kind: 'mermaid', source_hash: await hashSource(source), png_base64: await rasterize(result.svg, signal, pdf, pdf ? options.palette?.surface ?? (['dark','midnight-purple'].includes(options.theme_id) ? '#161b22' : '#ffffff') : '#ffffff') })
}
signal?.throwIfAborted()
// Keep the response handle when cancellation arrives during submission:
+18 -5
View File
@@ -29,12 +29,25 @@ export function mermaidThemeVariables(dark: boolean, useDocument = true) {
}
}
async function ensureInitialized(theme: 'light' | 'dark', raster = false) {
async function ensureInitialized(theme: 'light' | 'dark', raster = false, palette?: Record<string,string>, unlimited = false) {
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: mermaidThemeVariables(theme === 'dark', !raster),
themeVariables: 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,
tertiaryColor: palette.code, tertiaryTextColor: palette.text, tertiaryBorderColor: palette.border,
textColor: palette.text, lineColor: palette.muted, mainBkg: palette.code, nodeBorder: palette.border,
clusterBkg: palette.surface, clusterBorder: palette.border, edgeLabelBackground: palette.surface,
actorBkg: palette.code, actorBorder: palette.border, actorTextColor: palette.text, actorLineColor: palette.muted,
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),
...(unlimited ? { maxTextSize: Number.MAX_SAFE_INTEGER, maxEdges: Number.MAX_SAFE_INTEGER } : {}),
securityLevel: 'strict',
fontFamily: raster ? 'Arial, Microsoft YaHei, sans-serif' : 'var(--font-ui-sans)',
flowchart: { useMaxWidth: true, htmlLabels: !raster },
@@ -65,18 +78,18 @@ export interface MermaidParseError {
let renderCounter = 0
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster' } = {}): Promise<MermaidRenderResult> {
export function renderMermaid(source: string, options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}): Promise<MermaidRenderResult> {
return serialized(() => renderMermaidNow(source, options))
}
async function renderMermaidNow(
source: string,
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster' } = {}
options: { theme?: 'light' | 'dark'; mode?: 'interactive' | 'static' | 'raster'; palette?: Record<string,string>; unlimited?: boolean } = {}
): Promise<MermaidRenderResult> {
const theme = options.theme ?? 'light'
const id = `mermaid-${Date.now()}-${++renderCounter}`
try {
const mermaid = await ensureInitialized(theme, options.mode === 'raster')
const mermaid = await ensureInitialized(theme, options.mode === 'raster', options.palette, options.unlimited)
const result = await mermaid.render(id, source)
const parser = new DOMParser()
const doc = parser.parseFromString(result.svg, 'image/svg+xml')