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 跳转目标