// 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" "crypto" "encoding/hex" "encoding/json" "errors" "fmt" "image" "image/color" "io" "io/fs" "math" "reflect" "slices" "strconv" "strings" "syscall/js" "time" "github.com/xiaoqidun/ofdgo" ) // currentSession 当前WebUI文档会话 var currentSession *Session // currentEditor 当前编辑文档 var currentEditor *ofdgo.Editor // pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话 var pendingImport *ofdgo.Reader // copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 var copiedObjects *editorClipboard // copiedStyle 当前文档中的独立样式快照 var copiedStyle *ofdgo.GraphicObject // exportWriter 分块传递导出数据 type exportWriter struct { write js.Value } // browserReaderAt 分块读取浏览器文件,只缓存最近的一个数据块 type browserReaderAt struct { read js.Value checkpoint js.Value size int64 offset int64 data []byte checked time.Time } // apiResult 浏览器接口返回结果 type apiResult struct { OK bool `json:"ok"` Code string `json:"code,omitempty"` Error string `json:"error,omitempty"` Data any `json:"data,omitempty"` MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"` ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"` } // editorClipboard 当前编辑文档中的对象快照与剪贴板标识 type editorClipboard struct { token string objects []ofdgo.GraphicObject composite *ofdgo.CompositeSelection annotations *ofdgo.AnnotationSelection } // editorInfo 编辑文档信息与操作状态 type editorInfo struct { DocumentInfo Revision uint64 `json:"revision"` CanUndo bool `json:"canUndo"` CanRedo bool `json:"canRedo"` PageCapabilities []uint `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"` } // editorOutlineInfo 目录操作后的位置 type editorOutlineInfo struct { editorInfo OutlinePath []int `json:"outlinePath"` } // annotationOptions 保存注解面板的输入,资源字节单独传输 type annotationOptions struct { Kind, ID, Text, Old, Creator, Pages, URI, Color string Base string X, Y, Width, Height, Size, Angle float64 Alpha int Tile bool Keep bool Target int Area *ofdgo.Box } // 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 } // ReadAt 按需读取文件片段并定期交付取消检查点 // 入参: data 目标缓冲, offset 文件偏移 // 返回: int 读取字节数, error 读取错误 func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) { if offset < 0 { return 0, fmt.Errorf("negative file offset") } n := 0 for len(data) > 0 { if offset >= r.size { return n, io.EOF } if time.Since(r.checked) >= 50*time.Millisecond { if err := awaitExport(r.checkpoint); err != nil { return n, err } r.checked = time.Now() } if len(r.data) == 0 || offset < r.offset || offset-r.offset >= int64(len(r.data)) { r.offset = offset / (256 << 10) * (256 << 10) size := int(min(int64(256<<10), r.size-r.offset)) if cap(r.data) < size { r.data = make([]byte, size) } else { r.data = r.data[:size] } value := r.read.Invoke(float64(r.offset), size) if js.CopyBytesToGo(r.data, value) != size { return n, io.ErrUnexpectedEOF } } copied := copy(data, r.data[offset-r.offset:]) n += copied offset += int64(copied) data = data[copied:] } return n, nil } // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { registerCallback("ofdgoOpen", openDocument) registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument) registerAsyncCallback("ofdgoConvertFile", convertFile) registerAsyncCallback("ofdgoPackFiles", packFiles) registerCallback("ofdgoOutputFormats", func([]js.Value) (any, error) { return ofdgo.OutputFormats(), nil }) registerCallback("ofdgoConfigure", configureDocument) registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoVerifySignatures", verifySignatures) registerCallback("ofdgoRenderPage", renderPage) registerCallback("ofdgoRenderBackends", renderBackends) registerCallback("ofdgoSVGFontData", svgFontData) registerCallback("ofdgoSearchPage", searchPage) registerCallback("ofdgoPageTextString", pageTextString) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoParsePageRange", parsePageRange) registerAsyncCallback("ofdgoExportPage", exportPage) registerAsyncCallback("ofdgoExportDocument", exportDocument) registerAsyncCallback("ofdgoExportAttachment", exportAttachment) registerCallback("ofdgoImages", documentImages) registerAsyncCallback("ofdgoExportImages", exportImages) registerCallback("ofdgoMatchFontFiles", matchFontFiles) registerCallback("ofdgoFontFaces", fontFaces) registerCallback("ofdgoFontFace", fontFace) registerCallback("ofdgoCreateDocument", createDocument) registerAsyncCallback("ofdgoImportImages", importImages) registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoUpdateInfo", updateInfo) registerCallback("ofdgoChangeAttachment", changeAttachment) registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoBatchPages", batchPages) registerCallback("ofdgoChangeOutline", changeOutline) registerCallback("ofdgoChangeAnnotations", changeAnnotations) registerCallback("ofdgoChangeSelection", changeSelection) registerCallback("ofdgoWriteAnnotation", writeAnnotation) registerCallback("ofdgoReadAnnotation", readAnnotation) registerCallback("ofdgoInsertInk", insertInk) registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoPreviewPositionedText", previewPositionedText) registerCallback("ofdgoMoveOutline", moveOutline) registerCallback("ofdgoCaptureStyle", captureStyle) registerCallback("ofdgoGroupObjects", groupObjects) registerCallback("ofdgoPasteStyle", pasteStyle) registerCallback("ofdgoResizeObjects", resizeObjects) registerCallback("ofdgoLoadImport", loadImport) registerAsyncCallback("ofdgoImportPages", importPages) registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoPreviewText", previewText) registerCallback("ofdgoStyleText", styleText) registerCallback("ofdgoCheckTextFont", checkTextFont) 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("ofdgoReshapeLine", reshapeLine) registerCallback("ofdgoResetImageCrop", resetImageCrop) registerCallback("ofdgoLayoutText", layoutText) registerCallback("ofdgoTransformObjects", transformObjects) registerCallback("ofdgoCompositeObjects", compositeObjects) registerCallback("ofdgoChangeCompositeObjects", changeCompositeObjects) registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) registerCallback("ofdgoEraseObjects", eraseObjects) registerCallback("ofdgoEraseObjectsPath", eraseObjectsPath) 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) }) registerAsyncCallback("ofdgoSaveDocument", saveDocument) registerAsyncCallback("ofdgoSetEncryption", setEncryption) registerAsyncCallback("ofdgoSaveSigned", saveSigned) registerCallback("ofdgoPreviewSignatureSeal", previewSignatureSeal) registerCallback("ofdgoSignaturePlacement", signaturePlacement) select {} } // 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 } // 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) } // renderBackends 返回库提供的内置后端能力 // 入参: args 浏览器参数 // 返回: any 后端能力, error 错误信息 func renderBackends(args []js.Value) (any, error) { return ofdgo.RenderBackendInfos(), nil } // registerAsyncCallback 注册异步回调,为浏览器处理数据和取消请求让出事件循环 // 入参: name 回调名称, fn 回调函数 func registerAsyncCallback(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 { result := apiResult{Error: err.Error()} switch { case errors.Is(err, ofdgo.ErrPDFPassword): result.Code, result.Error = "pdfPassword", "PDF密码缺失或错误" case errors.Is(err, ofdgo.ErrCredentialsRequired): result.Code, result.Error = "credentialsRequired", "文档已加密" case errors.Is(err, ofdgo.ErrInvalidCredentials): result.Code, result.Error = "invalidCredentials", "凭据无效或文件损坏" case errors.Is(err, ofdgo.ErrUnsupportedEncryption): result.Code, result.Error = "unsupportedEncryption", "不支持此加密方式" case errors.Is(err, ofdgo.ErrEncryptionPolicyRequired): result.Code, result.Error = "encryptionPolicyRequired", "请设置加密方式" } var failure *ofdgo.EditError if errors.As(err, &failure) { result.ReasonCode = failure.Code } if errors.As(err, &result.MissingGlyphs) { result.ReasonCode = ofdgo.EditMissingGlyphs } return encodeResult(result) } 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) } // convertPDFDocument 通过库转换PDF,不替换当前文档会话 // 入参: args PDF数据、外部字体、UTF-8密码、字体目录、进度及字体读取回调 // 返回: any OFD数据及转换警告, error 错误信息 func convertPDFDocument(args []js.Value) (any, error) { if len(args) != 6 { return nil, fmt.Errorf("missing PDF data") } data, err := bytesFromJS(args[0]) if err != nil { return nil, err } fonts, err := fontsFromJS(args[1]) if err != nil { return nil, err } var output bytes.Buffer options := ofdgo.PDFImportOptions{OnProgress: editorOperationProgress(args[4]), PasswordUTF8: true} if !args[2].IsNull() && !args[2].IsUndefined() { options.Password, err = bytesFromJS(args[2]) if err != nil { return nil, err } defer clear(options.Password) } fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} if names := stringsFromJS(args[3]); len(names) > 0 { fontSources = append(fontSources, ofdgo.NewLazyFontFS(names, func(name string) ([]byte, error) { return loadConversionFont(args[5], name) })) } options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(fontSources...)} editor, report, err := ofdgo.ImportPDF(context.Background(), bytes.NewReader(data), int64(len(data)), options) if err != nil { return nil, err } if len(options.Password) != 0 { if err := editor.SetEncryption(&ofdgo.EncryptionOptions{Password: options.Password}); err != nil { return nil, err } } editor.OnWriteProgress = func(stage string, completed, total int) error { return options.OnProgress("write."+stage, completed, total) } if _, err := editor.WriteTo(&output); err != nil { return nil, err } warnings, err := json.Marshal(report.Warnings) if err != nil { return nil, err } return successResult(map[string]any{"bytes": bytesToJS(output.Bytes()), "warnings": string(warnings)}), nil } // convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 // 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查、字体及按需字体读取 // 返回: any 转换报告, error 转换错误 func convertFile(args []js.Value) (any, error) { if len(args) != 9 { return nil, fmt.Errorf("missing conversion arguments") } settings := args[2] backends, err := ofdgo.NewRenderBackends(settings.Get("backend").String()) if err != nil { return nil, err } fonts, err := fontsFromJS(args[7]) if err != nil { return nil, err } options := ofdgo.ConvertOptions{ Format: settings.Get("format").String(), PageRange: settings.Get("pages").String(), SkipUnchanged: true, Backends: &backends, RendererOptions: []ofdgo.RendererOption{ofdgo.WithDPI(settings.Get("dpi").Float())}, OnProgress: func(progress ofdgo.ConvertProgress) error { return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) }, } if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() { if err := compressionFromJS(value, &options.Compression); err != nil { return nil, err } } fontSources := []fs.FS{ofdgo.NewFontFS(fonts)} if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 { fontSources = append(fontSources, ofdgo.NewLazyFontFS(stringsFromJS(names), func(name string) ([]byte, error) { return loadConversionFont(args[8], name) })) } options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(fontSources...)) if value := settings.Get("credentials"); !value.IsNull() && !value.IsUndefined() { credentials, err := credentialsFromJS(value) if err != nil { return nil, err } defer clear(credentials.Password) options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) options.PDF.Password = credentials.Password options.PDF.PasswordUTF8 = true } var format ofdgo.OutputFormat for _, item := range ofdgo.OutputFormats() { if item.Value == options.Format { format = item } } writer := bufio.NewWriterSize(exportWriter{write: args[3]}, 1<<20) if format.Paged { options.PageOutput = func(index int, export func(io.Writer) error) error { name := fmt.Sprintf("%06d.%s", index+1, format.Extension) if err := awaitExport(args[5], "open", name); err != nil { return err } if err := export(writer); err != nil { return err } if err := writer.Flush(); err != nil { return err } return awaitExport(args[5], "close", name) } } else if err := awaitExport(args[5], "open", "document."+format.Extension); err != nil { return nil, err } source := &browserReaderAt{read: args[0], checkpoint: args[6], size: int64(args[1].Float())} report, err := ofdgo.Convert(newOutputContext(args[6]), source, source.size, writer, options) if err != nil { return nil, err } if report.Unchanged && report.Bytes == 0 { return report, nil } if err := writer.Flush(); err != nil { return nil, err } if !format.Paged { if err := awaitExport(args[5], "close", "document."+format.Extension); err != nil { return nil, err } } return report, nil } // loadConversionFont 按需读取浏览器已授权字体,不预读系统字体目录 // 入参: load 浏览器读取函数, name 字体文件名 // 返回: []byte 字体内容, error 读取错误或取消原因 func loadConversionFont(load js.Value, name string) ([]byte, error) { done := make(chan error, 1) var data []byte callback := js.FuncOf(func(this js.Value, args []js.Value) any { var err error if args[2].Bool() { err = context.Canceled } else if message := args[1].String(); message != "" { err = fmt.Errorf("%s", message) } else { data, err = bytesFromJS(args[0]) } done <- err return nil }) defer callback.Release() load.Invoke(name, callback) err := <-done return data, err } // packFiles 将已经成功转换的文件逐个打包,不重复转换或读取整个文件 // 入参: args 文件列表、分块读取、分块写出、进度及取消检查 // 返回: any 输出格式, error 打包错误 func packFiles(args []js.Value) (any, error) { if len(args) < 5 { return nil, fmt.Errorf("missing archive arguments") } writer := bufio.NewWriterSize(exportWriter{write: args[2]}, 1<<20) files := args[0] entries := make([]ofdgo.ArchiveFile, files.Length()) for i := 0; i < files.Length(); i++ { file := files.Index(i) name := file.Get("name").String() read := args[1].Invoke(i) source := &browserReaderAt{read: read, checkpoint: args[4], size: int64(file.Get("size").Float())} entries[i] = ofdgo.ArchiveFile{Name: name, Source: source, Size: source.size} } options := ofdgo.ArchiveOptions{OnProgress: func(completed, total int) error { if completed > 0 { entries[completed-1].Source.(*browserReaderAt).data = nil } return awaitExport(args[3], completed, total) }} if len(args) > 5 && !args[5].IsUndefined() && !args[5].IsNull() { if err := compressionFromJS(args[5], &options.Compression); err != nil { return nil, err } } if _, err := ofdgo.WriteArchive(newOutputContext(args[4]), writer, entries, options); err != nil { return nil, err } if err := writer.Flush(); err != nil { return nil, err } return map[string]any{"mime": "application/zip"}, nil } // 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() options := OpenOptions{Fonts: fonts, RenderAnnotations: renderAnnotations} if len(args) > 3 && !args[3].IsNull() && !args[3].IsUndefined() { credentials, err := credentialsFromJS(args[3]) if err != nil { return nil, err } defer clear(credentials.Password) options.ReaderOptions = append(options.ReaderOptions, ofdgo.WithCredentials(credentials)) } session, err := Open(data, options) if err != nil { return nil, err } clearImport() currentEditor = nil copiedObjects = nil copiedStyle = nil if currentSession != nil { _ = currentSession.Close() currentSession = nil } 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 currentEditor != nil { currentEditor.SetFontFS() if currentSession.fontFS != nil { currentEditor.SetFontFS(currentSession.fontFS) } } } if currentSession.Renderer.RenderAnnotations != args[1].Bool() { currentSession.Renderer.RenderAnnotations = args[1].Bool() clear(currentSession.textCache) currentSession.textOrder = nil } return currentSession.Summary(), nil } // documentInfo 获取字体统计和验签结果,首个参数为true时分批扫描 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 func documentInfo(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } if len(args) > 0 && args[0].Bool() { if !currentSession.scanFontInfoBatch() { return map[string]bool{"detailsPending": true}, nil } return map[string]any{"fonts": currentSession.fontInfos, "fontCount": len(currentSession.fontInfos), "detailsPending": false}, nil } return currentSession.Details(), nil } // documentImages 读取当前文档的原始图片目录 // 入参: args 浏览器参数 // 返回: any 图片目录, error 读取错误 func documentImages(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } if len(args) != 0 { return currentSession.Reader.Image(context.Background(), args[0].String()) } return currentSession.Reader.Images(context.Background()) } // exportImages 分块保存单幅原始图片或图片归档 // 入参: args 图片标识和写出回调,空标识保存全部 // 返回: any 文件类型, error 读写错误 func exportImages(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) mime := "application/octet-stream" if id := args[0].String(); id != "" { input, err := currentSession.Reader.OpenImage(context.Background(), id) if err != nil { return nil, err } defer input.Close() if _, err := io.Copy(writer, input); err != nil { return nil, err } } else { mime = "application/zip" if err := currentSession.Reader.WriteImages(context.Background(), writer); err != nil { return nil, err } } if err := writer.Flush(); err != nil { return nil, err } return successResult(map[string]any{"mime": mime}), 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 页面索引、后端组合、DPI和光栅显示开关 // 返回: 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") } backend, dpi := "canvas", 150.0 if len(args) > 1 { backend = args[1].String() } if len(args) > 2 { dpi = args[2].Float() } raster := len(args) > 3 && args[3].Bool() if currentEditor != nil { page, err := currentEditor.Page(args[0].Int()) if err != nil { return nil, err } if err := syncEditorPageIDs(args[0].Int(), page); err != nil { return nil, err } } page, err := currentSession.RenderPage(args[0].Int(), backend, dpi, raster) if err != nil { return nil, err } if currentEditor != nil && !reflect.DeepEqual(currentEditor.Backends(), currentSession.Renderer.Backends()) { currentEditor.SetRenderBackends(currentSession.Renderer.Backends()) } 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)) attachmentNames := make(map[string]string) for _, link := range page.Links { if link.Attachment == "" { continue } attachments, err := currentSession.Reader.Attachments() if err != nil { return nil, err } for _, attachment := range attachments { attachmentNames[attachment.ID] = attachment.FileName() } break } for i, link := range page.Links { item := map[string]any{ "uri": link.URI, "path": link.Path, "group": link.Group, "x": link.Box.X, "y": link.Box.Y, "width": link.Box.W, "height": link.Box.H, } if link.Attachment != "" { item["attachment"] = link.Attachment item["fileName"] = attachmentNames[link.Attachment] } if dest := link.Dest; dest != nil { item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top, "right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom, "omitLeft": dest.OmitLeft, "omitTop": dest.OmitTop, "omitZoom": dest.OmitZoom} } links[i] = item } 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} } annotations, err := json.Marshal(page.Annotations) if err != nil { return nil, err } return successResult(map[string]any{ "annotations": string(annotations), "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") } session, writerIndex, err := exportSession(args) if err != nil { return nil, err } writer := bufio.NewWriterSize(exportWriter{write: args[writerIndex]}, 1<<20) format, err := session.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") } session, writerIndex, err := exportSession(args) if err != nil { return nil, err } renderer := session.Renderer renderer.OnExportProgress = func(completed, total int) error { return awaitExport(args[writerIndex+1], 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[writerIndex]}, 1<<20) format, err := session.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 } // exportSession 创建独立导出配置,写入回调前可指定光栅后端 // 入参: args 浏览器参数 // 返回: *Session 导出会话, int 写入回调位置, error 错误信息 func exportSession(args []js.Value) (*Session, int, error) { session := *currentSession renderer := *session.Renderer session.Renderer = &renderer writerIndex := 3 if args[3].Type() == js.TypeString { if err := session.SetRenderBackend(args[3].String()); err != nil { return nil, 0, err } writerIndex++ } if args[writerIndex].Type() == js.TypeObject { if err := compressionFromJS(args[writerIndex], &renderer.Compression); err != nil { return nil, 0, err } writerIndex++ if len(args) > writerIndex+1 { renderer.OutputContext = newOutputContext(args[len(args)-1]) } } return &session, writerIndex, 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") } ids := make([]string, 0, len(currentSession.fontInfos)) for _, info := range currentSession.fontInfos { if info.Used > 0 { ids = append(ids, info.ID) } } return currentSession.Reader.MatchFontFiles(stringsFromJS(args[0]), ids...) } // fontFaces 读取上传字体的名称和集合索引 // 入参: args 字体数据 // 返回: any 字体列表, error 错误信息 func fontFaces(args []js.Value) (any, error) { data, err := bytesFromJS(args[0]) if err != nil { return nil, err } return (ofdgo.FontFile{Data: data}).Faces() } // fontFace 提取编辑时选定的字体,供输入预览和保存共用 // 入参: args 字体数据和零起始索引 // 返回: any 独立字体数据, error 错误信息 func fontFace(args []js.Value) (any, error) { data, err := bytesFromJS(args[0]) if err != nil { return nil, err } data, err = (ofdgo.FontFile{Data: data}).Face(args[1].Int()) if err != nil { return nil, err } return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } // 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) } // 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, "修改文档可能使原签名失效") } info.PageCapabilities = make([]uint, currentEditor.PageCount()) for i := range info.PageCapabilities { capability, _ := currentEditor.PageCapabilities(i) info.PageCapabilities[i] = pageCapabilityMask(capability) } return info } // pageCapabilityMask 按插入、复制、删除、移动和尺寸的固定顺序编码页面能力,避免重复传输字段名 // 入参: capability 页面能力 // 返回: uint 能力位标记 func pageCapabilityMask(capability ofdgo.PageCapabilities) uint { var mask uint for i, enabled := range [...]bool{capability.Insert, capability.Copy, capability.Delete, capability.Move, capability.Resize} { if enabled { mask |= 1 << i } } return mask } // 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 } info, err := currentEditor.PageObjectInfo(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 case "CompositeObject", "CompositeGraphicUnit": id = object.CompositeGraphicUnit.ID default: continue } capability := info[id].Capabilities var box ofdgo.Box var contours []ofdgo.ObjectContour switch object.Type { case "TextObject": box = textBoxes[id] default: box, contours, err = currentSession.Renderer.ObjectGeometry(object, layer.DrawParam) } if err != nil { continue } if box.W > 0 && box.H > 0 { position := info[id].Position item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, "capabilities": editorCapabilities(capability, object.Type)} editorAppearance(item, object, capability.Paint, editorPathScale(object.PathObject)) if object.Type == "PathObject" { path := object.PathObject kind, geometry := path.Shape() if kind != "" && capability.Update { item["shape"] = string(kind) item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} } else if outline, err := currentSession.Renderer.PathOutline(path); err == nil { item["outline"] = outline } if kind == "" && capability.Update { if kind, box, matrix := path.ShapeFrame(); kind != "" { item["oriented"] = map[string]any{"kind": string(kind), "box": editorBox(box), "matrix": editorMatrix(matrix)} } } } if object.Type == "PathObject" || object.Type == "ImageObject" || object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { paths := make([]any, len(contours)) for i, contour := range contours { paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} } item["contours"] = paths } if object.Type == "ImageObject" && capability.Update { full, err := object.ImageObject.ImageBounds() if err != nil { return nil, err } item["imageBounds"] = editorBox(full) } if object.Type == "ImageObject" { item["imageResource"] = object.ImageObject.ResourceID } if object.Type == "TextObject" { item["bounds"] = editorBox(box) 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 item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent 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 { item["paragraphWidth"] = 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 } } objects = append(objects, item) } } } for _, annotation := range currentSession.Reader.Annots[page.ID] { if annotation.Visible != nil && !*annotation.Visible { continue } box, contours, err := currentSession.Renderer.AnnotationGeometry(annotation) capabilities := map[string]any{"update": true, "transform": true, "arrange": true, "copy": true, "delete": true, "enter": len(annotation.Appearance.Objects) != 0} if err != nil { capabilities["transform"], capabilities["arrange"], capabilities["enter"] = false, false, false capabilities["reason"], capabilities["reasonCode"] = err.Error(), "invalidObject" } if box.W <= 0 || box.H <= 0 { box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary) } paths := make([]any, len(contours)) for i, contour := range contours { paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} } alpha, mixed := 255, false for i, object := range annotation.Appearance.Objects { var value *int switch object.Type { case "TextObject": value = object.TextObject.Alpha case "PathObject": value = object.PathObject.Alpha case "ImageObject": value = object.ImageObject.Alpha case "CompositeObject", "CompositeGraphicUnit": value = object.CompositeGraphicUnit.Alpha } current := 255 if value != nil { current = *value } if i == 0 { alpha = current } else if current != alpha { mixed = true } } item := map[string]any{"id": "annotation:" + annotation.ID, "type": "Annotation", "annotationType": annotation.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "remark": annotation.Remark, "creator": annotation.Creator, "alpha": alpha, "alphaMixed": mixed, "capabilities": capabilities} if len(annotation.Appearance.Objects) == 0 && strings.TrimSpace(annotation.Remark) != "" { item["note"] = true } else { item["contours"] = paths } objects = append(objects, item) } return objects, nil } // changeAnnotations 修改注解说明、平移或删除同页注解,复用会话历史与预览 // 入参: args 页面索引、注解标识数组、操作及参数 // 返回: any 编辑状态, error 错误信息 func changeAnnotations(args []js.Value) (any, error) { return changeObjects(func() error { ids := stringsFromJS(args[1]) for i := range ids { ids[i] = strings.TrimPrefix(ids[i], "annotation:") } switch args[2].String() { case "update": if len(ids) != 1 { return fmt.Errorf("select one annotation to update") } return currentEditor.UpdateAnnotation(args[0].Int(), ids[0], args[3].String(), args[4].String()) case "move": return currentEditor.MoveAnnotations(args[0].Int(), ids, args[3].Float(), args[4].Float()) case "delete": return currentEditor.DeleteAnnotations(args[0].Int(), ids) default: return fmt.Errorf("unsupported annotation action %q", args[2].String()) } }) } // readAnnotation 获取编辑面板所需的注解内容,不修改文件 // 入参: args 页面索引与注解标识 // 返回: any 面板数据, error 错误信息 func readAnnotation(args []js.Value) (any, error) { annotation, err := currentEditor.Annotation(args[0].Int(), strings.TrimPrefix(args[1].String(), "annotation:")) if err != nil { return nil, err } texts := make([]string, 0) visiting := make(map[string]bool) var collect func([]ofdgo.GraphicObject) collect = func(objects []ofdgo.GraphicObject) { for _, object := range objects { switch object.Type { case "TextObject": value := object.TextObject.Text() if !slices.Contains(texts, value) { texts = append(texts, value) } case "CompositeObject", "CompositeGraphicUnit": collect(object.CompositeGraphicUnit.Objects) id := object.CompositeGraphicUnit.ResourceID if unit := currentSession.Renderer.CompositeGraphicUnits[id]; unit != nil && !visiting[id] { visiting[id] = true collect(unit.Objects) delete(visiting, id) } } } } collect(annotation.Appearance.Objects) info := map[string]any{"texts": texts, "remark": annotation.Remark, "creator": annotation.Creator} if annotation.Type == "Link" { info["linkKind"] = "keep" link, err := currentEditor.AnnotationLink(args[0].Int(), annotation.ID) if err == nil && link.URI != nil { info["linkKind"], info["linkURI"], info["linkBase"] = "uri", link.URI.URI, link.URI.Base } else if err == nil && link.Goto != nil && link.Goto.Dest != nil { for i, page := range currentSession.doc.Pages.Page { if page.ID == link.Goto.Dest.PageID { info["linkKind"], info["linkPage"] = "page", i+1 break } } } } return info, nil } // writeAnnotation 原子创建多页注解或修改现有链接、重复文字 // 入参: args 当前页面、JSON选项、字体字节、图片字节 // 返回: any 文档信息及新选区, error 错误信息 func writeAnnotation(args []js.Value) (any, error) { var options annotationOptions if err := json.Unmarshal([]byte(args[1].String()), &options); err != nil { return nil, err } if options.Kind == "link" && options.ID != "" && options.Keep { return editorSummary(), nil } page := args[0].Int() var ids []string result, err := changeObjects(func() error { font, resource := "", "" if !args[2].IsNull() { data, err := bytesFromJS(args[2]) if err != nil { return err } font, err = currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { return err } } if !args[3].IsNull() { data, err := bytesFromJS(args[3]) if err != nil { return err } resource, err = currentEditor.AddImage(data) if err != nil { return err } config, _, err := image.DecodeConfig(bytes.NewReader(data)) if err != nil { return err } options.Height = options.Width * float64(config.Height) / float64(config.Width) } if options.Kind == "replace" { style := ofdgo.TextStyle{Font: font} _, err := currentEditor.ReplaceAnnotationText(page, options.ID, options.Old, options.Text, style) return err } target := ofdgo.AnnotationLink{URI: &ofdgo.URI{URI: options.URI, Base: options.Base}} if options.Target > 0 { if options.Target > currentEditor.PageCount() { return fmt.Errorf("destination page is out of range") } page, err := currentEditor.Page(options.Target - 1) if err != nil { return err } target = ofdgo.AnnotationLink{Dest: &ofdgo.Dest{Type: "Fit", PageID: page.ID}} } if options.Kind == "link" && options.ID != "" { return currentEditor.UpdateAnnotationLink(page, options.ID, target) } if options.Width <= 0 || options.Height <= 0 || options.Alpha < 0 || options.Alpha > 255 { return fmt.Errorf("invalid annotation dimensions or opacity") } pages, err := ofdgo.ParsePageRange(options.Pages, currentEditor.PageCount()) if err != nil { return err } if options.Kind == "watermark" { boundary := fmt.Sprintf("0 0 %g %g", options.Width, options.Height) var object ofdgo.GraphicObject if resource != "" { object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: boundary, Alpha: &options.Alpha}} } else { text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: boundary, Alpha: &options.Alpha} if err := currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil { return err } if err := setTextColor(&text, options.Color); err != nil { return err } object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text} } added, err := currentEditor.AddWatermarks(context.Background(), pages, object, ofdgo.WatermarkOptions{Creator: options.Creator, Angle: options.Angle, Tile: options.Tile, Area: options.Area}) if err != nil { return err } for i, index := range pages { if index == page { ids = append(ids, "annotation:"+added[i]) } } return nil } for _, index := range pages { box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height} var id string if options.Kind == "link" { id, err = currentEditor.AddLinkAnnotation(index, box, target, options.Creator) } else { annotation := ofdgo.Annotation{Creator: options.Creator, Appearance: ofdgo.Appearance{Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)}} switch options.Kind { case "note": annotation.Type, annotation.Remark = "Path", options.Text case "stamp": annotation.Type = "Stamp" if resource == "" { return fmt.Errorf("stamp requires an image") } object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}} angle := options.Angle * math.Pi / 180 matrix := ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle))) matrix = ofdgo.TranslationMatrix(box.W/2, box.H/2).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-box.W/2, -box.H/2)) object.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(object.ImageObject.CTM)).String() annotation.Appearance.Objects = []ofdgo.GraphicObject{object} default: return fmt.Errorf("unsupported annotation kind %q", options.Kind) } id, err = currentEditor.AddAnnotation(index, annotation) } if err != nil { return err } if index == page { ids = append(ids, "annotation:"+id) } } return nil }) if err != nil { return nil, err } return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil } // insertInk 将一笔采样写为一个标准矢量对象,复用内部插入与撤销逻辑 // 入参: args 页面、JSON采样、线宽、压感开关、颜色及可选父路径 // 返回: any 文档和选区, error 错误信息 func insertInk(args []js.Value) (any, error) { var points []ofdgo.InkPoint if err := json.Unmarshal([]byte(args[1].String()), &points); err != nil { return nil, err } path, err := ofdgo.NewInk(points, args[2].Float(), args[3].Bool()) if err != nil { return nil, err } if path.Fill != nil && *path.Fill { err = setEditorColor(path.FillColor, args[4].String()) } else { err = setEditorColor((*ofdgo.FillColor)(path.StrokeColor), args[4].String()) } if err != nil { return nil, err } return insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[5:]) } // splitEditorSelection 区分页面对象与注解标识,不以外观大小或类型猜测 // 入参: ids 画布选区 // 返回: []string 正文标识, []string 注解标识 func splitEditorSelection(ids []string) ([]string, []string) { var objects, annotations []string for _, id := range ids { if value, ok := strings.CutPrefix(id, "annotation:"); ok { annotations = append(annotations, value) } else { objects = append(objects, id) } } return objects, annotations } // changeSelection 原子处理正文和注解混选,保持绘制顺序及统一历史 // 入参: args 页面索引、标识列表、操作与参数 // 返回: any 文档与新选区, error 错误信息 func changeSelection(args []js.Value) (any, error) { var selected []string result, err := changeObjects(func() error { page, operation := args[0].Int(), args[2].String() objects, annotations := splitEditorSelection(stringsFromJS(args[1])) switch operation { case "align", "distribute": return arrangeEditorSelection(page, stringsFromJS(args[1]), operation, args[3].String()) case "delete": if err := currentEditor.DeleteObjects(page, objects); err != nil { return err } return currentEditor.DeleteAnnotations(page, annotations) case "copy": clipboard, err := captureEditorSelection(page, stringsFromJS(args[1])) if err != nil { return err } selected, err = pasteEditorSelection(page, clipboard, args[3].Float(), args[4].Float()) return err case "style": style := objectStyle(args[3]) if err := currentEditor.StyleObjects(page, objects, style); err != nil { return err } for _, id := range annotations { path := ofdgo.ObjectPath{Annotation: id} members, err := currentEditor.CompositeObjects(page, path) if err != nil { return err } indexes := make([]int, len(members)) for i := range indexes { indexes[i] = i } if err := currentEditor.StyleCompositeObjects(page, path, indexes, style); err != nil { return err } } return nil case "erase", "erasePath": var box ofdgo.Box var points []ofdgo.Point if operation == "erase" { box = ofdgo.Box{X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float()} if err := currentEditor.EraseObjects(page, objects, box); err != nil { return err } } else { points = pointsFromJS(args[3]) if err := currentEditor.EraseObjectsPath(page, objects, points); err != nil { return err } } for _, id := range annotations { path := ofdgo.ObjectPath{Annotation: id} members, err := currentEditor.CompositeObjects(page, path) if err != nil { return err } indexes := make([]int, len(members)) for i := range indexes { indexes[i] = i } if operation == "erase" { err = currentEditor.EraseCompositeObjects(page, path, indexes, box) } else { err = currentEditor.EraseCompositeObjectsPath(page, path, indexes, points) } if err != nil { return err } } return nil } matrix := ofdgo.IdentityMatrix if operation == "transform" { dx, dy, scale := args[3].Float(), args[4].Float(), args[5].Float() if scale <= 0 { return fmt.Errorf("scale must be positive") } matrix = ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", scale, scale, dx, dy)) } else { bounds, err := editorSelectionBounds(page, objects, annotations) if err != nil { return err } x, y := bounds.X+bounds.W/2, bounds.Y+bounds.H/2 switch operation { case "rotate": angle := float64(args[3].Int()) * math.Pi / 180 matrix = ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle))) case "flip": if args[3].String() == "horizontal" { matrix = ofdgo.NewMatrix("-1 0 0 1 0 0") } else if args[3].String() == "vertical" { matrix = ofdgo.NewMatrix("1 0 0 -1 0 0") } else { return fmt.Errorf("invalid flip axis") } case "resize": sx, sy := args[5].Float()/bounds.W, args[6].Float()/bounds.H if sx <= 0 || sy <= 0 { return fmt.Errorf("size must be positive") } matrix = ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", sx, sy, args[3].Float()-bounds.X*sx, args[4].Float()-bounds.Y*sy)) default: return fmt.Errorf("unsupported mixed selection operation %q", operation) } if operation != "resize" { matrix = ofdgo.TranslationMatrix(x, y).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-x, -y)) } } if err := currentEditor.TransformObjectsMatrix(page, objects, matrix); err != nil { return err } return currentEditor.TransformAnnotations(page, annotations, matrix) }) if err != nil || selected == nil { return result, err } return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil } // arrangeEditorSelection 对正文和注解统一对齐或分布,不改变各自绘制层级 // 入参: page 页面, ids 标识, operation 操作, direction 方向 // 返回: error 错误信息 func arrangeEditorSelection(page int, ids []string, operation, direction string) error { objects, annotations := splitEditorSelection(ids) switch operation { case "align": return currentEditor.AlignSelection(page, objects, annotations, direction) case "distribute": return currentEditor.DistributeSelection(page, objects, annotations, direction) default: return fmt.Errorf("invalid arrangement operation %q", operation) } } // editorSelectionBounds 计算混合选区的实际内容范围 // 入参: page 页面索引, objects 正文标识, annotations 注解标识 // 返回: ofdgo.Box 页面毫米范围, error 错误信息 func editorSelectionBounds(page int, objects, annotations []string) (ofdgo.Box, error) { return currentEditor.SelectionBounds(page, objects, annotations) } // captureEditorSelection 捕获正文与注解的独立副本 // 入参: page 页面索引, ids 画布标识 // 返回: *editorClipboard 快照, error 错误信息 func captureEditorSelection(page int, ids []string) (*editorClipboard, error) { objects, annotations := splitEditorSelection(ids) result := &editorClipboard{} var err error result.objects, err = currentEditor.Objects(page, objects) if err == nil && len(annotations) != 0 { result.annotations, err = currentEditor.CaptureAnnotations(page, annotations) } return result, err } // pasteEditorSelection 原子粘贴正文和注解,共享字体与图片数据 // 入参: page 目标页, clipboard 快照, dx、dy 位移 // 返回: []string 新画布标识, error 错误信息 func pasteEditorSelection(page int, clipboard *editorClipboard, dx, dy float64) ([]string, error) { var ids []string err := currentEditor.Transaction(func(edit *ofdgo.Editor) error { var err error ids, err = edit.CopyObjects(page, clipboard.objects, dx, dy) if err != nil { return err } if clipboard.annotations != nil { annotations, err := edit.PasteAnnotations(page, clipboard.annotations, dx, dy) if err != nil { return err } for _, id := range annotations { ids = append(ids, "annotation:"+id) } } return nil }) return ids, err } // editorCapabilities 统一顶层与内部对象的操作能力和受限原因 // 入参: capability 库层能力, kind 对象类型 // 返回: map[string]any 前端能力 func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { result := map[string]any{"update": capability.Update, "paint": capability.Paint, "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, "reasonCode": string(capability.ReasonCode), "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} result["textContent"] = capability.TextContent result["rewriteText"] = capability.RewriteText if missing := capability.MissingGlyphs; missing != nil { result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } return result } // editorAppearance 提供对象自身透明度及有效绘制外观,不混入父对象透明度 // 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率 func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) { links := 0 for _, action := range object.Actions() { if action.Event == "CLICK" && (action.URI != nil || action.Goto != nil) { links++ if data, err := json.Marshal(action); err == nil { item["link"] = string(data) } } } item["linkCount"] = links var alpha *int var fill, stroke *ofdgo.FillColor switch object.Type { case "TextObject": fill = object.TextObject.FillColor stroke = (*ofdgo.FillColor)(object.TextObject.StrokeColor) alpha = object.TextObject.Alpha if paint { item["color"] = editorColorHex(object.TextObject.FillColor) } case "ImageObject": alpha = object.ImageObject.Alpha item["imageBorder"] = object.ImageObject.Border != nil item["borderColor"] = "#000000" if border := object.ImageObject.Border; border != nil { item["borderColor"] = editorColorHex((*ofdgo.FillColor)(border.BorderColor)) } if data, err := json.Marshal(object.ImageObject.Border); err == nil { item["borderStyle"] = string(data) } case "PathObject": path := object.PathObject fill = path.FillColor stroke = (*ofdgo.FillColor)(path.StrokeColor) if box, err := ofdgo.ParseBox(path.Boundary); err == nil { item["paintSize"] = []any{box.W, box.H} } alpha = path.Alpha item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0 if path.DashOffset != nil { item["dashOffset"] = *path.DashOffset } if paint { item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke item["fillColor"], item["strokeColor"] = editorColorHex(path.FillColor), editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) item["lineWidth"] = path.LineWidth * scale } case "CompositeObject", "CompositeGraphicUnit": alpha = object.CompositeGraphicUnit.Alpha } item["alpha"] = 255 if alpha != nil { item["alpha"] = *alpha } for key, paint := range map[string]*ofdgo.FillColor{"fill": fill, "stroke": stroke} { if paint == nil { continue } if p := paint.Pattern; p != nil { item[key+"Pattern"] = map[string]any{"width": p.Width, "height": p.Height, "xStep": p.XStep, "yStep": p.YStep, "ctm": p.CTM} } var segments []ofdgo.ShdSegment if paint.AxialShd != nil { segments = paint.AxialShd.Segment } else if paint.RadialShd != nil { segments = paint.RadialShd.Segment } if len(segments) == 0 { continue } stops, err := currentEditor.GradientStops(segments, nil) if err != nil { continue } values := make([]any, len(stops)) for i, stop := range stops { c := segments[i].Color values[i] = map[string]any{"position": stop.Offset * 100, "color": editorColorHex(&ofdgo.FillColor{Value: c.Value, Index: c.Index, ColorSpace: c.ColorSpace}), "transparency": (1 - float64(stop.Color.A)/255) * 100} } if data, err := json.Marshal(paint); err == nil { item[key+"Gradient"] = map[string]any{"paint": string(data), "stops": values} } } } // compositePath 解析画布复合对象路径 // 入参: value 顶层标识与逐层序号 // 返回: ofdgo.ObjectPath 库层路径, error 错误信息 func compositePath(value string) (ofdgo.ObjectPath, error) { parts := strings.Split(value, "/") path := ofdgo.ObjectPath{ID: parts[0]} if id, ok := strings.CutPrefix(path.ID, "annotation:"); ok { path.ID, path.Annotation = "", id } for _, part := range parts[1:] { index, err := strconv.Atoi(part) if err != nil || index < 0 { return path, fmt.Errorf("invalid composite path") } path.Children = append(path.Children, index) } return path, nil } // compositeObjects 获取内部编辑范围,不重新渲染页面或传输图片 // 入参: args 页面索引与复合路径 // 返回: any 画布对象列表, error 错误信息 func compositeObjects(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } key := args[1].String() path, err := compositePath(key) if err != nil { return nil, err } members, err := currentEditor.CompositeObjects(args[0].Int(), path) if err != nil { return nil, err } 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 } objects := make([]any, 0, len(members)) for i, member := range members { box := member.Bounds if box.W <= 0 || box.H <= 0 { continue } item := map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": member.Position.Index, "count": member.Position.Count, "container": key + ":" + member.Position.Container, "scoped": true, "contours": member.Contours, "capabilities": editorCapabilities(member.Capabilities, member.Object.Type)} editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) if member.Object.Type == "ImageObject" { item["imageResource"] = member.Object.ImageObject.ResourceID } if kind, geometry := member.Shape(); kind != "" { item["shape"], item["geometry"] = string(kind), editorBox(geometry) } else if kind, box, matrix := member.ShapeFrame(); kind != "" { item["oriented"] = map[string]any{"kind": string(kind), "box": editorBox(box), "matrix": editorMatrix(matrix)} } if member.Object.Type == "TextObject" { object := member.Object.TextObject value, layout := object.TextLayout() item["text"] = value item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight item["letterSpacing"] = layout.LetterSpacing item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent item["font"], item["fontName"], item["size"] = object.Font, fontNames[object.Font], object.Size item["bounds"] = editorBox(box) if member.Capabilities.Reflow { inverse, _ := member.Matrix.Invert() local := inverse.TransformBox(box) width := math.Max(object.Size, local.X+local.W) if layout.Wrap { frame, err := object.TextFrame() if err != nil { return nil, err } item["paragraphWidth"] = frame.W } frame := ofdgo.Box{W: width, H: math.Max(object.Size, local.Y+local.H)} item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(member.Matrix)} if layout.Wrap { bounds := member.Matrix.TransformBox(frame) item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H } } } if member.Capabilities.CropImage { item["imageBounds"] = editorBox(box) item["cropped"] = member.Cropped } objects = append(objects, item) } return objects, nil } // changeCompositeObjects 提交内部选区操作,结构变化后返回新的选区路径 // 入参: args 页面索引、父路径、成员路径列表、操作及参数 // 返回: any 文档信息, error 错误信息 func changeCompositeObjects(args []js.Value) (any, error) { var selected []int result, err := changeObjects(func() error { key := args[1].String() path, err := compositePath(key) if err != nil { return err } indexes, err := compositeIndexes(key, stringsFromJS(args[2])) if err != nil { return err } page := args[0].Int() operation := args[3].String() switch operation { case "group": if args[4].Bool() { if len(indexes) != 1 { return fmt.Errorf("ungrouping requires one member") } selected, err = currentEditor.UngroupCompositeObject(page, path, indexes[0]) } else { var index int index, err = currentEditor.GroupCompositeObjects(page, path, indexes) selected = []int{index} } return err case "pasteStyle": if copiedStyle == nil { return fmt.Errorf("style clipboard is empty") } return currentEditor.CopyCompositeStyle(page, path, indexes, *copiedStyle) case "reshape", "line": if len(indexes) != 1 { return fmt.Errorf("shape editing requires one member") } box := ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()} if operation == "line" { return currentEditor.ReshapeCompositeLine(page, path, indexes[0], ofdgo.ShapeKind(args[8].String()), box) } if len(args) > 8 && args[8].Bool() { return currentEditor.ReshapeCompositeFrame(page, path, indexes[0], box) } return currentEditor.ReshapeCompositeObject(page, path, indexes[0], box) case "erase": return currentEditor.EraseCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) case "erasePath": return currentEditor.EraseCompositeObjectsPath(page, path, indexes, pointsFromJS(args[4])) case "delete": return currentEditor.DeleteCompositeObjects(page, path, indexes) case "copy": selected, err = currentEditor.CopyCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float()) return err case "order": selected, err = currentEditor.OrderCompositeObjects(page, path, indexes, args[4].String()) return err case "text", "textStyle": return changeCompositeText(page, path, indexes, operation, args[4:]) case "layout": if len(indexes) != 1 { return fmt.Errorf("text layout requires one member") } layout := ofdgo.TextLayout{AutoBidi: true, Wrap: args[6].Bool(), Align: args[7].String(), LineHeight: args[8].Float(), LetterSpacing: args[9].Float(), LeftIndent: args[10].Float(), RightIndent: args[11].Float(), FirstLineIndent: args[12].Float()} if !args[4].IsNull() { return currentEditor.ResizeCompositeTextFrame(page, path, indexes[0], args[4].Float(), args[5].Float(), layout) } member, err := currentEditor.CompositeObject(page, path, indexes[0]) if err != nil { return err } value, _ := member.Object.TextObject.TextLayout() return currentEditor.LayoutCompositeText(page, path, indexes[0], value, layout) case "fit", "resetCrop": if len(indexes) != 1 { return fmt.Errorf("image operation requires one member") } if operation == "fit" { return currentEditor.FitCompositeImage(page, path, indexes[0], args[4].String()) } return currentEditor.ResetCompositeImageCrop(page, path, indexes[0]) case "crop": if len(indexes) != 1 { return fmt.Errorf("image crop requires one member") } return currentEditor.CropCompositeImage(page, path, indexes[0], ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) case "image": if len(indexes) != 1 { return fmt.Errorf("image replacement requires one member") } data, err := bytesFromJS(args[4]) if err != nil { return err } return currentEditor.ReplaceCompositeImage(page, path, indexes[0], data) case "transform": return currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float()) case "rotate": return currentEditor.RotateCompositeObjects(page, path, indexes, args[4].Int()) case "flip": return currentEditor.FlipCompositeObjects(page, path, indexes, args[4].String()) case "resize": return currentEditor.ResizeCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) case "align": return currentEditor.AlignCompositeObjects(page, path, indexes, args[4].String()) case "distribute": return currentEditor.DistributeCompositeObjects(page, path, indexes, args[4].String()) case "style": return currentEditor.StyleCompositeObjects(page, path, indexes, objectStyle(args[4])) case "paint", "textColor": style := ofdgo.ObjectStyle{} if operation == "textColor" { style.FillColor = &ofdgo.FillColor{} if err := setEditorColor(style.FillColor, args[4].String()); err != nil { return err } } else { if !args[4].IsNull() { value := args[4].Bool() style.Fill = &value } if !args[6].IsNull() { value := args[6].Bool() style.Stroke = &value } var colors [2]*ofdgo.FillColor for i := range colors { if !args[5+i*2].IsNull() { colors[i] = &ofdgo.FillColor{} if err := setEditorColor(colors[i], args[5+i*2].String()); err != nil { return err } } } style.FillColor, style.StrokeColor = colors[0], (*ofdgo.StrokeColor)(colors[1]) if !args[8].IsNull() { value := args[8].Float() style.LineWidth = &value } } return currentEditor.StyleCompositeObjects(page, path, indexes, style) default: return fmt.Errorf("unsupported composite operation") } }) if err != nil { return nil, err } if selected == nil { return result, nil } doc := editorSelectionInfo{editorInfo: result.(editorInfo)} for _, index := range selected { doc.SelectedIDs = append(doc.SelectedIDs, fmt.Sprintf("%s/%d", args[1].String(), index)) } return doc, nil } // compositeIndexes 将画布成员路径转换为同一范围的序号 // 入参: key 父路径, ids 成员路径 // 返回: []int 成员序号, error 错误信息 func compositeIndexes(key string, ids []string) ([]int, error) { indexes := make([]int, 0, len(ids)) for _, id := range ids { part, found := strings.CutPrefix(id, key+"/") if !found { return nil, fmt.Errorf("selection is outside composite") } i, err := strconv.Atoi(part) if err != nil || i < 0 { return nil, fmt.Errorf("invalid composite member") } indexes = append(indexes, i) } return indexes, nil } // changeCompositeText 修改内部文字,嵌入字体前校验选区用字 // 入参: page 页面索引, path 父路径, indexes 成员序号, operation 操作, args 内容及样式 // 返回: error 错误信息 func changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operation string, args []js.Value) error { var value *string if operation == "text" { if len(indexes) != 1 { return fmt.Errorf("text editing requires one member") } if !args[0].IsNull() { text := args[0].String() value = &text } args = args[1:] } style := ofdgo.TextStyle{Size: args[1].Float()} if !args[0].IsNull() { data, err := bytesFromJS(args[0]) if err != nil { return err } members, err := currentEditor.CompositeObjectsAt(page, path, indexes) if err != nil { return err } var text strings.Builder for _, member := range members { if !member.Capabilities.ReplaceFont { return fmt.Errorf("composite font replacement is not supported") } if value != nil { text.WriteString(*value) } else { text.WriteString(member.Object.TextObject.Text()) } } if err := checkFontGlyphs(data, text.String()); err != nil { return err } style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { return err } } if !args[2].IsNull() { var fill ofdgo.FillColor if err := setEditorColor(&fill, args[2].String()); err != nil { return err } style.Color = fill.Value } if value != nil { return currentEditor.UpdateCompositeText(page, path, indexes[0], *value, style) } return currentEditor.StyleCompositeText(page, path, indexes, style) } // editorFont 读取对象编辑使用的字体,匹配的外部字体不嵌入文档 // 入参: args 字体资源标识 // 返回: any 字体数据, error 错误信息 func editorFont(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } data, err := currentEditor.FontData(args[0].String()) if err != nil { return nil, err } return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } // previewText 生成保留原定位的输入预览,不修改文档或历史 // 入参: args 页码、对象路径、新文字、可选字体数据和字号 // 返回: any 定位段及字体样式, error 错误信息 func previewText(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } page, key := args[0].Int(), args[1].String() var object ofdgo.GraphicObject if separator := strings.LastIndex(key, "/"); separator >= 0 { path, err := compositePath(key[:separator]) if err != nil { return nil, err } member, err := strconv.Atoi(key[separator+1:]) if err != nil { return nil, err } members, err := currentEditor.CompositeObjectsAt(page, path, []int{member}) if err != nil { return nil, err } object = members[0].Object } else { var err error object, err = currentEditor.Object(page, key) if err != nil { return nil, err } } text := object.TextObject if object.Type != "TextObject" { return nil, fmt.Errorf("object is not text") } editor := currentEditor if len(args) > 3 && !args[3].IsNull() { data, err := bytesFromJS(args[3]) if err != nil { return nil, err } editor = ofdgo.NewEditor() text.Font, err = editor.AddFont(FontFile{Data: data}, 0) if err != nil { return nil, err } } if len(args) > 4 && args[4].Float() != text.Size { var x, y float64 if strings.Contains(key, "/") { x, _ = strconv.ParseFloat(text.TextCode[0].X, 64) y, _ = strconv.ParseFloat(text.TextCode[0].Y, 64) } text.Size = args[4].Float() _, layout := text.TextLayout() if err := editor.LayoutText(&text, args[2].String(), layout); err != nil { return nil, err } if strings.Contains(key, "/") { baseline, _ := strconv.ParseFloat(text.TextCode[0].Y, 64) for i := range text.TextCode { code := &text.TextCode[i] cx, _ := strconv.ParseFloat(code.X, 64) cy, _ := strconv.ParseFloat(code.Y, 64) code.X, code.Y = fmt.Sprint(cx+x), fmt.Sprint(cy+y-baseline) } } } else if err := editor.RewriteText(&text, args[2].String()); err != nil { return nil, err } var runs []any var x, y float64 value, offset := []rune(text.Text()), 0 for _, code := range text.TextCode { if code.X != "" { x, _ = strconv.ParseFloat(code.X, 64) } if code.Y != "" { y, _ = strconv.ParseFloat(code.Y, 64) } line := offset < len(value) && value[offset] == '\n' if line { offset++ } content := (ofdgo.TextObject{TextCode: []ofdgo.TextCode{code}}).Text() runs = append(runs, map[string]any{"text": content, "x": x, "y": y, "dx": code.GetDeltaX(), "dy": code.GetDeltaY(), "line": line}) offset += len([]rune(content)) } scale := text.HScale if scale == 0 { scale = 1 } return map[string]any{"runs": runs, "scale": scale, "weight": text.Weight, "italic": text.Italic}, nil } // previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录 // 入参: args 页面、对象标识和内容 // 返回: any SVG预览, error 错误信息 func previewPositionedText(args []js.Value) (any, error) { object, err := currentEditor.Object(args[0].Int(), args[1].String()) if err != nil { return nil, err } if object.Type != "TextObject" { return nil, fmt.Errorf("object is not text") } if err := currentEditor.RewriteText(&object.TextObject, args[2].String()); err != nil { return nil, err } measured, err := currentSession.Renderer.MeasureObject(object, ofdgo.MeasureOptions{}) if err != nil { return nil, err } box := measured.Bounds margin := object.TextObject.Size / 4 box.X, box.Y, box.W, box.H = box.X-margin, box.Y-margin, box.W+2*margin, box.H+2*margin boundary, err := ofdgo.ParseBox(object.TextObject.Boundary) if err != nil { return nil, err } object.TextObject.Boundary = fmt.Sprintf("%g %g %g %g", boundary.X-box.X, boundary.Y-box.Y, boundary.W, boundary.H) page := &ofdgo.PageContent{Area: ofdgo.PageArea{PhysicalBox: fmt.Sprintf("0 0 %g %g", box.W, box.H)}, Content: ofdgo.Content{Layer: []ofdgo.Layer{{Objects: []ofdgo.GraphicObject{object}}}}} var output bytes.Buffer if err := currentSession.Renderer.RenderToSVG(page, &output); err != nil { return nil, err } return output.String(), 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 } value := object.TextObject.Text() if !args[2].IsNull() { value = args[2].String() } if err := checkFontGlyphs(data, value); 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() { if args[4].Float() == object.TextObject.Size { var style ofdgo.TextStyle if !args[3].IsNull() { style.Font = object.TextObject.Font } if !args[5].IsNull() { var fill ofdgo.FillColor if err := setEditorColor(&fill, args[5].String()); err != nil { return nil, err } style.Color = fill.Value } revision := currentEditor.Revision() if err := currentEditor.UpdateTextContent(page, id, args[2].String(), style); err != nil { return nil, err } if currentEditor.Revision() == revision { return editorSummary(), nil } return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } 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) } // checkFontGlyphs 只检查候选字体,不注册资源或改变文档 // 入参: data 字体数据, value 待替换文字 // 返回: error 缺字或字体格式错误 func checkFontGlyphs(data []byte, value string) error { missing, err := (FontFile{Data: data}).MissingGlyphs(0, value) if err != nil { return err } if missing != "" { return &ofdgo.MissingGlyphError{Characters: missing} } return nil } // checkTextFont 为画布输入预检候选字体 // 入参: args 字体数据和输入文字 // 返回: any 空结果, error 缺字或字体格式错误 func checkTextFont(args []js.Value) (any, error) { data, err := bytesFromJS(args[0]) if err != nil { return nil, err } return nil, checkFontGlyphs(data, args[1].String()) } // styleText 批量修改文字样式,字体预检通过后交由库原子提交 // 入参: args 页码、对象标识、字体数据、字号和颜色,null字体与颜色保持原值 // 返回: any 文档信息, error 错误信息 func styleText(args []js.Value) (any, error) { return changeObjects(func() error { page, ids := args[0].Int(), stringsFromJS(args[1]) style := ofdgo.TextStyle{Size: args[3].Float()} if !args[2].IsNull() { data, err := bytesFromJS(args[2]) if err != nil { return err } var value strings.Builder for _, id := range ids { object, err := currentEditor.Object(page, id) if err != nil { return err } if object.Type != "TextObject" { return fmt.Errorf("object %q is not text", id) } value.WriteString(object.TextObject.Text()) } if err := checkFontGlyphs(data, value.String()); err != nil { return err } style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { return err } } if !args[4].IsNull() { var color ofdgo.FillColor if err := setEditorColor(&color, args[4].String()); err != nil { return err } style.Color = color.Value } return currentEditor.StyleText(page, ids, style) }) } // 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) } // 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{AutoBidi: true, Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), LeftIndent: args[12].Float(), RightIndent: args[13].Float(), FirstLineIndent: args[14].Float()}); err != nil { return nil, err } if err := setTextColor(&object.TextObject, args[6].String()); err != nil { return nil, err } return insertEditorObject(page, object, args[15:]) } // 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{AutoBidi: true, Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].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) } // 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") } box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()} if len(args) > 6 && args[6].Bool() { object.PathObject, err = object.PathObject.ReshapeFrame(box) } else { object.PathObject, err = object.PathObject.Reshape(box) } 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) } // reshapeLine 修改直线端点与箭头类型,复用库层样式保留逻辑 // 入参: args 页码、对象标识、页面端点及箭头类型 // 返回: any 文档信息, error 错误信息 func reshapeLine(args []js.Value) (any, error) { return changeObjects(func() error { page, id := args[0].Int(), args[1].String() object, err := currentEditor.Object(page, id) if err != nil { return err } if object.Type != "PathObject" { return fmt.Errorf("object is not a path") } object.PathObject, err = object.PathObject.ReshapeLine(ofdgo.ShapeKind(args[6].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) if err != nil { return err } return currentEditor.UpdateObject(page, id, object) }) } // 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") } clipboard := &editorClipboard{token: args[2].String()} var err error if len(args) > 3 && args[3].String() != "" { key := args[3].String() path, pathErr := compositePath(key) if pathErr != nil { return nil, pathErr } indexes, indexErr := compositeIndexes(key, stringsFromJS(args[1])) if indexErr != nil { return nil, indexErr } clipboard.composite, err = currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes) } else { clipboard, err = captureEditorSelection(args[0].Int(), stringsFromJS(args[1])) clipboard.token = args[2].String() } if err != nil { return nil, err } copiedObjects = clipboard 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") } key := "" if len(args) > 4 { key = args[4].String() } page, dx, dy := args[0].Int(), args[2].Float(), args[3].Float() if key != "" { if copiedObjects.annotations != nil { return nil, fmt.Errorf("annotations cannot be nested in a page object") } path, err := compositePath(key) if err != nil { return nil, err } var indexes []int if copiedObjects.composite != nil { indexes, err = currentEditor.PasteCompositeObjects(page, path, copiedObjects.composite, dx, dy) } else { indexes, err = currentEditor.CopyObjectsToComposite(page, path, copiedObjects.objects, dx, dy) } if err != nil { return nil, err } info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) result := editorSelectionInfo{editorInfo: info} for _, index := range indexes { result.SelectedIDs = append(result.SelectedIDs, fmt.Sprintf("%s/%d", key, index)) } return result, err } if copiedObjects.composite != nil { ids, err := currentEditor.PasteCompositeSelection(page, copiedObjects.composite, dx, dy) if err != nil { return nil, err } info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) return editorSelectionInfo{info, ids}, err } ids, err := pasteEditorSelection(page, copiedObjects, dx, dy) if err != nil { return nil, err } info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) return editorSelectionInfo{info, ids}, err } // 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 } // groupObjects 组合或解组并返回新的选区,复用单次历史提交 // 入参: args 页面索引、对象标识列表和是否解组 // 返回: any 文档及选区, error 错误信息 func groupObjects(args []js.Value) (any, error) { var ids []string info, err := changeObjects(func() error { page, selected := args[0].Int(), stringsFromJS(args[1]) var err error if args[2].Bool() { if len(selected) != 1 { return fmt.Errorf("ungrouping requires one object") } ids, err = currentEditor.UngroupObject(page, selected[0]) } else { var id string id, err = currentEditor.GroupObjects(page, selected) ids = []string{id} } return err }) if err != nil { return nil, err } return editorSelectionInfo{info.(editorInfo), ids}, nil } // 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 } // batchPages 按页码范围批量复制、删除、移动或调整页面尺寸 // 入参: args 操作、页码表达式及插入位置或宽高 // 返回: any 文档信息及目标页, error 错误信息 func batchPages(args []js.Value) (any, error) { index := 0 info, err := changeObjects(func() error { indexes, err := ofdgo.ParsePageRange(args[1].String(), currentEditor.PageCount()) if err != nil { return err } switch args[0].String() { case "copy": copies, err := currentEditor.CopyPages(indexes) if err == nil && len(copies) > 0 { index = copies[0] } return err case "delete": if len(indexes) == currentEditor.PageCount() { return fmt.Errorf("the document must contain at least one page") } if err := currentEditor.DeletePages(indexes); err != nil { return err } index = min(indexes[0], currentEditor.PageCount()-1) return nil case "move": at := args[2].Int() if at < 0 || at > currentEditor.PageCount() { return fmt.Errorf("page index %d out of range", at) } index = at for _, selected := range indexes { if selected < at { index-- } } return currentEditor.MovePages(indexes, index) case "resize": index = indexes[0] return currentEditor.ResizePages(indexes, args[2].Float(), args[3].Float()) default: return fmt.Errorf("unsupported page action %q", args[0].String()) } }) if err != nil { return nil, err } return editorPageInfo{info.(editorInfo), index}, nil } // indexesFromJS 读取整数索引列表 // 入参: value JavaScript数组 // 返回: []int 索引列表 func indexesFromJS(value js.Value) []int { indexes := make([]int, value.Length()) for i := range indexes { indexes[i] = value.Index(i).Int() } return indexes } // moveOutline 调整目录顺序或层级 // 入参: args 源路径、目标父路径及插入索引 // 返回: any 文档及新路径, error 错误信息 func moveOutline(args []js.Value) (any, error) { var path []int info, err := changeObjects(func() error { var err error path, err = currentEditor.MoveOutline(indexesFromJS(args[0]), indexesFromJS(args[1]), args[2].Int()) return err }) if err != nil { return nil, err } return editorOutlineInfo{info.(editorInfo), path}, nil } // captureStyle 保存单个对象样式来源,不改写文档 // 入参: args 页面索引、对象标识及可选内部范围 // 返回: any 空结果, error 错误信息 func captureStyle(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } if len(args) > 2 && args[2].String() != "" { key := args[2].String() path, err := compositePath(key) if err != nil { return nil, err } indexes, err := compositeIndexes(key, []string{args[1].String()}) if err != nil { return nil, err } member, err := currentEditor.CompositeObject(args[0].Int(), path, indexes[0]) if err != nil { return nil, err } object := member.Style() copiedStyle = &object return nil, nil } object, err := currentEditor.Object(args[0].Int(), args[1].String()) if err != nil { return nil, err } copiedStyle = &object return nil, nil } // pasteStyle 将保存的样式应用到同类型选区 // 入参: args 页面索引和对象标识列表 // 返回: any 文档信息, error 错误信息 func pasteStyle(args []js.Value) (any, error) { return changeObjects(func() error { if copiedStyle == nil { return fmt.Errorf("style clipboard is empty") } return currentEditor.CopyStyle(args[0].Int(), stringsFromJS(args[1]), *copiedStyle) }) } // resizeObjects 设置选区位置及尺寸 // 入参: args 页面索引、对象标识、横纵坐标及宽高 // 返回: any 文档信息, error 错误信息 func resizeObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.ResizeObjects(args[0].Int(), stringsFromJS(args[1]), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) }) } // changeOutline 新增、修改或删除层级目录 // 入参: args 操作、层级索引、标题及目标页面索引 // 返回: any 文档信息, error 错误信息 func changeOutline(args []js.Value) (any, error) { path := indexesFromJS(args[1]) info, err := changeObjects(func() error { switch args[0].String() { case "add": var err error path, err = currentEditor.AddOutline(path, args[2].String(), args[3].Int()) return err case "update": return currentEditor.UpdateOutline(path, args[2].String(), args[3].Int()) case "delete": err := currentEditor.DeleteOutline(path) path = nil return err default: return fmt.Errorf("unsupported outline action %q", args[0].String()) } }) if err != nil { return nil, err } return editorOutlineInfo{info.(editorInfo), path}, nil } // styleObjects 原子更新对象外观和图案布局 // 入参: args 页面索引、对象标识数组及样式,省略字段保持原值 // 返回: any 文档信息, error 错误信息 func styleObjects(args []js.Value) (any, error) { style := objectStyle(args[2]) linkField := args[2].Get("link") var link *ofdgo.AnnotationLink if !linkField.IsUndefined() && !linkField.IsNull() { if err := json.Unmarshal([]byte(linkField.String()), &link); err != nil { return nil, err } } var border *ofdgo.ImageBorderStyle if field := args[2].Get("borderStyle"); !field.IsUndefined() { if err := json.Unmarshal([]byte(field.String()), &border); err != nil { return nil, err } } patterns := make(map[bool]*ofdgo.PatternStyle) for key, stroke := range map[string]bool{"fillPatternStyle": false, "strokePatternStyle": true} { if field := args[2].Get(key); !field.IsUndefined() { var pattern ofdgo.PatternStyle if err := json.Unmarshal([]byte(field.String()), &pattern); err != nil { return nil, err } patterns[stroke] = &pattern } } if field := args[2].Get("fillPaint"); !field.IsUndefined() { if err := json.Unmarshal([]byte(field.String()), &style.FillColor); err != nil { return nil, err } } if field := args[2].Get("strokePaint"); !field.IsUndefined() { if err := json.Unmarshal([]byte(field.String()), &style.StrokeColor); err != nil { return nil, err } } return changeObjects(func() error { return currentEditor.Transaction(func(editor *ofdgo.Editor) error { page, ids := args[0].Int(), stringsFromJS(args[1]) if !linkField.IsUndefined() { if err := editor.SetObjectLink(page, ids, link); err != nil { return err } } if border != nil { if err := resolveStylePaint((*ofdgo.FillColor)(border.Color)); err != nil { return err } if err := editor.StyleImageBorders(page, ids, *border); err != nil { return err } } for _, paint := range []*ofdgo.FillColor{style.FillColor, (*ofdgo.FillColor)(style.StrokeColor)} { if err := resolveStylePaint(paint); err != nil { return err } } if err := editor.StyleObjects(page, ids, style); err != nil { return err } for stroke, pattern := range patterns { if err := editor.StylePatterns(page, ids, stroke, *pattern); err != nil { return err } } return nil }) }) } // resolveStylePaint 将渐变色标中的浏览器色值转换为文档颜色空间 // 入参: paint 颜色数据 // 返回: error 错误信息 func resolveStylePaint(paint *ofdgo.FillColor) error { if paint == nil { return nil } if strings.HasPrefix(paint.Value, "#") { return setEditorColor(paint, paint.Value) } var segments []ofdgo.ShdSegment if paint.AxialShd != nil { segments = paint.AxialShd.Segment } else if paint.RadialShd != nil { segments = paint.RadialShd.Segment } for i := range segments { stop := &segments[i].Color if strings.HasPrefix(stop.Value, "#") { color := ofdgo.FillColor{Alpha: stop.Alpha} if err := setEditorColor(&color, stop.Value); err != nil { return err } *stop = ofdgo.ShdColor{Value: color.Value, ColorSpace: color.ColorSpace, Alpha: color.Alpha} } } return nil } // objectStyle 读取高级面板提交的外观字段,省略字段保持原值 // 入参: value 前端样式 // 返回: ofdgo.ObjectStyle 库层样式 func objectStyle(value js.Value) ofdgo.ObjectStyle { style := ofdgo.ObjectStyle{} if field := value.Get("fill"); !field.IsUndefined() { v := field.Bool() style.Fill = &v } if field := value.Get("stroke"); !field.IsUndefined() { v := field.Bool() style.Stroke = &v } if field := value.Get("alpha"); !field.IsUndefined() { v := field.Int() style.Alpha = &v } if field := value.Get("dashPattern"); !field.IsUndefined() { v := field.String() style.DashPattern = &v } if field := value.Get("dashOffset"); !field.IsUndefined() { v := field.Float() style.DashOffset = &v } if field := value.Get("cap"); !field.IsUndefined() { v := field.String() style.Cap = &v } if field := value.Get("join"); !field.IsUndefined() { v := field.String() style.Join = &v } return style } // clearImport 释放待插页文档 func clearImport() { if pendingImport != nil { _ = pendingImport.Close() pendingImport = nil } } // loadImport 加载待插页文件的索引,null参数清除待处理文件 // 入参: args OFD字节数组或null // 返回: any 页数及签名提示, error 错误信息 func loadImport(args []js.Value) (any, error) { clearImport() if args[0].IsNull() { return nil, nil } data, err := bytesFromJS(args[0]) if err != nil { return nil, err } var options []ofdgo.ReaderOption if len(args) > 1 && !args[1].IsNull() && !args[1].IsUndefined() { credentials, err := credentialsFromJS(args[1]) if err != nil { return nil, err } defer clear(credentials.Password) options = append(options, ofdgo.WithCredentials(credentials)) } reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data)), options...) if err != nil { return nil, err } doc, err := reader.Doc() if err != nil { reader.Close() return nil, err } if len(doc.Pages.Page) == 0 { reader.Close() return nil, fmt.Errorf("document has no pages") } pendingImport = reader return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != "", "encrypted": reader.Encryption().Encrypted}, nil } // importPages 插入指定来源页面并保持目标文档元数据 // 入参: args 页码表达式,空值为全部、目标零基插入位置、是否导入对应目录和可选进度回调 // 返回: any 文档信息及首个插入页, error 错误信息 func importPages(args []js.Value) (any, error) { if currentEditor == nil || pendingImport == nil { return nil, fmt.Errorf("no document is ready for page import") } doc, err := pendingImport.Doc() if err != nil { return nil, err } var indexes []int if value := strings.TrimSpace(args[0].String()); value != "" { indexes, err = ofdgo.ParsePageRange(value, len(doc.Pages.Page)) if err != nil { return nil, err } } else { for i := range doc.Pages.Page { indexes = append(indexes, i) } } at := args[1].Int() options := ofdgo.PageImportOptions{Outlines: len(args) > 2 && args[2].Bool()} progressIndex := 3 if len(args) > 3 && args[3].Type() == js.TypeBoolean { options.AllowDecrypted = args[3].Bool() progressIndex = 4 } if len(args) > progressIndex { options.OnProgress = editorOperationProgress(args[progressIndex]) } if _, err := currentEditor.ImportPagesWithOptions(pendingImport, indexes, at, options); err != nil { return nil, err } clearImport() info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) return editorPageInfo{info, at}, err } // importImages 逐张读取图片并建页,负数位置表示新建文档 // 入参: args 文件名、选项、插入位置、标题、注解设置、读取与进度回调 // 返回: any 文档信息, error 错误信息 func importImages(args []js.Value) (any, error) { var options ofdgo.ImagePageOptions if err := json.Unmarshal([]byte(js.Global().Get("JSON").Call("stringify", args[1]).String()), &options); err != nil { return nil, err } at := args[2].Int() editor := currentEditor if at < 0 { editor = ofdgo.NewEditor() editor.Info.Title = args[3].String() } else if editor == nil { return nil, fmt.Errorf("document is not editable") } if args[0].Length() == 0 { return nil, fmt.Errorf("请选择图片") } sources := make([]ofdgo.ImagePageSource, args[0].Length()) for i := range sources { sources[i] = ofdgo.ImagePageSource{Name: args[0].Index(i).String(), Read: func(context.Context) ([]byte, error) { result := args[5].Invoke(i) if message := result.Get("error"); message.Type() == js.TypeString { return nil, fmt.Errorf("%s", message.String()) } return bytesFromJS(result.Get("bytes")) }} } options.OnProgress = editorOperationProgress(args[6]) position := max(at, 0) if err := editor.ImportImages(context.Background(), sources, position, options); err != nil { return nil, err } if at < 0 { editor.SetHistoryLimit(100) } clearImport() info, err := previewEditor(editor, args[4].Bool()) return editorPageInfo{info, position}, err } // createDocument 新建单页文档 // 入参: args 标题、纸张宽高和注解设置 // 返回: any 文档信息, error 错误信息 func createDocument(args []js.Value) (any, error) { clearImport() 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()) } // updateInfo 修改文档信息和自定义字段,保留其他元数据及创建程序标识 // 入参: args 标题、作者、主题、可选自定义字段JSON // 返回: any 编辑状态, error 错误信息 func updateInfo(args []js.Value) (any, error) { return changeObjects(func() error { info := currentEditor.Info info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String() if len(args) > 3 { var submitted []struct { ofdgo.CustomData Source *int } if err := json.Unmarshal([]byte(args[3].String()), &submitted); err != nil { return err } var previous []ofdgo.CustomData if info.CustomDatas != nil { previous = info.CustomDatas.CustomData } fields := make([]ofdgo.CustomData, len(submitted)) for i, field := range submitted { if field.Source != nil { if *field.Source < 0 || *field.Source >= len(previous) { return fmt.Errorf("invalid custom data source") } fields[i] = previous[*field.Source] } fields[i].Name, fields[i].Value = field.Name, field.Value } if !slices.Equal(previous, fields) { info.CustomDatas = &ofdgo.CustomDatas{CustomData: fields} } } currentEditor.SetInfo(info) return nil }) } // changeAttachment 编辑附件,逐个读取文件并复用库层事务和历史 // 入参: args 操作、附件标识、名称列表、文件读取回调 // 返回: any 编辑状态, error 错误信息 func changeAttachment(args []js.Value) (any, error) { return changeObjects(func() error { action, id := args[0].String(), args[1].String() switch action { case "rename": return currentEditor.RenameAttachment(id, args[2].Index(0).String()) case "delete": return currentEditor.DeleteAttachment(id) case "add", "replace": count := args[2].Length() if count == 0 || action == "replace" && count != 1 { return fmt.Errorf("请选择附件") } for i := range count { result := args[3].Invoke(i) if message := result.Get("error"); message.Type() == js.TypeString { return fmt.Errorf("%s", message.String()) } data, err := bytesFromJS(result.Get("bytes")) if err != nil { return err } if action == "replace" { return currentEditor.ReplaceAttachment(id, data) } if _, err := currentEditor.AddAttachment(args[2].Index(i).String(), data); err != nil { return err } } return nil default: return fmt.Errorf("unsupported attachment action %q", action) } }) } // 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) if currentSession.fontFS != nil { editor.SetFontFS(currentSession.fontFS) } editor.SetRenderBackends(currentSession.Renderer.Backends()) page := 0 if len(args) != 0 { page = args[0].Int() } content, err := editor.Page(page) if err != nil { return nil, err } if err := syncEditorPageIDs(page, content); err != nil { return nil, err } currentEditor = editor currentSession.editor = editor currentSession.editing = true copiedObjects = nil copiedStyle = nil return editorSummary(), nil } // syncEditorPageIDs 同步编辑副本的对象编号,不改变原渲染属性或丢弃正常页面缓存 // 入参: index 页面索引, content 编辑页面 // 返回: error 页面读取错误 func syncEditorPageIDs(index int, content *ofdgo.PageContent) error { page, err := currentSession.pageContent(index) if err != nil { return err } for i, layer := range content.Content.Layer { for j, object := range layer.Objects { before := &page.Content.Layer[i].Objects[j] if before.TextObject.ID != object.TextObject.ID || before.PathObject.ID != object.PathObject.ID || before.ImageObject.ID != object.ImageObject.ID || before.CompositeGraphicUnit.ID != object.CompositeGraphicUnit.ID { before.TextObject.ID = object.TextObject.ID before.PathObject.ID = object.PathObject.ID before.ImageObject.ID = object.ImageObject.ID before.CompositeGraphicUnit.ID = object.CompositeGraphicUnit.ID delete(currentSession.textCache, index) } } } return nil } // 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 } editor.SetFontFS() if currentSession != nil { ofdgo.WithRenderBackends(currentSession.Renderer.Backends())(session.Renderer) if currentSession.fontFS != nil { session.fontFS = currentSession.fontFS session.Renderer.SetFontFS(session.fontFS) editor.SetFontFS(session.fontFS) } _ = currentSession.Close() } editor.SetRenderBackends(session.Renderer.Backends()) currentSession = session session.editor = editor session.editing = true if currentEditor != editor { copiedObjects = nil copiedStyle = nil } currentEditor = editor return editorSummary(), nil } // setEditorColor 写入当前文档的RGB纯色,保留透明度并替换原颜色空间和索引 // 入参: fill 颜色, value 十六进制色值 // 返回: error 错误信息 func setEditorColor(fill *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 } converted, err := currentEditor.RGBColor(color.NRGBA{R: rgb[0], G: rgb[1], B: rgb[2], A: 255}) if err != nil { return err } converted.Alpha = fill.Alpha *fill = *converted return nil } // editorColorHex 将当前文档纯色转换为浏览器色值 // 入参: value 颜色 // 返回: string 十六进制色值 func editorColorHex(value *ofdgo.FillColor) string { converted, err := currentEditor.Color(value) if err != nil { return "" } return fmt.Sprintf("#%02x%02x%02x", converted.R, converted.G, converted.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 填充开关及色值、描边开关及色值、线宽,null保持原值 // 返回: error 错误信息 func setPathStyle(object *ofdgo.PathObject, args []js.Value) error { fill, stroke := object.Fill != nil && *object.Fill, object.Stroke == nil || *object.Stroke if !args[0].IsNull() { fill = args[0].Bool() } if !args[2].IsNull() { stroke = args[2].Bool() } if kind, _ := object.Shape(); kind == ofdgo.ShapeLine || kind == ofdgo.ShapeArrow || kind == ofdgo.ShapeDoubleArrow { fill, stroke = false, true } if !fill && !stroke { if args[0].IsNull() || args[2].IsNull() { stroke = true } else { return fmt.Errorf("fill or stroke must be enabled") } } if !args[0].IsNull() || fill != (object.Fill != nil && *object.Fill) { object.Fill = &fill } if !args[2].IsNull() || stroke != (object.Stroke == nil || *object.Stroke) { object.Stroke = &stroke } if !args[4].IsNull() { width := args[4].Float() if width < 0 || math.IsNaN(width) || math.IsInf(width, 0) { return fmt.Errorf("line width must be nonnegative and finite") } if scale := editorPathScale(*object); width != object.LineWidth*scale { object.LineWidth = width / scale } object.LineWidthSet = width == 0 } if !args[1].IsNull() { if object.FillColor == nil { object.FillColor = &ofdgo.FillColor{} } if err := setEditorColor(object.FillColor, args[1].String()); err != nil { return err } } if !args[3].IsNull() { if object.StrokeColor == nil { object.StrokeColor = &ofdgo.StrokeColor{} } if err := setEditorColor((*ofdgo.FillColor)(object.StrokeColor), args[3].String()); err != nil { return err } } return nil } // 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 } return insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[11:]) } // updatePathStyle 原子更新选中路径的绘制样式,保留未修改属性 // 入参: args 页码、对象标识列表和绘制样式 // 返回: any 文档信息, error 错误信息 func updatePathStyle(args []js.Value) (any, error) { return changeObjects(func() error { page, ids := args[0].Int(), stringsFromJS(args[1]) objects := make([]ofdgo.GraphicObject, len(ids)) for i, id := range ids { object, err := currentEditor.Object(page, id) if err != nil { return err } if object.Type != "PathObject" { return fmt.Errorf("object is not a path") } if err := setPathStyle(&object.PathObject, args[2:]); err != nil { return err } objects[i] = object } return currentEditor.UpdateObjects(page, objects) }) } // 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()}) }) } // resetImageCrop 还原跨范围复制图片的会话裁剪,不移除原始及父级裁剪 // 入参: args 页面索引及对象标识 // 返回: any 文档信息, error 错误信息 func resetImageCrop(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.ResetImageCrop(args[0].Int(), args[1].String()) }) } // 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])) }) } // eraseObjectsPath 按页面闭合折线范围擦除对象,保留局部裁剪之外的内容 // 入参: args 页面索引、对象标识数组和坐标点数组 // 返回: any 文档信息, error 错误信息 func eraseObjectsPath(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.EraseObjectsPath(args[0].Int(), stringsFromJS(args[1]), pointsFromJS(args[2])) }) } // pointsFromJS 读取前端页面坐标点数组 // 入参: value 坐标点数组 // 返回: []ofdgo.Point 页面毫米坐标 func pointsFromJS(value js.Value) []ofdgo.Point { points := make([]ofdgo.Point, value.Length()) for i := range points { point := value.Index(i) points[i] = ofdgo.Point{X: point.Get("x").Float(), Y: point.Get("y").Float()} } return points } // eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容 // 入参: args 页面索引、对象标识数组和毫米坐标范围 // 返回: any 文档信息, error 错误信息 func eraseObjects(args []js.Value) (any, error) { return changeObjects(func() error { return currentEditor.EraseObjects(args[0].Int(), stringsFromJS(args[1]), ofdgo.Box{ X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float(), }) }) } // 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 := currentEditor.Transaction(func(*ofdgo.Editor) error { return 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; 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), }} return insertEditorObject(args[0].Int(), object, args[5:]) } // insertEditorObject 复用顶层和内部范围插入流程,返回稳定选区 // 入参: page 页码, object 新对象, scope 可选父路径 // 返回: any 文档信息及新选区, error 错误信息 func insertEditorObject(page int, object ofdgo.GraphicObject, scope []js.Value) (any, error) { var id string var err error if len(scope) > 0 && scope[0].String() != "" { key := scope[0].String() path, parseErr := compositePath(key) if parseErr != nil { return nil, parseErr } var index int index, err = currentEditor.AddCompositeObject(page, path, object) id = fmt.Sprintf("%s/%d", key, index) } else { id, err = currentEditor.AddObject(page, object) } if err != nil { return nil, err } info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) return editorSelectionInfo{info, []string{id}}, err } // editorOperationProgress 节流长操作检查点并向浏览器让出执行,保证阶段开始和结束可取消 // 入参: callback 浏览器进度回调 // 返回: func(string, int, int) error 进度回调 func editorOperationProgress(callback js.Value) func(string, int, int) error { var lastStage string var lastProgress time.Time return func(stage string, completed, total int) error { if stage == lastStage && (total == 0 || completed != total) && time.Since(lastProgress) < 32*time.Millisecond { return nil } lastStage, lastProgress = stage, time.Now() return awaitExport(callback, stage, completed, total) } } // credentialsFromJS 解析本次打开的口令或本机证书私钥 // 入参: value 解锁表单数据 // 返回: ofdgo.Credentials 会话凭据, error 错误信息 func credentialsFromJS(value js.Value) (ofdgo.Credentials, error) { credentials := ofdgo.Credentials{UserName: value.Get("userName").String()} if keyValue := value.Get("key"); !keyValue.IsNull() && !keyValue.IsUndefined() { key, err := bytesFromJS(keyValue) if err != nil { return credentials, err } defer clear(key) certificate, err := bytesFromJS(value.Get("certificate")) if err != nil { return credentials, err } var password []byte if field := value.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { password, err = bytesFromJS(field) if err != nil { return credentials, err } defer clear(password) } signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) if err != nil { return credentials, ofdgo.ErrInvalidCredentials } decrypter, ok := signer.(crypto.Decrypter) if !ok { return credentials, ofdgo.ErrInvalidCredentials } credentials.Certificate, credentials.Decrypter = certificate, decrypter return credentials, nil } var err error credentials.Password, err = bytesFromJS(value.Get("password")) return credentials, err } // securityFilesFromJS 读取会话内的本机证据文件 // 入参: value 二进制数组列表 // 返回: [][]byte 文件内容, error 错误信息 func securityFilesFromJS(value js.Value) ([][]byte, error) { if value.IsUndefined() || value.IsNull() { return nil, nil } files := make([][]byte, value.Length()) for i := range files { var err error files[i], err = bytesFromJS(value.Index(i)) if err != nil { return nil, err } } return files, nil } // verifySignatures 按本机信任锚和离线证据重新验签,不设置联网回调 // 入参: args 信任根、辅助证书、时间戳及撤销证据 // 返回: any 分项报告, error 错误信息 func verifySignatures(args []js.Value) (any, error) { if currentSession == nil || len(args) == 0 { return nil, fmt.Errorf("missing signature verification arguments") } input := args[0] files := make(map[string][][]byte) for _, name := range []string{"roots", "certificates", "timestampRoots", "tokens", "crls", "ocsp"} { data, err := securityFilesFromJS(input.Get(name)) if err != nil { return nil, err } files[name] = data } now := time.Now() policy := ofdgo.SignaturePolicy{RejectWeakAlgorithms: true, RequireDocumentCoverage: true, RequireTimestamp: input.Get("requireTimestamp").Bool(), RequireRevocation: input.Get("requireRevocation").Bool()} options := []ofdgo.SignatureVerifyOption{ ofdgo.WithSignatureVerifyTime(now), ofdgo.WithSignaturePolicy(policy), ofdgo.WithSignatureTrustCerts(files["roots"]...), ofdgo.WithSignatureCerts(files["certificates"]...), } if policy.RequireTimestamp || len(files["timestampRoots"])+len(files["tokens"]) > 0 { options = append(options, ofdgo.WithSignatureTimestamp(ofdgo.SignatureTimestampOptions{ TrustCerts: files["timestampRoots"], Certificates: files["certificates"], Tokens: files["tokens"], Required: policy.RequireTimestamp, VerifyTime: &now, })) } if policy.RequireRevocation || len(files["crls"])+len(files["ocsp"]) > 0 { options = append(options, ofdgo.WithSignatureRevocation(ofdgo.SignatureRevocationOptions{ SignatureRevocationEvidence: ofdgo.SignatureRevocationEvidence{CRLs: files["crls"], OCSPResponses: files["ocsp"]}, Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now, })) } reports, err := currentSession.Reader.VerifySignatures(options...) if err != nil { return nil, err } infos := make([]SignatureInfo, len(reports)) for i, report := range reports { infos[i] = signatureInfo(report) } currentSession.signatures, currentSession.signatureError, currentSession.signaturesRead = infos, nil, true return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil } // setEncryption 设置当前文档的保存策略,不修改页面或撤销历史 // 入参: args 加密选项,null选择明文保存 // 返回: any 编辑状态, error 错误信息 func setEncryption(args []js.Value) (any, error) { if currentSession == nil || len(args) != 1 { return nil, fmt.Errorf("missing encryption arguments") } options, err := encryptionOptionsFromJS(args[0]) if err != nil { return nil, err } if options != nil { defer clear(options.Password) } if currentEditor == nil { if _, err := editDocument(nil); err != nil { return nil, err } } if err := currentEditor.SetEncryption(options); err != nil { return nil, err } return editorSummary(), nil } // encryptionOptionsFromJS 读取加密选项并校验接收者证书 // 入参: value 加密选项,null选择明文保存 // 返回: *ofdgo.EncryptionOptions 加密选项, error 错误信息 func encryptionOptionsFromJS(value js.Value) (*ofdgo.EncryptionOptions, error) { if value.IsNull() { return nil, nil } if value.Type() != js.TypeObject { return nil, ofdgo.ErrEncryptionPolicyRequired } var password []byte if field := value.Get("password"); !field.IsNull() && !field.IsUndefined() { var err error password, err = bytesFromJS(field) if err != nil { return nil, err } } options := &ofdgo.EncryptionOptions{Password: password} success := false defer func() { if !success { clear(password) } }() if user := value.Get("userName"); user.Type() == js.TypeString { options.UserName = user.String() } if recipients := value.Get("recipients"); !recipients.IsUndefined() && !recipients.IsNull() { for i := 0; i < recipients.Length(); i++ { item := recipients.Index(i) data, err := bytesFromJS(item.Get("certificate")) if err != nil { return nil, err } certificate, err := ofdgo.ParseEncryptionCertificate(data) if err != nil { return nil, fmt.Errorf("接收者证书无效或不支持SM2加密") } options.Recipients = append(options.Recipients, ofdgo.EncryptionRecipient{UserName: item.Get("userName").String(), Certificate: certificate}) } } if len(password) == 0 && len(options.Recipients) == 0 { return nil, ofdgo.ErrEncryptionPolicyRequired } success = true return options, nil } // previewSignatureSeal 渲染独立印章预览并读取所选页面区域,不接触私钥或修改文档 // 入参: args 印章数据和页码范围 // 返回: any 印章图像、声明尺寸和页面区域, error 错误信息 func previewSignatureSeal(args []js.Value) (any, error) { if currentSession == nil || len(args) < 2 { return nil, fmt.Errorf("missing seal preview arguments") } data, err := bytesFromJS(args[0]) if err != nil { return nil, err } seal, err := ofdgo.ParseSignatureSeal(data) if err != nil { return nil, err } indices, err := ofdgo.ParsePageRange(args[1].String(), len(currentSession.doc.Pages.Page)) if err != nil { return nil, err } pages := make([]map[string]any, 0, len(indices)) for _, index := range indices { area, err := currentSession.Reader.PageArea(currentSession.doc.Pages.Page[index]) if err != nil { return nil, err } box, err := ofdgo.ParseBox(area.PhysicalBox) if err != nil { return nil, err } pages = append(pages, map[string]any{"index": index, "x": box.X, "y": box.Y, "width": box.W, "height": box.H}) } editor := ofdgo.NewEditor() if _, err := editor.AddPage(seal.Width, seal.Height); err != nil { return nil, err } reader, err := editor.Reader() if err != nil { return nil, err } defer reader.Close() doc, err := reader.Doc() if err != nil { return nil, err } reader.Stamps = map[string][]ofdgo.Stamp{doc.Pages.Page[0].ID: {{Box: ofdgo.Box{W: seal.Width, H: seal.Height}, Type: seal.Type, Data: seal.Data}}} renderer := ofdgo.NewRenderer(reader, ofdgo.WithRenderBackends(currentSession.Renderer.Backends())) renderer.TransparentBackground = true if currentSession.fontFS != nil { renderer.SetFontFS(currentSession.fontFS) } page, err := reader.PageContentByIndex(0) if err != nil { return nil, err } var svg bytes.Buffer if err := renderer.RenderToSVG(page, &svg); err != nil { return nil, err } return map[string]any{"svg": svg.String(), "width": seal.Width, "height": seal.Height, "pages": pages}, nil } // signatureStampsFromJS 复用库层坐标校验生成页面或骑缝印章位置 // 入参: input 页面和毫米坐标参数 // 返回: []ofdgo.SignatureStamp 印章位置, error 校验错误 func signatureStampsFromJS(input js.Value) ([]ofdgo.SignatureStamp, error) { count, err := currentSession.Reader.PageCount() if err != nil { return nil, err } indices, err := ofdgo.ParsePageRange(input.Get("pages").String(), count) if err != nil { return nil, err } pages := make([]int, len(indices)) for i, index := range indices { pages[i] = index + 1 } box := ofdgo.Box{X: input.Get("x").Float(), Y: input.Get("y").Float(), W: input.Get("width").Float(), H: input.Get("height").Float()} switch input.Get("placement").String() { case "normal": return currentSession.Reader.SignaturePageStamps(pages, box) case "seam": return currentSession.Reader.SignatureSeamStamps(pages, box) default: return nil, fmt.Errorf("invalid stamp placement") } } // signaturePlacement 校验预览确认的印章范围,不生成签名 // 入参: args 页面和毫米坐标参数 // 返回: any 校验结果, error 错误信息 func signaturePlacement(args []js.Value) (any, error) { if currentSession == nil || len(args) == 0 { return nil, fmt.Errorf("missing stamp placement arguments") } _, err := signatureStampsFromJS(args[0]) return successResult(nil), err } // saveSigned 调用库层最终包签署并在回验成功后交付浏览器 // 入参: args 本机证书私钥、信任根、印章位置、写出回调及进度回调 // 返回: any 保存结果, error 错误信息 func saveSigned(args []js.Value) (any, error) { if currentSession == nil || len(args) < 3 { return nil, fmt.Errorf("missing signature arguments") } input := args[0] key, err := bytesFromJS(input.Get("key")) if err != nil { return nil, err } defer clear(key) certificate, err := bytesFromJS(input.Get("certificate")) if err != nil { return nil, err } var password []byte if field := input.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() { password, err = bytesFromJS(field) if err != nil { return nil, err } defer clear(password) } signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password) if err != nil { return nil, fmt.Errorf("证书、私钥或口令无效") } roots, err := securityFilesFromJS(input.Get("roots")) if err != nil { return nil, err } intermediates, err := securityFilesFromJS(input.Get("intermediates")) if err != nil { return nil, err } options := ofdgo.SignatureWriteOptions{Signer: signer, Certificate: certificate, TrustRoots: roots, Intermediates: intermediates, Lock: input.Get("lock").Bool()} if compression := input.Get("compression"); !compression.IsUndefined() && !compression.IsNull() { if err := compressionFromJS(compression, &options.Compression); err != nil { return nil, err } } switch input.Get("mode").String() { case "append": options.Mode = ofdgo.SignatureAppend case "replace": options.Mode = ofdgo.SignatureReplace default: return nil, fmt.Errorf("invalid signature mode") } if seal := input.Get("seal"); !seal.IsNull() && !seal.IsUndefined() { options.Seal, err = bytesFromJS(seal) if err != nil { return nil, err } options.Stamps, err = signatureStampsFromJS(input) if err != nil { return nil, err } } progress := editorOperationProgress(args[2]) if err := progress("sign", 0, 1); err != nil { return nil, err } var output bytes.Buffer if currentEditor != nil { currentEditor.OnWriteProgress = progress defer func() { currentEditor.OnWriteProgress = nil }() _, err = ofdgo.SignEditorTo(&output, currentEditor, options) } else { _, err = currentSession.Reader.SignTo(&output, options) } if err != nil { return nil, err } if err := progress("sign", 1, 1); err != nil { return nil, err } if _, err := (exportWriter{write: args[1]}).Write(output.Bytes()); err != nil { return nil, err } return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil } // saveDocument 分块写出当前编辑文档或指定页面 // 入参: args 数据写出回调、可选准备进度回调和可选页面索引 // 返回: any 保存结果, error 错误信息 func saveDocument(args []js.Value) (any, error) { if currentEditor == nil { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } editor, err := currentSession.Reader.Editor() if err != nil { return nil, err } return saveEditor(editor, args) } return saveEditor(currentEditor, args) } // saveEditor 统一分块写出及进度取消,加密策略由库层执行 // 入参: editor 待写出文档, args 写出回调、进度回调及可选页面索引、压缩配置、取消检查 // 返回: any 保存结果, error 错误信息 func saveEditor(editor *ofdgo.Editor, args []js.Value) (any, error) { if len(args) == 0 || args[0].Type() != js.TypeFunction { return nil, fmt.Errorf("missing output callback") } if len(args) > 1 { editor.OnWriteProgress = editorOperationProgress(args[1]) defer func() { editor.OnWriteProgress = nil }() } writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20) var err error var options ofdgo.WriteOptions ctx := context.Background() if len(args) > 3 { if err := compressionFromJS(args[3], &options.Compression); err != nil { return nil, err } } if len(args) > 4 { ctx = newOutputContext(args[4]) } if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() { _, err = editor.WritePagesToWithOptions(ctx, writer, indexesFromJS(args[2]), options) } else { _, err = editor.WriteToWithOptions(ctx, writer, options) } if 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 } // compressionFromJS 读取压缩配置并交给库层校验 // 入参: value 浏览器配置, options 目标配置 // 返回: error 配置错误 func compressionFromJS(value js.Value, options *ofdgo.CompressionOptions) error { if err := json.Unmarshal([]byte(js.Global().Get("JSON").Call("stringify", value).String()), options); err != nil { return err } return options.Validate() } // outputContext 定期让出浏览器线程,将取消请求传入库层压缩循环 type outputContext struct { context.Context cancel context.CancelFunc checkpoint js.Value checked time.Time } // newOutputContext 创建单次串行导出的取消上下文 // 入参: checkpoint 浏览器取消检查回调 // 返回: context.Context 导出上下文 func newOutputContext(checkpoint js.Value) context.Context { ctx, cancel := context.WithCancel(context.Background()) return &outputContext{Context: ctx, cancel: cancel, checkpoint: checkpoint} } // Err 在取消检查间隙处理浏览器消息,不为每个像素触发跨语言回调 // 返回: error 取消错误 func (c *outputContext) Err() error { if err := c.Context.Err(); err != nil { return err } if time.Since(c.checked) >= 50*time.Millisecond { c.checked = time.Now() if err := awaitExport(c.checkpoint); err != nil { c.cancel() } } return c.Context.Err() }