// 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 ( "archive/zip" "bytes" "context" "crypto/sha256" "encoding/xml" "errors" "fmt" "maps" "path" "slices" "strconv" "strings" ) const ( // FontKeep 保留原有嵌入方式 FontKeep FontMode = iota // FontEmbed 嵌入可精确匹配的外部字体 FontEmbed // FontExternal 移除可安全恢复字符映射的内嵌字体 FontExternal ) // FontMode 指定字体资源的保存方式,不改变文字位置或压缩选项 type FontMode uint8 // FontChange 返回单个字体的处理结果,Document为零基文档索引,Reason非空表示保持原样 type FontChange struct { Document int `json:"document"` ID string `json:"id"` Name string `json:"name"` Changed bool `json:"changed"` Reason string `json:"reason,omitempty"` } // FontProcessOptions 设置全包字体处理方式及进度回调 // OnProgress按prepare、scan、fonts、commit阶段同步回报进度,total为0表示总量未知 // 回调返回错误时不提交修改,不可在回调中修改编辑器 type FontProcessOptions struct { Mode FontMode OnProgress func(stage string, completed, total int) error } // editorFontChange 汇总同一文档的字体修改,XML偏移均基于修改前的文件 type editorFontChange struct { patches map[string][]editorXMLPatch files map[string][]byte } // editorBindingFontCache 仅保留当前文档最近读取的字体文件及最近解析结果,操作结束后释放 type editorBindingFontCache struct { source string data []byte checksum [32]byte name string index int font *FontResource } // EmbedFont 嵌入当前文档指定字体,保留资源标识及全部使用位置,成功后可撤销 // file为空时从编辑器字体来源精确匹配;显式文件表示调用方确认替换,仍校验字符和字形 // 入参: ctx 取消上下文, id 字体标识, file 字体文件, index 集合索引 // 返回: error 字体、映射或包结构错误,失败时文档不变 func (e *Editor) EmbedFont(ctx context.Context, id string, file FontFile, index int) error { return e.changeFont(ctx, id, FontEmbed, file, index) } // ExternalizeFont 将当前文档指定字体改为外部引用,不保证其他设备具备该字体 // 仅移除可由原字体字符映射等价恢复的显式字形变换,无法确认的映射保持内嵌并返回错误 // 入参: ctx 取消上下文, id 字体标识 // 返回: error 字体、映射或包结构错误,失败时文档不变 func (e *Editor) ExternalizeFont(ctx context.Context, id string) error { return e.changeFont(ctx, id, FontExternal, FontFile{}, 0) } // changeFont 在独立包快照中修改单个字体,成功后一次提交 // 入参: ctx 取消上下文, id 字体标识, mode 保存方式, file 字体数据, index 集合索引 // 返回: error 检查或提交错误 func (e *Editor) changeFont(ctx context.Context, id string, mode FontMode, file FontFile, index int) error { if err := ctx.Err(); err != nil { return err } reader, err := e.Reader() if err != nil { return err } scan, err := scanEditorFonts(ctx, reader, reader.DocumentIndex()) if err != nil { return err } changed, err := e.changeFontSnapshot(ctx, scan, id, mode, file, index) if err != nil || !changed { return err } if mode == FontExternal { if err := pruneExternalFontFiles(ctx, reader, scan.fonts[editorResourceID(id)].location); err != nil { return err } } return e.commitFontSnapshot(ctx, reader) } // ProcessFonts 处理包内各文档的字体,逐字体保留不能转换的资源,取消或包结构错误时整体不提交 // 返回成功与未处理明细,整个操作计入一条撤销记录 // 入参: ctx 取消上下文, mode 保存方式 // 返回: []FontChange 字体处理结果, error 取消或结构错误 func (e *Editor) ProcessFonts(ctx context.Context, mode FontMode) ([]FontChange, error) { return e.ProcessFontsWithOptions(ctx, FontProcessOptions{Mode: mode}) } // ProcessFontsWithOptions 处理包内字体并回报已处理数量,失败或取消时不提交修改 // 入参: ctx 取消上下文, options 字体处理选项 // 返回: []FontChange 字体处理结果, error 取消、回调或结构错误 func (e *Editor) ProcessFontsWithOptions(ctx context.Context, options FontProcessOptions) ([]FontChange, error) { mode := options.Mode if mode > FontExternal { return nil, fmt.Errorf("invalid font mode") } if err := ctx.Err(); err != nil { return nil, err } if mode == FontKeep { return nil, nil } progress := func(stage string, completed, total int) error { if err := ctx.Err(); err != nil { return err } if err := editorProgress(options.OnProgress).report(stage, completed, total); err != nil { return err } return ctx.Err() } if err := progress("prepare", 0, 0); err != nil { return nil, err } reader, err := e.Reader() if err != nil { return nil, err } var report []FontChange var externalFiles []string changed := false for document := range reader.DocumentCount() { if err := progress("scan", document, reader.DocumentCount()); err != nil { return nil, err } scan, err := scanEditorFonts(ctx, reader, document) if err != nil { return nil, err } pending := &editorFontChange{patches: make(map[string][]editorXMLPatch), files: make(map[string][]byte)} ids := slices.Sorted(maps.Keys(scan.fonts)) ids = slices.DeleteFunc(ids, func(id string) bool { return (mode == FontEmbed) == (scan.fonts[id].location != "") }) total := 0 if document == reader.DocumentCount()-1 { total = len(report) + len(ids) } for _, id := range ids { if err := ctx.Err(); err != nil { return nil, err } declaration := scan.fonts[id] if err := progress("fonts", len(report), total); err != nil { return nil, err } item := FontChange{Document: document, ID: id, Name: declaration.font.FontName} change, err := e.prepareFontBinding(ctx, scan, id, mode, FontFile{}, 0, pending.files) if err != nil { if ctx.Err() != nil { return nil, ctx.Err() } item.Reason = err.Error() } item.Changed = change != nil if change != nil { for name, patches := range change.patches { pending.patches[name] = append(pending.patches[name], patches...) } maps.Copy(pending.files, change.files) } report = append(report, item) if err := progress("fonts", len(report), total); err != nil { return nil, err } if item.Changed { changed = true if mode == FontExternal { externalFiles = append(externalFiles, declaration.location) } } } if err := pending.apply(ctx, scan); err != nil { return nil, err } } if err := progress("fonts", len(report), len(report)); err != nil { return nil, err } if changed { if err := progress("commit", 0, 1); err != nil { return nil, err } if err := pruneExternalFontFiles(ctx, reader, externalFiles...); err != nil { return nil, err } err = e.commitFontSnapshot(ctx, reader) } return report, err } // changeFontSnapshot 校验字体全部文字引用并修改独立阅读器,不改写正文定位 // 入参: ctx 取消上下文, scan 文档引用, id 字体标识, mode 保存方式, file 字体文件, index 集合索引 // 返回: bool 是否修改, error 字体或结构错误 func (e *Editor) changeFontSnapshot(ctx context.Context, scan *editorFontDocument, id string, mode FontMode, file FontFile, index int) (bool, error) { change, err := e.prepareFontBinding(ctx, scan, id, mode, file, index, nil) if err != nil || change == nil { return false, err } if err := change.apply(ctx, scan); err != nil { return false, err } return true, nil } // prepareFontBinding 校验单个字体并生成独立修改集,不改变文档及已准备的资源 // 入参: ctx 取消上下文, scan 文档引用, id 字体标识, mode 保存方式, file 字体文件, index 集合索引, pending 已准备的资源文件 // 返回: *editorFontChange 字体修改,无需处理时为nil, error 字体或结构错误 func (e *Editor) prepareFontBinding(ctx context.Context, scan *editorFontDocument, id string, mode FontMode, file FontFile, index int, pending map[string][]byte) (*editorFontChange, error) { declaration := scan.fonts[editorResourceID(id)] if declaration == nil { return nil, fmt.Errorf("font not found: %s", id) } if (mode == FontEmbed) == (declaration.location != "") { return nil, nil } if scan.unsafe { return nil, fmt.Errorf("font references include unsupported content") } if e.backends.FontResources == nil { return nil, ErrBackendUnavailable } var data []byte var err error explicit := mode == FontEmbed && len(file.Data) != 0 if mode == FontExternal { data, err = scan.fontCache.read(ctx, scan.reader, declaration.location) if err != nil { return nil, err } file = FontFile{Data: data} if bytes.HasPrefix(data, []byte("ttcf")) { index = fontCollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic) } } else if len(file.Data) == 0 { file, err = e.resolveBindingFont(scan, declaration) if err != nil { return nil, err } index = 0 } parsed, err := scan.fontCache.open(ctx, e.backends.FontResources, file, index) if err != nil { return nil, err } if mode == FontEmbed { if err := validateFontEmbedding(parsed.Data); err != nil { return nil, err } } change := &editorFontChange{patches: make(map[string][]editorXMLPatch), files: make(map[string][]byte)} verifiedGlyphs := false for _, text := range scan.texts[editorResourceID(id)] { if err := ctx.Err(); err != nil { return nil, err } file, node := text.file, text.node var object TextObject if err := xml.Unmarshal(file.data[node.start:node.end], &object); err != nil { return nil, err } if err := checkFontBinding(object, parsed, mode); err != nil { return nil, err } if explicit && len(object.CGTransform) != 0 && !verifiedGlyphs { source, err := e.resolveBindingFont(scan, declaration) if err != nil || !sameBindingFont(source.Data, parsed.Data) { return nil, fmt.Errorf("font glyph mapping requires the original font") } verifiedGlyphs = true } if mode == FontExternal { for _, child := range node.children { if child.matchesOFD("CGTransform") { change.patches[file.name] = append(change.patches[file.name], editorXMLPatch{start: child.start, end: child.end}) } } } } data = declaration.file.data node := declaration.node fontFile := node.child("FontFile") var fragment []byte if mode == FontEmbed { digest := sha256.Sum256(parsed.Data) name, reused, err := fontBindingFile(ctx, scan.reader, pending, fmt.Sprintf("Fonts/%x%s", digest, parsed.Extension), parsed.Data) if err != nil { return nil, err } if !reused { change.files[name] = bytes.Clone(parsed.Data) } content := node.textXML("FontFile", "/"+name) if fontFile != nil { fragment = editorPatchXML(data[node.start:node.end], []editorXMLPatch{{fontFile.start - node.start, fontFile.end - node.start, content}}) } else if node.open == node.end { patch := editorXMLContent(data, node, content) patch.start -= node.start patch.end -= node.start fragment = editorPatchXML(data[node.start:node.end], []editorXMLPatch{patch}) } else { fragment = editorPatchXML(data[node.start:node.end], []editorXMLPatch{{node.close - node.start, node.close - node.start, content}}) } } else { if fontFile == nil { return nil, fmt.Errorf("font file declaration is missing") } fragment = editorPatchXML(data[node.start:node.end], []editorXMLPatch{{fontFile.start - node.start, fontFile.end - node.start, nil}}) for _, field := range []string{"FontName", "FamilyName"} { root, err := parseEditorXML(fragment) if err != nil { return nil, err } value := root.attr(field) if stripped := externalFontName(value); stripped != value { fragment, err = editorXMLAttribute(fragment, root, field, stripped) if err != nil { return nil, err } } } } if explicit { for _, field := range []struct{ name, value string }{{"FontName", parsed.Font.FontName}, {"FamilyName", parsed.Font.FamilyName}, {"Charset", "unicode"}} { root, err := parseEditorXML(fragment) if err != nil { return nil, err } fragment, err = editorXMLAttribute(fragment, root, field.name, field.value) if err != nil { return nil, err } } } if err := ctx.Err(); err != nil { return nil, err } change.patches[declaration.file.name] = append(change.patches[declaration.file.name], editorXMLPatch{node.start, node.end, fragment}) return change, nil } // apply 合并同一文档的XML修改,再统一隔离其他文档及版本使用的文件 // 入参: ctx 取消上下文, scan 修改前的文档引用 // 返回: error 取消、引用或结构错误 func (c *editorFontChange) apply(ctx context.Context, scan *editorFontDocument) error { if err := ctx.Err(); err != nil { return err } if len(c.patches) == 0 && len(c.files) == 0 { return nil } changes := maps.Clone(c.files) for name, patches := range c.patches { if err := ctx.Err(); err != nil { return err } changes[name] = editorPatchXML(scan.files[name].data, patches) } return scan.apply(ctx, changes) } // read 复用当前文档最近读取的字体,避免共享引用重复解压,不缓存错误或取消结果 // 入参: ctx 取消上下文, reader 包快照, name 字体路径 // 返回: []byte 只读字体数据, error 读取或取消错误 func (c *editorBindingFontCache) read(ctx context.Context, reader *Reader, name string) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err } name = cleanPackagePath(name) if c.source == name && c.data != nil { return c.data, nil } c.source, c.data = "", nil data, err := reader.readFileViewContext(ctx, name) if err != nil { return nil, err } if err := ctx.Err(); err != nil { return nil, err } c.source, c.data = name, data return data, nil } // open 按字体内容、名称及集合索引复用最近的解析结果,不缓存错误或取消结果 // 入参: ctx 取消上下文, backend 字体资源后端, file 字体文件, index 集合索引 // 返回: *FontResource 只读字体资源, error 解析或取消错误 func (c *editorBindingFontCache) open(ctx context.Context, backend FontResourceBackend, file FontFile, index int) (*FontResource, error) { if err := ctx.Err(); err != nil { return nil, err } checksum := sha256.Sum256(file.Data) if c.font != nil && c.checksum == checksum && c.name == file.Name && c.index == index { return c.font, ctx.Err() } c.font = nil font, err := backend.OpenFontResource(file, index) if err != nil { return nil, err } if err := ctx.Err(); err != nil { return nil, err } c.checksum, c.name, c.index, c.font = checksum, file.Name, index, font return font, nil } // fontBindingFile 查找未占用的字体路径,同名内容一致时复用已有文件或本次待写资源 // 入参: ctx 取消上下文, reader 包快照, pending 待写资源, name 期望路径, data 字体内容 // 返回: string 包内路径, bool 是否复用, error 取消错误 func fontBindingFile(ctx context.Context, reader *Reader, pending map[string][]byte, name string, data []byte) (string, bool, error) { base, extension := strings.TrimSuffix(name, path.Ext(name)), path.Ext(name) for i := 0; ; i++ { if err := ctx.Err(); err != nil { return "", false, err } candidate := name if i != 0 { candidate = base + "_" + strconv.Itoa(i) + extension } existing, occupied := pending[candidate] if !occupied { existing, occupied = reader.files[candidate] if !occupied { _, occupied = reader.packageFile(candidate) if occupied { var err error existing, err = reader.readFileViewContext(ctx, candidate) if err != nil { continue } } } } if !occupied || bytes.Equal(existing, data) { return candidate, occupied, ctx.Err() } } } // sameBindingFont 比较独立字体的全部数据表,忽略重新封装产生的校验和差异 // 入参: source 原字体, target 待嵌入字体 // 返回: bool 是否保留原字体内容及字形编号 func sameBindingFont(source, target []byte) bool { a, err := fontFileTables(source, 0) if err != nil { return false } b, err := fontFileTables(target, 0) if err != nil || len(a) != len(b) { return false } for tag, data := range a { other := b[tag] if tag == "head" && len(data) >= 12 && len(other) >= 12 { if !bytes.Equal(data[:8], other[:8]) || !bytes.Equal(data[12:], other[12:]) { return false } } else if !bytes.Equal(data, other) { return false } } return true } // resolveBindingFont 精确匹配当前字体并提取独立字体数据,集合索引不传递到提取结果 // 入参: scan 文档引用, declaration 字体声明 // 返回: FontFile 独立字体, error 匹配或解析错误 func (e *Editor) resolveBindingFont(scan *editorFontDocument, declaration *editorFontDeclaration) (FontFile, error) { reader := &Reader{Zip: scan.reader.Zip, files: maps.Clone(scan.reader.files), documentIndex: scan.index} if scan.index == scan.reader.documentIndex { reader.selectedVersion = scan.reader.selectedVersion } if err := reader.initRoot(); err != nil { return FontFile{}, err } if _, err := reader.Doc(); err != nil { return FontFile{}, err } definition := declaration.font definition.FontFile = "" reader.fontCache[definition.ID] = &definition resolved, err := e.newRenderer(reader).ResolveFont(definition.ID, true) if err != nil { return FontFile{}, err } if !resolved.Exact { return FontFile{}, fmt.Errorf("exact font is unavailable") } file := FontFile{Data: resolved.Data} index := 0 if bytes.HasPrefix(file.Data, []byte("ttcf")) && resolved.Face != nil { index = resolved.Face.Index } data, err := file.Face(index) return FontFile{Data: data}, err } // checkFontBinding 核对字符覆盖及字形编号,外置仅允许等价的一对一字符映射 // 入参: object 文字对象, font 目标字体, mode 保存方式 // 返回: error 字符或字形无法保留的原因 func checkFontBinding(object TextObject, font *FontResource, mode FontMode) error { var chars []rune for _, code := range object.TextCode { if code.Index != "" { return fmt.Errorf("font uses index-encoded text") } chars = append(chars, textCodeRunes(code.Value)...) } covered := make(map[int]bool) for _, transform := range object.CGTransform { values := strings.Fields(transform.Glyphs) if transform.CodePosition < 0 || transform.CodeCount < 1 || transform.GlyphCount < 1 || transform.CodePosition > len(chars)-transform.CodeCount || transform.GlyphCount != len(values) { return fmt.Errorf("invalid font glyph mapping") } if mode == FontExternal && transform.CodeCount != transform.GlyphCount { return fmt.Errorf("font uses non-character glyph mapping") } for i, value := range values { glyph, err := strconv.ParseUint(value, 10, 16) if err != nil || glyph == 0 || glyph >= uint64(font.NumGlyphs()) { return fmt.Errorf("font glyph is unavailable") } if mode == FontExternal && uint16(glyph) != font.GlyphIndex(chars[transform.CodePosition+i]) { return fmt.Errorf("font uses non-character glyph mapping") } } for i := transform.CodePosition; i < transform.CodePosition+transform.CodeCount; i++ { if covered[i] { return fmt.Errorf("overlapping font glyph mapping") } covered[i] = true } } for i, char := range chars { if mode == FontExternal && (char >= 0xe000 && char <= 0xf8ff || char >= 0xf0000 && char <= 0x10fffd) { return fmt.Errorf("font uses private character mapping") } if !covered[i] && font.GlyphIndex(char) == 0 { return fmt.Errorf("font has no glyph for U+%04X", char) } } return nil } // externalFontName 去除PDF字体子集前缀,不猜测或改写其他名称 // 入参: name 字体名称 // 返回: string 外部匹配名称 func externalFontName(name string) string { if len(name) > 7 && name[6] == '+' { for _, char := range name[:6] { if char < 'A' || char > 'Z' { return name } } return name[7:] } return name } // commitFontSnapshot 重新建立资源缓存并记录包快照,避免旧字体缓存跨撤销或重做复用 // 入参: ctx 取消上下文, reader 修改后的独立快照 // 返回: error 重建错误 func (e *Editor) commitFontSnapshot(ctx context.Context, reader *Reader) error { if err := ctx.Err(); err != nil { return err } clean := &Reader{Zip: reader.Zip, files: maps.Clone(reader.files), documentIndex: reader.DocumentIndex(), encryption: e.encryption, selectedVersion: reader.selectedVersion} if err := clean.initRootContext(ctx); err != nil { return err } next, err := clean.Editor() if err != nil { return err } if err := ctx.Err(); err != nil { return err } before := e.transactionSnapshot() e.restoreFontSnapshot(*next) if change := e.recordChange(); change != nil { after := e.transactionSnapshot() change.undo = func(e *Editor) { e.restoreFontSnapshot(before) } change.redo = func(e *Editor) { e.restoreFontSnapshot(after) } } return nil } // restoreFontSnapshot 恢复字体修改前后的包状态,保留运行配置与撤销队列 // 入参: state 包快照 func (e *Editor) restoreFontSnapshot(state Editor) { state = state.transactionSnapshot() state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit state.serial, state.revision = e.serial, e.revision state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress state.fontRenderer, state.fontSourcesCache = nil, nil state.fontMetrics = make(map[string]FontMetrics) *e = state } // pruneExternalFontFiles 流式核对全包字体引用并同步版本清单,保留附件及无法确认的扩展引用 // 入参: ctx 取消上下文, reader 包快照, candidates 待清理的字体路径 // 返回: error 取消或读取错误 func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...string) error { if err := ctx.Err(); err != nil { return err } removed := make(map[string]bool, len(candidates)) for _, candidate := range candidates { if key := cleanPackagePath(candidate); key != "" { removed[key] = true } } if len(removed) == 0 { return nil } indexes := make([]int, reader.DocumentCount()) for index := range indexes { if err := ctx.Err(); err != nil { return err } indexes[index] = index } _, structures, err := reader.documentDependencies(ctx, indexes...) if err != nil { return ctx.Err() } structures["OFD.xml"] = true files := make(map[string]bool) versions := make(map[string][]versionEditLink) names := slices.Sorted(maps.Keys(structures)) for _, name := range slices.Sorted(maps.Keys(reader.fileNames)) { if !structures[name] && strings.HasSuffix(strings.ToLower(name), ".xml") { names = append(names, name) } } for _, name := range names { if err := ctx.Err(); err != nil { return err } if files[name] && !structures[name] { continue } root, uncertain := "", false inspect := func(node xml.StartElement) error { if root == "" { root = node.Name.Local } if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.Name.Local) { uncertain = true return fmt.Errorf("unknown font file dependencies in %s", name) } for _, attr := range node.Attr { if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" { uncertain = true return fmt.Errorf("unknown font file dependencies in %s", name) } } return nil } add := func(location string, kind int) { location = cleanPackagePath(location) if root == "DocVersion" && kind == packageReferenceData { return } delete(removed, location) if kind == packageReferenceData { files[location] = true } } record := func(node *editorXML, attribute, target string) { if root == "DocVersion" && node.name.Local == "File" && attribute == "" && removed[target] { versions[name] = append(versions[name], versionEditLink{node: node, target: target}) } } if err := reader.scanDocumentFileReferences(ctx, name, true, add, record, inspect); err != nil { var syntax *xml.SyntaxError if uncertain || errors.As(err, &syntax) { return ctx.Err() } return err } if len(removed) == 0 { return ctx.Err() } } if err := ctx.Err(); err != nil { return err } updates := make(map[string][]byte) for name, links := range versions { if err := ctx.Err(); err != nil { return err } var patches []editorXMLPatch for _, link := range links { if err := ctx.Err(); err != nil { return err } if removed[link.target] { patches = append(patches, editorXMLPatch{start: link.node.start, end: link.node.end}) } } if len(patches) != 0 { data, err := reader.readFileViewContext(ctx, name) if err != nil { return err } updates[name] = editorPatchXML(data, patches) } } if err := ctx.Err(); err != nil { return err } view := *reader view.files = maps.Clone(reader.files) if view.files == nil { view.files = make(map[string][]byte) } maps.Copy(view.files, updates) for name := range view.files { if err := ctx.Err(); err != nil { return err } if removed[cleanPackagePath(name)] { delete(view.files, name) } } if view.Zip != nil { view.Zip = &zip.Reader{Comment: view.Zip.Comment, File: slices.DeleteFunc(slices.Clone(view.Zip.File), func(file *zip.File) bool { return removed[cleanPackagePath(file.Name)] })} } if err := view.initRootContext(ctx); err != nil { return err } *reader = view return nil }