// 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" "encoding/xml" "fmt" "slices" ) // AnnotationLink 表示外部地址或文档内目标,两者必须且只能指定一项 type AnnotationLink struct { URI *URI Dest *Dest } // SetObjectLink 修改对象的单个点击链接,保留其他事件和点击区域,nil移除链接 // 入参: page 页面索引, ids 对象标识, link 链接目标,不检查可达性 // 返回: error 错误信息 func (e *Editor) SetObjectLink(page int, ids []string, link *AnnotationLink) error { if link != nil { if _, err := annotationLinkXML(*link); err != nil { return err } } objects, _, err := e.selectedObjects(page, ids) if err != nil { return err } for i := range objects { var actions *[]Action switch objects[i].Type { case "TextObject": actions = &objects[i].TextObject.Actions case "PathObject": actions = &objects[i].PathObject.Actions case "ImageObject": actions = &objects[i].ImageObject.Actions case "CompositeObject", "CompositeGraphicUnit": actions = &objects[i].CompositeGraphicUnit.Actions default: return fmt.Errorf("object %q cannot contain links", ids[i]) } *actions = cloneEditorData(*actions) index := -1 for j, action := range *actions { if action.Event == "CLICK" && (action.URI != nil || action.Goto != nil) { if index >= 0 { return fmt.Errorf("object %q contains multiple click links", ids[i]) } index = j } } if link == nil { if index >= 0 { *actions = slices.Delete(*actions, index, index+1) } continue } if index < 0 { index = len(*actions) *actions = append(*actions, Action{Event: "CLICK"}) } action := &(*actions)[index] action.URI, action.Goto = cloneEditorData(link.URI), nil if link.Dest != nil { action.Goto = &Goto{Dest: cloneEditorData(link.Dest)} } } return e.updateObjects(page, objects, true) } // collectActionReferences 收集播放动作引用的音视频资源 // 入参: actions 动作列表, used 引用集合 func collectActionReferences(actions []Action, used map[string]bool) { for _, action := range actions { if action.Sound != nil { used[action.Sound.ResourceID] = true } if action.Movie != nil { used[action.Movie.ResourceID] = true } } } // SetObjectActions 原子替换普通对象的动作,空列表移除动作 // 入参: page 页面索引, ids 对象标识, actions 动作及已注册的资源引用 // 返回: error 错误信息 func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error { if err := validateObjectActions(actions); err != nil { return err } objects, _, err := e.selectedObjects(page, ids) if err != nil { return err } for i := range objects { switch objects[i].Type { case "TextObject": objects[i].TextObject.Actions = cloneEditorData(actions) case "PathObject", "Path": objects[i].PathObject.Actions = cloneEditorData(actions) case "ImageObject": objects[i].ImageObject.Actions = cloneEditorData(actions) case "CompositeObject", "CompositeGraphicUnit": objects[i].CompositeGraphicUnit.Actions = cloneEditorData(actions) default: return fmt.Errorf("object %q cannot contain actions", ids[i]) } } return e.updateObjects(page, objects, true) } // AnnotationLink 读取注解中唯一的点击跳转,书签目标保持原始动作 // 入参: page 页面索引, id 注解标识 // 返回: Action 跳转动作, error 错误信息 func (e *Editor) AnnotationLink(page int, id string) (Action, error) { data, err := e.annotationXML(page, id) if err != nil { return Action{}, err } root, err := parseEditorXML(data) if err != nil { return Action{}, err } targets, _ := annotationLinkNodes(root) if len(targets) != 1 { return Action{}, fmt.Errorf("annotation does not contain one link target") } var action Action node := targets[0].parent err = xml.Unmarshal(data[node.start:node.end], &action) return action, err } // AddLinkAnnotation 新增标准链接注解,区域位于页面毫米坐标,不检查目标是否可达 // 入参: page 页面索引, box 点击区域, target 链接目标, creator 作者 // 返回: string 注解标识, error 错误信息 func (e *Editor) AddLinkAnnotation(page int, box Box, target AnnotationLink, creator string) (string, error) { var id string err := e.Transaction(func(edit *Editor) error { if _, err := annotationLinkXML(target); err != nil { return err } object, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H}) if err != nil { return err } no := false object.Fill, object.Stroke = &no, &no id, err = edit.AddAnnotation(page, Annotation{Type: "Link", Creator: creator, Appearance: Appearance{ Boundary: editorBoxString(box), Objects: []GraphicObject{{Type: "PathObject", PathObject: object}}, }}) if err != nil { return err } return edit.UpdateAnnotationLink(page, id, target) }) if err != nil { return "", err } return id, nil } // UpdateAnnotationLink 修改注解中唯一的点击跳转,保留动作区域、其他动作及扩展属性 // 无跳转时仅允许在单个基本对象上添加,多个跳转不隐式合并 // 入参: page 页面索引, id 注解标识, target 新目标,不验证目标可达性 // 返回: error 错误信息 func (e *Editor) UpdateAnnotationLink(page int, id string, target AnnotationLink) error { link, err := annotationLinkXML(target) if err != nil { return err } return e.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) { data, err := editorXMLStandalone(data[node.start:node.end], node) if err != nil { return nil, err } root, err := parseEditorXML(data) if err != nil { return nil, err } targets, objects := annotationLinkNodes(root) if len(targets) > 1 { return nil, fmt.Errorf("annotation contains multiple link targets") } if len(targets) == 1 { n := targets[0] updated, err := editorXMLGenerated(link, n.name.Space) if err != nil { return nil, err } if n.name.Local == "URI" && target.URI != nil { var old URI if err := xml.Unmarshal(data[n.start:n.end], &old); err != nil { return nil, err } before, err := annotationLinkXML(AnnotationLink{URI: &old}) if err != nil { return nil, err } updated, err = editorXMLMerge(data, n, before, link) if err != nil { return nil, err } } else if n.name.Local == "Goto" && target.Dest != nil && n.child("Dest") != nil { var old Goto if err := xml.Unmarshal(data[n.start:n.end], &old); err != nil { return nil, err } before, err := annotationLinkXML(AnnotationLink{Dest: old.Dest}) if err != nil { return nil, err } updated, err = editorXMLMerge(data, n, before, link) if err != nil { return nil, err } } else { var check func(*editorXML) bool check = func(node *editorXML) bool { allowed := map[string]string{"URI": "URI Base Target", "Goto": "", "Dest": "Type PageID Left Right Top Bottom Zoom", "Bookmark": "Name"} attrs, known := allowed[node.name.Local] if !known || node.name.Space != root.name.Space || !editorXMLAttributes(node, attrs) { return false } for _, attr := range node.attrs { if attr.Name.Space != "" && attr.Name.Space != "xmlns" { return false } } for _, child := range node.children { if !check(child) { return false } } return true } if !check(n) { return nil, fmt.Errorf("changing link type would discard extension data") } } data = editorPatchXML(data, []editorXMLPatch{{n.start, n.end, updated}}) } else { if len(objects) != 1 { return nil, fmt.Errorf("a new link requires one appearance object") } action, err := editorXMLContainer("Action", ofdAttrs{{Name: xml.Name{Local: "Event"}, Value: "CLICK"}}, link) if err != nil { return nil, err } action = bytes.TrimPrefix(action, []byte(xml.Header)) if actions := objects[0].child("Actions"); actions != nil { action, err = editorXMLGenerated(action, actions.name.Space) if err != nil { return nil, err } data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, actions, append(bytes.Clone(data[actions.open:actions.close]), action...))}) } else { actions, err := editorXMLContainer("Actions", nil, action) if err != nil { return nil, err } actions, err = editorXMLGenerated(actions, objects[0].name.Space) if err != nil { return nil, err } position := objects[0].open data = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(actions, []byte(xml.Header))}}) } } return editorAnnotationDate(data) }) } // validateObjectActions 校验新建对象的动作结构,目标不必可达 // 入参: actions 动作列表 // 返回: error 错误信息 func validateObjectActions(actions []Action) error { for _, action := range actions { if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) { return fmt.Errorf("unsupported action event %q", action.Event) } count := 0 for _, present := range []bool{action.URI != nil, action.Goto != nil, action.GotoA != nil, action.Sound != nil, action.Movie != nil} { if present { count++ } } if count != 1 { return fmt.Errorf("specify one action target") } if action.GotoA != nil && action.GotoA.AttachID == "" { return fmt.Errorf("attachment action requires an attachment ID") } if sound := action.Sound; sound != nil { if sound.ResourceID == "" || sound.Volume != nil && (*sound.Volume < 0 || *sound.Volume > 100) { return fmt.Errorf("invalid sound action resource or volume") } } if movie := action.Movie; movie != nil { if movie.ResourceID == "" || !slices.Contains([]string{"", "Play", "Stop", "Pause", "Resume"}, movie.Operator) { return fmt.Errorf("invalid movie action resource or operator") } } if action.Goto != nil { if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) { return fmt.Errorf("specify one destination or bookmark") } if action.Goto.Dest != nil { if _, err := annotationLinkXML(AnnotationLink{Dest: action.Goto.Dest}); err != nil { return err } } } if action.Region != nil { for _, area := range action.Region.Area { if _, err := creationNumbers(area.Start, 2); err != nil { return err } for _, command := range area.Command { var points []string switch command.Type { case "Line": points = []string{command.Point1} case "QuadraticBezier": points = []string{command.Point1, command.Point2} case "CubicBezier": points = []string{command.Point1, command.Point2, command.Point3} case "Close": default: return fmt.Errorf("unsupported new action region command %q", command.Type) } for _, point := range points { if _, err := creationNumbers(point, 2); err != nil { return err } } } } } } return nil } // annotationLinkNodes 获取外观内的点击跳转和基本对象,不进入私有扩展 // 入参: root 注解根节点 // 返回: []*editorXML 跳转目标, []*editorXML 基本对象 func annotationLinkNodes(root *editorXML) ([]*editorXML, []*editorXML) { var targets, objects []*editorXML var walk func(*editorXML) walk = func(n *editorXML) { if n.name.Space != root.name.Space { return } if n.name.Local == "Action" && n.attr("Event") == "CLICK" { for _, child := range n.children { if child.name.Space == n.name.Space && (child.name.Local == "URI" || child.name.Local == "Goto") { targets = append(targets, child) } } } if slices.Contains([]string{"TextObject", "PathObject", "ImageObject"}, n.name.Local) { objects = append(objects, n) } for _, child := range n.children { walk(child) } } if appearance := root.child("Appearance"); appearance != nil { walk(appearance) } return targets, objects } // annotationLinkXML 序列化标准跳转目标,不改写其他动作或命名空间 // 入参: target 跳转目标 // 返回: []byte 独立XML片段, error 错误信息 func annotationLinkXML(target AnnotationLink) ([]byte, error) { if (target.URI == nil) == (target.Dest == nil) { return nil, fmt.Errorf("specify one URI or destination") } var data []byte var err error if target.URI != nil { attrs := ofdAttrs{{Name: xml.Name{Local: "URI"}, Value: target.URI.URI}} attrs.add("Base", target.URI.Base) attrs.add("Target", target.URI.Target) data, err = editorXMLContainer("URI", attrs, nil) } else { dest := target.Dest var attrs ofdAttrs attrs, err = dest.attributes() if err != nil { return nil, err } data, err = editorXMLContainer("Dest", attrs, nil) if err == nil { data, err = editorXMLContainer("Goto", nil, bytes.TrimPrefix(data, []byte(xml.Header))) } } return bytes.TrimPrefix(data, []byte(xml.Header)), err }