Compare commits

...
386 Commits
Author SHA1 Message Date
James R. Barlow aa6a32e7d1 Bump version: v17.9.0 2026-07-31 01:14:13 -07:00
James R. Barlow ea99758747 bump_version.py: format after edit 2026-07-31 01:14:00 -07:00
James R. Barlow 4942751a1b Add v17.9.0 release notes for #1723, #1713, and CI release-draft fix 2026-07-31 00:45:59 -07:00
James R. Barlow be06e3184a Merge remote-tracking branch 'origin/dependabot/uv/gitpython-3.1.54' 2026-07-31 00:26:12 -07:00
James R. Barlow 39bf09f1eb docs: format 2026-07-28 11:30:52 -07:00
James R. Barlow aaffc46f73 Update uv lock 2026-07-28 01:10:04 -07:00
dependabot[bot]andGitHub 0277b3b3ba build(deps): bump gitpython from 3.1.52 to 3.1.54
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.52 to 3.1.54.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.54)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.54
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 07:58:28 +00:00
James R. Barlow 0817542883 Merge remote-tracking branches 'origin/dependabot/uv/gitpython-3.1.52', 'origin/dependabot/uv/pillow-12.3.0' and 'origin/dependabot/github_actions/actions/setup-python-7' 2026-07-27 23:21:25 -07:00
James R. Barlow 6f4744dd20 Fix --jpeg-quality/--jpg-quality being dropped by the CLI (closes #1723)
namespace_to_options() only copied argparse namespace keys that were
literal members of OcrOptions.model_fields. The CLI dest for both
--jpeg-quality and --jpg-quality was jpeg_quality, but the pydantic field
was named jpg_quality (jpeg_quality existed only as a compatibility
property, absent from model_fields). The value was silently dropped into
extra_attrs, and the optimizer always fell back to its own hardcoded
default regardless of the flag.

The same alias mismatch also affected the Python API: create_options()
uses the same model_fields-matching logic as namespace_to_options(), so
ocrmypdf.ocr(jpeg_quality=...) was silently dropped too - only the
canonical jpg_quality= kwarg worked.

Rather than patch around the mismatch, consolidate on a single canonical
name: OcrOptions.jpeg_quality (matching the primary --jpeg-quality CLI
flag and the already-consistent naming in OptimizeOptions). jpg_quality
becomes a deprecated compatibility property, and ocrmypdf.ocr(jpg_quality=)
is a deprecated alias that warns and forwards to jpeg_quality via a new
create_options() remap step. --jpg-quality remains a working (already
hidden) CLI alias with no code-path divergence, since it now shares an
argparse dest that matches the field name directly.
2026-07-27 23:14:27 -07:00
James R. Barlow 5d49f75c56 Use any installed Noto font when named families lack glyphs (closes #1722)
SystemFontProvider.NOTO_FONT_PATTERNS enumerates about two dozen Noto
families by name, and MultiFontManager only ever asked for those. Any
script outside that list fell through to the glyphless Occulta fallback
even when the correct font was installed, which is the common case on
macOS: it ships around a hundred script-specific Noto faces in
/System/Library/Fonts/Supplemental, almost none of which we knew how to
ask for. The reporter had the fonts and still got told to install them.

Add an optional GlyphSearchingFontProvider protocol (find_font_with_glyphs)
implemented by SystemFontProvider, BuiltinFontProvider and
ChainedFontProvider, and a new selection phase that uses it once the named
families fail. The system scan enumerates every Noto face, reusing the
existing variable-font/-Regular/-VF filename classification, and keeps
only the font it selects so a full scan does not retain every font file on
the system. Fonts found this way are remembered and retried by name for
later words. Capability detection is isinstance-based so third-party
providers keep working unchanged.

The warning itself was also unactionable: it named neither the characters
nor the script, which is why the reporter had to ask which package to
install. It now identifies what it could not render, e.g.
'Ꮳ' U+13E3 CHEROKEE LETTER TSA. A word mixing scripts that no single font
covers now gets a distinct message saying that installing fonts will not
help, rather than sending the user after fonts they already have.

Finally, the documented macOS install command was wrong: `brew install
font-noto` does not exist, since Homebrew has no single Noto package, only
a cask per family. The Fedora package name was also corrected, as
google-noto-fonts-common ships no actual fonts.
2026-07-26 23:25:51 -07:00
dependabot[bot]andGitHub 5a824ddd8c build(deps): bump gitpython from 3.1.50 to 3.1.52
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.50 to 3.1.52.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.52)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.52
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-22 14:07:17 +00:00
dependabot[bot]andGitHub 54bf03a454 build(deps): bump pillow from 12.2.0 to 12.3.0
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 10:59:50 +00:00
dependabot[bot]andGitHub 009754d137 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 10:42:33 +00:00
James R. Barlow f0a3a74374 Don't re-draft an already-published release on every main push
stage_release runs on every push to main and unconditionally deletes
and recreates the draft release for the version in _version.py. Since
_version.py isn't bumped until sometime after a release is tagged and
published by release.yml, every intervening push to main was clobbering
the just-published release back to draft status. Confirmed this hit
v17.3.0 through v17.8.0 (and briefly v17.8.1, manually fixed).

Skip the delete/recreate if a non-draft release already exists for
this tag.
2026-07-17 09:52:35 -07:00
James R. Barlow 178d339c8e Merge branch 'fix-image-xobject-nondict' 2026-07-17 00:59:30 -07:00
jbarlowandGitHub d3f8d01227 Merge pull request #1714 from mvanhorn/fix-nonembedded-cid-fonts-nondict-resource
fix: guard find_nonembedded_cid_fonts against non-dictionary resources
2026-07-17 00:39:35 -07:00
James R. Barlow b60df59c62 Tolerate non-dictionary /Resources and /XObject in image scanner
A malformed PDF may store a non-dictionary object (an array, name, or
other type) at /Resources or /Resources /XObject. The pdfinfo image
scanner iterated these with .items()/.as_dict() and probed them with the
`in` operator, which raise TypeError/ValueError on non-dictionary pikepdf
objects and crashed PdfInfo on otherwise-processable files. OCRmyPDF's
domain is messy, machine-generated PDFs, so scanning must tolerate this.

Guard _image_xobjects and _find_form_xobject_images with
isinstance(x, Dictionary) before iterating, treating a non-dictionary
/Resources or /XObject as "no image XObjects". This is the same
robustness class as the pdfa.py find_nonembedded_cid_fonts fix, applied
to the pdfinfo image scanner.
2026-07-17 00:05:50 -07:00
James R. Barlow 640b3062b2 Refine non-dict resource guard: prefer isinstance, cover FontDescriptor
Follow-on to the non-dictionary /Font and /XObject guard. Replace the
_dict_entries() helper with an isinstance(..., pikepdf.Dictionary) check
at each resource lookup, which pikepdf's metaclass supports directly and
which reads as exactly the invariant being enforced. Iterate via
as_dict().values() so the values are typed and mypy stays clean once the
Any from the untyped resources argument is narrowed away.

Also guard _cid_font_is_embedded against a non-dictionary /FontDescriptor:
`key in descriptor` raises ValueError on a non-dict, which the caller's
except (AttributeError, TypeError, KeyError) does not catch. Such a font
now counts as non-embedded and is reported, so PDF/A conversion is refused
rather than risking Ghostscript corrupting a pre-existing CID text layer.

Adds a regression test for the FontDescriptor case (issue #1713).
2026-07-16 23:42:50 -07:00
James R. Barlow ef903db360 Merge remote-tracking branch 'origin/main' into fix-nonembedded-cid-fonts-nondict-resource 2026-07-16 23:42:13 -07:00
James R. Barlow 9cda02317b v17.8.1 release ntoes update 2026-07-16 11:54:25 -07:00
Matt Van Horn 92a2fe880a Guard find_nonembedded_cid_fonts against non-dictionary resources
A malformed PDF can store a non-dictionary object under a page's /Font or
/XObject resource. find_nonembedded_cid_fonts() iterated .values() on that
object outside the per-entry try/except, so scanning such a page raised
(TypeError/AttributeError depending on the pikepdf version) instead of
producing output. This surfaced as a PDF/A conversion crash.

Route both resource lookups through a small helper that returns an empty
list when the resource is missing or not a dictionary, so a garbage entry
is simply treated as having no fonts. Add a regression test covering a
non-dictionary /Font and /XObject.
2026-07-16 00:56:07 -07:00
James R. Barlow 089f46690a Enable ruff PTH (flake8-use-pathlib) and fix all findings
Replaces os.path/open()/os.stat()/os.chmod() calls with their Path
method equivalents across src, tests, misc, and bin, wrapping str
variables in Path(...) where they must stay str for other uses (e.g.
subprocess argv, CLI-arg formatting). helpers.safe_symlink() now
decodes StrOrBytesPath to a str Path via os.fsdecode() upfront, same
pattern already used elsewhere for the str|bytes union.
2026-07-16 00:52:57 -07:00
James R. Barlow e45c40b063 Fix mypy/CI mismatch: drop obsolete types-Pillow stubs
Pillow >=9.2 ships its own inline types (py.typed), which the separate
types-Pillow stub package shadows when both are installed. Since
types-Pillow lived in the `test` dependency group (not `dev`), a local
`uv sync --group dev --group test` env had it installed and mypy
silently preferred its stubs, while CI's lint job only syncs the
default `dev` group and used Pillow's own (correct) stubs - surfacing
5 real "Incompatible types in assignment" errors that never appeared
locally.

Fixed the underlying type errors in _pipeline.py and ghostscript.py:
`im` was inferred as ImageFile.ImageFile from `Image.open(...) as im`,
but later reassigned the result of `.resize()`/`.transpose()`, which
return the broader Image.Image - now declared explicitly as
`im: Image.Image` before the `with` block.
2026-07-15 23:47:31 -07:00
James R. Barlow bbac5307f2 v17.8.1 release notes 2026-07-15 23:18:34 -07:00
James R. Barlow 6167783696 Drop unreferenced validation coordinator registry 2026-07-09 15:04:56 -07:00
James R. Barlow 3d291e72c0 Enable check_untyped_defs and make mypy hook blocking
Fix the 13 errors that surfaced under mypy --check-untyped-defs so the
flag can be turned on permanently in pyproject.toml, and drop the
advisory exit-0 wrapper on the mypy pre-commit hook now that the tree is
clean.

- _plugin_manager: rename colliding loop vars (module/name were reused
  with conflicting types) and guard spec/spec.loader from
  spec_from_file_location; call __init__ via the class in __setstate__.
- __main__: pass Verbosity(options.verbose), not a bare int.
- subprocess/_check: widen package to str | Mapping[str, str] to match
  _error_trailer's existing per-platform handling.
- optimize.main: annotate the standalone PdfContext(..., None, None) that
  only ever reads context.options.
2026-07-07 15:02:32 -07:00
James R. Barlow efebe9ca2e Improve type checks under check-untyped-defs 2026-07-07 14:51:56 -07:00
James R. Barlow 12ec97f732 Fix remaining mypy errors in fpdf_renderer and its tests
Resolves the last 16 mypy errors in the project (src/ocrmypdf and
tests are now fully clean).

fpdf_renderer/renderer.py (9 errors):
- add_page(format=...): fpdf2's own stub types this param as str, but
  its docstring and get_page_format() helper confirm a (width, height)
  tuple is accepted too - the stub annotation on add_page() itself is
  the outlier. Used cast() to match the documented/actual behavior.
- pdf.current_font is typed CoreFont | TTFFont | None, but this
  renderer only ever registers fonts via add_font() with a TTF file
  (see _register_font/set_font call sites) - it never falls back to
  fpdf2's built-in CoreFont. Added assertions (isinstance(font,
  TTFFont) where shape_text()/escape_text() are needed, which
  CoreFont lacks; plain not-None elsewhere) documenting that
  invariant instead of narrowing defensively for a case that can't
  happen here.

tests/test_pdf_renderer.py (7 errors): the ToUnicode/glyph-extraction
test helpers used `.get(key, {})` (a plain dict literal default) then
called `.values()`/`.items()` on the result. pikepdf.Object doesn't
declare `values()` in its stub (only `keys()`), so this silently
degraded to Object's catch-all `__getattr__` returning another Object,
which then failed as "not callable". Switched to `.get(key,
Dictionary()).as_dict()`, which returns pikepdf's properly-typed
_ObjectMapping helper.
2026-07-07 00:57:45 -07:00
James R. Barlow 3f1aceade2 Fix mypy errors in concurrency abstractions
Resolves all 11 remaining errors in _concurrent.py and
builtin_plugins/concurrency.py.

Root cause for 9 of the 11: setup_executor() was annotated to return
ocrmypdf's own Executor ABC as its second tuple element, but it
actually returns a concurrent.futures pool class (ThreadPoolExecutor
or ProcessPoolExecutor) - an already-existing FuturesExecutorClass
alias was defined for exactly this but never wired into the
signature. That wrong annotation made mypy check
`executor_class(initializer=..., initargs=...)` in _execute() against
Executor.__call__'s signature (which has entirely different
parameters and returns None), cascading into 8 further errors
(unexpected keyword args, "function does not return a value", "None
has no attribute __enter__/__exit__"). Fixing the one annotation
resolved all of them.

Also:
- Added a proper Queue[LogRecord | None] generic parameter (was bare
  Queue, which needs an explicit type argument for mypy to infer
  loq_queue's type across the use_threads branches).
- _concurrent.py: Executor.__call__'s task parameter defaults to
  _task_noop (return type None) when the caller omits a task, but the
  parameter is typed Callable[..., T] for an unbound per-call T. Used
  cast() since task_finished's own no-op default already accepts Any,
  so the mismatch is never actually exercised unsafely.
2026-07-07 00:49:07 -07:00
James R. Barlow 212b28e602 Fix mypy pikepdf Object typing errors in pdfinfo/optimize/graft
Resolves the remaining 22 pikepdf-related mypy errors in
pdfinfo/_image.py, pdfinfo/info.py, optimize.py, and _graft.py
(89 -> 27 errors remaining, all in the concurrency/fpdf_renderer
clusters).

Adds pikepdf_get_int()/pikepdf_get_bool() to helpers.py: safe
accessors for dict.get(key, default) results, whose static type is
the ambiguous `Object | int`/`Object | bool` and doesn't support
arithmetic/comparison against a plain int/bool.

Important correctness fix caught by the test suite: pikepdf only
returns pikepdf.Object wrappers under explicit_conversion() mode,
which this codebase never enables. By default (implicit mode), PDF
Integers/Booleans are already unboxed to native int/bool by the time
callers see them, so calling the `.as_int()`/`.as_bool()`/
`.as_decimal()` safe accessors unconditionally crashes with
AttributeError on the native-type case (test_oversized_page caught
this for UserUnit). Fixed by using int()/float() builtins, which work
polymorphically on both native numbers and pikepdf.Object (bool()
does not, so pikepdf_get_bool checks isinstance first).

Also:
- pdfinfo/info.py: pass page.obj (an Object) to
  _process_content_streams() instead of page (a Page wrapper, not an
  Object subtype).
- pdfinfo/_image.py: cast() around Matrix(array_object) - pikepdf's
  stub omits the Object/Array constructor overload that the C++
  implementation actually supports.
- optimize.py: cast() around Object.write()'s filter/decode_parms
  args for the same reason.
- _graft.py: iterate parse_content_stream() via .operands/.operator
  instead of tuple-unpacking, since ContentStreamInstruction supports
  the legacy __getitem__-based iteration protocol but not __iter__,
  which mypy doesn't statically recognize.
- pdfinfo/_worker.py: _pdf_pageinfo_concurrent's return type was
  Sequence[PageInfo | None] but always returns a real list; narrowed
  to match, fixing PdfInfo.pages' declared list[...] return type.
2026-07-07 00:44:56 -07:00
James R. Barlow dfdb32995e Fix mypy errors: drop deprecation dep, fix PathOrIO union bugs
Reduces mypy errors in src/ocrmypdf and tests from 89 to 51.

- Replace the `deprecation` package with stdlib `warnings.deprecated`
  (falling back to typing_extensions on <3.13); drop the dependency.
- Add pypdfium2/uharfbuzz/pi_heif to mypy's ignore_missing_imports
  overrides (no upstream stubs); drop pluggy, which now ships py.typed.
- Add a tests.* mypy override so test functions aren't required to
  annotate -> None.

Real bugs found and fixed along the way, not just annotations:
- OcrmypdfPluginManager had a `pluggy` property shadowing the `pluggy`
  module import within its own class body, breaking every
  `pluggy.PluginManager` annotation below it; renamed to
  `pluggy_manager`.
- `_option_registry` was bolted onto OcrmypdfPluginManager from outside
  and read via `getattr(..., None)` instead of being a declared
  attribute; declared it properly.
- ValidationCoordinator.__init__ was typed to accept a raw
  pluggy.PluginManager, but every caller passes the OcrmypdfPluginManager
  wrapper.
- check_options_sidecar() did `options.output_file + '.txt'`, assuming
  output_file is always a str; would raise a raw TypeError if ever hit
  with a stream/bytes output. Added an explicit guard.
- is_file_writable() called Path(test_file), which raises TypeError on
  a bytes path; fixed via os.fsdecode().
- copy_final() had a dead, unused `original_file` parameter; removed it.
- run_hocr_pipeline() constructed PdfContext with the raw, untriaged
  input_file instead of the locally-copied origin_pdf, inconsistent
  with the other two pipelines.
- _options.py had jbig2_threshold declared twice in the same model.
2026-07-07 00:25:33 -07:00
James R. Barlow 273826377e Migrate pre-commit to prek
prek runs local hooks as plain execs against tools uv already provisions,
so ruff/mypy can never drift from the versions/config uv.lock pins
elsewhere and CI needs no separate hook-cache download.

- Add ruff and prek to the uv dev dependency group (ruff wasn't a
  uv-managed dependency before; pre-commit silently vendored its own).
- Replace .pre-commit-config.yaml with prek.toml: keep the
  pre-commit-hooks repo for generic file checks, convert ruff-format/
  ruff-check to local `uv run ruff ...` hooks, and add a local mypy
  hook that reports but never fails (87 pre-existing errors need a
  separate cleanup before it can be made blocking).
- Add a `lint` job to CI that runs `prek run --all-files` and gate the
  OS/Python test matrix on it so lint issues fail fast.
- Fix the ruff debt (format + lint) uncovered by actually running it,
  since it was small and mechanical, so the new CI gate starts green.
2026-07-06 23:35:52 -07:00
sokaiandGitHub 5569d4db07 Minor Update to tesseract_ocr.py (#1711)
Changed tesseract (v5.5.2) help command for `--tesseract-pagesegmode` parameter to faster help access: `tesseract --help` → `tesseract --help-extra`
2026-07-03 16:21:22 -07:00
James R. Barlow 8de7b05fb9 Fix flaky tagged-PDF skip-text test under Ghostscript 10.x
The test asserted that --mode skip preserves the structure tree, but with
the default --output-type auto the output runs through Ghostscript PDF/A
conversion, which discards /StructTreeRoot on Ghostscript 10.x (9.x kept
it). This failed on macOS CI and locally while passing on the Ubuntu
runners' Ghostscript 9.55. Pin the test to --output-type pdf so it exercises
OCRmyPDF's own structure-tree handling without the version-dependent GS step,
and document the caveat in advanced.md.
2026-07-01 00:10:57 -07:00
James R. Barlow 72ce05768e Bump version: v17.8.0 2026-06-30 01:45:30 -07:00
James R. Barlow 3dc68778fc Fix stale auto-mode comment and harden GS-raises test 2026-06-30 01:19:00 -07:00
James R. Barlow 1aec92b919 docs: auto output type falls back to Ghostscript for PDF/A (#1561) 2026-06-30 01:12:08 -07:00
James R. Barlow 43d3448709 Make --output-type auto fall back to Ghostscript for PDF/A 2026-06-30 01:08:04 -07:00
James R. Barlow 7512b1042a Detect veraPDF when version line is preceded by JVM warnings 2026-06-30 01:03:02 -07:00
James R. Barlow efe83e8c54 Protect non-embedded CID text layers from PDF/A corruption (closes #1561)
Ghostscript's PDF/A conversion re-embeds non-embedded CID (CJK) fonts by
substituting a system font, which corrupts the character-to-Unicode
mapping and silently destroys an existing text layer -- commonly the OCR
layer Adobe Acrobat adds to scanned CJK documents.

Detect non-embedded CID/Type0 fonts before conversion: with
--output-type auto (the default) downgrade to a regular PDF and preserve
the text layer; with an explicit --output-type pdfa* stop with an error
rather than emit corrupted output. Simple non-embedded fonts (e.g. Latin)
are left alone -- Ghostscript substitutes them without corrupting the
text, and they are far too common to treat as conversion blockers.

Use --output-type pdf to keep the existing text layer, or --force-ocr to
rebuild it with embedded fonts.
2026-06-30 00:01:42 -07:00
James R. Barlow a13d27bfb5 Protect stdout from corruption when writing PDF to standard output
Writing the output PDF to stdout (ocrmypdf in.pdf -) previously relied on
an honor system: no in-process code -- third-party libraries, plugins, or
stray print() calls -- was supposed to write to stdout, enforced only
indirectly. A single accidental write to fd 1 would silently corrupt the
output PDF.

Enforce this at the OS level. At CLI startup, before plugins load or any
worker process/thread starts, save the real stdout via os.dup() and point
fd 1 at stderr, so stray writes are diverted to stderr while only the final
"produce the PDF" step writes to the preserved descriptor. Exposed as the
opt-in public API function configure_stdout_protection(), mirroring
configure_logging(); it is not enabled inside ocr() so in-process library
users keep their own stdout.

Also fix check_requested_output_file() to test the preserved real stdout
for tty-ness, since after the redirect sys.stdout reports stderr's status.

Fold unreleased v17.7.2 notes into v17.8.0.
2026-06-28 12:02:34 -07:00
James R. Barlow ea7ad7d683 Handle non-UTF-8 DocumentInfo keys in repair_docinfo_nuls (closes #1540)
Some PDFs use a /Name dictionary key in /DocumentInfo whose bytes are not
valid UTF-8/PDFDocEncoding, e.g. a Latin-1 /Saks#e5r. Older pikepdf raised
UnicodeDecodeError while iterating such a block, crashing the pipeline
during PDF/A conversion. repair_docinfo_nuls is documented to log and
continue on a malformed DocumentInfo block, so catch UnicodeDecodeError
alongside TypeError.

Add a mock-based unit test that drives the decode-error branch (current
pikepdf surrogate-escapes instead of raising) and an end-to-end test over
the reporter's file, committed as docinfo_latin1_key.pdf.
2026-06-27 01:50:58 -07:00
James R. Barlow 8b20bb3c5b docs: add "Edit on GitHub" link to ReadTheDocs pages (closes #1490)
ReadTheDocs stopped injecting the GitHub edit context when it migrated to
Addons, so the sphinx_rtd_theme "Edit on GitHub" breadcrumb link vanished,
leaving only the static "View page source" (_sources/*.txt) copy. Set the
html_context explicitly so each page links back to its source on GitHub.
2026-06-27 01:25:17 -07:00
James R. Barlow 320876a6d1 docs: document Tesseract configs/ requirement (closes #1567)
When TESSDATA_PREFIX points at a hand-assembled tessdata folder lacking
the configs/ subdirectory (e.g. files pulled from tessdata_best),
Tesseract prints "read_params_file: Can't open hocr/txt" and produces no
output. The runtime now surfaces a clear error (v17.5.0); add the
matching documentation: a new errors.md entry and a note on the
TESSDATA_PREFIX docs.
2026-06-27 00:32:22 -07:00
James R. Barlow dfbb4c9275 Add regression test for blank page with non-zero MediaBox origin (#1709)
A page whose MediaBox has a non-zero origin (e.g. from PDF Arranger crops)
was rendered blank by --force-ocr in v16.12.0, because fix_pagepdf_boxes
offset the CropBox with the wrong sign, pushing it entirely outside the
image-page MediaBox. The behavior was fixed in v17.0.0 but had no
end-to-end test: the existing box tests only assert that ocrmypdf runs,
which the blank-page bug passed (it exited 0 with a valid, empty PDF/A).

Add a test that renders the output and asserts the visible content
survives, parametrized over both the ghostscript and pypdfium rasterizers
since the bug reproduced regardless of rasterizer.
2026-06-22 14:57:52 -07:00
James R. Barlow d4f5c2d160 Merge remote-tracking branch 'origin/dependabot/github_actions/actions/checkout-7' 2026-06-22 14:11:53 -07:00
dependabot[bot]andGitHub 263d6034be build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 10:42:29 +00:00
James R. Barlow de403f6d5e Bump version: v17.7.1 2026-06-19 16:44:31 -07:00
James R. Barlow 86b6f2c907 v17.7.1 release notes 2026-06-19 16:44:10 -07:00
e6fab76918 Fix Windows redo-ocr performance regression; drop pdfminer BUFSIZ workaround (#1706)
Since v16.4.3, OCRmyPDF forced pdfminer's read buffer to 256 MiB to work
around a pdfminer bug that mishandled tokens split across the buffer
boundary (gh #1361). On Windows this caused a severe performance
regression (gh #1662): CPython's BufferedReader.read(n) eagerly allocates
an n-byte buffer on every read, so pdfminer's thousands of seek+read
cycles each paid a ~30 ms 256 MiB allocation (this allocation is lazy and
effectively free on Linux). For a typical PDF the "Scanning contents"
phase went from ~5s on Linux to ~60s on Windows.

The underlying pdfminer bug was fixed upstream in pdfminer.six 20250327
(pdfminer/pdfminer.six#1030), with a follow-up for tokens split across
streams in 20260107 (pdfminer/pdfminer.six#1158). Remove the monkeypatch
entirely and raise the minimum pdfminer.six to 20260107 so we rely on the
upstream fix instead.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:34:20 -07:00
jbarlowandGitHub 334918d0f7 Discover variable and per-language Noto fonts for the OCR text layer (#1652) (#1707)
* Bump uv lock, fix bump_version

* Discover variable and per-language Noto fonts for the OCR text layer (#1652)

SystemFontProvider only matched static "-Regular.ttf/.otf" filenames, so
the variable fonts shipped by Homebrew casks and current Google Fonts
(e.g. NotoSansArabic[wdth,wght].ttf) were never found. Users who had
installed the font still got the glyphless Occulta fallback and a cryptic
"No font found" warning.

- Match variable fonts (Base[...]), -VF, and bare-family filenames via a
  boundary-aware flexible search, escaping the glob-special brackets.
- Make CJK language-aware: the modern per-language Noto fonts (NotoSansSC
  /TC/HK/JP/KR) are region subsets, so map each CJK language to its own
  family and keep the full-coverage pan-CJK super font as a shared
  fallback. Glyph coverage, not shape, is what matters for the invisible
  text layer.
- Reword the missing-font warning to explain the consequence (searchable
  but blank when highlighted) and name the language-specific font.
2026-06-18 10:34:08 -07:00
James R. Barlow d6329489ce Bump version: v17.7.0 2026-06-17 15:37:30 -07:00
James R. Barlow e6d240ee93 Update release notes 2026-06-17 15:37:19 -07:00
James R. Barlow ff45e54c07 Run Docker images as non-root user and default to /data workdir
Harden the Docker images by dropping root privileges, and make the
bind-mount workflow less fiddly.

- Create a non-root `app` user (uid/gid 1000) in both images and add
  `USER app` before the entrypoint, so ocrmypdf (and the
  webservice/watcher) no longer run as root. This also fixes the
  previously dangling `--chown=app:app`, which referenced a user that
  was never created. The Ubuntu base ships a default `ubuntu`/1000 user,
  so remove it first so `app` can take uid 1000 (parity with Alpine).

- Add `WORKDIR /data` (created and app-owned) so bind-mounted input and
  output can be passed as relative paths without `--workdir`. The
  webservice/watcher are now invoked by absolute path (`/app/*.py`)
  since the working directory is no longer `/app`.

- Drop the redundant `ppa:alex-p/tesseract-ocr5` from the Ubuntu image:
  Tesseract 5 ships in the Ubuntu archive as of 24.04, and the PPA had
  no build for the 26.04 base, which broke the build outright.

- Rewrite docs/docker.md rootless-first: stdin/stdout piping as the
  recommended permission-free path, then per-runtime volume guidance
  (rootless Docker `--user 0:0`, Podman `--userns keep-id`, rootful
  Docker as the special case). Update batch.md and the compose example
  to match (absolute script paths, per-runtime `user:` guidance).
2026-06-17 15:13:19 -07:00
James R. Barlow e0ee0882ef Fix typo in error message 2026-06-17 14:49:31 -07:00
James R. Barlow 3d17419a6c Update dockerfiles to latest uv and system images 2026-06-17 11:33:31 -07:00
James R. Barlow 476ec12383 Merge remote-tracking branches 'origin/dependabot/uv/starlette-1.3.1', 'origin/dependabot/uv/tornado-6.5.7', 'origin/dependabot/uv/cryptography-48.0.1' and 'origin/dependabot/uv/python-multipart-0.0.31' 2026-06-17 11:30:35 -07:00
dependabot[bot]andGitHub e99177ada7 build(deps): bump starlette from 1.1.0 to 1.3.1
Bumps [starlette](https://github.com/Kludex/starlette) from 1.1.0 to 1.3.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/1.1.0...1.3.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 09:45:21 +00:00
dependabot[bot]andGitHub e95ec9c497 build(deps): bump tornado from 6.5.5 to 6.5.7
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.5 to 6.5.7.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.7)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 09:45:20 +00:00
dependabot[bot]andGitHub 82f30bfbec build(deps): bump cryptography from 48.0.0 to 48.0.1
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.0 to 48.0.1.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.0...48.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 09:45:06 +00:00
dependabot[bot]andGitHub d1437e6bbc build(deps): bump python-multipart from 0.0.29 to 0.0.31
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.29 to 0.0.31.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.29...0.0.31)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-17 00:16:38 +00:00
dependabot[bot]andGitHub c669d30642 build(deps): bump sigstore/gh-action-sigstore-python from 3.3.0 to 3.4.0 (#1701) 2026-06-15 07:17:34 -07:00
James R. Barlow 3613b30ca8 Bump version: v17.6.0 2026-06-11 22:37:37 -07:00
James R. Barlow 0d4c3bcdcf feat: add --mode strip to remove the OCR text layer without rasterizing
Adds a processing mode that removes the invisible (render mode 3) OCR text
layer in place. Unlike `--ocr-engine none --force-ocr`, it does not
rasterize the page, so images and visible content are preserved unchanged
and the output is smaller rather than larger. Options that require
rasterization or OCR (--deskew, --clean, --sidecar, etc.) are rejected.

Only invisible text is removed; text drawn as visible glyphs under an
opaque image (some OCR engines, and OCRmyPDF v2.2 and earlier) cannot be
removed this way, as documented.

Closes #1435.
2026-06-11 12:34:03 -07:00
James R. Barlow 8a8d515933 feat: surface raw Tesseract diacritics message at debug level
When Tesseract reports a page with many diacritics, OCRmyPDF rewrites the
message to "lots of diacritics - possibly poor OCR", which hid the
original wording. Keep the interpreted hint but also emit Tesseract's raw
line at debug verbosity (-v 1) so users can see exactly what Tesseract
reported.

Closes #1566.
2026-06-11 00:21:39 -07:00
James R. Barlow 11de13ecfe feat: anti-alias Ghostscript rasterization to improve OCR quality
Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
extra word breaks or substituted characters. Enable text and graphics
anti-aliasing (-dTextAlphaBits=4 -dGraphicsAlphaBits=4) for the contone
raster devices, which empirically improves OCR accuracy on the
Ghostscript path, especially for small fonts at moderate DPI. The 1-bit
mono devices are excluded, since older Ghostscript rejects alpha bits on
them and pngmonod performs its own anti-aliased downscaling.

Also log which rasterizer rendered each page at debug verbosity and
clarify the --rasterizer help text, so quality reports are easier to
diagnose. The default rasterizer (auto) already prefers pypdfium2, which
anti-aliases; this primarily benefits --rasterizer ghostscript and
installs without pypdfium2.

Closes #1439.
2026-06-10 23:39:03 -07:00
James R. Barlow 58642d8411 fix: report un-optimizable images as a warning, not a traceback
The optimizer is best-effort: any image it cannot process is left
unchanged in the output, which remains valid. Previously, an extraction
failure (e.g. an exotic colorspace pikepdf cannot transcode) was logged
with log.exception, printing a full traceback at ERROR level that alarmed
users even though nothing was wrong with the output (issue #846).

Trap such failures with a concise warning that the image was left
unchanged and the output is still valid, and demote the traceback to
debug verbosity for diagnosis.
2026-06-10 13:38:05 -07:00
James R. Barlow 7e42d3c771 feat: make pdfa-image-compression=auto lossless at -O0
Ghostscript's `auto` image compression heuristic can transcode lossless
images to JPEG during PDF/A generation, which is surprising at
optimization levels that otherwise promise lossless-only operations
(issue #1124).

`--pdfa-image-compression=auto` (the default) now coerces to lossless at
-O0 so Ghostscript will not transcode lossless images to JPEG. -O1 and
above continue to defer to Ghostscript's heuristic; -O1 (the default
level) is kept as a historical exception because coercing it to lossless
substantially bloats output. Users wanting guaranteed lossless image
handling can pass --pdfa-image-compression=lossless or use -O0.

Also make `lossless` pass existing JPEGs through unchanged
(-dPassThroughJPEGImages=true) instead of re-encoding them with a
lossless codec, which only inflates already-lossy data.
2026-06-10 13:10:43 -07:00
James R. Barlow 5cb5d7a682 Merge remote-tracking branch 'origin/dependabot/github_actions/codecov/codecov-action-7' 2026-06-09 01:12:35 -07:00
James R. Barlow 37e71dece6 Merge branch 'feature/page-box-repair' 2026-06-09 01:10:58 -07:00
James R. Barlow df84945773 feat: validate and repair malformed page boxes
Validate and repair the page-boundary boxes (MediaBox, CropBox, TrimBox,
ArtBox, BleedBox) of input PDFs in triage(), following PDF 2.0:

- Coerce coordinates written in invalid exponential notation, stored by
  qpdf/pikepdf as strings (#1398).
- Normalize rectangles whose corners are given in reversed order, which
  previously crashed with NegativeDimensionError (#1526).
- Clamp a crop/trim/art/bleed box that extends outside the MediaBox to
  their intersection, or discard it when the intersection is empty, which
  previously produced a zero-height effective page some viewers rejected
  (#1400).

When a box is discarded, clamped, or reinterpreted, a warning recommends
visual inspection of the output. pdfinfo box reading shares the same
coercion helper so PdfInfo no longer crashes on malformed boxes.

Supersedes PR #1691. Thanks @ajdlinux.

Closes #1398, #1526, #1400
2026-06-09 01:10:26 -07:00
James R. Barlow b5a6a9f9f1 feat: discard stale structure tree when re-OCRing tagged PDFs
A tagged/structured PDF carries a logical structure tree
(/Root/StructTreeRoot, /MarkInfo) that maps marked page content to
semantic elements via MCIDs. When --force-ocr rasterizes pages or
--redo-ocr rewrites the text layer, those MCIDs are destroyed or
renumbered and the tree is left dangling. We cannot rebuild it to match
the new text, so discard it for force/redo modes, following the same
pattern as the thumbnail and search-index discards. --skip-text leaves
text pages untouched, so their structure is preserved.

Also broaden the default-mode "looks born-digital" stop signal to fire
on /StructTreeRoot, not just /MarkInfo/Marked, so structure-tree-only
PDFs are no longer silently OCR'd. The existing --tagged-pdf-mode ignore
escape hatch is unchanged.
2026-06-08 15:47:53 -07:00
dependabot[bot]andGitHub ed36aefe48 Bump codecov/codecov-action from 6 to 7
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 10:42:38 +00:00
James R. Barlow 32013f4294 Merge branch 'feature/discard-obsolete-pdf-features' 2026-06-07 02:09:00 -07:00
James R. Barlow 8f2bcc2c64 feat: discard stale embedded page thumbnails when rewriting PDF
A page may carry an optional /Thumb image XObject (ISO 32000-2 12.3.4),
a miniature rendering used only as a navigation aid. OCRmyPDF alters
page appearance (deskew, clean, rasterize, re-render) and plugins may
edit pages arbitrarily, so a retained thumbnail would be stale and no
longer match its page. Modern viewers generate thumbnails on demand, so
there is no loss of functionality.

OcrGrafter.finalize() now strips /Thumb from every page before saving,
alongside the existing search-index discard, covering both the OCR and
hOCR pipelines. Orphaned thumbnail XObjects are garbage-collected on save.
2026-06-07 00:30:34 -07:00
James R. Barlow 015b53ae30 feat: discard stale embedded text search index when rewriting PDF
Adobe Acrobat can embed a proprietary full-text search index in the
document catalog at /Root/PieceInfo/SearchIndex. Only Acrobat reads it;
other viewers ignore it and search the text on the fly. Any change to a
PDF invalidates the index, so once OCRmyPDF rewrites the document the
retained index is stale and returns incorrect search results in Acrobat.

OcrGrafter.finalize() now discards it before saving (covering both the
OCR and hOCR pipelines), preserving any other PieceInfo owner data and
dropping an empty PieceInfo. Modern viewers rebuild a search index on
demand, so there is no loss of search capability.
2026-06-07 00:02:28 -07:00
James R. Barlow 164cf2dc8a test: use bundled font in gray-mask test for macOS/Windows portability
test_gray_mask_ocrs_to_text loaded DejaVu, which only exists on the Linux
CI runners; the OSError fallback hardcoded a Linux-only path, so the test
failed with "cannot open resource" on macOS and Windows. Load the bundled
ocrmypdf.data/NotoSans-Regular.ttf via importlib.resources instead, which
is guaranteed present on every platform.
2026-06-05 12:48:33 -07:00
James R. Barlow 98d6d02704 Merge branch 'fix/1688-mask-fill-color-device'
Promote rasterization device based on image-mask fill color so gray/colored
stencil text is not destroyed by 1-bit dithering before OCR; default the
1-bit Ghostscript device to pngmonod. Fixes #1688.
2026-06-05 11:41:18 -07:00
James R. Barlow 5efb98931d fix: inherit fill color into Form XObjects; reset fill color on cs (#1688)
Address final review findings: a mask painted inside a Form XObject now
inherits the fill color in effect at the form's Do operator (previously it
reset to black, missing gray/color promotion one indirection deep). The cs
operator now resets the fill color to black per PDF spec, so a stale color
set before cs cannot leak to a subsequently drawn mask.
2026-06-05 11:40:50 -07:00
James R. Barlow 2f4e47213f style: ruff format operator whitelist line (#1688) 2026-06-05 11:40:50 -07:00
James R. Barlow 94c8123bd7 docs: release note for image mask fill-color device promotion (#1688) 2026-06-05 11:40:50 -07:00
James R. Barlow 0db130e1c3 test: end-to-end gray image-mask OCR across rasterizers (#1688) 2026-06-05 11:40:50 -07:00
James R. Barlow 91b6a818f5 feat: promote raster device for color/gray image masks; default pngmonod (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 6bc9499e68 feat: recognize pngmonod device in pypdfium rasterizer (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 09f2d6c386 feat: add pngmonod raster device to enum (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 87f918f58c feat: expose fill-color ink classification on ImageInfo (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 80e77fb021 feat: track image mask fill color during content stream interpretation (#1688)
Track the current PDF fill color on the graphics stack alongside the CTM and
record an Ink classification (mono/gray/color) per image-draw event. Image
masks are painted with the current fill color, so this enables later device
promotion. Color operators are tolerant of malformed operands to preserve
robustness on untrusted input.
2026-06-05 11:40:49 -07:00
James R. Barlow fa9c5b3fae feat: add fill color -> Ink classification helper (#1688) 2026-06-05 11:40:49 -07:00
James R. Barlow 3d17a60a54 feat: add Ink classification type for image mask fill colors (#1688) 2026-06-05 11:40:49 -07:00
jbarlowandGitHub c33f073d4f Improve DeviceN color conversion guidance (#1623) (#1694)
When Ghostscript reports a DeviceN colorspace with an inappropriate
alternate, the resulting PDF/A may render blank in viewers such as Adobe
Reader (#1187). The error is gated on that Ghostscript warning, which is
the authoritative signal that the *output* is broken.

Previously the error message always told the user to "use
--color-conversion-strategy", which is confusing when they already set
one and it didn't help. Crucially, the warning persists for strategies
that don't actually normalize the colorspace -- notably
UseDeviceIndependentColor (confirmed in #1187) -- so silencing the error
for any non-default strategy would emit a silently-broken PDF/A.

Keep raising whenever Ghostscript still reports the warning, regardless
of strategy, but tailor the guidance: if no conversion was requested,
suggest RGB/CMYK/Gray; if a conversion was requested but the warning
persisted, say so and point at strategies that work or --output-type pdf.

Add unit tests (mocked Ghostscript) covering the default case, the
warning-persists-despite-strategy case for both an ineffective strategy
and a normally-effective one, and the no-warning happy path.
2026-06-04 14:48:24 -07:00
James R. Barlow 5d7b5742e4 Bump version: v17.5.0 2026-05-27 13:36:30 -07:00
James R. Barlow c391b2b7d0 Draft release notes for v17.5.0 2026-05-27 13:35:45 -07:00
James R. Barlow 0250929150 Update uv.lock 2026-05-26 13:11:12 -07:00
James R. Barlow 9748208e68 Support 'end' alias for last page in --pages
Closes #1615. The token 'end' (case-insensitive) is now accepted as an
alias for the document's last page, e.g. --pages 3-end. Resolution is
deferred until the page count is known from the input PDF.
2026-05-26 12:18:09 -07:00
James R. Barlow e4b0c04be4 Fix pypdfium2 MediaBox rendering when CropBox is smaller
PDFium does not support negative crop values to expand the render
area beyond the CropBox: such values only pad the output canvas with
white, leaving content outside the CropBox clipped. Set the in-memory
CropBox to the MediaBox before rendering instead. Reported in #1685.
2026-05-25 23:17:17 -07:00
James R. Barlow efb83ad64f Add --ghostscript-jpeg-quality and --ghostscript-jpeg-maxdpi
Expose Ghostscript's -dJPEGQ and image downsampling switches as
advanced, plugin-scoped options for tuning PDF/A output, without
polluting the central OcrOptions registry. The optimizer's existing
--jpeg-quality remains the recommended JPEG quality control.

- GhostscriptOptions gains jpeg_quality and jpeg_maxdpi fields and CLI
  args (advanced help text). jpeg_quality=0 is honored as Ghostscript's
  maximum compression rather than being silently coerced to the default.
- _exec.ghostscript.generate_pdfa() forwards both values; when
  jpeg_maxdpi is set, downsample threshold is pinned at 1.0.
- _get_plugin_options falls back to extra_attrs for namespaced fields
  so plugins can own their options without registering them centrally.
- Documentation explains the rationale: Ghostscript is the legacy path
  (pypdfium + verapdf is preferred in v17+), the optimizer is the
  supported file-size lever, and lowering quality is almost always a
  better trade than downsampling.
2026-05-25 10:20:54 -07:00
James R. Barlow 08e40f96e8 Surface Tesseract config errors instead of FileNotFoundError
When Tesseract cannot find its 'hocr' or 'txt' config files in the
tessdata configs/ directory, it prints "read_params_file: Can't open"
warnings, exits 0, and produces no output. OCRmyPDF then crashed with a
confusing FileNotFoundError on the missing hOCR file (issue #1687).

Promote read_params_file warnings to TesseractConfigError with guidance
on the likely cause, and verify the expected output file exists after
Tesseract claims success as defense-in-depth for other silent-failure
modes.
2026-05-25 01:45:45 -07:00
James R. Barlow 3f6feb1dcc Merge branch 'main' of github.com:ocrmypdf/OCRmyPDF 2026-05-25 01:38:46 -07:00
jbarlowandGitHub ab6553f4ff Merge pull request #1677 from ocrmypdf/dependabot/uv/gitpython-3.1.50
Bump gitpython from 3.1.47 to 3.1.50
2026-05-25 01:36:02 -07:00
jbarlowandGitHub cedca9fa1f Merge pull request #1679 from ocrmypdf/dependabot/uv/urllib3-2.7.0
Bump urllib3 from 2.6.3 to 2.7.0
2026-05-25 01:35:45 -07:00
jbarlowandGitHub 3f40118022 Merge pull request #1686 from ocrmypdf/dependabot/uv/idna-3.15
Bump idna from 3.11 to 3.15
2026-05-25 01:35:31 -07:00
dependabot[bot]andGitHub b18b1da6d0 Bump idna from 3.11 to 3.15
Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.15.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.11...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 21:26:42 +00:00
James R. Barlow 14fb9f56e8 Add explanatory note about Ghostscript -dJPEG=95 2026-05-16 12:18:34 -07:00
James R. Barlow 8709cf506b Update uv.lock 2026-05-12 10:14:47 -07:00
jbarlowandGitHub 9a92eb40df Merge pull request #1680 from cislunarspace/docs/refresh-chinese-readme 2026-05-12 15:52:47 +02:00
ouyangjiahong 0a59c210f9 docs: refresh Chinese README translation
Align the Chinese README with the current English version and remove stale generated wrapper text.
2026-05-12 09:17:44 +08:00
dependabot[bot]andGitHub 0b370fdd15 Bump urllib3 from 2.6.3 to 2.7.0
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 17:48:53 +00:00
dependabot[bot]andGitHub 1c16dd26f7 Bump gitpython from 3.1.47 to 3.1.50
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.47 to 3.1.50.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.47...3.1.50)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.50
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-09 04:47:56 +00:00
dependabot[bot]andGitHub c355d927ba Bump gitpython from 3.1.46 to 3.1.47
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.46 to 3.1.47.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.47
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 01:22:53 +00:00
James R. Barlow c993857752 Fix Form XObject cycle detection in image xref scan (#1321)
The 2024 guard against runaway recursion in _find_image_xrefs_container
only deduplicated image xrefs, but Form XObject xrefs are never added to
include_xrefs/exclude_xrefs, so a self-referential or DAG-shaped Form
graph re-entered every branch until the depth limit fired -- producing
the reported flood of warnings (and minutes-long hangs) on PowerPoint
exports.

Thread a visited_forms set through the recursion so each Form XObject is
descended into at most once per document. With memoization in place the
depth limit is no longer a cycle defense, so demote its log to debug.

Add a regression test that synthesises a circular-Form PDF from the
existing formxobject.pdf fixture (no new binary fixture, no license
issues) and asserts zero "Recursion depth exceeded" warnings.
2026-04-25 00:48:25 -07:00
James R. Barlow 84f5fe9ee0 Separate probing from execution in _exec and subprocess modules
Split ocrmypdf.subprocess/__init__.py into three private submodules by
concern (_run, _version, _check) and reduce __init__ to re-exports.
Introduce ocrmypdf._exec._probe.ToolProbe to centralize the version()/
available() pattern each tool module was reimplementing, so the "is this
tool installed and suitable?" question is cleanly distinct from the
pure, picklable functions that do the work.

Also replace the ghostscript module-import log.addFilter() side effect
with an idempotent _ensure_log_filter_installed() called at the top of
each work function, so the DuplicateFilter is present in subprocess
workers without relying on import-time ordering.

Public API of ocrmypdf.subprocess is unchanged.
2026-04-24 13:33:34 -07:00
James R. Barlow 3336d67e77 Fix CJK test broken by fpdf2 2.8.7 CFF font encoding change
fpdf2 >= 2.8.7 emits a custom begincidchar Encoding CMap for CFF-based
CID fonts (e.g. NotoSansCJK). pdfminer.six returns <CMap: None> for such
CMaps, so text extraction yields empty output. Switch to pdftotext (poppler)
which handles the new encoding correctly.
2026-04-19 23:26:30 -07:00
James R. Barlow 73e16e7821 Merge remote-tracking branch 'origin/dependabot/github_actions/codecov/codecov-action-6' 2026-04-19 13:59:42 -07:00
James R. Barlow 6f1d37d78f Merge remote-tracking branch 'origin/dependabot/github_actions/sigstore/gh-action-sigstore-python-3.3.0' 2026-04-19 13:59:30 -07:00
James R. Barlow 2ed82de2e0 Update uv.lock again - pygithub 2026-04-19 13:58:46 -07:00
James R. Barlow c43903fa14 Bump version: v17.4.2 2026-04-19 13:45:34 -07:00
James R. Barlow 1c89cacfef Respect host-set PIL.Image.MAX_IMAGE_PIXELS in Python API
The API previously clobbered PIL.Image.MAX_IMAGE_PIXELS unconditionally
on every call, so host applications (e.g. Paperless-NGX) that configured
the PIL limit before invoking ocrmypdf.ocr() saw their setting silently
overwritten with the 250 MP default. Make max_image_mpixels default to
None and only apply the override when the caller explicitly sets it.
The CLI default of 250 MP is unchanged.

Fixes #1665
2026-04-19 13:44:57 -07:00
James R. Barlow 75714fe43e Update uv.lock
For Pillow vuln. Fixes #1669
2026-04-19 13:06:22 -07:00
dependabot[bot]andGitHub e371ce95ca Bump sigstore/gh-action-sigstore-python from 3.2.0 to 3.3.0
Bumps [sigstore/gh-action-sigstore-python](https://github.com/sigstore/gh-action-sigstore-python) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/sigstore/gh-action-sigstore-python/releases)
- [Changelog](https://github.com/sigstore/gh-action-sigstore-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sigstore/gh-action-sigstore-python/compare/v3.2.0...v3.3.0)

---
updated-dependencies:
- dependency-name: sigstore/gh-action-sigstore-python
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 10:50:26 +00:00
dependabot[bot]andGitHub 716a2e22c3 Bump codecov/codecov-action from 5 to 6
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 10:50:22 +00:00
James R. Barlow 10e6019ada Bump version: v17.4.1 2026-04-06 00:34:08 -07:00
James R. Barlow 89c76b5145 v17.4.1 release notes 2026-04-05 00:23:07 -07:00
James R. Barlow 83c04e6399 Update GS JPEG corruption warning for 10.7.0+
The JPEG truncation bug (1-15 bytes) persists in Ghostscript 10.7.0.
Update the warning message to show the actual GS version instead of
hardcoding "10.6.x", and remove the stale date reference. Also make
the test warning filter match the new message format.
2026-04-04 01:59:57 -07:00
James R. Barlow 7fdeeb3635 Refactor word_render_data tuple into WordRenderData dataclass 2026-04-04 01:43:25 -07:00
James R. Barlow 5be368fe75 Fix RTL text extraction order in fpdf2 renderer (#1655)
fpdf2's shape_text() produces RTL ligature glyphs (e.g. lam-alef) with
multi-character CMap entries whose character order gets reversed by the
bidi algorithm during text extraction, producing garbled output like
"سالح" instead of "سلاح".

For invisible text (the production OCR overlay path), bypass text shaping
and use encode_text() with pre-reversed strings. encode_text() maps
characters 1:1 in logical order, avoiding the ligature CMap issue. The
pre-reversal compensates for bidi reversal by text extractors. Since the
text is invisible (Tr=3), the lack of joining forms is harmless.

Add RTL text extraction tests that verify glyph stream order, ToUnicode
CMap 1:1 mappings, and correct logical order for Arabic (including
lam-alef ligature) and Hebrew scripts.
2026-04-04 01:40:38 -07:00
jbarlowandGitHub 91c5b1e480 Merge pull request #1613 from bluebox-steven:add-options.work_folder-to-pdfcontext
Set work_folder in PdfContext options initialization
2026-04-03 01:28:18 -07:00
jbarlowandGitHub 73154b97ba Merge pull request #1643 from ocrmypdf:dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 6 to 7
2026-04-03 01:13:07 -07:00
jbarlowandGitHub 76a40759ae Merge pull request #1644 from ocrmypdf:dependabot/github_actions/actions/download-artifact-8
Bump actions/download-artifact from 7 to 8
2026-04-03 01:12:43 -07:00
jbarlowandGitHub 12ce565e98 Merge pull request #1646 from ocrmypdf:dependabot/github_actions/docker/setup-qemu-action-4
Bump docker/setup-qemu-action from 3 to 4
2026-04-03 01:12:02 -07:00
jbarlowandGitHub 9f46126859 Merge pull request #1647 from ocrmypdf:dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 3 to 4
2026-04-03 01:11:36 -07:00
jbarlowandGitHub 11849e5a70 Merge pull request #1648 from ocrmypdf:dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-04-03 01:08:58 -07:00
jbarlowandGitHub e30c00cc26 Merge pull request #1649 from ocrmypdf:dependabot/uv/tornado-6.5.5
Bump tornado from 6.5.4 to 6.5.5
2026-04-03 01:07:59 -07:00
dependabot[bot]andGitHub 001b403657 Bump tornado from 6.5.4 to 6.5.5
Bumps [tornado](https://github.com/tornadoweb/tornado) from 6.5.4 to 6.5.5.
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.4...v6.5.5)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 08:06:38 +00:00
jbarlowandGitHub 851c61ee85 Merge pull request #1657 from ocrmypdf:dependabot/uv/cryptography-46.0.6
Bump cryptography from 46.0.5 to 46.0.6
2026-04-03 01:06:25 -07:00
jbarlowandGitHub f5ebd23b8f Merge pull request #1653 from ocrmypdf:dependabot/uv/requests-2.33.0
Bump requests from 2.32.5 to 2.33.0
2026-04-03 01:05:58 -07:00
jbarlowandGitHub 81118c6195 Merge pull request #1658 from ocrmypdf:dependabot/uv/pygments-2.20.0
Bump pygments from 2.19.2 to 2.20.0
2026-04-03 01:05:22 -07:00
dependabot[bot]andGitHub 834b60a02a Bump pygments from 2.19.2 to 2.20.0
Bumps [pygments](https://github.com/pygments/pygments) from 2.19.2 to 2.20.0.
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0)

---
updated-dependencies:
- dependency-name: pygments
  dependency-version: 2.20.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 20:07:51 +00:00
dependabot[bot]andGitHub 47e3b5b4d2 Bump cryptography from 46.0.5 to 46.0.6
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.5 to 46.0.6.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 02:02:03 +00:00
dependabot[bot]andGitHub d9346cc3d8 Bump requests from 2.32.5 to 2.33.0
Bumps [requests](https://github.com/psf/requests) from 2.32.5 to 2.33.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 17:26:22 +00:00
James R. Barlow 4e974ebd46 Bump version: v17.4.0 2026-03-21 01:43:13 -07:00
James R. Barlow 6f2b8408c1 v17.4.0 release notes 2026-03-21 01:43:03 -07:00
James R. Barlow 1dba941261 Add cyclopts for dev 2026-03-21 01:37:48 -07:00
James R. Barlow ef76625abb Fix text stretching in fpdf2 renderer for widely-spaced words
The inter-word Tz calculation stretched "word " to span from the current
word to the next, producing extreme horizontal scaling (300-500%) for
words far apart (e.g. in tables). Use per-word Tz instead — Td
positioning already handles inter-word gaps correctly.

Fixes #1635
2026-03-16 16:00:00 -07:00
James R. Barlow 57bb554a70 Fix verapdf NotADirectoryError crash on some platforms
Catch OSError (parent of both FileNotFoundError and
NotADirectoryError) in verapdf.available() so environments where
executing `verapdf` raises NotADirectoryError gracefully fall back
instead of crashing the pipeline. Fixes #1638.
2026-03-10 02:08:59 -07:00
James R. Barlow 5b9d6f979e Add --no-overwrite / -n option to prevent overwriting output files
Fixes #1642. Adds an early check in check_requested_output_file() that
raises OutputFileAccessError (exit code 5) if the destination file
already exists and --no-overwrite is set. The option is wired through
CLI, OcrOptions, and the Python API.
2026-03-10 01:58:57 -07:00
James R. Barlow b588e3bfd7 Fix optimize=2/3 crash when using Python API
The jpg_quality and png_quality options default to None in the pydantic
model, but the fallback check only handled == 0. This caused a TypeError
when calling ocrmypdf.ocr() with optimize >= 2 without explicitly
setting quality values. Fixes #1641.
2026-03-10 01:51:07 -07:00
dependabot[bot]andGitHub a35dd1f9ee Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 11:18:20 +00:00
dependabot[bot]andGitHub bf46f4fe35 Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 11:18:16 +00:00
dependabot[bot]andGitHub 55b76338a8 Bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 11:18:10 +00:00
dependabot[bot]andGitHub 2af7b1c179 Bump actions/download-artifact from 7 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 11:32:46 +00:00
dependabot[bot]andGitHub 69f4cca9b6 Bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 11:32:40 +00:00
James R. Barlow 59190ef643 Bump version: v17.3.0 2026-02-21 00:00:26 -08:00
James R. Barlow 910ccccc7d Fix bump-version 2026-02-21 00:00:14 -08:00
James R. Barlow 0c15ff594c v17.3.0 release notes 2026-02-20 23:52:48 -08:00
James R. Barlow e19ea653aa Switch to static versioning and two-workflow release model
Replace hatch-vcs dynamic versioning with static version in _version.py
and pyproject.toml. Split CI into build.yml (test + stage draft release
on main) and release.yml (publish from draft on tag push). Docker images
are built on main pushes and re-tagged with the release version on tag
push without rebuilding.
2026-02-20 23:34:03 -08:00
James R. Barlow a899f0d59a Split release_notes into parts for each major release 2026-02-20 18:19:31 -08:00
James R. Barlow b4e8e9dac9 Fix Python API ignoring language parameter (fixes #1640)
The API's 'language' param was silently dropped because OcrOptions uses
'languages' (plural). Map language->languages in create_options() and
_pdf_to_hocr(), coercing bare strings to lists and splitting '+'
separated codes to match CLI behavior.
2026-02-20 17:10:57 -08:00
James R. Barlow aca5eb626b Docker: increase alpine version to 3.23 2026-02-20 11:06:33 -08:00
James R. Barlow bd4a74de0e Restore image rendering for hocrtransform
Fixes [Question] How to reproduce hocr renderer image overlay ?
Fixes #1634
2026-02-18 18:00:34 -08:00
James R. Barlow 10b71937c4 Fix OCR text displacement on PDFs with non-zero MediaBox origins
_build_text_layer_ctm() returned None when text_rotation was 0,
skipping the origin translation needed for pages with non-zero
MediaBox origins (e.g. JSTOR PDFs with [0, 100, 595, 982]). This
caused the text layer to be offset by the page origin amount.

Always compute the full CTM and only return None when the result
is the identity matrix.

Fixes #1630
2026-02-17 23:34:33 -08:00
James R. Barlow 5890d1855e Fix Python API producing empty OCR due to tesseract_timeout defaulting to 0
OcrOptions.tesseract_timeout defaulted to 0.0, which caused
subprocess.run(timeout=0) to immediately raise TimeoutExpired before
Tesseract could produce any output. The CLI was unaffected because
argparse defaults --tesseract-timeout to 180. Change the OcrOptions
default to None so the plugin's own default (180s) is used.

Fixes #1636
2026-02-17 21:55:49 -08:00
James R. Barlow 3da952a23d Fix garbled Arabic/Devanagari text by using HarfBuzz text shaping
encode_text() maps unicode characters one-by-one to glyph IDs without
any text shaping, producing incorrect output for complex scripts:
Arabic glyphs in wrong order without joining forms, Devanagari conjuncts
broken apart. Replace with shape_text() which runs HarfBuzz for proper
BiDi reordering, Arabic shaping, and Devanagari conjunct formation.
2026-02-11 01:30:15 -08:00
James R. Barlow 716ce6324c Update dependencies 2026-02-11 00:43:01 -08:00
James R. Barlow 76fe2f7e28 Merge remote-tracking branch 'origin/dependabot/uv/cryptography-46.0.5' 2026-02-11 00:42:21 -08:00
James R. Barlow c85c8941d3 Fix pdftotext word spacing by emitting single BT block per line
poppler/pdftotext does not carry Tz (horizontal scaling) across
BT/ET boundaries, causing words to appear on separate lines.
Replace per-word BT blocks (via fpdf2's cell/set_stretching API)
with a single BT block per line using raw PDF operators. Each
non-last word gets a trailing space with Tz calculated to span
exactly to the next word's start position.
2026-02-11 00:42:10 -08:00
dependabot[bot]andGitHub 9a0dadbd4c Bump cryptography from 46.0.4 to 46.0.5
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.4 to 46.0.5.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.4...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-11 02:57:37 +00:00
James R. Barlow 4d7e398c4b Suppress rendering of text lines with improbable aspect ratios
When Tesseract completely fails to detect text rotation (no textangle,
slope=0), it produces garbage text in tall-narrow bounding boxes. Add
an aspect ratio plausibility check that compares the OCR bounding box
shape to the expected shape of the rendered text (accounting for
baseline slope). Lines where the ratio of aspect ratios is < 0.1 are
suppressed.

Uses a fast path (width >= height) to skip the expensive font
measurement for the common case of normal horizontal text.
2026-02-10 17:42:33 -08:00
James R. Barlow 56c0b41f97 Fix extreme font sizes for rotated text in fpdf2 renderer
Tesseract doesn't output the textangle hOCR attribute for 90-degree
rotated text. Instead, the rotation is encoded as extreme baseline
slope values (e.g., 462.2). Without detecting this, the renderer used
the axis-aligned bounding box height as line_size_height, producing
font sizes of 2000+ pt instead of ~10 pt.

Detect steep baseline slopes (|slope| > 1.0, i.e., > 45° from
horizontal) and extract the effective text rotation angle via atan().
The meaningless slope/intercept values are then replaced with
font-metrics-based defaults.
2026-02-10 17:02:25 -08:00
James R. Barlow 5c83dab8a7 Fix fpdf text mode in multi-page renderer; add v17.2.0 release notes
The previous fix (e62e73e4) only corrected text_rendering_mode →
text_mode in the single-page Fpdf2PdfRenderer, but the main OCR
pipeline uses Fpdf2MultiPageRenderer which still had the old
attribute name. Since fpdf2 has no text_rendering_mode property,
setting it silently created a no-op attribute while text_mode stayed
at FILL — so 3 Tr (invisible text) was never emitted.

Fixes #1631, #1632
2026-02-10 14:12:49 -08:00
James R. Barlow e62e73e441 Fix fpdf text mode 2026-02-09 02:05:23 -08:00
James R. Barlow d68e2f6e34 Fix OCR text layer misalignment with non-zero mediabox origins
Fixes #1630 where --redo-ocr would shift OCR text vertically on PDFs
with non-zero mediabox origins (e.g., [0, 100, width, height+100]).

The bug occurred in _graft_fpdf2_text_layer where the Form XObject BBox
was set to the text layer's mediabox [0, 0, w, h] instead of the base
page's mediabox [0, 100, w, h+100]. This caused a coordinate mismatch
between the BBox and the transformation matrix, resulting in text being
positioned incorrectly.

The fix changes line 450 in _graft.py to use base_mediabox instead of
mediabox, making the fpdf2 renderer consistent with the sandwich renderer
which already used base_mediabox correctly.

This issue commonly affected:
- JSTOR PDFs (generated by iText with cropping)
- Cropped PDFs from various tools
- PDFs with non-standard coordinate systems

Added regression test that creates a PDF with offset mediabox origin
and verifies --redo-ocr preserves coordinates correctly.
2026-02-08 23:55:26 -08:00
James R. Barlow 1684982cde Further adjustments to install docs 2026-02-06 17:17:44 -08:00
James R. Barlow 4d97dfd218 Update installation docs for modern tooling
- Prioritize uv over pip throughout, with uv as the recommended installer
- Update repology badges: Debian 13, Ubuntu 24.04, Fedora 40/41
- Make Python 3.12 the default (3.11 still supported)
- Promote Homebrew as full-featured option for macOS and Linux
- Add dependency summary table aligned with maintainers.md
- Document uharfbuzz and fonts-noto requirements
- Remove outdated warnings and simplify 32-bit section
2026-02-05 15:04:12 -08:00
James R. Barlow a35fcc9c43 Handle Ghostscript rasterization with DPI below 10
Ghostscript may fail when asked to rasterize at very low DPI values
(below 10 on either axis). This adds a workaround that uses a minimum
of 10 DPI for the Ghostscript call, then resizes the output image to
match the dimensions that would have resulted from the original low
DPI request.

Fixes #1612
2026-01-31 13:01:04 -08:00
James R. Barlow 3dd4cde7ce Tighten plugin manager return types to non-optional
Make filter_pdf_page, get_ocr_engine, and optimize_pdf return
non-optional types by handling None cases explicitly: raise errors
for required results, return sensible defaults for optional ones.
2026-01-31 12:12:07 -08:00
James R. Barlow 92beb474a5 Normalize unpaper_args to list at construction time
Use a Pydantic field validator to convert string input to list[str]
during OcrOptions construction, simplifying the type from
`str | list[str] | None` to `list[str] | None`. Security validation
(path injection check) now happens at construction rather than in
check_options_preprocessing().
2026-01-31 12:05:37 -08:00
James R. Barlow 9dcd882c83 Use uv to install docs with dependency groups 2026-01-31 00:05:27 -08:00
James R. Barlow 9d8aa5a0c3 v17.1.0 release notes 2026-01-30 16:15:50 -08:00
James R. Barlow e036a902ae Add --tagged-pdf-mode option to control Tagged PDF handling
Allow users to bypass the TaggedPDFError when processing Tagged PDFs
by setting --tagged-pdf-mode=ignore. This is useful when users know
they want to OCR a Tagged PDF despite the warning.

- 'default': Error if --mode is default, otherwise warn (current behavior)
- 'ignore': Always warn but continue processing (never error)
2026-01-30 16:15:43 -08:00
James R. Barlow 0a980fb11b Add Encoding.flate_jpeg to recognize deflated JPEG images
FlateDecode+DCTDecode compressed images are essentially deflated JPEGs,
typically created by OCRmyPDF's optimizer. This change ensures pdfinfo
correctly identifies them and should_visible_page_image_use_jpg treats
them as JPEG-origin images, allowing JPEG output when appropriate.
2026-01-30 12:53:59 -08:00
James R. Barlow 3abe8f71c7 v17.0.1 release notes 2026-01-30 00:15:13 -08:00
James R. Barlow 64f45b7fdb Fix pypdfium type checking 2026-01-30 00:14:02 -08:00
James R. Barlow 7e939ad44d Fix pypdfium rasterizer to respect raster_device colorspace
pypdfium was not converting images to the correct colorspace/mode based
on the raster_device parameter. For example, pngmono should produce a
1-bit image, but pypdfium was outputting full-color or grayscale images.

This caused images to be incorrectly promoted to PNG instead of being
preserved as CCITT/JBIG2 when using --force-ocr with pypdfium, because
the optimizer relies on the image being in the correct mode.

Changes:
- Render in grayscale for pngmono device (better input for 1-bit conversion)
- Add mode conversion to match Ghostscript's native device output:
  - pngmono: convert to mode '1' (1-bit)
  - pnggray/jpeggray: convert to mode 'L' (8-bit grayscale)
  - png256: convert to mode 'P' (8-bit indexed)
  - png16m/jpeg: convert to mode 'RGB'
2026-01-30 00:04:02 -08:00
James R. Barlow 297fb786a0 Update uv.lock (for protobuf) 2026-01-29 18:33:00 -08:00
James R. Barlow ad30dd94f7 Merge branch 'release/v17' 2026-01-29 18:31:11 -08:00
James R. Barlow e77f79ac6f Merge branch 'main' of github.com:ocrmypdf/OCRmyPDF 2026-01-29 18:30:54 -08:00
James R. Barlow c84fc56e45 Update CLI completions to match current options
Add new options: --mode, --ocr-engine, --rasterizer,
--continue-on-soft-render-error, --tesseract-non-ocr-timeout,
--tesseract-downsample-large-images, --tesseract-downsample-above,
--unpaper-args (fish), --plugin (fish).

Update --output-type to include 'auto' as default.
Update --pdf-renderer to include 'fpdf2' and mark hocr as deprecated.

Remove non-working options: --remove-background, --threshold.
2026-01-29 12:41:56 -08:00
James R. Barlow 0a0756b33e Tidy long lines and unnested with blocks 2026-01-27 15:28:27 -08:00
James R. Barlow c5d3ef4b17 Tighten ruff rules and modernize style 2026-01-27 14:04:52 -08:00
James R. Barlow 6b37583674 Refactor: move ocr_element to a better location 2026-01-27 14:01:30 -08:00
James R. Barlow de5f2b80f0 Further patching-out of fonts 2026-01-21 11:43:54 -08:00
James R. Barlow d951b4f0f7 Improve font fallback checking 2026-01-21 10:38:07 -08:00
James R. Barlow b386d39b3b tests: fix test_page_boxes when verapdf unavailable
The test expected MediaBox preservation for pdfa output, but this only
works when verapdf is available for speculative PDF/A conversion.
Without verapdf (Linux/Windows CI), Ghostscript normalizes the MediaBox.

Also convert pikepdf.Array to list in assertions for clearer error
messages, avoiding pytest repr issues with pikepdf objects.
2026-01-21 00:22:26 -08:00
James R. Barlow ec595a395b tests: little fixes 2026-01-20 23:23:43 -08:00
James R. Barlow bd29269c00 Various test fixes, mainly Windows issues 2026-01-20 22:28:06 -08:00
James R. Barlow 6fb7c5d95f Additional build fixes 2026-01-20 21:49:40 -08:00
James R. Barlow d57552c4f8 test: For Windows, ensure outputs are UTF-8 2026-01-20 21:37:13 -08:00
James R. Barlow f017c982cf watcher: use modern API 2026-01-20 21:25:12 -08:00
James R. Barlow 7ac51ac1a7 Fix type alias for Queue causing runtime TypeError
multiprocessing.Queue is a factory function, not a type, so it cannot
be used with the runtime | union operator. Move Queue, UserInit, and
WorkerInit type aliases into TYPE_CHECKING block to avoid evaluation
at runtime.
2026-01-20 20:38:33 -08:00
James R. Barlow db9f94de14 Ensure Noto font is installed where needed 2026-01-20 19:50:47 -08:00
James R. Barlow 37e7131a01 Drop support for Python 3.10, require Python 3.11+
Python 3.11 is now the minimum supported version. This aligns with
the codebase's use of StrEnum (introduced in 3.11) and removes
compatibility shims that were only needed for older versions.
2026-01-20 11:54:55 -08:00
James R. Barlow bc745d4d81 Replace magic Ghostscript raster device strings with StrEnum 2026-01-20 10:44:25 -08:00
James R. Barlow c818ad5e75 Drop deprecated NeverRaise exception 2026-01-20 10:43:21 -08:00
James R. Barlow 4b16228a4a docs: minor adjustments 2026-01-20 10:41:55 -08:00
James R. Barlow d40fca2590 Add verapdf to build for macOS 2026-01-20 10:41:43 -08:00
James R. Barlow 99f8106936 Update API documentation for OcrOptions-first calling convention
Document the new v17 API style where OcrOptions can be passed directly
to ocr(). Mark the positional argument style as legacy API for <v17
compatibility. Update examples to use modern syntax.
2026-01-20 10:30:33 -08:00
James R. Barlow ef88ba3f95 Add OcrOptions as first-class argument to ocr() function
Allow passing an OcrOptions object directly to ocr() as the first
positional argument, providing a cleaner API for programmatic use.
The old-style API with individual parameters remains fully supported.
2026-01-20 10:20:52 -08:00
James R. Barlow 2f4280b66c Comprrehensive documentation update in preparation for v17 2026-01-16 01:38:47 -08:00
James R. Barlow 6cf9d1c6ee Update release notes 2026-01-15 23:29:29 -08:00
James R. Barlow 6a7164a76c Update release notes with branch changes 2026-01-15 23:25:51 -08:00
James R. Barlow 3f328785f0 Fix pypdfium rasterizer to match Ghostscript dimensions
The pypdfium rasterizer was producing output images that differed by 1
pixel compared to Ghostscript due to floating-point precision issues in
dimension calculations.

Root cause:
- pypdfium used harmonic mean of x/y DPI to calculate a single scale
  factor, losing the distinction between x and y DPI
- No DPI rounding like Ghostscript's 6-decimal precision
- Compound rounding errors when converting points to pixels

Solution:
1. Round DPI to 6 decimals to match Ghostscript's precision
2. Calculate expected output dimensions using separate x/y DPI values
3. Handle dimension swapping for 90°/270° rotations
4. Resize output image if off by 1-2 pixels (graceful correction)

This ensures pixel-perfect matching with Ghostscript while being
minimally invasive and only resizing when necessary.

Changes:
- Modified _render_page_to_bitmap() to calculate expected dimensions
- Modified _process_image_for_output() to correct small discrepancies
- Updated rasterize_pdf_page() to pass dimensions through pipeline
- Parametrized rotation tests to run with both rasterizers

All 45 rotation tests now pass with both pypdfium and ghostscript.

Fixes test_rotated_skew_timeout with pypdfium rasterizer.
2026-01-14 14:37:24 -08:00
James R. Barlow 5acf21651f ruff lint and format 2026-01-13 01:50:57 -08:00
James R. Barlow 7bfe3ecd5b Fix double-compression of already-deflated JPEGs
Images with [FlateDecode, DCTDecode] filter chain were incorrectly
being marked for additional FlateDecode compression, resulting in
double-compressed data and invalid output PDFs.

Add _already_flate_encoded() helper to check if an image already has
FlateDecode in its filter chain, and skip such images in
_find_deflatable_jpeg().
2026-01-13 01:41:59 -08:00
James R. Barlow 5371cc5e39 Update test to match new error messag 2026-01-13 01:33:10 -08:00
James R. Barlow 4c7086c609 Replace typer with cyclopts CLI library in misc scripts
Migrate watcher.py and pdf_text_diff.py from typer to cyclopts for
CLI argument parsing. Update pyproject.toml to reflect the dependency
change in the watcher optional feature.
2026-01-13 00:43:14 -08:00
James R. Barlow bf76c8270c Rationalize optional dependencies vs dependency groups
Establish clear separation between user-facing optional dependencies
and developer-only dependency groups:

**Optional Dependencies (user features):**
- watcher: File watching service for batch processing
- webservice: Streamlit-based web UI
- Installable via: uv sync --extra <name> or pip install ocrmypdf[name]

**Dependency Groups (developer tools):**
- test: Testing infrastructure (merged from test + extended_test)
- docs: Documentation building tools
- streamlit-dev: Enhanced Streamlit development tools
- dev: General development tools (mypy, ipykernel)
- Installable via: uv sync --group <name> (uv only, NOT pip)

Breaking changes for developers:
- pip install -e .[test] no longer works → use uv sync --group test
- pip install -e .[docs] no longer works → use uv sync --group docs
- pip install -e .[extended_test] removed → merged into test group

No breaking changes for end users:
- pip install ocrmypdf[watcher] still works
- pip install ocrmypdf[webservice] still works

Updated:
- CI/CD workflows to use uv sync --group test
- Docker images to exclude test dependencies
- Documentation to recommend uv with pip as fallback
- pyproject.toml with clear comments explaining both systems
2026-01-13 00:34:55 -08:00
James R. Barlow 740f67091c Rename OCROptions to OcrOptions for consistency
Technically OCROptions is more Pythonic but we have several pre-existing classes named OcrWhatever. Go with the local flow.
2026-01-12 23:37:54 -08:00
James R. Barlow 36dea181e6 Update cookbook: Replace --tesseract-timeout 0 with --ocr-engine none
Update documentation examples to use the new --ocr-engine none option
instead of the deprecated --tesseract-timeout 0 idiom for disabling OCR.
2026-01-12 23:28:14 -08:00
James R. Barlow c69f293322 Add --mode/-m CLI argument with ProcessingMode enum
Introduce a new --mode (-m) argument that consolidates the three
mutually exclusive OCR processing options into a single enum:
- default: Error if text is found (standard behavior)
- force: Rasterize all content and run OCR (replaces --force-ocr)
- skip: Skip pages with existing text (replaces --skip-text)
- redo: Re-OCR pages, stripping old text layer (replaces --redo-ocr)

The legacy flags --force-ocr, --skip-text, and --redo-ocr remain as
silent aliases for backward compatibility. Both CLI and API usage
continue to work unchanged.
2026-01-12 15:23:08 -08:00
James R. Barlow e9fe061c30 Format fix 2026-01-12 10:25:24 -08:00
James R. Barlow c9ea07e954 Reduce chattiness of fonttools 2026-01-12 10:16:58 -08:00
James R. Barlow 0c3745a1a4 Add OCR engine selection framework and null OCR engine
Introduce --ocr-engine option to select between OCR engines:
- 'auto' (default): Uses Tesseract
- 'tesseract': Explicit Tesseract selection
- 'none': Skip OCR entirely (for PDF processing only)

Key changes:
- Extend OcrEngine ABC with generate_ocr() and supports_generate_ocr()
  for direct OcrElement tree output (bypasses hOCR)
- Add get_ocr_engine(options) hook parameter for engine selection
- Implement NullOcrEngine for --ocr-engine none
- Export OcrElement, OcrClass, BoundingBox from ocrmypdf package
- Add ocr_tree support to grafting pipeline

This prepares the foundation for pluggable OCR engines while maintaining
full backward compatibility with existing Tesseract-based workflows.
2026-01-12 10:11:14 -08:00
James R. Barlow 664c3e2a8e Update test cache for slow rotation tests 2026-01-10 16:30:25 -08:00
James R. Barlow 315d0df0e9 Fix incorrect rotation direction in pypdfium rasterizer
pypdfium2 expects clockwise rotation values, but OCRmyPDF tracks
rotation in counter-clockwise. Negate the rotation value to fix.

Also refactor nested try/finally blocks to use contextlib.closing()
for cleaner resource management.
2026-01-10 16:29:49 -08:00
James R. Barlow 3c94ada857 Fix tesseract_cache plugin to properly handle cache misses
- Check all required output files exist before declaring cache hit,
  not just stderr.bin
- Add 'hocr' to list of cached output file types
- Fix timeout=0.0 causing immediate timeout on cache miss by treating
  it as "no timeout"
2026-01-09 02:10:29 -08:00
James R. Barlow fcbdbac602 Update test_page_boxes MediaBox expectations for speculative PDF/A
When speculative PDF/A succeeds (verapdf available), Ghostscript is
bypassed and MediaBox is preserved rather than normalized to origin.
2026-01-09 01:25:31 -08:00
James R. Barlow 122450c19e Fix Ghostscript tests after default output type changed to 'auto'
- Add --output-type pdfa to tests that exercise Ghostscript-specific
  behavior (test_gs_render_failure, test_ghostscript_pdfa_failure,
  test_ghostscript_mandatory_color_conversion)
- Add Gs106WarningFilter to suppress expected Ghostscript 10.6.x JPEG
  encoding warning in test logs
2026-01-09 01:02:25 -08:00
James R. Barlow 0c4ee5af4e Add 'auto' output type for best-effort PDF/A without Ghostscript
- Add new '--output-type auto' option (now the default) that produces
  best-effort PDF/A without requiring Ghostscript
- When verapdf is available, use speculative PDF/A conversion
- Without verapdf, pass through as PDF/A if safe (input claims PDF/A
  or --force-ocr was used), otherwise output as regular PDF
- Make Ghostscript check conditional - only required for pdfa* output types
- Update soft error tests to explicitly use --output-type pdfa since they
  exercise Ghostscript failure modes
- Fix Tesseract OSD error handling to check both stdout and stderr for
  known non-fatal messages like "Too few characters"
2026-01-09 00:56:00 -08:00
James R. Barlow bdc50e9470 Add explicit word spacing for pdfminer.six compatibility
Insert space characters between words in the fpdf2 renderer so PDF
readers like pdfminer.six can properly segment words during text
extraction. Some PDF readers rely on explicit space characters rather
than inferring word boundaries from positioning.

- Use itertools.pairwise to iterate consecutive word pairs
- Render space immediately after each word (content stream order matters)
- Skip space insertion between CJK words (no spaces in CJK text)
- Use 5% line height threshold to filter OCR noise
- Support RTL text direction
2026-01-08 16:32:14 -08:00
James R. Barlow 4cb488d0fc Skip speculative PDF/A when --pdfa-image-compression is set
When the user explicitly sets --pdfa-image-compression to something
other than 'auto', skip the speculative PDF/A conversion and use
Ghostscript instead. The speculative conversion (using pikepdf +
verapdf) doesn't apply image compression settings, so Ghostscript
is required to honor the user's compression preference.
2026-01-08 15:12:35 -08:00
James R. Barlow bb5238e524 Update tests to use new OcrmypdfPluginManager interface
Replace pm.hook.method() calls with pm.method() calls to match the
refactored plugin manager that now uses composition over inheritance.
The hook attribute is no longer directly exposed; instead, type-safe
methods are provided directly on the plugin manager class.
2026-01-08 13:09:19 -08:00
James R. Barlow 900a60fd10 Add verapdf integration for speculative PDF/A conversion
Introduce a fast path for PDF/A conversion that uses pikepdf to add
PDF/A structures directly (sRGB ICC profile and XMP metadata), then
validates with verapdf. If validation passes, skip Ghostscript entirely.
If validation fails or verapdf is unavailable, fall back to the existing
Ghostscript conversion path.

New files:
- src/ocrmypdf/_exec/verapdf.py: CLI wrapper for verapdf validator
- tests/test_verapdf.py: Test suite for new functionality

Modified:
- pdfa.py: Add speculative_pdfa_conversion() and helpers
- _pipeline.py: Add try_speculative_pdfa() function
- _pipelines/_common.py: Integrate speculative path into postprocess()
2026-01-08 10:58:01 -08:00
James R. Barlow f5617ce44e Refactor OcrmypdfPluginManager to use composition over inheritance
Replace inheritance from pluggy.PluginManager with composition pattern,
providing a type-safe interface for all 16 hooks defined in pluginspec.py.
The underlying pluggy manager is now accessible via the .pluggy property
for advanced use cases like set_blocked().

This change enables IDE autocomplete and type checking for all hook calls
while maintaining full backward compatibility with the plugin system.
2026-01-07 17:23:13 -08:00
James R. Barlow 0e946a7498 Clarify messageabout number of workers 2026-01-07 16:41:18 -08:00
James R. Barlow b2b6a7c4b1 Pass OMP_THREAD_LIMIT to Tesseract subprocesses instead of modifying parent env
Instead of setting OMP_THREAD_LIMIT in the parent process's environment,
calculate the thread limit in the validate hook and pass it through to
Tesseract subprocess calls via the env parameter. This avoids polluting
the parent process's environment while still controlling Tesseract's
thread usage.
2026-01-06 18:43:29 -08:00
James R. Barlow 75c664793e Don't share claude 2026-01-06 15:42:51 -08:00
James R. Barlow bbd263ff48 Add tests for fpdf2 renderer and font infrastructure
- Add hOCR test fixtures for Latin, Arabic, CJK, Devanagari scripts
- Add tests for fpdf2 renderer, multi-font manager, system font provider
- Add multilingual rendering tests
- Update existing tests to use fpdf2 renderer
2026-01-06 13:46:11 -08:00
James R. Barlow 7a4b98974c Integrate fpdf2 renderer and remove legacy hOCR renderer
- Update pipeline to use fpdf2 renderer as default
- Remove legacy hocrtransform PDF renderer (_font.py, _hocr.py,
  pdf_renderer.py)
- Update CLI and options for fpdf2 renderer
- Add fpdf2 dependency to pyproject.toml
- Update graft module for fpdf2 multi-page rendering
2026-01-06 13:45:44 -08:00
James R. Barlow d72a494979 Add fpdf2-based PDF text layer renderer
Implement new PDF renderer using fpdf2 library that provides:
- Multilingual text support via font module
- Proper baseline and rotation handling
- Multi-page rendering with efficient font embedding
- Invisible but selectable text layer
2026-01-06 13:45:14 -08:00
James R. Barlow 64726f97b3 Add font infrastructure and glyphless font
- Add font module with FontManager, FontProvider, MultiFontManager,
  and SystemFontProvider for multilingual font support
- Add NotoSans-Regular.ttf for Latin text rendering
- Replace pdf.ttf with Occulta.ttf glyphless font
- Add script to generate new Occulta glyphless font
- System font discovery for CJK, Arabic, Devanagari scripts
2026-01-06 13:44:54 -08:00
bluebox-stevenandGitHub 4babdfcfbf Set work_folder in JobContext initialization 2026-01-06 09:27:13 +02:00
James R. Barlow 83a43408c2 Refactor tesseract thresholding to use enum type
Replace integer-based thresholding parameter with ThresholdingMethod
enum for improved type safety. The CLI still accepts the same string
values (auto, otsu, adaptive-otsu, sauvola) but internally uses a
strongly-typed enum. This makes the code more maintainable and catches
type errors at development time.
2025-12-27 13:32:56 -08:00
James R. Barlow 2cb0973540 Improve Ghostscript API/CLI definitions 2025-12-27 01:40:12 -08:00
SuperCowProductsandGitHub 8930efe787 Update README with Fedora installation instructions (#1610)
Added instructions for Fedora users to install Tesseract language packs.
2025-12-27 01:15:45 -08:00
James R. Barlow 0d6e0c4560 Merge branch 'main' into dev 2025-12-24 00:44:18 -08:00
James R. Barlow 94d7735862 docs: missing issue ref 2025-12-24 00:14:24 -08:00
James R. Barlow c540967429 docs: Update release notes 2025-12-23 15:44:44 -08:00
James R. Barlow 195344d307 Reinstate "Work around Ghostscript 10.6.0 JPEG encoding issue by forcing optimization.""
This reverts commit fc30cb8903.
It turns out that both fixes were necessary.
2025-12-23 15:41:34 -08:00
James R. Barlow de63d6eac9 Merge remote-tracking branches 'origin/dependabot/github_actions/actions/download-artifact-7', 'origin/dependabot/github_actions/actions/upload-artifact-6', 'origin/dependabot/github_actions/sigstore/gh-action-sigstore-python-3.2.0' and 'origin/dependabot/github_actions/actions/checkout-6' 2025-12-23 15:06:50 -08:00
James R. Barlow 6ada11ddae docs: Update release notes 2025-12-23 15:05:49 -08:00
James R. Barlow fc30cb8903 Revert "Work around Ghostscript 10.6.0 JPEG encoding issue by forcing optimization."
This reverts commit f4c6c8121b.

The issue is now resolved by correcting the encoidng issue directly.
2025-12-23 15:03:51 -08:00
James R. Barlow 01a3706281 docs: Add release notes for v16.13.0 2025-12-23 15:01:22 -08:00
James R. Barlow e613db6a82 Fix Ghostscript 10.6 JPEG corruption by repairing truncated images
Ghostscript 10.6 has a bug that truncates JPEG data by 1-15 bytes.
This adds detection and repair by comparing output images to input
images and restoring the original bytes when truncation is detected.

- Add warning when GS 10.6+ is used with PDF/A output
- Add _repair_gs106_jpeg_corruption() to fix damaged JPEGs after
  Ghostscript processing
- Add unit tests for the repair function
2025-12-23 14:56:24 -08:00
James R. Barlow 742a4bac17 Make rotation test more robust 2025-12-23 11:20:57 -08:00
James R. Barlow 4c1ef0b471 Also process art and bleed boxes 2025-12-23 11:20:41 -08:00
James R. Barlow eace567f7b Test and fix page box issues 2025-12-23 11:19:51 -08:00
James R. Barlow e9bfce34f1 Fix ruff linting issues
- Use X | Y syntax in isinstance calls (UP038)
- Remove trailing whitespace from blank lines (W293)
2025-12-23 03:07:48 -08:00
James R. Barlow 16c2604a07 Remove lossy JBIG2 support, retain lossless JBIG2 only
Lossy JBIG2 has been removed due to well-documented risks of character
substitution errors (e.g., 6/8 confusion). The --jbig2-lossy and
--jbig2-page-group-size arguments are now deprecated and ignored with
a warning.

Changes:
- Remove jbig2_lossy and jbig2_page_group_size from OCROptions
- Simplify optimize.py to use single-image JBIG2 encoding only
  (no symbol dictionaries/JBIG2Globals)
- Remove convert_group() from jbig2enc.py
- Deprecate CLI args with warnings for backward compatibility
- Update documentation to explain lossless-only JBIG2
2025-12-23 02:45:07 -08:00
James R. Barlow 9ebba91466 Use plugin namespace access pattern throughout codebase
Migrate all code from flat accessor pattern (options.tesseract_timeout)
to the plugin namespace pattern (options.tesseract.timeout).

Key changes:
- Fix _get_plugin_options to raise AttributeError for unregistered
  namespaces instead of silently returning None
- Add _convert_value helper to convert PathLike to str for plugin
  model field compatibility
- Filter out _plugin_cache_* entries from JSON serialization to fix
  worker process serialization (test_simulate_oom_killer)
- Update tesseract_ocr.py, ghostscript.py, _validation_coordinator.py,
  and _pipelines/ocr.py to use options.tesseract.* and
  options.ghostscript.* accessors
- Update tests to use setup_plugin_infrastructure() for plugin
  model registration
2025-12-23 02:02:21 -08:00
James R. Barlow aec995aced Require plugin model registration for namespace access in OCROptions
- Update __getattr__ docstring to clarify that plugin models must be
  registered for namespace access (e.g., options.tesseract.timeout)
- Update test_json_serialization.py to properly register TesseractOptions
  before accessing plugin namespaces
- Worker processes now register plugin models for multiprocessing tests
- Exclude plugin cache keys from extra_attrs comparison in tests
2025-12-22 15:09:55 -08:00
James R. Barlow be425e7405 Refactor pdfinfo: split info.py into focused modules
Split the 1288-line info.py into smaller, single-responsibility modules:
- _types.py: Enums, type aliases, lookup dictionaries
- _contentstream.py: PDF content stream parsing, DPI calculation
- _image.py: ImageInfo class and image finding functions
- _worker.py: Concurrency/worker process handling
- info.py: PageInfo, PdfInfo classes (reduced to ~530 lines)

Public API unchanged - all existing imports continue to work.
2025-12-22 01:27:23 -08:00
James R. BarlowandClaude Opus 4.5 b4f9673364 Add unit tests for HocrParser, PdfTextRenderer, and OcrElement
Comprehensive test coverage for the new hocrtransform components:

- test_ocr_element.py: Tests for BoundingBox, Baseline, FontInfo,
  OcrElement dataclass methods (iter_by_class, find_by_class,
  get_text_recursive, words/lines/paragraphs properties)

- test_hocr_parser.py: Tests for parsing hOCR files including
  page/paragraph/line/word extraction, RTL text, rotated text,
  different line types (header, caption), font info, and edge cases

- test_pdf_renderer.py: Tests for PDF rendering including text
  extraction verification, page sizing, multi-line content,
  text direction, baseline handling, textangle rotation, word breaks,
  debug options, and image overlay

Also fixes x_font regex pattern to not capture trailing semicolons.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 17:05:49 -08:00
James R. BarlowandClaude Opus 4.5 9ea804aff5 Refactor hocrtransform: separate parsing from rendering
Split the hOCR transformation code into three distinct layers:

1. ocr_element.py - Generic OcrElement dataclass that represents OCR
   output structure from any source (hOCR, ALTO, custom engines).
   Includes helper classes: BoundingBox, Baseline, FontInfo.

2. hocr_parser.py - HocrParser class that parses hOCR XML files into
   OcrElement trees, extracting bbox, baseline, textangle, confidence,
   font info, direction, and language.

3. pdf_renderer.py - PdfTextRenderer class that renders OcrElement
   trees to PDF text layers, handling text positioning, baseline
   rotation, LTR/RTL, and word break injection.

The existing HocrTransform class is preserved for backward compatibility,
now delegating to the new components internally.

This separation enables:
- Support for non-hOCR OCR output formats
- Independent improvements to text rendering
- Reuse of OcrElement for other purposes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:17:22 -08:00
James R. Barlow e162361d28 Make rotation test more robust 2025-12-21 14:42:14 -08:00
James R. Barlow 22d00837e3 WIP box tests 2025-12-21 14:03:28 -08:00
James R. Barlow 0faba42d36 test: Don't save local files 2025-12-21 14:03:28 -08:00
James R. Barlow 57e2600566 Also process art and bleed boxes 2025-12-21 14:03:28 -08:00
James R. Barlow 41758766a1 Test and fix page box issues 2025-12-21 14:03:28 -08:00
James R. BarlowandClaude Opus 4.5 3e46b039ed feat: add use_cropbox parameter to align rasterizer APIs
Added use_cropbox parameter to rasterize_pdf_page hook to allow
choosing between MediaBox and CropBox rendering:

- Default is use_cropbox=False (MediaBox) for consistency with
  Ghostscript's existing behavior
- Ghostscript: passes -dUseCropBox when use_cropbox=True
- pypdfium: calculates crop values to expand from CropBox to MediaBox
  when use_cropbox=False

This aligns both rasterizers to produce the same output dimensions
by default, making the rasterizer choice transparent for page
geometry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:29:17 -08:00
James R. BarlowandClaude Opus 4.5 ae783b4ae6 fix: add thread safety lock to pypdfium plugin
pypdfium2/PDFium is not thread-safe - concurrent calls from different
threads can crash or corrupt the process. Added a module-level lock to
serialize all pdfium operations.

PIL image processing and file I/O are done outside the lock since they
are thread-safe, minimizing lock contention.

For maximum parallelism, users can use process-based parallelism
(use_threads=False) where each process has its own pdfium instance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:29:17 -08:00
James R. BarlowandClaude Opus 4.5 b9f488d65c test: add comprehensive tests for --rasterizer option
Add test_rasterizer.py with tests covering:
- Basic rasterizer option validation ('auto', 'ghostscript', 'pypdfium')
- Rasterizer + --rotate-pages interaction
- PDFs with nonstandard MediaBox/TrimBox/CropBox
- Direct hook tests verifying plugins respect the option

Also fix pluggy parameter passing: make 'options' a required parameter
(no default) in the hookspec so pluggy forwards it to implementations.
Update test plugins and test_rotation.py to pass the new parameter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:29:17 -08:00
James R. BarlowandClaude Opus 4.5 ed813cec67 feat: add --rasterizer CLI option to select PDF rasterization backend
Add user control over which rasterizer is used for PDF page rendering:
- 'auto' (default): prefers pypdfium when available, falls back to Ghostscript
- 'pypdfium': force pypdfium2 (errors if not installed)
- 'ghostscript': force traditional Ghostscript rasterizer

Changes:
- Add rasterizer field with validation to OCROptions model
- Add --rasterizer CLI argument in the Advanced options group
- Update rasterize_pdf_page hookspec to pass options to plugins
- Update pypdfium plugin with check_options hook for availability check
- Update both plugins to respect the rasterizer option

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:29:17 -08:00
James R. BarlowandClaude Opus 4.5 938ce8e285 fix: make pypdfium plugin optional with Ghostscript fallback
- Remove check_options hook from pypdfium that raised error when
  pypdfium2 wasn't installed
- Return None from pypdfium's rasterize_pdf_page when pypdfium2 is
  unavailable, allowing the hook to fall through
- Restore Ghostscript's rasterize_pdf_page hook as fallback

This allows OCRmyPDF to work without pypdfium2 installed, using
Ghostscript for rasterization as before.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:29:17 -08:00
James R. Barlow cf3fb6e89b Fix raster_device settings for pypdfium rasterizer 2025-12-21 12:29:17 -08:00
James R. Barlowandaider 3482ea5fe5 refactor: Modularize rasterize_pdf_page into separate PDF, page, and image processing functions
Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
2025-12-21 12:29:17 -08:00
James R. Barlow e85c5bbb4d refactor: Simplify error message and code formatting in pypdfium plugin 2025-12-21 12:29:17 -08:00
James R. Barlowandaider 740b0bddc6 feat: add pypdfium2 rasterization plugin for OCRmyPDF
Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
2025-12-21 12:29:17 -08:00
James R. BarlowandClaude Opus 4.5 a4ee513cd4 refactor: clean up deprecated code and update plugin docs
- Remove outdated Phase comments from _options.py and cli.py
- Remove unused methods from PluginOptionRegistry:
  - get_extended_options_model() - replaced by __getattr__ in OCROptions
  - map_legacy_options() - unused
  - validate_plugin_options() - unused
- Update plugin documentation to document register_options hook
- Add documentation for nested plugin option access pattern

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:21:48 -08:00
James R. BarlowandClaude Opus 4.5 0ad7f5fc13 feat: add dynamic nested access to plugin options
Completes Phase 5 of the CLI refactoring plan by enabling nested
plugin option access (e.g., options.tesseract.timeout) alongside
the legacy flat access (options.tesseract_timeout).

Changes:
- Add module-level plugin option model registry in _options.py
- Add __getattr__ to OCROptions for dynamic namespace access
- Register plugin models in setup_plugin_infrastructure()
- Add test for nested plugin option access

Plugin option instances are lazily created from flat field values
and cached for subsequent access.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:21:48 -08:00
James R. BarlowandClaude Opus 4.5 47cea37487 docs: add CLAUDE.md for Claude Code guidance
Provides architecture overview, common commands, and testing info
for AI-assisted development.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider b89bb3b524 fix: add blocked language validation for osd and equ
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 95d9c3ed18 fix: add cross-cutting validation to OCROptions model
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider f91e41a209 fix: refine validation coordinator error handling
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider f6fcdfa618 fix: allow 0 as valid value for jbig2_page_group_size
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 01ea6c2b8b feat: add ValidationCoordinator for cross-cutting validation
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider f02d733d31 feat: add legacy field mapping to plugin registry
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 28d6ea0f10 feat: Add CLI generation methods to plugin option models
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 6913ec7cb8 feat: add PluginOptionRegistry for dynamic option models
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 40f01d85ae refactor: convert jbig2 properties to direct fields in OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider b7640bdb9c feat: implement plugin option models with backward compatibility
This commit introduces Pydantic models for plugin-specific options in OCRmyPDF, focusing on:
- Creating TesseractOptions, OptimizeOptions, and GhostscriptOptions
- Adding backward compatibility properties
- Maintaining existing CLI and API functionality
- Preparing for future plugin option registration system

The changes include:
- Added type-annotated option models with validation
- Updated OCROptions to include legacy fields
- Added backward compatibility properties for jbig2 options
- Prepared groundwork for dynamic plugin option handling

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 62ad37b276 refactor: centralize plugin manager setup
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlow b1de6a6ad4 Add more cached tests 2025-12-21 12:21:48 -08:00
James R. Barlow e4fa9dbc8f Drop obsolete subclass of ArgumentParser 2025-12-21 12:21:48 -08:00
James R. Barlow b7737446e4 cli: push up imports 2025-12-21 12:21:48 -08:00
James R. Barlowandaider 42891346d1 fix: update test files to use new get_options_and_plugins function
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 4ed0e4510c fix: add type checking imports for OCROptions and pluggy
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlowandaider 4f9c4c3e52 refactor: reorganize CLI and options initialization
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:48 -08:00
James R. Barlow 08ee5690bc Remove to_namespace and its user, since nothing triggers it 2025-12-21 12:21:48 -08:00
James R. Barlow 3f38ea4d80 Remove OCROptions getattr interface 2025-12-21 12:21:47 -08:00
James R. Barlowandaider f04b5504e8 test: fix hOCR pipeline output folder handling
The commit message captures the essence of the changes: we fixed how the output folder is handled in the hOCR pipeline by making it a proper field in OCROptions and updating the API functions accordingly.

Would you like me to generate a full commit message or is this sufficient?

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlow 69185e5819 refactor: remove custom attribute handling methods and add output_folder option 2025-12-21 12:21:47 -08:00
James R. Barlow ade3ecd5a1 fix: add error handling in hOCR pipeline 2025-12-21 12:21:47 -08:00
James R. Barlow e4f8ba8edc Fix computing of lossless_reconstruction and checking of redo_ocr conflicts 2025-12-21 12:21:47 -08:00
James R. Barlow 1225c0a45e fix: correct typing issues in concurrent and optimize modules 2025-12-21 12:21:47 -08:00
James R. Barlowandaider f0c292f4e1 refactor: Remove CLI-parser dependencies in experimental API functions
This commit updates `_pdf_to_hocr` and `_hocr_to_ocr_pdf` to use direct OCROptions construction, eliminating the last vestiges of CLI-parser dependency in the experimental APIs.

Key changes:
- Removed `parser = get_parser()` calls
- Added plugin validation similar to main `ocr()` function
- Simplified plugin manager hook calls
- Added None value filtering to use OCROptions defaults
- Maintained error handling and extra_attrs logic

The refactoring makes these experimental APIs truly API-first and simplifies the code by removing unnecessary CLI-related complexity.

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlow 1f493ba789 refactor: post-AI code cleanup 2025-12-21 12:21:47 -08:00
James R. Barlowandaider e1d976168c feat: handle Pydantic serialization iterators in JSON serialization
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 60182ac8a8 fix: update JSON serialization tests to match default values
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlow 53db7b384b refactor: set default values for optional OCR configuration parameters 2025-12-21 12:21:47 -08:00
James R. Barlowandaider d77d63f1dc feat: add JSON serialization tests for OCROptions in multiprocessing
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider ff250afa51 fix: add helper function to calculate effective JBIG2 page group size
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider cdb976db41 fix: handle None or zero jbig2_page_group_size in extract_images_jbig2
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 9535b52d06 fix: revert default option values to preserve original behavior
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: set default optimize value to 1 to prevent NoneType comparison

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider e1216eddb0 test: modify test_two_languages to use list of languages
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 21d69ffe87 fix: set default value for fast_web_view to prevent NoneType errors
This change ensures that `fast_web_view` always has a default value of 1.0, preventing the TypeError that was occurring in multiple test cases when trying to multiply `None` with an integer.

The modifications include:
1. Changing the type hint for `fast_web_view` from `float | None` to `float`
2. Setting a default value of 1.0
3. Adding a validation step in the `model_validator` to set the default value if not provided

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 3baeb83533 fix: Add compatibility for jpeg_quality and resolve strategy validation
This commit addresses several test failures by:
- Adding a compatibility property for `jpeg_quality`
- Changing default color conversion strategy from 'auto' to 'RGB'
- Ensuring `tesseract_config` is always a list
- Improving validation for color conversion and image compression strategies

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 134f4fcc28 fix: remove Union type hints and add default values in OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider a869a4ac42 fix: filter out None values from OCROptions kwargs
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 48a2fdb0f2 refactor: replace _kwargs_to_cmdline with direct OCROptions construction in experimental API functions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 91a2d39845 refactor: replace command line synthesis with direct OCROptions construction
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 3a0a7c546b feat: Implement JSON serialization for OCROptions with safe handling of Path and stream objects
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider d0a46a0359 feat: convert CLI Namespace to OCROptions in main entry point
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider cb22a35834 refactor: convert hOCR API entry points to use OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 9ff7ab491c fix: handle None jobs in hOCR pipeline concurrency
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

refactor: remove unused argparse import from pdf_to_hocr.py

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: import OCROptions in pdf_to_hocr pipeline

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: handle None jobs in hocr_to_ocr_pdf pipeline

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 0c3110857e fix: add lossless_reconstruction to namespace options in PdfContext
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider aad90bcb54 fix: compute lossless_reconstruction when not present in options
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 5f685aef6e fix: handle None jobs in concurrent processing
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 65b89cafde fix: use available_cpu_count fallback for jobs in hOCR
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider eeda99636a fix: handle Namespace conversion in PdfContext initialization
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider afc85333ac feat: add OCROptions import and type hints to pipeline functions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 3987a610e1 fix: update pipeline setup to handle immutable OCROptions correctly
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider dab969f97d refactor: update context management with OCROptions type hints
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlow 480a8253eb refactor: propagate lossless_reconstruction option to clean_options in PageContext 2025-12-21 12:21:47 -08:00
James R. Barlowandaider 8a06dd478a refactor: update pipeline functions to use OCROptions instead of argparse.Namespace
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 4dbd34f06a refactor: update type hint for do_get_pdfinfo to accept generic options
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 8668cf4524 fix: add type conversion for pages in pipeline common
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:47 -08:00
James R. Barlowandaider 1d74c2831f refactor: update pipeline entry points to use OCROptions directly
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:46 -08:00
James R. Barlowandaider 530186b468 docs: update documentation for OCROptions plugin interface migration
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:46 -08:00
James R. Barlowandaider 7b37f57b1c refactor: replace Namespace with OCROptions in plugins and validation
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:46 -08:00
James R. Barlow d4b7165d72 fix: resolve pickling issue with Pydantic validators in PageContext
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

Fix pickling issue
2025-12-21 12:21:46 -08:00
James R. Barlowandaider f5bfd2fd3e Add compat function for pages_from_ranges
fix: handle Pydantic validation errors with correct exit code

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-21 12:21:46 -08:00
dependabot[bot]andGitHub cdf956ffc4 Bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 10:02:30 +00:00
dependabot[bot]andGitHub c6b21d4dea Bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 10:02:24 +00:00
James R. Barlowandaider 7575dddefc fix: add lossless_reconstruction attribute to OCROptions for compatibility
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: resolve recursion error in lossless_reconstruction option

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: ensure lossless_reconstruction attribute is added to Namespace

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>

fix: ensure lossless_reconstruction attribute is correctly propagated to PageContext

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:42:49 -08:00
James R. Barlowandaider 66a3e8508e feat: add comprehensive validators to OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:42:23 -08:00
James R. Barlowandaider 7bb3a97208 refactor: update test_validation.py to use Pydantic model validation
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:42:23 -08:00
James R. Barlowandaider d2add01217 fix: remove deprecated set_lossless_reconstruction import and call
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:42:23 -08:00
James R. Barlowandaider 4476e81240 refactor: remove redundant validation functions from _validation.py
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:42:23 -08:00
James R. Barlowandaider a373fcd649 fix: add jobs fallback in pipeline common
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:42:23 -08:00
James R. Barlow 87478bc240 Fix options.jpg_quality issue 2025-12-13 11:42:23 -08:00
James R. Barlowandaider 04ad78f01d refactor: replace OptimizeOptions with OCROptions in tests
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:58 -08:00
James R. Barlowandaider 62c3ae80c7 fix: ensure pickling compatibility for OCROptions in multiprocessing
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:58 -08:00
James R. Barlow d556014185 Remove language warning 2025-12-13 11:41:58 -08:00
James R. Barlowandaider d18efcbbf1 fix: support flexible type inputs for pages and unpaper_args
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:27 -08:00
James R. Barlowandaider f9a4a2e240 fix: handle missing input_file in OCROptions namespace conversion
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:27 -08:00
James R. Barlowandaider 5f89100dc3 test: add lossless_reconstruction field to OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:27 -08:00
James R. Barlowandaider 1ef9aaf659 fix: Correct PDF/A part extraction and handle hOCR API output file
This commit addresses two issues:
1. Properly extract the PDF/A part from output_type
2. Add a placeholder output_file for hOCR API tests when output_folder is used

The changes include:
- Modifying `from_namespace` to add a placeholder output_file
- Updating PDF/A part extraction logic to handle different output_type formats
- Ensuring correct PDF/A part is passed to Ghostscript

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:27 -08:00
James R. Barlowandaider 4c4a1cfa17 fix: Resolve API test failures with pdf_renderer and output_file handling
This commit addresses several issues in the OCRmyPDF API:
- Fixed handling of 'auto' pdf_renderer by defaulting to 'hocr'
- Added placeholder for output_file when output_folder is present
- Updated model_fields access to use class method instead of instance attribute
- Improved error handling and default behavior in PDF rendering

Specifically:
- Modified `_options.py` to handle 'auto' pdf_renderer
- Updated attribute access to use class methods
- Added placeholder for output_file in special cases
- Updated `_pipelines/ocr.py` to handle 'auto' pdf_renderer

These changes resolve the test failures in `test_api.py` and improve the library's flexibility.

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:41:27 -08:00
James R. Barlowandaider 5251e21f7e refactor: migrate OCROptions validators to Pydantic V2
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:40:57 -08:00
James R. Barlowandaider 28eb923d9f feat: rename _extra_attrs to extra_attrs in OCROptions
Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:40:57 -08:00
James R. Barlowandaider 1579337ebe refactor: Create OCROptions model with Namespace compatibility
This commit introduces a new `OCROptions` class in `_options.py` that provides:
- Proper typing for OCRmyPDF options
- Pydantic validation
- Backward compatibility with `argparse.Namespace`
- Gradual migration support for the options system

Key changes:
- Added comprehensive option fields with type hints
- Implemented custom attribute access methods
- Created conversion methods between Namespace and OCROptions
- Updated type hints in multiple files to support both types
- Maintained existing validation logic

The new model allows for a step-by-step refactoring of the options handling throughout the project.

Co-authored-by: aider (openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-12-13 11:40:57 -08:00
dependabot[bot]andGitHub f673da9ab9 Bump sigstore/gh-action-sigstore-python from 3.1.0 to 3.2.0
Bumps [sigstore/gh-action-sigstore-python](https://github.com/sigstore/gh-action-sigstore-python) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/sigstore/gh-action-sigstore-python/releases)
- [Changelog](https://github.com/sigstore/gh-action-sigstore-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sigstore/gh-action-sigstore-python/compare/v3.1.0...v3.2.0)

---
updated-dependencies:
- dependency-name: sigstore/gh-action-sigstore-python
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-08 10:02:38 +00:00
rugkandGitHub 8d715c4157 docs: fix and clarify podman usage instructions (#1601)
* docs: fix and clarify podman usage instructions

* the full reference `jbarlow83/ocrmypdf-alpine` as in the other commands may fix an issue if you do not have `ocrmypdf` already downloaded locally
* also clarified the command at the end for usage when SELinux is enabled

* docs: clarify difference between SeLinux and rootless user mapping
2025-12-01 13:07:09 -08:00
dependabot[bot]andGitHub 0f3c7765aa Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 11:31:33 +00:00
Chris MayoandGitHub 9dbce33ee6 Update Changelog URL (#1597)
Renamed in:
d1a45e4a ("Convert remaining rst -> md", 2025-04-17)
2025-11-16 23:10:48 -08:00
James R. Barlow 54ce09496c v16.12.0 release notes 2025-11-11 13:48:06 -08:00
James R. Barlow f4c6c8121b Work around Ghostscript 10.6.0 JPEG encoding issue by forcing optimization.
Not an ideal fix, but it improves an issue affecting numerous users.

Fixes #1585.
2025-11-10 17:01:02 -08:00
James R. Barlow 057eaff36d Skip devnull testing on Windows
No longer seems to work - Windows Server 2025 change, perhaps? Doesn't really matter.
2025-11-10 16:57:30 -08:00
James R. Barlow b88d63bdf7 Add Python 3.14 to test matrix 2025-11-10 16:10:01 -08:00
James R. Barlow a385cd967d docs: Improve ocrmypdf.api 2025-11-10 15:58:47 -08:00
James R. Barlow 2f72f8e94a ghostscript: Disable subset fonts
For at least the PDF associated with this issue, disabling subset
fonts prevents Ghostscript from mangling the encoding when it is usable but not well-formed.

Fixes #1592
2025-11-10 15:58:14 -08:00
James R. Barlowandaider ee47e986f3 docs: Improve module-level docstring for OCRmyPDF Python API
Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>
2025-11-10 10:33:26 -08:00
James R. Barlow e44063da15 Update Dockerfile versions
tesseract-ocr/alex-p does not have a Tesseract 5 for Ubuntu 25.10 so we use 25.04 for now.

Ubuntu 25.04 gets us Ghostscript 10.05 which avoids issues in older versions.

Remove comment about now-legacy Alpine versions not working properly. Alpine provides Ghostscript 10.05.1.

Fixes #1587,
2025-11-09 15:20:55 -08:00
James R. Barlow abc2d41e2d Require recent pikepdf to fix check_pdf_syntax issue 2025-10-29 11:40:51 -07:00
James R. Barlow 38d60ea89b optimize: don't put flate on large jpegs unless compression is high
Putting flate on very large JPEGs can cause performance problems in PDF viewers, subjectively anyway.
2025-10-29 11:39:20 -07:00
James R. Barlow 35ec90af44 Merge remote-tracking branches 'origin/dependabot/github_actions/sigstore/gh-action-sigstore-python-3.1.0', 'origin/dependabot/github_actions/actions/upload-artifact-5' and 'origin/dependabot/github_actions/actions/download-artifact-6' 2025-10-28 13:40:08 -07:00
James R. Barlow aa1cc8ae04 Update packages 2025-10-27 17:07:14 -07:00
dependabot[bot]andGitHub eaceb66030 Bump sigstore/gh-action-sigstore-python from 3.0.1 to 3.1.0
Bumps [sigstore/gh-action-sigstore-python](https://github.com/sigstore/gh-action-sigstore-python) from 3.0.1 to 3.1.0.
- [Release notes](https://github.com/sigstore/gh-action-sigstore-python/releases)
- [Changelog](https://github.com/sigstore/gh-action-sigstore-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sigstore/gh-action-sigstore-python/compare/v3.0.1...v3.1.0)

---
updated-dependencies:
- dependency-name: sigstore/gh-action-sigstore-python
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 11:08:50 +00:00
dependabot[bot]andGitHub b1dcc2c445 Bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 11:02:08 +00:00
dependabot[bot]andGitHub ab3855af48 Bump actions/download-artifact from 5 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 10:47:34 +00:00
James R. Barlow 5c6cc4031f Merge remote-tracking branch 'origin/dependabot/github_actions/astral-sh/setup-uv-7' 2025-10-25 12:10:01 -07:00
James R. Barlow f181307e50 v16.11.1 release notes 2025-10-16 10:59:13 +02:00
James R. Barlow b213efb030 Account for new deskew output error message from recent Tesseract
Fixes #1576
2025-10-16 09:50:03 +02:00
James R. Barlow f59e68911f Drop macos-13 (now unsupported by Apple) 2025-10-13 15:10:28 +02:00
dependabot[bot]andGitHub 9605656a2f Bump astral-sh/setup-uv from 6 to 7
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 6 to 7.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 10:40:31 +00:00
James R. Barlow 599fb1a1f6 Fix test_semfree (skip Python 3.14)
This feature is now deprecated and won't be fixed for Python 3.14. Instead we just use threads on platforms that don't support semaphores.

Closes #1558
2025-09-14 13:02:33 -07:00
322 changed files with 35647 additions and 17550 deletions
+32 -8
View File
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
FROM ubuntu:24.04 AS base
FROM ubuntu:26.04 AS base
ENV LANG=C.UTF-8
ENV TZ=UTC
@@ -40,7 +40,7 @@ RUN \
WORKDIR /app
# Copy uv from ghcr
COPY --from=ghcr.io/astral-sh/uv:0.6.14 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
@@ -55,18 +55,18 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen \
--extra test --extra webservice --extra watcher --no-dev \
--extra webservice --extra watcher --no-dev \
--no-install-package pyarrow
FROM base
RUN apt-get update && apt-get install -y software-properties-common
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
# Tesseract 5 ships in the Ubuntu archive as of 24.04, so no third-party PPA is
# needed. (Previously this used ppa:alex-p/tesseract-ocr5.)
RUN apt-get update && apt-get install -y --no-install-recommends \
ghostscript \
fonts-droid-fallback \
fonts-noto-core \
fonts-noto-cjk \
jbig2dec \
pngquant \
tesseract-ocr \
@@ -79,6 +79,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unpaper \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user to run the application (defense in depth). The build
# stages above need root to install packages, but the entrypoint should not.
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
# and matches the --chown below. See docs/docker.md for the volume/permissions
# implications under rootless vs rootful Docker.
# The Ubuntu base ships a default "ubuntu" user at uid/gid 1000; remove it so
# "app" can claim that uid for parity with the Alpine image.
RUN userdel -r ubuntu 2>/dev/null; groupdel ubuntu 2>/dev/null; \
groupadd -g 1000 app \
&& useradd -u 1000 -g app -m -d /home/app app
ENV HOME=/home/app
WORKDIR /app
COPY --from=builder /usr/local/lib/ /usr/local/lib/
@@ -88,9 +100,21 @@ COPY --from=builder --chown=app:app /app /app
RUN rm -rf /app/.git && \
ln -s /app/misc/webservice.py /app/webservice.py && \
ln -s /app/misc/watcher.py /app/watcher.py
ln -s /app/misc/watcher.py /app/watcher.py && \
chown app:app /app
# Default working directory for bind-mounted data, so relative input/output
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
RUN mkdir -p /data && chown app:app /data
WORKDIR /data
ENV PATH="/app/.venv/bin:${PATH}"
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
# overridden) as the unprivileged app user. Override with `--user root` if you
# need root inside a running container (e.g. to apt install extra packages).
USER app
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
+26 -10
View File
@@ -1,13 +1,7 @@
# SPDX-FileCopyrightText: 2023 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
# Note: Alpine 3.20 builds tesseract with --enable-opencl, which is not
# supported by anyone. OCRmyPDF is not compatible with Alpine 3.20.0
# through 3.20.3. The issue is fixed in 3.21.
# Details
# https://gitlab.alpinelinux.org/alpine/aports/-/issues/16143
# https://github.com/ocrmypdf/OCRmyPDF/issues/1395
FROM alpine:3.21 AS base
FROM alpine:3.24 AS base
ENV LANG=C.UTF-8
ENV TZ=UTC
@@ -28,7 +22,7 @@ RUN apk add --no-cache \
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:0.6.14 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
@@ -45,7 +39,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen \
--extra test --extra webservice --extra watcher --no-dev \
--extra webservice --extra watcher --no-dev \
--no-install-package pyarrow
FROM base
@@ -63,18 +57,40 @@ RUN apk add --no-cache \
tesseract-ocr-data-osd \
tesseract-ocr-data-por \
tesseract-ocr-data-spa \
font-noto \
ttf-droid \
unpaper \
&& rm -rf /var/cache/apk/*
# Create a non-root user to run the application (defense in depth). The build
# stages above need root to install packages, but the entrypoint should not.
# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable
# and matches the --chown below. See docs/docker.md for the volume/permissions
# implications under rootless vs rootful Docker.
RUN addgroup -g 1000 app \
&& adduser -u 1000 -G app -D -h /home/app app
ENV HOME=/home/app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app
RUN rm -rf /app/.git && \
ln -s /app/misc/webservice.py /app/webservice.py && \
ln -s /app/misc/watcher.py /app/watcher.py
ln -s /app/misc/watcher.py /app/watcher.py && \
chown app:app /app
# Default working directory for bind-mounted data, so relative input/output
# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`).
# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this.
RUN mkdir -p /data && chown app:app /data
WORKDIR /data
ENV PATH="/app/.venv/bin:${PATH}"
# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when
# overridden) as the unprivileged app user. Override with `--user root` if you
# need root inside a running container (e.g. to apk add extra packages).
USER app
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
+1
View File
@@ -13,5 +13,6 @@
*.jpg binary
*.bin binary
*.afdesign binary
*.ttf binary
.git_archival.txt export-subst
+96 -96
View File
@@ -9,54 +9,73 @@ on:
- ci
- release/*
- feature/*
tags:
- v*
paths-ignore:
- README*
pull_request:
jobs:
lint:
name: Lint (prek)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v7
with:
python-version: "3.11"
- name: Run prek
run: |
uv run prek run --all-files
test_linux:
name: Test ${{ matrix.os }} with Python ${{ matrix.python }}
needs: lint
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-22.04, ubuntu-24.04]
python: ["3.10", "3.11", "3.12", "3.13"]
python: ["3.11", "3.12", "3.13", "3.14"]
include:
- os: ubuntu-22.04
tesseract_ppa: "ppa"
python: "3.10"
python: "3.11"
env:
OS: ${{ matrix.os }}
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
with:
version: "0.5.x"
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- name: Install Tesseract from PPA
if: matrix.tesseract_ppa == 'ppa'
run: |
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr5.3
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr5
- name: Install common packages
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
curl \
fonts-noto-core \
fonts-noto-cjk \
ghostscript \
jbig2dec \
img2pdf \
@@ -74,7 +93,7 @@ jobs:
- name: Install Python packages
run: |
uv sync --extra test --no-dev
uv sync --group test
- name: Report versions
run: |
@@ -89,7 +108,7 @@ jobs:
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -98,20 +117,19 @@ jobs:
test_macos:
name: Test macOS
needs: lint
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, macos-13] # macos-latest is arm64, macos-13 is x86_64
python: ["3.10", "3.11", "3.12", "3.13"]
os: [macos-latest]
python: ["3.11", "3.12", "3.13", "3.14"]
env:
OS: ${{ matrix.os }}
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/checkout@v7
- name: Install Homebrew deps
continue-on-error: true
@@ -123,21 +141,23 @@ jobs:
jbig2enc \
openjpeg \
pngquant \
tesseract
poppler \
tesseract \
verapdf
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
with:
version: "0.5.x"
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- name: Install Python packages
run: |
uv sync --extra test --no-dev
uv sync --group test
- name: Report versions
run: |
@@ -151,7 +171,7 @@ jobs:
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -160,46 +180,46 @@ jobs:
test_windows:
name: Test Windows
needs: lint
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest]
python: ["3.10", "3.11", "3.12", "3.13"]
python: ["3.11", "3.12", "3.13", "3.14"]
env:
OS: ${{ matrix.os }}
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
with:
version: "0.5.x"
version: "0.9.x"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python }}
- name: Install system packages
run: |
choco install --yes --no-progress --pre tesseract
choco install --yes --no-progress tesseract
choco install --yes --no-progress --ignore-checksums ghostscript --version 9.56.1
choco install --yes --no-progress poppler --version=25.11.0
- name: Install Python packages
run: |
uv sync --extra test --no-dev
uv sync --group test
- name: Test
run: |
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -210,84 +230,68 @@ jobs:
name: Build sdist and wheels
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@v7
with:
version: "0.5.x"
version: "0.9.x"
- name: Make wheels and sdist
run: |
uv build --sdist --wheel
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: artifact
path: |
./dist/*.whl
./dist/*.tar.gz
upload_pypi:
name: Deploy artifacts to PyPI
stage_release:
name: Stage release artifacts
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
runs-on: ubuntu-latest
environment: release
if: github.ref == 'refs/heads/main'
permissions:
id-token: write # mandatory for PyPI publishing
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
steps:
- uses: actions/download-artifact@v5
with:
name: artifact
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
create_release:
name: Create GitHub release
needs: [upload_pypi]
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
permissions:
# Required to create a release
contents: write
id-token: write
steps:
- uses: actions/download-artifact@v5
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: artifact
path: dist
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@v3.0.1
with:
inputs: |
./dist/*.tar.gz
./dist/*.whl
- name: Read version from source
id: version
run: |
VERSION=$(python3 -c "exec(open('src/ocrmypdf/_version.py').read()); print(__version__)")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create GitHub Release
- name: Create or update draft release
env:
GITHUB_TOKEN: ${{ github.token }}
run: >-
gh release create
"$GITHUB_REF_NAME"
--repo "$GITHUB_REPOSITORY"
--notes ""
run: |
TAG="v${{ steps.version.outputs.version }}"
- name: Upload artifact signatures to GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
# Upload to GitHub Release using the `gh` CLI.
# `dist/` contains the built packages, and the
# sigstore-produced signatures and certificates.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/**
--repo "$GITHUB_REPOSITORY"
# If release.yml already published this version, _version.py may
# still reflect it until the next version bump commit. Don't
# re-draft an already-published release on later pushes to main.
if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null)" == "false" ]]; then
echo "Release $TAG is already published; skipping."
exit 0
fi
# Delete existing draft release if it exists (ignore errors)
gh release delete "$TAG" --yes 2>/dev/null || true
# Create new draft release with all artifacts
gh release create "$TAG" \
--draft \
--title "$TAG" \
--notes "Draft release - will be updated when tag is pushed" \
dist/*
docker_ubuntu:
name: Build Ubuntu-based Docker image
@@ -308,22 +312,20 @@ jobs:
- name: Set image name
run: echo "DOCKER_IMAGE_NAME=ocrmypdf" >> $GITHUB_ENV
- uses: actions/checkout@v5
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/checkout@v7
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: jbarlow83
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Print image tag
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
@@ -356,19 +358,17 @@ jobs:
- name: Set image name
run: echo "DOCKER_IMAGE_NAME=ocrmypdf-alpine" >> $GITHUB_ENV
- uses: actions/checkout@v5
with:
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
- uses: actions/checkout@v7
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: jbarlow83
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Print image tag
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
+114
View File
@@ -0,0 +1,114 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
name: Publish Release
on:
push:
tags:
- "v*"
jobs:
publish:
name: Publish release
runs-on: ubuntu-latest
environment:
name: release
url: https://pypi.org/p/ocrmypdf
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v7
- name: Download artifacts from draft release
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
mkdir -p dist
gh release download "$GITHUB_REF_NAME" --dir dist --pattern '*.whl'
gh release download "$GITHUB_REF_NAME" --dir dist --pattern '*.tar.gz'
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# PyPI doesn't support sigstore publishing, so generate after publishing to PyPI
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@v3.4.0
with:
inputs: |
./dist/*.tar.gz
./dist/*.whl
- name: Extract release notes
run: |
VERSION="${GITHUB_REF_NAME#v}"
MAJOR="${VERSION%%.*}"
MAJOR_PADDED=$(printf "%02d" "$MAJOR")
RELEASE_FILE="docs/releasenotes/version${MAJOR_PADDED}.md"
python3 << EOF
import re
version = "${VERSION}"
release_file = "${RELEASE_FILE}"
try:
with open(release_file) as f:
content = f.read()
# Find the section for this version
# Match from "## vX.Y.Z" until the next "## v" or end of file
pattern = rf"## v{re.escape(version)}\n(.*?)(?=\n## v|\Z)"
match = re.search(pattern, content, re.DOTALL)
notes = match.group(1).strip() if match else ""
except FileNotFoundError:
notes = ""
with open("release_notes.md", "w") as f:
f.write(notes)
EOF
- name: Publish release (convert draft to published)
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
# Update release: remove draft status, add release notes
gh release edit "$GITHUB_REF_NAME" \
--draft=false \
--notes-file release_notes.md
# Upload signatures to the release
gh release upload "$GITHUB_REF_NAME" dist/*.sigstore.json --clobber
docker_tag:
name: Tag Docker images with release version
needs: [publish]
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: jbarlow83
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Tag ocrmypdf (Ubuntu) image
run: |
docker buildx imagetools create \
--tag "jbarlow83/ocrmypdf:$GITHUB_REF_NAME" \
"jbarlow83/ocrmypdf:latest"
- name: Tag ocrmypdf-ubuntu image
run: |
docker buildx imagetools create \
--tag "jbarlow83/ocrmypdf-ubuntu:$GITHUB_REF_NAME" \
"jbarlow83/ocrmypdf-ubuntu:latest"
- name: Tag ocrmypdf-alpine image
run: |
docker buildx imagetools create \
--tag "jbarlow83/ocrmypdf-alpine:$GITHUB_REF_NAME" \
"jbarlow83/ocrmypdf-alpine:latest"
+3 -1
View File
@@ -44,6 +44,8 @@ docs/_build/
docs/_static/
docs/_templates/
docs/Makefile
src/ocrmypdf/_version.py
.idea/
.aider*
CLAUDE.md
.claude/
-32
View File
@@ -1,32 +0,0 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: debug-statements
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.0.261"
hooks:
- id: ruff
files: "src/.*\\.pyi?$"
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
language_version: python
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.2.0
hooks:
- id: mypy
additional_dependencies:
- types-toml
- types-setuptools
- types-requests
- types-Pillow
+10 -8
View File
@@ -15,11 +15,13 @@ sphinx:
build:
os: ubuntu-22.04
tools:
python: "3.10"
python:
install:
- method: pip
path: .
extra_requirements:
- docs
python: "3.13"
jobs:
pre_create_environment:
- asdf plugin add uv
- asdf install uv latest
- asdf global uv latest
create_environment:
- uv venv "${READTHEDOCS_VIRTUALENV_PATH}"
install:
- UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs
+20 -4
View File
@@ -84,12 +84,12 @@ For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/la
OCRmyPDF uses Tesseract for OCR, and relies on its language packs. For Linux users, you can often find packages that provide language packs:
```bash
# Display a list of all Tesseract language packs
apt-cache search tesseract-ocr
# Debian/Ubuntu users
apt-cache search tesseract-ocr # Display a list of all Tesseract language packs
apt-get install tesseract-ocr-chi-sim # Example: Install Chinese Simplified language pack
# Arch Linux users
pacman -S tesseract-data-eng tesseract-data-deu # Example: Install the English and German language packs
@@ -99,6 +99,12 @@ pkg_add tesseract-cym # Example: Install the Welsh language pack
# brew macOS users
brew install tesseract-lang
# Fedora users
dnf search tesseract-langpack # Display a list of all Tesseract language packs
dnf install tesseract-langpack-ita # Example: Install the Italian language pack
```
You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple languages can be requested.
@@ -120,8 +126,8 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF
## Feature demo
```bash
# Add an OCR layer and convert to PDF/A
ocrmypdf input.pdf output.pdf
# Add an OCR layer and require PDF/A
ocrmypdf --output-type pdfa input.pdf output.pdf
# Convert an image to single page PDF
ocrmypdf input.jpg output.pdf
@@ -145,6 +151,16 @@ For more features, see the [documentation](https://ocrmypdf.readthedocs.io/en/la
In addition to the required Python version, OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
## Plugins
OCRmyPDF provides a plugin interface allowing its capabilities to be extended or replaced. Here are some plugins we are aware of:
- [OCRmyPDF-AppleOCR](https://github.com/mkyt/ocrmypdf-AppleOCR): replaces the standard Tesseract OCR engine with Apple Vision Framework. Requires macOS.
- [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR): replaces the standard Tesseract OCR engine with EasyOCR, a newer OCR engine based on PyTorch. GPU strongly recommended.
- [OCRmyPDF-PaddleOCR](https://github.com/clefru/ocrmypdf-paddleocr): replaces the standard Tesseract OCR engine with PaddleOCR, a powerful GPU accelerated OCR engine.
[paperless-ngx](https://docs.paperless-ngx.com/) provides integration of OCRmyPDF into a searchable document management system.
## Press & Media
- [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
+90 -76
View File
@@ -1,8 +1,3 @@
# OCRmyPDF 中文说明
我将为您创建一个中文版的 README.md 文件。以下是基于原始英文版本的中文翻译:
```markdown:/Users/huapai/PycharmProjects/OCRmyPDF/README.md
<!-- SPDX-FileCopyrightText: 2014 Julien Pfefferkorn -->
<!-- SPDX-FileCopyrightText: 2015 James R. Barlow -->
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
@@ -16,98 +11,109 @@
[docs]: https://readthedocs.org/projects/ocrmypdf/badge/?version=latest "RTD"
[pyversions]: https://img.shields.io/pypi/pyversions/ocrmypdf "支持的 Python 版本"
OCRmyPDF 为扫描 PDF 文件添加 OCR 文本层,使其可以搜索或复制粘贴。
OCRmyPDF 为扫描 PDF 文件添加 OCR 文本层,使其可以搜索或复制粘贴。
```bash
ocrmypdf # 是一个可脚本化的命令行程序
-l eng+fra # 支持多种语言
--rotate-pages # 可以修正旋转错误的页面
--deskew # 可以校正斜的 PDF
--title "My PDF" # 可以更改输出元数据
--jobs 4 # 默认使用多核心处理
--output-type pdfa # 默认生成 PDF/A 格式
ocrmypdf # 是一个可脚本化的命令行程序
-l eng+fra # 支持多种语言
--rotate-pages # 可以修正旋转方向错误的页面
--deskew # 可以校正斜的 PDF
--title "My PDF" # 可以更改输出元数据
--jobs 4 # 默认使用多个 CPU 核心
--output-type pdfa # 默认生成 PDF/A
input_scanned.pdf # 接受 PDF 输入(或图像)
output_searchable.pdf # 生成经过验证的 PDF 输出
```
[查看发布说明了解最新变更详情](https://ocrmypdf.readthedocs.io/en/latest/release_notes.html)。
[查看发布说明了解最新变更详情](https://ocrmypdf.readthedocs.io/en/latest/release_notes.html)。
## 主要特点
## 主要功能
- 从普通 PDF 生成可搜索的 [PDF/A](https://en.wikipedia.org/?title=PDF/A) 文件
- 准确地将 OCR 文本放置在图像下方,便于复制/粘贴
- 将 OCR 文本准确放置在图像下方,便于复制/粘贴
- 保持原始嵌入图像的精确分辨率
- 在可能的情况下,以"无损"操作方式插入 OCR 信息,不破坏任何其他内容
- 在可能,以无损操作插入 OCR 信息,不干扰任何其他内容
- 优化 PDF 图像,通常生成比输入文件更小的文件
- 如果需要,在执行 OCR 前对图像进行校正和/或清理
- 按需在执行 OCR 前校正和/或清理图像
- 验证输入和输出文件
- 在所有可用 CPU 核心分配工作
- 在所有可用 CPU 核心分配工作
- 使用 [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) 引擎识别超过 [100 种语言](https://github.com/tesseract-ocr/tessdata)
- 保护的私数据安全
- 适当扩展处理包含数千页的文件
- 在数百万 PDF 上经过实战测试
- 保护的私数据
- 可以妥善扩展处理包含数千页的文件
-在数百万 PDF 上经过实战检验。
<img src="misc/screencast/demo.svg" alt="终端会话中的 OCRmyPDF 演示">
<img src="misc/screencast/demo.svg" alt="OCRmyPDF 在终端会话中的演示">
详情请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/)。
## 开发动机
## 动机
我在网上搜索免费的命令行工具来对 PDF 文件行 OCR:我找到了很多,但没有一个真正令人满意:
在网上寻找一款免费的命令行工具来对 PDF 文件行 OCR:我找到了很多,但没有一个真正令人满意:
- 要么它们生成的 PDF 文件中文本位置错误(使复制/粘贴变得不可能
- 要么它们不处理重音和多语言字符
- 要么它们改变嵌入图像的分辨率
- 要么它们生成了体积巨大的 PDF 文件
- 要么它们在尝试 OCR 时崩溃
- 要么它们不生成有效的 PDF 文件
- 最重要的是,它们都不生成 PDF/A 文件(专为长期存储设计的格式)
- 要么生成的 PDF 文件中文本位于图像下方的错误位置(导致无法复制/粘贴
- 要么无法处理重音字符和多语言字符
- 要么改变嵌入图像的分辨率
- 要么生成的 PDF 文件大得离谱
- 要么在尝试 OCR 时崩溃
- 要么无法生成有效的 PDF 文件
- 除此之外,它们都不生成 PDF/A 文件(专为长期存储设计的格式)
...所以我决定开发自己的工具。
……所以我决定开发自己的工具。
## 安装
支持 Linux、Windows、macOS 和 FreeBSD。Docker 镜像也可用,同时支持 x64 和 ARM。
支持 Linux、Windows、macOS 和 FreeBSD。也提供 Docker 镜像,同时支持 x64 和 ARM。
| 操作系统 | 安装命令 |
| --------------------------- | ----------------------------- |
| Debian, Ubuntu | ``apt install ocrmypdf`` |
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
| Fedora | ``dnf install ocrmypdf`` |
| macOS (Homebrew) | ``brew install ocrmypdf`` |
| macOS (MacPorts) | ``port install ocrmypdf`` |
| macOS (nix) | ``nix-env -i ocrmypdf`` |
| LinuxBrew | ``brew install ocrmypdf`` |
| FreeBSD | ``pkg install py-ocrmypdf`` |
| Ubuntu Snap | ``snap install ocrmypdf`` |
| 操作系统 | 安装命令 |
| ----------------------------- | ------------------------------ |
| Debian, Ubuntu | ``apt install ocrmypdf`` |
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
| Fedora | ``dnf install ocrmypdf`` |
| macOS (Homebrew) | ``brew install ocrmypdf`` |
| macOS (MacPorts) | ``port install ocrmypdf`` |
| macOS (nix) | ``nix-env -i ocrmypdf`` |
| LinuxBrew | ``brew install ocrmypdf`` |
| FreeBSD | ``pkg install py-ocrmypdf`` |
| OpenBSD | ``pkg_add ocrmypdf`` |
| Ubuntu Snap | ``snap install ocrmypdf`` |
对于其他用户[请参阅我们的文档](https://ocrmypdf.readthedocs.io/en/latest/installation.html)了解安装步骤。
其他用户请[参阅我们的文档](https://ocrmypdf.readthedocs.io/en/latest/installation.html)了解安装步骤。
## 语言
OCRmyPDF 使用 Tesseract 行 OCR,并依赖其语言包。对于 Linux 用户,通常可以找到提供语言包的软件包:
OCRmyPDF 使用 Tesseract 行 OCR,并依赖其语言包。对于 Linux 用户,通常可以找到提供语言包的软件包:
```bash
# 显示所有 Tesseract 语言包的列表
apt-cache search tesseract-ocr
# Debian/Ubuntu 用户
apt-get install tesseract-ocr-chi-sim # 示例:安装中文简体语言包
apt-cache search tesseract-ocr # 显示所有 Tesseract 语言包列表
apt-get install tesseract-ocr-chi-sim # 示例:安装简体中文语言包
# Arch Linux 用户
pacman -S tesseract-data-eng tesseract-data-deu # 示例:安装英语和德语语言包
# OpenBSD 用户
pkg_info -aQ tesseract # 显示所有 Tesseract 语言包列表
pkg_add tesseract-cym # 示例:安装威尔士语语言包
# brew macOS 用户
brew install tesseract-lang
# Fedora 用户
dnf search tesseract-langpack # 显示所有 Tesseract 语言包列表
dnf install tesseract-langpack-ita # 示例:安装意大利语语言包
```
然后,您可以向 OCRmyPDF 传递 `-l LANG` 参数,提示它应搜索哪些语言。可以请求多种语言。
随后可以向 OCRmyPDF 传递 `-l LANG` 参数,提示它应搜索哪些语言。可以同时请求多种语言。
OCRmyPDF 支持 Tesseract 4.1.1+。它会自动使用 `PATH` 环境变量中首先找到的版本。在 Windows 上,如果 `PATH` 不提供 Tesseract 二进制文件,我们会根据 Windows 注册表使用已安装的最高版本号。
OCRmyPDF 支持 Tesseract 4.1.1+。它会自动使用 `PATH` 环境变量中首先找到的版本。在 Windows 上,如果 `PATH` 中没有 Tesseract 二进制文件,我们会根据 Windows 注册表使用已安装的最高版本号。
## 文档和支持
安装 OCRmyPDF 后,可以通过以下方式访问内置帮助,解命令语法和选项:
安装 OCRmyPDF 后,可以通过以下命令访问内置帮助,解命令语法和选项:
```bash
ocrmypdf --help
@@ -115,13 +121,13 @@ ocrmypdf --help
我们的[文档托管在 Read the Docs 上](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
请在我们的 [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) 页面报告问题,并遵循问题模板以获得快速响应。
请在我们的 [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) 页面报告问题,并遵循 issue 模板以便快速获得响应。
## 功能演示
```bash
# 添加 OCR 层并转换为 PDF/A
ocrmypdf input.pdf output.pdf
# 添加 OCR 层并要求输出 PDF/A
ocrmypdf --output-type pdfa input.pdf output.pdf
# 将图像转换为单页 PDF
ocrmypdf input.jpg output.pdf
@@ -129,45 +135,53 @@ ocrmypdf input.jpg output.pdf
# 就地为文件添加 OCR(仅在成功时修改文件)
ocrmypdf myfile.pdf myfile.pdf
# 使用非英语语言行 OCR(查找语言的 ISO 639-3 代码)
# 使用非英语语言行 OCR查找对应语言的 ISO 639-3 代码)
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
# OCR 多语言文档
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
# 校正(矫正倾斜的页面
# 校正歪斜页面
ocrmypdf --deskew input.pdf output.pdf
```
更多功能请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
更多功能请参阅[文档](https://ocrmypdf.readthedocs.io/en/latest/index.html)。
## 要求
所需的 Python 版本外,OCRmyPDF 还需要外部程序安装 Ghostscript 和 Tesseract OCR。OCRmyPDF 是纯 Python 编写的,几乎可以在所有平台上运行:Linux、macOS、Windows 和 FreeBSD。
除所需的 Python 版本外,OCRmyPDF 还需要安装 Ghostscript 和 Tesseract OCR 这两个外部程序。OCRmyPDF 是纯 Python 项目,几乎可以在所有平台上运行:Linux、macOS、Windows 和 FreeBSD。
## 媒体报道
## 插件
- [使用 OCRmyPDF 实现无纸化](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
- [将扫描文档转换为带有编辑的压缩可搜索 PDF](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
- [c't 1-2014, 第 59 页](https://heise.de/-2279695):在德国领先的 IT 杂志 c't 中详细介绍 OCRmyPDF v1.0
- [heise Open Source, 09/2014: 使用 OCRmyPDF 进行文本识别](https://heise.de/-2356670)
- [heise 使用 OCRmyPDF 创建可搜索的 PDF 文档](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
- [优秀实用工具:OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
- [LinuxUser 使用 OCRmyPDF 和 Scanbd 自动化文本识别](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
- [Y Combinator 讨论](https://news.ycombinator.com/item?id=32028752)
OCRmyPDF 提供插件接口,允许扩展或替换其能力。以下是我们知道的一些插件:
## 商业咨询
- [OCRmyPDF-AppleOCR](https://github.com/mkyt/ocrmypdf-AppleOCR):用 Apple Vision Framework 替换标准 Tesseract OCR 引擎。需要 macOS。
- [OCRmyPDF-EasyOCR](https://github.com/ocrmypdf/OCRmyPDF-EasyOCR):用 EasyOCR 替换标准 Tesseract OCR 引擎;EasyOCR 是基于 PyTorch 的较新 OCR 引擎。强烈建议使用 GPU。
- [OCRmyPDF-PaddleOCR](https://github.com/clefru/ocrmypdf-paddleocr):用 PaddleOCR 替换标准 Tesseract OCR 引擎;PaddleOCR 是功能强大的 GPU 加速 OCR 引擎。
如果没有公司和用户选择为功能开发和咨询提供支持,OCRmyPDF 就不会成为今天的软件。我们很乐意讨论所有咨询,无论是扩展现有功能集,还是将 OCRmyPDF 集成到更大的系统中。
[paperless-ngx](https://docs.paperless-ngx.com/) 将 OCRmyPDF 集成到可搜索的文档管理系统中。
## 新闻与媒体
- [Going paperless with OCRmyPDF](https://medium.com/@ikirichenko/going-paperless-with-ocrmypdf-e2f36143f46a)
- [Converting a scanned document into a compressed searchable PDF with redactions](https://medium.com/@treyharris/converting-a-scanned-document-into-a-compressed-searchable-pdf-with-redactions-63f61c34fe4c)
- [c't 1-2014,第 59 页](https://heise.de/-2279695):德国领先 IT 杂志 c't 对 OCRmyPDF v1.0 的详细介绍
- [heise Open Source, 09/2014: Texterkennung mit OCRmyPDF](https://heise.de/-2356670)
- [heise Durchsuchbare PDF-Dokumente mit OCRmyPDF erstellen](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
- [Y Combinator discussion](https://news.ycombinator.com/item?id=32028752)
## 商务咨询
如果没有公司和用户选择支持功能开发与咨询服务,OCRmyPDF 不会成为今天的软件。无论是扩展现有功能集,还是将 OCRmyPDF 集成到更大的系统中,我们都很乐意讨论各类咨询需求。
## 许可证
OCRmyPDF 软件根据 Mozilla 公共许可证 2.0 (MPL-2.0) 授权。许可证允许将 OCRmyPDF 与其他代码集成,包括商业和闭源代码,但要求发布对 OCRmyPDF 所做的源代码级修改。
OCRmyPDF 软件采用 Mozilla Public License 2.0 (MPL-2.0) 授权。许可证允许将 OCRmyPDF 与其他代码集成,包括商业代码和闭源代码,但要求发布对 OCRmyPDF 所做的源代码级修改。
OCRmyPDF 的某些组件其他许可证,标准 SPDX 许可证标识符或 DEP5 版权许可信息文件所示。一般来说,非核心代码根据 MIT 许可,文档和测试文件根据 Creative Commons ShareAlike 4.0 (CC-BY-SA 4.0) 许可。
OCRmyPDF 的某些组件采用其他许可证,具体由标准 SPDX 许可证标识符或 DEP5 版权许可信息文件标明。一般来说,非核心代码采用 MIT 许可,文档和测试文件采用 Creative Commons ShareAlike 4.0 (CC-BY-SA 4.0) 许可
## 免责声明
本软件按"原样"分发,不提供任何明示或暗示的保证或条件。
这份中文版 README.md 保留了原始文档的所有重要信息,包括功能介绍、安装说明、语言支持、使用示例等内容,同时保持了原始格式和结构。
本软件按原样分发,不提供任何明示或暗示的保证或条件。
+2 -8
View File
@@ -167,15 +167,9 @@ SPDX-FileCopyrightText = [
SPDX-License-Identifier = "Zlib"
[[annotations]]
path = "src/ocrmypdf/data/pdf.ttf"
path = "src/ocrmypdf/data/Occulta.ttf"
precedence = "aggregate"
SPDX-FileCopyrightText = [
"(C) 2014 Ray Smith",
"(C) 2015 Ken Sharp",
"(C) 2016 James R. Barlow",
"(C) 2016 Jeff Breidenbach",
"(C) 2017 Zdenko Podobný",
]
SPDX-FileCopyrightText = ["(C) 2026 James R. Barlow"]
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2017-2019 Joe Rickerby and contributors
# SPDX-License-Identifier: BSD-2-Clause
"""Bump the version number in all the right places."""
from __future__ import annotations
import os
import subprocess
import sys
import time
import urllib.parse
from pathlib import Path
import cyclopts
from packaging.version import InvalidVersion, Version
try:
from github import Auth, Github, GithubException
except ImportError:
Auth = None # type: ignore
Github = None # type: ignore
GithubException = Exception # type: ignore
import ocrmypdf
config = [
# file path, version find/replace format
("src/ocrmypdf/_version.py", '__version__ = "{}"'),
("pyproject.toml", 'version = "{}"'),
]
RED = "\u001b[31m"
GREEN = "\u001b[32m"
YELLOW = "\u001b[33m"
OFF = "\u001b[0m"
REPO_NAME = "ocrmypdf/OCRmyPDF"
def validate_release_notes(new_version: str) -> bool:
"""Check that the version appears in the release notes.
Returns True if the version is found, False otherwise.
"""
version_obj = Version(new_version)
major = version_obj.major
release_notes_path = Path(f"docs/releasenotes/version{major:02d}.md")
if not release_notes_path.exists():
print(f"{RED}error:{OFF} Release notes file not found: {release_notes_path}")
return False
content = release_notes_path.read_text(encoding="utf8")
version_header = f"## v{new_version}"
if version_header not in content:
print(
f"{RED}error:{OFF} Version v{new_version} not found in {release_notes_path}"
)
print(f" Expected to find: {version_header}")
return False
print(f"{GREEN}Found v{new_version} in {release_notes_path}{OFF}")
return True
def get_github_client():
"""Get an authenticated GitHub client."""
if Github is None or Auth is None:
print(f"{RED}error:{OFF} PyGithub is not installed")
print(" Install with: pip install PyGithub")
return None
# Try GITHUB_TOKEN env var first
token = os.environ.get("GITHUB_TOKEN")
# Fall back to gh CLI
if not token:
try:
result = subprocess.run(
["gh", "auth", "token"],
capture_output=True,
encoding="utf8",
check=True,
)
token = result.stdout.strip()
except (FileNotFoundError, subprocess.CalledProcessError):
print(f"{RED}error:{OFF} No GitHub authentication found")
print(" Set GITHUB_TOKEN env var or run: gh auth login")
return None
try:
return Github(auth=Auth.Token(token))
except GithubException as e:
print(f"{RED}error:{OFF} Failed to authenticate with GitHub: {e}")
return None
def wait_for_ci_completion(commit_sha: str, timeout_minutes: int = 30) -> bool:
"""Wait for CI to complete on the given commit.
Returns True if CI passed, False otherwise.
"""
gh = get_github_client()
if gh is None:
return False
try:
repo = gh.get_repo(REPO_NAME)
except GithubException as e:
print(f"{RED}error:{OFF} Failed to access repository: {e}")
return False
workflow_name = "Test and deploy"
start_time = time.time()
timeout_seconds = timeout_minutes * 60
poll_interval = 30 # seconds
print(f"Waiting for CI workflow '{workflow_name}' on commit {commit_sha[:8]}...")
# First, wait for the workflow run to appear
run = None
while time.time() - start_time < timeout_seconds:
try:
runs = repo.get_workflow_runs(head_sha=commit_sha)
for r in runs:
if r.name == workflow_name:
run = r
break
if run:
break
except GithubException as e:
print(f"{YELLOW}Warning:{OFF} Error checking workflow runs: {e}")
elapsed = int(time.time() - start_time)
print(f" Waiting for workflow to start... ({elapsed}s)")
time.sleep(poll_interval)
if not run:
print(
f"{RED}error:{OFF} Workflow run not found within {timeout_minutes} minutes"
)
return False
print(f" Found workflow run #{run.run_number} (ID: {run.id})")
# Now wait for the workflow to complete
while time.time() - start_time < timeout_seconds:
try:
run = repo.get_workflow_run(run.id) # Refresh the run
except GithubException as e:
print(f"{YELLOW}Warning:{OFF} Error refreshing workflow run: {e}")
time.sleep(poll_interval)
continue
status = run.status
conclusion = run.conclusion
elapsed = int(time.time() - start_time)
if status == "completed":
if conclusion == "success":
print(f"{GREEN}CI passed!{OFF} (took {elapsed}s)")
return True
else:
print(f"{RED}CI failed!{OFF} Conclusion: {conclusion}")
print(f" View details: {run.html_url}")
return False
else:
print(f" Status: {status} ({elapsed}s elapsed)")
time.sleep(poll_interval)
print(f"{RED}error:{OFF} CI did not complete within {timeout_minutes} minutes")
return False
def push_and_wait_for_ci(branch: str) -> bool:
"""Push to remote and wait for CI tests to pass."""
print("Pushing to GitHub...")
push_result = subprocess.run(
["git", "push", "origin", branch],
capture_output=True,
encoding="utf8",
)
if push_result.returncode != 0:
print(f"{RED}error:{OFF} Failed to push: {push_result.stderr}")
return False
# Get the commit SHA we just pushed
sha_result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
encoding="utf8",
check=True,
)
commit_sha = sha_result.stdout.strip()
print(f"Pushed commit {commit_sha[:8]}")
return wait_for_ci_completion(commit_sha)
def push_tag(tag: str) -> bool:
"""Push the tag to trigger release workflow."""
print(f"Pushing tag {tag} to trigger release...")
result = subprocess.run(
["git", "push", "origin", tag],
capture_output=True,
encoding="utf8",
)
if result.returncode != 0:
print(f"{RED}error:{OFF} Failed to push tag: {result.stderr}")
return False
print(f"{GREEN}Tag {tag} pushed successfully!{OFF}")
return True
def bump_version() -> None:
"""Bump the version number in all the right places."""
current_version = ocrmypdf.__version__ # type: ignore
try:
commit_date_str = subprocess.run(
[
"git",
"show",
"--no-patch",
"--pretty=format:%ci",
f"v{current_version}^{{commit}}",
],
check=True,
capture_output=True,
encoding="utf8",
).stdout
cd_date, cd_time, cd_tz = commit_date_str.split(" ")
url_opts = urllib.parse.urlencode(
{"q": f"is:pr merged:>{cd_date}T{cd_time}{cd_tz}"}
)
url = f"https://github.com/{REPO_NAME}/pulls?{url_opts}"
print(f"PRs merged since last release:\n {url}")
print()
except subprocess.CalledProcessError as e:
print(e)
print("Failed to get previous version tag information.")
print("Is the virtual environment active?")
sys.exit(1)
git_changes_result = subprocess.run(["git diff-index --quiet HEAD --"], shell=True)
repo_has_uncommitted_changes = git_changes_result.returncode != 0
if repo_has_uncommitted_changes:
print("error: Uncommitted changes detected.")
sys.exit(1)
# fmt: off
print( 'Current version:', current_version)
new_version = input(' New version: ').strip()
# fmt: on
try:
Version(new_version)
except InvalidVersion:
print("error: This version doesn't conform to PEP440")
print(" https://www.python.org/dev/peps/pep-0440/")
sys.exit(1)
# Validate release notes contain this version
if not validate_release_notes(new_version):
print()
print("Please add release notes for this version before proceeding.")
print(f"Edit: docs/releasenotes/version{Version(new_version).major:02d}.md")
sys.exit(1)
actions = []
for path_pattern, version_pattern in config:
paths = list(Path().glob(path_pattern))
if not paths:
print(f"error: Pattern {path_pattern} didn't match any files")
sys.exit(1)
find_pattern = version_pattern.format(current_version)
replace_pattern = version_pattern.format(new_version)
found_at_least_one_file_needing_update = False
for path in paths:
contents = path.read_text(encoding="utf8")
if find_pattern in contents:
found_at_least_one_file_needing_update = True
actions.append(
(
path,
find_pattern,
replace_pattern,
)
)
if not found_at_least_one_file_needing_update:
print(
f'''error: Didn't find any occurrences of "{find_pattern}" '''
f'''in "{path_pattern}"'''
)
sys.exit(1)
print()
print("Here's the plan:")
print()
for action in actions:
path, find, replace = action
print(f"{path} {RED}{find}{OFF}{GREEN}{replace}{OFF}")
print(f"Then commit, and tag as v{new_version}")
answer = input("Proceed? [y/N] ").strip()
if answer != "y":
print("Aborted")
sys.exit(1)
for path, find, replace in actions:
contents = path.read_text(encoding="utf8")
contents = contents.replace(find, replace)
path.write_text(contents, encoding="utf8")
# Format only after every file (including pyproject.toml) reflects the new
# version. Running `uv run` while pyproject.toml still had the old version
# would leave its post-bump environment/lockfile resync to happen for the
# first time during the commit's pre-commit hooks instead of here, which
# then aborts the commit with a spurious "files were modified by this
# hook" error.
for path, _find, _replace in actions:
if path.suffix == ".py":
subprocess.run(["uv", "run", "ruff", "format", str(path)], check=True)
print("Files updated.")
print()
while input('Type "done" to continue: ').strip().lower() != "done":
pass
subprocess.run(
[
"git",
"commit",
"--all",
f"--message=Bump version: v{new_version}",
],
check=True,
)
subprocess.run(
[
"git",
"tag",
"--annotate",
f"--message=v{new_version}",
f"v{new_version}",
],
check=True,
)
print("Commit and tag created locally.")
print()
# Get current branch
branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
encoding="utf8",
check=True,
)
branch = branch_result.stdout.strip()
# Push commit and wait for CI
if not push_and_wait_for_ci(branch):
print()
print(f"{RED}CI failed. The tag was NOT pushed.{OFF}")
print("Fix the issues, then manually push the tag:")
print(f" git push origin v{new_version}")
sys.exit(1)
# Push tag to trigger release
if not push_tag(f"v{new_version}"):
print(f"{RED}Failed to push tag.{OFF} Push manually:")
print(f" git push origin v{new_version}")
sys.exit(1)
print()
print(f"{GREEN}Done! Release workflow has been triggered.{OFF}")
print()
release_url = f"https://github.com/{REPO_NAME}/releases/tag/v{new_version}"
print("Monitor the release at:")
print(f" {release_url}")
if __name__ == "__main__":
os.chdir(Path(__file__).parent.parent.resolve())
cyclopts.run(bump_version)
+304 -23
View File
@@ -58,6 +58,38 @@ disk space.
OCRmyPDF provides many features to control the behavior of the OCR
engine, Tesseract.
### OCR processing mode
:::{versionadded} 17.0.0
The `--mode` (`-m`) argument consolidates OCR processing options.
:::
OCRmyPDF provides a unified `--mode` argument to control how pages with
existing text are handled:
| Mode | Behavior | Legacy equivalent |
|------|----------|-------------------|
| `default` | Error if text is found | (no flag) |
| `force` | Rasterize all content and run OCR | `--force-ocr` |
| `skip` | Skip pages with existing text | `--skip-text` |
| `redo` | Re-OCR pages, stripping old OCR layer | `--redo-ocr` |
```bash
# Skip pages that already have text
ocrmypdf --mode skip input.pdf output.pdf
# or equivalently:
ocrmypdf -m skip input.pdf output.pdf
# Force OCR on all pages (rasterizes everything)
ocrmypdf --mode force input.pdf output.pdf
# Re-do OCR, replacing old invisible text
ocrmypdf --mode redo input.pdf output.pdf
```
The legacy flags (`--force-ocr`, `--skip-text`, `--redo-ocr`) remain as
silent aliases for backward compatibility.
### When OCR is skipped
If a page in a PDF seems to have text, by default OCRmyPDF will exit
@@ -65,13 +97,13 @@ without modifying the PDF. This is to ensure that PDFs that were
previously OCRed or were "born digital" rather than scanned are not
processed.
If `--skip-text` is issued, then no image processing or OCR will be
If `--mode skip` (or `--skip-text`) is issued, then no image processing or OCR will be
performed on pages that already have text. The page will be copied to
the output. This may be useful for documents that contain both "born
digital" and scanned content, or to use OCRmyPDF to normalize and
convert to PDF/A regardless of their contents.
If `--redo-ocr` is issued, then a detailed text analysis is performed.
If `--mode redo` (or `--redo-ocr`) is issued, then a detailed text analysis is performed.
Text is categorized as either visible or invisible. Invisible text (OCR)
is stripped out. Then an image of each page is created with visible text
masked out. The page image is sent for OCR, and any additional text is
@@ -82,13 +114,37 @@ technically printable or visible in some way, perhaps by drawing it and
then painting over it. OCRmyPDF cannot distinguish this type of OCR
text from real text, so it will not be "redone".
If `--force-ocr` is issued, then all pages will be rasterized to
If `--mode force` (or `--force-ocr`) is issued, then all pages will be rasterized to
images, discarding any hidden OCR text, rasterizing any printable
text, and flattening form fields or interactive objects into their visual
representation. This is useful for redoing OCR, for fixing OCR text
with a damaged character map (text is selectable but not searchable),
and destroying redacted information.
### Tagged PDFs and structural markup
Some PDFs carry a logical structure tree (`/StructTreeRoot`), the markup that
makes a "Tagged PDF" — typically the result of layout analysis or a born-digital
export. By default OCRmyPDF treats this as a signal that the document may not need
OCR and exits, in the same way it stops on PDFs that already contain text. Use
`--tagged-pdf-mode ignore`, or one of `--mode skip`/`redo`/`force`, to process
such a file anyway.
OCRmyPDF cannot rebuild a structure tree to match newly recognized text. When
`--force-ocr` rasterizes pages, or `--redo-ocr` strips and rewrites the text layer,
the structure tree no longer corresponds to the page content, so it is discarded.
`--mode skip` leaves text pages untouched, so their structural markup is preserved.
:::{note}
Preservation under `--mode skip` only holds when the output is not converted to
PDF/A. PDF/A conversion is performed by Ghostscript, and Ghostscript 10.x discards
the structure tree during conversion (Ghostscript 9.x preserved it). Because the
default `--output-type auto` may fall back to Ghostscript, use
`--output-type pdf` if you need to guarantee that a Tagged PDF's structural markup
survives. For best results, install veraPDF so that speculative PDF/A
conversion can sidestep this issue entirely in most real cases.
:::
### Time and image size limits
By default, OCRmyPDF permits tesseract to run for three minutes (180
@@ -155,6 +211,13 @@ include:
Overrides the path to Tesseract's data files. This can allow
simultaneous installation of the "best" and "fast" training data
sets. OCRmyPDF does not manage this environment variable.
If you point ``TESSDATA_PREFIX`` at a hand-assembled ``tessdata``
folder (for example, individual ``.traineddata`` files downloaded
from tessdata_best), make sure it also contains the ``configs/``
subdirectory with the ``hocr`` and ``txt`` files. OCRmyPDF requires
these; without them Tesseract produces no output. See
:ref:`Tesseract cannot open its config file <tesseract-config-missing>`.
```
```{eval-rst}
@@ -257,44 +320,85 @@ Their use may interfere with `--rotate-pages` and other features.
It is currently not possible to use advanced Tesseract OCR features, such as creating
OCR information, when using Tesseract through OCRmyPDF.
## Changing the PDF renderer
## Choosing a PDF rasterizer
:::{versionadded} 17.0.0
:::
rasterizing
: Converting a PDF to an image for display.
: Converting a PDF page to an image for OCR processing.
OCRmyPDF supports two PDF rasterizers:
| Rasterizer | Package | Advantages | Disadvantages |
|------------|---------|------------|---------------|
| pypdfium2 | Python package | Faster, fewer version issues | Requires pypdfium2 package |
| Ghostscript | System binary | More widely packaged | Version consistency issues, restrictive AGPLv3 |
The `--rasterizer` argument controls which rasterizer is used:
```bash
# Automatic selection (default) - prefers pypdfium when available
ocrmypdf --rasterizer auto input.pdf output.pdf
# Force pypdfium2
ocrmypdf --rasterizer pypdfium input.pdf output.pdf
# Force Ghostscript
ocrmypdf --rasterizer ghostscript input.pdf output.pdf
```
pypdfium2 is a Python binding for pdfium, the PDF rendering library used
by Google Chrome and Chromium. It generally produces output identical to
Ghostscript but with better performance.
:::{note}
If pypdfium2 is not installed and `--rasterizer pypdfium` is requested,
OCRmyPDF will exit with an error. Install it with: `pip install pypdfium2`
:::
## Changing the PDF renderer
rendering
: Creating a new PDF from other data (such as an existing PDF).
OCRmyPDF has these PDF renderers: `sandwich` and `hocr`. The
:::{versionchanged} 17.0.0
The fpdf2 renderer is now the default, replacing the legacy hOCR renderer.
:::
OCRmyPDF uses PDF renderers to create the invisible text layer. The
renderer may be selected using `--pdf-renderer`. The default is
`auto` which lets OCRmyPDF select the renderer to use. Currently,
`auto` always selects `hocr`.
`auto` which selects `fpdf2`.
### The `hocr` renderer
### The `fpdf2` renderer (default)
:::{versionchanged} 16.0.0
:::{versionadded} 17.0.0
:::
The fpdf2 renderer creates text layers using the fpdf2 library. It provides:
- Full multilingual support including RTL languages (Arabic, Hebrew, Persian)
- Accurate text positioning aligned with OCR bounding boxes
- Improved "Occulta" glyphless font handling:
- Zero-width markers are properly handled
- Double-width CJK characters are properly sized
- Direct OcrElement tree input (no hOCR intermediate format required)
The fpdf2 renderer is the recommended choice for all installations.
:::{note}
The fpdf2 renderer may be slightly slower than the legacy hocrtransform
renderer for some workloads. This is an area of ongoing optimization.
:::
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
original PDF page (when `--mode force` 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.)
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.
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,
@@ -310,6 +414,11 @@ 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.
### Legacy renderer options
The `hocr` and `hocrdebug` renderer options are deprecated and
automatically redirect to `fpdf2`. They will be removed in a future version.
## Rendering and rasterizing options
:::{versionadded} 14.3.0
@@ -341,6 +450,178 @@ curves. In this case, you may want to use a different color conversion
strategy. The `--color-conversion-strategy` option allows you to select a
different strategy, such as `RGB`.
## Advanced Ghostscript tuning
:::{versionadded} 17.5.0
:::
OCRmyPDF intentionally hides most Ghostscript controls because Ghostscript
is a legacy code path. The preferred PDF/A pipeline in v17+ uses pypdfium2
as the rasterizer and verapdf to validate speculative PDF/A output, with
Ghostscript reserved as a fallback for PDFs that cannot be made compliant
without it. OCRmyPDF's separate optimizer (controlled by `--optimize`,
`--jpeg-quality`, `--png-quality`, etc.) is the supported way to shrink
output PDFs: it gives consistent results across input files, and isolates
Ghostscript so it can focus on producing a PDF/A with as few image
transformations as possible.
The two options below are exposed for advanced users who want to tune
Ghostscript's intermediate PDF/A output directly. Most users will get
more predictable results from the optimizer.
### `--ghostscript-jpeg-quality Q`
Sets Ghostscript's `-dJPEGQ` switch for images that Ghostscript chooses
to recompress to JPEG while building a PDF/A. `Q=0` requests maximum
compression and `Q=100` requests best quality; if the flag is omitted,
OCRmyPDF passes `95` (the historical default). This only affects images
Ghostscript transcodes — existing JPEGs pass through unchanged on modern
Ghostscript releases. For end-to-end JPEG quality tuning, prefer
`--jpeg-quality`, which is implemented by the OCRmyPDF optimizer and is
applied independently of whatever Ghostscript decides to do.
Note: setting both `--ghostscript-jpeg-quality` and `--jpeg-quality` can
result in double JPEG recompression, since the optimizer may re-encode
images that Ghostscript already recompressed. This can degrade quality
in subtle ways.
### `--ghostscript-jpeg-maxdpi DPI`
Enables Ghostscript's image downsampling and caps color, grayscale, and
monochrome image resolution to `DPI`. The downsample threshold is set to
`1.0`, so any image whose effective DPI exceeds the cap will be
downsampled.
Reducing JPEG quality is almost always a better trade than downsampling
at the same compression budget: a 400 DPI JPEG at modest quality usually
looks much better than a 200 DPI JPEG, because the JPEG codec can spend
bits where they count. Downsampling is also dangerous for PDFs that
combine a low-resolution color image with a high-resolution monochrome
mask — capping the mask resolution can produce visible quality loss.
For these reasons, prefer `--jpeg-quality` over `--ghostscript-jpeg-maxdpi`
unless you specifically want to force a hard DPI cap.
Example:
```bash
ocrmypdf --output-type pdfa \
--ghostscript-jpeg-quality 80 \
--ghostscript-jpeg-maxdpi 150 \
in.pdf out.pdf
```
These options only take effect when Ghostscript is invoked for PDF/A
conversion (`--output-type pdfa`, `pdfa-1`, `pdfa-2`, or `pdfa-3`, or
when `--output-type auto` falls back to Ghostscript).
## PDF/A output modes
:::{versionchanged} 17.0.0
The default `--output-type` is now `auto` instead of `pdfa`.
:::
OCRmyPDF can produce PDF/A compliant output for long-term archival. The
`--output-type` argument controls PDF/A conversion:
| Output type | Behavior |
|-------------|----------|
| `auto` | Best-effort PDF/A without requiring Ghostscript (default) |
| `pdfa` | PDF/A-2b via Ghostscript |
| `pdfa-1` | PDF/A-1b via Ghostscript |
| `pdfa-2` | PDF/A-2b via Ghostscript (same as `pdfa`) |
| `pdfa-3` | PDF/A-3b via Ghostscript |
| `pdf` | Standard PDF, no PDF/A conversion |
| `none` | No output file (useful with `--sidecar`) |
### Non-embedded fonts and PDF/A
:::{versionadded} 17.8.0
OCRmyPDF now refuses to corrupt non-embedded CID text layers during PDF/A
conversion.
:::
PDF/A requires every font to be embedded. If your input already has a text
layer that uses *non-embedded* CID fonts — most commonly a CJK
(Chinese-Japanese-Korean) OCR layer
produced by Adobe Acrobat, which relies on the reader's system fonts —
Ghostscript would have to substitute and re-embed a replacement font to make
the file PDF/A. For CID-keyed (CJK) fonts this routinely corrupts the
character-to-Unicode mapping, so the text silently becomes garbage or stops
being searchable even though the page still *looks* correct.
Rather than emit corrupted output, OCRmyPDF detects this situation and:
- with `--output-type auto` (the default), produces a regular PDF instead of
PDF/A, preserving the existing text layer exactly;
- with an explicit `--output-type pdfa` (or `pdfa-1`/`pdfa-2`/`pdfa-3`), stops
with an error.
This is a Ghostscript limitation that OCRmyPDF cannot repair, because a
non-embedded font cannot be made PDF/A-compliant without re-embedding it. To
keep the existing text layer, use `--output-type pdf`. To produce PDF/A anyway,
re-run OCR with `--force-ocr`, which discards the original text layer and
rebuilds it with embedded fonts. Text layers whose fonts are *already embedded*
are converted to PDF/A normally.
### Speculative PDF/A conversion
:::{versionadded} 17.0.0
:::
When `--output-type auto` is used (the default), OCRmyPDF attempts a
fast "speculative" PDF/A conversion that avoids Ghostscript when possible:
1. OCRmyPDF adds an sRGB ICC profile and PDF/A XMP metadata using pikepdf
2. If verapdf is available, it validates the result
3. If validation passes, Ghostscript is skipped entirely
4. If validation fails or verapdf is unavailable, falls back to Ghostscript
This fast path avoids some Ghostscript limitations (such as image
transcoding) and is used whenever it can produce valid PDF/A. When it
cannot — for example when veraPDF is not installed, or the input needs real
conversion — `auto` falls back to Ghostscript so that it still produces
PDF/A by default, matching OCRmyPDF 16 and earlier. If even Ghostscript
cannot safely produce PDF/A, `auto` outputs a regular PDF instead of failing.
### PDF/A conversion flow
The following diagram illustrates the PDF/A conversion decision tree:
```{mermaid}
flowchart TD
A[Start] --> B{--output-type?}
B -->|pdf| C[Output standard PDF]
B -->|pdfa/pdfa-N| D[Use Ghostscript]
B -->|auto| E[Attempt speculative conversion]
E --> F["Add sRGB ICC + XMP metadata (pikepdf)"]
F --> G{verapdf available?}
G -->|No| H{Ghostscript available?}
G -->|Yes| I[Validate with verapdf]
I --> J{Validation passed?}
J -->|Yes| K[Output PDF/A - Ghostscript skipped]
J -->|No| H
H -->|Yes| D
H -->|No| L[Output standard PDF + WARNING]
D --> M[Ghostscript PDF/A conversion]
M --> N[Output PDF/A]
style K fill:#90EE90
style N fill:#90EE90
style L fill:#FFB6C1
```
:::{warning}
**Breaking change:** If neither Ghostscript nor verapdf is installed,
`--output-type auto` will produce a standard PDF instead of PDF/A.
This is a change from previous versions where Ghostscript was required
and PDF/A was always produced.
:::
## Return code policy
OCRmyPDF writes all messages to `stderr`. `stdout` is reserved for
+64 -4
View File
@@ -13,8 +13,49 @@ subprocess call anyway, as this provides isolation of its activities.
## Example
OCRmyPDF provides one high-level function to run its main engine from an
application. The parameters are symmetric to the command line arguments
and largely have the same functions.
application.
```{versionchanged} 17.0
The {func}`ocrmypdf.ocr` function now accepts an {class}`~ocrmypdf.OcrOptions`
object as its first argument, providing a cleaner API with full type hints
and validation. The previous positional argument style remains supported.
```
### Modern API (recommended)
The recommended way to call {func}`ocrmypdf.ocr` is to construct an
{class}`~ocrmypdf.OcrOptions` object with all settings, then pass it
as the sole argument:
```python
import ocrmypdf
from ocrmypdf import OcrOptions
if __name__ == '__main__': # To ensure correct behavior on Windows and macOS
options = OcrOptions(
input_file='input.pdf',
output_file='output.pdf',
deskew=True,
languages=['eng'],
)
ocrmypdf.ocr(options)
```
{class}`~ocrmypdf.OcrOptions` is a Pydantic model that provides:
- Full type hints and IDE autocompletion
- Validation of option values at construction time
- Clear documentation of all available options
```{versionadded} 17.0
The {class}`~ocrmypdf.OcrOptions` class is now exported from the top-level
`ocrmypdf` module.
```
### Legacy API
For compatibility with OCRmyPDF < v17, the traditional calling style
with positional arguments is still fully supported:
```python
import ocrmypdf
@@ -23,7 +64,7 @@ if __name__ == '__main__': # To ensure correct behavior on Windows and macOS
ocrmypdf.ocr('input.pdf', 'output.pdf', deskew=True)
```
With some exceptions, all of the command line arguments are available
With this style, all of the command line arguments are available
and may be passed as equivalent keywords.
A few differences are that `verbose` and `quiet` are not available.
@@ -51,9 +92,14 @@ OCRmyPDF fails for any reason. For example:
```python
from multiprocessing import Process
import ocrmypdf
from ocrmypdf import OcrOptions
def ocrmypdf_process():
ocrmypdf.ocr('input.pdf', 'output.pdf')
options = OcrOptions(input_file='input.pdf', output_file='output.pdf')
ocrmypdf.ocr(options)
def call_ocrmypdf_from_my_app():
p = Process(target=ocrmypdf_process)
@@ -117,3 +163,17 @@ 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.
### Plugin Development Changes
```{versionchanged} 16.13
Plugin hooks now receive {class}`~ocrmypdf.OcrOptions` objects instead of
`argparse.Namespace`.
```
- {class}`~ocrmypdf.OcrOptions` provides the same attribute access as `Namespace` (duck-typing compatible)
- Plugin developers should update type hints: `from ocrmypdf import OcrOptions`
- Built-in plugins no longer modify options in-place for better immutability
Most existing plugins will continue working without modification due to the
duck-typing compatibility between {class}`~ocrmypdf.OcrOptions` and `Namespace`.
+7 -3
View File
@@ -13,6 +13,13 @@ should be mainly of interest to plugin developers.
:members:
```
## ocrmypdf._options
```{eval-rst}
.. automodule:: ocrmypdf._options
:members: OcrOptions
```
## ocrmypdf.exceptions
```{eval-rst}
@@ -26,9 +33,6 @@ should be mainly of interest to plugin developers.
```{eval-rst}
.. automodule:: ocrmypdf.helpers
:members:
:noindex: deprecated
.. autodecorator:: deprecated
```
## ocrmypdf.hocrtransform
+13 -1
View File
@@ -117,6 +117,10 @@ tend to give better performance. watcher.py works on all platforms.
Users may need to customize the script to meet their requirements.
:::{code} bash
# Using uv (recommended)
uv sync --extra watcher
# Or using pip
pip3 install ocrmypdf[watcher]
env OCR_INPUT_DIRECTORY=/mnt/input-pdfs \
@@ -170,7 +174,15 @@ docker run \
--env PYTHONUNBUFFERED=1 \
--interactive --tty --entrypoint python3 \
jbarlow83/ocrmypdf \
watcher.py
/app/watcher.py
:::
:::{note}
The image runs as the non-root `app` user (uid 1000) by default, so it
may not be able to write to the `/output` and `/processed` volumes unless
you add a `--user` argument. The correct value depends on whether you use
rootful Docker, rootless Docker, or Podman -- see
{ref}`Bind-mounted volumes <docker-volumes>` for details.
:::
This service will watch for a file that matches `/input/\*.pdf`, convert
+22 -2
View File
@@ -25,10 +25,11 @@
# sys.path.insert(0, os.path.abspath('.'))
# -- General configuration ------------------------------------------------
from __future__ import annotations
needs_sphinx = '8'
import datetime
import datetime as dt
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@@ -41,6 +42,8 @@ extensions = [
'sphinx.ext.napoleon',
'sphinx.ext.imgconverter', # PDF docs needs this for SVG to PNG conversion
'sphinx_issues',
'sphinx_reredirects',
'sphinxcontrib.mermaid',
]
myst_enable_extensions = ['colon_fence', 'attrs_block', 'attrs_inline', 'substitution']
@@ -49,6 +52,9 @@ myst_enable_extensions = ['colon_fence', 'attrs_block', 'attrs_inline', 'substit
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
napoleon_use_rtype = False
issues_github_path = "ocrmypdf/OCRmyPDF"
redirects = {
"release_notes": "releasenotes/index.html",
}
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -62,7 +68,7 @@ master_doc = 'index'
# General information about the project.
project = 'ocrmypdf'
year = str(datetime.date.today().year)
year = str(dt.date.today().year)
copyright = (
f'{year}, James R. Barlow. '
+ 'Licensed under Creative Commons Attribution-ShareAlike 4.0'
@@ -172,6 +178,20 @@ html_theme = 'sphinx_rtd_theme'
#
html_theme_options = {}
# ReadTheDocs used to inject the "Edit on GitHub" context automatically, but
# dropped it when it switched to Addons, so set it explicitly here. This makes
# sphinx_rtd_theme add an "Edit on GitHub" link to each page that points at the
# corresponding source file in the repository, replacing the static
# "View page source" (_sources/*.txt) link. See
# https://github.com/ocrmypdf/OCRmyPDF/issues/1490
html_context = {
'display_github': True,
'github_user': 'ocrmypdf',
'github_repo': 'OCRmyPDF',
'github_version': 'main',
'conf_py_path': '/docs/',
}
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
+119 -17
View File
@@ -31,6 +31,16 @@ ocrmypdf --output-type pdf input.pdf output.pdf
ocrmypdf --output-type pdfa --pdfa-image-compression jpeg input.pdf output.pdf
```
### Reduce JPEG quality with the optimizer
This is the recommended way to shrink JPEG content in the output. The
optimizer applies regardless of `--output-type`, so it works on both
plain PDFs and Ghostscript-produced PDF/A files.
```bash
ocrmypdf --optimize 2 --jpeg-quality 60 input.pdf output.pdf
```
### Modify a file in place
The file will only be overwritten if OCRmyPDF is successful.
@@ -215,15 +225,22 @@ ocrmypdf --deskew --clean --rotate-pages input.pdf output.pdf
Don\'t actually OCR my PDF
--------------------------
If you set `--tesseract-timeout 0` OCRmyPDF will apply its image
processing without performing OCR (by causing OCR to time out). This
works if all you want to is to apply image processing or PDF/A
If you set `--ocr-engine none` OCRmyPDF will apply its image processing without
performing OCR. This works if all you want to is to apply image processing or PDF/A
conversion.
```bash
ocrmypdf --tesseract-timeout 0 --remove-background input.pdf output.pdf
ocrmypdf --ocr-engine none --deskew --output-type pdfa input.pdf output.pdf
```
:::{versionchanged} v17.0.0
Prior to this version, `--tesseract-timeout 0` was recommended as an idiom
to turn off OCR. This is not longer recommended, as we move away from
Tesseract OCR as the primary OCR engine.
:::
:::{versionchanged} v14.1.0
Prior to this version, `--tesseract-timeout 0` would prevent other uses
@@ -232,27 +249,102 @@ case. Use `--tesseract-non-ocr-timeout` to control the timeout for
non-OCR operations, if needed.
:::
### Remove all text or OCR from my PDF
### Remove the OCR text layer from my PDF
This is getting ridiculous, but OCRmyPDF can complete strip all textual
information from a PDF and reconstruct it as a \"bag of images\" PDF.
To remove the invisible OCR text layer while keeping the original pages
exactly as they are -- no rasterizing, no change to images or visible
content, and a smaller output file -- use `--mode strip`:
```bash
ocrmypdf --tesseract-timeout 0 --force-ocr input.pdf output.pdf
ocrmypdf --mode strip input.pdf output.pdf
```
Why would you want to do this? Perhaps you have a PDF where OCR fails to
produce useful results, and just want to get rid of all OCR information.
This command also removes OCR generated by third party tools.
Why would you want to do this? Perhaps you have a PDF where OCR failed to
produce useful results and you simply want to get rid of it.
`--mode strip` removes only text drawn as *invisible* (PDF text render
mode 3), which is how OCRmyPDF and most OCR tools add a searchable layer
over a scanned page. Some OCR products -- and OCRmyPDF v2.2 and earlier --
instead draw *visible* text and paint an opaque image on top of it. That
text is part of the visible page, so `--mode strip` cannot remove it
without altering the page's appearance.
To strip *all* text, including such visible text, rasterize the whole page
into a \"bag of images\" PDF instead (this rebuilds every page as an image,
so the file usually grows and vector content is lost):
```bash
ocrmypdf --ocr-engine none --force-ocr input.pdf output.pdf
```
### Optimize images without performing OCR
You can also optimize all images without performing any OCR:
```bash
ocrmypdf --tesseract-timeout 0 --optimize 3 --skip-text input.pdf output.pdf
ocrmypdf --ocr-engine none --optimize 3 --skip-text input.pdf output.pdf
```
## Using v17 features
### Select a rasterizer
:::{versionadded} 17.0.0
:::
OCRmyPDF can use pypdfium2 or Ghostscript to rasterize PDF pages. pypdfium2
is generally faster and is preferred when available.
```bash
# Automatic selection (default) - prefers pypdfium when available
ocrmypdf --rasterizer auto input.pdf output.pdf
# Explicitly use pypdfium2 (requires pip install pypdfium2)
ocrmypdf --rasterizer pypdfium input.pdf output.pdf
# Explicitly use Ghostscript
ocrmypdf --rasterizer ghostscript input.pdf output.pdf
```
### PDF/A without Ghostscript
:::{versionadded} 17.0.0
:::
With verapdf installed, OCRmyPDF can produce PDF/A without using Ghostscript
for conversion. This is faster and avoids some Ghostscript limitations.
```bash
# Uses speculative conversion with verapdf validation (default)
ocrmypdf --output-type auto input.pdf output.pdf
# Explicitly request Ghostscript-based PDF/A conversion
ocrmypdf --output-type pdfa input.pdf output.pdf
```
### Using --mode instead of legacy flags
:::{versionadded} 17.0.0
:::
The `--mode` (`-m`) flag consolidates OCR behavior options:
```bash
# Instead of --skip-text
ocrmypdf --mode skip input.pdf output.pdf
# Instead of --force-ocr
ocrmypdf --mode force input.pdf output.pdf
# Instead of --redo-ocr
ocrmypdf --mode redo input.pdf output.pdf
# Short form
ocrmypdf -m skip input.pdf output.pdf
```
The legacy flags continue to work as aliases.
### Process only certain pages
You can ask OCRmyPDF to only apply [image processing](#image-processing)
@@ -266,12 +358,22 @@ Hyphens denote a range of pages and commas separate page numbers. If you
prefer to use spaces, quote all of the page numbers:
`--pages '2, 3, 5, 7'`.
The token `end` (case-insensitive) is an alias for the last page in the
document. For example, `--pages 3-end` OCRs from page 3 through the
final page, and `--pages end` OCRs only the last page:
```bash
ocrmypdf --pages 3-end input.pdf output.pdf
ocrmypdf --pages end input.pdf output.pdf
```
OCRmyPDF will warn if your list of page numbers contains duplicates or
overlapping pages. OCRmyPDF does not currently account for document page
numbers, such as an introduction section of a book that uses Roman
numerals. It simply counts the number of virtual pieces of paper since
the start. If your list of pages is out of numerical order, OCRmyPDF
will sort it for you.
overlapping pages. (Repeated page numbers are de-duplicated automatically,
since the underlying set of pages is what matters.) OCRmyPDF does not
currently account for document page numbers, such as an introduction
section of a book that uses Roman numerals. It simply counts the number
of virtual pieces of paper since the start. If your list of pages is out
of numerical order, OCRmyPDF will sort it for you.
Regardless of the argument to `--pages`, OCRmyPDF will optimize all
pages/images in the file and convert it to PDF/A, unless you disable
+90 -23
View File
@@ -71,15 +71,29 @@ application (as opposed to the more conventional case, where a Docker
container runs as a server). For that reason we usually use the `--rm`
argument to delete the container when it exits.
:::{note}
The image runs as a non-root user (`app`, uid/gid 1000) by default,
rather than as root. This is a defense-in-depth measure: a flaw in
OCRmyPDF or one of its dependencies cannot trivially act as root inside
the container. The examples below assume **rootless Docker** or
**Podman**; the differences for traditional *rootful* Docker are
described separately under *Special case: rootful Docker* below.
:::
To start a Docker container (instance of the image):
:::{code} bash
docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - -
:::
For convenience, create a shell alias to hide the Docker command. It is
easier to send the input file as stdin and read the output from stdout
-- **this avoids the messy permission issues with Docker entirely**.
### Recommended: pipe through stdin and stdout
The easiest and most portable way to use the image is to send the input
file on stdin and read the output from stdout. This **avoids file
permission issues entirely** -- nothing is written to a mounted
directory, so it does not matter which user the container runs as, nor
whether you use rootless or rootful Docker. For convenience, create a
shell alias to hide the Docker command:
:::{code} bash
alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine'
@@ -90,37 +104,74 @@ docker_ocrmypdf - - <input.pdf >output.pdf
Or in the wonderful [fish shell](https://fishshell.com/):
:::{code} fish
alias docker_ocrmypdf 'docker run --rm jbarlow83/ocrmypdf-alpine'
alias docker_ocrmypdf 'docker run --rm -i jbarlow83/ocrmypdf-alpine'
funcsave docker_ocrmypdf
:::
Alternately, you could mount the local current working directory as a
Docker volume:
{#docker-volumes}
### Bind-mounted volumes
If you would rather mount a directory and pass file paths, you need to
consider which user owns the files OCRmyPDF writes back into that
directory. The image's default working directory is `/data`, so mounting
your files there lets you pass plain relative paths without an explicit
`--workdir`. Because the container runs as the non-root `app` user, the
right invocation otherwise depends on your container runtime.
**Rootless Docker (the assumed default).** Your own account runs the
daemon, so the container's `root` maps back to *your* unprivileged host
user, while every other container uid -- including the image's default
`app`/1000 -- maps to a *subordinate* uid. A directory you own on the
host therefore appears owned by `root` inside the container, so the
default `app` user usually **cannot write to it at all**. Run the job as
container-`root`, which under rootless Docker is still your ordinary host
user, so the write succeeds and the output is owned by you:
:::{code} bash
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
docker_ocrmypdf /data/input.pdf /data/output.pdf
alias docker_ocrmypdf='docker run --rm -i --user 0:0 -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
docker_ocrmypdf input.pdf output.pdf
:::
## Podman
Especially if you use [Podman](https://podman.io/) (or have SELinux
enabled on your system), you may need to add `--userns keep-id` there,
otherwise you may get access errors, because the user is otherwise not
mapped to the same UID as on the host:
**Podman.** Podman provides `--userns keep-id`, which maps your host uid
straight through into the container. Combined with `--user`, you run as
your own uid and own the output directly, otherwise you may get access
errors because the user ID is not mapped to the same UID as on the host:
:::{code} bash
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" ocrmypdf'
podman_ocrmypdf /data/input.pdf /data/output.pdf
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
podman_ocrmypdf input.pdf output.pdf
:::
If you use SELinux you may additionally need to add the `:Z` [suffix to
If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to
the
volume](https://docs.podman.io/en/stable/markdown/podman-run.1.html#volume-v-source-volume-host-dir-container-dir-options)
or disable SELinux for the container using
`--security-opt label=disable`, which is suggested for system files as
they should not be re-labelled. Please refer to the „Note" section at
the end of the linked podman documentation for details.
the end of the linked podman documentation for details. This results in
the following full command:
:::{code} bash
alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine'
podman_ocrmypdf input.pdf output.pdf
:::
{#docker-rootful}
### Special case: rootful Docker
With a traditional root daemon, container uid *N* is the *same* uid *N*
on the host. Running the container as root would therefore fill your
mounted directory with root-owned files and -- more importantly -- a
container escape would run as real host root. Drop to your own uid so the
output is owned by you and the process stays unprivileged:
:::{code} bash
alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" -v "$PWD:/data" jbarlow83/ocrmypdf-alpine'
docker_ocrmypdf input.pdf output.pdf
:::
The non-root default and the `--user` override both reduce the risk here,
but rootless Docker or Podman remain the safer choice when available.
{#docker-lang-packs}
## Adding languages to the Docker image
@@ -133,8 +184,12 @@ creating a new Dockerfile based on the public one.
:::{code} dockerfile
FROM jbarlow83/ocrmypdf
# The image runs as the non-root "app" user, so switch back to root for
# build steps that install packages, then drop back to "app".
USER root
# Example: add Italian
RUN apt install tesseract-ocr-ita
RUN apt-get update && apt-get install -y tesseract-ocr-ita
USER app
:::
To install language packs (training data) such as the
@@ -173,7 +228,11 @@ Extending the Docker image
--------------------------
You can extend the Docker image with your own customizations, similar to
the way it is extended to add language packs.
the way it is extended to add language packs. Because the image runs as
the non-root `app` user, switch to `USER root` for any build steps that
require root (installing packages, writing to system directories) and
back to `USER app` afterwards, as shown in the language pack example
above.
Note that the Docker image is subject to change at any time. For
example, the base image may be updated to a newer version of Ubuntu or
@@ -190,7 +249,7 @@ Executing the test suite
The OCRmyPDF test suite is installed with image. To run it:
:::{code} bash
docker run --rm --entrypoint python jbarlow83/ocrmypdf -m pytest
docker run --rm --workdir /app --entrypoint python jbarlow83/ocrmypdf -m pytest
:::
Accessing the shell
@@ -199,7 +258,15 @@ Accessing the shell
To use the shell in the Docker image:
:::{code} bash
docker run -it --entrypoint sh jbarlow83/ocrmypdf
docker run -it --entrypoint sh jbarlow83/ocrmypdf-alpine
:::
This shell runs as the non-root `app` user. If you need root inside the
container -- for example to install extra packages with `apk` or `apt` --
add `--user root`:
:::{code} bash
docker run -it --user root --entrypoint sh jbarlow83/ocrmypdf-alpine
:::
Using the OCRmyPDF web service wrapper
@@ -209,7 +276,7 @@ The OCRmyPDF Docker image includes an example, barebones HTTP web
service. The webservice may be launched as follows:
:::{code} bash
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf webservice.py
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf /app/webservice.py
:::
We omit the `--rm` parameter so that the container will not be
+28
View File
@@ -49,3 +49,31 @@ pdftk input.pdf cat output output.pdf
Sometimes Acrobat can repair PDFs with its [Preflight
tool](https://helpx.adobe.com/acrobat/using/correcting-problem-areas-preflight-tool.html).
(tesseract-config-missing)=
## Tesseract cannot open its config file \'hocr\' or \'txt\'
:::{code}
ERROR - Tesseract cannot open its config file 'hocr'.
:::
OCRmyPDF asks Tesseract to produce `hocr` and `txt` output. Tesseract
reads the instructions for these output formats from configuration files
named `hocr` and `txt` that live in the `configs/` subdirectory of its
`tessdata` folder. If those files are missing, Tesseract prints
`read_params_file: Can't open hocr`, exits without error, and produces no
output.
This usually happens when a `tessdata` directory was assembled by hand --
for example, by downloading individual `.traineddata` files from
[tessdata_best](https://github.com/tesseract-ocr/tessdata_best) and
pointing `TESSDATA_PREFIX` at them -- because those repositories do not
include the `configs/` directory. A complete Tesseract installation from
your operating system\'s package manager includes it.
To fix this, ensure the `configs/hocr` and `configs/txt` files exist in
the `tessdata` directory that Tesseract is using. Copying the `configs/`
directory from a full Tesseract installation is sufficient. See
{envvar}`TESSDATA_PREFIX` for more on selecting an alternate `tessdata`
folder.
+1 -1
View File
@@ -17,7 +17,7 @@ image processing and OCR (recognized, searchable text) to existing PDFs.
:maxdepth: 1
introduction
release_notes
releasenotes/index
installation
languages
jbig2
+260 -138
View File
@@ -1,42 +1,42 @@
---
myst:
substitutions:
deb_11: |-
:::{image} https://repology.org/badge/version-for-repo/debian_11/ocrmypdf.svg
:alt: Debian 11
:::
deb_12: |-
:::{image} https://repology.org/badge/version-for-repo/debian_12/ocrmypdf.svg
:alt: Debian 12
:::
deb_13: |-
:::{image} https://repology.org/badge/version-for-repo/debian_13/ocrmypdf.svg
:alt: Debian 13
:::
deb_unstable: |-
:::{image} https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
:alt: Debian unstable
:::
fedora_38: |-
:::{image} https://repology.org/badge/version-for-repo/fedora_38/ocrmypdf.svg
:alt: Fedora 38
fedora_40: |-
:::{image} https://repology.org/badge/version-for-repo/fedora_40/ocrmypdf.svg
:alt: Fedora 40
:::
fedora_39: |-
:::{image} https://repology.org/badge/version-for-repo/fedora_39/ocrmypdf.svg
:alt: Fedora 39
fedora_41: |-
:::{image} https://repology.org/badge/version-for-repo/fedora_41/ocrmypdf.svg
:alt: Fedora 41
:::
fedora_rawhide: |-
:::{image} https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
:alt: Fedore Rawhide
:alt: Fedora Rawhide
:::
latest: |-
:::{image} https://img.shields.io/pypi/v/ocrmypdf.svg
:alt: OCRmyPDF latest released version on PyPI
:::
ubu_2004: |-
:::{image} https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg
:alt: Ubuntu 20.04 LTS
:::
ubu_2204: |-
:::{image} https://repology.org/badge/version-for-repo/ubuntu_22_04/ocrmypdf.svg
:alt: Ubuntu 22.04 LTS
:::
ubu_2404: |-
:::{image} https://repology.org/badge/version-for-repo/ubuntu_24_04/ocrmypdf.svg
:alt: Ubuntu 24.04 LTS
:::
---
% SPDX-FileCopyrightText: 2022 James R. Barlow
@@ -54,18 +54,16 @@ These platforms have one-liner installs:
:::{list-table}
:header-rows: 0
* - Homebrew (macOS and Linux)
- ``brew install ocrmypdf``
* - Debian, Ubuntu
- ``apt install ocrmypdf``
* - Windows Subsystem for Linux
- ``apt install ocrmypdf``
* - Fedora
- ``dnf install ocrmypdf tesseract-osd``
* - macOS (Homebrew)
- ``brew install ocrmypdf``
* - macOS (MacPorts)
- ``port install ocrmypdf``
* - LinuxBrew
- ``brew install ocrmypdf``
* - FreeBSD
- ``pkg install textproc/py-ocrmypdf``
* - Snap (snapcraft packaging)
@@ -82,15 +80,15 @@ install, or install a more recent version than your platform provides, read on.
## Installing on Linux
### Debian and Ubuntu 20.04 or newer
### Debian and Ubuntu 22.04 or newer
:::{list-table}
:header-rows: 1
* - OCRmyPDF versions in Debian & Ubuntu
* - {{ latest }}
* - {{ deb_11 }} {{ deb_12 }} {{ deb_unstable }}
* - {{ ubu_2004 }} {{ ubu_2204 }}
* - {{ deb_12 }} {{ deb_13 }} {{ deb_unstable }}
* - {{ ubu_2204 }} {{ ubu_2404 }}
:::
Users of Debian or Ubuntu may simply
@@ -112,9 +110,9 @@ For full details on version availability for your platform, check the
:::{note}
OCRmyPDF for Debian and Ubuntu currently omit the JBIG2 encoder.
OCRmyPDF works fine without it but will produce larger output files.
If you build jbig2enc from source, ocrmypdf will
automatically detect it (specifically the `jbig2` binary) on the
`PATH`. To add JBIG2 encoding, see {ref}`jbig2`.
All JBIG2 patents expired in 2017, so if you build jbig2enc from source,
OCRmyPDF will automatically detect it on the `PATH`.
To add JBIG2 encoding, see {ref}`jbig2`.
:::
### Fedora
@@ -124,7 +122,7 @@ automatically detect it (specifically the `jbig2` binary) on the
* - OCRmyPDF version
* - {{latest}}
* - {{fedora_38}} {{fedora_39}} {{fedora_rawhide}}
* - {{fedora_40}} {{fedora_41}} {{fedora_rawhide}}
:::
Users of Fedora may simply
@@ -141,21 +139,20 @@ to install the latest version from source. See [Installing HEAD revision
from sources](#installing-head-revision-from-sources).
:::{note}
OCRmyPDF for Fedora currently omits the JBIG2 encoder due to patent
issues. OCRmyPDF works fine without it but will produce larger output
files. If you build jbig2enc from source, ocrmypdf 7.0.0 and later
will automatically detect it on the `PATH`. To add JBIG2 encoding,
see {ref}`Installing the JBIG2 encoder <jbig2>`.
OCRmyPDF for Fedora currently omits the JBIG2 encoder. All JBIG2 patents
expired in 2017. OCRmyPDF works fine without it but will produce larger
output files. If you build jbig2enc from source, OCRmyPDF will automatically
detect it on the `PATH`. To add JBIG2 encoding, see {ref}`jbig2`.
:::
(ubuntu-lts-latest)=
### RHEL 9
Prepare the environment by getting Python 3.11:
Prepare the environment by getting Python 3.12:
```bash
dnf install python3.11 python3.11-pip
dnf install python3.12 python3.12-pip
```
Then, follow [Requirements for pip and HEAD install](#requirements-for-pip-and-head-install) to install dependencies:
@@ -167,42 +164,47 @@ dnf install ghostscript tesseract
and build ocrmypdf in virtual environment:
```bash
python3.11 -m venv .venv
python3.12 -m venv .venv
```
To add JBIG2 encoding, see {ref}`Installing the JBIG2 encoder <jbig2>`.
Note Fedora packages for language data haven't been branched for RHEL/EPEL, but you can get traineddata files directly from [tesseract](https://github.com/tesseract-ocr/tessdata/) and place them in `/usr/share/tesseract/tessdata`.
### Installing the latest version on Ubuntu 22.04 LTS
### Installing the latest version on Ubuntu 22.04/24.04 LTS
Ubuntu 22.04 includes ocrmypdf 13.4.0 - you can install that with
`apt install ocrmypdf`. To install a more recent version for the current
user, follow these steps:
Ubuntu includes an older version of OCRmyPDF - you can install that with
`apt install ocrmypdf`. To install the latest version, we recommend using uv:
```bash
# Install system dependencies first
sudo apt-get update
sudo apt-get -y install ocrmypdf python3-pip
sudo apt-get -y install ocrmypdf
pip install --user --upgrade ocrmypdf
# Install uv and upgrade to the latest OCRmyPDF
pip install uv
uv pip install --user --upgrade ocrmypdf
```
If you get the message `WARNING: The script ocrmypdf is installed in
'/home/$USER/.local/bin' which is not on PATH.`, you may need to re-login
or open a new shell, or manually adjust your PATH.
Alternatively, use Homebrew on Linux for a full-featured installation (see below).
To add JBIG2 encoding, see {ref}`jbig2`.
### Ubuntu 20.04 LTS
### Ubuntu 20.04 LTS (and other older distributions)
Ubuntu 20.04 includes ocrmypdf 9.6.0 - you can install that with `apt`. The
most convenient way to install recent OCRmyPDF on older Ubuntu is to use
Homebrew on Linux (Linuxbrew).
:::{note}
Ubuntu 20.04 is approaching end of life. Consider upgrading to Ubuntu 22.04 or 24.04 LTS.
:::
For older distributions, the most convenient way to install a recent version of
OCRmyPDF is to use Homebrew on Linux:
```bash
brew install ocrmypdf
```
See {ref}`homebrew-linux` for more information on using Homebrew on Linux.
### Arch Linux (AUR)
:::{image} https://repology.org/badge/version-for-repo/aur/ocrmypdf.svg
@@ -300,29 +302,45 @@ In general, first install the OCRmyPDF package for your system, then
optionally use the procedure [Installing with Python
pip](#installing-with-python-pip) to install a more recent version.
## Installing on macOS
(homebrew-linux)=
### Homebrew
## Installing with Homebrew (macOS and Linux)
:::{image} https://img.shields.io/homebrew/v/ocrmypdf.svg
:alt: homebrew
:target: https://formulae.brew.sh/formula/ocrmypdf
:::
OCRmyPDF is now a standard [Homebrew](https://brew.sh) formula. To
install on macOS:
[Homebrew](https://brew.sh) provides a full-featured OCRmyPDF installation
on both macOS and Linux with all recommended dependencies. This is often
the easiest way to get a complete, up-to-date installation.
```bash
brew install ocrmypdf
```
This will include only the English language pack. If you need other
languages you can optionally install them all:
This includes Tesseract, Ghostscript, and all required dependencies. English
language support is included by default. For other languages:
```bash
brew install tesseract-lang # Optional: Install all language packs
```
:::{tip}
**For Linux users:** Homebrew on Linux is an excellent choice when your
distribution's package is outdated or missing optional dependencies like
jbig2enc, pngquant, or unpaper. Homebrew provides a consistent, full-featured
installation that works across many Linux distributions.
Install Homebrew on Linux: https://brew.sh
:::
## Installing on macOS
### Homebrew
See {ref}`homebrew-linux` above - the installation is identical on macOS.
### MacPorts
:::{image} https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fports.macports.org%2Fapi%2Fv1%2Fports%2Focrmypdf%2F%3Fformat%3Djson&query=version&label=MacPorts
@@ -330,7 +348,7 @@ brew install tesseract-lang # Optional: Install all language packs
:target: https://ports.macports.org/port/ocrmypdf
:::
OCRmyPDF is includes in MacPorts:
OCRmyPDF is included in MacPorts:
```bash
sudo port install ocrmypdf
@@ -341,14 +359,13 @@ the appropriate tesseract [language ports](https://ports.macports.org/search/?se
### Manual installation on macOS
These instructions probably work on all macOS supported by Homebrew, and are
for installing a more current version of OCRmyPDF than is available from
Homebrew. Note that the Homebrew versions usually track the release versions
fairly closely.
These instructions are for installing a more current version of OCRmyPDF than
is available from Homebrew. Note that Homebrew versions usually track
releases fairly closely.
If it's not already present, [install Homebrew](http://brew.sh/).
Update Homebrew:
Update Homebrew and install dependencies:
```bash
brew update
@@ -367,16 +384,11 @@ packs. If you need other languages you can optionally install them all:
> brew install tesseract-lang # Option 2: for all language packs
> ```
Update the homebrew pip:
Install uv and OCRmyPDF:
```bash
pip install --upgrade pip
```
You can then install OCRmyPDF from PyPI for the current user:
```bash
pip install --user ocrmypdf
pip install uv
uv pip install --user ocrmypdf
```
The command line program should now be available:
@@ -405,7 +417,7 @@ You must install the following for Windows:
Using the [winget](https://docs.microsoft.com/en-us/windows/package-manager/winget/)
package manager:
- `winget install -e --id Python.Python.3.11`
- `winget install -e --id Python.Python.3.12`
- `winget install -e --id UB-Mannheim.TesseractOCR`
You will need to install Ghostscript manually, [since it does not support automated
@@ -452,13 +464,6 @@ override the versions OCRmyPDF selects, you can modify the `PATH` environment
variable. [Follow these directions](https://www.computerhope.com/issues/ch000549.htm#dospath)
to change the PATH.
:::{warning}
As of early 2021, users have reported problems with the Microsoft Store version of
Python and OCRmyPDF. These issues affect many other third party Python packages.
Please download Python from Python.org or a package manager instead of the
Microsoft Store version.
:::
:::{warning}
32-bit Windows is not supported.
:::
@@ -487,7 +492,7 @@ You can then run OCRmyPDF in the Windows command prompt or Powershell, prefixing
First install the the following prerequisite Cygwin packages using `setup-x86_64.exe`:
```
python310 (or later)
python311 (or later)
python3?-devel
python3?-pip
python3?-lxml
@@ -551,23 +556,35 @@ See [Installing the Docker image](docker) for more information.
(installing-with-python-pip)=
## Installing with Python pip
## Installing with uv (recommended)
OCRmyPDF is delivered by PyPI because it is a convenient way to install
the latest version. However, PyPI and `pip` cannot address the fact
that `ocrmypdf` depends on certain non-Python system libraries and
programs being installed.
We recommend using [uv](https://docs.astral.sh/uv/) for installing OCRmyPDF from PyPI.
uv is a fast, modern Python package manager that provides better dependency resolution
and consistent behavior across all platforms.
For best results, first install [your platform's
version](https://repology.org/metapackage/ocrmypdf/versions) of
`ocrmypdf`, using the instructions elsewhere in this document. Then
you can use `pip` to get the latest version if your platform version
is out of date. Chances are that this will satisfy most dependencies.
`ocrmypdf` using the instructions elsewhere in this document to satisfy system
dependencies. Then use uv to get the latest OCRmyPDF version.
```bash
# Install uv if you don't have it
pip install uv
# Install ocrmypdf in a virtual environment (recommended)
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv pip install ocrmypdf
# Or install globally
uv pip install --system ocrmypdf
```
Use `ocrmypdf --version` to confirm what version was installed.
Then you can install the latest OCRmyPDF from the Python wheels. First
try:
### Installing with pip
If you prefer pip, you can still use it:
```bash
pip install --user ocrmypdf
@@ -576,21 +593,20 @@ pip install --user ocrmypdf
(If the message appears `Requirement already satisfied: ocrmypdf in...`,
you will need to use `pip install --user --upgrade ocrmypdf`.)
You should then be able to run `ocrmypdf --version` and see that the
latest version was located.
### Installing with pipx
## Installing with pipx
Some users may prefer pipx for isolated command-line tool installations:
Some users may prefer pipx. As with the method above, you will need to
satisfy all non-Python dependencies. Then if pipx is installed, you
can use
```bash
pipx install ocrmypdf
```
Or run without permanent installation:
```bash
pipx run ocrmypdf
```
(If not installed, pipx will install first.)
(requirements-for-pip-and-head-install)=
### Requirements for pip and HEAD install
@@ -599,29 +615,98 @@ OCRmyPDF currently requires these external programs and libraries to be
installed, and must be satisfied using the operating system package
manager. `pip` cannot provide them.
:::{versionchanged} 17.0.0
Ghostscript is now optional. pypdfium2 can be used for PDF rasterization,
and verapdf can validate speculative PDF/A conversion.
:::
The following versions are required:
- Python 3.10 or newer
- Ghostscript 9.54 or newer
- Python 3.11 or newer (3.12+ recommended)
- Tesseract 4.1.1 or newer
- jbig2enc 0.29 or newer
- pngquant 2.5 or newer
- unpaper 6.1
- One of: Ghostscript 9.54+ **or** pypdfium2 (Python package)
- One of: Ghostscript 9.54+ **or** verapdf (for PDF/A output)
- fpdf2 2.8 or newer (Python package)
- uharfbuzz (Python package)
- fonts-noto or equivalent (system package, recommended)
- jbig2enc 0.29 or newer (optional)
- pngquant 2.5 or newer (optional)
- unpaper 6.1 (optional)
:::{note}
For the best user experience, install both Ghostscript and pypdfium2. pypdfium2 is
faster for rasterization, while Ghostscript provides is required for certain PDF/A
conversions.
:::
**Dependency summary:**
| Feature | Option 1 | Option 2 | Notes |
|---------|----------|----------|-------|
| PDF rasterization | pypdfium2 (Python) | Ghostscript (binary) | pypdfium2 preferred when available |
| PDF/A conversion | verapdf + pikepdf | Ghostscript | verapdf validates speculative conversion |
| Text rendering | fpdf2 + uharfbuzz | - | Required |
| OCR | tesseract-ocr | `--ocr-engine none` | Can be skipped entirely |
**Minimum viable installation:**
tesseract-ocr + (pypdfium2 OR Ghostscript) + fpdf2 + uharfbuzz
**Recommended installation:**
tesseract-ocr + pypdfium2 + Ghostscript + verapdf + fpdf2 + uharfbuzz + fonts-noto + unpaper + pngquant + jbig2enc
We recommend 64-bit versions of all software. (32-bit versions are not
supported, although on Linux, they may still work.)
jbig2enc, pngquant, and unpaper are optional. If missing certain
features are disabled. OCRmyPDF will discover them as soon as they are
available.
**fpdf2** and **uharfbuzz** are required dependencies that provide the text
layer rendering engine. fpdf2 generates the PDF text layer, while uharfbuzz
provides text shaping for proper multilingual support. These replace the
legacy hOCR-based renderer. Install with: `pip install fpdf2 uharfbuzz`
**fonts-noto** (or an equivalent comprehensive font package) is recommended
for proper text rendering, especially for non-Latin scripts. OCRmyPDF bundles
a Latin font only, and discovers the rest from the fonts installed on your
system.
- Debian/Ubuntu: `apt install fonts-noto`
- Fedora: `dnf install google-noto-fonts-all`
- macOS with Homebrew: Homebrew has no single Noto package; each family is a
separate cask. Install at least
`brew install --cask font-noto-sans font-noto-serif`, plus a cask per
additional script you OCR, for example
`brew install --cask font-noto-sans-arabic font-noto-sans-cjk`. Run
`brew search font-noto` to list them all.
If OCRmyPDF warns that no installed font has glyphs for some of the text, the
message names the characters it could not render, for example
`'Ꮳ' U+13E3 CHEROKEE LETTER TSA`. Install the Noto font for that script — here,
`fonts-noto-core` on Debian or `font-noto-sans-cherokee` on Homebrew. The text
layer remains searchable and copyable either way; only its appearance when
highlighted in a PDF viewer is affected.
**pypdfium2**, if present, provides fast PDF page rasterization using
the pdfium library (the same library used by Google Chrome). It is
preferred over Ghostscript when available due to better performance.
Install with: `pip install pypdfium2`
**verapdf**, if present, enables fast speculative PDF/A conversion.
OCRmyPDF attempts to create PDF/A by adding metadata and ICC profiles
using pikepdf, then validates with verapdf. If validation passes,
Ghostscript is skipped entirely. See your distribution's package manager
or visit [verapdf.org](https://verapdf.org/).
**jbig2enc**, if present, will be used to optimize the encoding of
monochrome images. This can significantly reduce the file size of the
output file. It is not required.
[jbig2enc](https://github.com/agl/jbig2enc) is not generally
available for Ubuntu or Debian due to lingering concerns about patent
issues, but can easily be built from source. To add JBIG2 encoding, see
{ref}`jbig2`.
[jbig2enc](https://github.com/agl/jbig2enc) is not available in some
distributions due to historical patent concerns, but all JBIG2 patents
expired in 2017. It can easily be built from source. To add JBIG2 encoding,
see {ref}`jbig2`.
:::{warning}
Lossy JBIG2 encoding (`--jbig2-lossy`) has been removed in v17.0.0 due to
well-documented risks of character substitution errors. Only lossless
JBIG2 compression is now supported.
:::
**pngquant**, if present, is optionally used to optimize the encoding of
PNG-style images in PDFs (actually, any that are that losslessly
@@ -638,8 +723,8 @@ unfortunately, the `pip install` command cannot satisfy all of them.
## Installing HEAD revision from sources
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
If you have `git` and Python 3.12 or newer installed, you can install
from source. (Python 3.11 is supported but 3.12+ is recommended.) When the `pip` installer runs, it will alert you if
dependencies are missing.
If you prefer to build every from source, you will need to [build
@@ -647,33 +732,39 @@ pikepdf from
source](https://pikepdf.readthedocs.io/en/latest/installation.html#building-from-source).
First ensure you can build and install pikepdf.
To install the HEAD revision from sources in the current Python 3
environment:
We recommend using uv to install from sources:
```bash
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
cd OCRmyPDF
pip install uv # If not already installed
uv sync
```
This creates a virtual environment and installs all dependencies. Activate
the environment to use ocrmypdf:
```bash
source .venv/bin/activate
ocrmypdf --help
```
Alternatively, install directly from GitHub using pip:
```bash
pip install git+https://github.com/ocrmypdf/OCRmyPDF.git
```
Or, to install in editable mode
allowing customization of OCRmyPDF, use the `-e` flag:
```bash
pip install -e git+https://github.com/ocrmypdf/OCRmyPDF.git
```
You may find it easiest to install in a virtual environment, rather than
system-wide:
Or, to install in editable mode allowing customization:
```bash
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
python3 -m venv .venv
source .venv/bin/activate
cd OCRmyPDF
pip install .
pip install -e .
```
However, `ocrmypdf` will only be accessible on the system PATH when
you activate the virtual environment.
Note: `ocrmypdf` will only be accessible when the virtual environment
is activated.
To run the program:
@@ -686,16 +777,56 @@ need to be installed. The script requires specific versions of the
dependencies. Older version than the ones mentioned in the release notes
are likely not to be compatible to OCRmyPDF.
## Optional Features
OCRmyPDF provides optional features and development tools. We recommend using `uv` as your package manager.
### Installing User Features
User features are available as optional dependencies. Install them with `uv` (recommended) or `pip`:
```bash
# Using uv (recommended)
uv sync --extra watcher # File watching service
uv sync --extra webservice # Streamlit web UI
uv sync --extra watcher --extra webservice # Multiple features
```
### Development Tools
Development tools use dependency groups:
```bash
# Testing infrastructure
uv sync --group test
# Documentation building
uv sync --group docs
# Enhanced Streamlit development
uv sync --group streamlit-dev
# All development groups
uv sync
```
**Why use uv?**
- Modern, fast Python package manager
- Required for development (testing, docs)
- Better dependency resolution
- Consistent across all platforms
Install uv: `curl -LsSf https://astral.sh/uv/install.sh | sh` or visit https://docs.astral.sh/uv/
### For development
To install all of the development and test requirements:
```bash
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
python -m venv .venv
source .venv/bin/activate
cd OCRmyPDF
pip install -e .[test]
uv sync --all-groups
```
To add JBIG2 encoding, see {ref}`jbig2`.
@@ -717,14 +848,5 @@ To manually install the `fish` completion, copy
## Note on 32-bit support
Many Python libraries no longer provide 32-bit binary wheels for Linux. This
includes many of the libraries that OCRmyPDF depends on, such as
Pillow. The easiest way to express this to end users is to say we don't
support 32-bit Linux.
However, if your Linux distribution still supports 32-bit binaries, you
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.
We don't support any 32-bit system, including 32-bit Python or 32-bit
Ghostscript on Windows.
+41 -9
View File
@@ -77,10 +77,17 @@ straightforward, and any PDF viewer can handle PDF/A files.
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.
losing content. It uses a PDF rasterizer (pypdfium2 or
[Ghostscript](http://ghostscript.com/)) to convert each page to an image and
subsequently performs OCR on the rasterized image to generate an OCR "layer."
This layer is then integrated back into the original PDF.
:::{versionchanged} 17.0.0
OCRmyPDF now supports pypdfium2 as an alternative rasterizer to Ghostscript.
pypdfium2 is a Python binding for pdfium, the PDF rendering library used by
Google Chrome. The `--rasterizer auto` setting (default) prefers pypdfium2
when available.
:::
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
@@ -156,23 +163,48 @@ These limitations are inherent to any software relying on Tesseract:
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:
### Ghostscript considerations
:::{versionchanged} 17.0.0
Ghostscript is no longer strictly required. OCRmyPDF can use pypdfium2
for rasterization and verapdf for PDF/A validation.
:::
While Ghostscript remains a capable and feature-rich tool with a long history,
recent releases have introduced some compatibility challenges that OCRmyPDF
v17 addresses through alternative codepaths. When Ghostscript is used:
- 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, 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 lacks an option to maintain the input image's format.
lossily, based on an internal algorithm. By default
(`--pdfa-image-compression=auto`) OCRmyPDF selects lossless image
compression at `-O0` so Ghostscript will not transcode lossless images
to JPEG. At `-O1` (the default optimization level) and above, `auto`
defers to Ghostscript's heuristic instead; `-O1` is a historical
exception, kept for backwards compatibility because coercing it to
lossless can substantially bloat output. You can override this by
setting `--pdfa-image-compression` to `jpeg` or `lossless` to force all
images to one type or the other. `lossless` passes existing JPEGs
through untouched (re-encoding them losslessly would only inflate them)
while encoding non-JPEG images losslessly.
(Modern Ghostscript can copy JPEG images without transcoding them.)
Advanced users can also tune Ghostscript's image recompression with
`--ghostscript-jpeg-quality` and `--ghostscript-jpeg-maxdpi`; see
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning).
Most users should prefer `--jpeg-quality` (applied by the OCRmyPDF
optimizer) over those Ghostscript-scoped controls.
- 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 may remove or deactivate
hyperlinks and other active content.
When pypdfium2 and verapdf are available, many of these limitations can be
avoided by using the speculative PDF/A conversion path (enabled by default
with `--output-type auto`).
You can use `--output-type pdf` to disable PDF/A conversion and produce
a standard, non-archival PDF.
+15 -27
View File
@@ -43,33 +43,21 @@ be required depending on your system.
[sudo] apt install autotools-dev automake libtool libleptonica-dev pkg-config
:::
{#jbig2-lossy}
## JBIG2 Compression
## Lossy mode JBIG2
OCRmyPDF uses JBIG2 lossless compression for bitonal (black and white)
images. This provides excellent compression ratios compared to the older
CCITT G4 standard, while preserving the exact pixel content of the
original image.
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. 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.
You can adjust the threshold for JBIG2 compression with
`--jbig2-threshold`. The default is 0.85.
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.
To turn on JBIG2 lossy mode, add the argument `--jbig2-lossy`.
`--optimize {1,2,3}` are necessary for the argument to take effect also
required. Also, a JBIG2 encoder must be installed as described in the
previous section.
You can adjust the threshold for JBIG2 compression with the
`--jbig2-threshold`. The default is 0.85, meaning that if two symbols
are 85% similar, they will be compressed together.
*Due to an oversight, ocrmypdf v7.0 and v7.1 used lossy mode by
default.*
:::{note}
Previous versions of OCRmyPDF supported a lossy JBIG2 mode
(`--jbig2-lossy`). This feature has been removed due to the well-known
risk of character substitution errors (e.g., 6/8 confusion). See
[JBIG2 disadvantages](https://en.wikipedia.org/wiki/JBIG2#Disadvantages)
for more information on why lossy JBIG2 is problematic. The `--jbig2-lossy`
and `--jbig2-page-group-size` arguments are now ignored with a warning.
:::
+116 -6
View File
@@ -20,12 +20,50 @@ with much stiffer build requirements. If you want to use OCRmyPDF on
some novel platform or distribution, first make sure you can package
pikepdf.
### Non-Python dependencies
### Core dependencies
Note that we have non-Python dependencies. In particular, OCRmyPDF
requires Ghostscript and Tesseract OCR to be installed and needs to be
able to locate their binaries on the system PATH. On Windows, OCRmyPDF
will also check the registry for their locations.
:::{versionchanged} 17.0.0
Ghostscript is no longer strictly required. OCRmyPDF now supports alternative
codepaths for both PDF rasterization and PDF/A conversion.
:::
OCRmyPDF has the following runtime dependencies:
**For PDF rasterization** (converting PDF pages to images for OCR):
- `pypdfium2` (Python package) - OR -
- `ghostscript` (system binary)
- Recommendation: Install both for best compatibility
**For PDF/A conversion**:
- `verapdf` (system binary) with pikepdf's speculative conversion - OR -
- `ghostscript` (system binary)
- Recommendation: Install both for best compatibility
**For OCR**:
- `tesseract-ocr` (system binary) - Required for MVP
**For text rendering** (expressing OCR results in PDF):
- `fpdf2` (Python package) - Required for text layer rendering
- `uharfbuzz` (Python package) - Required for text layer rendering
- Noto fonts (system package) - Recommended for text layer rendering.
`fonts-noto` on Debian/Ubuntu, `google-noto-fonts-all` on Fedora; Homebrew
has no single Noto package, only per-family casks such as `font-noto-sans`.
**Other dependencies**:
- `unpaper` (system binary) - Optional, enables `--clean` and `--clean-final`
- `pngquant` (system binary) - Optional, enables `--optimize 2` and `--optimize 3`
- `jbig2enc` (system binary) - Optional, improves compression of monochrome images
While Ghostscript remains a capable and feature-rich tool with a long history,
recent releases have introduced some compatibility challenges that OCRmyPDF v17
addresses through alternative codepaths. For the best user experience, packagers
should install both Ghostscript and the alternative tools (pypdfium2, verapdf)
when available.
On Windows, OCRmyPDF will also check the registry for Tesseract and Ghostscript
locations.
Tesseract OCR relies on SIMD for performance and only has proper support
for this on ARM and x86\_64. Performance may be poor on other processor
@@ -48,7 +86,79 @@ override versioning for some reason.
OCRmyPDF will use jbig2enc, a JBIG2 encoder, if one can be found. Some
distributions have shied away from packaging JBIG2 because it contains
patented algorithms, but all patents have expired since 2017. If
possible, consider packaging it too to improve OCRmyPDF\'s compression.
possible, consider packaging it too to improve OCRmyPDF's compression.
:::{note}
Lossy JBIG2 encoding has been removed in v17.0.0 due to well-documented
risks of character substitution errors. Previously we provided this feature
on a "caveat emptor" basis but in the interest of focusing and eliminating
risks, we decided to remove this option. Now, only lossless JBIG2 compression
is supported.
:::
### Dependency matrix for packagers
:::{versionadded} 17.0.0
:::
The following table summarizes the dependency options introduced in v17.0.0:
| Feature | Option 1 | Option 2 | Notes |
|---------|----------|----------|-------|
| PDF rasterization | pypdfium2 (Python) | ghostscript (binary) | pypdfium2 preferred when available |
| PDF/A conversion | verapdf + pikepdf | ghostscript | verapdf validates speculative conversion |
| Text rendering | fpdf2 (Python) | - | Required, replaces legacy hOCR renderer |
| OCR | tesseract-ocr | `--ocr-engine none` | Can be skipped entirely |
**Minimum viable installation:**
- tesseract-ocr + (pypdfium2 OR ghostscript) + fpdf2
**Recommended installation:**
- tesseract-ocr + pypdfium2 + ghostscript + verapdf + fpdf2 + unpaper + pngquant + jbig2enc
:::{warning}
If Ghostscript is not installed and verapdf is not available, PDF/A output
cannot be produced. The output will be a standard PDF instead. This is a
breaking change for rare configurations that previously relied on PDF/A
output without Ghostscript alternatives.
:::
**Sample debian/control dependency specification**
```
Depends:
fonts-noto,
fpdf2 (>= 2.8),
ghostscript (>= 9.55), # Not strictly required, but best user experience
icc-profiles-free,
img2pdf,
python3-coloredlogs,
python3-deprecation,
python3-pdfminer (>= 20181108+dfsg-3),
python3-pikepdf (>= 8.14.0),
python3-pil,
python3-pluggy,
python3-reportlab,
python3-rich,
python3-uharfbuzz, # Not currently in Debian
tesseract-ocr (>= 5.0.0),
zlib1g,
${misc:Depends},
${python3:Depends},
Recommends:
cyclopts, # Not currently in Debian
jbig2
paddleocr, # Not currently in Debian
pngquant,
pypdfium2, # Not currently in Debian
unpaper,
verapdf, # Not currently in Debian
Suggests:
ocrmypdf-doc,
python-watchdog,
```
### Command line completions
+10 -8
View File
@@ -28,9 +28,6 @@ header-rows: 1
- Enables lossless optimizations, such as transcoding images to more
efficient formats. Also compress other uncompressed objects in the
PDF and enables the more efficient "object streams" within the PDF.
(If ``--jbig2-lossy`` is issued, then lossy JBIG2 optimization is used.
The decision to use lossy JBIG2 is separate from standard optimization
settings.)
* - ``--optimize 2``
- ``-O2``
- All of the above, and enables lossy optimizations and color quantization.
@@ -101,11 +98,16 @@ If `pngquant` is installed, OCRmyPDF will use it to perform quantize
paletted images to reduce their size.
The quality of JPEGs may be lowered, on the assumption that a lower
quality image may be suitable for storage after OCR.
quality image may be suitable for storage after OCR. Use `--jpeg-quality`
to control the optimizer's JPEG quality target. The optimizer is the
recommended way to reduce JPEG image sizes: it applies consistently
regardless of whether Ghostscript was used to produce a PDF/A.
If you specifically need to tune Ghostscript's own PDF/A image handling
(for example, to force a hard DPI cap), see
[Advanced Ghostscript tuning](advanced.md#advanced-ghostscript-tuning)
for the separate `--ghostscript-jpeg-quality` and
`--ghostscript-jpeg-maxdpi` options.
It is not possible to optimize all image types. Uncommon image types may
be skipped by the optimizer.
OCRmyPDF provides `lossy mode JBIG2 <jbig2-lossy>`{.interpreted-text
role="ref"} as an advanced feature that additional requires the argument
`--jbig2-lossy`.
+166
View File
@@ -120,6 +120,7 @@ A plugin may provide the following hooks. Hooks must be decorated with
```python
from ocrmypdf import hookimpl
@hookimpl
def add_options(parser):
pass
@@ -164,6 +165,77 @@ chaining operations.
.. autofunction:: ocrmypdf.pluginspec.check_options
```
### Plugin option models
Plugins can define their own option models using Pydantic. This allows plugins to:
- Define type-safe option structures with validation
- Add CLI arguments that map to their option model fields
- Access options via nested namespaces (e.g., `options.tesseract.timeout`)
```{eval-rst}
.. autofunction:: ocrmypdf.pluginspec.register_options
```
Plugin options can be accessed in two ways:
1. **Flat access** (backward compatible): `options.tesseract_timeout`
2. **Nested access**: `options.tesseract.timeout`
Both access patterns are equivalent and return the same values.
:::{note}
**Plugin Interface Change**: Starting in OCRmyPDF v17.0.0, plugin hooks receive
`OcrOptions` objects instead of `argparse.Namespace` objects. Most plugins will
continue working due to duck-typing compatibility, but plugin developers should
update their type hints accordingly.
:::
### Migration guide for plugin developers
:::{versionadded} 17.0.0
:::
**Update imports:**
```python
from ocrmypdf._options import OcrOptions
```
**Update type hints:**
```python
# Before (v16 and earlier)
def check_options(options: argparse.Namespace) -> None: ...
# After (v17+)
def check_options(options: OcrOptions) -> None: ...
```
**Attribute access unchanged:**
```python
# These work exactly as before
options.languages
options.output_type
options.tesseract_timeout
```
**Remove in-place modifications:**
```python
# Before (v16 pattern - no longer recommended)
def check_options(options):
options.some_computed_value = compute_value(options)
# After (v17 pattern - compute at point of use)
def some_function(options):
computed = compute_value(options)
use_computed(computed)
```
### Execution and progress reporting
```{eval-rst}
@@ -248,3 +320,97 @@ chaining operations.
```{eval-rst}
.. autofunction:: ocrmypdf.pluginspec.is_optimization_enabled
```
### Working with OcrElement trees
:::{versionadded} 17.0.0
:::
OCRmyPDF v17 introduces the `OcrElement` dataclass for representing OCR
output in an engine-agnostic format. This enables plugins to work with
OCR results without parsing hOCR XML.
**Key classes:**
```python
from ocrmypdf import OcrElement, OcrClass, BoundingBox
# OcrElement - represents any OCR structural unit
page = OcrElement(
ocr_class=OcrClass.PAGE, bbox=BoundingBox(0, 0, 612, 792), children=[...]
)
# BoundingBox - axis-aligned bounding box (left, top, right, bottom)
bbox = BoundingBox(left=100, top=50, right=300, bottom=80)
# OcrClass - constants for element types
OcrClass.PAGE # "ocr_page"
OcrClass.LINE # "ocr_line"
OcrClass.WORD # "ocrx_word"
OcrClass.PARAGRAPH # "ocr_par"
```
**Navigating the tree:**
```python
# Get all words in a page
words = page.words # Returns list[OcrElement]
# Get all lines
lines = page.lines
# Get combined text
text = page.get_text_recursive()
# Iterate by class
for para in page.paragraphs:
print(para.get_text_recursive())
```
**OCR engine plugins:**
Plugins implementing custom OCR engines can now output `OcrElement` trees
directly via the `generate_ocr()` method, bypassing hOCR entirely:
```python
from pathlib import Path
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf import OcrElement, OcrClass, BoundingBox
class MyOcrEngine(OcrEngine):
def generate_ocr(
self,
input_file: Path,
options,
context,
) -> OcrElement:
# Perform OCR and return OcrElement tree directly
# No need to generate hOCR XML
return OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(0, 0, width, height),
dpi=300,
children=[
OcrElement(
ocr_class=OcrClass.LINE,
bbox=BoundingBox(100, 50, 500, 80),
children=[
OcrElement(
ocr_class=OcrClass.WORD,
bbox=BoundingBox(100, 50, 200, 80),
text="Hello",
),
# ... more words
],
),
# ... more lines
],
)
def supports_generate_ocr(self) -> bool:
return True # Indicate this engine uses generate_ocr()
```
This approach is simpler than generating hOCR and allows modern OCR
engines to integrate more naturally with OCRmyPDF.
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# Release notes
OCRmyPDF uses [semantic versioning](http://semver.org/) for its
command line interface and its public API.
OCRmyPDF's output messages are not considered part of the stable interface -
that is, output messages may be improved at any release level, so parsing them
may be unreliable. Use the API to depend on precise behavior.
The public API may be useful in scripts that launch OCRmyPDF processes or that
wish to use some of its features for working with PDFs.
The most recent release of OCRmyPDF is ![version](https://img.shields.io/pypi/v/ocrmypdf.svg). 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
about a forthcoming release that has not been tagged yet. A release is only
official when it's tagged and posted to PyPI.
:::
```{toctree}
:glob: true
:maxdepth: 1
:reversed: true
version*
```
+14
View File
@@ -0,0 +1,14 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v2
## v2.2-stable (2014-09-29)
OCRmyPDF versions 1 and 2 were implemented as shell scripts. OCRmyPDF
3.0+ is a fork that gradually replaced all shell scripts with Python
while maintaining the existing command line arguments. No one is
maintaining old versions.
For details on older versions, see the [final version of its release
notes](https://github.com/fritz-hh/OCRmyPDF/blob/7fd3dbdf42ca53a619412ce8add7532c5e81a9d1/RELEASE_NOTES.md).
+219
View File
@@ -0,0 +1,219 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v3
## v3.2.1
Changes
- Fixed {issue}`47`
"convert() got and unexpected keyword argument 'dpi'" by upgrading to
img2pdf 0.2
- Tweaked the Dockerfiles
## v3.2
New features
- Lossless reconstruction: when possible, OCRmyPDF will inject text
layers without otherwise manipulating the content and layout of a PDF
page. For example, a PDF containing a mix of vector and raster
content would see the vector content preserved. Images may still be
transcoded during PDF/A conversion. (`--deskew` and
`--clean-final` disable this mode, necessarily.)
- New argument `--tesseract-pagesegmode` allows you to pass page
segmentation arguments to Tesseract OCR. This helps for two column
text and other situations that confuse Tesseract.
- Added a new "polyglot" version of the Docker image, that generates
Tesseract with all languages packs installed, for the polyglots among
us. It is much larger.
Changes
- JPEG transcoding quality is now 95 instead of the default 75. Bigger
file sizes for less degradation.
## v3.1.1
Changes
- Fixed bug that caused incorrect page size and DPI calculations on
documents with mixed page sizes
## v3.1
Changes
- Default output format is now PDF/A-2b instead of PDF/A-1b
- Python 3.5 and macOS El Capitan are now supported platforms - no
changes were needed to implement support
- Improved some error messages related to missing input files
- Fixed {issue}`20`: uppercase .PDF extension not accepted
- Fixed an issue where OCRmyPDF failed to text that certain pages
contained previously OCR'ed text, such as OCR text produced by
Tesseract 3.04
- Inserts /Creator tag into PDFs so that errors can be traced back to
this project
- Added new option `--pdf-renderer=auto`, to let OCRmyPDF pick the
best PDF renderer. Currently it always chooses the 'hocrtransform'
renderer but that behavior may change.
- Set up Travis CI automatic integration testing
## v3.0
New features
- Easier installation with a Docker container or Python's `pip`
package manager
- Eliminated many external dependencies, so it's easier to setup
- Now installs `ocrmypdf` to `/usr/local/bin` or equivalent for
system-wide access and easier typing
- Improved command line syntax and usage help (`--help`)
- Tesseract 3.03+ PDF page rendering can be used instead for better
positioning of recognized text (`--pdf-renderer tesseract`)
- PDF metadata (title, author, keywords) are now transferred to the
output PDF
- PDF metadata can also be set from the command line (`--title`,
etc.)
- Automatic repairs malformed input PDFs if possible
- Added test cases to confirm everything is working
- Added option to skip extremely large pages that take too long to OCR
and are often not OCRable (e.g. large scanned maps or diagrams);
other pages are still processed (`--skip-big`)
- Added option to kill Tesseract OCR process if it seems to be taking
too long on a page, while still processing other pages
(`--tesseract-timeout`)
- Less common colorspaces (CMYK, palette) are now supported by
conversion to RGB
- Multiple images on the same PDF page are now supported
Changes
- New, robust rewrite in Python 3.4+ with
[ruffus](http://www.ruffus.org.uk/index.html) pipelines
- Now uses Ghostscript 9.14's improved color conversion model to
preserve PDF colors
- OCR text is now rendered in the PDF as invisible text. Previous
versions of OCRmyPDF incorrectly rendered visible text with an image
on top.
- All "tasks" in the pipeline can be executed in parallel on any
available CPUs, increasing performance
- The `-o DPI` argument has been phased out, in favor of
`--oversample DPI`, in case we need `-o OUTPUTFILE` in the future
- Removed several dependencies, so it's easier to install. We no longer
use:
- GNU [parallel](https://www.gnu.org/software/parallel/)
- [ImageMagick](http://www.imagemagick.org/script/index.php)
- Python 2.7
- Poppler
- [MuPDF](http://mupdf.com/docs/) tools
- shell scripts
- Java and [JHOVE](http://jhove.sourceforge.net/)
- libxml2
- Some new external dependencies are required or optional, compared to
v2.x:
- Ghostscript 9.14+
- [qpdf](http://qpdf.sourceforge.net/) 5.0.0+
- [Unpaper](https://github.com/Flameeyes/unpaper) 6.1 (optional)
- some automatically managed Python packages
Release candidates^
- rc9:
- Fix
{issue}`118`:
report error if ghostscript iccprofiles are missing
- fixed another issue related to
{issue}`111`: PDF
rasterized to palette file
- add support image files with a palette
- don't try to validate PDF file after an exception occurs
- rc8:
- Fix
{issue}`111`:
exception thrown if PDF is missing DocumentInfo dictionary
- rc7:
- fix error when installing direct from pip, "no such file
'requirements.txt'"
- rc6:
- dropped libxml2 (Python lxml) since Python 3's internal XML parser
is sufficient
- set up Docker container
- fix Unicode errors if recognized text contains Unicode characters
and system locale is not UTF-8
- rc5:
- dropped Java and JHOVE in favour of qpdf
- improved command line error output
- additional tests and bug fixes
- tested on Ubuntu 14.04 LTS
- rc4:
- dropped MuPDF in favour of qpdf
- fixed some installer issues and errors in installation
instructions
- improve performance: run Ghostscript with multithreaded rendering
- improve performance: use multiple cores by default
- bug fix: checking for wrong exception on process timeout
- rc3: skipping version number intentionally to avoid confusion with
Tesseract
- rc2: first release for public testing to test-PyPI, Github
- rc1: testing release process
## Compatibility notes
- `./OCRmyPDF.sh` script is still available for now
- Stacking the verbosity option like `-vvv` is no longer supported
- The configuration file `config.sh` has been removed. Instead, you
can feed a file to the arguments for common settings:
```
ocrmypdf input.pdf output.pdf @settings.txt
```
where `settings.txt` contains *one argument per line*, for example:
```
-l
deu
--author
A. Merkel
--pdf-renderer
tesseract
```
Fixes
- Handling of filenames containing spaces: fixed
Notes and known issues
- Some dependencies may work with lower versions than tested, so try
overriding dependencies if they are "in the way" to see if they work.
- `--pdf-renderer tesseract` will output files with an incorrect page
size in Tesseract 3.03, due to a bug in Tesseract.
- PDF files containing "inline images" are not supported and won't be
for the 3.0 release. Scanned images almost never contain inline
images.
+408
View File
@@ -0,0 +1,408 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v4
## v4.5.6
- Fixed {issue}`156`,
'NoneType' object has no attribute 'getObject' on pages with no
optional /Contents record. This should resolve all issues related to
pages with no /Contents record.
- Fixed {issue}`158`, ocrmypdf
now stops and terminates if Ghostscript fails on an intermediate
step, as it is not possible to proceed.
- Fixed {issue}`160`,
exception thrown on certain invalid arguments instead of error
message
## v4.5.5
- Automated update of macOS homebrew tap
- Fixed {issue}`154`, KeyError
'/Contents' when searching for text on blank pages that have no
/Contents record. Note: incomplete fix for this issue.
## v4.5.4
- Fixed `--skip-big` raising an exception if a page contains no images
({issue}`152`) (thanks
to @TomRaz)
- Fixed an issue where pages with no images might trigger "cannot write
mode P as JPEG"
({issue}`151`)
## v4.5.3
- Added a workaround for Ghostscript 9.21 and probably earlier versions
would fail with the error message "VMerror -25", due to a Ghostscript
bug in XMP metadata handling
- High Unicode characters (U+10000 and up) are no longer accepted for
setting metadata on the command line, as Ghostscript may not handle
them correctly.
- Fixed an issue where the `tess4` renderer would duplicate content
onto output pages if tesseract failed or timed out
- Fixed `tess4` renderer not recognized when lossless reconstruction
is possible
## v4.5.2
- Fixed {issue}`147`,
`--pdf-renderer tess4 --clean` will produce an oversized page
containing the original image in the bottom left corner, due to loss
DPI information.
- Make "using Tesseract 4.0" warning less ominous
- Set up machinery for homebrew OCRmyPDF tap
## v4.5.1
- Fixed {issue}`137`,
proportions of images with a non-square pixel aspect ratio would be
distorted in output for `--force-ocr` and some other combinations
of flags
## v4.5
- PDFs containing "Form XObjects" are now supported (issue
{issue}`134`; PDF
reference manual 8.10), and images they contain are taken into
account when determining the resolution for rasterizing
- The Tesseract 4 Docker image no longer includes all languages,
because it took so long to build something would tend to fail
- OCRmyPDF now warns about using `--pdf-renderer tesseract` with
Tesseract 3.04 or lower due to issues with Ghostscript corrupting the
OCR text in these cases
## v4.4.2
- The Docker images (ocrmypdf, ocrmypdf-polyglot, ocrmypdf-tess4) are
now based on Ubuntu 16.10 instead of Debian stretch
- This makes supporting the Tesseract 4 image easier
- This could be a disruptive change for any Docker users who built
customized these images with their own changes, and made those
changes in a way that depends on Debian and not Ubuntu
- OCRmyPDF now prevents running the Tesseract 4 renderer with Tesseract
3.04, which was permitted in v4.4 and v4.4.1 but will not work
## v4.4.1
- To prevent a [TIFF output
error](https://github.com/python-pillow/Pillow/issues/2206) caused
by img2pdf >= 0.2.1 and Pillow \<= 3.4.2, dependencies have been
tightened
- The Tesseract 4.00 simultaneous process limit was increased from 1 to
2, since it was observed that 1 lowers performance
- Documentation improvements to describe the `--tesseract-config`
feature
- Added test cases and fixed error handling for `--tesseract-config`
- Tweaks to setup.py to deal with issues in the v4.4 release
## v4.4
- Tesseract 4.00 is now supported on an experimental basis.
- A new rendering option `--pdf-renderer tess4` exploits Tesseract
4's new text-only output PDF mode. See the documentation on PDF
Renderers for details.
- The `--tesseract-oem` argument allows control over the Tesseract
4 OCR engine mode (tesseract's `--oem`). Use
`--tesseract-oem 2` to enforce the new LSTM mode.
- Fixed poor performance with Tesseract 4.00 on Linux
- Fixed an issue that caused corruption of output to stdout in some
cases
- Removed test for Pillow JPEG and PNG support, as the minimum
supported version of Pillow now enforces this
- OCRmyPDF now tests that the intended destination file is writable
before proceeding
- The test suite now requires `pytest-helpers-namespace` to run (but
not install)
- Significant code reorganization to make OCRmyPDF re-entrant and
improve performance. All changes should be backward compatible for
the v4.x series.
- However, OCRmyPDF's dependency "ruffus" is not re-entrant, so no
Python API is available. Scripts should continue to use the
command line interface.
## v4.3.5
- Update documentation to confirm Python 3.6.0 compatibility. No code
changes were needed, so many earlier versions are likely supported.
## v4.3.4
- Fixed "decimal.InvalidOperation: quantize result has too many digits"
for high DPI images
## v4.3.3
- Fixed PDF/A creation with Ghostscript 9.20 properly
- Fixed an exception on inline stencil masks with a missing optional
parameter
## v4.3.2
- Fixed a PDF/A creation issue with Ghostscript 9.20 (note: this fix
did not actually work)
## v4.3.1
- Fixed an issue where pages produced by the "hocr" renderer after a
Tesseract timeout would be rotated incorrectly if the input page was
rotated with a /Rotate marker
- Fixed a file handle leak in LeptonicaErrorTrap that would cause a
"too many open files" error for files around hundred pages of pages
long when `--deskew` or `--remove-background` or other Leptonica
based image processing features were in use, depending on the system
value of `ulimit -n`
- Ability to specify multiple languages for multilingual documents is
now advertised in documentation
- Reduced the file sizes of some test resources
- Cleaned up debug output
- Tesseract caching in test cases is now more cautious about false
cache hits and reproducing exact output, not that any problems were
observed
## v4.3
- New feature `--remove-background` to detect and erase the
background of color and grayscale images
- Better documentation
- Fixed an issue with PDFs that draw images when the raster stack depth
is zero
- ocrmypdf can now redirect its output to stdout for use in a shell
pipeline
- This does not improve performance since temporary files are still
used for buffering
- Some output validation is disabled in this mode
## v4.2.5
- Fixed an issue
({issue}`100`) with
PDFs that omit the optional /BitsPerComponent parameter on images
- Removed non-free file milk.pdf
## v4.2.4
- Fixed an error
({issue}`90`) caused by
PDFs that use stencil masks properly
- Fixed handling of PDFs that try to draw images or stencil masks
without properly setting up the graphics state (such images are now
ignored for the purposes of calculating DPI)
## v4.2.3
- Fixed an issue with PDFs that store page rotation (/Rotate) in an
indirect object
- Integrated a few fixes to simplify downstream packaging (Debian)
- The test suite no longer assumes it is installed
- If running Linux, skip a test that passes Unicode on the command
line
- Added a test case to check explicit masks and stencil masks
- Added a test case for indirect objects and linearized PDFs
- Deprecated the OCRmyPDF.sh shell script
## v4.2.2
- Improvements to documentation
## v4.2.1
- Fixed an issue where PDF pages that contained stencil masks would
report an incorrect DPI and cause Ghostscript to abort
- Implemented stdin streaming
## v4.2
- ocrmypdf will now try to convert single image files to PDFs if they
are provided as input
({issue}`15`)
- This is a basic convenience feature. It only supports a single
image and always makes the image fill the whole page.
- For better control over image to PDF conversion, use `img2pdf`
(one of ocrmypdf's dependencies)
- New argument `--output-type {pdf|pdfa}` allows disabling
Ghostscript PDF/A generation
- `pdfa` is the default, consistent with past behavior
- `pdf` provides a workaround for users concerned about the
increase in file size from Ghostscript forcing JBIG2 images to
CCITT and transcoding JPEGs
- `pdf` preserves as much as it can about the original file,
including problems that PDF/A conversion fixes
- PDFs containing images with "non-square" pixel aspect ratios, such as
200x100 DPI, are now handled and converted properly (fixing a bug
that caused to be cropped)
- `--force-ocr` rasterizes pages even if they contain no images
- supports users who want to use OCRmyPDF to reconstruct text
information in PDFs with damaged Unicode maps (copy and paste text
does not match displayed text)
- supports reinterpreting PDFs where text was rendered as curves for
printing, and text needs to be recovered
- fixes issue
{issue}`82`
- Fixes an issue where, with certain settings, monochrome images in
PDFs would be converted to 8-bit grayscale, increasing file size
({issue}`79`)
- Support for Ubuntu 12.04 LTS "precise" has been dropped in favor of
(roughly) Ubuntu 14.04 LTS "trusty"
- Some Ubuntu "PPAs" (backports) are needed to make it work
- Support for some older dependencies dropped
- Ghostscript 9.15 or later is now required (available in Ubuntu
trusty with backports)
- Tesseract 3.03 or later is now required (available in Ubuntu
trusty)
- Ghostscript now runs in "safer" mode where possible
## v4.1.4
- Bug fix: monochrome images with an ICC profile attached were
incorrectly converted to full color images if lossless reconstruction
was not possible due to other settings; consequence was increased
file size for these images
## v4.1.3
- More helpful error message for PDFs with version 4 security handler
- Update usage instructions for Windows/Docker users
- Fixed order of operations for matrix multiplication (no effect on most
users)
- Add a few leptonica wrapper functions (no effect on most users)
## v4.1.2
- Replace IEC sRGB ICC profile with Debian's sRGB (from
icc-profiles-free) which is more compatible with the MIT license
- More helpful error message for an error related to certain types of
malformed PDFs
## v4.1
- `--rotate-pages` now only rotates pages when reasonably confidence
in the orientation. This behavior can be adjusted with the new
argument `--rotate-pages-threshold`
- Fixed problems in error checking if `unpaper` is uninstalled or
missing at run-time
- Fixed problems with "RethrownJobError" errors during error handling
that suppressed the useful error messages
## v4.0.7
- Minor correction to Ghostscript output settings
## v4.0.6
- Update install instructions
- Provide a sRGB profile instead of using Ghostscript's
## v4.0.5
- Remove some verbose debug messages from v4.0.4
- Fixed temporary that wasn't being deleted
- DPI is now calculated correctly for cropped images, along with other
image transformations
- Inline images are now checked during DPI calculation instead of
rejecting the image
## v4.0.4
Released with verbose debug message turned on. Do not use. Skip to
v4.0.5.
## v4.0.3
New features
- Page orientations detected are now reported in a summary comment
Fixes
- Show stack trace if unexpected errors occur
- Treat "too few characters" error message from Tesseract as a reason
to skip that page rather than abort the file
- Docker: fix blank JPEG2000 issue by insisting on Ghostscript versions
that have this fixed
## v4.0.2
Fixes
- Fixed compatibility with Tesseract 3.04.01 release, particularly its
different way of outputting orientation information
- Improved handling of Tesseract errors and crashes
- Fixed use of chmod on Docker that broke most test cases
## v4.0.1
Fixes
- Fixed a KeyError if tesseract fails to find page orientation
information
## v4.0
New features
- Automatic page rotation (`-r`) is now available. It uses ignores
any prior rotation information on PDFs and sets rotation based on the
dominant orientation of detectable text. This feature is fairly
reliable but some false positives occur especially if there is not
much text to work with.
({issue}`4`)
- Deskewing is now performed using Leptonica instead of unpaper.
Leptonica is faster and more reliable at image deskewing than
unpaper.
Fixes
- Fixed an issue where lossless reconstruction could cause some pages
to be appear incorrectly if the page was rotated by the user in
Acrobat after being scanned (specifically if it a /Rotate tag)
- Fixed an issue where lossless reconstruction could misalign the
graphics layer with respect to text layer if the page had been
cropped such that its origin is not (0, 0)
({issue}`49`)
Changes
- Logging output is now much easier to read
- `--deskew` is now performed by Leptonica instead of unpaper
({issue}`25`)
- libffi is now required
- Some changes were made to the Docker and Travis build environments to
support libffi
- `--pdf-renderer=tesseract` now displays a warning if the Tesseract
version is less than 3.04.01, the planned release that will include
fixes to an important OCR text rendering bug in Tesseract 3.04.00.
You can also manually install ./share/sharp2.ttf on top of pdf.ttf in
your Tesseract tessdata folder to correct the problem.
+210
View File
@@ -0,0 +1,210 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v5
## v5.7.0
- Fixed an issue that caused poor CPU utilization on machines with more
than 4 cores when running Tesseract 4. (Related to {issue}`217`.)
- The 'hocr' renderer has been improved. The 'sandwich' and 'tesseract'
renderers are still better for most use cases, but 'hocr' may be
useful for people who work with the PDF.js renderer in English/ASCII
languages. ({issue}`225`)
- It now formats text in a matter that is easier for certain PDF
viewers to select and extract copy and paste text. This should
help macOS Preview and PDF.js in particular.
- The appearance of selected text and behavior of selecting text is
improved.
- The PDF content stream now uses relative moves, making it more
compact and easier for viewers to determine when two words on the
same line.
- It can now deal with text on a skewed baseline.
- Thanks to @cforcey for the pull request, @jbreiden for many
helpful suggestions, @ctbarbour for another round of improvements,
and @acaloiaro for an independent review.
## v5.6.3
- Suppress two debug messages that were too verbose
## v5.6.2
- Development branch accidentally tagged as release. Do not use.
## v5.6.1
- Fixed {issue}`219`: change
how the final output file is created to avoid triggering permission
errors when the output is a special file such as `/dev/null`
- Fixed test suite failures due to a qpdf 8.0.0 regression and Python
3.5's handling of symlink
- The "encrypted PDF" error message was different depending on the type
of PDF encryption. Now a single clear message appears for all types
of PDF encryption.
- ocrmypdf is now in Homebrew. Homebrew users are advised to the
version of ocrmypdf in the official homebrew-core formulas rather
than the private tap.
- Some linting
## v5.6.0
- Fixed {issue}`216`: preserve
"text as curves" PDFs without rasterizing file
- Related to the above, messages about rasterizing are more consistent
- For consistency versions minor releases will now get the trailing .0
they always should have had.
## v5.5
- Add new argument `--max-image-mpixels`. Pillow 5.0 now raises an
exception when images may be decompression bombs. This argument can
be used to override the limit Pillow sets.
- Fixed output page cropped when using the sandwich renderer and OCR is
skipped on a rotated and image-processed page
- A warning is now issued when old versions of Ghostscript are used in
cases known to cause issues with non-Latin characters
- Fixed a few parameter validation checks for `-output-type pdfa-1` and
`pdfa-2`
## v5.4.4
- Fixed {issue}`181`: fix
final merge failure for PDFs with more pages than the system file
handle limit (`ulimit -n`)
- Fixed {issue}`200`: an
uncommon syntax for formatting decimal numbers in a PDF would cause
qpdf to issue a warning, which ocrmypdf treated as an error. Now this
the warning is relayed.
- Fixed an issue where intermediate PDFs would be created at version 1.3
instead of the version of the original file. It's possible but
unlikely this had side effects.
- A warning is now issued when older versions of qpdf are used since
issues like
{issue}`200` cause
qpdf to infinite-loop
- Address issue
{issue}`140`: if
Tesseract outputs invalid UTF-8, escape it and print its message
instead of aborting with a Unicode error
- Adding previously unlisted setup requirement, pytest-runner
- Update documentation: fix an error in the example script for Synology
with Docker images, improved security guidance, advised
`pip install --user`
## v5.4.3
- If a subprocess fails to report its version when queried, exit
cleanly with an error instead of throwing an exception
- Added test to confirm that the system locale is Unicode-aware and
fail early if it's not
- Clarified some copyright information
- Updated pinned requirements.txt so the homebrew formula captures more
recent versions
## v5.4.2
- Fixed a regression from v5.4.1 that caused sidecar files to be
created as empty files
## v5.4.1
- Add workaround for Tesseract v4.00alpha crash when trying to obtain
orientation and the latest language packs are installed
## v5.4
- Change wording of a deprecation warning to improve clarity
- Added option to generate PDF/A-1b output if desired
(`--output-type pdfa-1`); default remains PDF/A-2b generation
- Update documentation
## v5.3.3
- Fixed missing error message that should occur when trying to force
`--pdf-renderer sandwich` on old versions of Tesseract
- Update copyright information in test files
- Set system `LANG` to UTF-8 in Dockerfiles to avoid UTF-8 encoding
errors
## v5.3.2
- Fixed a broken test case related to language packs
## v5.3.1
- Fixed wrong return code given for missing Tesseract language packs
- Fixed "brew audit" crashing on Travis when trying to auto-brew
## v5.3
- Added `--user-words` and `--user-patterns` arguments which are
forwarded to Tesseract OCR as words and regular expressions
respective to use to guide OCR. Supplying a list of subject-domain
words should assist Tesseract with resolving words.
({issue}`165`)
- Using a non Latin-1 language with the "hocr" renderer now warns about
possible OCR quality and recommends workarounds
({issue}`176`)
- Output file path added to error message when that location is not
writable
({issue}`175`)
- Otherwise valid PDFs with leading whitespace at the beginning of the
file are now accepted
## v5.2
- When using Tesseract 3.05.01 or newer, OCRmyPDF will select the
"sandwich" PDF renderer by default, unless another PDF renderer is
specified with the `--pdf-renderer` argument. The previous behavior
was to select `--pdf-renderer=hocr`.
- The "tesseract" PDF renderer is now deprecated, since it can cause
problems with Ghostscript on Tesseract 3.05.00
- The "tess4" PDF renderer has been renamed to "sandwich". "tess4" is
now a deprecated alias for "sandwich".
## v5.1
- Files with pages larger than 200" (5080 mm) in either dimension are
now supported with `--output-type=pdf` with the page size preserved
(in the PDF specification this feature is called UserUnit scaling).
Due to Ghostscript limitations this is not available in conjunction
with PDF/A output.
## v5.0.1
- Fixed {issue}`169`,
exception due to failure to create sidecar text files on some
versions of Tesseract 3.04, including the jbarlow83/ocrmypdf Docker
image
## v5.0
- Backward incompatible changes
> - Support for Python 3.4 dropped. Python 3.5 is now required.
> - Support for Tesseract 3.02 and 3.03 dropped. Tesseract 3.04 or
> newer is required. Tesseract 4.00 (alpha) is supported.
> - The OCRmyPDF.sh script was removed.
- Add a new feature, `--sidecar`, which allows creating "sidecar"
text files which contain the OCR results in plain text. These OCR
text is more reliable than extracting text from PDFs. Closes
{issue}`126`.
- New feature: `--pdfa-image-compression`, which allows overriding
Ghostscript's lossy-or-lossless image encoding heuristic and making
all images JPEG encoded or lossless encoded as desired. Fixes
{issue}`163`.
- Fixed {issue}`143`, added
`--quiet` to suppress "INFO" messages
- Fixed {issue}`164`, a typo
- Removed the command line parameters `-n` and `--just-print` since
they have not worked for some time (reported as Ubuntu bug
[#1687308](https://bugs.launchpad.net/ubuntu/+source/ocrmypdf/+bug/1687308))
+173
View File
@@ -0,0 +1,173 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v6
## v6.2.5
- Disable a failing test due to Tesseract 4.0rc1 behavior change.
Previously, Tesseract would exit with an error message if its
configuration was invalid, and OCRmyPDF would intercept this message.
Now Tesseract issues a warning, which OCRmyPDF v6.2.5 may relay or
ignore. (In v7.x, OCRmyPDF will respond to the warning.)
- This release branch no longer supports using the optional PyMuPDF
installation, since it was removed in v7.x.
- This release branch no longer supports macOS. macOS users should
upgrade to v7.x.
## v6.2.4
- Backport Ghostscript 9.25 compatibility fixes, which removes support
for setting Unicode metadata
- Backport blacklisting Ghostscript 9.24
- Older versions of Ghostscript are still supported
## v6.2.3
- Fixed compatibility with img2pdf >= 0.3.0 by rejecting input images
that have an alpha channel
- This version will be included in Ubuntu 18.10
## v6.2.2
- Backport compatibility fixes for Python 3.7 and ruffus 2.7.0 from
v7.0.0
- Backport fix to ignore masks when deciding what colors are on a page
- Backport some minor improvements from v7.0.0: better argument
validation and warnings about the Tesseract 4.0.0 `--user-words`
regression
## v6.2.1
- Fixed recent versions of Tesseract (after 4.0.0-beta1) not being
detected as supporting the `sandwich` renderer ({issue}`271`).
## v6.2.0
- **Docker**: The Docker image `ocrmypdf-tess4` has been removed. The
main Docker images, `ocrmypdf` and `ocrmypdf-polyglot` now use
Ubuntu 18.04 as a base image, and as such Tesseract 4.0.0-beta1 is
now the Tesseract version they use. There is no Docker image based on
Tesseract 3.05 anymore.
- Creation of PDF/A-3 is now supported. However, there is no ability to
attach files to PDF/A-3.
- Lists more reasons why the file size might grow.
- Fixed {issue}`262`,
`--remove-background` error on PDFs contained colormapped
(paletted) images.
- Fixed another XMP metadata validation issue, in cases where the input
file's creation date has no timezone and the creation date is not
overridden.
## v6.1.5
- Fixed {issue}`253`, a
possible division by zero when using the `hocr` renderer.
- Fixed incorrectly formatted `<xmp:ModifyDate>` field inside XMP
metadata for PDF/As. veraPDF flags this as a PDF/A validation
failure. The error is caused the timezone and final digit of the
seconds of modified time to be omitted, so at worst the modification
time stamp is rounded to the nearest 10 seconds.
## v6.1.4
- Fixed {issue}`248`
`--clean` argument may remove OCR from left column of text on
certain documents. We now set `--layout none` to suppress this.
- The test cache was updated to reflect the change above.
- Change test suite to accommodate Ghostscript 9.23's new ability to
insert JPEGs into PDFs without transcoding.
- XMP metadata in PDFs is now examined using `defusedxml` for safety.
- If an external process exits with a signal when asked to report its
version, we now print the system error message instead of suppressing
it. This occurred when the required executable was found but was
missing a shared library.
- qpdf 7.0.0 or newer is now required as the test suite can no longer
pass without it.
### Notes
- An apparent [regression in Ghostscript
9.23](https://bugs.ghostscript.com/show_bug.cgi?id=699216) will
cause some ocrmypdf output files to become invalid in rare cases; the
workaround for the moment is to set `--force-ocr`.
## v6.1.3
- Fixed {issue}`247`,
`/CreationDate` metadata not copied from input to output.
- A warning is now issued when Python 3.5 is used on files with a large
page count, as this case is known to regress to single core
performance. The cause of this problem is unknown.
## v6.1.2
- Upgrade to PyMuPDF v1.12.5 which includes a more complete fix to
{issue}`239`.
- Add `defusedxml` dependency.
## v6.1.1
- Fixed text being reported as found on all pages if PyMuPDF is not
installed.
## v6.1.0
- PyMuPDF is now an optional but recommended dependency, to alleviate
installation difficulties on platforms that have less access to
PyMuPDF than the author anticipated. (For version 6.x only) install
OCRmyPDF with `pip install ocrmypdf[fitz]` to use it to its full
potential.
- Fixed `FileExistsError` that could occur if OCR timed out while it
was generating the output file.
({issue}`218`)
- Fixed table of contents/bookmarks all being redirected to page 1 when
generating a PDF/A (with PyMuPDF). (Without PyMuPDF the table of
contents is removed in PDF/A mode.)
- Fixed "RuntimeError: invalid key in dict" when table of
contents/bookmarks titles contained the character `)`.
({issue}`239`)
- Added a new argument `--skip-repair` to skip the initial PDF repair
step if the PDF is already well-formed (because another program
repaired it).
## v6.0.0
- The software license has been changed to GPLv3 [it has since changed again].
Test resource files and some individual sources may have other licenses.
- OCRmyPDF now depends on
[PyMuPDF](https://pymupdf.readthedocs.io/en/latest/installation/).
Including PyMuPDF is the primary reason for the change to GPLv3.
- Other backward incompatible changes
- The `OCRMYPDF_TESSERACT`, `OCRMYPDF_QPDF`, `OCRMYPDF_GS` and
`OCRMYPDF_UNPAPER` environment variables are no longer used.
Change `PATH` if you need to override the external programs
OCRmyPDF uses.
- The `ocrmypdf` package has been moved to `src/ocrmypdf` to
avoid issues with accidental import.
- The function `ocrmypdf.exec.get_program` was removed.
- The deprecated module `ocrmypdf.pageinfo` was removed.
- The `--pdf-renderer tess4` alias for `sandwich` was removed.
- Fixed an issue where OCRmyPDF failed to detect existing text on
pages, depending on how the text and fonts were encoded within the
PDF. ({issue}`233,232`)
- Fixed an issue that caused dramatic inflation of file sizes when
`--skip-text --output-type pdf` was used. OCRmyPDF now removes
duplicate resources such as fonts, images and other objects that it
generates. ({issue}`237`)
- Improved performance of the initial page splitting step. Originally
this step was not believed to be expensive and ran in a process.
Large file testing revealed it to be a bottleneck, so it is now
parallelized. On a 700 page file with quad core machine, this change
saves about 2 minutes. ({issue}`234`)
- The test suite now includes a cache that can be used to speed up test
runs across platforms. This also does not require computing
checksums, so it's faster. ({issue}`217`)
+288
View File
@@ -0,0 +1,288 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v7
## v7.4.0
- `--force-ocr` may now be used with the new `--threshold` and
`--mask-barcodes` features
- pikepdf >= 0.9.1 is now required.
- Changed metadata handling to pikepdf 0.9.1. As a result, metadata
handling of non-ASCII characters in Ghostscript 9.25 or later is
fixed.
- chardet >= 3.0.4 is temporarily listed as required. pdfminer.six
depends on it, but the most recent release does not specify this
requirement.
({issue}`326`)
- python-xmp-toolkit and libexempi are no longer required.
- A new Docker image is now being provided for users who wish to access
OCRmyPDF over a simple HTTP interface, instead of the command line.
- Increase tolerance of PDFs that overflow or underflow the PDF
graphics stack.
({issue}`325`)
## v7.3.1
- Fixed performance regression from v7.3.0; fast page analysis was not
selected when it should be.
- Fixed a few exceptions related to the new `--mask-barcodes` feature
and improved argument checking
- Added missing detection of TrueType fonts that lack a Unicode mapping
## v7.3.0
- Added a new feature `--redo-ocr` to detect existing OCR in a file,
remove it, and redo the OCR. This may be particularly helpful for
anyone who wants to take advantage of OCR quality improvements in
Tesseract 4.0. Note that OCR added by OCRmyPDF before version 3.0
cannot be detected since it was not properly marked as invisible text
in the earliest versions. OCR that constructs a font from visible
text, such as Adobe Acrobat's ClearScan.
- OCRmyPDF's content detection is generally more sophisticated. It
learns more about the contents of each PDF and makes better
recommendations:
- OCRmyPDF can now detect when a PDF contains text that cannot be
mapped to Unicode (meaning it is readable to human eyes but
copy-pastes as gibberish). In these cases it recommends
`--force-ocr` to make the text searchable.
- PDFs containing vector objects are now rendered at more
appropriate resolution for OCR.
- We now exit with an error for PDFs that contain Adobe LiveCycle
Designer's dynamic XFA forms. Currently the open source community
does not have tools to work with these files.
- OCRmyPDF now warns when a PDF that contains Adobe AcroForms, since
such files probably do not need OCR. It can work with these files.
- Added three new **experimental** features to improve OCR quality in
certain conditions. The name, syntax and behavior of these arguments
is subject to change. They may also be incompatible with some other
features.
- `--remove-vectors` which strips out vector graphics. This can
improve OCR quality since OCR will not search artwork for readable
text; however, it currently removes "text as curves" as well.
- `--mask-barcodes` to detect and suppress barcodes in files. We
have observed that barcodes can interfere with OCR because they
are "text-like" but not actually textual.
- `--threshold` which uses a more sophisticated thresholding
algorithm than is currently in use in Tesseract OCR. This works
around a [known issue in Tesseract
4.0](https://github.com/tesseract-ocr/tesseract/issues/1990)
with dark text on bright backgrounds.
- Fixed an issue where an error message was not reported when the
installed Ghostscript was very old.
- The PDF optimizer now saves files with object streams enabled when
the optimization level is `--optimize 1` or higher (the default).
This makes files a little bit smaller, but requires PDF 1.5. PDF 1.5
was first released in 2003 and is broadly supported by PDF viewers,
but some rudimentary PDF parsers such as PyPDF2 do not understand
object streams. You can use the command line tool
`qpdf --object-streams=disable` or
[pikepdf](https://github.com/pikepdf/pikepdf) library to remove
them.
- New dependency: pdfminer.six 20181108. Note this is a fork of the
Python 2-only pdfminer.
- Deprecation notice: At the end of 2018, we will be ending support for
Python 3.5 and Tesseract 3.x. OCRmyPDF v7 will continue to work with
older versions.
## v7.2.1
- Fixed compatibility with an API change in pikepdf 0.3.5.
- A kludge to support Leptonica versions older than 1.72 in the test
suite was dropped. Older versions of Leptonica are likely still
compatible. The only impact is that a portion of the test suite will
be skipped.
## v7.2.0
**Lossy JBIG2 behavior change**
A user reported that ocrmypdf was in fact using JBIG2 in **lossy**
compression mode. This was not the intended behavior. Users should
[review the technical concerns with JBIG2 in lossy
mode](https://abbyy.technology/en:kb:tip:jbig2_compression_and_ocr)
and decide if this is a concern for their use case.
JBIG2 lossy mode does achieve higher compression ratios than any other
monochrome 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.
Only users who have reviewed the concerns with JBIG2 in lossy mode
should opt-in. As such, lossy mode JBIG2 is only turned on when the new
argument `--jbig2-lossy` is issued. This is independent of the setting
for `--optimize`.
Users who did not install an optional JBIG2 encoder are unaffected.
(Thanks to user 'bsdice' for reporting this issue.)
**Other issues**
- When the image optimizer quantizes an image to 1 bit per pixel, it
will now attempt to further optimize that image as CCITT or JBIG2,
instead of keeping it in the "flate" encoding which is not efficient
for 1 bpp images.
({issue}`297`)
- Images in PDFs that are used as soft masks (i.e. transparency masks
or alpha channels) are now excluded from optimization.
- Fixed handling of Tesseract 4.0-rc1 which now accepts invalid
Tesseract configuration files, which broke the test suite.
## v7.1.0
- Improve the performance of initial text extraction, which is done to
determine if a file contains existing text of some kind or not. On
large files, this initial processing is now about 20x times faster.
({issue}`299`)
- pikepdf 0.3.3 is now required.
- Fixed {issue}`231`, a
problem with JPEG2000 images where image metadata was only available
inside the JPEG2000 file.
- Fixed some additional Ghostscript 9.25 compatibility issues.
- Improved handling of KeyboardInterrupt error messages.
({issue}`301`)
- README.md is now served in GitHub markdown instead of
reStructuredText.
## v7.0.6
- Blacklist Ghostscript 9.24, now that 9.25 is available and fixes many
regressions in 9.24.
## v7.0.5
- Improve capability with Ghostscript 9.24, and enable the JPEG
passthrough feature when this version in installed.
- Ghostscript 9.24 lost the ability to set PDF title, author, subject
and keyword metadata to Unicode strings. OCRmyPDF will set ASCII
strings and warn when Unicode is suppressed. Other software may be
used to update metadata. This is a short term work around.
- PDFs generated by Kodak Capture Desktop, or generally PDFs that
contain indirect references to null objects in their table of
contents, would have an invalid table of contents after processing by
OCRmyPDF that might interfere with other viewers. This has been
fixed.
- Detect PDFs generated by Adobe LiveCycle, which can only be displayed
in Adobe Acrobat and Reader currently. When these are encountered,
exit with an error instead of performing OCR on the "Please wait"
error message page.
## v7.0.4
- Fixed exception thrown when trying to optimize a certain type of PNG
embedded in a PDF with the `-O2`
- Update to pikepdf 0.3.2, to gain support for optimizing some
additional image types that were previously excluded from
optimization (CMYK and grayscale). Fixes
{issue}`285`.
## v7.0.3
- Fixed {issue}`284`, an error
when parsing inline images that have are also image masks, by
upgrading pikepdf to 0.3.1
## v7.0.2
- Fixed a regression with `--rotate-pages` on pages that already had
rotations applied.
({issue}`279`)
- Improve quality of page rotation in some cases by rasterizing a
higher quality preview image.
({issue}`281`)
## v7.0.1
- Fixed compatibility with img2pdf >= 0.3.0 by rejecting input images
that have an alpha channel
- Add forward compatibility for pikepdf 0.3.0 (unrelated to img2pdf)
- Various documentation updates for v7.0.0 changes
## v7.0.0
- The core algorithm for combining OCR layers with existing PDF pages
has been rewritten and improved considerably. PDFs are no longer
split into single page PDFs for processing; instead, images are
rendered and the OCR results are grafted onto the input PDF. The new
algorithm uses less temporary disk space and is much more performant
especially for large files.
- New dependency: [pikepdf](https://github.com/pikepdf/pikepdf).
pikepdf is a powerful new Python PDF library driving the latest
OCRmyPDF features, built on the QPDF C++ library (libqpdf).
- New feature: PDF optimization with `-O` or `--optimize`. After
OCR, OCRmyPDF will perform image optimizations relevant to OCR PDFs.
- If a JBIG2 encoder is available, then monochrome images will be
converted, with the potential for huge savings on large black and
white images, since JBIG2 is far more efficient than any other
monochrome (bi-level) compression. (All known US patents related
to JBIG2 have probably expired, but it remains the responsibility
of the user to supply a JBIG2 encoder such as
[jbig2enc](https://github.com/agl/jbig2enc). OCRmyPDF does not
implement JBIG2 encoding.)
- If `pngquant` is installed, OCRmyPDF will optionally use it to
perform lossy quantization and compression of PNG images.
- The quality of JPEGs can also be lowered, on the assumption that a
lower quality image may be suitable for storage after OCR.
- This image optimization component will eventually be offered as an
independent command line utility.
- Optimization ranges from `-O0` through `-O3`, where `0`
disables optimization and `3` implements all options. `1`, the
default, performs only safe and lossless optimizations. (This is
similar to GCC's optimization parameter.) The exact type of
optimizations performed will vary over time.
- Small amounts of text in the margins of a page, such as watermarks,
page numbers, or digital stamps, will no longer prevent the rest of a
page from being OCRed when `--skip-text` is issued. This behavior
is based on a heuristic.
- Removed features
- The deprecated `--pdf-renderer tesseract` PDF renderer was
removed.
- `-g`, the option to generate debug text pages, was removed
because it was a maintenance burden and only worked in isolated
cases. HOCR pages can still be previewed by running the
hocrtransform.py with appropriate settings.
- Removed dependencies
- `PyPDF2`
- `defusedxml`
- `PyMuPDF`
- The `sandwich` PDF renderer can be used with all supported versions
of Tesseract, including that those prior to v3.05 which don't support
`-c textonly`. (Tesseract v4.0.0 is recommended and more
efficient.)
- `--pdf-renderer auto` option and the diagnostics used to select a
PDF renderer now work better with old versions, but may make
different decisions than past versions.
- If everything succeeds but PDF/A conversion fails, a distinct return
code is now returned (`ExitCode.pdfa_conversion_failed (10)`) where
this situation previously returned
`ExitCode.invalid_output_pdf (4)`. The latter is now returned only
if there is some indication that the output file is invalid.
- Notes for downstream packagers
- There is also a new dependency on `python-xmp-toolkit` which in
turn depends on `libexempi3`.
- It may be necessary to separately `pip install pycparser` to
avoid [another Python 3.7
issue](https://github.com/eliben/pycparser/pull/135).
+153
View File
@@ -0,0 +1,153 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v8
## v8.3.2
- Dropped workaround for macOS that allowed it work without pdfminer.six,
now a proper sdist release of pdfminer.six is available.
- pikepdf 1.5.0 is now required.
## v8.3.1
- Fixed an issue where PDFs with malformed metadata would be rendered as
blank pages. {issue}`398`.
## v8.3.0
- Improved the strategy for updating pages when a new image of the page
was produced. We now attempt to preserve more content from the
original file, for annotations in particular.
- For PDFs with more than 100 pages and a sequence where one PDF page
was replaced and one or more subsequent ones were skipped, an
intermediate file would be corrupted while grafting OCR text, causing
processing to fail. This is a regression, likely introduced in
v8.2.4.
- Previously, we resized the images produced by Ghostscript by a small
number of pixels to ensure the output image size was an exactly what
we wanted. Having discovered a way to get Ghostscript to produce the
exact image sizes we require, we eliminated the resizing step.
- Command line completions for `bash` are now available, in addition
to `fish`, both in `misc/completion`. Package maintainers, please
install these so users can take advantage.
- Updated requirements.
- pikepdf 1.3.0 is now required.
## v8.2.4
- Fixed a false positive while checking for a certain type of PDF that
only Acrobat can read. We now more accurately detect Acrobat-only
PDFs.
- OCRmyPDF holds fewer open file handles and is more prompt about
releasing those it no longer needs.
- Minor optimization: we no longer traverse the table of contents to
ensure all references in it are resolved, as changes to libqpdf have
made this unnecessary.
- pikepdf 1.2.0 is now required.
## v8.2.3
- Fixed that `--mask-barcodes` would occasionally leave a unwanted
temporary file named `junkpixt` in the current working folder.
- Fixed (hopefully) handling of Leptonica errors in an environment
where a non-standard `sys.stderr` is present.
- Improved help text for `--verbose`.
## v8.2.2
- Fixed a regression from v8.2.0, an exception that occurred while
attempting to report that `unpaper` or another optional dependency
was unavailable.
- In some cases, `ocrmypdf [-c|--clean]` failed to exit with an error
when `unpaper` is not installed.
## v8.2.1
- This release was canceled.
## v8.2.0
- A major improvement to our Docker image is now available thanks to
hard work contributed by @mawi12345. The new Docker image,
ocrmypdf-alpine, is based on Alpine Linux, and includes most of the
functionality of three existed images in a smaller package. This
image will replace the main Docker image eventually but for now all
are being built. [See documentation for
details](https://ocrmypdf.readthedocs.io/en/latest/docker.html).
- Documentation reorganized especially around the use of Docker images.
- Fixed a problem with PDF image optimization, where the optimizer
would unnecessarily decompress and recompress PNG images, in some
cases losing the benefits of the quantization it just had just
performed. The optimizer is now capable of embedding PNG images into
PDFs without transcoding them.
- Fixed a minor regression with lossy JBIG2 image optimization. All
JBIG2 candidates images were incorrectly placed into a single
optimization group for the whole file, instead of grouping pages
together. This usually makes a larger JBIG2Globals dictionary and
results in inferior compression, so it worked less well than
designed. However, quality would not be impacted. Lossless JBIG2 was
entirely unaffected.
- Updated dependencies, including pikepdf to 1.1.0. This fixes
{issue}`358`.
- The install-time version checks for certain external programs have
been removed from setup.py. These tests are now performed at
run-time.
- The non-standard option to override install-time checks
(`setup.py install --force`) is now deprecated and prints a
warning. It will be removed in a future release.
## v8.1.0
- Added a feature, `--unpaper-args`, which allows passing arbitrary
arguments to `unpaper` when using `--clean` or `--clean-final`.
The default, very conservative unpaper settings are suppressed.
- The argument `--clean-final` now implies `--clean`. It was
possible to issue `--clean-final` on its before this, but it would
have no useful effect.
- Fixed an exception on traversing corrupt table of contents entries
(specifically, those with invalid destination objects)
- Fixed an issue when using `--tesseract-timeout` and image
processing features on a file with more than 100 pages.
{issue}`347`
- OCRmyPDF now always calls `os.nice(5)` to signal to operating
systems that it is a background process.
## v8.0.1
- Fixed an exception when parsing PDFs that are missing a required
field. {issue}`325`
- pikepdf 1.0.5 is now required, to address some other PDF parsing
issues.
## v8.0.0
No major features. The intent of this release is to sever support for
older versions of certain dependencies.
**Breaking changes**
- Dropped support for Tesseract 3.x. Tesseract 4.0 or newer is now
required.
- Dropped support for Python 3.5.
- Some `ocrmypdf.pdfa` APIs that were deprecated in v7.x were
removed. This functionality has been moved to pikepdf.
**Other changes**
- Fixed an unhandled exception when attempting to mask barcodes.
{issue}`322`
- It is now possible to use ocrmypdf without pdfminer.six, to support
distributions that do not have it or cannot currently use it (e.g.
Homebrew). Downstream maintainers should include pdfminer.six if
possible.
- A warning is now issue when PDF/A conversion removes some XMP
metadata from the input PDF. (Only a "whitelist" of certain XMP
metadata types are allowed in PDF/A.)
- Fixed several issues that caused PDF/As to be produced with
nonconforming XMP metadata (would fail validation with veraPDF).
- Fixed some instances where invalid DocumentInfo from a PDF cause XMP
metadata creation to fail.
- Fixed a few documentation problems.
- pikepdf 1.0.2 is now required.
+252
View File
@@ -0,0 +1,252 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v9
## v9.8.2
- Fixed an issue where OCRmyPDF would ignore text inside Form XObject when
making certain decisions about whether a document already had text.
- Fixed file size increase warning to take overhead of small files into account.
- Added instructions for installing on Cygwin.
## v9.8.1
- Fixed an issue where unexpected files in the `%PROGRAMFILES%\gs` directory
(Windows) caused an exception.
- Mark pdfminer.six 20200517 as supported.
- If jbig2enc is missing and optimization is requested, a warning is issued
instead of an error, which was the intended behavior.
- Documentation updates.
## v9.8.0
- Fixed issue where only the first PNG (FlateDecode) image in a file would be
considered for optimization. File sizes should be improved from here on.
- Fixed a startup crash when the chosen language was Japanese ({issue}`543`).
- Added options to configure polling and log level to watcher.py.
## v9.7.2
- Fixed an issue with `ocrmypdf.ocr(...language=)` not accepting a list of
languages as documented.
- Updated setup.py to confirm that pdfminer.six version 20200402 is supported.
## v9.7.1
- Fixed version check failing when used with qpdf 10.0.0.
- Added some missing type annotations.
- Updated documentation to warn about need for "ifmain" guard and Windows.
## v9.7.0
- Fixed an error in watcher.py if `OCR_JSON_SETTINGS` was not defined.
- Ghostscript 9.51 is now blacklisted, due to numerous problems with this version.
- Added a workaround for a problem with "txtwrite" in Ghostscript 9.52.
- Fixed an issue where the incorrect number of threads used was shown when
`OMP_THREAD_LIMIT` was manipulated.
- Removed a possible performance bottlenecks for files that use hundreds to
thousands of images on the same page.
- Documentation improvements.
- Optimization will now be applied to some monochrome images that have a color
profile defined instead of only black and white.
- ICC profiles are consulted when determining the simplified colorspace of an
image.
## v9.6.1
- Documentation improvements - thanks to many users for their contributions!
> - Fixed installation instructions for ArchLinux (@pigmonkey)
> - Updated installation instructions for FreeBSD and other OSes (@knobix)
> - Added instructions for using Docker Compose with watchdog (@ianalexander,
> @deisi)
> - Other miscellany (@mb720, @toy, @caiofacchinato)
> - Some scripts provided in the documentation have been migrated out so that
> they can be copied out as whole files, and to ensure syntax checking
> is maintained.
- Fixed an error that caused bash completions to fail on macOS. ({issue}`502,504`;
@AlexanderWillner)
- Fixed a rare case where OCRmyPDF threw an exception while processing a PDF
with the wrong object type in its `/Trailer /Info`. The error is now logged
and incorrect object is ignored. ({issue}`497`)
- Removed potentially non-free file `enron1.pdf` and simplified the test that
used it.
- Removed potentially non-free file `misc/media/logo.afdesign`.
## v9.6.0
- Fixed a regression with transferring metadata from the input PDF to the output
PDF in certain situations.
- pdfminer.six is now supported up to version 2020-01-24.
- Messages are explaining page rotation decisions are now shown at the standard
verbosity level again when `--rotate-pages`. In some previous version they
were set to debug level messages that only appeared with the parameter `-v1`.
- Improvements to `misc/watcher.py`. Thanks to @ianalexander and @svenihoney.
- Documentation improvements.
## v9.5.0
- Added API functions to measure OCR quality.
- Modest improvements to handling PDFs with difficult/non compliant metadata.
## v9.4.0
- Updated recommended dependency versions.
- Improvements to test coverage and changes to facilitate better measurement of
test coverage, such as when tests run in subprocesses.
- Improvements to error messages when Leptonica is not installed correctly.
- Fixed use of pytest "session scope" that may have caused some intermittent
CI failures.
- When the argument `--keep-temporary-files` or verbosity is set to `-v1`,
a debug log file is generated in the working temporary folder.
## v9.3.0
- Improved native Windows support: we now check in the obvious places in
the "Program Files" folders installations of Tesseract and Ghostscript,
rather than relying on the user to edit `PATH` to specify their location.
The `PATH` environment variable can still be used to differentiate when
multiple installations are present or the programs are installed to non-
standard locations.
- Fixed an exception on parsing Ghostscript error messages.
- Added an improved example demonstrating how to set up a watched folder
for automated OCR processing (thanks to @ianalexander for the contribution).
## v9.2.0
- Native Windows is now supported.
- Continuous integration moved to Azure Pipelines.
- Improved test coverage and speed of tests.
- Fixed an issue where a page that was originally a JPEG would be saved as a
PNG, increasing file size. This occurred only when a preprocessing option
was selected along with `--output-type=pdf` and all images on the original
page were JPEGs. Regression since v7.0.0.
- OCRmyPDF no longer depends on the QPDF executable `qpdf` or `libqpdf`.
It uses pikepdf (which in turn depends on `libqpdf`). Package maintainers
should adjust dependencies so that OCRmyPDF no longer calls for libqpdf on
its own. For users of Python binary wheels, this change means a separate
installation of QPDF is no longer necessary. This change is mainly to
simplify installation on Windows.
- Fixed a rare case where log messages from Tesseract would be discarded.
- Fixed incorrect function signature for pixFindPageForeground, causing
exceptions on certain platforms/Leptonica versions.
## v9.1.1
- Expand the range of pdfminer.six versions that are supported.
- Fixed Docker build when using pikepdf 1.7.0.
- Fixed documentation to recommend using pip from get-pip.py.
## v9.1.0
- Improved diagnostics when file size increases at output. Now warns if JBIG2
or pngquant were not available.
- pikepdf 1.7.0 is now required, to pick up changes that remove the need for
a source install on Linux systems running Python 3.8.
## v9.0.5
- The Alpine Docker image (jbarlow83/ocrmypdf-alpine) has been dropped due to
the difficulties of supporting Alpine Linux.
- The primary Docker image (jbarlow83/ocrmypdf) has been improved to take on
the extra features that used to be exclusive to the Alpine image.
- No changes to application code.
- pdfminer.six version 20191020 is now supported.
## v9.0.4
- Fixed compatibility with Python 3.8 (but requires source install for the moment).
- Fixed Tesseract settings for `--user-words` and `--user-patterns`.
- Changed to pikepdf 1.6.5 (for Python 3.8).
- Changed to Pillow 6.2.0 (to mitigate a security vulnerability in earlier Pillow).
- A debug message now mentions when English is automatically selected if the locale
is not English.
## v9.0.3
- Embed an encoded version of the sRGB ICC profile in the intermediate
Postscript file (used for PDF/A conversion). Previously we included the
filename, which required Postscript to run with file access enabled. For
security, Ghostscript 9.28 enables `-dSAFER` and as such, no longer
permits access to any file by default. This fix is necessary for
compatibility with Ghostscript 9.28.
- Exclude a test that sometimes times out and fails in continuous integration
from the standard test suite.
## v9.0.2
- The image optimizer now skips optimizing flate (PNG) encoded images in some
situations where the optimization effort was likely wasted.
- The image optimizer now ignores images that specify arbitrary decode arrays,
since these are rare.
- Fixed an issue that caused inversion of black and white in monochrome images.
We are not certain but the problem seems to be linked to Leptonica 1.76.0 and
older.
- Fixed some cases where the test suite failed if
English or German Tesseract language packs were not installed.
- Fixed a runtime error if the Tesseract English language is not installed.
- Improved explicit closing of Pillow images after use.
- Actually fixed of Alpine Docker image build.
- Changed to pikepdf 1.6.3.
## v9.0.1
- Fixed test suite failing when either of optional dependencies unpaper and
pngquant were missing.
- Attempted fix of Alpine Docker image build.
- Documented that FreeBSD ports are now available.
- Changed to pikepdf 1.6.1.
## v9.0.0
**Breaking changes**
- The `--mask-barcodes` experimental feature has been dropped due to poor
reliability and occasional crashes, both due to the underlying library that
implements this feature (Leptonica).
- The `-v` (verbosity level) parameter now accepts only `0`, `1`, and
`2`.
- Dropped support for Tesseract 4.00.00-alpha releases. Tesseract 4.0 beta and
later remain supported.
- Dropped the `ocrmypdf-polyglot` and `ocrmypdf-webservice` images.
**New features**
- Added a high level API for applications that want to integrate OCRmyPDF.
Special thanks to Martin Wind (@mawi1988) whose made significant contributions
to this effort.
- Added progress bars for long-running steps. ■■■■■■■□□
- We now create linearized ("fast web view") PDFs by default. The new parameter
`--fast-web-view` provides control over when this feature is applied.
- Added a new `--pages` feature to limit OCR to only a specific page range.
The list may contain commas or single pages, such as `1, 3, 5-11`.
- When the number of pages is small compared to the number of allowed jobs, we
run Tesseract in multithreaded (OpenMP) mode when available. This should
improve performance on files with low page counts.
- Removed dependency on `ruffus`, and with that, the non-reentrancy
restrictions that previous made an API impossible.
- Output and logging messages overhauled so that ocrmypdf may be integrated
into applications that use the logging module.
- pikepdf 1.6.0 is required.
- Added a logo. 😊
**Bug fixes**
- Pages with vector artwork are treated as full color. Previously, vectors
were ignored when considering the colorspace needed to cover a page, which
could cause loss of color under certain settings.
- Test suite now spawns processes less frequently, allowing more accurate
measurement of code coverage.
- Improved test coverage.
- Fixed a rare division by zero (if optimization produced an invalid file).
- Updated Docker images to use newer versions.
- Fixed images encoded as JBIG2 with a colorspace other than `/DeviceGray`
were not interpreted correctly.
- Fixed a OCR text-image registration (i.e. alignment) problem when the page
when MediaBox had a nonzero corner.
+121
View File
@@ -0,0 +1,121 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v10
## v10.3.3
- Fixed a "KeyError: 'dpi'" error message when using `--threshold` on an image.
({issue}`607`)
## v10.3.2
- Fixed a case where we reported "no reason" for a file size increase, when we
could determine the reason.
- Enabled support for pdfminer.six 20200726.
## v10.3.1
- Fixed a number of test suite failures with pdfminer.six older than version 20200402.
- Enabled support for pdfminer.six 20200720.
## v10.3.0
- Fixed an issue where we would consider images that were already JBIG2-encoded
for optimization, potentially producing a less optimized image than the original.
We do not believe this issue would ever cause an image to loss fidelity.
- Where available, pikepdf memory mapping is now used. This improves performance.
- When Leptonica 1.79+ is installed, use its new error handling API to avoid
a "messy" redirection of stderr which was necessary to capture its error
messages.
- For older versions of Leptonica, added a new thread level lock. This fixes a
possible race condition in handling error conditions in Leptonica (although
there is no evidence it ever caused issues in practice).
- Documentation improvements and more type hinting.
## v10.2.1
- Disabled calculation of text box order with pdfminer. We never needed this result
and it is expensive to calculate on files with complex pre-existing text.
- Fixed plugin manager to accept `Path(plugin)` as a path to a plugin.
- Fixed some typing errors.
- Documentation improvements.
## v10.2.0
- Update Docker image to use Ubuntu 20.04.
- Fixed issue PDF/A acquires title "Untitled" after conversion. ({issue}`582`)
- Fixed a problem where, when using `--pdf-renderer hocr`, some text would
be missing from the output when using a more recent version of Tesseract.
Tesseract began adding more detailed markup about the semantics of text
that our HOCR transform did not recognize, so it ignored them. This option is
not the default. If necessary `--redo-ocr` also redoing OCR to fix such issues.
- Fixed an error in Python 3.9 beta, due to removal of deprecated
`Element.getchildren()`. ({issue}`584`)
- Implemented support using the API with `BytesIO` and other file stream objects.
({issue}`545`)
## v10.1.1
- Fixed `OMP_THREAD_LIMIT` set to invalid value error messages on some input
files. (The error was harmless, apart from less than optimal performance in
some cases.)
## v10.1.0
- Previously, we `--clean-final` would cause an unpaper-cleaned page image to
be produced twice, which was necessary in some cases but not in general. We
now take this optimization opportunity and reuse the image if possible.
- We now provide PNG files as input to unpaper, since it accepts them, instead
of generating PPM files which can be very large. This can improve performance
and temporary disk usage.
- Documentation updated for plugins.
## v10.0.1
- Fixed regression when `-l lang1+lang2` is used from command line.
## v10.0.0
**Breaking changes**
- Support for pdfminer.six version 20181108 has been dropped, along with a
monkeypatch that made this version work.
- Output messages are now displayed in color (when supported by the terminal)
and prefixes describing the severity of the message are removed. As such
programs that parse OCRmyPDF's log message will need to be revised. (Please
consider using OCRmyPDF as a library instead.)
- The minimum version for certain dependencies has increased.
- Many API changes; see developer changes.
- The Python libraries pluggy and coloredlogs are now required.
**New features and improvements**
- PDF page scanning is now parallelized across CPUs, speeding up this phase
dramatically for files with a high page counts.
- PDF page scanning is optimized, addressing some performance regressions.
- PDF page scanning is no longer run on pages that are not selected when the
`--pages` argument is used.
- PDF page scanning is now independent of Ghostscript, ending our past reliance
on this occasionally unstable feature in Ghostscript.
- A plugin architecture has been added, currently allowing one to more easily
use a different OCR engine or PDF renderer from Tesseract and Ghostscript,
respectively. A plugin can also override some decisions, such changing
the OCR settings after initial scanning.
- Colored log messages.
**Developer changes**
- The test spoofing mechanism, used to test correct handling of failures in
Tesseract and Ghostscript, has been removed in favor of using plugins for
testing. The spoofing mechanism was fairly complex and required many special
hacks for Windows.
- Code describing the resolution in DPI of images was refactored into a
`ocrmypdf.helpers.Resolution` class.
- The module `ocrmypdf._exec` is now private to OCRmyPDF.
- The `ocrmypdf.hocrtransform` module has been updated to follow PEP8 naming
conventions.
- Ghostscript is no longer used for finding the location of text in PDFs, and
APIs related to this feature have been removed.
- Lots of internal reorganization to support plugins.
+235
View File
@@ -0,0 +1,235 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v11
## v11.7.3
- Exclude CCITT Group 3 images from being optimized. Some libraries
OCRmyPDF uses do not seem to handle this obscure compression format properly.
You may get errors or possible corrupted output images without this fix.
## v11.7.2
- Updated pinned versions in main.txt, primarily to upgrade Pillow to 8.1.2, due
to recently disclosed security vulnerabilities in that software.
- The `--sidecar` parameter now causes an exception if set to the same file as
the input or output PDF.
## v11.7.1
- Some exceptions while attempting image optimization were only logged at the debug
level, causing them to be suppressed. These errors are now logged appropriately.
- Improved the error message related to `--unpaper-args`.
- Updated documentation to mention the new conda distribution.
## v11.7.0
- We now support using `--sidecar` in conjunction with `--pages`; these arguments
used to be mutually exclusive. ({issue}`735`)
- Fixed a possible issue with PDF/A-1b generation. Acrobat complained that our PDFs use
object streams. More robust PDF/A validators like veraPDF don't consider this a
problem, but we'll honor Acrobat's objection from here on. This may increase file
size of PDF/A-1b files. PDF/A-2b files will not be affected.
## v11.6.2
- Fixed a regression where the wrong page orientation would be produced when using
arguments such as `--deskew --rotate-pages` ({issue}`730`).
## v11.6.1
- Fixed an issue with attempting optimize unusually narrow-width images by excluding
these images from optimization ({issue}`732`).
- Remove an obsolete compatibility shim for a version of pikepdf that is no longer
supported.
## v11.6.0
- OCRmyPDF will now automatically register plugins from the same virtual environment
with an appropriate setuptools entrypoint.
- Refactor the plugin manager to remove unnecessary complications and make plugin
registration more automatic.
- `PageContext` and `PdfContext` are now formally part of the API, as they
should have been, since they were part of `ocrmypdf.pluginspec`.
## v11.5.0
- Fixed an issue where the output page size might differ by a fractional amount
due to rounding, when `--force-ocr` was used and the page contained objects
with multiple resolutions.
- When determining the resolution at which to rasterize a page, we now consider
printed text on the page as requiring a higher resolution. This fixes issues
with certain pages being rendered with unacceptably low resolution text, but
may increase output file sizes in some workflows where low resolution text
is acceptable.
- Added a workaround to fix an exception that occurs when trying to
`import ocrmypdf.leptonica` on Apple ARM silicon (or potentially, other
platforms that do not permit write+executable memory).
## v11.4.5
- Fixed an issue where files may not be closed when the API is used.
- Improved `setup.cfg` with better settings for test coverage.
## v11.4.4
- Fixed `AttributeError: 'NoneType' object has no attribute 'userunit'` ({issue}`700`),
related to OCRmyPDF not properly forwarded an error message from pdfminer.six.
- Adjusted typing of some arguments.
- `ocrmypdf.ocr` now takes a `threading.Lock` for reasons outlined in the
documentation.
## v11.4.3
- Removed a redundant debug message.
- Test suite now asserts that most patched functions are called when they should be.
- Test suite now skips a test that fails on two particular versions of piekpdf.
## v11.4.2
- Fixed support for Cygwin, hopefully.
- watcher.py: Fixed an issue with the OCR_LOGLEVEL not being interpreted.
## v11.4.1
- Fixed an issue where invalid pages ranges passed using the `pages` argument,
such as "1-0" would cause unhandled exceptions.
- Accepted a user-contributed to the Synology demo script in misc/synology.py.
- Clarified documentation about change of temporary file location `ocrmypdf.io`.
- Fixed Python wheel tag which was incorrectly set to py35 even though we long
since dropped support for Python 3.5.
## v11.4.0
- When looking for Tesseract and Ghostscript, we now check the Windows Registry to
see if their installers registered the location of their executables. This should
help Windows users who have installed these programs to non-standard
locations.
- We now report on the progress of PDF/A conversion, since this operation is
sometimes slow.
- Improved command line completions.
- The prefix of the temporary folder OCRmyPDF creates has been changed from
`com.github.ocrmypdf` to `ocrmypdf.io`. Scripts that chose to depend on this
prefix may need to be adjusted. (This has always been an implementation detail so is
not considered part of the semantic versioning "contract".)
- Fixed {issue}`692`, where a particular file with malformed fonts would flood an
internal message cue by generating so many debug messages.
- Fixed an exception on processing hOCR files with no page record. Tesseract
is not known to generate such files.
## v11.3.4
- Fixed an error message 'called readLinearizationData for file that is not
linearized' that may occur when pikepdf 2.1.0 is used. (Upgrading to pikepdf
2.1.1 also fixes the issue.)
- File watcher now automatically includes `.PDF` in addition to `.pdf` to
better support case sensitive file systems.
- Some documentation and comment improvements.
## v11.3.3
- If unpaper outputs non-UTF-8 data, quietly fix this rather than choke on the
conversion. (Possibly addresses {issue}`671`.)
## v11.3.2
- Explicitly require pikepdf 2.0.0 or newer when running on Python 3.9. (There are
concerns about the stability of pybind11 2.5.x with Python 3.9, which is used in
pikepdf 1.x.)
- Fixed another issue related to page rotation.
- Fixed an issue where image marked as image masks were not properly considered
as optimization candidates.
- On some systems, unpaper seems to be unable to process the PNGs we offer it
as input. We now convert the input to PNM format, which unpaper always accepts.
Fixes {issue}`665` and {issue}`667`.
- DPI sent to unpaper is now rounded to a more reasonable number of decimal digits.
- Debug and error messages from unpaper were being suppressed.
- Some documentation tweaks.
## v11.3.1
- Declare support for new versions: pdfminer.six 20201018 and pikepdf 2.x
- Fixed warning related to `--pdfa-image-compression` that appears at the wrong
time.
## v11.3.0
- The "OCR" step is describing as "Image processing" in the output messages when
OCR is disabled, to better explain the application's behavior.
- Debug logs are now only created when run as a command line, and not when OCR
is performed for an API call. It is the calling application's responsibility
to set up logging.
- For PDFs with a low number of pages, we gathered information about the input PDF
in a thread rather than process (when there are more pages). When run as a
thread, we did not close the file handle to the working PDF, leaking one file
handle per call of `ocrmypdf.ocr`.
- Fixed an issue where debug messages send by child worker processes did not match
the log settings of parent process, causing messages to be dropped. This affected
macOS and Windows only where the parent process is not forked.
- Fixed the hookspec of rasterize_pdf_page to remove default parameters that
were not handled in an expected way by pluggy.
- Fixed another issue with automatic page rotation ({issue}`658`) due to the issue above.
## v11.2.1
- Fixed an issue where optimization of a 1-bit image with a color palette or
associated ICC that was optimized to JBIG2 could have its colors inverted.
## v11.2.0
- Fixed an issue with optimizing PNG-type images that had soft masks or image masks.
This is a regression introduced in (or about) v11.1.0.
- Improved type checking of the `plugins` parameter for the `ocrmypdf.ocr`
API call.
## v11.1.2
- Fixed hOCR renderer writing the text in roughly reverse order. This should not
affect reasonably smart PDF readers that properly locate the position of all
text, but may confuse those that rely on the order of objects in the content
stream. ({issue}`642`)
## v11.1.1
- We now avoid using named temporary files when using pngquant allowing containerized
pngquant installs to be used.
- Clarified an error message.
- Highest number of 1's in a release ever!
## v11.1.0
- Fixed page rotation issues: {issue}`634,589`.
- Fixed some cases where optimization created an invalid image such as a
1-bit "RGB" image: {issue}`629,620`.
- Page numbers are now displayed in debug logs when pages are being grafted.
- ocrmypdf.optimize.rewrite_png and ocrmypdf.optimize.rewrite_png_as_g4 were
marked deprecated. Strictly speaking these should have been internal APIs,
but they were never hidden.
- As a precaution, pikepdf mmap-based file access has been disabled due to a
rare race condition that causes a crash when certain objects are deallocated.
The problem is likely in pikepdf's dependency pybind11.
- Extended the example plugin to demonstrate conversion to mono.
## v11.0.2
- Fixed {issue}`612`, TypeError exception. Fixed by eliminating unnecessary repair of
input PDF metadata in memory.
## v11.0.1
- Blacklist pdfminer.six 20200720, which has a regression fixed in 20200726.
- Approve img2pdf 0.4 as it passes tests.
- Clarify that the GPL-3 portion of pdfa.py was removed with the changes in v11.0.0;
the debian/copyright file did not properly annotate this change.
## v11.0.0
- Project license changed to Mozilla Public License 2.0. Some miscellaneous
code is now under MIT license and non-code content/media remains under
CC-BY-SA 4.0. License changed with approval of all people who were found
to have contributed to GPLv3 licensed sections of the project. ({issue}`600`)
- Because the license changed, this is being treated as a major version number
change; however, there are no known breaking changes in functional behavior
or API compared to v10.x.
+181
View File
@@ -0,0 +1,181 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v12
## v12.7.2
- Fixed "invalid version number" error for Tesseract packaging with nonstandard
version "5.0.0-rc1.20211030".
- Fixed use of deprecated `importlib.resources.read_binary`.
- Replace some uses of string paths with `pathlib.Path`.
- Fixed a leaked file handle when using `--output-type none`.
- Removed shims to support versions of pikepdf that are no longer supported.
## v12.7.1
- Declare support for pdfminer.six v20211012.
## v12.7.0
- Fixed test suite failure when using pikepdf 3.2.0 that was compiled with pybind11
2.8.0. {issue}`843`
- Improve advice to user about using `--max-image-mpixels` if OCR fails for this
reason.
- Minor documentation fixes. (Thanks to @mara004.)
- Don't require importlib-metadata and importlib-resources backports on versions of
Python where the standard library implementation is sufficient.
(Thanks to Marco Genasci.)
## v12.6.0
- Implemented `--output-type=none` to skip producing PDFs for applications that
only want sidecar files ({issue}`787`).
- Fixed ambiguities in descriptions of behavior of `--jbig2-lossy`.
- Various improvements to documentation.
## v12.5.0
- Fixed build failure for the combination of PyPy 3.6 and pikepdf 3.0. This
combination can work in a source build but does not work with wheels.
- Accepted bot that wanted to upgrade our deprecated requirements.txt.
- Documentation updates.
- Replace pkg_resources and install dependency on setuptools with
importlib-metadata and importlib-resources.
- Fixed regression in hocrtransform causing text to be omitted when this
renderer was used.
- Fixed some typing errors.
## v12.4.0
- When grafting text layers, use pikepdf's `unparse_content_stream` if available.
- Confirmed support for pluggy 1.0. (Thanks @QuLogic.)
- Fixed some typing issues, improved pre-commit settings, and fixed issues
flagged by linters.
- PyPy 7.3.3 (=Python 3.6) is now supported. Note that PyPy does not necessarily
run faster, because the vast majority of OCRmyPDF's execution time is spent
running OCR or generally executing native code. However, PyPy may bring speed
improvements in some areas.
## v12.3.3
- watcher.py: fixed interpretation of boolean env vars ({issue}`821`).
- Adjust CI scripts to test Tesseract 5 betas.
- Document our support for the Tesseract 5 betas.
## v12.3.2
- Indicate support for flask 2.x, watcher 2.x ({issue}`815, 816`).
## v12.3.1
- Fixed issue with selection of text when using the hOCR renderer ({issue}`813`).
- Fixed build errors with the Docker image by upgrading to a newer Ubuntu.
Also set the timezone of this image to UTC.
## v12.3.0
- Fixed a regression introduced in Pillow 8.3.0. Pillow no longer rounds DPI
for image resolutions. We now account for this ({issue}`802`).
- We no longer use some API calls that are deprecated in the latest versions of
pikepdf.
- Improved error message when a language is requested that doesn't look like a
typical ISO 639-2 code.
- Fixed some tests that attempted to symlink on Windows, breaking tests on a
Windows desktop but not usually on CI.
- Documentation fixes (thanks to @mara004)
## v12.2.0
- Fixed invalid Tesseract version number on Windows ({issue}`795`).
- Documentation tweaks. Documentation build now depends on sphinx-issues package.
## v12.1.0
- For security reasons we now require Pillow >= 8.2.x. (Older versions will continue
to work if upgrading is not an option.)
- The build system was reorganized to rely on `setup.cfg` instead of `setup.py`.
All changes should work with previously supported versions of setuptools.
- The files in `requirements/*` are now considered deprecated but will be retained for v12.
Instead use `pip install ocrmypdf[test]` instead of `requirements/test.txt`, etc.
These files will be removed in v13.
## v12.0.3
- Expand the list of languages supported by the hocr PDF renderer.
Several languages were previously considered not supported, particularly those
non-European languages that use the Latin alphabet.
- Fixed a case where the exception stack trace was suppressed in verbose mode.
- Improved documentation around commercial OCR.
## v12.0.2
- Fixed exception thrown when using `--remove-background` on files containing small
images ({issue}`769`).
- Improve documentation for description of adding language packs to the Docker image
and corrected name of French language pack.
## v12.0.1
- Fixed "invalid version number" for untagged tesseract versions ({issue}`770`).
## v12.0.0
**Breaking changes**
- Due to recent security issues in pikepdf, Pillow and reportlab, we now require
newer versions of these libraries and some of their dependencies. (If necessary,
package maintainers may override these versions at their discretion; lower
versions will often work.)
- We now use the "LeaveColorUnchanged" color conversion strategy when directing
Ghostscript to create a PDF/A. Generally this is faster than performing a
color conversion, which is not always necessary.
- OCR text is now packaged in a Form XObject. This makes it easier to isolate
OCR from other document content. However, some poorly implemented PDF text
extraction algorithms may fail to detect the text.
- Many API functions have stricter parameter checking or expect keyword arguments
were they previously did not.
- Some deprecated functions in `ocrmypdf.optimize` were removed.
- The `ocrmypdf.leptonica` module is now deprecated, due to difficulties with
the current strategy of ABI binding on newer platforms like Apple Silicon.
It will be removed and replaced, either by repackaging Leptonica as an
independent library using or using a different image processing library.
- Continuous integration moved to GitHub Actions.
- We no longer depend on `pytest_helpers_namespace` for testing.
**New features**
- New plugin hook: `get_progressbar_class`, for progress reporting,
allowing developers to replace the standard console progress bar with some
other mechanism, such as updating a GUI progress bar.
- New plugin hook: `get_executor`, for replacing the concurrency model.
This is primarily to support execution on AWS Lambda, which does not support
standard Python `multiprocessing` due to its lack of shared memory.
- New plugin hook: `get_logging_console`, for replacing the standard
way OCRmyPDF outputs its messages.
- New plugin hook: `filter_pdf_page`, for modifying individual PDF
pages produced by OCRmyPDF.
- OCRmyPDF now runs on nonstandard execution environments that do not have
interprocess semaphores, such as AWS Lambda and Android Termux. If the environment
does not have semaphores, OCRmyPDF will automatically select an alternate
process executor that does not use semaphores.
- Continuous integration moved to GitHub Actions.
- We now generate an ARM64-compatible Docker image alongside the x64 image.
Thanks to @andkrause for doing most of the work in a pull request several months
ago, which we were finally able to integrate now. Also thanks to @0x326 for
review comments.
**Fixes**
- Fixed a possible deadlock on attempting to flush `sys.stderr` when older
versions of Leptonica are in use.
- Some worker processes inherited resources from their parents such as log
handlers that may have also lead to deadlocks. These resources are now released.
- Improvements to test coverage.
- Removed vestiges of support for Tesseract versions older than 4.0.0-beta1 (
which ships with Ubuntu 18.04).
- OCRmyPDF can now parse all of Tesseract version numbers, since several
schemes have been in use.
- Fixed an issue with parsing PDFs that contain images drawn at a scale of 0. ({issue}`761`)
- Removed a frequently repeated message about disabling mmap.
+175
View File
@@ -0,0 +1,175 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v13
## v13.7.0
- Fixed an exception when attempting to run and Tesseract is not installed.
- Changed to SPDX license tracking and information files.
## v13.6.2
- Added a shim to prevent an "error during error handling" for Python 3.7 and 3.8.
- Modernized some type annotations.
- Improved annotations on our \_windows module to help IDEs and mypy figure out what
we're doing.
## v13.6.1
- Require setuptools-scm 7.0.5 to avoid possible issues with source distributions in
earlier versions of setuptools-scm.
- Suppress a spurious warning, improve tests, improve typing and other miscellany.
## v13.6.0
- Added a new `initialize` plugin hook, making it possible to suppress built-in
plugins more easily, among other possibilities.
- Fixed an issue where unpaper would exit with a "wrong stream" error, probably
related to images with an odd integer width. {issue}`887, 665`
## v13.5.0
- Added a new `optimize_pdf` plugin hook, making it possible to create plugins that
replace or enhance OCRmyPDF's PDF optimizer.
- Removed all max version restrictions. Our new policy is to blacklist known-bad releases
and only block known-bad versions of dependencies.
- The naming schema for object that holds all OCR text that OCRmyPDF inserts has
changed. This has always been an implementation detail (and remains so), but possibly,
someone was relying on it and would appreciate the heads-up.
- Cleanup.
## v13.4.7
- Fixed PermissionError when cleaning up temporary files in rare cases. {issue}`974`
- Fixed PermissionError when calling `os.nice` on platforms that lack it. {issue}`973`
- Suppressed some warnings from libxmp during tests.
## v13.4.6
- Convert error on corrupt ICC profiles into a warning. Thanks to @oscherler.
## v13.4.5
- Remove upper bound on pdfminer.six version.
- Documentation.
## v13.4.4
- Updated pdfminer.six version.
- Docker image changed to Ubuntu 22.04 now that it is released and provides the
dependencies we need. This seems more consistent than our recent change to
Debian.
## v13.4.3
- Fix error on pytest.skip() with older versions of pytest.
- Documentation updates.
## v13.4.2
- Worked around a
[major regression in Ghostscript 9.56.0](https://bugs.ghostscript.com/show_bug.cgi?id=705187)
where **all OCR text is stripped out of the PDF**. It simply removes all text,
even generated by software other than OCRmyPDF. Fortunately, we can ask
Ghostscript 9.56.0 to use its old behavior that worked correctly for our purposes.
Users must avoid the combination (Ghostscript 9.56.0, ocrmypdf \<13.4.2) since
older versions of OCRmyPDF have no way of detecting that this particular
version of Ghostscript removes all OCR text.
- Marked pdfminer 20220319 as supported.
- Fixed some deprecation warnings from recent versions of Pillow and pytest.
- Test suite now covers Python 3.10 (Python 3.10 worked fine before, but was not
being tested).
- Docker image now uses debian:bookworm-slim as the base image to fix the Docker
image build.
## v13.4.1
- Temporarily make threads rather than processes the default executor worker, due
to a persistent deadlock issue when processes are used. Add a new command line
argument `--no-use-threads` to disable this.
## v13.4.0
- Fixed test failures when using pikepdf 5.0.0.
- Various improvements to the optimizer. In particular, we now recognize PDF images
that are encoded with both deflate (PNG) and DCT (JPEG), and also produce PDF
with images compressed with deflate and DCT, since this often yields file size
improvements compared to plain DCT.
## v13.3.0
- Made a harmless but "scary" exception after failing to optimize an image less scary.
- Added a warning if a page image is too large for unpaper to clean. The image is
passed through without cleaning. This is due to a hard-coded limitation in a
C library used by unpaper so it cannot be rectified easily.
- We now use better default settings when calling img2pdf.
- We no longer try to optimize images that we failed to save in certain situations.
- We now account for some differences in text output from Tesseract 5 compared to
Tesseract 4.
- Better handling of Ghostscript producing empty images when attempting to rasterize
page images.
## v13.2.0
- Removed all runtime uses of distutils since it is deprecated in standard library. We
previous used `distutils.version` to examine version numbers of dependencies
at run time, and now use `packaging.version` for this. This is a new
dependency.
- Fixed an error message advising the user that Ghostscript was not installed being
suppressed when this condition actually happens.
- Fixed an issue with incorrect page number and totals being displayed in the progress
bar. This was purely a display/presentation issue. {issue}`876`.
## v13.1.1
- Fixed issue with attempting to deskew a blank page on Tesseract 5. {issue}`868`.
## v13.1.0
- Changed to using Python concurrent.futures-based parallel execution instead of
pools, since futures have now exceed pools in features.
- If a child worker is terminated (perhaps by the operating system or the user
killing it in a task manager), the parallel task will fail an error message.
Previously, the main ocrmypdf process would "hang" indefinitely, waiting for the
child to report.
- Added new argument `--tesseract-thresholding` to provide control over Tesseract 5's
threshold parameter.
- Documentation updates and changes. Better documentation for `--output-type none`,
added a few releases ago. Removed some obsolete documentation.
- Improved bash completions - thanks to @FPille.
## v13.0.0
**Breaking changes**
- The deprecated module `ocrmypdf.leptonica` has been removed.
- We no longer depend on Leptonica (`liblept`) or CFFI (`libffi`,
`python3-cffi`). (Note that Tesseract still requires Leptonica; OCRmyPDF no longer
directly uses this library.)
- The argument `--remove-background` is temporarily disabled while we search for an
alternative to the Leptonica implementation of this feature.
- The `--threshold` argument has been removed, since this also depended on Leptonica.
Tesseract 5.x has implemented improvements to thresholding, so this feature will be
redundant anyway.
- `--deskew` was previous calculated by a Leptonica algorithm. We now use a feature
of Tesseract to find the appropriate the angle to deskew a page. The deskew angle
according to Tesseract may differ from Leptonica's algorithm. At least in theory,
Tesseract's deskew angle is informed by a more complex analysis than Leptonica,
so this should improve results in general. We also use Pillow to perform the
deskewing, which may affect the appearance of the image compared to Leptonica.
- Support for Python 3.6 was dropped, since this release is approaching end of life.
- We now require pikepdf 4.0 or newer. This, in turn, means that OCRmyPDF requires
a system compatible with the manylinux2014 specification. This change was "forced"
by Pillow not releasing manylinux2010 wheels anymore.
- We no longer provide requirements.txt-style files. Use `pip install ocrmypdf[...]`
instead.
- Bumped required versions of several libraries.
**Fixes**
- Fixed an issue where OCRmyPDF failed to find Ghostscript on Windows even when
installed, and would exit with an error.
- By removing Leptonica, we fixed all issues related to Leptonica on Apple
Silicon or Leptonica failing to import on Windows.
+97
View File
@@ -0,0 +1,97 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v14
## v14.4.0
- Digitally signed PDFs are now detected. If the PDF is signed, OCRmyPDF will
refuse to modify it. Previously, only encrypted PDFs were detected, not
those that were signed but not encrypted. {issue}`1040`
- In addition, `--invalidate-digital-signatures` can be used to override the
above behavior and modify the PDF anyway. {issue}`1040`
- tqdm progress bars replaced with "rich" progress bars. The rich library is
a new dependency. Certain APIs that used tqdm are now deprecated and will
be removed in the next major release.
- Improved integration with GitHub Releases. Thanks to @stumpylog.
## v14.3.0
- Renamed master branch to main.
- Improve PDF rasterization accuracy by using the `-dPDFSTOPONERROR` option
to Ghostscript. Use `--continue-on-soft-render-error` if you want to render
the PDF anyway. The plugin specification was adjusted to support this feature;
plugin authors may want to adapt PDF rasterizing and rendering
plugins. {issue}`1083`
- The calculated deskew angle is now recorded in the logged output. {issue}`1101`
- Metadata can now be unset by setting a metadata type such as `--title` to an
empty string. {issue}`1117,1059`
- Fixed random order of languages due to use of a set. This may have caused output
to vary when multiple languages were set for OCR. {issue}`1113`
- Clarified the optimization ratio reported in the log output.
- Documentation improvements.
## v14.2.1
- Fixed {issue}`977`, where images inside Form XObjects were always excluded
from image optimization.
## v14.2.0
- Added `--tesseract-downsample-above` to downsample larger images even when
they do not exceed Tesseract's internal limits. This can be used to speed
up OCR, possibly sacrificing accuracy.
- Fixed resampling AttributeError on older Pillow. {issue}`1096`
- Removed an error about using Ghostscript on PDFs with that have the /UserUnit
feature in use. Previously, Ghostscript would fail to process these PDFs,
but in all supported versions it is now supported, so the error is no longer
needed.
- Improved documentation around installing other language packs for Tesseract.
## v14.1.0
- Added `--tesseract-non-ocr-timeout`. This allows using Tesseract's deskew
and other non-OCR features while disabling OCR using `--tesseract-timeout 0`.
- Added `--tesseract-downsample-large-images`. This downsamples larges images
that exceed the maximum image size Tesseract can handle. Large images may still
take a long time to process, but this allows them to be processed if that
is desired.
- Fixed {issue}`1082`, an issue with snap packaged building.
- Change linter to ruff, fix lint errors, update documentation.
## v14.0.4
- Fixed {issue}`1066, 1075`, an exception when processing certain malformed PDFs.
## v14.0.3
- Fixed {issue}`1068`, avoid deleting /dev/null when running as root.
- Other documentation fixes.
## v14.0.2
- Fixed {issue}`1052`, an exception on attempting to process certain nonconforming PDFs.
- Explicitly documented that Windows 32-bit is no longer supported.
- Fixed source installation instructions.
- Other documentation fixes.
## v14.0.1
- Fixed some version checks done with smart version comparison.
- Added missing jbig2dec to Docker image.
## v14.0.0
- Dropped support for Python 3.7.
- Dropped support generally speaking, all dependencies older than what Ubuntu 20.04
provides.
- Ghostscript 9.50 or newer is now required. Shims to support old versions were
removed.
- Tesseract 4.1.1 or newer is now required. Shims to support old versions were
removed.
- Docker image now uses Tesseract 5.
- Dropped setup.cfg configuration for pyproject.toml.
- Removed deprecation exception PdfMergeFailedError.
- A few more public domain test files were removed or replaced. We are aiming for
100% compliance with SPDX and generally towards simplifying copyright.
+137
View File
@@ -0,0 +1,137 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v15
## 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
- Added a Docker image based on Alpine Linux. This image is smaller than the
Ubuntu-based image and may be useful in some situations. Currently hosted at
jbarlow83/ocrmypdf-alpine. Currently not available in ARM flavor.
- The Ubuntu Docker is now aliased to jbarlow83/ocrmypdf-ubuntu.
- Updated Docker documentation.
## v15.1.0
- We now require Pillow 10.0.1, due a serious security vulnerability in all earlier
versions of that dependency. The vulnerability concerns WebP images and could
be triggered in OCRmyPDF when creating a PDF from a malicious WebP image.
- Added some keyword arguments to `ocrmypdf.ocr` that were previously accepted
but undocumented.
- Documentation updates and typing improvements.
## v15.0.2
- Added Python 3.12 to test matrix.
- Updated documentation for notes on Python 3.12, 32-bit support and some new
features in v15.
## v15.0.1
- Wheels Python tag changed to py39.
- Marked as a expected fail a test that fails on recent Ghostscript versions.
- Clarified documentation and release notes around the extent of 32-bit support.
- Updated installation documentation to changes in v15.
## v15.0.0
- Dropped support for Python 3.8.
- Dropped support some older dependencies, specifically `coloredlogs` and
`tqdm` in favor of rich - see `pyproject.toml` for details.
Generally speaking, Ubuntu 22.04 is our new baseline system.
- Tightened version requirements for some dependencies.
- Dropped support for 32-bit Linux wheels. We strongly recommend a 64-bit operating
system, and 64-bit versions of Python, Tesseract and Ghostscript to use OCRmyPDF.
Many of our dependencies are dropping 32-bit builds (e.g. Pillow), and we are
following suit. (Maintainers may still build 32-bit versions from source.)
- Changed to trusted release for PyPI publishing.
- pikepdf memory mapping is enabled again for improved performance, now that an
issue with feature in pikepdf is fixed.
- `ocrmypdf.helpers.calculate_downsample` previously had two variants, one
that took a `PIL.Image` and one that took a `tuple[int, int]`. The latter
was removed.
- The snap version of ocrmypdf is now based on Ubuntu core22.
- We now account for situations where a small portion of an image on a page is drawn
at high DPI (resolution). Previously, the entire page would be rasterized at the
highest resolution of any feature, which caused performance problems. Now,
the page is rasterized
at a resolution based on the average DPI of the page, weighted by the area that
each feature occupies. Typically, small areas of high resolution in PDFs are
errors or quirks from the repeated use of assets and high resolution is not
beneficial. {issue}`1010,1104,1004,1079,1010`
- Ghostscript color conversion strategy is now configurable using
`--color-conversion-strategy`. {issue}`1143`
- JBIG2 threshold for optimization is now configurable using
`--jbig2-threshold`. {issue}`1133`
+291
View File
@@ -0,0 +1,291 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v16
## v16.13.0
- Added detection and repair for Ghostscript 10.6 JPEG corruption. When GS 10.6
truncates JPEG data by 1-15 bytes, OCRmyPDF now restores the original image
bytes from the input PDF. A warning is issued when GS 10.6+ is detected.
{issue}`1603`
- We continue to force re-optimization of JPEGs, since this catches some issues with corruption for situations where Ghostscript modifies an image. It is likely there are still cases where we cannot mitigate all corruption issues. {issue}`1585`
- Fixed handling of PDF page boxes (ArtBox, BleedBox) which were not being
processed correctly in some cases. {issue}`1181,1360`
- Documentation: clarified podman usage instructions.
## v16.12.0
- Disable Ghostscript's subset fonts feature, which was found to corrupt text in certain
PDFs. Thanks @mnaegler for identifying this issue. {issue}`1592`
- Users of Ghostscript 10.6.0+ reported that Ghostscript seems to generate corrupted
JPEGs. We force re-optimization of these JPEGs to mitigate the corruption until
Ghostscript fixes the issue. {issue}`1585`
- OCRmyPDF now avoids applying flate compression to large JPEG images, unless maximum
optimization is requested, since flate+DCT compression reduces performances in PDF
viewers with large images.
- Updated Dockerfiles to use more recent base operating systems.
- Updated build and test matrix to include Python 3.14.
- Minor documentation improvements.
- pikepdf >= 10.0.0 is now required.
## v16.11.1
- Fixed issue with Tesseract changing an error message related to skew. {issue}`1576`
- Dropped macOS 13 from build-test matrix since it is no longer supported by Apple.
## v16.11.0
- Deprecated "semfree" plugin in favor of falling back to threads if the platform
does not support semaphores. Fixes an issue with Python 3.14.
- Fixed references to PDF/A compliances levels to be consistent with ISO nomenclature.
Thanks @5HT2. {issue}`1557`
- Fixed an issue around using plugin_manager as an argument. {issue}`1555`
- Added OpenBSD install steps to README. {issue}`1554`
- Removed PyPy from test matrix due to declining support in third party libraries.
- Documentation improvements.
## v16.10.4
- Corrected build errors in Python 3.13.3 and 3.13.4.
## v16.10.3 (not released)
- Blocked optimization of images with pre-blended soft masks. {issue}`1536`
- Fixed warning from hypothesis on running tests.
- Release incomplete due to new test failures in Python 3.13.3 and 3.13.4.
## v16.10.2
- Blacklist pikepdf 9.8.0 due to an incompatible change.
## v16.10.1
- No changes affecting OCRmyPDF functionality for command line end users.
- webservice: made page specification easier to find in UI.
- webservice: fix download button downloads wrong file.
- Converted project documentation from rST to Markdown.
- Added README translation to Simplified Chinese. Thanks @HuaPai.
- Modernized license specification in pyproject.toml.
- Modernized SPDX license to REUSE.toml.
## v16.10.0
- Added hocr textangle processing, improving handling of text at angles.
Thanks @0dinD {issue}`1467`
- Docker documentation updates related to podman. Thanks @rugk. {issue}`1489,1488`
- Dropped webservice.py's fragile use of ttyd. Instead, messages from ocrmypdf are
printed to the console.
- Fixed broken test test_hocrtransform_matches_sandwich, which had become
an invalid test. Thanks @QuLogic for reporting.
- Improved install instructions for Windows. Thanks @alex.
## v16.9.0
- Added hocr caption processing. Thanks @0dinD {issue}`1466`
- ocrmypdf-alpine Docker image is now built with Alpine 3.21.
- Fixed error handling of PDFs that contain invalid images with both ImageMask
and ColorSpace defined. {issue}`1453`
- Fixed test suite regression when only older Ghostscripts are installed.
- Improved documetnation of \_progressbar.py. Thanks @QuentinFuxa. {issue}`1456`
- Disabling building of documentation as PDF on ReadTheDocs, as this caused
complex build issues deemed not worth solving.
## v16.8.0
- Upgraded webservice.py demonstration using streamlit. It's now possible to
exercise most of OCRmyPDF's functionality in a simple web UI.
- Added cache to Dockerfiles to improve build speed.
- Fixed numerous formatting errors in the documentation that prevented some
parts of documentation from generating correctly.
- Improved OCR text rendering by suppressing negative-width spaces. Thanks
@pajowu. {issue}`1446`
- Improved detecting of invisible text when using `--redo-ocr`. Thanks
@pajowu. {issue}`1448``
## v16.7.0
- Fixed further issues with Docker build and updated some versions.
- Main Docker image returned to Ubuntu 24.04 since the fix in v16.6.2 resolved
that concern.
- Code that previously sent Ghostscript output to stdout has been changed to
output to temporary files, since Ghostscript was doing that anyway internally.
This is a modest efficiency improvement.
- Fixed an issue with debug log output being parsed as rich markup. {issue}`1444`
## v16.6.2
- Remove invalid hyperlink annotations to satisfy Ghostscript 10.x during PDF/A
conversion. {issue}`1425`
## v16.6.1
- Fixed some issues with Docker build, such as removing unnecessary content and using
a stable Tesseract version.
- Reverted Docker image to Ubuntu 22.04 to access older/more stable Ghostscript
for now.
- Clarified batch commands in documentation.
- Fixed an issue with JSON serialization and pickling of HOCRResult. {issue}`1427`
## v16.6.0
- Fixed an issue where damaged PDFs would fail with `--redo-ocr`. {issue}`1403`
- Fixed an error that prevented JBIG2 optimization on Windows if the image
was optimized in an earlier step. {issue}`1396`
- Fixed an error detecting the version of unpaper 7.0.0. {issue}`1409`
- Fixed a performance regression when scanning pages. {issue}`1378`. Thanks @aliemjay.
- Fixed Alpine Docker image by enforcing Alpine 3.19. Alpine 3.20 includes a
defective version of Tesseract OCR and so is not usable.
- Upgraded Ubuntu Docker image to use Ubuntu 24.04.
- Build and test scripts/actions switched to uv.
- When running in a container, we now remind the user that temporary folders
are inside the container and may not be accessible.
- Fixed Linux test coverage matrix, which was missing some key versions.
## v16.5.0
- Fixed issue with interpreting PDFs that have images with array masks.
{issue}`1377`
- Enabled testing on Python 3.13.
- Fixed a test that did not work correctly but still passed. {issue}`1382`
- Improved "PDF/A conversion failed" warning message to better describe implications.
- Updated documentation to better explain OCR_JSON_SETTINGS in batch processing.
- Build backend changed from setuptools to hatchling.
## v16.4.3
- Work around pdfminer.six issue where a token on the buffer boundary is incorrectly
parsed as two tokens. {issue}`1361`
- New rules are applied to stencil masks and explicit masks when calculating the
optimal page DPI for rendering. {issue}`1362`
- Fixed attempts to use an incompatible jbig2.EXE provided by TeX Live. {issue}`1363`
## v16.4.2
- Fixed order of filenames passed to Ghostscript for PDF/A generation. {issue}`1359`
- Suppressed missing jbig2dec warning message. {issue}`1358`
- Fixed calculation of image size when soft mask dimensions don't match image
dimension. {issue}`1351`
- Several fixes to documentation. Thanks to users Iris and JoKalliauer
who contributed these changes.
- Fixed error on processing PDFs that are missing certain image metadata. {issue}`1315`
## v16.4.1
- Fixed calculation of image printed area (used in finding weighted DPI for OCR).
{issue}`1334`
- Fixed "NotImplementedError: not sure how to get colorspace" error
messages in logs which simply records a failure to optimize images with
print production colorspaces. {issue}`1315`
## v16.4.0
- Selecting the `osd` and `equ` pseudo-languages with `-l/--language` now
exits with an error when using Tesseract OCR, because these are not
regular Tesseract languages but implementation details implemented.
Using them can cause Tesseract to crash.
- The hOCR renderer is more tolerant of extra whitespace in input files.
- watcher.py now changes the output file extension to .pdf when the input is not
.pdf.
- Improved handling of PDFs that contain circularly referenced Form XObjects.
{issue}`1321`
- Fixed Alpine Docker image for ARM64, which was not building correctly.
- Docker images now use pikepdf 9.0.0.
- Prevent use of Tesseract OCR 5.4.0, a version with known regressions.
- Disabled progressbar for "Linearizing" when `--no-progress-bar` set.
- Fixed some tests that warn about missing JBIG2 decoding via pikepdf, by
installing the necessary libraries during tests.
## v16.3.1
- Fixed a test suite failure with Ghostscript 10.03.0+. {issue}`1316`
- Fixed an issue with the presentation of the "OCR" progress bar. {issue}`1313`
## v16.3.0
- Fixed progress bar not displaying for Ghostscript PDF/A conversion. {issue}`1313`
- Added progress bar for linearization. {issue}`1313`
- If `--rotate-pages-threshold` issued without `--rotate-pages` we now exit with
an error since the user likely intended to use `--rotate-pages`. {issue}`1309`
- If Tesseract hOCR gives an invalid line box, print an error message instead of
exiting with an error. {issue}`1312`
## v16.2.0
- Fixed issue 'NoneType' object has no attribute 'get' when optimizing certain PDFs.
{issue}`1293,1271`
- Switched formatting from black to ruff.
- Added support for sending sidecar output to io.BytesIO.
- Added support for converting HEIF/HEIC images (the native image of iPhones and
some other devices) to PDFs, when the appropriate pi-hief library is installed.
This library is marked as a dependency, but maintainers may opt out if needed.
- We now default to downsampling large images that would exceed Tesseract's internal
limits, but only if it cause processing to fail. Previously, this behavior only
occurred if specifically requested on command line. It can still be configured
and disabled. See the --tesseract command line options.
- Added Macports install instructions. Thanks @akierig.
- Improved logging output when an unexpected error occurs while trying to obtain
the version of a third party program.
## v16.1.2
- Fixed test suite failure when using Ghostscript 10.3.
- Other minor corrections.
## v16.1.1
- Fixed PyPy 3.10 support.
## v16.1.0
- Improved hOCR renderer is now default for left to right languages.
- Improved handling of rotated pages. Previously, OCR text might be missing for
pages that were rotated with a /Rotate tag on the page entry.
- Improved handling of cropped pages. Previously, in some cases a page with a
crop box would not have its OCR applied correctly and misalignment between
OCR text and visible text coudl occur.
- Documentation improvements, especially installation instructions for less
common platforms.
## 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.
+433
View File
@@ -0,0 +1,433 @@
% SPDX-FileCopyrightText: 2022 James R. Barlow
% SPDX-License-Identifier: CC-BY-SA-4.0
# v17
## v17.9.0
- OCRmyPDF now uses any Noto font installed on the system, not just the two
dozen script families it knows by name ({issue}`1722`). Previously a document
in, say, Cherokee or Vai was rendered with the glyphless fallback font even
though the matching font was installed — a common situation on macOS, which
ships around a hundred script-specific Noto faces. When the named fonts
cannot cover a word, OCRmyPDF now searches the installed fonts for one that
can.
- The "no installed font has glyphs" warning now names the characters it could
not render, with their codepoints and Unicode names, so it is clear which
font to install. Text that mixes scripts no single font covers is now
reported as such, instead of advising the user to install fonts they may
already have.
- Fixed the macOS font installation instructions, which recommended a Homebrew
package (`font-noto`) that does not exist ({issue}`1722`). Homebrew has no
single Noto package; each family is a separate cask. The Fedora package name
was also corrected to `google-noto-fonts-all`.
- Font providers may now implement the optional `GlyphSearchingFontProvider`
protocol to participate in coverage-based font search.
- Fixed `--jpeg-quality`/`--jpg-quality` having no effect on the CLI: the
value was silently dropped before reaching the optimizer, which then
always used its own built-in default JPEG quality regardless of what was
requested ({issue}`1723`). The same bug affected the Python API's
`jpg_quality` parameter. `ocrmypdf.ocr()` now accepts `jpeg_quality`
(matching the CLI flag name) as the canonical parameter; `jpg_quality`
still works but is deprecated.
- Hardened PDF parsing against malformed (non-dictionary) `/Resources`,
`/XObject`, and `/FontDescriptor` entries, which previously crashed
`ocrmypdf.ocr()` with `AttributeError`/`TypeError`/`ValueError` on
otherwise-processable files, both during PDF/A font scanning and general
image scanning ({issue}`1713`). Thanks @mvanhorn for the initial fix.
- Release process improvements: fixed a CI bug where every push to main
after a release was tagged would incorrectly revert the just-published
GitHub release back to draft status.
## v17.8.1
- Improved the `--tesseract-pagesegmode` help text to point to
`tesseract --help-extra`, since Tesseract 5.5.2 moved the page segmentation
mode documentation there from `tesseract --help`. Thanks @sokai.
- Internal refactoring: completed a project-wide mypy type-checking pass
(`--check-untyped-defs` is now enabled, and the mypy pre-commit hook is now
blocking rather than advisory), fixing several latent edge-case bugs
surfaced along the way.
- Release process improvements: migrated from pre-commit to prek for local
git hooks, and added a dedicated lint job to CI.
- Improved typing strictness for `Path`.
## v17.8.0
- `--output-type auto` (the default) again produces PDF/A whenever it can,
matching OCRmyPDF 16's "PDF/A by default" behavior. It first tries the fast
Ghostscript-free conversion (validated by veraPDF when available) and now
falls back to Ghostscript when that cannot produce PDF/A, only emitting a
regular PDF when even Ghostscript cannot safely convert (for example, an
input with non-embedded CID/CJK fonts, per {issue}`1561`). A consequence is
that the default path may once again invoke Ghostscript, which is slower and
may transcode images; use `--output-type pdf` to skip PDF/A conversion
entirely.
- Fixed detection of veraPDF 1.30.0 and newer: recent builds print JVM
warnings before their version string, which caused OCRmyPDF to report
veraPDF as unavailable and skip the fast PDF/A path.
- OCRmyPDF no longer silently corrupts a non-embedded CID (CJK) text layer when
producing PDF/A ({issue}`1561`). PDF/A requires all fonts to be embedded, so
Ghostscript substitutes and re-embeds non-embedded CID fonts — such as the OCR
text layer Adobe Acrobat adds to scanned CJK documents — which mangles the
text and destroys searchability. OCRmyPDF now detects non-embedded CID fonts
before conversion: with `--output-type auto` (the default) it produces a
regular PDF and preserves the existing text layer, and with an explicit
`--output-type pdfa*` it stops with an error rather than emit corrupted
output. Use `--output-type pdf` to keep the text layer, or `--force-ocr` to
rebuild it with embedded fonts.
- Writing the output PDF to standard output (`ocrmypdf input.pdf -`) is now
protected against corruption at the operating system level. Previously
OCRmyPDF relied on no in-process code — third-party libraries, plugins, or
stray `print()` calls — ever writing to stdout; a single accidental write
would silently corrupt the PDF. The command line program now saves the real
stdout at startup, before plugins are loaded or any worker process/thread is
started, and redirects file descriptor 1 to stderr, so that only OCRmyPDF's
final PDF output can reach stdout. A consequence is that a plugin which
intentionally prints to stdout will have that output redirected to stderr.
- Added the public API function {func}`ocrmypdf.configure_stdout_protection`,
which installs this same protection. Like {func}`ocrmypdf.configure_logging`,
it is optional and intended for callers that want command-line-like behavior;
applications that manage their own standard output should not call it.
- Fixed an uncaught `UnicodeDecodeError` when processing a PDF whose
`/DocumentInfo` dictionary contains a `/Name` key encoded in Latin-1 (or
another non-UTF-8 encoding), such as `/Saks#e5r`. `repair_docinfo_nuls` now
treats such a block as malformed, logs a message, and continues instead of
crashing the pipeline ({issue}`1540`). Current pikepdf releases tolerate these
keys by surrogate-escaping them, but older versions raised while iterating the
dictionary.
## v17.7.1
- Fixed a severe, Windows-specific performance regression in the "Scanning
contents" phase, most visible with `--redo-ocr` ({issue}`1662`). Since
v16.4.3, OCRmyPDF forced pdfminer's read buffer to 256 MiB to work around a
pdfminer bug that mishandled tokens split across the buffer boundary
({issue}`1361`). On Windows, CPython's `BufferedReader.read()` eagerly
allocates a buffer of the requested size on every read, so the oversized
buffer made each of pdfminer's thousands of reads cost tens of milliseconds
(this allocation is lazy, and effectively free, on Linux). The underlying
pdfminer bug was fixed upstream in pdfminer.six 20250327
([#1030](https://github.com/pdfminer/pdfminer.six/pull/1030)), with a
follow-up for tokens split across streams in 20260107
([#1158](https://github.com/pdfminer/pdfminer.six/pull/1158)), so the
workaround has been removed and the minimum pdfminer.six version raised to
20260107.
- The font discovery used to build the OCR text layer now finds variable fonts
such as `NotoSansArabic[wdth,wght].ttf`, the form shipped by Homebrew casks
and current Google Fonts releases. Previously only static `-Regular.ttf`/`.otf`
files were matched, so users who had installed the correct Noto font still got
the glyphless fallback and a "No font found" warning ({issue}`1652`).
- Font discovery is now language-aware for CJK: each Chinese, Japanese, and
Korean language maps to its own per-language Noto family (NotoSansSC, TC, HK,
JP, KR), with the pan-CJK super font kept as a shared fallback, since the
per-language fonts are region subsets that may lack glyphs from other scripts.
- The warning shown when no installed font has glyphs for some text was reworded
to explain the consequence — the text is still added as a searchable, copyable
layer but appears blank when highlighted in a viewer — and to name the specific
font family to install.
## v17.7.0
- The Docker images now run as a non-root user (`app`, uid/gid 1000) by default
rather than as root, as a defense-in-depth measure. If you bind-mount a
directory for input and output, you may now need to add a `--user` argument so
the container can write to it; the correct value differs for rootless Docker,
Podman, and rootful Docker, and is described in the Docker documentation.
Piping the input and output through stdin/stdout still works with no
permission setup.
- The Docker images now default their working directory to `/data`, so files in
a directory mounted there can be given as relative paths without an explicit
`--workdir`.
- The Ubuntu Docker image now installs Tesseract 5 from the Ubuntu archive
instead of the third-party `alex-p/tesseract-ocr5` PPA, and the base images
were updated to Ubuntu 26.04 and Alpine 3.24.
- Fixed a missing space in the error message shown when OCRmyPDF cannot access
its working directory inside a Docker container.
- Updated packaged dependencies, including the optional web service stack
(starlette, tornado, python-multipart) and cryptography.
## v17.6.0
- When the optimizer encounters an image it cannot process (for example, an
exotic colorspace that cannot be transcoded), it now logs a concise warning
that the image was left unchanged rather than printing an alarming
traceback. The output file was already valid in these cases; only the
reporting was misleading. The full traceback is still available at debug
verbosity (`-v 1`) ({issue}`846`).
- `--pdfa-image-compression=auto` (the default) now selects lossless image
compression at `-O0` so Ghostscript no longer transcodes lossless images to
JPEG during PDF/A generation. At `-O1` and above, `auto` continues to defer
to Ghostscript's heuristic, which may recompress images lossily. `-O1` (the
default level) is kept as a historical exception because coercing it to
lossless can substantially bloat output; users who want guaranteed lossless
image handling should pass `--pdfa-image-compression=lossless` or use `-O0`
({issue}`1124`).
- `--pdfa-image-compression=lossless` now passes existing JPEG images through
unchanged rather than re-encoding them with a lossless codec. Re-encoding an
already-lossy JPEG losslessly cannot recover quality and only inflates the
file, so JPEGs are preserved while non-JPEG images are encoded losslessly.
- OCRmyPDF now validates and repairs malformed page-boundary boxes
(``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``, ``/BleedBox``) in its
input, following the PDF 2.0 specification. Coordinates written in invalid
exponential notation are reinterpreted ({issue}`1398`); rectangles whose
corners are given in reversed order are normalized, which previously crashed
with ``NegativeDimensionError`` ({issue}`1526`); and a crop/trim/art/bleed box
that falls outside the MediaBox is clamped to their intersection, or discarded
when that intersection is empty, which previously produced an output with a
zero-height effective page that some viewers refused to open ({issue}`1400`).
When a box is discarded, clamped, or reinterpreted, OCRmyPDF logs a warning
recommending visual inspection of the output. Thanks @ajdlinux for the initial
fix in PR #1691.
- OCRmyPDF now discards an embedded Adobe full-text search index
(``/Root/PieceInfo/SearchIndex``) from its output. This proprietary index,
produced by Acrobat's "Embed Index" feature, is read only by Adobe Acrobat;
other viewers ignore it and search the text on the fly. Because any change to
a PDF invalidates the index, retaining it after OCRmyPDF rewrites the document
would leave a stale index that returns incorrect search results in Acrobat.
Modern viewers rebuild a search index on demand, so there is no loss of
search capability.
- OCRmyPDF now discards embedded per-page thumbnail images (the optional
``/Thumb`` image XObject on a page) from its output. OCRmyPDF alters page
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
arbitrarily, so a retained thumbnail would be stale and no longer match its
page. Embedded thumbnails are a navigation aid that modern viewers generate
on demand, so there is no loss of functionality.
- Fixed a regression in OCR quality for PDFs that paint a 1-bit image mask
(stencil) with a gray or colored fill color. Previously such pages were
rasterized as 1-bit black-and-white before OCR, so Ghostscript dithered
mid-tone text into an unreadable stipple and Tesseract failed to recognize
it. The rasterizer now inspects the fill color used to paint a mask and
promotes the page to grayscale or full color as needed, so the distinction
is preserved for the OCR engine. This applies to both the Ghostscript and
pypdfium rasterizers. {issue}`1688`
- The default 1-bit raster device for Ghostscript is now ``pngmonod``
(error-diffusion) instead of ``pngmono`` (ordered dithering). It produces
better input for OCR on faint or anti-aliased scans at negligible cost and
no change to output file size, since the rasterized image is an
intermediate that is discarded after OCR.
- When rasterizing pages with Ghostscript, OCRmyPDF now enables text and
graphics anti-aliasing (``-dTextAlphaBits=4 -dGraphicsAlphaBits=4``) for the
grayscale and color raster devices. Ghostscript 10.x renders aliased glyphs
that OCR frequently misreads as extra word breaks or substituted characters;
anti-aliasing materially improves OCR accuracy on the Ghostscript
rasterization path, especially for small fonts at moderate resolution. The
1-bit monochrome devices are unaffected, since they perform their own
anti-aliased downscaling and older Ghostscript versions reject alpha-bit
options on them. Note that the default rasterizer (``--rasterizer auto``)
prefers pypdfium2, which already anti-aliases; this change benefits users who
select ``--rasterizer ghostscript`` or do not have pypdfium2 installed.
OCRmyPDF now also logs which rasterizer rendered each page at debug verbosity
(``-v 1``), and the ``--rasterizer`` help text explains the OCR-quality
trade-off, to make such reports easier to diagnose. {issue}`1439`
- When Tesseract reports a page with many diacritics, OCRmyPDF still logs its
interpreted "lots of diacritics - possibly poor OCR" hint, but now also emits
Tesseract's raw message at debug verbosity (``-v 1``) so the original wording
is available for diagnosis. {issue}`1566`
- Added ``--mode strip``, which removes the invisible OCR text layer from a PDF
in place. Unlike ``--ocr-engine none --force-ocr``, it does not rasterize the
page, so images and visible content are preserved unchanged and the output is
smaller rather than larger. Only text drawn as invisible (PDF text render mode
3) is removed; some OCR engines -- and OCRmyPDF v2.2 and earlier -- express
text as visible glyphs covered by an opaque image, and that text cannot be
removed this way. {issue}`1435`
## v17.5.0
- Added support for the ``end`` alias in ``--pages``, denoting the last page
of the document. For example, ``--pages 3-end`` OCRs from page 3 through
the final page. {issue}`1615`
- Added ``--ghostscript-jpeg-quality`` and ``--ghostscript-jpeg-maxdpi``
advanced options for tuning Ghostscript's PDF/A output. The optimizer's
``--jpeg-quality`` remains the recommended file-size control.
- Fixed pypdfium2 rasterizer clipping content when the CropBox was smaller
than the MediaBox (e.g. JSTOR or cropped PDFs). {issue}`1685`
- Fixed Form XObject cycle detection in the optimizer's image xref scan.
Self-referential or DAG-shaped Form graphs (notably from PowerPoint
exports) previously produced floods of recursion warnings and could hang
for minutes. {issue}`1321`
- Tesseract config errors are now surfaced as ``TesseractConfigError`` with
actionable guidance, instead of crashing later with a confusing
``FileNotFoundError`` on the missing hOCR output. {issue}`1687`
- Refreshed the Chinese README translation. Thanks @cislunarspace.
- Internal refactoring of the ``_exec`` and ``subprocess`` modules to
separate probing from execution.
- CI dependency updates.
## v17.4.2
- Fixed Python API unconditionally overriding ``PIL.Image.MAX_IMAGE_PIXELS``
when the caller did not explicitly set ``max_image_mpixels``. Host
applications (e.g. Paperless-NGX) that configure the PIL limit before
invoking ``ocrmypdf.ocr()`` now have their setting respected. The CLI
default of 250 megapixels is unchanged. {issue}`1665`
- Updated uv.lock to avoid pinning a vulnerable version of Pillow. {issue}`1666`
## v17.4.1
- Fixed RTL text extraction order in the fpdf2 renderer. Arabic lam-alef
ligatures and other multi-character CMap entries were garbled by the bidi
algorithm during text extraction. {issue}`1655`
- Fixed ``work_folder`` not being set in ``PdfContext`` options when using
the Python API. Thanks @bluebox-steven. {issue}`1613`
- Updated Ghostscript JPEG corruption warning to include the detected version
number, confirming the bug persists in Ghostscript 10.7.0.
- Internal refactoring.
- CI dependency updates.
## v17.4.0
- Added ``--no-overwrite`` / ``-n`` option to prevent overwriting output files.
If the destination file already exists, OCRmyPDF exits with code 5
(``OutputFileAccessError``). {issue}`1642`
- Fixed text layer stretching in the fpdf2 renderer for widely-spaced words.
The horizontal scaling (Tz) was incorrectly stretched to fill inter-word gaps
instead of relying on Td positioning, causing text selection to highlight far
beyond the actual word boundaries. {issue}`1635`
- Fixed ``optimize=2`` or ``optimize=3`` crash when using the Python API without
explicitly setting ``jpg_quality`` or ``png_quality``. {issue}`1641`
- Fixed ``verapdf`` availability check crashing with ``NotADirectoryError`` on
some platforms. {issue}`1638`
## v17.3.0
- Fixed Python API ignoring the ``language`` parameter, always defaulting to
``eng``. The API now correctly maps ``language`` to OcrOptions ``languages``
and splits ``+``-separated codes (e.g. ``eng+deu``) to match CLI behavior.
{issue}`1640`
- Fixed Python API producing empty OCR output because ``tesseract_timeout``
defaulted to 0, causing Tesseract to time out immediately. The default is
now ``None``, falling back to the plugin's 180-second timeout. {issue}`1636`
- Fixed OCR text layer displacement on PDFs with non-zero MediaBox origins
(e.g. JSTOR or cropped PDFs). The coordinate transformation matrix is now
always computed, not skipped when rotation is zero. {issue}`1630`
- Restored image overlay support (``--image``) for the hocrtransform tool,
enabling sandwich PDF output with the fpdf2 renderer. {issue}`1634`
- Docker: updated Alpine base image to 3.23.
- Documentation restructured into per-major-version release notes files.
- Release process improvements.
## v17.2.0
- Fixed incorrect word spacing in poppler-based PDF viewers and tools (Evince,
pdftotext, and others) where words on the same line appeared separated by
double newlines. This works around a poppler bug where Tz (horizontal scaling)
is not carried across BT/ET boundaries. {issue}`1632`
- Fixed OCR text layer being visible instead of invisible due to incorrect fpdf2
text rendering mode attribute. This caused OCR text to appear when images were
removed from the PDF. {issue}`1631`
- Fixed OCR text layer misalignment with non-zero mediabox origins, which
affected cropped PDFs and JSTOR PDFs generated by iText. The ``--redo-ocr``
mode would shift text vertically on these files. {issue}`1630`
- Fixed Ghostscript rasterization failure with very low DPI values (below 10).
OCRmyPDF now renders at a minimum of 10 DPI and resizes the output to match
the originally requested dimensions. {issue}`1612`
## v17.1.0
- Added `--tagged-pdf-mode` to allow skipping the TaggedPDF error message, if desired.
- Fixed an issue where deflated JPEGs (FlateDecode + DCTDecode) were counted as
lossless images for the purpose of determining whether to compress to JPEG,
causing file size inflation with some workflows (`--mode force` in particular).
## v17.0.1
- Fixed output file size inflation when using pypdfium as rasterizer and force-ocr
mode.
## v17.0.0
**Breaking changes**
- **Plugin interface migration**: Plugin hooks now receive `OcrOptions` objects instead of
`argparse.Namespace` objects. Most plugins will continue working due to duck-typing
compatibility, but plugin developers should update their type hints from `Namespace`
to `OcrOptions`.
- Built-in plugins no longer modify options in-place, improving immutability and
code clarity.
- **Lossy JBIG2 removed**: The `--jbig2-lossy` and `--jbig2-page-group-size` options have been
removed due to well-documented risks of character substitution errors. These options are now
deprecated and will emit warnings if used. Only lossless JBIG2 compression is supported.
- **PDF/A output behavior change**: If neither Ghostscript nor verapdf is installed,
`--output-type auto` (the new default) will produce a standard PDF instead of PDF/A. This is
a change from previous versions where Ghostscript was required and PDF/A was always produced.
This configuration is rare but users should be aware of the change.
**New features**
- **pypdfium2 rasterizer**: Added optional pypdfium2-based PDF rasterization plugin as an
alternative to Ghostscript for page rendering. Use `--rasterizer pypdfium` to enable
(requires `pip install pypdfium2`). The default `--rasterizer auto` prefers pypdfium when
available and falls back to Ghostscript.
- **Pluggable OCR engines**: New `--ocr-engine` option allows selecting OCR engines:
- `auto` (default): Uses Tesseract
- `tesseract`: Explicit Tesseract selection
- `none`: Skip OCR entirely for PDF processing-only workflows
This prepares the foundation for future third-party OCR engine plugins.
- **Smart PDF/A conversion**: New `--output-type auto` (now the default) produces best-effort
PDF/A output without requiring Ghostscript when the verapdf validator is available. Falls back
to traditional Ghostscript conversion when needed.
- **verapdf integration**: Added optional verapdf validation for fast PDF/A conversion. When
available, OCRmyPDF attempts speculative PDF/A conversion using pikepdf, validates with verapdf,
and skips Ghostscript if validation passes.
- **Optional Ghostscript**: As a consequence of the changes above, Ghostscript is no longer a required dependency. It is optional.
- **fpdf2 text renderer**: Replaced legacy hOCR text renderer with new fpdf2-based implementation,
providing better multilingual support and more accurate text positioning.
- **Improved Occulta glyphless font**: The new Occulta font provides better handling of
zero-width markers and double-width CJK characters for accurate text layer positioning.
- **Expanded multilingual font support**: Added FontProvider infrastructure with language-aware
font selection for Devanagari (Hindi, Sanskrit, Marathi, Nepali), CJK (Chinese, Japanese,
Korean), Arabic script, and many other scripts. System font discovery reduces package size.
- **Simplified mode selection**: New `--mode` (`-m`) argument consolidates processing options:
- `default`: Error if text is found (standard behavior)
- `force`: Rasterize all content and run OCR (replaces `--force-ocr`)
- `skip`: Skip pages with existing text (replaces `--skip-text`)
- `redo`: Re-OCR pages, stripping old text layer (replaces `--redo-ocr`)
Legacy flags remain as silent aliases for backward compatibility.
**API improvements**
- Centralized validation logic in the `OcrOptions` Pydantic model
- Removed scattered option mutation throughout the codebase
- Better type safety for plugin development
- Simplified plugin option handling
- New `OcrElement`, `OcrClass`, and `BoundingBox` exports for OCR engine plugin developers
- Extended `OcrEngine` ABC with `generate_ocr()` method for direct OCR tree output, eliding the need to translate a modern engine's output to hOCR or directly write to PDF.
**Bug fixes**
- Fixed double-compression of already-deflated JPEGs.
- Fixed tesseract_cache plugin to properly handle cache misses.
- Fixed handling of PDF page boxes (ArtBox, BleedBox) which were not being processed correctly.
- Added thread safety lock to pypdfium plugin for concurrent operations.
- Improved pdfminer.six compatibility with explicit word spacing.
**Documentation**
- Updated cookbook to replace deprecated `--tesseract-timeout 0` with `--ocr-engine none`.
- Added comprehensive plugin documentation for new OCR engine framework.
**Dependency changes**
- Requires: one of `pypdfium2` or `ghostscript` for PDF rasterization (PDF to image)
- Preferred: both
- Requires: one of `verapdf` or `ghostscript` for PDF/A generation
- Preferred: both
- Recommended: `pypdfium2` for PDF rasterization (new dependency)
- Recommended: `ghostscript` (used to be Required)
- Recommended: Noto fonts for improved OCR text positioning
- Optional: `verapdf` for fast PDF/A validation (new dependency)
- Requires: `fpdf2` for text layer rendering (new dependency)
- Recommended: replace `typer` with `cyclopts` in misc scripts (new dependency)
- See docs/maintainers.md for details.
**Migration guide for plugin developers**
- Update imports: `from ocrmypdf._options import OcrOptions`
- Update type hints: `def check_options(options: OcrOptions)` instead of `options: Namespace`
- Attribute access remains unchanged: `options.languages`, `options.output_type`, etc.
- Remove any in-place option modifications - compute values at point of use instead
- Most existing plugins will continue working without changes due to duck-typing
+42 -41
View File
@@ -96,8 +96,9 @@ with st.expander("Optimization after OCR"):
png_quality = st.slider(
"PNG quality", min_value=0, max_value=100, value=75, key="png_quality"
)
jbig2_lossy = st.checkbox("JBIG2 lossy (dangerous)", value=False, key="jbig2_lossy")
jbig2_threshold = st.number_input("JBIG2 threshold", value=0, key="jbig2_threshold")
jbig2_threshold = st.number_input(
"JBIG2 threshold", value=0.85, key="jbig2_threshold"
)
with st.expander("Advanced options"):
jobs = st.slider(
@@ -189,51 +190,51 @@ if uploaded:
args.append(f"--jpeg-quality={jpeg_quality}")
if optimize > '0' and png_quality:
args.append(f"--png-quality={png_quality}")
if jbig2_lossy:
args.append("--jbig2-lossy")
if jbig2_threshold:
args.append(f"--jbig2-threshold={jbig2_threshold}")
if jobs:
args.append(f"--jobs={jobs}")
input_file = NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}")
input_file.write(uploaded.getvalue())
input_file.flush()
input_file.seek(0)
args.append(str(input_file.name))
output_file = NamedTemporaryFile(delete=True, suffix=".pdf")
args.append(str(output_file.name))
with NamedTemporaryFile(delete=True, suffix=f"_{uploaded.name}") as input_file:
input_file.write(uploaded.getvalue())
input_file.flush()
input_file.seek(0)
args.append(str(input_file.name))
with NamedTemporaryFile(delete=True, suffix=".pdf") as output_file:
args.append(str(output_file.name))
st.session_state['running'] = (
'run_button' in st.session_state and st.session_state.run_button
)
if st.button(
"Run OCRmyPDF",
disabled=st.session_state.get("running", False),
key='run_button',
):
st.session_state['running'] = True
args = [sys.executable, '-u', '-m', "ocrmypdf"] + args
st.session_state['running'] = (
'run_button' in st.session_state and st.session_state.run_button
)
if st.button(
"Run OCRmyPDF",
disabled=st.session_state.get("running", False),
key='run_button',
):
st.session_state['running'] = True
args = [sys.executable, '-u', '-m', "ocrmypdf"] + args
proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
with st.container(border=True):
while proc.poll() is None:
line = proc.stderr.readline()
if line:
st.html("<code>" + line.decode().strip() + "</code>")
proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
with st.container(border=True):
while proc.poll() is None:
line = proc.stderr.readline()
if line:
st.html("<code>" + line.decode().strip() + "</code>")
if proc.returncode != 0:
st.error(f"ocrmypdf failed with exit code {proc.returncode}")
st.session_state['running'] = False
st.stop()
if proc.returncode != 0:
st.error(f"ocrmypdf failed with exit code {proc.returncode}")
st.session_state['running'] = False
st.stop()
if Path(output_file.name).stat().st_size == 0:
st.error("No output PDF file was generated")
st.stop()
if Path(output_file.name).stat().st_size == 0:
st.error("No output PDF file was generated")
st.stop()
st.download_button(
label="Download output PDF",
data=output_file.read(),
file_name=uploaded.name,
mime="application/pdf",
)
st.session_state['running'] = False
st.download_button(
label="Download output PDF",
data=output_file.read(),
file_name=uploaded.name,
mime="application/pdf",
)
st.session_state['running'] = False
+2 -6
View File
@@ -16,7 +16,6 @@ from __future__ import annotations
import filecmp
import logging
import os
import posixpath
import shutil
import sys
@@ -39,10 +38,7 @@ script_dir = Path(__file__).parent
# set archive_dir to a path for backup original documents. Leave empty if not required.
archive_dir = "/pdfbak"
if len(sys.argv) > 1:
start_dir = Path(sys.argv[1])
else:
start_dir = Path(".")
start_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path()
if len(sys.argv) > 2:
log_file = Path(sys.argv[2])
@@ -71,7 +67,7 @@ for filename in start_dir.glob("**/*.pdf"):
try:
shutil.copy2(filename, posixpath.dirname(archive_filename))
except OSError:
os.makedirs(posixpath.dirname(archive_filename))
Path(posixpath.dirname(archive_filename)).mkdir(parents=True)
shutil.copy2(filename, posixpath.dirname(archive_filename))
try:
result = ocrmypdf.ocr(filename, filename, deskew=True)
+2
View File
@@ -4,6 +4,8 @@
"""Helper script for bisecting PDFs to find a page with an issue."""
from __future__ import annotations
import sys
import pikepdf
+89 -8
View File
@@ -21,18 +21,18 @@ __ocrmypdf_arguments()
--subject (set metadata)
--keywords (set metadata)
--rotate-pages (rotate pages to correct orientation)
--remove-background (attempt to remove background from pages)
--deskew (fix small horizontal alignment skew)
--clean (clean document images before OCR)
--clean-final (clean document images and keep result)
--unpaper-args (a quoted string of arguments to pass to unpaper)
--oversample (oversample images to this DPI)
--remove-vectors (don\'t send vector objects to OCR)
--threshold (threshold images before OCR)
--mode (processing mode for pages with existing text)
--force-ocr (OCR documents that already have printable text)
--skip-text (skip OCR on any pages that already contain text)
--redo-ocr (redo OCR on any pages that seem to have OCR already)
--invalidate-digital-signatures (remove digital signatures from PDF)
--tagged-pdf-mode (control behavior for Tagged PDFs)
--skip-big (skip OCR on pages larger than this many MPixels)
--optimize (select optimization level)
--jpeg-quality (JPEG quality [0..100])
@@ -42,9 +42,14 @@ __ocrmypdf_arguments()
--pages (apply OCR to only the specified pages)
--max-image-mpixels (image decompression bomb threshold)
--pdf-renderer (select PDF renderer options)
--ocr-engine (OCR engine to use)
--rasterizer (PDF page rasterizer)
--rotate-pages-threshold (page rotation confidence)
--pdfa-image-compression (set PDF/A image compression options)
--ghostscript-jpeg-quality (Ghostscript JPEG quality during PDF/A [0..100])
--ghostscript-jpeg-maxdpi (cap Ghostscript image DPI during PDF/A)
--fast-web-view (if file size if above this amount in MB linearize PDF)
--continue-on-soft-render-error (continue after recoverable render errors)
--plugin (name of plugin to import)
--keep-temporary-files (keep temporary files (debug)
--tesseract-config (set custom tesseract config file)
@@ -52,6 +57,10 @@ __ocrmypdf_arguments()
--tesseract-oem (set tesseract --oem)
--tesseract-thresholding (set tesseract image thresholding)
--tesseract-timeout (maximum number of seconds to wait for OCR)
--tesseract-non-ocr-timeout (maximum seconds for non-OCR operations)
--tesseract-downsample-large-images (downsample large images before OCR)
--no-tesseract-downsample-large-images (do not downsample large images)
--tesseract-downsample-above (downsample images larger than this pixel size)
--user-words (specify location of user words file)
--user-patterns (specify location of user patterns file)
--no-progress-bar (disable the progress bar)
@@ -68,7 +77,8 @@ __ocrmypdf_arguments()
__ocrmypdf_output-type()
{
local choices="pdfa (output a PDF/A (default))
local choices="auto (best-effort PDF/A without Ghostscript (default))
pdfa (output a PDF/A-2b)
pdf (output a standard PDF)
pdfa-1 (output a PDF/A-1b)
pdfa-2 (output a PDF/A-2b)
@@ -114,10 +124,11 @@ __ocrmypdf_optimize()
__ocrmypdf_pdf-renderer()
{
local choices="auto (auto select PDF renderer)
hocr (use hOCR renderer)
hocrdebug (uses hOCR renderer in debug mode, showing recognized text)
sandwich (use sandwich renderer)"
local choices="auto (auto select PDF renderer, uses fpdf2)
fpdf2 (use fpdf2 renderer with full language support)
sandwich (use sandwich renderer)
hocr (use hOCR renderer - deprecated)
hocrdebug (uses hOCR renderer in debug mode - deprecated)"
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
@@ -210,6 +221,58 @@ UseDeviceIndependentColor (convert with device independent color)"
fi
}
__ocrmypdf_mode()
{
local choices="default (error if text is found)
force (rasterize all content and run OCR)
skip (skip pages with existing text)
redo (re-OCR pages, replacing old invisible text)"
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
# Remove description if only one completion exists
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
COMPREPLY=( ${COMPREPLY[0]%% *} )
fi
}
__ocrmypdf_tagged-pdf-mode()
{
local choices="default (error if --mode is default, otherwise warn)
ignore (always warn but continue processing)"
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
# Remove description if only one completion exists
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
COMPREPLY=( ${COMPREPLY[0]%% *} )
fi
}
__ocrmypdf_ocr-engine()
{
local choices="auto (select best available engine)
tesseract (use Tesseract OCR)
none (skip OCR entirely)"
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
# Remove description if only one completion exists
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
COMPREPLY=( ${COMPREPLY[0]%% *} )
fi
}
__ocrmypdf_rasterizer()
{
local choices="auto (prefer pypdfium, fall back to Ghostscript)
ghostscript (use Ghostscript rasterizer)
pypdfium (use pypdfium rasterizer - faster)"
COMPREPLY=( $( compgen -W "$choices" -- "$cur") )
# Remove description if only one completion exists
if [[ ${#COMPREPLY[*]} -eq 1 ]]; then
COMPREPLY=( ${COMPREPLY[0]%% *} )
fi
}
__ocrmypdf_check_previous()
{
case $prev in
@@ -241,6 +304,22 @@ __ocrmypdf_check_previous()
__ocrmypdf_pdf-renderer
return 0
;;
-m|--mode)
__ocrmypdf_mode
return 0
;;
--tagged-pdf-mode)
__ocrmypdf_tagged-pdf-mode
return 0
;;
--ocr-engine)
__ocrmypdf_ocr-engine
return 0
;;
--rasterizer)
__ocrmypdf_rasterizer
return 0
;;
--pdfa-image-compression)
__ocrmypdf_pdfa-image-compression
return 0
@@ -260,7 +339,9 @@ __ocrmypdf_check_previous()
--title|--author|--subject|--keywords|--unpaper-args|--pages|--plugin|\
--jpeg-quality|--png-quality|--image-dpi|--oversample|--skip-big|--max-image-mpixels|\
--tesseract-timeout|--rotate-pages-threshold|--fast-web-view)
--ghostscript-jpeg-quality|--ghostscript-jpeg-maxdpi|\
--tesseract-timeout|--tesseract-non-ocr-timeout|--tesseract-downsample-above|\
--rotate-pages-threshold|--fast-web-view)
# argument required but no completions available
return 0
;;
+42 -4
View File
@@ -11,13 +11,27 @@ complete -c ocrmypdf -s r -l rotate-pages -d "rotate pages to correct orientatio
complete -c ocrmypdf -s d -l deskew -d "fix small horizontal alignment skew"
complete -c ocrmypdf -s c -l clean -d "clean document images before OCR"
complete -c ocrmypdf -s i -l clean-final -d "clean document images and keep result"
complete -c ocrmypdf -x -l unpaper-args -d "quoted string of arguments to pass to unpaper"
complete -c ocrmypdf -l remove-vectors -d "don't send vector objects to OCR"
function __fish_ocrmypdf_mode
echo -e "default\t"(_ "error if text is found")
echo -e "force\t"(_ "rasterize all content and run OCR")
echo -e "skip\t"(_ "skip pages with existing text")
echo -e "redo\t"(_ "re-OCR pages, replacing old invisible text")
end
complete -c ocrmypdf -x -s m -l mode -a '(__fish_ocrmypdf_mode)' -d "processing mode for pages with existing text"
complete -c ocrmypdf -s f -l force-ocr -d "OCR documents that already have printable text"
complete -c ocrmypdf -s s -l skip-text -d "skip OCR on any pages that already contain text"
complete -c ocrmypdf -l redo-ocr -d "redo OCR on any pages that seem to have OCR already"
complete -c ocrmypdf -l invalidate-digital-signatures -d "invalidate digital signatures and allow OCR to proceed"
function __fish_ocrmypdf_tagged_pdf_mode
echo -e "default\t"(_ "error if --mode is default, otherwise warn")
echo -e "ignore\t"(_ "always warn but continue processing")
end
complete -c ocrmypdf -x -l tagged-pdf-mode -a '(__fish_ocrmypdf_tagged_pdf_mode)' -d "control behavior for Tagged PDFs"
complete -c ocrmypdf -s k -l keep-temporary-files -d "keep temporary files (debug)"
function __fish_ocrmypdf_languages
@@ -32,7 +46,8 @@ complete -c ocrmypdf -x -s l -l language -a '(__fish_ocrmypdf_languages)' -d lan
complete -c ocrmypdf -x -l image-dpi -d "assume this DPI if input image DPI is unknown"
function __fish_ocrmypdf_output_type
echo -e "pdfa\t"(_ "output a PDF/A (default)")
echo -e "auto\t"(_ "best-effort PDF/A without requiring Ghostscript (default)")
echo -e "pdfa\t"(_ "output a PDF/A-2b")
echo -e "pdf\t"(_ "output a standard PDF")
echo -e "pdfa-1\t"(_ "output a PDF/A-1b")
echo -e "pdfa-2\t"(_ "output a PDF/A-2b")
@@ -42,13 +57,28 @@ end
complete -c ocrmypdf -x -l output-type -a '(__fish_ocrmypdf_output_type)' -d "select PDF output options"
function __fish_ocrmypdf_pdf_renderer
echo -e "auto\t"(_ "auto select PDF renderer")
echo -e "hocr\t"(_ "use hOCR renderer")
echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode, showing recognized text")
echo -e "auto\t"(_ "auto select PDF renderer (default, uses fpdf2)")
echo -e "fpdf2\t"(_ "use fpdf2 renderer with full language support")
echo -e "sandwich\t"(_ "use sandwich renderer")
echo -e "hocr\t"(_ "use hOCR renderer (deprecated)")
echo -e "hocrdebug\t"(_ "uses hOCR renderer in debug mode (deprecated)")
end
complete -c ocrmypdf -x -l pdf-renderer -a '(__fish_ocrmypdf_pdf_renderer)' -d "select PDF renderer options"
function __fish_ocrmypdf_ocr_engine
echo -e "auto\t"(_ "select best available engine (default)")
echo -e "tesseract\t"(_ "use Tesseract OCR")
echo -e "none\t"(_ "skip OCR entirely")
end
complete -c ocrmypdf -x -l ocr-engine -a '(__fish_ocrmypdf_ocr_engine)' -d "OCR engine to use"
function __fish_ocrmypdf_rasterizer
echo -e "auto\t"(_ "prefer pypdfium, fall back to Ghostscript (default)")
echo -e "ghostscript\t"(_ "use Ghostscript rasterizer")
echo -e "pypdfium\t"(_ "use pypdfium rasterizer (faster)")
end
complete -c ocrmypdf -x -l rasterizer -a '(__fish_ocrmypdf_rasterizer)' -d "PDF page rasterizer"
function __fish_ocrmypdf_optimize
echo -e "0\t"(_ "do not optimize")
echo -e "1\t"(_ "do safe, lossless optimizations (default)")
@@ -72,6 +102,8 @@ function __fish_ocrmypdf_pdfa_compression
echo -e "lossless\t"(_ "convert color and grayscale images to lossless (PNG)")
end
complete -c ocrmypdf -x -l pdfa-image-compression -a '(__fish_ocrmypdf_pdfa_compression)' -d "set PDF/A image compression options"
complete -c ocrmypdf -x -l ghostscript-jpeg-quality -d "Ghostscript JPEG quality during PDF/A [0..100]"
complete -c ocrmypdf -x -l ghostscript-jpeg-maxdpi -d "cap Ghostscript image DPI during PDF/A"
complete -c ocrmypdf -x -s j -l jobs -d "how many worker processes to use"
complete -c ocrmypdf -x -l title -d "set metadata"
@@ -124,11 +156,17 @@ end
complete -c ocrmypdf -x -l tesseract-thresholding -a '(__fish_ocrmypdf_tesseract_thresholding)' -d "set tesseract thresholding method (needs Tesseract 5.x)"
complete -c ocrmypdf -x -l tesseract-timeout -d "maximum number of seconds to wait for OCR"
complete -c ocrmypdf -x -l tesseract-non-ocr-timeout -d "maximum seconds to wait for non-OCR operations"
complete -c ocrmypdf -l tesseract-downsample-large-images -d "downsample large images before OCR"
complete -c ocrmypdf -l no-tesseract-downsample-large-images -d "do not downsample large images"
complete -c ocrmypdf -x -l tesseract-downsample-above -d "downsample images larger than this pixel size"
complete -c ocrmypdf -x -l rotate-pages-threshold -d "page rotation confidence"
complete -c ocrmypdf -r -l user-words -d "specify location of user words file"
complete -c ocrmypdf -r -l user-patterns -d "specify location of user patterns file"
complete -c ocrmypdf -x -l fast-web-view -d "if file size if above this amount in MB, linearize PDF"
complete -c ocrmypdf -l continue-on-soft-render-error -d "continue processing after recoverable render errors"
complete -c ocrmypdf -r -l plugin -d "name of plugin to import"
function __fish_ocrmypdf_color_conversion_strategy
echo -e "LeaveColorUnchanged\t"(_ "do not convert color spaces (default)")
+9 -2
View File
@@ -6,12 +6,19 @@ services:
ocrmypdf:
restart: always
container_name: ocrmypdf
image: jbarlow83/ocrmypdf
image: jbarlow83/ocrmypdf-alpine
volumes:
- "/media/scan:/input"
- "/mnt/scan:/output"
environment:
- OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0
# The image runs as the non-root "app" user (uid 1000) by default. The
# correct value here depends on your runtime, so that the watcher can write
# to the /output bind mount and the files end up owned by you:
# rootful Docker -> your host uid:gid
# rootless Docker -> "0:0" (container root maps to your host user)
# Podman -> your host uid:gid, plus `userns_mode: "keep-id"`
# See docs/docker.md ("Bind-mounted volumes") for the reasoning.
user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>"
entrypoint: python3
command: watcher.py
command: /app/watcher.py
+6 -6
View File
@@ -37,8 +37,8 @@ def do_column(label, suffix, d):
env[k] = v
args = shlex.split(
cli.format(
in_=os.path.join(d, "input.pdf"),
out=os.path.join(d, f"output{suffix}.pdf"),
in_=Path(d) / "input.pdf",
out=Path(d) / f"output{suffix}.pdf",
)
)
with st.expander("Environment variables", expanded=bool(env_text.strip())):
@@ -106,10 +106,10 @@ def main():
)
)
doc1 = pymupdf.open(os.path.join(d, "output1.pdf"))
doc2 = pymupdf.open(os.path.join(d, "output2.pdf"))
for i, page1_2 in enumerate(zip(doc1, doc2)):
st.write(f"Page {i+1}")
doc1 = pymupdf.open(Path(d, "output1.pdf"))
doc2 = pymupdf.open(Path(d, "output2.pdf"))
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
st.write(f"Page {i + 1}")
page1, page2 = page1_2
col1, col2 = st.columns(2)
with col1, st.container(border=True):
+4 -5
View File
@@ -5,7 +5,6 @@
from __future__ import annotations
import os
from io import BytesIO
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -60,10 +59,10 @@ def main():
Path(d, "2.pdf").write_bytes(pdf_bytes2)
with st.expander("Text"):
doc1 = pymupdf.open(os.path.join(d, "1.pdf"))
doc2 = pymupdf.open(os.path.join(d, "2.pdf"))
for i, page1_2 in enumerate(zip(doc1, doc2)):
st.write(f"Page {i+1}")
doc1 = pymupdf.open(Path(d, "1.pdf"))
doc2 = pymupdf.open(Path(d, "2.pdf"))
for i, page1_2 in enumerate(zip(doc1, doc2, strict=False)):
st.write(f"Page {i + 1}")
page1, page2 = page1_2
col1, col2 = st.columns(2)
with col1, st.container(border=True):
+29 -18
View File
@@ -5,34 +5,45 @@
from __future__ import annotations
from pathlib import Path
from subprocess import run
from tempfile import NamedTemporaryFile
from typing import Annotated
import typer
import cyclopts
app = cyclopts.App()
@app.default
def main(
pdf1: Annotated[typer.FileBinaryRead, typer.Argument()],
pdf2: Annotated[typer.FileBinaryRead, typer.Argument()],
engine: Annotated[str, typer.Option()] = 'pdftotext',
pdf1: Annotated[Path, cyclopts.Parameter()],
pdf2: Annotated[Path, cyclopts.Parameter()],
*,
engine: Annotated[str, cyclopts.Parameter()] = 'pdftotext',
):
"""Compare text in PDFs."""
with pdf1.open('rb') as f1, pdf2.open('rb') as f2:
text1 = run(
['pdftotext', '-layout', '-', '-'],
stdin=f1,
capture_output=True,
check=True,
)
text2 = run(
['pdftotext', '-layout', '-', '-'],
stdin=f2,
capture_output=True,
check=True,
)
text1 = run(
['pdftotext', '-layout', '-', '-'], stdin=pdf1, capture_output=True, check=True
)
text2 = run(
['pdftotext', '-layout', '-', '-'], stdin=pdf2, capture_output=True, check=True
)
with NamedTemporaryFile() as f1, NamedTemporaryFile() as f2:
f1.write(text1.stdout)
f1.flush()
f2.write(text2.stdout)
f2.flush()
with NamedTemporaryFile() as t1, NamedTemporaryFile() as t2:
t1.write(text1.stdout)
t1.flush()
t2.write(text2.stdout)
t2.flush()
diff = run(
['diff', '--color=always', '--side-by-side', f1.name, f2.name],
['diff', '--color=always', '--side-by-side', t1.name, t2.name],
capture_output=True,
)
run(['less', '-R'], input=diff.stdout, check=True)
@@ -43,4 +54,4 @@ def main(
if __name__ == '__main__':
typer.run(main)
app()
+10 -9
View File
@@ -13,13 +13,14 @@ import shutil
import subprocess
import sys
import time
from pathlib import Path
# pylint: disable=logging-format-interpolation
# pylint: disable=logging-not-lazy
script_dir = os.path.dirname(os.path.realpath(__file__))
script_dir = Path(os.path.realpath(__file__)).parent
timestamp = time.strftime("%Y-%m-%d-%H%M_")
log_file = script_dir + '/' + timestamp + 'ocrmypdf.log'
log_file = script_dir / (timestamp + 'ocrmypdf.log')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(message)s',
@@ -33,10 +34,10 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
logging.info(dir_name)
os.chdir(dir_name)
for filename in file_list:
file_stem, file_ext = os.path.splitext(filename)
file_stem, file_ext = Path(filename).stem, Path(filename).suffix
if file_ext != '.pdf':
continue
full_path = os.path.join(dir_name, filename)
full_path = Path(dir_name, filename)
timestamp_ocr = time.strftime("%Y-%m-%d-%H%M_OCR_")
filename_ocr = timestamp_ocr + file_stem + '.pdf'
# create string for pdf processing
@@ -52,10 +53,10 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
'-',
]
logging.info(cmd)
full_path_ocr = os.path.join(dir_name, filename_ocr)
full_path_ocr = Path(dir_name, filename_ocr)
with (
open(filename, 'rb') as input_file,
open(full_path_ocr, 'wb') as output_file,
Path(filename).open('rb') as input_file,
full_path_ocr.open('wb') as output_file,
):
proc = subprocess.run(
cmd,
@@ -67,8 +68,8 @@ for dir_name, _subdirs, file_list in os.walk(start_dir):
errors='ignore',
)
logging.info(proc.stderr)
os.chmod(full_path_ocr, 0o664)
os.chmod(full_path, 0o664)
full_path_ocr.chmod(0o664)
full_path.chmod(0o664)
full_path_ocr_archive = sys.argv[2]
full_path_archive = sys.argv[2] + '/no_ocr'
shutil.move(full_path_ocr, full_path_ocr_archive)
+47 -64
View File
@@ -7,18 +7,18 @@
from __future__ import annotations
import datetime as dt
import json
import logging
import shutil
import sys
import time
from datetime import datetime
from enum import Enum
from enum import StrEnum
from pathlib import Path
from typing import Annotated, Any
import cyclopts
import pikepdf
import typer
from dotenv import load_dotenv
from watchdog.events import PatternMatchingEventHandler
from watchdog.observers import Observer
@@ -30,12 +30,12 @@ load_dotenv()
# pylint: disable=logging-format-interpolation
app = typer.Typer(name="ocrmypdf-watcher")
app = cyclopts.App(name="ocrmypdf-watcher")
log = logging.getLogger('ocrmypdf-watcher')
class LoggingLevelEnum(str, Enum):
class LoggingLevelEnum(StrEnum):
"""Enum for logging levels."""
DEBUG = "DEBUG"
@@ -48,7 +48,7 @@ class LoggingLevelEnum(str, Enum):
def get_output_path(root: Path, basename: str, output_dir_year_month: bool) -> Path:
assert '/' not in basename, "basename must not contain '/'"
if output_dir_year_month:
today = datetime.today()
today = dt.datetime.today()
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)
@@ -114,9 +114,11 @@ def execute_ocrmypdf(
f'kwargs: {ocrmypdf_kwargs}'
)
exit_code = ocrmypdf.ocr(
input_file=file_path,
output_file=output_path,
**ocrmypdf_kwargs,
ocrmypdf.OcrOptions(
input_file=file_path,
output_file=output_path,
**ocrmypdf_kwargs,
)
)
if exit_code == 0:
if on_success_delete:
@@ -138,7 +140,7 @@ class HandleObserverEvent(PatternMatchingEventHandler):
ignore_patterns=None,
ignore_directories=False,
case_sensitive=False,
settings={},
settings=None,
):
super().__init__(
patterns=patterns,
@@ -146,117 +148,101 @@ class HandleObserverEvent(PatternMatchingEventHandler):
ignore_directories=ignore_directories,
case_sensitive=case_sensitive,
)
self._settings = settings
self._settings = settings if settings else {}
def on_any_event(self, event):
if event.event_type in ['created']:
execute_ocrmypdf(file_path=Path(event.src_path), **self._settings)
@app.command()
@app.default
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,
cyclopts.Parameter(
env_var='OCR_INPUT_DIRECTORY',
),
] = '/input',
] = Path('/input'),
output_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_OUTPUT_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
cyclopts.Parameter(
env_var='OCR_OUTPUT_DIRECTORY',
),
] = '/output',
] = Path('/output'),
archive_dir: Annotated[
Path,
typer.Argument(
envvar='OCR_ARCHIVE_DIRECTORY',
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
cyclopts.Parameter(
env_var='OCR_ARCHIVE_DIRECTORY',
),
] = '/processed',
] = Path('/processed'),
*,
output_dir_year_month: Annotated[
bool,
typer.Option(
envvar='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
cyclopts.Parameter(
env_var='OCR_OUTPUT_DIRECTORY_YEAR_MONTH',
help='Create a subdirectory in the output directory for each year/month',
),
] = False,
on_success_delete: Annotated[
bool,
typer.Option(
envvar='OCR_ON_SUCCESS_DELETE',
cyclopts.Parameter(
env_var='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',
cyclopts.Parameter(
env_var='OCR_ON_SUCCESS_ARCHIVE',
help='Archive the input file after successful OCR',
),
] = False,
deskew: Annotated[
bool,
typer.Option(
envvar='OCR_DESKEW',
cyclopts.Parameter(
env_var='OCR_DESKEW',
help='Deskew the input file before OCR',
),
] = False,
ocr_json_settings: Annotated[
str,
typer.Option(
envvar='OCR_JSON_SETTINGS',
str | None,
cyclopts.Parameter(
env_var='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',
cyclopts.Parameter(
env_var='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',
cyclopts.Parameter(
env_var='OCR_USE_POLLING',
help='Use polling instead of filesystem events',
),
] = False,
retries_loading_file: Annotated[
int,
typer.Option(
envvar='OCR_RETRIES_LOADING_FILE',
cyclopts.Parameter(
env_var='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',
cyclopts.Parameter(
env_var='OCR_LOGLEVEL',
help='Logging level',
),
] = LoggingLevelEnum.INFO,
patterns: Annotated[
str,
typer.Option(
envvar='OCR_PATTERNS',
cyclopts.Parameter(
env_var='OCR_PATTERNS',
help='File patterns to watch',
),
] = '*.pdf,*.PDF',
@@ -316,13 +302,10 @@ def main(
'output_dir_year_month': output_dir_year_month,
},
)
if use_polling:
observer = PollingObserver()
else:
observer = Observer()
observer = PollingObserver() if use_polling else Observer()
observer.schedule(handler, input_dir, recursive=True)
observer.start()
typer.echo(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.")
print(f"Watching {input_dir} for new PDFs. Press Ctrl+C to exit.")
try:
while True:
time.sleep(30)
+3 -1
View File
@@ -4,6 +4,8 @@
"""Run the OCRmyPDF web service."""
from __future__ import annotations
import os
import sys
@@ -13,7 +15,7 @@ except ImportError:
raise ImportError(
'You need to install streamlit in the Python environment '
'to run the web service.\n'
)
) from None
if __name__ == '__main__':
os.execvp(
+64
View File
@@ -0,0 +1,64 @@
# prek pre-commit configuration — https://prek.j178.dev
#
# The local/system hooks below invoke the project's OWN pinned tools (ruff/mypy
# from uv.lock) and mirror .github/workflows/build.yml's lint job exactly, so
# they can never drift from CI's versions or rules. prek installs nothing of
# its own for them — "system" language just execs whatever `uv run` resolves.
#
# The pre-commit/pre-commit-hooks repo hooks below are generic file checks with
# no project-local tool equivalent, so they're kept as a normal (non-local) repo.
#
# Run all checks manually: `uv run prek run --all-files`
# Install the git hooks: `uv run prek install`
default_install_hook_types = ["pre-commit", "pre-push"]
default_stages = ["pre-commit"]
[[repos]]
repo = "https://github.com/pre-commit/pre-commit-hooks"
rev = "v4.4.0"
[[repos.hooks]]
id = "check-case-conflict"
[[repos.hooks]]
id = "check-merge-conflict"
[[repos.hooks]]
id = "check-toml"
[[repos.hooks]]
id = "check-yaml"
[[repos.hooks]]
id = "debug-statements"
[[repos]]
repo = "local"
[[repos.hooks]]
id = "ruff-format"
name = "ruff format (check)"
language = "system"
entry = "uv run ruff format --check ."
types = ["python"]
pass_filenames = false
require_serial = true
[[repos.hooks]]
id = "ruff-check"
name = "ruff check"
language = "system"
entry = "uv run ruff check ."
types = ["python"]
pass_filenames = false
require_serial = true
[[repos.hooks]]
id = "mypy"
name = "mypy"
language = "system"
entry = "uv run mypy src/ocrmypdf"
types = ["python"]
pass_filenames = false
require_serial = true
+83 -43
View File
@@ -1,26 +1,30 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
[build-system]
requires = ["hatchling", "hatch-vcs"]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ocrmypdf"
dynamic = ["version"]
version = "17.9.0"
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
readme = "README.md"
license = "MPL-2.0"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"deprecation>=2.1.0",
"fpdf2>=2.8.0",
"img2pdf>=0.5",
"packaging>=20",
"pdfminer.six>=20220319",
"pi-heif", # Heif image format - maintainers: if this is removed, it will NOT break
"pikepdf>=8.10.1,!=9.8.0",
"pdfminer.six>=20260107", # fixes parsing of tokens split across the read buffer/streams (gh #1361)
"pi-heif", # Heif image format - maintainers: if this is removed, it will NOT break
"pikepdf>=10",
"Pillow>=10.0.1",
"pluggy>=1",
"pydantic>=2.12.5",
"pypdfium2>=5.0.0",
"rich>=13",
"typing-extensions>=4.12; python_version < '3.13'",
"uharfbuzz>=0.53.2",
]
authors = [{ name = "James R. Barlow", email = "james@purplerock.ca" }]
classifiers = [
@@ -45,36 +49,18 @@ keywords = ["PDF", "OCR", "optical character recognition", "PDF/A", "scanning"]
Documentation = "https://ocrmypdf.readthedocs.io/"
Source = "https://github.com/ocrmypdf/OCRmyPDF"
Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
Changelog = "https://github.com/ocrmypdf/OCRmyPDF/docs/release_notes.rst"
Changelog = "https://github.com/ocrmypdf/OCRmyPDF/tree/main/docs/releasenotes"
[project.optional-dependencies]
docs = ["myst-parser>=4.0.1", "sphinx", "sphinx-issues", "sphinx-rtd-theme"]
extended_test = ["PyMuPDF>=1.19.1"]
test = [
"coverage[toml]>=6.2",
"hypothesis>=6.36.0",
"pytest>=6.2.5",
"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", "typer-slim[standard]", "python-dotenv"]
# User-installable features - use `uv sync --extra <name>` or `pip install ocrmypdf[name]`
watcher = ["watchdog>=1.0.2", "cyclopts>=3", "python-dotenv"]
webservice = ["streamlit>=1.41.0"]
[project.scripts]
ocrmypdf = "ocrmypdf.__main__:run"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/ocrmypdf/_version.py"
[tool.distutils.bdist_wheel]
python-tag = "py310"
python-tag = "py311"
[tool.coverage.run]
branch = true
@@ -112,40 +98,67 @@ filterwarnings = [
]
[tool.mypy]
check_untyped_defs = true
[[tool.mypy.overrides]]
module = [
'pluggy',
'img2pdf',
'pdfminer.*',
'reportlab.*',
'fitz',
'libxmp.utils',
'pypdfium2',
'uharfbuzz',
'pi_heif',
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# Test functions are not required to annotate their return type (almost
# always None); it's a low-value hint that would just be noise here.
module = 'tests.*'
disallow_untyped_defs = false
disallow_incomplete_defs = false
[tool.ruff]
target-version = "py310"
target-version = "py311"
exclude = ["src/ocrmypdf/_version.py"] # Autogenerated
[tool.ruff.lint]
"select" = [
"D", # pydocstyle
"E", # pycodestyle
"W", # pycodestyle
"F", # pyflakes
"I001", # isort
"UP", # pyupgrade
"D", # pydocstyle
"E", # pycodestyle
"W", # pycodestyle
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"SIM", # simplify
"B", # flake8-bugbear
"ICN", # flake8-import-conventions
"PTH", # flake8-use-pathlib
]
ignore = [
"B028", # warning with no explicit stacklevel
# rule is key in dict instead of key in dict.keys(); but pikepdf semantics differ
"SIM118",
]
[tool.ruff.lint.isort]
known-first-party = ["ocrmypdf"]
required-imports = ["from __future__ import annotations"]
[tool.ruff.lint.flake8-import-conventions]
# Prohibit explicit imports from the 'datetime' module
banned-from = ["datetime"]
# Optionally, suggest an alias for 'import datetime' (e.g., as dt)
extend-aliases = { "datetime" = "dt" }
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.per-file-ignores]
"docs/conf.py" = ["D100", "D101", "D105"]
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105", "E501"]
"misc/*.py" = ["D103", "D101", "D102"]
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
@@ -153,10 +166,37 @@ convention = "google"
quote-style = "preserve"
[dependency-groups]
# Developer-only tools - use `uv sync --group <name>`
dev = [
"mypy>=1.13.0",
"pymupdf>=1.24.14",
"streamlit-pdf-viewer>=0.0.19",
"streamlit>=1.40.2",
"ipykernel>=6.29.5",
"mypy>=1.13.0",
"ruff>=0.14.11",
"prek>=0.4.8",
"ipykernel>=6.29.5",
"reportlab>=4.4.4",
"cyclopts>=4.5.1",
"pygithub>=2.9.1",
]
test = [
# Core testing framework
"coverage[toml]>=6.2",
"hypothesis>=6.36.0",
"pytest>=6.2.5",
"pytest-cov>=3.0.0",
"pytest-xdist>=2.5.0",
# Test dependencies
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
"reportlab>=3.6.8",
# Type stubs for testing
"types-humanfriendly",
# Extended test capabilities (merged from extended_test)
"pymupdf>=1.24.14",
]
docs = [
"myst-parser>=4.0.1",
"sphinx",
"sphinx-issues",
"sphinx-reredirects",
"sphinx-rtd-theme",
"sphinxcontrib-mermaid",
]
streamlit-dev = ["streamlit>=1.40.2", "streamlit-pdf-viewer>=0.0.19"]
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Generate the Occulta glyphless font for OCRmyPDF.
Occulta (Latin for "hidden") is a glyphless font designed for invisible text layers
in searchable PDFs. It has proper Unicode cmap coverage using format 13 (many-to-one)
for efficient mapping of all BMP codepoints to a small set of width-specific glyphs.
Features:
- Full BMP coverage (U+0000 to U+FFFF)
- Width-aware glyphs for proper text selection:
- Zero-width for combining marks and invisible characters
- Regular width (500 units) for Latin, Greek, Cyrillic, Arabic, Hebrew, etc.
- Double width (1000 units) for CJK and fullwidth characters
- Uses cmap format 13 (many-to-one) for ~12KB size vs ~780KB with format 12
- Compatible with fpdf2 and other modern PDF libraries
Usage:
python scripts/generate_glyphless_font.py
Output:
src/ocrmypdf/data/Occulta.ttf
"""
from __future__ import annotations
import unicodedata
from pathlib import Path
from fontTools.fontBuilder import FontBuilder
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._c_m_a_p import CmapSubtable
from fontTools.ttLib.tables._g_l_y_f import Glyph
# Output path relative to this script
OUTPUT_PATH = Path(__file__).parent.parent / "src" / "ocrmypdf" / "data" / "Occulta.ttf"
# Font metrics (units per em = 1000)
UNITS_PER_EM = 1000
ASCENT = 800
DESCENT = -200
# Glyph definitions: (name, advance_width, left_side_bearing)
GLYPHS = [
(".notdef", 500, 0), # Required, used for unmapped characters
("space", 500, 0), # U+0020 SPACE
("nbspace", 500, 0), # U+00A0 NO-BREAK SPACE
("blank0", 0, 0), # Zero-width (combining marks, ZWNJ, ZWJ, BOM)
("blank1", 500, 0), # Regular width (most scripts)
("blank2", 1000, 0), # Double width (CJK, fullwidth)
]
# Explicit zero-width character codepoints
ZERO_WIDTH_CHARS = frozenset(
[
0x200B, # ZERO WIDTH SPACE
0x200C, # ZERO WIDTH NON-JOINER
0x200D, # ZERO WIDTH JOINER
0xFEFF, # ZERO WIDTH NO-BREAK SPACE (BOM)
0x200E, # LEFT-TO-RIGHT MARK
0x200F, # RIGHT-TO-LEFT MARK
0x202A, # LEFT-TO-RIGHT EMBEDDING
0x202B, # RIGHT-TO-LEFT EMBEDDING
0x202C, # POP DIRECTIONAL FORMATTING
0x202D, # LEFT-TO-RIGHT OVERRIDE
0x202E, # RIGHT-TO-LEFT OVERRIDE
0x2060, # WORD JOINER
0x2061, # FUNCTION APPLICATION
0x2062, # INVISIBLE TIMES
0x2063, # INVISIBLE SEPARATOR
0x2064, # INVISIBLE PLUS
]
)
def classify_codepoint(codepoint: int) -> str:
"""Classify a Unicode codepoint into one of our glyph categories.
Args:
codepoint: Unicode codepoint (0x0000 to 0xFFFF)
Returns:
Glyph name to map this codepoint to
"""
# Special cases first
if codepoint == 0x0020:
return "space"
if codepoint == 0x00A0:
return "nbspace"
if codepoint in ZERO_WIDTH_CHARS:
return "blank0"
# Use Unicode properties for the rest
char = chr(codepoint)
try:
category = unicodedata.category(char)
east_asian_width = unicodedata.east_asian_width(char)
# Combining marks are zero-width
if category.startswith("M"):
return "blank0"
# Wide and Fullwidth characters are double-width
if east_asian_width in ("W", "F"):
return "blank2"
# Everything else is regular width
return "blank1"
except (ValueError, TypeError):
# Fallback for any edge cases
return "blank1"
def build_cmap() -> dict[int, str]:
"""Build the Unicode to glyph name mapping for the entire BMP.
Returns:
Dictionary mapping codepoints to glyph names
"""
return {cp: classify_codepoint(cp) for cp in range(0x10000)}
def create_font() -> TTFont:
"""Create the Occulta glyphless font.
Returns:
TTFont object ready to be saved
"""
glyph_names = [g[0] for g in GLYPHS]
# Start building the font
fb = FontBuilder(UNITS_PER_EM, isTTF=True)
fb.setupGlyphOrder(glyph_names)
# Create empty (invisible) glyphs
glyphs = {}
for name, _, _ in GLYPHS:
glyph = Glyph()
glyph.numberOfContours = 0
glyphs[name] = glyph
fb.setupGlyf(glyphs)
# Set up horizontal metrics
metrics = {name: (width, lsb) for name, width, lsb in GLYPHS}
fb.setupHorizontalMetrics(metrics)
# Minimal cmap to satisfy FontBuilder (we'll replace it later)
fb.setupCharacterMap({0x0020: "space", 0x00A0: "nbspace"})
# Set up other required tables
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupOS2(
sTypoAscender=ASCENT,
sTypoDescender=DESCENT,
sTypoLineGap=0,
usWinAscent=UNITS_PER_EM,
usWinDescent=abs(DESCENT),
sxHeight=500,
sCapHeight=700,
)
import time
# Use current time for font timestamps
now = int(time.time())
fb.setupHead(unitsPerEm=UNITS_PER_EM, created=now, modified=now)
fb.setupPost()
fb.setupNameTable(
{
"familyName": "Occulta",
"styleName": "Regular",
"uniqueFontIdentifier": "OCRmyPDF;Occulta-Regular;2026",
"fullName": "Occulta Regular",
"version": "Version 2.0",
"psName": "Occulta-Regular",
}
)
# Build the font
font = fb.font
# Now replace the cmap with format 13 for efficient many-to-one mapping
char_to_glyph = build_cmap()
cmap13 = CmapSubtable.newSubtable(13)
cmap13.platformID = 3 # Windows
cmap13.platEncID = 10 # Unicode full repertoire
cmap13.language = 0
cmap13.cmap = char_to_glyph
font["cmap"].tables = [cmap13]
return font
def main() -> None:
"""Generate the Occulta font and save it."""
print("Generating Occulta glyphless font...")
font = create_font()
# Create output directory if needed
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
# Save the font
font.save(str(OUTPUT_PATH))
font.close()
# Report statistics
size = OUTPUT_PATH.stat().st_size
print(f"Saved to: {OUTPUT_PATH}")
print(f"Size: {size:,} bytes")
# Verify cmap
font = TTFont(str(OUTPUT_PATH))
for table in font["cmap"].tables:
print(
f"cmap: Platform {table.platformID}, "
f"Encoding {table.platEncID}, "
f"Format {table.format}, "
f"{len(table.cmap)} mappings"
)
font.close()
print("Done!")
if __name__ == "__main__":
main()
+8 -13
View File
@@ -5,7 +5,7 @@
name: ocrmypdf
title: OCRmyPDF
base: core22
base: core24
version: git
summary: OCRmyPDF adds a searchable text layer to scanned PDF files
description: OCRmyPDF packaged for snap
@@ -14,12 +14,13 @@ confinement: strict
icon: docs/images/logo-square-256.svg
license: MPL-2.0
architectures: [amd64]
platforms:
amd64:
environment:
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/4.00/tessdata
GS_LIB: $SNAP/usr/share/ghostscript/9.55.0/Resource/Init
GS_FONTPATH: $SNAP/usr/share/ghostscript/9.55.0/Resource/Font
TESSDATA_PREFIX: $SNAP/usr/share/tesseract-ocr/5/tessdata
GS_LIB: $SNAP/usr/share/ghostscript/10.02.1/Resource/Init
GS_FONTPATH: $SNAP/usr/share/ghostscript/10.02.1/Resource/Font
LD_LIBRARY_PATH: $SNAP/usr/lib/x86_64-linux-gnu
apps:
@@ -84,11 +85,5 @@ parts:
- wheel
override-build: |
pip3 install --user dephell[full]
$HOME/.local/bin/dephell deps convert \
--from-path pyproject.toml \
--from-format pyproject \
--to-path setup.py \
--to-format setuppy
snapcraftctl build
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
craftctl default
ln -sf ../usr/lib/libsnapcraft-preload.so $CRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
+17
View File
@@ -11,6 +11,7 @@ from ocrmypdf import helpers, hocrtransform, pdfa, pdfinfo
from ocrmypdf._concurrent import Executor
from ocrmypdf._defaults import PROGRAM_NAME
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._options import OcrOptions, TaggedPdfMode
from ocrmypdf._pipelines._common import (
configure_debug_logging,
)
@@ -18,6 +19,7 @@ from ocrmypdf._version import __version__
from ocrmypdf.api import (
Verbosity,
configure_logging,
configure_stdout_protection,
ocr,
)
from ocrmypdf.exceptions import (
@@ -34,6 +36,13 @@ from ocrmypdf.exceptions import (
TesseractConfigError,
UnsupportedImageFormatError,
)
from ocrmypdf.models.ocr_element import (
Baseline,
BoundingBox,
FontInfo,
OcrClass,
OcrElement,
)
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
hookimpl = _HookimplMarker('ocrmypdf')
@@ -41,20 +50,27 @@ hookimpl = _HookimplMarker('ocrmypdf')
__all__ = [
'__version__',
'BadArgsError',
'Baseline',
'BoundingBox',
'configure_debug_logging',
'configure_logging',
'configure_stdout_protection',
'DpiError',
'EncryptedPdfError',
'Executor',
'ExitCode',
'ExitCodeException',
'FontInfo',
'helpers',
'hocrtransform',
'hookimpl',
'InputFileError',
'MissingDependencyError',
'ocr',
'OcrClass',
'OcrElement',
'OcrEngine',
'OcrOptions',
'OrientationConfidence',
'OutputFileAccessError',
'PageContext',
@@ -64,6 +80,7 @@ __all__ = [
'PriorOcrFoundError',
'PROGRAM_NAME',
'SubprocessOutputError',
'TaggedPdfMode',
'TesseractConfigError',
'UnsupportedImageFormatError',
'Verbosity',
+9 -4
View File
@@ -15,9 +15,9 @@ 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._validation import check_options
from ocrmypdf.api import Verbosity, configure_logging
from ocrmypdf.api import Verbosity, configure_logging, configure_stdout_protection
from ocrmypdf.cli import get_options_and_plugins
from ocrmypdf.exceptions import (
BadArgsError,
ExitCode,
@@ -39,12 +39,17 @@ def sigbus(*args):
def run(args=None):
"""Run the ocrmypdf command line interface."""
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
# Protect the real stdout before loading plugins or starting any worker
# processes/threads, so that only our final PDF output can reach it and
# stray writes from plugins or libraries are diverted to stderr.
configure_stdout_protection()
options, plugin_manager = get_options_and_plugins(args=args)
with suppress(AttributeError, PermissionError):
os.nice(5)
verbosity = options.verbose
verbosity = Verbosity(options.verbose)
if not os.isatty(sys.stderr.fileno()):
options.progress_bar = False
if options.quiet:
+8 -5
View File
@@ -8,14 +8,14 @@ from __future__ import annotations
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterable
from typing import Any, TypeVar
from typing import Any, TypeVar, cast
from ocrmypdf._progressbar import NullProgressBar, ProgressBar
T = TypeVar('T')
def _task_noop(*_args, **_kwargs):
def _task_noop(*_args, **_kwargs) -> None:
return
@@ -72,7 +72,10 @@ class Executor(ABC):
if not task_finished:
task_finished = _task_finished_noop
if not task:
task = _task_noop
# _task_noop always returns None, but T is unbound here (it's
# only meaningful when a real task is supplied); task_finished's
# own no-op default accepts Any, so this is safe.
task = cast('Callable[..., T]', _task_noop)
with self.pool_lock:
self._execute(
@@ -101,8 +104,8 @@ class Executor(ABC):
def setup_executor(plugin_manager) -> Executor:
pbar_class = plugin_manager.hook.get_progressbar_class()
return plugin_manager.hook.get_executor(progressbar_class=pbar_class)
pbar_class = plugin_manager.get_progressbar_class()
return plugin_manager.get_executor(progressbar_class=pbar_class)
class SerialExecutor(Executor):
+2
View File
@@ -2,6 +2,8 @@
# SPDX-License-Identifier: MPL-2.0
# Enforce English hegemony
from __future__ import annotations
DEFAULT_LANGUAGE = 'eng'
# Default rotation threshold
+72
View File
@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2026 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Probe helper for external executables.
Each ``ocrmypdf._exec.<tool>`` module describes its external program with a
module-level :class:`ToolProbe` and delegates ``version()`` / ``available()``
to it. This separates the "is the tool installed and suitable?" question
(probing) from the "run the tool" question (execution). Work functions stay
as pure module-level functions so they are trivially picklable for use in
subprocess workers.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from packaging.version import Version
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version
@dataclass(frozen=True)
class ToolProbe:
"""Describes how to detect an external executable and its version.
Attributes:
program: The program name as it appears on PATH (or a full path).
version_arg: The argument that elicits a version string.
version_regex: A regex with a capturing group that extracts the
version from the program's output.
version_cls: A :class:`packaging.version.Version` subclass, used for
tools with non-standard version strings (e.g. Tesseract).
env: Optional environment overrides applied when probing the version.
also_catch: Additional exception types that should be treated as
"not available" by :meth:`available`. :class:`OSError` is useful
for tools like verapdf whose launcher may fail with non-standard
errors when the JVM is missing.
"""
program: str
version_arg: str = '--version'
version_regex: str = r'(\d+(\.\d+)*)'
version_cls: type[Version] = Version
env: Mapping[str, str] | None = None
also_catch: tuple[type[BaseException], ...] = ()
def version(self) -> Version:
"""Return the installed version of the program.
Raises:
MissingDependencyError: if the program cannot be found or its
version string cannot be parsed.
"""
raw = get_version(
self.program,
version_arg=self.version_arg,
regex=self.version_regex,
env=self.env,
)
return self.version_cls(raw)
def available(self) -> bool:
"""Return whether a usable version of the program is installed."""
try:
self.version()
except MissingDependencyError:
return False
except self.also_catch:
return False
return True
+120 -13
View File
@@ -9,7 +9,6 @@ import logging
import os
import re
from collections import deque
from io import BytesIO
from os import fspath
from pathlib import Path
from subprocess import PIPE, CalledProcessError
@@ -17,13 +16,15 @@ from subprocess import PIPE, CalledProcessError
from packaging.version import Version
from PIL import Image, UnidentifiedImageError
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import (
ColorConversionNeededError,
InputFileError,
SubprocessOutputError,
)
from ocrmypdf.helpers import Resolution
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
from ocrmypdf.pluginspec import GhostscriptRasterDevice
from ocrmypdf.subprocess import run, run_polling_stderr
COLOR_CONVERSION_STRATEGIES = frozenset(
[
@@ -69,11 +70,19 @@ class DuplicateFilter(logging.Filter):
return True
log.addFilter(DuplicateFilter(log))
PROBE = ToolProbe(program=GS)
version = PROBE.version
available = PROBE.available
def version() -> Version:
return Version(get_version(GS))
def _ensure_log_filter_installed() -> None:
"""Idempotently attach the duplicate-suppressing filter to the GS logger.
Called at the top of each work function so the filter is present in the
main process *and* in any subprocess worker that calls Ghostscript.
"""
if not any(isinstance(f, DuplicateFilter) for f in log.filters):
log.addFilter(DuplicateFilter(log))
def _gs_error_reported(stream) -> bool:
@@ -96,22 +105,64 @@ def _gs_devicen_reported(stream) -> bool:
def rasterize_pdf(
input_file: os.PathLike,
output_file: os.PathLike,
input_file: Path,
output_file: Path,
*,
raster_device: str,
raster_device: GhostscriptRasterDevice,
raster_dpi: Resolution,
pageno: int = 1,
page_dpi: Resolution | None = None,
rotation: int | None = None,
filter_vector: bool = False,
stop_on_error: bool = False,
use_cropbox: bool = False,
):
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units."""
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units.
Args:
input_file: The PDF file to rasterize.
output_file: The file to write the rasterized PDF to.
raster_device: The Ghostscript raster device to use to rasterize the PDF.
raster_dpi: Resolution in dots per inch at which to rasterize page.
pageno: Page number to rasterize (beginning at page 1).
page_dpi: Resolution, overriding output image DPI.
rotation: Cardinal angle, clockwise, to rotate page.
filter_vector: If True, remove vector graphics objects.
stop_on_error: If True, stop rasterizing on the first error.
use_cropbox: If True, rasterize the CropBox instead of MediaBox.
Default is False (use MediaBox).
"""
_ensure_log_filter_installed()
raster_dpi = raster_dpi.round(6)
if not page_dpi:
page_dpi = raster_dpi
# Ghostscript may fail with very low DPI values (below 10). If the requested
# DPI is too low, use a minimum of 10 DPI and resize the output afterward.
MIN_RASTER_DPI = 10
needs_low_dpi_resize = (
raster_dpi.x < MIN_RASTER_DPI or raster_dpi.y < MIN_RASTER_DPI
)
if needs_low_dpi_resize:
effective_dpi = Resolution(
max(raster_dpi.x, MIN_RASTER_DPI), max(raster_dpi.y, MIN_RASTER_DPI)
)
else:
effective_dpi = raster_dpi
# Anti-alias text and vector graphics when rendering to a contone device.
# Ghostscript 10.x renders aliased glyphs that OCR frequently misreads as
# extra word breaks; anti-aliasing empirically improves OCR accuracy on the
# Ghostscript path, especially for small fonts at moderate DPI (#1439).
# The 1-bit mono devices do not accept alpha bits (older Ghostscript
# rejects them) and pngmonod performs its own anti-aliased downscaling.
mono_devices = (GhostscriptRasterDevice.PNGMONO, GhostscriptRasterDevice.PNGMONOD)
antialias_args = (
[]
if raster_device in mono_devices
else ['-dTextAlphaBits=4', '-dGraphicsAlphaBits=4']
)
args_gs = (
[
GS,
@@ -122,8 +173,10 @@ def rasterize_pdf(
f'-sDEVICE={raster_device}',
f'-dFirstPage={pageno}',
f'-dLastPage={pageno}',
f'-r{raster_dpi.x:f}x{raster_dpi.y:f}',
f'-r{effective_dpi.x:f}x{effective_dpi.y:f}',
]
+ antialias_args
+ (['-dUseCropBox'] if use_cropbox else [])
+ (['-dFILTERVECTOR'] if filter_vector else [])
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
+ [
@@ -156,7 +209,18 @@ def rasterize_pdf(
)
try:
im: Image.Image
with Image.open(output_file) as im:
if needs_low_dpi_resize:
# Resize to the dimensions that would have resulted from the
# original low DPI request
scale_x = raster_dpi.x / effective_dpi.x
scale_y = raster_dpi.y / effective_dpi.y
new_size = (
max(1, int(round(im.width * scale_x))),
max(1, int(round(im.height * scale_y))),
)
im = im.resize(new_size, Image.Resampling.LANCZOS)
if rotation is not None:
log.debug("Rotating output by %i", rotation)
# rotation is a clockwise angle and Image.ROTATE_* is
@@ -225,15 +289,18 @@ class GhostscriptFollower:
def generate_pdfa(
pdf_pages,
output_file: os.PathLike,
output_file: Path,
*,
compression: str,
color_conversion_strategy: str,
jpeg_quality: int | None = None,
jpeg_maxdpi: int | None = None,
pdf_version: str = '1.5',
pdfa_part: str = '2',
progressbar_class=None,
stop_on_error: bool = False,
):
_ensure_log_filter_installed()
# Ghostscript's compression is all or nothing. We can either force all images
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
# In most case it's best to let it decide.
@@ -247,6 +314,11 @@ def generate_pdfa(
]
elif compression == 'lossless':
compression_args = [
# Re-encoding an existing JPEG with a lossless codec only inflates
# its size: the lossy data is already baked in, so there is nothing
# to gain. Pass JPEGs through untouched and apply lossless (Flate)
# encoding only to images that are not already JPEG.
"-dPassThroughJPEGImages=true",
"-dAutoFilterColorImages=false",
"-dColorImageFilter=/FlateEncode",
"-dAutoFilterGrayImages=false",
@@ -268,6 +340,35 @@ def generate_pdfa(
# Windows has lots of fatal "permission denied" errors
stop_on_error = False
# `-dJPEGQ=N` tells Ghostscript to use a JPEG quality of N, IF it decides
# to transcode an image to JPEG. When there are existing JPEG images,
# Ghostscript uses passthrough mode, so the quality level is not changed.
# OCRmyPDF's optimizer separately uses the `--jpeg-quality` command line
# option to potentially re-encode JPEG images, regardless of whether
# Ghostscript decided to transcode them to JPEG or not.
# `jpeg_quality=0` is meaningful to Ghostscript (maximum compression), so
# only fall back to the default when the value is None.
effective_jpeg_quality = jpeg_quality if jpeg_quality is not None else 95
# Downsampling images is a blunt-force way to reduce file size and almost
# always degrades quality more than lowering JPEG quality at the original
# resolution. We expose this for users with very specific needs (e.g.
# producing very small files for screen-only viewing); the optimizer is
# usually a better choice.
downsample_args: list[str] = []
if jpeg_maxdpi is not None:
downsample_args = [
"-dDownsampleColorImages=true",
"-dColorImageDownsampleThreshold=1.0",
"-dDownsampleGrayImages=true",
"-dGrayImageDownsampleThreshold=1.0",
"-dDownsampleMonoImages=true",
"-dMonoImageDownsampleThreshold=1.0",
f"-dColorImageResolution={jpeg_maxdpi}",
f"-dGrayImageResolution={jpeg_maxdpi}",
f"-dMonoImageResolution={jpeg_maxdpi}",
]
# nb no need to specify ProcessColorModel when ColorConversionStrategy
# is set; see:
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
@@ -284,8 +385,10 @@ def generate_pdfa(
]
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
+ compression_args
+ downsample_args
+ [
"-dJPEGQ=95",
f"-dJPEGQ={effective_jpeg_quality}", # See note above on JPEG quality
"-dSubsetFonts=false", # Prevents GS from messing up some encodings
f"-dPDFA={pdfa_part}",
"-dPDFACompatibilityPolicy=1",
"-o",
@@ -322,4 +425,8 @@ def generate_pdfa(
for part in stderr.split('****'):
log.error(part)
if _gs_devicen_reported(stderr):
raise ColorConversionNeededError()
# Ghostscript could not normalize the DeviceN colorspace for PDF/A,
# even if the user requested a conversion strategy. The output is
# liable to render blank in some viewers, so raise regardless of the
# strategy and tailor the guidance to what was attempted.
raise ColorConversionNeededError(color_conversion_strategy)
+7 -23
View File
@@ -9,21 +9,23 @@ from subprocess import PIPE, CalledProcessError
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
_PROBE = ToolProbe(program='jbig2', version_regex=r'jbig2enc (\d+(\.\d+)*).*')
def version() -> Version:
try:
version = get_version('jbig2', regex=r'jbig2enc (\d+(\.\d+)*).*')
return _PROBE.version()
except CalledProcessError as e:
# TeX Live for Windows provides an incompatible jbig2.EXE which may
# be on the PATH.
raise MissingDependencyError('jbig2enc') from e
return Version(version)
def available():
def available() -> bool:
try:
version()
except MissingDependencyError:
@@ -31,27 +33,9 @@ def available():
return True
def convert_group(cwd, infiles, out_prefix, threshold):
args = [
'jbig2',
'-b',
out_prefix,
'--symbol-mode', # symbol mode (lossy)
'-t',
str(threshold), # threshold
# '-r', # refinement mode (lossless symbol mode, currently disabled in
# jbig2)
'--pdf',
]
args.extend(infiles)
proc = run(args, cwd=cwd, stdout=PIPE, stderr=PIPE)
proc.check_returncode()
return proc
def convert_single(cwd, infile, outfile, threshold):
args = ['jbig2', '--pdf', '-t', str(threshold), infile]
with open(outfile, 'wb') as fstdout:
with outfile.open('wb') as fstdout:
proc = run(args, cwd=cwd, stdout=fstdout, stderr=PIPE)
proc.check_returncode()
return proc
+6 -16
View File
@@ -8,22 +8,12 @@ from __future__ import annotations
from pathlib import Path
from subprocess import PIPE
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.subprocess import run
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import get_version, run
def version() -> Version:
return Version(get_version('pngquant', regex=r'(\d+(\.\d+)*).*'))
def available():
try:
version()
except MissingDependencyError:
return False
return True
PROBE = ToolProbe(program='pngquant', version_regex=r'(\d+(\.\d+)*).*')
version = PROBE.version
available = PROBE.available
def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max: int):
@@ -35,7 +25,7 @@ def quantize(input_file: Path, output_file: Path, quality_min: int, quality_max:
quality_min: Minimum quality to use
quality_max: Maximum quality to use
"""
with open(input_file, 'rb') as input_stream:
with input_file.open('rb') as input_stream:
args = [
'pngquant',
'--force',
+133 -26
View File
@@ -6,8 +6,10 @@
from __future__ import annotations
import logging
import os
import re
from contextlib import suppress
from enum import IntEnum
from math import pi
from os import fspath
from pathlib import Path
@@ -15,22 +17,42 @@ from subprocess import PIPE, STDOUT, CalledProcessError, TimeoutExpired
from packaging.version import Version
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import (
MissingDependencyError,
SubprocessOutputError,
TesseractConfigError,
)
from ocrmypdf.pluginspec import OrientationConfidence
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
log = logging.getLogger(__name__)
def _tesseract_env(omp_thread_limit: int | None) -> dict[str, str] | None:
"""Create environment dict with OMP_THREAD_LIMIT set for Tesseract subprocesses."""
if omp_thread_limit is None:
return None
env = os.environ.copy()
env['OMP_THREAD_LIMIT'] = str(omp_thread_limit)
return env
class ThresholdingMethod(IntEnum):
"""Tesseract thresholding methods for image binarization."""
AUTO = 0
OTSU = 0 # Alias for AUTO - uses Tesseract's default (legacy Otsu)
ADAPTIVE_OTSU = 1
SAUVOLA = 2
# Legacy dictionary for backward compatibility
TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
'auto': 0,
'otsu': 0,
'adaptive-otsu': 1,
'sauvola': 2,
'auto': ThresholdingMethod.AUTO,
'otsu': ThresholdingMethod.OTSU,
'adaptive-otsu': ThresholdingMethod.ADAPTIVE_OTSU,
'sauvola': ThresholdingMethod.SAUVOLA,
}
@@ -94,8 +116,13 @@ class TesseractVersion(Version):
)
def version() -> Version:
return TesseractVersion(get_version('tesseract', regex=r'tesseract\s(.+)'))
PROBE = ToolProbe(
program='tesseract',
version_regex=r'tesseract\s(.+)',
version_cls=TesseractVersion,
)
version = PROBE.version
available = PROBE.available
def has_thresholding() -> bool:
@@ -155,7 +182,10 @@ def _parse_tesseract_output(binary_output: bytes) -> dict[str, str]:
def get_orientation(
input_file: Path, engine_mode: int | None, timeout: float
input_file: Path,
engine_mode: int | None,
timeout: float,
omp_thread_limit: int | None = None,
) -> OrientationConfidence:
args_tesseract = tess_base_args(['osd'], engine_mode) + [
'--psm',
@@ -165,15 +195,24 @@ def get_orientation(
]
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
except TimeoutExpired:
return OrientationConfidence(angle=0, confidence=0.0)
except CalledProcessError as e:
tesseract_log_output(e.stdout)
tesseract_log_output(e.stderr)
# Check both stdout (e.output) and stderr for known non-fatal messages
all_output = (e.output or b'') + (e.stderr or b'')
if (
b'Too few characters. Skipping this page' in e.output
or b'Image too large' in e.output
b'Too few characters. Skipping this page' in all_output
or b'Image too large' in all_output
):
return OrientationConfidence(0, 0)
raise SubprocessOutputError() from e
@@ -186,8 +225,24 @@ def get_orientation(
return orient_conf
def _is_empty_page_error(exc):
if b'Empty page!!' in exc.output: # Tesseract 4.x
return True
return exc.returncode == 1 and (
# Tesseract 5.0-5.4 or so
exc.output == b''
# Tesseract 5.5+
or exc.output.startswith(b"Error in boxClipToRectangle: box outside rectangle")
)
def get_deskew(
input_file: Path, languages: list[str], engine_mode: int | None, timeout: float
input_file: Path,
languages: list[str],
engine_mode: int | None,
timeout: float,
omp_thread_limit: int | None = None,
) -> float:
"""Gets angle to deskew this page, in degrees."""
args_tesseract = tess_base_args(languages, engine_mode) + [
@@ -198,17 +253,22 @@ def get_deskew(
]
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
except TimeoutExpired:
return 0.0
except CalledProcessError as e:
tesseract_log_output(e.stdout)
tesseract_log_output(e.stderr)
if b'Empty page!!' in e.output or (
e.output == b'' and e.returncode == 1
): # Not enough info for a skew angle - Tess 4 and 5 return different errors
if _is_empty_page_error(e):
# Not enough info for a skew angle
return 0.0
raise SubprocessOutputError() from e
parsed = _parse_tesseract_output(p.stdout)
@@ -233,12 +293,14 @@ def tesseract_log_output(stream: bytes) -> None:
lines = text.splitlines()
for line in lines:
if line.startswith("Tesseract Open Source"):
continue
elif line.startswith("Warning in pixReadMem"):
if line.startswith(("Tesseract Open Source", "Warning in pixReadMem")):
continue
elif 'diacritics' in line:
tlog.warning("lots of diacritics - possibly poor OCR")
# Surface the raw Tesseract message at debug level so users can see
# exactly what Tesseract reported (e.g. the affected count) without
# losing the interpreted hint above (#1566).
tlog.debug(line.strip())
elif line.startswith('OSD: Weak margin'):
tlog.warning("unsure about page orientation")
elif 'Error in pixScanForForeground' in line:
@@ -255,6 +317,23 @@ def tesseract_log_output(stream: bytes) -> None:
tlog.warning(line.strip())
elif 'read_params_file' in line.lower():
tlog.error(line.strip())
# Tesseract emits "read_params_file: Can't open <name>" when it
# cannot locate a config file (e.g. 'hocr', 'txt') in its
# tessdata configs/ directory, then exits 0 without producing
# the requested output. Promote to a hard error so the user
# sees the root cause instead of a downstream FileNotFoundError.
if "Can't open" in line:
missing = line.split("Can't open", 1)[1].strip()
else:
missing = line.strip()
raise TesseractConfigError(
f"Tesseract cannot open its config file '{missing}'. "
"This usually means Tesseract is installed but its config "
"files are missing from the tessdata configs/ directory. "
"On Debian/Ubuntu, ensure the 'tesseract-ocr' package is "
"fully installed. If you set TESSDATA_PREFIX, verify its "
"configs/ subdirectory contains the required files."
)
else:
tlog.info(line.strip())
@@ -284,9 +363,10 @@ def generate_hocr(
tessconfig: list[str],
timeout: float,
pagesegmode: int,
thresholding: int,
thresholding: ThresholdingMethod,
user_words,
user_patterns,
omp_thread_limit: int | None = None,
) -> None:
"""Generate a hOCR file, which must be converted to PDF."""
prefix = output_hocr.with_suffix('')
@@ -296,7 +376,7 @@ def generate_hocr(
if pagesegmode is not None:
args_tesseract.extend(['--psm', str(pagesegmode)])
if thresholding != 0 and has_thresholding():
if thresholding != ThresholdingMethod.AUTO and has_thresholding():
args_tesseract.extend(['-c', f'thresholding_method={thresholding}'])
if user_words:
@@ -310,7 +390,14 @@ def generate_hocr(
args_tesseract.extend([fspath(input_file), fspath(prefix), 'hocr', 'txt'])
args_tesseract.extend(tessconfig)
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
stdout = p.stdout
except TimeoutExpired:
# Generate a HOCR file with no recognized text if tesseract times out
@@ -327,6 +414,12 @@ def generate_hocr(
raise SubprocessOutputError() from e
else:
tesseract_log_output(stdout)
if not output_hocr.exists():
raise SubprocessOutputError(
"Tesseract exited successfully but did not produce the "
f"expected hOCR output at {output_hocr}. Tesseract output:\n"
+ (stdout.decode(errors='replace') if stdout else '(empty)')
)
# The sidecar text file will get the suffix .txt; rename it to
# whatever caller wants it named
with suppress(FileNotFoundError):
@@ -350,9 +443,10 @@ def generate_pdf(
tessconfig: list[str],
timeout: float,
pagesegmode: int,
thresholding: int,
thresholding: ThresholdingMethod,
user_words,
user_patterns,
omp_thread_limit: int | None = None,
) -> None:
"""Generate a PDF using Tesseract's internal PDF generator.
@@ -366,7 +460,7 @@ def generate_pdf(
args_tesseract.extend(['-c', 'textonly_pdf=1'])
if thresholding != 0 and has_thresholding():
if thresholding != ThresholdingMethod.AUTO and has_thresholding():
args_tesseract.extend(['-c', f'thresholding_method={thresholding}'])
if user_words:
@@ -383,10 +477,23 @@ def generate_pdf(
args_tesseract.extend([fspath(input_file), fspath(prefix), 'pdf', 'txt'])
args_tesseract.extend(tessconfig)
try:
p = run(args_tesseract, stdout=PIPE, stderr=STDOUT, timeout=timeout, check=True)
p = run(
args_tesseract,
stdout=PIPE,
stderr=STDOUT,
timeout=timeout,
check=True,
env=_tesseract_env(omp_thread_limit),
)
stdout = p.stdout
with suppress(FileNotFoundError):
prefix.with_suffix('.txt').replace(output_text)
if not output_pdf.exists():
raise SubprocessOutputError(
"Tesseract exited successfully but did not produce the "
f"expected PDF output at {output_pdf}. Tesseract output:\n"
+ (stdout.decode(errors='replace') if stdout else '(empty)')
)
except TimeoutExpired:
page_timedout(timeout)
use_skip_page(output_pdf, output_text)
+5 -12
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import logging
import os
import shlex
from collections.abc import Iterator
from contextlib import contextmanager
from decimal import Decimal
@@ -15,11 +14,11 @@ from pathlib import Path
from subprocess import PIPE, STDOUT
from tempfile import TemporaryDirectory
from packaging.version import Version
from PIL import Image
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import SubprocessOutputError
from ocrmypdf.subprocess import get_version, run
from ocrmypdf.subprocess import run
# unpaper documentation:
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
@@ -47,8 +46,9 @@ class UnpaperImageTooLargeError(Exception):
super().__init__(self.message)
def version() -> Version:
return Version(get_version('unpaper', regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)'))
PROBE = ToolProbe(program='unpaper', version_regex=r'(?m).*?(\d+(\.\d+)(\.\d+)?)')
version = PROBE.version
available = PROBE.available
@contextmanager
@@ -101,13 +101,6 @@ def run_unpaper(
) from e
def validate_custom_args(args: str) -> list[str]:
unpaper_args = shlex.split(args)
if any(('/' in arg or arg == '.' or arg == '..') for arg in unpaper_args):
raise ValueError('No filenames allowed in --unpaper-args')
return unpaper_args
def clean(
input_file: Path,
output_file: Path,
+102
View File
@@ -0,0 +1,102 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Interface to verapdf executable."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from subprocess import PIPE
from typing import NamedTuple
from ocrmypdf._exec._probe import ToolProbe
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import run
log = logging.getLogger(__name__)
class ValidationResult(NamedTuple):
"""Result of PDF/A validation."""
valid: bool
failed_rules: int
message: str
PROBE = ToolProbe(
program='verapdf',
version_regex=r'veraPDF (\d+(\.\d+)*)',
also_catch=(OSError,),
)
version = PROBE.version
available = PROBE.available
def output_type_to_flavour(output_type: str) -> str:
"""Map OCRmyPDF output_type to verapdf flavour.
Args:
output_type: One of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'
Returns:
verapdf flavour string like '1b', '2b', '3b'
"""
mapping = {
'pdfa': '2b',
'pdfa-1': '1b',
'pdfa-2': '2b',
'pdfa-3': '3b',
}
return mapping.get(output_type, '2b')
def validate(input_file: Path, flavour: str) -> ValidationResult:
"""Validate a PDF against a PDF/A profile.
Args:
input_file: Path to PDF file to validate
flavour: verapdf flavour (1a, 1b, 2a, 2b, 2u, 3a, 3b, 3u)
Returns:
ValidationResult with validation status
"""
args = [
'verapdf',
'--format',
'json',
'--flavour',
flavour,
str(input_file),
]
try:
proc = run(args, stdout=PIPE, stderr=PIPE, check=False)
except FileNotFoundError as e:
raise MissingDependencyError('verapdf') from e
try:
result = json.loads(proc.stdout)
jobs = result.get('report', {}).get('jobs', [])
if not jobs:
return ValidationResult(False, -1, 'No validation jobs in result')
validation_results = jobs[0].get('validationResult', [])
if not validation_results:
return ValidationResult(False, -1, 'No validation result in output')
validation_result = validation_results[0]
details = validation_result.get('details', {})
failed_rules = details.get('failedRules', 0)
if failed_rules == 0:
return ValidationResult(True, 0, 'PDF/A validation passed')
else:
return ValidationResult(
False,
failed_rules,
f'PDF/A validation failed with {failed_rules} rule violations',
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
log.debug('Failed to parse verapdf output: %s', e)
return ValidationResult(False, -1, f'Failed to parse verapdf output: {e}')
+585 -198
View File
@@ -6,31 +6,166 @@
from __future__ import annotations
import logging
from collections.abc import Collection
from contextlib import suppress
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, cast
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
from pikepdf import (
Dictionary,
Matrix,
Name,
Object,
Operator,
Page,
Pdf,
PdfError,
Stream,
parse_content_stream,
unparse_content_stream,
)
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._options import ProcessingMode
from ocrmypdf._pipeline import VECTOR_PAGE_DPI
class RenderMode(Enum):
"""Controls where the OCR text layer is placed relative to page content.
ON_TOP: Text layer renders above page content (reserved for future use).
UNDERNEATH: Text layer renders below page content (current default behavior).
"""
ON_TOP = 0
UNDERNEATH = 1
@dataclass
class Fpdf2PageInfo:
"""Information needed to render and graft an fpdf2 page."""
pageno: int
hocr_path: Path
dpi: float
autorotate_correction: int
emplaced_page: bool
@dataclass
class Fpdf2ParsedPage:
"""Parsed page data ready for fpdf2 rendering."""
pageno: int
ocr_tree: OcrElement
dpi: float
autorotate_correction: int
emplaced_page: bool
# Alias for backward compatibility with plan documentation
Fpdf2DirectPage = Fpdf2ParsedPage
def _compute_text_misalignment(
content_rotation: int, autorotate_correction: int, emplaced_page: bool
) -> int:
"""Compute rotation needed to align text layer with page content.
Args:
content_rotation: Original page /Rotate value (degrees).
autorotate_correction: Rotation applied during rasterization (degrees).
emplaced_page: Whether the page content was replaced with rasterized image.
Returns:
Rotation in degrees to apply to text layer to align with content.
"""
if emplaced_page:
# New image is upright after autorotation was applied
content_rotation = autorotate_correction
text_rotation = autorotate_correction
return (text_rotation - content_rotation) % 360
def _compute_page_rotation(
content_rotation: int, autorotate_correction: int, emplaced_page: bool
) -> int:
"""Compute final page /Rotate value after grafting.
Args:
content_rotation: Original page /Rotate value (degrees).
autorotate_correction: Rotation applied during rasterization (degrees).
emplaced_page: Whether the page content was replaced with rasterized image.
Returns:
Final /Rotate value for the page.
"""
if emplaced_page:
content_rotation = autorotate_correction
return (content_rotation - autorotate_correction) % 360
def _build_text_layer_ctm(
text_width: float,
text_height: float,
page_width: float,
page_height: float,
page_origin_x: float,
page_origin_y: float,
text_rotation: int,
):
"""Build transformation matrix to align text layer with page content.
Always computes the full CTM to handle non-zero page origins (e.g.,
JSTOR PDFs with MediaBox like [0, 100, 595, 982]) and minor scale
differences due to DPI rounding.
Args:
text_width: Width of text layer mediabox.
text_height: Height of text layer mediabox.
page_width: Width of target page mediabox.
page_height: Height of target page mediabox.
page_origin_x: X origin of target page mediabox.
page_origin_y: Y origin of target page mediabox.
text_rotation: Rotation in degrees (clockwise) to apply to text layer.
Returns:
pikepdf.Matrix transformation matrix, or None if identity.
"""
from pikepdf import Matrix
wt, ht = text_width, text_height
# Center text, rotate, scale to fit page, then position at page origin
translate = Matrix().translated(-wt / 2, -ht / 2)
untranslate = Matrix().translated(page_width / 2, page_height / 2)
corner = Matrix().translated(page_origin_x, page_origin_y)
# Negate rotation because input is clockwise angle
rotate = Matrix().rotated(-text_rotation % 360)
# Swap dimensions if 90 or 270 degree rotation
if text_rotation in (90, 270):
wt, ht = ht, wt
# Scale to fit page dimensions
scale_x = page_width / wt if wt else 1.0
scale_y = page_height / ht if ht else 1.0
scale = Matrix().scaled(scale_x, scale_y)
ctm = translate @ rotate @ scale @ untranslate @ corner
# Return None if the result is effectively identity
identity = Matrix()
if ctm == identity:
return None
return ctm
log = logging.getLogger(__name__)
MAX_REPLACE_PAGES = 100
@@ -41,22 +176,6 @@ def _ensure_dictionary(obj: Dictionary | Stream, name: Name):
return obj[name]
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.
"""
resources = _ensure_dictionary(obj, Name.Resources)
fonts = _ensure_dictionary(resources, Name.Font)
if font_key is not None and font_key not in fonts:
fonts[font_key] = font
def strip_invisible_text(pdf: Pdf, page: Page):
stream = []
in_text_obj = False
@@ -64,20 +183,21 @@ def strip_invisible_text(pdf: Pdf, page: Page):
render_mode_stack = []
text_objects = []
for operands, operator in parse_content_stream(page, ''):
for instruction in parse_content_stream(page, ''):
operands, operator = instruction.operands, instruction.operator
if operator == Operator('Tr'):
render_mode = operands[0]
# operands[0] is already a plain int under pikepdf's default
# (implicit) conversion mode, or a pikepdf.Object under explicit
# conversion mode; int() handles both.
render_mode = int(operands[0])
if operator == Operator('q'):
render_mode_stack.append(render_mode)
if operator == Operator('Q'):
try:
# IndexError is raised if stack is empty; try to carry on
with suppress(IndexError):
render_mode = render_mode_stack.pop()
except IndexError:
# Stack underflow: content stream is malformed
# but try to carry on
pass
if not in_text_obj:
if operator == Operator('BT'):
@@ -93,10 +213,103 @@ def strip_invisible_text(pdf: Pdf, page: Page):
stream.extend(text_objects)
text_objects.clear()
content_stream = unparse_content_stream(stream)
# pikepdf's Collection[...] parameter doesn't structurally match our
# _ObjectList-based tuples even though it works fine at runtime.
content_stream = unparse_content_stream(
cast('list[tuple[Collection[Object], Operator]]', stream)
)
page.Contents = Stream(pdf, content_stream)
def discard_text_search_index(pdf: Pdf) -> bool:
"""Discard an embedded Adobe full-text search index from the catalog.
Adobe Acrobat can embed a full-text search index in the document catalog at
``/Root/PieceInfo/SearchIndex``. It is built from the page text, and only
Acrobat reads it; other viewers ignore it and search the text on the fly.
Any change to the PDF invalidates the index, so once OCRmyPDF rewrites the
document (editing the text layer, rasterizing, optimizing) a retained index
would be stale and return incorrect search results in Acrobat. We cannot
update this vendor-private data, so we discard it; modern viewers rebuild a
search index on demand. Returns True if the catalog was modified.
"""
try:
pieceinfo = pdf.Root.get(Name.PieceInfo)
if not isinstance(pieceinfo, Dictionary) or Name.SearchIndex not in pieceinfo:
return False
del pieceinfo[Name.SearchIndex]
log.debug(
"Discarded embedded text search index "
"(/Root/PieceInfo/SearchIndex) because the PDF was rewritten; "
"it would otherwise be stale."
)
# Drop an empty PieceInfo rather than leave a husk behind.
if len(pieceinfo) == 0:
del pdf.Root.PieceInfo
return True
except (KeyError, TypeError, AttributeError):
return False
def discard_page_thumbnails(pdf: Pdf) -> int:
"""Discard embedded per-page thumbnail images.
A page object may carry an optional ``/Thumb`` image XObject a miniature
rendering of the page (ISO 32000-2, 12.3.4). It is only a navigation aid and
modern viewers generate page thumbnails on demand. OCRmyPDF alters page
appearance (deskew, clean, rasterize, re-render) and plugins may edit pages
arbitrarily, so any retained thumbnail would be stale and misrepresent its
page. We discard them; viewers rebuild thumbnails as needed. Returns the
number of thumbnails removed.
"""
removed = 0
for page in pdf.pages:
pageobj = page.obj
if Name.Thumb in pageobj:
del pageobj[Name.Thumb]
removed += 1
if removed:
log.debug(
"Discarded %d embedded page thumbnail(s) (/Thumb) because the PDF "
"was rewritten; they would otherwise be stale.",
removed,
)
return removed
def discard_structure_tree(pdf: Pdf) -> bool:
"""Discard the logical structure (tagged-PDF) tree from the document.
The structure tree (``/Root/StructTreeRoot``, ``/Root/MarkInfo``) maps
marked content in the page content streams to semantic elements via MCIDs.
When OCRmyPDF rasterizes pages (force) or strips and rewrites the text layer
(redo), those MCIDs are destroyed or renumbered, leaving the tree dangling
and inconsistent with the new content. We cannot rebuild it to match, so we
discard it; the page-level ``/StructParents`` keys go too. Returns True if
the catalog was modified.
"""
modified = False
try:
if Name.StructTreeRoot in pdf.Root:
del pdf.Root.StructTreeRoot
modified = True
if Name.MarkInfo in pdf.Root:
del pdf.Root.MarkInfo
modified = True
for page in pdf.pages:
if Name.StructParents in page.obj:
del page.obj[Name.StructParents]
modified = True
except (KeyError, TypeError, AttributeError):
return modified
if modified:
log.debug(
"Discarded the logical structure tree (/Root/StructTreeRoot) "
"because the PDF was re-OCR'd; it would otherwise be stale."
)
return modified
class OcrGrafter:
"""Manages grafting text-only PDFs onto regular PDFs."""
@@ -105,27 +318,53 @@ class OcrGrafter:
self.path_base = context.origin
self.pdf_base = Pdf.open(self.path_base)
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.emplacements = 1
self.interim_count = 0
self.render_mode = RenderMode.UNDERNEATH
# Check renderer type
pdf_renderer = context.options.pdf_renderer
self.use_sandwich_renderer = pdf_renderer == 'sandwich'
# For fpdf2: accumulate pages before rendering
self.fpdf2_hocr_pages: list[Fpdf2PageInfo] = []
self.fpdf2_parsed_pages: list[Fpdf2ParsedPage] = []
def graft_page(
self,
*,
pageno: int,
image: Path | None,
textpdf: Path | None,
ocr_output: Path | None,
ocr_tree: OcrElement | None,
autorotate_correction: int,
):
if textpdf and not self.font:
self.font, self.font_key = self._find_font(textpdf)
"""Graft OCR output onto a page of the base PDF.
Args:
pageno: Zero-based page number.
image: Path to the visible page image PDF, or None if not replacing.
ocr_output: Path to OCR output file. For fpdf2 renderer this is an
hOCR file; for sandwich renderer this is a text-only PDF.
ocr_tree: OCR tree for fpdf2 renderer.
autorotate_correction: Orientation correction in degrees (0, 90, 180, 270).
"""
if self.context.options.mode == ProcessingMode.strip_text:
# Strip mode: remove the invisible OCR text layer in place without
# rasterizing or grafting anything. Honor --pages if specified.
options = self.context.options
if not options.pages or pageno in options.pages:
strip_invisible_text(self.pdf_base, self.pdf_base.pages[pageno])
return
if ocr_output and ocr_tree:
raise ValueError(
'Cannot specify both ocr_output and ocr_tree for fpdf2 renderer'
)
# Handle image emplacement first
emplaced_page = False
content_rotation = self.pdfinfo[pageno].rotation
path_image = Path(image).resolve() if image else None
@@ -144,195 +383,343 @@ class OcrGrafter:
del self.pdf_base.pages[-1]
emplaced_page = True
# Calculate if the text is misaligned compared to the content
if emplaced_page:
content_rotation = autorotate_correction
text_rotation = autorotate_correction
text_misaligned = (text_rotation - content_rotation) % 360
log.debug(
f"Text rotation: (text, autorotate, content) -> text misalignment = "
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> "
f"{text_misaligned}"
)
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
self._graft_text_layer(
page_num=pageno + 1,
textpdf=textpdf,
font=self.font,
font_key=self.font_key,
text_rotation=text_misaligned,
strip_old_text=strip_old,
)
# Correct the overall page rotation if needed, now that the text and content
# are aligned
page_rotation = (content_rotation - autorotate_correction) % 360
self.pdf_base.pages[pageno].Rotate = page_rotation
log.debug(
f"Page rotation: (content, auto) -> page = "
f"({content_rotation}, {autorotate_correction}) -> {page_rotation}"
)
if self.emplacements % MAX_REPLACE_PAGES == 0:
self.save_and_reload()
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
the font to page 1 even if page 1 doesn't use it, so we have a way to get it
back.
"""
page0 = self.pdf_base.pages[0]
_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.
# {interim_count} is the opened file we were updating
# {interim_count - 1} can be deleted
# {interim_count + 1} is the new file will produce and open
old_file = self.output_file.with_suffix(f'.working{self.interim_count - 1}.pdf')
if not self.context.options.keep_temporary_files:
with suppress(FileNotFoundError):
old_file.unlink()
next_file = self.output_file.with_suffix(
f'.working{self.interim_count + 1}.pdf'
)
self.pdf_base.save(next_file)
self.pdf_base.close()
self.pdf_base = Pdf.open(next_file)
self.font, self.font_key = None, None # Ensure we reacquire this information
self.interim_count += 1
if self.use_sandwich_renderer:
# Sandwich renderer: graft pre-rendered PDF immediately
if ocr_output:
text_misaligned = _compute_text_misalignment(
content_rotation, autorotate_correction, emplaced_page
)
self._graft_sandwich_text_layer(
pageno=pageno,
textpdf=ocr_output,
text_rotation=text_misaligned,
)
page_rotation = _compute_page_rotation(
content_rotation, autorotate_correction, emplaced_page
)
self.pdf_base.pages[pageno].Rotate = page_rotation
else:
# fpdf2 renderer: accumulate page info for batch rendering.
# The hOCR coordinates are in the corrected (upright) coordinate system.
# We store autorotate_correction and emplaced_page to set the final
# page /Rotate tag after grafting.
if ocr_tree:
self.fpdf2_parsed_pages.append(
Fpdf2ParsedPage(
ocr_tree=ocr_tree,
pageno=pageno,
autorotate_correction=autorotate_correction,
emplaced_page=emplaced_page,
dpi=self.pdfinfo[pageno].dpi.to_scalar(),
)
)
if ocr_output:
self.fpdf2_hocr_pages.append(
Fpdf2PageInfo(
hocr_path=ocr_output,
pageno=pageno,
autorotate_correction=autorotate_correction,
emplaced_page=emplaced_page,
dpi=self.pdfinfo[pageno].dpi.to_scalar(),
)
)
def finalize(self):
# Can have hocr OR parsed pages OR neither (no OCR), but not both
assert not (self.fpdf2_hocr_pages and self.fpdf2_parsed_pages), (
"Can't have both hocr and ocrtree pages"
)
if self.fpdf2_hocr_pages:
# Render all pages with fpdf2, then graft
parsed_pages = self._parse_hocr_pages()
self.fpdf2_parsed_pages = parsed_pages
if self.fpdf2_parsed_pages:
self._render_and_graft_fpdf2_pages()
discard_text_search_index(self.pdf_base)
discard_page_thumbnails(self.pdf_base)
if self.context.options.mode in (ProcessingMode.force, ProcessingMode.redo):
discard_structure_tree(self.pdf_base)
self.pdf_base.save(self.output_file)
self.pdf_base.close()
return self.output_file
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(
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 = 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
return None, None
def _parse_hocr_pages(self) -> list[Fpdf2ParsedPage]:
"""Render all pages to multi-page PDF with shared fonts, then graft."""
from ocrmypdf.hocrtransform.hocr_parser import HocrParser
def _graft_text_layer(
log.info(
"Parsing %d pages with HocrParser",
len(self.fpdf2_hocr_pages),
)
# Parse all hOCR files and collect OcrElements
pages_data: list[Fpdf2ParsedPage] = []
for page_info in self.fpdf2_hocr_pages:
if page_info.hocr_path.stat().st_size == 0:
continue # Skip empty pages
# Parse hOCR to OcrElement
parser = HocrParser(page_info.hocr_path)
ocr_tree = parser.parse()
# Use DPI from hOCR (scan_res) which reflects actual rasterization DPI.
# Fall back to pdfinfo DPI or VECTOR_PAGE_DPI for vector-only pages.
effective_dpi = ocr_tree.dpi or page_info.dpi or float(VECTOR_PAGE_DPI)
pages_data.append(
Fpdf2ParsedPage(
pageno=page_info.pageno,
ocr_tree=ocr_tree,
dpi=effective_dpi,
autorotate_correction=page_info.autorotate_correction,
emplaced_page=page_info.emplaced_page,
)
)
return pages_data
def _render_and_graft_fpdf2_pages(self):
font_dir = Path(__file__).parent / "data"
# Render all pages to single PDF
multi_page_pdf_path = self.context.get_path('fpdf2_multipage.pdf')
from ocrmypdf.font import MultiFontManager
from ocrmypdf.fpdf_renderer import Fpdf2MultiPageRenderer
multi_font_manager = MultiFontManager(font_dir)
# Build renderer input as (pageno, ocr_tree, dpi) tuples
renderer_pages_data = [
(parsed.pageno, parsed.ocr_tree, parsed.dpi)
for parsed in self.fpdf2_parsed_pages
]
renderer = Fpdf2MultiPageRenderer(
pages_data=renderer_pages_data,
multi_font_manager=multi_font_manager,
invisible_text=True,
)
renderer.render(multi_page_pdf_path)
# Now graft each page from the multi-page PDF
with Pdf.open(multi_page_pdf_path) as pdf_text:
for idx, parsed in enumerate(self.fpdf2_parsed_pages):
# Copy page from multi-page PDF
text_page = pdf_text.pages[idx]
content_rotation = self.pdfinfo[parsed.pageno].rotation
text_misaligned = _compute_text_misalignment(
content_rotation,
parsed.autorotate_correction,
parsed.emplaced_page,
)
self._graft_fpdf2_text_layer(parsed.pageno, text_page, text_misaligned)
page_rotation = _compute_page_rotation(
content_rotation,
parsed.autorotate_correction,
parsed.emplaced_page,
)
self.pdf_base.pages[parsed.pageno].Rotate = page_rotation
# Clean up multi-page PDF if not keeping temp files
if not self.context.options.keep_temporary_files:
with suppress(FileNotFoundError):
multi_page_pdf_path.unlink()
def _graft_fpdf2_text_layer(self, pageno: int, text_page: Page, text_rotation: int):
"""Graft a single text page onto the base PDF.
Similar to existing _graft_text_layer but works with
already-rendered pikepdf Page instead of file path.
Args:
pageno: Zero-based page number.
text_page: The text-only PDF page to graft.
text_rotation: Rotation to apply to align text with content (degrees).
"""
from pikepdf import Array
base_page = self.pdf_base.pages[pageno]
# Extract content stream from text_page
text_contents = text_page.Contents.read_bytes()
# Get the mediabox from the text page
mediabox = Array([float(x) for x in text_page.mediabox]) # type: ignore[misc]
wt = float(mediabox[2]) - float(mediabox[0])
ht = float(mediabox[3]) - float(mediabox[1])
# Get base page mediabox
base_mediabox = base_page.mediabox
wp = float(base_mediabox[2]) - float(base_mediabox[0])
hp = float(base_mediabox[3]) - float(base_mediabox[1])
# Create Form XObject from text page content
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(text_contents)
base_xobjs[text_xobj_name] = xobj
xobj.Type = Name.XObject
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = base_mediabox
# Copy resources from text page's Resources to xobj
# We need to handle this carefully since text_page is from a foreign PDF
if hasattr(text_page, 'Resources') and text_page.Resources:
# Create empty Resources dictionary for xobj
xobj_resources = _ensure_dictionary(xobj, Name.Resources)
# Copy fonts if they exist
if Name.Font in text_page.Resources:
xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font)
text_fonts = text_page.Resources[Name.Font]
# Copy each font from the foreign PDF
for font_name, font_obj in text_fonts.items():
xobj_fonts[font_name] = self.pdf_base.copy_foreign(font_obj)
# Copy ExtGState (graphics state) if it exists - needed for transparency
if Name.ExtGState in text_page.Resources:
xobj_extstates = _ensure_dictionary(xobj_resources, Name.ExtGState)
text_extstates = text_page.Resources[Name.ExtGState]
# Copy each graphics state from the foreign PDF
for gs_name, gs_obj in text_extstates.items():
xobj_extstates[gs_name] = self.pdf_base.copy_foreign(gs_obj)
# Build transformation matrix for rotation and scaling
ctm = _build_text_layer_ctm(
wt,
ht,
wp,
hp,
float(base_mediabox[0]),
float(base_mediabox[1]),
text_rotation,
)
if ctm is not None:
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'Q\n'
)
else:
pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
# Strip old invisible text if redo mode is enabled
if self.context.options.mode == ProcessingMode.redo:
strip_invisible_text(self.pdf_base, base_page)
# Add text layer to base page
base_page.contents_coalesce()
base_page.contents_add(
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
)
base_page.contents_coalesce()
def _graft_sandwich_text_layer(
self,
*,
page_num: int,
pageno: int,
textpdf: Path,
font: Dictionary,
font_key: Name,
text_rotation: int,
strip_old_text: bool,
):
"""Insert the text layer from text page 0 on to pdf_base at page_num."""
# pylint: disable=invalid-name
"""Graft a pre-rendered text-only PDF onto the base PDF.
log.debug("Grafting")
This is used by the sandwich renderer which generates PDFs directly
from Tesseract rather than going through hOCR.
"""
from pikepdf import PdfError
log.debug("Grafting sandwich text layer")
if Path(textpdf).stat().st_size == 0:
return
# This is a pointer indicating a specific page in the base file
with Pdf.open(textpdf) as pdf_text:
pdf_text_contents = pdf_text.pages[0].Contents.read_bytes()
try:
with Pdf.open(textpdf) as pdf_text:
pdf_text_contents = pdf_text.pages[0].Contents.read_bytes()
base_page = self.pdf_base.pages.p(page_num)
base_page = self.pdf_base.pages[pageno]
# The text page always will be oriented up by this stage but the original
# 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 = pdf_text.pages[0].mediabox
wt, ht = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
# Get font from the text PDF
pdf_text_fonts = pdf_text.pages[0].Resources.get(
Name.Font, Dictionary()
)
font = None
font_key = None
for f in ('/f-0-0', '/F1'):
pdf_text_font = pdf_text_fonts.get(f, None)
if pdf_text_font is not None:
font_key = Name(f)
font = self.pdf_base.copy_foreign(pdf_text_font)
break
mediabox = base_page.mediabox
wp, hp = mediabox[2] - mediabox[0], mediabox[3] - mediabox[1]
# Get mediabox dimensions for rotation calculations
mediabox = pdf_text.pages[0].mediabox
wt = float(mediabox[2]) - float(mediabox[0])
ht = float(mediabox[3]) - float(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 = Matrix().rotated(text_rotation)
base_mediabox = base_page.mediabox
wp = float(base_mediabox[2]) - float(base_mediabox[0])
hp = float(base_mediabox[3]) - float(base_mediabox[1])
# 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
# is within 0.998.
if text_rotation in (90, 270):
wt, ht = ht, wt
scale_x = wp / wt
scale_y = hp / ht
# Build transformation matrix for rotation and scaling
ctm = _build_text_layer_ctm(
wt,
ht,
wp,
hp,
float(base_mediabox[0]),
float(base_mediabox[1]),
text_rotation,
)
log.debug("Grafting with ctm %r", ctm)
# log.debug('%r', scale_x, scale_y)
scale = Matrix().scaled(scale_x, scale_y)
# Create Form XObject
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)
base_xobjs[text_xobj_name] = xobj
xobj.Type = Name.XObject
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = base_mediabox
# 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
log.debug("Grafting with ctm %r", ctm)
# Add font to xobj resources
if font_key is not None and font is not None:
xobj_resources = _ensure_dictionary(xobj, Name.Resources)
xobj_fonts = _ensure_dictionary(xobj_resources, Name.Font)
if font_key not in xobj_fonts:
xobj_fonts[font_key] = font
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)
base_xobjs[text_xobj_name] = xobj
xobj.Type = Name.XObject
xobj.Subtype = Name.Form
xobj.FormType = 1
xobj.BBox = mediabox
_update_resources(obj=xobj, font=font, font_key=font_key)
if ctm is not None:
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode())
+ (b'%s Do\n' % text_xobj_name)
+ b'\nQ\n'
)
else:
pdf_draw_xobj = b'q\n' + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
pdf_draw_xobj = (
(b'q %s cm\n' % ctm.encode()) + (b'%s Do\n' % text_xobj_name) + b'\nQ\n'
)
new_text_layer = Stream(self.pdf_base, pdf_draw_xobj)
if self.context.options.mode == ProcessingMode.redo:
strip_invisible_text(self.pdf_base, base_page)
base_page.contents_coalesce()
base_page.contents_add(
new_text_layer, prepend=self.render_mode == RenderMode.UNDERNEATH
)
base_page.contents_coalesce()
if strip_old_text:
strip_invisible_text(self.pdf_base, base_page)
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)
# Add font to page resources
if font_key is not None and font is not None:
page_resources = _ensure_dictionary(base_page.obj, Name.Resources)
page_fonts = _ensure_dictionary(page_resources, Name.Font)
if font_key not in page_fonts:
page_fonts[font_key] = font
except (FileNotFoundError, PdfError):
# PdfError occurs if a 0-length file is written e.g. due to OCR timeout
pass
+37 -15
View File
@@ -5,35 +5,38 @@
from __future__ import annotations
import os
from argparse import Namespace
from collections.abc import Iterator
from copy import copy
from pathlib import Path
from typing import TYPE_CHECKING
from pluggy import PluginManager
from ocrmypdf._options import OcrOptions
from ocrmypdf.pdfinfo import PdfInfo
from ocrmypdf.pdfinfo.info import PageInfo
if TYPE_CHECKING:
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
class PdfContext:
"""Holds the context for a particular run of the pipeline."""
options: Namespace #: The specified options for processing this PDF.
options: OcrOptions #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pdfinfo: PdfInfo #: Detailed data for this PDF.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
plugin_manager: (
OcrmypdfPluginManager #: PluginManager for processing the current PDF.
)
def __init__(
self,
options: Namespace,
options: OcrOptions,
work_folder: Path,
origin: Path,
pdfinfo: PdfInfo,
plugin_manager,
):
self.options = options
self.options.work_folder = work_folder
self.work_folder = work_folder
self.origin = origin
self.pdfinfo = pdfinfo
@@ -65,21 +68,27 @@ class PageContext:
Must be pickle-able, so stores only intrinsic/simple data elements or those
capable of their serializing themselves via ``__getstate__``.
Note: Uses OcrOptions with JSON serialization for multiprocessing compatibility.
"""
options: Namespace #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pageno: int #: This page number (zero-based).
pageinfo: PageInfo #: Information on this page.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
plugin_manager: (
OcrmypdfPluginManager #: PluginManager for processing the current PDF.
)
def __init__(self, pdf_context: PdfContext, pageno):
self.work_folder = pdf_context.work_folder
self.origin = pdf_context.origin
# Store OcrOptions directly instead of Namespace
self.options = pdf_context.options
self.pageno = pageno
self.pageinfo = pdf_context.pdfinfo[pageno]
self.plugin_manager = pdf_context.plugin_manager
# Ensure no reference to PdfContext which contains OcrOptions
self._pdf_context = None
def get_path(self, name: str) -> Path:
"""Generate a ``Path`` for a file that is part of processing this page.
@@ -92,9 +101,22 @@ class PageContext:
def __getstate__(self):
state = self.__dict__.copy()
state['options'] = copy(self.options)
if not isinstance(state['options'].input_file, str | bytes | os.PathLike):
state['options'].input_file = 'stream'
if not isinstance(state['options'].output_file, str | bytes | os.PathLike):
state['options'].output_file = 'stream'
options_json = self.options.model_dump_json_safe()
state['options_json'] = options_json
# Remove the OcrOptions object to avoid pickle issues
del state['options']
# Remove any potential references to Pydantic objects
state.pop('_pdf_context', None)
return state
def __setstate__(self, state):
self.__dict__.update(state)
# Reconstruct OcrOptions from JSON if available
if 'options_json' in state:
from ocrmypdf._options import OcrOptions
self.options = OcrOptions.model_validate_json_safe(state['options_json'])
# Otherwise, we have a fallback Namespace (shouldn't happen in normal operation)
# Leave it as-is for compatibility
+16 -15
View File
@@ -5,9 +5,8 @@
from __future__ import annotations
import datetime as dt
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -15,7 +14,6 @@ from pikepdf import Dictionary, Name, Pdf
from pikepdf import __version__ as PIKEPDF_VERSION
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
from ocrmypdf._annots import remove_broken_goto_annotations
from ocrmypdf._defaults import PROGRAM_NAME
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
@@ -48,11 +46,13 @@ def get_docinfo(base_pdf: Pdf, context: PdfContext) -> dict[str, str]:
if options.subject:
pdfmark['/Subject'] = options.subject
creator_tag = context.plugin_manager.hook.get_ocr_engine().creator_tag(options)
creator_tag = context.plugin_manager.get_ocr_engine(options=options).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))
pdfmark['/ModDate'] = encode_pdf_date(dt.datetime.now(dt.UTC))
return pdfmark
@@ -87,8 +87,12 @@ def repair_docinfo_nuls(pdf):
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
except (TypeError, UnicodeDecodeError):
# TypeError: DocumentInfo is not a dictionary, or its items are
# unexpected types.
# UnicodeDecodeError: a DocumentInfo key or value contains bytes that
# are not valid PDFDocEncoding/UTF-16, e.g. a Latin-1 /Name key such as
# /Saks#e5r. Older pikepdf raised while iterating such a block (#1540).
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
return modified
@@ -98,10 +102,8 @@ def should_linearize(working_file: Path, context: PdfContext) -> bool:
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
filesize = working_file.stat().st_size
return filesize > (context.options.fast_web_view * 1_000_000)
def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata):
@@ -109,12 +111,11 @@ def _fix_metadata(meta_original: PdfMetadata, meta_pdf: PdfMetadata):
# 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':
if meta_pdf.get('dc:title') == 'Untitled' and ('dc:title' not in meta_original):
# 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']
del meta_pdf['dc:title']
def _unset_empty_metadata(meta: PdfMetadata, options):
@@ -187,7 +188,7 @@ def metadata_fixup(
output_file = context.get_path('metafix.pdf')
options = context.options
pbar_class = context.plugin_manager.hook.get_progressbar_class()
pbar_class = context.plugin_manager.get_progressbar_class()
with (
Pdf.open(context.origin) as original,
Pdf.open(working_file) as pdf,
+686
View File
@@ -0,0 +1,686 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Internal options model for OCRmyPDF."""
from __future__ import annotations
import json
import logging
import os
import shlex
import unicodedata
from collections.abc import Sequence
from enum import StrEnum
from io import IOBase
from pathlib import Path
from typing import Any, BinaryIO
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf.exceptions import BadArgsError
from ocrmypdf.helpers import monotonic
# Import plugin option models - these will be available after plugins are loaded
# We'll use forward references and handle imports dynamically
log = logging.getLogger(__name__)
# Module-level registry for plugin option models
# This is populated by setup_plugin_infrastructure() after plugins are loaded
_plugin_option_models: dict[str, type[BaseModel]] = {}
PathOrIO = BinaryIO | IOBase | Path | str | bytes
class ProcessingMode(StrEnum):
"""OCR processing mode for handling pages with existing text.
This enum controls how OCRmyPDF handles pages that already contain text:
- ``default``: Error if text is found (standard OCR behavior)
- ``force``: Rasterize all content and run OCR regardless of existing text
- ``skip``: Skip OCR on pages that already have text
- ``redo``: Re-OCR pages, stripping old invisible text layer
- ``strip``: Remove the invisible OCR text layer in place; do not OCR
"""
default = 'default'
force = 'force'
skip = 'skip'
redo = 'redo'
# User-facing value is '--mode strip'; the member is named strip_text to
# avoid shadowing str.strip on this str-based enum.
strip_text = 'strip'
class TaggedPdfMode(StrEnum):
"""Control behavior when encountering a Tagged PDF.
Tagged PDFs often indicate documents generated from office applications
that may not need OCR. This enum controls how OCRmyPDF handles them:
- ``default``: Error if ProcessingMode is default, otherwise warn
- ``ignore``: Always warn but continue processing (never error)
"""
default = 'default'
ignore = 'ignore'
def _has_end_alias(ranges: str) -> bool:
"""Return True if the page range string uses the ``end`` alias."""
return 'end' in ranges.lower()
def _resolve_page_token(token: str, total_pages: int | None) -> int:
"""Convert a single page-number token to a 1-based integer.
The literal ``end`` (case-insensitive) is resolved to ``total_pages``. If
``total_pages`` is None, an error is raised.
"""
if token.lower() == 'end':
if total_pages is None:
raise BadArgsError(
"'end' was used in --pages but the total page count is not yet known"
)
return total_pages
return int(token)
def _pages_from_ranges(ranges: str, total_pages: int | None = None) -> set[int]:
"""Convert page range string to set of 0-based page numbers.
The token ``end`` (case-insensitive) is an alias for the last page of the
document. It is resolved using ``total_pages``; if ``end`` appears in the
string and ``total_pages`` is None, a :class:`BadArgsError` is raised.
"""
pages: list[int] = []
page_groups = ranges.replace(' ', '').split(',')
for group in page_groups:
if not group:
continue
try:
start, end = group.split('-')
except ValueError:
try:
pages.append(_resolve_page_token(group, total_pages) - 1)
except ValueError:
raise BadArgsError(f"invalid page number '{group}'") from None
else:
try:
start_n = _resolve_page_token(start, total_pages)
end_n = _resolve_page_token(end, total_pages)
new_pages = list(range(start_n - 1, end_n))
if not new_pages:
raise BadArgsError(
f"invalid page subrange '{start}-{end}'"
) from None
pages.extend(new_pages)
except ValueError:
raise BadArgsError(f"invalid page subrange '{group}'") from None
if not pages:
raise BadArgsError(
f"The string of page ranges '{ranges}' did not contain any recognizable "
f"page ranges."
)
if not monotonic(pages):
log.warning(
"List of pages to process contains duplicate pages, or pages that are "
"out of order"
)
if any(page < 0 for page in pages):
raise BadArgsError("pages refers to a page number less than 1")
log.debug("OCRing only these pages: %s", pages)
return set(pages)
class OcrOptions(BaseModel):
"""Internal options model that can masquerade as argparse.Namespace.
This model provides proper typing and validation while maintaining
compatibility with existing code that expects argparse.Namespace behavior.
"""
# I/O options
input_file: PathOrIO
output_file: PathOrIO
sidecar: PathOrIO | None = None
output_folder: Path | None = None
work_folder: Path | None = None
# Core OCR options
languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE])
output_type: str = 'auto'
mode: ProcessingMode = ProcessingMode.default
# Backward compatibility properties for force_ocr, skip_text, redo_ocr
@property
def force_ocr(self) -> bool:
"""Backward compatibility alias for mode == ProcessingMode.force."""
return self.mode == ProcessingMode.force
@property
def skip_text(self) -> bool:
"""Backward compatibility alias for mode == ProcessingMode.skip."""
return self.mode == ProcessingMode.skip
@property
def redo_ocr(self) -> bool:
"""Backward compatibility alias for mode == ProcessingMode.redo."""
return self.mode == ProcessingMode.redo
# Job control
jobs: int | None = None
use_threads: bool = True
progress_bar: bool = True
quiet: bool = False
verbose: int = 0
keep_temporary_files: bool = False
# Image processing
image_dpi: int | None = None
deskew: bool = False
clean: bool = False
clean_final: bool = False
rotate_pages: bool = False
remove_background: bool = False
remove_vectors: bool = False
oversample: int = 0
unpaper_args: list[str] | None = None
# OCR behavior
skip_big: float | None = None
pages: str | set[int] | None = None # Can be string or set after validation
invalidate_digital_signatures: bool = False
tagged_pdf_mode: TaggedPdfMode = TaggedPdfMode.default
# Metadata
title: str | None = None
author: str | None = None
subject: str | None = None
keywords: str | None = None
# Optimization
optimize: int = 1
jpeg_quality: int | None = None
png_quality: int | None = None
# Deprecated compatibility alias for code that still uses the old field name
@property
def jpg_quality(self):
"""Deprecated compatibility alias for jpeg_quality."""
return self.jpeg_quality
@jpg_quality.setter
def jpg_quality(self, value):
"""Deprecated compatibility alias for jpeg_quality."""
self.jpeg_quality = value
# Output behavior
no_overwrite: bool = False
# Advanced options
max_image_mpixels: float | None = None
pdf_renderer: str = 'auto'
ocr_engine: str = 'auto'
rasterizer: str = 'auto'
rotate_pages_threshold: float = DEFAULT_ROTATE_PAGES_THRESHOLD
user_words: os.PathLike | None = None
user_patterns: os.PathLike | None = None
fast_web_view: float = 1.0
continue_on_soft_render_error: bool | None = None
# Tesseract options - also accessible via options.tesseract.<field>
tesseract_config: list[str] = []
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 = 32767
tesseract_downsample_large_images: bool | None = None
# Ghostscript options - also accessible via options.ghostscript.<field>
pdfa_image_compression: str | None = None
color_conversion_strategy: str = "LeaveColorUnchanged"
# Optimize/JBIG2 options - also accessible via options.optimize.<field>
jbig2_threshold: float = 0.85
# Plugin system
plugins: Sequence[Path | str] | None = None
# Store any extra attributes (for plugins and dynamic options)
extra_attrs: dict[str, Any] = Field(
default_factory=dict, exclude=True, alias='_extra_attrs'
)
@field_validator('languages')
@classmethod
def validate_languages(cls, v):
"""Ensure languages list is not empty."""
if not v:
return [DEFAULT_LANGUAGE]
return v
@field_validator('output_type')
@classmethod
def validate_output_type(cls, v):
"""Validate output type is one of the allowed values."""
valid_types = {'auto', 'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'}
if v not in valid_types:
raise ValueError(f"output_type must be one of {valid_types}")
return v
@field_validator('pdf_renderer')
@classmethod
def validate_pdf_renderer(cls, v):
"""Validate PDF renderer is one of the allowed values."""
valid_renderers = {'auto', 'sandwich', 'fpdf2'}
# Legacy hocr/hocrdebug are accepted but redirected to fpdf2
legacy_renderers = {'hocr', 'hocrdebug'}
all_accepted = valid_renderers | legacy_renderers
if v not in all_accepted:
raise ValueError(f"pdf_renderer must be one of {all_accepted}")
return v
@field_validator('rasterizer')
@classmethod
def validate_rasterizer(cls, v):
"""Validate rasterizer is one of the allowed values."""
valid_rasterizers = {'auto', 'ghostscript', 'pypdfium'}
if v not in valid_rasterizers:
raise ValueError(f"rasterizer must be one of {valid_rasterizers}")
return v
@field_validator('clean_final')
@classmethod
def validate_clean_final(cls, v, info):
"""If clean_final is True, also set clean to True."""
if v and hasattr(info, 'data') and 'clean' in info.data:
info.data['clean'] = True
return v
@field_validator('jobs')
@classmethod
def validate_jobs(cls, v):
"""Validate jobs is a reasonable number."""
if v is not None and (v < 0 or v > 256):
raise ValueError("jobs must be between 0 and 256")
return v
@field_validator('verbose')
@classmethod
def validate_verbose(cls, v):
"""Validate verbose level."""
if v < 0 or v > 2:
raise ValueError("verbose must be between 0 and 2")
return v
@field_validator('oversample')
@classmethod
def validate_oversample(cls, v):
"""Validate oversample DPI."""
if v < 0 or v > 5000:
raise ValueError("oversample must be between 0 and 5000")
return v
@field_validator('max_image_mpixels')
@classmethod
def validate_max_image_mpixels(cls, v):
"""Validate max image megapixels."""
if v is not None and v < 0:
raise ValueError("max_image_mpixels must be non-negative")
return v
@field_validator('rotate_pages_threshold')
@classmethod
def validate_rotate_pages_threshold(cls, v):
"""Validate rotate pages threshold."""
if v < 0 or v > 1000:
raise ValueError("rotate_pages_threshold must be between 0 and 1000")
return v
@field_validator('title', 'author', 'keywords', 'subject')
@classmethod
def validate_metadata_unicode(cls, v):
"""Validate metadata strings don't contain unsupported Unicode characters."""
if v is None:
return v
for char in v:
if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000:
hexchar = hex(ord(char))[2:].upper()
raise ValueError(
f"Metadata string contains unsupported Unicode character: "
f"{char} (U+{hexchar})"
)
return v
@field_validator('pages')
@classmethod
def validate_pages_format(cls, v):
"""Convert page ranges string to set of page numbers.
If the string uses the ``end`` alias, the original string is preserved
so that resolution can happen later, once the document's page count is
known.
"""
if v is None:
return v
if isinstance(v, set):
return v # Already processed
if _has_end_alias(v):
# Defer resolution until total page count is known
return v
# Convert string ranges to set of page numbers
return _pages_from_ranges(v)
@field_validator('unpaper_args', mode='before')
@classmethod
def validate_unpaper_args(cls, v):
"""Normalize unpaper_args from string to list and validate security."""
if v is None:
return v
if isinstance(v, str):
v = shlex.split(v)
if isinstance(v, list):
if any(('/' in arg or arg == '.' or arg == '..') for arg in v):
raise ValueError('No filenames allowed in --unpaper-args')
return v
raise ValueError(f'unpaper_args must be a string or list, got {type(v)}')
@model_validator(mode='before')
@classmethod
def handle_special_cases(cls, data):
"""Handle special cases for API compatibility and legacy options."""
if isinstance(data, dict):
# For hOCR API, output_file might not be present
if 'output_folder' in data and 'output_file' not in data:
data['output_file'] = '/dev/null' # Placeholder
# Convert legacy boolean options (force_ocr, skip_text, redo_ocr) to mode
force = data.pop('force_ocr', None)
skip = data.pop('skip_text', None)
redo = data.pop('redo_ocr', None)
# Count how many legacy options are set to True
legacy_set = [
(force, ProcessingMode.force),
(skip, ProcessingMode.skip),
(redo, ProcessingMode.redo),
]
legacy_true = [(val, mode) for val, mode in legacy_set if val]
legacy_count = len(legacy_true)
# Get current mode value (may be string or enum)
current_mode = data.get('mode', ProcessingMode.default)
if isinstance(current_mode, str):
current_mode = ProcessingMode(current_mode)
mode_is_set = current_mode != ProcessingMode.default
if legacy_count > 1:
raise ValueError(
"Choose only one of --force-ocr, --skip-text, --redo-ocr."
)
if legacy_count == 1:
expected_mode = legacy_true[0][1]
if mode_is_set and current_mode != expected_mode:
legacy_flag = f"--{expected_mode.value.replace('_', '-')}-ocr"
raise ValueError(
f"Conflicting options: --mode {current_mode.value} "
f"cannot be used with {legacy_flag} or similar legacy flag."
)
# Set mode from legacy option
data['mode'] = expected_mode
return data
@model_validator(mode='after')
def validate_redo_ocr_options(self):
"""Validate options compatible with redo mode."""
if self.mode == ProcessingMode.redo and (
self.deskew or self.clean_final or self.remove_background
):
raise ValueError(
"--redo-ocr (or --mode redo) is not currently compatible with "
"--deskew, --clean-final, and --remove-background"
)
return self
@model_validator(mode='after')
def validate_output_type_compatibility(self):
"""Validate output type is compatible with output file."""
if self.output_type == 'none' and str(self.output_file) not in (
os.devnull,
'-',
):
raise ValueError(
"Since you specified `--output-type none`, the output file "
f"{str(self.output_file)} cannot be produced. Set the output file to "
f"`-` to suppress this message."
)
return self
@property
def lossless_reconstruction(self):
"""Determine lossless_reconstruction based on other options."""
lossless = not any(
[
self.deskew,
self.clean_final,
self.mode == ProcessingMode.force,
self.remove_background,
]
)
return lossless
def model_dump_json_safe(self) -> str:
"""Serialize to JSON with special handling for non-serializable types."""
# Create a copy of the model data for serialization
data = self.model_dump()
# Handle special types that don't serialize to JSON directly
def _serialize_value(value):
if isinstance(value, Path):
return {'__type__': 'Path', 'value': str(value)}
elif (
isinstance(value, BinaryIO | IOBase)
or hasattr(value, 'read')
or hasattr(value, 'write')
):
# Stream object - replace with placeholder
return {'__type__': 'Stream', 'value': 'stream'}
elif hasattr(value, '__class__') and 'Iterator' in value.__class__.__name__:
# Handle Pydantic serialization iterators
return {'__type__': 'Stream', 'value': 'stream'}
elif isinstance(value, property):
# Handle property objects that shouldn't be serialized
return None
elif isinstance(value, list | tuple):
return [_serialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: _serialize_value(v) for k, v in value.items()}
else:
return value
# Process all fields
serializable_data = {}
for key, value in data.items():
serialized_value = _serialize_value(value)
if serialized_value is not None: # Skip None values from properties
serializable_data[key] = serialized_value
# Add extra_attrs, excluding plugin cache entries (they'll be recreated lazily)
if self.extra_attrs:
filtered_extra = {
k: v
for k, v in self.extra_attrs.items()
if not k.startswith('_plugin_cache_')
}
if filtered_extra:
serializable_data['_extra_attrs'] = _serialize_value(filtered_extra)
return json.dumps(serializable_data)
@classmethod
def model_validate_json_safe(cls, json_str: str) -> OcrOptions:
"""Reconstruct from JSON with special handling for non-serializable types."""
data = json.loads(json_str)
# Handle special types during deserialization
def _deserialize_value(value):
if isinstance(value, dict) and '__type__' in value:
if value['__type__'] == 'Path':
return Path(value['value'])
elif value['__type__'] == 'Stream':
# For streams, we'll use a placeholder string
return value['value']
else:
return value['value']
elif isinstance(value, list):
return [_deserialize_value(item) for item in value]
elif isinstance(value, dict):
return {k: _deserialize_value(v) for k, v in value.items()}
else:
return value
# Process all fields
deserialized_data = {}
extra_attrs = {}
for key, value in data.items():
if key == '_extra_attrs':
extra_attrs = _deserialize_value(value)
else:
deserialized_data[key] = _deserialize_value(value)
# Create instance
instance = cls(**deserialized_data)
instance.extra_attrs = extra_attrs
return instance
model_config = ConfigDict(
extra="forbid", # Force use of extra_attrs for unknown fields
arbitrary_types_allowed=True, # Allow BinaryIO, Path, etc.
validate_assignment=True, # Validate on attribute assignment
)
@classmethod
def register_plugin_models(cls, models: dict[str, type[BaseModel]]) -> None:
"""Register plugin option model classes for nested access.
Args:
models: Dictionary mapping namespace to model class
"""
global _plugin_option_models
_plugin_option_models.update(models)
def _get_plugin_options(self, namespace: str) -> Any:
"""Get or create a plugin options instance for the given namespace.
This method creates plugin option instances lazily from flat field values.
Args:
namespace: The plugin namespace (e.g., 'tesseract', 'optimize')
Returns:
An instance of the plugin's option model, or None if not registered
"""
# Use extra_attrs to cache plugin option instances
cache_key = f'_plugin_cache_{namespace}'
if cache_key in self.extra_attrs:
return self.extra_attrs[cache_key]
if namespace not in _plugin_option_models:
raise AttributeError(
f"Plugin namespace '{namespace}' is not registered. "
f"Ensure setup_plugin_infrastructure() was called."
)
model_class = _plugin_option_models[namespace]
def _convert_value(value):
"""Convert value to be compatible with plugin model fields."""
if isinstance(value, os.PathLike):
return os.fspath(value)
return value
# Build kwargs from flat fields
kwargs = {}
for field_name in model_class.model_fields:
# Try namespace_field pattern first (e.g., tesseract_timeout)
flat_name = f"{namespace}_{field_name}"
if flat_name in OcrOptions.model_fields:
value = getattr(self, flat_name)
if value is not None:
kwargs[field_name] = _convert_value(value)
# Plugin-scoped fields that aren't in the central OcrOptions
# registry: argparse stores them in extra_attrs under the
# namespace_field name.
elif flat_name in self.extra_attrs:
value = self.extra_attrs[flat_name]
if value is not None:
kwargs[field_name] = _convert_value(value)
# Also check direct field name (for fields like jbig2_lossy)
elif field_name in OcrOptions.model_fields:
value = getattr(self, field_name)
if value is not None:
kwargs[field_name] = _convert_value(value)
# Check for special mappings
elif namespace == 'optimize' and field_name == 'level':
# 'optimize' field maps to 'level' in OptimizeOptions
if 'optimize' in OcrOptions.model_fields:
value = self.optimize
if value is not None:
kwargs[field_name] = _convert_value(value)
# Create and cache the plugin options instance
instance = model_class(**kwargs)
self.extra_attrs[cache_key] = instance
return instance
def __getattr__(self, name: str) -> Any:
"""Support dynamic access to plugin option namespaces.
This allows accessing plugin options like:
options.tesseract.timeout
options.optimize.level
Plugin models must be registered via register_plugin_models() for
namespace access to work. Built-in plugins register their models
during initialization.
Args:
name: Attribute name
Returns:
Plugin options instance if name is a registered namespace,
otherwise raises AttributeError
"""
# Check if this is a plugin namespace
if name.startswith('_'):
# Private attributes should not trigger plugin lookup
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
# Try to get plugin options for this namespace
if name in _plugin_option_models:
return self._get_plugin_options(name)
# Check extra_attrs
if 'extra_attrs' in self.__dict__ and name in self.extra_attrs:
return self.extra_attrs[name]
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
+253
View File
@@ -0,0 +1,253 @@
# SPDX-FileCopyrightText: 2026 James R. Barlow
# SPDX-FileCopyrightText: 2025 ajdlinux
# SPDX-License-Identifier: MPL-2.0
"""Validate and repair malformed page-boundary boxes.
A page's boundary boxes (``/MediaBox``, ``/CropBox``, ``/TrimBox``, ``/ArtBox``,
``/BleedBox``) are sometimes malformed in ways that PDF readers tolerate but
that crash or corrupt downstream processing. This module normalizes them in
place following the PDF 2.0 specification (ISO 32000-2:2020):
- **Non-decimal coordinates** (§7.3.3): a coordinate written in exponential
notation is invalid PDF number syntax and is stored by qpdf/pikepdf as a
string. We coerce it back to a number (issue #1398).
- **Reversed corners** (§7.9.5): a rectangle is "a pair of diagonally opposite
corners"; ``[llx lly urx ury]`` is only the typical order. We normalize to
``[min_x, min_y, max_x, max_y]`` (issue #1526).
- **Sub-box outside the MediaBox** (§14.11.2): "If the bounds of the crop,
trim, bleed or art box extends outside of the bounds of the media box, a
processor shall treat the box as its intersection with the media box." We
clamp to that intersection, or discard the sub-box (so it inherits the
MediaBox) when the intersection is empty (issue #1400).
A rectangle is treated as empty when its width or height is ``<= 0``; PDF 2.0
permits zero-dimension rectangles and defines no minimum page size, so no other
size floor is imposed.
"""
from __future__ import annotations
import logging
import math
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
import pikepdf
from pikepdf import Name
log = logging.getLogger(__name__)
_SUBBOXES = ('CropBox', 'TrimBox', 'ArtBox', 'BleedBox')
@dataclass(frozen=True)
class BoxRepair:
"""A single change made to a page box.
Attributes:
box: The box name, e.g. ``"CropBox"``.
kind: One of ``"reordered"`` (reversed corners normalized; lossless),
``"recoded"`` (non-numeric/exponential coordinate coerced),
``"clamped"`` (sub-box clamped to the MediaBox), ``"discarded"``
(sub-box removed because its MediaBox intersection was empty), or
``"degenerate_mediabox"`` (MediaBox has zero width or height).
"""
box: str
kind: str
def _read_box(values: Sequence) -> tuple[list[float], bool, bool] | None:
"""Coerce a box array to floats and normalize corner order.
Returns ``(normalized_values, recoded, reordered)`` where ``recoded`` is
True if any element needed string/exponential coercion and ``reordered`` is
True if the corners were given in non-standard order. Returns None if the
array is not four finite numbers.
"""
if len(values) != 4:
return None
nums: list[float] = []
recoded = False
for v in values:
try:
n = float(v)
except (TypeError, ValueError):
try:
n = float(str(v))
except (TypeError, ValueError):
return None
recoded = True
if not math.isfinite(n):
return None
nums.append(n)
x0, y0, x1, y1 = nums
normalized = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)]
reordered = normalized != nums
return normalized, recoded, reordered
def coerce_box(values: Iterable) -> list[float]:
"""Return box values coerced to floats with corner order normalized.
Robust against exponential/string coordinates and reversed corners, so
callers that only need to read a box (e.g. dimension calculations) do not
crash on malformed input. Falls back to best-effort per-element coercion if
the array is not four numbers.
"""
values = list(values)
result = _read_box(values)
if result is not None:
return result[0]
coerced = []
for v in values:
try:
coerced.append(float(v))
except (TypeError, ValueError):
coerced.append(float(str(v)))
return coerced
def _is_empty(box: Sequence[float]) -> bool:
"""A rectangle is empty when its width or height is non-positive."""
return (box[2] - box[0]) <= 0 or (box[3] - box[1]) <= 0
def repair_page_boxes(page: pikepdf.Page) -> list[BoxRepair]:
"""Validate and repair the boundary boxes of a single page, in place.
Returns the list of changes made (empty if the page was already valid).
Only boxes that actually change are written back, so valid pages are left
untouched. Performs no logging or I/O.
"""
repairs: list[BoxRepair] = []
# MediaBox is the reference rectangle; read it inheritance-aware.
mediabox: list[float] | None = None
try:
mb_result = _read_box(list(page.mediabox.as_list()))
except (AttributeError, KeyError, RuntimeError):
mb_result = None
if mb_result is not None:
mediabox, recoded, reordered = mb_result
if reordered:
repairs.append(BoxRepair('MediaBox', 'reordered'))
if recoded:
repairs.append(BoxRepair('MediaBox', 'recoded'))
if recoded or reordered:
page.obj.MediaBox = pikepdf.Array(mediabox)
if _is_empty(mediabox):
repairs.append(BoxRepair('MediaBox', 'degenerate_mediabox'))
mediabox = None # don't clamp against a degenerate reference
for box in _SUBBOXES:
name = Name('/' + box)
if name not in page.obj:
continue
try:
sub_result = _read_box(list(page.obj[name]))
except (TypeError, RuntimeError):
continue
if sub_result is None:
continue
values, recoded, reordered = sub_result
if reordered:
repairs.append(BoxRepair(box, 'reordered'))
if recoded:
repairs.append(BoxRepair(box, 'recoded'))
if recoded or reordered:
page.obj[name] = pikepdf.Array(values)
if mediabox is None:
continue
intersection = [
max(values[0], mediabox[0]),
max(values[1], mediabox[1]),
min(values[2], mediabox[2]),
min(values[3], mediabox[3]),
]
if _is_empty(intersection):
del page.obj[name]
repairs.append(BoxRepair(box, 'discarded'))
elif intersection != values:
page.obj[name] = pikepdf.Array(intersection)
repairs.append(BoxRepair(box, 'clamped'))
return repairs
# Per-kind log severity and message template ({box} is substituted).
_KIND_MESSAGES: dict[str, tuple[int, str]] = {
'discarded': (
logging.WARNING,
'{box} lies outside the MediaBox and was discarded; '
'the full page will be shown',
),
'clamped': (
logging.WARNING,
'{box} extended beyond the MediaBox and was clamped to it',
),
'recoded': (
logging.WARNING,
'{box} used invalid (e.g. exponential) coordinates, which were reinterpreted',
),
'degenerate_mediabox': (
logging.WARNING,
'MediaBox has zero width or height and could not be repaired; '
'output may be invalid',
),
'reordered': (
logging.DEBUG,
'{box} corners were reversed and have been normalized',
),
}
# Kinds that change page appearance and warrant manual review of the output.
_INSPECT_KINDS = frozenset({'discarded', 'clamped', 'recoded'})
_INSPECT = ' Please visually inspect the output PDF.'
def _format_pages(pagenos: Iterable[int]) -> str:
"""Format 0-based page numbers as a compact 1-based range string."""
nums = sorted(p + 1 for p in pagenos)
ranges: list[tuple[int, int]] = []
start = prev = nums[0]
for n in nums[1:]:
if n == prev + 1:
prev = n
continue
ranges.append((start, prev))
start = prev = n
ranges.append((start, prev))
return ', '.join(f'{a}' if a == b else f'{a}-{b}' for a, b in ranges)
def summarize_box_repairs(
repairs_by_page: Mapping[int, Sequence[BoxRepair]],
) -> list[tuple[int, str]]:
"""Aggregate per-page repairs into ``(log_level, message)`` pairs.
Repairs are grouped by ``(kind, box)`` so a defect shared across many pages
yields a single message listing the affected pages, rather than one message
per page.
"""
groups: dict[tuple[str, str], set[int]] = {}
for pageno, repairs in repairs_by_page.items():
for repair in repairs:
groups.setdefault((repair.kind, repair.box), set()).add(pageno)
messages: list[tuple[int, str]] = []
for (kind, box), pages in sorted(groups.items()):
level, template = _KIND_MESSAGES[kind]
text = f'Page(s) {_format_pages(pages)}: {template.format(box=box)}.'
if kind in _INSPECT_KINDS:
text += _INSPECT
messages.append((level, text))
return messages
def log_box_repairs(repairs_by_page: Mapping[int, Sequence[BoxRepair]]) -> None:
"""Emit aggregated log messages for the repairs made across all pages."""
for level, message in summarize_box_repairs(repairs_by_page):
log.log(level, message)
+427 -155
View File
@@ -15,7 +15,10 @@ from contextlib import suppress
from io import BytesIO
from pathlib import Path
from shutil import copyfileobj
from typing import Any, BinaryIO, TypeVar, cast
from typing import TYPE_CHECKING, Any, BinaryIO, TypeVar, cast
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
import img2pdf
import pikepdf
@@ -25,21 +28,30 @@ from ocrmypdf._concurrent import Executor
from ocrmypdf._exec import unpaper
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._metadata import repair_docinfo_nuls
from ocrmypdf._options import OcrOptions, PathOrIO, ProcessingMode, TaggedPdfMode
from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes
from ocrmypdf._stdoutprotect import get_protected_stdout_fd
from ocrmypdf.exceptions import (
ColorConversionNeededError,
DigitalSignatureError,
DpiError,
EncryptedPdfError,
InputFileError,
NonEmbeddedFontsError,
PriorOcrFoundError,
SubprocessOutputError,
TaggedPDFError,
UnsupportedImageFormatError,
)
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
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
from ocrmypdf.pdfa import (
file_claims_pdfa,
find_nonembedded_cid_fonts,
generate_pdfa_ps,
speculative_pdfa_conversion,
)
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, Ink, PageInfo, PdfInfo
from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
try:
from pi_heif import register_heif_opener
@@ -58,7 +70,7 @@ VECTOR_PAGE_DPI = 400
register_heif_opener()
def triage_image_file(input_file: Path, output_file: Path, options) -> None:
def triage_image_file(input_file: Path, output_file: Path, options: OcrOptions) -> None:
"""Triage the input image file.
If the input file is an image, check its resolution and convert it to PDF.
@@ -110,8 +122,7 @@ def triage_image_file(input_file: Path, output_file: Path, options) -> None:
if im.mode in ('RGBA', 'LA'):
raise UnsupportedImageFormatError(
"The input image has an alpha channel. Remove the alpha "
"channel first."
"The input image has an alpha channel. Remove the alpha channel first."
)
if 'iccprofile' not in im.info:
@@ -129,7 +140,7 @@ def triage_image_file(input_file: Path, output_file: Path, options) -> None:
layout_fun = img2pdf.get_fixed_dpi_layout_fun(
Resolution(options.image_dpi, options.image_dpi)
)
with open(output_file, 'wb') as outf:
with output_file.open('wb') as outf:
img2pdf.convert(
os.fspath(input_file),
layout_fun=layout_fun,
@@ -148,7 +159,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
Returns empty string if not found, indicating file is probably not PDF.
"""
with open(input_file, 'rb') as f:
with input_file.open('rb') as f:
signature = f.read(search_window)
m = re.search(rb'%PDF-(\d\.\d)', signature)
if m:
@@ -157,7 +168,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
def triage(
original_filename: str, input_file: Path, output_file: Path, options
original_filename: str, input_file: Path, output_file: Path, options: OcrOptions
) -> Path:
"""Triage the input file. We can handle PDFs and images."""
try:
@@ -169,6 +180,12 @@ def triage(
)
try:
with pikepdf.open(input_file) as pdf:
repairs_by_page = {
n: repairs
for n, page in enumerate(pdf.pages)
if (repairs := repair_page_boxes(page))
}
log_box_repairs(repairs_by_page)
pdf.save(output_file)
except pikepdf.PdfError as e:
raise InputFileError() from e
@@ -227,10 +244,10 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
else:
raise DigitalSignatureError()
if pdfinfo.has_acroform:
if options.redo_ocr:
if options.mode == ProcessingMode.redo:
raise InputFileError(
"This PDF has a user fillable form. --redo-ocr is not "
"currently possible on such files."
"This PDF has a user fillable form. --redo-ocr (or --mode redo) "
"is not currently possible on such files."
)
else:
log.warning(
@@ -238,23 +255,29 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
"Chances are it is a pure digital "
"document that does not need OCR."
)
if not options.force_ocr:
if options.mode != ProcessingMode.force:
log.info(
"Use the option --force-ocr to produce an image of the "
"form and all filled form fields. The output PDF will be "
"'flattened' and will no longer be fillable."
"Use the option --force-ocr (or --mode force) to produce an "
"image of the 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:
if pdfinfo.is_tagged or pdfinfo.has_structure_tree:
log.warning(
"This PDF contains structural markup (it is a Tagged PDF or "
"carries a logical structure tree). This often indicates that the "
"PDF was generated from an office document or is otherwise born "
"digital, and does not need OCR. OCRmyPDF cannot rebuild this "
"structure to match new text, so any page it re-OCRs with "
"--force-ocr or --redo-ocr will have its structural markup "
"discarded."
)
if (
options.tagged_pdf_mode == TaggedPdfMode.default
and options.mode == ProcessingMode.default
):
log.info("Use --tagged-pdf-mode ignore to ignore Tagged PDFs.")
raise TaggedPDFError()
context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options)
context.plugin_manager.validate(pdfinfo=pdfinfo, options=options)
def _vector_page_dpi(pageinfo: PageInfo) -> int:
@@ -316,30 +339,35 @@ def is_ocr_required(page_context: PageContext) -> bool:
pageinfo = page_context.pageinfo
options = page_context.options
if options.mode == ProcessingMode.strip_text:
# Strip mode removes the OCR text layer in place; it never rasterizes
# or runs OCR. The stripping happens in OcrGrafter.graft_page.
return False
ocr_required = True
if options.pages and pageinfo.pageno not in options.pages:
log.debug(f"skipped {pageinfo.pageno} as requested by --pages {options.pages}")
ocr_required = False
elif pageinfo.has_text:
if not options.force_ocr and not (options.skip_text or options.redo_ocr):
if options.mode == ProcessingMode.default:
raise PriorOcrFoundError(
"page already has text! - aborting (use --force-ocr to force OCR; "
" see also help for the arguments --skip-text and --redo-ocr"
"page already has text! - aborting (use --force-ocr or --mode force "
"to force OCR; see also help for --skip-text, --redo-ocr, and --mode)"
)
elif options.force_ocr:
elif options.mode == ProcessingMode.force:
log.info("page already has text! - rasterizing text and running OCR anyway")
ocr_required = True
elif options.redo_ocr:
elif options.mode == ProcessingMode.redo:
if pageinfo.has_corrupt_text:
log.warning(
"some text on this page cannot be mapped to characters: "
"consider using --force-ocr instead"
"consider using --force-ocr (or --mode force) instead"
)
else:
log.info("redoing OCR")
ocr_required = True
elif options.skip_text:
elif options.mode == ProcessingMode.skip:
log.info("skipping all processing on this page")
ocr_required = False
elif not pageinfo.images and not options.lossless_reconstruction:
@@ -350,14 +378,14 @@ def is_ocr_required(page_context: PageContext) -> bool:
# ahead and rasterize. If not forced, then pretend there's no text
# on the page at all so we don't lose anything.
# This could be made smarter by explicitly searching for vector art.
if options.force_ocr and options.oversample:
if options.mode == ProcessingMode.force and options.oversample:
# The user really wants to reprocess this file
log.info(
"page has no images - "
f"rasterizing at {options.oversample} DPI because "
"--force-ocr --oversample was specified"
"--force-ocr --oversample (or --mode force --oversample) was specified"
)
elif options.force_ocr:
elif options.mode == ProcessingMode.force:
# Warn the user they might not want to do this
log.warning(
"page has no images - "
@@ -370,8 +398,8 @@ def is_ocr_required(page_context: PageContext) -> bool:
log.info(
"page has no images - "
"skipping all processing on this page to avoid losing detail. "
"Use --force-ocr if you wish to perform OCR on pages that "
"have vector content."
"Use --force-ocr (or --mode force) if you wish to perform OCR on "
"pages that have vector content."
)
ocr_required = False
@@ -394,16 +422,18 @@ def rasterize_preview(input_file: Path, page_context: PageContext) -> Path:
[get_canvas_square_dpi(page_context)]
)
page_dpi = Resolution(300.0, 300.0).take_min([get_page_square_dpi(page_context)])
page_context.plugin_manager.hook.rasterize_pdf_page(
page_context.plugin_manager.rasterize_pdf_page(
input_file=input_file,
output_file=output_file,
raster_device='jpeggray',
raster_device=GhostscriptRasterDevice.JPEGGRAY,
raster_dpi=canvas_dpi,
pageno=page_context.pageinfo.pageno + 1,
page_dpi=page_dpi,
rotation=0,
filter_vector=False,
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
options=page_context.options,
use_cropbox=False,
)
return output_file
@@ -423,10 +453,7 @@ def describe_rotation(
else:
action = 'rotation appears correct'
else:
if correction != 0:
action = 'confidence too low to rotate'
else:
action = 'no change'
action = "confidence too low to rotate" if correction != 0 else "no change"
facing = ''
@@ -452,9 +479,10 @@ def get_orientation_correction(preview: Path, page_context: PageContext) -> int:
which points it (hopefully) upright. _graft.py takes care of the orienting
the image and text layers.
"""
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
preview, page_context.options
ocr_engine = page_context.plugin_manager.get_ocr_engine(
options=page_context.options
)
orient_conf = ocr_engine.get_orientation(preview, page_context.options)
correction = orient_conf.angle % 360
log.info(describe_rotation(page_context, orient_conf, correction))
@@ -499,6 +527,49 @@ def calculate_raster_dpi(page_context: PageContext):
return canvas_dpi, page_dpi
def _select_raster_device(pageinfo: PageInfo) -> GhostscriptRasterDevice:
"""Choose the minimum raster device that preserves the page's color depth.
The device escalates from 1-bit mono through grayscale, indexed, and full
color as required by the page's images, image masks, and vector content.
Image masks are painted with the current fill color, so a mask painted in
gray or color escalates the device even though the mask itself is 1-bit.
"""
colorspaces = [
GhostscriptRasterDevice.PNGMONOD,
GhostscriptRasterDevice.PNGGRAY,
GhostscriptRasterDevice.PNG256,
GhostscriptRasterDevice.PNG16M,
]
device_idx = 0
def at_least(colorspace):
return max(device_idx, colorspaces.index(colorspace))
for image in pageinfo.images:
if image.type_ == 'stencil':
# The fill color used to paint the mask, not the 1-bit mask data,
# determines the color depth OCR needs.
if image.ink == Ink.color:
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
elif image.ink == Ink.gray:
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
continue
if image.bpc > 1:
if image.color == Colorspace.index:
device_idx = at_least(GhostscriptRasterDevice.PNG256)
elif image.color == Colorspace.gray:
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
else:
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
if pageinfo.has_vector:
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
return colorspaces[device_idx]
def rasterize(
input_file: Path,
page_context: PageContext,
@@ -520,40 +591,21 @@ def rasterize(
Returns:
Path: The output PNG file path.
"""
colorspaces = ['pngmono', 'pnggray', 'png256', 'png16m']
device_idx = 0
if remove_vectors is None:
remove_vectors = page_context.options.remove_vectors
output_file = page_context.get_path(f'rasterize{output_tag}.png')
pageinfo = page_context.pageinfo
def at_least(colorspace):
return max(device_idx, colorspaces.index(colorspace))
device = _select_raster_device(pageinfo)
for image in pageinfo.images:
if image.type_ != 'image':
continue # ignore masks
if image.bpc > 1:
if image.color == Colorspace.index:
device_idx = at_least('png256')
elif image.color == Colorspace.gray:
device_idx = at_least('pnggray')
else:
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]
log.debug(f"Rasterize with {device}, rotation {correction}")
log.debug(
f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}"
)
canvas_dpi, page_dpi = calculate_raster_dpi(page_context)
page_context.plugin_manager.hook.rasterize_pdf_page(
page_context.plugin_manager.rasterize_pdf_page(
input_file=input_file,
output_file=output_file,
raster_device=device,
@@ -563,6 +615,8 @@ def rasterize(
rotation=correction,
filter_vector=remove_vectors,
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
options=page_context.options,
use_cropbox=False,
)
return output_file
@@ -591,7 +645,9 @@ def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path:
output_file = page_context.get_path('pp_deskew.png')
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
ocr_engine = page_context.plugin_manager.hook.get_ocr_engine()
ocr_engine = page_context.plugin_manager.get_ocr_engine(
options=page_context.options
)
deskew_angle_degrees = ocr_engine.get_deskew(input_file, page_context.options)
with Image.open(input_file) as im:
@@ -627,14 +683,15 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path:
"""
output_file = page_context.get_path('ocr.png')
options = page_context.options
im: Image.Image
with Image.open(image) as im:
log.debug('resolution %r', im.info['dpi'])
if not options.force_ocr:
if options.mode != ProcessingMode.force:
# Do not mask text areas when forcing OCR, because we need to OCR
# all text areas
mask = None # Exclude both visible and invisible text from OCR
if options.redo_ocr:
if options.mode == ProcessingMode.redo:
mask = True # Mask visible text, but not invisible text
draw = ImageDraw.ImageDraw(im)
@@ -656,7 +713,7 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path:
draw.rectangle(pixcoords, fill='white')
# draw.rectangle(pixcoords, outline='pink')
filter_im = page_context.plugin_manager.hook.filter_ocr_image(
filter_im = page_context.plugin_manager.filter_ocr_image(
page=page_context, image=im
)
if filter_im is not None:
@@ -674,7 +731,7 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
hocr_text_out = page_context.get_path('ocr_hocr.txt')
options = page_context.options
ocr_engine = page_context.plugin_manager.hook.get_ocr_engine()
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
ocr_engine.generate_hocr(
input_file=input_file,
output_hocr=hocr_out,
@@ -684,10 +741,42 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path,
return hocr_out, hocr_text_out
def ocr_engine_direct(
input_file: Path, page_context: PageContext
) -> tuple[OcrElement, Path]:
"""Run the OCR engine and return OcrElement tree directly.
This is the modern path for OCR engines that support the generate_ocr() API.
It bypasses hOCR file generation for better performance and richer data.
Args:
input_file: The image file to OCR.
page_context: The page context with options and path utilities.
Returns:
A tuple of (OcrElement tree, path to text sidecar file).
"""
text_out = page_context.get_path('ocr_direct.txt')
options = page_context.options
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
ocr_tree, text_content = ocr_engine.generate_ocr(
input_file=input_file,
options=options,
page_number=page_context.pageno,
)
# Write text sidecar file
text_out.write_text(text_content, encoding='utf-8')
return ocr_tree, text_out
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
"""Determines whether the visible page image should be saved as a JPEG.
If all images were JPEGs originally, permit a JPEG as output.
If all images were JPEGs originally (including FlateDecode+DCTDecode),
permit a JPEG as output.
Args:
pageinfo: The PageInfo object containing information about the page.
@@ -696,7 +785,7 @@ def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
A boolean indicating whether the visible page image should be saved as a JPEG.
"""
return bool(pageinfo.images) and all(
im.enc == Encoding.jpeg for im in pageinfo.images
im.enc in (Encoding.jpeg, Encoding.flate_jpeg) for im in pageinfo.images
)
@@ -744,7 +833,7 @@ def create_pdf_page_from_image(
# Create a new single page PDF to hold
bio = BytesIO()
with open(image, 'rb') as imfile:
with image.open('rb') as imfile:
log.debug('convert')
layout_fun = img2pdf.get_layout_fun(pagesize)
@@ -761,47 +850,12 @@ def create_pdf_page_from_image(
bio.seek(0)
fix_pagepdf_boxes(bio, output_file, page_context, swap_axis=swap_axis)
output_file = page_context.plugin_manager.hook.filter_pdf_page(
output_file = page_context.plugin_manager.filter_pdf_page(
page=page_context, image_filename=image, output_pdf=output_file
)
return output_file
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')
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
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,
invisible_text=True if not debug_kwargs else False,
)
return output_file
def ocr_engine_textonly_pdf(
input_image: Path, page_context: PageContext
) -> tuple[Path, Path]:
@@ -810,7 +864,7 @@ def ocr_engine_textonly_pdf(
output_text = page_context.get_path('ocr_tess.txt')
options = page_context.options
ocr_engine = page_context.plugin_manager.hook.get_ocr_engine()
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
ocr_engine.generate_pdf(
input_file=input_image,
output_pdf=output_pdf,
@@ -830,6 +884,23 @@ def _offset_rect(rect: tuple[float, float, float, float], offset: tuple[float, f
)
def _adjust_pagebox(
page: pikepdf.Page,
media_box: FloatRect,
name: pikepdf.Name,
target_box: FloatRect,
offset: tuple[float, float],
swap_axis: bool,
):
if media_box == target_box:
return
box = _offset_rect(target_box, offset)
if swap_axis:
box = box[1], box[0], box[3], box[2]
page[name] = box
log.debug(f"{str(name)} = {target_box}")
def fix_pagepdf_boxes(
infile: Path | BinaryIO,
out_file: Path,
@@ -840,7 +911,7 @@ def fix_pagepdf_boxes(
The single page PDF is created with a normal MediaBox with its lower left corner
at (0, 0). infile is the single page PDF. page_context.mediabox has the original
file's mediabox, which may have a different origin. We needto adjust the other
file's mediabox, which may have a different origin. We need to adjust the other
boxes in the single page PDF to match the effect they had on the original page.
When correcting page rotation, we create a single page PDF that is correctly
@@ -854,18 +925,25 @@ def fix_pagepdf_boxes(
"""
with pikepdf.open(infile) as pdf:
for page in pdf.pages:
# page.BleedBox = page_context.pageinfo.bleedbox
# page.ArtBox = page_context.pageinfo.artbox
log.debug(
f"initial mediabox={page.MediaBox} and pageinfo "
f"mediabox={page_context.pageinfo.mediabox}"
)
mediabox = page_context.pageinfo.mediabox
offset = mediabox[0], mediabox[1]
cropbox = _offset_rect(page_context.pageinfo.cropbox, offset)
trimbox = _offset_rect(page_context.pageinfo.trimbox, offset)
offset = -mediabox[0], -mediabox[1]
if swap_axis:
cropbox = cropbox[1], cropbox[0], cropbox[3], cropbox[2]
trimbox = trimbox[1], trimbox[0], trimbox[3], trimbox[2]
page.CropBox = cropbox
page.TrimBox = trimbox
mediabox = mediabox[1], mediabox[0], mediabox[3], mediabox[2]
boxes = ['CropBox', 'TrimBox', 'ArtBox', 'BleedBox']
for box_name in boxes:
_adjust_pagebox(
page,
mediabox,
pikepdf.Name(f"/{box_name}"),
getattr(page_context.pageinfo, box_name.lower()),
offset,
swap_axis,
)
pdf.save(out_file)
return out_file
@@ -904,20 +982,37 @@ 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:
# Ghostscript would substitute and re-embed any non-embedded CID font to
# satisfy PDF/A, corrupting CJK text (e.g. an Acrobat OCR layer) in the
# process. Refuse rather than silently damage the user's text layer.
nonembedded = find_nonembedded_cid_fonts(pdf_file)
if nonembedded:
raise NonEmbeddedFontsError(nonembedded)
if repair_docinfo_nuls(pdf_file):
pdf_file.save(fix_docinfo_file)
else:
safe_symlink(input_pdf, fix_docinfo_file)
context.plugin_manager.hook.generate_pdfa(
# Extract PDF/A part correctly
if options.output_type.startswith('pdfa'):
if options.output_type == 'pdfa':
pdfa_part = '2' # Default to PDF/A-2
else:
pdfa_part = options.output_type.split('-')[
-1
] # Extract number from pdfa-1, pdfa-2, etc.
else:
pdfa_part = '2' # Fallback
context.plugin_manager.generate_pdfa(
pdf_version=input_pdfinfo.min_version,
pdf_pages=[fix_docinfo_file],
pdfmark=input_ps_stub,
output_file=output_file,
context=context,
pdfa_part=options.output_type[-1], # is pdfa-1, pdfa-2, or pdfa-3
pdfa_part=pdfa_part,
progressbar_class=(
context.plugin_manager.hook.get_progressbar_class()
context.plugin_manager.get_progressbar_class()
if options.progress_bar
else None
),
@@ -927,15 +1022,185 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
return output_file
def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None:
"""Try speculative PDF/A conversion with verapdf validation.
This attempts a fast PDF/A conversion by adding PDF/A structures
directly with pikepdf, then validating with verapdf. If validation
passes, returns the converted file. If it fails or verapdf is not
available, returns None to signal that Ghostscript should be used.
Args:
input_pdf: Path to the PDF to convert
context: The PDF context
Returns:
Path to valid PDF/A file, or None if speculative conversion failed
"""
from ocrmypdf._exec import verapdf
options = context.options
# Skip speculative conversion if user requested specific image compression,
# since that requires Ghostscript to apply
gs_opts = getattr(options, 'ghostscript', None)
if gs_opts is not None:
compression = getattr(gs_opts, 'pdfa_image_compression', 'auto')
if compression != 'auto':
log.debug(
'Skipping speculative PDF/A: --pdfa-image-compression=%s requires '
'Ghostscript',
compression,
)
return None
if not verapdf.available():
log.debug('verapdf not available, skipping speculative PDF/A conversion')
return None
output_file = context.get_path('speculative_pdfa.pdf')
try:
speculative_pdfa_conversion(input_pdf, output_file, options.output_type)
flavour = verapdf.output_type_to_flavour(options.output_type)
result = verapdf.validate(output_file, flavour)
if result.valid:
log.info('Speculative PDF/A conversion succeeded - skipping Ghostscript')
return output_file
else:
log.debug(
'Speculative PDF/A validation failed (%d rule violations), '
'falling back to Ghostscript',
result.failed_rules,
)
return None
except Exception as e:
log.debug('Speculative PDF/A conversion failed: %s', e)
return None
def _ghostscript_pdfa_fallback(input_pdf: Path, context: PdfContext) -> Path | None:
"""Best-effort PDF/A conversion via Ghostscript for 'auto' output type.
Returns the converted PDF/A path, or None if Ghostscript is unavailable,
fails, or cannot produce valid PDF/A. Never raises: 'auto' mode degrades to
a regular PDF instead of erroring or emitting corrupted output.
Args:
input_pdf: Path to the PDF to convert.
context: The PDF context.
"""
from ocrmypdf._exec import ghostscript
if not ghostscript.available():
return None
try:
ps_stub = generate_postscript_stub(context)
gs_out = convert_to_pdfa(input_pdf, ps_stub, context)
except (
SubprocessOutputError,
ColorConversionNeededError,
NonEmbeddedFontsError,
) as e:
log.info('Auto mode: Ghostscript could not produce PDF/A (%s)', e)
return None
if not file_claims_pdfa(gs_out)['pass']:
log.info('Auto mode: Ghostscript output is not valid PDF/A')
return None
return gs_out
def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
"""Best-effort PDF/A for 'auto' output type.
Order of attempts, first success wins:
1. Non-embedded CID fonts -> regular PDF (Ghostscript would corrupt them).
2. Speculative conversion validated by verapdf (no Ghostscript).
3. Without verapdf, pass through if already PDF/A or rebuilt with force-ocr.
4. Ghostscript conversion (best-effort; failures fall through).
5. Regular PDF if none of the above produced PDF/A.
Args:
input_pdf: Path to the PDF to convert
context: The PDF context
Returns:
Tuple of (output_path, actual_output_type) where actual_output_type
is 'pdfa' if PDF/A was achieved, 'pdf' otherwise
"""
from ocrmypdf._exec import verapdf
# Non-embedded CID fonts cannot be made PDF/A without Ghostscript font
# substitution that corrupts CID/CJK text. Rather than risk an existing
# text layer, downgrade to a regular PDF (the same outcome as any other
# case where best-effort PDF/A is not achievable).
with pikepdf.open(input_pdf) as pdf_file:
nonembedded = find_nonembedded_cid_fonts(pdf_file)
if nonembedded:
log.info(
"Auto mode: input has non-embedded CID fonts (%s) that cannot be "
"converted to PDF/A without corrupting the text; outputting a "
"regular PDF. Use --output-type pdf to select this explicitly.",
', '.join(sorted(nonembedded)),
)
return (input_pdf, 'pdf')
# Cheap path: speculative conversion validated by verapdf (no Ghostscript).
if verapdf.available():
result = try_speculative_pdfa(input_pdf, context)
if result is not None:
return (result, 'pdfa')
log.info('Auto mode: speculative PDF/A validation failed')
elif _is_safe_pdfa(input_pdf, context.options):
# No verapdf, but the input is already PDF/A or was rebuilt with
# --force-ocr, so we can pass it through without Ghostscript.
log.info('Auto mode: passing through as PDF/A (input already compliant)')
return (input_pdf, 'pdfa')
# Fall back to Ghostscript to produce real PDF/A (v16 behavior). Best-effort:
# if Ghostscript is unavailable or cannot safely produce PDF/A, keep a
# regular PDF rather than error.
gs_out = _ghostscript_pdfa_fallback(input_pdf, context)
if gs_out is not None:
log.info('Auto mode: produced PDF/A via Ghostscript')
return (gs_out, 'pdfa')
log.info('Auto mode: could not produce PDF/A, outputting regular PDF')
return (input_pdf, 'pdf')
def _is_safe_pdfa(input_pdf: Path, options) -> bool:
"""Check if file can be considered PDF/A without validation.
These are cases where our modifications don't break PDF/A compliance:
1. Input already claims PDF/A (we just grafted OCR text onto it)
2. We used force-ocr (we rewrote the entire PDF from scratch)
Args:
input_pdf: Path to the PDF to check
options: OCR options
Returns:
True if file can safely be considered PDF/A
"""
# Safe if input already claims PDF/A
pdfa_status = file_claims_pdfa(input_pdf)
if pdfa_status['pass']:
return True
# Safe if we rewrote the PDF with force mode
return options.mode == ProcessingMode.force
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
filesize = working_file.stat().st_size
return filesize > (context.options.fast_web_view * 1_000_000)
def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
@@ -989,7 +1254,7 @@ def optimize_pdf(
) -> tuple[Path, Sequence[str]]:
"""Optimize the given PDF file."""
output_file = context.get_path('optimize.pdf')
output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
output_pdf, messages = context.plugin_manager.optimize_pdf(
input_pdf=input_file,
output_pdf=output_file,
context=context,
@@ -1020,7 +1285,8 @@ def enumerate_compress_ranges(
A tuple containing a range of indices and the corresponding element.
If the element is None, the range represents a skipped range of indices.
"""
skipped_from, index = None, None
skipped_from: int | None = None
index: int | None = None
for index, txt_file in enumerate(iterable):
index += 1
if txt_file:
@@ -1032,6 +1298,9 @@ def enumerate_compress_ranges(
if skipped_from is None:
skipped_from = index
if skipped_from is not None:
# skipped_from can only be set inside the loop above, so the loop
# must have run at least once and index is guaranteed to be an int.
assert index is not None
yield (skipped_from, index), None
@@ -1043,7 +1312,7 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
and returns the path to the merged file.
"""
output_file = context.get_path('sidecar.txt')
with open(output_file, 'w', encoding="utf-8") as stream:
with output_file.open('w', encoding="utf-8") as stream:
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
if from_ != 1:
stream.write('\f') # Form feed between pages for all pages after first
@@ -1053,32 +1322,33 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Pat
# others don't. Remove it if it exists, since we add one manually.
stream.write(txt.removesuffix('\f'))
else:
if from_ != to_:
pages = f'{from_}-{to_}'
else:
pages = f'{from_}'
pages = f"{from_}-{to_}" if from_ != to_ else f"{from_}"
stream.write(f'[OCR skipped on page(s) {pages}]')
return output_file
def copy_final(
input_file: Path, output_file: str | Path | BinaryIO, original_file: Path | None
) -> None:
def copy_final(input_file: Path, output_file: PathOrIO) -> None:
"""Copy the final temporary file to the output destination.
Args:
input_file (Path): The intermediate input file to copy.
output_file (str | Path | BinaryIO): The output file to copy to.
original_file: The original file to copy attributes from.
Returns:
None
input_file: The intermediate input file to copy.
output_file: The output file to copy to.
"""
log.debug('%s -> %s', input_file, output_file)
with input_file.open('rb') as input_stream:
if output_file == '-':
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
fd = get_protected_stdout_fd()
if fd is not None:
# Stdout protection is active: write to the preserved real
# stdout. dup the saved fd so the with-block's close() does not
# close our long-lived descriptor.
with os.fdopen(os.dup(fd), 'wb') as stdout_stream:
copyfileobj(input_stream, stdout_stream)
stdout_stream.flush()
else:
# No protection installed (e.g. plain API use): legacy behavior.
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
elif hasattr(output_file, 'writable'):
output_stream = cast(BinaryIO, output_file)
copyfileobj(input_stream, output_stream) # type: ignore[misc]
@@ -1088,5 +1358,7 @@ 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, 'w+b') as output_stream:
# The `hasattr` check above already ruled out stream-like objects.
assert isinstance(output_file, str | bytes | os.PathLike)
with Path(os.fsdecode(output_file)).open('w+b') as output_stream:
copyfileobj(input_stream, output_stream)
+86 -20
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
@@ -17,9 +16,13 @@ from concurrent.futures.thread import BrokenThreadPool
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import NamedTuple, cast
from typing import TYPE_CHECKING, NamedTuple, cast
if TYPE_CHECKING:
from ocrmypdf.hocrtransform import OcrElement
import PIL
import PIL.Image
from pikepdf import Pdf
from ocrmypdf._annots import remove_broken_goto_annotations
@@ -27,6 +30,7 @@ from ocrmypdf._concurrent import Executor, setup_executor
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._logging import PageNumberFilter
from ocrmypdf._metadata import metadata_fixup
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import (
convert_to_pdfa,
create_ocr_image,
@@ -44,6 +48,8 @@ from ocrmypdf._pipeline import (
rasterize_preview,
should_linearize,
should_visible_page_image_use_jpg,
try_auto_pdfa,
try_speculative_pdfa,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
@@ -51,7 +57,6 @@ from ocrmypdf._validation import (
)
from ocrmypdf.exceptions import ExitCode, ExitCodeException
from ocrmypdf.helpers import (
available_cpu_count,
check_pdf,
pikepdf_enable_mmap,
running_in_docker,
@@ -105,6 +110,9 @@ class PageResult(NamedTuple):
orientation_correction: int = 0
"""Orientation correction in degrees."""
ocr_tree: OcrElement | None = None
"""Direct OcrElement tree (when using generate_ocr() API)."""
class HOCRResultEncoder(json.JSONEncoder):
def default(self, obj):
@@ -115,7 +123,8 @@ class HOCRResultEncoder(json.JSONEncoder):
class HOCRResultDecoder(json.JSONDecoder):
def __init__(self, *args, **kwargs):
super().__init__(object_hook=self.dict_to_object, *args, **kwargs)
kwargs['object_hook'] = self.dict_to_object
super().__init__(*args, **kwargs)
def dict_to_object(self, d):
if 'Path' in d:
@@ -142,6 +151,9 @@ class HOCRResult:
orientation_correction: int = 0
"""Orientation correction in degrees."""
ocr_tree: OcrElement | None = None
"""Direct OcrElement tree (when using generate_ocr() API)."""
@classmethod
def from_json(cls, json_str: str) -> HOCRResult:
"""Create an instance from a dict."""
@@ -194,7 +206,7 @@ def worker_init(max_pixels: int | None) -> None:
@contextmanager
def manage_debug_log_handler(
*,
options: argparse.Namespace,
options: OcrOptions,
work_folder: Path,
):
remover = None
@@ -243,8 +255,8 @@ def manage_work_folder(*, work_folder: Path, retain: bool, print_location: bool)
def cli_exception_handler(
fn: Callable[[argparse.Namespace, OcrmypdfPluginManager], ExitCode],
options: argparse.Namespace,
fn: Callable[[OcrOptions, OcrmypdfPluginManager], ExitCode],
options: OcrOptions,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Convert exceptions into command line error messages and exit codes.
@@ -274,6 +286,16 @@ def cli_exception_handler(
else:
log.error(type(e).__name__)
return e.exit_code
except ValueError as e:
# Convert Pydantic validation errors to BadArgsError for proper exit code
if "validation error" in str(e).lower() or "value error" in str(e).lower():
if options.verbose >= 1:
log.exception("Validation error")
else:
log.error("Invalid argument: %s", str(e))
return ExitCode.bad_args
# Re-raise other ValueErrors to be caught by the general exception handler
raise
except PIL.Image.DecompressionBombError:
log.exception(
"A decompression bomb error was encountered while executing the "
@@ -298,23 +320,41 @@ def cli_exception_handler(
def setup_pipeline(
options: argparse.Namespace,
options: OcrOptions,
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()
# Note: OcrOptions is immutable, so we can't modify options.jobs directly
# The jobs field should already be set correctly during OcrOptions creation
# Apply PIL max image pixels side effect only when explicitly requested.
# When None, leave PIL.Image.MAX_IMAGE_PIXELS as the host application
# configured it. The CLI passes its own default (250.0) via argparse.
if options.max_image_mpixels is not None:
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
if PIL.Image.MAX_IMAGE_PIXELS == 0:
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
pikepdf_enable_mmap()
executor = setup_executor(plugin_manager)
return executor
def do_get_pdfinfo(
pdf_path: Path, executor: Executor, options: argparse.Namespace
) -> PdfInfo:
def do_get_pdfinfo(pdf_path: Path, executor: Executor, options) -> PdfInfo:
# Handle pages field - it might be a string that needs conversion.
# A string indicates the ``end`` alias was used and resolution was
# deferred; we resolve it now using the document's actual page count.
check_pages = options.pages
if isinstance(check_pages, str):
from ocrmypdf._options import _pages_from_ranges
with Pdf.open(pdf_path) as pdf:
total_pages = len(pdf.pages)
check_pages = _pages_from_ranges(check_pages, total_pages=total_pages)
options.pages = check_pages
return get_pdfinfo(
pdf_path,
executor=executor,
@@ -322,7 +362,7 @@ def do_get_pdfinfo(
progbar=options.progress_bar,
max_workers=options.jobs,
use_threads=options.use_threads,
check_pages=options.pages,
check_pages=check_pages,
)
@@ -425,7 +465,7 @@ def process_page(page_context: PageContext) -> tuple[Path, Path | None, int]:
visible_image_out = preprocess_out
if should_visible_page_image_use_jpg(page_context.pageinfo):
visible_image_out = create_visible_page_jpg(visible_image_out, page_context)
filtered_image = page_context.plugin_manager.hook.filter_page_image(
filtered_image = page_context.plugin_manager.filter_page_image(
page=page_context, image_filename=visible_image_out
)
if filtered_image is not None: # None if no hook is present
@@ -448,11 +488,22 @@ def postprocess(
pdf_out = fix_annots
else:
pdf_out = pdf_file
if context.options.output_type.startswith('pdfa'):
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
if context.options.output_type == 'auto':
# Best effort PDF/A - may use Ghostscript as a last resort
pdf_out, actual_type = try_auto_pdfa(pdf_out, context)
# Store actual output type for reporting
context.options.extra_attrs['_actual_output_type'] = actual_type
elif context.options.output_type.startswith('pdfa'):
# Required PDF/A - uses Ghostscript as fallback
speculative_result = try_speculative_pdfa(pdf_out, context)
if speculative_result is not None:
pdf_out = speculative_result
else:
# Fall back to Ghostscript conversion
ps_stub_out = generate_postscript_stub(context)
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
optimizing = context.plugin_manager.hook.is_optimization_enabled(context=context)
optimizing = context.plugin_manager.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)
@@ -468,7 +519,22 @@ def report_output_pdf(options, start_input_file, optimize_messages) -> ExitCode:
elif samefile(options.output_file, Path(os.devnull)):
pass # Say nothing when sending to dev null
else:
if options.output_type.startswith('pdfa'):
if options.output_type == 'auto':
# For 'auto' mode, check what we actually produced
actual_type = options.extra_attrs.get('_actual_output_type', 'pdf')
pdfa_info = file_claims_pdfa(options.output_file)
if actual_type == 'pdfa' and pdfa_info['pass']:
log.info(
"Output file is a %s (auto mode achieved PDF/A)",
pdfa_info['conformance'],
)
elif pdfa_info['pass']:
# Unexpectedly got PDF/A
log.info("Output file is a %s", pdfa_info['conformance'])
else:
# Regular PDF - this is expected for auto mode fallback
log.info("Output file is a PDF (auto mode)")
elif 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'])
+16 -14
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import argparse
import logging
import logging.handlers
from collections.abc import Sequence
@@ -17,10 +16,8 @@ import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._pipeline import (
copy_final,
render_hocr_page,
)
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import copy_final
from ocrmypdf._pipelines._common import (
HOCRResult,
do_get_pdfinfo,
@@ -34,6 +31,7 @@ from ocrmypdf._pipelines._common import (
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.exceptions import ExitCode
from ocrmypdf.helpers import available_cpu_count
log = logging.getLogger(__name__)
@@ -45,9 +43,8 @@ def _exec_hocrtransform_sync(page_context: PageContext) -> HOCRResult:
# 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
)
# hOCR path is passed directly to the grafting phase where fpdf2 renders it
hocr_result.textpdf = page_context.get_path('ocr_hocr.hocr')
return hocr_result
@@ -55,7 +52,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
"""Convert hOCR files to OCR PDF."""
# Run exec_page_sync on every page
options = context.options
max_workers = min(len(context.pdfinfo), options.jobs)
jobs = options.jobs or available_cpu_count()
max_workers = min(len(context.pdfinfo), jobs)
if max_workers > 1:
log.info("Continue processing %d pages concurrently", max_workers)
@@ -69,7 +67,8 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.textpdf,
ocr_output=result.textpdf,
ocr_tree=result.ocr_tree,
autorotate_correction=result.orientation_correction,
)
pbar.update()
@@ -99,17 +98,20 @@ def exec_hocr_to_ocr_pdf(context: PdfContext, executor: Executor) -> Sequence[st
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)
# Copy PDF file to destination
copy_final(pdf, options.output_file)
return messages
def run_hocr_to_ocr_pdf_pipeline(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run pipeline to convert hOCR to final output PDF."""
# The _hocr_to_ocr_pdf() API requires work_folder: Path and stores it on
# options before this pipeline runs, so it is always set at this point.
assert options.work_folder is not None
with manage_work_folder(
work_folder=options.work_folder, retain=True, print_location=False
) as work_folder:
@@ -119,7 +121,7 @@ def run_hocr_to_ocr_pdf_pipeline(
# Gather pdfinfo and create context
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
plugin_manager.hook.check_options(options=options)
plugin_manager.check_options(options=options)
optimize_messages = exec_hocr_to_ocr_pdf(context, executor)
return report_output_pdf(options, origin_pdf, optimize_messages)
+33 -21
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import argparse
import logging
import logging.handlers
from collections.abc import Sequence
@@ -19,13 +18,14 @@ import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._graft import OcrGrafter
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import (
copy_final,
is_ocr_required,
merge_sidecars,
ocr_engine_direct,
ocr_engine_hocr,
ocr_engine_textonly_pdf,
render_hocr_page,
triage,
validate_pdfinfo_options,
)
@@ -49,23 +49,32 @@ from ocrmypdf._validation import (
create_input_file,
)
from ocrmypdf.exceptions import ExitCode
from ocrmypdf.helpers import available_cpu_count
from ocrmypdf.models.ocr_element import OcrElement
log = logging.getLogger(__name__)
def _image_to_ocr_text(
page_context: PageContext, ocr_image_out: Path
) -> tuple[Path, Path]:
) -> tuple[Path | None, Path, OcrElement | None]:
"""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':
pdf_renderer = options.pdf_renderer
# fpdf2 is the default renderer (auto resolves to fpdf2)
if pdf_renderer in ('auto', 'fpdf2'):
# Use generate_ocr() if the engine supports it, otherwise use hOCR path
ocr_engine = page_context.plugin_manager.get_ocr_engine(options=options)
if ocr_engine and ocr_engine.supports_generate_ocr():
ocr_tree, text_out = ocr_engine_direct(ocr_image_out, page_context)
return None, text_out, ocr_tree
ocr_out, text_out = ocr_engine_hocr(ocr_image_out, page_context)
elif 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
raise NotImplementedError(f"pdf_renderer {pdf_renderer}")
return ocr_out, text_out, None
def _exec_page_sync(page_context: PageContext) -> PageResult:
@@ -78,22 +87,24 @@ def _exec_page_sync(page_context: PageContext) -> PageResult:
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)
ocr_out, text_out, ocr_tree = _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,
ocr_tree=ocr_tree,
)
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)
jobs = options.jobs or available_cpu_count()
max_workers = min(len(context.pdfinfo), jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
log.info("Starting processing with %d workers concurrently", max_workers)
sidecars: list[Path | None] = [None] * len(context.pdfinfo)
ocrgraft = OcrGrafter(context)
@@ -107,7 +118,8 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
ocrgraft.graft_page(
pageno=result.pageno,
image=result.pdf_page_from_image,
textpdf=result.ocr,
ocr_output=result.ocr,
ocr_tree=result.ocr_tree,
autorotate_correction=result.orientation_correction,
)
pbar.update(0.5)
@@ -119,7 +131,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
max_workers=max_workers,
progress_kwargs=dict(
total=len(context.pdfinfo),
desc='OCR' if options.tesseract_timeout > 0 else 'Image processing',
desc='OCR' if options.ocr_engine != 'none' else 'Image processing',
unit='page',
disable=not options.progress_bar,
),
@@ -133,7 +145,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
if options.sidecar:
text = merge_sidecars(sidecars, context)
# Copy text file to destination
copy_final(text, options.sidecar, options.input_file)
copy_final(text, options.sidecar)
# Merge layers to one single pdf
pdf = ocrgraft.finalize()
@@ -145,12 +157,12 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
pdf, messages = postprocess(pdf, context, executor)
# Copy PDF file to destination
copy_final(pdf, options.output_file, options.input_file)
copy_final(pdf, options.output_file)
return messages
def _run_pipeline(
options: argparse.Namespace,
options: OcrOptions,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
with (
@@ -185,14 +197,14 @@ def _run_pipeline(
def run_pipeline_cli(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run the OCR pipeline with command line exception handling.
Args:
options: The parsed command line options.
options: The parsed OCR options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
"""
@@ -200,14 +212,14 @@ def run_pipeline_cli(
def run_pipeline(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> ExitCode:
"""Run the OCR pipeline without command line exception handling.
Args:
options: The parsed command line options.
options: The parsed OCR options.
plugin_manager: The plugin manager to use. If not provided, one will be
created.
"""
+13 -11
View File
@@ -6,9 +6,9 @@
from __future__ import annotations
import argparse
import logging
import logging.handlers
import os
import shutil
from functools import partial
@@ -16,6 +16,7 @@ import PIL
from ocrmypdf._concurrent import Executor
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._options import OcrOptions
from ocrmypdf._pipeline import (
is_ocr_required,
ocr_engine_hocr,
@@ -31,9 +32,7 @@ from ocrmypdf._pipelines._common import (
worker_init,
)
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._validation import (
set_lossless_reconstruction,
)
from ocrmypdf.helpers import available_cpu_count
log = logging.getLogger(__name__)
@@ -64,9 +63,10 @@ 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)
jobs = options.jobs or available_cpu_count()
max_workers = min(len(context.pdfinfo), jobs)
if max_workers > 1:
log.info("Start processing %d pages concurrently", max_workers)
log.info("Starting processing with %d workers concurrently", max_workers)
executor(
use_threads=options.use_threads,
@@ -85,11 +85,16 @@ def exec_pdf_to_hocr(context: PdfContext, executor: Executor) -> None:
def run_hocr_pipeline(
options: argparse.Namespace,
options: OcrOptions,
*,
plugin_manager: OcrmypdfPluginManager,
) -> None:
"""Run pipeline to output hOCR."""
if options.output_folder is None:
raise ValueError("output_folder must be specified for hOCR pipeline")
# This pipeline is only reachable via the _pdf_to_hocr() API, which
# declares input_pdf: Path - streams and raw bytes paths are not supported.
assert isinstance(options.input_file, str | os.PathLike)
with manage_work_folder(
work_folder=options.output_folder, retain=True, print_location=False
) as work_folder:
@@ -99,10 +104,7 @@ def run_hocr_pipeline(
# Gather pdfinfo and create context
pdfinfo = do_get_pdfinfo(origin_pdf, executor, options)
context = PdfContext(
options, work_folder, options.input_file, pdfinfo, plugin_manager
)
context = PdfContext(options, work_folder, origin_pdf, pdfinfo, plugin_manager)
# Validate options are okay for this pdf
set_lossless_reconstruction(options)
validate_pdfinfo_options(context)
exec_pdf_to_hocr(context, executor)
+221 -51
View File
@@ -1,33 +1,45 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Plugin manager using pluggy."""
"""Plugin manager using pluggy with type-safe interface."""
from __future__ import annotations
import argparse
import importlib
import importlib.util
import pkgutil
import sys
from argparse import ArgumentParser
from collections.abc import Sequence
from logging import Handler
from pathlib import Path
from typing import TYPE_CHECKING
import pluggy
from pydantic import BaseModel
import ocrmypdf.builtin_plugins
from ocrmypdf import pluginspec
from ocrmypdf.cli import get_parser, plugins_only_parser
from ocrmypdf import Executor, PdfContext, pluginspec
from ocrmypdf._options import OcrOptions
from ocrmypdf._plugin_registry import PluginOptionRegistry
from ocrmypdf._progressbar import ProgressBar
from ocrmypdf.helpers import Resolution
from ocrmypdf.pluginspec import OcrEngine
if TYPE_CHECKING:
from PIL import Image
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.pdfinfo import PdfInfo
class OcrmypdfPluginManager(pluggy.PluginManager):
"""pluggy.PluginManager that can fork.
class OcrmypdfPluginManager:
"""Type-safe wrapper around pluggy.PluginManager.
Capable of reconstructing itself in child workers.
Capable of reconstructing itself in child workers via pickle.
Arguments:
setup_func: callback that initializes the plugin manager with all
standard plugins
This class provides type-safe methods for all hooks defined in pluginspec.py,
removing the need for unsafe `hook.method_name()` calls.
"""
def __init__(
@@ -37,58 +49,232 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
builtins: bool = True,
**kwargs,
):
self.__init_args = args
self.__init_kwargs = kwargs
self.__plugins = plugins
self.__builtins = builtins
super().__init__(*args, **kwargs)
self.setup_plugins()
self._init_args = args
self._init_kwargs = kwargs
self._plugins = plugins
self._builtins = builtins
self._pm = pluggy.PluginManager(*args, **kwargs)
self._option_registry: PluginOptionRegistry | None = None
self._setup_plugins()
@property
def pluggy_manager(self) -> pluggy.PluginManager:
"""Access the underlying pluggy.PluginManager for advanced use cases.
This is useful for plugins that need to call methods like set_blocked()
in their initialize hook.
"""
return self._pm
def __getstate__(self):
state = dict(
init_args=self.__init_args,
plugins=self.__plugins,
builtins=self.__builtins,
init_kwargs=self.__init_kwargs,
init_args=self._init_args,
plugins=self._plugins,
builtins=self._builtins,
init_kwargs=self._init_kwargs,
)
return state
def __setstate__(self, state):
self.__init__(
OcrmypdfPluginManager.__init__(
self,
*state['init_args'],
plugins=state['plugins'],
builtins=state['builtins'],
**state['init_kwargs'],
)
def setup_plugins(self):
self.add_hookspecs(pluginspec)
def _setup_plugins(self):
self._pm.add_hookspecs(pluginspec)
# 1. Register builtins
if self.__builtins:
for module in sorted(
if self._builtins:
for module_info in sorted(
pkgutil.iter_modules(ocrmypdf.builtin_plugins.__path__)
):
name = f'ocrmypdf.builtin_plugins.{module.name}'
name = f'ocrmypdf.builtin_plugins.{module_info.name}'
module = importlib.import_module(name)
self.register(module)
self._pm.register(module)
# 2. Register setuptools plugins
self.load_setuptools_entrypoints('ocrmypdf')
self._pm.load_setuptools_entrypoints('ocrmypdf')
# 3. Register plugins specified on command line
for name in self.__plugins:
if isinstance(name, Path) or name.endswith('.py'):
for plugin in self._plugins:
if isinstance(plugin, Path) or plugin.endswith('.py'):
# Import by filename
module_name = Path(name).stem
spec = importlib.util.spec_from_file_location(module_name, name)
plugin_path = Path(plugin)
module_name = plugin_path.stem
spec = importlib.util.spec_from_file_location(module_name, plugin_path)
if spec is None or spec.loader is None:
raise ImportError(f'Could not load plugin from {plugin_path}')
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
else:
# Import by dotted module name
module = importlib.import_module(name)
self.register(module)
module = importlib.import_module(plugin)
self._pm.register(module)
# =========================================================================
# Type-safe hook methods
# =========================================================================
# --- firstresult hooks ---
def get_logging_console(self) -> Handler | None:
"""Returns a custom logging handler for progress bar compatibility."""
return self._pm.hook.get_logging_console()
def get_executor(self, *, progressbar_class: type[ProgressBar]) -> Executor | None:
"""Returns an executor for parallel processing."""
return self._pm.hook.get_executor(progressbar_class=progressbar_class)
def get_progressbar_class(self) -> type[ProgressBar] | None:
"""Returns a progress bar class."""
return self._pm.hook.get_progressbar_class()
def rasterize_pdf_page(
self,
*,
input_file: Path,
output_file: Path,
raster_device: str,
raster_dpi: Resolution,
pageno: int,
page_dpi: Resolution | None,
rotation: int | None,
filter_vector: bool,
stop_on_soft_error: bool,
options: OcrOptions | None,
use_cropbox: bool,
) -> Path | None:
"""Rasterize one page of a PDF at specified resolution."""
return self._pm.hook.rasterize_pdf_page(
input_file=input_file,
output_file=output_file,
raster_device=raster_device,
raster_dpi=raster_dpi,
pageno=pageno,
page_dpi=page_dpi,
rotation=rotation,
filter_vector=filter_vector,
stop_on_soft_error=stop_on_soft_error,
options=options,
use_cropbox=use_cropbox,
)
def filter_ocr_image(
self, *, page: PageContext, image: Image.Image
) -> Image.Image | None:
"""Filter the image before it is sent to OCR."""
return self._pm.hook.filter_ocr_image(page=page, image=image)
def filter_page_image(
self, *, page: PageContext, image_filename: Path
) -> Path | None:
"""Filter the whole page image before it is inserted into the PDF."""
return self._pm.hook.filter_page_image(page=page, image_filename=image_filename)
def filter_pdf_page(
self, *, page: PageContext, image_filename: Path, output_pdf: Path
) -> Path:
"""Convert a filtered whole page image into a PDF."""
result = self._pm.hook.filter_pdf_page(
page=page, image_filename=image_filename, output_pdf=output_pdf
)
if result is None:
raise ValueError('No PDF produced')
if result != output_pdf:
raise ValueError('filter_pdf_page must return output_pdf')
return result
def get_ocr_engine(self, *, options: OcrOptions | None = None) -> OcrEngine:
"""Returns an OcrEngine to use for processing.
Args:
options: OcrOptions to pass to the hook for engine selection.
"""
result = self._pm.hook.get_ocr_engine(options=options)
if result is None:
raise ValueError('No OCR engine selected')
return result
def generate_pdfa(
self,
*,
pdf_pages: list[Path],
pdfmark: Path,
output_file: Path,
context: PdfContext,
pdf_version: str,
pdfa_part: str,
progressbar_class: type[ProgressBar] | None,
stop_on_soft_error: bool,
) -> Path | None:
"""Generate a PDF/A file."""
return self._pm.hook.generate_pdfa(
pdf_pages=pdf_pages,
pdfmark=pdfmark,
output_file=output_file,
context=context,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
stop_on_soft_error=stop_on_soft_error,
)
def optimize_pdf(
self,
*,
input_pdf: Path,
output_pdf: Path,
context: PdfContext,
executor: Executor,
linearize: bool,
) -> tuple[Path, Sequence[str]]:
"""Optimize a PDF after OCR processing."""
result = self._pm.hook.optimize_pdf(
input_pdf=input_pdf,
output_pdf=output_pdf,
context=context,
executor=executor,
linearize=linearize,
)
if result is None:
return input_pdf, []
return result
def is_optimization_enabled(self, *, context: PdfContext) -> bool | None:
"""Returns whether optimization is enabled for given context."""
return self._pm.hook.is_optimization_enabled(context=context)
# --- non-firstresult hooks ---
def initialize(self, *, plugin_manager: pluggy.PluginManager) -> list[None]:
"""Called when plugins are first loaded.
Args:
plugin_manager: The underlying pluggy.PluginManager, allowing
plugins to call methods like set_blocked().
"""
return self._pm.hook.initialize(plugin_manager=plugin_manager)
def add_options(self, *, parser: ArgumentParser) -> list[None]:
"""Allows plugins to add command line and API arguments."""
return self._pm.hook.add_options(parser=parser)
def register_options(self) -> list[dict[str, type[BaseModel]]]:
"""Returns plugin option models keyed by namespace."""
return self._pm.hook.register_options()
def check_options(self, *, options: OcrOptions) -> list[None]:
"""Called to validate options after parsing."""
return self._pm.hook.check_options(options=options)
def validate(self, *, pdfinfo: PdfInfo, options: OcrOptions) -> list[None]:
"""Called to validate options and pdfinfo after PDF is loaded."""
return self._pm.hook.validate(pdfinfo=pdfinfo, options=options)
def get_plugin_manager(
@@ -101,20 +287,4 @@ def get_plugin_manager(
)
def get_parser_options_plugins(
args: Sequence[str],
) -> tuple[argparse.ArgumentParser, argparse.Namespace, pluggy.PluginManager]:
pre_options, _unused = plugins_only_parser.parse_known_args(args=args)
plugin_manager = get_plugin_manager(pre_options.plugins)
parser = get_parser()
plugin_manager.hook.initialize( # pylint: disable=no-member
plugin_manager=plugin_manager
)
plugin_manager.hook.add_options(parser=parser) # pylint: disable=no-member
options = parser.parse_args(args=args)
return parser, options, plugin_manager
__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager', 'get_parser_options_plugins']
__all__ = ['OcrmypdfPluginManager', 'get_plugin_manager']
+50
View File
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Plugin option registry for dynamic model composition."""
from __future__ import annotations
import logging
from pydantic import BaseModel
log = logging.getLogger(__name__)
class PluginOptionRegistry:
"""Registry for plugin option models.
This registry collects option models from plugins during initialization.
Plugin options can be accessed via nested namespaces on OcrOptions
(e.g., options.tesseract.timeout) or via flat field names for backward
compatibility (e.g., options.tesseract_timeout).
"""
def __init__(self) -> None:
self._option_models: dict[str, type[BaseModel]] = {}
def register_option_model(
self, namespace: str, model_class: type[BaseModel]
) -> None:
"""Register a plugin's option model.
Args:
namespace: The namespace for the plugin options (e.g., 'tesseract')
model_class: The Pydantic model class for the plugin options
"""
if namespace in self._option_models:
log.warning(
f"Plugin option namespace '{namespace}' already registered, overriding"
)
self._option_models[namespace] = model_class
log.debug(
f"Registered plugin option model for namespace '{namespace}': "
f"{model_class.__name__}"
)
def get_registered_models(self) -> dict[str, type[BaseModel]]:
"""Get all registered plugin option models."""
return self._option_models.copy()
+14 -6
View File
@@ -48,9 +48,11 @@ class ProgressBar(Protocol):
A brief description of the current step (e.g. "Scanning contents",
"OCR", "PDF/A conversion"). OCRmyPDF updates this before each major step.
unit (str | None):
A short label for the type of work being tracked (e.g. "page", "%", "image").
A short label for the type of work being tracked
(e.g. "page", "%", "image").
disable (bool):
If ``True``, progress updates are suppressed (no output). Defaults to ``False``.
If ``True``, progress updates are suppressed (no output).
Defaults to ``False``.
**kwargs:
Future or extra parameters that OCRmyPDF might pass. Implementations
should accept and ignore unrecognized keywords gracefully.
@@ -64,7 +66,8 @@ class ProgressBar(Protocol):
from ocrmypdf import hookimpl
class ConsoleProgressBar(ProgressBar):
def __init__(self, *, total=None, desc=None, unit=None, disable=False, **kwargs):
def __init__(self, *, total=None, desc=None, unit=None, disable=False,
**kwargs):
self.total = total
self.desc = desc
self.unit = unit
@@ -73,7 +76,9 @@ class ProgressBar(Protocol):
def __enter__(self):
if not self.disable:
print(f"Starting {self.desc or 'an OCR task'} (total={self.total} {self.unit})")
print(f"Starting {self.desc or 'an OCR task'} "
f"(total={self.total} {self.unit})"
)
return self
def __exit__(self, exc_type, exc_value, traceback):
@@ -86,7 +91,7 @@ class ProgressBar(Protocol):
def update(self, n=1, *, completed=None):
if completed is not None:
# If 'completed' is given, you could set self.current = completed
# If 'completed' is given, set self.current
# but let's just read it to show usage
print(f"Absolute completion reported: {completed}")
# Otherwise, we increment by 'n'
@@ -94,7 +99,10 @@ class ProgressBar(Protocol):
if not self.disable:
if self.total:
percent = (self.current / self.total) * 100
print(f"{self.desc}: {self.current}/{self.total} ({percent:.1f}%)")
print(
f"{self.desc}: {self.current}"
f"/{self.total} ({percent:.1f}%)"
)
else:
print(f"{self.desc}: {self.current} units done")
+83
View File
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Protect the real standard output from corruption by stray writes.
When OCRmyPDF writes its final PDF to standard output (``ocrmypdf in.pdf -``),
the bytes on stdout must be exactly the PDF and nothing else. Any accidental
write to file descriptor 1 anywhere in the process -- from a third-party
library, a plugin, or a stray ``print()`` -- would silently corrupt the output.
This module enforces that guarantee at the operating system level. It saves a
private duplicate of the real stdout and points file descriptor 1 at standard
error, so that anything that writes to stdout lands harmlessly on stderr. Only
OCRmyPDF's final "produce the PDF" step writes to the preserved real stdout, via
:func:`get_protected_stdout_fd`.
"""
from __future__ import annotations
import os
import sys
import threading
_lock = threading.Lock()
_saved_fd: int | None = None
_active = False
def protect_stdout() -> bool:
"""Redirect file descriptor 1 to stderr and preserve the real stdout.
After this call, any write to file descriptor 1 -- including ``print()`` and
writes from third-party C libraries -- is redirected to standard error and
cannot corrupt the real standard output. The real stdout is preserved on a
private file descriptor available from :func:`get_protected_stdout_fd`.
This mutates process-global state and affects the whole process. It must be
called once, early, before any plugins are loaded or any worker
process/thread is started, so that all of them inherit the redirected
descriptor.
Returns:
True if protection was installed (or was already active). False if
stdout is not backed by a real OS file descriptor -- for example under
a test harness that captures stdout -- in which case nothing is changed.
"""
global _saved_fd, _active
with _lock:
if _active:
return True
try:
fd1 = sys.stdout.fileno()
except (AttributeError, OSError, ValueError):
# stdout is not backed by a real file descriptor (e.g. captured by
# a test harness or replaced with an in-memory stream).
return False
try:
sys.stdout.flush()
saved = os.dup(fd1)
os.dup2(2, fd1) # point stdout at stderr
except OSError:
return False
_saved_fd = saved
_active = True
return True
def get_protected_stdout_fd() -> int | None:
"""Return the preserved real stdout file descriptor, or None if inactive."""
return _saved_fd if _active else None
def protected_stdout_isatty() -> bool | None:
"""Whether the preserved real stdout is a terminal.
Returns None if protection is not active, in which case the caller should
fall back to ``sys.stdout.isatty()``. When protection is active,
``sys.stdout`` reports the terminal status of stderr (its descriptor was
redirected), so this consults the saved real-stdout descriptor instead.
"""
if not _active or _saved_fd is None:
return None
return os.isatty(_saved_fd)
+116 -140
View File
@@ -6,22 +6,22 @@
from __future__ import annotations
import locale
import logging
import os
import sys
import unicodedata
from argparse import Namespace
from collections.abc import Sequence
from collections.abc import Set as AbstractSet
from pathlib import Path
from shutil import copyfileobj
from typing import BinaryIO, cast
import pikepdf
import PIL
from pluggy import PluginManager
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._exec import unpaper
from ocrmypdf._options import OcrOptions, ProcessingMode
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._stdoutprotect import protected_stdout_isatty
from ocrmypdf.exceptions import (
BadArgsError,
InputFileError,
@@ -30,7 +30,6 @@ from ocrmypdf.exceptions import (
)
from ocrmypdf.helpers import (
is_file_writable,
monotonic,
running_in_docker,
running_in_snap,
safe_symlink,
@@ -51,13 +50,19 @@ def check_platform() -> None:
def check_options_languages(
options: Namespace, ocr_engine_languages: list[str]
options: OcrOptions, ocr_engine_languages: AbstractSet[str]
) -> None:
if not options.languages:
options.languages = [DEFAULT_LANGUAGE]
system_lang = locale.getlocale()[0]
if system_lang and not system_lang.startswith('en'):
log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE)
# Check for blocked languages first, before checking if they're installed
DENIED_LANGUAGES = {'equ', 'osd'}
blocked = DENIED_LANGUAGES & set(options.languages)
if blocked:
raise BadArgsError(
"The following languages are for Tesseract's internal use and "
"should not be issued explicitly: "
f"{', '.join(blocked)}\n"
"Remove them from the -l/--language argument."
)
if not ocr_engine_languages:
return
@@ -81,36 +86,7 @@ def check_options_languages(
raise MissingDependencyError(msg)
def check_options_output(options: Namespace) -> None:
if options.output_type == 'none' and options.output_file not in (os.devnull, '-'):
raise BadArgsError(
"Since you specified `--output-type none`, the output file "
f"{options.output_file} cannot be produced. Set the output file to "
f"`-` to suppress this message."
)
def set_lossless_reconstruction(options: Namespace) -> None:
lossless_reconstruction = False
if not any(
(
options.deskew,
options.clean_final,
options.force_ocr,
options.remove_background,
)
):
lossless_reconstruction = True
options.lossless_reconstruction = lossless_reconstruction
if not options.lossless_reconstruction and options.redo_ocr:
raise BadArgsError(
"--redo-ocr is not currently compatible with --deskew, "
"--clean-final, and --remove-background"
)
def check_options_sidecar(options: Namespace) -> None:
def check_options_sidecar(options: OcrOptions) -> None:
if options.sidecar == '\0':
if options.output_file == '-':
raise BadArgsError("--sidecar filename needed when output file is stdout.")
@@ -118,14 +94,22 @@ def check_options_sidecar(options: Namespace) -> None:
raise BadArgsError(
"--sidecar filename needed when output file is /dev/null or NUL."
)
options.sidecar = options.output_file + '.txt'
elif not isinstance(options.output_file, str | Path):
# The '\0' sentinel is only ever set by the CLI, which always
# supplies output_file as a plain path - not a stream. If this
# somehow fires, the caller mixed a CLI-only sentinel with the
# stream-based API.
raise BadArgsError(
"--sidecar filename needed when output file is not a path."
)
options.sidecar = os.fspath(options.output_file) + '.txt'
if options.sidecar == options.input_file or options.sidecar == options.output_file:
raise BadArgsError(
"--sidecar file must be different from the input and output files"
)
def check_options_preprocessing(options: Namespace) -> None:
def check_options_preprocessing(options: OcrOptions) -> None:
if options.clean_final:
options.clean = True
if options.unpaper_args and not options.clean:
@@ -141,135 +125,107 @@ def check_options_preprocessing(options: Namespace) -> None:
package='unpaper',
version_checker=unpaper.version,
need_version='6.1',
required_for="--clean, --clean-final", # Problem arguments
required_for="--clean, --clean-final",
)
try:
if options.unpaper_args:
options.unpaper_args = unpaper.validate_custom_args(
options.unpaper_args
)
except Exception as e:
raise BadArgsError("--unpaper-args: " + str(e)) from e
def _pages_from_ranges(ranges: str) -> set[int]:
pages: list[int] = []
page_groups = ranges.replace(' ', '').split(',')
for group in page_groups:
if not group:
continue
try:
start, end = group.split('-')
except ValueError:
pages.append(int(group) - 1)
else:
try:
new_pages = list(range(int(start) - 1, int(end)))
if not new_pages:
raise BadArgsError(
f"invalid page subrange '{start}-{end}'"
) from None
pages.extend(new_pages)
except ValueError:
raise BadArgsError(f"invalid page subrange '{group}'") from None
def check_options_strip(options: OcrOptions) -> None:
"""Reject options that cannot apply in strip mode.
if not pages:
``--mode strip`` removes the OCR text layer in place without rasterizing or
running OCR, so image-processing and OCR-output options have no effect.
"""
if options.mode != ProcessingMode.strip_text:
return
incompatible = {
'--deskew': options.deskew,
'--clean': options.clean,
'--clean-final': options.clean_final,
'--remove-background': options.remove_background,
'--rotate-pages': options.rotate_pages,
'--oversample': options.oversample,
'--remove-vectors': options.remove_vectors,
'--sidecar': options.sidecar,
}
used = sorted(name for name, value in incompatible.items() if value)
if used:
raise BadArgsError(
f"The string of page ranges '{ranges}' did not contain any recognizable "
f"page ranges."
"--mode strip removes the OCR text layer without rasterizing or "
"running OCR, so these options have no effect and are not allowed: "
f"{', '.join(used)}"
)
if not monotonic(pages):
log.warning(
"List of pages to process contains duplicate pages, or pages that are "
"out of order"
)
if any(page < 0 for page in pages):
raise BadArgsError("pages refers to a page number less than 1")
log.debug("OCRing only these pages: %s", pages)
return set(pages)
def check_options_ocr_behavior(options: Namespace) -> None:
exclusive_options = sum(
(1 if opt else 0)
for opt in (options.force_ocr, options.skip_text, options.redo_ocr)
)
if exclusive_options >= 2:
raise BadArgsError("Choose only one of --force-ocr, --skip-text, --redo-ocr.")
if options.pages:
options.pages = _pages_from_ranges(options.pages)
def check_options_metadata(options: Namespace) -> None:
docinfo = [options.title, options.author, options.keywords, options.subject]
for s in (m for m in docinfo if m):
for char in s:
if unicodedata.category(char) == 'Co' or ord(char) >= 0x10000:
hexchar = hex(ord(char))[2:].upper()
raise ValueError(
"One of the metadata strings contains "
"an unsupported Unicode character: "
f"{char} (U+{hexchar})"
)
def check_options_pillow(options: Namespace) -> None:
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
if PIL.Image.MAX_IMAGE_PIXELS == 0:
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
def _check_plugin_invariant_options(options: Namespace) -> None:
def _check_plugin_invariant_options(options: OcrOptions) -> None:
check_platform()
check_options_metadata(options)
check_options_output(options)
set_lossless_reconstruction(options)
check_options_strip(options)
check_options_sidecar(options)
check_options_preprocessing(options)
check_options_ocr_behavior(options)
check_options_pillow(options)
def _check_plugin_options(options: Namespace, plugin_manager: PluginManager) -> None:
plugin_manager.hook.check_options(options=options)
ocr_engine_languages = plugin_manager.hook.get_ocr_engine().languages(options)
def _check_plugin_options(
options: OcrOptions, plugin_manager: OcrmypdfPluginManager
) -> None:
# First, let plugins check their external dependencies
plugin_manager.check_options(options=options)
# Then check OCR engine language support
ocr_engine_languages = plugin_manager.get_ocr_engine(options=options).languages(
options
)
check_options_languages(options, ocr_engine_languages)
# Finally, run comprehensive validation using the coordinator
from ocrmypdf._validation_coordinator import ValidationCoordinator
def check_options(options: Namespace, plugin_manager: PluginManager) -> None:
coordinator = ValidationCoordinator(plugin_manager)
coordinator.validate_all_options(options)
def check_options(options: OcrOptions, plugin_manager: OcrmypdfPluginManager) -> None:
"""Check options for validity and consistency.
This function coordinates validation across the entire system:
1. Core validation (platform, files, preprocessing)
2. Plugin external dependency validation
3. Plugin-specific validation (handled by plugin models)
4. Cross-cutting validation (handled by validation coordinator)
"""
_check_plugin_invariant_options(options)
_check_plugin_options(options, plugin_manager)
def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]:
def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str]:
if options.input_file == '-':
# stdin
log.info('reading file from standard input')
target = work_folder / 'stdin'
with open(target, 'wb') as stream_buffer:
with target.open('wb') as stream_buffer:
copyfileobj(sys.stdin.buffer, stream_buffer)
return target, "stdin"
elif hasattr(options.input_file, 'readable'):
if not options.input_file.readable():
input_stream = cast(BinaryIO, options.input_file)
if not input_stream.readable():
raise InputFileError("Input file stream is not readable")
log.info('reading file from input stream')
target = work_folder / 'stream'
with open(target, 'wb') as stream_buffer:
copyfileobj(options.input_file, stream_buffer)
with target.open('wb') as stream_buffer:
copyfileobj(input_stream, stream_buffer)
return target, "stream"
else:
# The branches above already ruled out the stdin sentinel and
# stream-like objects, so this must be a filesystem path.
assert isinstance(options.input_file, str | bytes | os.PathLike)
try:
target = work_folder / 'origin'
safe_symlink(options.input_file, target)
return target, os.fspath(options.input_file)
return target, os.fsdecode(options.input_file)
except FileNotFoundError as e:
msg = f"File not found - {options.input_file}"
msg = f"File not found - {os.fsdecode(options.input_file)}"
if running_in_docker(): # pragma: no cover
msg += (
"\nDocker cannot access your working directory unless you "
"explicitly share it with the Docker container and set up"
"explicitly share it with the Docker container and set up "
"permissions correctly.\n"
"You may find it easier to use stdin/stdout:"
"\n"
@@ -288,9 +244,15 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
raise InputFileError(msg) from e
def check_requested_output_file(options: Namespace) -> None:
def check_requested_output_file(options: OcrOptions) -> None:
if options.output_file == '-':
if sys.stdout.isatty():
# When stdout protection is active, fd 1 has been redirected to stderr,
# so sys.stdout.isatty() would report stderr's status. Consult the
# preserved real stdout instead, falling back when protection is off.
is_tty = protected_stdout_isatty()
if is_tty is None:
is_tty = sys.stdout.isatty()
if is_tty:
raise BadArgsError(
"Output was set to stdout '-' but it looks like stdout "
"is connected to a terminal. Please redirect stdout to a "
@@ -301,12 +263,24 @@ def check_requested_output_file(options: Namespace) -> None:
raise OutputFileAccessError("Output stream is not writable")
elif not is_file_writable(options.output_file):
raise OutputFileAccessError(
f"Output file location ({options.output_file}) is not a writable file."
f"Output file location ({os.fsdecode(options.output_file)}) is not a "
"writable file."
)
if (
options.no_overwrite
and not hasattr(options.output_file, 'writable')
and options.output_file != '-'
and Path(str(options.output_file)).exists()
):
raise OutputFileAccessError(
f"Output file already exists: {os.fsdecode(options.output_file)}\n"
"To overwrite it, omit the --no-overwrite / -n option."
)
def report_output_file_size(
options: Namespace,
options: OcrOptions,
input_file: Path,
output_file: Path,
optimize_messages: Sequence[str] | None = None,
@@ -335,13 +309,15 @@ def report_output_file_size(
'clean_final',
'remove_background',
'oversample',
'force_ocr',
}
for arg in image_preproc:
if getattr(options, arg, False):
reasons.append(
f"--{arg.replace('_', '-')} was issued, causing transcoding."
)
# Check force_ocr via the backward-compatible property
if options.force_ocr:
reasons.append("--force-ocr (or --mode force) was issued, causing transcoding.")
reasons.extend(optimize_messages)
+147
View File
@@ -0,0 +1,147 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Validation coordinator for plugin options and cross-cutting concerns."""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ocrmypdf._options import OcrOptions
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
log = logging.getLogger(__name__)
class ValidationCoordinator:
"""Coordinates validation across plugin models and core options."""
def __init__(self, plugin_manager: OcrmypdfPluginManager):
self.plugin_manager = plugin_manager
def validate_all_options(self, options: OcrOptions) -> None:
"""Run comprehensive validation on all options.
This runs validation in the correct order:
1. Plugin self-validation (already done by Pydantic)
2. Plugin context validation (requires external context)
3. Cross-cutting validation (between plugins and core)
Args:
options: The options to validate
"""
# Step 1: Plugin context validation
self._validate_plugin_contexts(options)
# Step 2: Cross-cutting validation
self._validate_cross_cutting_concerns(options)
def _validate_plugin_contexts(self, options: OcrOptions) -> None:
"""Validate plugin options that require external context."""
# For now, we'll run the plugin validation directly since the models
# are still being integrated. This ensures the validation warnings
# and checks still work as expected.
# Run Tesseract validation
self._validate_tesseract_options(options)
# Run Optimize validation
self._validate_optimize_options(options)
def _validate_tesseract_options(self, options: OcrOptions) -> None:
"""Validate Tesseract options."""
# Check pagesegmode warning
if options.tesseract.pagesegmode in (0, 2):
log.warning(
"The tesseract-pagesegmode you selected will disable OCR. "
"This may cause processing to fail."
)
# Check downsample consistency
if (
options.tesseract.downsample_above != 32767
and not options.tesseract.downsample_large_images
):
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
"--tesseract-downsample-large-images is also given."
)
# Note: blocked languages (equ, osd) are checked earlier in
# check_options_languages() to ensure the check runs before
# the missing language check.
def _validate_optimize_options(self, options: OcrOptions) -> None:
"""Validate optimization options."""
# Check optimization consistency
if options.optimize == 0 and any(
[
options.png_quality and options.png_quality > 0,
options.jpeg_quality and options.jpeg_quality > 0,
]
):
log.warning(
"The arguments --png-quality and --jpeg-quality "
"will be ignored because --optimize=0."
)
def _validate_cross_cutting_concerns(self, options: OcrOptions) -> None:
"""Validate cross-cutting concerns that span multiple plugins."""
from ocrmypdf._options import ProcessingMode
# Handle deprecated pdf_renderer values
self._handle_deprecated_pdf_renderer(options)
# Note: Mutual exclusivity of force_ocr/skip_text/redo_ocr is now enforced
# by the ProcessingMode enum - only one mode can be active at a time.
# Validate redo mode compatibility
if options.mode == ProcessingMode.redo and (
options.deskew or options.clean_final or options.remove_background
):
raise ValueError(
"--redo-ocr (or --mode redo) is not currently compatible with "
"--deskew, --clean-final, and --remove-background"
)
# Validate output type compatibility
output_file_display = (
os.fsdecode(options.output_file)
if isinstance(options.output_file, bytes)
else str(options.output_file)
)
if options.output_type == 'none' and output_file_display not in (
os.devnull,
'-',
):
raise ValueError(
"Since you specified `--output-type none`, the output file "
f"{output_file_display} cannot be produced. Set the output file to "
"`-` to suppress this message."
)
# Validate PDF/A image compression compatibility
if (
options.ghostscript.pdfa_image_compression
and options.ghostscript.pdfa_image_compression != 'auto'
and not options.output_type.startswith('pdfa')
):
log.warning(
"--pdfa-image-compression argument only applies when "
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
)
def _handle_deprecated_pdf_renderer(self, options: OcrOptions) -> None:
"""Handle deprecated pdf_renderer values by redirecting to fpdf2."""
if options.pdf_renderer in ('hocr', 'hocrdebug'):
log.info(
"The '%s' PDF renderer has been removed. Using 'fpdf2' instead, "
"which provides full international language support, proper RTL "
"rendering, and improved text positioning.",
options.pdf_renderer,
)
# Modify the options object to use fpdf2
object.__setattr__(options, 'pdf_renderer', 'fpdf2')
+3
View File
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
__version__ = "17.9.0"
+647 -172
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import logging
import logging.handlers
import multiprocessing
import multiprocessing.queues
import os
import queue
import signal
@@ -15,7 +16,7 @@ import threading
from collections.abc import Callable, Iterable
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from contextlib import suppress
from typing import Union
from typing import TYPE_CHECKING
from rich.console import Console as RichConsole
@@ -25,12 +26,17 @@ from ocrmypdf._progressbar import RichProgressBar
from ocrmypdf.exceptions import InputFileError
from ocrmypdf.helpers import remove_all_log_handlers
FuturesExecutorClass = Union[ # noqa: UP007
type[ThreadPoolExecutor], type[ProcessPoolExecutor]
]
Queue = Union[multiprocessing.Queue, queue.Queue] # noqa: UP007
UserInit = Callable[[], None]
WorkerInit = Callable[[Queue, UserInit, int], None]
if TYPE_CHECKING:
from logging import LogRecord
from typing import TypeAlias
Queue: TypeAlias = (
multiprocessing.queues.Queue[LogRecord | None] | queue.Queue[LogRecord | None]
)
UserInit: TypeAlias = Callable[[], None]
WorkerInit: TypeAlias = Callable[[Queue, UserInit, int], None]
FuturesExecutorClass = type[ThreadPoolExecutor] | type[ProcessPoolExecutor]
def log_listener(q: Queue):
@@ -96,7 +102,9 @@ def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
return
def setup_executor(use_threads: bool) -> tuple[Queue, Executor, WorkerInit]:
def setup_executor(
use_threads: bool,
) -> tuple[Queue, FuturesExecutorClass, WorkerInit]:
if not use_threads:
# Some execution environments like AWS Lambda and Termux do not support
# semaphores. Check if semaphore support is available, and if not, fall back
@@ -109,6 +117,8 @@ def setup_executor(use_threads: bool) -> tuple[Queue, Executor, WorkerInit]:
except ImportError:
use_threads = True
loq_queue: Queue
executor_class: FuturesExecutorClass
if use_threads:
loq_queue = queue.Queue(-1)
executor_class = ThreadPoolExecutor
+368 -54
View File
@@ -5,11 +5,17 @@
from __future__ import annotations
import logging
from enum import StrEnum
from pathlib import Path
from typing import Annotated
from packaging.version import Version
from pikepdf import Name, Pdf, Stream
from pydantic import BaseModel, Field
from ocrmypdf import hookimpl
from ocrmypdf._exec import ghostscript
from ocrmypdf._options import ProcessingMode
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.subprocess import check_external_program
@@ -20,72 +26,213 @@ log = logging.getLogger(__name__)
BLACKLISTED_GS_VERSIONS: frozenset[Version] = frozenset()
class ColorConversionStrategy(StrEnum):
"""Ghostscript color conversion strategies."""
CMYK = 'CMYK'
GRAY = 'Gray'
LEAVE_COLOR_UNCHANGED = 'LeaveColorUnchanged'
RGB = 'RGB'
USE_DEVICE_INDEPENDENT_COLOR = 'UseDeviceIndependentColor'
class PdfaImageCompression(StrEnum):
"""PDF/A image compression methods."""
AUTO = 'auto'
JPEG = 'jpeg'
LOSSLESS = 'lossless'
def _resolve_auto_compression(
compression: PdfaImageCompression, optimize_level: int
) -> PdfaImageCompression:
"""Resolve 'auto' image compression based on the optimization level.
At ``-O0`` (no optimization) ``auto`` maps to ``lossless`` so Ghostscript
will not transcode lossless images to JPEG during PDF/A generation. At all
other levels ``auto`` defers to Ghostscript's heuristic, which may
recompress images lossily.
``-O1`` is a historical exception: although it is otherwise a
lossless-only optimization level, coercing ``auto`` to ``lossless`` there
can bloat output substantially (Ghostscript's heuristic often picks JPEG
for photographic content), so the default is left alone for backwards
compatibility. Users who want guaranteed lossless image handling at any
level can pass ``--pdfa-image-compression=lossless`` explicitly.
Explicit ``jpeg`` and ``lossless`` choices are always respected.
"""
if compression == PdfaImageCompression.AUTO and optimize_level == 0:
return PdfaImageCompression.LOSSLESS
return compression
class GhostscriptOptions(BaseModel):
"""Options specific to Ghostscript operations."""
color_conversion_strategy: Annotated[
ColorConversionStrategy,
Field(description="Ghostscript color conversion strategy"),
] = ColorConversionStrategy.LEAVE_COLOR_UNCHANGED
pdfa_image_compression: Annotated[
PdfaImageCompression, Field(description="PDF/A image compression method")
] = PdfaImageCompression.AUTO
jpeg_quality: Annotated[
int | None,
Field(
ge=0,
le=100,
description=(
"JPEG quality (0-100) for Ghostscript image recompression during "
"PDF/A generation; None uses Ghostscript's default."
),
),
] = None
jpeg_maxdpi: Annotated[
int | None,
Field(
ge=1,
description=(
"Maximum DPI for Ghostscript image downsampling during PDF/A "
"generation."
),
),
] = None
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'ghostscript'):
"""Add Ghostscript-specific arguments to the argument parser.
Args:
parser: The argument parser to add arguments to
namespace: The namespace prefix for argument names (not used for ghostscript
for backward compatibility)
"""
gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript")
gs.add_argument(
'--color-conversion-strategy',
action='store',
type=str,
choices=[ccs.value for ccs in ColorConversionStrategy],
default=ColorConversionStrategy.LEAVE_COLOR_UNCHANGED.value,
help="Set Ghostscript color conversion strategy",
)
gs.add_argument(
'--pdfa-image-compression',
choices=[pc.value for pc in PdfaImageCompression],
default=PdfaImageCompression.AUTO.value,
help="Specify how to compress images in the output PDF/A. 'auto' lets "
"OCRmyPDF decide: at -O0 it uses lossless image compression so "
"Ghostscript does not transcode lossless images to JPEG; at -O1 and "
"above it defers to Ghostscript's heuristic, which may recompress "
"images lossily. 'jpeg' changes all grayscale and color images to "
"JPEG compression. 'lossless' uses PNG-style lossless compression "
"for non-JPEG images and passes existing JPEGs through unchanged "
"(re-encoding them losslessly would only inflate them). Monochrome "
"images are always compressed using a "
"lossless codec. Compression settings "
"are applied to all pages, including those for which OCR was "
"skipped. Not supported for --output-type=pdf ; that setting "
"preserves the original compression of all images.",
)
gs.add_argument(
'--ghostscript-jpeg-quality',
type=int,
metavar='Q',
default=None,
dest=f'{namespace}_jpeg_quality',
help=(
"Advanced: Set Ghostscript's -dJPEGQ for images that Ghostscript "
"transcodes to JPEG during PDF/A generation. 0 is maximum "
"compression; 100 is best quality. If omitted, Ghostscript's "
"default is used. This only affects images Ghostscript chooses "
"to recompress; for general JPEG quality tuning prefer "
"--jpeg-quality, which is applied by the OCRmyPDF optimizer."
),
)
gs.add_argument(
'--ghostscript-jpeg-maxdpi',
type=int,
metavar='DPI',
default=None,
dest=f'{namespace}_jpeg_maxdpi',
help=(
"Advanced: Force Ghostscript to downsample color, grayscale, "
"and monochrome images in PDF/A output to the given maximum DPI. "
"Reducing JPEG quality usually gives better results than "
"downsampling at the same file size, and can degrade quality "
"of high-resolution monochrome masks."
),
)
@hookimpl
def register_options():
"""Register Ghostscript option model."""
return {'ghostscript': GhostscriptOptions}
@hookimpl
def add_options(parser):
gs = parser.add_argument_group("Ghostscript", "Advanced control of Ghostscript")
gs.add_argument(
'--color-conversion-strategy',
action='store',
type=str,
metavar='STRATEGY',
choices=ghostscript.COLOR_CONVERSION_STRATEGIES,
default='LeaveColorUnchanged',
help="Set Ghostscript color conversion strategy",
)
gs.add_argument(
'--pdfa-image-compression',
choices=['auto', 'jpeg', 'lossless'],
default='auto',
help="Specify how to compress images in the output PDF/A. 'auto' lets "
"OCRmyPDF decide. 'jpeg' changes all grayscale and color images to "
"JPEG compression. 'lossless' uses PNG-style lossless compression "
"for all images. Monochrome images are always compressed using a "
"lossless codec. Compression settings "
"are applied to all pages, including those for which OCR was "
"skipped. Not supported for --output-type=pdf ; that setting "
"preserves the original compression of all images.",
)
# Use the model's CLI generation method
GhostscriptOptions.add_arguments_to_parser(parser)
@hookimpl
def check_options(options):
"""Check that the options are valid for this plugin."""
check_external_program(
program='gs',
package='ghostscript',
version_checker=ghostscript.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."
)
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."
# Only require Ghostscript for pdfa* output types (not 'auto' or 'pdf')
# 'auto' mode uses best-effort PDF/A without Ghostscript fallback
if options.output_type.startswith('pdfa'):
check_external_program(
program='gs',
package='ghostscript',
version_checker=ghostscript.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."
)
if Version('10.0.0') <= gs_version < Version('10.02.1') and (
options.mode in (ProcessingMode.skip, ProcessingMode.redo)
):
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 "
"(or --mode skip/redo). Please upgrade to a newer version, or use "
"--output-type pdf to avoid Ghostscript, or use --force-ocr "
"(or --mode force) to discard existing text."
)
if gs_version >= Version('10.6.0'):
log.warning(
"Ghostscript %s contains JPEG encoding errors that may corrupt "
"images. OCRmyPDF will attempt to mitigate, but versions 10.6.0+ "
"are strongly not recommended until this is fixed upstream.",
gs_version,
)
if options.output_type == 'pdfa':
options.output_type = 'pdfa-2'
if options.output_type == 'pdfa':
options.output_type = 'pdfa-2'
if options.color_conversion_strategy not in ghostscript.COLOR_CONVERSION_STRATEGIES:
if (
options.ghostscript.color_conversion_strategy
not in ghostscript.COLOR_CONVERSION_STRATEGIES
):
raise ValueError(
f"Invalid color conversion strategy: {options.color_conversion_strategy}"
f"Invalid color conversion strategy: "
f"{options.ghostscript.color_conversion_strategy}"
)
if options.pdfa_image_compression != 'auto' and not options.output_type.startswith(
'pdfa'
if (
options.ghostscript.pdfa_image_compression != 'auto'
and options.output_type not in ('auto', 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3')
):
log.warning(
"--pdfa-image-compression argument only applies when "
"--output-type is one of 'pdfa', 'pdfa-1', or 'pdfa-2'"
"--output-type is 'auto' or one of 'pdfa', 'pdfa-1', 'pdfa-2', 'pdfa-3'"
)
@@ -100,8 +247,17 @@ def rasterize_pdf_page(
rotation,
filter_vector,
stop_on_soft_error,
options,
use_cropbox,
):
"""Rasterize a single page of a PDF file using Ghostscript."""
# Check if user explicitly requested a different rasterizer
if options is not None and options.rasterizer == 'pypdfium':
# Let pypdfium handle it (it will error in check_options if unavailable)
return None
log.debug("Rasterizing page %d with the Ghostscript rasterizer", pageno)
ghostscript.rasterize_pdf(
input_file,
output_file,
@@ -112,10 +268,149 @@ def rasterize_pdf_page(
rotation=rotation,
filter_vector=filter_vector,
stop_on_error=stop_on_soft_error,
use_cropbox=use_cropbox,
)
return output_file
def _collect_dctdecode_images(pdf: Pdf) -> dict[tuple, list[tuple[Stream, bytes]]]:
"""Collect all DCTDecode (JPEG) images from a PDF.
Returns a dict mapping image signatures to a list of (stream, raw_bytes) tuples.
The signature is (Width, Height, Filter, BitsPerComponent, ColorSpace).
"""
images: dict[tuple, list[tuple[Stream, bytes]]] = {}
def get_colorspace_key(obj):
"""Get a hashable key for the colorspace."""
cs = obj.get(Name.ColorSpace)
if cs is None:
return None
if isinstance(cs, Name):
return str(cs)
# For array colorspaces like [/ICCBased ...], use the first element
try:
return str(cs[0]) if len(cs) > 0 else str(cs)
except (TypeError, KeyError):
return str(cs)
def process_xobject_dict(xobjects, depth=0):
"""Process an XObject dictionary for DCTDecode images."""
if xobjects is None:
return
if depth > 10:
log.warning("Recursion depth exceeded in _collect_dctdecode_images")
return
for key in xobjects.keys():
obj = xobjects[key]
if obj is None:
continue
# Check if it's an image with DCTDecode
if obj.get(Name.Subtype) == Name.Image:
filt = obj.get(Name.Filter)
if filt == Name.DCTDecode:
sig = (
int(obj.get(Name.Width, 0)),
int(obj.get(Name.Height, 0)),
str(filt),
int(obj.get(Name.BitsPerComponent, 0)),
get_colorspace_key(obj),
)
raw_bytes = obj.read_raw_bytes()
if sig not in images:
images[sig] = []
images[sig].append((obj, raw_bytes))
# Recurse into Form XObjects
elif obj.get(Name.Subtype) == Name.Form:
if Name.Resources in obj:
res = obj[Name.Resources]
if Name.XObject in res:
process_xobject_dict(res[Name.XObject], depth=depth + 1)
for page in pdf.pages:
if Name.Resources not in page:
continue
resources = page[Name.Resources]
if Name.XObject not in resources:
continue
process_xobject_dict(resources[Name.XObject])
return images
def _repair_gs106_jpeg_corruption(
input_pdf_path: Path,
output_pdf_path: Path,
) -> bool:
"""Repair JPEG corruption caused by Ghostscript 10.6.
Ghostscript 10.6 has a bug that truncates JPEG data by 1-15 bytes.
This function detects and repairs such corruption by copying the
original JPEG bytes from the input PDF.
Returns True if any repairs were made.
"""
repaired_count = 0
first_error_logged = False
with (
Pdf.open(input_pdf_path) as input_pdf,
Pdf.open(output_pdf_path, allow_overwriting_input=True) as output_pdf,
):
# Collect all DCTDecode images from both PDFs
input_images = _collect_dctdecode_images(input_pdf)
output_images = _collect_dctdecode_images(output_pdf)
# For each output image, try to find a corresponding input image
for sig, output_list in output_images.items():
if sig not in input_images:
continue
input_list = input_images[sig]
for output_stream, output_bytes in output_list:
# Try to find a matching input image
for _input_stream, input_bytes in input_list:
input_len = len(input_bytes)
output_len = len(output_bytes)
# Check if output is 1-15 bytes shorter
diff = input_len - output_len
if not (1 <= diff <= 15):
continue
# Check if the bytes are identical up to the truncation point
if output_bytes != input_bytes[:output_len]:
continue
# This is a corrupt image - repair it
if not first_error_logged:
log.error(
"Ghostscript 10.6 JPEG corruption detected. "
"Repairing damaged images from original PDF."
)
first_error_logged = True
log.warning(
f"Replacing corrupt JPEG image "
f"({sig[0]}x{sig[1]}, {diff} bytes truncated)"
)
# Write the original bytes back to the output stream
output_stream.write(
input_bytes,
filter=Name.DCTDecode,
)
repaired_count += 1
break # Move to next output image
if repaired_count > 0:
output_pdf.save(output_pdf_path)
log.info(
f"Repaired {repaired_count} JPEG image(s) corrupted by Ghostscript"
)
return repaired_count > 0
@hookimpl
def generate_pdfa(
pdf_pages,
@@ -128,14 +423,33 @@ def generate_pdfa(
stop_on_soft_error,
):
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
# Normalize output_type at point of use
output_type = context.options.output_type
if output_type == 'pdfa':
output_type = 'pdfa-2'
compression = _resolve_auto_compression(
context.options.ghostscript.pdfa_image_compression,
context.options.optimize,
)
ghostscript.generate_pdfa(
pdf_pages=[pdfmark, *pdf_pages],
output_file=output_file,
compression=context.options.pdfa_image_compression,
color_conversion_strategy=context.options.color_conversion_strategy,
compression=compression,
color_conversion_strategy=context.options.ghostscript.color_conversion_strategy,
jpeg_quality=context.options.ghostscript.jpeg_quality,
jpeg_maxdpi=context.options.ghostscript.jpeg_maxdpi,
pdf_version=pdf_version,
pdfa_part=pdfa_part,
progressbar_class=progressbar_class,
stop_on_error=stop_on_soft_error,
)
# Repair JPEG corruption caused by Ghostscript 10.6.x
gs_version = ghostscript.version()
if gs_version >= Version('10.6.0') and len(pdf_pages) == 1:
input_pdf = Path(pdf_pages[0])
_repair_gs106_jpeg_corruption(input_pdf, Path(output_file))
return output_file
+159
View File
@@ -0,0 +1,159 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Built-in plugin implementing a null OCR engine (no OCR).
This plugin provides an OCR engine that produces no text output. It is useful
when users want OCRmyPDF's image processing, PDF/A conversion, or optimization
features without performing actual OCR.
Usage:
ocrmypdf --ocr-engine none input.pdf output.pdf
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from PIL import Image
from ocrmypdf import hookimpl
from ocrmypdf.hocrtransform import BoundingBox, OcrClass, OcrElement
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
if TYPE_CHECKING:
from ocrmypdf._options import OcrOptions
class NullOcrEngine(OcrEngine):
"""A no-op OCR engine that produces no text output.
Use this when you want OCRmyPDF's image processing, PDF/A conversion,
or optimization features without performing actual OCR.
"""
@staticmethod
def version() -> str:
"""Return version string."""
return "none"
@staticmethod
def creator_tag(options: OcrOptions) -> str:
"""Return creator tag for PDF metadata."""
return "OCRmyPDF (no OCR)"
def __str__(self) -> str:
"""Return human-readable engine name."""
return "No OCR engine"
@staticmethod
def languages(options: OcrOptions) -> set[str]:
"""Return supported languages (empty set for null engine)."""
return set()
@staticmethod
def get_orientation(input_file: Path, options: OcrOptions) -> OrientationConfidence:
"""Return neutral orientation (no rotation detected)."""
return OrientationConfidence(angle=0, confidence=0.0)
@staticmethod
def get_deskew(input_file: Path, options: OcrOptions) -> float:
"""Return zero deskew angle."""
return 0.0
@staticmethod
def supports_generate_ocr() -> bool:
"""Return True - this engine supports the generate_ocr() API."""
return True
@staticmethod
def generate_ocr(
input_file: Path,
options: OcrOptions,
page_number: int = 0,
) -> tuple[OcrElement, str]:
"""Generate empty OCR results.
Args:
input_file: The image file (used to get dimensions).
options: OCR options (ignored).
page_number: Page number (stored in result).
Returns:
A tuple of (empty OcrElement page, empty string).
"""
# Get image dimensions
with Image.open(input_file) as img:
width, height = img.size
dpi_info = img.info.get('dpi', (72, 72))
dpi = dpi_info[0] if isinstance(dpi_info, tuple) else dpi_info
# Create empty page element with correct dimensions
page = OcrElement(
ocr_class=OcrClass.PAGE,
bbox=BoundingBox(left=0, top=0, right=width, bottom=height),
dpi=float(dpi),
page_number=page_number,
)
return page, ""
@staticmethod
def generate_hocr(
input_file: Path,
output_hocr: Path,
output_text: Path,
options: OcrOptions,
) -> None:
"""Generate empty hOCR file.
Creates minimal valid hOCR output with no text content.
"""
# Get image dimensions for hOCR bbox
with Image.open(input_file) as img:
width, height = img.size
hocr_content = f'''<?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>OCRmyPDF - No OCR</title>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta name='ocr-system' content='OCRmyPDF null engine'/>
</head>
<body>
<div class='ocr_page' title='bbox 0 0 {width} {height}'>
</div>
</body>
</html>
'''
output_hocr.write_text(hocr_content, encoding='utf-8')
output_text.write_text('', encoding='utf-8')
@staticmethod
def generate_pdf(
input_file: Path,
output_pdf: Path,
output_text: Path,
options: OcrOptions,
) -> None:
"""NullOcrEngine cannot generate PDFs directly.
Use pdf_renderer='fpdf2' instead of 'sandwich'.
"""
raise NotImplementedError(
"NullOcrEngine cannot generate PDFs directly. "
"Use --pdf-renderer fpdf2 instead of sandwich mode."
)
@hookimpl
def get_ocr_engine(options):
"""Return NullOcrEngine when --ocr-engine none is selected."""
if options is not None:
ocr_engine = getattr(options, 'ocr_engine', 'auto')
if ocr_engine != 'none':
return None
return NullOcrEngine()
+158 -85
View File
@@ -8,6 +8,9 @@ import argparse
import logging
from collections.abc import Sequence
from pathlib import Path
from typing import Annotated
from pydantic import BaseModel, Field, model_validator
from ocrmypdf import Executor, PdfContext, hookimpl
from ocrmypdf._exec import jbig2enc, pngquant
@@ -19,87 +22,165 @@ from ocrmypdf.subprocess import check_external_program
log = logging.getLogger(__name__)
class OptimizeOptions(BaseModel):
"""Options specific to PDF optimization."""
level: Annotated[
int,
Field(
ge=0,
le=3,
description="Optimization level (0=none, 1=safe, 2=lossy, 3=aggressive)",
),
] = 1
jpeg_quality: Annotated[
int, Field(ge=0, le=100, description="JPEG quality level for optimization")
] = 0
png_quality: Annotated[
int, Field(ge=0, le=100, description="PNG quality level for optimization")
] = 0
jbig2_threshold: Annotated[
float,
Field(ge=0.4, le=0.9, description="JBIG2 symbol classification threshold"),
] = 0.85
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'optimize'):
"""Add optimization-specific arguments to the argument parser.
Args:
parser: The argument parser to add arguments to
namespace: The namespace prefix for argument names
(not used for optimize for backward compatibility)
"""
optimizing = parser.add_argument_group(
"Optimization options", "Control how the PDF is optimized after OCR"
)
optimizing.add_argument(
'-O',
'--optimize',
type=int,
choices=range(0, 4),
default=1,
help=(
"Control how PDF is optimized after processing:"
"0 - do not optimize; "
"1 - do safe, lossless optimizations (default); "
"2 - do lossy JPEG and JPEG2000 optimizations; "
"3 - do more aggressive lossy JPEG and JPEG2000 optimizations. "
"To enable lossy JBIG2, see --jbig2-lossy."
),
)
optimizing.add_argument(
'--jpeg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust JPEG quality level for JPEG optimization. "
"100 is best quality and largest output size; "
"1 is lowest quality and smallest output; "
"0 uses the default."
),
)
optimizing.add_argument(
'--jpg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
dest='jpeg_quality',
help=argparse.SUPPRESS, # Alias for --jpeg-quality
)
optimizing.add_argument(
'--png-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust PNG quality level to use when quantizing PNGs. "
"Values have same meaning as with --jpeg-quality"
),
)
# Deprecated arguments - kept for backward compatibility, emit warnings
optimizing.add_argument(
'--jbig2-lossy',
action='store_true',
help=argparse.SUPPRESS, # Deprecated, hidden from help
)
optimizing.add_argument(
'--jbig2-page-group-size',
type=numeric(int, 1, 10000),
default=0,
metavar='N',
help=argparse.SUPPRESS, # Deprecated, hidden from help
)
optimizing.add_argument(
'--jbig2-threshold',
type=numeric(float, 0.4, 0.9),
default=0.85,
metavar='T',
help=(
"Adjust JBIG2 symbol code classification threshold "
"(default 0.85), range 0.4 to 0.9."
),
)
@model_validator(mode='after')
def validate_optimization_consistency(self):
"""Validate optimization options are consistent."""
if self.level == 0 and any([self.png_quality > 0, self.jpeg_quality > 0]):
log.warning(
"The arguments --png-quality and --jpeg-quality "
"will be ignored because --optimize=0."
)
return self
def validate_with_context(
self, external_programs_available: dict[str, bool]
) -> None:
"""Validate options that require external context.
Args:
external_programs_available: Dict of program name -> availability
"""
if self.level >= 2:
if not external_programs_available.get('pngquant', False):
log.warning(
"pngquant is not available, so PNG optimization will be limited"
)
if not external_programs_available.get('jbig2enc', False):
log.warning(
"jbig2enc is not available, so JBIG2 optimization will be limited"
)
@hookimpl
def register_options():
"""Register optimization option model."""
return {'optimize': OptimizeOptions}
@hookimpl
def add_options(parser):
optimizing = parser.add_argument_group(
"Optimization options", "Control how the PDF is optimized after OCR"
)
optimizing.add_argument(
'-O',
'--optimize',
type=int,
choices=range(0, 4),
default=1,
help=(
"Control how PDF is optimized after processing:"
"0 - do not optimize; "
"1 - do safe, lossless optimizations (default); "
"2 - do lossy JPEG and JPEG2000 optimizations; "
"3 - do more aggressive lossy JPEG and JPEG2000 optimizations. "
"To enable lossy JBIG2, see --jbig2-lossy."
),
)
optimizing.add_argument(
'--jpeg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust JPEG quality level for JPEG optimization. "
"100 is best quality and largest output size; "
"1 is lowest quality and smallest output; "
"0 uses the default."
),
)
optimizing.add_argument(
'--jpg-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
dest='jpeg_quality',
help=argparse.SUPPRESS, # Alias for --jpeg-quality
)
optimizing.add_argument(
'--png-quality',
type=numeric(int, 0, 100),
default=0,
metavar='Q',
help=(
"Adjust PNG quality level to use when quantizing PNGs. "
"Values have same meaning as with --jpeg-quality"
),
)
optimizing.add_argument(
'--jbig2-lossy',
action='store_true',
help=(
"Enable JBIG2 lossy mode (better compression, not suitable for some "
"use cases - see documentation). Only takes effect if --optimize 1 or "
"higher is also enabled."
),
)
optimizing.add_argument(
'--jbig2-page-group-size',
type=numeric(int, 1, 10000),
default=0,
metavar='N',
# Adjust number of pages to consider at once for JBIG2 compression
help=argparse.SUPPRESS,
)
optimizing.add_argument(
'--jbig2-threshold',
type=numeric(float, 0.4, 0.9),
default=0.85,
metavar='T',
help=(
"Adjust JBIG2 symbol code classification threshold "
"(default 0.85), range 0.4 to 0.9."
),
)
# Use the model's CLI generation method
OptimizeOptions.add_arguments_to_parser(parser)
@hookimpl
def check_options(options):
"""Check external dependencies for optimization."""
# Warn about deprecated options
if getattr(options, 'jbig2_lossy', False):
log.warning(
"The --jbig2-lossy option is deprecated and will be ignored. "
"Lossy JBIG2 compression has been removed due to risks of "
"character substitution errors."
)
if getattr(options, 'jbig2_page_group_size', 0) not in (0, None):
log.warning(
"The --jbig2-page-group-size option is deprecated and will be ignored."
)
if options.optimize >= 2:
check_external_program(
program='pngquant',
@@ -117,16 +198,8 @@ def check_options(options):
package='jbig2enc',
version_checker=jbig2enc.version,
need_version='0.28',
required_for='--optimize {2,3} | --jbig2-lossy',
recommended=True if not options.jbig2_lossy else False,
)
if options.optimize == 0 and any(
[options.jbig2_lossy, options.png_quality, options.jpeg_quality]
):
log.warning(
"The arguments --jbig2-lossy, --png-quality, and --jpeg-quality "
"will be ignored because --optimize=0."
required_for='--optimize {2,3}',
recommended=True,
)
+288
View File
@@ -0,0 +1,288 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Built-in plugin to implement PDF page rasterization using pypdfium2."""
from __future__ import annotations
import logging
import threading
from contextlib import closing
from pathlib import Path
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
import pypdfium2 as pdfium
else:
try:
import pypdfium2 as pdfium
except ImportError:
pdfium = None
from PIL import Image
from ocrmypdf import hookimpl
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.helpers import Resolution
log = logging.getLogger(__name__)
# pypdfium2/PDFium is not thread-safe. All calls to the library must be serialized.
# See: https://pypdfium2.readthedocs.io/en/stable/python_api.html#incompatibility-with-threading
# When using process-based parallelism (use_threads=False), each process has its own
# pdfium instance, so locking is not needed across processes.
_pdfium_lock = threading.Lock()
@hookimpl
def check_options(options):
"""Check that pypdfium2 is available if explicitly requested."""
if options.rasterizer == 'pypdfium' and pdfium is None:
raise MissingDependencyError(
"The --rasterizer pypdfium option requires the pypdfium2 package. "
"Install it with: pip install pypdfium2"
)
def _open_pdf_document(input_file: Path):
"""Open a PDF document using pypdfium2."""
assert pdfium is not None, "pypdfium2 must be available to call this function"
return pdfium.PdfDocument(input_file)
def _expand_cropbox_to_mediabox(page) -> None:
"""Set the page's CropBox to its MediaBox so PDFium renders the full page.
PDFium renders to the CropBox by default. Negative ``crop`` values to
``render()`` are not supported and only pad the output canvas without
expanding the rendered area content outside the CropBox is clipped.
The supported approach is to widen the CropBox in memory before rendering.
The document is never saved back to disk, so this mutation is local.
See https://github.com/ocrmypdf/OCRmyPDF/issues/1685.
"""
mediabox = page.get_mediabox() # (left, bottom, right, top)
page.set_cropbox(*mediabox)
def _render_page_to_bitmap(
page: pdfium.PdfPage,
raster_device: str,
raster_dpi: Resolution,
rotation: int | None,
use_cropbox: bool,
) -> tuple[pdfium.PdfBitmap, int, int]:
"""Render a PDF page to a bitmap."""
# Round DPI to match Ghostscript's precision
raster_dpi = raster_dpi.round(6)
# Get page dimensions BEFORE applying rotation
page_width_pts, page_height_pts = page.get_size()
# Calculate expected output dimensions using separate x/y DPI
expected_width = int(round(page_width_pts * raster_dpi.x / 72.0))
expected_height = int(round(page_height_pts * raster_dpi.y / 72.0))
# Calculate the scale factor based on DPI
# pypdfium2 uses points (72 DPI) as base unit
scale = raster_dpi.to_scalar() / 72.0
# Apply rotation if specified
if rotation:
# pypdfium2 rotation is in degrees, same as our input
# we track rotation in CCW, and pypdfium2 expects CW, so negate
page.set_rotation(-rotation % 360)
# When rotation is 90 or 270, dimensions are swapped in output
if rotation % 180 == 90:
expected_width, expected_height = expected_height, expected_width
# Render the page to a bitmap
# The scale parameter controls the resolution
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
grayscale = raster_device.lower() in (
'pngmono',
'pngmonod',
'pnggray',
'jpeggray',
)
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
if not use_cropbox:
_expand_cropbox_to_mediabox(page)
bitmap = page.render(
scale=scale,
rotation=0, # We already set rotation on the page
may_draw_forms=True,
draw_annots=True,
grayscale=grayscale,
# Note: pypdfium2 doesn't have a direct equivalent to filter_vector
# This would require more complex implementation if needed
)
return bitmap, expected_width, expected_height
def _process_image_for_output(
pil_image: Image.Image,
raster_device: str,
raster_dpi: Resolution,
page_dpi: Resolution | None,
stop_on_soft_error: bool,
expected_width: int | None = None,
expected_height: int | None = None,
) -> tuple[Image.Image, Literal['PNG', 'TIFF', 'JPEG']]:
"""Process PIL image for output format and set DPI metadata."""
# Correct dimensions if slightly off (within 2 pixels tolerance)
if expected_width and expected_height:
actual_width, actual_height = pil_image.width, pil_image.height
width_diff = abs(actual_width - expected_width)
height_diff = abs(actual_height - expected_height)
# Only resize if off by small amount (1-2 pixels)
if (width_diff <= 2 or height_diff <= 2) and (
width_diff > 0 or height_diff > 0
):
log.debug(
f"Adjusting rendered dimensions from "
f"{actual_width}x{actual_height} to expected "
f"{expected_width}x{expected_height}"
)
pil_image = pil_image.resize(
(expected_width, expected_height), Image.Resampling.LANCZOS
)
# Set the DPI metadata if page_dpi is specified
if page_dpi:
# PIL expects DPI as a tuple
dpi_tuple = (float(page_dpi.x), float(page_dpi.y))
pil_image.info['dpi'] = dpi_tuple
else:
# Use the raster DPI
dpi_tuple = (float(raster_dpi.x), float(raster_dpi.y))
pil_image.info['dpi'] = dpi_tuple
# Convert image mode to match raster_device
# This ensures pypdfium output matches Ghostscript's native device output
raster_device_lower = raster_device.lower()
if raster_device_lower in ('pngmono', 'pngmonod'):
# Convert to 1-bit black and white (matches Ghostscript pngmono/pngmonod)
if pil_image.mode != '1':
if pil_image.mode not in ('L', '1'):
pil_image = pil_image.convert('L')
pil_image = pil_image.convert('1')
elif raster_device_lower in ('pnggray', 'jpeggray'):
# Convert to 8-bit grayscale
if pil_image.mode not in ('L', '1'):
pil_image = pil_image.convert('L')
elif raster_device_lower == 'png256':
# Convert to 8-bit indexed color (256 colors)
if pil_image.mode != 'P':
if pil_image.mode not in ('RGB', 'RGBA'):
pil_image = pil_image.convert('RGB')
pil_image = pil_image.quantize(colors=256)
elif raster_device_lower in ('png16m', 'jpeg'):
# Convert to RGB
if pil_image.mode == 'RGBA':
background = Image.new('RGB', pil_image.size, (255, 255, 255))
background.paste(pil_image, mask=pil_image.split()[-1])
pil_image = background
elif pil_image.mode not in ('RGB',):
pil_image = pil_image.convert('RGB')
# pngalpha: keep RGBA as-is
# Determine output format based on raster_device
png_devices = (
'png',
'pngmono',
'pngmonod',
'pnggray',
'png256',
'png16m',
'pngalpha',
)
format_name: Literal['PNG', 'TIFF', 'JPEG']
if raster_device_lower in png_devices:
format_name = 'PNG'
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
format_name = 'JPEG'
elif raster_device_lower in ('tiff', 'tif'):
format_name = 'TIFF'
else:
# Default to PNG for unknown formats
format_name = 'PNG'
if stop_on_soft_error:
raise ValueError(f"Unsupported raster device: {raster_device}")
else:
log.warning(f"Unsupported raster device {raster_device}, using PNG")
return pil_image, format_name
def _save_image(pil_image: Image.Image, output_file: Path, format_name: str) -> None:
"""Save PIL image to file with appropriate DPI metadata."""
save_kwargs = {}
if (
format_name in ('PNG', 'TIFF')
and 'dpi' in pil_image.info
or format_name == 'JPEG'
and 'dpi' in pil_image.info
):
save_kwargs['dpi'] = pil_image.info['dpi']
pil_image.save(output_file, format=format_name, **save_kwargs)
@hookimpl
def rasterize_pdf_page(
input_file: Path,
output_file: Path,
raster_device: str,
raster_dpi: Resolution,
pageno: int,
page_dpi: Resolution | None,
rotation: int | None,
filter_vector: bool,
stop_on_soft_error: bool,
options,
use_cropbox: bool,
) -> Path | None:
"""Rasterize a single page of a PDF file using pypdfium2.
Returns None if pypdfium2 is not available or if the user has selected
a different rasterizer, allowing Ghostscript to be used.
"""
# Check if user explicitly requested a different rasterizer
if options is not None and options.rasterizer == 'ghostscript':
return None # Let Ghostscript handle it
if pdfium is None:
return None # Fall back to Ghostscript
log.debug("Rasterizing page %d with the pypdfium2 rasterizer", pageno)
# Acquire lock to ensure thread-safe access to pypdfium2
with (
_pdfium_lock,
closing(_open_pdf_document(input_file)) as pdf,
closing(pdf[pageno - 1]) as page,
):
# Render the page to a bitmap
bitmap, expected_width, expected_height = _render_page_to_bitmap(
page, raster_device, raster_dpi, rotation, use_cropbox
)
with closing(bitmap):
# Convert to PIL Image
pil_image = bitmap.to_pil()
# Process and save image outside the lock (PIL operations are thread-safe)
pil_image, format_name = _process_image_for_output(
pil_image,
raster_device,
raster_dpi,
page_dpi,
stop_on_soft_error,
expected_width,
expected_height,
)
_save_image(pil_image, output_file, format_name)
return output_file
+341 -161
View File
@@ -7,15 +7,18 @@ from __future__ import annotations
import argparse
import logging
import os
from typing import Annotated
from PIL import Image
from pydantic import BaseModel, Field, field_validator, model_validator
from ocrmypdf import hookimpl
from ocrmypdf._exec import tesseract
from ocrmypdf._exec.tesseract import ThresholdingMethod
from ocrmypdf._jobcontext import PageContext
from ocrmypdf.cli import numeric, str_to_int
from ocrmypdf.cli import numeric
from ocrmypdf.exceptions import BadArgsError, MissingDependencyError
from ocrmypdf.helpers import clamp
from ocrmypdf.helpers import available_cpu_count, clamp
from ocrmypdf.imageops import calculate_downsample, downsample_image
from ocrmypdf.pluginspec import OcrEngine
from ocrmypdf.subprocess import check_external_program
@@ -23,121 +26,286 @@ from ocrmypdf.subprocess import check_external_program
log = logging.getLogger(__name__)
def _thresholding_method_converter(value: str) -> ThresholdingMethod:
"""Convert string argument to ThresholdingMethod enum.
Args:
value: String name of thresholding method (auto, otsu, adaptive-otsu, sauvola)
Returns:
ThresholdingMethod enum value
Raises:
argparse.ArgumentTypeError: If value is not a valid thresholding method
"""
method_map = {
'auto': ThresholdingMethod.AUTO,
'otsu': ThresholdingMethod.OTSU,
'adaptive-otsu': ThresholdingMethod.ADAPTIVE_OTSU,
'sauvola': ThresholdingMethod.SAUVOLA,
}
if value.lower() not in method_map:
import argparse
valid = ', '.join(method_map.keys())
raise argparse.ArgumentTypeError(
f"Invalid thresholding method '{value}'. Must be one of: {valid}"
)
return method_map[value.lower()]
class TesseractOptions(BaseModel):
"""Options specific to Tesseract OCR engine."""
config: Annotated[
list[str], Field(description="Additional Tesseract configuration files")
] = []
pagesegmode: Annotated[
int | None,
Field(ge=0, le=13, description="Set Tesseract page segmentation mode"),
] = None
oem: Annotated[
int | None, Field(ge=0, le=3, description="Set Tesseract OCR engine mode")
] = None
thresholding: Annotated[
ThresholdingMethod,
Field(description="Set Tesseract input image thresholding mode"),
] = ThresholdingMethod.AUTO
timeout: Annotated[
float, Field(ge=0, description="Timeout for OCR operations in seconds")
] = 180.0
non_ocr_timeout: Annotated[
float, Field(ge=0, description="Timeout for non-OCR operations in seconds")
] = 180.0
downsample_large_images: Annotated[
bool, Field(description="Downsample large images before OCR")
] = True
downsample_above: Annotated[
int,
Field(
ge=100,
le=32767,
description="Downsample images larger than this pixel size",
),
] = 32767
user_words: Annotated[
str | None, Field(description="Path to Tesseract user words file")
] = None
user_patterns: Annotated[
str | None, Field(description="Path to Tesseract user patterns file")
] = None
omp_thread_limit: Annotated[
int | None,
Field(
description="Calculated OMP_THREAD_LIMIT for Tesseract subprocesses",
exclude=True,
),
] = None
@classmethod
def add_arguments_to_parser(cls, parser, namespace: str = 'tesseract'):
"""Add Tesseract-specific arguments to the argument parser.
Args:
parser: The argument parser to add arguments to
namespace: The namespace prefix for argument names
"""
tess = parser.add_argument_group(
"Tesseract", "Advanced control of Tesseract OCR"
)
tess.add_argument(
f'--{namespace}-config',
action='append',
metavar='CFG',
default=[],
dest=f'{namespace}_config',
help="Additional Tesseract configuration files -- see documentation.",
)
tess.add_argument(
f'--{namespace}-pagesegmode',
action='store',
type=int,
metavar='PSM',
choices=range(0, 14),
dest=f'{namespace}_pagesegmode',
help="Set Tesseract page segmentation mode (see tesseract --help-extra).",
)
tess.add_argument(
f'--{namespace}-oem',
action='store',
type=int,
metavar='MODE',
choices=range(0, 4),
dest=f'{namespace}_oem',
help=(
"Set Tesseract 4+ OCR engine mode: "
"0 - original Tesseract only; "
"1 - neural nets LSTM only; "
"2 - Tesseract + LSTM; "
"3 - default."
),
)
tess.add_argument(
f'--{namespace}-thresholding',
action='store',
type=_thresholding_method_converter,
default='auto',
dest=f'{namespace}_thresholding',
help=(
"Set Tesseract 5.0+ input image thresholding mode. This may improve "
"OCR results on low quality images or those that contain high "
"contrast color. Options: auto, otsu, adaptive-otsu, sauvola. "
"auto/otsu is the Tesseract default (legacy Otsu); adaptive-otsu "
"is an improved Otsu algorithm with improved sort for background "
"color changes; sauvola is based on local standard deviation."
),
)
tess.add_argument(
f'--{namespace}-timeout',
default=180.0,
type=numeric(float, 0.0),
metavar='SECONDS',
dest=f'{namespace}_timeout',
help=(
"Give up on OCR after the timeout, but copy the preprocessed page "
"into the final output. This timeout is only used when using Tesseract "
"for OCR. When Tesseract is used for other operations such as "
"deskewing and orientation, the timeout is controlled by "
f"--{namespace}-non-ocr-timeout."
),
)
tess.add_argument(
f'--{namespace}-non-ocr-timeout',
default=180.0,
type=numeric(float, 0.0),
metavar='SECONDS',
dest=f'{namespace}_non_ocr_timeout',
help=(
"Give up on non-OCR operations such as deskewing and orientation "
f"after timeout. This is a separate timeout from --{namespace}-timeout "
"because these operations are not as expensive as OCR."
),
)
tess.add_argument(
f'--{namespace}-downsample-large-images',
action=argparse.BooleanOptionalAction,
default=True,
dest=f'{namespace}_downsample_large_images',
help=(
"Downsample large images before OCR. Tesseract has "
"an upper limit on the size images it will support."
" If this argument is given, OCRmyPDF will "
"downsample large images to fit Tesseract. This "
"may reduce OCR quality, on large images the most"
" desirable text is usually larger. If this "
"parameter is not supplied, Tesseract will error "
"out and produce no OCR on the page in question. "
"This argument should be used with a high value "
f"of --{namespace}-timeout to ensure Tesseract "
"has enough to time."
),
)
tess.add_argument(
f'--{namespace}-downsample-above',
action='store',
type=numeric(int, 100, 32767),
default=32767,
dest=f'{namespace}_downsample_above',
help=(
"Downsample images larger than this size pixel size (either dimension) "
f"before OCR. --{namespace}-downsample-large-images downsamples when "
"an image exceeds Tesseract's internal limits. This argument causes "
"downsampling to occur when an image exceeds the given size. This may "
"reduce OCR quality, but on large images the most desirable text is "
"usually larger."
),
)
tess.add_argument(
'--user-words',
metavar='FILE',
dest='user_words',
help="Specify the location of the Tesseract user words file. This is a "
"list of words Tesseract should consider while performing OCR in "
"addition to its standard language dictionaries. This can improve "
"OCR quality especially for specialized and technical documents.",
)
tess.add_argument(
'--user-patterns',
metavar='FILE',
dest='user_patterns',
help="Specify the location of the Tesseract user patterns file.",
)
@field_validator('timeout', 'non_ocr_timeout')
@classmethod
def validate_timeout_reasonable(cls, v):
"""Validate timeout values are reasonable."""
if v > 3600: # 1 hour
log.warning(f"Timeout of {v} seconds is very long and may cause issues")
return v
@field_validator('pagesegmode')
@classmethod
def validate_pagesegmode_warning(cls, v):
"""Validate page segmentation mode and warn about problematic values."""
if v in (0, 2):
log.warning(
"The tesseract-pagesegmode you selected will disable OCR. "
"This may cause processing to fail."
)
return v
@model_validator(mode='after')
def validate_downsample_consistency(self):
"""Validate downsample options are consistent."""
if self.downsample_above != 32767 and not self.downsample_large_images:
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
"--tesseract-downsample-large-images is also given."
)
return self
def validate_with_context(self, languages: list[str]) -> None:
"""Validate options that require external context.
Args:
languages: List of languages being used for OCR
"""
# Validate languages are not internal Tesseract languages
DENIED_LANGUAGES = {'equ', 'osd'}
if DENIED_LANGUAGES & set(languages):
raise BadArgsError(
"The following languages are for Tesseract's internal use "
"and should not be issued explicitly: "
f"{', '.join(DENIED_LANGUAGES & set(languages))}\n"
"Remove them from the -l/--language argument."
)
@hookimpl
def register_options():
"""Register Tesseract option model."""
return {'tesseract': TesseractOptions}
@hookimpl
def add_options(parser):
tess = parser.add_argument_group("Tesseract", "Advanced control of Tesseract OCR")
tess.add_argument(
'--tesseract-config',
action='append',
metavar='CFG',
default=[],
help="Additional Tesseract configuration files -- see documentation.",
)
tess.add_argument(
'--tesseract-pagesegmode',
action='store',
type=int,
metavar='PSM',
choices=range(0, 14),
help="Set Tesseract page segmentation mode (see tesseract --help).",
)
tess.add_argument(
'--tesseract-oem',
action='store',
type=int,
metavar='MODE',
choices=range(0, 4),
help=(
"Set Tesseract 4+ OCR engine mode: "
"0 - original Tesseract only; "
"1 - neural nets LSTM only; "
"2 - Tesseract + LSTM; "
"3 - default."
),
)
tess.add_argument(
'--tesseract-thresholding',
action='store',
type=str_to_int(tesseract.TESSERACT_THRESHOLDING_METHODS),
default='auto',
metavar='METHOD',
help=(
"Set Tesseract 5.0+ input image thresholding mode. This may improve OCR "
"results on low quality images or those that contain high contrast color. "
"legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu "
"algorithm with improved sort for background color changes; sauvola is "
"based on local standard deviation."
),
)
tess.add_argument(
'--tesseract-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
help=(
"Give up on OCR after the timeout, but copy the preprocessed page "
"into the final output. This timeout is only used when using Tesseract "
"for OCR. When Tesseract is used for other operations such as "
"deskewing and orientation, the timeout is controlled by "
"--tesseract-non-ocr-timeout."
),
)
tess.add_argument(
'--tesseract-non-ocr-timeout',
default=180.0,
type=numeric(float, 0),
metavar='SECONDS',
help=(
"Give up on non-OCR operations such as deskewing and orientation "
"after timeout. This is a separate timeout from --tesseract-timeout "
"because these operations are not as expensive as OCR."
),
)
tess.add_argument(
'--tesseract-downsample-large-images',
action=argparse.BooleanOptionalAction,
default=True,
help=(
"Downsample large images before OCR. Tesseract has an upper limit on the "
"size images it will support. If this argument is given, OCRmyPDF will "
"downsample large images to fit Tesseract. This may reduce OCR quality, "
"on large images the most desirable text is usually larger. If this "
"parameter is not supplied, Tesseract will error out and produce no OCR "
"on the page in question. This argument should be used with a high value "
"of --tesseract-timeout to ensure Tesseract has enough to time."
),
)
tess.add_argument(
'--tesseract-downsample-above',
action='store',
type=numeric(int, 100, 32767),
default=32767,
help=(
"Downsample images larger than this size pixel size in either dimension "
"before OCR. --tesseract-downsample-large-images downsamples only when "
"an image exceeds Tesseract's internal limits. This argument causes "
"downsampling to occur when an image exceeds the given size. This may "
"reduce OCR quality, but on large images the most desirable text is "
"usually larger."
),
)
tess.add_argument(
'--user-words',
metavar='FILE',
help="Specify the location of the Tesseract user words file. This is a "
"list of words Tesseract should consider while performing OCR in "
"addition to its standard language dictionaries. This can improve "
"OCR quality especially for specialized and technical documents.",
)
tess.add_argument(
'--user-patterns',
metavar='FILE',
help="Specify the location of the Tesseract user patterns file.",
)
# Use the model's CLI generation method - it now handles all Tesseract options
TesseractOptions.add_arguments_to_parser(parser)
@hookimpl
def check_options(options):
"""Check external dependencies and version compatibility for Tesseract."""
check_external_program(
program='tesseract',
package={'linux': 'tesseract-ocr'},
@@ -152,33 +320,16 @@ def check_options(options):
"Please upgrade to a newer or supported older version."
)
# Decide on what renderer to use
if options.pdf_renderer == 'auto':
if {'ara', 'heb', 'fas', 'per'} & set(options.languages):
log.info("Using sandwich renderer since there is an RTL language")
options.pdf_renderer = 'sandwich'
else:
options.pdf_renderer = 'hocr'
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
# Check version-specific feature compatibility
if (
not tesseract.has_thresholding()
and options.tesseract.thresholding != ThresholdingMethod.AUTO
):
log.warning(
"The installed version of Tesseract does not support changes to its "
"thresholding method. The --tesseract-threshold argument will be "
"ignored."
)
if options.tesseract_pagesegmode in (0, 2):
log.warning(
"The --tesseract-pagesegmode argument you select will disable OCR. "
"This may cause processing to fail."
)
DENIED_LANGUAGES = {'equ', 'osd'}
if DENIED_LANGUAGES & set(options.languages):
raise BadArgsError(
"The following languages for Tesseract's internal use and should not "
"be issued explicitly: "
f"{', '.join(DENIED_LANGUAGES & set(options.languages))}\n"
"Remove them from the -l/--language argument."
)
@hookimpl
@@ -192,15 +343,17 @@ def validate(pdfinfo, options):
# constraint: (ocrmypdf workers) * (tesseract threads) <= max_workers.
# As of Tesseract 4.1, 3 threads is the most effective on a 4 core/8 thread system.
if not os.environ.get('OMP_THREAD_LIMIT', '').isnumeric():
tess_threads = clamp(options.jobs // len(pdfinfo), 1, 3)
os.environ['OMP_THREAD_LIMIT'] = str(tess_threads)
jobs = options.jobs or available_cpu_count()
tess_threads = clamp(jobs // len(pdfinfo), 1, 3)
else:
tess_threads = int(os.environ['OMP_THREAD_LIMIT'])
# Store the thread limit in options - it will be passed to subprocess env
options.tesseract.omp_thread_limit = tess_threads
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
if (
options.tesseract_downsample_above != 32767
and not options.tesseract_downsample_large_images
options.tesseract.downsample_above != 32767
and not options.tesseract.downsample_large_images
):
log.warning(
"The --tesseract-downsample-above argument will have no effect unless "
@@ -216,10 +369,12 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
or more than 2**31 bytes. This function resizes the image to fit within
those limits.
"""
threshold = min(page.options.tesseract_downsample_above, 32767)
options = page.options
if options.tesseract_downsample_large_images:
if getattr(options, 'tesseract', None) is None:
return image
threshold = min(options.tesseract.downsample_above, 32767)
if options.tesseract.downsample_large_images:
size = calculate_downsample(
image, max_size=(threshold, threshold), max_bytes=(2**31) - 1
)
@@ -234,10 +389,25 @@ class TesseractOcrEngine(OcrEngine):
def version():
return str(tesseract.version())
@staticmethod
def _determine_renderer(options):
"""Determine the PDF renderer to use based on options and languages."""
if options.pdf_renderer == 'auto':
return 'fpdf2'
return options.pdf_renderer
@staticmethod
def creator_tag(options):
tag = '-PDF' if options.pdf_renderer == 'sandwich' else '-hOCR'
return f"Tesseract OCR{tag} {TesseractOcrEngine.version()}"
renderer = TesseractOcrEngine._determine_renderer(options)
match renderer:
case 'hocr':
return f"OCRmyPDF hOCR + Tesseract OCR {TesseractOcrEngine.version()}"
case 'fpdf2':
return f"OCRmyPDF fpdf2 + Tesseract OCR {TesseractOcrEngine.version()}"
case "sandwich":
return f"Tesseract OCR + PDF {TesseractOcrEngine.version()}"
case _:
return f"Tesseract OCR {TesseractOcrEngine.version()}"
def __str__(self):
return f"Tesseract OCR {TesseractOcrEngine.version()}"
@@ -250,8 +420,9 @@ class TesseractOcrEngine(OcrEngine):
def get_orientation(input_file, options):
return tesseract.get_orientation(
input_file,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_non_ocr_timeout,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -259,8 +430,9 @@ class TesseractOcrEngine(OcrEngine):
return tesseract.get_deskew(
input_file,
languages=options.languages,
engine_mode=options.tesseract_oem,
timeout=options.tesseract_non_ocr_timeout,
engine_mode=options.tesseract.oem,
timeout=options.tesseract.non_ocr_timeout,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -270,13 +442,14 @@ class TesseractOcrEngine(OcrEngine):
output_hocr=output_hocr,
output_text=output_text,
languages=options.languages,
engine_mode=options.tesseract_oem,
tessconfig=options.tesseract_config,
timeout=options.tesseract_timeout,
pagesegmode=options.tesseract_pagesegmode,
thresholding=options.tesseract_thresholding,
user_words=options.user_words,
user_patterns=options.user_patterns,
engine_mode=options.tesseract.oem,
tessconfig=options.tesseract.config,
timeout=options.tesseract.timeout,
pagesegmode=options.tesseract.pagesegmode,
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@staticmethod
@@ -286,16 +459,23 @@ class TesseractOcrEngine(OcrEngine):
output_pdf=output_pdf,
output_text=output_text,
languages=options.languages,
engine_mode=options.tesseract_oem,
tessconfig=options.tesseract_config,
timeout=options.tesseract_timeout,
pagesegmode=options.tesseract_pagesegmode,
thresholding=options.tesseract_thresholding,
user_words=options.user_words,
user_patterns=options.user_patterns,
engine_mode=options.tesseract.oem,
tessconfig=options.tesseract.config,
timeout=options.tesseract.timeout,
pagesegmode=options.tesseract.pagesegmode,
thresholding=options.tesseract.thresholding,
user_words=options.tesseract.user_words,
user_patterns=options.tesseract.user_patterns,
omp_thread_limit=options.tesseract.omp_thread_limit,
)
@hookimpl
def get_ocr_engine():
def get_ocr_engine(options):
"""Return TesseractOcrEngine when selected or as default."""
if options is not None:
ocr_engine = getattr(options, 'ocr_engine', 'auto')
# Tesseract is selected if explicitly requested or if 'auto'
if ocr_engine not in ('auto', 'tesseract'):
return None
return TesseractOcrEngine()
+162 -57
View File
@@ -6,11 +6,14 @@
from __future__ import annotations
import argparse
from argparse import ArgumentParser
from collections.abc import Callable, Mapping
from typing import Any, TypeVar
from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._defaults import PROGRAM_NAME as _PROGRAM_NAME
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._version import __version__ as _VERSION
T = TypeVar('T', int, float)
@@ -51,39 +54,6 @@ def str_to_int(mapping: Mapping[str, int]):
return _str_to_int
class ArgumentParser(argparse.ArgumentParser):
"""Override parser's default behavior of calling sys.exit().
https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code
OCRmyPDF began as a CLI but eventually acquired an API. The API works inside out,
by synthesizing a command line argument. So we subclass the standard parser with
one that doesn't call sys.exit(). Obviously this is not the ideal way to do things
but it works for us.
"""
def __init__(self, *args, **kwargs):
"""Initialize the parser."""
super().__init__(*args, **kwargs)
self._api_mode = False
def enable_api_mode(self):
"""Enable API mode.
When set, the parser will not call sys.exit() on error. OCRmyPDF was originally
a command line program, but now it has an API. The API works by synthesizing
command line arguments.
"""
self._api_mode = True
def error(self, message):
"""Override the default argparse error behavior."""
if not self._api_mode:
super().error(message)
return
raise ValueError(message)
class LanguageSetAction(argparse.Action):
"""Manages a list of languages."""
@@ -96,7 +66,7 @@ class LanguageSetAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
"""Add a language to the set."""
dest = getattr(namespace, self.dest)
if '+' in values:
if isinstance(values, str) and '+' in values:
[dest.append(lang) for lang in values.split('+')]
else:
dest.append(values)
@@ -167,8 +137,9 @@ Online documentation is located at:
'output_file',
metavar="output_pdf",
help="Output searchable PDF file (or '-' to write to standard output). "
"Existing files will be overwritten. If same as input file, the "
"input file will be updated only if processing is successful.",
"Existing files will be overwritten (use --no-overwrite to prevent this). "
"If same as input file, the input file will be updated only if "
"processing is successful.",
)
parser.add_argument(
'-l',
@@ -189,16 +160,17 @@ Online documentation is located at:
)
parser.add_argument(
'--output-type',
choices=['pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'],
default='pdfa',
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' minimizes changes "
"to the input file. 'pdf-a1' creates a "
"PDF/A-1b file. 'pdf-a2' is equivalent to 'pdfa'. 'pdf-a3' creates a "
"PDF/A-3b file. 'none' will produce no output, which may be helpful if "
"only the --sidecar is desired.",
choices=['auto', 'pdfa', 'pdf', 'pdfa-1', 'pdfa-2', 'pdfa-3', 'none'],
default='auto',
help="Choose output type. 'auto' (default) produces best-effort PDF/A "
"without requiring Ghostscript - uses verapdf validation when available, "
"otherwise passes through as PDF/A if safe (input already PDF/A or "
"force-ocr was used), or falls back to regular PDF. 'pdfa' creates a "
"PDF/A-2b compliant file for long term archiving (requires Ghostscript "
"as fallback). 'pdf' minimizes changes to the input file. 'pdfa-1' "
"creates a PDF/A-1b file. 'pdfa-2' is equivalent to 'pdfa'. 'pdfa-3' "
"creates a PDF/A-3b file. 'none' will produce no output, which may be "
"helpful if only the --sidecar is desired.",
)
# Use null string '\0' as sentinel to indicate the user supplied no argument,
@@ -219,6 +191,15 @@ Online documentation is located at:
"may not both use stdout at the same time.",
)
parser.add_argument(
'-n',
'--no-overwrite',
action='store_true',
default=False,
help="If the output file already exists, exit with an error instead of "
"overwriting it.",
)
parser.add_argument(
'--version',
action='version',
@@ -337,12 +318,29 @@ Online documentation is located at:
)
ocrsettings = parser.add_argument_group("OCR options", "Control how OCR is applied")
ocrsettings.add_argument(
'-m',
'--mode',
choices=[mode.value for mode in ProcessingMode],
default=ProcessingMode.default.value,
help="Processing mode for pages with existing text. "
"'default' errors if text is found. "
"'force' rasterizes all content and runs OCR (same as --force-ocr). "
"'skip' skips pages with existing text (same as --skip-text). "
"'redo' re-OCRs pages, replacing old invisible text (same as --redo-ocr). "
"'strip' removes the invisible OCR text layer without rasterizing or "
"running OCR, producing a smaller file; only text drawn as invisible "
"(render mode 3) is removed, so text from some OCR engines cannot be "
"removed this way.",
)
# Legacy flags for backward compatibility - these set the mode internally
ocrsettings.add_argument(
'-f',
'--force-ocr',
action='store_true',
help="Rasterize any text or vector objects on each page, apply OCR, and "
"save the rastered output (this rewrites the PDF)",
"save the rastered output (this rewrites the PDF). "
"Equivalent to --mode force.",
)
ocrsettings.add_argument(
'-s',
@@ -350,7 +348,8 @@ Online documentation is located at:
action='store_true',
help="Skip OCR on any pages that already contain text, but include the "
"page in final output; useful for PDFs that contain a mix of "
"images, text pages, and/or previously OCRed pages",
"images, text pages, and/or previously OCRed pages. "
"Equivalent to --mode skip.",
)
ocrsettings.add_argument(
'--redo-ocr',
@@ -358,11 +357,12 @@ Online documentation is located at:
help="Attempt to detect and remove the hidden OCR layer from files that "
"were previously OCRed with OCRmyPDF or another program. Apply OCR "
"to text found in raster images. Existing visible text objects will "
"not be changed. If there is no existing OCR, OCR will be added.",
"not be changed. If there is no existing OCR, OCR will be added. "
"Equivalent to --mode redo.",
)
ocrsettings.add_argument(
'--skip-big',
type=numeric(float, 0, 5000),
type=numeric(float, 0.0, 5000.0),
metavar='MPixels',
help="Skip OCR on pages larger than the specified amount of megapixels, "
"but include skipped pages in final output",
@@ -374,6 +374,14 @@ Online documentation is located at:
"signature. This option allows OCR to proceed, but the digital signature "
"will be invalidated.",
)
ocrsettings.add_argument(
'--tagged-pdf-mode',
choices=[mode.value for mode in TaggedPdfMode],
default=TaggedPdfMode.default.value,
help="Control behavior when a Tagged PDF is encountered. "
"'default' errors if --mode is default, otherwise warns. "
"'ignore' always warns but continues processing.",
)
advanced = parser.add_argument_group(
"Advanced", "Advanced options to control OCRmyPDF"
@@ -383,13 +391,14 @@ Online documentation is located at:
type=str,
help=(
"Limit OCR to the specified pages (ranges or comma separated), "
"skipping others"
"skipping others. The token 'end' is an alias for the last page, "
"so e.g. '3-end' OCRs from page 3 to the last page."
),
)
advanced.add_argument(
'--max-image-mpixels',
action='store',
type=numeric(float, 0),
type=numeric(float, 0.0),
metavar='MPixels',
help="Set maximum number of megapixels to unpack before treating an image as a "
"decompression bomb",
@@ -397,22 +406,46 @@ Online documentation is located at:
)
advanced.add_argument(
'--pdf-renderer',
choices=['auto', 'hocr', 'sandwich', 'hocrdebug'],
choices=['auto', 'hocr', 'sandwich', 'hocrdebug', 'fpdf2'],
default='auto',
help="Choose OCR PDF renderer - the default option is to let OCRmyPDF "
"choose. See documentation for discussion.",
help="Choose OCR PDF renderer. 'auto' (recommended) uses fpdf2, which "
"provides full international language support including RTL scripts, "
"proper text positioning, and invisible text that becomes visible when "
"selected. 'sandwich' renders text as a background layer. Legacy 'hocr' "
"and 'hocrdebug' options are deprecated and will use fpdf2.",
)
advanced.add_argument(
'--ocr-engine',
choices=['auto', 'tesseract', 'none'],
default='auto',
help="OCR engine to use. 'auto' (default) selects the best available engine. "
"'tesseract' uses Tesseract OCR. "
"'none' skips OCR entirely, useful for PDF/A conversion or image processing "
"without text recognition.",
)
advanced.add_argument(
'--rasterizer',
choices=['auto', 'ghostscript', 'pypdfium'],
default='auto',
help="Choose PDF page rasterizer. 'auto' (the default) prefers pypdfium2 "
"when the pypdfium2 package is installed, falling back to Ghostscript "
"otherwise. pypdfium2 anti-aliases page content and generally produces "
"better input for OCR than Ghostscript 10.x, which can render aliased "
"glyphs that OCR misreads as extra word breaks. 'pypdfium' forces the "
"pypdfium2 rasterizer (requires the pypdfium2 package); 'ghostscript' "
"forces the traditional Ghostscript rasterizer.",
)
advanced.add_argument(
'--rotate-pages-threshold',
default=DEFAULT_ROTATE_PAGES_THRESHOLD,
type=numeric(float, 0, 1000),
type=numeric(float, 0.0, 1000.0),
metavar='CONFIDENCE',
help="Only rotate pages when confidence is above this value (arbitrary "
"units reported by tesseract)",
)
advanced.add_argument(
'--fast-web-view',
type=numeric(float, 0),
type=numeric(float, 0.0),
default=1.0,
metavar="MEGABYTES",
help="If the size of file is more than this threshold (in MB), then "
@@ -465,3 +498,75 @@ plugins_only_parser.add_argument(
default=[],
help="Name of plugin to import.",
)
def namespace_to_options(ns) -> OcrOptions:
"""Convert argparse.Namespace to OcrOptions.
This function encapsulates CLI-specific knowledge of how command line
arguments map to our internal options model.
"""
# Extract known fields
known_fields = {}
extra_attrs = {}
# Legacy boolean flags that map to mode - handled by OcrOptions model validator
legacy_mode_flags = {'force_ocr', 'skip_text', 'redo_ocr'}
for key, value in vars(ns).items():
if key in OcrOptions.model_fields:
known_fields[key] = value
elif key in legacy_mode_flags:
# Pass legacy flags to OcrOptions for conversion to mode
known_fields[key] = value
else:
extra_attrs[key] = value
# Handle special cases for hOCR API
if 'output_folder' in extra_attrs and 'output_file' not in known_fields:
known_fields['output_file'] = '/dev/null' # Placeholder
# Handle case where input_file is missing (e.g., in _hocr_to_ocr_pdf)
if 'work_folder' in extra_attrs and 'input_file' not in known_fields:
known_fields['input_file'] = '/dev/null' # Placeholder
instance = OcrOptions(**known_fields)
instance.extra_attrs = extra_attrs
return instance
def get_options_and_plugins(
args=None,
) -> tuple[OcrOptions, OcrmypdfPluginManager]:
"""Parse command line arguments and return OcrOptions and plugin manager.
This is the main entry point for CLI argument processing. It handles
plugin discovery, argument parsing, and conversion to our internal
options model.
Args:
args: Command line arguments. If None, uses sys.argv.
Returns:
Tuple of (OcrOptions, PluginManager)
"""
# Import here to avoid circular imports
from ocrmypdf.api import setup_plugin_infrastructure
# First pass: get plugins so we can register their options
pre_options, _unused = plugins_only_parser.parse_known_args(args=args)
# Set up plugin infrastructure with proper initialization
plugin_manager = setup_plugin_infrastructure(plugins=pre_options.plugins)
# Get parser and let plugins add their options
parser = get_parser()
plugin_manager.add_options(parser=parser)
# Parse all arguments
namespace = parser.parse_args(args=args)
# Convert to OcrOptions
options = namespace_to_options(namespace)
return options, plugin_manager
Binary file not shown.
Binary file not shown.
Binary file not shown.

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