feat(功能强化): 支持页面文字选择复制,优化选区显示
build.yaml / build (push) Successful in 38s

This commit is contained in:
xiaoqidun committed 2026-09-12 23:18:49 +08:00
1 parent 2ea937b7be
commit 1c7f3c03e9
6 files changed
+186 -8

No files matched your search

+30
View File
@@ -785,6 +785,34 @@ button.outline-link:hover {
transform-origin: 0 0; 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 { .page-link {
position: absolute; position: absolute;
} }
@@ -833,6 +861,8 @@ button.outline-link:hover {
display: block; display: block;
max-width: none; max-width: none;
transform-origin: 0 0; transform-origin: 0 0;
-webkit-user-select: none;
user-select: none;
} }
.meta-list { .meta-list {
+81 -4
View File
@@ -50,6 +50,7 @@ const state = {
continuous: false, continuous: false,
renderAnnotations: true, renderAnnotations: true,
pageCache: new Map(), pageCache: new Map(),
selectedPages: new Set(),
pageInFlight: new Map(), pageInFlight: new Map(),
pageRenderQueue: [], pageRenderQueue: [],
pageRenderRunning: false, pageRenderRunning: false,
@@ -194,6 +195,12 @@ el.fitButton.addEventListener("click", fitWidth);
el.fitHeightButton.addEventListener("click", fitHeight); el.fitHeightButton.addEventListener("click", fitHeight);
el.continuousButton.addEventListener("click", toggleContinuous); el.continuousButton.addEventListener("click", toggleContinuous);
el.annotationButton.addEventListener("click", toggleAnnotations); 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.pageExportFormat.addEventListener("change", () => updateDPIControl());
el.exportPageButton.addEventListener("click", exportCurrentPage); el.exportPageButton.addEventListener("click", exportCurrentPage);
el.exportButton.addEventListener("click", exportPDF); el.exportButton.addEventListener("click", exportPDF);
@@ -1471,6 +1478,7 @@ function renderPageFlow() {
function resetPageFlow() { function resetPageFlow() {
state.pageCache.clear(); state.pageCache.clear();
state.selectedPages.clear();
state.visiblePages.clear(); state.visiblePages.clear();
for (const task of state.pageInFlight.values()) { for (const task of state.pageInFlight.values()) {
task.resolve(null); task.resolve(null);
@@ -1509,13 +1517,11 @@ function flowPageObserver() {
const index = Number.parseInt(entry.target.dataset.pageIndex, 10); const index = Number.parseInt(entry.target.dataset.pageIndex, 10);
if (!entry.isIntersecting) { if (!entry.isIntersecting) {
state.visiblePages.delete(index); state.visiblePages.delete(index);
if (entry.target.classList.contains("rendered")) { unmountPage(index);
entry.target.querySelector(".page-surface").replaceChildren();
entry.target.classList.remove("rendered");
}
continue; continue;
} }
state.visiblePages.add(index); state.visiblePages.add(index);
state.selectedPages.delete(index);
renderFlowPage(index, { openSeq, priority: 4 }); renderFlowPage(index, { openSeq, priority: 4 });
} }
}, { }, {
@@ -1526,6 +1532,18 @@ function flowPageObserver() {
return state.pageObserver; 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 = {}) { async function renderFlowPage(index, options = {}) {
const openSeq = options.openSeq || state.openSeq; const openSeq = options.openSeq || state.openSeq;
if (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}`); const svg = parseSVG(page.svg, `p${openSeq}-${index}`);
svg.classList.add("ofd-svg"); svg.classList.add("ofd-svg");
surface.replaceChildren(svg); surface.replaceChildren(svg);
surface.append(createTextLayer(page.text));
for (const link of page.links) { for (const link of page.links) {
let url; let url;
try { 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) { function markFlowPageError(index, err) {
const shell = pageShell(index); const shell = pageShell(index);
if (!shell) { if (!shell) {
+9
View File
@@ -164,6 +164,14 @@ func renderPage(args []js.Value) (any, error) {
if err != nil { if err != nil {
return nil, err 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)) links := make([]any, len(page.Links))
for i, link := range page.Links { for i, link := range page.Links {
links[i] = map[string]any{ links[i] = map[string]any{
@@ -182,6 +190,7 @@ func renderPage(args []js.Value) (any, error) {
"height": page.Height, "height": page.Height,
"svg": page.SVG, "svg": page.SVG,
"links": links, "links": links,
"text": js.Global().Get("JSON").Call("parse", string(textData)),
}), nil }), nil
} }
+15 -4
View File
@@ -270,10 +270,10 @@ func (s *Session) SetFonts(fonts []FontFile) error {
return nil return nil
} }
// SearchPage 搜索指定页面,复用当前会话的文字索引 // PageText 获取指定页面文字,复用当前会话的文字索引
// 入参: index 页面索引, query 搜索文字 // 入参: index 页面索引
// 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 // 返回: *ofdgo.PageText 页面文字, error 错误信息
func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) { func (s *Session) PageText(index int) (*ofdgo.PageText, error) {
text := s.textCache[index] text := s.textCache[index]
if text == nil { if text == nil {
_, page, err := s.pageContent(index) _, page, err := s.pageContent(index)
@@ -286,6 +286,17 @@ func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error)
} }
s.textCache[index] = text 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 return text.Search(query), nil
} }
+9
View File
@@ -179,6 +179,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
return return
} }
face := ff.Face(sizePt, fillPaint, fontStyle, canvas.FontNormal) face := ff.Face(sizePt, fillPaint, fontStyle, canvas.FontNormal)
var metrics canvas.FontMetrics
if textRun != nil {
metrics = face.Metrics()
}
glyphTransforms := r.textObjectGlyphTransforms(fontID, obj) glyphTransforms := r.textObjectGlyphTransforms(fontID, obj)
hasUnderline := strings.Contains(obj.Decoration, "Underline") hasUnderline := strings.Contains(obj.Decoration, "Underline")
_, shadedFill := fillPaint.(canvas.Gradient) _, 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++ { for j := spans[i][0]; j < spans[i][1]; j++ {
textRun.Boxes[textPos+j] = unionTextBox(textRun.Boxes[textPos+j], box) 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 continue
} }
+42
View File
@@ -34,6 +34,15 @@ type TextRun struct {
ID string `json:"id"` ID string `json:"id"`
Text string `json:"text"` Text string `json:"text"`
Boxes []Box `json:"boxes"` 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字符区间 // 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) 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} 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)
}