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 返回后端标识