diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 49c471c..757472c 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -663,9 +663,19 @@ button.outline-link:hover { } .page-surface { + position: relative; transform-origin: 0 0; } +.page-link { + position: absolute; +} + +.page-link:hover, +.page-link:focus-visible { + background: rgba(15, 123, 104, 0.08); +} + .page-placeholder { position: absolute; inset: 0; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 5ea93f3..a935324 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1591,6 +1591,29 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { const svg = parseSVG(page.svg, `p${openSeq}-${index}`); svg.classList.add("ofd-svg"); surface.replaceChildren(svg); + for (const link of page.links) { + let url; + try { + url = new URL(link.uri); + } catch { + continue; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + continue; + } + const anchor = document.createElement("a"); + anchor.className = "page-link"; + anchor.href = url.href; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + anchor.title = url.href; + anchor.setAttribute("aria-label", url.href); + anchor.style.left = `${link.x / page.width * 100}%`; + anchor.style.top = `${link.y / page.height * 100}%`; + anchor.style.width = `${link.width / page.width * 100}%`; + anchor.style.height = `${link.height / page.height * 100}%`; + surface.append(anchor); + } shell.classList.add("rendered"); if (state.doc?.pages?.[index]) { layoutPageShell(shell, state.doc.pages[index]); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 6bd4f49..1648ea1 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -160,6 +160,16 @@ func renderPage(args []js.Value) (any, error) { if err != nil { return nil, err } + links := make([]any, len(page.Links)) + for i, link := range page.Links { + links[i] = map[string]any{ + "uri": link.URI, + "x": link.Box.X, + "y": link.Box.Y, + "width": link.Box.W, + "height": link.Box.H, + } + } return successResult(map[string]any{ "index": page.Index, "number": page.Number, @@ -167,6 +177,7 @@ func renderPage(args []js.Value) (any, error) { "width": page.Width, "height": page.Height, "svg": page.SVG, + "links": links, }), nil } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 1b53ad9..af72a2f 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -156,12 +156,13 @@ type PageInfo struct { // PageSVG 页面SVG结果 type PageSVG struct { - Index int `json:"index"` - Number int `json:"number"` - ID string `json:"id"` - Width float64 `json:"width"` - Height float64 `json:"height"` - SVG string `json:"svg"` + Index int `json:"index"` + Number int `json:"number"` + ID string `json:"id"` + Width float64 `json:"width"` + Height float64 `json:"height"` + SVG string `json:"svg"` + Links []ofdgo.PageLink `json:"-"` } // ExportFormat 导出格式 @@ -382,7 +383,11 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { if err := s.Renderer.RenderToSVG(page, &buf); err != nil { return PageSVG{}, err } - return PageSVG{Index: index, Number: index + 1, ID: pageRef.ID, Width: box.W, Height: box.H, SVG: buf.String()}, nil + links, err := s.Renderer.PageLinks(page) + if err != nil { + return PageSVG{}, err + } + return PageSVG{Index: index, Number: index + 1, ID: pageRef.ID, Width: box.W, Height: box.H, SVG: buf.String(), Links: links}, nil } // ExportPage 导出单页 diff --git a/ofdgo_action.go b/ofdgo_action.go index e2876c9..16fad48 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -16,6 +16,7 @@ package ofdgo import ( "encoding/xml" + "net/url" "strconv" ) @@ -58,6 +59,12 @@ type URI struct { Base string `xml:"Base,attr"` } +// PageLink 页面矩形外链,Box使用页面毫米坐标 +type PageLink struct { + URI string + Box Box +} + // GotoA 附件动作 type GotoA struct { AttachID string `xml:"AttachID,attr"` @@ -102,6 +109,12 @@ type RegionCommand struct { EndPoint string } +// actionSource 动作来源 +type actionSource struct { + Box Box + Actions []Action +} + // gotoDest 获取文档内跳转目标 // 入参: action 跳转动作, bookmarks 书签 // 返回: *Dest 跳转目标 @@ -211,3 +224,99 @@ func actionFloatAttr(start xml.StartElement, name string) float64 { value, _ := strconv.ParseFloat(attrValue(start, name), 64) return value } + +// pageActionSources 获取页面动作来源 +// 入参: page 页面内容, box 页面区域 +// 返回: []actionSource 动作来源 +func pageActionSources(page *PageContent, box Box) []actionSource { + sources := make([]actionSource, 0) + if len(page.Actions) > 0 { + sources = append(sources, actionSource{ + Box: Box{W: box.W, H: box.H}, + Actions: page.Actions, + }) + } + for _, layer := range page.Content.Layer { + for _, object := range layer.Objects { + sources = appendGraphicActionSources(sources, object, nil) + } + } + return sources +} + +// annotationActionSources 获取注释动作来源 +// 入参: annotations 页面注释 +// 返回: []actionSource 动作来源 +func annotationActionSources(annotations []Annotation) []actionSource { + sources := make([]actionSource, 0) + for _, annotation := range annotations { + if annotation.Visible != nil && !*annotation.Visible { + continue + } + box, err := ParseBox(annotation.Appearance.Boundary) + if err != nil { + continue + } + for _, object := range annotation.Appearance.Objects { + sources = appendGraphicActionSources(sources, object, &box) + } + } + return sources +} + +// appendGraphicActionSources 添加图形对象动作来源 +// 入参: sources 动作来源, object 图形对象, box 指定动作区域 +// 返回: []actionSource 动作来源 +func appendGraphicActionSources(sources []actionSource, object GraphicObject, box *Box) []actionSource { + var boundary string + var actions []Action + var children []GraphicObject + switch object.Type { + case "TextObject": + boundary = object.TextObject.Boundary + actions = object.TextObject.Actions + case "PathObject": + boundary = object.PathObject.Boundary + actions = object.PathObject.Actions + case "ImageObject": + boundary = object.ImageObject.Boundary + actions = object.ImageObject.Actions + case "CompositeGraphicUnit", "CompositeObject": + boundary = object.CompositeGraphicUnit.Boundary + actions = object.CompositeGraphicUnit.Actions + children = object.CompositeGraphicUnit.Objects + } + if len(actions) > 0 { + sourceBox := box + if sourceBox == nil && boundary != "" { + if value, err := ParseBox(boundary); err == nil { + sourceBox = &value + } + } + if sourceBox != nil { + sources = append(sources, actionSource{Box: *sourceBox, Actions: actions}) + } + } + for _, child := range children { + sources = appendGraphicActionSources(sources, child, box) + } + return sources +} + +// resolveActionURI 解析URI动作地址 +// 入参: action URI动作 +// 返回: string URI地址 +func resolveActionURI(action URI) string { + if action.Base == "" { + return action.URI + } + base, err := url.Parse(action.Base) + if err != nil { + return action.URI + } + target, err := url.Parse(action.URI) + if err != nil { + return action.URI + } + return base.ResolveReference(target).String() +} diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 6ed9cb2..8c13647 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -164,6 +164,32 @@ func (r *Renderer) renderPageToContext(ctx *canvas.Context, page *PageContent, d return nil } +// PageLinks 获取页面及可见注释的矩形外链,不包含自定义Region +// 入参: page 页面内容 +// 返回: []PageLink 页面外链, error 错误信息 +func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + sources := pageActionSources(page, box) + if r.RenderAnnotations { + sources = append(sources, annotationActionSources(r.Reader.Annots[page.ID])...) + } + var links []PageLink + for _, source := range sources { + if source.Box.W <= 0 || source.Box.H <= 0 { + continue + } + for _, action := range source.Actions { + if action.Event == "CLICK" && action.URI != nil && action.URI.URI != "" && action.Region == nil { + links = append(links, PageLink{URI: resolveActionURI(*action.URI), Box: source.Box}) + } + } + } + return links, nil +} + // RenderPageByIndex 按索引渲染页面 // 入参: index 页面索引 // 返回: *canvas.Canvas 画布实例, error 错误信息 diff --git a/ofdgo_renderer_pdf.go b/ofdgo_renderer_pdf.go index 0ae6e66..b55a4c4 100644 --- a/ofdgo_renderer_pdf.go +++ b/ofdgo_renderer_pdf.go @@ -16,7 +16,6 @@ package ofdgo import ( "fmt" - "net/url" "github.com/tdewolff/canvas" "github.com/tdewolff/canvas/renderers/pdf" @@ -55,12 +54,6 @@ type pdfOutline struct { Y float64 } -// pdfActionSource PDF动作来源 -type pdfActionSource struct { - Box Box - Actions []Action -} - // newPDFNavigation 创建PDF导航信息 // 入参: renderer 渲染器, doc 文档结构, pages 页面数据 // 返回: *pdfNavigation PDF导航信息 @@ -81,7 +74,7 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []pdfPage) *pdfNa } } for i, page := range pages { - sources := pageActionSources(page) + sources := pageActionSources(page.Content, page.Box) if renderer.RenderAnnotations { sources = append(sources, annotationActionSources(renderer.Reader.Annots[page.Content.ID])...) } @@ -159,82 +152,6 @@ func (n *pdfNavigation) apply(renderer *pdf.PDF, page int) { } } -// pageActionSources 获取页面动作来源 -// 入参: page 页面数据 -// 返回: []pdfActionSource 动作来源 -func pageActionSources(page pdfPage) []pdfActionSource { - sources := make([]pdfActionSource, 0) - if len(page.Content.Actions) > 0 { - sources = append(sources, pdfActionSource{ - Box: Box{W: page.Box.W, H: page.Box.H}, - Actions: page.Content.Actions, - }) - } - for _, layer := range page.Content.Content.Layer { - for _, object := range layer.Objects { - sources = appendGraphicActionSources(sources, object, nil) - } - } - return sources -} - -// annotationActionSources 获取注释动作来源 -// 入参: annotations 页面注释 -// 返回: []pdfActionSource 动作来源 -func annotationActionSources(annotations []Annotation) []pdfActionSource { - sources := make([]pdfActionSource, 0) - for _, annotation := range annotations { - if annotation.Visible != nil && !*annotation.Visible { - continue - } - box, err := ParseBox(annotation.Appearance.Boundary) - if err != nil { - continue - } - for _, object := range annotation.Appearance.Objects { - sources = appendGraphicActionSources(sources, object, &box) - } - } - return sources -} - -// appendGraphicActionSources 添加图形对象动作来源 -// 入参: sources 动作来源, object 图形对象, box 指定动作区域 -// 返回: []pdfActionSource 动作来源 -func appendGraphicActionSources(sources []pdfActionSource, object GraphicObject, box *Box) []pdfActionSource { - var boundary string - var actions []Action - var children []GraphicObject - switch object.Type { - case "TextObject": - boundary = object.TextObject.Boundary - actions = object.TextObject.Actions - case "PathObject": - boundary = object.PathObject.Boundary - actions = object.PathObject.Actions - case "ImageObject": - boundary = object.ImageObject.Boundary - actions = object.ImageObject.Actions - case "CompositeGraphicUnit", "CompositeObject": - boundary = object.CompositeGraphicUnit.Boundary - actions = object.CompositeGraphicUnit.Actions - children = object.CompositeGraphicUnit.Objects - } - sourceBox := box - if sourceBox == nil && boundary != "" { - if value, err := ParseBox(boundary); err == nil { - sourceBox = &value - } - } - if sourceBox != nil && len(actions) > 0 { - sources = append(sources, pdfActionSource{Box: *sourceBox, Actions: actions}) - } - for _, child := range children { - sources = appendGraphicActionSources(sources, child, box) - } - return sources -} - // outlineDest 获取大纲跳转目标 // 入参: outline 大纲节点, bookmarks 书签 // 返回: *Dest 跳转目标 @@ -254,24 +171,6 @@ func outlineDest(outline OutlineElem, bookmarks map[string]Dest) *Dest { return nil } -// resolveActionURI 解析URI动作地址 -// 入参: action URI动作 -// 返回: string URI地址 -func resolveActionURI(action URI) string { - if action.Base == "" { - return action.URI - } - base, err := url.Parse(action.Base) - if err != nil { - return action.URI - } - target, err := url.Parse(action.URI) - if err != nil { - return action.URI - } - return base.ResolveReference(target).String() -} - // pdfSourceRect 转换PDF动作区域 // 入参: box OFD区域, pageH 页面高度 // 返回: canvas.Rect PDF区域