From 66f0d8a9281aabb3bdd28d7ca930743a40411e41 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 12 Sep 2026 20:53:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=8A=9F=E8=83=BD=E5=BC=BA=E5=8C=96):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B3=A8=E8=A7=A3=E6=9F=A5=E7=9C=8B=E4=B8=8E?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=EF=BC=8C=E4=BC=98=E5=8C=96=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E5=8F=8A=E5=AD=97=E4=BD=93=E4=BF=A1=E6=81=AF=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 4 + assets/webui/ofdgo.css | 56 +++++++++----- assets/webui/ofdgo.js | 163 ++++++++++++++++++++++++---------------- internal/webui/webui.go | 38 +++++----- ofdgo_annot.go | 49 ++++++++++++ 5 files changed, 208 insertions(+), 102 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index 3d56e48..7d8ec22 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -129,6 +129,10 @@
+
OFD 字体
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 757472c..36b9cee 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -614,7 +614,7 @@ button.outline-link:hover { @media (prefers-reduced-motion: reduce) { .progress-spinner, - .stamp-highlight { + .region-highlight { animation: none; } @@ -694,17 +694,17 @@ button.outline-link:hover { display: none; } -.stamp-highlight { +.region-highlight { position: absolute; z-index: 4; border: 2px solid var(--accent); background: rgba(15, 123, 104, 0.08); box-shadow: 0 0 0 3px rgba(15, 123, 104, 0.16); pointer-events: none; - animation: stampPulse 0.9s ease-in-out 2; + animation: regionPulse 0.9s ease-in-out 2; } -@keyframes stampPulse { +@keyframes regionPulse { 50% { border-color: var(--accent-strong); box-shadow: 0 0 0 5px rgba(15, 123, 104, 0.18); @@ -742,6 +742,7 @@ button.outline-link:hover { .font-panel, .doc-font-panel, .attachment-panel, +.annotation-panel, .signature-panel { margin-top: 18px; padding-top: 12px; @@ -794,12 +795,14 @@ button.outline-link:hover { } .doc-font-list, +.annotation-list, .signature-list { display: grid; gap: 8px; } .doc-font-row, +.annotation-row, .signature-row { display: grid; gap: 6px; @@ -812,6 +815,7 @@ button.outline-link:hover { .page-list-item:not(:disabled):hover, .doc-font-row:hover, .signature-row:hover, +.annotation-row:hover, .font-row:hover { border-color: var(--line-strong); background: #ffffff; @@ -838,24 +842,20 @@ button.outline-link:hover { } .doc-font-head, +.annotation-head, .signature-head { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 8px; + display: flow-root; } .doc-font-name, .signature-name { - overflow: hidden; color: #1f2428; font-size: 13px; font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } -.signature-name-button { +.info-name-button { display: inline; padding: 0; border: 0; @@ -867,14 +867,15 @@ button.outline-link:hover { text-align: left; } -.signature-name-button:not(:disabled):hover { +.info-name-button:not(:disabled):hover { background: transparent; color: var(--accent-strong); text-decoration: underline; } -.signature-name-with-stamps { - white-space: normal; +.annotation-head .info-name-button { + color: var(--accent-strong); + font-size: 13px; } .signature-stamp-group { @@ -882,6 +883,10 @@ button.outline-link:hover { font-weight: 400; } +.signature-stamp-item { + white-space: nowrap; +} + .signature-stamp-link { padding: 0; border: 0; @@ -906,6 +911,17 @@ button.outline-link:hover { gap: 4px; } +.doc-font-badges, +.signature-badges, +.annotation-head > .font-badge { + float: right; + margin-left: 8px; +} + +.annotation-row .info-value { + white-space: pre-wrap; +} + .font-badge { display: inline-flex; align-items: center; @@ -937,7 +953,7 @@ button.outline-link:hover { color: #803f38; } -.signature-line { +.info-line { display: grid; grid-template-columns: 30px minmax(0, 1fr); align-items: start; @@ -947,21 +963,21 @@ button.outline-link:hover { line-height: 1.35; } -.signature-label { +.info-label { color: #7b8791; white-space: nowrap; } -.signature-value { +.info-value { min-width: 0; overflow-wrap: anywhere; } -.signature-line.ok .signature-value { +.info-line.ok .info-value { color: var(--accent-strong); } -.signature-line.fail .signature-value { +.info-line.fail .info-value { color: #803f38; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a935324..7a3d050 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -115,6 +115,8 @@ const el = { signaturePanel: document.querySelector("#signaturePanel"), signatureSummary: document.querySelector("#signatureSummary"), signatureList: document.querySelector("#signatureList"), + annotationPanel: document.querySelector("#annotationPanel"), + annotationList: document.querySelector("#annotationList"), docFontList: document.querySelector("#docFontList"), docFontSummary: document.querySelector("#docFontSummary"), availableFontSummary: document.querySelector("#availableFontSummary"), @@ -1245,15 +1247,15 @@ async function loadDocumentDetails(openSeq) { async function renderPage(index, options = {}) { if (!state.doc) { - return; + return null; } const openSeq = options.openSeq || state.openSeq; if (openSeq !== state.openSeq) { - return; + return null; } const pageCount = state.doc.pageCount || 0; if (index < 0 || index >= pageCount) { - return; + return null; } const pageSeq = ++state.pageSeq; if (!state.exporting) { @@ -1271,18 +1273,20 @@ async function renderPage(index, options = {}) { if (options.scroll !== false) { scrollToPage(index); } - await renderFlowPage(index, { throwError: true, openSeq, priority: 0 }); + const page = await renderFlowPage(index, { throwError: true, openSeq, priority: 0 }); if (openSeq !== state.openSeq || index !== state.pageIndex) { - return; + return null; } if (options.scroll !== false) { scrollToPage(index); } queueNearbyPages(index, openSeq); + return page; } catch (err) { if (openSeq === state.openSeq) { showError(err, false); } + return null; } finally { if (!options.keepBusy && !state.exporting && openSeq === state.openSeq && pageSeq === state.pageSeq) { setBusy(false); @@ -2071,6 +2075,7 @@ function renderMeta() { el.pageTotal.textContent = String(doc.pageCount || 0); renderAttachments(); renderSignatures(); + renderAnnotations(); renderDocumentFonts(); renderFontList(); updateLocalFontButton(); @@ -2140,17 +2145,17 @@ function renderSignatures() { badges.className = "signature-badges"; badges.append(fontBadge(signature.status === "valid" ? "通过" : signature.status === "invalid" ? "异常" : "未验", signature.status)); - head.append(name, badges); + head.append(badges, name); row.append(head); - appendSignatureLine(row, "编号", signature.id); - appendSignatureLine(row, "版本", signature.version); - appendSignatureLine(row, "章图", signature.sealType); - appendSignatureLine(row, "章号", signature.sealId); - appendSignatureLine(row, "章名", signature.sealName); - appendSignatureLine(row, "厂商", signature.sealVendor); - appendSignatureLine(row, "签者", signature.signer); - appendSignatureLine(row, "时间", formatSignatureTime(signature.signatureDateTime)); - appendSignatureLine(row, "机构", signatureAgency(signature)); + appendInfoLine(row, "编号", signature.id); + appendInfoLine(row, "版本", signature.version); + appendInfoLine(row, "章图", signature.sealType); + appendInfoLine(row, "章号", signature.sealId); + appendInfoLine(row, "章名", signature.sealName); + appendInfoLine(row, "厂商", signature.sealVendor); + appendInfoLine(row, "签者", signature.signer); + appendInfoLine(row, "时间", formatDocumentTime(signature.signatureDateTime)); + appendInfoLine(row, "机构", signatureAgency(signature)); appendSignatureCheck(row, "原文", signature.dataHashChecked, signature.dataHashOK); appendSignatureCheck(row, "签名", signature.signedValueChecked, signature.signedValueOK); if (signature.type !== "Sign") { @@ -2161,18 +2166,18 @@ function renderSignatures() { appendSignaturePolicy(row, "签期", signature.signatureTimeChecked, signature.signatureTimeOK); appendSignaturePolicy(row, "章期", signature.sealTimeChecked, signature.sealTimeOK); if (signature.sealCertTimeChecked) { - appendSignatureLine(row, "制期", signature.sealCertTimeOK ? "有效" : "失效", signature.sealCertTimeOK ? "ok" : ""); + appendInfoLine(row, "制期", signature.sealCertTimeOK ? "有效" : "失效", signature.sealCertTimeOK ? "ok" : ""); } appendSignaturePolicy(row, "时效", signature.certTimeChecked, signature.certTimeOK); appendSignatureCheck(row, "信任", signature.certTrustChecked, signature.certTrustOK); - appendSignatureLine(row, "保护", signatureReferenceText(signature), signatureReferenceStatus(signature)); - appendSignatureLine(row, "算法", signature.signatureMethod); - appendSignatureLine(row, "散列", signature.digestMethod); - appendSignatureLine(row, "序号", signature.signSerial); - appendSignatureLine(row, "主体", signature.signSubject && signature.signSubject !== signature.signer ? signature.signSubject : ""); - appendSignatureLine(row, "颁发", signature.signIssuer); - appendSignatureLine(row, "章证", signature.sealSubject); - appendSignatureLine(row, "错误", signature.error, "fail"); + appendInfoLine(row, "保护", signatureReferenceText(signature), signatureReferenceStatus(signature)); + appendInfoLine(row, "算法", signature.signatureMethod); + appendInfoLine(row, "散列", signature.digestMethod); + appendInfoLine(row, "序号", signature.signSerial); + appendInfoLine(row, "主体", signature.signSubject && signature.signSubject !== signature.signer ? signature.signSubject : ""); + appendInfoLine(row, "颁发", signature.signIssuer); + appendInfoLine(row, "章证", signature.sealSubject); + appendInfoLine(row, "错误", signature.error, "fail"); fragment.append(row); } el.signatureList.append(fragment); @@ -2196,20 +2201,17 @@ function signatureSummary(signatures) { function signatureNameNode(signature) { const stamps = signature.stamps || []; + const name = document.createElement("div"); + name.className = "signature-name"; if (!stamps.length) { - const name = document.createElement("div"); - name.className = "signature-name"; name.textContent = "签名"; return name; } - const name = document.createElement("div"); - name.className = "signature-name signature-name-with-stamps"; - const button = document.createElement("button"); button.type = "button"; - button.className = "signature-name-button"; + button.className = "info-name-button"; button.textContent = "签名"; - button.addEventListener("click", () => focusSignatureStamp(stamps[0])); + button.addEventListener("click", () => focusPageRegion(stamps[0], "签名")); name.append(button, signatureStampGroup(stamps)); return name; } @@ -2217,19 +2219,17 @@ function signatureNameNode(signature) { function signatureStampGroup(stamps) { const group = document.createElement("span"); group.className = "signature-stamp-group"; - group.append("("); for (const [index, stamp] of stamps.entries()) { - if (index > 0) { - group.append("、"); - } + const item = document.createElement("span"); + item.className = "signature-stamp-item"; const button = document.createElement("button"); button.type = "button"; button.className = "signature-stamp-link"; button.textContent = `第${index + 1}处`; - button.addEventListener("click", () => focusSignatureStamp(stamp)); - group.append(button); + button.addEventListener("click", () => focusPageRegion(stamp, "签名")); + item.append(index === 0 ? "(" : "", button, index === stamps.length - 1 ? ")" : "、"); + group.append(item); } - group.append(")"); return group; } @@ -2261,24 +2261,24 @@ function signatureReferenceStatus(signature) { return signature.referenceChecked === count ? "ok" : ""; } -function formatSignatureTime(value) { +function formatDocumentTime(value) { const text = String(value || "").trim(); return text.replace(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/, "$1-$2-$3 $4:$5:$6$7$8").replace("T", " "); } -function appendSignatureLine(row, label, value, status = "") { +function appendInfoLine(row, label, value, status = "") { if (!value && value !== 0) { return; } const line = document.createElement("div"); - line.className = status ? `signature-line ${status}` : "signature-line"; + line.className = status ? `info-line ${status}` : "info-line"; const key = document.createElement("span"); - key.className = "signature-label"; + key.className = "info-label"; key.textContent = label; const text = document.createElement("span"); - text.className = "signature-value"; + text.className = "info-value"; text.textContent = String(value); line.append(key, text); @@ -2286,7 +2286,7 @@ function appendSignatureLine(row, label, value, status = "") { } function appendSignatureCheck(row, label, checked, ok) { - appendSignatureLine(row, label, checked ? ok ? "通过" : "失败" : "未验", checked ? ok ? "ok" : "fail" : ""); + appendInfoLine(row, label, checked ? ok ? "通过" : "失败" : "未验", checked ? ok ? "ok" : "fail" : ""); } function appendSignaturePolicy(row, label, checked, ok) { @@ -2296,41 +2296,76 @@ function appendSignaturePolicy(row, label, checked, ok) { appendSignatureCheck(row, label, checked, ok); } -async function focusSignatureStamp(stamp) { - const pageIndex = (stamp.page || 0) - 1; - if (!state.doc || pageIndex < 0) { +async function focusPageRegion(region, label) { + const pageIndex = region.page - 1; + if (!state.doc || pageIndex < 0 || document.body.hasAttribute("aria-busy")) { + return; + } + const openSeq = state.openSeq; + if (!await renderPage(pageIndex, { fit: false, scroll: false })) { return; } - await renderPage(pageIndex, { fit: false, scroll: false }); await nextFrame(); - highlightSignatureStamp(stamp); - setStatus(`签名已定位 第 ${stamp.page} 页`); + if (openSeq !== state.openSeq || pageIndex !== state.pageIndex) { + return; + } + highlightPageRegion(region); + setStatus(`${label}已定位 第 ${region.page} 页`); } -function highlightSignatureStamp(stamp) { - clearStampHighlights(); - const pageIndex = (stamp.page || 0) - 1; +function highlightPageRegion(region) { + clearRegionHighlights(); + const pageIndex = region.page - 1; const shell = pageShell(pageIndex); - if (!shell || !stamp.width || !stamp.height) { + if (!shell || !(region.width > 0 && region.height > 0)) { return; } const mark = document.createElement("div"); - mark.className = "stamp-highlight"; - mark.style.left = `${stamp.x * MM_TO_PX * state.scale}px`; - mark.style.top = `${stamp.y * MM_TO_PX * state.scale}px`; - mark.style.width = `${stamp.width * MM_TO_PX * state.scale}px`; - mark.style.height = `${stamp.height * MM_TO_PX * state.scale}px`; + mark.className = "region-highlight"; + mark.style.left = `${region.x * MM_TO_PX * state.scale}px`; + mark.style.top = `${region.y * MM_TO_PX * state.scale}px`; + mark.style.width = `${region.width * MM_TO_PX * state.scale}px`; + mark.style.height = `${region.height * MM_TO_PX * state.scale}px`; shell.append(mark); mark.scrollIntoView({ block: "nearest", inline: "nearest" }); window.setTimeout(() => mark.remove(), 1800); } -function clearStampHighlights() { - for (const mark of el.svgHost.querySelectorAll(".stamp-highlight")) { +function clearRegionHighlights() { + for (const mark of el.svgHost.querySelectorAll(".region-highlight")) { mark.remove(); } } +function renderAnnotations() { + const annotations = (state.doc?.annotations || []).filter((annotation) => annotation.visible); + el.annotationPanel.hidden = !annotations.length; + el.annotationList.replaceChildren(); + const types = { Link: "链接", Path: "路径", Highlight: "高亮", Stamp: "印章", Watermark: "水印" }; + const fragment = document.createDocumentFragment(); + for (const annotation of annotations) { + const row = document.createElement("div"); + row.className = "annotation-row"; + const head = document.createElement("div"); + head.className = "annotation-head"; + const name = document.createElement("button"); + name.type = "button"; + name.className = "info-name-button"; + name.textContent = `第 ${annotation.page} 页`; + name.disabled = !state.renderAnnotations || annotation.width <= 0 || annotation.height <= 0; + name.title = state.renderAnnotations ? "定位注解" : "注解已隐藏"; + name.addEventListener("click", () => focusPageRegion(annotation, "注解")); + const badge = fontBadge(types[annotation.type] || "注解", ""); + head.append(badge, name); + row.append(head); + appendInfoLine(row, "内容", annotation.remark); + appendInfoLine(row, "作者", annotation.creator); + appendInfoLine(row, "时间", formatDocumentTime(annotation.lastModDate)); + fragment.append(row); + } + el.annotationList.append(fragment); +} + function renderDocumentFonts() { const fonts = state.doc?.fonts || []; el.docFontList.replaceChildren(); @@ -2363,7 +2398,7 @@ function renderDocumentFonts() { const resultText = fontResult(font); const metaText = fontMeta(font); - head.append(name, badges); + head.append(badges, name); row.append(head); appendFontDetail(row, metaText); appendFontDetail(row, resultText); @@ -2452,7 +2487,7 @@ function setContinuous(continuous) { function toggleContinuous() { const anchor = state.fitMode === "free" ? scaleAnchor() : null; setContinuous(!state.continuous); - clearStampHighlights(); + clearRegionHighlights(); if (state.fitMode === "free") { restoreScaleAnchor(anchor); } else { @@ -2541,7 +2576,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") { state.fitMode = fitMode; state.scale = scale; if (layoutChanged) { - clearStampHighlights(); + clearRegionHighlights(); layoutPages(); } updateFitSpace(); diff --git a/internal/webui/webui.go b/internal/webui/webui.go index af72a2f..410f48b 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -114,24 +114,25 @@ 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"` - 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"` + Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"` + Pages []PageInfo `json:"pages"` + Outlines []OutlineInfo `json:"outlines,omitempty"` + DetailsPending bool `json:"detailsPending,omitempty"` } // OutlineInfo 目录节点信息 @@ -320,6 +321,7 @@ 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 { diff --git a/ofdgo_annot.go b/ofdgo_annot.go index c1ea9e1..ff6b12a 100644 --- a/ofdgo_annot.go +++ b/ofdgo_annot.go @@ -42,9 +42,27 @@ type Annotation struct { Creator string `xml:"Creator,attr"` LastModDate string `xml:"LastModDate,attr"` Visible *bool `xml:"Visible,attr"` + Remark string `xml:"Remark"` Appearance Appearance } +// AnnotationInfo 注释信息,Page从1开始,位置和尺寸使用页面毫米坐标 +type AnnotationInfo struct { + ID string `json:"id"` + Page int `json:"page"` + PageID string `json:"pageId"` + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + Creator string `json:"creator,omitempty"` + LastModDate string `json:"lastModDate,omitempty"` + Remark string `json:"remark,omitempty"` + Visible bool `json:"visible"` + X float64 `json:"x"` + Y float64 `json:"y"` + Width float64 `json:"width"` + Height float64 `json:"height"` +} + // Appearance 注释外观 type Appearance struct { Boundary string `xml:"Boundary,attr"` @@ -55,6 +73,37 @@ type Appearance struct { CompositeGraphicUnit []CompositeGraphicUnit `xml:"CompositeGraphicUnit"` } +// AnnotationInfos 按页面顺序获取注释信息,包含不可见注释 +// 返回: []AnnotationInfo 注释信息, error 错误信息 +func (r *Reader) AnnotationInfos() ([]AnnotationInfo, error) { + doc, err := r.Doc() + if err != nil { + return nil, err + } + 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, + }) + } + } + return infos, nil +} + // parseAnnotations 解析注释文件 // 入参: doc 文档结构 // 返回: error 错误信息