diff --git a/assets/webui/index.html b/assets/webui/index.html index 7d8ec22..a0cbab2 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -22,13 +22,13 @@
- + - +
@@ -58,9 +58,23 @@
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 36b9cee..9739a93 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -149,6 +149,19 @@ button:not(:disabled):hover { font-size: 14px; } +.arrow-prev::before, +.arrow-next::before { + width: 5px; + height: 8px; + background: currentColor; + clip-path: polygon(100% 0, 100% 20%, 30% 50%, 100% 80%, 100% 100%, 0 50%); + content: ""; +} + +.arrow-next::before { + transform: rotate(180deg); +} + .open-icon { position: relative; width: 14px; @@ -287,6 +300,7 @@ body[data-hide-meta] .meta-panel { .page-list-panel, .viewer-panel, +.search-results, .meta-panel { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; @@ -400,6 +414,110 @@ body[data-hide-meta] .meta-panel { list-style: none; } +body:not([data-hide-pages]) .page-list-panel:has(#searchPanel:not([hidden])), +#searchPanel:not([hidden]) { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +#searchPanel { + flex: 1; +} + +#searchPanel :focus-visible { + outline-offset: -2px; +} + +.search-results { + min-height: 0; + overflow: auto; +} + +.search-form { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.search-form input { + width: 0; + min-width: 0; + flex: 1; + height: var(--control-height); + padding: 0 6px; + border: 1px solid var(--line); + border-radius: 0; + background: #ffffff; + color: #1f2428; + font-size: 13px; + appearance: none; +} + +.search-form button { + flex-shrink: 0; + font-size: 12px; +} + +.search-tools { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 6px; + margin: 8px 0; + color: var(--text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +#searchStatus { + flex: 1; + min-width: 0; +} + +.search-tools button { + width: 24px; + height: 24px; + padding: 0; +} + +.search-result { + display: block; + width: 100%; + height: auto; + padding: 8px 4px; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + font-size: 13px; + line-height: 1.5; + text-align: left; + overflow-wrap: anywhere; +} + +.search-result[aria-current="true"] { + background: #e4f3ef; +} + +.search-page { + display: block; + margin-bottom: 3px; + color: var(--text-muted); + font-size: 11px; +} + +.search-result mark { + background: #c9e6de; + color: var(--accent-strong); +} + +.search-highlight { + position: absolute; + background: rgba(15, 123, 104, 0.24); + pointer-events: none; +} + .outline-list .outline-list { padding-left: 14px; } @@ -1174,6 +1292,7 @@ button.outline-link:hover { .workspace { position: relative; grid-template-columns: minmax(0, 1fr); + overflow: clip; } body[data-hide-pages] .workspace, diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 7a3d050..ac21db9 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -32,6 +32,10 @@ const state = { openSeq: 0, pageSeq: 0, fontSeq: 0, + searchSeq: 0, + searchQuery: "", + searchMatches: [], + searchIndex: -1, localFonts: [], userFonts: [], fontSyncPending: false, @@ -99,6 +103,15 @@ const el = { outlinesTab: document.querySelector("#outlinesTab"), pageList: document.querySelector("#pageList"), outlineList: document.querySelector("#outlineList"), + searchTab: document.querySelector("#searchTab"), + searchPanel: document.querySelector("#searchPanel"), + searchForm: document.querySelector("#searchForm"), + searchInput: document.querySelector("#searchInput"), + searchStatus: document.querySelector("#searchStatus"), + searchCount: document.querySelector("#searchCount"), + searchPrev: document.querySelector("#searchPrev"), + searchNext: document.querySelector("#searchNext"), + searchResults: document.querySelector("#searchResults"), metaPanel: document.querySelector(".meta-panel"), appPanel: document.querySelector("#appPanel"), offlineStatus: document.querySelector("#offlineStatus"), @@ -128,14 +141,43 @@ 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.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab)); +el.outlinesTab.addEventListener("click", () => showNavigation(el.outlinesTab)); +el.searchTab.addEventListener("click", () => { + showNavigation(el.searchTab); + el.searchInput.focus({ preventScroll: 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(); + const tabs = [el.pagesTab, el.outlinesTab, el.searchTab].filter((tab) => !tab.hidden); + const current = tabs.indexOf(event.target); + const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 + : (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + showNavigation(tabs[index]); + tabs[index].focus(); + } +}); +el.searchForm.addEventListener("submit", (event) => { + event.preventDefault(); + searchDocument(); +}); +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", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && state.doc) { + event.preventDefault(); + if (!state.showPages) { + toggleSidebar("pages"); + } + showNavigation(el.searchTab); + el.searchInput.focus({ preventScroll: true }); + el.searchInput.select(); + } else if (event.key === "Escape" && !el.searchPanel.hidden) { + resetSearch(); + showNavigation(el.pagesTab); + el.pagesTab.focus(); } }); el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput)); @@ -1145,6 +1187,7 @@ async function openDocument(options = {}) { return; } const resetLocalFonts = !options.skipAutoFonts; + resetSearch(); if (resetLocalFonts) { state.localFonts = []; updateFontSummary(); @@ -1619,6 +1662,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { surface.append(anchor); } shell.classList.add("rendered"); + renderSearchHighlights(index); if (state.doc?.pages?.[index]) { layoutPageShell(shell, state.doc.pages[index]); } @@ -1823,25 +1867,190 @@ 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; +function showNavigation(selected) { + for (const [tab, panel] of [[el.pagesTab, el.pageList], [el.outlinesTab, el.outlineList], [el.searchTab, el.searchPanel]]) { + const active = tab === selected; + panel.hidden = !active; + tab.setAttribute("aria-selected", String(active)); + tab.tabIndex = active ? 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.pageListTitle.hidden = true; + el.navigationTabs.hidden = false; + el.outlinesTab.hidden = outlines.length === 0; el.outlineList.replaceChildren(); if (outlines.length > 0) { el.outlineList.append(createOutlineList(outlines)); } - showOutlines(false); + showNavigation(el.pagesTab); +} + +function resetSearch(clearInput = true) { + state.searchSeq += 1; + state.searchQuery = ""; + state.searchMatches = []; + state.searchIndex = -1; + if (clearInput) { + el.searchInput.value = ""; + } + el.searchStatus.textContent = "输入文字"; + el.searchResults.replaceChildren(); + updateSearchCount(); + clearSearchHighlights(); +} + +async function searchDocument() { + if (!state.doc || document.body.hasAttribute("aria-busy")) { + return; + } + const query = el.searchInput.value.trim(); + if (query && query === state.searchQuery) { + await selectSearchMatch(state.searchIndex + 1); + return; + } + resetSearch(false); + if (!query) { + return; + } + state.searchQuery = query; + const seq = state.searchSeq; + const openSeq = state.openSeq; + const pageCount = state.doc.pageCount; + try { + for (let page = 0; page < pageCount; page += 1) { + await waitForPaint(); + if (seq !== state.searchSeq || openSeq !== state.openSeq) { + return; + } + const matches = await callWASM("ofdgoSearchPage", page, query); + if (seq !== state.searchSeq || openSeq !== state.openSeq) { + return; + } + for (const match of matches || []) { + state.searchMatches.push({ ...match, page }); + } + el.searchStatus.textContent = `搜索 ${page + 1}/${pageCount} 页`; + renderSearchResults(); + } + el.searchStatus.textContent = state.searchMatches.length ? "搜索完成" : "未找到文字"; + if (state.searchIndex < 0 && state.searchMatches.length && !el.searchPanel.hidden && state.showPages) { + await selectSearchMatch(0); + } + } catch (err) { + if (seq === state.searchSeq && openSeq === state.openSeq) { + state.searchQuery = ""; + el.searchStatus.textContent = "搜索失败"; + showError(err, false); + } + } +} + +function updateSearchCount() { + const count = state.searchMatches.length; + el.searchCount.textContent = `${state.searchIndex + 1} / ${count}`; + el.searchPrev.disabled = count === 0; + el.searchNext.disabled = count === 0; +} + +function renderSearchResults() { + const start = Math.floor(Math.max(0, state.searchIndex) / 50) * 50; + if (el.searchResults.firstElementChild && Number(el.searchResults.firstElementChild.dataset.matchIndex) !== start) { + el.searchResults.replaceChildren(); + } + for (let i = start + el.searchResults.childElementCount; i < Math.min(start + 50, state.searchMatches.length); i += 1) { + const match = state.searchMatches[i]; + const button = document.createElement("button"); + button.type = "button"; + button.className = "search-result"; + button.dataset.matchIndex = String(i); + const page = document.createElement("span"); + page.className = "search-page"; + page.textContent = `第 ${match.page + 1} 页`; + const text = document.createElement("span"); + const mark = document.createElement("mark"); + mark.textContent = match.text; + text.append(match.before, mark, match.after); + button.append(page, text); + button.addEventListener("click", () => selectSearchMatch(i)); + el.searchResults.append(button); + } + for (const button of el.searchResults.children) { + button.setAttribute("aria-current", String(Number(button.dataset.matchIndex) === state.searchIndex)); + } + updateSearchCount(); +} + +async function selectSearchMatch(index) { + const count = state.searchMatches.length; + if (!count || document.body.hasAttribute("aria-busy")) { + return; + } + index = (index + count) % count; + state.searchIndex = index; + const match = state.searchMatches[index]; + const seq = state.searchSeq; + const openSeq = state.openSeq; + clearSearchHighlights(); + renderSearchResults(); + if (!await renderPage(match.page, { fit: false, scroll: false })) { + return; + } + await nextFrame(); + if (seq !== state.searchSeq || openSeq !== state.openSeq || state.searchIndex !== index || state.pageIndex !== match.page) { + return; + } + renderSearchHighlights(match.page); + const mark = pageShell(match.page).querySelector(".search-highlight"); + if (mark) { + const box = mark.getBoundingClientRect(); + const viewer = el.viewerPanel.getBoundingClientRect(); + el.viewerPanel.scrollTop += box.top + box.height / 2 - viewer.top - el.viewerPanel.clientHeight / 2; + if (box.left < viewer.left) { + el.viewerPanel.scrollLeft += box.left - viewer.left; + } else if (box.right > viewer.left + el.viewerPanel.clientWidth) { + el.viewerPanel.scrollLeft += box.right - viewer.left - el.viewerPanel.clientWidth; + } + } else { + scrollToPage(match.page); + } + const button = el.searchResults.querySelector("[aria-current=true]"); + const row = button.getBoundingClientRect(); + const panel = el.searchResults.getBoundingClientRect(); + if (row.top < panel.top) { + el.searchResults.scrollTop += row.top - panel.top; + } else if (row.bottom > panel.bottom) { + el.searchResults.scrollTop += row.bottom - panel.bottom; + } +} + +function clearSearchHighlights() { + for (const mark of el.svgHost.querySelectorAll(".search-highlight")) { + mark.remove(); + } +} + +function renderSearchHighlights(index) { + const match = state.searchMatches[state.searchIndex]; + if (!match || match.page !== index) { + return; + } + const surface = pageShell(index).querySelector(".page-surface"); + for (const mark of surface.querySelectorAll(".search-highlight")) { + mark.remove(); + } + for (const box of match.boxes || []) { + const mark = document.createElement("span"); + mark.className = "search-highlight"; + mark.style.left = `${box.X * MM_TO_PX}px`; + mark.style.top = `${box.Y * MM_TO_PX}px`; + mark.style.width = `${box.W * MM_TO_PX}px`; + mark.style.height = `${box.H * MM_TO_PX}px`; + surface.append(mark); + } } function createOutlineList(outlines) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 1648ea1..b8ef9f8 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -39,6 +39,7 @@ func RunWASM() { registerCallback("ofdgoDocumentInfo", documentInfo) registerCallback("ofdgoAttachmentData", attachmentData) registerCallback("ofdgoRenderPage", renderPage) + registerCallback("ofdgoSearchPage", searchPage) registerCallback("ofdgoExportFormats", exportFormats) registerCallback("ofdgoExportPage", exportPage) registerCallback("ofdgoExportPDF", exportPDF) @@ -118,7 +119,10 @@ func configureDocument(args []js.Value) (any, error) { return nil, err } } - currentSession.Renderer.RenderAnnotations = args[1].Bool() + if currentSession.Renderer.RenderAnnotations != args[1].Bool() { + currentSession.Renderer.RenderAnnotations = args[1].Bool() + clear(currentSession.textCache) + } return currentSession.Summary(), nil } @@ -181,6 +185,16 @@ func renderPage(args []js.Value) (any, error) { }), nil } +// searchPage 搜索OFD页面文字 +// 入参: args 浏览器参数 +// 返回: any 搜索结果, error 错误信息 +func searchPage(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + return currentSession.SearchPage(args[0].Int(), args[1].String()) +} + // exportFormats 获取导出格式 // 入参: args 浏览器参数 // 返回: any 导出格式, error 错误信息 diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 410f48b..fab7161 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -107,6 +107,7 @@ type Session struct { doc *ofdgo.Document pageCache map[int]*ofdgo.PageContent boxCache map[int]ofdgo.Box + textCache map[int]*ofdgo.PageText signatures []SignatureInfo signatureError error signaturesRead bool @@ -239,6 +240,7 @@ func Open(data []byte, opts OpenOptions) (*Session, error) { doc: doc, pageCache: make(map[int]*ofdgo.PageContent), boxCache: make(map[int]ofdgo.Box), + textCache: make(map[int]*ofdgo.PageText), }, nil } @@ -264,9 +266,29 @@ func (s *Session) SetFonts(fonts []FontFile) error { } else { s.Renderer.SetFontFS() } + clear(s.textCache) return nil } +// SearchPage 搜索指定页面,复用当前会话的文字索引 +// 入参: index 页面索引, query 搜索文字 +// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 +func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) { + text := s.textCache[index] + if text == nil { + _, page, err := s.pageContent(index) + if err != nil { + return nil, err + } + text, err = s.Renderer.PageText(page) + if err != nil { + return nil, err + } + s.textCache[index] = text + } + return text.Search(query), nil +} + // Summary 获取首屏所需信息,不解析页面图元或执行验签 // 返回: DocumentInfo 文档信息 func (s *Session) Summary() DocumentInfo { diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index 8c13647..d28cde0 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -45,6 +45,7 @@ type Renderer struct { fontDirs []string fontFS []fs.FS decodeImages bool + pageText *PageText } // RendererOption 渲染器配置选项 diff --git a/ofdgo_renderer_image.go b/ofdgo_renderer_image.go index f473524..ec64d30 100644 --- a/ofdgo_renderer_image.go +++ b/ofdgo_renderer_image.go @@ -30,6 +30,9 @@ import ( // renderImage 渲染图片 // 入参: ctx 画布上下文, obj 图片对象, pageH 页面高度, parentCTM 父级CTM, boundaryInCTM 边界是否参与父级CTM, parentClip 父级裁剪路径 func (r *Renderer) renderImage(ctx *canvas.Context, obj ImageObject, pageH float64, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { + if r.pageText != nil { + return + } if obj.Visible != nil && !*obj.Visible { return } diff --git a/ofdgo_renderer_path.go b/ofdgo_renderer_path.go index d1c3553..13bc465 100644 --- a/ofdgo_renderer_path.go +++ b/ofdgo_renderer_path.go @@ -173,6 +173,9 @@ func (s *pathStyle) scale(ctm Matrix) { // renderPath 渲染路径 // 入参: ctx 画布上下文, obj 路径对象, pageH 页面高度, defaults 默认绘制参数, parentCTM 父级CTM, boundaryInCTM 边界是否参与CTM变换, parentClip 父级裁剪路径 func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64, defaults *DrawParam, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { + if r.pageText != nil { + return + } if obj.Visible != nil && !*obj.Visible { return } @@ -499,7 +502,9 @@ func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, ob textObj.Alpha = nil textObj.FillColor = &FillColor{Value: "0 0 0"} textObj.StrokeColor = &StrokeColor{Value: "0 0 0"} - r.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil) + textRenderer := *r + textRenderer.pageText = nil + textRenderer.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil) } } if renderer.path == nil { diff --git a/ofdgo_renderer_stamp.go b/ofdgo_renderer_stamp.go index 5843871..7d9f8d6 100644 --- a/ofdgo_renderer_stamp.go +++ b/ofdgo_renderer_stamp.go @@ -28,6 +28,9 @@ import ( // renderStamp 渲染印章 // 入参: ctx 画布上下文, s 印章对象, pageH 页面高度 func (r *Renderer) renderStamp(ctx *canvas.Context, s Stamp, pageH float64) { + if r.pageText != nil { + return + } if s.Type == "ofd" && len(s.Data) > 0 { if s.Clip != nil { if img := r.renderOFDStampImage(s.Data); img != nil { diff --git a/ofdgo_renderer_text.go b/ofdgo_renderer_text.go index 7b6acb1..7cc576f 100644 --- a/ofdgo_renderer_text.go +++ b/ofdgo_renderer_text.go @@ -153,7 +153,28 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 fontStyle |= canvas.FontBold } ff := r.loadFont(fontID) + var textRun *TextRun + if r.pageText != nil { + textRun = r.pageText.addRun(obj) + } if ff == nil { + if textRun != nil { + box, _ := ParseBox(obj.Boundary) + boundaryCTM := TranslationMatrix(box.X, box.Y) + if parentCTM != nil { + if boundaryInCTM { + boundaryCTM = parentCTM.Multiply(boundaryCTM) + } else { + boundaryCTM = boundaryCTM.Multiply(*parentCTM) + } + } + m := canvas.Matrix{{boundaryCTM.a, -boundaryCTM.c, boundaryCTM.e}, {-boundaryCTM.b, boundaryCTM.d, pageH - boundaryCTM.f}} + path := canvas.Rectangle(box.W, box.H).Translate(0, -box.H).Transform(m) + box = textPathBox(applyClipPath(path, clipPath), pageH) + for i := range textRun.Boxes { + textRun.Boxes[i] = box + } + } ctx.Pop() return } @@ -172,6 +193,7 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 advanceX, advanceY = 0, -1 } codePos := 0 + textPos := 0 for _, tc := range obj.TextCode { var runes []rune var glyphs []textGlyph @@ -182,6 +204,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 runes = textCodeRunes(tc.Value) glyphs = textCodeGlyphs(runes, glyphTransforms, codePos) } + var spans [][2]int + if textRun != nil && tc.Index == "" { + spans = textGlyphSpans(runes, glyphTransforms, codePos) + } dxs, dys := parseFloats(tc.DeltaX), parseFloats(tc.DeltaY) xs, ys := parseFloats(tc.X), parseFloats(tc.Y) drawAsPath := embeddedFont || face.FauxBold > 0 || textCodePositioned(tc, xs, ys) || fillClip != nil || shadedFill || fillPattern != nil || shouldStroke @@ -247,6 +273,27 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if obj.CharDirection == 0 { advanceLimit = textGlyphAdvanceLimit(dxs, dys, xs, i, len(glyphs), cx) } + if textRun != nil { + if i < len(spans) { + if glyphPath == nil { + glyphPath, _ = r.cachedTextGlyphPath(face, glyph) + } + scaleX := hScale + if drawAsGlyphPath && advanceLimit > 0 && glyphWidth*scaleX > advanceLimit { + scaleX = advanceLimit / glyphWidth + } + m := canvas.Identity.Translate(canvasX, canvasY) + if useTextMatrix { + m = m.Mul(glyphMatrix) + } + path := glyphPath.Copy().Transform(m.Scale(scaleX, 1)) + box := textPathBox(applyClipPath(path, clipPath), pageH) + for j := spans[i][0]; j < spans[i][1]; j++ { + textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box) + } + } + continue + } if shouldStroke { scaleX := hScale if advanceLimit > 0 && glyphWidth*scaleX > advanceLimit { @@ -392,6 +439,13 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 } } codePos += len(runes) + if textRun != nil { + if tc.Index == "" { + textPos += len(runes) + } else { + textPos++ + } + } } ctx.Pop() } diff --git a/ofdgo_search.go b/ofdgo_search.go new file mode 100644 index 0000000..98fdad8 --- /dev/null +++ b/ofdgo_search.go @@ -0,0 +1,178 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "math" + "strings" + "unicode" + "unicode/utf8" + + "github.com/tdewolff/canvas" +) + +// PageText 页面文字,按绘制顺序保留文本对象,不包含图像和签名外观 +type PageText struct { + Runs []TextRun `json:"runs"` +} + +// TextRun 文本对象原文及逐字符区域,坐标以页面左上角为原点,单位为毫米 +// Boxes与Text的Unicode字符一一对应,缺少字体时使用文本对象区域 +type TextRun struct { + ID string `json:"id"` + Text string `json:"text"` + Boxes []Box `json:"boxes"` +} + +// TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间 +type TextMatch struct { + Run int `json:"run"` + Start int `json:"start"` + End int `json:"end"` + Before string `json:"before"` + Text string `json:"text"` + After string `json:"after"` + Boxes []Box `json:"boxes"` +} + +// PageText 提取页面及启用注释的原文,复用当前字体配置和渲染位置计算 +// 入参: page 页面内容 +// 返回: *PageText 页面文字, error 错误信息 +func (r *Renderer) PageText(page *PageContent) (*PageText, error) { + box, err := r.GetPageBox(page) + if err != nil { + return nil, err + } + renderer := *r + renderer.pageText = &PageText{} + if err := renderer.renderPageToContext(canvas.NewContext(canvas.New(box.W, box.H)), page, false); err != nil { + return nil, err + } + return renderer.pageText, nil +} + +// Search 按原文进行忽略大小写的字面匹配,不跨文本对象拼接或识别图像 +// 入参: query 搜索文字 +// 返回: []TextMatch 匹配结果及上下文 +func (p *PageText) Search(query string) []TextMatch { + var matches []TextMatch + if query == "" { + return matches + } + query = foldText(query) + length := utf8.RuneCountInString(query) + for index, run := range p.Runs { + text := foldText(run.Text) + runes := []rune(run.Text) + codeOffset := 0 + for offset := 0; offset < len(text); { + found := strings.Index(text[offset:], query) + if found < 0 { + break + } + start := codeOffset + utf8.RuneCountInString(text[offset:offset+found]) + offset += found + end := start + length + match := TextMatch{ + Run: index, Start: start, End: end, + Before: string(runes[max(0, start-16):start]), + Text: string(runes[start:end]), + After: string(runes[end:min(len(runes), end+16)]), + } + for _, box := range run.Boxes[start:end] { + if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) { + match.Boxes = append(match.Boxes, box) + } + } + matches = append(matches, match) + offset += len(query) + codeOffset = end + } + } + return matches +} + +// foldText 统一Unicode大小写等价字符,保留字符数量 +// 入参: text 原文 +// 返回: string 匹配用文字 +func foldText(text string) string { + return strings.Map(func(char rune) rune { + folded := char + for next := unicode.SimpleFold(char); next != char; next = unicode.SimpleFold(next) { + folded = min(folded, next) + } + return folded + }, text) +} + +// addRun 收集原始文本,字形索引不替代Unicode原文 +// 入参: obj 文本对象 +// 返回: *TextRun 文本及待填充的字符区域 +func (p *PageText) addRun(obj TextObject) *TextRun { + var text strings.Builder + for _, code := range obj.TextCode { + if code.Index == "" { + text.WriteString(string(textCodeRunes(code.Value))) + } else { + text.WriteRune('\uFFFC') + } + } + p.Runs = append(p.Runs, TextRun{ID: obj.ID, Text: text.String(), Boxes: make([]Box, utf8.RuneCountInString(text.String()))}) + return &p.Runs[len(p.Runs)-1] +} + +// textGlyphSpans 将绘制字形映射到原文字符区间 +// 入参: runes 原文字符, transforms 字形变换, offset 文本编码偏移 +// 返回: [][2]int 每个字形对应的字符起止位置 +func textGlyphSpans(runes []rune, transforms map[int]textGlyphTransform, offset int) [][2]int { + spans := make([][2]int, 0, len(runes)) + for i := 0; i < len(runes); { + if transform, ok := transforms[offset+i]; ok && i+transform.CodeCount <= len(runes) { + for range transform.Glyphs { + spans = append(spans, [2]int{i, i + transform.CodeCount}) + } + i += transform.CodeCount + } else { + spans = append(spans, [2]int{i, i + 1}) + i++ + } + } + return spans +} + +// textPathBox 将字形轮廓转换为页面区域 +// 入参: path 字形轮廓, pageH 页面高度 +// 返回: Box 字符区域 +func textPathBox(path *canvas.Path, pageH float64) Box { + if path.Empty() { + return Box{} + } + bounds := path.Bounds() + return Box{X: bounds.X0, Y: pageH - bounds.Y1, W: bounds.X1 - bounds.X0, H: bounds.Y1 - bounds.Y0} +} + +// unionTextBox 合并同一字符的字形区域 +// 入参: a 已有区域, b 新区域 +// 返回: Box 合并区域 +func unionTextBox(a, b Box) Box { + if a.W <= 0 || a.H <= 0 { + return b + } + if b.W <= 0 || b.H <= 0 { + return a + } + x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y) + return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y} +}