diff --git a/assets/webui/assets.go b/assets/webui/assets.go index fb4c825..d998630 100644 --- a/assets/webui/assets.go +++ b/assets/webui/assets.go @@ -22,7 +22,7 @@ import ( // FS WebUI嵌入静态文件 // -//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.sw.js ofdgo.wasm wasm_exec.js +//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.sw.js ofdgo.wasm ofdgo.wasm.js wasm_exec.js var FS embed.FS // Checksum 计算WebUI嵌入资源校验值 diff --git a/assets/webui/index.html b/assets/webui/index.html index 72fd02c..7c56f8a 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -152,7 +152,6 @@ - diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index b50c336..7e76fb0 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -13,19 +13,11 @@ const STATUS = { exporting: "正在导出文档", pageExporting: "正在导出单页", }; -const WASM_CALLBACKS = [ - "ofdgoOpen", - "ofdgoConfigure", - "ofdgoDocumentInfo", - "ofdgoRenderPage", - "ofdgoExportFormats", - "ofdgoExportPage", - "ofdgoExportPDF", - "ofdgoFontFileMatches", -]; +const wasmRequests = new Map(); let wasmPromise = null; -let wasmModule = null; +let wasmWorker = null; +let wasmRequestID = 0; let wasmRecoveryTimer = 0; let fontDatabase = null; @@ -35,9 +27,11 @@ const state = { wasmSeq: 0, wasmRecovering: false, wasmRecoveries: 0, + exporting: false, ofdBytes: null, fileName: "ofdgo.ofd", openSeq: 0, + pageSeq: 0, fontSeq: 0, localFonts: [], userFonts: [], @@ -275,7 +269,7 @@ async function boot() { fontsRestored = false; } setProgress("引擎准备完成", 70); - loadExportFormats(); + await loadExportFormats(); setEmpty("选择 OFD 文件"); updateFontSummary(); renderFontList(); @@ -419,8 +413,8 @@ async function refreshApplication() { } } -function loadExportFormats() { - const formats = callWASM("ofdgoExportFormats") || []; +async function loadExportFormats() { + const formats = await callWASM("ofdgoExportFormats") || []; state.exportFormats = formats; el.pageExportFormat.replaceChildren(); for (const format of formats) { @@ -448,47 +442,41 @@ async function ensureWASM() { } async function loadWASM() { - if (!globalThis.Go) { - throw new Error("渲染引擎脚本缺失"); - } const wasmSeq = state.wasmSeq + 1; state.wasmSeq = wasmSeq; state.ready = false; state.wasmExited = false; - clearWASMCallbacks(); - const go = new Go(); - if (!wasmModule) { - setProgress("正在下载引擎", 16); - const response = await fetch("./ofdgo.wasm"); - try { - setProgress("正在编译引擎", 35); - wasmModule = await WebAssembly.compileStreaming(response); - } catch { - const fallback = await fetch("./ofdgo.wasm"); - const bytes = await fallback.arrayBuffer(); - setProgress("正在编译引擎", 45); - wasmModule = await WebAssembly.compile(bytes); - } - } - setProgress("正在启动引擎", 58); - const instance = await WebAssembly.instantiate(wasmModule, go.importObject); - go.run(instance).then(() => { - markWASMExited(wasmSeq); - }).catch((err) => { - markWASMExited(wasmSeq, err); + await new Promise((resolve, reject) => { + const worker = new Worker("./ofdgo.wasm.js"); + wasmWorker = worker; + const fail = (err) => { + markWASMExited(wasmSeq, err); + reject(err); + }; + worker.onerror = (event) => fail(new Error(event.message || "渲染引擎加载失败")); + worker.onmessageerror = () => fail(new Error("渲染引擎通信失败")); + worker.onmessage = ({ data }) => { + if (worker !== wasmWorker) { + return; + } + if (data.type === "progress") { + setProgress(data.text, data.percent); + } else if (data.type === "ready") { + state.ready = true; + resolve(); + } else if (data.type === "exit") { + fail(new Error(data.error)); + } else { + const request = wasmRequests.get(data.id); + wasmRequests.delete(data.id); + if (data.ok) { + request.resolve(data.data); + } else { + request.reject(new Error(data.error)); + } + } + }; }); - await waitFor(() => WASM_CALLBACKS.every((name) => typeof globalThis[name] === "function")); - if (wasmSeq !== state.wasmSeq) { - return; - } - state.ready = true; - state.wasmExited = false; -} - -function clearWASMCallbacks() { - for (const name of WASM_CALLBACKS) { - globalThis[name] = undefined; - } } function markWASMExited(wasmSeq = state.wasmSeq, err) { @@ -497,6 +485,12 @@ function markWASMExited(wasmSeq = state.wasmSeq, err) { } state.ready = false; state.wasmExited = true; + wasmWorker?.terminate(); + wasmWorker = null; + for (const request of wasmRequests.values()) { + request.reject(err || new Error("渲染引擎已退出")); + } + wasmRequests.clear(); if (err) { setStatus("渲染引擎异常,正在恢复"); } @@ -517,43 +511,28 @@ async function recoverWASM() { } state.wasmRecovering = true; state.wasmRecoveries += 1; - const pageIndex = state.pageIndex; - const fitMode = state.fitMode || "width"; + const openSeq = ++state.openSeq; setBusy(true, "正在恢复引擎", 18, STATUS.recovering); try { - await ensureWASM(); await openDocument({ - pageIndex, - fitMode, + pageIndex: state.pageIndex, + fitMode: state.fitMode || "width", + scale: state.scale, skipAutoFonts: true, + openSeq, }); } catch (err) { - showError(err, !state.doc); + if (openSeq === state.openSeq) { + showError(err, !state.doc); + } } finally { state.wasmRecovering = false; - if (!state.doc) { + if (openSeq === state.openSeq) { setBusy(false); } } } -function waitFor(predicate) { - return new Promise((resolve, reject) => { - const started = performance.now(); - const timer = window.setInterval(() => { - if (predicate()) { - window.clearInterval(timer); - resolve(); - return; - } - if (performance.now() - started > 5000) { - window.clearInterval(timer); - reject(new Error("渲染引擎初始化超时")); - } - }, 20); - }); -} - async function openOFD(file) { if (!file) { return; @@ -564,14 +543,21 @@ async function openOFD(file) { return; } state.wasmRecoveries = 0; + const openSeq = ++state.openSeq; setBusy(true, "正在读取 OFD", 10, STATUS.opening); try { - state.ofdBytes = new Uint8Array(await file.arrayBuffer()); + const bytes = new Uint8Array(await file.arrayBuffer()); + if (openSeq !== state.openSeq) { + return; + } + state.ofdBytes = bytes; state.fileName = file.name || "ofdgo.ofd"; - await openDocument({ pageIndex: 0, resetScroll: true }); + await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); } catch (err) { - showError(err, true); - setBusy(false); + if (openSeq === state.openSeq) { + showError(err, true); + setBusy(false); + } } } @@ -818,9 +804,12 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { return false; } const docLoadLimit = Math.min(LOCAL_FONT_LOAD_LIMIT, Math.max(4, docNames.length * 3)); - let selected = selectLocalFonts(available, docNames, docLoadLimit); + let selected = await selectLocalFonts(available, docNames, docLoadLimit); if (!selected.length) { - selected = selectLocalFonts(available, [], 6); + selected = await selectLocalFonts(available, [], 6); + } + if (openSeq !== state.openSeq) { + return false; } const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到所需字体"; const fonts = []; @@ -860,7 +849,7 @@ function uniqueLocalFonts(fonts) { return selected; } -function selectLocalFonts(fonts, names, limit) { +async function selectLocalFonts(fonts, names, limit) { const available = new Map(); for (const font of uniqueLocalFonts(fonts)) { available.set(localFontName(font), font); @@ -870,7 +859,7 @@ function selectLocalFonts(fonts, names, limit) { } } } - const matched = callWASM("ofdgoFontFileMatches", [...available.keys()], names); + const matched = await callWASM("ofdgoFontFileMatches", [...available.keys()], names); return uniqueLocalFonts(matched.map((name) => available.get(name))).slice(0, limit); } @@ -996,6 +985,9 @@ async function applyFontChange(options = {}) { } async function toggleAnnotations() { + if (document.body.hasAttribute("aria-busy")) { + return; + } state.renderAnnotations = !state.renderAnnotations; updateAnnotationButton(); if (!state.ofdBytes || !state.doc) { @@ -1161,10 +1153,13 @@ async function openDocument(options = {}) { if (!state.ofdBytes) { return; } + const openSeq = options.openSeq || (state.openSeq += 1); if (!state.ready || state.wasmExited) { await ensureWASM(); } - const openSeq = options.openSeq || (state.openSeq += 1); + if (openSeq !== state.openSeq) { + return; + } const resetLocalFonts = !options.skipAutoFonts; if (resetLocalFonts) { state.localFonts = []; @@ -1184,8 +1179,8 @@ async function openDocument(options = {}) { return; } const doc = options.reuseSession - ? callWASM("ofdgoConfigure", fonts, state.renderAnnotations) - : callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations); + ? await callWASM("ofdgoConfigure", fonts, state.renderAnnotations) + : await callWASM("ofdgoOpen", state.ofdBytes, fonts, state.renderAnnotations); if (openSeq !== state.openSeq) { return; } @@ -1251,7 +1246,7 @@ async function loadDocumentDetails(openSeq) { return; } try { - const info = callWASM("ofdgoDocumentInfo"); + const info = await callWASM("ofdgoDocumentInfo"); if (openSeq === state.openSeq) { Object.assign(state.doc, info, { detailsPending: false }); renderMeta(); @@ -1275,10 +1270,13 @@ async function renderPage(index, options = {}) { if (index < 0 || index >= pageCount) { return; } - if (options.keepBusy) { - setProgress("正在渲染页面", 76); - } else { - setBusy(true, "正在渲染页面", 35, "正在渲染页面"); + const pageSeq = ++state.pageSeq; + if (!state.exporting) { + if (options.keepBusy) { + setProgress("正在渲染页面", 76); + } else { + setBusy(true, "正在渲染页面", 35, "正在渲染页面"); + } } try { setCurrentPage(index); @@ -1289,7 +1287,7 @@ async function renderPage(index, options = {}) { scrollToPage(index); } await renderFlowPage(index, { throwError: true, openSeq, priority: 0 }); - if (openSeq !== state.openSeq) { + if (openSeq !== state.openSeq || index !== state.pageIndex) { return; } if (options.scroll !== false) { @@ -1301,30 +1299,22 @@ async function renderPage(index, options = {}) { showError(err, false); } } finally { - if (!options.keepBusy && openSeq === state.openSeq) { + if (!options.keepBusy && !state.exporting && openSeq === state.openSeq && pageSeq === state.pageSeq) { setBusy(false); } } } async function exportPDF() { - if (!state.doc) { + if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } const openSeq = state.openSeq; - setExportControlsDisabled(true); - setBusy(true, "正在准备 PDF", 18, STATUS.exporting); + state.exporting = true; + updateControls(); + setBusy(true, "正在生成 PDF", 45, STATUS.exporting); try { - await waitForPaint(); - if (openSeq !== state.openSeq) { - return; - } - setProgress("正在生成 PDF", 45); - await waitForPaint(); - if (openSeq !== state.openSeq) { - return; - } - const result = callWASM("ofdgoExportPDF"); + const result = await callWASM("ofdgoExportPDF"); if (openSeq !== state.openSeq) { return; } @@ -1337,6 +1327,7 @@ async function exportPDF() { showError(err, false); } } finally { + state.exporting = false; if (openSeq === state.openSeq) { setBusy(false); updateControls(); @@ -1345,7 +1336,7 @@ async function exportPDF() { } async function exportCurrentPage() { - if (!state.doc) { + if (!state.doc || document.body.hasAttribute("aria-busy")) { return; } const format = el.pageExportFormat.value; @@ -1355,32 +1346,26 @@ async function exportCurrentPage() { const info = exportFormatInfo(format); const label = info?.label || String(format || "").toUpperCase(); const openSeq = state.openSeq; - setExportControlsDisabled(true); - setBusy(true, `正在准备 ${label}`, 18, STATUS.pageExporting); + const fileName = pageFileName(info?.extension || format); + state.exporting = true; + updateControls(); + setBusy(true, `正在生成 ${label}`, 45, STATUS.pageExporting); try { - await waitForPaint(); - if (openSeq !== state.openSeq) { - return; - } - setProgress(`正在生成 ${label}`, 45); - await waitForPaint(); - if (openSeq !== state.openSeq) { - return; - } const dpi = exportFormatUsesDPI(format) ? currentImageDPI() : 0; - const result = callWASM("ofdgoExportPage", state.pageIndex, format, dpi); + const result = await callWASM("ofdgoExportPage", state.pageIndex, format, dpi); if (openSeq !== state.openSeq) { return; } setProgress(`正在保存 ${result.label || label}`, 86); const bytes = result.bytes; - downloadBytes(bytes, result.mime || info?.mime || "application/octet-stream", pageFileName(result.extension || info?.extension || format)); + downloadBytes(bytes, result.mime || info?.mime || "application/octet-stream", fileName); setStatus(`${result.label || label} 已导出 ${formatBytes(result.size || bytes.length, result.label || label)}`); } catch (err) { if (openSeq === state.openSeq) { showError(err, false); } } finally { + state.exporting = false; if (openSeq === state.openSeq) { setBusy(false); updateControls(); @@ -1571,7 +1556,7 @@ async function processPageRenderQueue() { delayed = true; continue; } - const page = callWASM("ofdgoRenderPage", task.index); + const page = await callWASM("ofdgoRenderPage", task.index); if (task.openSeq === state.openSeq) { state.pageCache.set(task.index, page); cacheThumbnail(task.index, page.svg); @@ -1714,7 +1699,7 @@ function setCurrentPage(index) { } updatePageListCurrent(); updateControls(); - if (state.doc) { + if (state.doc && !state.exporting) { setStatus(pageStatus(index, state.doc.pageCount)); } } @@ -2461,7 +2446,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") { restoreScaleAnchor(anchor); } el.zoomLabel.textContent = `${Math.round(state.scale * 100)}%`; - if (updateStatus && state.doc) { + if (updateStatus && state.doc && !state.exporting) { setStatus(`第 ${state.pageIndex + 1} / ${state.doc.pageCount} 页`); } updateControls(); @@ -2559,9 +2544,9 @@ function updateControls() { el.fitButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "width"); el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height"); updateAnnotationButton(); - el.pageExportFormat.disabled = !hasDoc || !state.exportFormats.length; - el.exportPageButton.disabled = !hasDoc || !state.exportFormats.length; - el.exportButton.disabled = !hasDoc; + el.pageExportFormat.disabled = !hasDoc || state.exporting || !state.exportFormats.length; + el.exportPageButton.disabled = !hasDoc || state.exporting || !state.exportFormats.length; + el.exportButton.disabled = !hasDoc || state.exporting; updateDPIControl(); } @@ -2571,38 +2556,24 @@ function updateAnnotationButton() { el.annotationButton.setAttribute("aria-label", el.annotationButton.title); } -function setExportControlsDisabled(disabled) { - el.exportButton.disabled = disabled; - el.exportPageButton.disabled = disabled; - el.pageExportFormat.disabled = disabled; - updateDPIControl(disabled); -} - -function callWASM(name, ...args) { - const fn = globalThis[name]; - if (typeof fn !== "function") { - throw new Error("渲染引擎未初始化"); - } +async function callWASM(name, ...args) { if (state.wasmExited) { scheduleWASMRecovery(); throw new Error(state.wasmRecovering ? "渲染引擎正在恢复" : "渲染引擎已退出,正在恢复"); } - let payload; - try { - payload = fn(...args); - } catch (err) { - const message = String(err.message || err); - if (message.includes("Go program has already exited")) { - markWASMExited(state.wasmSeq); - throw new Error("渲染引擎已退出,正在恢复"); + if (!state.ready) { + throw new Error("渲染引擎未初始化"); + } + return new Promise((resolve, reject) => { + const id = ++wasmRequestID; + wasmRequests.set(id, { resolve, reject }); + try { + wasmWorker.postMessage({ id, name, args }); + } catch (err) { + wasmRequests.delete(id); + reject(err); } - throw err; - } - const result = typeof payload === "string" ? JSON.parse(payload) : payload; - if (!result.ok) { - throw new Error(result.error || "WASM 调用失败"); - } - return result.data; + }); } function showError(err, empty = !state.doc) { @@ -2694,8 +2665,8 @@ function exportFormatUsesDPI(value) { return value === "png" || value === "jpg"; } -function updateDPIControl(disabled = false) { - el.imageDPI.disabled = disabled || !state.doc || !exportFormatUsesDPI(el.pageExportFormat.value); +function updateDPIControl() { + el.imageDPI.disabled = state.exporting || !state.doc || !exportFormatUsesDPI(el.pageExportFormat.value); } function currentImageDPI() { diff --git a/assets/webui/ofdgo.sw.js b/assets/webui/ofdgo.sw.js index 9a2d474..4eaf33b 100644 --- a/assets/webui/ofdgo.sw.js +++ b/assets/webui/ofdgo.sw.js @@ -1,6 +1,6 @@ const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`; const META_CACHE = `${CACHE_PREFIX}meta`; -const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.sw.js", "ofdgo.wasm", "wasm_exec.js"] +const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.sw.js", "ofdgo.wasm", "ofdgo.wasm.js", "wasm_exec.js"] .map((path) => new URL(path, self.registration.scope).href); const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href; @@ -14,7 +14,7 @@ self.addEventListener("fetch", (event) => { if (event.request.method !== "GET" || !url.startsWith(self.registration.scope) || (navigation && url !== self.registration.scope)) { return; } - event.respondWith(navigator.locks.request(CACHE_PREFIX, { mode: navigation ? "exclusive" : "shared" }, () => readResource(event, navigation))); + event.respondWith(navigator.locks.request(CACHE_PREFIX, { mode: event.resultingClientId ? "exclusive" : "shared" }, () => readResource(event, navigation))); }); self.addEventListener("message", (event) => { @@ -85,13 +85,15 @@ async function readResource(event, navigation) { let bundle; if (navigation) { bundle = await currentBundle(meta); - await meta.put(CLIENT_PREFIX + event.resultingClientId, Response.json(bundle)); } else { bundle = await readBundle(meta, CLIENT_PREFIX + event.clientId); } if (!bundle || !await caches.has(bundle.name)) { return Response.error(); } + if (event.resultingClientId) { + await meta.put(CLIENT_PREFIX + event.resultingClientId, Response.json(bundle)); + } const url = resourceURL(event.request.url); if (!bundle.assets.includes(url)) { return fetch(event.request); diff --git a/assets/webui/ofdgo.wasm.js b/assets/webui/ofdgo.wasm.js new file mode 100644 index 0000000..a14aa61 --- /dev/null +++ b/assets/webui/ofdgo.wasm.js @@ -0,0 +1,31 @@ +importScripts("./wasm_exec.js"); + +self.onmessage = ({ data: { id, name, args } }) => { + try { + const payload = globalThis[name](...args); + const result = typeof payload === "string" ? JSON.parse(payload) : payload; + self.postMessage({ id, ...result }, result.data?.bytes ? [result.data.bytes.buffer] : []); + } catch (err) { + self.postMessage({ id, ok: false, error: err.message }); + } +}; + +function exitWASM(err) { + self.postMessage({ type: "exit", error: err?.message || "渲染引擎已退出" }); + self.close(); +} + +async function loadWASM() { + const go = new Go(); + self.postMessage({ type: "progress", text: "正在下载引擎", percent: 16 }); + const response = await fetch("./ofdgo.wasm"); + self.postMessage({ type: "progress", text: "正在编译引擎", percent: 35 }); + const { instance } = await WebAssembly.instantiateStreaming(response, go.importObject); + self.postMessage({ type: "progress", text: "正在启动引擎", percent: 58 }); + go.run(instance).then(exitWASM, exitWASM); + if (!go.exited) { + self.postMessage({ type: "ready" }); + } +} + +loadWASM().catch(exitWASM);