// 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" "bytes" "context" "encoding/hex" "encoding/json" "fmt" "image" "io" "math" "strconv" "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) { size := len(data) for len(data) > 0 { n := min(len(data), 1<<20) if err := awaitExport(w.write, bytesToJS(data[:n])); err != nil { return size - len(data), err } data = data[n:] } 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 // 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("ofdgoRenderPage", renderPage) registerCallback("ofdgoSVGFontData", svgFontData) registerCallback("ofdgoSearchPage", searchPage) registerCallback("ofdgoPageTextString", pageTextString) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoParsePageRange", parsePageRange) registerExportCallback("ofdgoExportPage", exportPage) registerExportCallback("ofdgoExportDocument", exportDocument) registerExportCallback("ofdgoExportAttachment", exportAttachment) registerCallback("ofdgoMatchFontFiles", matchFontFiles) registerCallback("ofdgoCreateDocument", createDocument) registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoInsertImage", insertImage) registerCallback("ofdgoInsertShape", insertShape) registerCallback("ofdgoUpdatePathStyle", updatePathStyle) registerCallback("ofdgoReplaceImage", replaceImage) registerCallback("ofdgoEditorFont", editorFont) registerCallback("ofdgoAlignObject", alignObject) registerCallback("ofdgoTransformObject", transformObject) registerCallback("ofdgoReshapeObject", reshapeObject) registerCallback("ofdgoLayoutText", layoutText) registerCallback("ofdgoTransformObjects", transformObjects) registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) registerCallback("ofdgoCopyObjects", copyObjects) registerCallback("ofdgoCaptureObjects", captureObjects) registerCallback("ofdgoPasteObjects", pasteObjects) registerCallback("ofdgoOrderObjects", orderObjects) registerCallback("ofdgoDistributeObjects", distributeObjects) registerCallback("ofdgoRotateObjects", rotateObjects) registerCallback("ofdgoFlipObjects", flipObjects) registerCallback("ofdgoCropImage", cropImage) registerCallback("ofdgoFitImage", fitImage) registerCallback("ofdgoPreviewImage", previewImage) registerCallback("ofdgoDeleteObject", deleteObject) registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) registerExportCallback("ofdgoSaveDocument", saveDocument) 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() currentEditor = nil copiedObjects = nil 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 } // exportAttachment 分块导出附件 // 入参: args 浏览器参数 // 返回: any 附件数据, error 错误信息 func exportAttachment(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } stream, err := currentSession.Reader.OpenAttachment(args[0].String()) if err != nil { return nil, err } defer stream.Close() writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) if _, err := io.Copy(writer, stream); err != nil { return nil, err } if err := writer.Flush(); err != nil { return nil, err } return successResult(map[string]any{ "mime": "application/octet-stream", }), 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 } objects, err := editorObjects(args[0].Int(), 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, "images": svgImagesToJS(page.Images), "text": string(textData), "objects": objects, }), nil } // pageTextString 提取OFD页面原文 // 入参: args 浏览器参数 // 返回: any 页面原文, error 错误信息 func pageTextString(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } return currentSession.PageTextString(args[0].Int()) } // 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) error { return awaitExport(args[4], 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) } // currentEditor 当前编辑文档 var currentEditor *ofdgo.Editor // editorClipboard 当前编辑文档中的对象快照与剪贴板标识。 type editorClipboard struct { token string objects []ofdgo.GraphicObject } // copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据。 var copiedObjects *editorClipboard // editorInfo 编辑文档信息与操作状态 type editorInfo struct { DocumentInfo Revision uint64 `json:"revision"` CanUndo bool `json:"canUndo"` CanRedo bool `json:"canRedo"` PageCapabilities []map[string]bool `json:"pageCapabilities"` EditWarnings []string `json:"editWarnings,omitempty"` } // editorPageInfo 页面操作结果与目标页面 type editorPageInfo struct { editorInfo PageIndex int `json:"pageIndex"` } // editorSelectionInfo 对象复制结果及新选区。 type editorSelectionInfo struct { editorInfo SelectedIDs []string `json:"selectedIDs"` } // editorSummary 获取当前编辑文档状态 // 返回: editorInfo 文档信息 func editorSummary() editorInfo { info := editorInfo{DocumentInfo: currentSession.Summary(), Revision: currentEditor.Revision(), CanUndo: currentEditor.CanUndo(), CanRedo: currentEditor.CanRedo()} if !currentSession.doc.Permissions.Edit { info.EditWarnings = append(info.EditWarnings, "原文件声明不允许编辑") } if currentSession.doc.Signatures != "" { info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效") } for i := 0; i < currentEditor.PageCount(); i++ { capability, _ := currentEditor.PageCapabilities(i) info.PageCapabilities = append(info.PageCapabilities, map[string]bool{"insert": capability.Insert, "copy": capability.Copy, "delete": capability.Delete, "move": capability.Move, "resize": capability.Resize}) } return info } // restoreEditor 撤销或重做并更新预览 // 入参: redo 是否重做 // 返回: any 文档信息, error 错误信息 func restoreEditor(redo bool) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } apply := currentEditor.Undo if redo { apply = currentEditor.Redo } if !apply() { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // editorObjects 提供编辑画布的对象范围、文字排版及原始图片边界 // 入参: index 页面索引, text 页面文字 // 返回: []any 对象区域, error 错误信息 func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { objects := []any{} if currentEditor == nil { return objects, nil } page, err := currentEditor.Page(index) if err != nil { return nil, err } textBoxes := make(map[string]ofdgo.Box) for _, run := range text.Runs { for _, box := range run.Boxes { if box.W <= 0 || box.H <= 0 { continue } prev, exists := textBoxes[run.ID] if !exists { textBoxes[run.ID] = box continue } x, y := math.Min(prev.X, box.X), math.Min(prev.Y, box.Y) textBoxes[run.ID] = ofdgo.Box{X: x, Y: y, W: math.Max(prev.X+prev.W, box.X+box.W) - x, H: math.Max(prev.Y+prev.H, box.Y+box.H) - y} } } fonts, err := currentSession.Reader.Fonts() if err != nil { return nil, err } fontNames := make(map[string]string, len(fonts)) for _, font := range fonts { fontNames[font.ID] = font.FontName } for _, layer := range page.Content.Layer { for order, object := range layer.Objects { var id string switch object.Type { case "TextObject": id = object.TextObject.ID case "ImageObject": id = object.ImageObject.ID case "PathObject": id = object.PathObject.ID default: continue } capability, err := currentEditor.ObjectCapabilities(index, id) if err != nil { return nil, err } if capability.Reason == "nested objects are read-only" { continue } var box ofdgo.Box switch object.Type { case "TextObject": box = textBoxes[id] case "ImageObject": box, err = ofdgo.ParseBox(object.ImageObject.Boundary) case "PathObject": box, err = ofdgo.ParseBox(object.PathObject.Boundary) } if err != nil { continue } if box.W > 0 && box.H > 0 { item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects), "layer": layer.ID, "capabilities": map[string]any{"update": capability.Update, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason}} if object.Type == "ImageObject" && capability.Update { full, err := object.ImageObject.ImageBounds() if err != nil { return nil, err } item["imageBounds"] = editorBox(full) } if object.Type == "PathObject" && capability.Update { path := object.PathObject kind, geometry := path.Shape() if kind != "" { item["shape"] = string(kind) item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} } item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke item["fillColor"] = editorColorHex(path.FillColor) item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) item["lineWidth"] = path.LineWidth * editorPathScale(path) } if object.Type == "TextObject" { value, layout := object.TextObject.TextLayout() item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight item["letterSpacing"] = layout.LetterSpacing if capability.Reflow { frame, err := object.TextObject.TextFrame() if err != nil { return nil, err } boundary, _ := ofdgo.ParseBox(object.TextObject.Boundary) matrix := ofdgo.TranslationMatrix(boundary.X, boundary.Y).Multiply(ofdgo.NewMatrix(object.TextObject.CTM)) inverse, _ := matrix.Invert() local := inverse.TransformBox(box) width := math.Max(object.TextObject.Size, local.X+local.W) if layout.Wrap { width = frame.W } frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)} item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(matrix)} if layout.Wrap { bounds := matrix.TransformBox(frame) item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H } } if codes := object.TextObject.TextCode; len(codes) > 1 { first, _ := strconv.ParseFloat(codes[0].Y, 64) second, _ := strconv.ParseFloat(codes[1].Y, 64) item["lineHeight"] = second - first } if capability.Update { item["color"] = editorColorHex(object.TextObject.FillColor) } } objects = append(objects, item) } } } return objects, nil } // editorFont 读取创作对象的完整内嵌字体,供画布输入使用 // 入参: args 字体资源标识 // 返回: any 字体数据, error 错误信息 func editorFont(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } fonts, err := currentSession.Reader.Fonts() if err != nil { return nil, err } for _, font := range fonts { if font.ID == args[0].String() { data, err := currentSession.Reader.ResData(font.FontFile) if err != nil { return nil, err } return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } } return nil, fmt.Errorf("font resource %q not found", args[0].String()) } // replaceImage 替换图片资源并更新预览 // 入参: args 页码、对象标识、图片数据和适应方式 // 返回: any 文档信息, error 错误信息 func replaceImage(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } data, err := bytesFromJS(args[2]) if err != nil { return nil, err } revision := currentEditor.Revision() if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data, args[3].String()); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // alignObject 将对象对齐页面并更新预览 // 入参: args 页码、对象标识和对齐方式 // 返回: any 文档信息, error 错误信息 func alignObject(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } revision := currentEditor.Revision() if err := currentEditor.AlignObject(args[0].Int(), args[1].String(), args[2].String()); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // transformObject 等比缩放后平移对象 // 入参: args 页码、对象标识、位移和缩放比例 // 返回: any 文档信息, error 错误信息 func transformObject(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, id := args[0].Int(), args[1].String() revision := currentEditor.Revision() if err := currentEditor.TransformObject(page, id, args[2].Float(), args[3].Float(), args[4].Float()); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // reshapeObject 调整基本图形的尺寸或直线端点,保留绘制样式 // 入参: args 页码、对象标识和页面几何范围 // 返回: any 文档信息, error 错误信息 func reshapeObject(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, id := args[0].Int(), args[1].String() object, err := currentEditor.Object(page, id) if err != nil { return nil, err } if object.Type != "PathObject" { return nil, fmt.Errorf("object is not a path") } object.PathObject, err = object.PathObject.Reshape(ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) if err != nil { return nil, err } revision := currentEditor.Revision() if err := currentEditor.UpdateObject(page, id, object); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // copyObjects 复制选区并返回新对象标识,复用字体和图片资源 // 入参: args 页码、对象标识列表和位移 // 返回: any 文档信息, error 错误信息 func copyObjects(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) if err != nil { return nil, err } return copyEditorObjects(args[0].Int(), objects, args[2].Float(), args[3].Float()) } // captureObjects 保存独立选区快照,不修改文档或历史。 // 入参: args 页面索引、对象标识数组和剪贴板标识 // 返回: any 空结果, error 错误信息 func captureObjects(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) if err != nil { return nil, err } copiedObjects = &editorClipboard{args[2].String(), objects} return nil, nil } // pasteObjects 将当前文档的对象快照粘贴到目标页。 // 入参: args 目标页、剪贴板标识和横纵位移 // 返回: any 文档信息及新选区, error 错误信息 func pasteObjects(args []js.Value) (any, error) { if currentEditor == nil || copiedObjects == nil || copiedObjects.token != args[1].String() { return nil, fmt.Errorf("object clipboard is no longer available") } return copyEditorObjects(args[0].Int(), copiedObjects.objects, args[2].Float(), args[3].Float()) } // copyEditorObjects 复制快照并更新预览,返回新对象标识。 // 入参: page 目标页, objects 对象快照, dx、dy 毫米位移 // 返回: any 文档信息及新选区, error 错误信息 func copyEditorObjects(page int, objects []ofdgo.GraphicObject, dx, dy float64) (any, error) { ids, err := currentEditor.CopyObjects(page, objects, dx, dy) if err != nil { return nil, err } if len(ids) == 0 { return editorSummary(), nil } info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) return editorSelectionInfo{info, ids}, err } // orderObjects 调整选区的绘制顺序 // 入参: args 页码、对象标识列表和层级动作 // 返回: any 文档信息, error 错误信息 func orderObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.OrderObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) }) } // distributeObjects 按指定轴等距分布选区。 // 入参: args 页面索引、对象标识数组和分布轴 // 返回: any 文档信息, error 错误信息 func distributeObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.DistributeObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) }) } // deleteObject 删除创作画布中选中的对象 // 入参: args 页码和对象标识 // 返回: any 文档信息, error 错误信息 func deleteObject(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } if err := currentEditor.DeleteObject(args[0].Int(), args[1].String()); err != nil { return nil, err } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // changePage 管理创作文档的页面 // 入参: args 操作、页面索引及尺寸或目标索引 // 返回: any 文档信息和目标页面, error 错误信息 func changePage(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } index := args[1].Int() revision := currentEditor.Revision() var err error switch action := args[0].String(); action { case "add": index, err = currentEditor.AddPage(args[2].Float(), args[3].Float()) case "copy": index, err = currentEditor.CopyPage(index) case "delete": if currentEditor.PageCount() == 1 { return nil, fmt.Errorf("the document must contain at least one page") } err = currentEditor.DeletePage(index) index = min(index, currentEditor.PageCount()-1) case "move": target := args[2].Int() err = currentEditor.MovePage(index, target) index = target case "resize": err = currentEditor.ResizePage(index, args[2].Float(), args[3].Float()) default: return nil, fmt.Errorf("unsupported page action %q", action) } if err != nil { return nil, err } if currentEditor.Revision() == revision { return editorPageInfo{editorSummary(), index}, nil } info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) if err != nil { return nil, err } return editorPageInfo{info, index}, nil } // createDocument 新建单页文档 // 入参: args 标题、纸张宽高和注解设置 // 返回: any 文档信息, error 错误信息 func createDocument(args []js.Value) (any, error) { editor := ofdgo.NewEditor() editor.Info.Title = args[0].String() if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil { return nil, err } editor.SetHistoryLimit(100) return previewEditor(editor, args[3].Bool()) } // editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置。 // 入参: args 浏览器参数 // 返回: any 编辑状态, error 错误信息 func editDocument(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } if currentEditor != nil { return editorSummary(), nil } editor, err := currentSession.Reader.Editor() if err != nil { return nil, err } editor.SetHistoryLimit(100) return previewEditor(editor, currentSession.Renderer.RenderAnnotations) } // previewEditor 通过内存快照更新预览,不生成中间压缩包 // 入参: editor 编辑文档, annotations 是否显示注解 // 返回: editorInfo 文档信息, error 错误信息 func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { reader, err := editor.Reader() if err != nil { return editorInfo{}, err } session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations}) if err != nil { return editorInfo{}, err } if currentSession != nil { if currentSession.fontFS != nil { session.fontFS = currentSession.fontFS session.Renderer.SetFontFS(session.fontFS) } _ = currentSession.Close() } currentSession = session if currentEditor != editor { copiedObjects = nil } currentEditor = editor return editorSummary(), nil } // updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性 // 入参: args 页码、对象标识、内容、字体数据、字号和颜色 // 返回: any 文档信息, error 错误信息 func updateText(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, id := args[0].Int(), args[1].String() object, err := currentEditor.Object(page, id) if err != nil { return nil, err } if object.Type != "TextObject" { return nil, fmt.Errorf("object %q is not text", id) } if !args[3].IsNull() { data, err := bytesFromJS(args[3]) if err != nil { return nil, err } object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { return nil, err } } if !args[2].IsNull() { object.TextObject.Size = args[4].Float() _, layout := object.TextObject.TextLayout() if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil { return nil, err } } if !args[5].IsNull() { if err := setTextColor(&object.TextObject, args[5].String()); err != nil { return nil, err } } revision := currentEditor.Revision() if err := currentEditor.UpdateObject(page, id, object); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度 // 入参: object 文字对象, value 十六进制色值 // 返回: error 错误信息 func setTextColor(object *ofdgo.TextObject, value string) error { if object.FillColor == nil { object.FillColor = &ofdgo.FillColor{} } return setEditorColor(object.FillColor, value) } // setEditorColor 写入浏览器RGB色值,保留其他颜色属性 // 入参: color 颜色, value 十六进制色值 // 返回: error 错误信息 func setEditorColor(color *ofdgo.FillColor, value string) error { if len(value) != 7 || value[0] != '#' { return fmt.Errorf("invalid RGB color %q", value) } rgb, err := hex.DecodeString(value[1:]) if err != nil { return err } color.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) return nil } // editorColorHex 将创作对象的RGB颜色转换为浏览器色值 // 入参: color 颜色 // 返回: string 十六进制色值 func editorColorHex(color *ofdgo.FillColor) string { var r, g, b int if color != nil { fmt.Sscan(color.Value, &r, &g, &b) } return fmt.Sprintf("#%02x%02x%02x", r, g, b) } // editorPathScale 获取路径线宽的页面缩放比例 // 入参: object 路径对象 // 返回: float64 缩放比例 func editorPathScale(object ofdgo.PathObject) float64 { m := ofdgo.NewMatrix(object.CTM) x, y := m.Transform(0, 0) ax, ay := m.Transform(1, 0) bx, by := m.Transform(0, 1) return math.Sqrt(math.Abs((ax-x)*(by-y) - (ay-y)*(bx-x))) } // setPathStyle 应用图形填充、描边和线宽 // 入参: object 路径对象, args 填充开关及色值、描边开关及色值、线宽 // 返回: error 错误信息 func setPathStyle(object *ofdgo.PathObject, args []js.Value) error { fill, stroke, width := args[0].Bool(), args[2].Bool(), args[4].Float() if kind, _ := object.Shape(); kind == ofdgo.ShapeLine { fill, stroke = false, true } if !fill && !stroke { return fmt.Errorf("fill or stroke must be enabled") } if width <= 0 || math.IsNaN(width) || math.IsInf(width, 0) { return fmt.Errorf("line width must be positive and finite") } object.Fill, object.Stroke = &fill, &stroke if scale := editorPathScale(*object); width != object.LineWidth*scale { object.LineWidth = width / scale } if object.FillColor == nil { object.FillColor = &ofdgo.FillColor{} } if object.StrokeColor == nil { object.StrokeColor = &ofdgo.StrokeColor{} } if err := setEditorColor(object.FillColor, args[1].String()); err != nil { return err } return setEditorColor((*ofdgo.FillColor)(object.StrokeColor), args[3].String()) } // insertShape 创建标准路径图形 // 入参: args 页码、图形类型、范围和绘制样式 // 返回: any 文档信息, error 错误信息 func insertShape(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } path, err := ofdgo.NewShape(ofdgo.ShapeKind(args[1].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) if err != nil { return nil, err } if err := setPathStyle(&path, args[6:]); err != nil { return nil, err } if _, err := currentEditor.AddObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}); err != nil { return nil, err } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // updatePathStyle 更新选中路径的绘制样式 // 入参: args 页码、对象标识和绘制样式 // 返回: any 文档信息, error 错误信息 func updatePathStyle(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, id := args[0].Int(), args[1].String() object, err := currentEditor.Object(page, id) if err != nil { return nil, err } if object.Type != "PathObject" { return nil, fmt.Errorf("object is not a path") } if err := setPathStyle(&object.PathObject, args[2:]); err != nil { return nil, err } revision := currentEditor.Revision() if err := currentEditor.UpdateObject(page, id, object); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // insertText 使用选定字体创建文字对象 // 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距和字距 // 返回: any 文档信息, error 错误信息 func insertText(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } data, err := bytesFromJS(args[2]) if err != nil { return nil, err } fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { return nil, err } page := args[0].Int() content, err := currentSession.pageContent(page) if err != nil { return nil, err } box, err := currentSession.pageBox(page, content) if err != nil { return nil, err } x, y := args[3].Float(), args[4].Float() width := args[7].Float() if width == 0 { width = 80 } box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y} object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{ Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), Font: fontID, Size: args[5].Float(), }} if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float()}); err != nil { return nil, err } if err := setTextColor(&object.TextObject, args[6].String()); err != nil { return nil, err } if _, err := currentEditor.AddObject(page, object); err != nil { return nil, err } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // layoutText 调整文字框和段落排版,保留软换行前的原文。 // 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距和字距 // 返回: any 文档信息, error 错误信息 func layoutText(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, id := args[0].Int(), args[1].String() object, err := currentEditor.Object(page, id) if err != nil { return nil, err } if object.Type != "TextObject" { return nil, fmt.Errorf("object is not text") } if !args[2].IsNull() { object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float()) if err != nil { return nil, err } } value, _ := object.TextObject.TextLayout() if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float()}); err != nil { return nil, err } revision := currentEditor.Revision() if err := currentEditor.UpdateObject(page, id, object); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // editorBox 将库层毫米边界传递给画布交互层。 // 入参: box 毫米坐标中的矩形范围 // 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高 func editorBox(box ofdgo.Box) map[string]any { return map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H} } // editorMatrix 将标准CTM转换为浏览器仿射矩阵分量。 // 入参: matrix 库层仿射矩阵 // 返回: []any 可直接传递给JavaScript的a、b、c、d、e、f分量,位移单位为毫米 func editorMatrix(matrix ofdgo.Matrix) []any { x, y := matrix.Transform(0, 0) ax, ay := matrix.Transform(1, 0) bx, by := matrix.Transform(0, 1) return []any{ax - x, ay - y, bx - x, by - y, x, y} } // rotateObjects 旋转同页选区,通用几何与历史记录由库负责。 // 入参: args 页面索引、对象标识数组和顺时针角度 // 返回: any 文档信息, error 错误信息 func rotateObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.RotateObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Int()) }) } // flipObjects 镜像同页选区。 // 入参: args 页面索引、对象标识数组和镜像轴 // 返回: any 文档信息, error 错误信息 func flipObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.FlipObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) }) } // cropImage 设置图片裁剪范围,保留原始图片数据。 // 入参: args 页面索引、对象标识及页面毫米坐标中的横纵坐标和宽高 // 返回: any 文档信息, error 错误信息 func cropImage(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.CropImage(args[0].Int(), args[1].String(), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) }) } // fitImage 按原始比例适应或填充图片框。 // 入参: args 页面索引、对象标识和适应方式 // 返回: any 文档信息, error 错误信息 func fitImage(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.FitImage(args[0].Int(), args[1].String(), args[2].String()) }) } // previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存。 // 入参: args 页面索引和图片对象标识 // 返回: any 页面SVG和分离的图片资源, error 错误信息 func previewImage(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, err := currentEditor.Page(args[0].Int()) if err != nil { return nil, err } id := args[1].String() found := false for i := range page.Content.Layer { for j := range page.Content.Layer[i].Objects { object := &page.Content.Layer[i].Objects[j] if object.Type == "ImageObject" && object.ImageObject.ID == id { object.ImageObject.Clips = nil found = true } } } if !found { return nil, fmt.Errorf("image object %q not found", id) } var out bytes.Buffer resources, err := currentSession.Renderer.RenderToSVGWithResources(page, &out) if err != nil { return nil, err } return successResult(map[string]any{"svg": out.String(), "images": svgImagesToJS(resources.Images)}), nil } // svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64。 // 入参: images SVG引用的图片资源 // 返回: []any 图片标识、类型和数据 func svgImagesToJS(images []ofdgo.SVGImage) []any { items := make([]any, len(images)) for i, image := range images { items[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)} } return items } // transformObjects 统一移动或缩放选区,生成一次撤销记录。 // 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例 // 返回: any 文档信息, error 错误信息 func transformObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.TransformObjects(args[0].Int(), stringsFromJS(args[1]), args[2].Float(), args[3].Float(), args[4].Float()) }) } // alignObjects 对齐选区中的对象。 // 入参: args 页面索引、对象标识数组和对齐方式 // 返回: any 文档信息, error 错误信息 func alignObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.AlignObjects(args[0].Int(), stringsFromJS(args[1]), args[2].String()) }) } // deleteObjects 一次删除选区中的对象。 // 入参: args 页面索引和对象标识数组 // 返回: any 文档信息, error 错误信息 func deleteObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.DeleteObjects(args[0].Int(), stringsFromJS(args[1])) }) } // changeObjects 提交库层批量操作,无修改时复用当前预览。 // 入参: apply 待执行的编辑操作 // 返回: any 文档信息, error 错误信息 func changeObjects(apply func() error) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } revision := currentEditor.Revision() if err := apply(); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // insertImage 按原始比例创建图片对象 // 入参: args 页码、图片数据、横纵坐标和宽度 // 返回: any 文档信息, error 错误信息 func insertImage(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } data, err := bytesFromJS(args[1]) if err != nil { return nil, err } resourceID, err := currentEditor.AddImage(data) if err != nil { return nil, err } config, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return nil, err } width := args[4].Float() box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: width, H: width * float64(config.Height) / float64(config.Width)} content, err := currentSession.pageContent(args[0].Int()) if err != nil { return nil, err } pageBox, err := currentSession.pageBox(args[0].Int(), content) if err != nil { return nil, err } if height := pageBox.H - box.Y*2; box.H > height { box.W *= height / box.H box.H = height } object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ ResourceID: resourceID, Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), }} if _, err := currentEditor.AddObject(args[0].Int(), object); err != nil { return nil, err } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // saveDocument 分块写出当前编辑文档 // 入参: args 数据写出回调 // 返回: any 保存结果, error 错误信息 func saveDocument(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) if _, err := currentEditor.WriteTo(writer); err != nil { return nil, err } if err := writer.Flush(); err != nil { return nil, err } return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil }