Fix options.jpg_quality issue
This commit is contained in:
@@ -145,6 +145,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)
|
||||
|
||||
+49
-41
@@ -9,11 +9,11 @@ import os
|
||||
from argparse import Namespace
|
||||
from collections.abc import Iterable, Sequence
|
||||
from copy import copy
|
||||
from io import IOBase
|
||||
from pathlib import Path
|
||||
from typing import Any, BinaryIO, Union
|
||||
from io import IOBase
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from ocrmypdf._defaults import DEFAULT_LANGUAGE, DEFAULT_ROTATE_PAGES_THRESHOLD
|
||||
|
||||
@@ -22,23 +22,23 @@ PathOrIO = Union[BinaryIO, IOBase, Path, str, bytes]
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Core OCR options
|
||||
languages: list[str] = Field(default_factory=lambda: [DEFAULT_LANGUAGE])
|
||||
output_type: str = 'pdfa'
|
||||
force_ocr: bool = False
|
||||
skip_text: bool = False
|
||||
redo_ocr: bool = False
|
||||
|
||||
|
||||
# Job control
|
||||
jobs: int | None = None
|
||||
use_threads: bool = True
|
||||
@@ -46,7 +46,7 @@ class OCROptions(BaseModel):
|
||||
quiet: bool = False
|
||||
verbose: int = 0
|
||||
keep_temporary_files: bool = False
|
||||
|
||||
|
||||
# Image processing
|
||||
image_dpi: int | None = None
|
||||
deskew: bool = False
|
||||
@@ -56,19 +56,21 @@ class OCROptions(BaseModel):
|
||||
remove_background: bool = False
|
||||
remove_vectors: bool = False
|
||||
oversample: int = 0
|
||||
unpaper_args: str | list[str] | None = None # Can be string or list after validation
|
||||
|
||||
unpaper_args: str | list[str] | None = (
|
||||
None # Can be string or list after validation
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# Metadata
|
||||
title: str | None = None
|
||||
author: str | None = None
|
||||
subject: str | None = None
|
||||
keywords: str | None = None
|
||||
|
||||
|
||||
# Optimization
|
||||
optimize: int | None = None
|
||||
jpg_quality: int | None = None
|
||||
@@ -76,7 +78,7 @@ class OCROptions(BaseModel):
|
||||
jbig2_lossy: bool | None = None
|
||||
jbig2_page_group_size: int | None = None
|
||||
jbig2_threshold: float | None = None
|
||||
|
||||
|
||||
# Advanced options
|
||||
max_image_mpixels: float = 250.0
|
||||
pdf_renderer: str = 'auto'
|
||||
@@ -95,22 +97,26 @@ class OCROptions(BaseModel):
|
||||
user_patterns: os.PathLike | None = None
|
||||
fast_web_view: float | None = None
|
||||
continue_on_soft_render_error: bool | None = None
|
||||
|
||||
|
||||
# Plugin system
|
||||
plugins: Sequence[Path | str] | None = None
|
||||
|
||||
|
||||
# Computed/derived options (set during validation)
|
||||
lossless_reconstruction: bool = False
|
||||
|
||||
|
||||
# Store any extra attributes (for plugins and dynamic options)
|
||||
extra_attrs: dict[str, Any] = Field(default_factory=dict, exclude=True, alias='_extra_attrs')
|
||||
|
||||
extra_attrs: dict[str, Any] = Field(
|
||||
default_factory=dict, exclude=True, alias='_extra_attrs'
|
||||
)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Allow attribute access like argparse.Namespace."""
|
||||
if name in self.extra_attrs:
|
||||
return self.extra_attrs[name]
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
"""Allow attribute setting like argparse.Namespace."""
|
||||
if name.startswith('_') or name in type(self).model_fields:
|
||||
@@ -119,7 +125,7 @@ class OCROptions(BaseModel):
|
||||
if not hasattr(self, 'extra_attrs'):
|
||||
super().__setattr__('extra_attrs', {})
|
||||
self.extra_attrs[name] = value
|
||||
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
"""Allow attribute deletion like argparse.Namespace."""
|
||||
if name in type(self).model_fields:
|
||||
@@ -127,48 +133,50 @@ class OCROptions(BaseModel):
|
||||
elif name in self.extra_attrs:
|
||||
del self.extra_attrs[name]
|
||||
else:
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
||||
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_namespace(cls, ns: Namespace) -> OCROptions:
|
||||
"""Convert argparse.Namespace to OCROptions."""
|
||||
# Extract known fields
|
||||
known_fields = {}
|
||||
extra_attrs = {}
|
||||
|
||||
|
||||
for key, value in vars(ns).items():
|
||||
if key in cls.model_fields:
|
||||
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 = cls(**known_fields)
|
||||
instance.extra_attrs = extra_attrs
|
||||
return instance
|
||||
|
||||
|
||||
def to_namespace(self) -> Namespace:
|
||||
"""Convert back to argparse.Namespace for compatibility."""
|
||||
ns = Namespace()
|
||||
|
||||
|
||||
# Add pydantic fields
|
||||
for field_name in type(self).model_fields:
|
||||
field_value = getattr(self, field_name)
|
||||
setattr(ns, field_name, field_value)
|
||||
|
||||
|
||||
# Add extra attributes
|
||||
for key, value in self.extra_attrs.items():
|
||||
setattr(ns, key, value)
|
||||
|
||||
|
||||
return ns
|
||||
|
||||
|
||||
@field_validator('languages')
|
||||
@classmethod
|
||||
def validate_languages(cls, v):
|
||||
@@ -176,7 +184,7 @@ class OCROptions(BaseModel):
|
||||
if not v:
|
||||
return [DEFAULT_LANGUAGE]
|
||||
return v
|
||||
|
||||
|
||||
@field_validator('output_type')
|
||||
@classmethod
|
||||
def validate_output_type(cls, v):
|
||||
@@ -185,7 +193,7 @@ class OCROptions(BaseModel):
|
||||
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):
|
||||
@@ -194,7 +202,7 @@ class OCROptions(BaseModel):
|
||||
if v not in valid_renderers:
|
||||
raise ValueError(f"pdf_renderer must be one of {valid_renderers}")
|
||||
return v
|
||||
|
||||
|
||||
@field_validator('clean_final')
|
||||
@classmethod
|
||||
def validate_clean_final(cls, v, info):
|
||||
@@ -202,7 +210,7 @@ class OCROptions(BaseModel):
|
||||
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):
|
||||
@@ -210,7 +218,7 @@ class OCROptions(BaseModel):
|
||||
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):
|
||||
@@ -218,7 +226,7 @@ class OCROptions(BaseModel):
|
||||
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):
|
||||
@@ -226,7 +234,7 @@ class OCROptions(BaseModel):
|
||||
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):
|
||||
@@ -234,7 +242,7 @@ class OCROptions(BaseModel):
|
||||
if 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):
|
||||
@@ -242,7 +250,7 @@ class OCROptions(BaseModel):
|
||||
if v < 0 or v > 1000:
|
||||
raise ValueError("rotate_pages_threshold must be between 0 and 1000")
|
||||
return v
|
||||
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def handle_special_cases(cls, data):
|
||||
@@ -255,7 +263,7 @@ class OCROptions(BaseModel):
|
||||
if data.get('pdf_renderer') == 'auto':
|
||||
data['pdf_renderer'] = 'hocr' # Default to hocr for auto
|
||||
return data
|
||||
|
||||
|
||||
model_config = ConfigDict(
|
||||
extra="forbid", # Force use of extra_attrs for unknown fields
|
||||
arbitrary_types_allowed=True, # Allow BinaryIO, Path, etc.
|
||||
|
||||
@@ -478,10 +478,13 @@ def convert_to_jbig2(
|
||||
|
||||
|
||||
def _optimize_jpeg(
|
||||
xref: Xref, in_jpg: Path, opt_jpg: Path, jpeg_quality: int
|
||||
xref: Xref, in_jpg: Path, opt_jpg: Path, jpg_quality: int
|
||||
) -> tuple[Xref, Path | None]:
|
||||
with Image.open(in_jpg) as im:
|
||||
im.save(opt_jpg, optimize=True, quality=jpeg_quality)
|
||||
save_kwargs = {'optimize': True}
|
||||
if isinstance(jpg_quality, int) and 0 < jpg_quality <= 100:
|
||||
save_kwargs['quality'] = jpg_quality
|
||||
im.save(opt_jpg, **save_kwargs)
|
||||
|
||||
if opt_jpg.stat().st_size > in_jpg.stat().st_size:
|
||||
log.debug(f"xref {xref}, jpeg, made larger - skip")
|
||||
@@ -499,7 +502,7 @@ def transcode_jpegs(
|
||||
for xref in jpegs:
|
||||
in_jpg = jpg_name(root, xref)
|
||||
opt_jpg = in_jpg.with_suffix('.opt.jpg')
|
||||
yield xref, in_jpg, opt_jpg, options.jpeg_quality
|
||||
yield xref, in_jpg, opt_jpg, options.jpg_quality
|
||||
|
||||
def finish_jpeg(result: tuple[Xref, Path | None], pbar: ProgressBar):
|
||||
xref, opt_jpg = result
|
||||
@@ -712,8 +715,8 @@ def optimize(
|
||||
safe_symlink(input_file, output_file)
|
||||
return output_file
|
||||
|
||||
if options.jpeg_quality == 0:
|
||||
options.jpeg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
||||
if options.jpg_quality == 0:
|
||||
options.jpg_quality = DEFAULT_JPEG_QUALITY if options.optimize < 3 else 40
|
||||
if options.png_quality == 0:
|
||||
options.png_quality = DEFAULT_PNG_QUALITY if options.optimize < 3 else 30
|
||||
if options.jbig2_page_group_size == 0:
|
||||
@@ -766,10 +769,11 @@ def main(infile, outfile, level, jobs=1):
|
||||
"""Entry point for direct optimization of a file."""
|
||||
from shutil import copy # pylint: disable=import-outside-toplevel
|
||||
from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
|
||||
|
||||
from ocrmypdf._options import OCROptions # pylint: disable=import-outside-toplevel
|
||||
|
||||
infile = Path(infile)
|
||||
|
||||
|
||||
# Create OCROptions with optimization-specific settings
|
||||
options = OCROptions(
|
||||
input_file=infile,
|
||||
|
||||
Reference in New Issue
Block a user