Repository navigation
Expand file tree
/
Copy pathbatchocr.py
More file actions
2016 lines (1820 loc) · 94.9 KB
/
Copy pathbatchocr.py
File metadata and controls
2016 lines (1820 loc) · 94.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
batchocr -- batch document extraction and OCR.
What it handles:
- PDF: hybrid text extraction by default, decided per page. Every page's
native text layer is scored (plausible characters and words); pages that
score at least the quality threshold keep it, the others (scans, blank
text layers, broken font encodings) get full-page Tesseract OCR. One
sampled page is also OCRed and compared with its text layer; if the words
disagree the whole layer is rejected. Native text comes out in reading
order (PyMuPDF content-stream order when PyMuPDF is installed, plain
pdftotext otherwise); --layout restores the old "pdftotext -layout" rows.
Use --full-ocr (or --ocr tesseract) to force OCR on every page, --ocr off
to never OCR, --ocr ocrmypdf to run OCRmyPDF first. -H/--hybrid is kept
as a harmless no-op for backward compatibility.
- PDF to Markdown: --to md (or -t md) writes .md with headings, emphasis,
lists, tables and math, using the same per-page routing; OCR pages get
their structure from Tesseract's layout data. Needs PyMuPDF (AGPL-3.0,
installed separately: pip install pymupdf) and the bundled m1ck4_pdfmd/
package (a pinned copy of M1ck4/pdfmd, MIT; see VENDORED.md). Pages start
with <!-- Page N --> comments (--no-page-markers drops them, --page-breaks
adds '---' rules); running headers/footers are removed from PDFs of 4+
pages; every page is checked so no detector can swallow its text.
- Plain text to Markdown: a .txt file read with --to md gets headings, lists,
tables and code guessed from how it is typed (batchocr_txt.py), paragraphs
re-flowed; no dependency. Off unless you ask: --to md is the request. The
letters and digits never change, and a text that measures as garbled (symbol
soup, OCR noise, replacement characters) is given paragraphs only, with the
rule that fired printed (--txt-structure force/off overrides).
- Images (.png/.jpg/.jpeg/.tif/.tiff/.bmp/.pnm): OCRed directly with
Tesseract, no rasterisation needed. A single image is treated like a
single-page document. A directory of page images (page1.png, page2.png,
...) is treated like one document's pages: sorted in natural numeric
order and, since directory input with no -o merges by default (see
"Directory runs" below), combined into one output automatically -- the
same way --concat combines multiple files.
- PPTX/DOCX: converts to Markdown with Pandoc by default, OCRs extracted
images, and places each image's OCR below its image reference. Images are
temporary unless --save-images is used. --engine docx=libreoffice switches
.docx to LibreOffice instead: plain text only, output is .txt, no
embedded-image OCR -- useful when you just want the text and don't care
about images. .pptx has no LibreOffice alternative (soffice has no plain-
text export filter for Impress/presentation files) and always uses Pandoc.
- Ebook/comic formats: uses Calibre's ebook-convert by default, with Pandoc
as a fallback when Calibre is unavailable or fails. Image-only comics may
produce little or no text because this path is extraction, not image OCR.
--engine epub=pandoc (or mobi=..., cbz=..., or ebook=... for every
Calibre-supported extension at once) forces Pandoc instead, skipping
Calibre entirely.
- Legacy Office/OpenDocument formats (.doc, .xls, .ppt, .pps, .sx*, .od*)
use LibreOffice headlessly to produce plain text.
- TXT/MD are copied directly. Pandoc-supported text/document formats are
passed to Pandoc with an explicit input format where one is known.
- --engine FORMAT=ENGINE (repeatable) is the general switch above, for any
format with more than one viable backend. See "Choosing an engine" below.
Important behavior:
- This is extension-based, not a universal wrapper around every format a
third-party program might accept. Unknown extensions are explicitly
reported as skipped. Add a handler before relying on a new format.
- Single-file and directory runs both accept an optional output; if omitted,
output is written beside the input (a directory merges into "<dirname>.txt"
beside it, same as --concat -- see "Directory runs" below).
- Existing output is skipped on normal reruns. Use --force to redo it.
Single-file mode always processes the selected file.
- Completed files are written as soon as ready. With --archive-dir, each
completed source is moved there immediately; unfinished sources remain.
- manifest.tsv is a tab-separated summary of outputs, not an OCR transcript;
off by default in every mode, use -m/--manifest to write it. Columns:
filename, type, pages, word_count, snippet, output_file, line_range.
output_file/line_range point into the merged file or stdout stream when
--concat/--stdout applies to that source; otherwise line_range is '-'.
With --stdout, -m prints the manifest to stderr instead of a file (real
stdout stays text-only). --meta-dir collects the manifest, default log,
and default Office OCR report in a separate directory.
--log optionally mirrors terminal messages to a log file.
- Ctrl-C cancels queued PDF pages and terminates OCR workers.
- PDF output is split into pages with "--- Page N ---" markers on both the
native (pdftotext) and OCR paths. If the PDF defines printed page labels
that differ from the physical page number (e.g. a handbook numbered
"3-12"), the marker shows both: "--- Page 150 (3-12) ---". Labels are read
with PyMuPDF or pypdf when either is installed; otherwise markers carry
only the physical number. --no-page-markers restores the old unmarked
native output (OCR output always keeps its markers).
Dependencies:
macOS: brew install poppler tesseract pandoc calibre libreoffice
Extra Tesseract languages: brew install tesseract-lang
Linux package names vary; install pdfinfo/pdftoppm, tesseract, Pandoc,
and optionally Calibre. If Calibre is absent, supported ebook formats try
Pandoc and print a warning.
Common commands:
batchocr paper.pdf --to md -o paper.md
batchocr sources/ -o out/ --dpi 300 -a sources/parsed
batchocr inbox/ -o text/ --quality-threshold 0.85 -a inbox/done
batchocr sources/ -o out/ --quality-threshold 0.85
batchocr input.pdf
batchocr input.pdf -o out/ --force --log
batchocr input.pdf -o output.txt --full-ocr
batchocr presentation.pptx -o out/ --save-images --ocr-report
batchocr a.pdf b.pdf c.pdf -o out/
batchocr a.pdf b.pdf -m
batchocr a.pdf b.pdf c.pdf -o notes.txt --concat
batchocr a.pdf b.pdf --stdout | some-other-tool
batchocr pages/ -o book.txt --concat
batchocr scan.png
batchocr contract.docx --engine docx=libreoffice
batchocr library/ -o out/ --engine ebook=pandoc
Choosing an engine (--engine FORMAT=ENGINE, repeatable):
batchocr file.docx --engine docx=libreoffice -> plain text via LibreOffice,
output file.txt, no image OCR
batchocr book.epub --engine epub=pandoc -> force Pandoc, skip Calibre
batchocr library/ --engine ebook=pandoc -> Pandoc for every Calibre-supported
extension (epub, mobi, cbz, ...)
Default engines (no --engine needed): .docx -> pandoc; .pptx -> pandoc always
(no LibreOffice alternative exists); ebook formats -> calibre, falling back
to Pandoc only if Calibre is missing or fails. --engine office=... and
--engine pptx=libreoffice are rejected since LibreOffice can't do it.
Input files:
Give one directory (batch mode), one file (single-file mode), or several
files (multi-file mode). A directory can't be mixed with other inputs in
the same run. -a is a short alias for --archive-dir; -H is a legacy short
alias for --hybrid, now a no-op since hybrid extraction is the default
(-h is taken by --help).
Single-file output rule (exactly one input file):
batchocr input.pdf -> beside input.pdf: input.txt
batchocr input.pdf -o . -> ./input.txt
batchocr input.pdf -o out/ -> out/input.txt
batchocr input.pdf -o output.txt -> exactly output.txt (not a directory)
batchocr input.pdf --stdout -> extracted text printed to the terminal
Multi-file output rule (more than one input file):
batchocr a.pdf b.pdf -o out/ -> out/a.txt, out/b.txt
batchocr a.pdf b.pdf -> each output written beside its own source
-o must be a directory (or omitted) when more than one input file is given --
it is never taken as an exact output filename, so an existing file can't
accidentally be used as an output target and overwritten.
Merging multiple files into one stream (-c/--concat and -s/--stdout):
batchocr a.pdf b.pdf -o notes.txt --concat -> notes.txt, sections headed
"=== a.pdf ===" / "=== b.pdf ==="
batchocr sources/ -o notes.txt --concat -> same, for every file in a directory
batchocr a.pdf b.pdf --stdout -> same merge, printed to stdout
batchocr sources/ --stdout -> whole directory batch, to stdout
batchocr input.pdf --stdout -> single file, printed raw (no header)
--stdout is mutually exclusive with -o/--output. All progress/status/manifest
messages move to stderr in --stdout mode, so stdout carries only the
extracted text -- safe to pipe into another command or script.
Directory runs:
batchocr sources/ -> merged into sources.txt beside sources/
batchocr sources/ -o out/ -> one output file per source, in out/
batchocr sources/ -o notes.txt --concat -> merged into notes.txt (explicit name)
batchocr sources/ --stdout -> merged, printed to stdout
batchocr pages/ -> folder of page1.png, page2.png, ... merged
into pages.txt in natural page order
Supplementary outputs:
batchocr sources/ -o out/ --meta-dir out/_meta
batchocr input.pdf -m
"""
BATCHOCR_VERSION = "1.2.6"
EDGE_ZONE = 0.12 # top/bottom share of a page where running headers and footers live
MIN_PAGES_HEADER_FOOTER = 4 # repeated header/footer removal needs at least this many pages
MIN_PAGE_CHARS = 20 # fewer non-space characters than this = no usable text layer
OCR_CHECK_MIN_OVERLAP = 0.3 # sampled-page word overlap below which the text layer is rejected
import argparse
import concurrent.futures as cf
import csv
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import glob
import unicodedata
import zipfile
from pathlib import Path
PDF_EXT = {".pdf"}
DIRECT_EXT = {".txt", ".md"}
CALIBRE_EXT = {
".epub", ".mobi", ".azw", ".azw3", ".kfx", ".fb2", ".fbz",
".lit", ".lrf", ".pdb", ".prc", ".rb", ".snb", ".tcr",
".htmlz", ".cbz", ".cbr", ".cb7", ".cbt",
}
LIBREOFFICE_EXT = {
".doc", ".docm", ".xls", ".xlsm", ".ppt", ".pptm", ".pps", ".pot",
".potx", ".potm", ".xlt", ".xltx", ".xltm", ".sdd", ".sxc", ".sxi", ".sxw",
".ods", ".odp", ".odg", ".fodt", ".fods", ".fodp",
}
PANDOC_EXT = {
".asciidoc", ".adoc", ".bib", ".biblatex", ".bits", ".commonmark",
".creole", ".csv", ".djot", ".docbook", ".dokuwiki", ".epub",
".endnotexml", ".fb2", ".html", ".htm", ".ipynb", ".jats", ".jira",
".json", ".latex", ".tex", ".man", ".mediawiki", ".muse", ".opml",
".org", ".pod", ".ris", ".rst", ".rtf", ".t2t", ".textile", ".tikiwiki",
".tsv", ".typst", ".vimwiki", ".xml", ".odt",
}
OFFICE_EXT = {".pptx", ".docx"}
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".pnm"}
# Formats where more than one extraction backend is viable; --engine picks between them.
# LibreOffice has no plain-text export filter for Impress (.pptx) documents --
# "soffice --convert-to txt" fails with "no export filter found" for pptx even
# though it works fine for Writer formats -- so pptx only ever gets pandoc.
EBOOK_ENGINES = {"calibre", "pandoc"}
ENGINE_CHOICES = {ext: EBOOK_ENGINES for ext in CALIBRE_EXT}
ENGINE_CHOICES[".docx"] = {"pandoc", "libreoffice"}
ENGINE_CHOICES[".pptx"] = {"pandoc"}
def find_tool(name: str) -> str | None:
"""Find a command, including standard macOS application-bundle paths."""
found = shutil.which(name)
if found:
return found
candidates = []
configured = os.environ.get("BATCHOCR_SOFFICE") if name == "soffice" else None
if configured:
candidates.append(Path(configured).expanduser())
if name == "soffice":
candidates.extend([
Path("/Applications/LibreOffice.app/Contents/MacOS/soffice"),
Path.home() / "Applications/LibreOffice.app/Contents/MacOS/soffice",
])
for candidate in candidates:
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return None
class Tee:
"""Write output to the terminal and, optionally, a log file."""
def __init__(self, terminal, logfile):
self.terminal = terminal
self.logfile = logfile
def write(self, text):
self.terminal.write(text)
self.logfile.write(text)
def flush(self):
self.terminal.flush()
self.logfile.flush()
def isatty(self):
return self.terminal.isatty()
# tool -> (brew command, apt package); poppler and tesseract ship several tools each
INSTALL_HINTS = {
"pdfinfo": ("brew install poppler", "apt install poppler-utils"),
"pdftoppm": ("brew install poppler", "apt install poppler-utils"),
"pdftotext": ("brew install poppler", "apt install poppler-utils"),
"tesseract": ("brew install tesseract", "apt install tesseract-ocr"),
"pandoc": ("brew install pandoc", "apt install pandoc"),
"ebook-convert": ("brew install --cask calibre", "apt install calibre"),
"soffice": ("brew install --cask libreoffice", "apt install libreoffice"),
}
def install_hint(missing: list) -> str:
"""One 'brew ... / apt ...' line per distinct package behind the missing tools."""
lines = dict.fromkeys(INSTALL_HINTS[t] for t in missing if t in INSTALL_HINTS)
return "\n".join(f" macOS: {brew} Linux: sudo {apt}" for brew, apt in lines)
def check_pdf_tools():
"""Hard requirement, checked only once we know the run actually has PDFs."""
needed = ["pdfinfo", "pdftoppm", "pdftotext", "tesseract"]
missing = [t for t in needed if shutil.which(t) is None]
if missing:
sys.exit(f"Missing required tools: {', '.join(missing)}.\n{install_hint(missing)}")
def check_image_tools():
"""Hard requirement, checked only once we know the run actually has images."""
if shutil.which("tesseract") is None:
sys.exit(f"Missing required tool: tesseract.\n{install_hint(['tesseract'])}")
def check_ocr_languages(lang: str):
"""Tesseract returns nothing for a language pack it lacks; say so instead of writing empty files."""
result = subprocess.run(["tesseract", "--list-langs"], capture_output=True, text=True)
have = {l.strip() for l in result.stdout.splitlines()[1:]} if result.returncode == 0 else None
if have is None:
return
missing = [l for l in lang.split("+") if l and l not in have]
if missing:
sys.exit(
f"Tesseract has no language data for: {', '.join(missing)}.\n"
f" macOS: brew install tesseract-lang Linux: sudo apt install "
+ " ".join(f"tesseract-ocr-{l}" for l in missing)
)
def natural_sort_key(path: Path):
"""Numeric-aware sort key so page2.png sorts before page10.png."""
return [int(tok) if tok.isdigit() else tok.lower() for tok in re.split(r"(\d+)", path.name)]
def check_tools(need_pandoc: bool, need_calibre: bool, need_soffice: bool):
"""Soft requirements: warn only for tools this run's inputs would actually use."""
if need_pandoc and shutil.which("pandoc") is None:
print(
"[warn] pandoc not found -- HTML/RTF/ODT files and Calibre fallback "
f"will be unavailable.\n{install_hint(['pandoc'])}",
file=sys.stderr,
)
if need_calibre and shutil.which("ebook-convert") is None:
print(
"[warn] ebook-convert not found -- supported ebook/comic files "
f"will fall back to Pandoc when possible.\n{install_hint(['ebook-convert'])}",
file=sys.stderr,
)
if need_soffice and find_tool("soffice") is None and shutil.which("libreoffice") is None:
print(
"[warn] LibreOffice not found -- legacy Office formats will be skipped.\n"
+ install_hint(["soffice"]),
file=sys.stderr,
)
def format_duration(seconds: float) -> str:
total = max(0, int(seconds))
hours, remainder = divmod(total, 3600)
minutes, secs = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
def enable_logging(log_path: Path):
log_path.parent.mkdir(parents=True, exist_ok=True)
logfile = log_path.open("a", encoding="utf-8", buffering=1)
logfile.write(f"\n--- batchocr started {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
sys.stdout = Tee(sys.__stdout__, logfile)
sys.stderr = Tee(sys.__stderr__, logfile)
def archive_source(src: Path, archive_dir: Path | None) -> bool:
"""Archive one completed source immediately, if requested."""
if archive_dir is None or not src.exists():
return False
dest = archive_dir / src.name
if dest.exists():
print(f"[warn] {dest} already exists in archive dir, leaving {src.name} in place")
return False
shutil.move(str(src), str(dest))
print(f"[archive] {src.name} -> {archive_dir}")
return True
MANIFEST_HEADER = ["filename", "type", "pages", "word_count", "snippet", "output_file", "line_range"]
def write_manifest_file(meta_dir: Path, manifest_rows: list, manifest_flag: bool, to_stderr: bool = False) -> None:
if not manifest_flag:
print("\n[manifest] not written (use -m/--manifest to enable)")
return
if to_stderr:
# --stdout keeps real stdout text-only, so the manifest goes to the
# terminal via stderr instead of a manifest.tsv file on disk.
w = csv.writer(sys.stderr, delimiter="\t")
w.writerow(MANIFEST_HEADER)
w.writerows(manifest_rows)
return
manifest_path = meta_dir / "manifest.tsv"
with manifest_path.open("w", newline="", encoding="utf-8") as f:
w = csv.writer(f, delimiter="\t")
w.writerow(MANIFEST_HEADER)
w.writerows(manifest_rows)
print(f"\n[manifest] {manifest_path}")
def build_merge(concat_entries: dict, ordered_files: list) -> tuple[str, dict]:
"""Build the '=== filename ===' merged text and, for each source that made
it in, the 1-indexed inclusive (start_line, end_line) it occupies there --
header line through its last line of content, so a manifest row can point
straight at the right slice of the merged output."""
chunks = []
line_ranges = {}
line_no = 1
first = True
for f in ordered_files:
if f not in concat_entries:
continue
if not first:
chunks.append("") # blank separator line between sections
line_no += 1
first = False
section_lines = [f"=== {f.name} ===", ""] + concat_entries[f].strip().split("\n")
start = line_no
chunks.extend(section_lines)
line_no += len(section_lines)
line_ranges[f] = (start, line_no - 1)
merged = "\n".join(chunks) + ("\n" if chunks else "")
return merged, line_ranges
def write_merged_output(concat_target: Path | None, dump_stdout: bool,
concat_entries: dict, ordered_files: list) -> dict:
"""Merge whatever's in concat_entries, in original input order, either into
concat_target or (dump_stdout) straight to the real terminal stdout -- which
stays the true stdout even after main() redirects sys.stdout to sys.stderr.
Returns the source -> (start_line, end_line) map for the manifest."""
if concat_target is None and not dump_stdout:
return {}
merged, line_ranges = build_merge(concat_entries, ordered_files)
if dump_stdout:
sys.__stdout__.write(merged)
print(f"[stdout] dumped {len(line_ranges)}/{len(ordered_files)} file(s)", file=sys.stderr)
else:
concat_target.write_text(merged, encoding="utf-8")
print(f"\n[concat] merged {len(line_ranges)}/{len(ordered_files)} file(s) -> {concat_target}")
return line_ranges
def assemble_manifest_rows(all_files: list, manifest_entries: dict, line_ranges: dict,
concat_target: Path | None) -> list:
"""Turn the per-source bookkeeping into manifest.tsv rows, in original input
order. output_file/line_range point into the merged file/stream when one of
--concat/--stdout applies to this source, otherwise line_range is '-'."""
merged_label = concat_target.name if concat_target is not None else "(stdout)"
rows = []
for f in all_files:
if f not in manifest_entries:
continue
ext, pages, word_count, snippet, label = manifest_entries[f]
if f in line_ranges:
start, end = line_ranges[f]
rows.append([f.name, ext, pages, word_count, snippet, merged_label, f"{start}-{end}"])
else:
rows.append([f.name, ext, pages, word_count, snippet, label, "-"])
return rows
def stop_executor(executor, futures):
"""Cancel queued pages and terminate workers after Ctrl-C."""
for future in futures:
future.cancel()
terminate_workers = getattr(executor, "terminate_workers", None)
if terminate_workers is not None:
terminate_workers()
return
# Compatibility fallback for Python versions before 3.14.
for process in getattr(executor, "_processes", {}).values():
process.terminate()
executor.shutdown(wait=False, cancel_futures=True)
def pdf_page_count(pdf_path: Path) -> int:
out = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True
).stdout
m = re.search(r"Pages:\s*(\d+)", out)
return int(m.group(1)) if m else 0
def word_quality(text: str) -> float:
"""Cheap heuristic: fraction of tokens that look like real words,
not font-encoding garbage. Returns 1.0 when there are no letter tokens
to judge (digits, symbols, scripts without word spacing)."""
tokens = re.findall(r"[A-Za-zÀ-ÿЀ-ӿ][A-Za-zÀ-ÿЀ-ӿ'\-]*", text)
if not tokens:
return 1.0
good = sum(
1
for t in tokens
if 1 <= len(t) <= 25
and not re.search(r"[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{5,}", t)
)
return good / len(tokens)
# Letters from these Latin-Extended-B code points are real (Romanian, Vietnamese,
# pinyin); the rest of that block, IPA and modifier letters are what a broken
# font encoding typically produces.
_LATIN_EXT_B_OK = {0x218, 0x219, 0x21A, 0x21B, 0x1A0, 0x1A1, 0x1AF, 0x1B0}
_EXPECTED_PUNCT = frozenset(".,;:'\"()[]-–—/%?!“”‘’«»…&@#*+=<>_|\\^~$€£§©®°±×÷•·†‡¶fffiflffiffl")
def char_quality(text: str) -> float:
"""Share of non-space characters that are plausible text: letters (outside the
IPA/modifier/Latin-Extended-B ranges that broken font encodings spill into),
digits and ordinary punctuation. Private-use, replacement and control
characters count against the page. 1.0 for a page with no characters."""
chars = [c for c in text if not c.isspace()]
if not chars:
return 1.0
ok = 0
for c in chars:
cp = ord(c)
cat = unicodedata.category(c)
if cat in ("Co", "Cs", "Cn", "Cc") or cp == 0xFFFD or 0x0250 <= cp <= 0x02FF:
continue
if 0x0180 <= cp <= 0x024F and cp not in _LATIN_EXT_B_OK and not 0x1CD <= cp <= 0x1DC:
continue
if cat[0] in "LNM" or c in _EXPECTED_PUNCT:
ok += 1
return ok / len(chars)
def page_quality(text: str) -> float:
"""Quality of one page's native text layer: the worse of the character check
and the word check. A page with (almost) no text scores 0 so it gets OCRed."""
if sum(1 for c in text if not c.isspace()) < MIN_PAGE_CHARS:
return 0.0
return min(char_quality(text), word_quality(text))
def word_overlap(native: str, ocr: str) -> float:
"""Share of the OCR's words (3+ letters) that also occur in the native text.
OCR can miss words, but a corrupted text layer cannot reproduce the page's words."""
ocr_words = re.findall(r"\w{3,}", ocr.lower())
if not ocr_words:
return 1.0
have = set(re.findall(r"\w{3,}", native.lower()))
return sum(w in have for w in ocr_words) / len(ocr_words)
def native_pages(pdf_path: Path, layout: bool = False) -> list[str]:
"""Native text of every page, in reading order. PyMuPDF blocks in content-stream
order when PyMuPDF is installed (columns come out whole); otherwise plain
pdftotext, which also reads columns in order. layout=True is the old
'pdftotext -layout' (visual rows: columns and tables come out interleaved)."""
if not layout:
try:
import pymupdf
except ImportError:
pymupdf = None
if pymupdf is not None:
pymupdf.TOOLS.mupdf_display_errors(False)
try:
with pymupdf.open(pdf_path) as doc:
return [
"\n\n".join(
b[4].strip("\n") for b in page.get_text("blocks")
if b[6] == 0 and b[4].strip()
)
for page in doc
]
except Exception:
pass # encrypted or broken for PyMuPDF; pdftotext may still cope
cmd = ["pdftotext"] + (["-layout"] if layout else []) + [str(pdf_path), "-"]
out = subprocess.run(cmd, capture_output=True, text=True, errors="replace").stdout
pages = out.split("\f")
if pages and not pages[-1].strip():
pages.pop()
return pages
def plan_native_pages(pdf_path: Path, page_texts: list[str], threshold: float,
lang: str, tmp_root: Path, crosscheck: bool = True) -> tuple[dict, str]:
"""Decide per page whether the native text layer can be trusted. Returns
({page_number: text} for the pages that keep it, a one-line explanation).
Pages scoring under the threshold, or with no text, are left out (-> OCR).
One sampled page is also OCRed and compared word-for-word with its text
layer; if they disagree the whole layer is distrusted, because a corrupted
font encoding usually affects the entire document."""
scores = {pn: page_quality(t) for pn, t in enumerate(page_texts, start=1)}
keep = {pn: page_texts[pn - 1] for pn, q in scores.items() if q >= threshold}
bad = sorted(set(scores) - set(keep))
note = f"{len(keep)}/{len(scores)} pages pass the text-layer check"
if keep and crosscheck:
sample = min(keep, key=lambda pn: (scores[pn], abs(pn - len(scores) // 2)))
try:
overlap = word_overlap(keep[sample], ocr_one_page(pdf_path, sample, 150, lang, tmp_root))
except Exception:
overlap = 1.0
if overlap < OCR_CHECK_MIN_OVERLAP:
return {}, (f"OCR check on page {sample}: only {overlap:.0%} of its words are in the "
"text layer -> text layer is corrupt, using OCR")
note += f", OCR check on page {sample}: {overlap:.0%} word overlap"
if bad:
note += f"; OCR for page{'s' if len(bad) > 1 else ''} {format_page_list(bad)}"
return keep, note
def format_page_list(pages: list[int], limit: int = 12) -> str:
shown = ", ".join(str(p) for p in pages[:limit])
return shown + (f" (+{len(pages) - limit} more)" if len(pages) > limit else "")
def ocr_one_page(pdf_path: Path, page_num: int, dpi: int, lang: str, tmp_root: Path,
tsv: bool = False) -> str:
# tmp_root is a run-wide scratch directory owned by the parent process, so
# that a forcibly-terminated worker (Ctrl-C) still gets its leftovers
# swept up by the parent's cleanup instead of orphaning files in /tmp.
td = Path(tempfile.mkdtemp(prefix=f"p{page_num}-", dir=tmp_root))
try:
prefix = td / "p"
subprocess.run(
[
"pdftoppm", "-png", "-r", str(dpi),
"-f", str(page_num), "-l", str(page_num),
str(pdf_path), str(prefix),
],
check=True,
capture_output=True,
)
imgs = sorted(td.glob("p*.png"))
if not imgs:
return ""
result = subprocess.run(
["tesseract", str(imgs[0]), "stdout", "-l", lang] + (["tsv"] if tsv else []),
capture_output=True,
text=True,
)
return result.stdout
finally:
shutil.rmtree(td, ignore_errors=True)
def pdf_page_labels(pdf_path: Path) -> dict[int, str]:
"""Printed page labels ("iv", "3-12", ...) keyed by 1-based physical page,
only for pages whose label differs from the physical number. Empty if the
PDF defines none or neither PyMuPDF nor pypdf is installed (both optional)."""
labels = []
try:
import pymupdf
with pymupdf.open(pdf_path) as doc:
labels = [page.get_label() for page in doc]
except ImportError:
try:
import pypdf
labels = list(pypdf.PdfReader(pdf_path).page_labels)
except ImportError:
return {}
except Exception:
return {}
except Exception:
return {}
return {i: lab for i, lab in enumerate(labels, start=1) if lab and lab != str(i)}
def page_marker(page_num: int, labels: dict[int, str]) -> str:
label = labels.get(page_num)
return f"--- Page {page_num} ({label}) ---" if label else f"--- Page {page_num} ---"
def assemble_pages(pdf_path: Path, texts: dict[int, str], page_markers: bool = True) -> str:
"""Join per-page texts in page order, with '--- Page N ---' markers unless disabled."""
if not page_markers:
return "\n".join(texts[pn].strip("\n") + "\n" for pn in sorted(texts))
labels = pdf_page_labels(pdf_path)
return "".join(
f"\n\n{page_marker(pn, labels)}\n\n{texts[pn].strip()}\n" for pn in sorted(texts)
)
def ocr_image(image_path: Path, lang: str) -> str:
"""OCR a single standalone image file directly (no rasterisation needed)."""
result = subprocess.run(
["tesseract", str(image_path), "stdout", "-l", lang],
capture_output=True,
text=True,
)
return result.stdout
PANDOC_FORMAT_BY_EXT = {
".asciidoc": "asciidoc", ".adoc": "asciidoc", ".bib": "bibtex",
".biblatex": "biblatex", ".commonmark": "commonmark", ".creole": "creole",
".djot": "djot", ".docbook": "docbook", ".dokuwiki": "dokuwiki",
".endnotexml": "endnotexml", ".epub": "epub", ".fb2": "fb2",
".html": "html", ".htm": "html", ".ipynb": "ipynb", ".jats": "jats",
".jira": "jira", ".json": "json", ".latex": "latex", ".tex": "latex",
".man": "man", ".mediawiki": "mediawiki", ".muse": "muse", ".opml": "opml",
".org": "org", ".pod": "pod", ".ris": "ris", ".rst": "rst",
".rtf": "rtf", ".t2t": "t2t", ".textile": "textile", ".tikiwiki": "tikiwiki",
".tsv": "tsv", ".typst": "typst", ".vimwiki": "vimwiki", ".xml": "xml",
".odt": "odt",
}
def pandoc_extract(src_path: Path) -> str:
input_format = PANDOC_FORMAT_BY_EXT.get(src_path.suffix.lower())
try:
command = ["pandoc", str(src_path)]
if input_format:
command.extend(["-f", input_format])
command.extend(["-t", "plain"])
result = subprocess.run(
command,
capture_output=True,
text=True,
errors="replace",
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout
except FileNotFoundError:
pass
if src_path.suffix.lower() == ".rtf":
raw = src_path.read_text(errors="replace")
text = re.sub(r"\\'[0-9a-fA-F]{2}", " ", raw)
text = re.sub(r"\\[a-zA-Z]+\d* ?", "", text)
text = re.sub(r"[{}]", "", text)
return text
return ""
def calibre_extract(src_path: Path) -> str:
"""Extract readable text through Calibre's ebook-convert command."""
with tempfile.TemporaryDirectory(prefix="batchocr-calibre-") as td:
converted = Path(td) / "converted.txt"
result = subprocess.run(
["ebook-convert", str(src_path), str(converted)],
capture_output=True,
text=True,
errors="replace",
)
if result.returncode != 0 or not converted.exists():
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(detail or "ebook-convert produced no output")
return converted.read_text(errors="replace")
def book_extract(src_path: Path, force_engine: str | None = None) -> tuple[str, str]:
"""Use Calibre first, falling back to Pandoc when Calibre is unavailable.
force_engine (from --engine) pins the choice instead: 'calibre' or 'pandoc',
no fallback."""
calibre = shutil.which("ebook-convert")
pandoc = shutil.which("pandoc")
if force_engine == "pandoc":
if not pandoc:
raise RuntimeError("pandoc is not installed")
return pandoc_extract(src_path), "pandoc (--engine)"
if force_engine == "calibre":
if not calibre:
raise RuntimeError("ebook-convert is not installed")
return calibre_extract(src_path), "calibre (--engine)"
if calibre:
try:
return calibre_extract(src_path), "calibre"
except Exception as exc:
if pandoc:
print(f"[warn] Calibre failed for {src_path.name}: {exc}; falling back to Pandoc",
file=sys.stderr)
else:
raise
else:
print(f"[warn] ebook-convert not found; falling back to Pandoc for {src_path.name}",
file=sys.stderr)
if not pandoc:
raise RuntimeError("Neither ebook-convert nor pandoc is installed")
return pandoc_extract(src_path), "pandoc fallback"
def libreoffice_extract(src_path: Path) -> str:
"""Extract plain text from legacy Office/OpenDocument files."""
soffice = find_tool("soffice") or shutil.which("libreoffice")
if not soffice:
raise RuntimeError("soffice/libreoffice is not installed")
with tempfile.TemporaryDirectory(prefix="batchocr-libreoffice-") as td:
out_dir = Path(td) / "out"
profile_dir = Path(td) / "profile"
out_dir.mkdir()
profile_dir.mkdir()
profile_uri = profile_dir.as_uri()
result = subprocess.run(
[
soffice,
f"-env:UserInstallation={profile_uri}",
"--headless", "--convert-to", "txt:Text",
"--outdir", str(out_dir), str(src_path),
],
capture_output=True,
text=True,
errors="replace",
)
converted = out_dir / f"{src_path.stem}.txt"
if result.returncode != 0 or not converted.exists():
detail = (result.stderr or result.stdout).strip()
raise RuntimeError(detail or "soffice produced no text output")
return converted.read_text(errors="replace")
def is_office_lock_file(p: Path) -> bool:
"""Word, Excel and PowerPoint leave `~$name.docx` (a few bytes, no zip inside) while a document is open."""
return p.name.startswith("~$")
def repair_office_zip(src: Path, dst: Path) -> int:
"""Copy an Office zip with two faults of some generators (.NET Open XML SDK, PowerShell scripts) mended; return how
many parts changed. (1) A UTF-8 byte-order mark at the start of an XML part: PowerPoint opens it, pandoc's XML
reader refuses it. (2) Absolute relationship targets (Target="/ppt/slides/slide1.xml") in .rels parts: pandoc
looks for an entry named "/ppt/..." and fails with "Entry not found", so they are made relative to the part."""
fixed = 0
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
data, name = zin.read(info), info.filename
if name.lower().endswith((".xml", ".rels")):
new = data[3:] if data.startswith(b"\xef\xbb\xbf") else data
if name.lower().endswith(".rels"):
# the part a .rels file describes lives one folder up from "_rels/": base = that folder
base = os.path.dirname(os.path.dirname(name))
new = re.sub(rb'(Target=")/([^"]*)(")',
lambda m: m[1] + os.path.relpath(m[2].decode("utf8", "replace"), base or ".").encode() + m[3],
new)
if new != data:
data, fixed = new, fixed + 1
zout.writestr(info.filename, data)
return fixed
def office_convert(source_path: Path, md_file: Path, pandoc_target: str,
save_images: bool) -> tuple[Path, Path]:
"""Convert a PPTX/DOCX to Markdown at exactly md_file; return its Markdown/media paths."""
md_file.parent.mkdir(parents=True, exist_ok=True)
# Always a per-document directory. Sharing md_file.parent let pandoc's
# media/image1.png overwrite between decks, and OCR's rglob then picked up
# other documents' images.
media_root = (
md_file.parent / f"{md_file.stem}_media" if save_images
else md_file.parent / f".{md_file.stem}_media_tmp"
)
shutil.rmtree(media_root, ignore_errors=True) # drop stale images from earlier runs
media_root.mkdir(parents=True, exist_ok=True)
source_format = source_path.suffix.lower().lstrip(".")
targets = [
pandoc_target,
"markdown+tex_math_dollars+tex_math_single_backslash",
"markdown",
"gfm+tex_math_dollars",
"gfm",
"commonmark",
]
last_err = ""
pandoc_in, bom_tmp = source_path, None
try:
for target in targets:
while True:
result = subprocess.run(
[
"pandoc", str(pandoc_in), "-f", source_format, "-t", target,
f"--extract-media={media_root}", "-o", str(md_file), "--wrap=none",
],
capture_output=True,
text=True,
)
if (result.returncode != 0 and bom_tmp is None and zipfile.is_zipfile(source_path)
and ("\\65279" in result.stderr or "Found content after root element" in result.stderr
or "Entry not found: /" in result.stderr)):
# a byte-order mark or absolute targets inside the Office file: convert a mended copy, retry this target
bom_tmp = tempfile.TemporaryDirectory()
pandoc_in = Path(bom_tmp.name) / source_path.name
if repair_office_zip(source_path, pandoc_in):
print(f"[fix] {source_path.name}: converted a mended copy (byte-order marks / absolute targets in its XML parts)", file=sys.stderr)
continue
break
if result.returncode == 0:
return md_file, media_root
if "not supported" in result.stderr or "Unknown extension" in result.stderr:
last_err = result.stderr.strip()
continue
raise RuntimeError(f"Pandoc failed for {source_path.name}: {result.stderr.strip()}")
raise RuntimeError(f"All Pandoc targets failed for {source_path.name}: {last_err}")
finally:
if bom_tmp is not None:
bom_tmp.cleanup()
def office_inline_ocr(md_file: Path, media_root: Path, out_dir: Path,
source_name: str, langs: str, report_path: Path | None) -> int:
"""OCR extracted Office images and place each result below its image."""
if shutil.which("tesseract") is None:
print("[warn] tesseract not found; Office image OCR skipped", file=sys.stderr)
return 0
images = []
for ext in ("*.png", "*.jpg", "*.jpeg", "*.tiff", "*.bmp", "*.emf", "*.wmf"):
images.extend(media_root.rglob(ext))
ocr_results = []
for image in sorted(set(images)):
try:
if image.stat().st_size < 1024:
continue
result = subprocess.run(
["tesseract", str(image), "stdout", "-l", langs, "--psm", "6"],
capture_output=True,
text=True,
timeout=30,
)
text = result.stdout.strip()
if text and len(text) > 2:
ocr_results.append((image.relative_to(out_dir), text))
except Exception as exc:
print(f"[warn] OCR failed for {image.name}: {exc}", file=sys.stderr)
if not ocr_results:
return 0
markdown = md_file.read_text(encoding="utf-8")
for relative, text in ocr_results:
relative_text = str(relative).replace("\\", "/")
marker = (
f"\n\n<!-- BEGIN IMAGE OCR: {relative_text} -->\n"
f"{text}\n"
f"<!-- END IMAGE OCR: {relative_text} -->"
)
pattern = re.compile(
r"(?m)^(?P<line>.*!\[[^\]]*\]\([^\n)]*"
+ re.escape(relative_text)
+ r"[^\n)]*\)[^\n]*)$"
)
markdown, count = pattern.subn(
lambda match: match.group("line") + marker, markdown, count=1
)
if count == 0:
markdown += f"\n\n{marker}\n"
md_file.write_text(markdown, encoding="utf-8")
if report_path:
with report_path.open("w", encoding="utf-8") as report:
report.write(f"# OCR from {source_name}\n\n")
for relative, text in ocr_results:
report.write(
f"### {relative}\n\n\n\n"
f"```\n{text}\n```\n\n---\n\n"
)
return len(ocr_results)
def process_office(source_path: Path, md_file_target: Path, pandoc_target: str,
save_images: bool, do_ocr: bool, langs: str,
report_path: Path | None) -> tuple[Path, int]:
md_file, media_root = office_convert(source_path, md_file_target, pandoc_target, save_images)
try:
image_count = office_inline_ocr(
md_file, media_root, md_file.parent, source_path.name, langs, report_path
) if do_ocr else 0
return md_file, image_count
finally:
if not save_images:
shutil.rmtree(media_root, ignore_errors=True)
LIGATURES = {"ff": "ff", "fi": "fi", "fl": "fl", "ffi": "ffi", "ffl": "ffl",
"ſt": "st", "st": "st"}
OCR_MIN_CONF = 70 # mean word confidence under which an OCR line cannot be a heading
OCR_HEADING_RATIO = 1.3 # an OCR line this much taller than the page median may be a heading
PROSE_CELL_WORDS = 8 # a table cell this long is a sentence: the 'table' is really a paragraph
PROVENANCE_COMMENT = f"<!-- text extracted with batchocr v{BATCHOCR_VERSION} -->"
MD_GUARD_TOLERANCE = 0.003 # share of a page's letters/digits a Markdown render may change
def require_markdown_deps():
"""Markdown mode needs PyMuPDF (AGPL-3.0, so installed separately, never bundled)."""
try:
import pymupdf
except ImportError:
sys.exit("PDF -> Markdown needs PyMuPDF (AGPL-3.0, installed separately): "
"pip install pymupdf (plain-text mode works without it)")
try:
import m1ck4_pdfmd # noqa: F401
except ImportError as exc:
sys.exit(f"PDF -> Markdown: the vendored m1ck4_pdfmd package is missing or broken ({exc})")
return pymupdf
def tsv_to_pagetext(tsv: str):
"""Tesseract TSV -> PageText. One Block per Tesseract paragraph, in Tesseract's own
reading order; one Span per line. The median word height of a line stands in for font
size (robust to drop caps), quantised so only clearly taller lines (headings) differ
from body text; lines Tesseract is unsure about are flagged low_conf and never become headings."""
from m1ck4_pdfmd.models import Block, Line, PageText, Span
lines: dict = {}
page_h = 0
for row in tsv.splitlines()[1:]:
f = row.split("\t")
if len(f) >= 12 and int(f[0]) == 1:
page_h = int(f[9])
if len(f) < 12 or int(f[0]) != 5 or not f[11].strip():
continue
ln = lines.setdefault((int(f[2]), int(f[3]), int(f[4])),
{"words": [], "h": [], "conf": [], "top": 10 ** 9, "bottom": 0})
ln["words"].append(f[11].strip())
ln["h"].append(int(f[9]))
ln["conf"].append(float(f[10]))
ln["top"] = min(ln["top"], int(f[7]))
ln["bottom"] = max(ln["bottom"], int(f[7]) + int(f[9]))
if not lines: