From 81e6657c6515b9f15fa35dad7076ded6bcf4e77f Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 24 Sep 2026 10:13:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E7=AB=AF=E6=9E=B6=E6=9E=84):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=8E=E7=AB=AF=E6=89=A9=E5=B1=95=E4=B8=8E?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E8=B5=84=E6=BA=90=E8=A7=A3=E8=80=A6=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=83=E6=95=B0=E6=8D=AE=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=92=8C=E7=BC=A9=E7=95=A5=E5=9B=BE=E5=BA=95=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 2 +- ofdgo_backend.go | 83 ++++++++++++++++-------- ofdgo_editor.go | 64 ++++++------------- ofdgo_editor_font.go | 15 ++++- ofdgo_editor_package.go | 99 ++++++++++++++++++++++++++-- ofdgo_editor_resources.go | 9 ++- ofdgo_font_resource.go | 131 ++++++++++++++++++++++++++++++++++++++ ofdgo_renderer_canvas.go | 14 ++++ 8 files changed, 337 insertions(+), 80 deletions(-) create mode 100644 ofdgo_font_resource.go diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 1b99071..279ca9b 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -5714,7 +5714,7 @@ function layoutThumbnails() { } view.offsets = offsets; view.gap = 12; - view.total = Math.max(0, offsets.at(-1) - 12); + view.total = Math.ceil(offsets.at(-1)); for (const [index, button] of view.nodes) { layoutThumbnail(button, state.doc.pages[index]); button.style.height = `${offsets[index + 1] - offsets[index] - 12}px`; diff --git a/ofdgo_backend.go b/ofdgo_backend.go index 0365b57..442572e 100644 --- a/ofdgo_backend.go +++ b/ofdgo_backend.go @@ -20,6 +20,7 @@ import ( "image" "io" "reflect" + "sync" ) // ErrBackendUnavailable 表示未配置所需后端,不会自动切换其他实现 @@ -105,53 +106,83 @@ type EPSBackend interface { // RenderBackends 按职责组合后端,nil表示明确禁用,不隐式回退 // 实例及其Renderer需串行使用,第三方实现无需引用任何内置绘图库 type RenderBackends struct { - Fonts FontBackend - Geometry GeometryBackend - Compiler PageCompiler - Raster RasterBackend - SVG SVGBackend - PDF PDFBackend - EPS EPSBackend + Resources FontResourceBackend + Fonts FontBackend + Geometry GeometryBackend + Compiler PageCompiler + Raster RasterBackend + SVG SVGBackend + PDF PDFBackend + EPS EPSBackend } // BackendInfo 描述各项能力实际使用的后端,空标识表示未配置 type BackendInfo struct { - 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"` + 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 内置后端标识 +// NewRenderBackends 创建已注册后端组合,各项能力通过Info报告实际提供者 +// 入参: name 已注册后端标识 // 返回: RenderBackends 后端组合, error 未知后端错误 func NewRenderBackends(name string) (RenderBackends, error) { - if name != "canvas" { + backendRegistry.RLock() + factory := backendRegistry.factories[name] + backendRegistry.RUnlock() + if factory == nil { return RenderBackends{}, fmt.Errorf("unknown render backend %q", name) } - return defaultRenderBackends(), nil + return factory(), nil } -// RenderBackendInfos 列出内置组合及其实际能力,供原生程序和WASM共用 +// RenderBackendInfos 列出已注册组合及其实际能力,供原生程序和WASM共用 // 返回: map[string]BackendInfo 后端能力 func RenderBackendInfos() map[string]BackendInfo { - return map[string]BackendInfo{"canvas": defaultRenderBackends().Info()} + 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 } -// defaultRenderBackends 创建独立的默认配置 -// 返回: RenderBackends 默认后端组合 -func defaultRenderBackends() RenderBackends { - backend := CanvasBackend{} - return RenderBackends{Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} +// 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 } // Info 返回配置中各能力的提供者,不依赖WebUI推断 // 返回: BackendInfo 后端能力 func (b RenderBackends) Info() BackendInfo { - return BackendInfo{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{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)} } // backendName 获取已配置的后端标识 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 103864d..14c1786 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -31,7 +31,6 @@ import ( "time" "unicode/utf8" - "github.com/tdewolff/font" "github.com/xiaoqidun/jbig2" ) @@ -45,7 +44,7 @@ type Editor struct { Info DocInfo pages []PageContent resources []editorResource - fonts map[string]*font.SFNT + fonts map[string]*FontResource fontFS []fs.FS fontRenderer *Renderer fontMetrics map[string]FontMetrics @@ -86,6 +85,12 @@ 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) { + e.fonts = make(map[string]*FontResource) + for i := range e.resources { + e.resources[i].subset = nil + } + } e.backends = backends if e.fontRenderer != nil { WithRenderBackends(backends)(e.fontRenderer) @@ -150,7 +155,7 @@ func NewEditor() *Editor { DocID: hex.EncodeToString(id[:]), CreationDate: time.Now().Format("2006-01-02"), }, - fonts: make(map[string]*font.SFNT), + fonts: make(map[string]*FontResource), images: make(map[string]image.Point), resourceID: make(map[editorResourceKey]string), fontMetrics: make(map[string]FontMetrics), @@ -564,67 +569,36 @@ func (e *Editor) nextID() string { // 入参: file 字体文件, index 集合内字体索引,单字体为0 // 返回: string 字体资源标识, error 错误信息 func (e *Editor) AddFont(file FontFile, index int) (string, error) { - data, err := font.ToSFNT(file.Data) + 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 } - key := editorResourceKey{checksum: sha256.Sum256(data), index: index} + data := parsed.Data + key := editorResourceKey{checksum: sha256.Sum256(data)} if id, ok := e.resourceID[key]; ok { return id, nil } - data, err = (FontFile{Data: data}).Face(index) - if err != nil { - return "", err - } - sfnt, err := font.ParseSFNT(data, 0) - if err != nil { - return "", err - } - name := editorFontName(sfnt, font.NamePostScript, font.NameFull, font.NameFontFamily) - if name == "" { - return "", fmt.Errorf("font has no name") - } if err := e.prepareSourceIDs(); err != nil { return "", err } id := e.nextID() - extension := ".ttf" - if sfnt.IsCFF { - extension = ".otf" - } + definition := parsed.Font + definition.ID = id resource := editorResource{ - name: e.packageName("Res/Fonts/Font_" + id + extension), + name: e.packageName("Res/Fonts/Font_" + id + parsed.Extension), data: data, - font: &Font{ - ID: id, 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, - }, + font: &definition, } resource.font.FontFile = "/" + resource.name e.resources = append(e.resources, resource) - e.fonts[id] = sfnt + e.fonts[id] = parsed e.resourceID[key] = id return id, 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 "" -} - // AddImage 注册PNG或JPEG图片,重复资源复用标识,引用后写入文档 // 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码 // 入参: data 图片数据 diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 005c24a..39d02ba 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -187,6 +187,9 @@ type editorFontSubset struct { // 入参: progress 保存进度回调 // 返回: map[string][]byte 包内字体子集, error 错误信息 func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) { + if e.backends.Resources == nil { + return nil, nil + } used := make(map[string]map[uint16]bool) mapped := make(map[string]bool) for _, resource := range e.resources { @@ -194,7 +197,15 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) continue } sfnt := e.fonts[resource.font.ID] - if sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) { + if sfnt == nil { + var err error + sfnt, err = e.backends.Resources.OpenFontResource(FontFile{Data: resource.data}, 0) + if err != nil { + return nil, err + } + e.fonts[resource.font.ID] = sfnt + } + if sfnt.CanSubset { used[resource.font.ID] = make(map[uint16]bool) } } @@ -309,7 +320,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 := subsetEditorFont(resource.data, ids, mapped[resource.font.ID]) + data, err := e.backends.Resources.SubsetFont(resource.data, ids, mapped[resource.font.ID]) if err != nil { return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err) } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index ab746d0..5b65dbb 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -441,9 +441,6 @@ func (e *Editor) sourceInfoXML() ([]byte, error) { return nil, fmt.Errorf("document has no DocInfo") } before, after := e.source.info, e.Info - if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) { - return nil, fmt.Errorf("editing imported custom metadata is not supported") - } var values [][2]string for _, field := range [][3]string{ {"DocID", before.DocID, after.DocID}, {"Title", before.Title, after.Title}, {"Author", before.Author, after.Author}, @@ -454,7 +451,101 @@ func (e *Editor) sourceInfoXML() ([]byte, error) { values = append(values, [2]string{field[0], field[2]}) } } - return editorXMLSetText(data, info, values), nil + 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条目直接复制到新包,逐项写入改动,不持有原资源解压副本 diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go index 02941fa..b556619 100644 --- a/ofdgo_editor_resources.go +++ b/ofdgo_editor_resources.go @@ -18,6 +18,7 @@ import ( "bufio" "bytes" "encoding/xml" + "fmt" "io" "maps" "path" @@ -172,14 +173,18 @@ func (e *Editor) compactSourceResources(parts map[string][]byte, progress editor } key := strings.ToLower(cleanPackagePath(name)) usage := fontFiles[key] - if usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil { + if e.backends.Resources == 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 } - if subset := subsetSourceFont(data, usage); subset != nil { + 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}) + if err != nil { + return nil, fmt.Errorf("subset font %s: %w", name, err) + } + if subset != nil { updates[name] = subset } } diff --git a/ofdgo_font_resource.go b/ofdgo_font_resource.go new file mode 100644 index 0000000..8fe8dbf --- /dev/null +++ b/ofdgo_font_resource.go @@ -0,0 +1,131 @@ +// 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" + "slices" + + "github.com/tdewolff/font" +) + +// FontResource 保存独立字体数据、标准元信息与只读度量,不包含绘图库类型 +type FontResource struct { + FontMetrics + Data []byte + Font Font + Extension string + CanSubset bool +} + +// FontUsage 描述全包已确认的字体引用,Unsafe禁止裁剪 +type FontUsage struct { + Characters []rune + Glyphs []uint16 + Unsafe bool +} + +// FontResourceBackend 解析和裁剪保存资源,独立于显示字体后端 +// SubsetFont的preserve为true时不得改变字形编号,SubsetSourceFont无安全收益时返回nil +type FontResourceBackend interface { + Backend + OpenFontResource(file FontFile, index int) (*FontResource, error) + 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_renderer_canvas.go b/ofdgo_renderer_canvas.go index 05243ed..ce29693 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -29,6 +29,20 @@ type CanvasBackend struct { ColorSpace canvas.ColorSpace } +// init 注册Canvas适配器和默认组合 +func init() { + if err := RegisterRenderBackend("canvas", defaultRenderBackends); err != nil { + panic(err) + } +} + +// defaultRenderBackends 创建默认组合,调用方可按职责替换任意后端 +// 返回: RenderBackends 默认后端组合 +func defaultRenderBackends() RenderBackends { + backend := CanvasBackend{} + return RenderBackends{Resources: SFNTBackend{}, Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend} +} + // Name 返回后端标识 // 返回: string 后端标识 func (CanvasBackend) Name() string { return "canvas" }