From 05080293872b78f7e4669843661c13f5b00403bb Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 24 Sep 2026 10:30:57 +0800 Subject: [PATCH] =?UTF-8?q?style(=E4=BB=A3=E7=A0=81=E9=A3=8E=E6=A0=BC):=20?= =?UTF-8?q?=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81=E5=BD=92=E5=B1=9E=E4=B8=8E?= =?UTF-8?q?=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 3 +- internal/webui/wasm.go | 820 ++++++++++++++++++------------------ internal/webui/webui.go | 382 ++++++++--------- ofdgo_backend.go | 86 +--- ofdgo_backend_registry.go | 71 ++++ ofdgo_editor.go | 36 +- ofdgo_editor_font.go | 200 +++------ ofdgo_editor_metadata.go | 149 +++++++ ofdgo_editor_package.go | 127 ------ ofdgo_editor_resources.go | 4 +- ofdgo_font_resource.go | 91 ---- ofdgo_font_resource_sfnt.go | 243 +++++++++++ ofdgo_renderer_canvas.go | 2 +- 13 files changed, 1135 insertions(+), 1079 deletions(-) create mode 100644 ofdgo_backend_registry.go create mode 100644 ofdgo_editor_metadata.go create mode 100644 ofdgo_font_resource_sfnt.go diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 279ca9b..95aa894 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -26,6 +26,7 @@ let wasmRequestID = 0; let wasmRecoveryTimer = 0; let textMeasure = null; let credentialRequest = null; +let cancelPageTouch = null; const state = { composite: null, @@ -5570,8 +5571,6 @@ el.finishPageSelection.addEventListener("click", () => { updateControls(); }); -let cancelPageTouch = null; - el.pageList.addEventListener("pointerdown", event => { cancelPageTouch?.(); state.pageTouchPointer = null; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 77c56c8..50852a0 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -45,6 +45,79 @@ type exportWriter struct { write js.Value } +// 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 +} + +// currentSession 当前WebUI文档会话 +var currentSession *Session + +// currentEditor 当前编辑文档 +var currentEditor *ofdgo.Editor + +// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话 +var pendingImport *ofdgo.Reader + +// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 +var copiedObjects *editorClipboard + +// copiedStyle 当前文档中的独立样式快照 +var copiedStyle *ofdgo.GraphicObject + // Write 将数据块交给浏览器保存 // 入参: data 导出数据 // 返回: int 写入长度, error 错误信息 @@ -80,25 +153,6 @@ func awaitExport(fn js.Value, args ...any) error { return <-done } -// currentSession 当前WebUI文档会话 -var currentSession *Session - -// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话 -var pendingImport *ofdgo.Reader - -// copiedStyle 当前文档中的独立样式快照 -var copiedStyle *ofdgo.GraphicObject - -// 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"` -} - // RunWASM 注册浏览器WASM接口并阻塞运行 func RunWASM() { registerCallback("ofdgoOpen", openDocument) @@ -297,45 +351,6 @@ func openDocument(args []js.Value) (any, error) { return currentSession.Summary(), nil } -// 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 -} - // configureDocument 更新当前会话的字体和注解设置 // 入参: args 浏览器参数 // 返回: any 文档信息, error 错误信息 @@ -378,70 +393,6 @@ func documentInfo(args []js.Value) (any, error) { return currentSession.Info(), nil } -// 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 -} - // exportAttachment 分块导出附件 // 入参: args 浏览器参数 // 返回: any 附件数据, error 错误信息 @@ -820,42 +771,6 @@ func encodeResult(result apiResult) string { return string(data) } -// currentEditor 当前编辑文档 -var currentEditor *ofdgo.Editor - -// editorClipboard 当前编辑文档中的对象快照与剪贴板标识 -type editorClipboard struct { - token string - objects []ofdgo.GraphicObject - composite *ofdgo.CompositeSelection - annotations *ofdgo.AnnotationSelection -} - -// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 -var copiedObjects *editorClipboard - -// 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"` -} - // editorSummary 获取当前编辑文档状态 // 返回: editorInfo 文档信息 func editorSummary() editorInfo { @@ -1119,18 +1034,6 @@ func changeAnnotations(args []js.Value) (any, error) { }) } -// 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 -} - // readAnnotation 获取编辑面板所需的注解内容,不修改文件 // 入参: args 页面索引与注解标识 // 返回: any 面板数据, error 错误信息 @@ -2277,6 +2180,233 @@ func previewText(args []js.Value) (any, error) { return map[string]any{"runs": runs, "scale": scale, "weight": text.Weight, "italic": text.Italic}, 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 { + style := ofdgo.TextStyle{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{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{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 错误信息 @@ -2651,12 +2781,6 @@ func batchPages(args []js.Value) (any, error) { return editorPageInfo{info.(editorInfo), index}, nil } -// editorOutlineInfo 目录操作后的位置 -type editorOutlineInfo struct { - editorInfo - OutlinePath []int `json:"outlinePath"` -} - // indexesFromJS 读取整数索引列表 // 入参: value JavaScript数组 // 返回: []int 索引列表 @@ -3007,155 +3131,6 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { return editorSummary(), nil } -// updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性 -// 入参: args 页码、对象标识、内容、字体数据、字号和颜色 -// 返回: any 文档信息, error 错误信息 -func updateText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - page, id := args[0].Int(), args[1].String() - object, err := currentEditor.Object(page, id) - if err != nil { - return nil, err - } - if object.Type != "TextObject" { - return nil, fmt.Errorf("object %q is not text", id) - } - if !args[3].IsNull() { - data, err := bytesFromJS(args[3]) - if err != nil { - return nil, err - } - 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 { - style := ofdgo.TextStyle{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) -} - // setEditorColor 写入当前文档的RGB纯色,保留透明度并替换原颜色空间和索引 // 入参: fill 颜色, value 十六进制色值 // 返回: error 错误信息 @@ -3294,84 +3269,6 @@ func updatePathStyle(args []js.Value) (any, error) { }) } -// insertText 使用选定字体创建文字对象 -// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进 -// 返回: any 文档信息, error 错误信息 -func insertText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - data, err := bytesFromJS(args[2]) - if err != nil { - return nil, err - } - fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0) - if err != nil { - return nil, err - } - page := args[0].Int() - content, err := currentSession.pageContent(page) - if err != nil { - return nil, err - } - box, err := currentSession.pageBox(page, content) - if err != nil { - return nil, err - } - x, y := args[3].Float(), args[4].Float() - width := args[7].Float() - if width == 0 { - width = 80 - } - box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y} - object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{ - Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), - Font: fontID, Size: args[5].Float(), - }} - if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), 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{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) -} - // editorBox 将库层毫米边界传递给画布交互层 // 入参: box 毫米坐标中的矩形范围 // 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高 @@ -3631,14 +3528,107 @@ func editorOperationProgress(callback js.Value) func(string, int, int) error { } } -// saveDocument 分块写出当前编辑文档或指定页面 -// 入参: args 数据写出回调、可选准备进度回调和可选页面索引 -// 返回: any 保存结果, error 错误信息 -func saveDocument(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") +// 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 } - return saveEditor(currentEditor, args) + 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 } // saveEncrypted 以独立快照加密另存,不修改当前文档的输出策略 @@ -3790,6 +3780,16 @@ func saveSigned(args []js.Value) (any, error) { 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 { + return nil, fmt.Errorf("no document is being edited") + } + return saveEditor(currentEditor, args) +} + // saveEditor 统一分块写出及进度取消,加密策略由库层执行 // 入参: editor 待写出文档, args 写出回调、进度回调和可选页面索引 // 返回: any 保存结果, error 错误信息 diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 2aa4d19..390b0c4 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -39,6 +39,97 @@ type FontFile = ofdgo.FontFile // FontInfo OFD字体信息 type FontInfo = ofdgo.FontInfo +// Session WebUI文档会话 +type Session struct { + Reader *ofdgo.Reader + Renderer *ofdgo.Renderer + fontFS *ofdgo.FontFS + doc *ofdgo.Document + pageCache map[int]*ofdgo.PageContent + boxCache []pageBoxInfo + textCache map[int]*ofdgo.PageText + svgFonts map[string][]byte + fontScan *ofdgo.FontInfoScanner + fontInfos []FontInfo + fontsRead bool + fontAnnotations bool + signatures []SignatureInfo + signatureError error + signaturesRead bool + editing bool +} + +// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域 +type pageBoxInfo struct { + box ofdgo.Box + valid bool +} + +// DocumentInfo 文档信息 +type DocumentInfo struct { + Encryption EncryptionInfo `json:"encryption"` + Version string `json:"version"` + DocType string `json:"docType"` + Title string `json:"title"` + Author string `json:"author"` + Subject string `json:"subject"` + CreationDate string `json:"creationDate"` + ModDate string `json:"modDate"` + PageCount int `json:"pageCount"` + FontCount int `json:"fontCount"` + SignatureCount int `json:"signatureCount"` + SignatureError string `json:"signatureError,omitempty"` + AttachmentError string `json:"attachmentError,omitempty"` + Attachments []AttachmentInfo `json:"attachments,omitempty"` + Fonts []FontInfo `json:"fonts"` + Signatures []SignatureInfo `json:"signatures"` + Pages []PageInfo `json:"pages"` + Outlines []OutlineInfo `json:"outlines,omitempty"` + DetailsPending bool `json:"detailsPending,omitempty"` +} + +// OutlineInfo 目录节点信息 +type OutlineInfo = ofdgo.OutlineInfo + +// AttachmentInfo 可见附件信息,Size单位为KB +type AttachmentInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Format string `json:"format,omitempty"` + Size *float64 `json:"size,omitempty"` + FileName string `json:"fileName"` +} + +// PageInfo 页面信息 +type PageInfo struct { + Index int `json:"index"` + ID string `json:"id"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + +// PageSVG 页面SVG结果 +type PageSVG struct { + Index int `json:"index"` + Number int `json:"number"` + ID string `json:"id"` + Width float64 `json:"width"` + Height float64 `json:"height"` + SVG string `json:"svg"` + Links []ofdgo.PageLink `json:"-"` + Fonts []ofdgo.SVGFont `json:"-"` + Images []ofdgo.SVGImage `json:"-"` + Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"` +} + +// ExportFormat 导出格式 +type ExportFormat struct { + Value string `json:"value"` + Label string `json:"label"` + Extension string `json:"extension"` + MIME string `json:"mime"` +} + // SignatureInfo 签名验证信息 type SignatureInfo struct { DocIndex int `json:"docIndex"` @@ -144,55 +235,6 @@ type SignatureStampInfo struct { Height float64 `json:"height,omitempty"` } -// Session WebUI文档会话 -type Session struct { - Reader *ofdgo.Reader - Renderer *ofdgo.Renderer - fontFS *ofdgo.FontFS - doc *ofdgo.Document - pageCache map[int]*ofdgo.PageContent - boxCache []pageBoxInfo - textCache map[int]*ofdgo.PageText - svgFonts map[string][]byte - fontScan *ofdgo.FontInfoScanner - fontInfos []FontInfo - fontsRead bool - fontAnnotations bool - signatures []SignatureInfo - signatureError error - signaturesRead bool - editing bool -} - -// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域 -type pageBoxInfo struct { - box ofdgo.Box - valid bool -} - -// DocumentInfo 文档信息 -type DocumentInfo struct { - Encryption EncryptionInfo `json:"encryption"` - Version string `json:"version"` - DocType string `json:"docType"` - Title string `json:"title"` - Author string `json:"author"` - Subject string `json:"subject"` - CreationDate string `json:"creationDate"` - ModDate string `json:"modDate"` - PageCount int `json:"pageCount"` - FontCount int `json:"fontCount"` - SignatureCount int `json:"signatureCount"` - SignatureError string `json:"signatureError,omitempty"` - AttachmentError string `json:"attachmentError,omitempty"` - Attachments []AttachmentInfo `json:"attachments,omitempty"` - Fonts []FontInfo `json:"fonts"` - Signatures []SignatureInfo `json:"signatures"` - Pages []PageInfo `json:"pages"` - Outlines []OutlineInfo `json:"outlines,omitempty"` - DetailsPending bool `json:"detailsPending,omitempty"` -} - // EncryptionInfo 仅向界面传递非敏感加密状态 type EncryptionInfo struct { Encrypted bool `json:"encrypted"` @@ -201,55 +243,6 @@ type EncryptionInfo struct { Layers int `json:"layers,omitempty"` } -// encryptionInfo 获取当前文档的加密来源,不包含凭据 -// 返回: EncryptionInfo 加密状态 -func (s *Session) encryptionInfo() EncryptionInfo { - info := s.Reader.Encryption() - return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers} -} - -// OutlineInfo 目录节点信息 -type OutlineInfo = ofdgo.OutlineInfo - -// AttachmentInfo 可见附件信息,Size单位为KB -type AttachmentInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Format string `json:"format,omitempty"` - Size *float64 `json:"size,omitempty"` - FileName string `json:"fileName"` -} - -// PageInfo 页面信息 -type PageInfo struct { - Index int `json:"index"` - ID string `json:"id"` - Width float64 `json:"width"` - Height float64 `json:"height"` -} - -// PageSVG 页面SVG结果 -type PageSVG struct { - Index int `json:"index"` - Number int `json:"number"` - ID string `json:"id"` - Width float64 `json:"width"` - Height float64 `json:"height"` - SVG string `json:"svg"` - Links []ofdgo.PageLink `json:"-"` - Fonts []ofdgo.SVGFont `json:"-"` - Images []ofdgo.SVGImage `json:"-"` - Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"` -} - -// ExportFormat 导出格式 -type ExportFormat struct { - Value string `json:"value"` - Label string `json:"label"` - Extension string `json:"extension"` - MIME string `json:"mime"` -} - // supportedExportFormats 导出格式列表 var supportedExportFormats = []ExportFormat{ {Value: "pdf", Label: "PDF", Extension: "pdf", MIME: "application/pdf"}, @@ -260,30 +253,6 @@ var supportedExportFormats = []ExportFormat{ {Value: "txt", Label: "TXT", Extension: "txt", MIME: "text/plain"}, } -// ExportFormats 获取导出格式 -// 返回: []ExportFormat 导出格式列表 -func ExportFormats() []ExportFormat { - formats := make([]ExportFormat, len(supportedExportFormats)) - copy(formats, supportedExportFormats) - return formats -} - -// exportFormat 获取导出格式 -// 入参: value 格式值 -// 返回: ExportFormat 导出格式, bool 是否支持 -func exportFormat(value string) (ExportFormat, bool) { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "jpeg" { - value = "jpg" - } - for _, format := range supportedExportFormats { - if format.Value == value { - return format, true - } - } - return ExportFormat{}, false -} - // Open 打开浏览器内存中的OFD文档 // 入参: data OFD文件数据, opts 打开选项 // 返回: *Session 文档会话, error 错误信息 @@ -647,6 +616,64 @@ func (s *Session) SVGFontData(name string) ([]byte, error) { return data, nil } +// pageContent 读取页面,复用最近一次解析结果 +// 入参: index 页面索引 +// 返回: *ofdgo.PageContent 页面内容, error 错误信息 +func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) { + if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if page, ok := s.pageCache[index]; ok { + return page, nil + } + clear(s.pageCache) + page, err := s.Reader.PageContentByIndex(index) + if err != nil { + return nil, err + } + s.pageCache[index] = page + return page, nil +} + +// pageBox 获取页面物理区域 +// 入参: index 页面索引, page 页面内容 +// 返回: ofdgo.Box 页面物理区域, error 错误信息 +func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) { + if cached := s.boxCache[index]; cached.valid { + return cached.box, nil + } + box, err := s.Renderer.GetPageBox(page) + if err != nil { + return ofdgo.Box{}, err + } + s.boxCache[index] = pageBoxInfo{box: box, valid: true} + return box, nil +} + +// ExportFormats 获取导出格式 +// 返回: []ExportFormat 导出格式列表 +func ExportFormats() []ExportFormat { + formats := make([]ExportFormat, len(supportedExportFormats)) + copy(formats, supportedExportFormats) + return formats +} + +// exportFormat 获取导出格式 +// 入参: value 格式值 +// 返回: ExportFormat 导出格式, bool 是否支持 +func exportFormat(value string) (ExportFormat, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "jpeg" { + value = "jpg" + } + for _, format := range supportedExportFormats { + if format.Value == value { + return format, true + } + } + return ExportFormat{}, false +} + // ExportPage 导出单页 // 入参: index 页面索引, value 导出格式, dpi 图片DPI, writer 输出流 // 返回: ExportFormat 导出格式, error 错误信息 @@ -666,6 +693,48 @@ func (s *Session) ExportPage(index int, value string, dpi float64, writer io.Wri return format, renderer.RenderTo(page, writer, format.Value) } +// ExportDocument 导出文档为PDF、TXT或逐页打包ZIP +// 入参: value 导出格式, dpi 图片DPI, writer 输出流, indices 零基页面索引,省略则全部 +// 返回: ExportFormat 导出格式, error 错误信息 +func (s *Session) ExportDocument(value string, dpi float64, writer io.Writer, indices ...int) (ExportFormat, error) { + format, ok := exportFormat(value) + if !ok { + return ExportFormat{}, fmt.Errorf("unsupported export format %s", value) + } + if format.Value == "pdf" { + return format, s.ExportPDF(writer, indices...) + } + if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { + return ExportFormat{}, fmt.Errorf("ofd document is not opened") + } + if format.Value == "txt" { + return format, s.Renderer.RenderToMultiPageText(writer, indices...) + } + renderer := *s.Renderer + if dpi > 0 && (format.Value == "png" || format.Value == "jpg") { + renderer.DPI = dpi + } + err := renderer.RenderToZIP(writer, format.Value, indices...) + return ExportFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"}, err +} + +// ExportPDF 导出文档为PDF +// 入参: writer 输出流, indices 零基页面索引,省略则全部 +// 返回: error 错误信息 +func (s *Session) ExportPDF(writer io.Writer, indices ...int) error { + if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { + return fmt.Errorf("ofd document is not opened") + } + return s.Renderer.RenderToMultiPagePDF(writer, indices...) +} + +// encryptionInfo 获取当前文档的加密来源,不包含凭据 +// 返回: EncryptionInfo 加密状态 +func (s *Session) encryptionInfo() EncryptionInfo { + info := s.Reader.Encryption() + return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers} +} + // signatureInfos 获取签名验证信息 // 返回: []SignatureInfo 签名验证信息, error 错误信息 func (s *Session) signatureInfos() ([]SignatureInfo, error) { @@ -855,72 +924,3 @@ func signatureStampInfos(positions []ofdgo.SignatureStampPosition) []SignatureSt } return infos } - -// ExportDocument 导出文档为PDF、TXT或逐页打包ZIP -// 入参: value 导出格式, dpi 图片DPI, writer 输出流, indices 零基页面索引,省略则全部 -// 返回: ExportFormat 导出格式, error 错误信息 -func (s *Session) ExportDocument(value string, dpi float64, writer io.Writer, indices ...int) (ExportFormat, error) { - format, ok := exportFormat(value) - if !ok { - return ExportFormat{}, fmt.Errorf("unsupported export format %s", value) - } - if format.Value == "pdf" { - return format, s.ExportPDF(writer, indices...) - } - if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { - return ExportFormat{}, fmt.Errorf("ofd document is not opened") - } - if format.Value == "txt" { - return format, s.Renderer.RenderToMultiPageText(writer, indices...) - } - renderer := *s.Renderer - if dpi > 0 && (format.Value == "png" || format.Value == "jpg") { - renderer.DPI = dpi - } - err := renderer.RenderToZIP(writer, format.Value, indices...) - return ExportFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"}, err -} - -// ExportPDF 导出文档为PDF -// 入参: writer 输出流, indices 零基页面索引,省略则全部 -// 返回: error 错误信息 -func (s *Session) ExportPDF(writer io.Writer, indices ...int) error { - if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { - return fmt.Errorf("ofd document is not opened") - } - return s.Renderer.RenderToMultiPagePDF(writer, indices...) -} - -// pageContent 读取页面,复用最近一次解析结果 -// 入参: index 页面索引 -// 返回: *ofdgo.PageContent 页面内容, error 错误信息 -func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) { - if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { - return nil, fmt.Errorf("ofd document is not opened") - } - if page, ok := s.pageCache[index]; ok { - return page, nil - } - clear(s.pageCache) - page, err := s.Reader.PageContentByIndex(index) - if err != nil { - return nil, err - } - s.pageCache[index] = page - return page, nil -} - -// pageBox 获取页面物理区域 -// 入参: index 页面索引, page 页面内容 -// 返回: ofdgo.Box 页面物理区域, error 错误信息 -func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) { - if cached := s.boxCache[index]; cached.valid { - return cached.box, nil - } - box, err := s.Renderer.GetPageBox(page) - if err != nil { - return ofdgo.Box{}, err - } - s.boxCache[index] = pageBoxInfo{box: box, valid: true} - return box, nil -} diff --git a/ofdgo_backend.go b/ofdgo_backend.go index 442572e..cb65b25 100644 --- a/ofdgo_backend.go +++ b/ofdgo_backend.go @@ -20,7 +20,6 @@ import ( "image" "io" "reflect" - "sync" ) // ErrBackendUnavailable 表示未配置所需后端,不会自动切换其他实现 @@ -106,83 +105,32 @@ type EPSBackend interface { // RenderBackends 按职责组合后端,nil表示明确禁用,不隐式回退 // 实例及其Renderer需串行使用,第三方实现无需引用任何内置绘图库 type RenderBackends struct { - Resources FontResourceBackend - Fonts FontBackend - Geometry GeometryBackend - Compiler PageCompiler - Raster RasterBackend - SVG SVGBackend - PDF PDFBackend - EPS EPSBackend + FontResources FontResourceBackend + Fonts FontBackend + Geometry GeometryBackend + Compiler PageCompiler + Raster RasterBackend + SVG SVGBackend + PDF PDFBackend + EPS EPSBackend } // BackendInfo 描述各项能力实际使用的后端,空标识表示未配置 type BackendInfo struct { - Resources string `json:"resources"` - Fonts string `json:"fonts"` - Geometry string `json:"geometry"` - Compiler string `json:"compiler"` - Raster string `json:"raster"` - SVG string `json:"svg"` - PDF string `json:"pdf"` - EPS string `json:"eps"` -} - -// NewRenderBackends 创建已注册后端组合,各项能力通过Info报告实际提供者 -// 入参: name 已注册后端标识 -// 返回: RenderBackends 后端组合, error 未知后端错误 -func NewRenderBackends(name string) (RenderBackends, error) { - backendRegistry.RLock() - factory := backendRegistry.factories[name] - backendRegistry.RUnlock() - if factory == nil { - return RenderBackends{}, fmt.Errorf("unknown render backend %q", name) - } - return factory(), nil -} - -// RenderBackendInfos 列出已注册组合及其实际能力,供原生程序和WASM共用 -// 返回: map[string]BackendInfo 后端能力 -func RenderBackendInfos() map[string]BackendInfo { - backendRegistry.RLock() - factories := make(map[string]func() RenderBackends, len(backendRegistry.factories)) - for name, factory := range backendRegistry.factories { - factories[name] = factory - } - backendRegistry.RUnlock() - result := make(map[string]BackendInfo, len(factories)) - for name, factory := range factories { - result[name] = factory().Info() - } - return result -} - -// backendRegistry 保存后端工厂,不共享有状态的后端实例 -var backendRegistry = struct { - sync.RWMutex - factories map[string]func() RenderBackends -}{factories: make(map[string]func() RenderBackends)} - -// RegisterRenderBackend 注册后端组合,工厂应返回独立实例,重复标识不会覆盖已有实现 -// 入参: name 后端标识, factory 后端工厂 -// 返回: error 无效或重复注册错误 -func RegisterRenderBackend(name string, factory func() RenderBackends) error { - if name == "" || factory == nil { - return fmt.Errorf("backend name and factory are required") - } - backendRegistry.Lock() - defer backendRegistry.Unlock() - if _, exists := backendRegistry.factories[name]; exists { - return fmt.Errorf("render backend %q already registered", name) - } - backendRegistry.factories[name] = factory - return nil + FontResources string `json:"fontResources"` + Fonts string `json:"fonts"` + Geometry string `json:"geometry"` + Compiler string `json:"compiler"` + Raster string `json:"raster"` + SVG string `json:"svg"` + PDF string `json:"pdf"` + EPS string `json:"eps"` } // Info 返回配置中各能力的提供者,不依赖WebUI推断 // 返回: BackendInfo 后端能力 func (b RenderBackends) Info() BackendInfo { - return BackendInfo{Resources: backendName(b.Resources), Fonts: backendName(b.Fonts), Geometry: backendName(b.Geometry), Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)} + return BackendInfo{FontResources: backendName(b.FontResources), Fonts: backendName(b.Fonts), Geometry: backendName(b.Geometry), Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)} } // backendName 获取已配置的后端标识 diff --git a/ofdgo_backend_registry.go b/ofdgo_backend_registry.go new file mode 100644 index 0000000..1e17b55 --- /dev/null +++ b/ofdgo_backend_registry.go @@ -0,0 +1,71 @@ +// 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. + +package ofdgo + +import ( + "fmt" + "sync" +) + +// backendRegistry 保存后端工厂,不共享有状态的后端实例 +var backendRegistry = struct { + sync.RWMutex + factories map[string]func() RenderBackends +}{factories: make(map[string]func() RenderBackends)} + +// RegisterRenderBackend 注册后端组合,工厂应返回独立实例,重复标识不会覆盖已有实现 +// 入参: name 后端标识, factory 后端工厂 +// 返回: error 无效或重复注册错误 +func RegisterRenderBackend(name string, factory func() RenderBackends) error { + if name == "" || factory == nil { + return fmt.Errorf("backend name and factory are required") + } + backendRegistry.Lock() + defer backendRegistry.Unlock() + if _, exists := backendRegistry.factories[name]; exists { + return fmt.Errorf("render backend %q already registered", name) + } + backendRegistry.factories[name] = factory + return nil +} + +// NewRenderBackends 创建已注册后端组合,各项能力通过Info报告实际提供者 +// 入参: name 已注册后端标识 +// 返回: RenderBackends 后端组合, error 未知后端错误 +func NewRenderBackends(name string) (RenderBackends, error) { + backendRegistry.RLock() + factory := backendRegistry.factories[name] + backendRegistry.RUnlock() + if factory == nil { + return RenderBackends{}, fmt.Errorf("unknown render backend %q", name) + } + return factory(), nil +} + +// RenderBackendInfos 列出已注册组合及其实际能力,供原生程序和WASM共用 +// 返回: map[string]BackendInfo 后端能力 +func RenderBackendInfos() map[string]BackendInfo { + backendRegistry.RLock() + factories := make(map[string]func() RenderBackends, len(backendRegistry.factories)) + for name, factory := range backendRegistry.factories { + factories[name] = factory + } + backendRegistry.RUnlock() + result := make(map[string]BackendInfo, len(factories)) + for name, factory := range factories { + result[name] = factory().Info() + } + return result +} diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 14c1786..1cb94b9 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -85,7 +85,7 @@ func (e *Editor) SetPageCompiler(compiler PageCompiler) { // 入参: backends 后端组合,未配置的能力不会沿用默认实现 func (e *Editor) SetRenderBackends(backends RenderBackends) { fontsChanged := !sameBackend(e.backends.Fonts, backends.Fonts) - if !sameBackend(e.backends.Resources, backends.Resources) { + if !sameBackend(e.backends.FontResources, backends.FontResources) { e.fonts = make(map[string]*FontResource) for i := range e.resources { e.resources[i].subset = nil @@ -565,40 +565,6 @@ func (e *Editor) nextID() string { return strconv.Itoa(e.maxID) } -// AddFont 注册OpenType字体,集合字体按索引提取,重复资源复用标识,引用后写入文档 -// 入参: file 字体文件, index 集合内字体索引,单字体为0 -// 返回: string 字体资源标识, error 错误信息 -func (e *Editor) AddFont(file FontFile, index int) (string, error) { - if e.backends.Resources == nil { - return "", fmt.Errorf("font resources: %w", ErrBackendUnavailable) - } - parsed, err := e.backends.Resources.OpenFontResource(file, index) - if err != nil { - return "", err - } - data := parsed.Data - key := editorResourceKey{checksum: sha256.Sum256(data)} - if id, ok := e.resourceID[key]; ok { - return id, nil - } - if err := e.prepareSourceIDs(); err != nil { - return "", err - } - id := e.nextID() - definition := parsed.Font - definition.ID = id - resource := editorResource{ - name: e.packageName("Res/Fonts/Font_" + id + parsed.Extension), - data: data, - font: &definition, - } - resource.font.FontFile = "/" + resource.name - e.resources = append(e.resources, resource) - e.fonts[id] = parsed - e.resourceID[key] = id - return id, nil -} - // AddImage 注册PNG或JPEG图片,重复资源复用标识,引用后写入文档 // 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码 // 入参: data 图片数据 diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 39d02ba..bee4e5f 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -16,14 +16,12 @@ package ofdgo import ( "bytes" - "encoding/binary" + "crypto/sha256" "fmt" "maps" "slices" "strconv" "strings" - - "github.com/tdewolff/font" ) // editorFontUsage 汇总原文档字体的字符、显式字形及无法确定的引用 @@ -33,6 +31,47 @@ type editorFontUsage struct { unsafe bool } +// editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合 +type editorFontSubset struct { + glyphs []uint16 + data []byte + mapped bool +} + +// AddFont 注册OpenType字体,集合字体按索引提取,重复资源复用标识,引用后写入文档 +// 入参: file 字体文件, index 集合内字体索引,单字体为0 +// 返回: string 字体资源标识, error 错误信息 +func (e *Editor) AddFont(file FontFile, index int) (string, error) { + if e.backends.FontResources == nil { + return "", fmt.Errorf("font resources: %w", ErrBackendUnavailable) + } + parsed, err := e.backends.FontResources.OpenFontResource(file, index) + if err != nil { + return "", err + } + data := parsed.Data + key := editorResourceKey{checksum: sha256.Sum256(data)} + if id, ok := e.resourceID[key]; ok { + return id, nil + } + if err := e.prepareSourceIDs(); err != nil { + return "", err + } + id := e.nextID() + definition := parsed.Font + definition.ID = id + resource := editorResource{ + name: e.packageName("Res/Fonts/Font_" + id + parsed.Extension), + data: data, + font: &definition, + } + resource.font.FontFile = "/" + resource.name + e.resources = append(e.resources, resource) + e.fonts[id] = parsed + e.resourceID[key] = id + return id, nil +} + // newEditorFontUsage 创建字体用字记录 // 返回: *editorFontUsage 用字记录 func newEditorFontUsage() *editorFontUsage { @@ -84,110 +123,11 @@ func (u *editorFontUsage) merge(other *editorFontUsage, unsafe bool) { } } -// subsetSourceFont 裁剪静态TrueType原有字体,保留字形编号、复合依赖、度量及提示指令 -// 集合、字形替换、可变、彩色和未知表保持原样,不对不完整的引用或异常字体猜测修复 -// 入参: data 字体数据, usage 全包用字记录 -// 返回: []byte 更小的字体子集,无确定收益时为空 -func subsetSourceFont(data []byte, usage *editorFontUsage) []byte { - if usage.unsafe || len(usage.chars)+len(usage.glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) { - return nil - } - sfnt, err := font.ParseSFNT(data, 0) - if err != nil || !sfnt.IsTrueType { - return nil - } - for tag := range sfnt.Tables { - switch tag { - case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM": - default: - return nil - } - } - var unicodeTables []uint16 - subtables := make(map[uint32]uint16) - for i, record := range sfnt.Cmap.EncodingRecords { - if record.Format == 14 { - return nil - } - offset := binary.BigEndian.Uint32(sfnt.Tables["cmap"][8+8*i:]) - index, ok := subtables[offset] - if !ok { - index = uint16(len(subtables)) - subtables[offset] = index - } - if record.PlatformID == 0 || record.PlatformID == 3 && (record.EncodingID == 1 || record.EncodingID == 10) { - unicodeTables = append(unicodeTables, index) - } - } - if len(unicodeTables) == 0 { - return nil - } - glyphs := maps.Clone(usage.glyphs) - glyphs[0] = true - mapping := make(map[rune]uint16, len(usage.chars)) - for char := range usage.chars { - id := sfnt.GlyphIndex(char) - for _, index := range unicodeTables { - if alternate, ok := sfnt.Cmap.Subtables[index].Get(char); ok && alternate != id { - return nil - } - } - glyphs[id] = true - if id != 0 { - mapping[char] = id - } - } - for id := range glyphs { - if id >= sfnt.NumGlyphs() { - return nil - } - dependencies, err := sfnt.Glyf.Dependencies(id) - if err != nil { - return nil - } - for _, dependency := range dependencies { - glyphs[dependency] = true - } - } - var glyf, loca []byte - for id := 0; id <= int(sfnt.NumGlyphs()); id++ { - if sfnt.Head.IndexToLocFormat == 0 { - loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2)) - } else { - loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf))) - } - if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] { - glyf = append(glyf, sfnt.Glyf.Get(uint16(id))...) - if len(glyf)%2 != 0 { - glyf = append(glyf, 0) - } - } - } - tables := maps.Clone(sfnt.Tables) - tables["glyf"], tables["loca"] = glyf, loca - tables["cmap"] = buildCmapTable(uint16(sfnt.NumGlyphs()), mapping) - tables["head"] = bytes.Clone(tables["head"]) - clear(tables["head"][8:12]) - delete(tables, "DSIG") - result, err := serializeOTF(tables) - if err != nil || len(result) >= len(data) { - return nil - } - return result -} - -// editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合 -type editorFontSubset struct { - glyphs []uint16 - data []byte - mapped bool -} - // subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,保留完整编辑字体 // 入参: progress 保存进度回调 // 返回: map[string][]byte 包内字体子集, error 错误信息 func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) { - if e.backends.Resources == nil { + if e.backends.FontResources == nil { return nil, nil } used := make(map[string]map[uint16]bool) @@ -196,16 +136,16 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) if resource.font == nil { continue } - sfnt := e.fonts[resource.font.ID] - if sfnt == nil { + resourceFont := e.fonts[resource.font.ID] + if resourceFont == nil { var err error - sfnt, err = e.backends.Resources.OpenFontResource(FontFile{Data: resource.data}, 0) + resourceFont, err = e.backends.FontResources.OpenFontResource(FontFile{Data: resource.data}, 0) if err != nil { return nil, err } - e.fonts[resource.font.ID] = sfnt + e.fonts[resource.font.ID] = resourceFont } - if sfnt.CanSubset { + if resourceFont.CanSubset { used[resource.font.ID] = make(map[uint16]bool) } } @@ -320,7 +260,7 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } slices.Sort(ids) if resource.subset == nil || resource.subset.mapped != mapped[resource.font.ID] || !slices.Equal(resource.subset.glyphs, ids) { - data, err := e.backends.Resources.SubsetFont(resource.data, ids, mapped[resource.font.ID]) + data, err := e.backends.FontResources.SubsetFont(resource.data, ids, mapped[resource.font.ID]) if err != nil { return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err) } @@ -338,45 +278,3 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } return result, nil } - -// subsetEditorFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令 -// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号 -// 返回: []byte 字体子集, error 错误信息 -func subsetEditorFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) { - sfnt, err := font.ParseSFNT(bytes.Clone(data), 0) - if err != nil { - return nil, err - } - if mapped { - usage := newEditorFontUsage() - for _, id := range glyphs { - usage.glyphs[id] = true - for _, char := range sfnt.Cmap.ToUnicode(id) { - usage.chars[char] = true - } - } - tables := make(map[string][]byte) - for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} { - if table := sfnt.Tables[tag]; table != nil { - tables[tag] = table - } - } - fixed, err := serializeOTF(tables) - if err != nil { - return nil, err - } - if subset := subsetSourceFont(fixed, usage); subset != nil { - return subset, nil - } - return fixed, nil - } - subset, err := sfnt.Subset(glyphs, font.SubsetOptions{Tables: []string{ - "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", - }}) - if err != nil { - return nil, err - } - subset.Tables["name"] = sfnt.Tables["name"] - clear(subset.Tables["head"][8:12]) - return serializeOTF(subset.Tables) -} diff --git a/ofdgo_editor_metadata.go b/ofdgo_editor_metadata.go new file mode 100644 index 0000000..9b1cb8e --- /dev/null +++ b/ofdgo_editor_metadata.go @@ -0,0 +1,149 @@ +// 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. + +package ofdgo + +import ( + "bytes" + "encoding/xml" + "fmt" + "reflect" +) + +// sourceInfoXML 更新公开元数据,保留原创建程序和未识别字段 +// 返回: []byte 根索引XML, error 错误信息 +func (e *Editor) sourceInfoXML() ([]byte, error) { + data, err := e.source.reader.readFile("OFD.xml") + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + body := root.child("DocBody") + if body == nil { + return nil, fmt.Errorf("document has no DocBody") + } + info := body.child("DocInfo") + if info == nil { + return nil, fmt.Errorf("document has no DocInfo") + } + before, after := e.source.info, e.Info + var values [][2]string + for _, field := range [][3]string{ + {"DocID", before.DocID, after.DocID}, {"Title", before.Title, after.Title}, {"Author", before.Author, after.Author}, + {"Subject", before.Subject, after.Subject}, {"Abstract", before.Abstract, after.Abstract}, + {"CreationDate", before.CreationDate, after.CreationDate}, {"ModDate", before.ModDate, after.ModDate}, + } { + if field[1] != field[2] { + values = append(values, [2]string{field[0], field[2]}) + } + } + data = editorXMLSetText(data, info, values) + if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) { + root, err = parseEditorXML(data) + if err != nil { + return nil, err + } + return editorCustomDatas(data, root.child("DocBody").child("DocInfo"), after.CustomDatas) + } + return data, nil +} + +// editorCustomDatas 更新自定义字段,保留容器扩展与同名字段的未知属性和子节点 +// 入参: data 原XML, info 文档信息节点, values 新字段 +// 返回: []byte 修改后的XML, error 编码错误 +func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) { + container := info.child("CustomDatas") + var items []CustomData + if values != nil { + items = values.CustomData + } + byName := make(map[string][]*editorXML) + var nodes []*editorXML + if container != nil { + for _, node := range container.children { + if node.name.Local == "CustomData" && (node.name.Space == ofdNamespace || node.name.Space == container.name.Space) { + nodes = append(nodes, node) + byName[node.attr("Name")] = append(byName[node.attr("Name")], node) + } + } + } + var encoded [][]byte + for _, item := range items { + matches := byName[item.Name] + if len(matches) == 0 { + entry, err := xml.Marshal(struct { + XMLName xml.Name + CustomData + }{xml.Name{Space: ofdNamespace, Local: "CustomData"}, item}) + if err != nil { + return nil, err + } + encoded = append(encoded, entry) + continue + } + node := matches[0] + byName[item.Name] = matches[1:] + var original CustomData + if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil { + return nil, err + } + if original.Value == item.Value { + encoded = append(encoded, data[node.start:node.end]) + continue + } + var content bytes.Buffer + if err := xml.EscapeText(&content, []byte(item.Value)); err != nil { + return nil, err + } + for _, child := range node.children { + content.Write(data[child.start:child.end]) + } + patch := editorXMLContent(data, node, content.Bytes()) + entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...) + entry = append(entry, data[patch.end:node.end]...) + encoded = append(encoded, entry) + } + if container == nil { + if len(encoded) == 0 { + return data, nil + } + content := []byte("") + content = append(content, bytes.Join(encoded, nil)...) + content = append(content, []byte("")...) + if info.open == info.end { + return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, info, content)}), nil + } + return editorPatchXML(data, []editorXMLPatch{{info.close, info.close, content}}), nil + } + var patches []editorXMLPatch + for i, node := range nodes { + var entry []byte + if i < len(encoded) { + entry = encoded[i] + } + patches = append(patches, editorXMLPatch{node.start, node.end, entry}) + } + if len(encoded) > len(nodes) { + added := bytes.Join(encoded[len(nodes):], nil) + if container.open == container.end { + patches = append(patches, editorXMLContent(data, container, added)) + } else { + patches = append(patches, editorXMLPatch{container.close, container.close, added}) + } + } + return editorPatchXML(data, patches), nil +} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 5b65dbb..8e78548 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -421,133 +421,6 @@ func (e *Editor) sourceNewPageXML(page PageContent) ([]byte, error) { return editorXMLContainer("Page", nil, append(area, content...)) } -// sourceInfoXML 更新公开元数据,保留原创建程序和未识别字段 -// 返回: []byte 根索引XML, error 错误信息 -func (e *Editor) sourceInfoXML() ([]byte, error) { - data, err := e.source.reader.readFile("OFD.xml") - if err != nil { - return nil, err - } - root, err := parseEditorXML(data) - if err != nil { - return nil, err - } - body := root.child("DocBody") - if body == nil { - return nil, fmt.Errorf("document has no DocBody") - } - info := body.child("DocInfo") - if info == nil { - return nil, fmt.Errorf("document has no DocInfo") - } - before, after := e.source.info, e.Info - var values [][2]string - for _, field := range [][3]string{ - {"DocID", before.DocID, after.DocID}, {"Title", before.Title, after.Title}, {"Author", before.Author, after.Author}, - {"Subject", before.Subject, after.Subject}, {"Abstract", before.Abstract, after.Abstract}, - {"CreationDate", before.CreationDate, after.CreationDate}, {"ModDate", before.ModDate, after.ModDate}, - } { - if field[1] != field[2] { - values = append(values, [2]string{field[0], field[2]}) - } - } - data = editorXMLSetText(data, info, values) - if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) { - root, err = parseEditorXML(data) - if err != nil { - return nil, err - } - return editorCustomDatas(data, root.child("DocBody").child("DocInfo"), after.CustomDatas) - } - return data, nil -} - -// editorCustomDatas 更新自定义字段,保留容器扩展与同名字段的未知属性和子节点 -// 入参: data 原XML, info 文档信息节点, values 新字段 -// 返回: []byte 修改后的XML, error 编码错误 -func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) { - container := info.child("CustomDatas") - var items []CustomData - if values != nil { - items = values.CustomData - } - byName := make(map[string][]*editorXML) - var nodes []*editorXML - if container != nil { - for _, node := range container.children { - if node.name.Local == "CustomData" && (node.name.Space == ofdNamespace || node.name.Space == container.name.Space) { - nodes = append(nodes, node) - byName[node.attr("Name")] = append(byName[node.attr("Name")], node) - } - } - } - var encoded [][]byte - for _, item := range items { - matches := byName[item.Name] - if len(matches) == 0 { - entry, err := xml.Marshal(struct { - XMLName xml.Name - CustomData - }{xml.Name{Space: ofdNamespace, Local: "CustomData"}, item}) - if err != nil { - return nil, err - } - encoded = append(encoded, entry) - continue - } - node := matches[0] - byName[item.Name] = matches[1:] - var original CustomData - if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil { - return nil, err - } - if original.Value == item.Value { - encoded = append(encoded, data[node.start:node.end]) - continue - } - var content bytes.Buffer - if err := xml.EscapeText(&content, []byte(item.Value)); err != nil { - return nil, err - } - for _, child := range node.children { - content.Write(data[child.start:child.end]) - } - patch := editorXMLContent(data, node, content.Bytes()) - entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...) - entry = append(entry, data[patch.end:node.end]...) - encoded = append(encoded, entry) - } - if container == nil { - if len(encoded) == 0 { - return data, nil - } - content := []byte("") - content = append(content, bytes.Join(encoded, nil)...) - content = append(content, []byte("")...) - if info.open == info.end { - return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, info, content)}), nil - } - return editorPatchXML(data, []editorXMLPatch{{info.close, info.close, content}}), nil - } - var patches []editorXMLPatch - for i, node := range nodes { - var entry []byte - if i < len(encoded) { - entry = encoded[i] - } - patches = append(patches, editorXMLPatch{node.start, node.end, entry}) - } - if len(encoded) > len(nodes) { - added := bytes.Join(encoded[len(nodes):], nil) - if container.open == container.end { - patches = append(patches, editorXMLContent(data, container, added)) - } else { - patches = append(patches, editorXMLPatch{container.close, container.close, added}) - } - } - return editorPatchXML(data, patches), nil -} - // writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本 // 入参: writer 输出流, fonts 新增字体子集, progress 保存进度回调 // 返回: int64 写入字节数, error 错误信息 diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go index b556619..930b826 100644 --- a/ofdgo_editor_resources.go +++ b/ofdgo_editor_resources.go @@ -173,14 +173,14 @@ func (e *Editor) compactSourceResources(parts map[string][]byte, progress editor } key := strings.ToLower(cleanPackagePath(name)) usage := fontFiles[key] - if e.backends.Resources == nil || usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil { + if e.backends.FontResources == nil || usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil { continue } data, err := reader.readFile(name) if err != nil { return nil, err } - subset, err := e.backends.Resources.SubsetSourceFont(data, FontUsage{Characters: slices.Sorted(maps.Keys(usage.chars)), Glyphs: slices.Sorted(maps.Keys(usage.glyphs)), Unsafe: usage.unsafe}) + subset, err := e.backends.FontResources.SubsetSourceFont(data, FontUsage{Characters: slices.Sorted(maps.Keys(usage.chars)), Glyphs: slices.Sorted(maps.Keys(usage.glyphs)), Unsafe: usage.unsafe}) if err != nil { return nil, fmt.Errorf("subset font %s: %w", name, err) } diff --git a/ofdgo_font_resource.go b/ofdgo_font_resource.go index 8fe8dbf..17cf660 100644 --- a/ofdgo_font_resource.go +++ b/ofdgo_font_resource.go @@ -14,13 +14,6 @@ package ofdgo -import ( - "fmt" - "slices" - - "github.com/tdewolff/font" -) - // FontResource 保存独立字体数据、标准元信息与只读度量,不包含绘图库类型 type FontResource struct { FontMetrics @@ -45,87 +38,3 @@ type FontResourceBackend interface { SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) } - -// SFNTBackend 提供默认字体资源处理,不依赖Canvas绘图库 -type SFNTBackend struct{} - -// Name 返回资源后端标识 -// 返回: string 后端标识 -func (SFNTBackend) Name() string { return "sfnt" } - -// OpenFontResource 提取独立字体并读取名称、样式和裁剪能力 -// 入参: file 字体文件, index 集合索引 -// 返回: *FontResource 字体资源, error 解析错误 -func (SFNTBackend) OpenFontResource(file FontFile, index int) (*FontResource, error) { - data, err := file.Face(index) - if err != nil { - return nil, err - } - sfnt, err := font.ParseSFNT(data, 0) - if err != nil { - return nil, err - } - name := editorFontName(sfnt, font.NamePostScript, font.NameFull, font.NameFontFamily) - if name == "" { - return nil, fmt.Errorf("font has no name") - } - extension := ".ttf" - if sfnt.IsCFF { - extension = ".otf" - } - return &FontResource{ - FontMetrics: sfntResourceMetrics{sfnt}, Data: data, Extension: extension, - Font: Font{FontName: name, FamilyName: editorFontName(sfnt, font.NamePreferredFamily, font.NameFontFamily), Charset: "unicode", Bold: sfnt.Head.MacStyle[0], Italic: sfnt.Head.MacStyle[1], FixedWidth: sfnt.Post.IsFixedPitch != 0}, - CanSubset: sfntSubsettable(sfnt), - }, nil -} - -// sfntSubsettable 判断默认资源后端能否无损裁剪该字体 -// 入参: sfnt 已解析字体 -// 返回: bool 是否支持裁剪 -func sfntSubsettable(sfnt *font.SFNT) bool { - return sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) -} - -// sfntResourceMetrics 隔离资源解析器的度量实现 -type sfntResourceMetrics struct{ *font.SFNT } - -// Write 返回未改动元信息的字体数据 -// 返回: []byte 字体数据 -func (f sfntResourceMetrics) Write() []byte { return fontSFNTData(f.SFNT) } - -// SubsetFont 裁剪新建字体并按要求保留显式字形编号 -// 入参: data 字体数据, glyphs 用字, preserve 是否保留编号 -// 返回: []byte 子集, error 裁剪错误 -func (SFNTBackend) SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) { - return subsetEditorFont(data, glyphs, preserve) -} - -// SubsetSourceFont 按全包引用裁剪原字体,不确定时保留原资源 -// 入参: data 字体数据, usage 用字信息 -// 返回: []byte 子集, error 裁剪错误 -func (SFNTBackend) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) { - u := newEditorFontUsage() - u.unsafe = usage.Unsafe - for _, char := range usage.Characters { - u.chars[char] = true - } - for _, glyph := range usage.Glyphs { - u.glyphs[glyph] = true - } - return subsetSourceFont(data, u), nil -} - -// editorFontName 获取字体中的名称 -// 入参: sfnt 字体, names 名称类型,按顺序查找 -// 返回: string 字体名称 -func editorFontName(sfnt *font.SFNT, names ...font.NameID) string { - for _, name := range names { - for _, record := range sfnt.Name.Get(name) { - if value := record.String(); value != "" { - return value - } - } - } - return "" -} diff --git a/ofdgo_font_resource_sfnt.go b/ofdgo_font_resource_sfnt.go new file mode 100644 index 0000000..0b2a399 --- /dev/null +++ b/ofdgo_font_resource_sfnt.go @@ -0,0 +1,243 @@ +// 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. + +package ofdgo + +import ( + "bytes" + "encoding/binary" + "fmt" + "maps" + "slices" + + "github.com/tdewolff/font" +) + +// SFNTBackend 提供默认字体资源处理,不依赖Canvas绘图库 +type SFNTBackend struct{} + +// sfntResourceMetrics 隔离资源解析器的度量实现 +type sfntResourceMetrics struct{ *font.SFNT } + +// Name 返回资源后端标识 +// 返回: string 后端标识 +func (SFNTBackend) Name() string { return "sfnt" } + +// OpenFontResource 提取独立字体并读取名称、样式和裁剪能力 +// 入参: file 字体文件, index 集合索引 +// 返回: *FontResource 字体资源, error 解析错误 +func (SFNTBackend) OpenFontResource(file FontFile, index int) (*FontResource, error) { + data, err := file.Face(index) + if err != nil { + return nil, err + } + sfnt, err := font.ParseSFNT(data, 0) + if err != nil { + return nil, err + } + name := sfntFontName(sfnt, font.NamePostScript, font.NameFull, font.NameFontFamily) + if name == "" { + return nil, fmt.Errorf("font has no name") + } + extension := ".ttf" + if sfnt.IsCFF { + extension = ".otf" + } + return &FontResource{ + FontMetrics: sfntResourceMetrics{sfnt}, Data: data, Extension: extension, + Font: Font{FontName: name, FamilyName: sfntFontName(sfnt, font.NamePreferredFamily, font.NameFontFamily), Charset: "unicode", Bold: sfnt.Head.MacStyle[0], Italic: sfnt.Head.MacStyle[1], FixedWidth: sfnt.Post.IsFixedPitch != 0}, + CanSubset: sfntSubsettable(sfnt), + }, nil +} + +// SubsetFont 裁剪新建字体并按要求保留显式字形编号 +// 入参: data 字体数据, glyphs 用字, preserve 是否保留编号 +// 返回: []byte 子集, error 裁剪错误 +func (SFNTBackend) SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) { + return subsetSFNTFont(data, glyphs, preserve) +} + +// SubsetSourceFont 按全包引用裁剪原字体,不确定时保留原资源 +// 入参: data 字体数据, usage 用字信息 +// 返回: []byte 子集, error 裁剪错误 +func (SFNTBackend) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) { + u := newEditorFontUsage() + u.unsafe = usage.Unsafe + for _, char := range usage.Characters { + u.chars[char] = true + } + for _, glyph := range usage.Glyphs { + u.glyphs[glyph] = true + } + return subsetSourceSFNTFont(data, u), nil +} + +// Write 返回未改动元信息的字体数据 +// 返回: []byte 字体数据 +func (f sfntResourceMetrics) Write() []byte { return fontSFNTData(f.SFNT) } + +// sfntSubsettable 判断默认资源后端能否无损裁剪该字体 +// 入参: sfnt 已解析字体 +// 返回: bool 是否支持裁剪 +func sfntSubsettable(sfnt *font.SFNT) bool { + return sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) +} + +// sfntFontName 获取字体中的名称 +// 入参: sfnt 字体, names 名称类型,按顺序查找 +// 返回: string 字体名称 +func sfntFontName(sfnt *font.SFNT, names ...font.NameID) string { + for _, name := range names { + for _, record := range sfnt.Name.Get(name) { + if value := record.String(); value != "" { + return value + } + } + } + return "" +} + +// subsetSFNTFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令 +// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号 +// 返回: []byte 字体子集, error 错误信息 +func subsetSFNTFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) { + sfnt, err := font.ParseSFNT(bytes.Clone(data), 0) + if err != nil { + return nil, err + } + if mapped { + usage := newEditorFontUsage() + for _, id := range glyphs { + usage.glyphs[id] = true + for _, char := range sfnt.Cmap.ToUnicode(id) { + usage.chars[char] = true + } + } + tables := make(map[string][]byte) + for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} { + if table := sfnt.Tables[tag]; table != nil { + tables[tag] = table + } + } + fixed, err := serializeOTF(tables) + if err != nil { + return nil, err + } + if subset := subsetSourceSFNTFont(fixed, usage); subset != nil { + return subset, nil + } + return fixed, nil + } + subset, err := sfnt.Subset(glyphs, font.SubsetOptions{Tables: []string{ + "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", + }}) + if err != nil { + return nil, err + } + subset.Tables["name"] = sfnt.Tables["name"] + clear(subset.Tables["head"][8:12]) + return serializeOTF(subset.Tables) +} + +// subsetSourceSFNTFont 裁剪静态TrueType原有字体,保留字形编号、复合依赖、度量及提示指令 +// 集合、字形替换、可变、彩色和未知表保持原样,不对不完整的引用或异常字体猜测修复 +// 入参: data 字体数据, usage 全包用字记录 +// 返回: []byte 更小的字体子集,无确定收益时为空 +func subsetSourceSFNTFont(data []byte, usage *editorFontUsage) []byte { + if usage.unsafe || len(usage.chars)+len(usage.glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) { + return nil + } + sfnt, err := font.ParseSFNT(data, 0) + if err != nil || !sfnt.IsTrueType { + return nil + } + for tag := range sfnt.Tables { + switch tag { + case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM": + default: + return nil + } + } + var unicodeTables []uint16 + subtables := make(map[uint32]uint16) + for i, record := range sfnt.Cmap.EncodingRecords { + if record.Format == 14 { + return nil + } + offset := binary.BigEndian.Uint32(sfnt.Tables["cmap"][8+8*i:]) + index, ok := subtables[offset] + if !ok { + index = uint16(len(subtables)) + subtables[offset] = index + } + if record.PlatformID == 0 || record.PlatformID == 3 && (record.EncodingID == 1 || record.EncodingID == 10) { + unicodeTables = append(unicodeTables, index) + } + } + if len(unicodeTables) == 0 { + return nil + } + glyphs := maps.Clone(usage.glyphs) + glyphs[0] = true + mapping := make(map[rune]uint16, len(usage.chars)) + for char := range usage.chars { + id := sfnt.GlyphIndex(char) + for _, index := range unicodeTables { + if alternate, ok := sfnt.Cmap.Subtables[index].Get(char); ok && alternate != id { + return nil + } + } + glyphs[id] = true + if id != 0 { + mapping[char] = id + } + } + for id := range glyphs { + if id >= sfnt.NumGlyphs() { + return nil + } + dependencies, err := sfnt.Glyf.Dependencies(id) + if err != nil { + return nil + } + for _, dependency := range dependencies { + glyphs[dependency] = true + } + } + var glyf, loca []byte + for id := 0; id <= int(sfnt.NumGlyphs()); id++ { + if sfnt.Head.IndexToLocFormat == 0 { + loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2)) + } else { + loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf))) + } + if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] { + glyf = append(glyf, sfnt.Glyf.Get(uint16(id))...) + if len(glyf)%2 != 0 { + glyf = append(glyf, 0) + } + } + } + tables := maps.Clone(sfnt.Tables) + tables["glyf"], tables["loca"] = glyf, loca + tables["cmap"] = buildCmapTable(uint16(sfnt.NumGlyphs()), mapping) + tables["head"] = bytes.Clone(tables["head"]) + clear(tables["head"][8:12]) + delete(tables, "DSIG") + result, err := serializeOTF(tables) + if err != nil || len(result) >= len(data) { + return nil + } + return result +} diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index ce29693..58a36a5 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -40,7 +40,7 @@ func init() { // 返回: RenderBackends 默认后端组合 func defaultRenderBackends() RenderBackends { backend := CanvasBackend{} - return RenderBackends{Resources: SFNTBackend{}, Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} + return RenderBackends{FontResources: SFNTBackend{}, Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} } // Name 返回后端标识