diff --git a/assets/webui/index.html b/assets/webui/index.html index c182311..4d2bd41 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -49,6 +49,7 @@ + @@ -137,7 +138,7 @@ - +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 35e5cdf..0b4dca4 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -117,6 +117,15 @@ a:focus-visible { clip: rect(0, 0, 0, 0); } +.print-frame { + position: fixed; + top: 0; + right: 100%; + width: 1px; + height: 1px; + border: 0; +} + .button, .icon-button { display: inline-flex; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a35b72c..ea94a20 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -10,7 +10,8 @@ const STATUS = { engine: "正在准备引擎", recovering: "正在恢复引擎", fonts: "正在匹配字体", - exporting: "正在导出文档 PDF", + exporting: "正在导出文档", + printing: "正在准备打印", pageExporting: "正在导出单页", }; const WASM_CALLBACKS = [ @@ -28,6 +29,7 @@ let wasmPromise = null; let wasmModule = null; let wasmRecoveryTimer = 0; let fontDatabase = null; +let printFrame = null; const state = { ready: false, @@ -64,6 +66,7 @@ const state = { thumbnailObserver: null, visibleThumbnails: new Set(), exportFormats: [], + outputting: false, showPages: !COMPACT_LAYOUT.matches, showMeta: !COMPACT_LAYOUT.matches, }; @@ -93,6 +96,7 @@ const el = { pageExportFormat: document.querySelector("#pageExportFormat"), exportPageButton: document.querySelector("#exportPageButton"), exportButton: document.querySelector("#exportButton"), + printButton: document.querySelector("#printButton"), emptyState: document.querySelector("#emptyState"), progressPanel: document.querySelector("#progressPanel"), progressLabel: document.querySelector("#progressLabel"), @@ -143,7 +147,8 @@ el.continuousButton.addEventListener("click", toggleContinuous); el.annotationButton.addEventListener("click", toggleAnnotations); el.pageExportFormat.addEventListener("change", () => updateDPIControl()); el.exportPageButton.addEventListener("click", exportCurrentPage); -el.exportButton.addEventListener("click", exportPDF); +el.exportButton.addEventListener("click", () => outputPDF(false)); +el.printButton.addEventListener("click", () => outputPDF(true)); el.refreshAppButton.addEventListener("click", refreshApplication); el.pageInput.addEventListener("change", () => { const page = Number.parseInt(el.pageInput.value, 10); @@ -692,7 +697,7 @@ async function loadLocalFonts() { return; } if (!canReadLocalFonts()) { - setStatus("当前浏览器不支持读取系统字体"); + setStatus("不支持读取系统字体"); return; } setBusy(true, state.doc ? "正在匹配字体" : "正在请求授权", 12, state.doc ? STATUS.fonts : "正在请求授权"); @@ -708,7 +713,7 @@ async function loadLocalFonts() { } } catch (err) { if (err && err.name === "NotAllowedError") { - setStatus("未授权读取系统字体"); + setStatus("系统字体未授权"); } else { setStatus(String(err.message || err)); } @@ -727,7 +732,7 @@ async function requestLocalFontsBeforeOpen() { setStatus(available.length ? `已授权 ${available.length} 个系统字体` : "未读取到系统字体"); } catch (err) { if (err && err.name === "NotAllowedError") { - setStatus("未授权读取系统字体"); + setStatus("系统字体未授权"); return; } setStatus(String(err.message || err)); @@ -766,7 +771,7 @@ async function autoLoadDocumentLocalFonts(openSeq) { return await loadDocumentLocalFonts(available, openSeq); } catch (err) { if (err && err.name === "NotAllowedError") { - setStatus("未授权读取系统字体"); + setStatus("系统字体未授权"); return false; } setStatus(String(err.message || err)); @@ -786,7 +791,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { } if (!docNames.length) { state.localFonts = []; - setStatus("OFD 字体均为内嵌"); + setStatus("文档字体均为内嵌"); updateFontSummary(); renderFontList(); return false; @@ -796,7 +801,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { if (!selected.length) { selected = selectLocalFonts(available, [], 6); } - const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到文档所需字体"; + const emptyStatus = available.length === 0 ? "未读取到系统字体" : "未匹配到所需字体"; const fonts = []; for (let i = 0; i < selected.length; i += 1) { setProgress(`正在读取字体 ${i + 1}/${selected.length}`, 20 + Math.round(i / selected.length * 60)); @@ -1093,7 +1098,7 @@ function fontSourceText(source) { function updateLocalFontButton() { const supported = canReadLocalFonts(); el.localFontButton.disabled = !supported; - el.localFontButton.title = supported ? "授权读取浏览器可访问的系统字体" : "当前浏览器不支持读取系统字体"; + el.localFontButton.title = supported ? "读取系统字体" : "不支持读取系统字体"; updateFontPermissionHint(); } @@ -1273,19 +1278,23 @@ async function renderPage(index, options = {}) { } } -async function exportPDF() { - if (!state.doc) { +async function outputPDF(print) { + if (!state.doc || state.outputting) { + return; + } + if (print && !navigator.pdfViewerEnabled) { + setStatus("请导出 PDF 后打印"); return; } const openSeq = state.openSeq; - setExportControlsDisabled(true); - setBusy(true, "正在准备文档 PDF", 18, STATUS.exporting); + setOutputting(true); + setBusy(true, "正在准备 PDF", 18, print ? STATUS.printing : STATUS.exporting); try { await waitForPaint(); if (openSeq !== state.openSeq) { return; } - setProgress("正在生成文档 PDF", 45); + setProgress("正在生成 PDF", 45); await waitForPaint(); if (openSeq !== state.openSeq) { return; @@ -1294,10 +1303,18 @@ async function exportPDF() { if (openSeq !== state.openSeq) { return; } - setProgress("正在保存文档 PDF", 86); const bytes = result.bytes; - downloadBytes(bytes, "application/pdf", pdfFileName()); - setStatus(`文档 PDF 已导出 ${formatBytes(result.size || bytes.length)}`); + if (print) { + setProgress("正在加载 PDF", 86); + await printPDF(bytes, openSeq); + if (openSeq === state.openSeq) { + setStatus("已请求打印"); + } + } else { + setProgress("正在保存 PDF", 86); + downloadBytes(bytes, "application/pdf", pdfFileName()); + setStatus(`PDF 已导出 ${formatBytes(result.size || bytes.length)}`); + } } catch (err) { if (openSeq === state.openSeq) { showError(err, false); @@ -1305,13 +1322,60 @@ async function exportPDF() { } finally { if (openSeq === state.openSeq) { setBusy(false); - updateControls(); } + setOutputting(false); } } +function clearPrintFrame() { + if (!printFrame) { + return; + } + URL.revokeObjectURL(printFrame.src); + printFrame.remove(); + printFrame = null; +} + +function printPDF(bytes, openSeq) { + clearPrintFrame(); + const frame = document.createElement("iframe"); + frame.className = "print-frame"; + frame.title = "文档打印"; + frame.tabIndex = -1; + frame.setAttribute("aria-hidden", "true"); + frame.src = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })); + printFrame = frame; + return new Promise((resolve, reject) => { + const clear = () => { + if (printFrame === frame) { + clearPrintFrame(); + } + }; + const fail = () => { + clear(); + reject(new Error("请导出 PDF 后打印")); + }; + frame.onload = () => { + if (openSeq !== state.openSeq) { + clear(); + resolve(); + return; + } + try { + frame.contentWindow.addEventListener("afterprint", clear, { once: true }); + frame.contentWindow.print(); + resolve(); + } catch { + fail(); + } + }; + frame.onerror = fail; + document.body.append(frame); + }); +} + async function exportCurrentPage() { - if (!state.doc) { + if (!state.doc || state.outputting) { return; } const format = el.pageExportFormat.value; @@ -1321,7 +1385,7 @@ async function exportCurrentPage() { const info = exportFormatInfo(format); const label = info?.label || String(format || "").toUpperCase(); const openSeq = state.openSeq; - setExportControlsDisabled(true); + setOutputting(true); setBusy(true, `正在准备 ${label}`, 18, STATUS.pageExporting); try { await waitForPaint(); @@ -1349,8 +1413,8 @@ async function exportCurrentPage() { } finally { if (openSeq === state.openSeq) { setBusy(false); - updateControls(); } + setOutputting(false); } } @@ -2182,7 +2246,7 @@ async function focusSignatureStamp(stamp) { await renderPage(pageIndex, { fit: false, scroll: false }); await nextFrame(); highlightSignatureStamp(stamp); - setStatus(stamp.page ? `已定位签名外观 第 ${stamp.page} 页` : "已定位签名外观"); + setStatus(stamp.page ? `已定位签名 第 ${stamp.page} 页` : "已定位签名"); } function highlightSignatureStamp(stamp) { @@ -2287,7 +2351,7 @@ function pageStatus(index, pageCount) { function statusText(status) { switch (status) { case "pending": - return "待检查"; + return "待查"; case "embedded": return "内嵌"; case "matched": @@ -2510,6 +2574,7 @@ function updateFitSpace() { function updateControls() { const hasDoc = Boolean(state.doc); + const canExport = hasDoc && !state.outputting; const pageCount = state.doc ? state.doc.pageCount : 0; el.prevButton.disabled = !hasDoc || state.pageIndex <= 0; el.nextButton.disabled = !hasDoc || state.pageIndex >= pageCount - 1; @@ -2524,23 +2589,22 @@ 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 = !canExport || !state.exportFormats.length; + el.exportPageButton.disabled = !canExport || !state.exportFormats.length; + el.exportButton.disabled = !canExport; + el.printButton.disabled = !canExport; updateDPIControl(); } function updateAnnotationButton() { el.annotationButton.setAttribute("aria-pressed", String(state.renderAnnotations)); - el.annotationButton.title = state.renderAnnotations ? "关闭注解渲染" : "开启注解渲染"; + el.annotationButton.title = state.renderAnnotations ? "关闭注解" : "开启注解"; 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 setOutputting(outputting) { + state.outputting = outputting; + updateControls(); } function callWASM(name, ...args) { @@ -2659,8 +2723,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.outputting || !state.doc || !exportFormatUsesDPI(el.pageExportFormat.value); } function currentImageDPI() {