#!/usr/bin/env python3 # © 2015-17 James R. Barlow: github.com/jbarlow83 # # This file is part of OCRmyPDF. # # OCRmyPDF is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # OCRmyPDF is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . import locale import logging import os import sys import unicodedata from pathlib import Path from shutil import copyfileobj from typing import Tuple import pikepdf import PIL from ocrmypdf._exec import jbig2enc, pngquant, unpaper from ocrmypdf._unicodefun import verify_python3_env from ocrmypdf.exceptions import ( BadArgsError, InputFileError, MissingDependencyError, OutputFileAccessError, ) from ocrmypdf.helpers import ( is_file_writable, is_iterable_notstr, monotonic, safe_symlink, ) from ocrmypdf.subprocess import check_external_program # ------------- # External dependencies HOCR_OK_LANGS = frozenset(['eng', 'deu', 'spa', 'ita', 'por']) DEFAULT_LANGUAGE = 'eng' # Enforce English hegemony log = logging.getLogger(__name__) # -------- # Critical environment tests verify_python3_env() def check_platform(): if os.name == 'nt' and sys.maxsize <= 2 ** 32: # pragma: no cover # 32-bit interpreter on Windows log.error( "You are running OCRmyPDF in a 32-bit (x86) Python interpreter." "Please use a 64-bit (x86-64) version of Python." ) def check_options_languages(options, ocr_engine_languages): 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) if not ocr_engine_languages: return if not options.languages.issubset(ocr_engine_languages): msg = ( f"OCR engine does not have language data for the following " "requested languages: \n" ) for lang in options.languages - ocr_engine_languages: msg += lang + '\n' raise MissingDependencyError(msg) def check_options_output(options): is_latin = options.languages.issubset(HOCR_OK_LANGS) if options.pdf_renderer == 'hocr' and not is_latin: msg = ( "The 'hocr' PDF renderer is known to cause problems with one " "or more of the languages in your document. Use " "--pdf-renderer auto (the default) to avoid this issue." ) log.warning(msg) 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): if options.sidecar == '\0': if options.output_file == '-': raise BadArgsError( "--sidecar filename must be specified when output file is stdout." ) options.sidecar = options.output_file + '.txt' def check_options_preprocessing(options): if options.clean_final: options.clean = True if options.unpaper_args and not options.clean: raise BadArgsError("--clean is required for --unpaper-args") if options.clean: check_external_program( program='unpaper', package='unpaper', version_checker=unpaper.version, need_version='6.1', 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(str(e)) def _pages_from_ranges(ranges): if is_iterable_notstr(ranges): return set(ranges) pages = [] page_groups = ranges.replace(' ', '').split(',') for g in page_groups: if not g: continue try: start, end = g.split('-') except ValueError: pages.append(int(g) - 1) else: try: pages.extend(range(int(start) - 1, int(end))) except ValueError: raise BadArgsError("invalid page range") 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): 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 and options.sidecar: raise BadArgsError("--pages and --sidecar are mutually exclusive") if options.pages: options.pages = _pages_from_ranges(options.pages) def check_options_optimizing(options): if options.optimize >= 2: check_external_program( program='pngquant', package='pngquant', version_checker=pngquant.version, need_version='2.0.1', required_for='--optimize {2,3}', ) if options.optimize >= 2: # Although we use JBIG2 for optimize=1, don't nag about it unless the # user is asking for more optimization check_external_program( program='jbig2', 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." ) def check_options_advanced(options): if options.pdfa_image_compression != 'auto' and options.output_type.startswith( 'pdfa' ): log.warning( "--pdfa-image-compression argument has no effect when " "--output-type is not 'pdfa', 'pdfa-1', or 'pdfa-2'" ) def check_options_metadata(options): docinfo = [options.title, options.author, options.keywords, options.subject] for s in (m for m in docinfo if m): for c in s: if unicodedata.category(c) == 'Co' or ord(c) >= 0x10000: raise ValueError( "One of the metadata strings contains " "an unsupported Unicode character: '{}' (U+{})".format( c, hex(ord(c))[2:].upper() ) ) def check_options_pillow(options): 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 def _check_options(options, plugin_manager, ocr_engine_languages): check_platform() check_options_languages(options, ocr_engine_languages) check_options_metadata(options) check_options_output(options) check_options_sidecar(options) check_options_preprocessing(options) check_options_ocr_behavior(options) check_options_optimizing(options) check_options_advanced(options) check_options_pillow(options) plugin_manager.hook.check_options(options=options) def check_options(options, plugin_manager): ocr_engine_languages = plugin_manager.hook.get_ocr_engine().languages(options) _check_options(options, plugin_manager, ocr_engine_languages) def check_closed_streams(options): # pragma: no cover """Work around Python issue with multiprocessing forking on closed streams https://bugs.python.org/issue28326 Attempting to a fork/exec a new Python process when any of std{in,out,err} are closed or not flushable for some reason may raise an exception. Fix this by opening devnull if the handle seems to be closed. Do this globally to avoid tracking places all places that fork. Seems to be specific to multiprocessing.Process not all Python process forkers. The error actually occurs when the stream object is not flushable, but replacing an open stream object that is not flushable with /dev/null is a bad idea since it will create a silent failure. Replacing a closed handle with /dev/null seems safe. """ if sys.version_info[0:3] >= (3, 6, 4): return True # Issued fixed in Python 3.6.4+ if sys.stderr is None: sys.stderr = open(os.devnull, 'w') if sys.stdin is None: if options.input_file == '-': log.error("Trying to read from stdin but stdin seems closed") return False sys.stdin = open(os.devnull, 'r') if sys.stdout is None: if options.output_file == '-': # Can't replace stdout if the user is piping # If this case can even happen, it must be some kind of weird # stream. log.error( "Output was set to stdout '-' but the stream attached to " "stdout does not support the flush() system call. This " "will fail." ) return False sys.stdout = open(os.devnull, 'w') return True def log_page_orientations(pdfinfo): direction = {0: 'n', 90: 'e', 180: 's', 270: 'w'} orientations = [] for n, page in enumerate(pdfinfo): angle = page.rotation or 0 if angle != 0: orientations.append('{0}{1}'.format(n + 1, direction.get(angle, ''))) if orientations: log.info('Page orientations detected: %s', ' '.join(orientations)) def create_input_file(options, 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: copyfileobj(sys.stdin.buffer, stream_buffer) return target, "stdin" elif hasattr(options.input_file, 'readable'): if not options.input_file.readable(): raise InputFileError("Input file stream is not readable") log.info('reading file from input stream') target = os.path.join(work_folder, 'stream') with open(target, 'wb') as stream_buffer: copyfileobj(options.input_file, stream_buffer) return target, "stream" else: try: target = work_folder / 'origin' safe_symlink(options.input_file, target) return target, os.fspath(options.input_file) except FileNotFoundError: raise InputFileError(f"File not found - {options.input_file}") def check_requested_output_file(options): if options.output_file == '-': if sys.stdout.isatty(): raise BadArgsError( "Output was set to stdout '-' but it looks like stdout " "is connected to a terminal. Please redirect stdout to a " "file." ) elif hasattr(options.output_file, 'writable'): if not options.output_file.writable(): 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." ) def report_output_file_size(options, input_file, output_file): try: output_size = Path(output_file).stat().st_size input_size = Path(input_file).stat().st_size except FileNotFoundError: return # Outputting to stream or something with pikepdf.open(output_file) as p: # Overhead constants obtained by estimating amount of data added by OCR # PDF/A conversion, and possible XMP metadata addition, with compression FILE_OVERHEAD = 4000 OCR_PER_PAGE_OVERHEAD = 3000 reasonable_overhead = FILE_OVERHEAD + OCR_PER_PAGE_OVERHEAD * len(p.pages) ratio = output_size / input_size reasonable_ratio = output_size / (input_size + reasonable_overhead) if reasonable_ratio < 1.35 or input_size < 25000: return # Seems fine reasons = [] image_preproc = { 'deskew', 'clean_final', 'remove_background', 'oversample', 'force_ocr', } for arg in image_preproc: if getattr(options, arg, False): reasons.append( f"The argument --{arg.replace('_', '-')} was issued, causing transcoding." ) if options.optimize == 0: reasons.append("Optimization was disabled.") else: image_optimizers = { 'jbig2': jbig2enc.available(), 'pngquant': pngquant.available(), } for name, available in image_optimizers.items(): if not available: reasons.append( f"The optional dependency '{name}' was not found, so some image " f"optimizations could not be attempted." ) if reasons: explanation = "Possible reasons for this include:\n" + '\n'.join(reasons) + "\n" else: explanation = "No reason for this increase is known. Please report this issue." log.warning( f"The output file size is {ratio:.2f}× larger than the input file.\n" f"{explanation}" )