From 31e67b2721a95c7b66b34474c3e4f8df64f5bb8c Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 27 Sep 2026 22:31:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E6=90=9C=E7=B4=A2):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=99=84=E4=BB=B6=E7=BC=96=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E8=B7=A8=E9=A1=B5=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- ofdgo_editor_attachment.go | 143 ++++++++++++++++++++++++++++++++++++- ofdgo_reader.go | 12 +++- ofdgo_search.go | 71 ++++++++++++++++-- 5 files changed, 221 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 9cf7af6..53a0025 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 588be39..b6036bd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/ofdgo_editor_attachment.go b/ofdgo_editor_attachment.go index 5443eaf..0c2f838 100644 --- a/ofdgo_editor_attachment.go +++ b/ofdgo_editor_attachment.go @@ -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 +} diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 690f6f5..37a0354 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -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数据 diff --git a/ofdgo_search.go b/ofdgo_search.go index dbeaa22..4b666ad 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -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大小写等价字符,保留字符数量