From 7bdb03f7bd8ba7d5f4c20fc4a4b7da1b61b4ee19 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Tue, 13 Oct 2026 14:24:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=EF=BC=8C=E4=BC=98=E5=8C=96=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 16 ++-- internal/webui/wasm.go | 6 ++ ofdgo_editor.go | 37 +++++---- ofdgo_editor_attachment.go | 28 ++++++- ofdgo_editor_composite_ops.go | 13 ++- ofdgo_editor_images.go | 11 +-- ofdgo_editor_package.go | 3 + ofdgo_editor_resource.go | 150 ++++++++++++++++++++++++++++++---- ofdgo_image.go | 12 +++ ofdgo_package.go | 54 +++++++++++- ofdgo_reader.go | 2 + ofdgo_resource.go | 55 ++++++++++--- 12 files changed, 319 insertions(+), 68 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index d411289..5613cd3 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -752,8 +752,8 @@ resourceElements.Locate.addEventListener("click", () => { resourceElements.Add.addEventListener("click", () => { if (!state.editing) return; const kind = resourceElements.Kind.value; - if (kind === "Font") { resourceElements.Panel.close(); openFontFile(el.fontInput); } - else if (kind === "Image") { resourceElements.Panel.close(); openInsertPanel(); } + if (kind === "Font") openFontFile(el.fontInput); + else if (kind === "Image") openInsertPanel(); else if (kind === "Attachment") openAttachmentDialog("add"); else chooseResourceFile("add"); }); @@ -3144,6 +3144,7 @@ async function insertObject(event) { return; } el.insertPanel.close(); + if (!item && resourceElements.Panel.open) resourceElements.Panel.close(); if (item && doc.revision === state.editorInfo?.revision) { canvasEditor.focus(); return; @@ -4334,6 +4335,7 @@ async function openSelectedFonts(event) { const { saved, changed } = await fontManager.add(fonts); if (changed) { await applyFontChange(); + if (resourceElements.Panel.open) await resourceTask(loadManagedResources); } setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用"); } catch (err) { @@ -5098,6 +5100,8 @@ function resourceError(message) { if (/still referenced/.test(message)) return "资源仍被引用"; if (/unsupported content/.test(message)) return "引用无法确认"; if (/empty media|nonempty Audio/.test(message)) return "文件内容为空"; + if (/image mask .*dimensions differ from source/.test(message)) return "蒙版尺寸不符"; + if (/image mask contains non-binary pixel/.test(message)) return "蒙版像素不符"; return message; } @@ -5158,7 +5162,7 @@ function selectManagedResource(item) { function updateResourceControls() { const item = resourceManager.selected, editing = state.editing; const disabled = resourceManager.busy || !state.doc || document.body.hasAttribute("aria-busy"); - const media = item && ["Audio", "Video"].includes(item.kind); + const actionable = item && ["Audio", "Video", "Attachment"].includes(item.kind); resourceElements.Add.hidden = resourceElements.Replace.hidden = resourceElements.Delete.hidden = !editing; resourceElements.Rename.hidden = !editing || item?.kind !== "Attachment"; resourceElements.Rename.disabled = disabled || item?.kind !== "Attachment"; @@ -5171,8 +5175,8 @@ function updateResourceControls() { resourceElements.Delete.disabled = disabled || !item || item.kind !== "Attachment" && (!item.complete || Boolean(item.references?.length)); resourceElements.Delete.title = item?.kind === "Attachment" ? "删除附件" : item?.references?.length ? "资源仍被引用" : "删除资源"; resourceElements.Locate.disabled = disabled || !(item?.references?.[Number(resourceElements.Reference.value)]?.page >= 0); - resourceElements.Binding.hidden = !editing || !media; - resourceElements.Bind.disabled = resourceElements.Unbind.disabled = disabled || !media || !resourceElements.Object.value; + resourceElements.Binding.hidden = !editing || !actionable; + resourceElements.Bind.disabled = resourceElements.Unbind.disabled = disabled || !actionable || !resourceElements.Object.value; } async function loadResourceTargets() { @@ -5198,7 +5202,7 @@ async function changeManagedResource(action, item, file) { if (!item || !state.editing) return; releaseResourcePreview(); const format = file?.name.includes(".") ? file.name.split(".").at(-1).toUpperCase() : ""; - const changed = item.kind === "Attachment" + const changed = item.kind === "Attachment" && action !== "bind" && action !== "unbind" ? await changeDocument("ofdgoChangeAttachment", null, action, item.id, file ? [file.name] : [], file ? [file] : []) : await changeDocument("ofdgoChangeResource", null, action, item.kind, item.id, format, file || null, Number(resourceElements.Page.value), resourceElements.Object.value); if (changed) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 6a68ef0..3038502 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -842,6 +842,12 @@ func changeResource(args []js.Value) (any, error) { case "delete": return currentEditor.DeleteResource(ctx, kind, id) case "bind", "unbind": + if kind == "Attachment" { + return currentEditor.BindAttachment(ctx, id, args[5].Int(), args[6].String(), action == "bind") + } + if kind != "Audio" && kind != "Video" { + return fmt.Errorf("resource does not support actions") + } return currentEditor.BindMedia(ctx, id, args[5].Int(), args[6].String(), action == "bind") case "add", "replace": data, err := bytesFromJS(args[4]) diff --git a/ofdgo_editor.go b/ofdgo_editor.go index c18b728..706f81d 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -665,6 +665,7 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err // ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局 // 更换主图资源时清除旧替换图引用,保留蒙版及其他绘制属性 +// 完整校验图片像素,失败时不修改文档或注册新资源 // 入参: page 页面索引, id 图片对象标识, data AddImage支持的图片数据, fit 为contain、cover或空,空值保留原变换和裁剪 // 返回: error 错误信息 func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) error { @@ -675,29 +676,31 @@ func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) erro if object.Type != "ImageObject" { return fmt.Errorf("object %q is not an image", id) } - if object.ImageObject.ImageMask != "" { - config, _, err := decodeImageConfigData(data) - if err != nil { - return err - } - if err := e.validateImageMask(object.ImageObject.ImageMask, image.Pt(config.Width, config.Height)); err != nil { - return err - } - } - resource, err := e.AddImage(data) + decoded, _, err := decodeImagePixelsContext(context.Background(), data) if err != nil { return err } - if !sameResourceID(object.ImageObject.ResourceID, resource) { - object.ImageObject.Substitution = "" - } - object.ImageObject.ResourceID = resource - if fit != "" { - if err := e.fitImage(&object.ImageObject, fit); err != nil { + if object.ImageObject.ImageMask != "" { + if err := e.validateImageMask(object.ImageObject.ImageMask, decoded.Bounds().Size()); err != nil { return err } } - return e.UpdateObject(page, id, object) + return e.Transaction(func(e *Editor) error { + resource, err := e.addDecodedImage(data, decoded) + if err != nil { + return err + } + if !sameResourceID(object.ImageObject.ResourceID, resource) { + object.ImageObject.Substitution = "" + } + object.ImageObject.ResourceID = resource + if fit != "" { + if err := e.fitImage(&object.ImageObject, fit); err != nil { + return err + } + } + return e.UpdateObject(page, id, object) + }) } // AlignObject 将对象对齐页面,文字采用实际字形范围,路径和图片采用对象边界 diff --git a/ofdgo_editor_attachment.go b/ofdgo_editor_attachment.go index 5a0f0ef..b680477 100644 --- a/ofdgo_editor_attachment.go +++ b/ofdgo_editor_attachment.go @@ -16,6 +16,7 @@ package ofdgo import ( "bytes" + "context" "encoding/xml" "fmt" "maps" @@ -163,11 +164,33 @@ func (e *Editor) RenameAttachment(id, name string) error { } // ReplaceAttachment 替换附件内容,使用独立资源避免影响共享引用,保留名称和格式,不新增日期 +// 内容相同时不修改文件及撤销记录 // 入参: id 附件标识, data 文件数据,调用后可复用 // 返回: error 附件结构或提交错误 func (e *Editor) ReplaceAttachment(id string, data []byte) error { + return e.replaceAttachment(context.Background(), id, data) +} + +// replaceAttachment 比较附件内容并提交替换,传递内容读取过程中的取消状态 +// 入参: ctx 取消上下文, id 附件标识, data 新文件数据 +// 返回: error 读取、取消或提交错误 +func (e *Editor) replaceAttachment(ctx context.Context, id string, data []byte) error { var maximum int + changed := false err := e.editAttachment(id, func(fragment []byte, node *editorXML, parts map[string][]byte) ([]byte, error) { + if child := node.child("FileLoc"); child != nil { + location := strings.TrimSpace(editorImportText(fragment, child)) + if location != "" { + name := e.source.reader.documentRoot + if external := strings.TrimSpace(e.source.document.Attachments.Path); external != "" { + name = e.source.reader.ResPath(external) + } + equal, err := packageDataEqual(ctx, e.source.reader, resolveResourcePath(name, "", location), data) + if err != nil || equal { + return fragment, err + } + } + } var err error maximum, err = e.sourceMaxID(nil) if err != nil { @@ -194,12 +217,15 @@ func (e *Editor) ReplaceAttachment(id string, data []byte) error { return nil, err } parts[file] = bytes.Clone(data) + changed = true return fragment, nil }) if err != nil { return err } - e.maxID, e.source.idsReady = maximum+1, true + if changed { + e.maxID, e.source.idsReady = maximum+1, true + } return nil } diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 85a4b3b..5b3020b 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -18,7 +18,6 @@ import ( "bytes" "context" "fmt" - "image" "maps" "slices" ) @@ -122,20 +121,20 @@ func (e *Editor) ReplaceCompositeImage(page int, path ObjectPath, index int, dat return fmt.Errorf("composite member is not an image") } object := node.object + decoded, _, err := decodeImagePixelsContext(context.Background(), data) + if err != nil { + return err + } if object.ImageObject.ImageMask != "" { - config, _, err := decodeImageConfigData(data) - if err != nil { - return err - } mask, err := renderer.ImageResource(object.ImageObject.ImageMask) if err != nil { return err } - if err := validateImageMask(context.Background(), mask, image.Pt(config.Width, config.Height)); err != nil { + if err := validateImageMask(context.Background(), mask, decoded.Bounds().Size()); err != nil { return err } } - id, err := e.AddImage(data) + id, err := e.addDecodedImage(data, decoded) if err != nil { return err } diff --git a/ofdgo_editor_images.go b/ofdgo_editor_images.go index 24bbb29..363db96 100644 --- a/ofdgo_editor_images.go +++ b/ofdgo_editor_images.go @@ -15,12 +15,10 @@ package ofdgo import ( - "bytes" "context" "crypto/sha256" "fmt" "image" - "io" "math" ) @@ -127,14 +125,7 @@ func (e *Editor) addImagePage(ctx context.Context, data []byte, options ImagePag var decoded image.Image checksum := sha256.Sum256(data) if !verified[checksum] { - if format == "bmp" || format == "tiff" { - decoded, _, err = decodeImageDataContext(ctx, data) - } else { - decoded, _, err = image.Decode(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) - } - if cancelErr := ctx.Err(); cancelErr != nil { - return 0, cancelErr - } + decoded, _, err = decodeImagePixelsContext(ctx, data) if err != nil { return 0, err } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 03628db..a4db64a 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -768,6 +768,9 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re reader.files = make(map[string][]byte) } maps.Copy(reader.files, parts) + if len(parts) != 0 { + reader.resourceInfoCache = nil + } reader.encryption = e.encryption for name := range reader.pageHeaderCache { if _, changed := parts[name]; changed { diff --git a/ofdgo_editor_resource.go b/ofdgo_editor_resource.go index 8b71871..6487740 100644 --- a/ofdgo_editor_resource.go +++ b/ofdgo_editor_resource.go @@ -17,9 +17,9 @@ package ofdgo import ( "bytes" "context" - "crypto/sha256" "encoding/xml" "fmt" + "image" "path" "slices" "strconv" @@ -106,31 +106,34 @@ func (e *Editor) ImportMedia(ctx context.Context, kind, format string, data []by // ReplaceResource 替换当前文档图片、音视频或附件的文件,保留标识和全部使用位置 // 图片同时清除受影响对象的旧替换图、底纹缩略图及复合预览;字体须使用EmbedFont或ExternalizeFont +// 图片替换须满足关联蒙版与主图尺寸一致、蒙版像素为不透明黑白二值的要求 // 入参: ctx 取消上下文, kind 资源类型, id 资源标识, format 媒体格式,图片按实际数据确定, data 完整文件 // 返回: error 校验或提交错误,失败时文档不变 func (e *Editor) ReplaceResource(ctx context.Context, kind, id, format string, data []byte) error { if kind == "Font" { return fmt.Errorf("use font binding operations for fonts") } - return e.changeResource(ctx, kind, id, func(reader *Reader, item ResourceInfo, target editorFontText, changes map[string][]byte) error { + return e.changeResource(ctx, kind, id, func(catalog *resourceCatalog, item ResourceInfo, changes map[string][]byte) error { + reader := catalog.scan.reader + target := catalog.nodes[item.Kind+"/"+item.ID] if len(data) == 0 { return fmt.Errorf("empty media data") } if kind == "Image" { - _, actual, err := decodeImageDataContext(ctx, data) + img, actual, err := decodeImagePixelsContext(ctx, data) if err != nil { return err } + if err := validateResourceImageMask(ctx, catalog, item.ID, img); err != nil { + return err + } format = actual } if item.Size == uint64(len(data)) && strings.EqualFold(item.Format, strings.TrimSpace(format)) { - digest, err := outputResourceDigest(ctx, reader, nil, item.File, make([]byte, 32<<10)) - if err != nil { + equal, err := packageDataEqual(ctx, reader, item.File, data) + if err != nil || equal { return err } - if digest == sha256.Sum256(data) { - return nil - } } name := packageAvailableName(reader, changes, path.Join(e.packageDirectory(), "Res", "Media", "Media_"+item.ID)) changes[name] = bytes.Clone(data) @@ -167,7 +170,7 @@ func (e *Editor) DeleteResource(ctx context.Context, kind, id string) error { // changeResource 校验引用后在独立快照中修改声明,借用既有版本隔离和历史提交路径 // 入参: ctx 取消上下文, kind 资源类型, id 标识, replace 替换操作,nil表示删除, data 附件替换数据 // 返回: error 引用、结构或提交错误 -func (e *Editor) changeResource(ctx context.Context, kind, id string, replace func(*Reader, ResourceInfo, editorFontText, map[string][]byte) error, data []byte) error { +func (e *Editor) changeResource(ctx context.Context, kind, id string, replace func(*resourceCatalog, ResourceInfo, map[string][]byte) error, data []byte) error { reader, err := e.ReaderContext(ctx) if err != nil { return err @@ -176,7 +179,7 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu if err != nil { return err } - item, err := catalog.find(kind, id) + item, err := findResource(catalog.items, kind, id) if err != nil { return err } @@ -191,14 +194,18 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu if err != nil { return err } + revision := working.Revision() if replace == nil { err = working.DeleteAttachment(item.ID) } else { - err = working.ReplaceAttachment(item.ID, data) + err = working.replaceAttachment(ctx, item.ID, data) } if err != nil { return err } + if working.Revision() == revision { + return ctx.Err() + } reader, err = working.ReaderContext(ctx) if err != nil { return err @@ -213,7 +220,7 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu node = node.parent } changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{start: node.start, end: node.end}}) - } else if err := replace(reader, item, target, changes); err != nil { + } else if err := replace(catalog, item, changes); err != nil { return err } if len(changes) == 0 { @@ -233,6 +240,65 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu return e.commitResourceSnapshot(ctx, reader) } +// validateResourceImageMask 校验图片替换涉及的全部蒙版引用,复用尺寸和二值像素校验结果 +// 入参: ctx 取消上下文, catalog 当前文档资源, id 替换图片标识, replacement 新图片 +// 返回: error 资源、尺寸、像素或取消错误 +func validateResourceImageMask(ctx context.Context, catalog *resourceCatalog, id string, replacement image.Image) error { + sizes := map[string]image.Point{id: replacement.Bounds().Size()} + validated := make(map[string]image.Point) + read := func(id string) ([]byte, error) { + item, err := findResource(catalog.items, "Image", id) + if err != nil { + return nil, err + } + return catalog.scan.reader.readFileViewContext(ctx, item.File) + } + for _, entry := range catalog.images { + if err := ctx.Err(); err != nil { + return err + } + source, mask := editorResourceID(entry.node.attr("ResourceID")), editorResourceID(entry.node.attr("ImageMask")) + if mask == "" || source != id && mask != id { + continue + } + size, ok := sizes[source] + if !ok { + data, err := read(source) + if err != nil { + return err + } + config, _, err := decodeImageConfigDataContext(ctx, data) + if err != nil { + return err + } + size = image.Pt(config.Width, config.Height) + sizes[source] = size + } + if previous, ok := validated[mask]; ok { + if previous != size { + return fmt.Errorf("image mask %s dimensions differ from source %s", mask, source) + } + continue + } + img := replacement + if mask != id { + data, err := read(mask) + if err != nil { + return err + } + img, _, err = decodeImageDataContext(ctx, data) + if err != nil { + return err + } + } + if err := validateImageMask(ctx, img, size); err != nil { + return fmt.Errorf("image object %s in %s: %w", entry.node.attr("ID"), entry.file.name, err) + } + validated[mask] = size + } + return ctx.Err() +} + // invalidateResourcePreviews 沿绘制参数和复合引用移除图片变化后失效的替换图及底纹预览 // 入参: ctx 取消上下文, scan 当前文档结构, id 图片标识, changes 待提交文件 // 返回: error 解析或取消错误 @@ -349,6 +415,46 @@ func (e *Editor) BindMedia(ctx context.Context, id string, page int, object stri if kind == "" { return fmt.Errorf("audio or video resource not found") } + return e.bindResourceAction(ctx, kind, id, page, object, bind) +} + +// BindAttachment 追加或移除指定图元的附件点击动作,附件标识须属于当前文档及所选版本 +// 保留其他动作及已有附件动作的区域和窗口设置,重复操作不产生修改 +// 入参: ctx 取消上下文, id 附件标识, page 零基页码, object 图元标识, bind 为false时移除该附件的点击动作 +// 返回: error 附件、对象或提交错误 +func (e *Editor) BindAttachment(ctx context.Context, id string, page int, object string, bind bool) error { + if err := ctx.Err(); err != nil { + return err + } + attachments, err := e.Attachments() + if err != nil { + return err + } + found := false + for _, attachment := range attachments { + if err := ctx.Err(); err != nil { + return err + } + if attachment.ID == id { + if found { + return fmt.Errorf("duplicate attachment ID: %s", id) + } + found = true + } + } + if !found { + return fmt.Errorf("attachment not found: %s", id) + } + return e.bindResourceAction(ctx, "Attachment", id, page, object, bind) +} + +// bindResourceAction 修改单个资源的点击动作,沿用图元编辑的版本隔离及撤销机制 +// 入参: ctx 取消上下文, kind 资源类型, id 资源标识, page 零基页码, object 图元标识, bind 是否绑定 +// 返回: error 对象或提交错误 +func (e *Editor) bindResourceAction(ctx context.Context, kind, id string, page int, object string, bind bool) error { + if err := ctx.Err(); err != nil { + return err + } target, err := e.Object(page, object) if err != nil { return err @@ -368,15 +474,29 @@ func (e *Editor) BindMedia(ctx context.Context, id string, page int, object stri } found := false actions = slices.DeleteFunc(slices.Clone(actions), func(action Action) bool { - match := action.Event == "CLICK" && (action.Sound != nil && sameResourceID(action.Sound.ResourceID, id) || action.Movie != nil && sameResourceID(action.Movie.ResourceID, id) && (action.Movie.Operator == "" || action.Movie.Operator == "Play")) + if action.Event != "CLICK" { + return false + } + match := false + switch kind { + case "Attachment": + match = action.GotoA != nil && action.GotoA.AttachID == id + case "Audio": + match = action.Sound != nil && sameResourceID(action.Sound.ResourceID, id) + case "Video": + match = action.Movie != nil && sameResourceID(action.Movie.ResourceID, id) && (action.Movie.Operator == "" || action.Movie.Operator == "Play") + } found = found || match return match && !bind }) if bind && !found { action := Action{Event: "CLICK"} - if kind == "Audio" { + switch kind { + case "Attachment": + action.GotoA = &GotoA{AttachID: id} + case "Audio": action.Sound = &Sound{ResourceID: id} - } else { + case "Video": action.Movie = &Movie{ResourceID: id, Operator: "Play"} } actions = append(actions, action) diff --git a/ofdgo_image.go b/ofdgo_image.go index 47db175..7522f5f 100644 --- a/ofdgo_image.go +++ b/ofdgo_image.go @@ -183,6 +183,18 @@ func decodeImageDataContext(ctx context.Context, data []byte) (img image.Image, return nil, "", err } +// decodeImagePixelsContext 完整解码图片,确保像素可读取并传递取消状态 +// 入参: ctx 取消上下文, data 图片编码数据 +// 返回: image.Image 解码像素, string 图片格式, error 解码或取消错误 +func decodeImagePixelsContext(ctx context.Context, data []byte) (image.Image, string, error) { + img, format, err := decodeImageDataContext(ctx, data) + if err != nil { + return nil, format, err + } + pixels, err := imagePixelDataContext(ctx, img) + return pixels, format, err +} + // isJPEGData 判断是否为JPEG图片数据 // 入参: data 图片数据 // 返回: bool 是否为JPEG图片数据 diff --git a/ofdgo_package.go b/ofdgo_package.go index 432ec6b..24f1a28 100644 --- a/ofdgo_package.go +++ b/ofdgo_package.go @@ -17,6 +17,7 @@ package ofdgo import ( "bytes" "context" + "io" "path" "slices" "strconv" @@ -40,6 +41,7 @@ func packagePagePath(directory, id string) string { } // packageName 为新增条目分配文档内路径,不覆盖原文件或本次资源 +// 无暂存资源冲突时直接返回路径,避免重复构建资源映射 // 入参: relative 文档相对路径 // 返回: string 未占用路径 func (e *Editor) packageName(relative string) string { @@ -47,11 +49,16 @@ func (e *Editor) packageName(relative string) string { if e.source != nil { reader = e.source.reader } + name := path.Join(e.packageDirectory(), relative) + candidate := packageAvailableName(reader, nil, name) + if !slices.ContainsFunc(e.resources, func(resource editorResource) bool { return resource.name == candidate }) { + return candidate + } files := make(map[string][]byte, len(e.resources)) for _, resource := range e.resources { files[resource.name] = nil } - return packageAvailableName(reader, files, path.Join(e.packageDirectory(), relative)) + return packageAvailableName(reader, files, name) } // packageAvailableName 仅在包内同名条目冲突时添加序号,路径区分大小写 @@ -78,6 +85,51 @@ func packageAvailableName(reader *Reader, files map[string][]byte, name string) } } +// packageDataEqual 流式比较包内文件与给定数据,文件缺失时返回false +// 入参: ctx 取消上下文, reader 原包, name 包内路径, data 待比较内容 +// 返回: bool 内容是否相同, error 读取或取消错误 +func packageDataEqual(ctx context.Context, reader *Reader, name string, data []byte) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + name = cleanPackagePath(name) + if current, ok := reader.files[name]; ok { + return bytes.Equal(current, data), ctx.Err() + } + if _, ok := reader.packageFile(name); !ok { + return false, nil + } + stream, err := reader.openFile(name) + if err != nil { + return false, err + } + defer stream.Close() + input := imageInput{ReadCloser: stream, context: ctx} + var buffer [32 << 10]byte + for len(data) != 0 { + block := buffer[:min(len(buffer), len(data))] + _, err := io.ReadFull(input, block) + if err == io.EOF || err == io.ErrUnexpectedEOF { + return false, ctx.Err() + } + if err != nil { + return false, err + } + if !bytes.Equal(block, data[:len(block)]) { + return false, ctx.Err() + } + data = data[len(block):] + } + _, err = io.ReadFull(input, buffer[:1]) + if err == io.EOF { + return true, ctx.Err() + } + if err == nil { + err = ctx.Err() + } + return false, err +} + // mergePackageResources 合并新增资源定义,保留原索引、路径基准与未知字段 // 入参: ctx 取消上下文, reader 原包, parts 待写入条目, doc 文档, directory 文档目录, added 新增资源XML,文件引用使用绝对包路径 // 返回: string 资源索引路径, bool 是否需要新增文档引用, error 读取、解析或取消错误 diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 096157a..d3308be 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -39,6 +39,7 @@ type Reader struct { resourcesRead map[string]bool resourceFiles map[string]string imageCatalog []ImageInfo + resourceInfoCache []ResourceInfo mediaCache map[string]MultiMedia fontCache map[string]*Font fontResourcesRead bool @@ -658,6 +659,7 @@ func (r *Reader) initRootContext(ctx context.Context) error { r.documentRoot, r.RootDir = "", "" r.relatedPending, r.fontResourcesRead = false, false r.imageCatalog = nil + r.resourceInfoCache = nil r.Stamps, r.Annots, r.annotationFiles = nil, nil, nil r.ResMap = make(map[string]string) r.resourcesRead = make(map[string]bool) diff --git a/ofdgo_resource.go b/ofdgo_resource.go index 3f0da64..50aa241 100644 --- a/ofdgo_resource.go +++ b/ofdgo_resource.go @@ -51,31 +51,43 @@ type ResourceReference struct { // resourceCatalog 保存当前资源声明和原文位置,供查询及隔离修改复用 type resourceCatalog struct { - scan *editorFontDocument - items []ResourceInfo - nodes map[string]editorFontText + scan *editorFontDocument + items []ResourceInfo + nodes map[string]editorFontText + images []editorFontText } // Resources 列出当前文档及所选版本的资源,标记缺失文件,不读取二进制内容或执行媒体动作 +// 重复查询复用已完成的扫描,返回值及引用位置与阅读器相互独立 // 入参: ctx 取消上下文 // 返回: []ResourceInfo 资源及引用位置, error 结构或读取错误 func (r *Reader) Resources(ctx context.Context) ([]ResourceInfo, error) { - catalog, err := r.resourceCatalog(ctx) + items, err := r.resourceList(ctx) if err != nil { return nil, err } - return catalog.items, nil + result := slices.Clone(items) + for i := range result { + if err := ctx.Err(); err != nil { + return nil, err + } + result[i].References = slices.Clone(result[i].References) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return result, nil } // OpenResource 打开当前资源的原始文件,外部字体无可导出的内嵌文件 // 入参: ctx 取消上下文, kind 资源类型, id 资源标识 // 返回: io.ReadCloser 文件流,由调用方关闭, error 资源或文件错误 func (r *Reader) OpenResource(ctx context.Context, kind, id string) (io.ReadCloser, error) { - catalog, err := r.resourceCatalog(ctx) + items, err := r.resourceList(ctx) if err != nil { return nil, err } - item, err := catalog.find(kind, id) + item, err := findResource(items, kind, id) if err != nil { return nil, err } @@ -89,6 +101,24 @@ func (r *Reader) OpenResource(ctx context.Context, kind, id string) (io.ReadClos return imageInput{ReadCloser: stream, context: ctx}, nil } +// resourceList 缓存当前阅读视图的资源摘要,不保留扫描使用的XML树 +// 入参: ctx 取消上下文 +// 返回: []ResourceInfo 内部只读摘要, error 扫描或取消错误 +func (r *Reader) resourceList(ctx context.Context) ([]ResourceInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if r.resourceInfoCache != nil { + return r.resourceInfoCache, nil + } + catalog, err := r.resourceCatalog(ctx) + if err != nil { + return nil, err + } + r.resourceInfoCache = catalog.items + return r.resourceInfoCache, nil +} + // resourceCatalog 按标准内容路径收集资源声明及引用,不把任意数字属性作为资源引用 // 入参: ctx 取消上下文 // 返回: *resourceCatalog 资源索引, error 结构或读取错误 @@ -158,6 +188,9 @@ func (r *Reader) resourceCatalog(ctx context.Context) (*resourceCatalog, error) if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject"}, node.name.Local) { object = node.attr("ID") } + if node.matchesOFD("ImageObject") { + catalog.images = append(catalog.images, editorFontText{file: file, node: node}) + } add := func(role, id string) { key := editorResourceID(id) if role == "AttachID" { @@ -238,11 +271,11 @@ func (c *resourceCatalog) add(item ResourceInfo, node editorFontText) error { return nil } -// find 查找当前视图内的资源,不接受调用方提供的任意包路径 -// 入参: kind 资源类型, id 资源标识 +// findResource 查找当前视图内的资源,不接受调用方提供的任意包路径 +// 入参: items 资源列表, kind 资源类型, id 资源标识 // 返回: ResourceInfo 资源信息, error 未找到资源 -func (c *resourceCatalog) find(kind, id string) (ResourceInfo, error) { - for _, item := range c.items { +func findResource(items []ResourceInfo, kind, id string) (ResourceInfo, error) { + for _, item := range items { if item.Kind == kind && (item.ID == id || kind != "Attachment" && sameResourceID(item.ID, id)) { return item, nil }