fix(export): 内联 PDF 字体并清理历史记录
This commit is contained in:
@@ -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 内存。
|
桌面端的 `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
|
```text
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {mount,flushPromises} from '@vue/test-utils'
|
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 ExportDialog from './ExportDialog.vue'
|
||||||
import {apiClient} from '@/services/apiClient'
|
import {apiClient} from '@/services/apiClient'
|
||||||
vi.mock('@/stores/editor',()=>({useEditorStore:()=>({content:'# snapshot',currentFilePath:'note.md'})}))
|
vi.mock('@/stores/editor',()=>({useEditorStore:()=>({content:'# snapshot',currentFilePath:'note.md'})}))
|
||||||
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({currentThemeId:'light'})}))
|
vi.mock('@/stores/theme',()=>({useThemeStore:()=>({currentThemeId:'light'})}))
|
||||||
vi.mock('@/services/mermaidService',()=>({renderMermaid:vi.fn()}))
|
vi.mock('@/services/mermaidService',()=>({renderMermaid:vi.fn()}))
|
||||||
vi.mock('@/services/apiClient',()=>({apiClient:{post:vi.fn(),get: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:'<div><slot /></div>'}}}})
|
||||||
|
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()=>{
|
it('closing the dialog after submitting preserves the background export',async()=>{
|
||||||
let finish!:(value:unknown)=>void
|
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.get).mockImplementation(async(path:string)=>path==='/api/exports'?{items:[]}:{job_id:'closing-job',status:'cancelled',warnings:[],error:null,file:null})
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ const format = ref<ExportFormat>('html'), page = ref('A4'), title = ref(true)
|
|||||||
const jobs = ref<ExportJob[]>([]), error = ref(''), preparing = ref(false)
|
const jobs = ref<ExportJob[]>([]), error = ref(''), preparing = ref(false)
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
|
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
|
||||||
let controller: AbortController | undefined
|
let controller: AbortController | undefined
|
||||||
|
const visibleJobIds = new Set<string>()
|
||||||
|
let initialized = false
|
||||||
const labels = { queued: '排队中', running: '渲染中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
const labels = { queued: '排队中', running: '渲染中', completed: '已完成', failed: '失败', cancelled: '已取消' }
|
||||||
async function refresh() {
|
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)
|
if (!disposed) timer = setTimeout(refresh, 1500)
|
||||||
}
|
}
|
||||||
async function start() {
|
async function start() {
|
||||||
@@ -20,6 +30,7 @@ async function start() {
|
|||||||
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
|
const snapshot = editor.content, name = editor.currentFilePath?.split('/').pop()?.replace(/\.md$/i, '') ?? '笔记'
|
||||||
try {
|
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)
|
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)
|
if (!disposed) jobs.value.unshift(job)
|
||||||
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
|
} catch (e) { error.value = e instanceof DOMException && e.name === 'AbortError' ? '已取消导出' : String(e) }
|
||||||
finally { preparing.value = false }
|
finally { preparing.value = false }
|
||||||
|
|||||||
@@ -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('@/stores/headingAppearance',()=>({useHeadingAppearanceStore:()=>({cssVariables:{'--heading-1-size':'37px'},preferences:{custom:true}})}))
|
||||||
vi.mock('@/components/common/MarkdownContent.vue',()=>({default:{}}))
|
vi.mock('@/components/common/MarkdownContent.vue',()=>({default:{}}))
|
||||||
vi.mock('@/features/editor/VisualMarkdownEditor.vue',()=>({default:{__scopeId:'data-v-editor'}}))
|
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 {apiClient} from './apiClient'
|
||||||
import {renderMarkdown} from '@/utils/markdown'
|
import {renderMarkdown} from '@/utils/markdown'
|
||||||
afterEach(()=>vi.clearAllMocks())
|
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()=>{
|
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)
|
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'
|
document.documentElement.dataset.theme='paper'
|
||||||
|
|||||||
@@ -38,8 +38,31 @@ async function dataUrl(url: string, signal?:AbortSignal):Promise<string> {
|
|||||||
const blob=await response.blob()
|
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)})
|
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<css.length;index+=1) {
|
||||||
|
const character=css[index]!
|
||||||
|
if(quote) {
|
||||||
|
if(character==='\\')index+=1
|
||||||
|
else if(character===quote)quote=''
|
||||||
|
} else if(character==='"'||character==="'")quote=character
|
||||||
|
else if(character==='(')depth+=1
|
||||||
|
else if(character===')')depth=Math.max(0,depth-1)
|
||||||
|
else if(character===';'&&depth===0){valueEnd=index;break}
|
||||||
|
}
|
||||||
|
if(valueEnd<0)return css
|
||||||
|
const sources=css.slice(valueStart,valueEnd)
|
||||||
|
const woff2=sources.match(/url\(\s*(?:["'][^"']*["']|[^)]*)\s*\)\s*format\(\s*["']?woff2["']?\s*\)/i)
|
||||||
|
return woff2 ? `${css.slice(0,valueStart)} ${woff2[0]}${css.slice(valueEnd)}` : css
|
||||||
|
}
|
||||||
async function embedCss(css: string, base: string, signal?:AbortSignal) {
|
async function embedCss(css: string, base: string, signal?:AbortSignal) {
|
||||||
// 打印进程完全离线,主题资源必须来自应用同源地址并在此转换为 data URL。
|
// 打印进程完全离线,主题资源必须来自应用同源地址并在此转换为 data URL。
|
||||||
|
// Chromium 支持 WOFF2;丢弃同一字体的 WOFF/TTF 回退,避免重复嵌入三份字体。
|
||||||
|
css=preferWoff2FontSource(css)
|
||||||
const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)]
|
const matches=[...css.matchAll(/url\(\s*(['"]?)(.*?)\1\s*\)/g)]
|
||||||
for(const match of matches) {
|
for(const match of matches) {
|
||||||
const url=match[2]!
|
const url=match[2]!
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
manifest: true,
|
manifest: true,
|
||||||
|
// PDF 快照运行在离线打印页中;将 Chromium 实际使用的 WOFF2 字体内联,
|
||||||
|
// 避免 Tauri 资源协议无法通过 fetch 转存字体而导致整次导出失败。
|
||||||
|
assetsInlineLimit(filePath) {
|
||||||
|
return /\.woff2$/i.test(filePath) ? true : undefined
|
||||||
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
// Keep lazy languages/diagrams independent; do not collect every vendor into one bundle.
|
// Keep lazy languages/diagrams independent; do not collect every vendor into one bundle.
|
||||||
|
|||||||
Reference in New Issue
Block a user