diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 9739a93..be7d460 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -785,6 +785,34 @@ button.outline-link:hover { transform-origin: 0 0; } +.text-layer { + position: absolute; + inset: 0; + overflow: hidden; + color: transparent; + opacity: 0.24; + pointer-events: none; + -webkit-user-select: text; + user-select: text; + text-size-adjust: none; +} + +.text-run > span { + position: absolute; + left: 0; + top: 0; + font: 100px/1 sans-serif; + white-space: pre; + transform-origin: 0 0; + cursor: text; + pointer-events: auto; +} + +.text-run > span::selection { + color: transparent; + background: var(--accent); +} + .page-link { position: absolute; } @@ -833,6 +861,8 @@ button.outline-link:hover { display: block; max-width: none; transform-origin: 0 0; + -webkit-user-select: none; + user-select: none; } .meta-list { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index ac21db9..a70791e 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -50,6 +50,7 @@ const state = { continuous: false, renderAnnotations: true, pageCache: new Map(), + selectedPages: new Set(), pageInFlight: new Map(), pageRenderQueue: [], pageRenderRunning: false, @@ -194,6 +195,12 @@ el.fitButton.addEventListener("click", fitWidth); el.fitHeightButton.addEventListener("click", fitHeight); el.continuousButton.addEventListener("click", toggleContinuous); el.annotationButton.addEventListener("click", toggleAnnotations); +document.addEventListener("copy", copySelection); +document.addEventListener("selectionchange", () => { + for (const index of state.selectedPages) { + unmountPage(index); + } +}); el.pageExportFormat.addEventListener("change", () => updateDPIControl()); el.exportPageButton.addEventListener("click", exportCurrentPage); el.exportButton.addEventListener("click", exportPDF); @@ -1471,6 +1478,7 @@ function renderPageFlow() { function resetPageFlow() { state.pageCache.clear(); + state.selectedPages.clear(); state.visiblePages.clear(); for (const task of state.pageInFlight.values()) { task.resolve(null); @@ -1509,13 +1517,11 @@ function flowPageObserver() { const index = Number.parseInt(entry.target.dataset.pageIndex, 10); if (!entry.isIntersecting) { state.visiblePages.delete(index); - if (entry.target.classList.contains("rendered")) { - entry.target.querySelector(".page-surface").replaceChildren(); - entry.target.classList.remove("rendered"); - } + unmountPage(index); continue; } state.visiblePages.add(index); + state.selectedPages.delete(index); renderFlowPage(index, { openSeq, priority: 4 }); } }, { @@ -1526,6 +1532,18 @@ function flowPageObserver() { return state.pageObserver; } +function unmountPage(index) { + const shell = pageShell(index); + const selection = document.getSelection(); + if (selection.rangeCount && selection.getRangeAt(0).intersectsNode(shell)) { + state.selectedPages.add(index); + return; + } + shell.querySelector(".page-surface").replaceChildren(); + shell.classList.remove("rendered"); + state.selectedPages.delete(index); +} + async function renderFlowPage(index, options = {}) { const openSeq = options.openSeq || state.openSeq; if (openSeq !== state.openSeq) { @@ -1638,6 +1656,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { const svg = parseSVG(page.svg, `p${openSeq}-${index}`); svg.classList.add("ofd-svg"); surface.replaceChildren(svg); + surface.append(createTextLayer(page.text)); for (const link of page.links) { let url; try { @@ -1668,6 +1687,64 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { } } +function createTextLayer(text) { + const layer = document.createElement("div"); + layer.className = "text-layer"; + const measure = document.createElement("canvas").getContext("2d"); + measure.font = "100px sans-serif"; + for (const run of text.runs || []) { + const line = document.createElement("span"); + line.className = "text-run"; + const chars = Array.from(run.text); + for (const item of run.spans || []) { + const span = document.createElement("span"); + span.textContent = chars.slice(item.start, item.end).join(""); + const width = measure.measureText(span.textContent).width || 1; + const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX); + span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`; + line.append(span); + } + layer.append(line); + } + return layer; +} + +function copySelection(event) { + if (event.target.closest?.("input, textarea, [contenteditable]")) { + return; + } + const selection = document.getSelection(); + if (selection.isCollapsed || !selection.rangeCount) { + return; + } + const range = selection.getRangeAt(0); + if (!el.svgHost.contains(range.startContainer) || !el.svgHost.contains(range.endContainer)) { + return; + } + const lines = []; + for (const run of el.svgHost.querySelectorAll(".text-run")) { + if (!range.intersectsNode(run)) { + continue; + } + const part = range.cloneRange(); + if (!run.contains(part.startContainer)) { + part.setStart(run, 0); + } + if (!run.contains(part.endContainer)) { + part.setEnd(run, run.childNodes.length); + } + const text = part.cloneContents().textContent; + if (text) { + lines.push(text); + } + } + if (!lines.length) { + return; + } + event.clipboardData.setData("text/plain", lines.join("\n")); + event.preventDefault(); +} + function markFlowPageError(index, err) { const shell = pageShell(index); if (!shell) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index b8ef9f8..2f76dc4 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -164,6 +164,14 @@ func renderPage(args []js.Value) (any, error) { if err != nil { return nil, err } + text, err := currentSession.PageText(args[0].Int()) + if err != nil { + return nil, err + } + textData, err := json.Marshal(text) + if err != nil { + return nil, err + } links := make([]any, len(page.Links)) for i, link := range page.Links { links[i] = map[string]any{ @@ -182,6 +190,7 @@ func renderPage(args []js.Value) (any, error) { "height": page.Height, "svg": page.SVG, "links": links, + "text": js.Global().Get("JSON").Call("parse", string(textData)), }), nil } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index fab7161..c0cc3c5 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -270,10 +270,10 @@ func (s *Session) SetFonts(fonts []FontFile) error { return nil } -// SearchPage 搜索指定页面,复用当前会话的文字索引 -// 入参: index 页面索引, query 搜索文字 -// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 -func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) { +// PageText 获取指定页面文字,复用当前会话的文字索引 +// 入参: index 页面索引 +// 返回: *ofdgo.PageText 页面文字, error 错误信息 +func (s *Session) PageText(index int) (*ofdgo.PageText, error) { text := s.textCache[index] if text == nil { _, page, err := s.pageContent(index) @@ -286,6 +286,17 @@ func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) } s.textCache[index] = text } + return text, nil +} + +// SearchPage 搜索指定页面,复用当前会话的文字索引 +// 入参: index 页面索引, query 搜索文字 +// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 +func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) { + text, err := s.PageText(index) + if err != nil { + return nil, err + } return text.Search(query), nil } diff --git a/ofdgo_renderer_text.go b/ofdgo_renderer_text.go index 7cc576f..cdca500 100644 --- a/ofdgo_renderer_text.go +++ b/ofdgo_renderer_text.go @@ -179,6 +179,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 return } face := ff.Face(sizePt, fillPaint, fontStyle, canvas.FontNormal) + var metrics canvas.FontMetrics + if textRun != nil { + metrics = face.Metrics() + } glyphTransforms := r.textObjectGlyphTransforms(fontID, obj) hasUnderline := strings.Contains(obj.Decoration, "Underline") _, shadedFill := fillPaint.(canvas.Gradient) @@ -291,6 +295,11 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 for j := spans[i][0]; j < spans[i][1]; j++ { textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box) } + bounds := canvas.Rect{Y0: -metrics.Descent, X1: glyphWidth, Y1: metrics.Ascent} + if !glyphPath.Empty() { + bounds = bounds.Add(glyphPath.Bounds()) + } + textRun.addSpan(textPos+spans[i][0], textPos+spans[i][1], bounds, m.Scale(scaleX, 1), clipPath, pageH) } continue } diff --git a/ofdgo_search.go b/ofdgo_search.go index 98fdad8..aeb49ea 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -31,9 +31,18 @@ type PageText struct { // TextRun 文本对象原文及逐字符区域,坐标以页面左上角为原点,单位为毫米 // Boxes与Text的Unicode字符一一对应,缺少字体时使用文本对象区域 type TextRun struct { - ID string `json:"id"` - Text string `json:"text"` - Boxes []Box `json:"boxes"` + ID string `json:"id"` + Text string `json:"text"` + Boxes []Box `json:"boxes"` + Spans []TextSpan `json:"spans"` +} + +// TextSpan 可选文字区间,Start和End为Unicode字符偏移 +// Matrix依次为a、b、c、d、e、f,将单位矩形映射到页面左上角为原点的毫米坐标 +type TextSpan struct { + Start int `json:"start"` + End int `json:"end"` + Matrix [6]float64 `json:"matrix"` } // TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间 @@ -176,3 +185,36 @@ func unionTextBox(a, b Box) Box { 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} } + +// addSpan 收集字形选择区域,同一原文区间的多个字形合并为一项 +// 入参: start 起始字符, end 结束字符, bounds 字形布局区域, m 字形变换, clip 裁剪路径, pageH 页面高度 +func (run *TextRun) addSpan(start, end int, bounds canvas.Rect, m canvas.Matrix, clip *canvas.Path, pageH float64) { + if m.Det() == 0 { + return + } + if clip != nil { + path := canvas.Rectangle(bounds.W(), bounds.H()).Translate(bounds.X0, bounds.Y0).Transform(m) + path = applyClipPath(path, clip) + if path.Empty() { + return + } + bounds = path.Transform(m.Inv()).Bounds() + } + if bounds.W() < 0 || bounds.H() <= 0 { + return + } + m = canvas.Matrix{{1, 0, 0}, {0, -1, pageH}}.Mul(m).Translate(bounds.X0, bounds.Y1).Scale(bounds.W(), -bounds.H()) + span := TextSpan{Start: start, End: end} + if n := len(run.Spans); n > 0 && run.Spans[n-1].Start == start && run.Spans[n-1].End == end { + span = run.Spans[n-1] + v := span.Matrix + previous := canvas.Matrix{{v[0], v[2], v[4]}, {v[1], v[3], v[5]}} + if previous.Det() != 0 { + bounds = canvas.Rect{X1: 1, Y1: 1}.Add(canvas.Rect{X1: 1, Y1: 1}.Transform(previous.Inv().Mul(m))) + m = previous.Translate(bounds.X0, bounds.Y0).Scale(bounds.W(), bounds.H()) + } + run.Spans = run.Spans[:n-1] + } + span.Matrix = [6]float64{m[0][0], m[1][0], m[0][1], m[1][1], m[0][2], m[1][2]} + run.Spans = append(run.Spans, span) +}