// Copyright 2025-2026 肖其顿 (XIAO QI DUN) // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build js && wasm package webui import ( "bufio" "encoding/json" "fmt" "syscall/js" "github.com/xiaoqidun/ofdgo" ) // 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 // apiResult 浏览器接口返回结果 type apiResult struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Data any `json:"data,omitempty"` } // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { registerCallback("ofdgoOpen", openDocument) registerCallback("ofdgoConfigure", configureDocument) registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoAttachmentData", attachmentData) registerCallback("ofdgoRenderPage", renderPage) registerCallback("ofdgoSVGFontData", svgFontData) registerCallback("ofdgoSearchPage", searchPage) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoParsePageRange", parsePageRange) registerExportCallback("ofdgoExportPage", exportPage) registerExportCallback("ofdgoExportDocument", exportDocument) registerCallback("ofdgoMatchFontFiles", matchFontFiles) select {} } // registerCallback 注册浏览器回调函数 // 入参: name 回调名称, fn 回调函数 func registerCallback(name string, fn func([]js.Value) (any, error)) { cb := js.FuncOf(func(this js.Value, args []js.Value) any { 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 错误信息 func safeCall(fn func([]js.Value) (any, error), args []js.Value) (data any, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("%v", r) } }() return fn(args) } // openDocument 打开OFD文档 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 func openDocument(args []js.Value) (any, error) { if len(args) < 3 { return nil, fmt.Errorf("missing open arguments") } data, err := bytesFromJS(args[0]) if err != nil { return nil, err } fonts, err := fontsFromJS(args[1]) if err != nil { return nil, err } renderAnnotations := args[2].Bool() if currentSession != nil { _ = currentSession.Close() currentSession = nil } session, err := Open(data, OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations}) if err != nil { return nil, err } currentSession = session return currentSession.Summary(), nil } // configureDocument 更新当前会话的字体和注解设置 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 func configureDocument(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } if !args[0].IsNull() && !args[0].IsUndefined() { fonts, err := fontsFromJS(args[0]) if err != nil { return nil, err } if err := currentSession.SetFonts(fonts); err != nil { return nil, err } } if currentSession.Renderer.RenderAnnotations != args[1].Bool() { currentSession.Renderer.RenderAnnotations = args[1].Bool() clear(currentSession.textCache) } return currentSession.Summary(), nil } // documentInfo 获取首屏之后加载的字体统计和验签结果 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 func documentInfo(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } return currentSession.Info(), nil } // attachmentData 读取附件数据 // 入参: args 浏览器参数 // 返回: any 附件数据, error 错误信息 func attachmentData(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } data, err := currentSession.Reader.AttachmentData(args[0].String()) if err != nil { return nil, err } return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } // svgFontData 读取SVG字体资源 // 入参: args 浏览器参数 // 返回: any 字体数据, error 错误信息 func svgFontData(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } data, err := currentSession.SVGFontData(args[0].String()) if err != nil { return nil, err } return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } // renderPage 渲染OFD页面 // 入参: args 浏览器参数 // 返回: any 页面SVG结果, error 错误信息 func renderPage(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } if len(args) == 0 { return nil, fmt.Errorf("missing page index") } page, err := currentSession.RenderPageSVG(args[0].Int()) if err != nil { return nil, err } text, err := currentSession.PageText(args[0].Int()) if err != nil { return nil, err } textData, err := json.Marshal(text) if err != nil { return nil, err } links := make([]any, len(page.Links)) for i, link := range page.Links { links[i] = map[string]any{ "uri": link.URI, "x": link.Box.X, "y": link.Box.Y, "width": link.Box.W, "height": link.Box.H, } } fonts := make([]any, len(page.Fonts)) for i, font := range page.Fonts { fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style} } return successResult(map[string]any{ "index": page.Index, "number": page.Number, "id": page.ID, "width": page.Width, "height": page.Height, "svg": page.SVG, "links": links, "fonts": fonts, "text": string(textData), }), nil } // searchPage 搜索OFD页面文字 // 入参: args 浏览器参数 // 返回: any 搜索结果, error 错误信息 func searchPage(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } return currentSession.SearchPage(args[0].Int(), args[1].String()) } // exportFormats 获取导出格式 // 入参: args 浏览器参数 // 返回: any 导出格式, error 错误信息 func exportFormats(args []js.Value) (any, error) { return ExportFormats(), nil } // parsePageRange 解析当前文档的导出页码 // 入参: args 浏览器参数 // 返回: any 页面索引, error 错误信息 func parsePageRange(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } count, err := currentSession.Reader.PageCount() if err != nil { return nil, err } return ofdgo.ParsePageRange(args[0].String(), count) } // exportPage 导出OFD单页 // 入参: args 浏览器参数 // 返回: any 导出结果, error 错误信息 func exportPage(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } if len(args) < 4 { return nil, fmt.Errorf("missing export page arguments") } 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{ "label": format.Label, "mime": format.MIME, }), nil } // exportDocument 导出OFD文档 // 入参: args 浏览器参数 // 返回: any 导出结果, error 错误信息 func exportDocument(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } renderer := currentSession.Renderer renderer.OnExportProgress = func(completed, total int) { args[4].Invoke(completed, total) } defer func() { renderer.OnExportProgress = nil }() var indices []int if !args[2].IsNull() { indices = make([]int, args[2].Length()) for i := range indices { indices[i] = args[2].Index(i).Int() } } writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) format, err := currentSession.ExportDocument(args[0].String(), args[1].Float(), writer, indices...) if err != nil { return nil, err } if err := writer.Flush(); err != nil { return nil, err } return successResult(map[string]any{ "label": format.Label, "mime": format.MIME, }), nil } // successResult 创建成功接口结果 // 入参: data 返回数据 // 返回: js.Value 接口结果 func successResult(data any) js.Value { result := js.Global().Get("Object").New() result.Set("ok", true) result.Set("data", data) return result } // bytesToJS 将字节数据转换为Uint8Array // 入参: data 字节数据 // 返回: js.Value Uint8Array对象 func bytesToJS(data []byte) js.Value { value := js.Global().Get("Uint8Array").New(len(data)) js.CopyBytesToJS(value, data) return value } // matchFontFiles 匹配文档所需的字体文件 // 入参: args 浏览器参数 // 返回: any 匹配的字体文件名称, error 错误信息 func matchFontFiles(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } return currentSession.Reader.MatchFontFiles(stringsFromJS(args[0])) } // bytesFromJS 从浏览器值读取二进制数据 // 入参: value 浏览器值 // 返回: []byte 二进制数据, error 错误信息 func bytesFromJS(value js.Value) ([]byte, error) { if value.IsUndefined() || value.IsNull() { return nil, fmt.Errorf("missing binary data") } uint8Array := js.Global().Get("Uint8Array") arrayBuffer := js.Global().Get("ArrayBuffer") if value.InstanceOf(arrayBuffer) { value = uint8Array.New(value) } if !value.InstanceOf(uint8Array) { return nil, fmt.Errorf("binary data must be Uint8Array or ArrayBuffer") } data := make([]byte, value.Get("byteLength").Int()) if n := js.CopyBytesToGo(data, value); n != len(data) { return nil, fmt.Errorf("copied %d of %d bytes", n, len(data)) } return data, nil } // fontsFromJS 从浏览器值读取字体文件 // 入参: value 浏览器值 // 返回: []FontFile 字体文件, error 错误信息 func fontsFromJS(value js.Value) ([]FontFile, error) { if value.IsUndefined() || value.IsNull() { return nil, nil } length := value.Get("length").Int() fonts := make([]FontFile, 0, length) for i := 0; i < length; i++ { item := value.Index(i) data, err := bytesFromJS(item.Get("data")) if err != nil { return nil, err } fonts = append(fonts, FontFile{Name: item.Get("name").String(), Data: data}) } return fonts, nil } // stringsFromJS 从浏览器值读取字符串列表 // 入参: value 浏览器值 // 返回: []string 字符串列表 func stringsFromJS(value js.Value) []string { items := make([]string, value.Length()) for i := range items { items[i] = value.Index(i).String() } return items } // encodeResult 编码浏览器接口返回结果 // 入参: result 返回结果 // 返回: string JSON字符串 func encodeResult(result apiResult) string { data, err := json.Marshal(result) if err != nil { fallback, _ := json.Marshal(apiResult{OK: false, Error: err.Error()}) return string(fallback) } return string(data) }