feat(资源管理): 完善资源管理与校验,优化性能
build.yaml / build (push) Successful in 50s

This commit is contained in:
xiaoqidun committed 2026-10-13 14:24:37 +08:00
1 parent 0e423c9713
commit 7bdb03f7bd
12 files changed
+300 -49

No files matched your search

+10 -6
View File
@@ -752,8 +752,8 @@ resourceElements.Locate.addEventListener("click", () => {
resourceElements.Add.addEventListener("click", () => { resourceElements.Add.addEventListener("click", () => {
if (!state.editing) return; if (!state.editing) return;
const kind = resourceElements.Kind.value; const kind = resourceElements.Kind.value;
if (kind === "Font") { resourceElements.Panel.close(); openFontFile(el.fontInput); } if (kind === "Font") openFontFile(el.fontInput);
else if (kind === "Image") { resourceElements.Panel.close(); openInsertPanel(); } else if (kind === "Image") openInsertPanel();
else if (kind === "Attachment") openAttachmentDialog("add"); else if (kind === "Attachment") openAttachmentDialog("add");
else chooseResourceFile("add"); else chooseResourceFile("add");
}); });
@@ -3144,6 +3144,7 @@ async function insertObject(event) {
return; return;
} }
el.insertPanel.close(); el.insertPanel.close();
if (!item && resourceElements.Panel.open) resourceElements.Panel.close();
if (item && doc.revision === state.editorInfo?.revision) { if (item && doc.revision === state.editorInfo?.revision) {
canvasEditor.focus(); canvasEditor.focus();
return; return;
@@ -4334,6 +4335,7 @@ async function openSelectedFonts(event) {
const { saved, changed } = await fontManager.add(fonts); const { saved, changed } = await fontManager.add(fonts);
if (changed) { if (changed) {
await applyFontChange(); await applyFontChange();
if (resourceElements.Panel.open) await resourceTask(loadManagedResources);
} }
setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用"); setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用");
} catch (err) { } catch (err) {
@@ -5098,6 +5100,8 @@ function resourceError(message) {
if (/still referenced/.test(message)) return "资源仍被引用"; if (/still referenced/.test(message)) return "资源仍被引用";
if (/unsupported content/.test(message)) return "引用无法确认"; if (/unsupported content/.test(message)) return "引用无法确认";
if (/empty media|nonempty Audio/.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; return message;
} }
@@ -5158,7 +5162,7 @@ function selectManagedResource(item) {
function updateResourceControls() { function updateResourceControls() {
const item = resourceManager.selected, editing = state.editing; const item = resourceManager.selected, editing = state.editing;
const disabled = resourceManager.busy || !state.doc || document.body.hasAttribute("aria-busy"); 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.Add.hidden = resourceElements.Replace.hidden = resourceElements.Delete.hidden = !editing;
resourceElements.Rename.hidden = !editing || item?.kind !== "Attachment"; resourceElements.Rename.hidden = !editing || item?.kind !== "Attachment";
resourceElements.Rename.disabled = disabled || 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.disabled = disabled || !item || item.kind !== "Attachment" && (!item.complete || Boolean(item.references?.length));
resourceElements.Delete.title = item?.kind === "Attachment" ? "删除附件" : 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.Locate.disabled = disabled || !(item?.references?.[Number(resourceElements.Reference.value)]?.page >= 0);
resourceElements.Binding.hidden = !editing || !media; resourceElements.Binding.hidden = !editing || !actionable;
resourceElements.Bind.disabled = resourceElements.Unbind.disabled = disabled || !media || !resourceElements.Object.value; resourceElements.Bind.disabled = resourceElements.Unbind.disabled = disabled || !actionable || !resourceElements.Object.value;
} }
async function loadResourceTargets() { async function loadResourceTargets() {
@@ -5198,7 +5202,7 @@ async function changeManagedResource(action, item, file) {
if (!item || !state.editing) return; if (!item || !state.editing) return;
releaseResourcePreview(); releaseResourcePreview();
const format = file?.name.includes(".") ? file.name.split(".").at(-1).toUpperCase() : ""; 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("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); : await changeDocument("ofdgoChangeResource", null, action, item.kind, item.id, format, file || null, Number(resourceElements.Page.value), resourceElements.Object.value);
if (changed) { if (changed) {
+6
View File
@@ -842,6 +842,12 @@ func changeResource(args []js.Value) (any, error) {
case "delete": case "delete":
return currentEditor.DeleteResource(ctx, kind, id) return currentEditor.DeleteResource(ctx, kind, id)
case "bind", "unbind": 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") return currentEditor.BindMedia(ctx, id, args[5].Int(), args[6].String(), action == "bind")
case "add", "replace": case "add", "replace":
data, err := bytesFromJS(args[4]) data, err := bytesFromJS(args[4])
+7 -4
View File
@@ -665,6 +665,7 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err
// ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局 // ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局
// 更换主图资源时清除旧替换图引用,保留蒙版及其他绘制属性 // 更换主图资源时清除旧替换图引用,保留蒙版及其他绘制属性
// 完整校验图片像素,失败时不修改文档或注册新资源
// 入参: page 页面索引, id 图片对象标识, data AddImage支持的图片数据, fit 为contain、cover或空,空值保留原变换和裁剪 // 入参: page 页面索引, id 图片对象标识, data AddImage支持的图片数据, fit 为contain、cover或空,空值保留原变换和裁剪
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) error { func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) error {
@@ -675,16 +676,17 @@ func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) erro
if object.Type != "ImageObject" { if object.Type != "ImageObject" {
return fmt.Errorf("object %q is not an image", id) return fmt.Errorf("object %q is not an image", id)
} }
if object.ImageObject.ImageMask != "" { decoded, _, err := decodeImagePixelsContext(context.Background(), data)
config, _, err := decodeImageConfigData(data)
if err != nil { if err != nil {
return err return err
} }
if err := e.validateImageMask(object.ImageObject.ImageMask, image.Pt(config.Width, config.Height)); err != nil { if object.ImageObject.ImageMask != "" {
if err := e.validateImageMask(object.ImageObject.ImageMask, decoded.Bounds().Size()); err != nil {
return err return err
} }
} }
resource, err := e.AddImage(data) return e.Transaction(func(e *Editor) error {
resource, err := e.addDecodedImage(data, decoded)
if err != nil { if err != nil {
return err return err
} }
@@ -698,6 +700,7 @@ func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) erro
} }
} }
return e.UpdateObject(page, id, object) return e.UpdateObject(page, id, object)
})
} }
// AlignObject 将对象对齐页面,文字采用实际字形范围,路径和图片采用对象边界 // AlignObject 将对象对齐页面,文字采用实际字形范围,路径和图片采用对象边界
+26
View File
@@ -16,6 +16,7 @@ package ofdgo
import ( import (
"bytes" "bytes"
"context"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"maps" "maps"
@@ -163,11 +164,33 @@ func (e *Editor) RenameAttachment(id, name string) error {
} }
// ReplaceAttachment 替换附件内容,使用独立资源避免影响共享引用,保留名称和格式,不新增日期 // ReplaceAttachment 替换附件内容,使用独立资源避免影响共享引用,保留名称和格式,不新增日期
// 内容相同时不修改文件及撤销记录
// 入参: id 附件标识, data 文件数据,调用后可复用 // 入参: id 附件标识, data 文件数据,调用后可复用
// 返回: error 附件结构或提交错误 // 返回: error 附件结构或提交错误
func (e *Editor) ReplaceAttachment(id string, data []byte) 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 var maximum int
changed := false
err := e.editAttachment(id, func(fragment []byte, node *editorXML, parts map[string][]byte) ([]byte, error) { 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 var err error
maximum, err = e.sourceMaxID(nil) maximum, err = e.sourceMaxID(nil)
if err != nil { if err != nil {
@@ -194,12 +217,15 @@ func (e *Editor) ReplaceAttachment(id string, data []byte) error {
return nil, err return nil, err
} }
parts[file] = bytes.Clone(data) parts[file] = bytes.Clone(data)
changed = true
return fragment, nil return fragment, nil
}) })
if err != nil { if err != nil {
return err return err
} }
if changed {
e.maxID, e.source.idsReady = maximum+1, true e.maxID, e.source.idsReady = maximum+1, true
}
return nil return nil
} }
+4 -5
View File
@@ -18,7 +18,6 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"image"
"maps" "maps"
"slices" "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") return fmt.Errorf("composite member is not an image")
} }
object := node.object object := node.object
if object.ImageObject.ImageMask != "" { decoded, _, err := decodeImagePixelsContext(context.Background(), data)
config, _, err := decodeImageConfigData(data)
if err != nil { if err != nil {
return err return err
} }
if object.ImageObject.ImageMask != "" {
mask, err := renderer.ImageResource(object.ImageObject.ImageMask) mask, err := renderer.ImageResource(object.ImageObject.ImageMask)
if err != nil { if err != nil {
return err 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 return err
} }
} }
id, err := e.AddImage(data) id, err := e.addDecodedImage(data, decoded)
if err != nil { if err != nil {
return err return err
} }
+1 -10
View File
@@ -15,12 +15,10 @@
package ofdgo package ofdgo
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"image" "image"
"io"
"math" "math"
) )
@@ -127,14 +125,7 @@ func (e *Editor) addImagePage(ctx context.Context, data []byte, options ImagePag
var decoded image.Image var decoded image.Image
checksum := sha256.Sum256(data) checksum := sha256.Sum256(data)
if !verified[checksum] { if !verified[checksum] {
if format == "bmp" || format == "tiff" { decoded, _, err = decodeImagePixelsContext(ctx, data)
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
}
if err != nil { if err != nil {
return 0, err return 0, err
} }
+3
View File
@@ -768,6 +768,9 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re
reader.files = make(map[string][]byte) reader.files = make(map[string][]byte)
} }
maps.Copy(reader.files, parts) maps.Copy(reader.files, parts)
if len(parts) != 0 {
reader.resourceInfoCache = nil
}
reader.encryption = e.encryption reader.encryption = e.encryption
for name := range reader.pageHeaderCache { for name := range reader.pageHeaderCache {
if _, changed := parts[name]; changed { if _, changed := parts[name]; changed {
+135 -15
View File
@@ -17,9 +17,9 @@ package ofdgo
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"image"
"path" "path"
"slices" "slices"
"strconv" "strconv"
@@ -106,31 +106,34 @@ func (e *Editor) ImportMedia(ctx context.Context, kind, format string, data []by
// ReplaceResource 替换当前文档图片、音视频或附件的文件,保留标识和全部使用位置 // ReplaceResource 替换当前文档图片、音视频或附件的文件,保留标识和全部使用位置
// 图片同时清除受影响对象的旧替换图、底纹缩略图及复合预览;字体须使用EmbedFont或ExternalizeFont // 图片同时清除受影响对象的旧替换图、底纹缩略图及复合预览;字体须使用EmbedFont或ExternalizeFont
// 图片替换须满足关联蒙版与主图尺寸一致、蒙版像素为不透明黑白二值的要求
// 入参: ctx 取消上下文, kind 资源类型, id 资源标识, format 媒体格式,图片按实际数据确定, data 完整文件 // 入参: ctx 取消上下文, kind 资源类型, id 资源标识, format 媒体格式,图片按实际数据确定, data 完整文件
// 返回: error 校验或提交错误,失败时文档不变 // 返回: error 校验或提交错误,失败时文档不变
func (e *Editor) ReplaceResource(ctx context.Context, kind, id, format string, data []byte) error { func (e *Editor) ReplaceResource(ctx context.Context, kind, id, format string, data []byte) error {
if kind == "Font" { if kind == "Font" {
return fmt.Errorf("use font binding operations for fonts") 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 { if len(data) == 0 {
return fmt.Errorf("empty media data") return fmt.Errorf("empty media data")
} }
if kind == "Image" { if kind == "Image" {
_, actual, err := decodeImageDataContext(ctx, data) img, actual, err := decodeImagePixelsContext(ctx, data)
if err != nil { if err != nil {
return err return err
} }
if err := validateResourceImageMask(ctx, catalog, item.ID, img); err != nil {
return err
}
format = actual format = actual
} }
if item.Size == uint64(len(data)) && strings.EqualFold(item.Format, strings.TrimSpace(format)) { 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)) equal, err := packageDataEqual(ctx, reader, item.File, data)
if err != nil { if err != nil || equal {
return err return err
} }
if digest == sha256.Sum256(data) {
return nil
}
} }
name := packageAvailableName(reader, changes, path.Join(e.packageDirectory(), "Res", "Media", "Media_"+item.ID)) name := packageAvailableName(reader, changes, path.Join(e.packageDirectory(), "Res", "Media", "Media_"+item.ID))
changes[name] = bytes.Clone(data) changes[name] = bytes.Clone(data)
@@ -167,7 +170,7 @@ func (e *Editor) DeleteResource(ctx context.Context, kind, id string) error {
// changeResource 校验引用后在独立快照中修改声明,借用既有版本隔离和历史提交路径 // changeResource 校验引用后在独立快照中修改声明,借用既有版本隔离和历史提交路径
// 入参: ctx 取消上下文, kind 资源类型, id 标识, replace 替换操作,nil表示删除, data 附件替换数据 // 入参: ctx 取消上下文, kind 资源类型, id 标识, replace 替换操作,nil表示删除, data 附件替换数据
// 返回: error 引用、结构或提交错误 // 返回: 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) reader, err := e.ReaderContext(ctx)
if err != nil { if err != nil {
return err return err
@@ -176,7 +179,7 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu
if err != nil { if err != nil {
return err return err
} }
item, err := catalog.find(kind, id) item, err := findResource(catalog.items, kind, id)
if err != nil { if err != nil {
return err return err
} }
@@ -191,14 +194,18 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu
if err != nil { if err != nil {
return err return err
} }
revision := working.Revision()
if replace == nil { if replace == nil {
err = working.DeleteAttachment(item.ID) err = working.DeleteAttachment(item.ID)
} else { } else {
err = working.ReplaceAttachment(item.ID, data) err = working.replaceAttachment(ctx, item.ID, data)
} }
if err != nil { if err != nil {
return err return err
} }
if working.Revision() == revision {
return ctx.Err()
}
reader, err = working.ReaderContext(ctx) reader, err = working.ReaderContext(ctx)
if err != nil { if err != nil {
return err return err
@@ -213,7 +220,7 @@ func (e *Editor) changeResource(ctx context.Context, kind, id string, replace fu
node = node.parent node = node.parent
} }
changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{start: node.start, end: node.end}}) 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 return err
} }
if len(changes) == 0 { 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) 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 沿绘制参数和复合引用移除图片变化后失效的替换图及底纹预览 // invalidateResourcePreviews 沿绘制参数和复合引用移除图片变化后失效的替换图及底纹预览
// 入参: ctx 取消上下文, scan 当前文档结构, id 图片标识, changes 待提交文件 // 入参: ctx 取消上下文, scan 当前文档结构, id 图片标识, changes 待提交文件
// 返回: error 解析或取消错误 // 返回: error 解析或取消错误
@@ -349,6 +415,46 @@ func (e *Editor) BindMedia(ctx context.Context, id string, page int, object stri
if kind == "" { if kind == "" {
return fmt.Errorf("audio or video resource not found") 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) target, err := e.Object(page, object)
if err != nil { if err != nil {
return err return err
@@ -368,15 +474,29 @@ func (e *Editor) BindMedia(ctx context.Context, id string, page int, object stri
} }
found := false found := false
actions = slices.DeleteFunc(slices.Clone(actions), func(action Action) bool { 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 found = found || match
return match && !bind return match && !bind
}) })
if bind && !found { if bind && !found {
action := Action{Event: "CLICK"} action := Action{Event: "CLICK"}
if kind == "Audio" { switch kind {
case "Attachment":
action.GotoA = &GotoA{AttachID: id}
case "Audio":
action.Sound = &Sound{ResourceID: id} action.Sound = &Sound{ResourceID: id}
} else { case "Video":
action.Movie = &Movie{ResourceID: id, Operator: "Play"} action.Movie = &Movie{ResourceID: id, Operator: "Play"}
} }
actions = append(actions, action) actions = append(actions, action)
+12
View File
@@ -183,6 +183,18 @@ func decodeImageDataContext(ctx context.Context, data []byte) (img image.Image,
return nil, "", err 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图片数据 // isJPEGData 判断是否为JPEG图片数据
// 入参: data 图片数据 // 入参: data 图片数据
// 返回: bool 是否为JPEG图片数据 // 返回: bool 是否为JPEG图片数据
+53 -1
View File
@@ -17,6 +17,7 @@ package ofdgo
import ( import (
"bytes" "bytes"
"context" "context"
"io"
"path" "path"
"slices" "slices"
"strconv" "strconv"
@@ -40,6 +41,7 @@ func packagePagePath(directory, id string) string {
} }
// packageName 为新增条目分配文档内路径,不覆盖原文件或本次资源 // packageName 为新增条目分配文档内路径,不覆盖原文件或本次资源
// 无暂存资源冲突时直接返回路径,避免重复构建资源映射
// 入参: relative 文档相对路径 // 入参: relative 文档相对路径
// 返回: string 未占用路径 // 返回: string 未占用路径
func (e *Editor) packageName(relative string) string { func (e *Editor) packageName(relative string) string {
@@ -47,11 +49,16 @@ func (e *Editor) packageName(relative string) string {
if e.source != nil { if e.source != nil {
reader = e.source.reader 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)) files := make(map[string][]byte, len(e.resources))
for _, resource := range e.resources { for _, resource := range e.resources {
files[resource.name] = nil files[resource.name] = nil
} }
return packageAvailableName(reader, files, path.Join(e.packageDirectory(), relative)) return packageAvailableName(reader, files, name)
} }
// packageAvailableName 仅在包内同名条目冲突时添加序号,路径区分大小写 // 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 合并新增资源定义,保留原索引、路径基准与未知字段 // mergePackageResources 合并新增资源定义,保留原索引、路径基准与未知字段
// 入参: ctx 取消上下文, reader 原包, parts 待写入条目, doc 文档, directory 文档目录, added 新增资源XML,文件引用使用绝对包路径 // 入参: ctx 取消上下文, reader 原包, parts 待写入条目, doc 文档, directory 文档目录, added 新增资源XML,文件引用使用绝对包路径
// 返回: string 资源索引路径, bool 是否需要新增文档引用, error 读取、解析或取消错误 // 返回: string 资源索引路径, bool 是否需要新增文档引用, error 读取、解析或取消错误
+2
View File
@@ -39,6 +39,7 @@ type Reader struct {
resourcesRead map[string]bool resourcesRead map[string]bool
resourceFiles map[string]string resourceFiles map[string]string
imageCatalog []ImageInfo imageCatalog []ImageInfo
resourceInfoCache []ResourceInfo
mediaCache map[string]MultiMedia mediaCache map[string]MultiMedia
fontCache map[string]*Font fontCache map[string]*Font
fontResourcesRead bool fontResourcesRead bool
@@ -658,6 +659,7 @@ func (r *Reader) initRootContext(ctx context.Context) error {
r.documentRoot, r.RootDir = "", "" r.documentRoot, r.RootDir = "", ""
r.relatedPending, r.fontResourcesRead = false, false r.relatedPending, r.fontResourcesRead = false, false
r.imageCatalog = nil r.imageCatalog = nil
r.resourceInfoCache = nil
r.Stamps, r.Annots, r.annotationFiles = nil, nil, nil r.Stamps, r.Annots, r.annotationFiles = nil, nil, nil
r.ResMap = make(map[string]string) r.ResMap = make(map[string]string)
r.resourcesRead = make(map[string]bool) r.resourcesRead = make(map[string]bool)
+41 -8
View File
@@ -54,28 +54,40 @@ type resourceCatalog struct {
scan *editorFontDocument scan *editorFontDocument
items []ResourceInfo items []ResourceInfo
nodes map[string]editorFontText nodes map[string]editorFontText
images []editorFontText
} }
// Resources 列出当前文档及所选版本的资源,标记缺失文件,不读取二进制内容或执行媒体动作 // Resources 列出当前文档及所选版本的资源,标记缺失文件,不读取二进制内容或执行媒体动作
// 重复查询复用已完成的扫描,返回值及引用位置与阅读器相互独立
// 入参: ctx 取消上下文 // 入参: ctx 取消上下文
// 返回: []ResourceInfo 资源及引用位置, error 结构或读取错误 // 返回: []ResourceInfo 资源及引用位置, error 结构或读取错误
func (r *Reader) Resources(ctx context.Context) ([]ResourceInfo, error) { func (r *Reader) Resources(ctx context.Context) ([]ResourceInfo, error) {
catalog, err := r.resourceCatalog(ctx) items, err := r.resourceList(ctx)
if err != nil { if err != nil {
return nil, err 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 打开当前资源的原始文件,外部字体无可导出的内嵌文件 // OpenResource 打开当前资源的原始文件,外部字体无可导出的内嵌文件
// 入参: ctx 取消上下文, kind 资源类型, id 资源标识 // 入参: ctx 取消上下文, kind 资源类型, id 资源标识
// 返回: io.ReadCloser 文件流,由调用方关闭, error 资源或文件错误 // 返回: io.ReadCloser 文件流,由调用方关闭, error 资源或文件错误
func (r *Reader) OpenResource(ctx context.Context, kind, id string) (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 { if err != nil {
return nil, err return nil, err
} }
item, err := catalog.find(kind, id) item, err := findResource(items, kind, id)
if err != nil { if err != nil {
return nil, err 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 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 按标准内容路径收集资源声明及引用,不把任意数字属性作为资源引用 // resourceCatalog 按标准内容路径收集资源声明及引用,不把任意数字属性作为资源引用
// 入参: ctx 取消上下文 // 入参: ctx 取消上下文
// 返回: *resourceCatalog 资源索引, error 结构或读取错误 // 返回: *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) { if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject"}, node.name.Local) {
object = node.attr("ID") object = node.attr("ID")
} }
if node.matchesOFD("ImageObject") {
catalog.images = append(catalog.images, editorFontText{file: file, node: node})
}
add := func(role, id string) { add := func(role, id string) {
key := editorResourceID(id) key := editorResourceID(id)
if role == "AttachID" { if role == "AttachID" {
@@ -238,11 +271,11 @@ func (c *resourceCatalog) add(item ResourceInfo, node editorFontText) error {
return nil return nil
} }
// find 查找当前视图内的资源,不接受调用方提供的任意包路径 // findResource 查找当前视图内的资源,不接受调用方提供的任意包路径
// 入参: kind 资源类型, id 资源标识 // 入参: items 资源列表, kind 资源类型, id 资源标识
// 返回: ResourceInfo 资源信息, error 未找到资源 // 返回: ResourceInfo 资源信息, error 未找到资源
func (c *resourceCatalog) find(kind, id string) (ResourceInfo, error) { func findResource(items []ResourceInfo, kind, id string) (ResourceInfo, error) {
for _, item := range c.items { for _, item := range items {
if item.Kind == kind && (item.ID == id || kind != "Attachment" && sameResourceID(item.ID, id)) { if item.Kind == kind && (item.ID == id || kind != "Attachment" && sameResourceID(item.ID, id)) {
return item, nil return item, nil
} }