ocrize
Fabrice P. Lauss𝕪s inelike Web

ocrize

ocrize is a python script that puts a text layer on a scanned pdf, so that an old paper which is nothing but photographs of its own pages becomes searchable and copiable. It makes sure that it remains selectable one column at a time in okular (evince works by default).

Written with Claude Opus 5 on 12 September (2026). Version 2.2.0.

The problem

Some old papers are not text-selectable. They are typically 300 dpi scans, one image per page. It might have its typographic charm but it's frustrating to work with.In my bib, that include,s e.g., einstein17a, keldysh79a and other old library copies. When glossing fano61a, I asked Claude to help me automatize OCR-ing.

Optical character recognition fixes that by sliding an invisible text layer underneath the picture. The picture stays the thing one looks at; the text underneath is what gets searched and selected. ocrmypdf and tesseract do the recognising; this script is about automatizing their work and ensuring that everything that recognition alone gets wrong, gets fixed.

Never deskew

ocrmypdf offers --deskew, to rotate the image by the opposite angle of an hypothetical skew, to correct it. This is tempting on an old scan, but it rewrites the page pixels and re-tags the colourspace from grey to ICC. So: never deskew an archival scan. The script never does.

Why okular selected both columns at once

Although pdftotext -raw reads the left column all the way down before starting the right, okular has a problem in selecting double columns. Other readers, like evince, typically don't. For okular, dragging a selection across a two-column paper grabs a band straight across both columns, a few lines of the left and a few lines of the right.

Okular does not trust the order the pdf gives it. It throws it away and re-derives it in TextPagePrivate::correctTextOrder(), in core/textpage.cpp. First makeAndSortLines gathers into a single line every word whose box overlaps another's vertically by seventy per cent—and in a journal the two columns sit on a shared baseline grid, so the left-hand line and the right-hand line at the same height become one line running across the page. That is deliberate, because an XY-cut is then supposed to split the columns apart again before anything is read out. The cut is the safety net, and the net has a hole:

const int tcx = word_spacing * 2;
const int tcy = line_spacing * 2;
...
} else if (gap_x >= gap_y && gap_x >= tcx) {
    cut_ver = true;

The vertical cut that separates the columns fires only if the widest empty vertical band is at least twice the average word spacing. Both are in okular's own units, which are points times 2000/(width+height) of the page. On fano61a:

measurement okular units
the gutter between the columns 19
average word spacing 12
what the cut needs, twice that 24

Nineteen against twenty-four. It misses by five units, three and a half points, so no vertical cut ever fires; nor does the horizontal one. The page survives as a single undivided region, the words come out of it row by row across the full width, and that row-major order is what the drag follows. Every scanned two-column paper in the collection had this, silently, and so does every one anybody else makes with ocrmypdf.

The fix

The reflex is to attack the gutter, but the gutter is a fact of the 1961 typesetting and cannot be widened without moving text. The other side of the inequality is free.

Tesseract sizes every word box tightly to its ink, which leaves the gaps between words as wide as they look on paper, and it is those gaps that are averaged into word_spacing. So the script stretches each word's horizontal scale until it reaches to within a point and a half of its neighbour on the same line. Nothing moves: every word still starts exactly where its ink starts, the glyph heights are untouched, the last word of a line is never stretched, and the gutter is never crossed. Only the trailing advance grows—which is what a born-digital pdf does anyway, where a word's advance has always included the space after it.

The average word gap collapses, tcx drops far below the gutter, the cut fires, the columns come apart. On fano61a the words okular emits out of reading order went from 1703 out of 1750 to none at all on pages 2 to 7. Page 1 mixes a full-width title and abstract with columns, and reads correctly too.

It checks its own work

The script carries a re-implementation of correctTextOrder, faithful to the original down to the noise threshold and the gap scan, and measures its own output with it. It tries a few inter-word margins and stops at the first that actually produces a correct reading order, rather than trusting that it ought to.

Scans that carry a stamp

Some papers are Nature pages whose only text is an overlaid © 1952 Nature Publishing Group. Dozens of characters for a whole paper, which a flat threshold would mistake for a text layer, so what counts is characters per page—under fifty and the paper is a scan whatever it claims. ocrmypdf then refuses to touch it at all, since a page with any text raises PriorOcrFoundError, and the fix is --redo-ocr: it keeps the stamp and recognises the image underneath. Not --force-ocr, which rasterises the page—the one thing never to do to a scan.

Usage

ocrize ~/bib/transit/fano61a.pdf

which says what it decided and why:

  fano61a.pdf: 7 pages, running OCR...
  fano61a.pdf: done (gutter 15.5pt, margin 1.5pt, Okular order 1703->0 of 1750
  words out of place); 24845 chars; original in .ocr-backup/

Several at once is the normal way to use it, since it passes over anything that already has text:

ocrize ~/bib/transit/*.pdf

-h explains the lot. --dry-run reports without writing, --force re-recognises a paper that already has a text layer, --margin pins the inter-word margin instead of letting it be chosen, --lang passes a language to tesseract, -V gives the version.

The original is copied to ~/bib/transit/.ocr-backup/ before anything is written. That directory is hidden on purpose: showme looks for papers with find … -maxdepth 1 -iname "*key*.pdf", so a backup sitting beside the original as a second .pdf would make it stop and ask which one I meant, every single time.

Two more things that cost an afternoon. The default output type of ocrmypdf, PDF/A, is required: with --output-type pdf the text layer lands inside a form XObject and everything downstream reads it worse. And the centred running head straddles the middle of the page, so a naive search for the gutter reports four points instead of fifteen—one has to look below the header, and the script tries several cuts before believing an answer.

What it does not do

Equations come out as nonsense, as they do from any recogniser: the prose is reliable, Cab(t) is not. Nothing is recognised twice without --force. When no gutter can be found the paper is taken for single-column and left as recognised, which is right for the Nature pages and means only that the column repair is skipped. And the page images are never touched, so the worst case is a text layer one does not like, underneath a scan that is exactly as it always was.

Version history

  • v°2.2.0 (12 September (2026)) — stamped scans recognised with --redo-ocr instead of being mistaken for text and skipped; unreadable files skipped with a word rather than a traceback.
  • v°2.0.0 (12 September (2026)) — the word-widening fix, and the re-implementation of okular's ordering that verifies it. Selection follows the columns at last.
  • v°1.0.0 to 1.5.0 (12 September (2026)) — first version, forcing one font size across the text layer. It fixes poppler's idea of the page and leaves okular exactly as it was.

The file

#!/usr/bin/env python3
"""ocrize 2.2.0 -- OCR a scanned paper so its text layer is actually usable,
including text SELECTION in Okular on two-column papers.

Why the second step exists
--------------------------
Plain ocrmypdf output looks fine but a drag-selection in a 2-column paper
grabs a band across BOTH columns.  Okular does not trust the PDF's text
order; it re-derives it in TextPagePrivate::correctTextOrder() (Okular
core/textpage.cpp): words on a shared baseline are merged into one line
across the whole page, and an XY-cut is then supposed to split the columns
apart again.  That cut only fires when the widest vertical gap in the region
is at least twice the average word spacing:

    tcx = word_spacing * 2 ;  cut_ver requires  gap_x >= tcx

Tesseract sizes every word box to its ink, so the gaps BETWEEN words stay
wide.  On fano61a that gave word_spacing 12 against a gutter of 19 (Okular
units): tcx = 24 > 19, no cut, one region, row-major order, band selection.

The fix is to widen each word box towards its neighbour on the same line,
leaving a small margin, so the average word spacing collapses while the
column gutter is untouched.  Nothing moves: each word still STARTS exactly
where its ink starts, and the page images are never touched.  Only the
trailing advance grows, which is what a born-digital PDF does anyway.

The result is verified by re-implementing Okular's own algorithm and
checking the reading order it produces.
"""
import argparse, os, re, shutil, subprocess, sys, tempfile
from pathlib import Path

VERSION = "2.2.0"
MARGINS = (3.0, 2.0, 1.5, 1.0, 0.5)

# ---------------------------------------------------------------- helpers

def sh(*a, **kw):
    return subprocess.run(a, capture_output=True, text=True, **kw)

def text_density(pdf):
    """(total non-space characters, characters per page)."""
    n = len(re.sub(r'\s+', '', sh('pdftotext', str(pdf), '-').stdout))
    return n, n/max(npages(pdf), 1)

def has_text(pdf):
    """A real text layer, as opposed to none or a copyright stamp.
    Many Nature scans carry a "(c) 1952 Nature Publishing Group" overlay and
    nothing else: dozens of characters over a whole paper, which a flat
    threshold would mistake for a text layer and skip."""
    return text_density(pdf)[1] > 50

def npages(pdf):
    m = re.search(r'Pages:\s+(\d+)', sh('pdfinfo', str(pdf)).stdout)
    return int(m.group(1)) if m else 0

def page_size(pdf):
    m = re.search(r'Page size:\s+([\d.]+) x ([\d.]+)', sh('pdfinfo', str(pdf)).stdout)
    return (float(m.group(1)), float(m.group(2))) if m else (595.0, 842.0)

def words_of(pdf, page, W, H):
    xml = sh('pdftotext', '-bbox', '-f', str(page), '-l', str(page), str(pdf), '-').stdout
    return [(float(a)/W, float(b)/H, float(c)/W, float(d)/H, t) for a, b, c, d, t in
            re.findall(r'<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">([^<]*)</word>', xml)]

def find_gutter(pdf, pages):
    """Widest clear vertical band near the page centre. Tried at several top
    cuts: a centred running head straddles the middle and hides the gutter."""
    W, H = page_size(pdf)
    best = (0.0, W / 2)
    for top in (0.08, 0.12, 0.16, 0.20):
        for p in pages:
            ws = [w for w in words_of(pdf, p, 1.0, 1.0) if top * H < w[1] < 0.95 * H]
            if len(ws) < 50: continue
            x = 0.32 * W
            while x < 0.68 * W:
                if not any(w[0] < x < w[2] for w in ws):
                    lo = hi = x
                    while lo > 0.05 * W and not any(w[0] < lo < w[2] for w in ws): lo -= 0.5
                    while hi < 0.95 * W and not any(w[0] < hi < w[2] for w in ws): hi += 0.5
                    if hi - lo > best[0]: best = (hi - lo, (lo + hi) / 2)
                    x = hi + 0.5
                else:
                    x += 0.5
    return best

# ------------------------------------------- Okular's ordering, re-implemented
# Faithful to okular/core/textpage.cpp: makeAndSortLines,
# calculateStatisticalInformation, XYCutForBoundingBoxes.

class R:
    __slots__ = ('x', 'y', 'w', 'h')
    def __init__(s, x, y, w, h): s.x, s.y, s.w, s.h = x, y, w, h
    def right(s): return s.x + s.w - 1
    def bottom(s): return s.y + s.h - 1
    def intersects(s, o):
        return not (o.x > s.right() or o.right() < s.x or o.y > s.bottom() or o.bottom() < s.y)

def _geom(nr, pw, ph):
    l, t, r, b = int(nr[0]*pw), int(nr[1]*ph), int(nr[2]*pw), int(nr[3]*ph)
    return R(l, t, r-l+1, b-t+1)

def _overlap(l1, r1, l2, r2, th):
    if l1 <= l2 and r1 >= r2: return True
    if l1 >= l2 and r1 <= r2: return True
    if r2 >= l1 and r1 >= l2:
        ov = (r1-l2) if r2 >= r1 else (r2-l1)
        return ov*100 >= th*min(r1-l1, r2-l2)
    return False

def _lines(words, pw, ph):
    ws = sorted(words, key=lambda w: (_geom(w, pw, ph).y, _geom(w, pw, ph).x))
    out = []
    for w in ws:
        e = _geom(w, pw, ph); hit = False
        for L in out:
            a = L[1]
            if _overlap(e.y, e.bottom(), a.y, a.bottom(), 70):
                L[0].append(w)
                nl, nr = min(a.x, e.x), max(a.x+a.w, e.x+e.w)
                nt, nb = min(a.y, e.y), max(a.y+a.h, e.y+e.h)
                L[1] = R(nl, nt, nr-nl, nb-nt); hit = True; break
        if not hit: out.append([[w], R(e.x, e.y, e.w, e.h)])
    for L in out: L[0].sort(key=lambda w: _geom(w, pw, ph).x)
    return out

def _stats(words, pw, ph):
    lines = _lines(words, pw, ph)
    ls = {}
    for i in range(len(lines)-1):
        a, b = lines[i][1], lines[i+1][1]
        d = abs(b.y - (a.y+a.h)); ls[d] = ls.get(d, 0)+1
    lsp = 0; wc = 0
    for k, v in ls.items(): lsp += k*v; wc += v
    lsp = int(lsp/wc+0.5) if wc and lsp else 0
    hor = {}; col = {}
    for L in lines:
        lst = L[0]; mx = 0
        for i in range(len(lst)-1):
            a1, a2 = _geom(lst[i], pw, ph), _geom(lst[i+1], pw, ph)
            sp = a2.x - a1.right()
            if sp > mx: mx = sp
            if sp != 0 and sp != pw: hor[sp] = hor.get(sp, 0)+1
        if mx in hor:
            if hor[mx] != 1: hor[mx] -= 1
            else: del hor[mx]
        if mx != 0: col[mx] = col.get(mx, 0)+1
    wsp = 0; wc = 0
    for k, v in hor.items():
        if k > 0: wsp += k*v; wc += v
    if wc: wsp = int(wsp/wc+0.5)
    csp = 0
    if col:
        best = max(col.values()); csp = min(k for k, v in col.items() if v == best)
    if len(lines) == 1: wsp = csp
    return wsp, lsp

def _widest(proj, n):
    begin = end = -1; gap = -1; pos = -1
    for j in range(1, n):
        if begin >= 0 and proj[j-1] <= 0 and proj[j] > 0: end = j
        if proj[j-1] > 0 and proj[j] <= 0: begin = j
        if begin > 0 and end > 0 and end-begin > gap:
            gap = end-begin; pos = (end+begin)//2; begin = end = -1
    return gap, pos

def okular_order(words, W, H):
    """Return the word order Okular's correctTextOrder() would produce."""
    sf = 2000.0/(W+H); pw, ph = int(sf*W), int(sf*H)
    tree = [[words, R(0, 0, pw, ph)]]
    i = 0
    while i < len(tree):
        lst, reg = tree[i]
        if not lst: i += 1; continue
        ol, ot, sx, sy = reg.x, reg.y, reg.w, reg.h
        px = [0]*sx; py = [0]*sy
        wsp, lsp = _stats(lst, pw, ph)
        tcx, tcy = wsp*2, lsp*2
        for w in lst:
            e = _geom(w, pw, ph)
            for k in range(e.x, e.x+e.w+1):
                q = k-reg.x
                if 0 <= q < sx: px[q] += e.h
            for k in range(e.y, e.y+e.h+1):
                q = k-reg.y
                if 0 <= q < sy: py[q] += e.w
        nz = [v for v in px if v]
        avgX = int(sum(nz)/len(nz)) if nz else 0
        xb = 0
        while xb < sx and px[xb] <= 0: xb += 1
        xe = sx-1
        while xe >= 0 and px[xe] <= 0: xe -= 1
        yb = 0
        while yb < sy and py[yb] <= 0: yb += 1
        ye = sy-1
        while ye >= 0 and py[ye] <= 0: ye -= 1
        if xb > xe or yb > ye: i += 1; continue
        reg2 = R(ol+xb, ot+yb, xe-xb+1, ye-yb+1)
        tnx = int(avgX*10.0/100.0+0.5)
        px = [v-tnx for v in px]
        gy, posy = _widest(py, sy)
        gx, posx = _widest(px, sx)
        ch = cv = False
        if   gy >= gx and gy >= tcy: ch = True
        elif gy >= gx and gy <= tcy and gx >= tcx: cv = True
        elif gx >= gy and gx >= tcx: cv = True
        elif gx >= gy and gx <= tcx and gy >= tcy: ch = True
        else:
            tree[i] = [lst, reg2]; i += 1; continue
        if ch:
            th = posy-(reg2.y-ot)
            top = R(reg2.x, reg2.y, reg2.w, th); bot = R(reg2.x, reg2.y+th, reg2.w, reg2.h-th)
            tree[i] = [[w for w in lst if top.intersects(_geom(w, pw, ph))], top]
            tree.insert(i+1, [[w for w in lst if not top.intersects(_geom(w, pw, ph))], bot])
        else:
            lw = posx-(reg2.x-ol)
            lf = R(reg2.x, reg2.y, lw, reg2.h); rg = R(reg2.x+lw, reg2.y, reg2.w-lw, reg2.h)
            tree[i] = [[w for w in lst if lf.intersects(_geom(w, pw, ph))], lf]
            tree.insert(i+1, [[w for w in lst if not lf.intersects(_geom(w, pw, ph))], rg])
    out = []
    for lst, reg in tree:
        for L in _lines(lst, pw, ph): out.extend(L[0])
    return out, len(tree)

def okular_disorder(pdf, pages, gut_pt):
    """How many left-column words Okular would emit AFTER a right-column word."""
    W, H = page_size(pdf); g = gut_pt/W
    tot = bad = 0; regions = []
    for p in pages:
        order, nreg = okular_order(words_of(pdf, p, W, H), W, H)
        regions.append(nreg); seen = False
        for w in order:
            if w[1] < 0.13: continue
            if w[2] < g:
                tot += 1
                if seen: bad += 1
            elif w[0] > g: seen = True
    return bad, tot, regions

# ------------------------------------------------- the text-layer transform

def _unescape_len(s):
    """Number of 2-byte glyphs in a PDF literal string body."""
    out = bytearray(); i = 0
    while i < len(s):
        c = s[i:i+1]
        if c == b'\\':
            n = s[i+1:i+2]
            if n in (b'n', b'r', b't', b'b', b'f', b'(', b')', b'\\'):
                out += b'\0'; i += 2
            elif n.isdigit():
                j = i+1
                while j < len(s) and j < i+4 and s[j:j+1].isdigit(): j += 1
                out += b'\0'; i = j
            elif n in (b'\n', b'\r'):
                i += 2
            else:
                out += b'\0'; i += 2
        else:
            out += c; i += 1
    return len(out)//2

def _strings(data):
    """Spans of PDF literal strings, honouring escapes and nesting."""
    spans = []; i = 0; n = len(data)
    while i < n:
        if data[i:i+1] == b'(':
            j = i+1; depth = 1
            while j < n:
                c = data[j:j+1]
                if c == b'\\': j += 2; continue
                if c == b'(': depth += 1
                elif c == b')':
                    depth -= 1
                    if depth == 0: break
                j += 1
            spans.append((i, min(j+1, n))); i = min(j+1, n)
        else:
            i += 1
    return spans

def widen_stream(data, margin):
    """Grow each word's horizontal scale so it reaches to within `margin`
    points of the next word on the same line. Word START positions and the
    column gutter are left untouched."""
    spans = _strings(data)
    masked = bytearray(data)
    for a, b in spans: masked[a:b] = b' '*(b-a)
    masked = bytes(masked)
    tf = [(m.start(), float(m.group(1))) for m in re.finditer(rb'/\S+\s+([\d.]+)\s+Tf', masked)]
    tms = [m for m in re.finditer(rb'([\d.]+)\s+0\s+0\s+1\s+([\d.]+)\s+([\d.]+)\s+Tm', masked)]
    if not tms: return None
    words = []
    for m in tms:
        nxt = next(((a, b) for a, b in spans if a > m.end()), None)
        if not nxt: continue
        size = 0.0
        for pos, s in tf:
            if pos < m.start(): size = s
            else: break
        ng = _unescape_len(data[nxt[0]+1:nxt[1]-1])
        if size <= 0 or ng <= 0: continue
        a = float(m.group(1)); tx = float(m.group(2)); ty = float(m.group(3))
        words.append({'m': m, 'a': a, 'tx': tx, 'ty': ty, 'w': ng*0.5*size*a})
    edits = []
    for i, w in enumerate(words):
        if i+1 >= len(words): break
        n = words[i+1]
        if abs(n['ty']-w['ty']) > 0.01: continue        # different line
        if n['tx'] <= w['tx']: continue                 # not forward
        want = (n['tx']-margin)-w['tx']
        if want <= w['w'] or want > w['w']*5: continue  # only widen, sanely
        na = w['a']*want/w['w']
        m = w['m']
        edits.append((m.start(), m.end(),
                      f"{na:.5f} 0 0 1 {m.group(2).decode()} {m.group(3).decode()} Tm".encode()))
    if not edits: return None
    out = bytearray(); pos = 0
    for st, en, rep in edits:
        out += data[pos:st]; out += rep; pos = en
    out += data[pos:]
    return bytes(out)

def widen(src, dst, margin):
    import pikepdf
    pdf = pikepdf.open(src); seen = set()
    def do(obj, depth=0):
        try: key = obj.objgen
        except Exception: return
        if key in seen or depth > 3: return
        seen.add(key)
        try: data = obj.read_bytes()
        except Exception: return
        new = widen_stream(data, margin)
        if new is not None: obj.write(new)
        try: res = obj.get('/Resources')
        except Exception: res = None
        if res is not None and '/XObject' in res:
            for _, xo in res['/XObject'].items():
                if xo.get('/Subtype') == pikepdf.Name.Form: do(xo, depth+1)
    for page in pdf.pages:
        pg = pikepdf.Page(page)
        try:
            pg.contents_coalesce(); do(pg.obj.Contents)
        except Exception: pass
        res = pg.obj.get('/Resources')
        if res is not None and '/XObject' in res:
            for _, xo in res['/XObject'].items():
                if xo.get('/Subtype') == pikepdf.Name.Form: do(xo, 1)
    pdf.save(dst)

# ---------------------------------------------------------------- driver

def process(src, args):
    src = Path(src).resolve()
    if not src.is_file():
        print(f"  {src.name}: not found", file=sys.stderr); return False
    if has_text(src) and not args.force:
        print(f"  {src.name}: already has a text layer (use --force)"); return False
    n = npages(src)
    if n == 0:
        print(f"  {src.name}: not a readable PDF, skipped", file=sys.stderr); return False
    pages = list(range(1, n+1))
    probe = pages[1:8] or pages          # skip p1: title/abstract span legitimately
    chars = text_density(src)[0]
    # A stamped scan already carries a little text, and ocrmypdf refuses to
    # touch a page that has any (PriorOcrFoundError). --redo-ocr keeps that
    # text and recognises the image underneath; --force-ocr would rasterise
    # the page, which is exactly what we never want on a scan.
    mode = ['--force-ocr'] if args.force else (['--redo-ocr'] if chars else [])
    with tempfile.TemporaryDirectory() as td:
        td = Path(td); raw = td/'ocr.pdf'
        print(f"  {src.name}: {n} pages, running OCR"
              f"{' (redo, page carries a stamp)' if mode == ['--redo-ocr'] else ''}...", flush=True)
        r = sh('ocrmypdf', '-l', args.lang, *mode, str(src), str(raw))
        if r.returncode != 0 or not raw.exists():
            print(f"  {src.name}: ocrmypdf failed\n{r.stderr[-400:]}", file=sys.stderr); return False
        gw, gc = find_gutter(raw, probe)
        before = okular_disorder(raw, probe, gc)[0]
        best = None
        if gw < 6:
            why = f"single column (no gutter found), left as OCR'd"
            final = raw
        else:
            for mg in ([args.margin] if args.margin else MARGINS):
                t = td/f'w{mg}.pdf'
                widen(raw, t, mg)
                bad, tot, regs = okular_disorder(t, probe, gc)
                if best is None or bad < best[1]: best = (mg, bad, tot, regs, t)
                if bad == 0: break
            mg, bad, tot, regs, final = best
            why = (f"gutter {gw:.1f}pt, margin {mg}pt, Okular order {before}->{bad} "
                   f"of {tot} words out of place")
        if args.dry_run:
            print(f"  {src.name}: would install ({why})"); return True
        bk = src.parent/'.ocr-backup'; bk.mkdir(exist_ok=True)
        if not (bk/src.name).exists(): shutil.copy2(src, bk/src.name)
        shutil.copy(final, src)
        print(f"  {src.name}: done ({why}); "
              f"{len(sh('pdftotext', str(src), '-').stdout)} chars; original in .ocr-backup/")
    return True

def main():
    ap = argparse.ArgumentParser(
        prog='ocrize',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        description=(
            "Put a text layer on a scanned paper, so it can be searched, copied and\n"
            "-- the hard part -- selected one column at a time in Okular.\n\n"
            "Runs ocrmypdf (never --deskew: that rewrites the page pixels for nothing),\n"
            "then widens each word box towards its neighbour on the same line.  Okular\n"
            "re-derives its own reading order and only separates two columns when the\n"
            "gutter is at least twice the average word spacing; tight Tesseract word\n"
            "boxes leave that spacing too wide, so a drag selects a band across BOTH\n"
            "columns.  Widening the words collapses the spacing and the columns split.\n"
            "Word positions, glyph heights and the page images are left untouched."),
        epilog=(
            "examples:\n"
            "  ocrize ~/bib/transit/fano61a.pdf        one paper\n"
            "  ocrize ~/bib/transit/*.pdf              every scan, skipping what has text\n"
            "  ocrize --dry-run ~/bib/sci/*.pdf        say what would be done, write nothing\n"
            "  ocrize --force old.pdf                  redo a paper that already has text\n\n"
            "A scan carrying only a copyright stamp counts as having no text, and is\n"
            "recognised with --redo-ocr so the stamp survives and the image is not\n"
            "rasterised.\n\n"
            "The original is always kept in .ocr-backup/ beside the file.  That folder is\n"
            "hidden so showme does not offer it as a second candidate.\n"
            "Wiki: http://localhost/laussywiki/index.php/Ocrize"))
    ap.add_argument('pdfs', nargs='*', metavar='PDF',
                    help='the scanned PDFs to process; any that already have a '
                         'text layer are skipped unless --force is given')
    ap.add_argument('--lang', default='eng', metavar='LANG',
                    help='Tesseract language, e.g. eng, fra, deu (default: eng)')
    ap.add_argument('--margin', type=float, metavar='PT',
                    help='pin the inter-word margin in points instead of letting '
                         'it be chosen (it tries 3, 2, 1.5, 1 and 0.5 and stops at '
                         'the first that gives a correct reading order)')
    ap.add_argument('--force', action='store_true',
                    help='re-run OCR even on a paper that already has a text layer')
    ap.add_argument('--dry-run', action='store_true',
                    help='report what would be done without writing anything')
    ap.add_argument('-V', '--version', action='version', version=f'ocrize {VERSION}')
    a = ap.parse_args()
    if not a.pdfs: ap.error('no PDFs given')
    ok = sum(bool(process(p, a)) for p in a.pdfs)
    print(f"{ok}/{len(a.pdfs)} done")

if __name__ == '__main__':
    main()