diff --git a/assets/webui/index.html b/assets/webui/index.html index 7c56f8a..9b09c1a 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -54,8 +54,13 @@
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 689933a..7887bb1 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -371,6 +371,84 @@ body[data-hide-meta] .meta-panel { line-height: 1; } +.navigation-tabs { + display: flex; + gap: 16px; + margin-bottom: 10px; + border-bottom: 1px solid var(--line); +} + +.navigation-tabs button { + height: 28px; + padding: 0 0 6px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-muted); + font-size: 12px; + font-weight: 700; +} + +.navigation-tabs button[aria-selected="true"] { + border-bottom-color: var(--accent); + color: var(--accent-strong); +} + +.outline-list { + margin: 0; + padding: 0; + list-style: none; +} + +.outline-list .outline-list { + padding-left: 14px; +} + +.outline-list summary { + margin-left: 14px; + color: var(--text-muted); + cursor: pointer; + list-style-position: outside; +} + +.outline-leaf { + padding-left: 14px; +} + +.outline-link { + display: inline-flex; + align-items: baseline; + gap: 8px; + width: 100%; + min-height: 30px; + height: auto; + padding: 5px 4px; + border: 0; + background: transparent; + font-size: 13px; + line-height: 1.5; + text-align: left; + vertical-align: top; +} + +.outline-link > :first-child { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.outline-page { + flex-shrink: 0; + color: var(--text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +button.outline-link:hover { + background: #e4f3ef; + color: var(--accent-strong); +} + .page-list { display: grid; gap: 8px; @@ -470,6 +548,8 @@ body[data-hide-meta] .meta-panel { } .empty-state[hidden], +.navigation-tabs[hidden], +.page-list[hidden], .page-frame[hidden], .progress-panel[hidden] { display: none; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 09239cf..4e7869a 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -93,7 +93,12 @@ const el = { viewerPanel: document.querySelector(".viewer-panel"), svgHost: document.querySelector("#svgHost"), pageListPanel: document.querySelector(".page-list-panel"), + pageListTitle: document.querySelector("#pageListTitle"), + navigationTabs: document.querySelector("#navigationTabs"), + pagesTab: document.querySelector("#pagesTab"), + outlinesTab: document.querySelector("#outlinesTab"), pageList: document.querySelector("#pageList"), + outlineList: document.querySelector("#outlineList"), metaPanel: document.querySelector(".meta-panel"), appPanel: document.querySelector("#appPanel"), offlineStatus: document.querySelector("#offlineStatus"), @@ -119,6 +124,16 @@ const el = { el.ofdButton.addEventListener("click", openOFDFile); el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages")); el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta")); +el.pagesTab.addEventListener("click", () => showOutlines(false)); +el.outlinesTab.addEventListener("click", () => showOutlines(true)); +el.navigationTabs.addEventListener("keydown", (event) => { + if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { + event.preventDefault(); + const outlines = event.key === "End" || (event.key !== "Home" && el.outlineList.hidden); + showOutlines(outlines); + (outlines ? el.outlinesTab : el.pagesTab).focus(); + } +}); el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput)); el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput)); el.localFontButton.addEventListener("click", loadLocalFonts); @@ -1178,6 +1193,9 @@ async function openDocument(options = {}) { } resetPageFlow(); renderPageList(); + if (options.resetScroll || el.outlineList.childElementCount === 0) { + renderOutlines(); + } renderMeta(); renderPageFlow(); if (options.resetScroll) { @@ -1748,6 +1766,68 @@ function prefixSVGIds(svg, prefix) { } } +function showOutlines(show) { + el.pageList.hidden = show; + el.outlineList.hidden = !show; + el.pagesTab.setAttribute("aria-selected", String(!show)); + el.outlinesTab.setAttribute("aria-selected", String(show)); + el.pagesTab.tabIndex = show ? -1 : 0; + el.outlinesTab.tabIndex = show ? 0 : -1; + el.pageListPanel.scrollTop = 0; +} + +function renderOutlines() { + const outlines = state.doc.outlines || []; + el.pageListTitle.hidden = outlines.length > 0; + el.navigationTabs.hidden = outlines.length === 0; + el.outlineList.replaceChildren(); + if (outlines.length > 0) { + el.outlineList.append(createOutlineList(outlines)); + } + showOutlines(false); +} + +function createOutlineList(outlines) { + const list = document.createElement("ul"); + list.className = "outline-list"; + for (const outline of outlines) { + const item = document.createElement("li"); + const hasChildren = outline.children?.length > 0; + const row = document.createElement(hasChildren ? "summary" : "div"); + if (!hasChildren) { + row.className = "outline-leaf"; + } + const link = document.createElement(outline.page ? "button" : "span"); + link.className = "outline-link"; + const title = document.createElement("span"); + title.textContent = outline.title; + link.append(title); + if (outline.page) { + link.type = "button"; + const page = document.createElement("span"); + page.className = "outline-page"; + page.textContent = String(outline.page); + page.setAttribute("aria-label", `第 ${outline.page} 页`); + link.append(page); + link.addEventListener("click", (event) => { + event.preventDefault(); + renderPage(outline.page - 1); + }); + } + row.append(link); + if (hasChildren) { + const details = document.createElement("details"); + details.open = outline.expanded; + details.append(row, createOutlineList(outline.children)); + item.append(details); + } else { + item.append(row); + } + list.append(item); + } + return list; +} + function renderPageList() { el.pageList.replaceChildren(); if (!state.doc) { diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 07b6fe0..3252a6c 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -127,9 +127,13 @@ type DocumentInfo struct { Fonts []FontInfo `json:"fonts"` Signatures []SignatureInfo `json:"signatures"` Pages []PageInfo `json:"pages"` + Outlines []OutlineInfo `json:"outlines,omitempty"` DetailsPending bool `json:"detailsPending,omitempty"` } +// OutlineInfo 目录节点信息 +type OutlineInfo = ofdgo.OutlineInfo + // PageInfo 页面信息 type PageInfo struct { Index int `json:"index"` @@ -257,6 +261,7 @@ func (s *Session) Summary() DocumentInfo { DocType: s.Reader.DocType(), PageCount: len(s.doc.Pages.Page), Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), + Outlines: s.doc.OutlineInfos(), DetailsPending: true, } if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil { @@ -300,6 +305,7 @@ func (s *Session) Info() DocumentInfo { DocType: s.Reader.DocType(), PageCount: len(s.doc.Pages.Page), Pages: make([]PageInfo, 0, len(s.doc.Pages.Page)), + Outlines: s.doc.OutlineInfos(), } if signatures, err := s.signatureInfos(); err == nil { info.Signatures = signatures diff --git a/ofdgo_action.go b/ofdgo_action.go index f857769..e2876c9 100644 --- a/ofdgo_action.go +++ b/ofdgo_action.go @@ -102,6 +102,21 @@ type RegionCommand struct { EndPoint string } +// gotoDest 获取文档内跳转目标 +// 入参: action 跳转动作, bookmarks 书签 +// 返回: *Dest 跳转目标 +func gotoDest(action *Goto, bookmarks map[string]Dest) *Dest { + if action.Dest != nil { + return action.Dest + } + if action.Bookmark != nil { + if dest, ok := bookmarks[action.Bookmark.Name]; ok { + return &dest + } + } + return nil +} + // UnmarshalXML 解析跳转目标 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 diff --git a/ofdgo_doc.go b/ofdgo_doc.go index fe64103..e5d4442 100644 --- a/ofdgo_doc.go +++ b/ofdgo_doc.go @@ -161,6 +161,14 @@ type OutlineElem struct { OutlineElem []OutlineElem `xml:"OutlineElem"` } +// OutlineInfo 目录节点信息,Page从1开始,0表示没有有效的文档内目标页 +type OutlineInfo struct { + Title string `json:"title"` + Expanded bool `json:"expanded"` + Page int `json:"page,omitempty"` + Children []OutlineInfo `json:"children,omitempty"` +} + // Permissions 权限声明 type Permissions struct { Edit bool `xml:"Edit"` @@ -182,6 +190,48 @@ type ValidPeriod struct { EndDate string `xml:"EndDate,attr"` } +// OutlineInfos 获取目录层级和目标页码 +// 返回: []OutlineInfo 目录节点信息 +func (doc *Document) OutlineInfos() []OutlineInfo { + if len(doc.Outlines.OutlineElem) == 0 { + return nil + } + bookmarks := make(map[string]Dest, len(doc.Bookmarks.Bookmark)) + for _, bookmark := range doc.Bookmarks.Bookmark { + bookmarks[bookmark.Name] = bookmark.Dest + } + pages := make(map[string]int, len(doc.Pages.Page)) + for index, page := range doc.Pages.Page { + pages[page.ID] = index + 1 + } + return outlineInfos(doc.Outlines.OutlineElem, bookmarks, pages) +} + +// outlineInfos 转换目录节点信息 +// 入参: outlines 大纲节点, bookmarks 书签, pages 页码索引 +// 返回: []OutlineInfo 目录节点信息 +func outlineInfos(outlines []OutlineElem, bookmarks map[string]Dest, pages map[string]int) []OutlineInfo { + infos := make([]OutlineInfo, 0, len(outlines)) + for _, outline := range outlines { + info := OutlineInfo{Title: outline.Title, Expanded: outline.Expanded} + for _, action := range outline.Actions { + if action.Goto != nil { + if dest := gotoDest(action.Goto, bookmarks); dest != nil { + info.Page = pages[dest.PageID] + if info.Page != 0 { + break + } + } + } + } + if len(outline.OutlineElem) > 0 { + info.Children = outlineInfos(outline.OutlineElem, bookmarks, pages) + } + infos = append(infos, info) + } + return infos +} + // UnmarshalXML 解析文档并应用权限默认值 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 diff --git a/ofdgo_renderer_pdf.go b/ofdgo_renderer_pdf.go index 0c06a38..0ae6e66 100644 --- a/ofdgo_renderer_pdf.go +++ b/ofdgo_renderer_pdf.go @@ -235,21 +235,6 @@ func appendGraphicActionSources(sources []pdfActionSource, object GraphicObject, return sources } -// gotoDest 获取文档内跳转目标 -// 入参: action 跳转动作, bookmarks 书签 -// 返回: *Dest 跳转目标 -func gotoDest(action *Goto, bookmarks map[string]Dest) *Dest { - if action.Dest != nil { - return action.Dest - } - if action.Bookmark != nil { - if dest, ok := bookmarks[action.Bookmark.Name]; ok { - return &dest - } - } - return nil -} - // outlineDest 获取大纲跳转目标 // 入参: outline 大纲节点, bookmarks 书签 // 返回: *Dest 跳转目标