// 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 ( "bytes" "context" "fmt" "image" "path" "slices" "strconv" "strings" ) // ResourceTarget 描述当前页可绑定资源动作的图元,Bound表示已有该资源的点击动作 type ResourceTarget struct { ID string `json:"id"` Type string `json:"type"` Text string `json:"text,omitempty"` Bound bool `json:"bound"` } // Resources 获取当前编辑快照的资源,覆盖正文、模板、注解、图案和复合图元 // 入参: ctx 取消上下文 // 返回: []ResourceInfo 资源及引用位置, error 快照或结构错误 func (e *Editor) Resources(ctx context.Context) ([]ResourceInfo, error) { reader, err := e.ReaderContext(ctx) if err != nil { return nil, err } return reader.Resources(ctx) } // ResourceTargets 列出当前页可独立绑定音视频或附件动作的图元及绑定状态 // 模板和共享复合资源的内部成员不作为普通页面图元返回 // 入参: ctx 取消上下文, kind 为Audio、Video或Attachment, id 资源标识, page 零基页码 // 返回: []ResourceTarget 按绘制顺序排列的候选, error 资源、页面或取消错误 func (e *Editor) ResourceTargets(ctx context.Context, kind, id string, page int) ([]ResourceTarget, error) { if kind != "Audio" && kind != "Video" && kind != "Attachment" { return nil, fmt.Errorf("resource does not support actions") } items, err := e.Resources(ctx) if err != nil { return nil, err } if _, err := findResource(items, kind, id); err != nil { return nil, err } content, err := e.page(page) if err != nil { return nil, err } var result []ResourceTarget for _, layer := range content.Content.Layer { for _, object := range layer.Objects { if err := ctx.Err(); err != nil { return nil, err } actions, err := resourceObjectActions(object) if err != nil { continue } target := ResourceTarget{ID: editorObjectID(object), Type: object.Type} if object.Type == "TextObject" { target.Text = object.TextObject.Text() } target.Bound = slices.ContainsFunc(actions, func(action Action) bool { return resourceActionMatches(action, kind, id) }) result = append(result, target) } } return result, ctx.Err() } // ImportMedia 导入可独立管理的音视频资源,即使尚未绑定动作也保留在当前文档中 // 不解码或转码,格式由调用方提供;修改隔离其他文档及历史版本,支持撤销 // 入参: ctx 取消上下文, kind 为Audio或Video, format 媒体格式, data 完整媒体数据 // 返回: string 新资源标识, error 数据或包结构错误 func (e *Editor) ImportMedia(ctx context.Context, kind, format string, data []byte) (string, error) { if kind != "Audio" && kind != "Video" || len(data) == 0 { return "", fmt.Errorf("nonempty Audio or Video resource required") } reader, err := e.ReaderContext(ctx) if err != nil { return "", err } working, err := reader.Editor() if err != nil { return "", err } id, err := working.AddMedia(kind, format, data) if err != nil { return "", err } resource := working.resources[len(working.resources)-1] encoded, err := working.encodeXML(func(x *ofdXML) { x.resources(nil, []editorResource{resource}, nil) }) if err != nil { return "", err } doc, err := reader.DocContext(ctx) if err != nil { return "", err } parts := map[string][]byte{resource.name: resource.data} name, added, err := mergePackageResources(ctx, reader, parts, doc, working.packageDirectory(), encoded) if err != nil { return "", err } entry := reader.documentRoot content, err := reader.readFileViewContext(ctx, entry) if err != nil { return "", err } root, err := parseEditorXMLContext(ctx, content) if err != nil { return "", err } common := root.child("CommonData") if common == nil || common.child("MaxUnitID") == nil { return "", fmt.Errorf("missing CommonData or MaxUnitID") } maximum := common.child("MaxUnitID") patches := []editorXMLPatch{{maximum.start, maximum.end, maximum.textXML("MaxUnitID", strconv.Itoa(working.maxID))}} if added { position := common.close for _, child := range common.children { if child.matchesOFD("TemplatePage") || child.matchesOFD("DefaultCS") { position = child.start break } } patches = append(patches, editorXMLPatch{position, position, common.textXML("DocumentRes", "/"+name)}) } parts[entry] = editorPatchXML(content, patches) scan := &editorFontDocument{reader: reader, index: reader.DocumentIndex()} if err := scan.apply(ctx, parts); err != nil { return "", err } return id, e.commitResourceSnapshot(ctx, reader) } // ReplaceResource 替换当前文档图片、音视频或附件的文件,保留标识和全部使用位置 // 图片同时清除受影响对象的旧替换图、底纹缩略图及复合预览;字体须使用EmbedFont或ExternalizeFont // 图片替换须满足关联蒙版与主图尺寸一致、蒙版像素为不透明黑白二值的要求 // 入参: ctx 取消上下文, kind 资源类型, id 资源标识, format 媒体格式,图片按实际数据确定, data 完整文件 // 返回: error 校验或提交错误,失败时文档不变 func (e *Editor) ReplaceResource(ctx context.Context, kind, id, format string, data []byte) error { if !slices.Contains([]string{"Image", "Audio", "Video", "Attachment"}, kind) { return fmt.Errorf("use typed definition or font binding operations for %s", kind) } 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" { 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)) { equal, err := packageDataEqual(ctx, reader, item.File, data) if err != nil || equal { return err } } name := packageAvailableName(reader, changes, path.Join(e.packageDirectory(), "Res", "Media", "Media_"+item.ID)) changes[name] = bytes.Clone(data) fragment, err := editorXMLStandalone(target.file.data[target.node.start:target.node.end], target.node) if err != nil { return err } root, err := parseEditorXML(fragment) if err != nil { return err } file := root.child("MediaFile") if file == nil { return fmt.Errorf("missing MediaFile") } attribute, err := editorXMLAttributePatch(fragment, root, "Format", strings.ToUpper(strings.TrimSpace(format))) if err != nil { return err } fragment = editorPatchXML(fragment, []editorXMLPatch{attribute, {file.start, file.end, file.textXML("MediaFile", "/"+name)}}) changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{target.node.start, target.node.end, fragment}}) return nil }, data) } // DeleteResource 删除未被引用的资源声明,仅清理全包中可确认无引用的文件 // 未知扩展导致引用不完整时拒绝删除;其他文档及历史版本保持不变 // 入参: ctx 取消上下文, kind 资源类型, id 资源标识 // 返回: error 仍有引用、结构或提交错误 func (e *Editor) DeleteResource(ctx context.Context, kind, id string) error { return e.changeResource(ctx, kind, id, nil, nil) } // changeResource 校验引用后在独立快照中修改声明,借用既有版本隔离和历史提交路径 // 入参: ctx 取消上下文, kind 资源类型, id 标识, replace 替换操作,nil表示删除, data 附件替换数据 // 返回: 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 } catalog, err := reader.resourceCatalog(ctx) if err != nil { return err } item, err := findResource(catalog.items, kind, id) if err != nil { return err } if !item.Complete { return fmt.Errorf("resource references contain unsupported content") } if replace == nil && len(item.References) != 0 { return fmt.Errorf("resource is still referenced") } if kind == "Attachment" { working, err := reader.Editor() if err != nil { return err } revision := working.Revision() if replace == nil { err = working.DeleteAttachment(item.ID) } else { 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 } return e.commitResourceSnapshot(ctx, reader) } target := catalog.nodes[item.Kind+"/"+item.ID] changes := make(map[string][]byte) if replace == nil { node := target.node if node.parent != nil && len(node.parent.children) == 1 { node = node.parent } changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{start: node.start, end: node.end}}) } else if err := replace(catalog, item, changes); err != nil { return err } if len(changes) == 0 { return nil } if replace != nil && kind == "Image" { if err := invalidateResourcePreviews(ctx, catalog.scan, changes, item.ID); err != nil { return err } } if err := catalog.scan.apply(ctx, changes); err != nil { return err } if err := pruneUnreferencedFiles(ctx, reader, item.File); err != nil { return err } 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 当前文档结构, changes 待提交文件, ids 资源标识 // 返回: error 解析或取消错误 func invalidateResourcePreviews(ctx context.Context, scan *editorFontDocument, changes map[string][]byte, ids ...string) error { if len(ids) == 0 { return ctx.Err() } reader := scan.reader if scan.index != reader.DocumentIndex() { var err error reader, err = reader.DocumentContext(ctx, scan.index) if err != nil { return err } } doc, err := reader.DocContext(ctx) if err != nil { return err } defaultCS := strconv.Itoa(doc.CommonData.DefaultCS) affected := make(map[string]bool, len(ids)) for _, id := range ids { affected[editorResourceID(id)] = true } for { count := len(affected) for name, file := range scan.files { data, exists := changes[name] if !exists { data = file.data } root, err := parseEditorXMLContext(ctx, data) if err != nil { return err } var patches []editorXMLPatch var visit func(*editorXML) (bool, error) visit = func(node *editorXML) (bool, error) { if err := ctx.Err(); err != nil { return false, err } if !node.matchesOFD(node.name.Local) { return false, nil } primary := affected[editorResourceID(node.attr("ResourceID"))] || affected[editorResourceID(node.attr("ImageMask"))] changed := primary || affected[editorResourceID(node.attr("Substitution"))] || affected[editorResourceID(node.attr("DrawParam"))] || affected[editorResourceID(node.attr("ColorSpace"))] changed = changed || affected[editorResourceID(node.attr("Font"))] if resourceColorNode(node) && node.attr("ColorSpace") == "" { changed = changed || affected[defaultCS] } if node.matchesOFD("DrawParam") { changed = changed || affected[editorResourceID(node.attr("Relative"))] } if node.matchesOFD("ImageObject") && primary && node.hasAttr("Substitution") { patch, err := editorXMLAttributeChange(data, node, "Substitution", nil) if err != nil { return false, err } patches = append(patches, patch) } for _, child := range node.children { used, err := visit(child) if err != nil { return false, err } changed = changed || used } if changed && node.matchesOFD("CellContent") && node.parent != nil && node.parent.matchesOFD("Pattern") && node.hasAttr("Thumbnail") { patch, err := editorXMLAttributeChange(data, node, "Thumbnail", nil) if err != nil { return false, err } patches = append(patches, patch) } if changed && node.matchesOFD("DrawParam") && node.parent != nil && node.parent.matchesOFD("DrawParams") { affected[editorResourceID(node.attr("ID"))] = true } if changed && node.matchesOFD("CompositeGraphicUnit") && node.parent != nil && node.parent.matchesOFD("CompositeGraphicUnits") { affected[editorResourceID(node.attr("ID"))] = true for _, key := range []string{"Thumbnail", "Substitution"} { if child := node.child(key); child != nil { patches = append(patches, editorXMLPatch{start: child.start, end: child.end}) } } } return changed, nil } if _, err := visit(root); err != nil { return err } if len(patches) != 0 { changes[name] = editorPatchXML(data, patches) } } if len(affected) == count { return nil } } } // BindMedia 追加或移除指定图元的音视频点击播放动作,保留停止、暂停等其他动作 // 已有播放动作时保留其区域和参数,重复操作不产生修改 // 入参: ctx 取消上下文, id 媒体标识, page 零基页码, object 图元标识, bind 为false时移除该媒体的点击播放动作 // 返回: error 资源、对象或提交错误 func (e *Editor) BindMedia(ctx context.Context, id string, page int, object string, bind bool) error { kind := "" for _, resource := range e.resources { if media := resource.image; media != nil && sameResourceID(media.ID, id) && (media.Type == "Audio" || media.Type == "Video") { kind, id = media.Type, media.ID break } } if kind == "" { items, err := e.Resources(ctx) if err != nil { return err } for _, item := range items { if sameResourceID(item.ID, id) && (item.Kind == "Audio" || item.Kind == "Video") { kind, id = item.Kind, item.ID break } } } 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 } actions, err := resourceObjectActions(target) if err != nil { return err } found := false actions = slices.DeleteFunc(slices.Clone(actions), func(action Action) bool { match := resourceActionMatches(action, kind, id) found = found || match return match && !bind }) if bind && !found { action := Action{Event: "CLICK"} switch kind { case "Attachment": action.GotoA = &GotoA{AttachID: id} case "Audio": action.Sound = &Sound{ResourceID: id} case "Video": action.Movie = &Movie{ResourceID: id, Operator: "Play"} } actions = append(actions, action) } if err := ctx.Err(); err != nil { return err } if bind == found { return nil } return e.SetObjectActions(page, []string{object}, actions) } // resourceObjectActions 读取支持动作的图元,不复制或修改动作 // 入参: object 页面图元 // 返回: []Action 原动作, error 不支持动作的图元类型 func resourceObjectActions(object GraphicObject) ([]Action, error) { switch object.Type { case "TextObject": return object.TextObject.Actions, nil case "ImageObject": return object.ImageObject.Actions, nil case "Path", "PathObject": return object.PathObject.Actions, nil case "CompositeObject", "CompositeGraphicUnit": return object.CompositeGraphicUnit.Actions, nil default: return nil, fmt.Errorf("object does not support actions") } } // resourceActionMatches 检查资源的点击动作,视频停止或暂停不视为播放绑定 // 入参: action 动作, kind 资源类型, id 资源标识 // 返回: bool 是否匹配 func resourceActionMatches(action Action, kind, id string) bool { if action.Event != "CLICK" { return false } switch kind { case "Attachment": return action.GotoA != nil && action.GotoA.AttachID == id case "Audio": return action.Sound != nil && sameResourceID(action.Sound.ResourceID, id) case "Video": return action.Movie != nil && sameResourceID(action.Movie.ResourceID, id) && (action.Movie.Operator == "" || action.Movie.Operator == "Play") default: return false } }