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:
@@ -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