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
+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
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)