feat(编辑搜索): 完善附件编辑与跨页搜索
build.yaml / build (push) Successful in 42s

This commit is contained in:
xiaoqidun committed 2026-09-27 22:31:06 +08:00
1 parent 8d91752943
commit 31e67b2721
5 files changed
+221 -11

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1
github.com/xiaoqidun/pdfgo v0.0.0-20260927125911-04f5476a9051
github.com/xiaoqidun/pdfgo v0.0.0-20260927142759-12ed20917f60
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/text v0.42.0
+2 -2
View File
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260927125911-04f5476a9051 h1:VhoxfD1yFJMBcgThFZCo480VVBEU5dB8qAZXWCUSeqI=
github.com/xiaoqidun/pdfgo v0.0.0-20260927125911-04f5476a9051/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260927142759-12ed20917f60 h1:0JC6RK3DMb11RIVoaSSlprHo9oAZb+YyfUThEsTs0Bk=
github.com/xiaoqidun/pdfgo v0.0.0-20260927142759-12ed20917f60/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+141 -2
View File
@@ -20,14 +20,25 @@ import (
"fmt"
"path"
"strings"
"time"
"unicode/utf8"
)
// Attachments 获取当前编辑状态的附件列表,返回值与编辑器相互独立
// 返回: []Attachment 附件信息, error 结构错误
func (e *Editor) Attachments() ([]Attachment, error) {
if e.source == nil {
return nil, nil
}
return e.source.reader.Attachments()
}
// AddAttachment 添加文档附件,保留原始数据,一次操作计入一条撤销记录
// 入参: name 显示文件名, data 文件数据
// 返回: string 附件标识, error 文件名或文档结构错误
func (e *Editor) AddAttachment(name string, data []byte) (string, error) {
if strings.TrimSpace(name) == "" {
return "", fmt.Errorf("attachment name is required")
if err := validateAttachmentName(name); err != nil {
return "", err
}
if err := e.prepareSourceIDs(); err != nil {
return "", err
@@ -103,3 +114,131 @@ func (e *Editor) AddAttachment(name string, data []byte) (string, error) {
}
return id, nil
}
// RenameAttachment 修改附件显示名称,保留格式、内容及扩展字段,相同名称不产生撤销记录
// 入参: id 附件标识, name 显示名称
// 返回: error 名称或附件结构错误
func (e *Editor) RenameAttachment(id, name string) error {
if err := validateAttachmentName(name); err != nil {
return err
}
return e.editAttachment(id, func(data []byte, node *editorXML, parts map[string][]byte) ([]byte, error) {
if node.attr("Name") == name {
return data, nil
}
return editorXMLAttribute(data, node, "Name", name)
})
}
// ReplaceAttachment 替换附件内容,使用独立资源避免影响共享引用,保留名称和格式
// 入参: id 附件标识, data 文件数据,调用后可复用
// 返回: error 附件结构或提交错误
func (e *Editor) ReplaceAttachment(id string, data []byte) error {
return e.editAttachment(id, func(fragment []byte, node *editorXML, parts map[string][]byte) ([]byte, error) {
if err := e.prepareSourceIDs(); err != nil {
return nil, err
}
file := e.packageName("Attachments/Attachment_" + e.nextID())
var location bytes.Buffer
if err := xml.EscapeText(&location, []byte("/"+file)); err != nil {
return nil, err
}
if child := node.child("FileLoc"); child != nil {
fragment = editorPatchXML(fragment, []editorXMLPatch{editorXMLContent(fragment, child, location.Bytes())})
} else if node.open == node.end {
fragment = editorPatchXML(fragment, []editorXMLPatch{editorXMLContent(fragment, node, editorXMLText("FileLoc", "/"+file))})
} else {
fragment = editorPatchXML(fragment, []editorXMLPatch{{node.open, node.open, editorXMLText("FileLoc", "/"+file)}})
}
for _, attr := range []struct{ name, value string }{
{"Size", ofdNumber(float64(len(data)) / 1024)}, {"ModDate", time.Now().UTC().Format(time.RFC3339)},
} {
root, err := parseEditorXML(fragment)
if err != nil {
return nil, err
}
fragment, err = editorXMLAttribute(fragment, root, attr.name, attr.value)
if err != nil {
return nil, err
}
}
parts[file] = bytes.Clone(data)
return fragment, nil
})
}
// editAttachment 对单个附件片段执行原子编辑,兼容独立附件文件及内联声明
// 入参: id 附件标识, change 片段及资源修改函数
// 返回: error 结构、标识或提交错误
func (e *Editor) editAttachment(id string, change func([]byte, *editorXML, map[string][]byte) ([]byte, error)) error {
base := e.source
if base == nil {
return fmt.Errorf("attachment not found: %s", id)
}
name := cleanPackagePath(base.reader.OFD.DocBody[0].DocRoot)
external := strings.TrimSpace(base.document.Attachments.Path) != ""
if external {
name = base.reader.ResPath(base.document.Attachments.Path)
}
data, err := base.reader.readFile(name)
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
if !external {
root = root.child("Attachments")
}
var found *editorXML
if root != nil {
for _, child := range root.children {
if child.name.Local == "Attachment" && child.attr("ID") == id {
if found != nil {
return fmt.Errorf("duplicate attachment ID: %s", id)
}
found = child
}
}
}
if found == nil {
return fmt.Errorf("attachment not found: %s", id)
}
fragment, err := editorXMLStandalone(data[found.start:found.end], found)
if err != nil {
return err
}
node, err := parseEditorXML(fragment)
if err != nil {
return err
}
parts := make(map[string][]byte)
updated, err := change(fragment, node, parts)
if err != nil {
return err
}
if bytes.Equal(updated, fragment) && len(parts) == 0 {
return nil
}
parts[name] = editorPatchXML(data, []editorXMLPatch{{found.start, found.end, updated}})
return e.commitAnnotationParts(base, parts)
}
// validateAttachmentName 校验非空附件名及XML可无损表示的Unicode字符
// 入参: name 显示名称
// 返回: error 名称错误
func validateAttachmentName(name string) error {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("attachment name is required")
}
if !utf8.ValidString(name) {
return fmt.Errorf("attachment name is not valid UTF-8")
}
for _, char := range name {
if char < 0x20 && char != '\t' && char != '\n' && char != '\r' || char == 0xfffe || char == 0xffff {
return fmt.Errorf("attachment name contains an invalid XML character")
}
}
return nil
}
+10 -2
View File
@@ -21,6 +21,7 @@ import (
"fmt"
"io"
"path"
"slices"
"strings"
)
@@ -600,7 +601,7 @@ func (r *Reader) Bookmarks() ([]Bookmark, error) {
return doc.Bookmarks.Bookmark, nil
}
// Attachments 获取附件列表
// Attachments 获取独立的附件列表,修改返回值不影响阅读器缓存
// 返回: []Attachment 附件列表, error 错误信息
func (r *Reader) Attachments() ([]Attachment, error) {
doc, err := r.Doc()
@@ -624,7 +625,14 @@ func (r *Reader) Attachments() ([]Attachment, error) {
}
doc.Attachments.Attachment = attachments.Attachment
}
return doc.Attachments.Attachment, nil
attachments := slices.Clone(doc.Attachments.Attachment)
for i := range attachments {
if attachments[i].Size != nil {
size := *attachments[i].Size
attachments[i].Size = &size
}
}
return attachments, nil
}
// OpenAttachment 打开附件文件流,调用方负责关闭,完整读取后校验ZIP数据
+67 -4
View File
@@ -15,6 +15,7 @@
package ofdgo
import (
"context"
"encoding/xml"
"fmt"
"io"
@@ -171,16 +172,42 @@ func (p *PageText) writeTo(writer io.Writer, separator bool) (int64, error) {
// 返回: []TextMatch 匹配结果及上下文
func (p *PageText) Search(query string) []TextMatch {
var matches []TextMatch
_ = p.SearchEach(context.Background(), query, func(match TextMatch) error {
matches = append(matches, match)
return nil
})
return matches
}
// SearchEach 逐项返回忽略大小写的字面匹配,不累积全部结果,在对象与匹配之间检查取消
// 入参: ctx 取消上下文, query 搜索文字, visit 匹配访问函数,可返回错误停止
// 返回: error 取消或访问错误
func (p *PageText) SearchEach(ctx context.Context, query string, visit func(TextMatch) error) error {
if err := ctx.Err(); err != nil {
return err
}
return p.searchEach(ctx, foldText(query), visit)
}
// searchEach 使用预折叠的查询执行搜索,供跨页搜索复用
// 入参: ctx 取消上下文, query 已折叠查询, visit 匹配访问函数
// 返回: error 取消或访问错误
func (p *PageText) searchEach(ctx context.Context, query string, visit func(TextMatch) error) error {
if query == "" {
return matches
return ctx.Err()
}
query = foldText(query)
length := utf8.RuneCountInString(query)
for index, run := range p.Runs {
if err := ctx.Err(); err != nil {
return err
}
text := foldText(run.Text)
var runes []rune
codeOffset := 0
for offset := 0; offset < len(text); {
if err := ctx.Err(); err != nil {
return err
}
found := strings.Index(text[offset:], query)
if found < 0 {
break
@@ -203,12 +230,48 @@ func (p *PageText) Search(query string) []TextMatch {
match.Boxes = append(match.Boxes, box)
}
}
matches = append(matches, match)
if err := visit(match); err != nil {
return err
}
offset += len(query)
codeOffset = end
}
}
return matches
return ctx.Err()
}
// SearchText 逐页搜索原文并立即返回匹配,不保留整本文字或识别图像
// 入参: ctx 取消上下文,页面提取之间及匹配时检查, query 搜索文字, visit 零基页码和匹配访问函数, indices 页码,省略则全部,按原页序去重
// 返回: error 页面提取、取消或访问错误
func (r *Renderer) SearchText(ctx context.Context, query string, visit func(int, TextMatch) error, indices ...int) error {
if err := ctx.Err(); err != nil {
return err
}
if query == "" {
return nil
}
count, err := r.Reader.PageCount()
if err != nil {
return err
}
indices, err = exportPageIndices(count, indices)
if err != nil {
return err
}
query = foldText(query)
for _, index := range indices {
if err := ctx.Err(); err != nil {
return err
}
text, err := r.PageTextByIndex(index)
if err != nil {
return fmt.Errorf("failed to search page %d: %w", index+1, err)
}
if err := text.searchEach(ctx, query, func(match TextMatch) error { return visit(index, match) }); err != nil {
return err
}
}
return ctx.Err()
}
// foldText 统一Unicode大小写等价字符,保留字符数量