fix: preserve exports on close and support themed PDF without export quotas
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) }
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user