diff --git a/assets/webui/index.html b/assets/webui/index.html
index 7216f39..311df65 100644
--- a/assets/webui/index.html
+++ b/assets/webui/index.html
@@ -104,6 +104,7 @@
加载中
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index fd4c449..ce9159a 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -803,6 +803,7 @@ button.outline-link:hover {
.form-row[hidden],
.page-list[hidden],
.page-frame[hidden],
+.progress-panel > [hidden],
.progress-panel[hidden] {
display: none;
}
@@ -825,6 +826,10 @@ button.outline-link:hover {
backdrop-filter: blur(8px);
}
+.progress-panel:has(> button:not([hidden])) {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+}
+
.progress-spinner {
width: 18px;
height: 18px;
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 48cbb31..efb7988 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -28,6 +28,7 @@ const state = {
wasmRecovering: false,
wasmRecoveries: 0,
exporting: false,
+ exportRequestID: 0,
ofdBytes: null,
fileName: "ofdgo.ofd",
openSeq: 0,
@@ -111,6 +112,7 @@ const el = {
emptyState: document.querySelector("#emptyState"),
progressPanel: document.querySelector("#progressPanel"),
progressLabel: document.querySelector("#progressLabel"),
+ cancelExportButton: document.querySelector("#cancelExportButton"),
progressBar: document.querySelector("#progressBar"),
pageFrame: document.querySelector("#pageFrame"),
viewerPanel: document.querySelector(".viewer-panel"),
@@ -214,6 +216,7 @@ el.exportFormat.addEventListener("change", () => updateDPIControl());
el.exportPageButton.addEventListener("click", () => exportFile(false));
el.exportButton.addEventListener("click", openExportPanel);
el.exportCancel.addEventListener("click", () => el.exportPanel.close());
+el.cancelExportButton.addEventListener("click", cancelExport);
el.exportPanel.addEventListener("pointerdown", (event) => {
state.exportBackdrop = event.target === el.exportPanel;
});
@@ -673,11 +676,14 @@ async function loadWASM() {
} else if (data.type === "export") {
if (wasmRequests.get(data.id)?.openSeq === state.openSeq && state.exporting) {
if (data.stage === "save") {
+ el.cancelExportButton.disabled = true;
setProgress("正在保存", null);
- } else if (data.completed === data.total) {
- setProgress("正在封装", null);
- } else {
- setProgress(`正在导出 ${data.completed} / ${data.total} 页`, data.completed / data.total * 100);
+ } else if (!el.cancelExportButton.disabled) {
+ if (data.completed === data.total) {
+ setProgress("正在封装", null);
+ } else {
+ setProgress(`正在导出 ${data.completed} / ${data.total} 页`, data.completed / data.total * 100);
+ }
}
}
} else if (data.type === "ready") {
@@ -691,7 +697,11 @@ async function loadWASM() {
if (data.ok) {
request.resolve(data.data);
} else {
- request.reject(new Error(data.error));
+ const err = new Error(data.error);
+ if (data.canceled) {
+ err.name = "AbortError";
+ }
+ request.reject(err);
}
}
};
@@ -1613,6 +1623,8 @@ async function exportFile(whole, indices = null) {
}
} finally {
state.exporting = false;
+ state.exportRequestID = 0;
+ el.cancelExportButton.hidden = true;
if (openSeq === state.openSeq) {
setBusy(false);
updateControls();
@@ -1620,6 +1632,15 @@ async function exportFile(whole, indices = null) {
}
}
+function cancelExport() {
+ if (!state.exportRequestID || el.cancelExportButton.disabled) {
+ return;
+ }
+ el.cancelExportButton.disabled = true;
+ setProgress("正在取消", null);
+ wasmWorker.postMessage({ type: "cancel", id: state.exportRequestID });
+}
+
function renderPageFlow() {
el.svgHost.replaceChildren();
el.viewerPanel.classList.remove("single-page-fits-height");
@@ -3312,6 +3333,11 @@ async function callWASM(name, ...args) {
return new Promise((resolve, reject) => {
const id = ++wasmRequestID;
wasmRequests.set(id, { resolve, reject, openSeq: state.openSeq });
+ if (name === "ofdgoExportPage" || name === "ofdgoExportDocument") {
+ state.exportRequestID = id;
+ el.cancelExportButton.hidden = false;
+ el.cancelExportButton.disabled = false;
+ }
try {
wasmWorker.postMessage({ id, name, args });
} catch (err) {
diff --git a/assets/webui/ofdgo.wasm.js b/assets/webui/ofdgo.wasm.js
index d47a4ed..b52f90b 100644
--- a/assets/webui/ofdgo.wasm.js
+++ b/assets/webui/ofdgo.wasm.js
@@ -1,38 +1,61 @@
importScripts("./wasm_exec.js");
let pending = Promise.resolve();
+const exports = new Map();
self.onmessage = ({ data }) => {
+ if (data.type === "cancel") {
+ exports.get(data.id)?.abort();
+ return;
+ }
+ if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument") {
+ exports.set(data.id, new AbortController());
+ }
pending = pending.then(() => handleMessage(data));
};
async function handleMessage({ id, name, args }) {
let output;
+ let channel;
+ const signal = exports.get(id)?.signal;
try {
+ signal?.throwIfAborted();
const chunks = [];
let size = 0;
- const exporting = name === "ofdgoExportPage" || name === "ofdgoExportDocument";
- if (exporting) {
+ if (signal) {
const file = args.pop();
if (file) {
output = await file.createWritable();
}
+ signal.throwIfAborted();
+ channel = new MessageChannel();
+ const finish = (done, err) => {
+ channel.port1.onmessage = () => done(err?.message || "", signal.aborted);
+ channel.port2.postMessage(null);
+ };
args.push((bytes, done) => {
+ if (signal.aborted) {
+ finish(done);
+ return;
+ }
size += bytes.length;
if (output) {
- output.write(bytes).then(() => done(), (err) => done(err.message));
+ output.write(bytes).then(() => finish(done), (err) => finish(done, err));
} else {
chunks.push(new Blob([bytes]));
- setTimeout(done, 0);
+ finish(done);
}
});
if (name === "ofdgoExportDocument") {
- args.push((completed, total) => {
+ args.push((completed, total, done) => {
self.postMessage({ id, type: "export", stage: "pages", completed, total });
+ finish(done);
});
}
}
const payload = await globalThis[name](...args);
+ signal?.throwIfAborted();
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
- if (exporting && result.ok) {
+ if (signal && result.ok) {
+ exports.delete(id);
result.data.size = size;
self.postMessage({ id, type: "export", stage: "save" });
if (output) {
@@ -47,7 +70,16 @@ async function handleMessage({ id, name, args }) {
self.postMessage({ id, ...result }, result.data?.bytes ? [result.data.bytes.buffer] : []);
} catch (err) {
await output?.abort().catch(() => {});
- self.postMessage({ id, ok: false, error: err.message });
+ const result = { id, ok: false, error: err.message };
+ if (signal?.aborted) {
+ result.error = "导出已取消";
+ result.canceled = true;
+ }
+ self.postMessage(result);
+ } finally {
+ channel?.port1.close();
+ channel?.port2.close();
+ exports.delete(id);
}
}
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index 9e191d8..d007137 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -18,6 +18,7 @@ package webui
import (
"bufio"
+ "context"
"encoding/json"
"fmt"
"syscall/js"
@@ -34,21 +35,10 @@ type exportWriter struct {
// 入参: data 导出数据
// 返回: int 写入长度, error 错误信息
func (w exportWriter) Write(data []byte) (int, error) {
- done := make(chan error)
- callback := js.FuncOf(func(this js.Value, args []js.Value) any {
- var err error
- if len(args) > 0 {
- err = fmt.Errorf("%s", args[0].String())
- }
- done <- err
- return nil
- })
- defer callback.Release()
size := len(data)
for len(data) > 0 {
n := min(len(data), 1<<20)
- w.write.Invoke(bytesToJS(data[:n]), callback)
- if err := <-done; err != nil {
+ if err := awaitExport(w.write, bytesToJS(data[:n])); err != nil {
return size - len(data), err
}
data = data[n:]
@@ -56,6 +46,26 @@ func (w exportWriter) Write(data []byte) (int, error) {
return size, nil
}
+// awaitExport 等待浏览器处理导出检查点
+// 入参: fn 浏览器回调, args 回调参数
+// 返回: error 写入错误或取消原因
+func awaitExport(fn js.Value, args ...any) error {
+ done := make(chan error)
+ callback := js.FuncOf(func(this js.Value, args []js.Value) any {
+ var err error
+ if args[1].Bool() {
+ err = context.Canceled
+ } else if message := args[0].String(); message != "" {
+ err = fmt.Errorf("%s", message)
+ }
+ done <- err
+ return nil
+ })
+ defer callback.Release()
+ fn.Invoke(append(args, callback)...)
+ return <-done
+}
+
// currentSession 当前WebUI文档会话
var currentSession *Session
@@ -336,8 +346,8 @@ func exportDocument(args []js.Value) (any, error) {
return nil, fmt.Errorf("ofd document is not opened")
}
renderer := currentSession.Renderer
- renderer.OnExportProgress = func(completed, total int) {
- args[4].Invoke(completed, total)
+ renderer.OnExportProgress = func(completed, total int) error {
+ return awaitExport(args[4], completed, total)
}
defer func() { renderer.OnExportProgress = nil }()
var indices []int
diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go
index abeb778..5a8cae8 100644
--- a/ofdgo_renderer.go
+++ b/ofdgo_renderer.go
@@ -24,13 +24,13 @@ import (
// Renderer 渲染器实现
// 通过NewRenderer创建,实例及共享的Reader需串行使用
// OnPageText可选,接收页面绘制时同步提取的文字,不包含图案和签名外观
-// OnExportProgress可选,同步回报文档导出的已处理页数及总页数,不代表封装或写入成功
+// OnExportProgress可选,同步回报文档导出的已处理页数及总页数,返回错误则停止,页数完成不代表写入成功
type Renderer struct {
Reader *Reader
DPI float64
RenderAnnotations bool
OnPageText func(*PageContent, *PageText)
- OnExportProgress func(completed, total int)
+ OnExportProgress func(completed, total int) error
DrawParams map[string]*DrawParam
CompositeGraphicUnits map[string]*CompositeGraphicUnit
fontFamily *canvas.FontFamily
diff --git a/ofdgo_renderer_output.go b/ofdgo_renderer_output.go
index ca64aac..11fbf85 100644
--- a/ofdgo_renderer_output.go
+++ b/ofdgo_renderer_output.go
@@ -90,8 +90,8 @@ func (r *Renderer) RenderToZIP(writer io.Writer, format string, indices ...int)
method = zip.Store
}
width := len(strconv.Itoa(count))
- if r.OnExportProgress != nil {
- r.OnExportProgress(0, len(indices))
+ if err := r.exportProgress(0, len(indices)); err != nil {
+ return err
}
for i, index := range indices {
page, err := r.Reader.PageContentByIndex(index)
@@ -108,13 +108,23 @@ func (r *Renderer) RenderToZIP(writer io.Writer, format string, indices ...int)
if err := render(page, entry); err != nil {
return fmt.Errorf("failed to export page %d: %w", index+1, err)
}
- if r.OnExportProgress != nil {
- r.OnExportProgress(i+1, len(indices))
+ if err := r.exportProgress(i+1, len(indices)); err != nil {
+ return err
}
}
return archive.Close()
}
+// exportProgress 回报导出进度并传递调用方的停止原因
+// 入参: completed 已处理页数, total 总页数
+// 返回: error 停止原因
+func (r *Renderer) exportProgress(completed, total int) error {
+ if r.OnExportProgress != nil {
+ return r.OnExportProgress(completed, total)
+ }
+ return nil
+}
+
// rasterRenderer 保留页面物理尺寸的光栅渲染器
type rasterRenderer struct {
*rasterizer.Rasterizer
@@ -288,8 +298,8 @@ func (r *Renderer) RenderToMultiPagePDF(writer io.Writer, indices ...int) error
if err != nil {
return err
}
- if r.OnExportProgress != nil {
- r.OnExportProgress(0, len(indices))
+ if err := r.exportProgress(0, len(indices)); err != nil {
+ return err
}
pages := make([]pdfPage, len(indices))
for i, index := range indices {
@@ -313,8 +323,8 @@ func (r *Renderer) RenderPagesToPDF(contents []*PageContent, writer io.Writer) e
if len(contents) == 0 {
return fmt.Errorf("no pages found")
}
- if r.OnExportProgress != nil {
- r.OnExportProgress(0, len(contents))
+ if err := r.exportProgress(0, len(contents)); err != nil {
+ return err
}
pages := make([]pdfPage, len(contents))
for i, page := range contents {
@@ -330,7 +340,7 @@ func (r *Renderer) RenderPagesToPDF(contents []*PageContent, writer io.Writer) e
// renderPDFPages 渲染页面并复用调用方的字节缓冲
// 入参: pages 页面列表, writer 输出流, progress 页面处理进度
// 返回: error 错误信息
-func (r *Renderer) renderPDFPages(pages []pdfPage, writer io.Writer, progress func(int, int)) error {
+func (r *Renderer) renderPDFPages(pages []pdfPage, writer io.Writer, progress func(int, int) error) error {
navigation := newPDFNavigation(r, r.Reader.doc, pages)
buf, direct := writer.(*bytes.Buffer)
if !direct {
@@ -354,7 +364,10 @@ func (r *Renderer) renderPDFPages(pages []pdfPage, writer io.Writer, progress fu
return fmt.Errorf("failed to render page %d: %w", i+1, err)
}
if progress != nil {
- progress(i+1, len(pages))
+ if err := progress(i+1, len(pages)); err != nil {
+ buf.Truncate(start)
+ return err
+ }
}
}
if err := p.Close(); err != nil {