Select and copy text directly on the page

The text pane is gone: recognised text is selected on the page image like
in a PDF viewer (drag in reading order, double-click a word, triple-click a
line, shift-click to extend, Ctrl+A), copied with Ctrl+C or the context
menu, and previewed in the status bar; on X11/Wayland it also goes to the
primary selection. Middle-drag or Space+drag pans.

OCR asks RapidOCR for per-character boxes and keeps each line's character
boundaries (Line.edges), with Line.locate/quad/word for hit testing and
drawing; lines whose boxes don't match the text fall back to even spacing.

Errors are logged to the terminal with their traceback, and Ctrl+C in the
terminal closes the app (restoring mouse mode), dismissing any open dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-09 12:25:11 +00:00
co-authored by Claude Opus 5.5
parent 909baec1e7
commit 67333528ca
3 changed files with 315 additions and 108 deletions
+78 -3
View File
@@ -5,8 +5,12 @@ multilingual PP-OCRv6 models ship inside the wheel, so it works offline. They re
Latin script (English, Swedish, German, French, ...) as well as Chinese and Japanese.
Independent of the scanner hardware, like myscan.stitching.
lines = Ocr()(bgr) # [Line(box, text, score)], box = 4x2 corner points
lines = Ocr()(bgr) # [Line(box, text, score, edges)], box = 4x2 corner points
text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
Each line knows where its characters are (from the recogniser's per-character boxes),
so text can be selected on the image: Line.locate maps a point to a character
position, Line.quad gives the outline of a range of characters.
"""
import threading
from dataclasses import dataclass
@@ -19,6 +23,11 @@ class Line:
box: np.ndarray # 4x2 float corners (clockwise from top left), image pixels
text: str
score: float
edges: np.ndarray = None # len(text) + 1 character boundaries, 0..1 along the line
def __post_init__(self):
if self.edges is None: # no character boxes: assume evenly spaced characters
self.edges = np.linspace(0.0, 1.0, len(self.text) + 1)
@property
def cy(self):
@@ -34,6 +43,67 @@ class Line:
grows with the slant of a skewed page)."""
return float(np.hypot(*(self.box[3] - self.box[0])))
def local(self, p):
"""Image point -> (u, v): u runs 0..1 along the line, v 0..1 from its top to
its bottom edge."""
b = self.box
m = np.array([b[1] - b[0], b[3] - b[0]]).T
return np.linalg.solve(m, np.asarray(p, float) - b[0])
def locate(self, p):
"""(distance in pixels from the line's box, character position nearest p)."""
u, v = self.local(p)
b = self.box
du = max(-u, u - 1, 0) * np.hypot(*(b[1] - b[0]))
dv = max(-v, v - 1, 0) * self.height
return float(np.hypot(du, dv)), int(np.abs(self.edges - u).argmin())
def quad(self, a, b):
"""4x2 outline of characters a..b (positions in text)."""
top, bot = self.box[[0, 1]], self.box[[3, 2]]
ta, tb = self.edges[a], self.edges[b]
lerp = lambda e, t: e[0] + t * (e[1] - e[0])
return np.array([lerp(top, ta), lerp(top, tb), lerp(bot, tb), lerp(bot, ta)])
def word(self, k):
"""(start, end) of the word around character position k."""
a = b = min(k, len(self.text))
while a > 0 and not self.text[a - 1].isspace():
a -= 1
while b < len(self.text) and not self.text[b].isspace():
b += 1
return a, b
def char_edges(box, text, chars):
"""Character boundaries along a line (0..1) from the recogniser's boxes for its
non-space characters (4x2 corners each); a space spans the gap between its
neighbours. None if the boxes don't match the text."""
if not chars or len(chars) != len(text) - text.count(" "):
return None
m0, m1 = (box[0] + box[3]) / 2, (box[1] + box[2]) / 2
d = m1 - m0
t = lambda p: float(np.dot(p - m0, d) / np.dot(d, d))
ext = iter([(t((c[0] + c[3]) / 2), t((c[1] + c[2]) / 2)) for c in (np.asarray(c, float) for c in chars)])
lo, hi = [None] * len(text), [None] * len(text)
for i, ch in enumerate(text):
if ch != " ":
lo[i], hi[i] = next(ext)
end = 0.0
for i in range(len(text)): # spaces start where the previous character ends
if lo[i] is None:
lo[i] = end
else:
end = hi[i]
start = 1.0
for i in reversed(range(len(text))): # ... and end where the next one starts
if hi[i] is None:
hi[i] = start
else:
start = lo[i]
edges = [0.0] + [(hi[i - 1] + lo[i]) / 2 for i in range(1, len(text))] + [1.0]
return np.clip(np.maximum.accumulate(edges), 0.0, 1.0)
class Ocr:
"""Lazily loads the engine (about 0.3 s) on first use; calls are serialised."""
@@ -50,10 +120,15 @@ class Ocr:
def __call__(self, bgr):
with self._lock:
r = self.engine()(bgr)
r = self.engine()(bgr, return_word_box=True, return_single_char_box=True)
if r.boxes is None:
return []
return [Line(np.asarray(b, float), t, float(s)) for b, t, s in zip(r.boxes, r.txts, r.scores)]
words = r.word_results or [None] * len(r.txts)
out = []
for b, t, s, w in zip(r.boxes, r.txts, r.scores, words):
b = np.asarray(b, float)
out.append(Line(b, t, float(s), char_edges(b, t, [c[2] for c in w]) if w else None))
return out
def rows(lines):