// 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 ( "context" "fmt" "image" "image/color" "strconv" "github.com/tdewolff/canvas" cimage "github.com/tdewolff/canvas/image" "github.com/tdewolff/canvas/renderers/pdf" "github.com/xiaoqidun/pdfgo" ) // pdfRenderer PDF渲染器 type pdfRenderer struct { *pdf.PDF glyphPaths map[*canvas.Path]*canvas.Path images [][]image.Image exactImages bool imageError error navigation *pdfNavigation } // pdfNavigation PDF导航信息 type pdfNavigation struct { Link map[int][]pdfLink Outline []pdfOutline exactLinks bool } // pdfLink PDF链接 type pdfLink struct { URI string Rect canvas.Rect Region *pdfgo.LinkRegion Target *pdfTarget } // pdfTarget 保存输出页索引及默认用户空间中的跳转参数 type pdfTarget struct { Page int Destination pdfgo.Destination } // pdfOutline PDF大纲 type pdfOutline struct { Name string Level int Actions []pdfOutlineAction } // pdfOutlineAction 保存目录的导航动作,输出时关联实际页面引用 type pdfOutlineAction struct { URI string Target *pdfTarget } // RenderImage 校验惰性图片并同步不透明画笔,保留解码错误及后端透明度状态 // 入参: img 图像, matrix 图像变换 func (r *pdfRenderer) RenderImage(img image.Image, matrix canvas.Matrix) { if r.imageError != nil { return } if source, ok := img.(interface{ Image() (image.Image, error) }); ok { pixels, err := source.Image() if err != nil { r.imageError = err return } if pixels == nil || pixels.Bounds() != img.Bounds() { r.imageError = fmt.Errorf("PDF output image pixel bounds differ") return } } if len(r.images) != 0 { var original image.Image opaque := false if source, ok := img.(interface{ Opaque() bool }); ok { opaque = source.Opaque() } switch img.ColorModel() { case color.GrayModel, color.CMYKModel: case color.RGBAModel, color.NRGBAModel, color.AlphaModel: if !opaque { original = img } default: if source, ok := img.(*cimage.Image); !ok || source.Mimetype != "image/jpeg" || source.Mask != nil { original = img } } r.exactImages = r.exactImages || original != nil last := len(r.images) - 1 r.images[last] = append(r.images[last], original) } style := canvas.DefaultStyle style.Fill = canvas.Paint{Color: canvas.White} style.Stroke = canvas.Paint{Color: canvas.Black} r.PDF.RenderPath(&canvas.Path{}, style, canvas.Identity) r.PDF.RenderImage(img, matrix) } // glyphPath 复用相似变换下的PDF字形圆弧转换 // 入参: path 缓存字形路径, matrix 字形变换 // 返回: *canvas.Path PDF字形路径 func (r *pdfRenderer) glyphPath(path *canvas.Path, matrix canvas.Matrix) *canvas.Path { if !matrix.IsSimilarity() { return path } if cached, ok := r.glyphPaths[path]; ok { return cached } converted := path.ReplaceArcs() r.glyphPaths[path] = converted return converted } // newPDFNavigation 创建PDF导航信息 // 入参: renderer 渲染器, doc 文档结构, pages 页面数据 // 返回: *pdfNavigation PDF导航信息, error 区域或目标错误 func newPDFNavigation(renderer *Renderer, doc *Document, pages []RenderDocumentPage) (*pdfNavigation, error) { navigation := &pdfNavigation{ Link: make(map[int][]pdfLink), } pageIndex := make(map[string]int, len(pages)) for i, page := range pages { pageIndex[page.Content.ID] = i } bookmarks := make(map[string]Dest) if doc != nil { for _, bookmark := range doc.Bookmarks.Bookmark { bookmarks[bookmark.Name] = bookmark.Dest } } for i, page := range pages { sources := renderer.pageActionSources(page.Content, page.Box) if renderer.RenderAnnotations { sources = append(sources, renderer.annotationActionSources(renderer.Reader.Annots[page.Content.ID])...) } for _, source := range sources { for _, action := range source.Actions { if action.Event != "CLICK" { continue } box, outline, err := renderer.actionLinkRegion(source, action) if err != nil { return nil, err } if box.W <= 0 || box.H <= 0 { continue } rect := pdfSourceRect(box, page.Box.H) count := len(navigation.Link[i]) if err := navigation.addAction(i, rect, action, bookmarks, pageIndex, pages); err != nil { return nil, err } if len(navigation.Link[i]) == count || outline == "" { continue } region, err := pdfActionRegion(renderer.outputContext(), outline, page.Box.H) if err != nil { return nil, err } if region != nil && len(region.Quads) == 0 { navigation.Link[i] = navigation.Link[i][:count] continue } if region != nil { navigation.Link[i][count].Region = region navigation.exactLinks = true } } } } if doc != nil { if err := navigation.addOutlines(doc.Outlines.OutlineElem, 0, bookmarks, pageIndex, pages); err != nil { return nil, err } } return navigation, nil } // addAction 添加PDF动作 // 入参: page 页面索引, rect 动作区域, action 动作, bookmarks 书签, pageIndex 页面索引表, pages 页面数据 // 返回: error 目标参数错误 func (n *pdfNavigation) addAction(page int, rect canvas.Rect, action Action, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) error { if action.Goto != nil { dest := gotoDest(action.Goto, bookmarks) if dest == nil { return nil } target, ok := pageIndex[dest.PageID] if !ok { return nil } destination, err := pdfDestination(*dest, pages[target].Box.H) if err != nil { return err } n.Link[page] = append(n.Link[page], pdfLink{Rect: rect, Target: &pdfTarget{Page: target, Destination: destination}}) n.exactLinks = true return nil } if action.URI != nil && action.URI.URI != "" { n.Link[page] = append(n.Link[page], pdfLink{URI: resolveActionURI(*action.URI), Rect: rect}) } return nil } // addOutlines 保留目录层级及节点自身的导航动作,选页时移除无保留子项的失效跳转节点 // 入参: outlines 大纲节点, level 节点层级, bookmarks 书签, pageIndex 页面索引表, pages 页面数据 // 返回: error 目标参数错误 func (n *pdfNavigation) addOutlines(outlines []OutlineElem, level int, bookmarks map[string]Dest, pageIndex map[string]int, pages []RenderDocumentPage) error { for _, outline := range outlines { entry := pdfOutline{Name: outline.Title, Level: level} onlyMissingTargets := len(outline.Actions) != 0 for _, action := range outline.Actions { if action.Goto != nil { dest := gotoDest(action.Goto, bookmarks) if dest == nil { continue } page, ok := pageIndex[dest.PageID] if !ok { continue } destination, err := pdfDestination(*dest, pages[page].Box.H) if err != nil { return err } entry.Actions = append(entry.Actions, pdfOutlineAction{Target: &pdfTarget{Page: page, Destination: destination}}) } else { onlyMissingTargets = false if action.URI != nil && action.URI.URI != "" { entry.Actions = append(entry.Actions, pdfOutlineAction{URI: resolveActionURI(*action.URI)}) } } } index := len(n.Outline) n.Outline = append(n.Outline, entry) if err := n.addOutlines(outline.OutlineElem, level+1, bookmarks, pageIndex, pages); err != nil { return err } if onlyMissingTargets && len(entry.Actions) == 0 && len(n.Outline) == index+1 { n.Outline = n.Outline[:index] } } n.exactLinks = n.exactLinks || len(n.Outline) != 0 return nil } // apply 生成链接及大纲结构,内部目标在原生写入时关联 // 入参: renderer PDF渲染器, page 页面索引 func (n *pdfNavigation) apply(renderer *pdf.PDF, page int) { for _, link := range n.Link[page] { renderer.AddLink(link.URI, link.Rect) } if page == 0 { for index, outline := range n.Outline { renderer.AddOutline(strconv.Itoa(index), outline.Level, 0) } } } // destinations 将输出页引用关联到原有链接和先序大纲,不按目标页重新排序 // 入参: ctx 取消上下文, reader 输出PDF, pages 输出页引用, options 替换配置 // 返回: error 目录结构或取消错误 func (n *pdfNavigation) destinations(ctx context.Context, reader *pdfgo.Reader, pages []pdfgo.Reference, options *pdfgo.RewriteOptions) error { options.LinkDestinations = make(map[pdfgo.AnnotationLocation]pdfgo.Destination) for page, links := range n.Link { for index, link := range links { if err := ctx.Err(); err != nil { return err } if link.Target != nil { dest := link.Target.Destination dest.Page = pages[link.Target.Page] options.LinkDestinations[pdfgo.AnnotationLocation{Page: pages[page], Index: index}] = dest } } } if len(n.Outline) == 0 { return nil } options.OutlineDestinations = make(map[pdfgo.Reference]pdfgo.Destination, len(n.Outline)) options.OutlineTitles = make(map[pdfgo.Reference]string, len(n.Outline)) options.OutlineActions = make(map[pdfgo.Reference][]pdfgo.NavigationAction) count := 0 if err := reader.WalkOutlines(ctx, func(level int, item pdfgo.OutlineItem) error { if count >= len(n.Outline) || n.Outline[count].Level != level || strconv.Itoa(count) != item.Title { return fmt.Errorf("PDF output outline differs") } actions := n.Outline[count].Actions if len(actions) == 1 && actions[0].Target != nil { target := actions[0].Target dest := target.Destination dest.Page = pages[target.Page] options.OutlineDestinations[item.Reference] = dest } else { converted := make([]pdfgo.NavigationAction, 0, len(actions)) for _, action := range actions { value := pdfgo.NavigationAction{URI: action.URI} if action.Target != nil { dest := action.Target.Destination dest.Page = pages[action.Target.Page] value.Destination = &dest } converted = append(converted, value) } options.OutlineActions[item.Reference] = converted } options.OutlineTitles[item.Reference] = n.Outline[count].Name count++ return nil }); err != nil { return err } if count != len(n.Outline) { return fmt.Errorf("PDF output outline missing") } return nil } // pdfSourceRect 转换PDF动作区域 // 入参: box OFD区域, pageH 页面高度 // 返回: canvas.Rect PDF区域 func pdfSourceRect(box Box, pageH float64) canvas.Rect { return canvas.RectFromSize(box.X, pageH-box.Y-box.H, box.W, box.H) } // pdfDestination 按OFD目标语义转换PDF坐标、显示模式及缩放比例 // 入参: dest OFD跳转目标, pageH 页面高度 // 返回: pdfgo.Destination 待关联输出页的目标, error 目标参数错误 func pdfDestination(dest Dest, pageH float64) (pdfgo.Destination, error) { dest = dest.effective() if _, err := dest.attributes(); err != nil { return pdfgo.Destination{}, err } const scale = 72.0 / 25.4 result := pdfgo.Destination{Mode: pdfgo.Name(dest.Type)} switch dest.Type { case "XYZ": result.Parameters = pdfgo.Array{pdfgo.Real(dest.Left * scale), pdfgo.Real((pageH - dest.Top) * scale), pdfgo.Real(dest.Zoom)} case "FitH": result.Parameters = pdfgo.Array{pdfgo.Real((pageH - dest.Top) * scale)} case "FitV": result.Parameters = pdfgo.Array{pdfgo.Real(dest.Left * scale)} case "FitR": result.Parameters = pdfgo.Array{pdfgo.Real(dest.Left * scale), pdfgo.Real((pageH - dest.Bottom) * scale), pdfgo.Real(dest.Right * scale), pdfgo.Real((pageH - dest.Top) * scale)} } return result, nil }