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
|
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
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user