From 6631073be5c9351eef2ece24e6bfaf9cb34ac4b2 Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 9 Oct 2026 12:44:40 +0000 Subject: [PATCH] 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 --- README.md | 6 ++++- myscan/app.py | 58 +++++++++++++++++++++++++++++++------------ myscan/ocr.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7311f91..ee38a52 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/myscan/app.py b/myscan/app.py index e81c6f3..826056b 100644 --- a/myscan/app.py +++ b/myscan/app.py @@ -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): diff --git a/myscan/ocr.py b/myscan/ocr.py index fd7bbf9..35e45cc 100644 --- a/myscan/ocr.py +++ b/myscan/ocr.py @@ -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)