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:
@@ -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
|
||||
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.
|
||||
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
|
||||
- `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)
|
||||
|
||||
+42
-16
@@ -25,7 +25,7 @@ from PySide6.QtWidgets import (QApplication, QFileDialog, QGraphicsPathItem, QGr
|
||||
QGraphicsPolygonItem, QGraphicsScene, QGraphicsView, QLabel, QMainWindow, QMenu,
|
||||
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)"
|
||||
|
||||
@@ -169,7 +169,9 @@ class LineItem(QGraphicsPolygonItem):
|
||||
class PageView(QGraphicsView):
|
||||
"""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
|
||||
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."""
|
||||
selectionChanged = Signal(str)
|
||||
|
||||
@@ -189,7 +191,7 @@ class PageView(QGraphicsView):
|
||||
self.sel_item.setZValue(2)
|
||||
self.scene().addItem(self.sel_item)
|
||||
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.pan_from, self.space, self.last_double = None, False, None
|
||||
|
||||
@@ -209,6 +211,7 @@ class PageView(QGraphicsView):
|
||||
it.setVisible(self.boxes_visible)
|
||||
self.scene().addItem(it)
|
||||
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.select(0, 0)
|
||||
|
||||
@@ -253,10 +256,20 @@ class PageView(QGraphicsView):
|
||||
def select_all(self):
|
||||
self.select(0, len(self.text))
|
||||
|
||||
def selected_text(self):
|
||||
a, b = self.sel
|
||||
def text_range(self, a=0, b=None, paragraphs=None):
|
||||
"""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]
|
||||
|
||||
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):
|
||||
a, b = self.position(scene_pos, self.unit)
|
||||
a0, b0 = self.anchor
|
||||
@@ -334,15 +347,18 @@ class PageView(QGraphicsView):
|
||||
if not self.lines:
|
||||
return
|
||||
m = QMenu(self)
|
||||
m.addAction("Copy", self.copy).setEnabled(self.sel[0] != self.sel[1])
|
||||
m.addAction("Copy all text", lambda: QGuiApplication.clipboard().setText(self.text))
|
||||
has_sel = self.sel[0] != self.sel[1]
|
||||
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.addAction("Select all", self.select_all)
|
||||
m.exec(ev.globalPos())
|
||||
|
||||
def copy(self):
|
||||
if self.selected_text():
|
||||
QGuiApplication.clipboard().setText(self.selected_text())
|
||||
def copy(self, paragraphs=None):
|
||||
if text := self.selected_text(paragraphs):
|
||||
QGuiApplication.clipboard().setText(text)
|
||||
|
||||
# ---- zoom ----
|
||||
|
||||
@@ -456,6 +472,10 @@ class MainWindow(QMainWindow):
|
||||
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_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()
|
||||
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)
|
||||
@@ -468,7 +488,8 @@ class MainWindow(QMainWindow):
|
||||
self.camera = ImageLabel("camera")
|
||||
self.view = PageView()
|
||||
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.setShortcut(key)
|
||||
a.triggered.connect(slot)
|
||||
@@ -624,8 +645,8 @@ class MainWindow(QMainWindow):
|
||||
self.view.set_lines(lines, self.text, spans)
|
||||
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.set_message(f"read {len(lines)} lines in {secs:.1f} s" + (" · drag over the text to select it"
|
||||
if lines else ""))
|
||||
notes = ["drag over the text to select it"] 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_savetxt.setEnabled(bool(self.text))
|
||||
|
||||
@@ -640,12 +661,17 @@ class MainWindow(QMainWindow):
|
||||
cb = QGuiApplication.clipboard()
|
||||
if cb.supportsSelection(): # X11/Wayland: middle-click pastes it elsewhere
|
||||
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] + "…"
|
||||
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):
|
||||
QGuiApplication.clipboard().setText(self.text)
|
||||
QGuiApplication.clipboard().setText(self.view.text_range())
|
||||
self.set_message("text copied to the clipboard")
|
||||
|
||||
def save_text(self):
|
||||
@@ -653,7 +679,7 @@ class MainWindow(QMainWindow):
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Save text", base + ".txt", "Text (*.txt)")
|
||||
if path:
|
||||
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}")
|
||||
|
||||
def closeEvent(self, ev):
|
||||
|
||||
@@ -7,6 +7,8 @@ Independent of the scanner hardware, like myscan.stitching.
|
||||
|
||||
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
|
||||
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),
|
||||
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)
|
||||
pos += len(ln.text)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user