From 8b5d1f3b08187b758fc48a3d9fcfe85664a9a1e6 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 00:25:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(export):=20=E5=86=85=E8=81=94=20PDF=20?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=B9=B6=E6=B8=85=E7=90=86=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/Export开发说明.md | 2 ++ .../src/features/editor/ExportDialog.spec.ts | 23 ++++++++++++++++++- frontend/src/features/editor/ExportDialog.vue | 13 ++++++++++- .../src/services/pdfSnapshotService.spec.ts | 9 +++++++- frontend/src/services/pdfSnapshotService.ts | 23 +++++++++++++++++++ frontend/vite.config.ts | 5 ++++ 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md index 7712cb6..d824e1b 100644 --- a/docs/development/Export开发说明.md +++ b/docs/development/Export开发说明.md @@ -8,6 +8,8 @@ Export Service 把笔记或未保存的 Markdown 文本渲染为可下载的 HTM 桌面端的 `core_request` 保持浏览器 Fetch 语义:JSON 响应以 UTF-8 文本传递,HTML、PDF、DOCX 等下载响应以 Base64 穿过 Tauri IPC,并在前端还原为带正确 `Content-Type` 的 `Response`。Host 将单次响应限制为 64 MiB,防止异常产物耗尽 WebView 内存。 +生产构建将 PDF 打印所需的 WOFF2 字体内联到 CSS,并在快照中去除同字体的 WOFF/TTF 回退源,使 Tauri 资源协议不参与字体读取。导出弹窗只保留本次打开期间创建或接回的活动任务;关闭后再次打开会清除已完成、失败和取消的历史显示,仍在后台运行的任务会继续显示直到结束。 + ## 模块布局 ```text diff --git a/frontend/src/features/editor/ExportDialog.spec.ts b/frontend/src/features/editor/ExportDialog.spec.ts index 385257c..9ce5f48 100644 --- a/frontend/src/features/editor/ExportDialog.spec.ts +++ b/frontend/src/features/editor/ExportDialog.spec.ts @@ -1,12 +1,33 @@ // @vitest-environment jsdom import {mount,flushPromises} from '@vue/test-utils' -import {it,expect,vi} from 'vitest' +import {it,expect,vi,beforeEach} 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()}})) +beforeEach(()=>{vi.useRealTimers();vi.clearAllMocks()}) +it('starts each dialog with no completed history while retaining active background jobs',async()=>{ + vi.useFakeTimers() + vi.mocked(apiClient.get) + .mockResolvedValueOnce({items:[ + {job_id:'old',status:'completed',warnings:[],error:null,file:{file_name:'old.pdf',size:1}}, + {job_id:'active',status:'running',warnings:[],error:null,file:null}, + ]}) + .mockResolvedValueOnce({items:[ + {job_id:'old',status:'completed',warnings:[],error:null,file:{file_name:'old.pdf',size:1}}, + {job_id:'active',status:'completed',warnings:[],error:null,file:{file_name:'active.pdf',size:1}}, + ]}) + const wrapper=mount(ExportDialog,{global:{stubs:{AppDialog:{template:'
'}}}}) + await flushPromises() + expect(wrapper.text()).not.toContain('old.pdf') + expect(wrapper.text()).toContain('active') + await vi.advanceTimersByTimeAsync(1500) + await flushPromises() + expect(wrapper.text()).toContain('active.pdf') + wrapper.unmount() +}) 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}) diff --git a/frontend/src/features/editor/ExportDialog.vue b/frontend/src/features/editor/ExportDialog.vue index addbca6..4e312a9 100644 --- a/frontend/src/features/editor/ExportDialog.vue +++ b/frontend/src/features/editor/ExportDialog.vue @@ -10,9 +10,19 @@ const format = ref('html'), page = ref('A4'), title = ref(true) const jobs = ref([]), error = ref(''), preparing = ref(false) let timer: ReturnType | undefined, disposed = false let controller: AbortController | undefined +const visibleJobIds = new Set() +let initialized = false const labels = { queued: '排队中', running: '渲染中', completed: '已完成', failed: '失败', cancelled: '已取消' } async function refresh() { - try { const value = await exportService.list(); if (!disposed) jobs.value = value } catch (e) { error.value = String(e) } + try { + const value = await exportService.list() + if (!initialized) { + // 重新打开弹窗时丢弃历史终态记录,但接回仍在后台执行的任务。 + value.filter(job => ['queued','running'].includes(job.status)).forEach(job => visibleJobIds.add(job.id)) + initialized = true + } + if (!disposed) jobs.value = value.filter(job => visibleJobIds.has(job.id)) + } catch (e) { error.value = String(e) } if (!disposed) timer = setTimeout(refresh, 1500) } async function start() { @@ -20,6 +30,7 @@ async function start() { 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, palette: captureExportPalette() }, controller.signal, editor.currentFilePath ?? undefined) + visibleJobIds.add(job.id) if (!disposed) jobs.value.unshift(job) } catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) } finally { preparing.value = false } diff --git a/frontend/src/services/pdfSnapshotService.spec.ts b/frontend/src/services/pdfSnapshotService.spec.ts index 2025e3b..8aba59a 100644 --- a/frontend/src/services/pdfSnapshotService.spec.ts +++ b/frontend/src/services/pdfSnapshotService.spec.ts @@ -8,10 +8,17 @@ vi.mock('@/stores/markdownPreferences',()=>({useMarkdownPreferencesStore:()=>({n 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 {preparePdfSnapshot,preferWoff2FontSource} from './pdfSnapshotService' import {apiClient} from './apiClient' import {renderMarkdown} from '@/utils/markdown' afterEach(()=>vi.clearAllMocks()) +it('keeps only the WOFF2 source when preparing embedded print fonts',()=>{ + const css='@font-face { font-family: "Demo"; src: url("data:font/woff2;base64,d09GMg==") format("woff2"), url("demo.woff") format("woff"), url("demo.ttf") format("truetype"); font-style: normal; }' + const compact=preferWoff2FontSource(css) + expect(compact).toContain('url("data:font/woff2;base64,d09GMg==") format("woff2")') + expect(compact).not.toContain('demo.woff"') + expect(compact).not.toContain('demo.ttf') +}) 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' diff --git a/frontend/src/services/pdfSnapshotService.ts b/frontend/src/services/pdfSnapshotService.ts index dacb25d..7ca1357 100644 --- a/frontend/src/services/pdfSnapshotService.ts +++ b/frontend/src/services/pdfSnapshotService.ts @@ -38,8 +38,31 @@ async function dataUrl(url: string, signal?:AbortSignal):Promise { 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)}) } +export function preferWoff2FontSource(css:string):string { + if(!/^\s*@font-face\b/i.test(css))return css + const declaration=/\bsrc\s*:/i.exec(css) + if(!declaration)return css + const valueStart=declaration.index+declaration[0].length + let quote='',depth=0,valueEnd=-1 + for(let index=valueStart;index