From 5bc92dddd123d3102e7bb0c67f4a3211a43f3a46 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 10 Oct 2026 21:12:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ofdgo_editor_documents.go | 38 +++- ofdgo_editor_versions.go | 419 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+), 9 deletions(-) create mode 100644 ofdgo_editor_versions.go diff --git a/ofdgo_editor_documents.go b/ofdgo_editor_documents.go index 0eb9f09..0052e16 100644 --- a/ofdgo_editor_documents.go +++ b/ofdgo_editor_documents.go @@ -27,10 +27,11 @@ import ( "strings" ) -// editorDocumentIdentity 标记编辑状态所属的文档,不持有页面或资源数据 +// editorDocumentIdentity 标记编辑状态所属的文档和版本,不持有页面或资源数据 type editorDocumentIdentity struct { root string id string + version string fresh bool single bool fromNew bool @@ -309,7 +310,26 @@ func (e *Editor) editDocuments(edit func(*Reader, []byte, *editorXML) (int, erro if _, err := reader.Doc(); err != nil { return err } - if e.source != nil && e.documentRoot() == reader.documentRoot && e.Info.DocID == reader.OFD.DocBody[index].DocInfo.DocID { + if err := e.adoptDocumentReader(reader); err != nil { + return err + } + e.recordTransaction(before) + return nil +} + +// adoptDocumentReader 接入已加载的编辑入口,跨文档或版本时重建页面与资源状态 +// 入参: reader 不拥有输入文件的独立阅读器 +// 返回: error 编辑器初始化错误 +func (e *Editor) adoptDocumentReader(reader *Reader) error { + version, err := reader.DocumentVersionID() + if err != nil { + return err + } + current, err := e.DocumentVersionID() + if err != nil { + return err + } + if e.source != nil && e.documentRoot() == reader.documentRoot && current == version && e.Info.DocID == reader.OFD.DocBody[reader.documentIndex].DocInfo.DocID { source := *e.source source.reader = reader e.source = &source @@ -326,7 +346,6 @@ func (e *Editor) editDocuments(edit func(*Reader, []byte, *editorXML) (int, erro next.validatedPaths = e.validatedPaths *e = *next } - e.recordTransaction(before) return nil } @@ -339,23 +358,24 @@ func (e *Editor) documentRoot() string { return e.source.reader.documentRoot } -// sameDocument 判断快照是否属于同一文档,区别删除后复用目录的新文档 +// sameDocument 判断快照是否属于同一文档版本,区别删除后复用目录的新文档 // 入参: other 编辑快照 // 返回: bool 是否为同一文档 func (e *Editor) sameDocument(other *Editor) bool { return e.documentIdentity().matches(other.documentIdentity()) } -// documentIdentity 获取当前文档的入口与原始标识,文档排序不改变身份 +// documentIdentity 获取当前文档的入口、原始标识及版本,文档排序不改变身份 // 返回: editorDocumentIdentity 文档身份 func (e *Editor) documentIdentity() editorDocumentIdentity { if e.source == nil { return editorDocumentIdentity{id: e.Info.DocID, fresh: true} } - return editorDocumentIdentity{root: e.documentRoot(), id: e.source.info.DocID, single: e.DocumentCount() == 1, fromNew: e.source.fromNew} + version, _ := e.DocumentVersionID() + return editorDocumentIdentity{root: e.documentRoot(), id: e.source.info.DocID, version: version, single: e.DocumentCount() == 1, fromNew: e.source.fromNew} } -// matches 判断两个编辑状态是否属于同一文档,允许新文档首次建立包结构 +// matches 判断两个编辑状态是否属于同一文档版本,允许新文档首次建立包结构 // 入参: other 另一编辑状态的文档身份 // 返回: bool 是否为同一文档 func (identity editorDocumentIdentity) matches(other editorDocumentIdentity) bool { @@ -367,7 +387,7 @@ func (identity editorDocumentIdentity) matches(other editorDocumentIdentity) boo if !fresh.fresh { fresh, packaged = other, identity } - return packaged.single && (packaged.fromNew || fresh.id != "" && fresh.id == packaged.id) + return packaged.version == "" && packaged.single && (packaged.fromNew || fresh.id != "" && fresh.id == packaged.id) } - return identity.root == other.root && identity.id == other.id + return identity.root == other.root && identity.id == other.id && identity.version == other.version } diff --git a/ofdgo_editor_versions.go b/ofdgo_editor_versions.go new file mode 100644 index 0000000..5417c51 --- /dev/null +++ b/ofdgo_editor_versions.go @@ -0,0 +1,419 @@ +// 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" + "context" + "encoding/xml" + "fmt" + "maps" + "math" + "path" + "slices" + "strconv" + "strings" +) + +// DocumentVersions 获取当前编辑文档的版本信息,包含尚未写出的修改 +// 返回: []DocumentVersionInfo 版本信息, error 读取错误 +func (e *Editor) DocumentVersions() ([]DocumentVersionInfo, error) { + if e.source == nil { + return nil, nil + } + reader, err := e.Reader() + if err != nil { + return nil, err + } + return reader.DocumentVersions() +} + +// DocumentVersionID 获取当前编辑版本的标识,主入口返回空字符串 +// 返回: string 版本标识, error 读取错误 +func (e *Editor) DocumentVersionID() (string, error) { + if e.source == nil { + return "", nil + } + return e.source.reader.DocumentVersionID() +} + +// SelectVersion 切换当前文档的编辑版本,保留各版本修改,不改变默认版本 +// 切换及此前编辑均可通过Undo撤销;空标识选择主入口 +// 入参: id 版本标识 +// 返回: error 切换错误 +func (e *Editor) SelectVersion(id string) error { + current, err := e.DocumentVersionID() + if err != nil || current == id { + return err + } + reader, err := e.Reader() + if err != nil { + return err + } + view, err := reader.DocumentVersion(id) + if err != nil { + return err + } + before := e.transactionSnapshot() + if err := e.adoptDocumentReader(view); err != nil { + return err + } + e.recordTransaction(before) + return nil +} + +// AddVersion 以当前编辑内容创建版本并选中,设为默认版本,可通过Undo撤销 +// 初始共享文件,后续修改按需隔离;不自动写入创建日期,修改入口可能使已有签名失效 +// 入参: name 版本名称,可为空 +// 返回: string 新版本标识, error 创建错误 +func (e *Editor) AddVersion(name string) (string, error) { + var id string + err := e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + versions, err := reader.documentVersionEntries() + if err != nil { + return 0, err + } + index := int32(0) + for _, version := range versions { + index = max(index, version.Index) + } + if index == math.MaxInt32 { + return 0, fmt.Errorf("document version index exhausted") + } + index++ + ids := make(map[string]bool) + var collect func(*editorXML) + collect = func(node *editorXML) { + ids[node.attr("ID")] = true + for _, child := range node.children { + collect(child) + } + } + collect(root) + for serial := int64(index); ; serial++ { + id = "v" + strconv.FormatInt(serial, 10) + if !ids[id] { + break + } + } + entry, err := reader.DocumentRoot() + if err != nil { + return 0, err + } + files, err := reader.versionSnapshotFiles(context.Background()) + if err != nil { + return 0, err + } + body := root.childAt("DocBody", reader.documentIndex) + location := packageAvailableName(reader, nil, path.Join(path.Dir(entry), "Versions", id+".xml")) + attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}} + format := reader.Version() + if reader.versionInfo != nil && reader.versionInfo.Version != "" { + format = reader.versionInfo.Version + } + attrs.add("Version", format) + if name != "" { + attrs.add("Name", name) + } + descriptor, err := encodeOFDXML(func(x *ofdXML) { + x.root("DocVersion", attrs) + x.start("FileList", nil) + for serial, file := range slices.Sorted(maps.Keys(files)) { + x.start("File", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: "file" + strconv.Itoa(serial+1)}}) + x.token(xml.CharData("/" + file)) + x.end("File") + } + x.end("FileList") + x.text("DocRoot", "/"+entry) + x.end("DocVersion") + }) + if err != nil { + return 0, err + } + descriptor, err = editorXMLGenerated(descriptor, body.name.Space) + if err != nil { + return 0, err + } + version, err := body.containerXML("Version", ofdAttrs{ + {Name: xml.Name{Local: "ID"}, Value: id}, + {Name: xml.Name{Local: "Index"}, Value: strconv.FormatInt(int64(index), 10)}, + {Name: xml.Name{Local: "Current"}, Value: "true"}, + {Name: xml.Name{Local: "BaseLoc"}, Value: "/" + location}, + }, nil) + if err != nil { + return 0, err + } + data, err = editorVersionDefault(data, body, "") + if err != nil { + return 0, err + } + root, err = parseEditorXML(data) + if err != nil { + return 0, err + } + body = root.childAt("DocBody", reader.documentIndex) + var patch editorXMLPatch + if list := body.child("Versions"); list != nil { + patch = editorXMLPatch{list.close, list.close, version} + } else { + list, err := body.containerXML("Versions", nil, version) + if err != nil { + return 0, err + } + position := body.child("DocRoot").end + patch = editorXMLPatch{position, position, list} + } + reader.files[location] = descriptor + reader.files["OFD.xml"] = editorPatchXML(data, []editorXMLPatch{patch}) + reader.selectedVersion = &id + return reader.documentIndex, nil + }) + if err != nil { + return "", err + } + return id, nil +} + +// SetDefaultVersion 设置下次打开使用的版本,不切换当前编辑入口,可通过Undo撤销 +// 空标识取消默认版本并使用主入口;修改OFD.xml可能使已有签名失效 +// 入参: id 版本标识 +// 返回: error 设置错误 +func (e *Editor) SetDefaultVersion(id string) error { + versions, err := e.DocumentVersions() + if err != nil { + return err + } + found, changed := id == "", false + for _, version := range versions { + found = found || version.ID == id + changed = changed || version.Current != (version.ID == id) + } + if !found { + return fmt.Errorf("document version not found: %s", id) + } + if !changed { + return nil + } + return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + current, err := reader.DocumentVersionID() + if err != nil { + return 0, err + } + updated, err := editorVersionDefault(data, root.childAt("DocBody", reader.documentIndex), id) + if err != nil { + return 0, err + } + reader.files["OFD.xml"], reader.selectedVersion = updated, ¤t + return reader.documentIndex, nil + }) +} + +// RenameVersion 修改版本名称,保留其他属性及扩展内容,可通过Undo撤销 +// 描述文件被共享时不覆盖原文件;修改入口可能使已有签名失效 +// 入参: id 版本标识, name 版本名称,空值清空名称 +// 返回: error 修改错误 +func (e *Editor) RenameVersion(id, name string) error { + versions, err := e.DocumentVersions() + if err != nil { + return err + } + index := slices.IndexFunc(versions, func(version DocumentVersionInfo) bool { return version.ID == id }) + if index < 0 { + return fmt.Errorf("document version not found: %s", id) + } + if versions[index].Name == name { + return nil + } + return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + version := versions[index] + original, err := reader.readFile(version.Location) + if err != nil { + return 0, err + } + descriptor, err := parseEditorXML(original) + if err != nil { + return 0, err + } + updated, err := editorXMLAttribute(original, descriptor, "Name", name) + if err != nil { + return 0, err + } + extension := path.Ext(version.Location) + location := packageAvailableName(reader, nil, strings.TrimSuffix(version.Location, extension)+".edit"+extension) + body := root.childAt("DocBody", reader.documentIndex) + for _, node := range body.child("Versions").children { + if node.matchesOFD("Version") && node.attr("ID") == id { + fragment, err := editorXMLAttribute(data, node, "BaseLoc", "/"+location) + if err != nil { + return 0, err + } + reader.files["OFD.xml"] = editorPatchXML(data, []editorXMLPatch{{node.start, node.end, fragment}}) + break + } + } + reader.files[location] = updated + if err := reader.retireVersionFiles(map[string]bool{version.Location: true}); err != nil { + return 0, err + } + return reader.documentIndex, nil + }) +} + +// DeleteVersion 删除版本及其独占文件,可通过Undo撤销,不删除文档主入口 +// 删除当前版本后转到剩余默认版本或主入口;修改入口可能使已有签名失效 +// 入参: id 版本标识,不能为空 +// 返回: error 删除错误 +func (e *Editor) DeleteVersion(id string) error { + return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + versions, err := reader.DocumentVersions() + if err != nil { + return 0, err + } + index := slices.IndexFunc(versions, func(version DocumentVersionInfo) bool { return version.ID == id }) + if index < 0 { + return 0, fmt.Errorf("document version not found: %s", id) + } + current, err := reader.DocumentVersionID() + if err != nil { + return 0, err + } + list := root.childAt("DocBody", reader.documentIndex).child("Versions") + for _, node := range list.children { + if node.matchesOFD("Version") && node.attr("ID") == id { + if len(versions) == 1 { + node = list + } + reader.files["OFD.xml"] = editorPatchXML(data, []editorXMLPatch{{start: node.start, end: node.end}}) + break + } + } + if current == id { + reader.selectedVersion = nil + } + removed := map[string]bool{versions[index].Location: true} + for _, file := range versions[index].Files { + removed[file.Location] = true + } + if err := reader.retireVersionFiles(removed); err != nil { + return 0, err + } + return reader.documentIndex, nil + }) +} + +// editorVersionDefault 修改版本入口的Current属性,不改变条目顺序或其他属性 +// 入参: data OFD入口原文, body 文档节点, id 默认版本,空值取消默认版本 +// 返回: []byte 更新后的入口, error 属性编码错误 +func editorVersionDefault(data []byte, body *editorXML, id string) ([]byte, error) { + list := body.child("Versions") + if list == nil { + return data, nil + } + var patches []editorXMLPatch + for _, node := range list.children { + if !node.matchesOFD("Version") { + continue + } + current := node.attr("ID") == id + if !current && !node.hasAttr("Current") { + continue + } + fragment, err := editorXMLAttribute(data, node, "Current", strconv.FormatBool(current)) + if err != nil { + return nil, err + } + patches = append(patches, editorXMLPatch{node.start, node.end, fragment}) + } + return editorPatchXML(data, patches), nil +} + +// versionSnapshotFiles 汇总当前入口及其版本清单,补入适用的封面和签章文件 +// 入参: ctx 取消上下文 +// 返回: map[string]bool 快照文件, error 引用或读取错误 +func (r *Reader) versionSnapshotFiles(ctx context.Context) (map[string]bool, error) { + entry, err := r.DocumentRoot() + if err != nil { + return nil, err + } + graph, err := r.versionEditGraph(ctx, entry, nil) + if err != nil { + return nil, err + } + if r.versionInfo != nil { + for _, file := range r.versionInfo.Files { + graph.used[file.Location] = true + } + } + body := r.OFD.DocBody[r.documentIndex] + data, err := r.readFile("OFD.xml") + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if info := root.childAt("DocBody", r.documentIndex).child("DocInfo"); info != nil { + if cover := info.child("Cover"); cover != nil { + name, err := versionFileLocation("OFD.xml", editorImportText(data, cover)) + if err != nil { + return nil, err + } + graph.used[name] = true + } + } + if signature := r.versionSignatures(body.Signatures); signature != "" { + files, err := r.versionEditGraph(ctx, cleanPackagePath(signature), nil) + if err != nil { + return nil, err + } + maps.Copy(graph.used, files.used) + } + for name := range graph.used { + if !r.fileNames[name] { + return nil, fmt.Errorf("version file not found: %s", name) + } + } + return graph.used, ctx.Err() +} + +// retireVersionFiles 清理修改入口后不再被任何文档或版本引用的候选文件 +// 入参: candidates 待清理路径,OFD.xml始终保留 +// 返回: error 依赖读取错误 +func (r *Reader) retireVersionFiles(candidates map[string]bool) error { + view := *r + if err := view.initRoot(); err != nil { + return err + } + for index := range view.DocumentCount() { + files, err := view.documentFiles(context.Background(), index) + if err != nil { + return err + } + for name := range files { + delete(candidates, name) + } + } + delete(candidates, "OFD.xml") + for name := range candidates { + delete(r.files, name) + } + if r.Zip != nil { + r.Zip = &zip.Reader{Comment: r.Zip.Comment, File: slices.DeleteFunc(slices.Clone(r.Zip.File), func(file *zip.File) bool { + return candidates[cleanPackagePath(file.Name)] + })} + } + return nil +}