diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 5462901..3eabee4 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1496,26 +1496,41 @@ async function exportFile(whole) { return; } const label = whole ? (format.value === "pdf" ? "PDF" : "ZIP") : format.label; + const extension = whole ? label.toLowerCase() : format.extension; + const mime = whole && format.value !== "pdf" ? "application/zip" : format.mime; const openSeq = state.openSeq; - const fileName = whole ? `${baseFileName()}.${label.toLowerCase()}` : pageFileName(format.extension); + const fileName = whole ? `${baseFileName()}.${extension}` : pageFileName(extension); + const pageIndex = state.pageIndex; + const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0; state.exporting = true; updateControls(); setBusy(true, `正在生成 ${label}`, null, whole ? STATUS.exporting : STATUS.pageExporting); try { - const dpi = exportFormatUsesDPI(format.value) ? currentImageDPI() : 0; + const file = window.showSaveFilePicker ? await window.showSaveFilePicker({ + suggestedName: fileName, + types: [{ description: label, accept: { [mime]: [`.${extension}`] } }], + }) : null; + if (openSeq !== state.openSeq) { + return; + } const result = whole - ? await callWASM("ofdgoExportDocument", format.value, dpi) - : await callWASM("ofdgoExportPage", state.pageIndex, format.value, dpi); + ? await callWASM("ofdgoExportDocument", format.value, dpi, file) + : await callWASM("ofdgoExportPage", pageIndex, format.value, dpi, file); if (openSeq !== state.openSeq) { return; } setProgress(`正在保存 ${result.label}`, 100); - const bytes = result.bytes; - downloadBytes(bytes, result.mime, fileName); - setStatus(`${result.label} 导出完成 ${formatBytes(bytes.length, result.label)}`); + if (result.blob) { + downloadBytes(result.blob, result.mime, fileName); + } + setStatus(`${result.label} 导出完成 ${formatBytes(result.size, result.label)}`); } catch (err) { if (openSeq === state.openSeq) { - showError(err, false); + if (err.name === "AbortError") { + setStatus("导出已取消"); + } else { + showError(err, false); + } } } finally { state.exporting = false; diff --git a/assets/webui/ofdgo.wasm.js b/assets/webui/ofdgo.wasm.js index f604560..c256829 100644 --- a/assets/webui/ofdgo.wasm.js +++ b/assets/webui/ofdgo.wasm.js @@ -1,14 +1,49 @@ importScripts("./wasm_exec.js"); -self.onmessage = ({ data: { id, name, args } }) => { +let pending = Promise.resolve(); +self.onmessage = ({ data }) => { + pending = pending.then(() => handleMessage(data)); +}; +async function handleMessage({ id, name, args }) { + let output; try { - const payload = globalThis[name](...args); + const chunks = []; + let size = 0; + const exporting = name === "ofdgoExportPage" || name === "ofdgoExportDocument"; + if (exporting) { + const file = args.pop(); + if (file) { + output = await file.createWritable(); + } + args.push((bytes, done) => { + size += bytes.length; + if (output) { + output.write(bytes).then(() => done(), (err) => done(err.message)); + } else { + chunks.push(new Blob([bytes])); + setTimeout(done, 0); + } + }); + } + const payload = await globalThis[name](...args); const result = typeof payload === "string" ? JSON.parse(payload) : payload; + if (exporting && result.ok) { + result.data.size = size; + if (output) { + await output.close(); + } else { + result.data.blob = new Blob(chunks, { type: result.data.mime }); + } + } else if (output) { + await output.abort(); + } + output = null; 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 }); } -}; +} function exitWASM(err) { self.postMessage({ type: "exit", error: err?.message || "引擎运行中断" }); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 34095ba..8850d72 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -17,11 +17,43 @@ package webui import ( + "bufio" "encoding/json" "fmt" "syscall/js" ) +// exportWriter 分块传递导出数据 +type exportWriter struct { + write js.Value +} + +// Write 将数据块交给浏览器保存 +// 入参: 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 { + return size - len(data), err + } + data = data[n:] + } + return size, nil +} + // currentSession 当前WebUI文档会话 var currentSession *Session @@ -42,8 +74,8 @@ func RunWASM() { registerCallback("ofdgoSVGFontData", svgFontData) registerCallback("ofdgoSearchPage", searchPage) registerCallback("ofdgoExportFormats", exportFormats) - registerCallback("ofdgoExportPage", exportPage) - registerCallback("ofdgoExportDocument", exportDocument) + registerExportCallback("ofdgoExportPage", exportPage) + registerExportCallback("ofdgoExportDocument", exportDocument) registerCallback("ofdgoMatchFontFiles", matchFontFiles) select {} } @@ -52,18 +84,42 @@ func RunWASM() { // 入参: name 回调名称, fn 回调函数 func registerCallback(name string, fn func([]js.Value) (any, error)) { cb := js.FuncOf(func(this js.Value, args []js.Value) any { - data, err := safeCall(fn, args) - if err != nil { - return encodeResult(apiResult{OK: false, Error: err.Error()}) - } - if result, ok := data.(js.Value); ok { - return result - } - return encodeResult(apiResult{OK: true, Data: data}) + return callbackResult(fn, args) }) js.Global().Set(name, cb) } +// registerExportCallback 异步导出,为浏览器处理数据块让出事件循环 +// 入参: name 回调名称, fn 回调函数 +func registerExportCallback(name string, fn func([]js.Value) (any, error)) { + cb := js.FuncOf(func(this js.Value, args []js.Value) any { + executor := js.FuncOf(func(this js.Value, callbacks []js.Value) any { + resolve := callbacks[0] + go func() { + resolve.Invoke(callbackResult(fn, args)) + }() + return nil + }) + defer executor.Release() + return js.Global().Get("Promise").New(executor) + }) + js.Global().Set(name, cb) +} + +// callbackResult 转换浏览器接口结果 +// 入参: fn 回调函数, args 回调参数 +// 返回: any 浏览器结果 +func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any { + data, err := safeCall(fn, args) + if err != nil { + return encodeResult(apiResult{OK: false, Error: err.Error()}) + } + if result, ok := data.(js.Value); ok { + return result + } + return encodeResult(apiResult{OK: true, Data: data}) +} + // safeCall 调用浏览器回调并转换异常 // 入参: fn 回调函数, args 回调参数 // 返回: any 回调结果, error 错误信息 @@ -238,15 +294,18 @@ func exportPage(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } - if len(args) < 3 { + if len(args) < 4 { return nil, fmt.Errorf("missing export page arguments") } - data, format, err := currentSession.ExportPage(args[0].Int(), args[1].String(), args[2].Float()) + writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) + format, err := currentSession.ExportPage(args[0].Int(), args[1].String(), args[2].Float(), writer) if err != nil { return nil, err } + if err := writer.Flush(); err != nil { + return nil, err + } return successResult(map[string]any{ - "bytes": bytesToJS(data), "label": format.Label, "mime": format.MIME, }), nil @@ -259,12 +318,15 @@ func exportDocument(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } - data, format, err := currentSession.ExportDocument(args[0].String(), args[1].Float()) + writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20) + format, err := currentSession.ExportDocument(args[0].String(), args[1].Float(), writer) if err != nil { return nil, err } + if err := writer.Flush(); err != nil { + return nil, err + } return successResult(map[string]any{ - "bytes": bytesToJS(data), "label": format.Label, "mime": format.MIME, }), nil diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 679897f..9683d2e 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -15,12 +15,10 @@ package webui import ( - "archive/zip" "bytes" "fmt" - "image/jpeg" + "io" "path" - "strconv" "strings" "time" @@ -450,38 +448,22 @@ func (s *Session) SVGFontData(name string) ([]byte, error) { } // ExportPage 导出单页 -// 入参: index 页面索引, value 导出格式, dpi 图片DPI -// 返回: []byte 文件数据, ExportFormat 导出格式, error 错误信息 -func (s *Session) ExportPage(index int, value string, dpi float64) ([]byte, ExportFormat, error) { +// 入参: index 页面索引, value 导出格式, dpi 图片DPI, writer 输出流 +// 返回: ExportFormat 导出格式, error 错误信息 +func (s *Session) ExportPage(index int, value string, dpi float64, writer io.Writer) (ExportFormat, error) { format, ok := exportFormat(value) if !ok { - return nil, ExportFormat{}, fmt.Errorf("unsupported export format %s", value) + return ExportFormat{}, fmt.Errorf("unsupported export format %s", value) } page, err := s.pageContent(index) if err != nil { - return nil, ExportFormat{}, err + return ExportFormat{}, err } - var buf bytes.Buffer renderer := *s.Renderer if dpi > 0 && (format.Value == "png" || format.Value == "jpg") { renderer.DPI = dpi } - switch format.Value { - case "svg": - err = renderer.RenderToSVG(page, &buf) - case "pdf": - err = renderer.RenderToPDF(page, &buf) - case "eps": - err = renderer.RenderToEPS(page, &buf) - case "png": - err = renderer.RenderToPNG(page, &buf) - case "jpg": - err = renderer.RenderToJPEG(page, &buf, &jpeg.Options{Quality: 95}) - } - if err != nil { - return nil, ExportFormat{}, err - } - return buf.Bytes(), format, nil + return format, renderer.RenderTo(page, writer, format.Value) } // signatureInfos 获取签名验证信息 @@ -632,72 +614,43 @@ func signatureStampInfos(positions []ofdgo.SignatureStampPosition) []SignatureSt } // ExportDocument 导出文档为PDF或逐页打包ZIP -// 入参: value 导出格式, dpi 图片DPI -// 返回: []byte 文件数据, ExportFormat 导出格式, error 错误信息 -func (s *Session) ExportDocument(value string, dpi float64) ([]byte, ExportFormat, error) { +// 入参: value 导出格式, dpi 图片DPI, writer 输出流 +// 返回: ExportFormat 导出格式, error 错误信息 +func (s *Session) ExportDocument(value string, dpi float64, writer io.Writer) (ExportFormat, error) { format, ok := exportFormat(value) if !ok { - return nil, ExportFormat{}, fmt.Errorf("unsupported export format %s", value) + return ExportFormat{}, fmt.Errorf("unsupported export format %s", value) } if format.Value == "pdf" { - data, err := s.ExportPDF() - return data, format, err + return format, s.ExportPDF(writer) } if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { - return nil, ExportFormat{}, fmt.Errorf("ofd document is not opened") + return ExportFormat{}, fmt.Errorf("ofd document is not opened") } - count := len(s.doc.Pages.Page) - if count == 0 { - return nil, ExportFormat{}, fmt.Errorf("no pages found") + renderer := *s.Renderer + if dpi > 0 && (format.Value == "png" || format.Value == "jpg") { + renderer.DPI = dpi } - var buf bytes.Buffer - archive := zip.NewWriter(&buf) - method := zip.Deflate - if format.Value == "png" || format.Value == "jpg" { - method = zip.Store - } - width := len(strconv.Itoa(count)) - for index := range count { - data, _, err := s.ExportPage(index, format.Value, dpi) - if err != nil { - return nil, ExportFormat{}, fmt.Errorf("failed to export page %d: %w", index+1, err) - } - entry, err := archive.CreateHeader(&zip.FileHeader{ - Name: fmt.Sprintf("%0*d.%s", width, index+1, format.Extension), - Method: method, - }) - if err != nil { - return nil, ExportFormat{}, err - } - if _, err := entry.Write(data); err != nil { - return nil, ExportFormat{}, err - } - } - if err := archive.Close(); err != nil { - return nil, ExportFormat{}, err - } - return buf.Bytes(), ExportFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"}, nil + err := renderer.RenderToZIP(writer, format.Value) + return ExportFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"}, err } // ExportPDF 导出文档为PDF -// 返回: []byte PDF文件数据, error 错误信息 -func (s *Session) ExportPDF() ([]byte, error) { +// 入参: writer 输出流 +// 返回: error 错误信息 +func (s *Session) ExportPDF(writer io.Writer) error { if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { - return nil, fmt.Errorf("ofd document is not opened") + return fmt.Errorf("ofd document is not opened") } - var buf bytes.Buffer pages := make([]*ofdgo.PageContent, len(s.doc.Pages.Page)) for i := range pages { page, err := s.pageContent(i) if err != nil { - return nil, err + return err } pages[i] = page } - if err := s.Renderer.RenderPagesToPDF(pages, &buf); err != nil { - return nil, err - } - return buf.Bytes(), nil + return s.Renderer.RenderPagesToPDF(pages, writer) } // pageContent 获取页面内容 diff --git a/ofdgo_renderer_output.go b/ofdgo_renderer_output.go index b7eca0c..59c2700 100644 --- a/ofdgo_renderer_output.go +++ b/ofdgo_renderer_output.go @@ -15,6 +15,7 @@ package ofdgo import ( + "archive/zip" "bufio" "bytes" "fmt" @@ -22,6 +23,8 @@ import ( "image/jpeg" "image/png" "io" + "strconv" + "strings" "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/renderers/pdf" @@ -32,6 +35,79 @@ import ( "golang.org/x/image/math/f64" ) +// RenderTo 按格式渲染单页,JPEG使用95画质 +// 入参: page 页面内容, writer 输出流, format svg、pdf、eps、png、jpg或jpeg +// 返回: error 错误信息 +func (r *Renderer) RenderTo(page *PageContent, writer io.Writer, format string) error { + _, render, err := r.outputRenderer(format) + if err != nil { + return err + } + return render(page, writer) +} + +// outputRenderer 获取格式扩展名和渲染方法 +// 入参: format 输出格式 +// 返回: string 扩展名, func 渲染方法, error 错误信息 +func (r *Renderer) outputRenderer(format string) (string, func(*PageContent, io.Writer) error, error) { + switch strings.ToLower(strings.TrimSpace(format)) { + case "svg": + return "svg", r.RenderToSVG, nil + case "pdf": + return "pdf", r.RenderToPDF, nil + case "eps": + return "eps", r.RenderToEPS, nil + case "png": + return "png", r.RenderToPNG, nil + case "jpg", "jpeg": + return "jpg", func(page *PageContent, writer io.Writer) error { + return r.RenderToJPEG(page, writer, &jpeg.Options{Quality: 95}) + }, nil + default: + return "", nil, fmt.Errorf("unsupported output format %s", format) + } +} + +// RenderToZIP 逐页渲染并写入ZIP,文件编号按总页数补零,出错时调用方应丢弃输出 +// 入参: writer 输出流, format svg、pdf、eps、png、jpg或jpeg +// 返回: error 错误信息 +func (r *Renderer) RenderToZIP(writer io.Writer, format string) error { + extension, render, err := r.outputRenderer(format) + if err != nil { + return err + } + count, err := r.Reader.PageCount() + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("no pages found") + } + archive := zip.NewWriter(writer) + method := zip.Deflate + if extension == "png" || extension == "jpg" { + method = zip.Store + } + width := len(strconv.Itoa(count)) + for index := range count { + page, err := r.Reader.PageContentByIndex(index) + if err != nil { + return fmt.Errorf("failed to read page %d: %w", index+1, err) + } + entry, err := archive.CreateHeader(&zip.FileHeader{ + Name: fmt.Sprintf("%0*d.%s", width, index+1, extension), + Method: method, + }) + if err != nil { + return err + } + if err := render(page, entry); err != nil { + return fmt.Errorf("failed to export page %d: %w", index+1, err) + } + } + return archive.Close() +} + // rasterRenderer 保留页面物理尺寸的光栅渲染器 type rasterRenderer struct { *rasterizer.Rasterizer @@ -139,9 +215,13 @@ func (r *Renderer) RenderToSVG(page *PageContent, writer io.Writer) error { if err != nil { return err } - renderer := svg.New(writer, c.W, c.H, nil) + buffer := bufio.NewWriter(writer) + renderer := svg.New(buffer, c.W, c.H, nil) c.RenderTo(renderer) - return renderer.Close() + if err := renderer.Close(); err != nil { + return err + } + return buffer.Flush() } // replacePDFProducer 替换PDF的Producer属性