From fb4f03465b0ca22552d3e202ad6ba067b28553de Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 26 Oct 2026 17:21:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=BC=BA=E5=8C=96=E5=8A=A0=E8=BD=BD):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81PDF=E6=8C=89=E9=A1=B5=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E4=B8=8E=E5=A2=9E=E9=87=8F=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 78 +++++--- assets/webui/ofdgo_host.js | 52 ++++-- assets/webui/ofdgo_view.js | 2 +- assets/webui/ofdgo_wasm.js | 17 +- internal/webui/webui.go | 284 ++++++++++++++++++++++++---- internal/webui/webui_native.go | 12 +- internal/webui/webui_wasm.go | 11 ++ ofdgo_editor_open.go | 3 + ofdgo_pdf.go | 330 ++++++++++++++++++++++++++------- ofdgo_reader.go | 10 +- ofdgo_version_reader.go | 2 +- 11 files changed, 647 insertions(+), 154 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 8335a72..49c91ba 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -116,6 +116,8 @@ function createDocumentState() { textFontID: null, textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, ofdBytes: null, + sourcePDF: false, + pdfComplete: false, fileName: "ofdgo.ofd", openSeq: 0, pageSeq: 0, @@ -5503,11 +5505,10 @@ async function loadWASM(tab = documentTab()) { if (data.type === "progress") { if (visible) setProgress(data.text, data.percent); } else if (data.type === "conversion") { - if (visible && wasmRequests.get(data.id)?.openSeq === state.openSeq && !el.cancelExportButton.disabled) { + if (visible && wasmRequests.get(data.id)?.name !== "ofdgoLoadPDF" && wasmRequests.get(data.id)?.openSeq === state.openSeq && !el.cancelExportButton.disabled) { if (data.phase === "open") setProgress("正在读取文档", null); else if (data.phase === "pages") setProgress(data.completed ? `正在整理页面 · ${data.completed} 页` : "正在整理页面", null); else if (data.phase === "convert") setProgress(`正在转换页面 · ${data.completed} / ${data.total} 页`, data.total ? 28 + data.completed / data.total * 22 : null); - else if (data.phase === "write.fonts") setProgress("正在处理字体", null); else setProgress("正在生成文档", null); } } else if (data.type === "export") { @@ -5723,7 +5724,6 @@ async function openOFD(file, remote = null, handle = file?.handle) { workspace.tabs.push(tab); const current = () => !tab.closed && tab.opening === request; let accepted = false; - let originalSource = null; try { setBusy(true, "正在读取文档", 10, STATUS.opening, tab); await activateDocumentTab(tab); @@ -5742,11 +5742,10 @@ async function openOFD(file, remote = null, handle = file?.handle) { const source = downloaded ? downloaded.file : file; const format = await callTabWASM(tab, "ofdgoDetectFormat", source); if (!current()) return; - let bytes = await documentBytes(source); - originalSource = bytes; + const bytes = await documentBytes(source); tab.state.ofdBytes = bytes; + tab.state.sourcePDF = format === "pdf"; const name = (downloaded ? downloaded.name : file.name) || `ofdgo.${format}`; - let warnings = []; let convertedDoc; if (!current()) { return; @@ -5765,10 +5764,7 @@ async function openOFD(file, remote = null, handle = file?.handle) { } } if (!current()) return; - bytes = converted.bytes; - tab.state.ofdBytes = bytes; - convertedDoc = converted.doc; - warnings = JSON.parse(converted.warnings || "null") || []; + convertedDoc = converted; } await ensureTabEngine(tab); if (!current()) return; @@ -5776,7 +5772,7 @@ async function openOFD(file, remote = null, handle = file?.handle) { if (!current()) return; tab.state.saveHandle = format === "ofd" && !remote ? handle || null : null; tab.state.saveStamp = tab.state.saveHandle && !nativeHost ? {size: file.size, modified: file.lastModified} : null; - tab.state.conversionWarnings = warnings; + tab.state.conversionWarnings = []; tab.state.fileName = (name || "ofdgo.ofd").replace(/\.pdf$/i, ".ofd"); if (remote && !/\.ofd$/i.test(tab.state.fileName)) tab.state.fileName += ".ofd"; tab.state.doc = doc; @@ -5791,6 +5787,7 @@ async function openOFD(file, remote = null, handle = file?.handle) { if (isForegroundDocument(tab) && openSeq === tab.state.openSeq) tab.initialLoad = false; } else tab.status = "文档已打开"; + if (tab.state.sourcePDF) completePDF(tab); } catch (err) { if (current()) { tab.error = err.name === "AbortError" ? "打开已取消" : err.message; @@ -5807,7 +5804,6 @@ async function openOFD(file, remote = null, handle = file?.handle) { if (nativeHost) await nativeHost.release(source); tab.state.ofdBytes = null; } - if (nativeHost && originalSource !== source) await nativeHost.release(originalSource); } return accepted; } @@ -6313,22 +6309,15 @@ function requestCredentials(code, openSeq, pdf = false, tab = documentTab()) { async function openWithCredentials(fonts, openSeq, data = state.ofdBytes, importing = false, pdf = false, tab = documentTab()) { const state = tab.state; const callWASM = (name, ...args) => callTabWASM(tab, name, ...args); + pdf ||= !importing && state.sourcePDF; const current = () => !tab.closed && (Boolean(tab.opening) || openSeq === state.openSeq); let credentials = null; try { while (current()) { try { if (pdf) { - const converted = await callWASM("ofdgoConvertPDF", data, fonts, credentials?.password || null); - let accepted = false; - try { - if (!current()) return converted; - converted.doc = await callWASM("ofdgoOpen", converted.bytes, fonts, state.renderAnnotations, credentials); - accepted = current(); - return converted; - } finally { - if (!accepted && nativeHost) await nativeHost.release(converted.bytes); - } + state.pdfComplete = false; + return await callWASM("ofdgoOpenPDF", data, fonts, credentials?.password || null, state.renderAnnotations); } return importing ? await callWASM("ofdgoLoadImport", data?.nativeFile ? data : data.slice(), credentials) : await callWASM("ofdgoOpen", data, fonts, state.renderAnnotations, credentials); @@ -6444,8 +6433,10 @@ async function openDocument(options = {}) { if (openSeq !== state.openSeq) { return; } - wasmWorker.configurePages?.(!state.editorInfo && !doc.encrypted && state.ofdBytes ? { - file: state.ofdBytes, fonts, annotations: state.renderAnnotations, document: doc.documentIndex, version: doc.documentVersion + const pageFonts = state.sourcePDF && !nativeHost && !state.editorInfo ? await fontManager.files(fontManager.records()) : fonts; + if (openSeq !== state.openSeq) return; + tab.worker.configurePages?.(!state.editorInfo && !doc.encrypted && state.ofdBytes ? { + file: state.ofdBytes, fonts: pageFonts, pdf: state.sourcePDF, fontNames: state.sourcePDF ? [...fontManager.localEntries().keys()] : [], annotations: state.renderAnnotations, document: doc.documentIndex, version: doc.documentVersion } : null); if (options.keepPreview) { pageView.resetPageLoading(); @@ -6535,10 +6526,40 @@ async function openDocument(options = {}) { } } +async function completePDF(tab) { + const state = tab.state, source = state.ofdBytes, wasmSeq = state.wasmSeq; + if (tab.pdfTask?.wasmSeq === wasmSeq || state.pdfComplete || !state.sourcePDF || !source || state.editorInfo) return; + const task = {wasmSeq}; + tab.pdfTask = task; + const current = () => !tab.closed && state.ofdBytes === source && state.wasmSeq === wasmSeq; + try { + while (current()) { + await new Promise(resolve => setTimeout(resolve, 0)); + if (!current()) return; + const result = await callTabWASM(tab, "ofdgoLoadPDF"); + if (!current()) return; + if (!result.done) continue; + state.pdfComplete = true; + state.conversionWarnings = JSON.parse(result.warnings || "null") || []; + if (!state.editorInfo) Object.assign(state.doc, result.doc); + if (isForegroundDocument(tab)) loadDocumentDetails(state.openSeq); + return; + } + } catch (err) { + if (current() && !err.canceled) { + tab.status = err.message; + if (isForegroundDocument(tab)) showError(err, false); + } + } finally { + if (tab.pdfTask === task) tab.pdfTask = null; + } +} + async function loadDocumentDetails(openSeq) { const tab = documentTab(), state = tab.state; const callWASM = (name, ...args) => callTabWASM(tab, name, ...args); if (openSeq !== state.openSeq || !state.doc) return; + if (state.sourcePDF && !state.pdfComplete && !state.editorInfo) { completePDF(tab); return; } try { const details = await callWASM("ofdgoDocumentInfo"); if (openSeq !== state.openSeq) return; @@ -9930,7 +9951,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value, if (tab.closed || wasmSeq !== state.wasmSeq) return; setBusy(true, "正在生成文件", null, status, tab); } - const readingSave = saving && !state.editorInfo && indices === null && compression.mode === 0; + const readingSave = saving && !state.sourcePDF && !state.editorInfo && indices === null && compression.mode === 0; const destination = !nativeHost && file && file === state.saveHandle && state.saveStamp ? {fileHandle: file, saveStamp: state.saveStamp} : file; if (saving && indices === null && !readingSave) { snapshotPrepared = true; @@ -12350,13 +12371,13 @@ async function callTabWASM(tab, name, ...args) { if (name === "ofdgoResources" && workspace.active === tab && resourceElements.Panel.open) resourceManager.requestID = id; wasmRequests.set(id, { name, resolve, reject, openSeq: state.openSeq, tab }); if (name === "ofdgoExportMedia") wasmRequests.get(id).mediaID = args[0]; - if (name === "ofdgoConvertPDF") { + if (name === "ofdgoOpenPDF" || state.sourcePDF && !state.editorInfo) { const request = wasmRequests.get(id); request.localFonts = fontManager.localEntries(); request.fontData = new Map(); - args.push([...request.localFonts.keys()]); + if (name === "ofdgoOpenPDF") args.push([...request.localFonts.keys()]); } - if (name === "ofdgoImportDocuments" || name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { + if (name === "ofdgoImportDocuments" || name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoOpenPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { state.exportRequestID = id; if (visible) { el.cancelExportButton.hidden = false; @@ -12371,6 +12392,7 @@ async function callTabWASM(tab, name, ...args) { } if (name === "ofdgoLoadImport" && ArrayBuffer.isView(args[0]) && !nativeHost) transfer.push(args[0].buffer); const message = {id, name, args}; + if (name === "ofdgoLoadPDF") message.background = true; if (name === "ofdgoRenderPage" && [...state.pageInFlight.values()].some(task => task.index === args[0] && pageView.pageRenderPriority(task, state) >= 6)) message.preload = true; tab.worker.postMessage(message, transfer); } catch (err) { diff --git a/assets/webui/ofdgo_host.js b/assets/webui/ofdgo_host.js index afadee3..a6e8d2c 100644 --- a/assets/webui/ofdgo_host.js +++ b/assets/webui/ofdgo_host.js @@ -35,7 +35,7 @@ class BrowserScheduler { } priority(task) { - return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload); + return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload || !!task.data.background); } submit(worker, data, transfer) { @@ -92,8 +92,8 @@ class BrowserScheduler { const busy = [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name !== "ofdgoSaveSnapshot"); const snapshot = task.data.name === "ofdgoSaveSnapshot" && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name === "ofdgoSaveSnapshot"); const active = this.active(); - const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload; - const foregroundActive = [...this.workers].some(worker => [...worker.operations.values()].some(data => data.name !== "ofdgoSaveSnapshot" && worker.primary && this.owner(worker, data)?.foreground === true && !data.preload)); + const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload && !task.data.background; + const foregroundActive = [...this.workers].some(worker => [...worker.operations.values()].some(data => data.name !== "ofdgoSaveSnapshot" && worker.primary && this.owner(worker, data)?.foreground === true && !data.preload && !data.background)); const continuing = task.worker.primary && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID); const available = continuing || active < this.limit || interactive && !foregroundActive && active < this.limit + 1; if (busy || snapshot || !available || !task.worker.primary && active && this.memory + (64 << 20) > this.budget) { i++; continue; } @@ -307,6 +307,8 @@ class BrowserEngine { this.primary.onerror = event => this.onerror?.(event); this.primary.onmessageerror = event => this.onmessageerror?.(event); this.requests = new Map(); + this.fontRequests = new Map(); + this.primaryPages = new Set(); this.slots = new Set(); this.sequence = 0; this.revision = 0; @@ -323,8 +325,9 @@ class BrowserEngine { configurePages(source) { const current = this.pageSource; const fonts = (source?.fonts || []).map(font => font?.data !== undefined ? {name:font.name, data:font.data} : font); + const sameNames = JSON.stringify(source?.fontNames || []) === JSON.stringify(current?.fontNames || []); const sameFonts = current && fonts.length === current.fonts.length && fonts.every((font, index) => font === current.fonts[index] || font?.data !== undefined && font.name === current.fonts[index]?.name && font.data === current.fonts[index]?.data); - if (source && current && source.file === current.file && (source.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts) return; + if (source && current && source.file === current.file && !!source.pdf === !!current.pdf && (source.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts && sameNames) return; this.invalidatePages(); if (!source || this.closed) return; this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""}; @@ -347,6 +350,7 @@ class BrowserEngine { invalidatePages() { this.revision++; + this.primaryPages.clear(); this.source = null; this.pageSource = null; this.parallelism = 1; @@ -358,6 +362,7 @@ class BrowserEngine { closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) { if (!this.slots.delete(slot)) return; slot.worker.terminate(); + for (const [id, request] of this.fontRequests) if (request.worker === slot.worker) this.fontRequests.delete(id); slot.rejectReady(error); for (const pending of slot.pending.values()) pending.reject(error); slot.pending.clear(); @@ -378,6 +383,12 @@ class BrowserEngine { if (this.source) this.parallelism = engineScheduler.limit; if (data.type === "ready") { resolve(); return; } if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); return; } + if (data.type === "font") { + const request = `${slot.worker.engineID}:${data.request}`; + this.fontRequests.set(request, {worker:slot.worker, request:data.request}); + this.onmessage?.({data:{...data, id:slot.requestID, request}}); + return; + } if (data.type) return; const request = slot.pending.get(data.id); if (!request) return; @@ -389,7 +400,9 @@ class BrowserEngine { }); this.slots.add(slot); slot.ready = ready.then(async () => { - let opened = await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]); + let opened = source.pdf + ? await this.callSlot(slot, "ofdgoOpenPDF", [source.file, source.fonts, null, source.annotations, source.fontNames]) + : await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]); if ((source.document || 0) !== (opened.data.documentIndex || 0)) opened = await this.callSlot(slot, "ofdgoSelectDocument", [source.document]); if ((source.version || "") !== (opened.data.documentVersion || "")) await this.callSlot(slot, "ofdgoSelectVersion", [source.version || ""]); if (this.slots.has(slot)) this.startupTime = performance.now() - started; @@ -409,6 +422,7 @@ class BrowserEngine { } pageSlot(data) { + if (this.primaryPages.has(data.args[0])) return null; const preferred = [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0])); const pending = [...this.requests.values()].filter(slot => slot === null).length - Number(this.requests.has(data.id)); if (!preferred && pending <= 0) return null; @@ -440,6 +454,14 @@ class BrowserEngine { postMessage(data, transfer) { if (this.closed) return; + if (data.type === "font" && typeof data.request === "string") { + const request = this.fontRequests.get(data.request); + if (request) { + this.fontRequests.delete(data.request); + request.worker.postMessage({...data, request:request.request}, transfer); + } + return; + } if (!data.type && data.preload && this.pagesPaused) { this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}}); return; @@ -451,7 +473,7 @@ class BrowserEngine { return; } if (!data.type && data.name !== "ofdgoRenderPage" && ![ - "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData", + "ofdgoLoadPDF", "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData", "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets", "ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles", "ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures", @@ -472,11 +494,19 @@ class BrowserEngine { renderSlot(slot, data, transfer) { slot.busy = true; + slot.requestID = data.id; this.requests.set(data.id, slot); const revision = this.revision; let fallback = false; slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => { if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError"); + if (this.source.pdf && ((JSON.parse(result.data.annotations || "null") || []).length || result.data.links?.length)) { + this.primaryPages.add(data.args[0]); + this.requests.set(data.id, this.primary); + fallback = true; + this.primary.postMessage(data, transfer); + return; + } slot.lastPage = data.args[0]; slot.pages.delete(slot.lastPage); slot.pages.add(slot.lastPage); @@ -612,13 +642,13 @@ class NativeHost { } } - async decode(value, name = "", key = "", signal) { + async decode(value, signal) { const reads = []; const visit = (item, field, replace) => { if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return; if (item.nativeFile) { const file = new NativeFile(item); - if (field === "file" || name === "ofdgoConvertPDF" && field === "bytes") replace(file); + if (field === "file") replace(file); else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes)))); return; } @@ -626,7 +656,7 @@ class NativeHost { if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result); } }; - visit(value, key, result => value = result); + visit(value, "", result => value = result); const results = await Promise.allSettled(reads); const failed = results.find(result => result.status === "rejected"); if (failed) throw failed.reason; @@ -688,7 +718,7 @@ class NativeHost { return; } if (data.type === "font") return; - if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoConvertPDF"].includes(data.name)) { + if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoOpenPDF"].includes(data.name)) { const input = data.args?.[0]; if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile); } @@ -721,7 +751,7 @@ class NativeHost { let decoded; try { controller.signal.throwIfAborted(); - decoded = await host.decode(result, data.name, "", controller.signal); + decoded = await host.decode(result, controller.signal); controller.signal.throwIfAborted(); } catch (error) { await host.release(result); throw error; } diff --git a/assets/webui/ofdgo_view.js b/assets/webui/ofdgo_view.js index bf159d9..f6bf61b 100644 --- a/assets/webui/ofdgo_view.js +++ b/assets/webui/ofdgo_view.js @@ -229,7 +229,7 @@ export function createPageView(context) { for (const task of context.state.pagePresentQueue.values()) task.resolve(null); context.state.pagePresentQueue.clear(); for (const [id, request] of context.wasmRequests) { - if (request.tab === context.documentTab() && request.name === "ofdgoRenderPage") context.wasmWorker?.postMessage({ type: "cancel", id }); + if (request.tab === context.documentTab() && request.name === "ofdgoRenderPage" && (!context.state.sourcePDF || context.state.pdfComplete || context.state.editorInfo)) context.wasmWorker?.postMessage({ type: "cancel", id }); } for (const task of context.state.pageInFlight.values()) { task.resolve(null); diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 7d8d1eb..90fe2e9 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -235,9 +235,9 @@ async function handleMessage(engine, { id, name, args }) { let saveStamp; let channel; const signal = operations.get(id)?.signal; - const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name); + const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoOpenPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name); const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments"; - const converting = name === "ofdgoConvertPDF"; + const converting = name === "ofdgoOpenPDF"; const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned"; const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name); const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null; @@ -245,7 +245,7 @@ async function handleMessage(engine, { id, name, args }) { const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null; try { signal?.throwIfAborted(); - if (["ofdgoOpen", "ofdgoLoadImport", "ofdgoConvertPDF"].includes(name) && args[0] instanceof Blob) { + if (["ofdgoOpen", "ofdgoOpenPDF", "ofdgoLoadImport"].includes(name) && args[0] instanceof Blob) { const file = args[0], reader = new FileReaderSync(); args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))}; } @@ -337,10 +337,21 @@ async function handleMessage(engine, { id, name, args }) { if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done)); } } + if (engine.pdf && name !== "ofdgoOpenPDF") { + channel ||= new MessageChannel(); + const context = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPDFContext", (phase, completed, total, done) => { + postMessage({id, type: "conversion", phase, completed, total}); + channel.port1.onmessage = () => done("", signal.aborted); + channel.port2.postMessage(null); + }, (name, done) => requestFont(engine, id, signal, name, done)); + engine.pdf = JSON.parse(context).data === true; + } const started = name === "ofdgoOpen" || name === "ofdgoRenderPage" ? performance.now() : null; const payload = await globalThis.ofdgoEngineCall(engine.id, name, ...args); signal?.throwIfAborted(); const result = typeof payload === "string" ? JSON.parse(payload) : payload; + if (result.ok && name === "ofdgoOpenPDF") engine.pdf = true; + if (result.ok && (name === "ofdgoOpen" || name === "ofdgoLoadPDF" && result.data.done)) engine.pdf = false; if (started !== null && result.ok) result.elapsed = performance.now() - started; if (cancellable && !importing && !converting && !resources && result.ok) { result.data.size = size; diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 81271cf..ea83454 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -32,6 +32,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/xiaoqidun/ofdgo" @@ -435,15 +436,30 @@ type outputContext struct { cancel context.CancelFunc checkpoint func() error checked time.Time - pending uint8 + checks atomic.Uint32 interval uint8 } +// pendingPDF 保存当前标签的增量转换、输入及操作回调 +type pendingPDF struct { + importer *ofdgo.PDFImport + retry bool + input *ofdgo.Input + page int + next int + progress platformValue + font platformValue + operation *outputContext + report func(string, int, int) error +} + // businessEngine 独立文档、编辑、剪贴板及操作上下文 type businessEngine struct { resources *ofdgo.Resources currentSession *Session currentEditor *ofdgo.Editor + pdf *pendingPDF + pdfWarnings []byte saveSnapshot *ofdgo.Editor printDocument *ofdgo.PrintDocument pendingImport *ofdgo.Reader @@ -1394,58 +1410,243 @@ func (e *businessEngine) detectDocumentFormat(args []platformValue) (any, error) return ofdgo.DetectFormat(input) } -// convertPDFDocument 通过库转换PDF,不替换当前文档会话 -// 入参: args PDF数据、外部字体、UTF-8密码、字体目录、进度及字体读取回调 -// 返回: any OFD数据及转换警告, error 错误信息 -func (e *businessEngine) convertPDFDocument(args []platformValue) (any, error) { - if len(args) != 6 { - return nil, fmt.Errorf("missing PDF data") +// clearPDF 释放当前标签的未完成转换和输入 +func (e *businessEngine) clearPDF() { + e.pdfWarnings = nil + if e.pdf == nil { + return + } + e.pdf.importer.Close() + e.pdf.input.Close() + e.pdf = nil +} + +// pdfContext 更新当前操作的取消、进度和按需字体读取回调 +// 入参: args 进度及字体回调 +// 返回: any 是否仍有待转换内容, error 参数错误 +func (e *businessEngine) pdfContext(args []platformValue) (any, error) { + if len(args) != 2 { + return nil, fmt.Errorf("missing PDF callbacks") + } + if e.pdf != nil { + e.pdf.progress, e.pdf.font = args[0], args[1] + } + return e.pdf != nil, nil +} + +// openPDF 建立PDF索引,当前页转换由阅读请求触发 +// 入参: args 输入、字体、密码、注解设置、字体目录、进度及字体回调 +// 返回: any 文档索引, error 打开错误 +func (e *businessEngine) openPDF(args []platformValue) (any, error) { + if len(args) != 7 { + return nil, fmt.Errorf("missing PDF input") } input, err := e.inputFromValue(args[0]) if err != nil { return nil, err } - defer input.Close() + pending := &pendingPDF{input: input, page: -1, progress: args[5], font: args[6]} + keep := false + var password []byte + defer func() { clear(password) }() + defer func() { + if !keep { + input.Close() + if pending.importer != nil { + pending.importer.Close() + } + } + }() fonts, err := e.fontsFromJS(args[1]) if err != nil { return nil, err } - var output bytes.Buffer - options := ofdgo.PDFImportOptions{Resources: e.resources, OnProgress: e.editorOperationProgress(args[4]), PasswordUTF8: true} if !args[2].IsNull() && !args[2].IsUndefined() { - options.Password, err = e.bytesFromJS(args[2]) + password, err = e.bytesFromJS(args[2]) if err != nil { return nil, err } - defer clear(options.Password) + password = slices.Clone(password) } - fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} - if names := e.stringsFromJS(args[3]); len(names) > 0 { - fontSources = append(fontSources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) { - return e.loadConversionFont(args[5], name) + pending.operation, pending.report = e.newEditorOperationContext(pending.progress) + options := ofdgo.PDFImportOptions{Resources: e.resources, PasswordUTF8: true, Password: password, + OnProgress: func(stage string, completed, total int) error { + return pending.report(stage, completed, total) + }, + } + sources := []fs.FS{ofdgo.NewFontFS(fonts)} + if names := e.stringsFromJS(args[4]); len(names) != 0 { + sources = append(sources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) { + return e.loadConversionFont(pending.font, name) })) } - options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)} - editor, report, err := ofdgo.ImportPDF(e.operationContext(), input, options) + options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(sources...)} + pending.importer, err = ofdgo.NewPDFImport(pending.operation, input, options) if err != nil { return nil, err } - if len(options.Password) != 0 { - if err := editor.SetEncryption(&ofdgo.EncryptionOptions{Password: options.Password}); err != nil { + if len(password) != 0 { + if err := pending.importer.SetEncryption(&ofdgo.EncryptionOptions{Password: password}); err != nil { return nil, err } } - editor.OnWriteProgress = func(stage string, completed, total int) error { - return options.OnProgress("write."+stage, completed, total) - } - if _, err := editor.WriteTo(&output); err != nil { - return nil, err - } - warnings, err := json.Marshal(report.Warnings) + reader, err := pending.importer.Reader(pending.operation, -1) if err != nil { return nil, err } - return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "warnings": string(warnings)}), nil + session, err := newSession(reader, OpenOptions{Fonts: fonts, RenderAnnotations: args[3].Bool()}) + if err != nil { + return nil, err + } + e.clearPDF() + e.clearImport() + e.currentSession.Close() + e.currentSession, e.currentEditor = session, nil + e.copiedObjects, e.copiedStyle = nil, nil + e.pdf = pending + keep = true + return session.Summary(), nil +} + +// pdfError 记录需要重建的取消状态,后续请求可继续使用原文档 +// 入参: err 转换错误 +// 返回: error 原错误 +func (e *businessEngine) pdfError(err error) error { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + e.pdf.retry = true + } + return err +} + +// replacePDFReader 切换独立阅读快照,保留当前字体和注解设置 +// 入参: reader 已生成的阅读器 +// 返回: error 会话创建错误 +func (e *businessEngine) replacePDFReader(reader *ofdgo.Reader) error { + previous := e.currentSession + next, err := newSession(reader, OpenOptions{RenderAnnotations: previous.Renderer.RenderAnnotations}) + if err != nil { + return err + } + dirs, sources := previous.Renderer.FontSources() + ofdgo.WithFontDirs(dirs...)(next.Renderer) + ofdgo.WithFontFS(sources...)(next.Renderer) + ofdgo.WithRenderBackends(previous.Renderer.Backends())(next.Renderer) + next.Renderer.OutputContext = e.pdf.operation + next.fontFS = previous.fontFS + next.svgFonts = previous.svgFonts + e.currentSession = next + return previous.Close() +} + +// finishPDF 补全转换后直接创建阅读快照,不生成中间压缩包 +// 返回: []byte 转换警告JSON, error 转换错误 +func (e *businessEngine) finishPDF() ([]byte, error) { + if e.pdf == nil { + return e.pdfWarnings, nil + } + editor, report, err := e.pdf.importer.Finish(e.pdf.operation) + if err != nil { + return nil, e.pdfError(err) + } + + reader, err := editor.Reader(e.pdf.operation) + if err != nil { + return nil, err + } + if err := e.replacePDFReader(reader); err != nil { + return nil, err + } + warnings, err := json.Marshal(report.Warnings) + e.clearPDF() + e.pdfWarnings = warnings + return warnings, err +} + +// loadPDF 按时间片补全未转换页面,全部完成后交付完整会话 +// 入参: args 空参数 +// 返回: any 完成标识、文档信息及警告, error 转换错误 +func (e *businessEngine) loadPDF(args []platformValue) (any, error) { + if e.currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if e.pdf != nil { + deadline := time.Now().Add(32 * time.Millisecond) + for e.pdf.next < e.pdf.importer.PageCount() { + index := e.pdf.next + if !e.pdf.importer.Ready(index) { + if err := e.pdf.importer.ConvertPage(e.pdf.operation, index); err != nil { + return nil, e.pdfError(err) + } + } + e.pdf.next++ + if time.Now().After(deadline) { + return map[string]any{"done": false}, nil + } + } + } + warnings, err := e.finishPDF() + if err != nil { + return nil, err + } + return map[string]any{"done": true, "doc": e.currentSession.Summary(), "warnings": string(warnings)}, nil +} + +// preparePDF 按命令准备页面快照或完整文档,不将未完成内容交给编辑和输出 +// 入参: name 命令名称, args 命令参数 +// 返回: error 转换或取消错误 +func (e *businessEngine) preparePDF(name string, args []platformValue) error { + if e.pdf == nil { + return nil + } + if name == "ofdgoPDFContext" || name == "ofdgoOpenPDF" || name == "ofdgoOpen" { + return nil + } + e.pdf.operation, e.pdf.report = e.newEditorOperationContext(e.pdf.progress) + e.pdf.operation.checked = time.Now() + e.pdf.operation.interval = 255 + e.currentSession.Renderer.OutputContext = e.pdf.operation + if e.pdf.retry { + if err := e.pdf.importer.Reset(e.pdf.operation); err != nil { + return err + } + e.pdf.page, e.pdf.next, e.pdf.retry = -1, 0, false + reader, err := e.pdf.importer.Reader(e.pdf.operation, -1) + if err != nil { + return err + } + if err := e.replacePDFReader(reader); err != nil { + return err + } + } + switch name { + case "ofdgoLoadPDF", "ofdgoConfigure", "ofdgoDocumentInfo", "ofdgoSVGFontData", "ofdgoRenderBackends", "ofdgoOutputFormats", "ofdgoExportFormats", "ofdgoParsePageRange", "ofdgoMatchFontFiles", "ofdgoOutlineLinks", "ofdgoDocumentNavigation", "ofdgoInvoices": + return nil + case "ofdgoOpenLinks": + if len(args) == 0 { + return nil + } + fallthrough + case "ofdgoRenderPage", "ofdgoSearchPage", "ofdgoPageTextString": + if len(args) == 0 { + return fmt.Errorf("missing page index") + } + index := args[0].Int() + if e.pdf.page == index { + return nil + } + reader, err := e.pdf.importer.Reader(e.pdf.operation, index) + if err != nil { + return e.pdfError(err) + } + if err := e.replacePDFReader(reader); err != nil { + return err + } + e.pdf.page = index + return nil + default: + _, err := e.finishPDF() + return err + } } // convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 @@ -1641,6 +1842,7 @@ func (e *businessEngine) openDocument(args []platformValue) (any, error) { if err != nil { return nil, err } + e.clearPDF() e.clearImport() e.currentEditor = nil e.copiedObjects = nil @@ -1689,6 +1891,9 @@ func (e *businessEngine) configureDocument(args []platformValue) (any, error) { // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 func (e *businessEngine) documentInfo(args []platformValue) (any, error) { + if e.pdf != nil { + return map[string]any{"detailsPending": true}, nil + } if e.currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } @@ -7418,8 +7623,8 @@ func (e *businessEngine) newOutputContext(checkpoint platformValue) *outputConte // newEditorOperationContext 在底层取消检查时保持最近的进度,避免版本处理期间界面无响应 // 入参: callback 浏览器进度及取消回调 -// 返回: context.Context 操作上下文, func(string, int, int) error 阶段进度回调 -func (e *businessEngine) newEditorOperationContext(callback platformValue) (context.Context, func(string, int, int) error) { +// 返回: *outputContext 操作上下文, func(string, int, int) error 阶段进度回调 +func (e *businessEngine) newEditorOperationContext(callback platformValue) (*outputContext, func(string, int, int) error) { ctx, cancel := context.WithCancel(e.operationContext()) stage, completed, total := "ids", 0, 0 progress := e.editorOperationProgress(callback) @@ -7436,16 +7641,15 @@ func (c *outputContext) Err() error { if err := c.Context.Err(); err != nil { return err } - c.mu.Lock() - defer c.mu.Unlock() - if c.pending != 0 { - c.pending-- + interval := uint32(c.interval) + 1 + if c.interval == 0 { + interval = 16 + } + if (c.checks.Add(1)-1)%interval != 0 { return nil } - c.pending = 15 - if c.interval != 0 { - c.pending = c.interval - } + c.mu.Lock() + defer c.mu.Unlock() if time.Since(c.checked) >= 50*time.Millisecond { c.checked = time.Now() if err := c.checkpoint(); err != nil { @@ -7465,6 +7669,9 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoDiscardPrint": e.discardPrint, "ofdgoDetectFormat": e.detectDocumentFormat, "ofdgoOpen": e.openDocument, + "ofdgoOpenPDF": e.openPDF, + "ofdgoLoadPDF": e.loadPDF, + "ofdgoPDFContext": e.pdfContext, "ofdgoSelectDocument": e.selectDocument, "ofdgoDocumentVersions": e.documentVersions, "ofdgoExportVersion": e.exportVersion, @@ -7488,7 +7695,6 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoChangeVersion": e.changeDocumentVersion, "ofdgoChangeDocument": e.changePackageDocument, "ofdgoPackageDocuments": e.packageDocuments, - "ofdgoConvertPDF": e.convertPDFDocument, "ofdgoConvertFile": e.convertFile, "ofdgoPackFiles": e.packFiles, "ofdgoOutputFormats": func([]platformValue) (any, error) { return ofdgo.OutputFormats(), nil }, diff --git a/internal/webui/webui_native.go b/internal/webui/webui_native.go index 35cb5d5..1e27b7c 100644 --- a/internal/webui/webui_native.go +++ b/internal/webui/webui_native.go @@ -191,7 +191,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f if e.closed { return nativeFailure(fmt.Errorf("native engine is closed")) } - if name == "ofdgoRenderPage" && e.currentSession != nil { + if name == "ofdgoRenderPage" && e.currentSession != nil && e.pdf == nil { defer func() { if failure := recover(); failure != nil { result = nativeFailure(fmt.Errorf("%v", failure)) @@ -312,6 +312,10 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f return nativeFailure(fmt.Errorf("unknown operation: %s", name)) } call := &nativeCall{engine: e, ctx: ctx, emit: emit} + if e.pdf != nil { + e.pdf.progress = platformValueOf(call.progress("conversion", "", true)) + e.pdf.font = platformValueOf(nativeCallback(call.font)) + } defer func() { if err := call.abort(); err != nil { if result == nil || result["ok"] == true { @@ -329,6 +333,9 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f for i, arg := range prepared { values[i] = platformValueOf(arg) } + if err := e.preparePDF(name, values); err != nil { + return nativeFailure(err) + } data, err := e.safeCall(fn, values) if err != nil { return nativeFailure(err) @@ -357,6 +364,7 @@ func (e *NativeEngine) Close() error { e.gate <- struct{}{} defer func() { <-e.gate }() e.closed = true + e.clearPDF() _, _ = e.discardPrint(nil) _ = e.waitRenders(context.Background()) e.renderIdle = nil @@ -955,7 +963,7 @@ func (c *nativeCall) prepare(name string, args []any) ([]any, error) { return append(args, checkpoint), nil case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages": return append(args, c.progress("import", "", true)), nil - case "ofdgoConvertPDF": + case "ofdgoOpenPDF": return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned": if len(args) == 0 { diff --git a/internal/webui/webui_wasm.go b/internal/webui/webui_wasm.go index 4b5cf20..5028fbf 100644 --- a/internal/webui/webui_wasm.go +++ b/internal/webui/webui_wasm.go @@ -79,10 +79,20 @@ func (w *wasmEngines) call(args []js.Value) (any, error) { if e == nil { return nil, fmt.Errorf("unknown engine: %s", args[0].String()) } + if e.pdf != nil { + defer func() { + if e.currentSession != nil { + e.currentSession.Renderer.OutputContext = context.Background() + } + }() + } fn := e.businessCommands()[args[1].String()] if fn == nil { return nil, fmt.Errorf("unknown operation: %s", args[1].String()) } + if err := e.preparePDF(args[1].String(), args[2:]); err != nil { + return nil, err + } return fn(args[2:]) } @@ -100,6 +110,7 @@ func (w *wasmEngines) close(args []js.Value) (any, error) { } delete(w.engines, id) _, _ = e.discardPrint(nil) + e.clearPDF() e.clearImport() err := e.currentSession.Close() *e = businessEngine{} diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 3988bd0..00eddf7 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -131,6 +131,9 @@ func (r *Reader) Editor() (*Editor, error) { // 入参: ctx 取消上下文 // 返回: *Editor 编辑器, error 初始化或取消错误 func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) { + if r.pageReady != nil { + return nil, ErrIncompleteDocument + } _, err := r.DocContext(ctx) if err != nil { return nil, err diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 6d43295..e0fd274 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -69,6 +69,25 @@ type PDFImportReport struct { Warnings []pdfgo.Diagnostic } +// PDFImport 按页转换PDF,实例需串行使用,输入源由调用方持有至Close +// 页面阅读快照独立于后续转换,Finish完成全局结构后交付独立编辑文档 +// 转换失败后可用Reset重建,输入及回调在会话存续期间须保持有效 +type PDFImport struct { + input *Input + importer *pdfImporter + options PDFImportOptions + pages []PageContent + ready []bool + err error + finished bool +} + +// pdfImportInput 将输入读取绑定到当前转换上下文 +type pdfImportInput struct { + input *Input + importer *pdfImporter +} + // pdfImporter 保存单次转换的对象与资源状态 type pdfImporter struct { resources *Resources @@ -212,21 +231,34 @@ func (o pdfObjectOrigin) merge(other pdfObjectOrigin) pdfObjectOrigin { // 入参: ctx 取消上下文, input 输入源, options 转换选项 // 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息 func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) { + pending, err := NewPDFImport(ctx, input, options) + if err != nil { + return nil, PDFImportReport{}, err + } + defer pending.Close() + return pending.Finish(ctx) +} + +// NewPDFImport 建立PDF页面索引和文档信息,不转换页面图元 +// 内部复制输入密码供Reset使用,Close时清除;其他选项引用需保持有效 +// 入参: ctx 取消上下文, input 调用方持有的输入源, options 转换选项 +// 返回: *PDFImport 增量转换会话, error 读取错误 +func NewPDFImport(ctx context.Context, input *Input, options PDFImportOptions) (*PDFImport, error) { if options.Resources == nil { options.Resources, _ = NewResources(ResourceOptions{}) } ctx = options.Resources.context(ctx) if input == nil { - return nil, PDFImportReport{}, fmt.Errorf("missing source") + return nil, fmt.Errorf("missing source") } if err := ctx.Err(); err != nil { - return nil, PDFImportReport{}, err + return nil, err } if err := options.Compression.Validate(); err != nil { - return nil, PDFImportReport{}, err + return nil, err } if !finite(options.RasterDPI) || options.RasterDPI < 0 { - return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI") + return nil, fmt.Errorf("invalid PDF raster DPI") } renderer := NewRenderer(&Reader{resources: options.Resources}, options.RendererOptions...) if options.Backends != nil { @@ -236,18 +268,25 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed renderer.DPI = options.RasterDPI } if !finite(renderer.DPI) || renderer.DPI <= 0 { - return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI") + return nil, fmt.Errorf("invalid PDF raster DPI") } + keep := false + options.Password = slices.Clone(options.Password) + defer func() { + if !keep { + clear(options.Password) + } + }() progress := editorProgress(options.OnProgress).withContext(ctx) if err := progress.report("open", 0, 0); err != nil { - return nil, PDFImportReport{}, err + return nil, err } editor := NewEditor() editor.runtimeResources = options.Resources editor.SetRenderBackends(renderer.Backends()) editor.fontDirs, editor.fontFS = renderer.FontSources() editor.fontSourcesCache = renderer.fontSourcesCache - importer := pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, page: -1, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}} + importer := &pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, page: -1, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}} readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText} if !options.Strict { importer.warning = func(warning pdfgo.Diagnostic) { @@ -256,22 +295,26 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed } readOptions.Warning = importer.warning } - reader, err := pdfgo.NewReaderWithOptions(convertReader{ctx: ctx, input: input}, input.Size(), readOptions) + reader, err := pdfgo.NewReaderWithOptions(&pdfImportInput{input: input, importer: importer}, input.Size(), readOptions) if err != nil { - return nil, PDFImportReport{}, err + return nil, err } - defer reader.Close() + defer func() { + if !keep { + reader.Close() + } + }() importer.reader = reader if err := importer.prepareStructure(ctx); err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err) + return nil, fmt.Errorf("import PDF structure: %w", err) } outputIntents, err := reader.OutputIntents() if err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF output intents: %w", err) + return nil, fmt.Errorf("import PDF output intents: %w", err) } if len(outputIntents) != 0 { if options.Strict { - return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"} + return nil, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"} } importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"}) } @@ -281,10 +324,14 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed importer.halftones = options.Halftones importer.compression = options.Compression importer.references = &codecpdf.ReferenceCache{} - defer importer.references.Close() + defer func() { + if !keep { + importer.references.Close() + } + }() importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool) if err := progress.report("pages", 0, 0); err != nil { - return nil, PDFImportReport{}, err + return nil, err } err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error { _, width, height := codecpdf.PageMatrix(page) @@ -298,82 +345,229 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed return progress.report("pages", index+1, 0) }) if err != nil { - return nil, PDFImportReport{}, err + return nil, err } if len(editor.pages) != 0 { importer.page = -1 if err := importer.documentMetadata(ctx, options.Strict); err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF metadata: %w", err) + return nil, fmt.Errorf("import PDF metadata: %w", err) } if err := importer.documentAttachments(ctx); err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF attachments: %w", err) + return nil, fmt.Errorf("import PDF attachments: %w", err) } if err := importer.outlines(ctx); err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF outlines: %w", err) + return nil, fmt.Errorf("import PDF outlines: %w", err) } if err := importer.documentOpenActions(ctx); err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF document actions: %w", err) + return nil, fmt.Errorf("import PDF document actions: %w", err) } importer.page = 0 } if err := progress.report("convert", 0, len(editor.pages)); err != nil { - return nil, PDFImportReport{}, err + return nil, err } - err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error { - matrix, width, height := codecpdf.PageMatrix(page) - importer.matrix = matrix - importer.page = index - importer.rasterWarned = false - importer.pageBox = page.CropBox - importer.pageWidth = width - importer.pageHeight = height - importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path) - importer.compositeCache = nil - importer.compositeNodes = nil - importer.compositeSpace = nil - importer.patternCells = nil - importer.patternOrigins = nil - importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject) - importer.textOrigins = nil - importer.clipPaths = nil - importer.annotationData = nil - importer.annotationBindings = nil - if err := importer.pageOpenActions(ctx, page); err != nil { - return fmt.Errorf("import PDF page %d actions: %w", index+1, err) + if len(editor.pages) == 0 { + return nil, fmt.Errorf("PDF contains no pages") + } + keep = true + return &PDFImport{input: input, importer: importer, options: options, pages: cloneEditorData(editor.pages), ready: make([]bool, len(editor.pages))}, nil +} + +// ReadAt 读取当前操作需要的数据,不取得输入所有权 +// 入参: data 目标缓冲, offset 文件偏移 +// 返回: int 读取字节数, error 读取或取消错误 +func (r *pdfImportInput) ReadAt(data []byte, offset int64) (int, error) { + if err := r.importer.ctx.Err(); err != nil { + return 0, err + } + return r.input.ReadAt(data, offset) +} + +// PageCount 返回文档页数 +// 返回: int 页面总数 +func (p *PDFImport) PageCount() int { return len(p.pages) } + +// Ready 返回指定页是否转换完成 +// 入参: index 页面索引,从0开始 +// 返回: bool 页面是否就绪 +func (p *PDFImport) Ready(index int) bool { + return index >= 0 && index < len(p.ready) && p.ready[index] +} + +// SetEncryption 设置转换文档的输出加密方式,不改变PDF读取凭据 +// 入参: options 加密选项,nil取消输出加密 +// 返回: error 加密配置错误 +func (p *PDFImport) SetEncryption(options *EncryptionOptions) error { + if p.importer == nil { + return fmt.Errorf("PDF import is closed") + } + return p.importer.editor.SetEncryption(options) +} + +// ConvertPage 转换指定页面,已完成的页面不重复处理 +// 入参: ctx 取消上下文, index 页面索引,从0开始 +// 返回: error 转换或取消错误 +func (p *PDFImport) ConvertPage(ctx context.Context, index int) (err error) { + if p.importer == nil { + return fmt.Errorf("PDF import is closed") + } + if p.err != nil { + return p.err + } + if index < 0 || index >= len(p.pages) { + return fmt.Errorf("invalid page index: %d", index) + } + if err := ctx.Err(); err != nil { + return err + } + if p.ready[index] { + return nil + } + defer func() { + if err != nil { + p.err = err } - if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil { - return fmt.Errorf("import PDF page %d: %w", index+1, err) - } - if err := importer.annotations(ctx, page, options.Strict); err != nil { - return fmt.Errorf("import PDF page %d: %w", index+1, err) - } - if err := importer.commitAnnotations(); err != nil { - return fmt.Errorf("import PDF page %d: %w", index+1, err) - } - if err := importer.commitObjects(); err != nil { - return fmt.Errorf("import PDF page %d: %w", index+1, err) - } - importer.report.Pages++ - if err := progress.report("convert", index+1, len(editor.pages)); err != nil { + }() + importer := p.importer + importer.ctx = importer.resources.context(ctx) + ctx = importer.ctx + importer.renderer.OutputContext = ctx + page := importer.pageOrder[index] + matrix, width, height := codecpdf.PageMatrix(page) + importer.matrix = matrix + importer.page = index + importer.rasterWarned = false + importer.pageBox = page.CropBox + importer.pageWidth = width + importer.pageHeight = height + importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path) + importer.compositeCache = nil + importer.compositeNodes = nil + importer.compositeSpace = nil + importer.patternCells = nil + importer.patternOrigins = nil + importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject) + importer.textOrigins = nil + importer.clipPaths = nil + importer.annotationData = nil + importer.annotationBindings = nil + if err := importer.pageOpenActions(ctx, page); err != nil { + return fmt.Errorf("import PDF page %d actions: %w", index+1, err) + } + if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return importer.reader.WalkPage(ctx, page, visitor) }); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) + } + if err := importer.annotations(ctx, page, p.options.Strict); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) + } + if err := importer.commitAnnotations(); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) + } + if err := importer.commitObjects(); err != nil { + return fmt.Errorf("import PDF page %d: %w", index+1, err) + } + importer.report.Pages++ + if err := editorProgress(p.options.OnProgress).withContext(ctx).report("convert", importer.report.Pages, len(p.pages)); err != nil { + return err + } + if p.options.Progress != nil { + if err := p.options.Progress(importer.report.Pages); err != nil { return err } - if options.Progress != nil { - if err := options.Progress(index + 1); err != nil { - return err - } + } + + p.ready[index] = true + return nil +} + +// Reader 创建指定页的独立阅读快照,其他页仅保留尺寸和标识 +// 未包含的页面返回ErrIncompleteDocument;index为-1时仅返回索引,完整编辑或导出须使用Finish +// 入参: ctx 取消上下文, index 页面索引 +// 返回: *Reader 阅读快照, error 转换或构建错误 +func (p *PDFImport) Reader(ctx context.Context, index int) (*Reader, error) { + if p.importer == nil { + return nil, fmt.Errorf("PDF import is closed") + } + if p.err != nil { + return nil, p.err + } + if index != -1 { + if err := p.ConvertPage(ctx, index); err != nil { + return nil, err } - return ctx.Err() - }) + } + snapshot := *p.importer.editor + snapshot.pages = slices.Clone(p.pages) + if index >= 0 { + snapshot.pages[index] = p.importer.editor.pages[index] + } + reader, err := snapshot.Reader(ctx) if err != nil { - return nil, PDFImportReport{}, err + return nil, err } - if importer.report.Pages == 0 { - return nil, PDFImportReport{}, fmt.Errorf("PDF contains no pages") + reader.pageReady = make(map[string]bool) + if index >= 0 { + reader.pageReady[reader.doc.Pages.Page[index].ID] = true } - if err := importer.documentStructures(ctx); err != nil { - return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err) + return reader, nil +} + +// Finish 转换尚未就绪的页面并补全文档结构,返回独立编辑文档 +// 入参: ctx 取消上下文 +// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 转换错误 +func (p *PDFImport) Finish(ctx context.Context) (*Editor, PDFImportReport, error) { + if p.importer == nil { + return nil, PDFImportReport{}, fmt.Errorf("PDF import is closed") } - return editor, importer.report, nil + for index := range p.pages { + if err := p.ConvertPage(ctx, index); err != nil { + return nil, PDFImportReport{}, err + } + } + if !p.finished { + p.importer.ctx = p.importer.resources.context(ctx) + if err := p.importer.documentStructures(p.importer.ctx); err != nil { + p.err = err + return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err) + } + p.finished = true + } + return p.importer.editor, p.importer.report, nil +} + +// Reset 放弃已转换内容并重建索引,保留文档标识及输出加密策略 +// 失败时保留原会话,已交付的阅读快照不受影响;输入源仍由调用方持有 +// 入参: ctx 本次重建的取消上下文 +// 返回: error 重建错误 +func (p *PDFImport) Reset(ctx context.Context) error { + if p.importer == nil { + return fmt.Errorf("PDF import is closed") + } + next, err := NewPDFImport(ctx, p.input, p.options) + if err != nil { + return err + } + next.importer.editor.Info.DocID = p.importer.editor.Info.DocID + next.importer.editor.encryption = p.importer.editor.encryption + p.Close() + *p = *next + return nil +} + +// Close 释放PDF索引、引用缓存和转换状态,不关闭调用方的输入源 +// 返回: error 关闭错误 +func (p *PDFImport) Close() error { + if p.importer == nil { + return nil + } + p.importer.references.Close() + err := p.importer.reader.Close() + p.importer = nil + p.pages, p.ready = nil, nil + clear(p.options.Password) + p.options = PDFImportOptions{} + p.input = nil + return err } // ConvertPDF 将PDF转换并写入OFD,转换阶段失败时不写入目标 diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 2aff2c8..ecf87ee 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -20,6 +20,7 @@ import ( "context" "crypto/sha256" "encoding/xml" + "errors" "fmt" "io" "maps" @@ -28,6 +29,9 @@ import ( "strings" ) +// ErrIncompleteDocument 表示渐进阅读快照尚未包含请求的页面或完整文档 +var ErrIncompleteDocument = errors.New("document content is incomplete") + // Reader OFD文件阅读器 // Reader及共享它的Renderer需串行调用 type Reader struct { @@ -50,6 +54,7 @@ type Reader struct { drawParamCache map[string]*DrawParam compositeGraphicUnitCache map[string]*CompositeGraphicUnit pageHeaderCache map[string]PageContent + pageReady map[string]bool pageContents *renderCache[pageContentKey, PageContent] doc *Document relatedPending bool @@ -211,7 +216,7 @@ func (r *Reader) DocumentContext(ctx context.Context, index int) (*Reader, error if index < 0 || index >= r.DocumentCount() { return nil, fmt.Errorf("document index %d out of range", index) } - next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources} + next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources, pageReady: r.pageReady} if err := next.initRootContext(ctx); err != nil { return nil, err } @@ -1098,6 +1103,9 @@ func resolveResourcePath(resPath, baseLoc, filePath string) string { // 入参: ctx 取消上下文, page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取 // 返回: *PageContent 页面内容, error 错误信息 func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, template bool) (*PageContent, error) { + if !template && r.pageReady != nil && !r.pageReady[page.ID] { + return nil, ErrIncompleteDocument + } fullPath := r.ResPath(page.BaseLoc) data, err := r.readFileViewContext(ctx, fullPath) if err != nil { diff --git a/ofdgo_version_reader.go b/ofdgo_version_reader.go index a423b33..3f5f3b5 100644 --- a/ofdgo_version_reader.go +++ b/ofdgo_version_reader.go @@ -113,7 +113,7 @@ func (r *Reader) DocumentVersion(id string) (*Reader, error) { // 入参: ctx 取消上下文, id 版本标识,空值选择主入口 // 返回: *Reader 版本阅读器, error 读取或取消错误 func (r *Reader) DocumentVersionContext(ctx context.Context, id string) (*Reader, error) { - next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id} + next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id, pageReady: r.pageReady} if err := next.initRootContext(ctx); err != nil { return nil, err }