Compare commits

...
201 Commits
Author SHA1 Message Date
James R. Barlow 035ebea72f Change Windows build to use older Ghostscript 2024-01-07 01:56:38 -08:00
James R. Barlow a499956462 v16.0.4 release notes 2024-01-07 01:39:06 -08:00
James R. Barlow 74d2a156c4 Update cache 2024-01-07 01:35:05 -08:00
James R. Barlow f87fc7b12d gs: check for text-corrupting versions 2024-01-07 01:33:49 -08:00
James R. Barlow 602f5632cb hocr: fix unbound local 2024-01-07 01:33:36 -08:00
James R. Barlow 9fbbcf7599 Merge branch 'feature/hocrdebug' 2024-01-07 01:22:54 -08:00
James R. Barlow 9498f01f59 hocr: Pass text direction for standalone words 2024-01-06 16:21:21 -08:00
James R. Barlow 2c59aca5a1 Fix render_mode ON_TOP when previous content stream does not restore CTM 2024-01-06 16:20:55 -08:00
James R. Barlow 51301d69c9 Change default graft render mode back to underneath
It was mistakenly changed to on top at some point
2024-01-06 16:20:30 -08:00
James R. Barlow 7e608fd1df Use Courier as debug font 2024-01-06 14:24:25 -08:00
James R. Barlow ecc79315df Adjust issue template files description 2024-01-04 00:27:06 -08:00
James R. Barlow 14365d10b8 Skip testing oom killer on Python 3.12
Need to investigate further if there's a safe way to do this test.
2024-01-02 16:28:22 -08:00
James R. Barlow 5e5320020f v16.0.3 release notes 2024-01-02 02:49:54 -08:00
James R. Barlow 103c3e0cd6 Drop Ghostscript CVE warning and adjust version down to 9.54
Most distros have probably now backported the CVE fix. Dropped version to 9.54 so RHEL 9 can use recent ocrmypdf.
2024-01-01 13:02:57 -08:00
Anthony NabilandGitHub 7a1c89edd9 fixed a spelling mistake (#1222) 2023-12-30 15:53:22 -08:00
James R. Barlow a5ff3d2f42 Update instructions about custom language packages
Closes #1218
2023-12-27 16:46:10 -08:00
James R. Barlow b71d16dd96 Draw RTL text with reversed matrix instead of reversing characters 2023-12-24 01:34:03 -08:00
James R. Barlow fd593eb5e9 Reversing character order for RTL helps output 2023-12-24 01:24:44 -08:00
James R. Barlow a0b98abb94 Improve and externalize debug rendering settings 2023-12-24 00:10:09 -08:00
James R. Barlow 18353e1e94 Also fix space rendering 2023-12-24 00:09:47 -08:00
James R. Barlow 9adcad84da Overhaul debug rendering to support possible use of Helvetica for testing 2023-12-23 23:47:35 -08:00
James R. Barlow f2714586d8 Fix upside down glyphless font 2023-12-23 23:44:37 -08:00
James R. Barlow 0b6fb62967 Update issue template 2023-12-23 16:21:17 -08:00
James R. Barlow 1db8b0b943 Temporarily revert default renderer to sandwich while investigating regressions in macOS Preview 2023-12-23 16:19:44 -08:00
James R. Barlow f38aebb3d5 Update issue template 2023-12-23 15:46:43 -08:00
James R. Barlow 7162c36d37 Name artifacts explicitly 2023-12-20 13:53:31 -08:00
James R. Barlow f4d4ea46c8 Update artifact actions 2023-12-20 12:44:43 -08:00
James R. Barlow 2fd1a0f178 v16.0.1 release notes 2023-12-20 12:33:41 -08:00
James R. Barlow 73ed33a086 Tighten dependencies 2023-12-20 12:33:18 -08:00
James R. Barlow e6095a9949 Fix text rendering issue with new hOCR text renderer 2023-12-20 12:26:06 -08:00
James R. Barlow 16f05af401 Fix release notes - drop rc from version 2023-12-18 20:08:45 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1631afc878 Bump actions/setup-python from 4 to 5 (#1205)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-17 15:35:13 -08:00
Robin RichtsfeldandGitHub 63d87fc440 Fix --fast-web-view documentation (#1206) 2023-12-17 14:54:38 -08:00
James R. Barlow 9489c01259 Skip test_encrypted on Py3.12 + macOS 2023-12-08 00:12:24 -08:00
James R. Barlow 30d92ad83f Fix build settings to adjust for dropping py39 2023-12-07 23:40:45 -08:00
James R. Barlow a4987733c4 Filter rl_safe_eval deprecation warning
Full message
eportlab/lib/rl_safe_eval.py:11: DeprecationWarning: ast.NameConstant is deprecated and will be removed in Python 3.14; use ast.Constant instead
    haveNameConstant = hasattr(ast,'NameConstant')

Warning triggered by reportlab-4.0.7 and Python 3.12
2023-12-07 23:40:23 -08:00
James R. Barlow 39eee05230 v16.0.0rc1 release notes
Fixes #1009, #1191, #1157
2023-12-03 15:44:34 -08:00
James R. Barlow 5b2f2e6290 Merge branch 'feature/modernhocr' 2023-12-03 15:17:02 -08:00
James R. Barlow 445617a1a5 Rebuild cache for hocr default case 2023-12-03 15:16:18 -08:00
James R. Barlow f6e90a5934 hOCR renderer is now default 2023-12-02 19:58:00 -08:00
James R. Barlow 43618e6b3f Move canvas API to pikepdf and import it 2023-12-02 19:42:35 -08:00
James R. Barlow e97f89de3b Refactor font so glyphless isn't as hard coded 2023-12-02 08:55:01 -08:00
James R. Barlow 11d3e32f1e Fix hocrtransform CLI 2023-12-02 08:08:29 -08:00
James R. Barlow 2affa83efe Remove code that attempted to manage xattrs out of output file
Feature requested in issue #1179, but caused #1195. On further review,
there is no platform independent way to manage extended attributes
and it is not clear copying them through is necessarily the sensible
thing to do.

Closes #1179.
2023-11-29 23:25:51 -08:00
James R. Barlow c90d5cd84b Fix Ghostscript installation instructions and add warning 2023-11-29 14:10:04 -08:00
James R. Barlow aacaba3d26 Ignore pypy for now 2023-11-21 01:05:23 -08:00
James R. Barlow fec53be841 Remove next major release deprecations 2023-11-21 00:47:51 -08:00
James R. Barlow 3f7b540f76 Drop Python 3.9 support 2023-11-21 00:46:00 -08:00
James R. Barlow d217856166 Make hocrdebug work, and try to handle CJK spacing better 2023-11-21 00:33:02 -08:00
James R. Barlow e2be457e9b Avoid divzero 2023-11-20 23:08:00 -08:00
James R. Barlow 4850f486d2 Make text API more like an accessor 2023-11-20 22:59:50 -08:00
James R. Barlow 729c7febd9 Fix placement of spaces in debug mode 2023-11-20 22:44:12 -08:00
James R. Barlow 6c6aca2f1e Refactor save_state 2023-11-20 22:29:21 -08:00
James R. Barlow c69823f496 Refactor; accumulate content stream as bytes rather than discrete pikepdf objects 2023-11-20 22:11:59 -08:00
James R. Barlow 73f8f6aac8 Add RTL output - seems to work, but debug does not 2023-11-20 20:28:07 -08:00
James R. Barlow d944254e45 hocr: typing cont'd 2023-11-20 17:07:52 -08:00
James R. Barlow f7ddffe554 hocr: typing 2023-11-20 16:52:55 -08:00
James R. Barlow 8a73ed5d5a Fix JBIG2 not updating progress bar 2023-11-20 16:25:30 -08:00
James R. Barlow 03669183d7 Rationalize canvas interface 2023-11-20 15:54:13 -08:00
James R. Barlow 74e101a2fa Improve canvas interface with chaining 2023-11-20 14:42:48 -08:00
James R. Barlow 532cf18ad3 Restructure hocrtransform submodule to avoid having everything in __init__ 2023-11-20 00:57:58 -08:00
James R. Barlow 0b90b697e2 More tidying 2023-11-20 00:43:43 -08:00
James R. Barlow 6be7c5f7c8 Fix colors and space box rendering 2023-11-20 00:30:54 -08:00
James R. Barlow db2e5132e6 Remove some obsolete parameters 2023-11-20 00:10:55 -08:00
James R. Barlow b14f6f778a Tidying new hOCR renderer 2023-11-19 23:51:27 -08:00
James R. Barlow 415de77457 imageops: fix annots since not using singledispatch anymore 2023-11-19 23:51:27 -08:00
James R. Barlow a9466c4f58 Improve word box positioning 2023-11-19 23:51:27 -08:00
James R. Barlow d9ae453a63 Significantly improvement overall 2023-11-19 23:51:27 -08:00
James R. Barlow 9841e09233 More adjustments 2023-11-19 23:51:27 -08:00
James R. Barlow 0ca314e066 Replace Rect with pikepdf.Rectangle, migrate line matrix to page 2023-11-19 23:51:27 -08:00
James R. Barlow d7680cae27 Correcting Matrix logic helps
The good: don't have to do inverse and intermediate transforms.

The bad: skew looks bad, partly because the hOCR coordinate system is inconsistent around skew?
2023-11-19 23:51:27 -08:00
James R. Barlow 491b6bdb1f Remove concept of HOCR_OK_LANGS 2023-11-19 23:51:27 -08:00
James R. Barlow c591f9601a Remove Latin hOCR test 2023-11-19 23:51:27 -08:00
James R. Barlow 8d1e75017e Remote reportlab backend and make reportlab a test-only dependency 2023-11-19 23:51:27 -08:00
James R. Barlow 94615f7ad4 hOCR now works for all languages 2023-11-19 23:51:27 -08:00
James R. Barlow e5df8e1315 Nearly pixel perfect 2023-11-19 23:51:27 -08:00
James R. Barlow d739b91aef Tidy up 2023-11-19 23:51:27 -08:00
James R. Barlow 686cfb2539 Add rendering of space between boxes 2023-11-19 23:51:27 -08:00
James R. Barlow 2633716bb7 Render interword spaces separately and avoid box overlap 2023-11-19 23:51:27 -08:00
James R. Barlow 0a07c0a44e Fix more things 2023-11-19 23:51:27 -08:00
James R. Barlow 2ca6e110ca Fix private accessors, rename pdf to canvas 2023-11-19 23:51:27 -08:00
James R. Barlow 334a07c839 Refactor debug printing 2023-11-19 23:51:27 -08:00
James R. Barlow a57c39358d Refactor: extract methods 2023-11-19 23:51:27 -08:00
James R. Barlow 30a0c315fb Further exploratory improvements 2023-11-19 23:51:27 -08:00
James R. Barlow b860f0d94c Make coordinate system more consistent 2023-11-19 23:51:27 -08:00
James R. Barlow 14f4c19f5a WIP improve text positioning (not there yet) 2023-11-19 23:51:27 -08:00
James R. Barlow 7ab5c55d46 More colors 2023-11-19 23:51:27 -08:00
James R. Barlow 8b6ecd5971 Fix line and rect drawing 2023-11-19 23:51:27 -08:00
James R. Barlow 7b0871ae4c Fix position errors; ignore non-glyphless font 2023-11-19 23:51:27 -08:00
James R. Barlow b73af7ce10 Fix dashes 2023-11-19 23:51:26 -08:00
James R. Barlow 60645717e2 Test pikepdf canvas - renders... something at this point 2023-11-19 23:51:26 -08:00
James R. Barlow 1cbf578538 Fix import of pdf.ttf 2023-11-19 23:51:26 -08:00
James R. Barlow e966c1fceb Add pdf.ttf 2023-11-19 23:51:26 -08:00
James R. Barlow d0133f8641 Create pikepdf backend renderer 2023-11-19 23:51:26 -08:00
James R. Barlow 6d30b497dc Refactor reportlab into generic backend 2023-11-19 23:51:26 -08:00
James R. Barlow f3b89e66eb Refactor reportlab backend out of hocrtransform 2023-11-19 23:51:26 -08:00
James R. Barlow 04154e207c hocrtransform: move to module 2023-11-19 23:51:26 -08:00
James R. Barlow 9898904be7 Fix pikepdf PdfMatrix deprecation warning; v15.4.3 release notes 2023-11-15 20:27:16 -08:00
James R. Barlow 27d5229842 Make logger names unique 2023-11-09 23:03:39 -08:00
James R. Barlow 4a9a575ef0 ghostscript: better comments 2023-11-09 22:39:49 -08:00
James R. Barlow 52fd9a630d v15.4.2 release notes 2023-11-09 22:35:51 -08:00
James R. Barlow a596ccf844 Raise exception if resulting PDF might appear blank in a known in some PDF viewers
Fixes #1187
2023-11-09 22:33:22 -08:00
James R. Barlow e7fa97731f ghostscript duplicate filter: filter within a window of previous messages 2023-11-09 22:32:39 -08:00
James R. Barlow 290aa28108 Fix error on attempt to write to debug log after removing debug log handler 2023-11-09 16:02:41 -08:00
James R. Barlow a95640ed9e v15.4.1 release notes
Closes #1185
Closes #1183
2023-11-07 23:57:30 -08:00
James R. Barlow f69267bb67 watcher: restore ability to read json from file or command line string 2023-11-07 18:05:29 -08:00
James R. Barlow e36d5a309f Make grafting a little bit more configurable 2023-11-05 14:01:01 -08:00
James R. Barlow 55566d9830 Fix watcher.py kwarg error 2023-11-05 13:58:24 -08:00
James R. Barlow f02ea20678 docs: plugin documentation missing key special members 2023-11-05 00:10:51 -07:00
James R. Barlow 372c22d42b docs: improve 2023-11-04 02:43:08 -07:00
James R. Barlow 949265bbd0 graft: improve typing and remove procset tracking
ProcSet is optional and deprecated in PDF 2.0, and does little anyway; so
we removed it.
2023-11-04 02:32:39 -07:00
James R. Barlow 916106733c Skip semfree unless on Linux 2023-10-30 00:33:21 -07:00
James R. Barlow 44bcafd3aa Add missing file header 2023-10-30 00:28:09 -07:00
James R. Barlow 71166f7be8 Make hocr API experimental for now
This commit can be reverted when we are ready to release a new version.
2023-10-30 00:07:10 -07:00
James R. Barlow 580252a1a0 Merge branch 'feature/gscan2pdf'
Reconcile release notes and copy_final() with new pipeline.
2023-10-30 00:01:28 -07:00
James R. Barlow c0b60dae6a build: add repository -y 2023-10-29 23:41:36 -07:00
James R. Barlow ae123fd209 Try to retain/copy xattrs 2023-10-28 01:42:06 -07:00
James R. Barlow 454ad0acc5 build/macos: add openssl 2023-10-28 01:41:28 -07:00
James R. Barlow 0c306ac328 v15.3.1 release ntoes 2023-10-28 00:49:37 -07:00
James R. Barlow 52d99732b1 Fix mistakes with watcher loglevel handling 2023-10-28 00:47:40 -07:00
James R. Barlow 5b5827983b Tweak documentation of --output-type 2023-10-26 23:57:08 -07:00
James R. Barlow 56f9bc311d Improve verbosity of colorspace selection 2023-10-25 00:38:56 -07:00
James R. Barlow eb17dc1ecf Fix pdf save settings at metadata_fixup 2023-10-25 00:13:17 -07:00
James R. Barlow 6f8115a052 Fix import of metadata_fixup 2023-10-25 00:06:26 -07:00
James R. Barlow aac913c666 tesseract: EAFP 2023-10-24 13:50:04 -07:00
James R. Barlow b5e73ac4e4 Drop check for obsolete .dockerinit file 2023-10-24 13:49:46 -07:00
James R. Barlow 9e98c90891 docs: note on docker performance 2023-10-24 13:34:26 -07:00
James R. Barlow ca2592c1d9 Update draft release notes 2023-10-24 00:56:00 -07:00
James R. Barlow a31f17bb9d Update comments and make worker functions private 2023-10-24 00:56:00 -07:00
James R. Barlow 1cb46afa94 Update pluginspec docs 2023-10-24 00:56:00 -07:00
James R. Barlow 5a759947dd Update release notes so far 2023-10-24 00:56:00 -07:00
James R. Barlow db3df13e95 Remove ocrmypdf._sync 2023-10-24 00:54:31 -07:00
James R. Barlow 2a8bc03167 optimize: typing 2023-10-24 00:54:31 -07:00
James R. Barlow d2297b39d0 info: clarify ICC -> components checking 2023-10-24 00:54:31 -07:00
James R. Barlow e4cd081d4d info: clarify pageinfo context management 2023-10-24 00:54:31 -07:00
James R. Barlow d2dbea6cf8 Reorganize progress bars so they can be typed properly 2023-10-24 00:54:31 -07:00
James R. Barlow 46a279a49a Improve passing of arguments to workers
The executor system was built around passing only a single
argument to workers, which was
always PageContext. For other tasks, all actual arguments were packed in
a tuple, which meant we needed intermediate functions to unpack the
tuple.

The situation is now rationlized and resembles how Python handles
argument passing to familiar multiprocessing tools.
2023-10-24 00:54:31 -07:00
James R. Barlow 299f0c4003 Update dep5 2023-10-24 00:54:31 -07:00
James R. Barlow 9ffb45f283 Remove public domain congress.jpg and replace with baiona_color.jpg
For reuse compliance we are phasing out public domain licenses
2023-10-24 00:54:31 -07:00
James R. Barlow cd61c4efd9 pngquant: remove unused ability to quantize a non-PNG
Covering testing showed this branch was never used, and when tested it didn't work.
2023-10-24 00:54:31 -07:00
James R. Barlow a06ab2a1c5 unpaper: Remove format conversion
Code is no longer reachable since we rasterize a 1/L/RGB image prior to this point.
2023-10-24 00:54:31 -07:00
James R. Barlow dfa4ebf1a6 Simplify function signature of extract_image_filter 2023-10-24 00:54:31 -07:00
James R. Barlow 58f388c69d optimize: better coverage 2023-10-24 00:54:31 -07:00
James R. Barlow 990b462a94 Fix coverage settings and cover semfree 2023-10-24 00:54:31 -07:00
James R. Barlow b928dc0808 Skip fewer tests 2023-10-24 00:54:31 -07:00
James R. Barlow 8916955f45 Convert many run_ocrmypdf -> run_ocrmypdf_api 2023-10-24 00:54:31 -07:00
James R. Barlow 82bef40aa6 Eliminate more run_ocrmypdf calls 2023-10-24 00:54:31 -07:00
James R. Barlow 1c45f32941 tests: replace many run_ocrmypdf -> run_ocrmypdf_api 2023-10-24 00:54:31 -07:00
James R. Barlow fadc0cf69b Replace cryptic test error messages with more informative ones 2023-10-24 00:54:31 -07:00
James R. Barlow 7ce9d08b2d Define progress bar plugins formally instead of "tqdm-like" 2023-10-24 00:54:31 -07:00
James R. Barlow eb3a51e33a Prefer pikepdf's newer Page.mediabox accessor over .MediaBox 2023-10-24 00:54:31 -07:00
James R. Barlow f3dd733773 optimize: explore page container as objects instead of page helpers 2023-10-24 00:54:31 -07:00
James R. Barlow 4dbc5e1dba Fix some typing issues 2023-10-24 00:54:31 -07:00
James R. Barlow c0637c287e vscode isn't ready for black py312, revert 2023-10-24 00:54:31 -07:00
James R. Barlow 6127f7abd6 tqdm_kwargs to progress_kwargs 2023-10-24 00:54:31 -07:00
James R. Barlow a4059762e6 Fix hocrtransform test to generate blank hocr 2023-10-24 00:54:31 -07:00
James R. Barlow 40afcd68a7 pluginspec: spacing 2023-10-24 00:54:31 -07:00
James R. Barlow f238e721ed Improve documentation of new public hOCR APIs 2023-10-24 00:54:31 -07:00
James R. Barlow 16eb5627a7 Fix unused imports and other trivia 2023-10-24 00:54:31 -07:00
James R. Barlow fbf0674189 hocr_to_ocr_pdf: handle missing hocr json file 2023-10-24 00:54:31 -07:00
James R. Barlow 62c4f65fc3 Remove duplicate thread local storage of page numbers 2023-10-24 00:54:31 -07:00
James R. Barlow e400112f32 Improve ._pipelines naming 2023-10-24 00:54:31 -07:00
James R. Barlow 7935914f55 Use empty .hocr file instead of dummy template for symmetry with sandwich 2023-10-24 00:54:31 -07:00
James R. Barlow ad3a1dbbad deps: update PyMuPDF req 2023-10-24 00:54:31 -07:00
James R. Barlow 0655f8e7ae Add py312 to black coverage 2023-10-24 00:54:30 -07:00
James R. Barlow 04a9372584 docs: some copyediting 2023-10-24 00:54:30 -07:00
James R. Barlow b9646b6f85 Enable multiprocessing freeze_support (for Windows) and enable forkserver
Since we use a thread for logging among other possibilities, we were
never able to use threads safely when forking. forkserver will do the job.
2023-10-24 00:54:30 -07:00
James R. Barlow 53c953a561 Fix use_threads logic for get_pdfinfo
Some debug code was level in place that forced pdfinfo to run with only
one worker when --use-threads was issued. That is how it ought to be,
since threaded pdfinfo workers just fight over the GIL and there is no
sense in parallelizing them.

Also, the user's --use-threads or --no-use-threads would be ignored in
the case of pdfinfo. By setting max_workers=1 we disabled worker processes.

This fixes how that decision is made (putting it in the relevant code, which
knows its constraints) and allows the user to influence the
thread/process decision again.
2023-10-24 00:54:30 -07:00
James R. Barlow c278fecb34 Rename post_process -> postprocess
For consistency with preprocess
2023-10-24 00:54:30 -07:00
James R. Barlow 23951c9e38 Working HOCR folder to PDF converter 2023-10-24 00:54:30 -07:00
James R. Barlow e8ae370ceb Eliminate api= kwarg and implicit creation of pluginmanager 2023-10-24 00:54:30 -07:00
James R. Barlow 67be4d1904 Refactor CLI exception handling 2023-10-24 00:54:30 -07:00
James R. Barlow 6f82097d14 Refactor setup_pipeline to decouple manage_work_folder 2023-10-24 00:54:30 -07:00
James R. Barlow fc6f959d21 Refactor debug log and work folder context cleanup 2023-10-24 00:54:30 -07:00
James R. Barlow e38d569d8f languages: kwargs are overkill 2023-10-24 00:54:30 -07:00
James R. Barlow 0856750ee2 Fix exit code error on Ghostscript failure 2023-10-24 00:54:30 -07:00
James R. Barlow 05721ba84a Fix error on no languages available 2023-10-24 00:54:30 -07:00
James R. Barlow 38c3422e5e Automatically set document language to OCR language 2023-10-24 00:54:30 -07:00
James R. Barlow d153a6f6df Refactor metadata handling 2023-10-24 00:54:30 -07:00
James R. Barlow 1a7738a925 Refactor -migrate metadata repair to new module 2023-10-24 00:54:30 -07:00
James R. Barlow 8985c0dfe9 Refactor setup_pipeline to context manager 2023-10-24 00:52:31 -07:00
James R. Barlow ebfe008432 Refactor logging record thread local storage 2023-10-24 00:52:31 -07:00
James R. Barlow 1f16eb6f50 Refactor main pipeline into discrete pipelines
- test_ghostscript_pdfa_failure fails
- haven't dealt with logging record factories

Further refactoring and duplicate elimination
2023-10-24 00:52:31 -07:00
James R. Barlow cbb0868ae3 Add hocr to ocr pdf pipeline 2023-10-24 00:52:31 -07:00
James R. Barlow 68bb38d0ad pdf_to_hocr: improve plugin handling 2023-10-24 00:52:31 -07:00
James R. Barlow 0443e87345 Introduce pdf_to_hocr API 2023-10-24 00:52:31 -07:00
James R. Barlow b3de5833d3 Refactor conversion of ocrmypdf.ocr() arguments to cmdline 2023-10-24 00:52:31 -07:00
James R. Barlow 95b14ee282 Refactor lossless reconstruction setter into separate function
Still messy but good enough as a start.
2023-10-24 00:52:31 -07:00
James R. Barlow 07b89e6a19 Plugin manager: set reasonable default when called without params 2023-10-24 00:52:31 -07:00
James R. Barlow 8991d2cb33 Refactor main pipeline and start hocr pipeline 2023-10-24 00:52:31 -07:00
James R. Barlow 86a20c4130 Refactor exec_page_sync to outputs 2023-10-24 00:52:31 -07:00
James R. Barlow 6827a6efe8 Refactor exec_page_sync -> extract _process_page 2023-10-24 00:52:31 -07:00
James R. Barlow c6b5332699 Test on release py312 not py312-rc 2023-10-21 15:17:07 -07:00
Michael FlaggandGitHub 68610046c6 Correct the archive dir name in Watched folders with Docker (#1173) 2023-10-21 14:59:33 -07:00
James R. Barlow 880326868d v15.3.0 release notes 2023-10-21 14:59:09 -07:00
James R. Barlow c6be3ba076 watcher: Improve parameter validation 2023-10-20 20:11:00 -07:00
James R. Barlow 0565cb0b10 misc/watcher.py: use Typer and dotenv to improve ease of use 2023-10-20 19:56:39 -07:00
James R. Barlow dc49906704 Improve wait_for_file_ready loop 2023-10-20 19:55:50 -07:00
James R. Barlow 93fda0dd00 Detect and warn about Tagged PDFs 2023-10-12 01:03:09 -07:00
James R. Barlow d4110e78cb Fix "pikepdf mmap disabled" spam on macOS 2023-10-11 01:24:29 -07:00
James R. Barlow 5285d68fcc Add check for filenotfound due to being inside a snap 2023-10-11 01:02:50 -07:00
189 changed files with 8307 additions and 3671 deletions
@@ -1,4 +1,4 @@
name: General issues
name: Installation, packaging, dependencies
description: Installation, packages, dependencies, "nothing works", test suite failures...
title: "[Bug]: "
labels: ["bug", "triage"]
@@ -9,6 +9,10 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
If your issue involves using OCRmyPDF on specific file(s) and not getting
good results, this is the *wrong* issue template. Please use the recommended
template to ensure we have enough information to help.
- type: textarea
id: what-happened
attributes:
@@ -21,7 +21,7 @@ body:
id: reproduce
attributes:
label: Steps to reproduce
description: Please include steps to reproduce
description: Please include steps to reproduce.
value: |
1. Run ocrmypdf -v1 ...arguments... input.pdf output.pdf
2. Open output.pdf
@@ -31,8 +31,21 @@ body:
id: files
attributes:
label: Files
description: Please attach the input and output files, or any screenshots that may be helpful.
placeholder: Drag and drop files here
description: |
Please attach the input and output files, or any screenshots that may be helpful.
If you cannot provide a test file, we probably won't be able to help with the issue.
PDF is a complex file format, and there may be technical details in the PDF that are
causing the issue. There's really no substitute for a test file.
We understand files may contain personal or sensitive information. Here are some options:
- Try reproducing the issue with a file from the test suite. (See tests/resources)
- Try to create another file in the same way as your private file.
- Encrypt the file to OCRmyPDF's private GPG key, and then zip the GPG file.
- Use ``qpdf --json yourfile.pdf`` to produce a JSON representation of your file that
omits personal information.
placeholder: |
Drag and drop files here.
- type: dropdown
id: packaging-system
attributes:
+18 -18
View File
@@ -22,20 +22,18 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-22.04
python: "3.9"
- os: ubuntu-22.04
python: "3.10"
- os: ubuntu-22.04
python: "3.11"
- os: ubuntu-22.04
python: "3.9"
python: "3.10"
tesseract5: true
- os: ubuntu-latest
python: "3.12-dev"
python: "3.12"
tesseract5: true
#- os: ubuntu-latest
# python: "pypy3.9"
# - os: ubuntu-latest
# python: "pypy3.10"
env:
OS: ${{ matrix.os }}
@@ -46,7 +44,7 @@ jobs:
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
name: Setup Python
with:
python-version: ${{ matrix.python }}
@@ -55,7 +53,7 @@ jobs:
- name: Install Tesseract 5
if: matrix.tesseract5
run: |
sudo add-apt-repository ppa:alex-p/tesseract-ocr-devel
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
- name: Install common packages
run: |
@@ -113,7 +111,7 @@ jobs:
strategy:
matrix:
os: [macos-latest]
python: ["3.10", "3.11", "3.12-dev"]
python: ["3.10", "3.11", "3.12"]
env:
OS: ${{ matrix.os }}
@@ -133,10 +131,11 @@ jobs:
ghostscript \
jbig2enc \
openjpeg \
openssl \
pngquant \
tesseract
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
name: Setup Python
with:
python-version: ${{ matrix.python }}
@@ -170,7 +169,7 @@ jobs:
strategy:
matrix:
os: [windows-latest]
python: ["3.10", "3.11", "3.12-dev"]
python: ["3.10", "3.11", "3.12"]
env:
OS: ${{ matrix.os }}
@@ -181,7 +180,7 @@ jobs:
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
name: Setup Python
with:
python-version: ${{ matrix.python }}
@@ -190,7 +189,7 @@ jobs:
- name: Install system packages
run: |
choco install --yes --no-progress --pre tesseract
choco install --yes --no-progress --ignore-checksums ghostscript
choco install --yes --no-progress --ignore-checksums ghostscript --version 9.56.1
- name: Install Python packages
run: |
@@ -215,10 +214,10 @@ jobs:
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
name: Setup Python
with:
python-version: "3.9"
python-version: "3.10"
cache: "pip"
- name: Make wheels and sdist
@@ -226,8 +225,9 @@ jobs:
python -m pip install --upgrade pip wheel build
python -m build --sdist --wheel
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: artifact
path: |
./dist/*.whl
./dist/*.tar.gz
@@ -241,7 +241,7 @@ jobs:
id-token: write # mandatory for PyPI publishing
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
steps:
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
name: artifact
path: dist
@@ -258,7 +258,7 @@ jobs:
# Required to create a release
contents: write
steps:
- uses: actions/download-artifact@v3
- uses: actions/download-artifact@v4
with:
name: artifact
path: dist
+1 -1
View File
@@ -19,7 +19,7 @@ formats:
build:
os: ubuntu-22.04
tools:
python: "3.9"
python: "3.10"
python:
install:
+14 -7
View File
@@ -3,7 +3,6 @@ Upstream-Name: OCRmyPDF
Upstream-Contact: James R. Barlow <james@purplerock.ca>
Source: https://github.com/ocrmypdf/OCRmyPDF
Files:
.git_archival.txt
docs/images/logo-social.png
@@ -11,14 +10,13 @@ Files:
docs/images/logo-square.png
docs/images/logo-square.svg
docs/images/logo.svg
setup.cfg
Copyright: (C) 2022 James R. Barlow
License: MPL-2.0
Files:
.github/ISSUE_TEMPLATE/*.md
.github/ISSUE_TEMPLATE/*.yml
docs/images/macos-workflow.png
Copyright: (C) 2022 James R. Barlow
Copyright: (C) 2023 James R. Barlow
License: CC-BY-SA-4.0
Files:
@@ -34,15 +32,16 @@ Files:
tests/resources/invalid.pdf
tests/resources/kcs.pdf
tests/resources/livecycle.pdf
tests/resources/meta.pdf
tests/resources/missing_docinfo.pdf
tests/resources/negzero.pdf
tests/resources/no_contents.pdf
tests/resources/tagged*
tests/resources/toc.pdf
tests/resources/trivial.pdf
tests/resources/truetype_font_nomapping.pdf
tests/resources/type3_font_nomapping.pdf
misc/screencast/*
Copyright: (C) 2022 James R. Barlow
Copyright: (C) 2023 James R. Barlow
License: CC-BY-SA-4.0
Files:
@@ -52,7 +51,6 @@ Copyright: (C) 2012 SmokeyJoe
License: GFDL-1.2-or-later or CC-BY-SA-3.0
Files: tests/resources/c02-22.pdf
tests/resources/congress.jpg
tests/resources/multipage.pdf
Copyright: Public domain
License: public-domain
@@ -72,6 +70,7 @@ Files: tests/resources/linn.png
tests/resources/ccitt.pdf
tests/resources/cardinal.pdf
tests/resources/jbig2.pdf
tests/resources/jbig2_baddevicen.pdf
tests/resources/skew.pdf
tests/resources/rotated_skew.pdf
tests/resources/poster.pdf
@@ -125,6 +124,14 @@ Copyright: Kai-Uwe Behrmann <www.behrmann.name>
ColorSolutions <www.basICColor.com>
License: Zlib
Files: src/ocrmypdf/data/pdf.ttf
Copyright: (C) 2014 Ray Smith
(C) 2015 Ken Sharp
(C) 2016 James R. Barlow
(C) 2016 Jeff Breidenbach
(C) 2017 Zdenko Podobný
License: Apache-2.0
Files: tests/resources/3small.pdf
Copyright: (C) 2014 Euskaldunaa
(C) 2017 James R. Barlow
+33 -33
View File
@@ -239,46 +239,46 @@ rendering
OCRmyPDF has these PDF renderers: ``sandwich`` and ``hocr``. The
renderer may be selected using ``--pdf-renderer``. The default is
``auto`` which lets OCRmyPDF select the renderer to use. Currently,
``auto`` always selects ``sandwich``.
The ``sandwich`` renderer
-------------------------
The ``sandwich`` renderer uses Tesseract's new text-only PDF feature,
which produces a PDF page that lays out the OCR in invisible text. This
page is then "sandwiched" onto the original PDF page, allowing lossless
application of OCR even to PDF pages that contain other vector objects.
Currently this is the best renderer for most uses, however it is
implemented in Tesseract so OCRmyPDF cannot influence it. Currently some
problematic PDF viewers like Mozilla PDF.js and macOS Preview have
problems with segmenting its text output, and
mightrunseveralwordstogether.
When image preprocessing features like ``--deskew`` are used, the
original PDF will be rendered as a full page and the OCR layer will be
placed on top.
``auto`` always selects ``hocr``.
The ``hocr`` renderer
---------------------
The ``hocr`` renderer works with older versions of Tesseract. The image
layer is copied from the original PDF page if possible, avoiding
potentially lossy transcoding or loss of other PDF information. If
preprocessing is specified, then the image layer is a new PDF. (You may
need to disable PDF/A conversion nad optimization to eliminate all
lossy transformations.)
.. versionchanged:: 16.0.0
Unlike ``sandwich`` this renderer is implemented within OCRmyPDF; anyone
looking to customize how OCR is presented should look here. A major
disadvantage of this renderer is it not capable of correctly handling
text outside the Latin alphabet (specifically, it supports the ISO 8859-1
character set). Pull requests to improve the situation are welcome.
In both renderers, a text-only layer is rendered and sandwiched (overlaid)
on to either the original PDF page, or newly rasterized version of the
original PDF page (when ``--force-ocr`` is used). In this way, loss
of PDF information is generally avoided. (You may need to disable PDF/A
conversion and optimization to eliminate all lossy transformations.)
Currently, this renderer has the best compatibility with Mozilla's
PDF.js viewer.
The current approach used by the new hOCR renderer is a re-implementation
of Tesseract's PDF renderer, using the same Glyphless font and general
ideas, but fixing many technical issues that impeded it. The new hocr
provides better text placement accuracy, avoids issues with word
segmentation, and provides better positioning of skewed text.
This works in all versions of Tesseract.
Using the experimental API, it is also possible to edit the OCR output
from Tesseract, using any tool that is capable of editing hOCR files.
Older versions of this renderer did not support non-Latin languages, but
it is now universal.
The ``sandwich`` renderer
-------------------------
The ``sandwich`` renderer uses Tesseract's text-only PDF feature,
which produces a PDF page that lays out the OCR in invisible text.
Currently some problematic PDF viewers like Mozilla PDF.js and macOS
Preview have problems with segmenting its text output, and
mightrunseveralwordstogether. It also does not implement right to left
fonts (Arabic, Hebrew, Persian). The output of this renderer cannot
be edited. The sandwich renderer is retained for testing.
When image preprocessing features like ``--deskew`` are used, the
original PDF will be rendered as a full page and the OCR layer will be
placed on top.
Rendering and rasterizing options
=================================
-11
View File
@@ -124,14 +124,3 @@ handler. OCRmyPDF will clean up its temporary files and worker processes
automatically when an exception occurs.
When OCRmyPDF succeeds conditionally, it returns an integer exit code.
Reference
---------
.. autofunction:: ocrmypdf.ocr
.. autoclass:: ocrmypdf.Verbosity
:members:
:undoc-members:
.. autofunction:: ocrmypdf.configure_logging
+14 -2
View File
@@ -3,11 +3,11 @@
.. SPDX-License-Identifier: CC-BY-SA-4.0
=============
API Reference
API reference
=============
This page summarizes the rest of the public API. Generally speaking this
should mainly of interest to plugin developers.
should be mainly of interest to plugin developers.
ocrmypdf
========
@@ -18,6 +18,18 @@ ocrmypdf
.. autoclass:: ocrmypdf.PdfContext
:members:
.. autoclass:: ocrmypdf.Verbosity
:members:
:undoc-members:
.. autofunction:: ocrmypdf.configure_logging
.. autofunction:: ocrmypdf.ocr
.. autofunction:: ocrmypdf.pdf_to_hocr
.. autofunction:: ocrmypdf.hocr_to_ocr_pdf
ocrmypdf.exceptions
===================
+3 -3
View File
@@ -152,7 +152,7 @@ The watcher service is included in the OCRmyPDF Docker image. To run it:
docker run \
--volume <path to files to convert>:/input \
--volume <path to store results>:/output \
--volume <path to store processed originals>:/archive \
--volume <path to store processed originals>:/processed \
--env OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1 \
--env OCR_ON_SUCCESS_ARCHIVE=1 \
--env OCR_DESKEW=1 \
@@ -163,7 +163,7 @@ The watcher service is included in the OCRmyPDF Docker image. To run it:
This service will watch for a file that matches ``/input/\*.pdf``,
convert it to a OCRed PDF in ``/output/``, and move the processed
original to ``/archive``. The parameters to this image are:
original to ``/processed``. The parameters to this image are:
.. csv-table:: watcher.py parameters for Docker
:header: "Parameter", "Description"
@@ -171,7 +171,7 @@ original to ``/archive``. The parameters to this image are:
"``--volume <path to files to convert>:/input``", "Files placed in this location will be OCRed"
"``--volume <path to store results>:/output``", "This is where OCRed files will be stored"
"``--volume <path to store processed originals>:/archive``", "Archive processed originals here"
"``--volume <path to store processed originals>:/processed``", "Archive processed originals here"
"``--env OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1``", "Define environment variable ``OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1`` to place files in the output in ``{output}/{year}/{month}/{filename}``"
"``--env OCR_ON_SUCCESS_ARCHIVE=1``", "Define environment variable ``OCR_ON_SUCCESS_ARCHIVE`` to move processed originals"
"``--env OCR_DESKEW=1``", "Define environment variable ``OCR_DESKEW`` to apply deskew to crooked input PDFs"
+5
View File
@@ -1,3 +1,8 @@
.. SPDX-FileCopyrightText: 2023 James R. Barlow
..
.. SPDX-License-Identifier: CC-BY-SA-4.0
.. _ocr-service:
==================
+1 -1
View File
@@ -19,7 +19,7 @@ Code style
==========
We use PEP8, ``black`` for code formatting and ``ruff`` for everything else. The
settings for these programs are in ``pyproject.toml`` and ``setup.cfg``. Pull
settings for these programs are in ``pyproject.toml``. Pull
requests should follow the style guide. One difference we use from "black" style
is that strings shown to the user are always in double quotes (``"``) and strings
for internal uses are in single quotes (``'``).
+6 -6
View File
@@ -12,12 +12,12 @@ OCRmyPDF is also available in Docker images that packages recent
versions of all dependencies.
For users who already have Docker installed this may be an easy and
convenient option. However, it is less performant than a system
installation and may require Docker engine configuration. OCRmyPDF
needs a generous amount of RAM, CPU cores, temporary storage
space, whether running in a Docker container or on its own. It may be
necessary to ensure the container is provisioned with additional
resources.
convenient option.
On platforms other than Linux, Docker runs in a virtual machine, and so may
be less performant. You may also want to adjust the Docker virtual machine's
memory and CPU allocation. On Linux, the Docker image runs natively and
performance is comparable to a system installation.
.. _docker-install:
+9 -7
View File
@@ -391,18 +391,20 @@ package manager:
* ``winget install -e --id Python.Python.3.11``
* ``winget install -e --id UB-Mannheim.TesseractOCR``
* ``winget install -e --id ArtifexSoftware.GhostScript``
You will need to install Ghostscript manually, `since it does not support automated
installs anymore <https://artifex.com/news/ghostscript-10.01.0-disabling-silent-install-option>`_.
* `Ghostscript download page <https://ghostscript.com/releases/gsdnld.html>`_.`
(Or alternately, using the `Chocolatey <https://chocolatey.org/>`_ package manager, install
the following when running in an Administrator command prompt):
* ``choco install python3``
* ``choco install --pre tesseract``
* ``choco install ghostscript``
* ``choco install pngquant`` (optional)
Either set of commands will install the required software. At the mmoment there is no
Either set of commands will install the required software. At the moment there is no
single command to install Windows.
You may then use ``pip`` to install ocrmypdf. (This can performed by a user or
@@ -452,7 +454,7 @@ Cygwin64
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
python39 (or later)
python310 (or later)
python3?-devel
python3?-pip
python3?-lxml
@@ -550,7 +552,7 @@ manager. ``pip`` cannot provide them.
The following versions are required:
- Python 3.9 or newer
- Python 3.10 or newer
- Ghostscript 9.55 or newer
- Tesseract 4.1.1 or newer
- jbig2enc 0.29 or newer
@@ -586,7 +588,7 @@ unfortunately, the ``pip install`` command cannot satisfy all of them.
Installing HEAD revision from sources
=====================================
If you have ``git`` and Python 3.9 or newer installed, you can install
If you have ``git`` and Python 3.10 or newer installed, you can install
from source. When the ``pip`` installer runs, it will alert you if
dependencies are missing.
@@ -679,4 +681,4 @@ can still install and use OCRmyPDF. A warning message will appear.
In practice, OCRmyPDF may need more than 32-bit memory space to run when
large documents are processed, so there are practical limitations to what
users can accomplish with it. Still, for the common use case of an 32-bit
ARM NAS or Raspberry Pi processing small documents, it should work.
ARM NAS or Raspberry Pi processing small documents, it should work.
+107 -124
View File
@@ -6,23 +6,23 @@
Introduction
============
OCRmyPDF is an application and library that adds text "layers" to images
in PDFs, making scanned image PDFs searchable. It uses OCR to guess what text
is contained in images. It is written in Python. OCRmyPDF supports plugins
that allow customization of its processing steps, and is very tolerant of
PDFs that contain scanned images and "born digital" content that needs no
text recognition.
OCRmyPDF is a Python application and library that adds text "layers" to images in
PDFs, making scanned image PDFs searchable. It uses OCR to guess the text
contained in images. OCRmyPDF also supports plugins
that enable customization of its processing steps, and it is highly tolerant
of PDFs containing scanned images and "born digital" content that doesn't
require text recognition.
About OCR
=========
`Optical character
recognition <https://en.wikipedia.org/wiki/Optical_character_recognition>`__
is technology that converts images of typed or handwritten text, such as
in a scanned document, to computer text that can be selected, searched and copied.
is a technology that converts images of typed or handwritten text, such as
in a scanned document, into computer text that can be selected, searched and copied.
OCRmyPDF uses
`Tesseract <https://github.com/tesseract-ocr/tesseract>`__, the best
`Tesseract <https://github.com/tesseract-ocr/tesseract>`__, a widely
available open source OCR engine, to perform OCR.
.. _raster-vector:
@@ -30,19 +30,19 @@ available open source OCR engine, to perform OCR.
About PDFs
==========
PDFs are page description files that attempts to preserve a layout
PDFs are page description files that attempt to preserve a layout
exactly. They contain `vector
graphics <http://vector-conversions.com/vectorizing/raster_vs_vector.html>`__
that can contain raster objects such as scanned images. Because PDFs can
that can contain raster objects, such as scanned images. Because PDFs can
contain multiple pages (unlike many image formats) and can contain fonts
and text, it is a good format for exchanging scanned documents.
and text, they are a suitable format for exchanging scanned documents.
|image|
A PDF page might contain multiple images, even if it only appears to
have one image. Some scanners or scanning software will segment pages
into monochromatic text and color regions for example, to improve the
compression ratio and appearance of the page.
A PDF page may contain multiple images, even if it appears to have only
one image. Some scanners or scanning software may segment pages into
monochromatic text and color regions, for example, to enhance the compression
ratio and appearance of the page.
Rasterizing a PDF is the process of generating corresponding raster images.
OCR engines like Tesseract work with images, not scalable vector graphics
@@ -54,147 +54,131 @@ About PDF/A
`PDF/A <https://en.wikipedia.org/wiki/PDF/A>`__ is an ISO-standardized
subset of the full PDF specification that is designed for archiving (the
'A' stands for Archive). PDF/A differs from PDF primarily by omitting
features that would make it difficult to read the file in the future,
features that could complicate future file readability,
such as embedded Javascript, video, audio and references to external
fonts. All fonts and resources needed to interpret the PDF must be
contained within it. Because PDF/A disables Javascript and other types
of embedded content, it is probably more secure.
of embedded content, it is likely more secure.
There are various conformance levels and versions, such as "PDF/A-2b".
Generally speaking, the best format for scanned documents is PDF/A. Some
In general, the preferred format for scanned documents is PDF/A. Some
governments and jurisdictions, US Courts in particular, `mandate the use
of PDF/A <https://pdfblog.com/2012/02/13/what-is-pdfa/>`__ for scanned
documents.
Since most people who scan documents are interested in reading them
indefinitely into the future, OCRmyPDF generates PDF/A-2b by default.
Since most individuals scanning documents aim for long-term readability,
OCRmyPDF defaults to generating PDF/A-2b.
PDF/A has a few drawbacks. Some PDF viewers include an alert that the
file is a PDF/A, which may confuse some users. It also tends to produce
larger files than PDF, because it embeds certain resources even if they
are commonly available. PDF/A files can be digitally signed, but may not
be encrypted, to ensure they can be read in the future. Fortunately,
converting from PDF/A to a regular PDF is trivial, and any PDF viewer
can view PDF/A.
PDF/A does have a few drawbacks. Some PDF viewers display an alert
indicating that the file is in PDF/A format, which may confuse some users.
Additionally, it tends to result in larger files than standard PDFs because
it embeds certain resources, even if they are widely available. PDF/A
files can be digitally signed but may not be encrypted to ensure future
readability. Fortunately, converting from PDF/A to a regular PDF is
straightforward, and any PDF viewer can handle PDF/A files.
What OCRmyPDF does
==================
OCRmyPDF analyzes each page of a PDF to determine the colorspace and
resolution (DPI) needed to capture all of the information on that page
without losing content. It uses
`Ghostscript <http://ghostscript.com/>`__ to rasterize the page, and
then performs OCR on the rasterized image to create an OCR "layer".
The layer is then grafted back onto the original PDF.
OCRmyPDF analyzes each page of a PDF to determine the required colorspace
and resolution (DPI) for capturing all the information on that page without
losing content. It uses
`Ghostscript <http://ghostscript.com/>`__ to rasterize each page and subsequently
performs OCR on the rasterized image to generate an OCR "layer." This layer
is then integrated back into the original PDF.
While one can use a program like Ghostscript or ImageMagick to get an
image and put the image through Tesseract, that actually creates a new
PDF and many details may be lost. OCRmyPDF can produce a minimally
changed PDF as output.
While it is possible to use a program like Ghostscript or ImageMagick to
obtain an image and then run that image through Tesseract OCR, this process
actually generates a new PDF, potentially resulting in the loss of various
details (such as the document's metadata). In contrast, OCRmyPDF can produce
a minimally altered PDF as the output.
OCRmyPDF also provides some image processing options, like deskew, which
improves the appearance of files and quality of OCR. When these are used,
the OCR layer is grafted onto the processed image instead.
OCRmyPDF also offers several image processing options, such as deskew, which
enhances the visual quality of files and the accuracy of OCR. When these
options are utilized, the OCR layer is integrated into the processed image.
By default, OCRmyPDF produces archival PDFs PDF/A, which are a
stricter subset of PDF features designed for long term archives. If
regular PDFs are desired, this can be disabled with
``--output-type pdf``.
By default, OCRmyPDF generates archival PDFs in the PDF/A format, which is
a more rigid subset of PDF features designed for long-term archives. If you
prefer regular PDFs, you can disable this feature using the
``--output-type pdf`` option.
Why you shouldn't do this manually
==================================
A PDF is similar to an HTML file, in that it contains document structure
along with images. Sometimes a PDF does nothing more than present a full
page image, but often there is additional content that would be lost.
along with images. While some PDFs may solely display a full-page image,
they often contain additional content that would be forfeited if not preserved.
A manual process could work like either of these:
A manual process could take one of these approaches:
1. Rasterize each page as an image, OCR the images, and combine the
output into a PDF. This preserves the layout of each page, but
resamples all images (possibly losing quality, increasing file size,
introducing compression artifacts, etc.).
2. Extract each image, OCR, and combine the output into a PDF. This
loses the context in which images are used in the PDF, meaning that
cropping, rotation and scaling of pages may be lost. Some scanned
PDFs use multiple images segmented into black and white, grayscale
1. Rasterize each page as an image, perform OCR on the images, and then merge the
output into a PDF. This method preserves the layout of each page, but
resamples all images potentially leading to quality loss, increased file size,
and the introduction of compression artifacts, among other issues.
2. Extract each image, OCR, and combine the output into a PDF. This approach
loses the context in which images are used in the PDF, potentially resulting
in loss of information related to scaling and position of images. Some scanned
PDFs contain multiple images segmented into black and white, grayscale
and color regions, with stencil masks to prevent overlap, as this can
enhance the appearance of a file while reducing file size. Clearly,
reassembling these images will be easy. This also loses and text or
vector art on any pages in a PDF with both scanned and pure digital
content.
enhance the appearance of a file while reducing file size.
Reassembling these images can be challenging, and risks losing vector art
or text that is not part of an image.
In the case of a PDF that is nothing other than a container of images
(no rotation, scaling, cropping, one image per page), the second
approach can be lossless.
In cases where a PDF solely serves as a container for images without any
rotation, scaling, or cropping, the second approach can be lossless.
OCRmyPDF uses several strategies depending on input options and the
input PDF itself, but generally speaking it rasterizes a page for OCR
and then grafts the OCR back onto the original. As such it can handle
complex PDFs and still preserve their contents as much as possible.
OCRmyPDF uses various strategies depending on input options and the input PDF
itself. Generally, it rasterizes a page for OCR and then integrates the OCR
data back into the original PDF. This approach allows it to handle complex
PDFs and preserve their content as much as possible.
OCRmyPDF also supports a many, many edge cases that have cropped over
several years of development. We support PDF features like images inside
of Form XObjects, and pages with UserUnit scaling. We support rare image
formats like non-monochrome 1-bit images. We warn about files you may
not to OCR. Thanks to pikepdf and QPDF, we auto-repair PDFs that are
damaged. (Not that you need to know what any of these are! You should be
able to throw any PDF at it.)
Furthermore, OCRmyPDF supports a wide range of edge cases that have emerged
during several years of development. It accommodates PDF features like
images within Form XObjects and pages with UserUnit scaling. It also
supports less common image formats like non-monochrome 1-bit images and
provides warnings about files you may not want to OCR. Thanks to tools
like pikepdf and QPDF, it can auto-repair damaged PDFs. You don't need to
understand the intricacies of these issues; you should be able to use
OCRmyPDF with any PDF file, and expect reasonable results.
Limitations
===========
OCRmyPDF is limited by the Tesseract OCR engine. As such it experiences
these limitations, as do any other programs that rely on Tesseract:
OCRmyPDF is subject to limitations imposed by the Tesseract OCR engine.
These limitations are inherent to any software relying on Tesseract:
- The OCR is not as accurate as commercial OCR solutions.
- It is not capable of recognizing handwriting.
- It may find gibberish and report this as OCR output.
- If a document contains languages outside of those given in the
``-l LANG`` arguments, results may be poor.
- It is not always good at analyzing the natural reading order of
documents. For example, it may fail to recognize that a document
contains two columns, and may try to join text across columns.
- Poor quality scans may produce poor quality OCR. Garbage in, garbage
out.
- It does not expose information about what font family text belongs
to.
OCRmyPDF is also limited by the PDF specification:
- PDF encodes the position of text glyphs but does not encode document
structure. There is no markup that divides a document in sections,
paragraphs, sentences, or even words (since blank spaces are not
represented). As such all elements of document structure including
the spaces between words must be derived heuristically. Some PDF
viewers do a better job of this than others.
- Because some popular open source PDF viewers have a particularly hard
time with spaces between words, OCRmyPDF appends a space to each text
element as a workaround (when using ``--pdf-renderer hocr``). While
this mixes document structure with graphical information that ideally
should be left to the PDF viewer to interpret, it improves
compatibility with some viewers and does not cause problems for
better ones.
- The OCR accuracy may not match that of commercial OCR solutions.
- It is incapable of recognizing handwriting.
- It may detect gibberish and report it as OCR output.
- Results may be subpar when a document contains languages not specified
in the ``-l LANG`` argument.
- Tesseract may struggle to analyze the natural reading order of documents.
For instance, it might fail to recognize two columns in a document and
attempt to join text across columns.
- Poor quality scans can result in subpar OCR quality. In other words, the
quality of the OCR output depends on the quality of the input.
- Tesseract does not provide information about the font family to which text
belongs.
- Tesseract does not divide text into paragraphs or headings. It only provides
the text and its bounding box. As such, the generated PDF does not
contain any information about the document's structure.
Ghostscript also imposes some limitations:
- PDFs containing JBIG2-encoded content will be converted to CCITT
Group4 encoding, which has lower compression ratios, if Ghostscript
PDF/A is enabled.
- PDFs containing JPEG 2000-encoded content will be converted to JPEG
- PDFs containing JPEG 2000-encoded content may be converted to JPEG
encoding, which may introduce compression artifacts, if Ghostscript
PDF/A is enabled.
- Ghostscript may transcode grayscale and color images, either lossy to
lossless or lossless to lossy, based on an internal algorithm. This
- Ghostscript may transcode grayscale and color images, potentially
lossily, based on an internal algorithm. This
behavior can be suppressed by setting ``--pdfa-image-compression`` to
``jpeg`` or ``lossless`` to set all images to one type or the other.
Ghostscript has no option to maintain the input image's format.
Ghostscript lacks an option to maintain the input image's format.
(Modern Ghostscript can copy JPEG images without transcoding them.)
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
one of the standard XMP metadata namespaces for PDFs. In particular,
PRISM Metadata is removed.
- Ghostscript's PDF/A conversion seems to remove or deactivate
- Ghostscript's PDF/A conversion may remove or deactivate
hyperlinks and other active content.
You can use ``--output-type pdf`` to disable PDF/A conversion and produce
@@ -202,7 +186,7 @@ a standard, non-archival PDF.
Regarding OCRmyPDF itself:
- PDFs that use transparency are not currently represented in the test
- PDFs using transparency are not currently represented in the test
suite
Similar programs
@@ -210,11 +194,7 @@ Similar programs
To the author's knowledge, OCRmyPDF is the most feature-rich and
thoroughly tested command line OCR PDF conversion tool. If it does not
meet your needs, contributions and suggestions are welcome. If not,
consider one of these similar open source programs:
- pdf2pdfocr
- pdfsandwich
meet your needs, contributions and suggestions are welcome.
Ghostscript recently added three "pdfocr" output devices. They work by
rasterizing all content and converting all pages to a single colour space.
@@ -222,16 +202,19 @@ rasterizing all content and converting all pages to a single colour space.
Web front-ends
==============
The Docker image ``ocrmypdf`` provides a web service front-end
that allows files to submitted over HTTP and the results "downloaded".
This is an HTTP server intended to simplify web services deployments; it
is not intended to be deployed on the public internet and no real
security measures to speak of.
The Docker image of OCRmyPDF provides a web service front-end
that allows files to submitted over HTTP, and the results can be downloaded.
This is an HTTP server intended to demonstrate how OCRmyPDF can be
integrated into a web service. It is not intended to be deployed on the
public internet and does not provide any security measures.
In addition, the following third-party integrations are available:
- `Paperless-ngx <https://docs.paperless-ngx.com/>`__ is a free software
document management system that uses OCRmyPDF to perform OCR on
uploaded documents.
- `Nextcloud OCR <https://github.com/janis91/ocr>`__ is a free software
plugin for the Nextcloud private cloud software
plugin for the Nextcloud private cloud software.
OCRmyPDF is not designed to be secure against malware-bearing PDFs (see
`Using OCRmyPDF online <ocr-service>`__). Users should ensure they
+16 -8
View File
@@ -14,17 +14,20 @@ expired as of 2017, but it is possible that unknown patents exist.
JBIG2 encoding is recommended for OCRmyPDF and is used to losslessly
create smaller PDFs. If JBIG2 encoding is not available, lower quality
encodings will be used.
CCITT encoding will be used for monochrome images.
JBIG2 decoding is not patented and is performed automatically by most
PDF viewers. It is widely supported and has been part of the PDF
specification since 2001.
On macOS, Homebrew packages jbig2enc and OCRmyPDF includes it by
default. The Docker image for OCRmyPDF also builds its own JBIG2 encoder
from source.
JBIG encoding is automatically provided by these OCRmyPDF packages:
- Docker image (both Ubuntu and Alpine)
- Snap package
- ArchLinux AUR package
- Alpine Linux package
- Homebrew on macOS
For all other Linux, you must build a JBIG2 encoder from source:
For all other platforms, you would need to build the JBIG2 encoder from source:
.. code-block:: bash
@@ -43,16 +46,21 @@ as libtool and leptonica-devel.
Lossy mode JBIG2
================
OCRmyPDF provides lossy mode JBIG2 as an advanced feature. Users should
OCRmyPDF provides lossy mode JBIG2 as an advanced and potentially dangerous
feature. Users should
`review the technical concerns with JBIG2 in lossy
mode <https://en.wikipedia.org/wiki/JBIG2#Disadvantages>`__
and decide if this feature is acceptable for their use case.
and decide if this feature is acceptable for their use case. In general,
this mode should not be used for archival purposes, should not be used when
the original document is not available or will be destroyed, and should
not be used when numbers present in the document are important, because
there is a risk of 6/8 and 8/6 substitution errors.
JBIG2 lossy mode does achieve higher compression ratios than any other
monochrome (bitonal) compression technology; for large text documents
the savings are considerable. JBIG2 lossless still gives great
compression ratios and is a major improvement over the older CCITT G4
standard. As explained above, there is some risk of substitution errors.
standard.
To turn on JBIG2 lossy mode, add the argument ``--jbig2-lossy``.
``--optimize {1,2,3}`` are necessary for the argument to take effect
+31 -13
View File
@@ -18,16 +18,26 @@ Tesseract's documentation also lists the three-letter code for your language.
Some are anglicized, e.g. Spanish is ``spa`` rather than ``esp``, while others
are not, e.g. German is ``deu`` and French is ``fra``.
Language packs (strictly speaking, Tesseract "traineddata" files) generally correspond
to the language in question, but different language packs are used in certain
situations. For German, the "Fraktur" language pack can assist with reading older
materials in the Fraktur typeface family (``deu_frak``). Some communities have changed
their script from Cyrillic to Latin; the Cyrillic version of Uzbek is available
as ``uzb_cyrl`` and the Latin version is ``uzb``.
After you have installed a language pack, you can use it with ``ocrmypdf -l <language>``,
for example ``ocrmypdf -l spa``. For multilingual documents, you can specify
all languages to be expected, e.g. ``ocrmypdf -l eng+fra`` for English and French.
English is assumed by default unless other language(s) are specified.
For Linux users, you can often find packages that provide language
packs:
packs.
Debian and Ubuntu users
=======================
Platform install steps
======================
Debian and Ubuntu (apt)
-----------------------
.. code-block:: bash
@@ -42,8 +52,8 @@ to what languages it should search for. Multiple languages can be
requested using either ``-l eng+fra`` (English and French) or
``-l eng -l fra``.
Fedora users
============
Fedora
------
.. code-block:: bash
@@ -58,8 +68,8 @@ to what languages it should search for. Multiple languages can be
requested using either ``-l eng+fra`` (English and French) or
``-l eng -l fra``.
Gentoo users
============
Gentoo
------
On Gentoo the package ``app-text/tessdata_fast``, which ``app-text/tesseract`` depends on, handles Tesseract languages.
It accepts USE flags to select what languages should be installed, these can be set in ``/etc/portage/package.use``.
@@ -85,23 +95,31 @@ to what languages it should search for. Multiple languages can be
requested using either ``-l eng+fra`` (English and French) or
``-l eng -l fra``.
macOS users
===========
macOS
-----
You can install additional language packs by
:ref:`installing Tesseract using Homebrew with all language packs <macos-all-languages>`.
Docker users
============
Docker
------
Users of the OCRmyPDF Docker image should install language packs into a
derived Docker image as
:ref:`described in that section <docker-lang-packs>`.
Windows users
=============
Windows
-------
The Tesseract installer provided by Chocolatey currently includes only English language.
To install other languages, download the respective language pack (``.traineddata`` file)
from https://github.com/tesseract-ocr/tessdata/ and place it in
``C:\\Program Files\\Tesseract-OCR\\tessdata`` (or wherever Tesseract OCR is installed).
Custom language packs
=====================
If you have fine-tuned or trained Tesseract and generated custom trained data, you can
copy your ``customlang.traineddata`` file into your Tesseract "tessdata" folder, and
then use the ``-l customlang`` argument to tell OCRmyPDF to pass that language on to
Tesseract.
+1 -1
View File
@@ -17,7 +17,7 @@ If running OCRmyPDF quickly is your main goal, you can use settings such as:
* ``--optimize 0`` to disable file size optimization
* ``--output-type pdf`` to disable PDF/A generation
* ``--fast-web-view 0`` to disable fast web view optimization
* ``--fast-web-view 999999`` to disable fast web view optimization
* ``--skip-big`` to skip large images, if some pages have large images
You can also avoid:
+5
View File
@@ -177,8 +177,13 @@ Custom command line arguments
Execution and progress reporting
--------------------------------
.. autoclass:: ocrmypdf.pluginspec.ProgressBar
:members:
:special-members: __init__, __enter__, __exit__
.. autoclass:: ocrmypdf.pluginspec.Executor
:members:
:special-members: __call__
.. autofunction:: ocrmypdf.pluginspec.get_logging_console
+128
View File
@@ -20,6 +20,8 @@ The most recent release of OCRmyPDF is |OCRmyPDF PyPI|. Any newer versions
referred to in these notes may exist the main branch but have not been
tagged yet.
OCRmyPDF typically supports the three most recent Python versions.
.. note::
Attention maintainers: these release notes may be updated with information
@@ -28,6 +30,132 @@ tagged yet.
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
v16.0.4
=======
- Fixed some issues for left-to-right text with the new hOCR renderer. It is still
not default yet but will be made so soon. Right-to-left text is still in progress.
- Added an error to prevent use of several versions of Ghostscript that seem
corrupt existing text in input PDFs. Newly generated OCR is not affected.
For best results, use Ghostscript 10.02.1 or newer, which contains the fix
for the issue.
v16.0.3
=======
- Changed minimum required Ghostscript to 9.54, to support users of RHEL 9 and its
derivatives, since that is the latest version available there.
- Removed warning message about CVE-2023-43115, on the assumption that most
distributions have backported the patch by now.
v16.0.2
=======
- Temporarily changed PDF text renderer back to sandwich by default to address
regressions in macOS Preview.
v16.0.1
=======
- Fixed text rendering issue with new hOCR text renderer - extraneous byte order
marks.
- Tightened dependencies.
v16.0.0
=======
- Added OCR text renderer, combined the best ideas of Tesseract's PDF
generator and the older hOCR transformer renderer. The result is a hopefully
permanent fix for wordssmushedtogetherwithoutspaces issues in extracted text,
better registration/position of text on skewed baselines :issue:`1009`,
fixes to character output when the German Fraktur script is used :issue:`1191`,
proper rendering of right to left languages (Arabic, Hebrew, Persian) :issue:`1157`.
Asian languages may still have excessive word breaks compared to expectations.
The new renderer is the default; the old sandwich renderer is still available
using ``--pdf-renderer sandwich``; the old hOCR renderer is no more.
- The ``ocrmypdf.hocrtransform`` API has changed substantially.
- Support for Python 3.9 has been dropped. Python 3.10+ is now required.
- pikepdf >= 8.8.0 is now required.
v15.4.4
=======
- Fixed documentation for installing Ghostscript on Windows. :issue:`1198`
- Added warning message about security issue in older versions of Ghostscript.
v15.4.3
=======
- Fixed deprecation warning in pikepdf older than 8.7.1; pikepdf >= 8.7.1 is
now required.
v15.4.2
=======
- We now raise an exception on a certain class of PDFs that likely need an
explicit color conversion strategy selected to display correctly
for PDF/A conversion.
- Fixed an error that occurred while trying to write a log message after the
debug log handler was removed.
v15.4.1
=======
- Fixed misc/watcher.py regressions: accept ``--ocr-json-settings`` as either
filename or JSON string, as previously; and argument count mismatch.
:issue:`1183,1185`
- We no longer attempt to set /ProcSet in the PDF output, since this is an
obsolete PDF feature.
- Documentation improvements.
v15.4.0
=======
- Added new experimental APIs to support offline editing of the final text.
Specifically, one can now generate hOCR files with OCRmyPDF, edit them with
some other tool, and then finalize the PDF. They are experimental and
subject to change, including details of how the working folder is used.
There is no command line interface.
- Code reorganization: executors, progress bars, initialization and setup.
- Fixed test coverage in cases where the coverage tool did not properly trace
into threads or subprocesses. This code was still being tested but appeared
as not covered.
- In the test suite, reduced use of subprocesses and other techniques that
interfere with coverage measurement.
- Improved error check for when we appear to be running inside a snap container
and files are not available.
- Plugin specification now properly defines progress bars as a protocol rather
than defining them as "tqdm-like".
- We now default to using "forkserver" process creation on POSIX platforms
rather than fork, since this is method is more robust and avoids some
issues when threads are present.
- Fixed an instance where the user's request to ``--no-use-threads`` was ignored.
- If a PDF does not have language metadata on its top level object, we add
the OCR language.
- Replace some cryptic test error messages with more helpful ones.
- Debug messages for how OCRmyPDF picks the colorspace for a page are now
more descriptive.
v15.3.1
=======
- Fixed an issue with logging settings for misc/watcher.py introduced in the
previous release. :issue:`1180`
- We now attempt to preserve the input's extended attributes when creating
the output file.
- For some reason, the macOS build now needs OpenSSL explicitly installed.
- Updated documentation on Docker performance concerns.
v15.3.0
=======
- Update misc/watcher.py to improve command line interface using Typer, and
support ``.env`` specification of environment variables. Improved error
messages. Thanks to @mflagg2814 for the PR that prompted this improvement.
- Improved error message when a file cannot be read because we are running in
a snap container.
v15.2.0
=======
+236 -75
View File
@@ -5,107 +5,125 @@
"""Watch a directory for new PDFs and OCR them."""
from __future__ import annotations
# Do not enable annotations!
# https://github.com/tiangolo/typer/discussions/598
# from __future__ import annotations
import json
import logging
import os
import shutil
import sys
import time
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Annotated, Any
import pikepdf
import typer
from dotenv import load_dotenv
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
import ocrmypdf
load_dotenv()
# pylint: disable=logging-format-interpolation
def getenv_bool(name: str, default: str = 'False'):
return os.getenv(name, default).lower() in ('true', 'yes', 'y', '1')
INPUT_DIRECTORY = os.getenv('OCR_INPUT_DIRECTORY', '/input')
OUTPUT_DIRECTORY = os.getenv('OCR_OUTPUT_DIRECTORY', '/output')
ARCHIVE_DIRECTORY = os.getenv('OCR_ARCHIVE_DIRECTORY', '/processed')
OUTPUT_DIRECTORY_YEAR_MONTH = getenv_bool('OCR_OUTPUT_DIRECTORY_YEAR_MONTH')
ON_SUCCESS_DELETE = getenv_bool('OCR_ON_SUCCESS_DELETE')
ON_SUCCESS_ARCHIVE = getenv_bool('OCR_ON_SUCCESS_ARCHIVE')
DESKEW = getenv_bool('OCR_DESKEW')
OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}'))
POLL_NEW_FILE_SECONDS = int(os.getenv('OCR_POLL_NEW_FILE_SECONDS', '1'))
USE_POLLING = getenv_bool('OCR_USE_POLLING')
RETRIES_LOADING_FILE = int(os.getenv('OCR_RETRIES_LOADING_FILE', '5'))
LOGLEVEL = os.getenv('OCR_LOGLEVEL', 'INFO')
PATTERNS = ['*.pdf', '*.PDF']
app = typer.Typer(name="ocrmypdf-watcher")
log = logging.getLogger('ocrmypdf-watcher')
def get_output_dir(root, basename):
if OUTPUT_DIRECTORY_YEAR_MONTH:
class LoggingLevelEnum(str, Enum):
"""Enum for logging levels."""
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
def get_output_dir(root: Path, basename: str, output_dir_year_month: bool) -> Path:
if output_dir_year_month:
today = datetime.today()
output_directory_year_month = (
Path(root) / str(today.year) / f'{today.month:02d}'
)
output_directory_year_month = root / str(today.year) / f'{today.month:02d}'
if not output_directory_year_month.exists():
output_directory_year_month.mkdir(parents=True, exist_ok=True)
output_path = Path(output_directory_year_month) / basename
else:
output_path = Path(OUTPUT_DIRECTORY) / basename
output_path = root / basename
return output_path
def wait_for_file_ready(file_path):
def wait_for_file_ready(
file_path: Path, poll_new_file_seconds: int, retries_loading_file: int
):
# This loop waits to make sure that the file is completely loaded on
# disk before attempting to read. Docker sometimes will publish the
# watchdog event before the file is actually fully on disk, causing
# pikepdf to fail.
retries = RETRIES_LOADING_FILE
while retries:
tries = retries_loading_file + 1
while tries:
try:
pdf = pikepdf.open(file_path)
except (FileNotFoundError, pikepdf.PdfError) as e:
with pikepdf.Pdf.open(file_path) as pdf:
log.debug(f"{file_path} ready with {pdf.pages} pages")
return True
except (FileNotFoundError, OSError) as e:
log.info(f"File {file_path} is not ready yet")
log.debug("Exception was", exc_info=e)
time.sleep(POLL_NEW_FILE_SECONDS)
retries -= 1
else:
pdf.close()
return True
time.sleep(poll_new_file_seconds)
tries -= 1
except pikepdf.PdfError as e:
log.info(f"File {file_path} is not full written yet")
log.debug("Exception was", exc_info=e)
time.sleep(poll_new_file_seconds)
tries -= 1
return False
def execute_ocrmypdf(file_path):
file_path = Path(file_path)
output_path = get_output_dir(OUTPUT_DIRECTORY, file_path.name)
def execute_ocrmypdf(
*,
file_path: Path,
archive_dir: Path,
output_dir: Path,
ocrmypdf_kwargs: dict[str, Any],
on_success_delete: bool,
on_success_archive: bool,
poll_new_file_seconds: int,
retries_loading_file: int,
output_dir_year_month: bool,
):
output_path = get_output_dir(output_dir, file_path.name, output_dir_year_month)
log.info("-" * 20)
log.info(f'New file: {file_path}. Waiting until fully loaded...')
if not wait_for_file_ready(file_path):
log.info(f'New file: {file_path}. Waiting until fully written...')
if not wait_for_file_ready(file_path, poll_new_file_seconds, retries_loading_file):
log.info(f"Gave up waiting for {file_path} to become ready")
return
log.info(f'Attempting to OCRmyPDF to: {output_path}')
log.debug(
f'OCRmyPDF input_file={file_path} output_file={output_path} '
f'kwargs: {ocrmypdf_kwargs}'
)
exit_code = ocrmypdf.ocr(
input_file=file_path,
output_file=output_path,
deskew=DESKEW,
**OCR_JSON_SETTINGS,
**ocrmypdf_kwargs,
)
if exit_code == 0:
if ON_SUCCESS_DELETE:
if on_success_delete:
log.info(f'OCR is done. Deleting: {file_path}')
file_path.unlink()
elif ON_SUCCESS_ARCHIVE:
log.info(f'OCR is done. Archiving {file_path.name} to {ARCHIVE_DIRECTORY}')
shutil.move(file_path, f'{ARCHIVE_DIRECTORY}/{file_path.name}')
elif on_success_archive:
log.info(f'OCR is done. Archiving {file_path.name} to {archive_dir}')
shutil.move(file_path, f'{archive_dir}/{file_path.name}')
else:
log.info('OCR is done')
else:
@@ -113,61 +131,204 @@ def execute_ocrmypdf(file_path):
class HandleObserverEvent(PatternMatchingEventHandler):
def __init__(
self,
patterns=None,
ignore_patterns=None,
ignore_directories=False,
case_sensitive=False,
settings={},
):
super().__init__(
patterns=patterns,
ignore_patterns=ignore_patterns,
ignore_directories=ignore_directories,
case_sensitive=case_sensitive,
)
self._settings = settings
def on_any_event(self, event):
if event.event_type in ['created']:
execute_ocrmypdf(event.src_path)
execute_ocrmypdf(file_path=Path(event.src_path), **self._settings)
def main():
@app.command()
def main(
input_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_INPUT_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
resolve_path=True,
),
] = '/input',
output_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_OUTPUT_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
] = '/output',
archive_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_ARCHIVE_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
] = '/processed',
output_dir_year_month: Annotated[
bool,
typer.Option(
envvar='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
help='Create a subdirectory in the output directory for each year and month',
),
] = False,
on_success_delete: Annotated[
bool,
typer.Option(
envvar='OCR_ON_SUCCESS_DELETE',
help='Delete the input file after successful OCR',
),
] = False,
on_success_archive: Annotated[
bool,
typer.Option(
envvar='OCR_ON_SUCCESS_ARCHIVE',
help='Archive the input file after successful OCR',
),
] = False,
deskew: Annotated[
bool,
typer.Option(
envvar='OCR_DESKEW',
help='Deskew the input file before OCR',
),
] = False,
ocr_json_settings: Annotated[
str,
typer.Option(
envvar='OCR_JSON_SETTINGS',
help='JSON settings to pass to OCRmyPDF (JSON string or file path)',
),
] = None,
poll_new_file_seconds: Annotated[
int,
typer.Option(
envvar='OCR_POLL_NEW_FILE_SECONDS',
help='Seconds to wait before polling a new file',
min=0,
),
] = 1,
use_polling: Annotated[
bool,
typer.Option(
envvar='OCR_USE_POLLING',
help='Use polling instead of filesystem events',
),
] = False,
retries_loading_file: Annotated[
int,
typer.Option(
envvar='OCR_RETRIES_LOADING_FILE',
help='Number of times to retry loading a file before giving up',
min=0,
),
] = 5,
loglevel: Annotated[
LoggingLevelEnum,
typer.Option(
envvar='OCR_LOGLEVEL',
help='Logging level',
),
] = LoggingLevelEnum.INFO,
patterns: Annotated[
str,
typer.Option(
envvar='OCR_PATTERNS',
help='File patterns to watch',
),
] = '*.pdf,*.PDF',
):
ocrmypdf.configure_logging(
verbosity=(
ocrmypdf.Verbosity.default
if LOGLEVEL != 'DEBUG'
if loglevel != LoggingLevelEnum.DEBUG
else ocrmypdf.Verbosity.debug
),
manage_root_logger=True,
)
log.setLevel(LOGLEVEL)
log.setLevel(loglevel.value)
log.info(
f"Starting OCRmyPDF watcher with config:\n"
f"Input Directory: {INPUT_DIRECTORY}\n"
f"Output Directory: {OUTPUT_DIRECTORY}\n"
f"Output Directory Year & Month: {OUTPUT_DIRECTORY_YEAR_MONTH}\n"
f"Archive Directory: {ARCHIVE_DIRECTORY}"
f"Input Directory: {input_dir}\n"
f"Output Directory: {output_dir}\n"
f"Output Directory Year & Month: {output_dir_year_month}\n"
f"Archive Directory: {archive_dir}"
)
log.debug(
f"INPUT_DIRECTORY: {INPUT_DIRECTORY}\n"
f"OUTPUT_DIRECTORY: {OUTPUT_DIRECTORY}\n"
f"OUTPUT_DIRECTORY_YEAR_MONTH: {OUTPUT_DIRECTORY_YEAR_MONTH}\n"
f"ARCHIVE_DIRECTORY: {ARCHIVE_DIRECTORY}\n"
f"ON_SUCCESS_DELETE: {ON_SUCCESS_DELETE}\n"
f"ON_SUCCESS_ARCHIVE: {ON_SUCCESS_ARCHIVE}\n"
f"DESKEW: {DESKEW}\n"
f"ARGS: {OCR_JSON_SETTINGS}\n"
f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n"
f"RETRIES_LOADING_FILE: {RETRIES_LOADING_FILE}\n"
f"USE_POLLING: {USE_POLLING}\n"
f"LOGLEVEL: {LOGLEVEL}"
f"INPUT_DIRECTORY: {input_dir}\n"
f"OUTPUT_DIRECTORY: {output_dir}\n"
f"OUTPUT_DIRECTORY_YEAR_MONTH: {output_dir_year_month}\n"
f"ARCHIVE_DIRECTORY: {archive_dir}\n"
f"ON_SUCCESS_DELETE: {on_success_delete}\n"
f"ON_SUCCESS_ARCHIVE: {on_success_archive}\n"
f"DESKEW: {deskew}\n"
f"ARGS: {ocr_json_settings}\n"
f"POLL_NEW_FILE_SECONDS: {poll_new_file_seconds}\n"
f"RETRIES_LOADING_FILE: {retries_loading_file}\n"
f"USE_POLLING: {use_polling}\n"
f"LOGLEVEL: {loglevel.value}"
)
if 'input_file' in OCR_JSON_SETTINGS or 'output_file' in OCR_JSON_SETTINGS:
log.error('OCR_JSON_SETTINGS should not specify input file or output file')
if ocr_json_settings and Path(ocr_json_settings).exists():
json_settings = json.loads(Path(ocr_json_settings).read_text())
else:
json_settings = json.loads(ocr_json_settings or '{}')
if 'input_file' in json_settings or 'output_file' in json_settings:
log.error(
'OCR_JSON_SETTINGS (--ocr-json-settings) may not specify input/output file'
)
sys.exit(1)
handler = HandleObserverEvent(patterns=PATTERNS)
if USE_POLLING:
handler = HandleObserverEvent(
patterns=patterns.split(','),
settings={
'archive_dir': archive_dir,
'output_dir': output_dir,
'ocrmypdf_kwargs': json_settings | {'deskew': deskew},
'on_success_delete': on_success_delete,
'on_success_archive': on_success_archive,
'poll_new_file_seconds': poll_new_file_seconds,
'retries_loading_file': retries_loading_file,
'output_dir_year_month': output_dir_year_month,
},
)
if use_polling:
observer = PollingObserver()
else:
observer = Observer()
observer.schedule(handler, INPUT_DIRECTORY, recursive=True)
observer.schedule(handler, input_dir, recursive=True)
observer.start()
typer.echo(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.")
try:
while True:
time.sleep(1)
time.sleep(30)
except KeyboardInterrupt:
observer.stop()
observer.join()
if __name__ == "__main__":
main()
app()
+12 -12
View File
@@ -10,18 +10,16 @@ dynamic = ["version"]
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
readme = "README.md"
license = { text = "MPL-2.0" }
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"Pillow>=10.0.1",
"deprecation>=2.1.0",
"img2pdf>=0.4.4",
"img2pdf>=0.5",
"packaging>=20",
"pdfminer.six>=20220319",
"pikepdf>=8",
"pluggy>=0.13.0",
"reportlab>=3.6.8",
"pikepdf>=8.10.1",
"pluggy>=1",
"rich>=13",
"typing-extensions>=4;python_version<'3.10'",
]
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
classifiers = [
@@ -50,7 +48,7 @@ Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
[project.optional-dependencies]
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
extended_test = ["PyMuPDF==1.19.1"]
extended_test = ["PyMuPDF>=1.19.1"]
test = [
"coverage[toml]>=6.2",
"hypothesis>=6.36.0",
@@ -58,10 +56,11 @@ test = [
"pytest-cov>=3.0.0",
"pytest-xdist>=2.5.0",
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
"reportlab>=3.6.8",
"types-Pillow",
"types-humanfriendly",
]
watcher = ["watchdog>=1.0.2"]
watcher = ["watchdog>=1.0.2", "typer[all]", "python-dotenv"]
webservice = ["Flask>=2.0.1"]
[project.scripts]
@@ -77,11 +76,11 @@ namespaces = false
[tool.setuptools_scm]
[tool.distutils.bdist_wheel]
python-tag = "py39"
python-tag = "py310"
[tool.black]
line-length = 88
target-version = ["py39", "py310", "py311"]
target-version = ["py310", "py311", "py312"]
skip-string-normalization = true
include = '\.pyi?$'
exclude = '''
@@ -105,7 +104,8 @@ exclude = '''
[tool.coverage.run]
branch = true
parallel = true
concurrency = ["multiprocessing"]
concurrency = ["multiprocessing", "thread"]
sigterm = true
[tool.coverage.paths]
source = ["src/ocrmypdf"]
@@ -157,7 +157,7 @@ select = [
"I001", # isort
"UP", # pyupgrade
]
target-version = "py39"
target-version = "py310"
[tool.ruff.isort]
known-first-party = ["ocrmypdf"]
+10 -1
View File
@@ -10,8 +10,15 @@ from pluggy import HookimplMarker as _HookimplMarker
from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
from ocrmypdf._concurrent import Executor
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._pipelines._common import (
configure_debug_logging,
)
from ocrmypdf._version import PROGRAM_NAME, __version__
from ocrmypdf.api import Verbosity, configure_logging, ocr
from ocrmypdf.api import (
Verbosity,
configure_logging,
ocr,
)
from ocrmypdf.exceptions import (
BadArgsError,
DpiError,
@@ -30,9 +37,11 @@ from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
hookimpl = _HookimplMarker('ocrmypdf')
__all__ = [
'__version__',
'BadArgsError',
'configure_debug_logging',
'configure_logging',
'DpiError',
'EncryptedPdfError',
+6 -2
View File
@@ -7,14 +7,15 @@
from __future__ import annotations
import logging
import multiprocessing
import os
import signal
import sys
from contextlib import suppress
from ocrmypdf import __version__
from ocrmypdf._pipelines.ocr import run_pipeline_cli
from ocrmypdf._plugin_manager import get_parser_options_plugins
from ocrmypdf._sync import run_pipeline
from ocrmypdf._validation import check_options
from ocrmypdf.api import Verbosity, configure_logging
from ocrmypdf.exceptions import (
@@ -71,9 +72,12 @@ def run(args=None):
with suppress(AttributeError, OSError):
signal.signal(signal.SIGBUS, sigbus)
result = run_pipeline(options=options, plugin_manager=plugin_manager)
result = run_pipeline_cli(options=options, plugin_manager=plugin_manager)
return result
if __name__ == '__main__':
multiprocessing.freeze_support()
if os.name == 'posix':
multiprocessing.set_start_method('forkserver')
sys.exit(run())
+17 -25
View File
@@ -8,27 +8,19 @@ from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from collections.abc import Iterable
from typing import Callable
from typing import Any, Callable, TypeVar
from ocrmypdf._progressbar import NullProgressBar, ProgressBar
T = TypeVar('T')
def _task_noop(*_args, **_kwargs):
return
class NullProgressBar:
"""Progress bar API that takes no actions."""
def __init__(self, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def update(self, _arg=None):
return
def _task_finished_noop(_result: Any, pbar: ProgressBar):
pbar.update()
class Executor(ABC):
@@ -46,11 +38,11 @@ class Executor(ABC):
*,
use_threads: bool,
max_workers: int,
tqdm_kwargs: dict,
progress_kwargs: dict,
worker_initializer: Callable | None = None,
task: Callable | None = None,
task: Callable[..., T] | None = None,
task_arguments: Iterable | None = None,
task_finished: Callable | None = None,
task_finished: Callable[[T, ProgressBar], None] | None = None,
) -> None:
"""Set up parallel execution and progress reporting.
@@ -60,7 +52,7 @@ class Executor(ABC):
heavily, and parallelizing it with threads is not expected to be
performant).
max_workers: The maximum number of workers that should be run.
tqdm_kwargs: Arguments to set up the progress bar.
progress_kwargs: Arguments to set up the progress bar.
worker_initializer: Called when a worker is initialized, in the worker's
execution context. If the child workers are processes, it must be
possible to marshall/pickle the worker initializer.
@@ -78,7 +70,7 @@ class Executor(ABC):
if not worker_initializer:
worker_initializer = _task_noop
if not task_finished:
task_finished = _task_noop
task_finished = _task_finished_noop
if not task:
task = _task_noop
@@ -86,7 +78,7 @@ class Executor(ABC):
self._execute(
use_threads=use_threads,
max_workers=max_workers,
tqdm_kwargs=tqdm_kwargs,
progress_kwargs=progress_kwargs,
worker_initializer=worker_initializer,
task=task,
task_arguments=task_arguments,
@@ -99,7 +91,7 @@ class Executor(ABC):
*,
use_threads: bool,
max_workers: int,
tqdm_kwargs: dict,
progress_kwargs: dict,
worker_initializer: Callable,
task: Callable,
task_arguments: Iterable,
@@ -125,13 +117,13 @@ class SerialExecutor(Executor):
*,
use_threads: bool,
max_workers: int,
tqdm_kwargs: dict,
progress_kwargs: dict,
worker_initializer: Callable,
task: Callable,
task_arguments: Iterable,
task_finished: Callable,
): # pylint: disable=unused-argument
with self.pbar_class(**tqdm_kwargs) as pbar:
with self.pbar_class(**progress_kwargs) as pbar:
for args in task_arguments:
result = task(args)
result = task(*args)
task_finished(result, pbar)
+36 -14
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import re
from collections import deque
from io import BytesIO
from os import fspath
from pathlib import Path
@@ -16,7 +17,7 @@ from subprocess import PIPE, CalledProcessError
from packaging.version import Version
from PIL import Image, UnidentifiedImageError
from ocrmypdf.exceptions import SubprocessOutputError
from ocrmypdf.exceptions import ColorConversionNeededError, SubprocessOutputError
from ocrmypdf.helpers import Resolution
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
@@ -29,38 +30,44 @@ COLOR_CONVERSION_STRATEGIES = frozenset(
'UseDeviceIndependentColor',
]
)
# Ghostscript executable - gswin32c is not supported
GS = 'gswin64c' if os.name == 'nt' else 'gs'
log = logging.getLogger(__name__)
class DuplicateFilter(logging.Filter):
"""Filter out duplicate log messages."""
"""Filter out duplicate log messages.
def __init__(self, logger: logging.Logger):
self.last: logging.LogRecord | None = None
self.count = 0
A context window of default 5 messages is used to determine if a message is a
duplicate. This is because some Ghostscript messages are word wrapped.
"""
def __init__(self, logger: logging.Logger, context_window=5):
self.window: deque[str] = deque([], maxlen=context_window)
self.logger = logger
self.levelno = logging.DEBUG
self.count = 0
def filter(self, record):
if self.last and record.msg == self.last.msg:
if record.msg in self.window:
self.count += 1
self.levelno = record.levelno
return False
else:
if self.count >= 1:
rep_msg = f"(previous message repeated {self.count} times)"
rep_msg = f"(suppressed {self.count} repeated lines)"
self.count = 0 # Avoid infinite recursion
self.logger.log(self.last.levelno, rep_msg)
self.last = record
self.logger.log(self.levelno, rep_msg)
self.window.clear()
self.window.append(record.msg)
return True
log.addFilter(DuplicateFilter(log))
# Ghostscript executable - gswin32c is not supported
GS = 'gswin64c' if os.name == 'nt' else 'gs'
def version() -> Version:
return Version(get_version(GS))
@@ -70,6 +77,20 @@ def _gs_error_reported(stream) -> bool:
return bool(match)
def _gs_devicen_reported(stream) -> bool:
"""Did Ghostscript warn about a DeviceN with inappropriate alternate?
If so, we need the user to select a color conversion, or the resulting PDF will
not present correctly in some PDF viewers.
"""
match = re.search(
r'DeviceN.*inappropriate alternate',
stream,
flags=re.IGNORECASE | re.MULTILINE,
)
return bool(match)
def rasterize_pdf(
input_file: os.PathLike,
output_file: os.PathLike,
@@ -243,7 +264,6 @@ def generate_pdfa(
]
)
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
try:
with Path(output_file).open('wb') as output:
p = run_polling_stderr(
@@ -272,3 +292,5 @@ def generate_pdfa(
# the **** pattern to split the stderr into parts.
for part in stderr.split('****'):
log.error(part)
if _gs_devicen_reported(stderr):
raise ColorConversionNeededError()
+2 -14
View File
@@ -25,7 +25,7 @@ def available():
return True
def convert_group(*, cwd, infiles, out_prefix, threshold):
def convert_group(cwd, infiles, out_prefix, threshold):
args = [
'jbig2',
'-b',
@@ -43,21 +43,9 @@ def convert_group(*, cwd, infiles, out_prefix, threshold):
return proc
def convert_group_mp(args):
return convert_group(
cwd=args[0], infiles=args[1], out_prefix=args[2], threshold=args[3]
)
def convert_single(*, cwd, infile, outfile, threshold):
def convert_single(cwd, infile, outfile, threshold):
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
with open(outfile, 'wb') as fstdout:
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
proc.check_returncode()
return proc
def convert_single_mp(args):
return convert_single(
cwd=args[0], infile=args[1], outfile=args[2], threshold=args[3]
)
+9 -21
View File
@@ -5,13 +5,10 @@
from __future__ import annotations
from contextlib import contextmanager
from io import BytesIO
from pathlib import Path
from subprocess import PIPE
from packaging.version import Version
from PIL import Image
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
@@ -29,21 +26,16 @@ def available():
return True
@contextmanager
def input_as_png(input_file: Path):
if not input_file.name.endswith('.png'):
with Image.open(input_file) as im:
bio = BytesIO()
im.save(bio, format='png')
bio.seek(0)
yield bio
else:
with open(input_file, 'rb') as f:
yield f
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
with input_as_png(input_file) as input_stream:
"""Quantize a PNG image using pngquant.
Args:
input_file: Input PNG image
output_file: Output PNG image
quality_min: Minimum quality to use
quality_max: Maximum quality to use
"""
with open(input_file, 'rb') as input_stream:
args = [
'pngquant',
'--force',
@@ -58,7 +50,3 @@ def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max:
if result.returncode == 0:
# input_file could be the same as output_file, so we defer the write
output_file.write_bytes(result.stdout)
def quantize_mp(args):
return quantize(*args)
+5 -27
View File
@@ -7,13 +7,13 @@ from __future__ import annotations
import logging
import re
from contextlib import suppress
from math import pi
from os import fspath
from pathlib import Path
from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
from packaging.version import Version
from PIL import Image
from ocrmypdf.exceptions import (
MissingDependencyError,
@@ -26,25 +26,6 @@ from ocrmypdf.subprocess import get_version, run
log = logging.getLogger(__name__)
HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
<meta name='ocr-system' content='tesseract 4.1.1' />
<meta name='ocr-capabilities'
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1'
title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'>
</div>
</body>
</html>
"""
TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
'auto': 0,
'otsu': 0,
@@ -284,14 +265,11 @@ def page_timedout(timeout: float) -> None:
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
"""Produce a .hocr file that reports no text detected.
"""Produce an empty .hocr file.
Ensures page is the same size as the input image.
"""
with Image.open(image) as im:
w, h = im.size
output_hocr.write_text(HOCR_TEMPLATE.format(w, h), encoding='utf-8')
output_hocr.write_text('', encoding='utf-8')
output_text.write_text('[skipped page]', encoding='utf-8')
@@ -350,7 +328,7 @@ def generate_hocr(
tesseract_log_output(stdout)
# The sidecar text file will get the suffix .txt; rename it to
# whatever caller wants it named
if prefix.with_suffix('.txt').exists():
with suppress(FileNotFoundError):
prefix.with_suffix('.txt').replace(output_text)
@@ -406,7 +384,7 @@ def generate_pdf(
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
stdout = p.stdout
if prefix.with_suffix('.txt').exists():
with suppress(FileNotFoundError):
prefix.with_suffix('.txt').replace(output_text)
except TimeoutExpired:
page_timedout(timeout)
+9 -61
View File
@@ -14,6 +14,7 @@ from contextlib import contextmanager
from decimal import Decimal
from pathlib import Path
from subprocess import PIPE, STDOUT
from tempfile import TemporaryDirectory
from typing import Union
from packaging.version import Version
@@ -26,27 +27,6 @@ from ocrmypdf.subprocess import get_version, run
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
if sys.version_info >= (3, 10):
from tempfile import TemporaryDirectory
else:
from tempfile import TemporaryDirectory as _TemporaryDirectory
class TemporaryDirectory(_TemporaryDirectory):
"""Shim to consume ignore_cleanup_errors kwarg on Python 3.9 and older.
The argument is consumed without action. If users are getting errors related
to temporary file cleanup, they should upgrade to Python 3.10 which properly
cleans up temporary directories on Windows.
See: https://github.com/python/cpython/pull/24793
"""
def __init__(self, ignore_cleanup_errors=False, **kwargs):
super().__init__(**kwargs)
del _TemporaryDirectory
UNPAPER_IMAGE_PIXEL_LIMIT = 256 * 1024 * 1024
DecFloat = Union[Decimal, float]
@@ -73,52 +53,20 @@ def version() -> Version:
return Version(get_version('unpaper'))
SUPPORTED_MODES = {'1', 'L', 'RGB'}
def _convert_image(im: Image.Image) -> tuple[Image.Image, bool]:
im_modified = False
if im.mode not in SUPPORTED_MODES:
log.info("Converting image to other colorspace")
try:
if im.mode == 'P' and len(im.getcolors()) == 2:
im = im.convert(mode='1')
else:
im = im.convert(mode='RGB')
except OSError as e:
raise MissingDependencyError(
"Could not convert image with type " + im.mode
) from e
else:
im_modified = True
if im.mode not in SUPPORTED_MODES:
raise MissingDependencyError(
"Failed to convert image to a supported format."
) from None
return im, im_modified
@contextmanager
def _setup_unpaper_io(input_file: Path) -> Iterator[tuple[Path, Path, Path]]:
with Image.open(input_file) as im:
if im.width * im.height >= UNPAPER_IMAGE_PIXEL_LIMIT:
raise UnpaperImageTooLargeError(w=im.width, h=im.height)
im, im_modified = _convert_image(im)
with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
tmppath = Path(tmpdir)
if im_modified or input_file.suffix != '.png':
input_png = tmppath / 'input.png'
im.save(input_png, format='PNG')
else:
# No changes, PNG input, just use the file we already have
input_png = input_file
# unpaper can write .png too, but it seems to write them slowly
# adds a few seconds to test suite - so just use pnm
output_pnm = tmppath / 'output.pnm'
yield input_png, output_pnm, tmppath
with TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
tmppath = Path(tmpdir)
# No changes, PNG input, just use the file we already have
input_png = input_file
# unpaper can write .png too, but it seems to write them slowly
# adds a few seconds to test suite - so just use pnm
output_pnm = tmppath / 'output.pnm'
yield input_png, output_pnm, tmppath
def run_unpaper(
+68 -47
View File
@@ -7,32 +7,46 @@ from __future__ import annotations
import logging
from contextlib import suppress
from enum import Enum
from pathlib import Path
from pikepdf import (
Dictionary,
Matrix,
Name,
Object,
Operator,
Page,
Pdf,
PdfError,
PdfMatrix,
Stream,
parse_content_stream,
unparse_content_stream,
)
from ocrmypdf._jobcontext import PdfContext
class RenderMode(Enum):
ON_TOP = 0
UNDERNEATH = 1
log = logging.getLogger(__name__)
MAX_REPLACE_PAGES = 100
def _ensure_dictionary(obj, name):
def _ensure_dictionary(obj: Dictionary | Stream, name: Name):
if name not in obj:
obj[name] = Dictionary({})
return obj[name]
def _update_resources(*, obj, font, font_key, procset):
def _update_resources(
*,
obj: Dictionary | Stream,
font: Dictionary | None,
font_key: Name | None,
):
"""Update this obj's fonts with a reference to the Glyphless font.
obj can be a page or Form XObject.
@@ -42,13 +56,8 @@ def _update_resources(*, obj, font, font_key, procset):
if font_key is not None and font_key not in fonts:
fonts[font_key] = font
# Reassign /ProcSet to one that just lists everything - ProcSet is
# obsolete and doesn't matter but recommended for old viewer support
if procset:
resources['/ProcSet'] = procset
def strip_invisible_text(pdf, page):
def strip_invisible_text(pdf: Pdf, page: Page):
stream = []
in_text_obj = False
render_mode = 0
@@ -79,22 +88,20 @@ def strip_invisible_text(pdf, page):
class OcrGrafter:
"""Manages grafting text-only PDFs onto regular PDFs."""
def __init__(self, context):
def __init__(self, context: PdfContext):
self.context = context
self.path_base = context.origin
self.pdf_base = Pdf.open(self.path_base)
self.font, self.font_key = None, None
self.font: Dictionary | None = None
self.font_key: Name | None = None
self.pdfinfo = context.pdfinfo
self.output_file = context.get_path('graft_layers.pdf')
self.procset = self.pdf_base.make_indirect(
Object.parse(b'[ /PDF /Text /ImageB /ImageC /ImageI ]')
)
self.emplacements = 1
self.interim_count = 0
self.render_mode = RenderMode.UNDERNEATH
def graft_page(
self,
@@ -119,7 +126,9 @@ class OcrGrafter:
foreign_image_page = pdf_image.pages[0]
self.pdf_base.pages.append(foreign_image_page)
local_image_page = self.pdf_base.pages[-1]
self.pdf_base.pages[pageno].emplace(local_image_page)
self.pdf_base.pages[pageno].emplace(
local_image_page, retain=(Name.Parent,)
)
del self.pdf_base.pages[-1]
emplaced_page = True
@@ -135,6 +144,8 @@ class OcrGrafter:
)
if textpdf and self.font:
if self.font_key is None:
raise ValueError("Font key is not set")
# Graft the text layer onto this page, whether new or old, possibly
# rotating the text layer by the amount is misaligned.
strip_old = self.context.options.redo_ocr
@@ -144,7 +155,6 @@ class OcrGrafter:
font=self.font,
font_key=self.font_key,
text_rotation=text_misaligned,
procset=self.procset,
strip_old_text=strip_old,
)
@@ -159,7 +169,7 @@ class OcrGrafter:
if self.emplacements % MAX_REPLACE_PAGES == 0:
self.save_and_reload()
def save_and_reload(self):
def save_and_reload(self) -> None:
"""Save and reload the Pdf.
This will keep a lid on our memory usage for very large files. Attach
@@ -167,9 +177,7 @@ class OcrGrafter:
back.
"""
page0 = self.pdf_base.pages[0]
_update_resources(
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
)
_update_resources(obj=page0.obj, font=self.font, font_key=self.font_key)
# We cannot read and write the same file, that will corrupt it
# but we don't to keep more copies than we need to. Delete intermediates.
@@ -188,7 +196,6 @@ class OcrGrafter:
self.pdf_base.close()
self.pdf_base = Pdf.open(next_file)
self.procset = self.pdf_base.pages[0].Resources.ProcSet
self.font, self.font_key = None, None # Ensure we reacquire this information
self.interim_count += 1
@@ -197,24 +204,32 @@ class OcrGrafter:
self.pdf_base.close()
return self.output_file
def _find_font(self, text):
def _find_font(self, text: Path) -> tuple[Dictionary | None, Name | None]:
"""Copy a font from the filename text into pdf_base."""
font, font_key = None, None
possible_font_names = ('/f-0-0', '/F1')
try:
with Pdf.open(text) as pdf_text:
try:
pdf_text_fonts = pdf_text.pages[0].Resources.get('/Font', {})
pdf_text_fonts = pdf_text.pages[0].Resources.get(
Name.Font, Dictionary()
)
except (AttributeError, IndexError, KeyError):
return None, None
if not isinstance(pdf_text_fonts, Dictionary):
log.warning("Page fonts are not stored in a dictionary")
return None, None
pdf_text_font = None
for f in possible_font_names:
pdf_text_font = pdf_text_fonts.get(f, None)
if pdf_text_font is not None:
font_key = f
font_key = Name(f)
break
if pdf_text_font:
font = self.pdf_base.copy_foreign(pdf_text_font)
if not isinstance(font, Dictionary):
log.warning("Font is not a dictionary")
font, font_key = None, None
return font, font_key
except (FileNotFoundError, PdfError):
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
@@ -225,9 +240,8 @@ class OcrGrafter:
*,
page_num: int,
textpdf: Path,
font: Object,
font_key: Object,
procset: Object,
font: Dictionary,
font_key: Name,
text_rotation: int,
strip_old_text: bool,
):
@@ -248,19 +262,19 @@ class OcrGrafter:
# content may have a rotation applied. Wrap the text stream with a rotation
# so it will be oriented the same way as the rest of the page content.
# (Previous versions OCRmyPDF rotated the content layer to match the text.)
mediabox = [float(pdf_text.pages[0].MediaBox[v]) for v in range(4)]
mediabox = pdf_text.pages[0].mediabox
wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
mediabox = [float(base_page.MediaBox[v]) for v in range(4)]
mediabox = base_page.mediabox
wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
translate = PdfMatrix().translated(-wt / 2, -ht / 2)
untranslate = PdfMatrix().translated(wp / 2, hp / 2)
corner = PdfMatrix().translated(mediabox[0], mediabox[1])
translate = Matrix().translated(-wt / 2, -ht / 2)
untranslate = Matrix().translated(wp / 2, hp / 2)
corner = Matrix().translated(mediabox[0], mediabox[1])
# -rotation because the input is a clockwise angle and this formula
# uses CCW
text_rotation = -text_rotation % 360
rotate = PdfMatrix().rotated(text_rotation)
rotate = Matrix().rotated(text_rotation)
# Because of rounding of DPI, we might get a text layer that is not
# identically sized to the target page. Scale to adjust. Normally this
@@ -271,14 +285,16 @@ class OcrGrafter:
scale_y = hp / ht
# log.debug('%r', scale_x, scale_y)
scale = PdfMatrix().scaled(scale_x, scale_y)
scale = Matrix().scaled(scale_x, scale_y)
# Translate the text so it is centered at (0, 0), rotate it there, adjust
# for a size different between initial and text PDF, then untranslate, and
# finally move the lower left corner to match the mediabox
ctm = translate @ rotate @ scale @ untranslate @ corner
# finally move the lower left corner to match the mediabox. All transforms
# must be premultiplied so they are applied in reverse order here.
ctm = corner @ untranslate @ scale @ rotate @ translate
log.debug("Grafting with ctm %r", ctm)
base_resources = _ensure_dictionary(base_page, Name.Resources)
base_resources = _ensure_dictionary(base_page.obj, Name.Resources)
base_xobjs = _ensure_dictionary(base_resources, Name.XObject)
text_xobj_name = Name.random(prefix="OCR-")
xobj = self.pdf_base.make_stream(pdf_text_contents)
@@ -287,9 +303,7 @@ class OcrGrafter:
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = mediabox
_update_resources(
obj=xobj, font=font, font_key=font_key, procset=[Name.PDF]
)
_update_resources(obj=xobj, font=font, font_key=font_key)
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
@@ -298,9 +312,16 @@ class OcrGrafter:
if strip_old_text:
strip_invisible_text(self.pdf_base, base_page)
base_page.contents_add(new_text_layer, prepend=True)
_update_resources(
obj=base_page, font=font, font_key=font_key, procset=procset
base_page.contents_coalesce()
if self.render_mode == RenderMode.ON_TOP:
# Add q/Q to ensure content we append is drawn correctly
# Strictly speaking this needs to trace the whole q/Q stack in case
# stack is not balanced.
original = base_page.Contents.read_bytes()
base_page.Contents.write(b'q\n' + original + b'\nQ\n')
base_page.contents_add(
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
)
base_page.contents_coalesce()
_update_resources(obj=base_page.obj, font=font, font_key=font_key)
+6 -9
View File
@@ -6,8 +6,6 @@
from __future__ import annotations
import os
import shutil
import sys
from argparse import Namespace
from collections.abc import Iterator
from copy import copy
@@ -55,6 +53,12 @@ class PdfContext:
for n in range(npages):
yield PageContext(self, n)
def get_page_context_args(self) -> Iterator[tuple[PageContext]]:
"""Get all ``PageContext`` for this PDF packaged in tuple for args-splatting."""
npages = len(self.pdfinfo)
for n in range(npages):
yield (PageContext(self, n),)
class PageContext:
"""Holds our context for a page.
@@ -94,10 +98,3 @@ class PageContext:
if not isinstance(state['options'].output_file, (str, bytes, os.PathLike)):
state['options'].output_file = 'stream'
return state
def cleanup_working_files(work_folder: Path, options: Namespace):
if options.keep_temporary_files:
print(f"Temporary working files retained at:\n{work_folder}", file=sys.stderr)
else:
shutil.rmtree(work_folder, ignore_errors=True)
-62
View File
@@ -9,15 +9,6 @@ import logging
from rich.console import Console
from rich.logging import RichHandler
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
)
from rich.table import Column
class PageNumberFilter(logging.Filter):
@@ -37,56 +28,3 @@ class RichLoggingHandler(RichHandler):
super().__init__(
console=console, show_level=False, show_time=False, markup=True, **kwargs
)
class RichTqdmProgressAdapter:
"""Adapt tqdm API to rich progress bar."""
def __init__(
self,
*,
console: Console,
desc: str,
total: float | None = None,
unit: str | None = None,
unit_scale: float | None = 1.0,
disable: bool = False,
**kwargs,
):
self.progress = Progress(
TextColumn(
"[progress.description]{task.description}",
table_column=Column(min_width=20),
),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
console=console,
auto_refresh=True,
redirect_stderr=True,
redirect_stdout=False,
disable=disable,
**kwargs,
)
self.unit_scale = unit_scale
self.progress_bar = self.progress.add_task(
desc,
total=total * self.unit_scale
if total is not None and self.unit_scale is not None
else None,
unit=unit,
)
def __enter__(self):
self.progress.start()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.progress.refresh()
self.progress.stop()
return False
def update(self, value=None):
advance = self.unit_scale if value is None else value
self.progress.update(self.progress_bar, advance=advance)
+184
View File
@@ -0,0 +1,184 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""OCRmyPDF page processing pipeline functions."""
from __future__ import annotations
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pikepdf import Dictionary, Name, Pdf
from pikepdf import __version__ as PIKEPDF_VERSION
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._version import PROGRAM_NAME
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
from ocrmypdf.languages import iso_639_2_from_3
log = logging.getLogger(__name__)
def get_docinfo(base_pdf: Pdf, context: PdfContext) -> dict[str, str]:
"""Read the document info and store it in a dictionary."""
options = context.options
def from_document_info(key):
try:
s = base_pdf.docinfo[key]
return str(s)
except (KeyError, TypeError):
return ''
pdfmark = {
k: from_document_info(k)
for k in ('/Title', '/Author', '/Keywords', '/Subject', '/CreationDate')
}
if options.title:
pdfmark['/Title'] = options.title
if options.author:
pdfmark['/Author'] = options.author
if options.keywords:
pdfmark['/Keywords'] = options.keywords
if options.subject:
pdfmark['/Subject'] = options.subject
creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options)
pdfmark['/Creator'] = f'{PROGRAM_NAME} {OCRMYPF_VERSION} / {creator_tag}'
pdfmark['/Producer'] = f'pikepdf {PIKEPDF_VERSION}'
pdfmark['/ModDate'] = encode_pdf_date(datetime.now(timezone.utc))
return pdfmark
def report_on_metadata(options, missing):
if not missing:
return
if options.output_type.startswith('pdfa'):
log.warning(
"Some input metadata could not be copied because it is not "
"permitted in PDF/A. You may wish to examine the output "
"PDF's XMP metadata."
)
log.debug("The following metadata fields were not copied: %r", missing)
else:
log.error(
"Some input metadata could not be copied."
"You may wish to examine the output PDF's XMP metadata."
)
log.info("The following metadata fields were not copied: %r", missing)
def repair_docinfo_nuls(pdf):
"""If the DocumentInfo block contains NUL characters, remove them.
If the DocumentInfo block is malformed, log an error and continue.
"""
modified = False
try:
if not isinstance(pdf.docinfo, Dictionary):
raise TypeError("DocumentInfo is not a dictionary")
for k, v in pdf.docinfo.items():
if isinstance(v, str) and b'\x00' in bytes(v):
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
modified = True
except TypeError:
# TypeError can also be raised if dictionary items are unexpected types
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
return modified
def should_linearize(working_file: Path, context: PdfContext) -> bool:
"""Determine whether the PDF should be linearized.
For smaller files, linearization is not worth the effort.
"""
filesize = os.stat(working_file).st_size
if filesize > (context.options.fast_web_view * 1_000_000):
return True
return False
def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata):
# If xmp:CreateDate is missing, set it to the modify date to
# ensure consistency with Ghostscript.
if 'xmp:CreateDate' not in meta_pdf:
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
if meta_pdf.get('dc:title') == 'Untitled':
# Ghostscript likes to set title to Untitled if omitted from input.
# Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1
# and the XMP Spec do not make this recommendation.
if 'dc:title' not in meta_original:
del meta_pdf['dc:title']
def _unset_empty_metadata(meta: PdfMetadata, options):
"""Unset metadata fields that were explicitly set to empty strings.
If the user explicitly specified an empty string for any of the
following, they should be unset and not reported as missing in
the output pdf. Note that some metadata fields use differing names
between PDF/A and PDF.
"""
if options.title == '' and 'dc:title' in meta:
del meta['dc:title'] # PDF/A and PDF
if options.author == '':
if 'dc:creator' in meta:
del meta['dc:creator'] # PDF/A (Not xmp:CreatorTool)
if 'pdf:Author' in meta:
del meta['pdf:Author'] # PDF
if options.subject == '':
if 'dc:description' in meta:
del meta['dc:description'] # PDF/A
if 'dc:subject' in meta:
del meta['dc:subject'] # PDF
if options.keywords == '' and 'pdf:Keywords' in meta:
del meta['pdf:Keywords'] # PDF/A and PDF
def _set_language(pdf: Pdf, languages: list[str]):
"""Set the language of the PDF."""
if Name.Lang in pdf.Root or not languages:
return # Already set or can't change
primary_language_iso639_3 = languages[0]
if not primary_language_iso639_3:
return
iso639_2 = iso_639_2_from_3(primary_language_iso639_3)
if not iso639_2:
return
pdf.Root.Lang = iso639_2
def metadata_fixup(
working_file: Path, context: PdfContext, pdf_save_settings: dict[str, Any]
) -> Path:
"""Fix certain metadata fields after Ghostscript PDF/A conversion.
Also report on metadata in the input file that was not retained during
PDF/A conversion.
"""
output_file = context.get_path('metafix.pdf')
options = context.options
with Pdf.open(context.origin) as original, Pdf.open(working_file) as pdf:
docinfo = get_docinfo(original, context)
with original.open_metadata(
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
) as meta_original, pdf.open_metadata() as meta_pdf:
meta_pdf.load_from_docinfo(
docinfo, delete_missing=False, raise_failure=False
)
_fix_metadata(meta_original, meta_pdf)
_unset_empty_metadata(meta_original, options)
_unset_empty_metadata(meta_pdf, options)
meta_missing = set(meta_original.keys()) - set(meta_pdf.keys())
report_on_metadata(options, meta_missing)
_set_language(pdf, options.languages)
pdf.save(output_file, **pdf_save_settings)
return output_file
+47 -151
View File
@@ -12,31 +12,30 @@ import re
import sys
from collections.abc import Iterable, Iterator, Sequence
from contextlib import suppress
from datetime import datetime, timezone
from pathlib import Path
from shutil import copyfileobj
from shutil import copyfileobj, copystat
from typing import Any, BinaryIO, TypeVar, cast
import img2pdf
import pikepdf
from pikepdf.models.metadata import encode_pdf_date
from PIL import Image, ImageColor, ImageDraw
from ocrmypdf._concurrent import Executor
from ocrmypdf._exec import unpaper
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._version import PROGRAM_NAME
from ocrmypdf._version import __version__ as VERSION
from ocrmypdf._metadata import repair_docinfo_nuls
from ocrmypdf.exceptions import (
DigitalSignatureError,
DpiError,
EncryptedPdfError,
InputFileError,
PriorOcrFoundError,
TaggedPDFError,
UnsupportedImageFormatError,
)
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
from ocrmypdf.hocrtransform import HocrTransform
from ocrmypdf.hocrtransform import DebugRenderOptions, HocrTransform
from ocrmypdf.hocrtransform._font import Courier
from ocrmypdf.pdfa import generate_pdfa_ps
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
from ocrmypdf.pluginspec import OrientationConfidence
@@ -167,6 +166,7 @@ def get_pdfinfo(
detailed_analysis: bool = False,
progbar: bool = False,
max_workers: int | None = None,
use_threads: bool = True,
check_pages=None,
) -> PdfInfo:
"""Get the PDF info."""
@@ -176,6 +176,7 @@ def get_pdfinfo(
detailed_analysis=detailed_analysis,
progbar=progbar,
max_workers=max_workers,
use_threads=use_threads,
check_pages=check_pages,
executor=executor,
)
@@ -218,6 +219,16 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
"form and all filled form fields. The output PDF will be "
"'flattened' and will no longer be fillable."
)
if pdfinfo.is_tagged:
if options.force_ocr or options.skip_text or options.redo_ocr:
log.warning(
"This PDF is marked as a Tagged PDF. This often indicates "
"that the PDF was generated from an office document and does "
"not need OCR. PDF pages processed by OCRmyPDF may not be "
"tagged correctly."
)
else:
raise TaggedPDFError()
context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options)
@@ -508,6 +519,7 @@ def rasterize(
device_idx = at_least('png16m')
if pageinfo.has_vector:
log.debug("Page has vector content, using png16m")
device_idx = at_least('png16m')
device = colorspaces[device_idx]
@@ -644,7 +656,7 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
output_text=hocr_text_out,
options=options,
)
return (hocr_out, hocr_text_out)
return hocr_out, hocr_text_out
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
@@ -724,16 +736,31 @@ def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
"""Render the hOCR page to a PDF."""
options = page_context.options
output_file = page_context.get_path('ocr_hocr.pdf')
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
debug_mode = options.pdf_renderer == 'hocrdebug'
if hocr.stat().st_size == 0:
# If hOCR file is empty (skipped page marker), create an empty PDF file
output_file.touch()
return output_file
hocrtransform = HocrTransform(hocr_filename=hocr, dpi=dpi.x) # square
hocrtransform.to_pdf(
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
debug_kwargs = {}
if options.pdf_renderer == 'hocrdebug':
debug_kwargs = dict(
debug_render_options=DebugRenderOptions(
render_baseline=True,
render_triangle=True,
render_line_bbox=False,
render_word_bbox=True,
render_paragraph_bbox=False,
render_space_bbox=False,
),
font=Courier(),
)
HocrTransform(
hocr_filename=hocr, dpi=dpi.to_scalar(), **debug_kwargs # square
).to_pdf(
out_filename=output_file,
image_filename=None,
show_bounding_boxes=False if not debug_mode else True,
invisible_text=True if not debug_mode else False,
interword_spaces=True,
invisible_text=True if not debug_kwargs else False,
)
return output_file
@@ -756,38 +783,6 @@ def ocr_engine_textonly_pdf(
return (output_pdf, output_text)
def get_docinfo(base_pdf: pikepdf.Pdf, context: PdfContext) -> dict[str, str]:
"""Read the document info and store it in a dictionary."""
options = context.options
def from_document_info(key):
try:
s = base_pdf.docinfo[key]
return str(s)
except (KeyError, TypeError):
return ''
pdfmark = {
k: from_document_info(k)
for k in ('/Title', '/Author', '/Keywords', '/Subject', '/CreationDate')
}
if options.title:
pdfmark['/Title'] = options.title
if options.author:
pdfmark['/Author'] = options.author
if options.keywords:
pdfmark['/Keywords'] = options.keywords
if options.subject:
pdfmark['/Subject'] = options.subject
creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options)
pdfmark['/Creator'] = f'{PROGRAM_NAME} {VERSION} / {creator_tag}'
pdfmark['/Producer'] = f'pikepdf {pikepdf.__version__}'
pdfmark['/ModDate'] = encode_pdf_date(datetime.now(timezone.utc))
return pdfmark
def generate_postscript_stub(context: PdfContext) -> Path:
"""Generates a PostScript file stub for the given PDF context.
@@ -822,7 +817,7 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
# pikepdf can deal with this, but we make the world a better place by
# stamping them out as soon as possible.
with pikepdf.open(input_pdf) as pdf_file:
if _repair_docinfo_nuls(pdf_file):
if repair_docinfo_nuls(pdf_file):
pdf_file.save(fix_docinfo_file)
else:
safe_symlink(input_pdf, fix_docinfo_file)
@@ -845,25 +840,6 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
return output_file
def _repair_docinfo_nuls(pdf):
"""If the DocumentInfo block contains NUL characters, remove them.
If the DocumentInfo block is malformed, log an error and continue.
"""
modified = False
try:
if not isinstance(pdf.docinfo, pikepdf.Dictionary):
raise TypeError("DocumentInfo is not a dictionary")
for k, v in pdf.docinfo.items():
if isinstance(v, str) and b'\x00' in bytes(v):
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
modified = True
except TypeError:
# TypeError can also be raised if dictionary items are unexpected types
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
return modified
def should_linearize(working_file: Path, context: PdfContext) -> bool:
"""Determine whether the PDF should be linearized.
@@ -898,86 +874,6 @@ def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
)
def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
"""Fix certain metadata fields after Ghostscript PDF/A conversion.
Also report on metadata in the input file that was not retained during
PDF/A conversion.
"""
output_file = context.get_path('metafix.pdf')
options = context.options
def report_on_metadata(missing):
if not missing:
return
if options.output_type.startswith('pdfa'):
log.warning(
"Some input metadata could not be copied because it is not "
"permitted in PDF/A. You may wish to examine the output "
"PDF's XMP metadata."
)
log.debug("The following metadata fields were not copied: %r", missing)
else:
log.error(
"Some input metadata could not be copied."
"You may wish to examine the output PDF's XMP metadata."
)
log.info("The following metadata fields were not copied: %r", missing)
with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf:
docinfo = get_docinfo(original, context)
with original.open_metadata(
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
) as meta_original, pdf.open_metadata() as meta_pdf:
meta_pdf.load_from_docinfo(
docinfo, delete_missing=False, raise_failure=False
)
# If xmp:CreateDate is missing, set it to the modify date to
# ensure consistency with Ghostscript.
if 'xmp:CreateDate' not in meta_pdf:
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
if meta_pdf.get('dc:title') == 'Untitled':
# Ghostscript likes to set title to Untitled if omitted from input.
# Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1
# and the XMP Spec do not make this recommendation.
if 'dc:title' not in meta_original:
del meta_pdf['dc:title']
# If the user explicitly specified an empty string for any of the
# following, they should be unset and not reported as missing in
# the output pdf. Note that some metadata fields use differing names
# between PDF-A and PDF.
for meta in [meta_pdf, meta_original]:
if options.title == '' and 'dc:title' in meta:
del meta['dc:title'] # PDF-A and PDF
if options.author == '':
if 'dc:creator' in meta:
del meta['dc:creator'] # PDF-A (Not xmp:CreatorTool)
if 'pdf:Author' in meta:
del meta['pdf:Author'] # PDF
if options.subject == '':
if 'dc:description' in meta:
del meta['dc:description'] # PDF-A
if 'dc:subject' in meta:
del meta['dc:subject'] # PDF
if options.keywords == '' and 'pdf:Keywords' in meta:
del meta['pdf:Keywords'] # PDF-A and PDF
meta_missing = set(meta_original.keys()) - set(meta_pdf.keys())
report_on_metadata(meta_missing)
optimizing = context.plugin_manager.hook.is_optimization_enabled(
context=context
)
pdf.save(
output_file,
**get_pdf_save_settings(options.output_type),
linearize=( # Don't linearize if optimize() will be linearizing too
not optimizing and should_linearize(working_file, context)
),
)
return output_file
def _file_size_ratio(
input_file: Path, output_file: Path
) -> tuple[float | None, float | None]:
@@ -1025,7 +921,7 @@ def optimize_pdf(
def enumerate_compress_ranges(
iterable: Iterable[T],
) -> Iterator[tuple[tuple[int, int], T]]:
) -> Iterator[tuple[tuple[int, int], T | None]]:
"""Enumerate the ranges of non-empty elements in an iterable.
Compresses consecutive ranges of length 1 into single elements.
@@ -1079,14 +975,14 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
def copy_final(
input_file: Path, output_file: str | Path | BinaryIO, _context: PdfContext
input_file: Path, output_file: str | Path | BinaryIO, original_file: Path | None
) -> None:
"""Copy the final temporary file to the output destination.
Args:
input_file (Path): The input file to copy.
input_file (Path): The intermediate input file to copy.
output_file (str | Path | BinaryIO): The output file to copy to.
_context (PdfContext): The PDF context.
original_file: The original file to copy attributes from.
Returns:
None
@@ -1105,5 +1001,5 @@ def copy_final(
# At this point we overwrite the output_file specified by the user
# use copyfileobj because then we use open() to create the file and
# get the appropriate umask, ownership, etc.
with open(output_file, 'wb') as output_stream:
with open(output_file, 'w+b') as output_stream:
copyfileobj(input_stream, output_stream)
+5
View File
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
@@ -1,66 +1,53 @@
# SPDX-FileCopyrightText: 2019-2022 James R. Barlow
# SPDX-FileCopyrightText: 2019 Martin Wind
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Implements the concurrent and page synchronous parts of the pipeline."""
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
import os
import shutil
import sys
import threading
from collections.abc import Sequence
from concurrent.futures.process import BrokenProcessPool
from concurrent.futures.thread import BrokenThreadPool
from functools import partial
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from tempfile import mkdtemp
from typing import NamedTuple, cast
from typing import Callable, NamedTuple, cast
import PIL
from ocrmypdf._concurrent import Executor, setup_executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext, cleanup_working_files
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._logging import PageNumberFilter
from ocrmypdf._metadata import metadata_fixup
from ocrmypdf._pipeline import (
convert_to_pdfa,
copy_final,
create_ocr_image,
create_pdf_page_from_image,
create_visible_page_jpg,
generate_postscript_stub,
get_orientation_correction,
get_pdfinfo,
is_ocr_required,
merge_sidecars,
metadata_fixup,
ocr_engine_hocr,
ocr_engine_textonly_pdf,
get_pdf_save_settings,
optimize_pdf,
preprocess_clean,
preprocess_deskew,
preprocess_remove_background,
rasterize,
rasterize_preview,
render_hocr_page,
should_linearize,
should_visible_page_image_use_jpg,
triage,
validate_pdfinfo_options,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
check_requested_output_file,
create_input_file,
report_output_file_size,
)
from ocrmypdf.exceptions import ExitCode, ExitCodeException
from ocrmypdf.helpers import (
NeverRaise,
available_cpu_count,
check_pdf,
pikepdf_enable_mmap,
@@ -69,33 +56,248 @@ from ocrmypdf.helpers import (
from ocrmypdf.pdfa import file_claims_pdfa
log = logging.getLogger(__name__)
tls = threading.local()
tls.pageno = None
def _set_logging_tls(tls):
"""Inject current page number (when available) into log records."""
old_factory = logging.getLogRecordFactory()
def wrapper(*args, **kwargs):
record = old_factory(*args, **kwargs)
if hasattr(tls, 'pageno'):
record.pageno = tls.pageno
return record
logging.setLogRecordFactory(wrapper)
_set_logging_tls(tls)
def set_thread_pageno(pageno: int | None):
"""Set page number (1-based) that the current thread is processing."""
tls.pageno = pageno
class PageResult(NamedTuple):
"""Result when a page is finished processing."""
pageno: int
pdf_page_from_image: Path | None
ocr: Path | None
text: Path | None
orientation_correction: int
"""Page number, 0-based."""
pdf_page_from_image: Path | None = None
"""Single page PDF from image."""
ocr: Path | None = None
"""Single page OCR PDF."""
text: Path | None = None
"""Single page text file."""
orientation_correction: int = 0
"""Orientation correction in degrees."""
tls = threading.local()
tls.pageno = None
@dataclass
class HOCRResult:
"""Result when hOCR is finished processing."""
pageno: int
"""Page number, 0-based."""
pdf_page_from_image: Path | None = None
"""Single page PDF from image."""
hocr: Path | None = None
"""Single page hOCR file."""
textpdf: Path | None = None
"""hOCR file after conversion to PDF."""
orientation_correction: int = 0
"""Orientation correction in degrees."""
def __getstate__(self):
"""Return state values to be pickled."""
return {
k: (
('Path://' + str(v))
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
else v
)
for k, v in self.__dict__.items()
}
def __setstate__(self, state):
"""Restore state from the unpickled state values."""
self.__dict__.update(
{
k: (
Path(v.removeprefix('Path://'))
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
else v
)
for k, v in state.items()
}
)
@classmethod
def from_json(cls, json_str: str) -> HOCRResult:
"""Create an instance from a dict."""
return cls(**json.loads(json_str))
def to_json(self) -> str:
"""Serialize to a JSON string."""
return json.dumps(self.__getstate__())
old_factory = logging.getLogRecordFactory()
def configure_debug_logging(
log_filename: Path, prefix: str = ''
) -> tuple[logging.FileHandler, Callable[[], None]]:
"""Create a debug log file at a specified location.
Returns the log handler, and a function to remove the handler.
Args:
log_filename: Where to the put the log file.
prefix: The logging domain prefix that should be sent to the log.
"""
log_file_handler = logging.FileHandler(log_filename, delay=True)
log_file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'[%(asctime)s] - %(name)s - %(levelname)7s -%(pageno)s %(message)s'
)
log_file_handler.setFormatter(formatter)
log_file_handler.addFilter(PageNumberFilter())
logging.getLogger(prefix).addHandler(log_file_handler)
def remover():
try:
logging.getLogger(prefix).removeHandler(log_file_handler)
log_file_handler.close()
except OSError as e:
print(e, file=sys.stderr)
return log_file_handler, remover
def record_factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
if hasattr(tls, 'pageno'):
record.pageno = tls.pageno
return record
def worker_init(max_pixels: int) -> None:
"""Initialize a worker thread or process."""
# In Windows, child process will not inherit our change to this value in
# the parent process, so ensure workers get it set. Not needed when running
# threaded, but harmless to set again.
PIL.Image.MAX_IMAGE_PIXELS = max_pixels
pikepdf_enable_mmap()
logging.setLogRecordFactory(record_factory)
@contextmanager
def manage_debug_log_handler(
*,
options: argparse.Namespace,
work_folder: Path,
):
remover = None
if (options.keep_temporary_files or options.verbose >= 1) and not os.environ.get(
'PYTEST_CURRENT_TEST', ''
):
# Debug log for command line interface only with verbose output
# See https://github.com/pytest-dev/pytest/issues/5502 for why we skip this
# when pytest is running
_debug_log_handler, remover = configure_debug_logging(
work_folder / "debug.log", prefix=""
) # pragma: no cover
try:
yield
finally:
if remover:
remover()
@contextmanager
def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool):
try:
yield work_folder
finally:
if retain:
if print_location:
print(
f"Temporary working files retained at:\n{work_folder}",
file=sys.stderr,
)
else:
shutil.rmtree(work_folder, ignore_errors=True)
def cli_exception_handler(
fn: Callable[[argparse.Namespace, OcrmypdfPluginManager], ExitCode],
options: argparse.Namespace,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Convert exceptions into command line error messages and exit codes.
When known exceptions are raised, the exception message is printed to stderr
and the program exits with a non-zero exit code. When unknown exceptions are
raised, the exception traceback is printed to stderr and the program exits
with a non-zero exit code.
"""
try:
# We cannot use a generator and yield here, as would be the usual pattern
# for exception handling context managers, because we need to return an exit
# code.
return fn(options, plugin_manager)
except KeyboardInterrupt:
if options.verbose >= 1:
log.exception("KeyboardInterrupt")
else:
log.error("KeyboardInterrupt")
return ExitCode.ctrl_c
except ExitCodeException as e:
e = cast(ExitCodeException, e)
if options.verbose >= 1:
log.exception("ExitCodeException")
elif str(e):
log.error("%s: %s", type(e).__name__, str(e))
else:
log.error(type(e).__name__)
return e.exit_code
except PIL.Image.DecompressionBombError:
log.exception(
"A decompression bomb error was encountered while executing the "
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
"image pixel limit."
)
return ExitCode.other_error
except (
BrokenProcessPool,
BrokenThreadPool,
):
log.exception(
"A worker process was terminated unexpectedly. This is known to occur if "
"processing your file takes all available swap space and RAM. It may "
"help to try again with a smaller number of jobs, using the --jobs "
"argument."
)
return ExitCode.child_process_error
except Exception: # pylint: disable=broad-except
log.exception("An exception occurred while executing the pipeline")
return ExitCode.other_error
def setup_pipeline(
options: argparse.Namespace,
plugin_manager: OcrmypdfPluginManager,
) -> Executor:
# Any changes to options will not take effect for options that are already
# bound to function parameters in the pipeline. (For example
# options.input_file, options.pdf_renderer are already bound.)
if not options.jobs:
options.jobs = available_cpu_count()
pikepdf_enable_mmap()
executor = setup_executor(plugin_manager)
return executor
def preprocess(
@@ -175,20 +377,9 @@ def make_intermediate_images(
return ocr_image, preprocess_out
def exec_page_sync(page_context: PageContext) -> PageResult:
"""Execute a pipeline for a single page synchronously."""
def process_page(page_context: PageContext) -> tuple[Path, Path | None, int]:
"""Process page to create OCR image, visible page image and orientation."""
options = page_context.options
tls.pageno = page_context.pageno + 1
if not is_ocr_required(page_context):
return PageResult(
pageno=page_context.pageno,
pdf_page_from_image=None,
ocr=None,
text=None,
orientation_correction=0,
)
orientation_correction = 0
if options.rotate_pages:
# Rasterize
@@ -216,25 +407,10 @@ def exec_page_sync(page_context: PageContext) -> PageResult:
pdf_page_from_image_out = create_pdf_page_from_image(
visible_image_out, page_context, orientation_correction
)
if options.pdf_renderer.startswith('hocr'):
(hocr_out, text_out) = ocr_engine_hocr(ocr_image_out, page_context)
ocr_out = render_hocr_page(hocr_out, page_context)
elif options.pdf_renderer == 'sandwich':
(ocr_out, text_out) = ocr_engine_textonly_pdf(ocr_image_out, page_context)
else:
raise NotImplementedError(f"pdf_renderer {options.pdf_renderer}")
return PageResult(
pageno=page_context.pageno,
pdf_page_from_image=pdf_page_from_image_out,
ocr=ocr_out,
text=text_out,
orientation_correction=orientation_correction,
)
return ocr_image_out, pdf_page_from_image_out, orientation_correction
def post_process(
def postprocess(
pdf_file: Path, context: PdfContext, executor: Executor
) -> tuple[Path, Sequence[str]]:
"""Postprocess the PDF file."""
@@ -243,242 +419,36 @@ def post_process(
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
pdf_out = metadata_fixup(pdf_out, context)
optimizing = context.plugin_manager.hook.is_optimization_enabled(context=context)
save_settings = get_pdf_save_settings(context.options.output_type)
save_settings['linearize'] = not optimizing and should_linearize(pdf_out, context)
pdf_out = metadata_fixup(pdf_out, context, pdf_save_settings=save_settings)
return optimize_pdf(pdf_out, context, executor)
def worker_init(max_pixels: int) -> None:
"""Initialize a worker thread or process."""
# In Windows, child process will not inherit our change to this value in
# the parent process, so ensure workers get it set. Not needed when running
# threaded, but harmless to set again.
PIL.Image.MAX_IMAGE_PIXELS = max_pixels
pikepdf_enable_mmap()
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
"""Execute the OCR pipeline concurrently."""
# Run exec_page_sync on every page
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
sidecars: list[Path | None] = [None] * len(context.pdfinfo)
ocrgraft = OcrGrafter(context)
def update_page(result: PageResult, pbar):
"""After OCR is complete for a page, update the PDF."""
try:
tls.pageno = result.pageno + 1
sidecars[result.pageno] = result.text
pbar.update()
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.ocr,
autorotate_correction=result.orientation_correction,
)
pbar.update()
finally:
tls.pageno = None
executor(
use_threads=options.use_threads,
max_workers=max_workers,
tqdm_kwargs=dict(
total=(2 * len(context.pdfinfo)),
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
unit='page',
unit_scale=0.5,
disable=not options.progress_bar,
),
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
task=exec_page_sync,
task_arguments=context.get_page_contexts(),
task_finished=update_page,
)
# Output sidecar text
if options.sidecar:
text = merge_sidecars(sidecars, context)
# Copy text file to destination
copy_final(text, options.sidecar, context)
# Merge layers to one single pdf
pdf = ocrgraft.finalize()
messages: Sequence[str] = []
if options.output_type != 'none':
# PDF/A and metadata
log.info("Postprocessing...")
pdf, messages = post_process(pdf, context, executor)
# Copy PDF file to destination
copy_final(pdf, options.output_file, context)
return messages
def configure_debug_logging(
log_filename: Path, prefix: str = ''
) -> logging.FileHandler:
"""Create a debug log file at a specified location.
Args:
log_filename: Where to the put the log file.
prefix: The logging domain prefix that should be sent to the log.
"""
log_file_handler = logging.FileHandler(log_filename, delay=True)
log_file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter(
'[%(asctime)s] - %(name)s - %(levelname)7s -%(pageno)s %(message)s'
)
log_file_handler.setFormatter(formatter)
log_file_handler.addFilter(PageNumberFilter())
logging.getLogger(prefix).addHandler(log_file_handler)
return log_file_handler
def run_pipeline(
options: argparse.Namespace,
*,
plugin_manager: OcrmypdfPluginManager | None,
api: bool = False,
) -> ExitCode:
"""Run the OCR pipeline.
Args:
options: The parsed command line options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
api: If ``True``, the pipeline is being run from the API. This is used
to manage exceptions in a way appropriate for API or CLI usage.
For CLI (``api=False``), exceptions are printed and described;
for API use, they are propagated to the caller.
"""
# Any changes to options will not take effect for options that are already
# bound to function parameters in the pipeline. (For example
# options.input_file, options.pdf_renderer are already bound.)
if not options.jobs:
options.jobs = available_cpu_count()
if not plugin_manager:
plugin_manager = get_plugin_manager(options.plugins)
work_folder = Path(mkdtemp(prefix="ocrmypdf.io."))
debug_log_handler = None
if (
(options.keep_temporary_files or options.verbose >= 1)
and not os.environ.get('PYTEST_CURRENT_TEST', '')
and not api
):
# Debug log for command line interface only with verbose output
# See https://github.com/pytest-dev/pytest/issues/5502 for why we skip this
# when pytest is running
debug_log_handler = configure_debug_logging(
Path(work_folder) / "debug.log"
) # pragma: no cover
pikepdf_enable_mmap()
executor = setup_executor(plugin_manager)
try:
check_requested_output_file(options)
start_input_file, original_filename = create_input_file(options, work_folder)
# Triage image or pdf
origin_pdf = triage(
original_filename, start_input_file, work_folder / 'origin.pdf', options
def report_output_pdf(options, start_input_file, optimize_messages) -> ExitCode:
if options.output_file == '-':
log.info("Output sent to stdout")
elif hasattr(options.output_file, 'writable') and options.output_file.writable():
log.info("Output written to stream")
elif samefile(options.output_file, Path(os.devnull)):
pass # Say nothing when sending to dev null
else:
if options.output_type.startswith('pdfa'):
pdfa_info = file_claims_pdfa(options.output_file)
if pdfa_info['pass']:
log.info("Output file is a %s (as expected)", pdfa_info['conformance'])
else:
log.warning(
"Output file is okay but is not PDF/A (seems to be %s)",
pdfa_info['conformance'],
)
return ExitCode.pdfa_conversion_failed
if not check_pdf(options.output_file):
log.warning('Output file: The generated PDF is INVALID')
return ExitCode.invalid_output_pdf
report_output_file_size(
options, start_input_file, options.output_file, optimize_messages
)
# Gather pdfinfo and create context
pdfinfo = get_pdfinfo(
origin_pdf,
executor=executor,
detailed_analysis=options.redo_ocr,
progbar=options.progress_bar,
max_workers=options.jobs if not options.use_threads else 1, # To help debug
check_pages=options.pages,
)
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
# Validate options are okay for this pdf
validate_pdfinfo_options(context)
# Execute the pipeline
optimize_messages = exec_concurrent(context, executor)
if options.output_file == '-':
log.info("Output sent to stdout")
elif (
hasattr(options.output_file, 'writable') and options.output_file.writable()
):
log.info("Output written to stream")
elif samefile(options.output_file, Path(os.devnull)):
pass # Say nothing when sending to dev null
else:
if options.output_type.startswith('pdfa'):
pdfa_info = file_claims_pdfa(options.output_file)
if pdfa_info['pass']:
log.info(
"Output file is a %s (as expected)", pdfa_info['conformance']
)
else:
log.warning(
"Output file is okay but is not PDF/A (seems to be %s)",
pdfa_info['conformance'],
)
return ExitCode.pdfa_conversion_failed
if not check_pdf(options.output_file):
log.warning('Output file: The generated PDF is INVALID')
return ExitCode.invalid_output_pdf
report_output_file_size(
options, start_input_file, options.output_file, optimize_messages
)
except KeyboardInterrupt if not api else NeverRaise:
if options.verbose >= 1:
log.exception("KeyboardInterrupt")
else:
log.error("KeyboardInterrupt")
return ExitCode.ctrl_c
except ExitCodeException if not api else NeverRaise as e:
e = cast(ExitCodeException, e)
if options.verbose >= 1:
log.exception("ExitCodeException")
elif str(e):
log.error("%s: %s", type(e).__name__, str(e))
else:
log.error(type(e).__name__)
return e.exit_code
except PIL.Image.DecompressionBombError if not api else NeverRaise:
log.exception(
"A decompression bomb error was encountered while executing the "
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
"image pixel limit."
)
return ExitCode.other_error
except (
BrokenProcessPool if not api else NeverRaise,
BrokenThreadPool if not api else NeverRaise,
):
log.exception(
"A worker process was terminated unexpectedly. This is known to occur if "
"processing your file takes all available swap space and RAM. It may "
"help to try again with a smaller number of jobs, using the --jobs "
"argument."
)
return ExitCode.child_process_error
except Exception if not api else NeverRaise: # pylint: disable=broad-except
log.exception("An exception occurred while executing the pipeline")
return ExitCode.other_error
finally:
if debug_log_handler:
try:
debug_log_handler.close()
log.removeHandler(debug_log_handler)
except OSError as e:
print(e, file=sys.stderr)
cleanup_working_files(work_folder, options)
return ExitCode.ok
+134
View File
@@ -0,0 +1,134 @@
# SPDX-FileCopyrightText: 2019-2023 James R. Barlow
# SPDX-FileCopyrightText: 2019 Martin Wind
# SPDX-License-Identifier: MPL-2.0
"""Implements the concurrent and page synchronous parts of the pipeline."""
from __future__ import annotations
import argparse
import logging
import logging.handlers
from collections.abc import Sequence
from functools import partial
import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._pipeline import (
copy_final,
get_pdfinfo,
render_hocr_page,
)
from ocrmypdf._pipelines._common import (
HOCRResult,
manage_work_folder,
postprocess,
report_output_pdf,
set_thread_pageno,
setup_pipeline,
worker_init,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import ExitCode
log = logging.getLogger(__name__)
def _exec_hocrtransform_sync(page_context: PageContext) -> HOCRResult:
"""Process each page."""
hocr_json = page_context.get_path('hocr.json')
if not hocr_json.exists():
# No hOCR file, so no OCR was performed on this page.
return HOCRResult(pageno=page_context.pageno)
hocr_result = HOCRResult.from_json(hocr_json.read_text())
hocr_result.textpdf = render_hocr_page(
page_context.get_path('ocr_hocr.hocr'), page_context
)
return hocr_result
def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[str]:
"""Convert hOCR files to OCR PDF."""
# Run exec_page_sync on every page
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
if max_workers > 1:
log.info("Continue processing %d pages concurrently", max_workers)
ocrgraft = OcrGrafter(context)
def graft_page(result: HOCRResult, pbar: ProgressBar):
"""Graft text only PDF on to main PDF's page."""
try:
set_thread_pageno(result.pageno + 1)
pbar.update()
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.textpdf,
autorotate_correction=result.orientation_correction,
)
pbar.update()
finally:
set_thread_pageno(None)
executor(
use_threads=options.use_threads,
max_workers=max_workers,
progress_kwargs=dict(
total=(2 * len(context.pdfinfo)),
desc='Grafting hOCR to PDF',
unit='page',
unit_scale=0.5,
disable=not options.progress_bar,
),
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
task=_exec_hocrtransform_sync,
task_arguments=context.get_page_context_args(),
task_finished=graft_page,
)
pdf = ocrgraft.finalize()
messages: Sequence[str] = []
if options.output_type != 'none':
# PDF/A and metadata
log.info("Postprocessing...")
pdf, messages = postprocess(pdf, context, executor)
# Copy PDF file to destination (we don't know the input PDF file name)
copy_final(pdf, options.output_file, None)
return messages
def run_hocr_to_ocr_pdf_pipeline(
options: argparse.Namespace,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run pipeline to convert hOCR to final output PDF."""
with manage_work_folder(
work_folder=options.work_folder, retain=True, print_location=False
) as work_folder:
executor = setup_pipeline(options, plugin_manager)
origin_pdf = work_folder / 'origin.pdf'
# Gather pdfinfo and create context
pdfinfo = get_pdfinfo(
origin_pdf,
executor=executor,
detailed_analysis=options.redo_ocr,
progbar=options.progress_bar,
max_workers=options.jobs,
use_threads=options.use_threads,
check_pages=options.pages,
)
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
plugin_manager.hook.check_options(options=options)
optimize_messages = exec_hocr_to_ocr_pdf(context, executor)
return report_output_pdf(options, origin_pdf, optimize_messages)
+225
View File
@@ -0,0 +1,225 @@
# SPDX-FileCopyrightText: 2019-2023 James R. Barlow
# SPDX-FileCopyrightText: 2019 Martin Wind
# SPDX-License-Identifier: MPL-2.0
"""Implements the concurrent and page synchronous parts of the pipeline."""
from __future__ import annotations
import argparse
import logging
import logging.handlers
from collections.abc import Sequence
from functools import partial
from pathlib import Path
from tempfile import mkdtemp
import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._pipeline import (
copy_final,
get_pdfinfo,
is_ocr_required,
merge_sidecars,
ocr_engine_hocr,
ocr_engine_textonly_pdf,
render_hocr_page,
triage,
validate_pdfinfo_options,
)
from ocrmypdf._pipelines._common import (
PageResult,
cli_exception_handler,
manage_debug_log_handler,
manage_work_folder,
postprocess,
process_page,
report_output_pdf,
set_thread_pageno,
setup_pipeline,
worker_init,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf._validation import (
check_requested_output_file,
create_input_file,
)
from ocrmypdf.exceptions import ExitCode
log = logging.getLogger(__name__)
def _image_to_ocr_text(
page_context: PageContext, ocr_image_out: Path
) -> tuple[Path, Path]:
"""Run OCR engine on image to create OCR PDF and text file."""
options = page_context.options
if options.pdf_renderer.startswith('hocr'):
hocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
ocr_out = render_hocr_page(hocr_out, page_context)
elif options.pdf_renderer == 'sandwich':
ocr_out, text_out = ocr_engine_textonly_pdf(ocr_image_out, page_context)
else:
raise NotImplementedError(f"pdf_renderer {options.pdf_renderer}")
return ocr_out, text_out
def _exec_page_sync(page_context: PageContext) -> PageResult:
"""Execute a pipeline for a single page synchronously."""
set_thread_pageno(page_context.pageno + 1)
if not is_ocr_required(page_context):
return PageResult(pageno=page_context.pageno)
ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page(
page_context
)
ocr_out, text_out = _image_to_ocr_text(page_context, ocr_image_out)
return PageResult(
pageno=page_context.pageno,
pdf_page_from_image=pdf_page_from_image_out,
ocr=ocr_out,
text=text_out,
orientation_correction=orientation_correction,
)
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
"""Execute the OCR pipeline concurrently."""
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
sidecars: list[Path | None] = [None] * len(context.pdfinfo)
ocrgraft = OcrGrafter(context)
def update_page(result: PageResult, pbar: ProgressBar):
"""After OCR is complete for a page, update the PDF."""
try:
set_thread_pageno(result.pageno + 1)
sidecars[result.pageno] = result.text
pbar.update()
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.ocr,
autorotate_correction=result.orientation_correction,
)
pbar.update()
finally:
set_thread_pageno(None)
executor(
use_threads=options.use_threads,
max_workers=max_workers,
progress_kwargs=dict(
total=(2 * len(context.pdfinfo)),
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
unit='page',
unit_scale=0.5,
disable=not options.progress_bar,
),
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
task=_exec_page_sync,
task_arguments=context.get_page_context_args(),
task_finished=update_page,
)
# Output sidecar text
if options.sidecar:
text = merge_sidecars(sidecars, context)
# Copy text file to destination
copy_final(text, options.sidecar, options.input_file)
# Merge layers to one single pdf
pdf = ocrgraft.finalize()
messages: Sequence[str] = []
if options.output_type != 'none':
# PDF/A and metadata
log.info("Postprocessing...")
pdf, messages = postprocess(pdf, context, executor)
# Copy PDF file to destination
copy_final(pdf, options.output_file, options.input_file)
return messages
def _run_pipeline(
options: argparse.Namespace,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
with manage_work_folder(
work_folder=Path(mkdtemp(prefix="ocrmypdf.io.")),
retain=options.keep_temporary_files,
print_location=options.keep_temporary_files,
) as work_folder, manage_debug_log_handler(
options=options, work_folder=work_folder
):
executor = setup_pipeline(options, plugin_manager)
check_requested_output_file(options)
start_input_file, original_filename = create_input_file(options, work_folder)
# Triage image or pdf
origin_pdf = triage(
original_filename, start_input_file, work_folder / 'origin.pdf', options
)
# Gather pdfinfo and create context
pdfinfo = get_pdfinfo(
origin_pdf,
executor=executor,
detailed_analysis=options.redo_ocr,
progbar=options.progress_bar,
max_workers=options.jobs,
use_threads=options.use_threads,
check_pages=options.pages,
)
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
# Validate options are okay for this pdf
validate_pdfinfo_options(context)
# Execute the pipeline
optimize_messages = exec_concurrent(context, executor)
exitcode = report_output_pdf(options, start_input_file, optimize_messages)
return exitcode
def run_pipeline_cli(
options: argparse.Namespace,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run the OCR pipeline with command line exception handling.
Args:
options: The parsed command line options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
"""
return cli_exception_handler(_run_pipeline, options, plugin_manager)
def run_pipeline(
options: argparse.Namespace,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run the OCR pipeline without command line exception handling.
Args:
options: The parsed command line options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
"""
return _run_pipeline(options, plugin_manager)
+116
View File
@@ -0,0 +1,116 @@
# SPDX-FileCopyrightText: 2019-2023 James R. Barlow
# SPDX-FileCopyrightText: 2019 Martin Wind
# SPDX-License-Identifier: MPL-2.0
"""Implements the concurrent and page synchronous parts of the pipeline."""
from __future__ import annotations
import argparse
import logging
import logging.handlers
import shutil
from functools import partial
import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._pipeline import (
get_pdfinfo,
is_ocr_required,
ocr_engine_hocr,
validate_pdfinfo_options,
)
from ocrmypdf._pipelines._common import (
HOCRResult,
manage_work_folder,
process_page,
set_thread_pageno,
setup_pipeline,
worker_init,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
set_lossless_reconstruction,
)
log = logging.getLogger(__name__)
def _exec_page_hocr_sync(page_context: PageContext) -> HOCRResult:
"""Execute a pipeline for a single page hOCR."""
set_thread_pageno(page_context.pageno + 1)
if not is_ocr_required(page_context):
return HOCRResult(pageno=page_context.pageno)
ocr_image_out, pdf_page_from_image_out, orientation_correction = process_page(
page_context
)
hocr_out, _ = ocr_engine_hocr(ocr_image_out, page_context)
result = HOCRResult(
pageno=page_context.pageno,
pdf_page_from_image=pdf_page_from_image_out,
hocr=hocr_out,
orientation_correction=orientation_correction,
)
page_context.get_path('hocr.json').write_text(result.to_json())
return result
def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None:
"""Execute the OCR pipeline concurrently and output hOCR."""
# Run exec_page_sync on every page
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
executor(
use_threads=options.use_threads,
max_workers=max_workers,
progress_kwargs=dict(
total=(2 * len(context.pdfinfo)),
desc='hOCR',
unit='page',
unit_scale=0.5,
disable=not options.progress_bar,
),
worker_initializer=partial(worker_init, PIL.Image.MAX_IMAGE_PIXELS),
task=_exec_page_hocr_sync,
task_arguments=context.get_page_context_args(),
)
def run_hocr_pipeline(
options: argparse.Namespace,
*,
plugin_manager: OcrmypdfPluginManager,
) -> None:
"""Run pipeline to output hOCR."""
with manage_work_folder(
work_folder=options.output_folder, retain=True, print_location=False
) as work_folder:
executor = setup_pipeline(options, plugin_manager)
shutil.copy2(options.input_file, work_folder / 'origin.pdf')
# Gather pdfinfo and create context
pdfinfo = get_pdfinfo(
options.input_file,
executor=executor,
detailed_analysis=options.redo_ocr,
progbar=options.progress_bar,
max_workers=options.jobs,
use_threads=options.use_threads,
check_pages=options.pages,
)
context = PdfContext(
options, work_folder, options.input_file, pdfinfo, plugin_manager
)
# Validate options are okay for this pdf
set_lossless_reconstruction(options)
validate_pdfinfo_options(context)
exec_pdf_to_hocr(context, executor)
+3 -3
View File
@@ -33,7 +33,7 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
def __init__(
self,
*args,
plugins: list[str | Path],
plugins: Sequence[str | Path],
builtins: bool = True,
**kwargs,
):
@@ -101,11 +101,11 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
def get_plugin_manager(
plugins: list[str | Path], builtins=True
plugins: Sequence[str | Path] | None = None, builtins=True
) -> OcrmypdfPluginManager:
return OcrmypdfPluginManager(
project_name='ocrmypdf',
plugins=plugins,
plugins=plugins if plugins is not None else [],
builtins=builtins,
)
+142
View File
@@ -0,0 +1,142 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Defines progress bar API."""
from __future__ import annotations
from typing import Protocol
from rich.console import Console
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
)
from rich.table import Column
class ProgressBar(Protocol):
"""The protocol that OCRmyPDF expects progress bar classes to be compatible with.
In practice this could be used for any time of monitoring, not just a progress bar.
Calling the class should return a new progress bar object, which is activated
with ``__enter__`` and terminated with ``__exit__``. An update method is called
whenever the progress bar is updated. Progress bar objects will not be reused;
a new one will be created for each group of tasks.
The progress bar is held in the main process/thread and not updated by child
process/threads. When a child notifies the parent of completed work, the
parent updates the progress bar.
Progress bars should never write to ``sys.stdout``, or they will corrupt the
output if OCRmyPDF writes a PDF to standard output.
The type of events that OCRmyPDF reports to a progress bar may change in
minor releases.
"""
def __init__(
self,
*,
total: int | float | None,
desc: str | None,
unit: str | None,
disable: bool = False,
**kwargs,
):
"""Initialize a progress bar.
*total* indicates the total number of work units. If None, the total
number of work units is unknown. If *disable* is True, the progress bar
should be disabled. *unit* is a description of the work unit.
*desc* is a description of the overall task to be performed.
Unrecognized keyword arguments must be ignored, as the list of keyword
arguments may grow with time.
"""
def __enter__(self):
"""Enter a progress bar context."""
def __exit__(self, *args):
"""Exit a progress bar context."""
def update(self, n=1):
"""Update the progress bar by an increment.
For use within a progress bar context.
"""
class NullProgressBar:
"""Progress bar API that takes no actions."""
def __init__(self, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def update(self, _arg=None):
return
class RichProgressBar:
"""Display progress bar using rich."""
def __init__(
self,
*,
console: Console,
desc: str,
total: float | None = None,
unit: str | None = None,
unit_scale: float | None = 1.0,
disable: bool = False,
**kwargs,
):
self.progress = Progress(
TextColumn(
"[progress.description]{task.description}",
table_column=Column(min_width=20),
),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
console=console,
auto_refresh=True,
redirect_stderr=True,
redirect_stdout=False,
disable=disable,
**kwargs,
)
self.unit_scale = unit_scale
self.progress_bar = self.progress.add_task(
desc,
total=total * self.unit_scale
if total is not None and self.unit_scale is not None
else None,
unit=unit,
)
def __enter__(self):
self.progress.start()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.progress.refresh()
self.progress.stop()
return False
def update(self, value=None):
advance = self.unit_scale if value is None else value
self.progress.update(self.progress_bar, advance=advance)
+26 -12
View File
@@ -28,7 +28,6 @@ from ocrmypdf.exceptions import (
OutputFileAccessError,
)
from ocrmypdf.helpers import is_file_writable, monotonic, safe_symlink
from ocrmypdf.hocrtransform import HOCR_OK_LANGS
from ocrmypdf.subprocess import check_external_program
# -------------
@@ -83,15 +82,6 @@ def check_options_languages(
def check_options_output(options: Namespace) -> None:
is_latin = set(options.languages).issubset(HOCR_OK_LANGS)
if options.pdf_renderer.startswith('hocr') and not is_latin:
log.warning(
"The 'hocr' PDF renderer is known to cause problems with one "
"or more of the languages in your document. Use "
"`--pdf-renderer auto` (the default) to avoid this issue."
)
if options.output_type == 'none' and options.output_file not in (os.devnull, '-'):
raise BadArgsError(
"Since you specified `--output-type none`, the output file "
@@ -99,6 +89,8 @@ def check_options_output(options: Namespace) -> None:
f"`-` to suppress this message."
)
def set_lossless_reconstruction(options: Namespace) -> None:
lossless_reconstruction = False
if not any(
(
@@ -228,6 +220,7 @@ def _check_plugin_invariant_options(options: Namespace) -> None:
check_platform()
check_options_metadata(options)
check_options_output(options)
set_lossless_reconstruction(options)
check_options_sidecar(options)
check_options_preprocessing(options)
check_options_ocr_behavior(options)
@@ -245,6 +238,18 @@ def check_options(options: Namespace, plugin_manager: PluginManager) -> None:
_check_plugin_options(options, plugin_manager)
def _in_docker():
return Path('/.dockerenv').exists()
def _in_snap():
try:
cgroup_text = Path('/proc/self/cgroup').read_text()
return 'snap.ocrmypdf' in cgroup_text
except FileNotFoundError:
return False
def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]:
if options.input_file == '-':
# stdin
@@ -268,9 +273,9 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
return target, os.fspath(options.input_file)
except FileNotFoundError as e:
msg = f"File not found - {options.input_file}"
if Path('/.dockerenv').exists(): # pragma: no cover
if _in_docker(): # pragma: no cover
msg += (
"\nDocker cannot your working directory unless you "
"\nDocker cannot access your working directory unless you "
"explicitly share it with the Docker container and set up"
"permissions correctly.\n"
"You may find it easier to use stdin/stdout:"
@@ -278,6 +283,15 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf"
"\n"
)
elif _in_snap(): # pragma: no cover
msg += (
"\nSnap applications cannot access files outside of "
"your home directory unless you explicitly allow it. "
"You may find it easier to use stdin/stdout:"
"\n"
"\tsnap run ocrmypdf - - <input.pdf >output.pdf"
"\n"
)
raise InputFileError(msg) from e
+215 -35
View File
@@ -10,7 +10,7 @@ import os
import sys
import threading
from argparse import Namespace
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from enum import IntEnum
from io import IOBase
from pathlib import Path
@@ -20,8 +20,10 @@ from warnings import warn
import pluggy
from ocrmypdf._logging import PageNumberFilter
from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf._sync import run_pipeline
from ocrmypdf._validation import check_options
from ocrmypdf.cli import ArgumentParser, get_parser
from ocrmypdf.helpers import is_iterable_notstr
@@ -29,6 +31,9 @@ from ocrmypdf.helpers import is_iterable_notstr
StrPath = Union[Path, AnyStr]
PathOrIO = Union[BinaryIO, StrPath]
# Installing plugins affects the global state of the Python interpreter,
# so we need to use a lock to prevent multiple threads from installing
# plugins at the same time.
_api_lock = threading.Lock()
@@ -133,34 +138,19 @@ def configure_logging(
return log
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
) -> Namespace:
"""Construct an options object from the input/output files and keyword arguments.
Args:
input_file: Input file path or file object.
output_file: Output file path or file object.
parser: ArgumentParser object.
**kwargs: Keyword arguments.
Returns:
argparse.Namespace: A Namespace object containing the parsed arguments.
Raises:
TypeError: If the type of a keyword argument is not supported.
"""
def _kwargs_to_cmdline(
*, defer_kwargs: set[str], **kwargs
) -> tuple[list[str], dict[str, AnyStr]]:
"""Convert kwargs to command line arguments."""
cmdline = []
deferred = []
deferred = {}
for arg, val in kwargs.items():
if val is None:
continue
# These arguments with special handling for which we bypass
# argparse
if arg in {'progress_bar', 'plugins'}:
deferred.append((arg, val))
# Skip arguments that are handled elsewhere
if arg in defer_kwargs:
deferred[arg] = val
continue
cmd_style_arg = arg.replace('_', '-')
@@ -187,7 +177,30 @@ def create_options(
cmdline.append(str(val))
else:
raise TypeError(f"{arg}: {val} ({type(val)})")
return cmdline, deferred
def create_options(
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
) -> Namespace:
"""Construct an options object from the input/output files and keyword arguments.
Args:
input_file: Input file path or file object.
output_file: Output file path or file object.
parser: ArgumentParser object.
**kwargs: Keyword arguments.
Returns:
argparse.Namespace: A Namespace object containing the parsed arguments.
Raises:
TypeError: If the type of a keyword argument is not supported.
"""
cmdline, deferred = _kwargs_to_cmdline(
defer_kwargs={'progress_bar', 'plugins', 'parser', 'input_file', 'output_file'},
**kwargs,
)
if isinstance(input_file, (BinaryIO, IOBase)):
cmdline.append('stream://input_file')
else:
@@ -199,7 +212,7 @@ def create_options(
parser.enable_api_mode()
options = parser.parse_args(cmdline)
for keyword, val in deferred:
for keyword, val in deferred.items():
setattr(options, keyword, val)
if options.input_file == 'stream://input_file':
@@ -336,17 +349,15 @@ def ocr( # noqa: D417
plugins = list(plugins)
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {k: v for k, v in locals().items() if k != 'kwargs'}
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'input_file', 'output_file', 'kwargs'}
}
create_options_kwargs.update(kwargs)
parser = get_parser()
create_options_kwargs['parser'] = parser
with _api_lock:
# We can't allow multiple ocrmypdf.ocr() threads to run in parallel, because
# they might install different plugins, and generally speaking we have areas
# of code that use global state.
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
@@ -354,9 +365,177 @@ def ocr( # noqa: D417
if 'verbose' in kwargs:
warn("ocrmypdf.ocr(verbose=) is ignored. Use ocrmypdf.configure_logging().")
options = create_options(**create_options_kwargs)
options = create_options(
input_file=input_file,
output_file=output_file,
parser=parser,
**create_options_kwargs,
)
check_options(options, plugin_manager)
return run_pipeline(options=options, plugin_manager=plugin_manager, api=True)
return run_pipeline(options=options, plugin_manager=plugin_manager)
def _pdf_to_hocr( # noqa: D417
input_pdf: Path,
output_folder: Path,
*,
language: Iterable[str] | None = None,
image_dpi: int | None = None,
jobs: int | None = None,
use_threads: bool | None = None,
title: str | None = None,
author: str | None = None,
subject: str | None = None,
keywords: str | None = None,
rotate_pages: bool | None = None,
remove_background: bool | None = None,
deskew: bool | None = None,
clean: bool | None = None,
clean_final: bool | None = None,
unpaper_args: str | None = None,
oversample: int | None = None,
remove_vectors: bool | None = None,
force_ocr: bool | None = None,
skip_text: bool | None = None,
redo_ocr: bool | None = None,
skip_big: float | None = None,
pages: str | None = None,
max_image_mpixels: float | None = None,
tesseract_config: Iterable[str] | None = None,
tesseract_pagesegmode: int | None = None,
tesseract_oem: int | None = None,
tesseract_thresholding: int | None = None,
tesseract_timeout: float | None = None,
tesseract_non_ocr_timeout: float | None = None,
tesseract_downsample_above: int | None = None,
tesseract_downsample_large_images: bool | None = None,
rotate_pages_threshold: float | None = None,
user_words: os.PathLike | None = None,
user_patterns: os.PathLike | None = None,
continue_on_soft_render_error: bool | None = None,
invalidate_digital_signatures: bool | None = None,
plugin_manager=None,
plugins: Sequence[StrPath] | None = None,
keep_temporary_files: bool | None = None,
**kwargs,
):
"""Partially run OCRmyPDF and produces an output folder containing hOCR files.
Given a PDF file, this function will run OCRmyPDF up to the point where
the PDF is rasterized to images, OCRed, and the hOCR files are produced,
all of which are saved to the output folder. This is useful for applications
that want to provide an interface for users to edit the text before
rendering the final PDF.
Use :func:`hocr_to_ocr_pdf` to produce the final PDF.
For arguments not explicitly documented here, see documentation for the
equivalent command line parameter.
This API is **experimental** and subject to change.
Args:
input_pdf: Input PDF file path.
output_folder: Output folder path.
**kwargs: Keyword arguments.
"""
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'input_pdf', 'output_folder', 'kwargs'}
}
create_options_kwargs.update(kwargs)
parser = get_parser()
with _api_lock:
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
cmdline, deferred = _kwargs_to_cmdline(
defer_kwargs={'input_pdf', 'output_folder', 'plugins'},
**create_options_kwargs,
)
cmdline.append(str(input_pdf))
cmdline.append(str(output_folder))
parser.enable_api_mode()
options = parser.parse_args(cmdline)
for keyword, val in deferred.items():
setattr(options, keyword, val)
delattr(options, 'output_file')
setattr(options, 'output_folder', output_folder)
return run_hocr_pipeline(options=options, plugin_manager=plugin_manager)
def _hocr_to_ocr_pdf( # noqa: D417
work_folder: Path,
output_file: Path,
*,
jobs: int | None = None,
use_threads: bool | None = None,
optimize: int | None = None,
jpg_quality: int | None = None,
png_quality: int | None = None,
jbig2_lossy: bool | None = None,
jbig2_page_group_size: int | None = None,
jbig2_threshold: float | None = None,
pdfa_image_compression: str | None = None,
color_conversion_strategy: str | None = None,
fast_web_view: float | None = None,
plugin_manager=None,
plugins: Sequence[StrPath] | None = None,
**kwargs,
):
"""Run OCRmyPDF on a work folder and produce an output PDF.
After running :func:`pdf_to_hocr`, this function will run OCRmyPDF on the work
folder to produce an output PDF. This function consolidates any changes made
to the hOCR files in the work folder and produces a final PDF.
For arguments not explicitly documented here, see documentation for the
equivalent command line parameter.
This API is **experimental** and subject to change.
Args:
work_folder: Work folder path, as generated by :func:`pdf_to_hocr`.
output_file: Output PDF file path.
**kwargs: Keyword arguments.
"""
# No new variable names should be assigned until these two steps are run
create_options_kwargs = {
k: v
for k, v in locals().items()
if k not in {'work_folder', 'output_pdf', 'kwargs'}
}
create_options_kwargs.update(kwargs)
parser = get_parser()
with _api_lock:
if not plugin_manager:
plugin_manager = get_plugin_manager(plugins)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
cmdline, deferred = _kwargs_to_cmdline(
defer_kwargs={'work_folder', 'output_file', 'plugins'},
**create_options_kwargs,
)
cmdline.append(str(work_folder))
cmdline.append(str(output_file))
parser.enable_api_mode()
options = parser.parse_args(cmdline)
for keyword, val in deferred.items():
setattr(options, keyword, val)
delattr(options, 'input_file')
setattr(options, 'work_folder', work_folder)
return run_hocr_to_ocr_pdf_pipeline(
options=options, plugin_manager=plugin_manager
)
__all__ = [
@@ -369,4 +548,5 @@ __all__ = [
'get_plugin_manager',
'ocr',
'run_pipeline',
'run_pipeline_cli',
]
+8 -7
View File
@@ -20,7 +20,8 @@ from typing import Callable, Union
from rich.console import Console as RichConsole
from ocrmypdf import Executor, hookimpl
from ocrmypdf._logging import RichLoggingHandler, RichTqdmProgressAdapter
from ocrmypdf._logging import RichLoggingHandler
from ocrmypdf._progressbar import RichProgressBar
from ocrmypdf.exceptions import InputFileError
from ocrmypdf.helpers import remove_all_log_handlers
@@ -101,7 +102,7 @@ class StandardExecutor(Executor):
*,
use_threads: bool,
max_workers: int,
tqdm_kwargs: dict,
progress_kwargs: dict,
worker_initializer: Callable,
task: Callable,
task_arguments: Iterable,
@@ -127,12 +128,12 @@ class StandardExecutor(Executor):
listener = threading.Thread(target=log_listener, args=(log_queue,))
listener.start()
with self.pbar_class(**tqdm_kwargs) as pbar, executor_class(
with self.pbar_class(**progress_kwargs) as pbar, executor_class(
max_workers=max_workers,
initializer=initializer,
initargs=(log_queue, worker_initializer, logging.getLogger("").level),
) as executor:
futures = [executor.submit(task, args) for args in task_arguments]
futures = [executor.submit(task, *args) for args in task_arguments]
try:
for future in as_completed(futures):
result = future.result()
@@ -172,10 +173,10 @@ RICH_CONSOLE = RichConsole(stderr=True)
def get_progressbar_class():
"""Return the default progress bar class."""
def partial_RichTqdmProgressAdapter(*args, **kwargs):
return RichTqdmProgressAdapter(*args, **kwargs, console=RICH_CONSOLE)
def partial_RichProgressBar(*args, **kwargs):
return RichProgressBar(*args, **kwargs, console=RICH_CONSOLE)
return partial_RichTqdmProgressAdapter
return partial_RichProgressBar
@hookimpl
+16 -4
View File
@@ -6,6 +6,8 @@ from __future__ import annotations
import logging
from packaging.version import Version
from ocrmypdf import hookimpl
from ocrmypdf._exec import ghostscript
from ocrmypdf.exceptions import MissingDependencyError
@@ -15,7 +17,7 @@ log = logging.getLogger(__name__)
# Currently all blacklisted versions are lower than 9.55, so none need to
# be added here. If a future version is blacklisted, add it here.
BLACKLISTED_GS_VERSIONS: frozenset[str] = frozenset()
BLACKLISTED_GS_VERSIONS: frozenset[Version] = frozenset()
@hookimpl
@@ -52,14 +54,24 @@ def check_options(options):
program='gs',
package='ghostscript',
version_checker=ghostscript.version,
need_version='9.55', # Ubuntu 22.04's version
need_version='9.54', # RHEL 9's version; Ubuntu 22.04 has 9.55
)
gs_version = ghostscript.version()
if gs_version in BLACKLISTED_GS_VERSIONS:
raise MissingDependencyError(
f"Ghostscript {gs_version} contains serious regressions and is not "
"supported. Please upgrade to a newer version, or downgrade to the "
"previous version."
"supported. Please upgrade to a newer version."
)
if Version('10.0.0') <= gs_version < Version('10.02.1') and (
options.skip_text or options.redo_ocr
):
raise MissingDependencyError(
f"Ghostscript 10.0.0 through 10.02.0 (your version: {gs_version}) "
"contain serious regressions that corrupt PDFs with existing text, "
"such as those processed using --skip-text or --redo-ocr. "
"Please upgrade to a "
"newer version, or use --output-type pdf to avoid Ghostscript, or "
"use --force-ocr to discard existing text."
)
if options.output_type == 'pdfa':
@@ -216,7 +216,7 @@ class TesseractOcrEngine(OcrEngine):
@staticmethod
def creator_tag(options):
tag = '-PDF' if options.pdf_renderer == 'sandwich' else ''
tag = '-PDF' if options.pdf_renderer == 'sandwich' else 'hOCR'
return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}"
def __str__(self):
+2 -2
View File
@@ -193,8 +193,8 @@ Online documentation is located at:
help="Choose output type. 'pdfa' creates a PDF/A-2b compliant file for "
"long term archiving (default, recommended) but may not suitable "
"for users who want their file altered as little as possible. 'pdfa' "
"also has problems with full Unicode text. 'pdf' attempts to "
"preserve file contents as much as possible. 'pdf-a1' creates a "
"also has problems with full Unicode text. 'pdf' minimizes changes "
"to the input file. 'pdf-a1' creates a "
"PDF/A1-b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a "
"PDF/A3-b file. 'none' will produce no output, which may be helpful if "
"only the --sidecar is desired.",
Binary file not shown.
+31 -6
View File
@@ -108,10 +108,16 @@ class EncryptedPdfError(ExitCodeException):
)
class DigitalSignatureError(ExitCodeException):
class TesseractConfigError(ExitCodeException):
"""Tesseract config can't be parsed."""
exit_code = ExitCode.invalid_config
message = "Error occurred while parsing a Tesseract configuration file"
class DigitalSignatureError(InputFileError):
"""PDF has a digital signature."""
exit_code = ExitCode.input_file
message = dedent(
"""\
Input PDF has a digital signature. OCR would alter the document,
@@ -120,8 +126,27 @@ class DigitalSignatureError(ExitCodeException):
)
class TesseractConfigError(ExitCodeException):
"""Tesseract config can't be parsed."""
class TaggedPDFError(InputFileError):
"""PDF is tagged."""
exit_code = ExitCode.invalid_config
message = "Error occurred while parsing a Tesseract configuration file"
message = dedent(
"""\
This PDF is marked as a Tagged PDF. This often indicates
that the PDF was generated from an office document and does
not need OCR. Use --force-ocr, --skip-text or --redo-ocr to
override this error.
"""
)
class ColorConversionNeededError(BadArgsError):
"""PDF needs color conversion."""
message = dedent(
"""\
The input PDF has an unusual color space. Use
--color-conversion-strategy to convert to a common color space
such as RGB, or use --output-type pdf to skip PDF/A conversion
and retain the original color space.
"""
)
+5 -5
View File
@@ -94,7 +94,7 @@ def process_loop(
for args in task_args:
try:
result = task(args)
result = task(*args)
except Exception as e: # pylint: disable=broad-except
conn.send((MessageType.exception, e))
break
@@ -114,16 +114,16 @@ class LambdaExecutor(Executor):
*,
use_threads: bool,
max_workers: int,
tqdm_kwargs: dict,
progress_kwargs: dict,
worker_initializer: Callable,
task: Callable,
task_arguments: Iterable,
task_finished: Callable,
):
if use_threads and max_workers == 1:
with self.pbar_class(**tqdm_kwargs) as pbar:
with self.pbar_class(**progress_kwargs) as pbar:
for args in task_arguments:
result = task(args)
result = task(*args)
task_finished(result, pbar)
return
@@ -157,7 +157,7 @@ class LambdaExecutor(Executor):
for process in processes:
process.start()
with self.pbar_class(**tqdm_kwargs) as pbar:
with self.pbar_class(**progress_kwargs) as pbar:
while connections:
for result in wait(connections):
if not isinstance(result, Connection):
+10 -4
View File
@@ -319,10 +319,16 @@ def remove_all_log_handlers(logger: logging.Logger) -> None:
def pikepdf_enable_mmap() -> None:
"""Enable pikepdf mmap."""
"""Enable pikepdf memory mapping."""
try:
if pikepdf._core.set_access_default_mmap(True):
log.debug("pikepdf mmap enabled")
pikepdf._core.set_access_default_mmap(True)
log.debug(
"pikepdf mmap "
+ (
'enabled'
if pikepdf._core.get_access_default_mmap() # type: ignore[attr-defined]
else 'disabled'
)
)
except AttributeError:
log.debug("pikepdf mmap not available")
log.debug("pikepdf mmap disabled")
-461
View File
@@ -1,461 +0,0 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MIT
"""Transform .hocr and page image to text PDF."""
from __future__ import annotations
import argparse
import os
import re
import warnings
from math import atan, cos, sin
from pathlib import Path
from typing import Any, NamedTuple
from xml.etree import ElementTree
with warnings.catch_warnings():
# reportlab uses deprecated load_module
# shim can be removed when we require reportlab >= 3.7
warnings.filterwarnings(
'ignore', category=DeprecationWarning, message=r".*load_module.*"
)
from reportlab.lib.colors import black, cyan, magenta, red
from reportlab.lib.units import inch
from reportlab.pdfgen.canvas import Canvas
# According to Wikipedia these languages are supported in the ISO-8859-1 character
# set, meaning reportlab can generate them and they are compatible with hocr,
# assuming Tesseract has the necessary languages installed. Note that there may
# not be language packs for them.
HOCR_OK_LANGS = frozenset(
[
# Languages fully covered by Latin-1:
'afr', # Afrikaans
'alb', # Albanian
'ast', # Leonese
'baq', # Basque
'bre', # Breton
'cos', # Corsican
'eng', # English
'eus', # Basque
'fao', # Faoese
'gla', # Scottish Gaelic
'glg', # Galician
'glv', # Manx
'ice', # Icelandic
'ind', # Indonesian
'isl', # Icelandic
'ita', # Italian
'ltz', # Luxembourgish
'mal', # Malay Rumi
'mga', # Irish
'nor', # Norwegian
'oci', # Occitan
'por', # Portugeuse
'roh', # Romansh
'sco', # Scots
'sma', # Sami
'spa', # Spanish
'sqi', # Albanian
'swa', # Swahili
'swe', # Swedish
'tgl', # Tagalog
'wln', # Walloon
# Languages supported by Latin-1 except for a few rare characters that OCR
# is probably not trained to recognize anyway:
'cat', # Catalan
'cym', # Welsh
'dan', # Danish
'deu', # German
'dut', # Dutch
'est', # Estonian
'fin', # Finnish
'fra', # French
'hun', # Hungarian
'kur', # Kurdish
'nld', # Dutch
'wel', # Welsh
]
)
Element = ElementTree.Element
class Rect(NamedTuple):
"""A rectangle for managing PDF coordinates."""
x1: Any
y1: Any
x2: Any
y2: Any
class HocrTransformError(Exception):
"""Error while applying hOCR transform."""
class HocrTransform:
"""A class for converting documents from the hOCR format.
For details of the hOCR format, see:
http://kba.cloud/hocr-spec/.
"""
box_pattern = re.compile(r'bbox((\s+\d+){4})')
baseline_pattern = re.compile(
r'''
baseline \s+
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
([\-\+]?\d+) # +/- int''',
re.VERBOSE,
)
ligatures = str.maketrans(
{'': 'ff', '': 'ffi', '': 'ffl', '': 'fi', '': 'fl'}
)
def __init__(self, *, hocr_filename: str | Path, dpi: float):
"""Initialize the HocrTransform object."""
self.dpi = dpi
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
# if the hOCR file has a namespace, ElementTree requires its use to
# find elements
matches = re.match(r'({.*})html', self.hocr.getroot().tag)
self.xmlns = ''
if matches:
self.xmlns = matches.group(1)
# get dimension in pt (not pixel!!!!) of the OCRed image
self.width, self.height = None, None
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
coords = self.element_coordinates(div)
pt_coords = self.pt_from_pixel(coords)
self.width = pt_coords.x2 - pt_coords.x1
self.height = pt_coords.y2 - pt_coords.y1
# there shouldn't be more than one, and if there is, we don't want
# it
break
if self.width is None or self.height is None:
raise HocrTransformError("hocr file is missing page dimensions")
def __str__(self): # pragma: no cover
"""Return the textual content of the HTML body."""
if self.hocr is None:
return ''
body = self.hocr.find(self._child_xpath('body'))
if body:
return self._get_element_text(body)
else:
return ''
def _get_element_text(self, element: Element):
"""Return the textual content of the element and its children."""
text = ''
if element.text is not None:
text += element.text
for child in element:
text += self._get_element_text(child)
if element.tail is not None:
text += element.tail
return text
@classmethod
def element_coordinates(cls, element: Element) -> Rect:
"""Get coordinates of the bounding box around an element."""
out = Rect._make(0 for _ in range(4))
if 'title' in element.attrib:
matches = cls.box_pattern.search(element.attrib['title'])
if matches:
coords = matches.group(1).split()
out = Rect._make(int(coords[n]) for n in range(4))
return out
@classmethod
def baseline(cls, element: Element) -> tuple[float, float]:
"""Get baseline's slope and intercept."""
if 'title' in element.attrib:
matches = cls.baseline_pattern.search(element.attrib['title'])
if matches:
return float(matches.group(1)), int(matches.group(2))
return (0.0, 0.0)
def pt_from_pixel(self, pxl) -> Rect:
"""Returns the quantity in PDF units (pt) given quantity in pixels."""
return Rect._make((c / self.dpi * inch) for c in pxl)
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
xpath = f".//{self.xmlns}{html_tag}"
if html_class:
xpath += f"[@class='{html_class}']"
return xpath
@classmethod
def replace_unsupported_chars(cls, s: str) -> str:
"""Replaces characters with those available in the Helvetica typeface."""
return s.translate(cls.ligatures)
def to_pdf(
self,
*,
out_filename: Path,
image_filename: Path | None = None,
show_bounding_boxes: bool = False,
fontname: str = "Helvetica",
invisible_text: bool = False,
interword_spaces: bool = False,
) -> None:
"""Creates a PDF file with an image superimposed on top of the text.
Text is positioned according to the bounding box of the lines in
the hOCR file.
The image need not be identical to the image used to create the hOCR
file.
It can have a lower resolution, different color mode, etc.
Arguments:
out_filename: Path of PDF to write.
image_filename: Image to use for this file. If omitted, the OCR text
is shown.
show_bounding_boxes: Show bounding boxes around various text regions,
for debugging.
fontname: Name of font to use.
invisible_text: If True, text is rendered invisible so that is
selectable but never drawn. If False, text is visible and may
be seen if the image is skipped or deleted in Acrobat.
interword_spaces: If True, insert spaces between words rather than
drawing each word without spaces. Generally this improves text
extraction.
"""
# create the PDF file
# page size in points (1/72 in.)
pdf = Canvas(
os.fspath(out_filename),
pagesize=(self.width, self.height),
pageCompression=1,
)
# draw bounding box for each paragraph
# light blue for bounding box of paragraph
pdf.setStrokeColor(cyan)
# light blue for bounding box of paragraph
pdf.setFillColor(cyan)
pdf.setLineWidth(0) # no line for bounding box
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
elemtxt = self._get_element_text(elem).rstrip()
if len(elemtxt) == 0:
continue
pxl_coords = self.element_coordinates(elem)
pt = self.pt_from_pixel(pxl_coords) # pylint: disable=invalid-name
# draw the bbox border
if show_bounding_boxes: # pragma: no cover
pdf.rect(
pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=1
)
found_lines = False
for line in (
element
for element in self.hocr.iterfind(self._child_xpath('span'))
if 'class' in element.attrib
and element.attrib['class'] in {'ocr_header', 'ocr_line', 'ocr_textfloat'}
):
found_lines = True
self._do_line(
pdf,
line,
"ocrx_word",
fontname,
invisible_text,
interword_spaces,
show_bounding_boxes,
)
if not found_lines:
# Tesseract did not report any lines (just words)
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
self._do_line(
pdf,
root,
"ocrx_word",
fontname,
invisible_text,
interword_spaces,
show_bounding_boxes,
)
# put the image on the page, scaled to fill the page
if image_filename is not None:
pdf.drawImage(
os.fspath(image_filename), 0, 0, width=self.width, height=self.height
)
# finish up the page and save it
pdf.showPage()
pdf.save()
@classmethod
def polyval(cls, poly, x): # pragma: no cover
"""Calculate the value of a polynomial at a point."""
return x * poly[0] + poly[1]
def _do_line(
self,
pdf: Canvas,
line: Element | None,
elemclass: str,
fontname: str,
invisible_text: bool,
interword_spaces: bool,
show_bounding_boxes: bool,
):
if line is None:
return
pxl_line_coords = self.element_coordinates(line)
line_box = self.pt_from_pixel(pxl_line_coords)
line_height = line_box.y2 - line_box.y1
slope, pxl_intercept = self.baseline(line)
if abs(slope) < 0.005:
slope = 0.0
angle = atan(slope)
cos_a, sin_a = cos(angle), sin(angle)
text = pdf.beginText()
intercept = pxl_intercept / self.dpi * inch
# Don't allow the font to break out of the bounding box. Division by
# cos_a accounts for extra clearance between the glyph's vertical axis
# on a sloped baseline and the edge of the bounding box.
fontsize = (line_height - abs(intercept)) / cos_a
text.setFont(fontname, fontsize)
if invisible_text:
text.setTextRenderMode(3) # Invisible (indicates OCR text)
# Intercept is normally negative, so this places it above the bottom
# of the line box
baseline_y2 = self.height - (line_box.y2 + intercept)
if show_bounding_boxes: # pragma: no cover
# draw the baseline in magenta, dashed
pdf.setDash()
pdf.setStrokeColor(magenta)
pdf.setLineWidth(0.5)
# negate slope because it is defined as a rise/run in pixel
# coordinates and page coordinates have the y axis flipped
pdf.line(
line_box.x1,
baseline_y2,
line_box.x2,
self.polyval((-slope, baseline_y2), line_box.x2 - line_box.x1),
)
# light green for bounding box of word/line
pdf.setDash(6, 3)
pdf.setStrokeColor(red)
text.setTextTransform(cos_a, -sin_a, sin_a, cos_a, line_box.x1, baseline_y2)
pdf.setFillColor(black) # text in black
elements = line.findall(self._child_xpath('span', elemclass))
for elem in elements:
elemtxt = self._get_element_text(elem).strip()
elemtxt = self.replace_unsupported_chars(elemtxt)
if elemtxt == '':
continue
pxl_coords = self.element_coordinates(elem)
box = self.pt_from_pixel(pxl_coords)
if interword_spaces:
# if `--interword-spaces` is true, append a space
# to the end of each text element to allow simpler PDF viewers
# such as PDF.js to better recognize words in search and copy
# and paste. Do not remove space from last word in line, even
# though it would look better, because it will interfere with
# naive text extraction. \n does not work either.
elemtxt += ' '
box = Rect._make(
(
box.x1,
line_box.y1,
box.x2 + pdf.stringWidth(' ', fontname, line_height),
line_box.y2,
)
)
box_width = box.x2 - box.x1
font_width = pdf.stringWidth(elemtxt, fontname, fontsize)
# draw the bbox border
if show_bounding_boxes: # pragma: no cover
pdf.rect(
box.x1, self.height - line_box.y2, box_width, line_height, fill=0
)
# Adjust relative position of cursor
# This is equivalent to:
# text.setTextOrigin(pt.x1, self.height - line_box.y2)
# but the former generates a full text reposition matrix (Tm) in the
# content stream while this issues a "offset" (Td) command.
# .moveCursor() is relative to start of the text line, where the
# "text line" means whatever reportlab defines it as. Do not use
# use .getCursor(), since moveCursor() rather unintuitively plans
# its moves relative to .getStartOfLine().
# For skewed lines, in the text transform we set up a rotated
# coordinate system, so we don't have to account for the
# incremental offset. Surprisingly most PDF viewers can handle this.
cursor = text.getStartOfLine()
dx = box.x1 - cursor[0]
dy = baseline_y2 - cursor[1]
text.moveCursor(dx, dy)
# If reportlab tells us this word is 0 units wide, our best seems
# to be to suppress this text
if font_width > 0:
text.setHorizScale(100 * box_width / font_width)
text.textOut(elemtxt)
pdf.drawText(text)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
parser.add_argument(
'-b',
'--boundingboxes',
action="store_true",
default=False,
help='Show bounding boxes borders',
)
parser.add_argument(
'-r',
'--resolution',
type=int,
default=300,
help='Resolution of the image that was OCRed',
)
parser.add_argument(
'-i',
'--image',
default=None,
help='Path to the image to be placed above the text',
)
parser.add_argument(
'--interword-spaces',
action='store_true',
default=False,
help='Add spaces between words',
)
parser.add_argument('hocrfile', help='Path to the hocr file to be parsed')
parser.add_argument('outputfile', help='Path to the PDF file to be generated')
args = parser.parse_args()
hocr = HocrTransform(hocr_filename=args.hocrfile, dpi=args.resolution)
hocr.to_pdf(
out_filename=args.outputfile,
image_filename=args.image,
show_bounding_boxes=args.boundingboxes,
interword_spaces=args.interword_spaces,
)
+18
View File
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MIT
"""Transform .hocr and page image to text PDF."""
from __future__ import annotations
from ocrmypdf.hocrtransform._hocr import (
DebugRenderOptions,
HocrTransform,
HocrTransformError,
)
__all__ = (
'HocrTransform',
'HocrTransformError',
'DebugRenderOptions',
)
+40
View File
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MIT
"""Simple CLI for testing HOCR."""
import argparse
from ocrmypdf.hocrtransform import HocrTransform
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Convert hocr file to PDF')
parser.add_argument(
'-b',
'--boundingboxes',
action="store_true",
default=False,
help='Show bounding boxes borders',
)
parser.add_argument(
'-r',
'--resolution',
type=int,
default=300,
help='Resolution of the image that was OCRed',
)
parser.add_argument(
'-i',
'--image',
default=None,
help='Path to the image to be placed above the text',
)
parser.add_argument('hocrfile', help='Path to the hocr file to be parsed')
parser.add_argument('outputfile', help='Path to the PDF file to be generated')
args = parser.parse_args()
hocr = HocrTransform(hocr_filename=args.hocrfile, dpi=args.resolution)
hocr.to_pdf(
out_filename=args.outputfile,
image_filename=args.image,
)
+141
View File
@@ -0,0 +1,141 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
import logging
import unicodedata
import zlib
from importlib.resources import files as package_files
from pikepdf import (
Dictionary,
Name,
Pdf,
)
from pikepdf.canvas import Font
log = logging.getLogger(__name__)
class EncodableFont(Font):
def text_encode(self, text: str) -> bytes:
raise NotImplementedError()
class GlyphlessFont(EncodableFont):
CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536)
GLYPHLESS_FONT_NAME = 'pdf.ttf'
GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes()
CHAR_ASPECT = 2
def __init__(self):
pass
def text_width(self, text: str, fontsize: float) -> float:
"""Estimate the width of a text string when rendered with the given font."""
# NFKC: split ligatures, combine diacritics
return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT)
def text_encode(self, text: str) -> bytes:
return text.encode('utf-16be')
def register(self, pdf: Pdf):
"""Register the glyphless font.
Create several data structures in the Pdf to describe the font. While it create
the data, a reference should be set in at least one page's /Resources dictionary
to retain the font in the output PDF and ensure it is usable on that page.
"""
PLACEHOLDER = Name.Placeholder
basefont = pdf.make_indirect(
Dictionary(
BaseFont=Name.GlyphLessFont,
DescendantFonts=[PLACEHOLDER],
Encoding=Name("/Identity-H"),
Subtype=Name.Type0,
ToUnicode=PLACEHOLDER,
Type=Name.Font,
)
)
cid_font_type2 = pdf.make_indirect(
Dictionary(
BaseFont=Name.GlyphLessFont,
CIDToGIDMap=PLACEHOLDER,
CIDSystemInfo=Dictionary(
Ordering="Identity",
Registry="Adobe",
Supplement=0,
),
FontDescriptor=PLACEHOLDER,
Subtype=Name.CIDFontType2,
Type=Name.Font,
DW=1000 // self.CHAR_ASPECT,
)
)
basefont.DescendantFonts = [cid_font_type2]
cid_font_type2.CIDToGIDMap = pdf.make_stream(
self.CID_TO_GID_DATA, Filter=Name.FlateDecode
)
basefont.ToUnicode = pdf.make_stream(
b"/CIDInit /ProcSet findresource begin\n"
b"12 dict begin\n"
b"begincmap\n"
b"/CIDSystemInfo\n"
b"<<\n"
b" /Registry (Adobe)\n"
b" /Ordering (UCS)\n"
b" /Supplement 0\n"
b">> def\n"
b"/CMapName /Adobe-Identify-UCS def\n"
b"/CMapType 2 def\n"
b"1 begincodespacerange\n"
b"<0000> <FFFF>\n"
b"endcodespacerange\n"
b"1 beginbfrange\n"
b"<0000> <FFFF> <0000>\n"
b"endbfrange\n"
b"endcmap\n"
b"CMapName currentdict /CMap defineresource pop\n"
b"end\n"
b"end\n"
)
font_descriptor = pdf.make_indirect(
Dictionary(
Ascent=1000,
CapHeight=1000,
Descent=-1,
Flags=5, # Fixed pitch and symbolic
FontBBox=[0, 0, 1000 // self.CHAR_ASPECT, 1000],
FontFile2=PLACEHOLDER,
FontName=Name.GlyphLessFont,
ItalicAngle=0,
StemV=80,
Type=Name.FontDescriptor,
)
)
font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT)
cid_font_type2.FontDescriptor = font_descriptor
return basefont
class Courier(EncodableFont):
"""Courier font."""
def text_width(self, text: str, fontsize: float) -> float:
"""Estimate the width of a text string when rendered with the given font."""
return len(text) * fontsize
def text_encode(self, text: str) -> bytes:
return text.encode('pdfdoc', errors='ignore')
def register(self, pdf: Pdf) -> Dictionary:
"""Register the font."""
return pdf.make_indirect(
Dictionary(
BaseFont=Name.Courier,
Type=Name.Font,
Subtype=Name.Type1,
)
)
+489
View File
@@ -0,0 +1,489 @@
# SPDX-FileCopyrightText: 2010 Jonathan Brinley
# SPDX-FileCopyrightText: 2013-2014 Julien Pfefferkorn
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MIT
"""hOCR transform implementation."""
from __future__ import annotations
import logging
import os
import re
import unicodedata
from dataclasses import dataclass
from itertools import pairwise
from math import atan, cos, pi
from pathlib import Path
from xml.etree import ElementTree
from pikepdf import Matrix, Name, Rectangle
from pikepdf.canvas import (
BLACK,
BLUE,
CYAN,
DARKGREEN,
GREEN,
MAGENTA,
RED,
Canvas,
Text,
TextDirection,
)
from ocrmypdf.hocrtransform._font import EncodableFont as Font
from ocrmypdf.hocrtransform._font import GlyphlessFont
log = logging.getLogger(__name__)
INCH = 72.0
Element = ElementTree.Element
@dataclass
class DebugRenderOptions:
"""A class for managing rendering options."""
render_paragraph_bbox: bool = False
render_baseline: bool = False
render_triangle: bool = False
render_line_bbox: bool = False
render_word_bbox: bool = False
render_space_bbox: bool = False
class HocrTransformError(Exception):
"""Error while applying hOCR transform."""
class HocrTransform:
"""A class for converting documents from the hOCR format.
For details of the hOCR format, see:
http://kba.cloud/hocr-spec/.
"""
box_pattern = re.compile(r'bbox (\d+) (\d+) (\d+) (\d+)')
baseline_pattern = re.compile(
r'''
baseline \s+
([\-\+]?\d*\.?\d*) \s+ # +/- decimal float
([\-\+]?\d+) # +/- int''',
re.VERBOSE,
)
def __init__(
self,
*,
hocr_filename: str | Path,
dpi: float,
debug: bool = False,
fontname: Name = Name("/f-0-0"),
font: Font = GlyphlessFont(),
debug_render_options: DebugRenderOptions | None = None,
):
"""Initialize the HocrTransform object."""
if debug:
log.warning("Use debug_render_options instead", DeprecationWarning)
self.render_options = DebugRenderOptions(
render_baseline=debug,
render_triangle=debug,
render_line_bbox=False,
render_word_bbox=debug,
render_paragraph_bbox=False,
render_space_bbox=False,
)
else:
self.render_options = debug_render_options or DebugRenderOptions()
self.dpi = dpi
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
self._fontname = fontname
self._font = font
# if the hOCR file has a namespace, ElementTree requires its use to
# find elements
matches = re.match(r'({.*})html', self.hocr.getroot().tag)
self.xmlns = ''
if matches:
self.xmlns = matches.group(1)
for div in self.hocr.findall(self._child_xpath('div', 'ocr_page')):
coords = self.element_coordinates(div)
if not coords:
raise HocrTransformError("hocr file is missing page dimensions")
self.width = (coords.urx - coords.llx) / (self.dpi / INCH)
self.height = (coords.ury - coords.lly) / (self.dpi / INCH)
# Stop after first div that has page coordinates
break
def _get_element_text(self, element: Element):
"""Return the textual content of the element and its children."""
text = ''
if element.text is not None:
text += element.text
for child in element:
text += self._get_element_text(child)
if element.tail is not None:
text += element.tail
return text
@classmethod
def element_coordinates(cls, element: Element) -> Rectangle | None:
"""Get coordinates of the bounding box around an element."""
matches = cls.box_pattern.search(element.attrib.get('title', ''))
if not matches:
return None
return Rectangle(
float(matches.group(1)), # llx = left
float(matches.group(2)), # lly = top
float(matches.group(3)), # urx = right
float(matches.group(4)), # ury = bottom
)
@classmethod
def baseline(cls, element: Element) -> tuple[float, float]:
"""Get baseline's slope and intercept."""
matches = cls.baseline_pattern.search(element.attrib.get('title', ''))
if not matches:
return (0.0, 0.0)
return float(matches.group(1)), int(matches.group(2))
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
xpath = f".//{self.xmlns}{html_tag}"
if html_class:
xpath += f"[@class='{html_class}']"
return xpath
@classmethod
def normalize_text(cls, s: str) -> str:
"""Normalize the given text using the NFKC normalization form."""
return unicodedata.normalize("NFKC", s)
def to_pdf(
self,
*,
out_filename: Path,
image_filename: Path | None = None,
invisible_text: bool = True,
) -> None:
"""Creates a PDF file with an image superimposed on top of the text.
Text is positioned according to the bounding box of the lines in
the hOCR file.
The image need not be identical to the image used to create the hOCR
file.
It can have a lower resolution, different color mode, etc.
Arguments:
out_filename: Path of PDF to write.
image_filename: Image to use for this file. If omitted, the OCR text
is shown.
invisible_text: If True, text is rendered invisible so that is
selectable but never drawn. If False, text is visible and may
be seen if the image is skipped or deleted in Acrobat.
"""
# create the PDF file
# page size in points (1/72 in.)
canvas = Canvas(page_size=(self.width, self.height))
canvas.add_font(self._fontname, self._font)
page_matrix = (
Matrix()
.translated(0, self.height)
.scaled(1, -1)
.scaled(INCH / self.dpi, INCH / self.dpi)
)
log.debug(page_matrix)
with canvas.do.save_state(cm=page_matrix):
self._debug_draw_paragraph_boxes(canvas)
found_lines = False
for par in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
for line in (
element
for element in par.iterfind(self._child_xpath('span'))
if 'class' in element.attrib
and element.attrib['class']
in {'ocr_header', 'ocr_line', 'ocr_textfloat'}
):
found_lines = True
direction = self._get_text_direction(par)
inject_word_breaks = self._get_inject_word_breaks(par)
self._do_line(
canvas,
line,
"ocrx_word",
invisible_text,
direction,
inject_word_breaks,
)
if not found_lines:
# Tesseract did not report any lines (just words)
root = self.hocr.find(self._child_xpath('div', 'ocr_page'))
direction = self._get_text_direction(root)
self._do_line(
canvas,
root,
"ocrx_word",
invisible_text,
direction,
True,
)
# put the image on the page, scaled to fill the page
if image_filename is not None:
canvas.do.draw_image(
image_filename, 0, 0, width=self.width, height=self.height
)
# finish up the page and save it
canvas.to_pdf().save(out_filename)
def _get_text_direction(self, par):
"""Get the text direction of the paragraph.
Arabic, Hebrew, Persian, are right-to-left languages.
"""
return (
TextDirection.RTL
if par.attrib.get('dir', 'ltr') == 'rtl'
else TextDirection.LTR
)
def _get_inject_word_breaks(self, par):
"""Determine whether word breaks should be injected.
In Chinese, Japanese, and Korean, word breaks are not injected, because
words are usually one or two characters and separators are usually explicit.
In all other languages, we inject word breaks to help word segmentation.
"""
lang = par.attrib.get('lang', '')
log.debug(lang)
if lang in {'chi_sim', 'chi_tra', 'jpn', 'kor'}:
return False
return True
@classmethod
def polyval(cls, poly, x): # pragma: no cover
"""Calculate the value of a polynomial at a point."""
return x * poly[0] + poly[1]
def _do_line(
self,
canvas: Canvas,
line: Element | None,
elemclass: str,
invisible_text: bool,
text_direction: TextDirection,
inject_word_breaks: bool,
):
"""Render the text for a given line.
The canvas's coordinate system must be configured so that hOCR pixel
coordinates are mapped to PDF coordinates.
"""
if line is None:
return
line_box = self.element_coordinates(line)
if not line_box:
return
assert line_box.ury > line_box.lly # lly is top, ury is bottom
self._debug_draw_line_bbox(canvas, line_box)
# Baseline is a polynomial (usually straight line) that describes the
# text baseline relative to the bottom left corner of the line bounding
# box.
bottom_left_corner = line_box.llx, line_box.ury
slope, intercept = self.baseline(line)
if abs(slope) < 0.005:
slope = 0.0
angle = atan(slope)
# Setup a new coordinate system on the line box's intercept and rotated by
# its slope.
line_matrix = (
Matrix()
.translated(*bottom_left_corner)
.translated(0, intercept)
.rotated(angle / pi * 180)
)
log.debug(line_matrix)
with canvas.do.save_state(cm=line_matrix):
text = Text(direction=text_direction)
# Don't allow the font to break out of the bounding box. Division by
# cos_a accounts for extra clearance between the glyph's vertical axis
# on a sloped baseline and the edge of the bounding box.
line_box_height = abs(line_box.height) / cos(angle)
fontsize = line_box_height + intercept
text.font(self._fontname, fontsize)
text.render_mode(3 if invisible_text else 0)
self._debug_draw_baseline(
canvas, line_matrix.inverse().transform(line_box), 0
)
canvas.do.fill_color(BLACK) # text in black
elements = line.findall(self._child_xpath('span', elemclass))
for elem, next_elem in pairwise(elements + [None]):
self._do_line_word(
canvas,
line_matrix,
text,
fontsize,
elem,
next_elem,
text_direction,
inject_word_breaks,
)
canvas.do.draw_text(text)
def _do_line_word(
self,
canvas: Canvas,
line_matrix: Matrix,
text: Text,
fontsize: float,
elem: Element,
next_elem: Element | None,
text_direction: TextDirection,
inject_word_breaks: bool,
):
"""Render the text for a single word."""
if elem is None:
return
elemtxt = self.normalize_text(self._get_element_text(elem).strip())
if elemtxt == '':
return
hocr_box = self.element_coordinates(elem)
if hocr_box is None:
return
box = line_matrix.inverse().transform(hocr_box)
font_width = self._font.text_width(elemtxt, fontsize)
# Debug sketches
self._debug_draw_word_triangle(canvas, box)
self._debug_draw_word_bbox(canvas, box)
# If this word is 0 units wide, our best bet seems to be to suppress this text
if text_direction == TextDirection.RTL:
log.info("RTL: %s", elemtxt)
if font_width > 0:
if text_direction == TextDirection.LTR:
text.text_transform(Matrix(1, 0, 0, -1, box.llx, 0))
elif text_direction == TextDirection.RTL:
text.text_transform(Matrix(-1, 0, 0, -1, box.llx + box.width, 0))
text.horiz_scale(100 * box.width / font_width)
text.show(self._font.text_encode(elemtxt))
# Get coordinates of the next word (if there is one)
hocr_next_box = (
self.element_coordinates(next_elem) if next_elem is not None else None
)
if hocr_next_box is None:
return
# Render a space this word and the next word. The explicit space helps
# PDF viewers identify the word break, and horizontally scaling it to
# occupy the space the between the words helps the PDF viewer
# avoid combiningthewordstogether.
if not inject_word_breaks:
return
next_box = line_matrix.inverse().transform(hocr_next_box)
if text_direction == TextDirection.LTR:
space_box = Rectangle(box.urx, box.lly, next_box.llx, next_box.ury)
elif text_direction == TextDirection.RTL:
space_box = Rectangle(next_box.urx, box.lly, box.llx, next_box.ury)
self._debug_draw_space_bbox(canvas, space_box)
space_width = self._font.text_width(' ', fontsize)
if space_width > 0:
if text_direction == TextDirection.LTR:
text.text_transform(Matrix(1, 0, 0, -1, space_box.llx, 0))
elif text_direction == TextDirection.RTL:
text.text_transform(
Matrix(-1, 0, 0, -1, space_box.llx + space_box.width, 0)
)
text.horiz_scale(100 * space_box.width / space_width)
text.show(self._font.text_encode(' '))
def _debug_draw_paragraph_boxes(self, canvas: Canvas, color=CYAN):
"""Draw boxes around paragraphs in the document."""
if not self.render_options.render_paragraph_bbox: # pragma: no cover
return
with canvas.do.save_state():
# draw box around paragraph
canvas.do.stroke_color(color).line_width(0.1)
for elem in self.hocr.iterfind(self._child_xpath('p', 'ocr_par')):
elemtxt = self._get_element_text(elem).strip()
if len(elemtxt) == 0:
continue
ocr_par = self.element_coordinates(elem)
if ocr_par is None:
continue
canvas.do.rect(
ocr_par.llx, ocr_par.lly, ocr_par.width, ocr_par.height, fill=0
)
def _debug_draw_line_bbox(self, canvas: Canvas, line_box: Rectangle, color=BLUE):
"""Render the bounding box of a text line."""
if not self.render_options.render_line_bbox: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(0.15).rect(
line_box.llx, line_box.lly, line_box.width, line_box.height, fill=0
)
def _debug_draw_word_triangle(
self, canvas: Canvas, box: Rectangle, color=RED, line_width=0.1
):
"""Render a triangle that conveys word height and drawing direction."""
if not self.render_options.render_triangle: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(line_width).line(
box.llx, box.lly, box.urx, box.lly
).line(box.urx, box.lly, box.llx, box.ury).line(
box.llx, box.lly, box.llx, box.ury
)
def _debug_draw_word_bbox(
self, canvas: Canvas, box: Rectangle, color=GREEN, line_width=0.1
):
"""Render a box depicting the word."""
if not self.render_options.render_word_bbox: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(line_width).rect(
box.llx, box.lly, box.width, box.height, fill=0
)
def _debug_draw_space_bbox(
self, canvas: Canvas, box: Rectangle, color=DARKGREEN, line_width=0.1
):
"""Render a box depicting the space between two words."""
if not self.render_options.render_space_bbox: # pragma: no cover
return
with canvas.do.save_state():
canvas.do.fill_color(color).line_width(line_width).rect(
box.llx, box.lly, box.width, box.height, fill=1
)
def _debug_draw_baseline(
self,
canvas: Canvas,
line_box: Rectangle,
baseline_lly,
color=MAGENTA,
line_width=0.25,
):
"""Render the text baseline."""
if not self.render_options.render_baseline:
return
with canvas.do.save_state():
canvas.do.stroke_color(color).line_width(line_width).line(
line_box.llx,
baseline_lly,
line_box.urx,
baseline_lly,
)
+6 -14
View File
@@ -7,17 +7,9 @@ from __future__ import annotations
import logging
from math import floor, sqrt
from typing import Optional
from PIL import Image
# While from __future__ import annotations, we use singledispatch here, which
# does not support annotations. Disable check about using old-style typing
# until Python 3.10, OR when drop singledispatch in ocrmypdf 15.
# ruff: noqa: UP006
# ruff: noqa: UP007
log = logging.getLogger(__name__)
@@ -38,9 +30,9 @@ def _calculate_downsample(
image_size: tuple[int, int],
bytes_per_pixel: int,
*,
max_size: Optional[tuple[int, int]] = None,
max_pixels: Optional[int] = None,
max_bytes: Optional[int] = None,
max_size: tuple[int, int] | None = None,
max_pixels: int | None = None,
max_bytes: int | None = None,
) -> tuple[int, int]:
"""Calculate image size required to downsample an image to fit limits.
@@ -98,9 +90,9 @@ def _calculate_downsample(
def calculate_downsample(
image: Image.Image,
*,
max_size: Optional[tuple[int, int]] = None,
max_pixels: Optional[int] = None,
max_bytes: Optional[int] = None,
max_size: tuple[int, int] | None = None,
max_pixels: int | None = None,
max_bytes: int | None = None,
) -> tuple[int, int]:
"""Calculate image size required to downsample an image to fit limits.
+849
View File
@@ -0,0 +1,849 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Language codes and names from ISO 639.
Derived from
https://www.loc.gov/standards/iso639-2/ascii_8bits.html
"""
from typing import NamedTuple
class ISOCodeData(NamedTuple):
"""Data for a single ISO 639 code."""
alt: str
alpha_2: str
english: str
french: str
ISO_639_3 = {
'aar': ISOCodeData('', 'aa', 'Afar', 'afar'),
'abk': ISOCodeData('', 'ab', 'Abkhazian', 'abkhaze'),
'ace': ISOCodeData('', '', 'Achinese', 'aceh'),
'ach': ISOCodeData('', '', 'Acoli', 'acoli'),
'ada': ISOCodeData('', '', 'Adangme', 'adangme'),
'ady': ISOCodeData('', '', 'Adyghe; Adygei', 'adyghé'),
'afa': ISOCodeData(
'',
'',
'Afro-Asiatic languages',
'afro-asiatiques, langues',
),
'afh': ISOCodeData('', '', 'Afrihili', 'afrihili'),
'afr': ISOCodeData('', 'af', 'Afrikaans', 'afrikaans'),
'ain': ISOCodeData('', '', 'Ainu', 'aïnou'),
'aka': ISOCodeData('', 'ak', 'Akan', 'akan'),
'akk': ISOCodeData('', '', 'Akkadian', 'akkadien'),
'alb': ISOCodeData('sqi', 'sq', 'Albanian', 'albanais'),
'ale': ISOCodeData('', '', 'Aleut', 'aléoute'),
'alg': ISOCodeData(
'',
'',
'Algonquian languages',
'algonquines, langues',
),
'alt': ISOCodeData('', '', 'Southern Altai', 'altai du Sud'),
'amh': ISOCodeData('', 'am', 'Amharic', 'amharique'),
'ang': ISOCodeData(
'',
'',
'English, Old (ca.450-1100)',
'anglo-saxon (ca.450-1100)',
),
'anp': ISOCodeData('', '', 'Angika', 'angika'),
'apa': ISOCodeData('', '', 'Apache languages', 'apaches, langues'),
'ara': ISOCodeData('', 'ar', 'Arabic', 'arabe'),
'arc': ISOCodeData(
'',
'',
'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)',
"araméen d'empire (700-300 BCE)",
),
'arg': ISOCodeData('', 'an', 'Aragonese', 'aragonais'),
'arm': ISOCodeData('hye', 'hy', 'Armenian', 'arménien'),
'arn': ISOCodeData(
'',
'',
'Mapudungun; Mapuche',
'mapudungun; mapuche; mapuce',
),
'arp': ISOCodeData('', '', 'Arapaho', 'arapaho'),
'art': ISOCodeData(
'',
'',
'Artificial languages',
'artificielles, langues',
),
'arw': ISOCodeData('', '', 'Arawak', 'arawak'),
'asm': ISOCodeData('', 'as', 'Assamese', 'assamais'),
'ast': ISOCodeData(
'',
'',
'Asturian; Bable; Leonese; Asturleonese',
'asturien; bable; léonais; asturoléonais',
),
'ath': ISOCodeData(
'',
'',
'Athapascan languages',
'athapascanes, langues',
),
'aus': ISOCodeData(
'',
'',
'Australian languages',
'australiennes, langues',
),
'ava': ISOCodeData('', 'av', 'Avaric', 'avar'),
'ave': ISOCodeData('', 'ae', 'Avestan', 'avestique'),
'awa': ISOCodeData('', '', 'Awadhi', 'awadhi'),
'aym': ISOCodeData('', 'ay', 'Aymara', 'aymara'),
'aze': ISOCodeData('', 'az', 'Azerbaijani', 'azéri'),
'bad': ISOCodeData('', '', 'Banda languages', 'banda, langues'),
'bai': ISOCodeData('', '', 'Bamileke languages', 'bamiléké, langues'),
'bak': ISOCodeData('', 'ba', 'Bashkir', 'bachkir'),
'bal': ISOCodeData('', '', 'Baluchi', 'baloutchi'),
'bam': ISOCodeData('', 'bm', 'Bambara', 'bambara'),
'ban': ISOCodeData('', '', 'Balinese', 'balinais'),
'baq': ISOCodeData('eus', 'eu', 'Basque', 'basque'),
'bas': ISOCodeData('', '', 'Basa', 'basa'),
'bat': ISOCodeData('', '', 'Baltic languages', 'baltes, langues'),
'bej': ISOCodeData('', '', 'Beja; Bedawiyet', 'bedja'),
'bel': ISOCodeData('', 'be', 'Belarusian', 'biélorusse'),
'bem': ISOCodeData('', '', 'Bemba', 'bemba'),
'ben': ISOCodeData('', 'bn', 'Bengali', 'bengali'),
'ber': ISOCodeData('', '', 'Berber languages', 'berbères, langues'),
'bho': ISOCodeData('', '', 'Bhojpuri', 'bhojpuri'),
'bih': ISOCodeData('', 'bh', 'Bihari languages', 'langues biharis'),
'bik': ISOCodeData('', '', 'Bikol', 'bikol'),
'bin': ISOCodeData('', '', 'Bini; Edo', 'bini; edo'),
'bis': ISOCodeData('', 'bi', 'Bislama', 'bichlamar'),
'bla': ISOCodeData('', '', 'Siksika', 'blackfoot'),
'bnt': ISOCodeData('', '', 'Bantu languages', 'bantou, langues'),
'bos': ISOCodeData('', 'bs', 'Bosnian', 'bosniaque'),
'bra': ISOCodeData('', '', 'Braj', 'braj'),
'bre': ISOCodeData('', 'br', 'Breton', 'breton'),
'btk': ISOCodeData('', '', 'Batak languages', 'batak, langues'),
'bua': ISOCodeData('', '', 'Buriat', 'bouriate'),
'bug': ISOCodeData('', '', 'Buginese', 'bugi'),
'bul': ISOCodeData('', 'bg', 'Bulgarian', 'bulgare'),
'bur': ISOCodeData('mya', 'my', 'Burmese', 'birman'),
'byn': ISOCodeData('', '', 'Blin; Bilin', 'blin; bilen'),
'cad': ISOCodeData('', '', 'Caddo', 'caddo'),
'cai': ISOCodeData(
'',
'',
'Central American Indian languages',
"amérindiennes de L'Amérique centrale, langues",
),
'car': ISOCodeData('', '', 'Galibi Carib', 'karib; galibi; carib'),
'cat': ISOCodeData('', 'ca', 'Catalan; Valencian', 'catalan; valencien'),
'cau': ISOCodeData(
'',
'',
'Caucasian languages',
'caucasiennes, langues',
),
'ceb': ISOCodeData('', '', 'Cebuano', 'cebuano'),
'cel': ISOCodeData(
'',
'',
'Celtic languages',
'celtiques, langues; celtes, langues',
),
'cha': ISOCodeData('', 'ch', 'Chamorro', 'chamorro'),
'chb': ISOCodeData('', '', 'Chibcha', 'chibcha'),
'che': ISOCodeData('', 'ce', 'Chechen', 'tchétchène'),
'chg': ISOCodeData('', '', 'Chagatai', 'djaghataï'),
'chi': ISOCodeData('zho', 'zh', 'Chinese', 'chinois'),
'chk': ISOCodeData('', '', 'Chuukese', 'chuuk'),
'chm': ISOCodeData('', '', 'Mari', 'mari'),
'chn': ISOCodeData('', '', 'Chinook jargon', 'chinook, jargon'),
'cho': ISOCodeData('', '', 'Choctaw', 'choctaw'),
'chp': ISOCodeData('', '', 'Chipewyan; Dene Suline', 'chipewyan'),
'chr': ISOCodeData('', '', 'Cherokee', 'cherokee'),
'chu': ISOCodeData(
'',
'cu',
('Church Slavic; Old Slavonic; Church Slavonic;'
' Old Bulgarian; Old Church Slavonic'),
"slavon d'église; vieux slave; slavon liturgique; vieux bulgare",
),
'chv': ISOCodeData('', 'cv', 'Chuvash', 'tchouvache'),
'chy': ISOCodeData('', '', 'Cheyenne', 'cheyenne'),
'cmc': ISOCodeData('', '', 'Chamic languages', 'chames, langues'),
'cnr': ISOCodeData('', '', 'Montenegrin', 'monténégrin'),
'cop': ISOCodeData('', '', 'Coptic', 'copte'),
'cor': ISOCodeData('', 'kw', 'Cornish', 'cornique'),
'cos': ISOCodeData('', 'co', 'Corsican', 'corse'),
'cpe': ISOCodeData(
'',
'',
'Creoles and pidgins, English based',
"créoles et pidgins basés sur l'anglais",
),
'cpf': ISOCodeData(
'',
'',
'Creoles and pidgins, French-based',
'créoles et pidgins basés sur le français',
),
'cpp': ISOCodeData(
'',
'',
'Creoles and pidgins, Portuguese-based',
'créoles et pidgins basés sur le portugais',
),
'cre': ISOCodeData('', 'cr', 'Cree', 'cree'),
'crh': ISOCodeData(
'',
'',
'Crimean Tatar; Crimean Turkish',
'tatar de Crimé',
),
'crp': ISOCodeData('', '', 'Creoles and pidgins', 'créoles et pidgins'),
'csb': ISOCodeData('', '', 'Kashubian', 'kachoube'),
'cus': ISOCodeData('', '', 'Cushitic languages', 'couchitiques, langues'),
'cze': ISOCodeData('ces', 'cs', 'Czech', 'tchèque'),
'dak': ISOCodeData('', '', 'Dakota', 'dakota'),
'dan': ISOCodeData('', 'da', 'Danish', 'danois'),
'dar': ISOCodeData('', '', 'Dargwa', 'dargwa'),
'day': ISOCodeData('', '', 'Land Dayak languages', 'dayak, langues'),
'del': ISOCodeData('', '', 'Delaware', 'delaware'),
'den': ISOCodeData('', '', 'Slave (Athapascan)', 'esclave (athapascan)'),
'dgr': ISOCodeData('', '', 'Dogrib', 'dogrib'),
'din': ISOCodeData('', '', 'Dinka', 'dinka'),
'div': ISOCodeData('', 'dv', 'Divehi; Dhivehi; Maldivian', 'maldivien'),
'doi': ISOCodeData('', '', 'Dogri', 'dogri'),
'dra': ISOCodeData(
'',
'',
'Dravidian languages',
'dravidiennes, langues',
),
'dsb': ISOCodeData('', '', 'Lower Sorbian', 'bas-sorabe'),
'dua': ISOCodeData('', '', 'Duala', 'douala'),
'dum': ISOCodeData(
'',
'',
'Dutch, Middle (ca.1050-1350)',
'néerlandais moyen (ca. 1050-1350)',
),
'dut': ISOCodeData('nld', 'nl', 'Dutch; Flemish', 'néerlandais; flamand'),
'dyu': ISOCodeData('', '', 'Dyula', 'dioula'),
'dzo': ISOCodeData('', 'dz', 'Dzongkha', 'dzongkha'),
'efi': ISOCodeData('', '', 'Efik', 'efik'),
'egy': ISOCodeData('', '', 'Egyptian (Ancient)', 'égyptien'),
'eka': ISOCodeData('', '', 'Ekajuk', 'ekajuk'),
'elx': ISOCodeData('', '', 'Elamite', 'élamite'),
'eng': ISOCodeData('', 'en', 'English', 'anglais'),
'enm': ISOCodeData(
'',
'',
'English, Middle (1100-1500)',
'anglais moyen (1100-1500)',
),
'epo': ISOCodeData('', 'eo', 'Esperanto', 'espéranto'),
'est': ISOCodeData('', 'et', 'Estonian', 'estonien'),
'ewe': ISOCodeData('', 'ee', 'Ewe', 'éwé'),
'ewo': ISOCodeData('', '', 'Ewondo', 'éwondo'),
'fan': ISOCodeData('', '', 'Fang', 'fang'),
'fao': ISOCodeData('', 'fo', 'Faroese', 'féroïen'),
'fat': ISOCodeData('', '', 'Fanti', 'fanti'),
'fij': ISOCodeData('', 'fj', 'Fijian', 'fidjien'),
'fil': ISOCodeData('', '', 'Filipino; Pilipino', 'filipino; pilipino'),
'fin': ISOCodeData('', 'fi', 'Finnish', 'finnois'),
'fiu': ISOCodeData(
'',
'',
'Finno-Ugrian languages',
'finno-ougriennes, langues',
),
'fon': ISOCodeData('', '', 'Fon', 'fon'),
'fre': ISOCodeData('fra', 'fr', 'French', 'français'),
'frm': ISOCodeData(
'',
'',
'French, Middle (ca.1400-1600)',
'français moyen (1400-1600)',
),
'fro': ISOCodeData(
'',
'',
'French, Old (842-ca.1400)',
'français ancien (842-ca.1400)',
),
'frr': ISOCodeData('', '', 'Northern Frisian', 'frison septentrional'),
'frs': ISOCodeData('', '', 'Eastern Frisian', 'frison oriental'),
'fry': ISOCodeData('', 'fy', 'Western Frisian', 'frison occidental'),
'ful': ISOCodeData('', 'ff', 'Fulah', 'peul'),
'fur': ISOCodeData('', '', 'Friulian', 'frioulan'),
'gaa': ISOCodeData('', '', 'Ga', 'ga'),
'gay': ISOCodeData('', '', 'Gayo', 'gayo'),
'gba': ISOCodeData('', '', 'Gbaya', 'gbaya'),
'gem': ISOCodeData('', '', 'Germanic languages', 'germaniques, langues'),
'geo': ISOCodeData('kat', 'ka', 'Georgian', 'géorgien'),
'ger': ISOCodeData('deu', 'de', 'German', 'allemand'),
'gez': ISOCodeData('', '', 'Geez', 'guèze'),
'gil': ISOCodeData('', '', 'Gilbertese', 'kiribati'),
'gla': ISOCodeData(
'',
'gd',
'Gaelic; Scottish Gaelic',
'gaélique; gaélique écossais',
),
'gle': ISOCodeData('', 'ga', 'Irish', 'irlandais'),
'glg': ISOCodeData('', 'gl', 'Galician', 'galicien'),
'glv': ISOCodeData('', 'gv', 'Manx', 'manx; mannois'),
'gmh': ISOCodeData(
'',
'',
'German, Middle High (ca.1050-1500)',
'allemand, moyen haut (ca. 1050-1500)',
),
'goh': ISOCodeData(
'',
'',
'German, Old High (ca.750-1050)',
'allemand, vieux haut (ca. 750-1050)',
),
'gon': ISOCodeData('', '', 'Gondi', 'gond'),
'gor': ISOCodeData('', '', 'Gorontalo', 'gorontalo'),
'got': ISOCodeData('', '', 'Gothic', 'gothique'),
'grb': ISOCodeData('', '', 'Grebo', 'grebo'),
'grc': ISOCodeData(
'',
'',
'Greek, Ancient (to 1453)',
"grec ancien (jusqu'à 1453)",
),
'gre': ISOCodeData(
'ell',
'el',
'Greek, Modern (1453-)',
'grec moderne (après 1453)',
),
'grn': ISOCodeData('', 'gn', 'Guarani', 'guarani'),
'gsw': ISOCodeData(
'',
'',
'Swiss German; Alemannic; Alsatian',
'suisse alémanique; alémanique; alsacien',
),
'guj': ISOCodeData('', 'gu', 'Gujarati', 'goudjrati'),
'gwi': ISOCodeData('', '', "Gwich'in", "gwich'in"),
'hai': ISOCodeData('', '', 'Haida', 'haida'),
'hat': ISOCodeData(
'',
'ht',
'Haitian; Haitian Creole',
'haïtien; créole haïtien',
),
'hau': ISOCodeData('', 'ha', 'Hausa', 'haoussa'),
'haw': ISOCodeData('', '', 'Hawaiian', 'hawaïen'),
'heb': ISOCodeData('', 'he', 'Hebrew', 'hébreu'),
'her': ISOCodeData('', 'hz', 'Herero', 'herero'),
'hil': ISOCodeData('', '', 'Hiligaynon', 'hiligaynon'),
'him': ISOCodeData(
'',
'',
'Himachali languages; Western Pahari languages',
'langues himachalis; langues paharis occidentales',
),
'hin': ISOCodeData('', 'hi', 'Hindi', 'hindi'),
'hit': ISOCodeData('', '', 'Hittite', 'hittite'),
'hmn': ISOCodeData('', '', 'Hmong; Mong', 'hmong'),
'hmo': ISOCodeData('', 'ho', 'Hiri Motu', 'hiri motu'),
'hrv': ISOCodeData('', 'hr', 'Croatian', 'croate'),
'hsb': ISOCodeData('', '', 'Upper Sorbian', 'haut-sorabe'),
'hun': ISOCodeData('', 'hu', 'Hungarian', 'hongrois'),
'hup': ISOCodeData('', '', 'Hupa', 'hupa'),
'iba': ISOCodeData('', '', 'Iban', 'iban'),
'ibo': ISOCodeData('', 'ig', 'Igbo', 'igbo'),
'ice': ISOCodeData('isl', 'is', 'Icelandic', 'islandais'),
'ido': ISOCodeData('', 'io', 'Ido', 'ido'),
'iii': ISOCodeData('', 'ii', 'Sichuan Yi; Nuosu', 'yi de Sichuan'),
'ijo': ISOCodeData('', '', 'Ijo languages', 'ijo, langues'),
'iku': ISOCodeData('', 'iu', 'Inuktitut', 'inuktitut'),
'ile': ISOCodeData('', 'ie', 'Interlingue; Occidental', 'interlingue'),
'ilo': ISOCodeData('', '', 'Iloko', 'ilocano'),
'ina': ISOCodeData(
'',
'ia',
'Interlingua (International Auxiliary Language Association)',
'interlingua (langue auxiliaire internationale)',
),
'inc': ISOCodeData('', '', 'Indic languages', 'indo-aryennes, langues'),
'ind': ISOCodeData('', 'id', 'Indonesian', 'indonésien'),
'ine': ISOCodeData(
'',
'',
'Indo-European languages',
'indo-européennes, langues',
),
'inh': ISOCodeData('', '', 'Ingush', 'ingouche'),
'ipk': ISOCodeData('', 'ik', 'Inupiaq', 'inupiaq'),
'ira': ISOCodeData('', '', 'Iranian languages', 'iraniennes, langues'),
'iro': ISOCodeData('', '', 'Iroquoian languages', 'iroquoises, langues'),
'ita': ISOCodeData('', 'it', 'Italian', 'italien'),
'jav': ISOCodeData('', 'jv', 'Javanese', 'javanais'),
'jbo': ISOCodeData('', '', 'Lojban', 'lojban'),
'jpn': ISOCodeData('', 'ja', 'Japanese', 'japonais'),
'jpr': ISOCodeData('', '', 'Judeo-Persian', 'judéo-persan'),
'jrb': ISOCodeData('', '', 'Judeo-Arabic', 'judéo-arabe'),
'kaa': ISOCodeData('', '', 'Kara-Kalpak', 'karakalpak'),
'kab': ISOCodeData('', '', 'Kabyle', 'kabyle'),
'kac': ISOCodeData('', '', 'Kachin; Jingpho', 'kachin; jingpho'),
'kal': ISOCodeData('', 'kl', 'Kalaallisut; Greenlandic', 'groenlandais'),
'kam': ISOCodeData('', '', 'Kamba', 'kamba'),
'kan': ISOCodeData('', 'kn', 'Kannada', 'kannada'),
'kar': ISOCodeData('', '', 'Karen languages', 'karen, langues'),
'kas': ISOCodeData('', 'ks', 'Kashmiri', 'kashmiri'),
'kau': ISOCodeData('', 'kr', 'Kanuri', 'kanouri'),
'kaw': ISOCodeData('', '', 'Kawi', 'kawi'),
'kaz': ISOCodeData('', 'kk', 'Kazakh', 'kazakh'),
'kbd': ISOCodeData('', '', 'Kabardian', 'kabardien'),
'kha': ISOCodeData('', '', 'Khasi', 'khasi'),
'khi': ISOCodeData('', '', 'Khoisan languages', 'khoïsan, langues'),
'khm': ISOCodeData('', 'km', 'Central Khmer', 'khmer central'),
'kho': ISOCodeData('', '', 'Khotanese; Sakan', 'khotanais; sakan'),
'kik': ISOCodeData('', 'ki', 'Kikuyu; Gikuyu', 'kikuyu'),
'kin': ISOCodeData('', 'rw', 'Kinyarwanda', 'rwanda'),
'kir': ISOCodeData('', 'ky', 'Kirghiz; Kyrgyz', 'kirghiz'),
'kmb': ISOCodeData('', '', 'Kimbundu', 'kimbundu'),
'kok': ISOCodeData('', '', 'Konkani', 'konkani'),
'kom': ISOCodeData('', 'kv', 'Komi', 'kom'),
'kon': ISOCodeData('', 'kg', 'Kongo', 'kongo'),
'kor': ISOCodeData('', 'ko', 'Korean', 'coréen'),
'kos': ISOCodeData('', '', 'Kosraean', 'kosrae'),
'kpe': ISOCodeData('', '', 'Kpelle', 'kpellé'),
'krc': ISOCodeData('', '', 'Karachay-Balkar', 'karatchai balkar'),
'krl': ISOCodeData('', '', 'Karelian', 'carélien'),
'kro': ISOCodeData('', '', 'Kru languages', 'krou, langues'),
'kru': ISOCodeData('', '', 'Kurukh', 'kurukh'),
'kua': ISOCodeData('', 'kj', 'Kuanyama; Kwanyama', 'kuanyama; kwanyama'),
'kum': ISOCodeData('', '', 'Kumyk', 'koumyk'),
'kur': ISOCodeData('', 'ku', 'Kurdish', 'kurde'),
'kut': ISOCodeData('', '', 'Kutenai', 'kutenai'),
'lad': ISOCodeData('', '', 'Ladino', 'judéo-espagnol'),
'lah': ISOCodeData('', '', 'Lahnda', 'lahnda'),
'lam': ISOCodeData('', '', 'Lamba', 'lamba'),
'lao': ISOCodeData('', 'lo', 'Lao', 'lao'),
'lat': ISOCodeData('', 'la', 'Latin', 'latin'),
'lav': ISOCodeData('', 'lv', 'Latvian', 'letton'),
'lez': ISOCodeData('', '', 'Lezghian', 'lezghien'),
'lim': ISOCodeData(
'',
'li',
'Limburgan; Limburger; Limburgish',
'limbourgeois',
),
'lin': ISOCodeData('', 'ln', 'Lingala', 'lingala'),
'lit': ISOCodeData('', 'lt', 'Lithuanian', 'lituanien'),
'lol': ISOCodeData('', '', 'Mongo', 'mongo'),
'loz': ISOCodeData('', '', 'Lozi', 'lozi'),
'ltz': ISOCodeData(
'',
'lb',
'Luxembourgish; Letzeburgesch',
'luxembourgeois',
),
'lua': ISOCodeData('', '', 'Luba-Lulua', 'luba-lulua'),
'lub': ISOCodeData('', 'lu', 'Luba-Katanga', 'luba-katanga'),
'lug': ISOCodeData('', 'lg', 'Ganda', 'ganda'),
'lui': ISOCodeData('', '', 'Luiseno', 'luiseno'),
'lun': ISOCodeData('', '', 'Lunda', 'lunda'),
'luo': ISOCodeData(
'',
'',
'Luo (Kenya and Tanzania)',
'luo (Kenya et Tanzanie)',
),
'lus': ISOCodeData('', '', 'Lushai', 'lushai'),
'mac': ISOCodeData('mkd', 'mk', 'Macedonian', 'macédonien'),
'mad': ISOCodeData('', '', 'Madurese', 'madourais'),
'mag': ISOCodeData('', '', 'Magahi', 'magahi'),
'mah': ISOCodeData('', 'mh', 'Marshallese', 'marshall'),
'mai': ISOCodeData('', '', 'Maithili', 'maithili'),
'mak': ISOCodeData('', '', 'Makasar', 'makassar'),
'mal': ISOCodeData('', 'ml', 'Malayalam', 'malayalam'),
'man': ISOCodeData('', '', 'Mandingo', 'mandingue'),
'mao': ISOCodeData('mri', 'mi', 'Maori', 'maori'),
'map': ISOCodeData(
'',
'',
'Austronesian languages',
'austronésiennes, langues',
),
'mar': ISOCodeData('', 'mr', 'Marathi', 'marathe'),
'mas': ISOCodeData('', '', 'Masai', 'massaï'),
'may': ISOCodeData('msa', 'ms', 'Malay', 'malais'),
'mdf': ISOCodeData('', '', 'Moksha', 'moksa'),
'mdr': ISOCodeData('', '', 'Mandar', 'mandar'),
'men': ISOCodeData('', '', 'Mende', 'mendé'),
'mga': ISOCodeData(
'',
'',
'Irish, Middle (900-1200)',
'irlandais moyen (900-1200)',
),
'mic': ISOCodeData('', '', "Mi'kmaq; Micmac", "mi'kmaq; micmac"),
'min': ISOCodeData('', '', 'Minangkabau', 'minangkabau'),
'mis': ISOCodeData('', '', 'Uncoded languages', 'langues non codées'),
'mkh': ISOCodeData('', '', 'Mon-Khmer languages', 'môn-khmer, langues'),
'mlg': ISOCodeData('', 'mg', 'Malagasy', 'malgache'),
'mlt': ISOCodeData('', 'mt', 'Maltese', 'maltais'),
'mnc': ISOCodeData('', '', 'Manchu', 'mandchou'),
'mni': ISOCodeData('', '', 'Manipuri', 'manipuri'),
'mno': ISOCodeData('', '', 'Manobo languages', 'manobo, langues'),
'moh': ISOCodeData('', '', 'Mohawk', 'mohawk'),
'mon': ISOCodeData('', 'mn', 'Mongolian', 'mongol'),
'mos': ISOCodeData('', '', 'Mossi', 'moré'),
'mul': ISOCodeData('', '', 'Multiple languages', 'multilingue'),
'mun': ISOCodeData('', '', 'Munda languages', 'mounda, langues'),
'mus': ISOCodeData('', '', 'Creek', 'muskogee'),
'mwl': ISOCodeData('', '', 'Mirandese', 'mirandais'),
'mwr': ISOCodeData('', '', 'Marwari', 'marvari'),
'myn': ISOCodeData('', '', 'Mayan languages', 'maya, langues'),
'myv': ISOCodeData('', '', 'Erzya', 'erza'),
'nah': ISOCodeData('', '', 'Nahuatl languages', 'nahuatl, langues'),
'nai': ISOCodeData(
'',
'',
'North American Indian languages',
'nord-amérindiennes, langues',
),
'nap': ISOCodeData('', '', 'Neapolitan', 'napolitain'),
'nau': ISOCodeData('', 'na', 'Nauru', 'nauruan'),
'nav': ISOCodeData('', 'nv', 'Navajo; Navaho', 'navaho'),
'nbl': ISOCodeData(
'',
'nr',
'Ndebele, South; South Ndebele',
'ndébélé du Sud',
),
'nde': ISOCodeData(
'',
'nd',
'Ndebele, North; North Ndebele',
'ndébélé du Nord',
),
'ndo': ISOCodeData('', 'ng', 'Ndonga', 'ndonga'),
'nds': ISOCodeData(
'',
'',
'Low German; Low Saxon; German, Low; Saxon, Low',
'bas allemand; bas saxon; allemand, bas; saxon, bas',
),
'nep': ISOCodeData('', 'ne', 'Nepali', 'népalais'),
'new': ISOCodeData('', '', 'Nepal Bhasa; Newari', 'nepal bhasa; newari'),
'nia': ISOCodeData('', '', 'Nias', 'nias'),
'nic': ISOCodeData(
'',
'',
'Niger-Kordofanian languages',
'nigéro-kordofaniennes, langues',
),
'niu': ISOCodeData('', '', 'Niuean', 'niué'),
'nno': ISOCodeData(
'',
'nn',
'Norwegian Nynorsk; Nynorsk, Norwegian',
'norvégien nynorsk; nynorsk, norvégien',
),
'nob': ISOCodeData(
'',
'nb',
'Bokmål, Norwegian; Norwegian Bokmål',
'norvégien bokmål',
),
'nog': ISOCodeData('', '', 'Nogai', 'nogaï; nogay'),
'non': ISOCodeData('', '', 'Norse, Old', 'norrois, vieux'),
'nor': ISOCodeData('', 'no', 'Norwegian', 'norvégien'),
'nqo': ISOCodeData('', '', "N'Ko", "n'ko"),
'nso': ISOCodeData(
'',
'',
'Pedi; Sepedi; Northern Sotho',
'pedi; sepedi; sotho du Nord',
),
'nub': ISOCodeData('', '', 'Nubian languages', 'nubiennes, langues'),
'nwc': ISOCodeData(
'',
'',
'Classical Newari; Old Newari; Classical Nepal Bhasa',
'newari classique',
),
'nya': ISOCodeData(
'',
'ny',
'Chichewa; Chewa; Nyanja',
'chichewa; chewa; nyanja',
),
'nym': ISOCodeData('', '', 'Nyamwezi', 'nyamwezi'),
'nyn': ISOCodeData('', '', 'Nyankole', 'nyankolé'),
'nyo': ISOCodeData('', '', 'Nyoro', 'nyoro'),
'nzi': ISOCodeData('', '', 'Nzima', 'nzema'),
'oci': ISOCodeData(
'',
'oc',
'Occitan (post 1500)',
'occitan (après 1500)',
),
'oji': ISOCodeData('', 'oj', 'Ojibwa', 'ojibwa'),
'ori': ISOCodeData('', 'or', 'Oriya', 'oriya'),
'orm': ISOCodeData('', 'om', 'Oromo', 'galla'),
'osa': ISOCodeData('', '', 'Osage', 'osage'),
'oss': ISOCodeData('', 'os', 'Ossetian; Ossetic', 'ossète'),
'ota': ISOCodeData(
'',
'',
'Turkish, Ottoman (1500-1928)',
'turc ottoman (1500-1928)',
),
'oto': ISOCodeData('', '', 'Otomian languages', 'otomi, langues'),
'paa': ISOCodeData('', '', 'Papuan languages', 'papoues, langues'),
'pag': ISOCodeData('', '', 'Pangasinan', 'pangasinan'),
'pal': ISOCodeData('', '', 'Pahlavi', 'pahlavi'),
'pam': ISOCodeData('', '', 'Pampanga; Kapampangan', 'pampangan'),
'pan': ISOCodeData('', 'pa', 'Panjabi; Punjabi', 'pendjabi'),
'pap': ISOCodeData('', '', 'Papiamento', 'papiamento'),
'pau': ISOCodeData('', '', 'Palauan', 'palau'),
'peo': ISOCodeData(
'',
'',
'Persian, Old (ca.600-400 B.C.)',
'perse, vieux (ca. 600-400 av. J.-C.)',
),
'per': ISOCodeData('fas', 'fa', 'Persian', 'persan'),
'phi': ISOCodeData(
'',
'',
'Philippine languages',
'philippines, langues',
),
'phn': ISOCodeData('', '', 'Phoenician', 'phénicien'),
'pli': ISOCodeData('', 'pi', 'Pali', 'pali'),
'pol': ISOCodeData('', 'pl', 'Polish', 'polonais'),
'pon': ISOCodeData('', '', 'Pohnpeian', 'pohnpei'),
'por': ISOCodeData('', 'pt', 'Portuguese', 'portugais'),
'pra': ISOCodeData('', '', 'Prakrit languages', 'prâkrit, langues'),
'pro': ISOCodeData(
'',
'',
'Provençal, Old (to 1500); Occitan, Old (to 1500)',
"provençal ancien (jusqu'à 1500); occitan ancien (jusqu'à 1500)",
),
'pus': ISOCodeData('', 'ps', 'Pushto; Pashto', 'pachto'),
'qaa': ISOCodeData(
'',
'',
'Reserved for local use',
"réservée à l'usage local",
),
'que': ISOCodeData('', 'qu', 'Quechua', 'quechua'),
'raj': ISOCodeData('', '', 'Rajasthani', 'rajasthani'),
'rap': ISOCodeData('', '', 'Rapanui', 'rapanui'),
'rar': ISOCodeData(
'',
'',
'Rarotongan; Cook Islands Maori',
'rarotonga; maori des îles Cook',
),
'roa': ISOCodeData('', '', 'Romance languages', 'romanes, langues'),
'roh': ISOCodeData('', 'rm', 'Romansh', 'romanche'),
'rom': ISOCodeData('', '', 'Romany', 'tsigane'),
'rum': ISOCodeData(
'ron',
'ro',
'Romanian; Moldavian; Moldovan',
'roumain; moldave',
),
'run': ISOCodeData('', 'rn', 'Rundi', 'rundi'),
'rup': ISOCodeData(
'',
'',
'Aromanian; Arumanian; Macedo-Romanian',
'aroumain; macédo-roumain',
),
'rus': ISOCodeData('', 'ru', 'Russian', 'russe'),
'sad': ISOCodeData('', '', 'Sandawe', 'sandawe'),
'sag': ISOCodeData('', 'sg', 'Sango', 'sango'),
'sah': ISOCodeData('', '', 'Yakut', 'iakoute'),
'sai': ISOCodeData(
'',
'',
'South American Indian languages',
'sud-amérindiennes, langues',
),
'sal': ISOCodeData('', '', 'Salishan languages', 'salishennes, langues'),
'sam': ISOCodeData('', '', 'Samaritan Aramaic', 'samaritain'),
'san': ISOCodeData('', 'sa', 'Sanskrit', 'sanskrit'),
'sas': ISOCodeData('', '', 'Sasak', 'sasak'),
'sat': ISOCodeData('', '', 'Santali', 'santal'),
'scn': ISOCodeData('', '', 'Sicilian', 'sicilien'),
'sco': ISOCodeData('', '', 'Scots', 'écossais'),
'sel': ISOCodeData('', '', 'Selkup', 'selkoupe'),
'sem': ISOCodeData('', '', 'Semitic languages', 'sémitiques, langues'),
'sga': ISOCodeData(
'',
'',
'Irish, Old (to 900)',
"irlandais ancien (jusqu'à 900)",
),
'sgn': ISOCodeData('', '', 'Sign Languages', 'langues des signes'),
'shn': ISOCodeData('', '', 'Shan', 'chan'),
'sid': ISOCodeData('', '', 'Sidamo', 'sidamo'),
'sin': ISOCodeData('', 'si', 'Sinhala; Sinhalese', 'singhalais'),
'sio': ISOCodeData('', '', 'Siouan languages', 'sioux, langues'),
'sit': ISOCodeData(
'',
'',
'Sino-Tibetan languages',
'sino-tibétaines, langues',
),
'sla': ISOCodeData('', '', 'Slavic languages', 'slaves, langues'),
'slo': ISOCodeData('slk', 'sk', 'Slovak', 'slovaque'),
'slv': ISOCodeData('', 'sl', 'Slovenian', 'slovène'),
'sma': ISOCodeData('', '', 'Southern Sami', 'sami du Sud'),
'sme': ISOCodeData('', 'se', 'Northern Sami', 'sami du Nord'),
'smi': ISOCodeData('', '', 'Sami languages', 'sames, langues'),
'smj': ISOCodeData('', '', 'Lule Sami', 'sami de Lule'),
'smn': ISOCodeData('', '', 'Inari Sami', "sami d'Inari"),
'smo': ISOCodeData('', 'sm', 'Samoan', 'samoan'),
'sms': ISOCodeData('', '', 'Skolt Sami', 'sami skolt'),
'sna': ISOCodeData('', 'sn', 'Shona', 'shona'),
'snd': ISOCodeData('', 'sd', 'Sindhi', 'sindhi'),
'snk': ISOCodeData('', '', 'Soninke', 'soninké'),
'sog': ISOCodeData('', '', 'Sogdian', 'sogdien'),
'som': ISOCodeData('', 'so', 'Somali', 'somali'),
'son': ISOCodeData('', '', 'Songhai languages', 'songhai, langues'),
'sot': ISOCodeData('', 'st', 'Sotho, Southern', 'sotho du Sud'),
'spa': ISOCodeData('', 'es', 'Spanish; Castilian', 'espagnol; castillan'),
'srd': ISOCodeData('', 'sc', 'Sardinian', 'sarde'),
'srn': ISOCodeData('', '', 'Sranan Tongo', 'sranan tongo'),
'srp': ISOCodeData('', 'sr', 'Serbian', 'serbe'),
'srr': ISOCodeData('', '', 'Serer', 'sérère'),
'ssa': ISOCodeData(
'',
'',
'Nilo-Saharan languages',
'nilo-sahariennes, langues',
),
'ssw': ISOCodeData('', 'ss', 'Swati', 'swati'),
'suk': ISOCodeData('', '', 'Sukuma', 'sukuma'),
'sun': ISOCodeData('', 'su', 'Sundanese', 'soundanais'),
'sus': ISOCodeData('', '', 'Susu', 'soussou'),
'sux': ISOCodeData('', '', 'Sumerian', 'sumérien'),
'swa': ISOCodeData('', 'sw', 'Swahili', 'swahili'),
'swe': ISOCodeData('', 'sv', 'Swedish', 'suédois'),
'syc': ISOCodeData('', '', 'Classical Syriac', 'syriaque classique'),
'syr': ISOCodeData('', '', 'Syriac', 'syriaque'),
'tah': ISOCodeData('', 'ty', 'Tahitian', 'tahitien'),
'tai': ISOCodeData('', '', 'Tai languages', 'tai, langues'),
'tam': ISOCodeData('', 'ta', 'Tamil', 'tamoul'),
'tat': ISOCodeData('', 'tt', 'Tatar', 'tatar'),
'tel': ISOCodeData('', 'te', 'Telugu', 'télougou'),
'tem': ISOCodeData('', '', 'Timne', 'temne'),
'ter': ISOCodeData('', '', 'Tereno', 'tereno'),
'tet': ISOCodeData('', '', 'Tetum', 'tetum'),
'tgk': ISOCodeData('', 'tg', 'Tajik', 'tadjik'),
'tgl': ISOCodeData('', 'tl', 'Tagalog', 'tagalog'),
'tha': ISOCodeData('', 'th', 'Thai', 'thaï'),
'tib': ISOCodeData('bod', 'bo', 'Tibetan', 'tibétain'),
'tig': ISOCodeData('', '', 'Tigre', 'tigré'),
'tir': ISOCodeData('', 'ti', 'Tigrinya', 'tigrigna'),
'tiv': ISOCodeData('', '', 'Tiv', 'tiv'),
'tkl': ISOCodeData('', '', 'Tokelau', 'tokelau'),
'tlh': ISOCodeData('', '', 'Klingon; tlhIngan-Hol', 'klingon'),
'tli': ISOCodeData('', '', 'Tlingit', 'tlingit'),
'tmh': ISOCodeData('', '', 'Tamashek', 'tamacheq'),
'tog': ISOCodeData('', '', 'Tonga (Nyasa)', 'tonga (Nyasa)'),
'ton': ISOCodeData(
'',
'to',
'Tonga (Tonga Islands)',
'tongan (Îles Tonga)',
),
'tpi': ISOCodeData('', '', 'Tok Pisin', 'tok pisin'),
'tsi': ISOCodeData('', '', 'Tsimshian', 'tsimshian'),
'tsn': ISOCodeData('', 'tn', 'Tswana', 'tswana'),
'tso': ISOCodeData('', 'ts', 'Tsonga', 'tsonga'),
'tuk': ISOCodeData('', 'tk', 'Turkmen', 'turkmène'),
'tum': ISOCodeData('', '', 'Tumbuka', 'tumbuka'),
'tup': ISOCodeData('', '', 'Tupi languages', 'tupi, langues'),
'tur': ISOCodeData('', 'tr', 'Turkish', 'turc'),
'tut': ISOCodeData('', '', 'Altaic languages', 'altaïques, langues'),
'tvl': ISOCodeData('', '', 'Tuvalu', 'tuvalu'),
'twi': ISOCodeData('', 'tw', 'Twi', 'twi'),
'tyv': ISOCodeData('', '', 'Tuvinian', 'touva'),
'udm': ISOCodeData('', '', 'Udmurt', 'oudmourte'),
'uga': ISOCodeData('', '', 'Ugaritic', 'ougaritique'),
'uig': ISOCodeData('', 'ug', 'Uighur; Uyghur', 'ouïgour'),
'ukr': ISOCodeData('', 'uk', 'Ukrainian', 'ukrainien'),
'umb': ISOCodeData('', '', 'Umbundu', 'umbundu'),
'und': ISOCodeData('', '', 'Undetermined', 'indéterminée'),
'urd': ISOCodeData('', 'ur', 'Urdu', 'ourdou'),
'uzb': ISOCodeData('', 'uz', 'Uzbek', 'ouszbek'),
'vai': ISOCodeData('', '', 'Vai', 'vaï'),
'ven': ISOCodeData('', 've', 'Venda', 'venda'),
'vie': ISOCodeData('', 'vi', 'Vietnamese', 'vietnamien'),
'vol': ISOCodeData('', 'vo', 'Volapük', 'volapük'),
'vot': ISOCodeData('', '', 'Votic', 'vote'),
'wak': ISOCodeData('', '', 'Wakashan languages', 'wakashanes, langues'),
'wal': ISOCodeData('', '', 'Wolaitta; Wolaytta', 'wolaitta; wolaytta'),
'war': ISOCodeData('', '', 'Waray', 'waray'),
'was': ISOCodeData('', '', 'Washo', 'washo'),
'wel': ISOCodeData('cym', 'cy', 'Welsh', 'gallois'),
'wen': ISOCodeData('', '', 'Sorbian languages', 'sorabes, langues'),
'wln': ISOCodeData('', 'wa', 'Walloon', 'wallon'),
'wol': ISOCodeData('', 'wo', 'Wolof', 'wolof'),
'xal': ISOCodeData('', '', 'Kalmyk; Oirat', 'kalmouk; oïrat'),
'xho': ISOCodeData('', 'xh', 'Xhosa', 'xhosa'),
'yao': ISOCodeData('', '', 'Yao', 'yao'),
'yap': ISOCodeData('', '', 'Yapese', 'yapois'),
'yid': ISOCodeData('', 'yi', 'Yiddish', 'yiddish'),
'yor': ISOCodeData('', 'yo', 'Yoruba', 'yoruba'),
'ypk': ISOCodeData('', '', 'Yupik languages', 'yupik, langues'),
'zap': ISOCodeData('', '', 'Zapotec', 'zapotèque'),
'zbl': ISOCodeData(
'',
'',
'Blissymbols; Blissymbolics; Bliss',
'symboles Bliss; Bliss',
),
'zen': ISOCodeData('', '', 'Zenaga', 'zenaga'),
'zgh': ISOCodeData(
'',
'',
'Standard Moroccan Tamazight',
'amazighe standard marocain',
),
'zha': ISOCodeData('', 'za', 'Zhuang; Chuang', 'zhuang; chuang'),
'znd': ISOCodeData('', '', 'Zande languages', 'zandé, langues'),
'zul': ISOCodeData('', 'zu', 'Zulu', 'zoulou'),
'zun': ISOCodeData('', '', 'Zuni', 'zuni'),
'zxx': ISOCodeData(
'',
'',
'No linguistic content; Not applicable',
'pas de contenu linguistique; non applicable',
),
'zza': ISOCodeData(
'',
'',
'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki',
'zaza; dimili; dimli; kirdki; kirmanjki; zazaki',
),
}
def iso_639_2_from_3(iso3: str) -> str:
"""Convert ISO 639-3 code to ISO 639-2 code."""
if iso3 in ISO_639_3:
return ISO_639_3[iso3].alpha_2
else:
return ""
+27 -27
View File
@@ -14,7 +14,8 @@ from collections import defaultdict
from collections.abc import Iterator, MutableSet, Sequence
from os import fspath
from pathlib import Path
from typing import Callable, NamedTuple, NewType
from typing import Any, Callable, NamedTuple, NewType
from warnings import warn
from zlib import compress
import img2pdf
@@ -34,6 +35,7 @@ from PIL import Image
from ocrmypdf._concurrent import Executor, SerialExecutor
from ocrmypdf._exec import jbig2enc, pngquant
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import OutputFileAccessError
from ocrmypdf.helpers import IMG2PDF_KWARGS, safe_symlink
@@ -69,12 +71,9 @@ def jpg_name(root: Path, xref: Xref) -> Path:
def extract_image_filter(
pdf: Pdf, root: Path, image: Stream, xref: Xref
image: Stream, xref: Xref
) -> tuple[PdfImage, tuple[Name, Object]] | None:
"""Determine if an image is extractable."""
del pdf # unused args
del root
if image.Subtype != Name.Image:
return None
if image.Length < 100:
@@ -132,7 +131,7 @@ def extract_image_jbig2(
"""Extract an image, saving it as a JBIG2 file."""
del options # unused arg
result = extract_image_filter(pdf, root, image, xref)
result = extract_image_filter(image, xref)
if result is None:
return None
pim, filtdp = result
@@ -172,7 +171,7 @@ def extract_image_generic(
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None:
"""Generic image extraction."""
result = extract_image_filter(pdf, root, image, xref)
result = extract_image_filter(image, xref)
if result is None:
return None
pim, filtdp = result
@@ -283,7 +282,7 @@ def _find_image_xrefs(pdf: Pdf):
for pageno, page in enumerate(pdf.pages):
_find_image_xrefs_container(
pdf, page, pageno, include_xrefs, exclude_xrefs, pageno_for_xref
pdf, page.obj, pageno, include_xrefs, exclude_xrefs, pageno_for_xref
)
working_xrefs = include_xrefs - exclude_xrefs
@@ -371,7 +370,7 @@ def _produce_jbig2_images(
options.jbig2_threshold,
)
def jbig2_single_args(root, groups: dict[int, list[XrefExt]]):
def jbig2_single_args(root: Path, groups: dict[int, list[XrefExt]]):
for group, xref_exts in groups.items():
prefix = f'group{group:08d}'
# Second loop is to ensure multiple images per page are unpacked
@@ -386,15 +385,15 @@ def _produce_jbig2_images(
if options.jbig2_page_group_size > 1:
jbig2_args = jbig2_group_args
jbig2_convert = jbig2enc.convert_group_mp
jbig2_convert = jbig2enc.convert_group
else:
jbig2_args = jbig2_single_args
jbig2_convert = jbig2enc.convert_single_mp
jbig2_convert = jbig2enc.convert_single
executor(
use_threads=True,
max_workers=options.jobs,
tqdm_kwargs=dict(
progress_kwargs=dict(
total=len(jbig2_groups),
desc="JBIG2",
unit='item',
@@ -450,9 +449,9 @@ def convert_to_jbig2(
)
def _optimize_jpeg(args: tuple[Xref, Path, Path, int]) -> tuple[Xref, Path | None]:
xref, in_jpg, opt_jpg, jpeg_quality = args
def _optimize_jpeg(
xref: Xref, in_jpg: Path, opt_jpg: Path, jpeg_quality: int
) -> tuple[Xref, Path | None]:
with Image.open(in_jpg) as im:
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
@@ -474,7 +473,7 @@ def transcode_jpegs(
opt_jpg = in_jpg.with_suffix('.opt.jpg')
yield xref, in_jpg, opt_jpg, options.jpeg_quality
def finish_jpeg(result: tuple[Xref, Path | None], pbar):
def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar):
xref, opt_jpg = result
if opt_jpg:
compdata = opt_jpg.read_bytes() # JPEG can inserted into PDF as is
@@ -485,7 +484,7 @@ def transcode_jpegs(
executor(
use_threads=True, # Processes are significantly slower at this task
max_workers=options.jobs,
tqdm_kwargs=dict(
progress_kwargs=dict(
desc="Recompressing JPEGs",
total=len(jpegs),
unit='image',
@@ -500,7 +499,7 @@ def transcode_jpegs(
def _find_deflatable_jpeg(
*, pdf: Pdf, root: Path, image: Stream, xref: Xref, options
) -> XrefExt | None:
result = extract_image_filter(pdf, root, image, xref)
result = extract_image_filter(image, xref)
if result is None:
return None
_pim, filtdp = result
@@ -511,8 +510,9 @@ def _find_deflatable_jpeg(
return None
def _deflate_jpeg(args: tuple[Pdf, threading.Lock, Xref, int]) -> tuple[Xref, bytes]:
pdf, lock, xref, complevel = args
def _deflate_jpeg(
pdf: Pdf, lock: threading.Lock, xref: Xref, complevel: int
) -> tuple[Xref, bytes]:
with lock:
xobj = pdf.get_object(xref, 0)
try:
@@ -547,7 +547,7 @@ def deflate_jpegs(pdf: Pdf, root: Path, options, executor: Executor) -> None:
for xref in jpegs:
yield pdf, lock, xref, complevel
def finish(result, pbar):
def finish(result: tuple[Xref, bytes], pbar: ProgressBar):
xref, compdata = result
if len(compdata) > 0:
with lock:
@@ -558,7 +558,7 @@ def deflate_jpegs(pdf: Pdf, root: Path, options, executor: Executor) -> None:
executor(
use_threads=True, # We're sharing the pdf directly, must use threads
max_workers=options.jobs,
tqdm_kwargs=dict(
progress_kwargs=dict(
desc="Deflating JPEGs",
total=len(jpegs),
unit='image',
@@ -617,7 +617,7 @@ def transcode_pngs(
image_name_fn: Callable[[Path, Xref], Path],
root: Path,
options,
executor,
executor: Executor,
) -> None:
"""Apply lossy transcoding to PNGs."""
modified: MutableSet[Xref] = set()
@@ -641,13 +641,13 @@ def transcode_pngs(
executor(
use_threads=True,
max_workers=options.jobs,
tqdm_kwargs=dict(
progress_kwargs=dict(
desc="PNGs",
total=len(images),
unit='image',
disable=not options.progress_bar,
),
task=pngquant.quantize_mp,
task=pngquant.quantize,
task_arguments=pngquant_args(),
)
@@ -662,8 +662,8 @@ DEFAULT_EXECUTOR = SerialExecutor()
def optimize(
input_file: Path,
output_file: Path,
context,
save_settings,
context: PdfContext,
save_settings: dict[str, Any],
executor: Executor = DEFAULT_EXECUTOR,
) -> Path:
"""Optimize images in a PDF file."""
+95 -48
View File
@@ -13,7 +13,7 @@ import statistics
import sys
from collections import defaultdict
from collections.abc import Container, Iterable, Iterator, Mapping, Sequence
from contextlib import ExitStack
from contextlib import contextmanager
from decimal import Decimal
from enum import Enum, auto
from functools import partial
@@ -25,18 +25,20 @@ from warnings import warn
from pdfminer.layout import LTPage, LTTextBox
from pikepdf import (
Matrix,
Name,
Object,
Page,
Pdf,
PdfImage,
PdfInlineImage,
PdfMatrix,
Stream,
UnsupportedImageTypeError,
parse_content_stream,
)
from ocrmypdf._concurrent import Executor, SerialExecutor
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap
from ocrmypdf.pdfinfo.layout import LTStateAwareChar, get_page_analysis, get_text_boxes
@@ -207,7 +209,7 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
CTM unchanged.
"""
stack = []
ctm = PdfMatrix(initial_shorthand)
ctm = Matrix(initial_shorthand)
xobject_settings: list[XobjectSettings] = []
inline_images: list[InlineSettings] = []
name_index = defaultdict(lambda: [])
@@ -238,7 +240,7 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
# to do. Just pretend nothing happened, keep calm and carry on.
warn("PDF graphics stack underflowed - PDF may be malformed")
elif operator == 'cm':
ctm = PdfMatrix(operands) @ ctm
ctm = Matrix(operands) @ ctm
elif operator == 'Do':
image_name = operands[0]
settings = XobjectSettings(
@@ -357,7 +359,7 @@ class ImageInfo:
if inline is not None:
self._origin = 'inline'
pim = inline
elif pdfimage is not None:
elif pdfimage is not None and isinstance(pdfimage, Stream):
self._origin = 'xobject'
pim = PdfImage(pdfimage)
else:
@@ -386,33 +388,43 @@ class ImageInfo:
if self._enc == Encoding.jpeg2000:
self._color = Colorspace.jpeg2000
if self._color == Colorspace.icc:
# Check the ICC profile to determine actual colorspace
try:
pim_icc = pim.icc
if pim_icc.profile.xcolor_space == 'GRAY':
self._comp = 1
elif pim_icc.profile.xcolor_space == 'CMYK':
self._comp = 4
else:
self._comp = 3
except (AttributeError, UnsupportedImageTypeError) as ex:
self._comp = None
logger.warning(
f"An image with a corrupt or unreadable ICC profile was found. "
f"The output PDF may not match the input PDF visually: {ex}. {self}"
)
self._comp = None
if self._color == Colorspace.icc and isinstance(pim, PdfImage):
self._comp = self._init_icc(pim)
else:
if isinstance(self._color, Colorspace):
self._comp = FRIENDLY_COMP.get(self._color)
else:
self._comp = None
# Bit of a hack... infer grayscale if component count is uncertain
# but encoding only supports monochrome.
if self._comp is None and self._enc in (Encoding.ccitt, Encoding.jbig2):
self._comp = FRIENDLY_COMP[Colorspace.gray]
def _init_icc(self, pim: PdfImage):
try:
icc = pim.icc
except UnsupportedImageTypeError as e:
logger.warning(
f"An image with a corrupt or unreadable ICC profile was found. "
f"Output PDF may not match the input PDF visually: {e}. {self}"
)
return None
# Check the ICC profile to determine actual colorspace
if icc is None or not hasattr(icc, 'profile'):
logger.warning(
f"An image with an ICC profile but no ICC profile data was found. "
f"The output PDF may not match the input PDF visually. {self}"
)
return None
try:
if icc.profile.xcolor_space == 'GRAY':
return 1
elif icc.profile.xcolor_space == 'CMYK':
return 4
else:
return 3
except AttributeError:
return None
@property
def name(self):
"""Name of the image as it appears in the PDF."""
@@ -602,12 +614,12 @@ def _process_content_streams(
):
# Set the CTM to the state it was when the "Do" operator was
# encountered that is drawing this instance of the Form XObject
ctm = PdfMatrix(shorthand) if shorthand else PdfMatrix.identity()
ctm = Matrix(shorthand) if shorthand else Matrix()
# A Form XObject may provide its own matrix to map form space into
# user space. Get this if one exists
form_shorthand = container.get(Name.Matrix, PdfMatrix.identity())
form_matrix = PdfMatrix(form_shorthand)
form_shorthand = container.get(Name.Matrix, Matrix())
form_matrix = Matrix(form_shorthand)
# Concatenate form matrix with CTM to ensure CTM is correct for
# drawing this instance of the XObject
@@ -693,29 +705,41 @@ def _pdf_pageinfo_sync_init(pdf: Pdf, infile: Path, pdfminer_loglevel):
atexit.register(on_process_close)
def _pdf_pageinfo_sync(args):
pageno, thread_pdf, infile, check_pages, detailed_analysis = args
pdf = thread_pdf if thread_pdf is not None else worker_pdf
with ExitStack() as stack:
if not pdf: # When called with SerialExecutor
pdf = stack.enter_context(Pdf.open(infile))
page = PageInfo(pdf, pageno, infile, check_pages, detailed_analysis)
return page
@contextmanager
def _pdf_pageinfo_sync_pdf(thread_pdf: Pdf | None, infile: Path):
if thread_pdf is not None:
yield thread_pdf
elif worker_pdf is not None:
yield worker_pdf
else:
with Pdf.open(infile) as pdf:
yield pdf
def _pdf_pageinfo_sync(
pageno: int,
thread_pdf: Pdf | None,
infile: Path,
check_pages: Container[int],
detailed_analysis: bool,
) -> PageInfo:
with _pdf_pageinfo_sync_pdf(thread_pdf, infile) as pdf:
return PageInfo(pdf, pageno, infile, check_pages, detailed_analysis)
def _pdf_pageinfo_concurrent(
pdf,
executor: Executor,
max_workers: int,
use_threads: bool,
infile,
progbar,
max_workers,
check_pages,
detailed_analysis=False,
detailed_analysis: bool = False,
) -> Sequence[PageInfo | None]:
pages: Sequence[PageInfo | None] = [None] * len(pdf.pages)
pages: list[PageInfo | None] = [None] * len(pdf.pages)
def update_pageinfo(result, pbar):
page = result
def update_pageinfo(page: PageInfo, pbar: ProgressBar):
if not page:
raise InputFileError("Could read a page in the PDF")
pages[page.pageno] = page
@@ -726,13 +750,17 @@ def _pdf_pageinfo_concurrent(
total = len(pdf.pages)
use_threads = False # No performance gain if threaded due to GIL
n_workers = min(1 + len(pages) // 4, max_workers)
if n_workers == 1:
# But if we decided on only one worker, there is no point in using
# If we decided on only one worker, there is no point in using
# a separate process.
use_threads = True
if use_threads and n_workers > 1:
# If we are using threads, there is no point in using more than one
# worker thread - they will just fight over the GIL.
n_workers = 1
# If we use a thread, we can pass the already-open Pdf for them to use
# If we use processes, we pass a None which tells the init function to open its
# own
@@ -742,10 +770,15 @@ def _pdf_pageinfo_concurrent(
(n, initial_pdf, infile, check_pages, detailed_analysis) for n in range(total)
)
assert n_workers == 1 if use_threads else n_workers >= 1, "Not multithreadable"
logger.debug(
f"Gathering info with {n_workers} "
+ ('thread' if use_threads else 'process')
+ " workers"
)
executor(
use_threads=use_threads,
max_workers=n_workers,
tqdm_kwargs=dict(
progress_kwargs=dict(
total=total, desc="Scanning contents", unit='page', disable=not progbar
),
worker_initializer=partial(
@@ -817,7 +850,7 @@ class PageInfo:
detailed_analysis: bool,
):
page: Page = pdf.pages[pageno]
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
mediabox = [Decimal(d) for d in page.mediabox.as_list()]
width_pt = mediabox[2] - mediabox[0]
height_pt = mediabox[3] - mediabox[1]
@@ -1038,7 +1071,11 @@ DEFAULT_EXECUTOR = SerialExecutor()
class PdfInfo:
"""Get summary information about a PDF."""
"""Extract summary information about a PDF without retaining the PDF itself.
Crucially this lets us get the information in a pure Python format so that
it can be pickled and passed to a worker process.
"""
_has_acroform: bool = False
_has_signature: bool = False
@@ -1046,11 +1083,12 @@ class PdfInfo:
def __init__(
self,
infile,
infile: Path,
*,
detailed_analysis: bool = False,
progbar: bool = False,
max_workers: int | None = None,
use_threads: bool = True,
check_pages=None,
executor: Executor = DEFAULT_EXECUTOR,
):
@@ -1065,9 +1103,10 @@ class PdfInfo:
self._pages = _pdf_pageinfo_concurrent(
pdf,
executor,
max_workers,
use_threads,
infile,
progbar,
max_workers,
check_pages=check_pages,
detailed_analysis=detailed_analysis,
)
@@ -1078,6 +1117,9 @@ class PdfInfo:
elif Name.XFA in pdf.Root.AcroForm:
self._has_acroform = True
self._has_signature = bool(pdf.Root.AcroForm.get(Name.SigFlags, 0) & 1)
self._is_tagged = bool(
pdf.Root.get(Name.MarkInfo, {}).get(Name.Marked, False)
)
@property
def pages(self) -> Sequence[PageInfo | None]:
@@ -1105,6 +1147,11 @@ class PdfInfo:
"""Return True if the document annotations has a digital signature."""
return self._has_signature
@property
def is_tagged(self) -> bool:
"""Return True if the document catalog indicates this is a Tagged PDF."""
return self._is_tagged
@property
def filename(self) -> str | Path:
"""Return filename of PDF."""
@@ -1134,7 +1181,7 @@ class PdfInfo:
return f"<PdfInfo('...'), page count={len(self)}>"
def main():
def main(): # pragma: no cover
"""Run as a script."""
import argparse # pylint: disable=import-outside-toplevel
from pprint import pprint # pylint: disable=import-outside-toplevel
+38 -45
View File
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, NamedTuple
import pluggy
from ocrmypdf import Executor, PdfContext
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.helpers import Resolution
if TYPE_CHECKING:
@@ -102,7 +103,6 @@ def check_options(options: Namespace) -> None:
and the application should terminate gracefully with an informative
message and error code.
Note:
This hook will be called from the main process, and may modify global state
before child worker processes are forked.
@@ -110,7 +110,7 @@ def check_options(options: Namespace) -> None:
@hookspec(firstresult=True)
def get_executor(progressbar_class) -> Executor:
def get_executor(progressbar_class: type[ProgressBar]) -> Executor:
"""Called to obtain an object that manages parallel execution.
This may be used to replace OCRmyPDF's default parallel execution system
@@ -132,41 +132,24 @@ def get_executor(progressbar_class) -> Executor:
This hook will be called from the main process, and may modify global state
before child worker processes are forked.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@hookspec(firstresult=True)
def get_progressbar_class():
def get_progressbar_class() -> type[ProgressBar]:
"""Called to obtain a class that can be used to monitor progress.
A progress bar is assumed, but this could be used for any type of monitoring.
The class should follow a tqdm-like protocol. Calling the class should return
a new progress bar object, which is activated with ``__enter__`` and terminated
``__exit__``. An update method is called whenever the progress bar is updated.
Progress bar objects will not be reused; a new one will be created for each
group of tasks.
The progress bar is held in the main process/thread and not updated by child
process/threads. When a child notifies the parent of completed work, the
parent updates the progress bar.
The arguments are the same as `tqdm <https://github.com/tqdm/tqdm>`_ accepts.
Progress bars should never write to ``sys.stdout``, or they will corrupt the
output if OCRmyPDF writes a PDF to standard output.
The type of events that OCRmyPDF reports to a progress bar may change in
minor releases.
OCRmyPDF will call this function when it wants to display a progress bar.
The class returned by this function must be compatible with the
:class:`ProgressBar` protocol.
Here is how OCRmyPDF will use the progress bar:
Example:
pbar_class = pm.hook.get_progressbar_class()
with pbar_class(**tqdm_kwargs) as pbar:
with pbar_class(**progress_kwargs) as pbar:
...
pbar.update(1)
"""
@@ -187,7 +170,6 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
and the application should terminate gracefully with an informative
message and error code.
Note:
This hook will be called from the main process, and may modify global state
before child worker processes are forked.
@@ -231,6 +213,7 @@ def rasterize_pdf_page(
Returns:
Path: output_file if successful
Note:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
@@ -270,7 +253,6 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@@ -308,7 +290,6 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
This hook will be called from child processes. Modifying global state
will not affect the main process or other child processes.
Note:
This is a :ref:`firstresult hook<firstresult>`.
"""
@@ -428,21 +409,42 @@ class OcrEngine(ABC):
def generate_hocr(
input_file: Path, output_hocr: Path, output_text: Path, options: Namespace
) -> None:
"""Called to produce a hOCR file and sidecar text file."""
"""Called to produce a hOCR file from a page image and sidecar text file.
A hOCR file is an HTML-like file that describes the position of text on a
page. OCRmyPDF can create a text only PDF from the hOCR file and graft it
onto the output PDF.
This function executes in a worker thread or worker process. OCRmyPDF
automatically parallelizes OCR over pages. The OCR engine should not
introduce more parallelism.
Args:
input_file: A page image on which to perform OCR.
output_hocr: The expected name of the output hOCR file.
output_text: The expected name of a text file containing the
recognized text.
options: The command line options.
"""
@staticmethod
@abstractmethod
def generate_pdf(
input_file: Path, output_pdf: Path, output_text: Path, options: Namespace
) -> None:
"""Called to produce a text only PDF.
"""Called to produce a text only PDF from a page image.
A text only PDF should contain no visible material of any kind, as it
will be grafted onto the input PDF page. It must be sized to the
exact dimensions of the input image.
This function executes in a worker thread or worker process. OCRmyPDF
automatically parallelizes OCR over pages. The OCR engine should not
introduce more parallelism.
Args:
input_file: A page image on which to perform OCR.
output_pdf: The expected name of the output PDF, which must be
a single page PDF with no visible content of any kind, sized
to the dimensions implied by the input_file's width, height
and DPI. The image will be grafted onto the input PDF page.
output_pdf: The expected name of the output PDF.
output_text: The expected name of a text file containing the
recognized text.
options: The command line options.
@@ -469,7 +471,7 @@ def generate_pdfa(
context: PdfContext,
pdf_version: str,
pdfa_part: str,
progressbar_class,
progressbar_class: type[ProgressBar] | None,
stop_on_soft_error: bool,
) -> Path:
"""Generate a PDF/A.
@@ -489,14 +491,8 @@ def generate_pdfa(
At its own discretion, the PDF/A generator may raise the version,
but should not lower it.
pdfa_part: The desired PDF/A compliance level, such as ``'2B'``.
progressbar_class: The class of a progress bar with a tqdm-like API. An
instance of this class will be initialized when PDF/A conversion
begins, using
``instance = progressbar_class(total: int, desc: str, unit:str)``,
defining the number of work units, a user-visible description,
and the name of the work units ("page"). Then ``instance.update()``
will be called when a work unit is completed. If ``None``, no
progress information is reported.
progressbar_class: The class of a progress bar, which must implement
the ProgressBar protocol. If None, no progress is reported.
stop_on_soft_error: If there is an "soft error" such that PDF/A generation
can proceed and produce a valid PDF/A, but output may be invalid or
may not visually resemble the original, the implementer of this hook
@@ -514,9 +510,6 @@ def generate_pdfa(
Before version 15.0.0, the ``context`` was not provided and ``compression``
was provided instead. Plugins should now read the context object to determine
if compression is requested.
See Also:
https://github.com/tqdm/tqdm
"""
@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.jcdzf4up/000001_ocr.png"; bbox 0 0 4000 2864; ppageno 0; scan_res 2400 2400'>
<div class='ocr_carea' id='block_1_1' title="bbox 251 146 2173 237">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 251 146 2173 237">
<span class='ocr_line' id='line_1_1' title="bbox 251 146 2173 237; baseline -0.006 5; x_size 99.418808; x_descenders 9.4188042; x_ascenders 32">
<span class='ocrx_word' id='word_1_1' title='bbox 251 154 274 176; x_wconf 89'>i</span>
<span class='ocrx_word' id='word_1_2' title='bbox 1080 146 1152 237; x_wconf 89'>a</span>
<span class='ocrx_word' id='word_1_3' title='bbox 1254 153 1406 235; x_wconf 92'>la</span>
<span class='ocrx_word' id='word_1_4' title='bbox 1500 153 2173 235; x_wconf 95'>Waterman</span>
</span>
</p>
</div>
<div class='ocr_separator' id='block_1_2' title="bbox 135 202 2180 295"></div>
<div class='ocr_carea' id='block_1_3' title="bbox 145 414 2929 1221">
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 145 414 1154 516">
<span class='ocr_line' id='line_1_2' title="bbox 145 414 1154 516; baseline -0.005 -4; x_size 122.38636; x_descenders 24.386362; x_ascenders 40">
<span class='ocrx_word' id='word_1_5' title='bbox 145 414 211 512; x_wconf 60'>4</span>
<span class='ocrx_word' id='word_1_6' title='bbox 318 453 551 516; x_wconf 93'>ons</span>
<span class='ocrx_word' id='word_1_7' title='bbox 660 430 1154 512; x_wconf 91'>linzen</span>
</span>
</p>
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 146 568 1239 667">
<span class='ocr_line' id='line_1_3' title="bbox 146 568 1239 667; baseline -0.004 -16; x_size 99; x_descenders 17; x_ascenders 24">
<span class='ocrx_word' id='word_1_8' title='bbox 146 569 209 667; x_wconf 94'>3</span>
<span class='ocrx_word' id='word_1_9' title='bbox 323 568 729 652; x_wconf 83'>liter</span>
<span class='ocrx_word' id='word_1_10' title='bbox 821 569 1239 650; x_wconf 96'>water</span>
</span>
</p>
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 148 705 645 804">
<span class='ocr_line' id='line_1_4' title="bbox 148 705 645 804; baseline -0.016 -14; x_size 103; x_descenders 19; x_ascenders 27">
<span class='ocrx_word' id='word_1_11' title='bbox 148 706 211 804; x_wconf 88'>3</span>
<span class='ocrx_word' id='word_1_12' title='bbox 311 705 645 789; x_wconf 52'>uien</span>
</span>
</p>
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 147 832 1154 950">
<span class='ocr_line' id='line_1_5' title="bbox 147 832 1154 950; baseline -0.004 -27; x_size 118; x_descenders 28; x_ascenders 30">
<span class='ocrx_word' id='word_1_13' title='bbox 147 832 623 950; x_wconf 91'>bloem,</span>
<span class='ocrx_word' id='word_1_14' title='bbox 737 843 1154 924; x_wconf 91'>boter</span>
</span>
</p>
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 148 979 1241 1087">
<span class='ocr_line' id='line_1_6' title="bbox 148 979 1241 1087; baseline -0.005 -21; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_15' title='bbox 148 983 215 1066; x_wconf 88'>2</span>
<span class='ocrx_word' id='word_1_16' title='bbox 312 983 807 1087; x_wconf 88'>kopjes</span>
<span class='ocrx_word' id='word_1_17' title='bbox 905 979 1241 1062; x_wconf 92'>melk</span>
</span>
</p>
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 154 1109 2929 1221">
<span class='ocr_line' id='line_1_7' title="bbox 154 1109 2929 1221; baseline -0.003 -21; x_size 112; x_descenders 28; x_ascenders 26">
<span class='ocrx_word' id='word_1_18' title='bbox 154 1117 791 1221; x_wconf 92'>laurier,</span>
<span class='ocrx_word' id='word_1_19' title='bbox 906 1111 1810 1219; x_wconf 90'>kruidnagel,</span>
<span class='ocrx_word' id='word_1_20' title='bbox 1927 1109 2490 1219; x_wconf 90'>kerrie,</span>
<span class='ocrx_word' id='word_1_21' title='bbox 2615 1110 2929 1195; x_wconf 91'>zout</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_4' title="bbox 147 1383 3706 2731">
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 147 1383 3706 2731">
<span class='ocr_line' id='line_1_8' title="bbox 149 1383 3612 1474; baseline -0.003 -2; x_size 107.01524; x_descenders 25.01524; x_ascenders 21">
<span class='ocrx_word' id='word_1_22' title='bbox 149 1395 303 1474; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_23' title='bbox 411 1390 902 1473; x_wconf 80'>linzgen</span>
<span class='ocrx_word' id='word_1_24' title='bbox 996 1409 1497 1470; x_wconf 89'>wassen</span>
<span class='ocrx_word' id='word_1_25' title='bbox 1595 1409 1752 1470; x_wconf 88'>en</span>
<span class='ocrx_word' id='word_1_26' title='bbox 1853 1385 2166 1467; x_wconf 75'>in-l</span>
<span class='ocrx_word' id='word_1_27' title='bbox 2275 1383 2684 1466; x_wconf 93'>liter</span>
<span class='ocrx_word' id='word_1_28' title='bbox 2775 1383 3278 1464; x_wconf 91'>kokend</span>
<span class='ocrx_word' id='word_1_29' title='bbox 3368 1401 3612 1462; x_wconf 90'>wa-</span>
</span>
<span class='ocr_line' id='line_1_9' title="bbox 157 1516 3520 1632; baseline -0.004 -21; x_size 110; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_30' title='bbox 157 1531 394 1611; x_wconf 94'>ter</span>
<span class='ocrx_word' id='word_1_31' title='bbox 495 1527 558 1609; x_wconf 93'>1</span>
<span class='ocrx_word' id='word_1_32' title='bbox 658 1530 903 1632; x_wconf 84'>dag</span>
<span class='ocrx_word' id='word_1_33' title='bbox 1002 1527 1415 1609; x_wconf 90'>laten</span>
<span class='ocrx_word' id='word_1_34' title='bbox 1505 1525 1979 1611; x_wconf 58'>weken,</span>
<span class='ocrx_word' id='word_1_35' title='bbox 2103 1521 2167 1603; x_wconf 96'>2</span>
<span class='ocrx_word' id='word_1_36' title='bbox 2275 1518 2683 1603; x_wconf 83'>liter</span>
<span class='ocrx_word' id='word_1_37' title='bbox 2777 1519 3194 1601; x_wconf 96'>water</span>
<span class='ocrx_word' id='word_1_38' title='bbox 3286 1516 3520 1599; x_wconf 89'>bij</span>
</span>
<span class='ocr_line' id='line_1_10' title="bbox 152 1651 3616 1767; baseline -0.004 -21; x_size 110; x_descenders 27; x_ascenders 23">
<span class='ocrx_word' id='word_1_39' title='bbox 152 1668 302 1747; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_40' title='bbox 407 1662 905 1747; x_wconf 91'>linzen</span>
<span class='ocrx_word' id='word_1_41' title='bbox 996 1682 1559 1767; x_wconf 92'>voegen,</span>
<span class='ocrx_word' id='word_1_42' title='bbox 1680 1659 2176 1741; x_wconf 96'>zonder</span>
<span class='ocrx_word' id='word_1_43' title='bbox 2267 1655 2506 1737; x_wconf 96'>het</span>
<span class='ocrx_word' id='word_1_44' title='bbox 2606 1655 3023 1737; x_wconf 92'>water</span>
<span class='ocrx_word' id='word_1_45' title='bbox 3116 1651 3616 1735; x_wconf 91'>waarin</span>
</span>
<span class='ocr_line' id='line_1_11' title="bbox 153 1782 3704 1905; baseline -0.004 -20; x_size 117; x_descenders 23; x_ascenders 34">
<span class='ocrx_word' id='word_1_46' title='bbox 153 1824 305 1885; x_wconf 91'>ze</span>
<span class='ocrx_word' id='word_1_47' title='bbox 407 1800 979 1905; x_wconf 85'>geweekt</span>
<span class='ocrx_word' id='word_1_48' title='bbox 1089 1797 1412 1900; x_wconf 96'>zijn</span>
<span class='ocrx_word' id='word_1_49' title='bbox 1510 1796 1672 1878; x_wconf 96'>af</span>
<span class='ocrx_word' id='word_1_50' title='bbox 1770 1782 1914 1876; x_wconf 93'>te</span>
<span class='ocrx_word' id='word_1_51' title='bbox 2019 1792 2576 1899; x_wconf 54'>gieten,</span>
<span class='ocrx_word' id='word_1_52' title='bbox 2690 1790 2850 1874; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_53' title='bbox 2948 1791 3357 1872; x_wconf 89'>helft</span>
<span class='ocrx_word' id='word_1_54' title='bbox 3452 1811 3704 1873; x_wconf 96'>van</span>
</span>
<span class='ocr_line' id='line_1_12' title="bbox 151 1928 3593 2035; baseline -0.003 -13; x_size 109; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_55' title='bbox 151 1942 305 2024; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_56' title='bbox 403 1940 735 2021; x_wconf 89'>uien</span>
<span class='ocrx_word' id='word_1_57' title='bbox 829 1938 1330 2022; x_wconf 91'>bakken</span>
<span class='ocrx_word' id='word_1_58' title='bbox 1419 1939 1659 2018; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_59' title='bbox 1770 1933 2346 2016; x_wconf 91'>laurier</span>
<span class='ocrx_word' id='word_1_60' title='bbox 2447 1953 2603 2014; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_61' title='bbox 2691 1928 3593 2035; x_wconf 63'>Kruidnagel.</span>
</span>
<span class='ocr_line' id='line_1_13' title="bbox 151 2067 3451 2180; baseline -0.004 -21; x_size 108; x_descenders 25; x_ascenders 24">
<span class='ocrx_word' id='word_1_62' title='bbox 151 2076 473 2159; x_wconf 92'>Alle</span>
<span class='ocrx_word' id='word_1_63' title='bbox 569 2076 965 2180; x_wconf 88'>uien,</span>
<span class='ocrx_word' id='word_1_64' title='bbox 1079 2072 1578 2156; x_wconf 90'>kerrie</span>
<span class='ocrx_word' id='word_1_65' title='bbox 1685 2092 1837 2153; x_wconf 93'>en</span>
<span class='ocrx_word' id='word_1_66' title='bbox 1938 2072 2254 2153; x_wconf 81'>zgout</span>
<span class='ocrx_word' id='word_1_67' title='bbox 2355 2068 2593 2172; x_wconf 43'>bij</span>
<span class='ocrx_word' id='word_1_68' title='bbox 2696 2071 2850 2150; x_wconf 91'>de</span>
<span class='ocrx_word' id='word_1_69' title='bbox 2957 2067 3451 2150; x_wconf 85'>linzen</span>
</span>
<span class='ocr_line' id='line_1_14' title="bbox 147 2205 3614 2318; baseline -0.003 -23; x_size 106; x_descenders 22; x_ascenders 25">
<span class='ocrx_word' id='word_1_70' title='bbox 147 2234 711 2318; x_wconf 88'>voegen,</span>
<span class='ocrx_word' id='word_1_71' title='bbox 826 2210 1234 2295; x_wconf 95'>Alles</span>
<span class='ocrx_word' id='word_1_72' title='bbox 1342 2232 1588 2295; x_wconf 95'>aan</span>
<span class='ocrx_word' id='word_1_73' title='bbox 1679 2212 1831 2291; x_wconf 96'>de</span>
<span class='ocrx_word' id='word_1_74' title='bbox 1928 2208 2263 2290; x_wconf 93'>kook</span>
<span class='ocrx_word' id='word_1_75' title='bbox 2355 2206 3000 2308; x_wconf 54'>brengen,</span>
<span class='ocrx_word' id='word_1_76' title='bbox 3117 2205 3367 2285; x_wconf 95'>Van</span>
<span class='ocrx_word' id='word_1_77' title='bbox 3462 2206 3614 2287; x_wconf 95'>de</span>
</span>
<span class='ocr_line' id='line_1_15' title="bbox 152 2341 3706 2447; baseline -0.003 -18; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_78' title='bbox 152 2352 565 2434; x_wconf 91'>bloem</span>
<span class='ocrx_word' id='word_1_79' title='bbox 655 2351 896 2431; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_80' title='bbox 997 2349 1669 2431; x_wconf 90'>boter</span>
<span class='ocrx_word' id='word_1_81' title='bbox 1514 2337 1683 2455; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_82' title='bbox 1757 2343 2095 2426; x_wconf 88'>melk</span>
<span class='ocrx_word' id='word_1_83' title='bbox 2193 2364 2432 2427; x_wconf 93'>een</span>
<span class='ocrx_word' id='word_1_84' title='bbox 2527 2341 2935 2447; x_wconf 90'>papje</span>
<span class='ocrx_word' id='word_1_85' title='bbox 3029 2341 3453 2422; x_wconf 96'>maken</span>
<span class='ocrx_word' id='word_1_86' title='bbox 3549 2362 3706 2422; x_wconf 95'>en</span>
</span>
<span class='ocr_line' id='line_1_16' title="bbox 149 2477 3619 2586; baseline -0.003 -16; x_size 107; x_descenders 24; x_ascenders 23">
<span class='ocrx_word' id='word_1_87' title='bbox 149 2489 650 2571; x_wconf 86'>verder</span>
<span class='ocrx_word' id='word_1_88' title='bbox 750 2486 1330 2570; x_wconf 90'>afmaken</span>
<span class='ocrx_word' id='word_1_89' title='bbox 1420 2485 1660 2567; x_wconf 96'>met</span>
<span class='ocrx_word' id='word_1_90' title='bbox 1765 2485 1917 2566; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_91' title='bbox 2025 2505 2409 2586; x_wconf 86'>soep,</span>
<span class='ocrx_word' id='word_1_92' title='bbox 2521 2479 2764 2564; x_wconf 96'>Als</span>
<span class='ocrx_word' id='word_1_93' title='bbox 2868 2480 3021 2561; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_94' title='bbox 3127 2477 3619 2562; x_wconf 91'>linzen</span>
</span>
<span class='ocr_line' id='line_1_17' title="bbox 155 2619 2412 2731; baseline -0.005 -20; x_size 98; x_descenders 15; x_ascenders 23">
<span class='ocrx_word' id='word_1_95' title='bbox 155 2647 481 2731; x_wconf 35'>gfgaar</span>
<span class='ocrx_word' id='word_1_96' title='bbox 581 2624 909 2728; x_wconf 87'>Zijn</span>
<span class='ocrx_word' id='word_1_97' title='bbox 1005 2623 1153 2707; x_wconf 95'>is</span>
<span class='ocrx_word' id='word_1_98' title='bbox 1255 2624 1409 2706; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_99' title='bbox 1522 2642 1840 2726; x_wconf 91'>soep</span>
<span class='ocrx_word' id='word_1_100' title='bbox 1929 2619 2412 2709; x_wconf 89'>klaar.</span>
</span>
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,24 @@
i a la Waterman
4 ons linzen
3 liter water
3 uien
bloem, boter
2 kopjes melk
laurier, kruidnagel, kerrie, zout
De linzgen wassen en in-l liter kokend wa-
ter 1 dag laten weken, 2 liter water bij
de linzen voegen, zonder het water waarin
ze geweekt zijn af te gieten, De helft van
de uien bakken met laurier en Kruidnagel.
Alle uien, kerrie en zgout bij de linzen
voegen, Alles aan de kook brengen, Van de
bloem met boter en melk een papje maken en
verder afmaken met de soep, Als de linzen
gfgaar Zijn is de soep klaar.
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.vh2to5iv/000001_ocr.png"; bbox 0 0 640 682; ppageno 0; scan_res 230 230'>
<div class='ocr_carea' id='block_1_1' title="bbox 365 15 429 29">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 365 15 429 29">
<span class='ocr_line' id='line_1_1' title="bbox 365 15 429 29; baseline 0 0; x_size 19.238094; x_descenders 5.2380953; x_ascenders 4">
<span class='ocrx_word' id='word_1_1' title='bbox 365 15 429 29; x_wconf 92'>Tarnose</span>
</span>
</p>
</div>
<div class='ocr_photo' id='block_1_2' title="bbox 255 38 616 72"></div>
<div class='ocr_photo' id='block_1_3' title="bbox 273 38 349 57"></div>
<div class='ocr_carea' id='block_1_4' title="bbox 186 20 244 49">
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 186 20 345 49">
<span class='ocr_line' id='line_1_2' title="bbox 186 20 244 49; baseline -0.006 -10; x_size 20.454546; x_descenders 5.4545455; x_ascenders 5">
<span class='ocrx_word' id='word_1_2' title='bbox 186 20 244 49; x_wconf 92'>Bokale</span>
<span class='ocrx_word' id='word_1_3' title='bbox 299 28 345 46; x_wconf 42'>oa</span>
</span>
</p>
</div>
<div class='ocr_photo' id='block_1_5' title="bbox 64 38 186 72"></div>
<div class='ocr_carea' id='block_1_6' title="bbox 537 276 612 291">
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 537 276 612 291">
<span class='ocr_line' id='line_1_3' title="bbox 537 276 612 291; baseline 0 0; x_size 20.238094; x_descenders 5.2380953; x_ascenders 5">
<span class='ocrx_word' id='word_1_4' title='bbox 537 276 612 291; x_wconf 92'>Lehuntze</span>
</span>
</p>
</div>
<div class='ocr_photo' id='block_1_7' title="bbox 20 269 616 314"></div>
<div class='ocr_carea' id='block_1_8' title="bbox 480 323 550 341">
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 480 323 550 341">
<span class='ocr_line' id='line_1_4' title="bbox 480 323 550 341; baseline 0 -4; x_size 18; x_descenders 4; x_ascenders 4">
<span class='ocrx_word' id='word_1_5' title='bbox 480 323 550 341; x_wconf 91'>Mugerre</span>
</span>
</p>
</div>
<div class='ocr_photo' id='block_1_9' title="bbox 44 574 197 623"></div>
<div class='ocr_carea' id='block_1_10' title="bbox 204 542 603 574">
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 204 542 603 574">
<span class='ocr_line' id='line_1_5' title="bbox 204 542 603 574; baseline 0.018 -11; x_size 23.26087; x_descenders 5.2608695; x_ascenders 7">
<span class='ocrx_word' id='word_1_6' title='bbox 204 542 295 574; x_wconf 91'>Milafranga</span>
<span class='ocrx_word' id='word_1_7' title='bbox 439 552 603 569; x_wconf 90'>Komunikabideak</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_11' title="bbox 220 584 616 619">
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 220 584 616 625">
<span class='ocr_line' id='line_1_6' title="bbox 220 584 616 619; baseline -0.005 -1; x_size 43.666668; x_descenders 10.916667; x_ascenders 10.916667">
<span class='ocrx_word' id='word_1_8' title='bbox 220 585 404 619; x_wconf 2'>BAIONA</span>
<span class='ocrx_word' id='word_1_9' title='bbox 468 584 576 625; x_wconf 0'> zeiteninsiie</span>
<span class='ocrx_word' id='word_1_10' title='bbox 585 588 616 610; x_wconf 86'>—</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_12' title="bbox 393 623 598 634">
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 393 623 598 634">
<span class='ocr_line' id='line_1_7' title="bbox 393 623 598 634; baseline 0 0; x_size 22.75; x_descenders 5.5; x_ascenders 5.75">
<span class='ocrx_word' id='word_1_11' title='bbox 393 629 396 631; x_wconf 54'>7</span>
<span class='ocrx_word' id='word_1_12' title='bbox 470 623 539 634; x_wconf 24'>Trenbideak</span>
<span class='ocrx_word' id='word_1_13' title='bbox 550 628 598 630; x_wconf 24'>-----</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_13' title="bbox 81 633 613 667">
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 81 633 613 667">
<span class='ocr_line' id='line_1_8' title="bbox 81 633 613 667; baseline -0.002 -13; x_size 21; x_descenders 4; x_ascenders 7">
<span class='ocrx_word' id='word_1_14' title='bbox 81 637 111 653; x_wconf 0'>t\</span>
<span class='ocrx_word' id='word_1_15' title='bbox 123 633 201 663; x_wconf 22'>Basusarri</span>
<span class='ocrx_word' id='word_1_16' title='bbox 214 633 222 663; x_wconf 0'>—</span>
<span class='ocrx_word' id='word_1_17' title='bbox 230 647 325 656; x_wconf 0'>spmeans:20141004</span>
<span class='ocrx_word' id='word_1_18' title='bbox 373 633 415 667; x_wconf 42'>ae:</span>
<span class='ocrx_word' id='word_1_19' title='bbox 441 650 444 653; x_wconf 25'>.</span>
<span class='ocrx_word' id='word_1_20' title='bbox 521 649 544 657; x_wconf 17'>_</span>
<span class='ocrx_word' id='word_1_21' title='bbox 595 650 613 659; x_wconf 7'>~</span>
</span>
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,15 @@
Tarnose
Bokale oa
Lehuntze
Mugerre
Milafranga Komunikabideak
BAIONA zeiteninsiie —
7 Trenbideak -----
t\ Basusarri — spmeans:20141004 ae: . _ ~
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.vh2to5iv/000002_ocr.png"; bbox 0 0 400 50; ppageno 0; scan_res 200 200'>
<div class='ocr_carea' id='block_1_1' title="bbox 54 16 344 33">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 54 16 344 33">
<span class='ocr_line' id='line_1_1' title="bbox 54 16 344 33; baseline 0 -4; x_size 25.75; x_descenders 5.5; x_ascenders 6.75">
<span class='ocrx_word' id='word_1_1' title='bbox 54 16 110 29; x_wconf 84'>Covfefe</span>
<span class='ocrx_word' id='word_1_2' title='bbox 115 17 127 29; x_wconf 95'>is</span>
<span class='ocrx_word' id='word_1_3' title='bbox 132 20 140 29; x_wconf 95'>a</span>
<span class='ocrx_word' id='word_1_4' title='bbox 145 16 212 33; x_wconf 85'>perfectly</span>
<span class='ocrx_word' id='word_1_5' title='bbox 217 16 296 29; x_wconf 85'>cromulent</span>
<span class='ocrx_word' id='word_1_6' title='bbox 300 16 344 29; x_wconf 95'>word.</span>
</span>
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1 @@
Covfefe is a perfectly cromulent word.
@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.vh2to5iv/000003_ocr.png"; bbox 0 0 4000 2864; ppageno 0; scan_res 1440 1440'>
<div class='ocr_carea' id='block_1_1' title="bbox 142 146 2173 258">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 142 146 2173 258">
<span class='ocr_line' id='line_1_1' title="bbox 142 146 2173 258; baseline -0.004 -19; x_size 113; x_descenders 23; x_ascenders 32">
<span class='ocrx_word' id='word_1_1' title='bbox 142 154 982 258; x_wconf 90'>Linzensoep</span>
<span class='ocrx_word' id='word_1_2' title='bbox 1080 146 1152 237; x_wconf 96'>a</span>
<span class='ocrx_word' id='word_1_3' title='bbox 1254 153 1406 235; x_wconf 96'>la</span>
<span class='ocrx_word' id='word_1_4' title='bbox 1500 153 2173 235; x_wconf 96'>Waterman</span>
</span>
</p>
</div>
<div class='ocr_separator' id='block_1_2' title="bbox 136 253 2180 275"></div>
<div class='ocr_carea' id='block_1_3' title="bbox 145 414 2929 1221">
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 145 414 1154 516">
<span class='ocr_line' id='line_1_2' title="bbox 145 414 1154 516; baseline -0.005 -4; x_size 122.38636; x_descenders 24.386362; x_ascenders 40">
<span class='ocrx_word' id='word_1_5' title='bbox 145 414 211 512; x_wconf 60'>4</span>
<span class='ocrx_word' id='word_1_6' title='bbox 318 453 551 516; x_wconf 93'>ons</span>
<span class='ocrx_word' id='word_1_7' title='bbox 660 430 1154 512; x_wconf 91'>linzen</span>
</span>
</p>
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 146 568 1239 667">
<span class='ocr_line' id='line_1_3' title="bbox 146 568 1239 667; baseline -0.004 -16; x_size 99; x_descenders 17; x_ascenders 24">
<span class='ocrx_word' id='word_1_8' title='bbox 146 569 209 667; x_wconf 94'>3</span>
<span class='ocrx_word' id='word_1_9' title='bbox 323 568 729 652; x_wconf 83'>liter</span>
<span class='ocrx_word' id='word_1_10' title='bbox 821 569 1239 650; x_wconf 96'>water</span>
</span>
</p>
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 148 705 645 804">
<span class='ocr_line' id='line_1_4' title="bbox 148 705 645 804; baseline -0.016 -14; x_size 103; x_descenders 19; x_ascenders 27">
<span class='ocrx_word' id='word_1_11' title='bbox 148 706 211 804; x_wconf 88'>3</span>
<span class='ocrx_word' id='word_1_12' title='bbox 311 705 645 789; x_wconf 52'>uien</span>
</span>
</p>
<p class='ocr_par' id='par_1_5' lang='eng' title="bbox 147 832 1154 950">
<span class='ocr_line' id='line_1_5' title="bbox 147 832 1154 950; baseline -0.004 -27; x_size 118; x_descenders 28; x_ascenders 30">
<span class='ocrx_word' id='word_1_13' title='bbox 147 832 623 950; x_wconf 91'>bloem,</span>
<span class='ocrx_word' id='word_1_14' title='bbox 737 843 1154 924; x_wconf 91'>boter</span>
</span>
</p>
<p class='ocr_par' id='par_1_6' lang='eng' title="bbox 148 979 1241 1087">
<span class='ocr_line' id='line_1_6' title="bbox 148 979 1241 1087; baseline -0.005 -21; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_15' title='bbox 148 983 215 1066; x_wconf 88'>2</span>
<span class='ocrx_word' id='word_1_16' title='bbox 312 983 807 1087; x_wconf 88'>kopjes</span>
<span class='ocrx_word' id='word_1_17' title='bbox 905 979 1241 1062; x_wconf 92'>melk</span>
</span>
</p>
<p class='ocr_par' id='par_1_7' lang='eng' title="bbox 154 1109 2929 1221">
<span class='ocr_line' id='line_1_7' title="bbox 154 1109 2929 1221; baseline -0.003 -21; x_size 112; x_descenders 28; x_ascenders 26">
<span class='ocrx_word' id='word_1_18' title='bbox 154 1117 791 1221; x_wconf 92'>laurier,</span>
<span class='ocrx_word' id='word_1_19' title='bbox 906 1111 1810 1219; x_wconf 90'>kruidnagel,</span>
<span class='ocrx_word' id='word_1_20' title='bbox 1927 1109 2490 1219; x_wconf 90'>kerrie,</span>
<span class='ocrx_word' id='word_1_21' title='bbox 2615 1110 2929 1195; x_wconf 91'>zout</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_4' title="bbox 147 1383 3706 2731">
<p class='ocr_par' id='par_1_8' lang='eng' title="bbox 147 1383 3706 2731">
<span class='ocr_line' id='line_1_8' title="bbox 149 1383 3612 1474; baseline -0.003 -2; x_size 107.01524; x_descenders 25.01524; x_ascenders 21">
<span class='ocrx_word' id='word_1_22' title='bbox 149 1395 303 1474; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_23' title='bbox 411 1390 902 1473; x_wconf 80'>linzgen</span>
<span class='ocrx_word' id='word_1_24' title='bbox 996 1409 1497 1470; x_wconf 89'>wassen</span>
<span class='ocrx_word' id='word_1_25' title='bbox 1595 1409 1752 1470; x_wconf 88'>en</span>
<span class='ocrx_word' id='word_1_26' title='bbox 1853 1385 2166 1467; x_wconf 75'>in-l</span>
<span class='ocrx_word' id='word_1_27' title='bbox 2275 1383 2684 1466; x_wconf 93'>liter</span>
<span class='ocrx_word' id='word_1_28' title='bbox 2775 1383 3278 1464; x_wconf 91'>kokend</span>
<span class='ocrx_word' id='word_1_29' title='bbox 3368 1401 3612 1462; x_wconf 90'>wa-</span>
</span>
<span class='ocr_line' id='line_1_9' title="bbox 157 1516 3521 1632; baseline -0.004 -21; x_size 110; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_30' title='bbox 157 1531 394 1611; x_wconf 93'>ter</span>
<span class='ocrx_word' id='word_1_31' title='bbox 495 1527 558 1609; x_wconf 94'>1</span>
<span class='ocrx_word' id='word_1_32' title='bbox 658 1530 903 1632; x_wconf 75'>dag</span>
<span class='ocrx_word' id='word_1_33' title='bbox 1002 1527 1415 1609; x_wconf 85'>laten</span>
<span class='ocrx_word' id='word_1_34' title='bbox 1505 1525 1979 1611; x_wconf 86'>weken,</span>
<span class='ocrx_word' id='word_1_35' title='bbox 2103 1521 2167 1603; x_wconf 96'>2</span>
<span class='ocrx_word' id='word_1_36' title='bbox 2275 1518 2683 1603; x_wconf 93'>liter</span>
<span class='ocrx_word' id='word_1_37' title='bbox 2777 1519 3194 1601; x_wconf 96'>water</span>
<span class='ocrx_word' id='word_1_38' title='bbox 3286 1516 3521 1620; x_wconf 93'>bij</span>
</span>
<span class='ocr_line' id='line_1_10' title="bbox 152 1651 3616 1767; baseline -0.004 -21; x_size 110; x_descenders 27; x_ascenders 23">
<span class='ocrx_word' id='word_1_39' title='bbox 152 1668 302 1747; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_40' title='bbox 407 1662 905 1747; x_wconf 91'>linzen</span>
<span class='ocrx_word' id='word_1_41' title='bbox 996 1682 1559 1767; x_wconf 92'>voegen,</span>
<span class='ocrx_word' id='word_1_42' title='bbox 1680 1659 2176 1741; x_wconf 96'>zonder</span>
<span class='ocrx_word' id='word_1_43' title='bbox 2267 1655 2506 1737; x_wconf 96'>het</span>
<span class='ocrx_word' id='word_1_44' title='bbox 2606 1655 3023 1737; x_wconf 92'>water</span>
<span class='ocrx_word' id='word_1_45' title='bbox 3116 1651 3616 1735; x_wconf 91'>waarin</span>
</span>
<span class='ocr_line' id='line_1_11' title="bbox 153 1782 3704 1905; baseline -0.004 -20; x_size 117; x_descenders 23; x_ascenders 34">
<span class='ocrx_word' id='word_1_46' title='bbox 153 1824 305 1885; x_wconf 91'>ze</span>
<span class='ocrx_word' id='word_1_47' title='bbox 407 1800 979 1905; x_wconf 85'>geweekt</span>
<span class='ocrx_word' id='word_1_48' title='bbox 1089 1797 1412 1900; x_wconf 96'>zijn</span>
<span class='ocrx_word' id='word_1_49' title='bbox 1510 1796 1672 1878; x_wconf 96'>af</span>
<span class='ocrx_word' id='word_1_50' title='bbox 1770 1782 1914 1876; x_wconf 93'>te</span>
<span class='ocrx_word' id='word_1_51' title='bbox 2019 1792 2576 1899; x_wconf 54'>gieten,</span>
<span class='ocrx_word' id='word_1_52' title='bbox 2690 1790 2850 1874; x_wconf 93'>De</span>
<span class='ocrx_word' id='word_1_53' title='bbox 2948 1791 3357 1872; x_wconf 89'>helft</span>
<span class='ocrx_word' id='word_1_54' title='bbox 3452 1811 3704 1873; x_wconf 96'>van</span>
</span>
<span class='ocr_line' id='line_1_12' title="bbox 151 1928 3593 2035; baseline -0.003 -13; x_size 109; x_descenders 25; x_ascenders 25">
<span class='ocrx_word' id='word_1_55' title='bbox 151 1942 305 2024; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_56' title='bbox 403 1940 735 2021; x_wconf 89'>uien</span>
<span class='ocrx_word' id='word_1_57' title='bbox 829 1938 1330 2022; x_wconf 91'>bakken</span>
<span class='ocrx_word' id='word_1_58' title='bbox 1419 1939 1659 2018; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_59' title='bbox 1770 1933 2346 2016; x_wconf 91'>laurier</span>
<span class='ocrx_word' id='word_1_60' title='bbox 2447 1953 2603 2014; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_61' title='bbox 2691 1928 3593 2035; x_wconf 63'>Kruidnagel.</span>
</span>
<span class='ocr_line' id='line_1_13' title="bbox 151 2067 3451 2180; baseline -0.004 -21; x_size 108; x_descenders 25; x_ascenders 24">
<span class='ocrx_word' id='word_1_62' title='bbox 151 2076 473 2159; x_wconf 92'>Alle</span>
<span class='ocrx_word' id='word_1_63' title='bbox 569 2076 965 2180; x_wconf 88'>uien,</span>
<span class='ocrx_word' id='word_1_64' title='bbox 1079 2072 1578 2156; x_wconf 90'>kerrie</span>
<span class='ocrx_word' id='word_1_65' title='bbox 1685 2092 1837 2153; x_wconf 93'>en</span>
<span class='ocrx_word' id='word_1_66' title='bbox 1938 2072 2254 2153; x_wconf 81'>zgout</span>
<span class='ocrx_word' id='word_1_67' title='bbox 2355 2068 2593 2172; x_wconf 43'>bij</span>
<span class='ocrx_word' id='word_1_68' title='bbox 2696 2071 2850 2150; x_wconf 91'>de</span>
<span class='ocrx_word' id='word_1_69' title='bbox 2957 2067 3451 2150; x_wconf 85'>linzen</span>
</span>
<span class='ocr_line' id='line_1_14' title="bbox 147 2205 3614 2318; baseline -0.003 -23; x_size 106; x_descenders 22; x_ascenders 25">
<span class='ocrx_word' id='word_1_70' title='bbox 147 2234 711 2318; x_wconf 88'>voegen,</span>
<span class='ocrx_word' id='word_1_71' title='bbox 826 2210 1234 2295; x_wconf 95'>Alles</span>
<span class='ocrx_word' id='word_1_72' title='bbox 1342 2232 1588 2295; x_wconf 95'>aan</span>
<span class='ocrx_word' id='word_1_73' title='bbox 1679 2212 1831 2291; x_wconf 96'>de</span>
<span class='ocrx_word' id='word_1_74' title='bbox 1928 2208 2263 2290; x_wconf 93'>kook</span>
<span class='ocrx_word' id='word_1_75' title='bbox 2355 2206 3000 2308; x_wconf 54'>brengen,</span>
<span class='ocrx_word' id='word_1_76' title='bbox 3117 2205 3367 2285; x_wconf 95'>Van</span>
<span class='ocrx_word' id='word_1_77' title='bbox 3462 2206 3614 2287; x_wconf 95'>de</span>
</span>
<span class='ocr_line' id='line_1_15' title="bbox 152 2341 3706 2447; baseline -0.003 -18; x_size 107; x_descenders 24; x_ascenders 24">
<span class='ocrx_word' id='word_1_78' title='bbox 152 2352 565 2434; x_wconf 91'>bloem</span>
<span class='ocrx_word' id='word_1_79' title='bbox 655 2351 896 2431; x_wconf 92'>met</span>
<span class='ocrx_word' id='word_1_80' title='bbox 997 2349 1669 2431; x_wconf 90'>boter</span>
<span class='ocrx_word' id='word_1_81' title='bbox 1514 2337 1683 2455; x_wconf 91'>en</span>
<span class='ocrx_word' id='word_1_82' title='bbox 1757 2343 2095 2426; x_wconf 88'>melk</span>
<span class='ocrx_word' id='word_1_83' title='bbox 2193 2364 2432 2427; x_wconf 93'>een</span>
<span class='ocrx_word' id='word_1_84' title='bbox 2527 2341 2935 2447; x_wconf 90'>papje</span>
<span class='ocrx_word' id='word_1_85' title='bbox 3029 2341 3453 2422; x_wconf 96'>maken</span>
<span class='ocrx_word' id='word_1_86' title='bbox 3549 2362 3706 2422; x_wconf 95'>en</span>
</span>
<span class='ocr_line' id='line_1_16' title="bbox 149 2477 3619 2586; baseline -0.003 -16; x_size 107; x_descenders 24; x_ascenders 23">
<span class='ocrx_word' id='word_1_87' title='bbox 149 2489 650 2571; x_wconf 86'>verder</span>
<span class='ocrx_word' id='word_1_88' title='bbox 750 2486 1330 2570; x_wconf 90'>afmaken</span>
<span class='ocrx_word' id='word_1_89' title='bbox 1420 2485 1660 2567; x_wconf 96'>met</span>
<span class='ocrx_word' id='word_1_90' title='bbox 1765 2485 1917 2566; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_91' title='bbox 2025 2505 2409 2586; x_wconf 86'>soep,</span>
<span class='ocrx_word' id='word_1_92' title='bbox 2521 2479 2764 2564; x_wconf 96'>Als</span>
<span class='ocrx_word' id='word_1_93' title='bbox 2868 2480 3021 2561; x_wconf 92'>de</span>
<span class='ocrx_word' id='word_1_94' title='bbox 3127 2477 3619 2562; x_wconf 91'>linzen</span>
</span>
<span class='ocr_line' id='line_1_17' title="bbox 155 2619 2412 2731; baseline -0.005 -20; x_size 98; x_descenders 15; x_ascenders 23">
<span class='ocrx_word' id='word_1_95' title='bbox 155 2647 481 2731; x_wconf 35'>gfgaar</span>
<span class='ocrx_word' id='word_1_96' title='bbox 581 2624 909 2728; x_wconf 87'>Zijn</span>
<span class='ocrx_word' id='word_1_97' title='bbox 1005 2623 1153 2707; x_wconf 95'>is</span>
<span class='ocrx_word' id='word_1_98' title='bbox 1255 2624 1409 2706; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_99' title='bbox 1522 2642 1840 2726; x_wconf 91'>soep</span>
<span class='ocrx_word' id='word_1_100' title='bbox 1929 2619 2412 2709; x_wconf 89'>klaar.</span>
</span>
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,24 @@
Linzensoep a la Waterman
4 ons linzen
3 liter water
3 uien
bloem, boter
2 kopjes melk
laurier, kruidnagel, kerrie, zout
De linzgen wassen en in-l liter kokend wa-
ter 1 dag laten weken, 2 liter water bij
de linzen voegen, zonder het water waarin
ze geweekt zijn af te gieten, De helft van
de uien bakken met laurier en Kruidnagel.
Alle uien, kerrie en zgout bij de linzen
voegen, Alles aan de kook brengen, Van de
bloem met boter en melk een papje maken en
verder afmaken met de soep, Als de linzen
gfgaar Zijn is de soep klaar.
@@ -9,7 +9,7 @@
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.9_hn68nz/000001_ocr.png"; bbox 0 0 1000 800; ppageno 0; scan_res 200 200'>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.su6l_waz/000001_ocr.png"; bbox 0 0 1000 800; ppageno 0; scan_res 200 200'>
<div class='ocr_photo' id='block_1_1' title="bbox 296 96 704 504"></div>
<div class='ocr_carea' id='block_1_2' title="bbox 150 592 841 622">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 150 592 841 622">
@@ -9,7 +9,7 @@
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.q0nk4qy2/000001_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.g296cpyi/000001_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
@@ -9,7 +9,7 @@
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.q0nk4qy2/000002_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.91dzclia/000002_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
@@ -9,7 +9,7 @@
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.q0nk4qy2/000003_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.91dzclia/000003_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
@@ -9,7 +9,7 @@
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.q0nk4qy2/000004_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.91dzclia/000004_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
@@ -9,7 +9,7 @@
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.zopxh6db/000001_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.bgz1p10r/000001_ocr.png"; bbox 0 0 2550 3300; ppageno 0; scan_res 300 300'>
<div class='ocr_carea' id='block_1_1' title="bbox 582 131 1968 303">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 582 131 1968 303">
<span class='ocr_header' id='line_1_1' title="bbox 882 131 1657 217; baseline 0.001 -17; x_size 85; x_descenders 16; x_ascenders 19">
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.9pz7mnez/000001_ocr.png"; bbox 0 0 900 900; ppageno 0; scan_res 300 300'>
<div class='ocr_carea' id='block_1_1' title="bbox 180 159 760 740">
<p class='ocr_par' id='par_1_1' lang='deu' title="bbox 180 159 760 740">
<span class='ocr_line' id='line_1_1' title="bbox 182 159 721 198; baseline 0 -8; x_size 39; x_descenders 8; x_ascenders 10">
<span class='ocrx_word' id='word_1_1' title='bbox 182 160 292 190; x_wconf 92'>Portez</span>
<span class='ocrx_word' id='word_1_2' title='bbox 307 169 344 190; x_wconf 93'>ce</span>
<span class='ocrx_word' id='word_1_3' title='bbox 357 160 449 190; x_wconf 92'>vieux</span>
<span class='ocrx_word' id='word_1_4' title='bbox 462 159 583 197; x_wconf 91'>whisky</span>
<span class='ocrx_word' id='word_1_5' title='bbox 595 169 633 190; x_wconf 92'>au</span>
<span class='ocrx_word' id='word_1_6' title='bbox 646 160 721 198; x_wconf 87'>juge</span>
</span>
<span class='ocr_line' id='line_1_2' title="bbox 181 209 640 247; baseline 0 -7; x_size 38; x_descenders 7; x_ascenders 10">
<span class='ocrx_word' id='word_1_7' title='bbox 181 209 276 240; x_wconf 95'>blond</span>
<span class='ocrx_word' id='word_1_8' title='bbox 293 210 343 247; x_wconf 93'>qui</span>
<span class='ocrx_word' id='word_1_9' title='bbox 358 210 448 240; x_wconf 91'>fume</span>
<span class='ocrx_word' id='word_1_10' title='bbox 463 219 516 240; x_wconf 96'>sur</span>
<span class='ocrx_word' id='word_1_11' title='bbox 529 219 587 240; x_wconf 96'>son</span>
<span class='ocrx_word' id='word_1_12' title='bbox 602 209 640 240; x_wconf 89'>Ile</span>
</span>
<span class='ocr_line' id='line_1_3' title="bbox 181 259 676 295; baseline 0 -5; x_size 36; x_descenders 5; x_ascenders 10">
<span class='ocrx_word' id='word_1_13' title='bbox 181 260 357 295; x_wconf 89'>interieure,</span>
<span class='ocrx_word' id='word_1_14' title='bbox 372 260 387 290; x_wconf 83'>a</span>
<span class='ocrx_word' id='word_1_15' title='bbox 404 260 478 290; x_wconf 61'>cöte</span>
<span class='ocrx_word' id='word_1_16' title='bbox 493 259 534 290; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_17' title='bbox 551 259 676 290; x_wconf 81'>l&#39;alcöve</span>
</span>
<span class='ocr_line' id='line_1_4' title="bbox 180 309 613 345; baseline 0 -5; x_size 36; x_descenders 5; x_ascenders 10">
<span class='ocrx_word' id='word_1_18' title='bbox 180 309 305 345; x_wconf 92'>ovoide,</span>
<span class='ocrx_word' id='word_1_19' title='bbox 321 310 362 340; x_wconf 38'>oU</span>
<span class='ocrx_word' id='word_1_20' title='bbox 380 309 423 340; x_wconf 92'>les</span>
<span class='ocrx_word' id='word_1_21' title='bbox 440 309 562 340; x_wconf 92'>büches</span>
<span class='ocrx_word' id='word_1_22' title='bbox 577 319 613 340; x_wconf 96'>se</span>
</span>
<span class='ocr_line' id='line_1_5' title="bbox 180 359 693 397; baseline 0 -7; x_size 36; x_descenders 5; x_ascenders 10">
<span class='ocrx_word' id='word_1_23' title='bbox 180 362 375 390; x_wconf 92'>consument</span>
<span class='ocrx_word' id='word_1_24' title='bbox 389 359 469 390; x_wconf 92'>dans</span>
<span class='ocrx_word' id='word_1_25' title='bbox 485 359 575 395; x_wconf 86'>l&#39;ätre,</span>
<span class='ocrx_word' id='word_1_26' title='bbox 591 369 628 390; x_wconf 96'>ce</span>
<span class='ocrx_word' id='word_1_27' title='bbox 643 360 693 397; x_wconf 96'>qui</span>
</span>
<span class='ocr_line' id='line_1_6' title="bbox 181 409 617 447; baseline 0 -7; x_size 38; x_descenders 7; x_ascenders 10">
<span class='ocrx_word' id='word_1_28' title='bbox 181 409 217 440; x_wconf 49'>ui</span>
<span class='ocrx_word' id='word_1_29' title='bbox 234 412 359 447; x_wconf 91'>permet</span>
<span class='ocrx_word' id='word_1_30' title='bbox 373 409 414 440; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_31' title='bbox 430 419 548 447; x_wconf 90'>penser</span>
<span class='ocrx_word' id='word_1_32' title='bbox 556 405 568 451; x_wconf 73'>&amp;</span>
<span class='ocrx_word' id='word_1_33' title='bbox 594 409 617 440; x_wconf 96'>la</span>
</span>
<span class='ocr_line' id='line_1_7' title="bbox 180 459 680 498; baseline 0 -8; x_size 39; x_descenders 8; x_ascenders 10">
<span class='ocrx_word' id='word_1_34' title='bbox 180 460 402 498; x_wconf 79'>caenogenese</span>
<span class='ocrx_word' id='word_1_35' title='bbox 417 459 458 490; x_wconf 93'>de</span>
<span class='ocrx_word' id='word_1_36' title='bbox 475 459 557 490; x_wconf 42'>|&#39;etre</span>
<span class='ocrx_word' id='word_1_37' title='bbox 572 459 653 490; x_wconf 96'>dont</span>
<span class='ocrx_word' id='word_1_38' title='bbox 668 459 680 490; x_wconf 94'>il</span>
</span>
<span class='ocr_line' id='line_1_8' title="bbox 180 509 646 547; baseline 0 -7; x_size 38; x_descenders 7; x_ascenders 10">
<span class='ocrx_word' id='word_1_39' title='bbox 180 512 231 540; x_wconf 96'>est</span>
<span class='ocrx_word' id='word_1_40' title='bbox 245 510 396 547; x_wconf 96'>question</span>
<span class='ocrx_word' id='word_1_41' title='bbox 412 509 492 540; x_wconf 96'>dans</span>
<span class='ocrx_word' id='word_1_42' title='bbox 509 509 531 540; x_wconf 95'>la</span>
<span class='ocrx_word' id='word_1_43' title='bbox 548 519 646 540; x_wconf 96'>cause</span>
</span>
<span class='ocr_line' id='line_1_9' title="bbox 180 559 736 598; baseline 0 -8; x_size 36; x_descenders 5; x_ascenders 10">
<span class='ocrx_word' id='word_1_44' title='bbox 180 559 328 598; x_wconf 87'>ambigu&amp;</span>
<span class='ocrx_word' id='word_1_45' title='bbox 343 559 510 590; x_wconf 91'>entendue</span>
<span class='ocrx_word' id='word_1_46' title='bbox 525 560 541 590; x_wconf 93'>a</span>
<span class='ocrx_word' id='word_1_47' title='bbox 559 560 641 597; x_wconf 61'>MoY,</span>
<span class='ocrx_word' id='word_1_48' title='bbox 657 559 736 590; x_wconf 96'>dans</span>
</span>
<span class='ocr_line' id='line_1_10' title="bbox 181 609 719 647; baseline 0 -7; x_size 38; x_descenders 7; x_ascenders 10">
<span class='ocrx_word' id='word_1_49' title='bbox 181 619 221 640; x_wconf 93'>un</span>
<span class='ocrx_word' id='word_1_50' title='bbox 237 609 452 647; x_wconf 92'>capharnaüm</span>
<span class='ocrx_word' id='word_1_51' title='bbox 469 610 528 647; x_wconf 93'>qui,</span>
<span class='ocrx_word' id='word_1_52' title='bbox 545 609 719 647; x_wconf 91'>pense-t-il,</span>
</span>
<span class='ocr_line' id='line_1_11' title="bbox 180 659 760 697; baseline 0 -7; x_size 38; x_descenders 7; x_ascenders 10">
<span class='ocrx_word' id='word_1_53' title='bbox 180 659 319 690; x_wconf 90'>diminue</span>
<span class='ocrx_word' id='word_1_54' title='bbox 334 660 369 697; x_wconf 95'>ca</span>
<span class='ocrx_word' id='word_1_55' title='bbox 385 662 418 690; x_wconf 95'>et</span>
<span class='ocrx_word' id='word_1_56' title='bbox 434 659 457 690; x_wconf 94'>la</span>
<span class='ocrx_word' id='word_1_57' title='bbox 474 659 497 690; x_wconf 92'>la</span>
<span class='ocrx_word' id='word_1_58' title='bbox 514 659 630 697; x_wconf 75'>qualit&amp;</span>
<span class='ocrx_word' id='word_1_59' title='bbox 645 659 686 690; x_wconf 96'>de</span>
<span class='ocrx_word' id='word_1_60' title='bbox 701 669 760 690; x_wconf 96'>son</span>
</span>
<span class='ocr_line' id='line_1_12' title="bbox 180 719 300 740; baseline 0 0; x_size 39.328766; x_descenders 7.3287673; x_ascenders 10.5">
<span class='ocrx_word' id='word_1_61' title='bbox 180 719 300 740; x_wconf 66'>ceuvre.</span>
</span>
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
Portez ce vieux whisky au juge
blond qui fume sur son Ile
interieure, a cöte de l'alcöve
ovoide, oU les büches se
consument dans l'ätre, ce qui
ui permet de penser & la
caenogenese de |'etre dont il
est question dans la cause
ambigu& entendue a MoY, dans
un capharnaüm qui, pense-t-il,
diminue ca et la la qualit& de son
ceuvre.
@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='tesseract 5.3.2' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "/tmp/ocrmypdf.io.2ahiyq00/000001_ocr.png"; bbox 0 0 3992 1667; ppageno 0; scan_res 400 400'>
<div class='ocr_separator' id='block_1_1' title="bbox 185 50 3278 54"></div>
<div class='ocr_separator' id='block_1_2' title="bbox 185 249 3278 254"></div>
<div class='ocr_separator' id='block_1_3' title="bbox 185 446 1721 450"></div>
<div class='ocr_separator' id='block_1_4' title="bbox 184 642 846 646"></div>
<div class='ocr_separator' id='block_1_5' title="bbox 184 842 966 846"></div>
<div class='ocr_separator' id='block_1_6' title="bbox 204 50 209 1257"></div>
<div class='ocr_separator' id='block_1_7' title="bbox 2937 446 3278 450"></div>
<div class='ocr_separator' id='block_1_8' title="bbox 1183 642 2438 646"></div>
<div class='ocr_separator' id='block_1_9' title="bbox 2930 642 3278 646"></div>
<div class='ocr_separator' id='block_1_10' title="bbox 1907 842 3278 846"></div>
<div class='ocr_separator' id='block_1_11' title="bbox 448 1037 2904 1042"></div>
<div class='ocr_separator' id='block_1_12' title="bbox 209 1220 1765 1261"></div>
<div class='ocr_separator' id='block_1_13' title="bbox 1725 1133 2047 1151"></div>
<div class='ocr_separator' id='block_1_14' title="bbox 1755 1229 3279 1243"></div>
<div class='ocr_carea' id='block_1_15' title="bbox 45 36 3110 1619">
<p class='ocr_par' id='par_1_1' lang='eng' title="bbox 45 36 3110 1619">
<span class='ocr_line' id='line_1_1' title="bbox 68 36 153 72; baseline 0 0; x_size 43.868851; x_descenders 7.8688521; x_ascenders 11.409836">
<span class='ocrx_word' id='word_1_1' title='bbox 68 36 153 72; x_wconf 95'>600</span>
</span>
<span class='ocr_line' id='line_1_2' title="bbox 69 66 388 446; baseline -0.006 -178; x_size 43.868851; x_descenders 7.8688521; x_ascenders 11.409836">
<span class='ocrx_word' id='word_1_2' title='bbox 69 232 153 268; x_wconf 96'>500</span>
<span class='ocrx_word' id='word_1_3' title='bbox 262 66 388 446; x_wconf 29'>.</span>
</span>
<span class='ocr_line' id='line_1_3' title="bbox 63 388 2969 642; baseline -0.009 -173.963; x_size 62.52; x_descenders 11.52; x_ascenders 15">
<span class='ocrx_word' id='word_1_4' title='bbox 63 413 158 484; x_wconf 96'>400</span>
<span class='ocrx_word' id='word_1_5' title='bbox 355 400 657 642; x_wconf 46'>_</span>
<span class='ocrx_word' id='word_1_6' title='bbox 2164 392 2374 463; x_wconf 28'>EB</span>
<span class='ocrx_word' id='word_1_7' title='bbox 2731 388 2788 458; x_wconf 23'>fp</span>
<span class='ocrx_word' id='word_1_8' title='bbox 2814 450 2969 627; x_wconf 0'>ys</span>
</span>
<span class='ocr_line' id='line_1_4' title="bbox 69 446 2897 771; baseline -0.009 -107; x_size 59; x_descenders 9; x_ascenders 14">
<span class='ocrx_word' id='word_1_9' title='bbox 69 610 153 677; x_wconf 96'>300</span>
<span class='ocrx_word' id='word_1_10' title='bbox 941 618 1130 666; x_wconf 0'>—f—</span>
<span class='ocrx_word' id='word_1_11' title='bbox 1679 446 1950 771; x_wconf 0'>EN</span>
<span class='ocrx_word' id='word_1_12' title='bbox 2031 446 2140 771; x_wconf 0'>/</span>
<span class='ocrx_word' id='word_1_13' title='bbox 2501 446 2706 655; x_wconf 73'>~/</span>
<span class='ocrx_word' id='word_1_14' title='bbox 2793 450 2897 642; x_wconf 19'>Y</span>
</span>
<span class='ocr_line' id='line_1_5' title="bbox 70 646 2737 964; baseline -0.016 -24; x_size 131; x_descenders 32; x_ascenders 39">
<span class='ocrx_word' id='word_1_15' title='bbox 70 828 365 964; x_wconf 0'>Tg</span>
<span class='ocrx_word' id='word_1_16' title='bbox 1494 670 2098 940; x_wconf 42'>ANA</span>
<span class='ocrx_word' id='word_1_17' title='bbox 2158 646 2737 948; x_wconf 40'>a3</span>
</span>
<span class='ocr_line' id='line_1_6' title="bbox 70 1024 2937 1141; baseline -0.002 -56; x_size 31.799999; x_descenders 4.7999997; x_ascenders 12">
<span class='ocrx_word' id='word_1_18' title='bbox 70 1024 152 1061; x_wconf 90'>100</span>
<span class='ocrx_word' id='word_1_19' title='bbox 184 1037 204 1042; x_wconf 50'>-</span>
<span class='ocrx_word' id='word_1_20' title='bbox 1247 1038 1428 1141; x_wconf 23'>nee</span>
<span class='ocrx_word' id='word_1_21' title='bbox 1453 1038 1642 1141; x_wconf 0'>-eemiae</span>
</span>
<span class='ocr_line' id='line_1_7' title="bbox 121 1154 2840 1292; baseline -0.008 -12; x_size 41; x_descenders 8; x_ascenders 12">
<span class='ocrx_word' id='word_1_22' title='bbox 121 1216 299 1292; x_wconf 0'>0-+—-*</span>
<span class='ocrx_word' id='word_1_23' title='bbox 1811 1243 1964 1263; x_wconf 13'>a</span>
<span class='ocrx_word' id='word_1_24' title='bbox 2167 1154 2307 1274; x_wconf 31'>ee</span>
<span class='ocrx_word' id='word_1_25' title='bbox 2377 1154 2477 1274; x_wconf 17'>ee</span>
<span class='ocrx_word' id='word_1_26' title='bbox 2527 1154 2617 1274; x_wconf 32'>ee</span>
<span class='ocrx_word' id='word_1_27' title='bbox 2667 1243 2840 1263; x_wconf 26'>eee</span>
</span>
<span class='ocr_line' id='line_1_8' title="bbox 248 1293 3058 1353; baseline -0.002 -14; x_size 50; x_descenders 11; x_ascenders 8">
<span class='ocrx_word' id='word_1_28' title='bbox 248 1300 279 1339; x_wconf 53'>S</span>
<span class='ocrx_word' id='word_1_29' title='bbox 395 1310 425 1339; x_wconf 49'>@</span>
<span class='ocrx_word' id='word_1_30' title='bbox 540 1300 572 1339; x_wconf 23'>s</span>
<span class='ocrx_word' id='word_1_31' title='bbox 979 1309 1012 1339; x_wconf 81'>&gt;</span>
<span class='ocrx_word' id='word_1_32' title='bbox 1121 1295 1155 1353; x_wconf 37'>oO</span>
<span class='ocrx_word' id='word_1_33' title='bbox 1278 1306 1304 1334; x_wconf 24'>©</span>
<span class='ocrx_word' id='word_1_34' title='bbox 1407 1295 1450 1353; x_wconf 20'>ve</span>
<span class='ocrx_word' id='word_1_35' title='bbox 1567 1308 1593 1338; x_wconf 61'>S</span>
<span class='ocrx_word' id='word_1_36' title='bbox 1712 1308 1740 1338; x_wconf 0'>)</span>
<span class='ocrx_word' id='word_1_37' title='bbox 1858 1308 1888 1338; x_wconf 61'>@</span>
<span class='ocrx_word' id='word_1_38' title='bbox 2004 1305 2037 1338; x_wconf 80'>+</span>
<span class='ocrx_word' id='word_1_39' title='bbox 2153 1308 2180 1336; x_wconf 7'>&amp;</span>
<span class='ocrx_word' id='word_1_40' title='bbox 2287 1305 2329 1347; x_wconf 88'>&amp;</span>
<span class='ocrx_word' id='word_1_41' title='bbox 2578 1293 2613 1351; x_wconf 51'>“A</span>
<span class='ocrx_word' id='word_1_42' title='bbox 2738 1308 2765 1335; x_wconf 41'>5</span>
<span class='ocrx_word' id='word_1_43' title='bbox 3014 1308 3058 1344; x_wconf 23'>oo</span>
</span>
<span class='ocr_line' id='line_1_9' title="bbox 184 1306 3034 1411; baseline -0.008 -16; x_size 63; x_descenders 14; x_ascenders 18">
<span class='ocrx_word' id='word_1_44' title='bbox 184 1327 400 1399; x_wconf 40'>Se</span>
<span class='ocrx_word' id='word_1_45' title='bbox 622 1308 718 1404; x_wconf 53'>°</span>
<span class='ocrx_word' id='word_1_46' title='bbox 774 1330 988 1397; x_wconf 28'>Ps</span>
<span class='ocrx_word' id='word_1_47' title='bbox 1194 1360 1251 1411; x_wconf 19'>as</span>
<span class='ocrx_word' id='word_1_48' title='bbox 1360 1324 1425 1390; x_wconf 33'>ge</span>
<span class='ocrx_word' id='word_1_49' title='bbox 1641 1325 1717 1401; x_wconf 5'>eS</span>
<span class='ocrx_word' id='word_1_50' title='bbox 1801 1328 1869 1393; x_wconf 11'>€</span>
<span class='ocrx_word' id='word_1_51' title='bbox 1941 1325 2017 1392; x_wconf 68'>x</span>
<span class='ocrx_word' id='word_1_52' title='bbox 2077 1326 2161 1406; x_wconf 28'>ro</span>
<span class='ocrx_word' id='word_1_53' title='bbox 2232 1337 2297 1403; x_wconf 43'>NS</span>
<span class='ocrx_word' id='word_1_54' title='bbox 2379 1306 2468 1394; x_wconf 38'>Po</span>
<span class='ocrx_word' id='word_1_55' title='bbox 2535 1323 2597 1391; x_wconf 8'>e?</span>
<span class='ocrx_word' id='word_1_56' title='bbox 2667 1324 2751 1406; x_wconf 66'>&amp;</span>
<span class='ocrx_word' id='word_1_57' title='bbox 2845 1308 2913 1376; x_wconf 28'>s</span>
<span class='ocrx_word' id='word_1_58' title='bbox 2959 1324 3034 1401; x_wconf 48'>AS</span>
</span>
<span class='ocr_line' id='line_1_10' title="bbox 161 1360 3110 1447; baseline -0.003 -5; x_size 64; x_descenders 16; x_ascenders 20">
<span class='ocrx_word' id='word_1_59' title='bbox 161 1382 207 1426; x_wconf 15'>a</span>
<span class='ocrx_word' id='word_1_60' title='bbox 436 1384 494 1430; x_wconf 17'>a</span>
<span class='ocrx_word' id='word_1_61' title='bbox 750 1395 758 1441; x_wconf 17'>©</span>
<span class='ocrx_word' id='word_1_62' title='bbox 1318 1378 1521 1443; x_wconf 61'>FF</span>
<span class='ocrx_word' id='word_1_63' title='bbox 1615 1387 1655 1435; x_wconf 48'>SF</span>
<span class='ocrx_word' id='word_1_64' title='bbox 1753 1385 1813 1434; x_wconf 41'>HY</span>
<span class='ocrx_word' id='word_1_65' title='bbox 1891 1382 1960 1442; x_wconf 40'>HK</span>
<span class='ocrx_word' id='word_1_66' title='bbox 2050 1399 2092 1430; x_wconf 17'>SK</span>
<span class='ocrx_word' id='word_1_67' title='bbox 2187 1388 2251 1441; x_wconf 52'>BM</span>
<span class='ocrx_word' id='word_1_68' title='bbox 2630 1396 2678 1435; x_wconf 0'>ee</span>
<span class='ocrx_word' id='word_1_69' title='bbox 2920 1393 3110 1442; x_wconf 44'>sO</span>
</span>
<span class='ocr_line' id='line_1_11' title="bbox 141 1413 3090 1485; baseline -0.005 -17; x_size 42.720001; x_descenders 6.7199998; x_ascenders 15">
<span class='ocrx_word' id='word_1_70' title='bbox 141 1416 171 1446; x_wconf 35'>e</span>
<span class='ocrx_word' id='word_1_71' title='bbox 266 1438 288 1467; x_wconf 87'>&lt;</span>
<span class='ocrx_word' id='word_1_72' title='bbox 413 1426 448 1477; x_wconf 28'>Na</span>
<span class='ocrx_word' id='word_1_73' title='bbox 569 1429 589 1458; x_wconf 0'></span>
<span class='ocrx_word' id='word_1_74' title='bbox 975 1454 982 1462; x_wconf 8'>:</span>
<span class='ocrx_word' id='word_1_75' title='bbox 1142 1428 1183 1463; x_wconf 23'>&gt;</span>
<span class='ocrx_word' id='word_1_76' title='bbox 1300 1421 1334 1471; x_wconf 10'>cy</span>
<span class='ocrx_word' id='word_1_77' title='bbox 1417 1420 1471 1480; x_wconf 57'>xs</span>
<span class='ocrx_word' id='word_1_78' title='bbox 1566 1424 1625 1476; x_wconf 40'>eS</span>
<span class='ocrx_word' id='word_1_79' title='bbox 1882 1433 1910 1460; x_wconf 59'>@</span>
<span class='ocrx_word' id='word_1_80' title='bbox 2024 1417 2069 1456; x_wconf 59'>Ww</span>
<span class='ocrx_word' id='word_1_81' title='bbox 2878 1413 2934 1485; x_wconf 16'>a)</span>
<span class='ocrx_word' id='word_1_82' title='bbox 3029 1425 3090 1476; x_wconf 33'>Oo</span>
</span>
<span class='ocr_line' id='line_1_12' title="bbox 45 1433 3041 1546; baseline -0.011 -11; x_size 108.376; x_descenders 22; x_ascenders 27.375999">
<span class='ocrx_word' id='word_1_83' title='bbox 45 1433 279 1535; x_wconf 61'>ee</span>
<span class='ocrx_word' id='word_1_84' title='bbox 353 1450 430 1514; x_wconf 27'>S&amp;S</span>
<span class='ocrx_word' id='word_1_85' title='bbox 528 1445 722 1535; x_wconf 55'>FF</span>
<span class='ocrx_word' id='word_1_86' title='bbox 915 1443 1158 1546; x_wconf 49'>SF</span>
<span class='ocrx_word' id='word_1_87' title='bbox 1233 1445 1317 1518; x_wconf 42'>HK</span>
<span class='ocrx_word' id='word_1_88' title='bbox 1386 1462 1427 1518; x_wconf 63'>S</span>
<span class='ocrx_word' id='word_1_89' title='bbox 1833 1453 1889 1502; x_wconf 66'>e</span>
<span class='ocrx_word' id='word_1_90' title='bbox 2004 1440 2046 1476; x_wconf 33'>©</span>
<span class='ocrx_word' id='word_1_91' title='bbox 3006 1457 3041 1492; x_wconf 77'>4</span>
</span>
<span class='ocr_line' id='line_1_13' title="bbox 152 1516 1391 1593; baseline -0.006 -40; x_size 40; x_descenders 7; x_ascenders 13">
<span class='ocrx_word' id='word_1_92' title='bbox 152 1520 214 1575; x_wconf 71'>~</span>
<span class='ocrx_word' id='word_1_93' title='bbox 586 1526 646 1587; x_wconf 85'>&amp;</span>
<span class='ocrx_word' id='word_1_94' title='bbox 1304 1516 1391 1593; x_wconf 0'>e</span>
</span>
<span class='ocr_line' id='line_1_14' title="bbox 247 1567 597 1619; baseline -0.009 0; x_size 55.75; x_descenders 10; x_ascenders 14.5">
<span class='ocrx_word' id='word_1_95' title='bbox 247 1570 297 1619; x_wconf 32'>3</span>
<span class='ocrx_word' id='word_1_96' title='bbox 550 1567 597 1609; x_wconf 43'>x</span>
</span>
</p>
</div>
<div class='ocr_carea' id='block_1_16' title="bbox 3353 611 3919 1060">
<p class='ocr_par' id='par_1_2' lang='eng' title="bbox 3354 611 3660 656">
<span class='ocr_line' id='line_1_15' title="bbox 3354 611 3660 656; baseline -0.013 -5; x_size 46; x_descenders 9; x_ascenders 10">
<span class='ocrx_word' id='word_1_97' title='bbox 3354 611 3660 656; x_wconf 59'>—¢—Support</span>
</span>
</p>
<p class='ocr_par' id='par_1_3' lang='eng' title="bbox 3354 711 3831 960">
<span class='ocr_line' id='line_1_16' title="bbox 3354 711 3831 760; baseline 0 -9; x_size 49; x_descenders 9; x_ascenders 13">
<span class='ocrx_word' id='word_1_98' title='bbox 3354 712 3542 751; x_wconf 0'>—H—</span>
<span class='ocrx_word' id='word_1_99' title='bbox 3480 707 3524 765; x_wconf 90'>No</span>
<span class='ocrx_word' id='word_1_100' title='bbox 3555 711 3773 760; x_wconf 80'>vote[note</span>
<span class='ocrx_word' id='word_1_101' title='bbox 3790 711 3831 760; x_wconf 96'>1]</span>
</span>
<span class='ocr_line' id='line_1_17' title="bbox 3355 815 3652 860; baseline 0.003 -9; x_size 44; x_descenders 8; x_ascenders 9">
<span class='ocrx_word' id='word_1_102' title='bbox 3355 811 3457 864; x_wconf 29'>ir</span>
<span class='ocrx_word' id='word_1_103' title='bbox 3492 811 3652 864; x_wconf 96'>Oppose</span>
</span>
<span class='ocr_line' id='line_1_18' title="bbox 3354 912 3735 960; baseline 0.003 -9; x_size 50; x_descenders 10; x_ascenders 12">
<span class='ocrx_word' id='word_1_104' title='bbox 3354 912 3677 960; x_wconf 0'>—=—Net[note</span>
<span class='ocrx_word' id='word_1_105' title='bbox 3694 912 3735 960; x_wconf 96'>2]</span>
</span>
</p>
<p class='ocr_par' id='par_1_4' lang='eng' title="bbox 3353 1011 3919 1060">
<span class='ocr_line' id='line_1_19' title="bbox 3353 1011 3919 1060; baseline 0.004 -10; x_size 51; x_descenders 10; x_ascenders 13">
<span class='ocrx_word' id='word_1_106' title='bbox 3353 1007 3453 1066; x_wconf 23'>re</span>
<span class='ocrx_word' id='word_1_107' title='bbox 3486 1007 3723 1066; x_wconf 82'>Percentage</span>
<span class='ocrx_word' id='word_1_108' title='bbox 3749 1011 3864 1060; x_wconf 95'>[note</span>
<span class='ocrx_word' id='word_1_109' title='bbox 3877 1011 3919 1060; x_wconf 95'>3]</span>
</span>
</p>
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More