Copy OCR text as paragraphs

A Paragraphs toggle (Ctrl+J, on by default) makes copied and saved text
put each paragraph on one line: line breaks inside a paragraph become
spaces and words hyphenated across lines are rejoined (the hyphen stays
before a capital, as in "Nord-Amerika"). A line ends its paragraph if the
next one is indented, has a different text size, or starts with a word
that would have fitted at the end of this one, which works for justified
and ragged-right text alike; rows of several items (tables) are left
alone. The right-click menu offers the other form.

ocr.paragraph_joins gives the joins as edits on layout()'s text, and
reflow applies those inside any range, so selections keep working on the
page's character positions.

Ctrl+C now copies in the chosen form (QAction.triggered passed
checked=False through as paragraphs=False), and tabs show in the status
bar's selection preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-09 12:44:40 +00:00
co-authored by Claude Opus 5.5
parent 67333528ca
commit 6631073be5
3 changed files with 116 additions and 17 deletions
+5 -1
View File
@@ -24,7 +24,11 @@ Linux driver tools for the Hama myScan (00052343) scanner mouse, a Dacuda design
multilingual PP-OCRv6 models bundled in the wheel, works offline). Recognised lines are outlined on the page by multilingual PP-OCRv6 models bundled in the wheel, works offline). Recognised lines are outlined on the page by
confidence, and the text is selected right on the page like in a PDF viewer: drag (in reading order across confidence, and the text is selected right on the page like in a PDF viewer: drag (in reading order across
lines), double-click a word, triple-click a line, shift-click to extend, Ctrl+A; Ctrl+C or right-click copies. lines), double-click a word, triple-click a line, shift-click to extend, Ctrl+A; Ctrl+C or right-click copies.
Middle-drag or Space+drag pans, the wheel zooms. Copy all and Save text (.txt) take the whole page. `python tools/app.py [image]` also opens and reads existing images (Open, or With Paragraphs on (Ctrl+J, the default), copied and saved text has each paragraph on one line:
line breaks inside a paragraph become spaces and words hyphenated across lines are rejoined; a line ends a
paragraph if the next line is indented or its first word would have fitted on it (works for justified and
ragged text), and rows with several columns are left alone. Right-click offers the other form. Middle-drag or
Space+drag pans, the wheel zooms. Copy all and Save text (.txt) take the whole page. `python tools/app.py [image]` also opens and reads existing images (Open, or
drag and drop), which is all it does on machines without the mouse drag and drop), which is all it does on machines without the mouse
- `myscan/scanner.py` — the scan loop shared by `scan.py` and the app (finds the mouse's `/dev/videoN` itself) - `myscan/scanner.py` — the scan loop shared by `scan.py` and the app (finds the mouse's `/dev/videoN` itself)
- `myscan/live.py`, `myscan/webview.py` — the live preview and its browser viewer (standard library only) - `myscan/live.py`, `myscan/webview.py` — the live preview and its browser viewer (standard library only)
+42 -16
View File
@@ -25,7 +25,7 @@ from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGr
QGraphicsPolygonItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu, QGraphicsPolygonItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget) QMessageBox, QSizePolicy, QSplitter, QToolBar, QVBoxLayout, QWidget)
from .ocr import Ocr, layout from .ocr import Ocr, layout, paragraph_joins, reflow
IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)" IMAGE_FILTER = "Images (*.png *.jpg *.jpeg *.tif *.tiff *.bmp *.webp)"
@@ -169,7 +169,9 @@ class LineItem(QGraphicsPolygonItem):
class PageView(QGraphicsView): class PageView(QGraphicsView):
"""Zoomable page whose recognised text can be selected like in a PDF viewer: drag to """Zoomable page whose recognised text can be selected like in a PDF viewer: drag to
select in reading order, double-click for a word, triple-click for a line, shift-click select in reading order, double-click for a word, triple-click for a line, shift-click
to extend. Middle-drag (or Space + drag, or any drag before there is text) pans. Keeps to extend. Copied text either keeps the page's line breaks or, with `paragraphs` set,
has each paragraph on one line (see ocr.paragraph_joins). Middle-drag (or Space +
drag, or any drag before there is text) pans. Keeps
the page fitted to the window until the user zooms.""" the page fitted to the window until the user zooms."""
selectionChanged = Signal(str) selectionChanged = Signal(str)
@@ -189,7 +191,7 @@ class PageView(QGraphicsView):
self.sel_item.setZValue(2) self.sel_item.setZValue(2)
self.scene().addItem(self.sel_item) self.scene().addItem(self.sel_item)
self.items_, self.auto_fit, self.boxes_visible = [], True, True self.items_, self.auto_fit, self.boxes_visible = [], True, True
self.lines, self.spans, self.text = [], [], "" self.lines, self.spans, self.text, self.joins, self.paragraphs = [], [], "", [], True
self.sel, self.anchor, self.unit = (0, 0), None, None # unit: drag extends by "word"/"line" self.sel, self.anchor, self.unit = (0, 0), None, None # unit: drag extends by "word"/"line"
self.pan_from, self.space, self.last_double = None, False, None self.pan_from, self.space, self.last_double = None, False, None
@@ -209,6 +211,7 @@ class PageView(QGraphicsView):
it.setVisible(self.boxes_visible) it.setVisible(self.boxes_visible)
self.scene().addItem(it) self.scene().addItem(it)
self.lines, self.text, self.spans = lines, text, list(spans) self.lines, self.text, self.spans = lines, text, list(spans)
self.joins = paragraph_joins(lines, text, self.spans) if lines else []
self.sel, self.anchor = None, None self.sel, self.anchor = None, None
self.select(0, 0) self.select(0, 0)
@@ -253,10 +256,20 @@ class PageView(QGraphicsView):
def select_all(self): def select_all(self):
self.select(0, len(self.text)) self.select(0, len(self.text))
def selected_text(self): def text_range(self, a=0, b=None, paragraphs=None):
a, b = self.sel """The text from a to b (positions in self.text), reflowed into paragraphs if
`paragraphs` (default: self.paragraphs)."""
if self.paragraphs if paragraphs is None else paragraphs:
return reflow(self.text, self.joins, a, b)
return self.text[a:b] return self.text[a:b]
def selected_text(self, paragraphs=None):
return self.text_range(*self.sel, paragraphs=paragraphs)
def set_paragraphs(self, on):
self.paragraphs = on
self.selectionChanged.emit(self.selected_text())
def drag_to(self, scene_pos): def drag_to(self, scene_pos):
a, b = self.position(scene_pos, self.unit) a, b = self.position(scene_pos, self.unit)
a0, b0 = self.anchor a0, b0 = self.anchor
@@ -334,15 +347,18 @@ class PageView(QGraphicsView):
if not self.lines: if not self.lines:
return return
m = QMenu(self) m = QMenu(self)
m.addAction("Copy", self.copy).setEnabled(self.sel[0] != self.sel[1]) has_sel = self.sel[0] != self.sel[1]
m.addAction("Copy all text", lambda: QGuiApplication.clipboard().setText(self.text)) other = "Copy with the page's line breaks" if self.paragraphs else "Copy as paragraphs"
m.addAction("Copy", self.copy).setEnabled(has_sel)
m.addAction(other, lambda: self.copy(not self.paragraphs)).setEnabled(has_sel)
m.addAction("Copy all text", lambda: QGuiApplication.clipboard().setText(self.text_range()))
m.addSeparator() m.addSeparator()
m.addAction("Select all", self.select_all) m.addAction("Select all", self.select_all)
m.exec(ev.globalPos()) m.exec(ev.globalPos())
def copy(self): def copy(self, paragraphs=None):
if self.selected_text(): if text := self.selected_text(paragraphs):
QGuiApplication.clipboard().setText(self.selected_text()) QGuiApplication.clipboard().setText(text)
# ---- zoom ---- # ---- zoom ----
@@ -456,6 +472,10 @@ class MainWindow(QMainWindow):
self.act_auto.setChecked(True) self.act_auto.setChecked(True)
self.act_copy = act("Copy all", self.copy_text, "Ctrl+Shift+C", "Copy all text to the clipboard") self.act_copy = act("Copy all", self.copy_text, "Ctrl+Shift+C", "Copy all text to the clipboard")
self.act_savetxt = act("Save text…", self.save_text, "Ctrl+Shift+S", "Save the text as a .txt file") self.act_savetxt = act("Save text…", self.save_text, "Ctrl+Shift+S", "Save the text as a .txt file")
self.act_para = act("Paragraphs", self.view_paragraphs, "Ctrl+J",
"Copy and save each paragraph as one line, undoing the page's line breaks and "
"hyphenation (off: keep the lines as on the page)", True)
self.act_para.setChecked(True)
tb.addSeparator() tb.addSeparator()
self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True) self.act_boxes = act("Boxes", lambda on: self.view.show_boxes(on), "Ctrl+B", "Show recognised lines on the page", True)
self.act_boxes.setChecked(True) self.act_boxes.setChecked(True)
@@ -468,7 +488,8 @@ class MainWindow(QMainWindow):
self.camera = ImageLabel("camera") self.camera = ImageLabel("camera")
self.view = PageView() self.view = PageView()
self.view.selectionChanged.connect(self.on_selection) self.view.selectionChanged.connect(self.on_selection)
for key, slot in ((QKeySequence.Copy, self.view.copy), (QKeySequence.SelectAll, self.view.select_all)): for key, slot in ((QKeySequence.Copy, lambda: self.view.copy()),
(QKeySequence.SelectAll, self.view.select_all)):
a = QAction(self) a = QAction(self)
a.setShortcut(key) a.setShortcut(key)
a.triggered.connect(slot) a.triggered.connect(slot)
@@ -624,8 +645,8 @@ class MainWindow(QMainWindow):
self.view.set_lines(lines, self.text, spans) self.view.set_lines(lines, self.text, spans)
conf = f", mean confidence {np.mean([ln.score for ln in lines]):.0%}" if lines else "" conf = f", mean confidence {np.mean([ln.score for ln in lines]):.0%}" if lines else ""
self.page_head.setText(f"{self.page_title} · {len(lines)} lines{conf}") self.page_head.setText(f"{self.page_title} · {len(lines)} lines{conf}")
self.set_message(f"read {len(lines)} lines in {secs:.1f} s" + (" · drag over the text to select it" notes = ["drag over the text to select it"] if lines else []
if lines else "")) self.set_message(" · ".join([f"read {len(lines)} lines in {secs:.1f} s", *notes]))
self.act_copy.setEnabled(bool(self.text)) self.act_copy.setEnabled(bool(self.text))
self.act_savetxt.setEnabled(bool(self.text)) self.act_savetxt.setEnabled(bool(self.text))
@@ -640,12 +661,17 @@ class MainWindow(QMainWindow):
cb = QGuiApplication.clipboard() cb = QGuiApplication.clipboard()
if cb.supportsSelection(): # X11/Wayland: middle-click pastes it elsewhere if cb.supportsSelection(): # X11/Wayland: middle-click pastes it elsewhere
cb.setText(text, QClipboard.Selection) cb.setText(text, QClipboard.Selection)
preview = " ⏎ ".join(t for t in text.split("\n") if t) preview = " ⏎ ".join(t for t in text.replace("\t", " ").split("\n") if t)
preview = preview if len(preview) <= 90 else preview[:87] + "…" preview = preview if len(preview) <= 90 else preview[:87] + "…"
self.set_message(f"{len(text)} characters selected (Ctrl+C to copy): {preview}") self.set_message(f"{len(text)} characters selected (Ctrl+C to copy): {preview}")
def view_paragraphs(self, on):
self.view.set_paragraphs(on)
if not self.view.selected_text():
self.set_message("copying paragraphs as single lines" if on else "copying lines as on the page")
def copy_text(self): def copy_text(self):
QGuiApplication.clipboard().setText(self.text) QGuiApplication.clipboard().setText(self.view.text_range())
self.set_message("text copied to the clipboard") self.set_message("text copied to the clipboard")
def save_text(self): def save_text(self):
@@ -653,7 +679,7 @@ class MainWindow(QMainWindow):
path, _ = QFileDialog.getSaveFileName(self, "Save text", base + ".txt", "Text (*.txt)") path, _ = QFileDialog.getSaveFileName(self, "Save text", base + ".txt", "Text (*.txt)")
if path: if path:
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
f.write(self.text + "\n") f.write(self.view.text_range() + "\n")
self.set_message(f"saved {path}") self.set_message(f"saved {path}")
def closeEvent(self, ev): def closeEvent(self, ev):
+69
View File
@@ -7,6 +7,8 @@ Independent of the scanner hardware, like myscan.stitching.
lines = Ocr()(bgr) # [Line(box, text, score, edges)], 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 text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
joins = paragraph_joins(lines, text, spans)
reflow(text, joins) # the text with paragraphs on one line each
Each line knows where its characters are (from the recogniser's per-character boxes), 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 so text can be selected on the image: Line.locate maps a point to a character
@@ -169,3 +171,70 @@ def layout(lines):
parts.append(ln.text) parts.append(ln.text)
pos += len(ln.text) pos += len(ln.text)
return "".join(parts), spans return "".join(parts), spans
def _extent(ln, o, d):
"""(start, end) of a line's box along direction d (unit vector) from point o."""
t = (ln.box - o) @ d
return float(t.min()), float(t.max())
def _continues(rs, k):
"""Whether row k + 1 continues the paragraph of row k (both single lines): similar
text height, the next line isn't indented, and line k is full, i.e. the next line's
first word would not have fitted in the space left at its end (which works for
justified and ragged-right text alike)."""
a, b = rs[k][0], rs[k + 1][0]
h = min(a.height, b.height)
if not a.text.strip() or not b.text.strip() or abs(a.height - b.height) > 0.3 * max(a.height, b.height):
return False
o = a.box[0]
d = (a.box[1] - a.box[0]) / np.hypot(*(a.box[1] - a.box[0]))
la, ra = _extent(a, o, d)
lb, _ = _extent(b, o, d)
if lb > la + 0.8 * h:
return False
left = min(la, lb)
right = ra
for j in range(max(k - 2, 0), min(k + 4, len(rs))): # the paragraph's right margin
if len(rs[j]) == 1 and abs(rs[j][0].height - h) < 0.3 * h:
lj, rj = _extent(rs[j][0], o, d)
if abs(lj - left) < 3 * h:
right = max(right, rj)
_, e = b.word(0)
word = (b.edges[e] - b.edges[0]) * np.hypot(*(b.box[1] - b.box[0]))
return right - ra < word + 0.5 * h
def paragraph_joins(lines, text, spans):
"""Where layout()'s text breaks a paragraph across lines, as edits for reflow():
(start, end, replacement) replacing a line break with a space, or removing it along
with the hyphen of a word split over two lines (the hyphen stays, without the
break, when the next line starts with a capital, as in "Nord-Amerika")."""
index = {id(ln): i for i, ln in enumerate(lines)}
rs = rows(lines)
out = []
for k in range(len(rs) - 1):
if len(rs[k]) != 1 or len(rs[k + 1]) != 1: # several items: a table, not prose
continue
a, b = rs[k][0], rs[k + 1][0]
e = spans[index[id(a)]][1]
if text[e:spans[index[id(b)]][0]] != "\n" or not _continues(rs, k):
continue
if len(a.text) > 1 and a.text[-1] in "-\u00ad¬" and a.text[-2].isalpha():
out.append((e - 1, e + 1, "") if b.text[0].islower() else (e, e + 1, ""))
else:
out.append((e, e + 1, " "))
return out
def reflow(text, joins, a=0, b=None):
"""text[a:b] with the paragraph_joins() edits that lie inside it applied."""
b = len(text) if b is None else b
parts, pos = [], a
for s, e, r in joins:
if a <= s and e <= b:
parts += [text[pos:s], r]
pos = e
parts.append(text[pos:b])
return "".join(parts)