diff --git a/assets/webui/index.html b/assets/webui/index.html index 026411c..32fc2a6 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -92,19 +92,6 @@ -
- - - -
@@ -150,6 +137,19 @@ 图片
+
+ + + +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 4ab1718..ae6031a 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1257,13 +1257,13 @@ button.outline-link:hover { overflow-y: hidden; } -.viewer-panel:focus-visible { - outline: 2px solid var(--focus); - outline-offset: -2px; +.viewer-panel:focus { + outline: none; } -.viewer-panel.pointer-focus:focus { - outline: none; +.viewer-panel.keyboard-focus:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; } .viewer-panel.pan-mode, @@ -1781,6 +1781,29 @@ button.outline-link:hover { background: rgba(15, 123, 104, 0.08); } +.page-region-link, +.page-region-link:hover, +.page-region-link:focus-visible { + pointer-events: none; + background: none; +} + +.page-region-link > svg { + display: block; + width: 100%; + height: 100%; +} + +.page-region-link path { + pointer-events: fill; + fill: transparent; +} + +.page-region-link:hover path, +.page-region-link:focus-visible path { + fill: rgba(15, 123, 104, 0.08); +} + .annotation-note { position: absolute; z-index: 2; @@ -1793,7 +1816,16 @@ button.outline-link:hover { font-size: 16px; line-height: 20px; text-align: center; - transform: translateX(-100%); + transform-origin: 0 0; + transform: rotate(var(--note-rotation, 0deg)) scale(var(--note-scale, 1)) translateX(-100%); +} + +.annotation-note.no-zoom { + --note-scale: calc(1 / var(--surface-scale, 1)); +} + +.annotation-note.no-rotate { + --note-rotation: calc(-1 * var(--surface-rotation, 0deg)); } .annotation-detail { @@ -1804,7 +1836,8 @@ button.outline-link:hover { overflow-wrap: anywhere; } -.editing .annotation-note { +.editing .annotation-note, +.editing .page-link { display: none; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 1a7f7d1..7ea76a2 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -55,7 +55,7 @@ const state = { outlineExpanded: new Map(), textFonts: [], textFontID: null, - textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, + textDefaults: { type: "TextObject", size: 16 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, ofdBytes: null, fileName: "ofdgo.ofd", openSeq: 0, @@ -802,8 +802,10 @@ el.searchInput.addEventListener("input", () => resetSearch(false)); el.searchPrev.addEventListener("click", () => selectSearchMatch(Math.max(0, state.searchIndex) - 1)); el.searchNext.addEventListener("click", () => selectSearchMatch(state.searchIndex + 1)); document.addEventListener("keydown", handleKeyDown); -document.addEventListener("keydown", () => el.viewerPanel.classList.remove("pointer-focus"), true); -document.addEventListener("pointerdown", () => el.viewerPanel.classList.add("pointer-focus"), true); +document.addEventListener("keydown", event => { + if (event.key === "Tab") el.viewerPanel.classList.add("keyboard-focus"); +}, true); +document.addEventListener("pointerdown", () => el.viewerPanel.classList.remove("keyboard-focus"), true); el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput)); el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput)); el.localFontButton.addEventListener("click", loadLocalFonts); @@ -960,7 +962,7 @@ function handleKeyDown(event) { state.showPages = false; updateSidebarState(); } - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); return; } if (key === "Enter" && target === el.pageInput && !event.shiftKey) { @@ -1424,7 +1426,7 @@ async function enterCompositeScope(index, key) { if (page && surface) mountEditorObjects(index, page, surface); updateControls(); setStatus(pageStatus(index, state.doc.pageCount)); - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } catch (err) { if (openSeq === state.openSeq) showError(err, false); } finally { @@ -1439,7 +1441,7 @@ async function exitCompositeScope() { if (split >= 0) return enterCompositeScope(scope.index, scope.key.slice(0, split)); resetCompositeScope(); updateControls(); - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } async function startImageCrop(item) { @@ -1524,7 +1526,7 @@ async function toggleTextTool() { state.selectObjects = true; setPan(false); canvasEditor.setTool("text"); - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } async function beginCanvasText(index, box, surface, page) { @@ -1684,7 +1686,7 @@ async function insertObject(event) { } el.insertPanel.close(); if (item && doc.revision === state.editorInfo?.revision) { - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); return; } setEditorInfo(doc); @@ -1695,7 +1697,7 @@ async function insertObject(event) { await refreshEditorPage(doc, index, openSeq); if (openSeq === state.openSeq) { rememberEditorView(before, revision, !item && !!state.composite); - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } } catch (err) { if (openSeq === state.openSeq) { @@ -1846,7 +1848,7 @@ function toggleDrawingTool(tool) { state.selectObjects = true; setPan(false); canvasEditor.setTool(next); - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } function updateDrawingControls() { @@ -1972,7 +1974,7 @@ async function changeDocument(name, item, ...args) { } if (openSeq === state.openSeq) { if (!restoring) rememberEditorView(before, revision, reindex); - if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true }); + if (!toolbarFocus) canvasEditor.focus(); return true; } } catch (err) { @@ -3036,7 +3038,7 @@ async function openDocument(options = {}) { const page = options.keepPreview ? state.pageCache.get(pageIndex) : await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq }); if (page && options.resetScroll) { - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } queueNearbyPages(pageIndex, openSeq); loadDocumentDetails(openSeq); @@ -3516,8 +3518,9 @@ async function processPageRenderQueue() { task.resolve(null); continue; } - page.cacheBytes = (page.svg.length + page.text.length) * 2; + page.cacheBytes = (page.svg.length + page.text.length + page.annotations.length) * 2; page.text = JSON.parse(page.text); + page.annotations = JSON.parse(page.annotations); state.pageCache.set(task.index, page); } task.resolve(page); @@ -3612,36 +3615,89 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { if (state.editorInfo) { mountEditorObjects(index, page, surface); } - mountAnnotationNotes(index, surface); - for (const link of page.links) { + mountAnnotationNotes(index, surface, page.annotations); + mountPageLinks(page, surface); + shell.classList.add("rendered"); + renderSearchHighlights(index); + if (state.doc?.pages?.[index]) { + layoutPageShell(shell, state.doc.pages[index]); + } +} + +function pageLinkTarget(link) { + if (link.dest) { + const target = state.doc.pages.findIndex(page => page.id === link.dest.pageID); + return { href: target < 0 ? "#" : `#page-${target + 1}`, title: target < 0 ? "文档链接" : `第${target + 1}页`, activate: () => navigateDestination(link.dest) }; + } + if (link.attachment) { + return { href: "#", title: link.fileName ? `下载附件:${link.fileName}` : "下载附件", activate: () => { + if (!link.fileName) { setStatus("附件不存在"); return; } + return downloadAttachment({ id: link.attachment, fileName: link.fileName }); + } }; + } + let url; + try { url = new URL(link.uri); } catch { return null; } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return { href: url.href, title: url.href, external: true, activate: () => { window.open(url.href, "_blank", "noopener,noreferrer"); } }; +} + +function mountPageLinks(page, surface) { + const groups = new Map(); + const mounted = new Map(); + for (const [index, link] of page.links.entries()) { + const target = pageLinkTarget(link); + if (!target) continue; + target.order = index; + const key = link.group ? JSON.stringify([link.group, link.x, link.y, link.width, link.height, link.path]) : index; + if (!groups.has(key)) groups.set(key, { link, targets: [] }); + groups.get(key).targets.push(target); + } + for (const { link, targets } of groups.values()) { const anchor = document.createElement("a"); anchor.className = "page-link"; - if (link.dest) { - const target = state.doc.pages.findIndex(page => page.id === link.dest.pageID); - anchor.href = target < 0 ? "#" : `#page-${target + 1}`; - anchor.title = target < 0 ? "文档链接" : `第${target + 1}页`; - anchor.addEventListener("click", event => { event.preventDefault(); navigateDestination(link.dest); }); - } else { - let url; - try { url = new URL(link.uri); } catch { continue; } - if (url.protocol !== "http:" && url.protocol !== "https:") continue; - anchor.href = url.href; + anchor.href = targets[0].href; + anchor.title = targets[0].title; + mounted.set(anchor, { link, targets }); + if (!link.group && targets[0].external) { anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; - anchor.title = url.href; + } else { + anchor.addEventListener("click", async event => { + event.preventDefault(); + if (document.body.hasAttribute("aria-busy")) return; + const openSeq = state.openSeq; + let actions = targets; + if (event.detail && link.group) { + const matches = new Set(); + for (const node of document.elementsFromPoint(event.clientX, event.clientY)) { + const entry = mounted.get(node.closest(".page-link")); + if (entry?.link.group === link.group) entry.targets.forEach(target => matches.add(target)); + } + actions = [...matches].sort((a, b) => a.order - b.order); + } + for (const target of actions) { + if (openSeq !== state.openSeq) break; + await target.activate(); + } + }); } anchor.setAttribute("aria-label", anchor.title); 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}%`; + if (link.path) { + anchor.classList.add("page-region-link"); + const region = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + region.setAttribute("viewBox", `${link.x} ${link.y} ${link.width} ${link.height}`); + region.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", link.path); + region.append(path); + anchor.append(region); + } surface.append(anchor); } - shell.classList.add("rendered"); - renderSearchHighlights(index); - if (state.doc?.pages?.[index]) { - layoutPageShell(shell, state.doc.pages[index]); - } } function destinationPoint(page, x, y) { @@ -4121,6 +4177,7 @@ function layoutPageShell(shell, page) { const y = state.rotation >= 180 ? viewHeight : 0; surface.style.transform = `scale(${scale}) translate(${x}px, ${y}px) rotate(${state.rotation}deg)`; surface.style.setProperty("--surface-scale", String(scale)); + surface.style.setProperty("--surface-rotation", `${state.rotation}deg`); surface.classList.toggle("sideways", state.rotation % 180 !== 0); } } @@ -4920,7 +4977,6 @@ function renderMeta(keepDetails = false) { el.metaSignatures.textContent = doc.detailsPending ? "正在检查" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0); renderMetaContent(el.attachmentList, [doc.attachments, doc.attachmentError], renderAttachments); renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures); - renderAnnotations(); renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts); updateTextFonts(canvasEditor.selected, true); updateLocalFontButton(); @@ -5189,21 +5245,17 @@ function clearRegionHighlights() { } } -function renderAnnotations() { - for (const shell of el.svgHost.querySelectorAll(".page-shell.rendered")) { - mountAnnotationNotes(Number(shell.dataset.pageIndex), shell.querySelector(".page-surface")); - } -} - -function mountAnnotationNotes(index, surface) { +function mountAnnotationNotes(index, surface, annotations) { for (const note of surface.querySelectorAll(".annotation-note")) note.remove(); if (!state.renderAnnotations) return; const page = state.doc.pages[index]; - for (const annotation of state.doc.annotations || []) { + for (const annotation of annotations || []) { if (!annotation.visible || annotation.page !== index + 1 || !annotation.remark?.trim()) continue; const note = document.createElement("button"); note.type = "button"; note.className = "annotation-note"; + note.classList.toggle("no-zoom", !!annotation.noZoom); + note.classList.toggle("no-rotate", !!annotation.noRotate); note.textContent = "\u24d8"; note.title = "查看注解"; note.setAttribute("aria-label", "查看注解"); @@ -5408,7 +5460,7 @@ function startPan(event) { state.pan = { id: event.pointerId, x: event.clientX + el.viewerPanel.scrollLeft, y: event.clientY + el.viewerPanel.scrollTop }; el.viewerPanel.setPointerCapture(event.pointerId); el.viewerPanel.classList.add("panning"); - el.viewerPanel.focus({ preventScroll: true }); + canvasEditor.focus(); } function movePan(event) { diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 33b7f2f..82294e3 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -242,6 +242,11 @@ export class CanvasEditor { window.addEventListener("resize", () => this.cancel()); } + focus() { + this.viewer.classList.remove("keyboard-focus"); + this.viewer.focus({ preventScroll: true }); + } + setEnabled(enabled) { this.enabled = enabled; this.viewer.classList.toggle("editing-select", enabled); @@ -499,7 +504,7 @@ export class CanvasEditor { return; } event.preventDefault(); - this.viewer.focus({ preventScroll: true }); + this.focus(); const { item, box } = this.crop; this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: resizeCorner(this.crop.node, event), page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY }; @@ -520,7 +525,7 @@ export class CanvasEditor { const target = event.target.dataset.corner || direct && !direct.contours ? direct : this.itemsAt(event)[0]; if (node || target) event.preventDefault(); node = target?.node; - this.viewer.focus({ preventScroll: true }); + this.focus(); const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple; if (!target) { this.startMarquee(event, additive); @@ -579,7 +584,7 @@ export class CanvasEditor { const items = this.itemsAt(event); if (!items.length) return; event.preventDefault(); - this.viewer.focus({ preventScroll: true }); + this.focus(); this.select(items[(items.indexOf(this.selected) + 1) % items.length]); } @@ -804,7 +809,7 @@ export class CanvasEditor { const page = this.pages.get(surface); if (!page) return; event.preventDefault(); - this.viewer.focus({ preventScroll: true }); + this.focus(); this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)), erased: new Set(), shape: "rectangle", pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY, rect: surface.getBoundingClientRect(), rotation: this.options.rotation() }; @@ -878,7 +883,7 @@ export class CanvasEditor { return; } event.preventDefault(); - this.viewer.focus({ preventScroll: true }); + this.focus(); const shape = this.tool; const style = this.options.drawStyle(); if (lineShape(shape)) { @@ -1117,7 +1122,7 @@ export class CanvasEditor { if (event.key === "Escape") { event.preventDefault(); this.closeText(); - this.viewer.focus({ preventScroll: true }); + this.focus(); } else if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { event.preventDefault(); this.commitText(); @@ -1224,7 +1229,7 @@ export class CanvasEditor { item.surface.classList.add("cropping"); this.crop = { item, box, initialBox: { ...box }, node, mask, preview, original, pagePreview }; this.paintCrop(); - this.viewer.focus({ preventScroll: true }); + this.focus(); } paintCrop() { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index f0f7ffd..93967bb 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -28,6 +28,7 @@ import ( "image/color" "io" "math" + "path" "strconv" "strings" "syscall/js" @@ -363,11 +364,23 @@ func renderPage(args []js.Value) (any, error) { for i, link := range page.Links { item := map[string]any{ "uri": link.URI, + "path": link.Path, + "group": link.Group, "x": link.Box.X, "y": link.Box.Y, "width": link.Box.W, "height": link.Box.H, } + if link.Attachment != "" { + item["attachment"] = link.Attachment + attachments, _ := currentSession.Reader.Attachments() + for _, attachment := range attachments { + if attachment.ID == link.Attachment { + item["fileName"] = path.Base(currentSession.Reader.ResPath(attachment.FileLoc)) + break + } + } + } if dest := link.Dest; dest != nil { item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top, "right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom} @@ -378,18 +391,23 @@ func renderPage(args []js.Value) (any, error) { for i, font := range page.Fonts { fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style} } + annotations, err := json.Marshal(page.Annotations) + if err != nil { + return nil, err + } return successResult(map[string]any{ - "index": page.Index, - "number": page.Number, - "id": page.ID, - "width": page.Width, - "height": page.Height, - "svg": page.SVG, - "links": links, - "fonts": fonts, - "images": svgImagesToJS(page.Images), - "text": string(textData), - "objects": objects, + "annotations": string(annotations), + "index": page.Index, + "number": page.Number, + "id": page.ID, + "width": page.Width, + "height": page.Height, + "svg": page.SVG, + "links": links, + "fonts": fonts, + "images": svgImagesToJS(page.Images), + "text": string(textData), + "objects": objects, }), nil } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 5c99973..1f73eef 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -114,25 +114,24 @@ type Session struct { // DocumentInfo 文档信息 type DocumentInfo struct { - Version string `json:"version"` - DocType string `json:"docType"` - Title string `json:"title"` - Author string `json:"author"` - Subject string `json:"subject"` - CreationDate string `json:"creationDate"` - ModDate string `json:"modDate"` - PageCount int `json:"pageCount"` - FontCount int `json:"fontCount"` - SignatureCount int `json:"signatureCount"` - SignatureError string `json:"signatureError,omitempty"` - AttachmentError string `json:"attachmentError,omitempty"` - Attachments []AttachmentInfo `json:"attachments,omitempty"` - Fonts []FontInfo `json:"fonts"` - Signatures []SignatureInfo `json:"signatures"` - Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"` - Pages []PageInfo `json:"pages"` - Outlines []OutlineInfo `json:"outlines,omitempty"` - DetailsPending bool `json:"detailsPending,omitempty"` + Version string `json:"version"` + DocType string `json:"docType"` + Title string `json:"title"` + Author string `json:"author"` + Subject string `json:"subject"` + CreationDate string `json:"creationDate"` + ModDate string `json:"modDate"` + PageCount int `json:"pageCount"` + FontCount int `json:"fontCount"` + SignatureCount int `json:"signatureCount"` + SignatureError string `json:"signatureError,omitempty"` + AttachmentError string `json:"attachmentError,omitempty"` + Attachments []AttachmentInfo `json:"attachments,omitempty"` + Fonts []FontInfo `json:"fonts"` + Signatures []SignatureInfo `json:"signatures"` + Pages []PageInfo `json:"pages"` + Outlines []OutlineInfo `json:"outlines,omitempty"` + DetailsPending bool `json:"detailsPending,omitempty"` } // OutlineInfo 目录节点信息 @@ -157,15 +156,16 @@ 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"` - Links []ofdgo.PageLink `json:"-"` - Fonts []ofdgo.SVGFont `json:"-"` - Images []ofdgo.SVGImage `json:"-"` + 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:"-"` + Fonts []ofdgo.SVGFont `json:"-"` + Images []ofdgo.SVGImage `json:"-"` + Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"` } // ExportFormat 导出格式 @@ -391,7 +391,6 @@ func (s *Session) Info() DocumentInfo { Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), Outlines: s.doc.OutlineInfos(), } - info.Annotations, _ = s.Reader.AnnotationInfos() if attachments, err := s.Reader.Attachments(); err == nil { for _, attachment := range attachments { if attachment.Visible { @@ -468,11 +467,15 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { if err != nil { return PageSVG{}, err } + annotations, err := s.Reader.AnnotationInfosByIndex(index) + if err != nil { + return PageSVG{}, err + } for i, font := range resources.Fonts { s.svgFonts[font.Name] = font.Data resources.Fonts[i].Data = nil } - return PageSVG{Index: index, Number: index + 1, ID: page.ID, Width: box.W, Height: box.H, SVG: buf.String(), Links: links, Fonts: resources.Fonts, Images: resources.Images}, nil + return PageSVG{Index: index, Number: index + 1, ID: page.ID, Width: box.W, Height: box.H, SVG: buf.String(), Links: links, Fonts: resources.Fonts, Images: resources.Images, Annotations: annotations}, nil } // SVGFontData 获取已渲染页面引用的字体数据 diff --git a/ofdgo_action.go b/ofdgo_action.go index 458ad24..2bccf01 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -18,6 +18,8 @@ import ( "encoding/xml" "net/url" "strconv" + + "github.com/tdewolff/canvas" ) // Action 动作 @@ -59,11 +61,16 @@ type URI struct { Base string `xml:"Base,attr"` } -// PageLink 页面矩形链接,URI与Dest分别表示外链和文档内跳转,Box使用页面毫米坐标 +// PageLink 页面链接,URI、Dest和Attachment分别表示外链、文档内跳转和附件 +// Box使用页面毫米坐标,Path为可选的页面坐标SVG路径,存在时作为精确点击区域 +// 同一来源的多个动作共享非零Group,组内按返回顺序执行 type PageLink struct { - URI string - Box Box - Dest *Dest + URI string + Box Box + Dest *Dest + Attachment string + Path string + Group int } // GotoA 附件动作 @@ -114,6 +121,8 @@ type RegionCommand struct { type actionSource struct { Box Box Actions []Action + Matrix Matrix + Path string } // gotoDest 获取文档内跳转目标 @@ -229,17 +238,48 @@ func actionFloatAttr(start xml.StartElement, name string) float64 { // pageActionSources 获取页面动作来源 // 入参: page 页面内容, box 页面区域 // 返回: []actionSource 动作来源 -func pageActionSources(page *PageContent, box Box) []actionSource { +func (r *Renderer) 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, + Matrix: IdentityMatrix, }) } - for _, layer := range page.Content.Layer { + for order := range 3 { + if r.Reader.doc != nil { + for _, ref := range page.Template { + kind := ref.ZOrder + if kind == "" { + kind = "Background" + } + if layerOrder(kind) != order { + continue + } + if template := r.loadTemplate(ref.TemplateID); template != nil { + sources = append(sources, actionSource{Box: Box{W: box.W, H: box.H}, Actions: template.Actions, Matrix: IdentityMatrix}) + for templateOrder := range 3 { + sources = r.appendLayerActionSources(sources, template.Content.Layer, templateOrder) + } + } + } + } + sources = r.appendLayerActionSources(sources, page.Content.Layer, order) + } + return sources +} + +// appendLayerActionSources 按绘制顺序添加图层动作来源 +// 入参: sources 动作来源, layers 页面图层, order 图层顺序 +// 返回: []actionSource 动作来源 +func (r *Renderer) appendLayerActionSources(sources []actionSource, layers []Layer, order int) []actionSource { + for _, layer := range layers { + if layerOrder(layer.Type) != order { + continue + } for _, object := range layer.Objects { - sources = appendGraphicActionSources(sources, object, nil) + sources = r.appendGraphicActionSources(sources, object, IdentityMatrix, false, nil) } } return sources @@ -248,7 +288,7 @@ func pageActionSources(page *PageContent, box Box) []actionSource { // annotationActionSources 获取注释动作来源 // 入参: annotations 页面注释 // 返回: []actionSource 动作来源 -func annotationActionSources(annotations []Annotation) []actionSource { +func (r *Renderer) annotationActionSources(annotations []Annotation) []actionSource { sources := make([]actionSource, 0) for _, annotation := range annotations { if annotation.Visible != nil && !*annotation.Visible { @@ -259,51 +299,162 @@ func annotationActionSources(annotations []Annotation) []actionSource { continue } for _, object := range annotation.Appearance.Objects { - sources = appendGraphicActionSources(sources, object, &box) + sources = r.appendGraphicActionSources(sources, object, TranslationMatrix(box.X, box.Y), false, nil) } } return sources } // appendGraphicActionSources 添加图形对象动作来源 -// 入参: sources 动作来源, object 图形对象, box 指定动作区域 +// 入参: sources 动作来源, object 图形对象, parent 父级变换, boundaryInCTM 边界是否参与父级变换, seen 当前资源引用链 // 返回: []actionSource 动作来源 -func appendGraphicActionSources(sources []actionSource, object GraphicObject, box *Box) []actionSource { - var boundary string +func (r *Renderer) appendGraphicActionSources(sources []actionSource, object GraphicObject, parent Matrix, boundaryInCTM bool, seen map[string]bool) []actionSource { + var boundary, ctm, resource string var actions []Action var children []GraphicObject + composite := false switch object.Type { case "TextObject": boundary = object.TextObject.Boundary + ctm = object.TextObject.CTM actions = object.TextObject.Actions case "PathObject": boundary = object.PathObject.Boundary + ctm = object.PathObject.CTM actions = object.PathObject.Actions case "ImageObject": boundary = object.ImageObject.Boundary + ctm = object.ImageObject.CTM actions = object.ImageObject.Actions case "CompositeGraphicUnit", "CompositeObject": boundary = object.CompositeGraphicUnit.Boundary + ctm = object.CompositeGraphicUnit.CTM + resource = object.CompositeGraphicUnit.ResourceID actions = object.CompositeGraphicUnit.Actions children = object.CompositeGraphicUnit.Objects + composite = true } + box, _ := ParseBox(boundary) + placement := TranslationMatrix(box.X, box.Y).Multiply(parent) + if boundaryInCTM || composite { + placement = parent.Multiply(TranslationMatrix(box.X, box.Y)) + } + matrix := placement.Multiply(NewMatrix(ctm)) if len(actions) > 0 { - sourceBox := box - if sourceBox == nil && boundary != "" { - if value, err := ParseBox(boundary); err == nil { - sourceBox = &value - } + source := actionSource{Box: placement.TransformBox(Box{W: box.W, H: box.H}), Actions: actions, Matrix: matrix} + if placement.a == 1 && placement.b == 0 && placement.c == 0 && placement.d == 1 { + source.Box.W, source.Box.H = box.W, box.H } - if sourceBox != nil { - sources = append(sources, actionSource{Box: *sourceBox, Actions: actions}) + if box.W > 0 && box.H > 0 && !axisAlignedMatrix(placement) { + source.Path = canvas.Rectangle(box.W, box.H).Transform(actionCanvasMatrix(placement)).ToSVG() + } + sources = append(sources, source) + } + if resource != "" && !seen[resource] { + if unit := r.CompositeGraphicUnits[resource]; unit != nil { + if seen == nil { + seen = make(map[string]bool) + } + seen[resource] = true + sources = r.appendGraphicActionSources(sources, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: *unit}, matrix, true, seen) + delete(seen, resource) } } for _, child := range children { - sources = appendGraphicActionSources(sources, child, box) + sources = r.appendGraphicActionSources(sources, child, matrix, boundaryInCTM, seen) } return sources } +// actionCanvasMatrix 转换页面坐标矩阵,不翻转Y轴 +// 入参: matrix 页面变换 +// 返回: canvas.Matrix 路径变换 +func actionCanvasMatrix(matrix Matrix) canvas.Matrix { + return canvas.Matrix{{matrix.a, matrix.c, matrix.e}, {matrix.b, matrix.d, matrix.f}} +} + +// actionRegionPath 解析复杂点击区域并转换为页面坐标 +// 入参: region 动作区域, matrix 坐标变换 +// 返回: *canvas.Path 点击路径 +func actionRegionPath(region *Region, matrix Matrix) *canvas.Path { + path := &canvas.Path{} + for _, area := range region.Area { + start := parseFloats(area.Start) + if len(start) != 2 { + continue + } + part := &canvas.Path{} + part.MoveTo(start[0], start[1]) + for _, command := range area.Command { + p1, p2, p3 := parseFloats(command.Point1), parseFloats(command.Point2), parseFloats(command.Point3) + switch command.Type { + case "Move": + if len(p1) == 2 { + part.Close() + part.MoveTo(p1[0], p1[1]) + } + case "Line": + if len(p1) == 2 { + part.LineTo(p1[0], p1[1]) + } + case "QuadraticBezier": + if len(p1) == 2 && len(p2) == 2 { + part.QuadTo(p1[0], p1[1], p2[0], p2[1]) + } + case "CubicBezier": + if len(p3) == 2 { + first, second := part.Pos(), canvas.Point{X: p3[0], Y: p3[1]} + if len(p1) == 2 { + first = canvas.Point{X: p1[0], Y: p1[1]} + } + if len(p2) == 2 { + second = canvas.Point{X: p2[0], Y: p2[1]} + } + part.CubeTo(first.X, first.Y, second.X, second.Y, p3[0], p3[1]) + } + case "Arc": + size, end := parseFloats(command.EllipseSize), parseFloats(command.EndPoint) + angle, err := strconv.ParseFloat(command.RotationAngle, 64) + large, largeErr := strconv.ParseBool(command.LargeArc) + sweep, sweepErr := strconv.ParseBool(command.SweepDirection) + if len(end) == 2 && err == nil && largeErr == nil && sweepErr == nil { + rx, ry := 0.0, 0.0 + if len(size) > 0 { + rx, ry = size[0], size[0] + } + if len(size) > 1 { + ry = size[1] + } + part.ArcTo(rx, ry, angle, large, sweep, end[0], end[1]) + } + case "Close": + part.Close() + } + } + part.Close() + path = path.Append(part) + } + if canvas.Equal(matrix.a*matrix.d-matrix.b*matrix.c, 0) { + path = path.ReplaceArcs() + } + return path.Transform(actionCanvasMatrix(matrix)) +} + +// actionLinkRegion 获取动作点击区域 +// 入参: source 动作来源, action 动作 +// 返回: Box 外接矩形, string 精确路径 +func actionLinkRegion(source actionSource, action Action) (Box, string) { + if action.Region == nil { + return source.Box, source.Path + } + path := actionRegionPath(action.Region, source.Matrix) + if path.Empty() { + return Box{}, "" + } + box := path.Bounds() + return Box{X: box.X0, Y: box.Y0, W: box.W(), H: box.H()}, path.ToSVG() +} + // resolveActionURI 解析URI动作地址 // 入参: action URI动作 // 返回: string URI地址 diff --git a/ofdgo_annot.go b/ofdgo_annot.go index ff6b12a..91ee4e3 100644 --- a/ofdgo_annot.go +++ b/ofdgo_annot.go @@ -14,7 +14,10 @@ package ofdgo -import "encoding/xml" +import ( + "encoding/xml" + "fmt" +) // Annotations 注释集合 type Annotations struct { @@ -42,6 +45,8 @@ type Annotation struct { Creator string `xml:"Creator,attr"` LastModDate string `xml:"LastModDate,attr"` Visible *bool `xml:"Visible,attr"` + NoZoom bool `xml:"NoZoom,attr"` + NoRotate bool `xml:"NoRotate,attr"` Remark string `xml:"Remark"` Appearance Appearance } @@ -57,6 +62,8 @@ type AnnotationInfo struct { LastModDate string `json:"lastModDate,omitempty"` Remark string `json:"remark,omitempty"` Visible bool `json:"visible"` + NoZoom bool `json:"noZoom,omitempty"` + NoRotate bool `json:"noRotate,omitempty"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"width"` @@ -82,28 +89,53 @@ func (r *Reader) AnnotationInfos() ([]AnnotationInfo, error) { } var infos []AnnotationInfo for index, page := range doc.Pages.Page { - for _, annotation := range r.Annots[page.ID] { - box, _ := ParseBox(annotation.Appearance.Boundary) - infos = append(infos, AnnotationInfo{ - ID: annotation.ID, - Page: index + 1, - PageID: page.ID, - Type: annotation.Type, - Subtype: annotation.Subtype, - Creator: annotation.Creator, - LastModDate: annotation.LastModDate, - Remark: annotation.Remark, - Visible: annotation.Visible == nil || *annotation.Visible, - X: box.X, - Y: box.Y, - Width: box.W, - Height: box.H, - }) - } + infos = append(infos, r.annotationInfos(index, page.ID)...) } return infos, nil } +// AnnotationInfosByIndex 获取指定页面的注释信息,包含不可见注释,不解析页面图元 +// 入参: index 页面索引,从0开始 +// 返回: []AnnotationInfo 注释信息, error 错误信息 +func (r *Reader) AnnotationInfosByIndex(index int) ([]AnnotationInfo, error) { + doc, err := r.Doc() + if err != nil { + return nil, err + } + if index < 0 || index >= len(doc.Pages.Page) { + return nil, fmt.Errorf("page index out of range: %d", index) + } + return r.annotationInfos(index, doc.Pages.Page[index].ID), nil +} + +// annotationInfos 提取指定页面的注释信息 +// 入参: index 页面索引, pageID 页面标识 +// 返回: []AnnotationInfo 注释信息 +func (r *Reader) annotationInfos(index int, pageID string) []AnnotationInfo { + var infos []AnnotationInfo + for _, annotation := range r.Annots[pageID] { + box, _ := ParseBox(annotation.Appearance.Boundary) + infos = append(infos, AnnotationInfo{ + ID: annotation.ID, + Page: index + 1, + PageID: pageID, + Type: annotation.Type, + Subtype: annotation.Subtype, + Creator: annotation.Creator, + LastModDate: annotation.LastModDate, + Remark: annotation.Remark, + Visible: annotation.Visible == nil || *annotation.Visible, + NoZoom: annotation.NoZoom, + NoRotate: annotation.NoRotate, + X: box.X, + Y: box.Y, + Width: box.W, + Height: box.H, + }) + } + return infos +} + // parseAnnotations 解析注释文件 // 入参: doc 文档结构 // 返回: error 错误信息 diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index ddb949d..2d8dc60 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -180,7 +180,7 @@ func (r *Renderer) renderPageToContext(ctx *canvas.Context, page *PageContent, d return nil } -// PageLinks 获取页面及可见注释的矩形链接,解析书签目标,不包含自定义Region +// PageLinks 获取页面、模板和可见注释的点击链接,包含复杂区域、组合图元和附件动作 // 入参: page 页面内容 // 返回: []PageLink 页面链接, error 错误信息 func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { @@ -188,20 +188,23 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { if err != nil { return nil, err } - sources := pageActionSources(page, box) + sources := r.pageActionSources(page, box) if r.RenderAnnotations { - sources = append(sources, annotationActionSources(r.Reader.Annots[page.ID])...) + sources = append(sources, r.annotationActionSources(r.Reader.Annots[page.ID])...) } var links []PageLink var bookmarks map[string]Dest - for _, source := range sources { - if source.Box.W <= 0 || source.Box.H <= 0 { - continue - } + for sourceIndex, source := range sources { + start := len(links) for _, action := range source.Actions { - if action.Event != "CLICK" || action.Region != nil { + if action.Event != "CLICK" { continue } + box, path := actionLinkRegion(source, action) + if box.W <= 0 || box.H <= 0 { + continue + } + link := PageLink{Box: box, Path: path} if action.Goto != nil { if bookmarks == nil { doc, err := r.Reader.Doc() @@ -215,10 +218,20 @@ func (r *Renderer) PageLinks(page *PageContent) ([]PageLink, error) { } if dest := gotoDest(action.Goto, bookmarks); dest != nil { value := *dest - links = append(links, PageLink{Dest: &value, Box: source.Box}) + link.Dest = &value + links = append(links, link) } } else if action.URI != nil && action.URI.URI != "" { - links = append(links, PageLink{URI: resolveActionURI(*action.URI), Box: source.Box}) + link.URI = resolveActionURI(*action.URI) + links = append(links, link) + } else if action.GotoA != nil { + link.Attachment = action.GotoA.AttachID + links = append(links, link) + } + } + if len(links)-start > 1 { + for i := start; i < len(links); i++ { + links[i].Group = sourceIndex + 1 } } } diff --git a/ofdgo_renderer_pdf.go b/ofdgo_renderer_pdf.go index 6e7023f..63fe904 100644 --- a/ofdgo_renderer_pdf.go +++ b/ofdgo_renderer_pdf.go @@ -95,16 +95,20 @@ func newPDFNavigation(renderer *Renderer, doc *Document, pages []pdfPage) *pdfNa } } for i, page := range pages { - sources := pageActionSources(page.Content, page.Box) + sources := renderer.pageActionSources(page.Content, page.Box) if renderer.RenderAnnotations { - sources = append(sources, annotationActionSources(renderer.Reader.Annots[page.Content.ID])...) + sources = append(sources, renderer.annotationActionSources(renderer.Reader.Annots[page.Content.ID])...) } for _, source := range sources { - rect := pdfSourceRect(source.Box, page.Box.H) for _, action := range source.Actions { if action.Event != "CLICK" { continue } + box, _ := actionLinkRegion(source, action) + if box.W <= 0 || box.H <= 0 { + continue + } + rect := pdfSourceRect(box, page.Box.H) navigation.addAction(i, rect, action, bookmarks, pageIndex, pages) } }