├── requirements-dev.txt ├── tests ├── data │ ├── images.txt │ ├── test.bmp │ ├── test.gif │ ├── test.jpg │ ├── test.pgm │ ├── test.png │ ├── test.ppm │ ├── test.tiff │ ├── test.webp │ ├── test.jpeg2000 │ ├── test_la.png │ ├── test-small.jpg │ └── test-european.jpg ├── tessdata │ ├── eng.traineddata │ └── dzo_test.traineddata └── pytesseract_test.py ├── setup.py ├── tox.ini ├── pytesseract ├── __init__.py └── pytesseract.py ├── setup.cfg ├── .pre-commit-config.yaml ├── .gitignore ├── .github └── workflows │ └── ci.yaml ├── README.rst └── LICENSE /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit 2 | pytest 3 | -------------------------------------------------------------------------------- /tests/data/images.txt: -------------------------------------------------------------------------------- 1 | ./tests/data/test.jpg 2 | ./tests/data/test-european.jpg -------------------------------------------------------------------------------- /tests/data/test.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.bmp -------------------------------------------------------------------------------- /tests/data/test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.gif -------------------------------------------------------------------------------- /tests/data/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.jpg -------------------------------------------------------------------------------- /tests/data/test.pgm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.pgm -------------------------------------------------------------------------------- /tests/data/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.png -------------------------------------------------------------------------------- /tests/data/test.ppm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.ppm -------------------------------------------------------------------------------- /tests/data/test.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.tiff -------------------------------------------------------------------------------- /tests/data/test.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.webp -------------------------------------------------------------------------------- /tests/data/test.jpeg2000: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test.jpeg2000 -------------------------------------------------------------------------------- /tests/data/test_la.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test_la.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | 5 | 6 | setup() 7 | -------------------------------------------------------------------------------- /tests/data/test-small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test-small.jpg -------------------------------------------------------------------------------- /tests/data/test-european.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/data/test-european.jpg -------------------------------------------------------------------------------- /tests/tessdata/eng.traineddata: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/tessdata/eng.traineddata -------------------------------------------------------------------------------- /tests/tessdata/dzo_test.traineddata: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madmaze/pytesseract/HEAD/tests/tessdata/dzo_test.traineddata -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py, pandas 3 | 4 | [pytest] 5 | addopts = --strict-markers --verbose --cache-clear --color=yes -p no:doctest 6 | markers = 7 | pytesseract: Requires commandline pytesseract installed. 8 | lang_fra: Requires French (fra) pytesseract language. 9 | 10 | [testenv] 11 | deps = 12 | -r{toxinidir}/requirements-dev.txt 13 | passenv = * 14 | commands = 15 | python -bb -m pytest {posargs:tests} 16 | 17 | [testenv:pandas] 18 | deps = 19 | numpy 20 | pandas 21 | -r{toxinidir}/requirements-dev.txt 22 | passenv = * 23 | commands = 24 | python -bb -m pytest {posargs:tests} 25 | -------------------------------------------------------------------------------- /pytesseract/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F401 2 | from __future__ import annotations 3 | 4 | from .pytesseract import ALTONotSupported 5 | from .pytesseract import get_languages 6 | from .pytesseract import get_tesseract_version 7 | from .pytesseract import image_to_alto_xml 8 | from .pytesseract import image_to_boxes 9 | from .pytesseract import image_to_data 10 | from .pytesseract import image_to_osd 11 | from .pytesseract import image_to_pdf_or_hocr 12 | from .pytesseract import image_to_string 13 | from .pytesseract import Output 14 | from .pytesseract import run_and_get_multiple_output 15 | from .pytesseract import run_and_get_output 16 | from .pytesseract import TesseractError 17 | from .pytesseract import TesseractNotFoundError 18 | from .pytesseract import TSVNotSupported 19 | 20 | 21 | __version__ = '0.3.14' 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pytesseract 3 | version = attr: pytesseract.__version__ 4 | description = Python-tesseract is a python wrapper for Google's Tesseract-OCR 5 | long_description = file: README.rst 6 | long_description_content_type = text/x-rst 7 | url = https://github.com/madmaze/pytesseract 8 | author = Samuel Hoffstaetter 9 | author_email = samuel@hoffstaetter.com 10 | maintainer = Matthias Lee 11 | maintainer_email = pytesseract@madmaze.net 12 | license = Apache-2.0 13 | license_files = LICENSE 14 | classifiers = 15 | License :: OSI Approved :: Apache Software License 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: Implementation :: CPython 19 | Programming Language :: Python :: Implementation :: PyPy 20 | keywords = python-tesseract OCR Python 21 | 22 | [options] 23 | packages = find: 24 | install_requires = 25 | Pillow>=8.0.0 26 | packaging>=21.3 27 | python_requires = >=3.9 28 | include_package_data = True 29 | 30 | [options.packages.find] 31 | exclude = 32 | tests* 33 | testing* 34 | 35 | [options.entry_points] 36 | console_scripts = 37 | pytesseract = pytesseract.pytesseract:main 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^(tests/data/) 2 | repos: 3 | - repo: https://github.com/psf/black 4 | rev: 25.1.0 5 | hooks: 6 | - id: black 7 | args: [-S, --line-length=79, --safe, --quiet] 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - id: end-of-file-fixer 13 | - id: check-yaml 14 | - id: debug-statements 15 | - id: double-quote-string-fixer 16 | - id: name-tests-test 17 | - id: requirements-txt-fixer 18 | - id: check-docstring-first 19 | - repo: https://github.com/asottile/setup-cfg-fmt 20 | rev: v2.7.0 21 | hooks: 22 | - id: setup-cfg-fmt 23 | - repo: https://github.com/asottile/reorder-python-imports 24 | rev: v3.14.0 25 | hooks: 26 | - id: reorder-python-imports 27 | args: [--py39-plus, --add-import, 'from __future__ import annotations'] 28 | - repo: https://github.com/asottile/add-trailing-comma 29 | rev: v3.1.0 30 | hooks: 31 | - id: add-trailing-comma 32 | - repo: https://github.com/asottile/pyupgrade 33 | rev: v3.19.1 34 | hooks: 35 | - id: pyupgrade 36 | args: [--py39-plus] 37 | - repo: https://github.com/hhatto/autopep8 38 | rev: v2.3.2 39 | hooks: 40 | - id: autopep8 41 | - repo: https://github.com/PyCQA/flake8 42 | rev: 7.1.2 43 | hooks: 44 | - id: flake8 45 | - repo: local 46 | hooks: 47 | - id: rst 48 | name: rst 49 | entry: rst-lint --encoding utf-8 50 | files: ^(README.rst)$ 51 | language: python 52 | additional_dependencies: [pygments, restructuredtext_lint] 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | .pytest_cache 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # PyCharm project settings 98 | .idea 99 | *.iml 100 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | tags: '*' 7 | pull_request: 8 | 9 | env: 10 | PYTHON_LATEST_TAG: py313 11 | PYTHON_LATEST_VER: 3.13 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | tox: 19 | name: ${{ matrix.env }} 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | env: [py39, py310, py311, py312, py313] 24 | steps: 25 | - uses: asottile/workflows/.github/actions/fast-checkout@v1.8.0 26 | with: 27 | submodules: false 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: | 31 | ${{ 32 | (matrix.env == 'py39' || startsWith(matrix.env, 'py39-')) && '3.9' || 33 | (matrix.env == 'py310' || startsWith(matrix.env, 'py310-')) && '3.10' || 34 | (matrix.env == 'py311' || startsWith(matrix.env, 'py311-')) && '3.11' || 35 | (matrix.env == 'py312' || startsWith(matrix.env, 'py312-')) && '3.12' || 36 | (matrix.env == env.PYTHON_LATEST_TAG) && env.PYTHON_LATEST_VER 37 | }} 38 | - name: Install tesseract 39 | run: sudo apt-get -y update && sudo apt-get install -y tesseract-ocr tesseract-ocr-fra 40 | - name: Print tesseract version 41 | run: echo $(tesseract --version) 42 | - name: Update tools 43 | run: python -mpip install --upgrade setuptools tox virtualenv 44 | - name: Run tox 45 | run: tox -e ${{ matrix.env != env.PYTHON_LATEST_TAG && matrix.env || 'pandas' }} 46 | - name: Test pytesseract package installation 47 | if: ${{ matrix.env == env.PYTHON_LATEST_TAG }} 48 | run: python -mpip install -U . && python -mpip show pytesseract && python -c 'import pytesseract' 49 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python Tesseract 2 | ================ 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/pytesseract.svg 5 | :target: https://pypi.python.org/pypi/pytesseract 6 | :alt: Python versions 7 | 8 | .. image:: https://img.shields.io/github/release/madmaze/pytesseract.svg 9 | :target: https://github.com/madmaze/pytesseract/releases 10 | :alt: Github release 11 | 12 | .. image:: https://img.shields.io/pypi/v/pytesseract.svg?color=blue 13 | :target: https://pypi.python.org/pypi/pytesseract 14 | :alt: PyPI release 15 | 16 | .. image:: https://img.shields.io/conda/vn/conda-forge/pytesseract.svg?color=blue 17 | :target: https://anaconda.org/conda-forge/pytesseract 18 | :alt: Conda release 19 | 20 | .. image:: https://results.pre-commit.ci/badge/github/madmaze/pytesseract/master.svg 21 | :target: https://results.pre-commit.ci/latest/github/madmaze/pytesseract/master 22 | :alt: Pre-commit CI status 23 | 24 | .. image:: https://github.com/madmaze/pytesseract/workflows/CI/badge.svg?branch=master 25 | :target: https://github.com/madmaze/pytesseract/actions?query=workflow%3ACI 26 | :alt: CI workflow status 27 | 28 | Python-tesseract is an optical character recognition (OCR) tool for python. 29 | That is, it will recognize and "read" the text embedded in images. 30 | 31 | Python-tesseract is a wrapper for `Google's Tesseract-OCR Engine `_. 32 | It is also useful as a stand-alone invocation script to tesseract, as it can read all image types 33 | supported by the Pillow and Leptonica imaging libraries, including jpeg, png, gif, bmp, tiff, 34 | and others. Additionally, if used as a script, Python-tesseract will print the recognized 35 | text instead of writing it to a file. 36 | 37 | USAGE 38 | ----- 39 | 40 | **Quickstart** 41 | 42 | *Note*: Test images are located in the ``tests/data`` folder of the Git repo. 43 | 44 | Library usage: 45 | 46 | .. code-block:: python 47 | 48 | from PIL import Image 49 | 50 | import pytesseract 51 | 52 | # If you don't have tesseract executable in your PATH, include the following: 53 | pytesseract.pytesseract.tesseract_cmd = r'' 54 | # Example tesseract_cmd = r'C:\Program Files (x86)\Tesseract-OCR\tesseract' 55 | 56 | # Simple image to string 57 | print(pytesseract.image_to_string(Image.open('test.png'))) 58 | 59 | # In order to bypass the image conversions of pytesseract, just use relative or absolute image path 60 | # NOTE: In this case you should provide tesseract supported images or tesseract will return error 61 | print(pytesseract.image_to_string('test.png')) 62 | 63 | # List of available languages 64 | print(pytesseract.get_languages(config='')) 65 | 66 | # French text image to string 67 | print(pytesseract.image_to_string(Image.open('test-european.jpg'), lang='fra')) 68 | 69 | # Batch processing with a single file containing the list of multiple image file paths 70 | print(pytesseract.image_to_string('images.txt')) 71 | 72 | # Timeout/terminate the tesseract job after a period of time 73 | try: 74 | print(pytesseract.image_to_string('test.jpg', timeout=2)) # Timeout after 2 seconds 75 | print(pytesseract.image_to_string('test.jpg', timeout=0.5)) # Timeout after half a second 76 | except RuntimeError as timeout_error: 77 | # Tesseract processing is terminated 78 | pass 79 | 80 | # Get bounding box estimates 81 | print(pytesseract.image_to_boxes(Image.open('test.png'))) 82 | 83 | # Get verbose data including boxes, confidences, line and page numbers 84 | print(pytesseract.image_to_data(Image.open('test.png'))) 85 | 86 | # Get information about orientation and script detection 87 | print(pytesseract.image_to_osd(Image.open('test.png'))) 88 | 89 | # Get a searchable PDF 90 | pdf = pytesseract.image_to_pdf_or_hocr('test.png', extension='pdf') 91 | with open('test.pdf', 'w+b') as f: 92 | f.write(pdf) # pdf type is bytes by default 93 | 94 | # Get HOCR output 95 | hocr = pytesseract.image_to_pdf_or_hocr('test.png', extension='hocr') 96 | 97 | # Get ALTO XML output 98 | xml = pytesseract.image_to_alto_xml('test.png') 99 | 100 | # getting multiple types of output with one call to save compute time 101 | # currently supports mix and match of the following: txt, pdf, hocr, box, tsv 102 | text, boxes = pytesseract.run_and_get_multiple_output('test.png', extensions=['txt', 'box']) 103 | 104 | Support for OpenCV image/NumPy array objects 105 | 106 | .. code-block:: python 107 | 108 | import cv2 109 | 110 | img_cv = cv2.imread(r'//digits.png') 111 | 112 | # By default OpenCV stores images in BGR format and since pytesseract assumes RGB format, 113 | # we need to convert from BGR to RGB format/mode: 114 | img_rgb = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB) 115 | print(pytesseract.image_to_string(img_rgb)) 116 | # OR 117 | img_rgb = Image.frombytes('RGB', img_cv.shape[:2], img_cv, 'raw', 'BGR', 0, 0) 118 | print(pytesseract.image_to_string(img_rgb)) 119 | 120 | 121 | If you need custom configuration like `oem`/`psm`, use the **config** keyword. 122 | 123 | .. code-block:: python 124 | 125 | # Example of adding any additional options 126 | custom_oem_psm_config = r'--oem 3 --psm 6' 127 | pytesseract.image_to_string(image, config=custom_oem_psm_config) 128 | 129 | # Example of using pre-defined tesseract config file with options 130 | cfg_filename = 'words' 131 | pytesseract.run_and_get_output(image, extension='txt', config=cfg_filename) 132 | 133 | Add the following config, if you have tessdata error like: "Error opening data file..." 134 | 135 | .. code-block:: python 136 | 137 | # Example config: r'--tessdata-dir "C:\Program Files (x86)\Tesseract-OCR\tessdata"' 138 | # It's important to add double quotes around the dir path. 139 | tessdata_dir_config = r'--tessdata-dir ""' 140 | pytesseract.image_to_string(image, lang='chi_sim', config=tessdata_dir_config) 141 | 142 | **Functions** 143 | 144 | * **get_languages** Returns all currently supported languages by Tesseract OCR. 145 | 146 | * **get_tesseract_version** Returns the Tesseract version installed in the system. 147 | 148 | * **image_to_string** Returns unmodified output as string from Tesseract OCR processing 149 | 150 | * **image_to_boxes** Returns result containing recognized characters and their box boundaries 151 | 152 | * **image_to_data** Returns result containing box boundaries, confidences, and other information. Requires Tesseract 3.05+. For more information, please check the `Tesseract TSV documentation `_ 153 | 154 | * **image_to_osd** Returns result containing information about orientation and script detection. 155 | 156 | * **image_to_alto_xml** Returns result in the form of Tesseract's ALTO XML format. 157 | 158 | * **run_and_get_output** Returns the raw output from Tesseract OCR. Gives a bit more control over the parameters that are sent to tesseract. 159 | 160 | * **run_and_get_multiple_output** Returns like `run_and_get_output` but can handle multiple extensions. This function replaces the `extension: str` kwarg with `extension: List[str]` kwarg where a list of extensions can be specified and the corresponding data is returned after only one `tesseract` call. This function reduces the number of calls to `tesseract` when multiple output formats, like both text and bounding boxes, are needed. 161 | 162 | **Parameters** 163 | 164 | ``image_to_data(image, lang=None, config='', nice=0, output_type=Output.STRING, timeout=0, pandas_config=None)`` 165 | 166 | * **image** Object or String - either PIL Image, NumPy array or file path of the image to be processed by Tesseract. If you pass object instead of file path, pytesseract will implicitly convert the image to `RGB mode `_. 167 | 168 | * **lang** String - Tesseract language code string. Defaults to ``eng`` if not specified! Example for multiple languages: ``lang='eng+fra'`` 169 | 170 | * **config** String - Any **additional custom configuration flags** that are not available via the pytesseract function. For example: ``config='--psm 6'`` 171 | 172 | * **nice** Integer - modifies the processor priority for the Tesseract run. Not supported on Windows. Nice adjusts the niceness of unix-like processes. 173 | 174 | * **output_type** Class attribute - specifies the type of the output, defaults to ``string``. For the full list of all supported types, please check the definition of `pytesseract.Output `_ class. 175 | 176 | * **timeout** Integer or Float - duration in seconds for the OCR processing, after which, pytesseract will terminate and raise RuntimeError. 177 | 178 | * **pandas_config** Dict - only for the **Output.DATAFRAME** type. Dictionary with custom arguments for `pandas.read_csv `_. Allows you to customize the output of **image_to_data**. 179 | 180 | CLI usage: 181 | 182 | .. code-block:: bash 183 | 184 | pytesseract [-l lang] image_file 185 | 186 | INSTALLATION 187 | ------------ 188 | 189 | Prerequisites: 190 | 191 | - Python-tesseract requires Python 3.6+ 192 | - You will need the Python Imaging Library (PIL) (or the `Pillow `_ fork). 193 | Please check the `Pillow documentation `_ to know the basic Pillow installation. 194 | - Install `Google Tesseract OCR `_ 195 | (additional info how to install the engine on Linux, Mac OSX and Windows). 196 | You must be able to invoke the tesseract command as *tesseract*. If this 197 | isn't the case, for example because tesseract isn't in your PATH, you will 198 | have to change the "tesseract_cmd" variable ``pytesseract.pytesseract.tesseract_cmd``. 199 | Under Debian/Ubuntu you can use the package **tesseract-ocr**. 200 | For Mac OS users. please install homebrew package **tesseract**. 201 | 202 | *Note:* In some rare cases, you might need to additionally install ``tessconfigs`` and ``configs`` from `tesseract-ocr/tessconfigs `_ if the OS specific package doesn't include them. 203 | 204 | | Installing via pip: 205 | 206 | Check the `pytesseract package page `_ for more information. 207 | 208 | .. code-block:: bash 209 | 210 | pip install pytesseract 211 | 212 | | Or if you have git installed: 213 | 214 | .. code-block:: bash 215 | 216 | pip install -U git+https://github.com/madmaze/pytesseract.git 217 | 218 | | Installing from source: 219 | 220 | .. code-block:: bash 221 | 222 | git clone https://github.com/madmaze/pytesseract.git 223 | cd pytesseract && pip install -U . 224 | 225 | | Install with conda (via `conda-forge `_): 226 | 227 | .. code-block:: bash 228 | 229 | conda install -c conda-forge pytesseract 230 | 231 | TESTING 232 | ------- 233 | 234 | To run this project's test suite, install and run ``tox``. Ensure that you have ``tesseract`` 235 | installed and in your PATH. 236 | 237 | .. code-block:: bash 238 | 239 | pip install tox 240 | tox 241 | 242 | LICENSE 243 | ------- 244 | Check the LICENSE file included in the Python-tesseract repository/distribution. 245 | As of Python-tesseract 0.3.1 the license is Apache License Version 2.0 246 | 247 | CONTRIBUTORS 248 | ------------ 249 | - Originally written by `Samuel Hoffstaetter `_ 250 | - `Full list of contributors `_ 251 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/pytesseract_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import partial 4 | from glob import iglob 5 | from multiprocessing import Pool 6 | from os import getcwd 7 | from os import path 8 | from os import sep 9 | from sys import platform 10 | from sys import version_info 11 | from tempfile import gettempdir 12 | from unittest import mock 13 | 14 | import pytest 15 | 16 | from pytesseract import ALTONotSupported 17 | from pytesseract import get_languages 18 | from pytesseract import get_tesseract_version 19 | from pytesseract import image_to_alto_xml 20 | from pytesseract import image_to_boxes 21 | from pytesseract import image_to_data 22 | from pytesseract import image_to_osd 23 | from pytesseract import image_to_pdf_or_hocr 24 | from pytesseract import image_to_string 25 | from pytesseract import Output 26 | from pytesseract import run_and_get_multiple_output 27 | from pytesseract import TesseractNotFoundError 28 | from pytesseract import TSVNotSupported 29 | from pytesseract.pytesseract import file_to_dict 30 | from pytesseract.pytesseract import LANG_PATTERN 31 | from pytesseract.pytesseract import numpy_installed 32 | from pytesseract.pytesseract import pandas_installed 33 | from pytesseract.pytesseract import prepare 34 | 35 | if numpy_installed: 36 | import numpy as np 37 | 38 | if pandas_installed: 39 | import pandas 40 | 41 | try: 42 | from PIL import Image 43 | except ImportError: 44 | import Image 45 | 46 | 47 | IS_PYTHON_2 = version_info[:1] < (3,) 48 | IS_PYTHON_3 = not IS_PYTHON_2 49 | 50 | TESSERACT_VERSION = tuple(get_tesseract_version().release) # to skip tests 51 | 52 | TESTS_DIR = path.dirname(path.abspath(__file__)) 53 | DATA_DIR = path.join(TESTS_DIR, 'data') 54 | TESSDATA_DIR = path.join(TESTS_DIR, 'tessdata') 55 | TEST_JPEG = path.join(DATA_DIR, 'test.jpg') 56 | 57 | pytestmark = pytest.mark.pytesseract # used marker for the module 58 | string_type = unicode if IS_PYTHON_2 else str # noqa: 821 59 | 60 | 61 | @pytest.fixture(scope='session') 62 | def test_file(): 63 | return TEST_JPEG 64 | 65 | 66 | @pytest.fixture(scope='session') 67 | def test_invalid_file(): 68 | return TEST_JPEG + 'invalid' 69 | 70 | 71 | @pytest.fixture(scope='session') 72 | def test_file_european(): 73 | return path.join(DATA_DIR, 'test-european.jpg') 74 | 75 | 76 | @pytest.fixture(scope='session') 77 | def test_file_small(): 78 | return path.join(DATA_DIR, 'test-small.jpg') 79 | 80 | 81 | @pytest.fixture(scope='session') 82 | def function_mapping(): 83 | return { 84 | 'pdf': partial(image_to_pdf_or_hocr, extension='pdf'), 85 | 'txt': image_to_string, 86 | 'box': image_to_boxes, 87 | 'hocr': partial(image_to_pdf_or_hocr, extension='hocr'), 88 | 'tsv': image_to_data, 89 | } 90 | 91 | 92 | @pytest.mark.parametrize( 93 | 'test_file', 94 | [ 95 | 'test.jpg', 96 | 'test.jpeg2000', 97 | 'test.pgm', 98 | 'test.png', 99 | 'test.ppm', 100 | 'test.tiff', 101 | 'test.gif', 102 | 'test.webp', 103 | # 'test.bmp', # https://github.com/tesseract-ocr/tesseract/issues/2558 104 | ], 105 | ids=[ 106 | 'jpg', 107 | 'jpeg2000', 108 | 'pgm', 109 | 'png', 110 | 'ppm', 111 | 'tiff', 112 | 'gif', 113 | 'webp', 114 | # 'bmp', 115 | ], 116 | ) 117 | def test_image_to_string_with_image_type(test_file): 118 | # Don't perform assertion against full string in case the version 119 | # of tesseract installed doesn't catch it all. This test is testing 120 | # that pytesseract command line program is called correctly. 121 | if test_file.endswith('gif') and TESSERACT_VERSION[0] < 4: 122 | pytest.skip('skip gif test') 123 | test_file_path = path.join(DATA_DIR, test_file) 124 | assert 'The quick brown dog' in image_to_string(test_file_path, 'eng') 125 | 126 | 127 | @pytest.mark.parametrize( 128 | 'test_file', 129 | [TEST_JPEG, Image.open(TEST_JPEG)], 130 | ids=['path_str', 'image_object'], 131 | ) 132 | def test_image_to_string_with_args_type(test_file): 133 | assert 'The quick brown dog' in image_to_string(test_file, 'eng') 134 | 135 | # Test cleanup of temporary files 136 | for _ in iglob(gettempdir() + sep + 'tess_*'): 137 | assert False, 'Failed to cleanup temporary files' 138 | 139 | 140 | @pytest.mark.skipif(numpy_installed is False, reason='requires numpy') 141 | def test_image_to_string_with_numpy_array(test_file): 142 | assert 'The quick brown dog' in image_to_string( 143 | np.array(Image.open(test_file)), 144 | 'eng', 145 | ) 146 | 147 | 148 | @pytest.mark.lang_fra 149 | def test_image_to_string_european(test_file_european): 150 | assert 'La volpe marrone' in image_to_string(test_file_european, 'fra') 151 | 152 | 153 | @pytest.mark.skipif( 154 | platform.startswith('win32'), 155 | reason='used paths with `/` as separator', 156 | ) 157 | def test_image_to_string_batch(): 158 | batch_file = path.join(DATA_DIR, 'images.txt') 159 | assert 'The quick brown dog' in image_to_string(batch_file) 160 | 161 | 162 | def test_image_to_string_multiprocessing(): 163 | """Test parallel system calls.""" 164 | test_files = [ 165 | 'test.jpg', 166 | 'test.pgm', 167 | 'test.png', 168 | 'test.ppm', 169 | 'test.tiff', 170 | 'test.webp', 171 | ] 172 | test_files = [path.join(DATA_DIR, test_file) for test_file in test_files] 173 | p = Pool(2) 174 | results = p.map(image_to_string, test_files) 175 | for result in results: 176 | assert 'The quick brown dog' in result 177 | p.close() 178 | p.join() 179 | 180 | 181 | def test_image_to_string_timeout(test_file): 182 | with pytest.raises(RuntimeError): 183 | image_to_string(test_file, timeout=0.000000001) 184 | 185 | 186 | def test_la_image_to_string(): 187 | filepath = path.join(DATA_DIR, 'test_la.png') 188 | img = Image.open(filepath) 189 | assert 'This is test message' == image_to_string(img).strip() 190 | 191 | 192 | def test_image_to_boxes(test_file): 193 | result = image_to_boxes(test_file) 194 | assert isinstance(result, string_type) 195 | 196 | lines = result.strip().split('\n') 197 | assert len(lines) > 0 198 | 199 | assert lines[0].split(' ')[0] == 'T' # T of word 'This' 200 | 201 | for line in lines: 202 | chars = line.split(' ') 203 | assert chars[1].isnumeric() # left 204 | assert chars[2].isnumeric() # top 205 | assert chars[3].isnumeric() # width 206 | assert chars[4].isnumeric() # height 207 | 208 | 209 | def test_image_to_osd(test_file): 210 | result = image_to_osd(test_file) 211 | assert isinstance(result, string_type) 212 | for key in [ 213 | 'Page number', 214 | 'Orientation in degrees', 215 | 'Rotate', 216 | 'Orientation confidence', 217 | 'Script', 218 | 'Script confidence', 219 | ]: 220 | assert key + ':' in result 221 | 222 | 223 | @pytest.mark.parametrize('extension', ['pdf', 'hocr']) 224 | def test_image_to_pdf_or_hocr(test_file, extension): 225 | result = image_to_pdf_or_hocr(test_file, extension=extension) 226 | 227 | if extension == 'pdf': 228 | if IS_PYTHON_2: 229 | assert isinstance(result, str) 230 | result = str(result).strip() 231 | assert result.startswith('%PDF') 232 | assert result.endswith('EOF') 233 | else: 234 | assert isinstance(result, bytes) 235 | 236 | if extension == 'hocr': 237 | assert isinstance(result, bytes) # type 238 | result = ( 239 | result.decode('utf-8') if IS_PYTHON_2 else str(result, 'utf-8') 240 | ) 241 | result = str(result).strip() 242 | assert result.startswith('') 244 | 245 | 246 | @pytest.mark.parametrize( 247 | 'extensions', 248 | [ 249 | ['tsv', 'pdf', 'txt', 'box', 'hocr'], 250 | # This tests a case where the extensions do not add any config params 251 | # Here this test is not merged with the test above because we might get 252 | # into a racing condition where test results from different parameter 253 | # are mixed in the test below 254 | ['pdf', 'txt'], 255 | ], 256 | ) 257 | def test_run_and_get_multiple_output(test_file, function_mapping, extensions): 258 | compound_results = run_and_get_multiple_output( 259 | test_file, 260 | extensions=extensions, 261 | ) 262 | for result, extension in zip(compound_results, extensions): 263 | if extension == 'pdf': 264 | # pdf creation time could be different between the two so do not 265 | # check the whole string 266 | assert ( 267 | result[:1000] == function_mapping[extension](test_file)[:1000] 268 | ) 269 | else: 270 | assert result == function_mapping[extension](test_file) 271 | 272 | 273 | @pytest.mark.skipif( 274 | TESSERACT_VERSION[:2] < (4, 1), 275 | reason='requires tesseract >= 4.1', 276 | ) 277 | def test_image_to_alto_xml(test_file): 278 | result = image_to_alto_xml(test_file) 279 | assert isinstance(result, bytes) 280 | result = result.decode('utf-8') if IS_PYTHON_2 else str(result, 'utf-8') 281 | result = str(result).strip() 282 | assert result.startswith('') 284 | 285 | 286 | @pytest.mark.skipif( 287 | TESSERACT_VERSION[:2] >= (4, 1), 288 | reason='requires tesseract < 4.1', 289 | ) 290 | def test_image_to_alto_xml_support(test_file): 291 | with pytest.raises(ALTONotSupported): 292 | image_to_alto_xml(test_file) 293 | 294 | 295 | @pytest.mark.skipif( 296 | TESSERACT_VERSION[:2] >= (3, 5), 297 | reason='requires tesseract < 3.05', 298 | ) 299 | def test_image_to_data_pandas_support(test_file_small): 300 | with pytest.raises(TSVNotSupported): 301 | image_to_data(test_file_small, output_type=Output.DATAFRAME) 302 | 303 | 304 | @pytest.mark.skipif( 305 | TESSERACT_VERSION[:2] < (3, 5), 306 | reason='requires tesseract >= 3.05', 307 | ) 308 | @pytest.mark.skipif(pandas_installed is False, reason='requires pandas') 309 | def test_image_to_data_pandas_output(test_file_small): 310 | """Test and compare the type and meta information of the result.""" 311 | result = image_to_data(test_file_small, output_type=Output.DATAFRAME) 312 | assert isinstance(result, pandas.DataFrame) 313 | expected_columns = [ 314 | 'level', 315 | 'page_num', 316 | 'block_num', 317 | 'par_num', 318 | 'line_num', 319 | 'word_num', 320 | 'left', 321 | 'top', 322 | 'width', 323 | 'height', 324 | 'conf', 325 | 'text', 326 | ] 327 | assert bool(set(result.columns).intersection(expected_columns)) 328 | 329 | 330 | @pytest.mark.skipif( 331 | TESSERACT_VERSION[:2] < (3, 5), 332 | reason='requires tesseract >= 3.05', 333 | ) 334 | @pytest.mark.parametrize( 335 | 'output', 336 | [Output.BYTES, Output.DICT, Output.STRING], 337 | ids=['bytes', 'dict', 'string'], 338 | ) 339 | def test_image_to_data_common_output(test_file_small, output): 340 | """Test and compare the type of the result.""" 341 | result = image_to_data(test_file_small, output_type=output) 342 | expected_dict_result = { 343 | 'level': [1, 2, 3, 4, 5], 344 | 'page_num': [1, 1, 1, 1, 1], 345 | 'block_num': [0, 1, 1, 1, 1], 346 | 'par_num': [0, 0, 1, 1, 1], 347 | 'line_num': [0, 0, 0, 1, 1], 348 | 'word_num': [0, 0, 0, 0, 1], 349 | 'left': [0, 11, 11, 11, 11], 350 | 'top': [0, 11, 11, 11, 11], 351 | 'width': [79, 60, 60, 60, 60], 352 | 'height': [47, 24, 24, 24, 24], 353 | # 'conf': ['-1', '-1', '-1', '-1', 96], 354 | 'text': ['', '', '', '', 'This'], 355 | } 356 | 357 | if output is Output.BYTES: 358 | assert isinstance(result, bytes) 359 | 360 | elif output is Output.DICT: 361 | confidence_values = result.pop('conf', None) 362 | assert confidence_values is not None 363 | assert 0 <= confidence_values[-1] <= 100 364 | assert result == expected_dict_result 365 | 366 | elif output is Output.STRING: 367 | assert isinstance(result, string_type) 368 | for key in expected_dict_result.keys(): 369 | assert key in result 370 | 371 | 372 | @pytest.mark.parametrize('obj', [1, 1.0, None], ids=['int', 'float', 'none']) 373 | def test_wrong_prepare_type(obj): 374 | with pytest.raises(TypeError): 375 | prepare(obj) 376 | 377 | 378 | @pytest.mark.parametrize( 379 | 'test_path', 380 | [r'wrong_tesseract', getcwd() + path.sep + r'wrong_tesseract'], 381 | ids=['executable_name', 'absolute_path'], 382 | ) 383 | def test_wrong_tesseract_cmd(monkeypatch, test_file, test_path): 384 | """Test wrong or missing tesseract command.""" 385 | import pytesseract 386 | 387 | monkeypatch.setattr('pytesseract.pytesseract.tesseract_cmd', test_path) 388 | 389 | with pytest.raises(TesseractNotFoundError): 390 | pytesseract.get_languages.__wrapped__() 391 | 392 | with pytest.raises(TesseractNotFoundError): 393 | pytesseract.get_tesseract_version.__wrapped__() 394 | 395 | with pytest.raises(TesseractNotFoundError): 396 | pytesseract.image_to_string(test_file) 397 | 398 | 399 | def test_main_not_found_cases( 400 | capsys, 401 | monkeypatch, 402 | test_file, 403 | test_invalid_file, 404 | ): 405 | """Test wrong or missing tesseract command in main.""" 406 | import pytesseract 407 | 408 | monkeypatch.setattr('sys.argv', ['', test_invalid_file]) 409 | assert pytesseract.pytesseract.main() == 1 410 | captured = capsys.readouterr() 411 | assert ( 412 | 'No such file or directory' in captured.err 413 | and repr(test_invalid_file) in captured.err 414 | ) 415 | 416 | monkeypatch.setattr( 417 | 'pytesseract.pytesseract.tesseract_cmd', 418 | 'wrong_tesseract', 419 | ) 420 | monkeypatch.setattr('sys.argv', ['', test_file]) 421 | assert pytesseract.pytesseract.main() == 1 422 | assert ( 423 | "wrong_tesseract is not installed or it's not in your PATH. " 424 | 'See README file for more information.' in capsys.readouterr().err 425 | ) 426 | 427 | monkeypatch.setattr('sys.argv', ['']) 428 | assert pytesseract.pytesseract.main() == 2 429 | assert 'Usage: pytesseract [-l lang] input_file' in capsys.readouterr().err 430 | 431 | 432 | @pytest.mark.parametrize( 433 | 'test_path', 434 | [path.sep + r'wrong_tesseract', r''], 435 | ids=['permission_error_path', 'invalid_path'], 436 | ) 437 | def test_proper_oserror_exception_handling(monkeypatch, test_file, test_path): 438 | """ "Test for bubbling up OSError exceptions.""" 439 | import pytesseract 440 | 441 | monkeypatch.setattr( 442 | 'pytesseract.pytesseract.tesseract_cmd', 443 | test_path, 444 | ) 445 | 446 | with pytest.raises( 447 | TesseractNotFoundError if IS_PYTHON_2 and test_path else OSError, 448 | ): 449 | pytesseract.image_to_string(test_file) 450 | 451 | 452 | DEFAULT_LANGUAGES = ('fra', 'eng', 'osd') 453 | 454 | 455 | @pytest.mark.parametrize( 456 | 'test_config,expected', 457 | [ 458 | ('', DEFAULT_LANGUAGES), 459 | (f'--tessdata-dir {TESSDATA_DIR}/', ('dzo_test', 'eng')), 460 | ('--tessdata-dir /dev/null', ()), 461 | ('--tessdata-dir invalid_path/', ()), 462 | ('--tessdata-dir=invalid_config/', DEFAULT_LANGUAGES), 463 | ], 464 | ids=[ 465 | 'default_empty_config', 466 | 'custom_tessdata_dir', 467 | 'incorrect_tessdata_dir', 468 | 'invalid_tessdata_dir', 469 | 'invalid_config', 470 | ], 471 | ) 472 | def test_get_languages(test_config, expected): 473 | result = get_languages.__wrapped__(test_config) 474 | if not result: 475 | assert result == [] 476 | 477 | for lang in expected: 478 | assert lang in result 479 | 480 | 481 | @pytest.mark.parametrize( 482 | ('input_args', 'expected'), 483 | ( 484 | (('', ' ', 0), {}), 485 | (('\n', '\n', 0), {}), 486 | (('header1 header2 header3\n', '\t', 0), {}), 487 | ), 488 | ) 489 | def test_file_to_dict(input_args, expected): 490 | assert file_to_dict(*input_args) == expected 491 | 492 | 493 | @pytest.mark.parametrize( 494 | ('tesseract_version', 'expected'), 495 | ( 496 | (b'3.5.0', '3.5.0'), 497 | (b'4.1-a8s6f8d3f', '4.1'), 498 | (b'v4.0.0-beta1.9', '4.0.0'), 499 | ), 500 | ) 501 | def test_get_tesseract_version(tesseract_version, expected): 502 | with mock.patch('subprocess.check_output', spec=True) as output_mock: 503 | output_mock.return_value = tesseract_version 504 | assert get_tesseract_version.__wrapped__().public == expected 505 | 506 | 507 | @pytest.mark.parametrize( 508 | ('tesseract_version', 'expected_msg'), 509 | ( 510 | (b'', 'Invalid tesseract version: ""'), 511 | (b'invalid', 'Invalid tesseract version: "invalid"'), 512 | ), 513 | ) 514 | def test_get_tesseract_version_invalid(tesseract_version, expected_msg): 515 | with mock.patch('subprocess.check_output', spec=True) as output_mock: 516 | output_mock.return_value = tesseract_version 517 | with pytest.raises(SystemExit) as e: 518 | get_tesseract_version.__wrapped__() 519 | 520 | (msg,) = e.value.args 521 | assert msg == expected_msg 522 | 523 | 524 | @pytest.mark.parametrize( 525 | 'lang', 526 | [ 527 | 'bhu', 528 | 'eng', 529 | 'bhu_eng', 530 | 'eng_bhu', 531 | '7seg', 532 | 'bhu32', 533 | 'bhu32_7seg', 534 | '7seg_eng', 535 | ], 536 | ) 537 | def test_allowed_language_formats(lang): 538 | assert LANG_PATTERN.match(lang) 539 | -------------------------------------------------------------------------------- /pytesseract/pytesseract.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import annotations 3 | 4 | import logging 5 | import re 6 | import shlex 7 | import string 8 | import subprocess 9 | import sys 10 | from contextlib import contextmanager 11 | from csv import QUOTE_NONE 12 | from errno import ENOENT 13 | from functools import wraps 14 | from glob import iglob 15 | from io import BytesIO 16 | from os import environ 17 | from os import extsep 18 | from os import linesep 19 | from os import remove 20 | from os.path import normcase 21 | from os.path import normpath 22 | from os.path import realpath 23 | from tempfile import NamedTemporaryFile 24 | from time import sleep 25 | 26 | from packaging.version import InvalidVersion 27 | from packaging.version import parse 28 | from packaging.version import Version 29 | from PIL import Image 30 | 31 | 32 | tesseract_cmd = 'tesseract' 33 | 34 | try: 35 | from numpy import ndarray 36 | 37 | numpy_installed = True 38 | except ModuleNotFoundError: 39 | numpy_installed = False 40 | 41 | try: 42 | import pandas as pd 43 | 44 | pandas_installed = True 45 | except ModuleNotFoundError: 46 | pandas_installed = False 47 | 48 | LOGGER = logging.getLogger('pytesseract') 49 | 50 | DEFAULT_ENCODING = 'utf-8' 51 | LANG_PATTERN = re.compile('^[a-z0-9_]+$') 52 | RGB_MODE = 'RGB' 53 | SUPPORTED_FORMATS = { 54 | 'JPEG', 55 | 'JPEG2000', 56 | 'PNG', 57 | 'PBM', 58 | 'PGM', 59 | 'PPM', 60 | 'TIFF', 61 | 'BMP', 62 | 'GIF', 63 | 'WEBP', 64 | } 65 | 66 | OSD_KEYS = { 67 | 'Page number': ('page_num', int), 68 | 'Orientation in degrees': ('orientation', int), 69 | 'Rotate': ('rotate', int), 70 | 'Orientation confidence': ('orientation_conf', float), 71 | 'Script': ('script', str), 72 | 'Script confidence': ('script_conf', float), 73 | } 74 | 75 | EXTENTION_TO_CONFIG = { 76 | 'box': 'tessedit_create_boxfile=1 batch.nochop makebox', 77 | 'xml': 'tessedit_create_alto=1', 78 | 'hocr': 'tessedit_create_hocr=1', 79 | 'tsv': 'tessedit_create_tsv=1', 80 | } 81 | 82 | TESSERACT_MIN_VERSION = Version('3.05') 83 | TESSERACT_ALTO_VERSION = Version('4.1.0') 84 | 85 | 86 | class Output: 87 | BYTES = 'bytes' 88 | DATAFRAME = 'data.frame' 89 | DICT = 'dict' 90 | STRING = 'string' 91 | 92 | 93 | class PandasNotSupported(EnvironmentError): 94 | def __init__(self): 95 | super().__init__('Missing pandas package') 96 | 97 | 98 | class TesseractError(RuntimeError): 99 | def __init__(self, status, message): 100 | self.status = status 101 | self.message = message 102 | self.args = (status, message) 103 | 104 | 105 | class TesseractNotFoundError(EnvironmentError): 106 | def __init__(self): 107 | super().__init__( 108 | f"{tesseract_cmd} is not installed or it's not in your PATH." 109 | f' See README file for more information.', 110 | ) 111 | 112 | 113 | class TSVNotSupported(EnvironmentError): 114 | def __init__(self): 115 | super().__init__( 116 | 'TSV output not supported. Tesseract >= 3.05 required', 117 | ) 118 | 119 | 120 | class ALTONotSupported(EnvironmentError): 121 | def __init__(self): 122 | super().__init__( 123 | 'ALTO output not supported. Tesseract >= 4.1.0 required', 124 | ) 125 | 126 | 127 | def kill(process, code): 128 | process.terminate() 129 | try: 130 | process.wait(1) 131 | except TypeError: # python2 Popen.wait(1) fallback 132 | sleep(1) 133 | except Exception: # python3 subprocess.TimeoutExpired 134 | pass 135 | finally: 136 | process.kill() 137 | process.returncode = code 138 | 139 | 140 | @contextmanager 141 | def timeout_manager(proc, seconds=None): 142 | try: 143 | if not seconds: 144 | yield proc.communicate()[1] 145 | return 146 | 147 | try: 148 | _, error_string = proc.communicate(timeout=seconds) 149 | yield error_string 150 | except subprocess.TimeoutExpired: 151 | kill(proc, -1) 152 | raise RuntimeError('Tesseract process timeout') 153 | finally: 154 | proc.stdin.close() 155 | proc.stdout.close() 156 | proc.stderr.close() 157 | 158 | 159 | def run_once(func): 160 | @wraps(func) 161 | def wrapper(*args, **kwargs): 162 | if not kwargs.pop('cached', False) or wrapper._result is wrapper: 163 | wrapper._result = func(*args, **kwargs) 164 | return wrapper._result 165 | 166 | wrapper._result = wrapper 167 | return wrapper 168 | 169 | 170 | def get_errors(error_string): 171 | return ' '.join( 172 | line for line in error_string.decode(DEFAULT_ENCODING).splitlines() 173 | ).strip() 174 | 175 | 176 | def cleanup(temp_name): 177 | """Tries to remove temp files by filename wildcard path.""" 178 | for filename in iglob(f'{temp_name}*' if temp_name else temp_name): 179 | try: 180 | remove(filename) 181 | except OSError as e: 182 | if e.errno != ENOENT: 183 | raise 184 | 185 | 186 | def prepare(image): 187 | if numpy_installed and isinstance(image, ndarray): 188 | image = Image.fromarray(image) 189 | 190 | if not isinstance(image, Image.Image): 191 | raise TypeError('Unsupported image object') 192 | 193 | extension = 'PNG' if not image.format else image.format 194 | if extension not in SUPPORTED_FORMATS: 195 | raise TypeError('Unsupported image format/type') 196 | 197 | if 'A' in image.getbands(): 198 | # discard and replace the alpha channel with white background 199 | background = Image.new(RGB_MODE, image.size, (255, 255, 255)) 200 | background.paste(image, (0, 0), image.getchannel('A')) 201 | image = background 202 | 203 | image.format = extension 204 | return image, extension 205 | 206 | 207 | @contextmanager 208 | def save(image): 209 | try: 210 | with NamedTemporaryFile(prefix='tess_', delete=False) as f: 211 | if isinstance(image, str): 212 | yield f.name, realpath(normpath(normcase(image))) 213 | return 214 | image, extension = prepare(image) 215 | input_file_name = f'{f.name}_input{extsep}{extension}' 216 | image.save(input_file_name, format=image.format) 217 | yield f.name, input_file_name 218 | finally: 219 | cleanup(f.name) 220 | 221 | 222 | def subprocess_args(include_stdout=True): 223 | # See https://github.com/pyinstaller/pyinstaller/wiki/Recipe-subprocess 224 | # for reference and comments. 225 | 226 | kwargs = { 227 | 'stdin': subprocess.PIPE, 228 | 'stderr': subprocess.PIPE, 229 | 'startupinfo': None, 230 | 'env': environ, 231 | } 232 | 233 | if hasattr(subprocess, 'STARTUPINFO'): 234 | kwargs['startupinfo'] = subprocess.STARTUPINFO() 235 | kwargs['startupinfo'].dwFlags |= subprocess.STARTF_USESHOWWINDOW 236 | kwargs['startupinfo'].wShowWindow = subprocess.SW_HIDE 237 | 238 | if include_stdout: 239 | kwargs['stdout'] = subprocess.PIPE 240 | else: 241 | kwargs['stdout'] = subprocess.DEVNULL 242 | 243 | return kwargs 244 | 245 | 246 | def run_tesseract( 247 | input_filename, 248 | output_filename_base, 249 | extension, 250 | lang, 251 | config='', 252 | nice=0, 253 | timeout=0, 254 | ): 255 | cmd_args = [] 256 | not_windows = not (sys.platform == 'win32') 257 | 258 | if not_windows and nice != 0: 259 | cmd_args += ('nice', '-n', str(nice)) 260 | 261 | cmd_args += (tesseract_cmd, input_filename, output_filename_base) 262 | 263 | if lang is not None: 264 | cmd_args += ('-l', lang) 265 | 266 | if config: 267 | cmd_args += shlex.split(config, posix=not_windows) 268 | 269 | for _extension in extension.split(): 270 | if _extension not in {'box', 'osd', 'tsv', 'xml'}: 271 | cmd_args.append(_extension) 272 | LOGGER.debug('%r', cmd_args) 273 | 274 | try: 275 | proc = subprocess.Popen(cmd_args, **subprocess_args()) 276 | except OSError as e: 277 | if e.errno != ENOENT: 278 | raise 279 | else: 280 | raise TesseractNotFoundError() 281 | 282 | with timeout_manager(proc, timeout) as error_string: 283 | if proc.returncode: 284 | raise TesseractError(proc.returncode, get_errors(error_string)) 285 | 286 | 287 | def _read_output(filename: str, return_bytes: bool = False): 288 | with open(filename, 'rb') as output_file: 289 | if return_bytes: 290 | return output_file.read() 291 | return output_file.read().decode(DEFAULT_ENCODING) 292 | 293 | 294 | def run_and_get_multiple_output( 295 | image, 296 | extensions: list[str], 297 | lang: str | None = None, 298 | nice: int = 0, 299 | timeout: int = 0, 300 | return_bytes: bool = False, 301 | ): 302 | config = ' '.join( 303 | EXTENTION_TO_CONFIG.get(extension, '') for extension in extensions 304 | ).strip() 305 | if config: 306 | config = f'-c {config}' 307 | else: 308 | config = '' 309 | 310 | with save(image) as (temp_name, input_filename): 311 | kwargs = { 312 | 'input_filename': input_filename, 313 | 'output_filename_base': temp_name, 314 | 'extension': ' '.join(extensions), 315 | 'lang': lang, 316 | 'config': config, 317 | 'nice': nice, 318 | 'timeout': timeout, 319 | } 320 | 321 | run_tesseract(**kwargs) 322 | 323 | return [ 324 | _read_output( 325 | f"{kwargs['output_filename_base']}{extsep}{extension}", 326 | True if extension in {'pdf', 'hocr'} else return_bytes, 327 | ) 328 | for extension in extensions 329 | ] 330 | 331 | 332 | def run_and_get_output( 333 | image, 334 | extension='', 335 | lang=None, 336 | config='', 337 | nice=0, 338 | timeout=0, 339 | return_bytes=False, 340 | ): 341 | with save(image) as (temp_name, input_filename): 342 | kwargs = { 343 | 'input_filename': input_filename, 344 | 'output_filename_base': temp_name, 345 | 'extension': extension, 346 | 'lang': lang, 347 | 'config': config, 348 | 'nice': nice, 349 | 'timeout': timeout, 350 | } 351 | 352 | run_tesseract(**kwargs) 353 | return _read_output( 354 | f"{kwargs['output_filename_base']}{extsep}{extension}", 355 | return_bytes, 356 | ) 357 | 358 | 359 | def file_to_dict(tsv, cell_delimiter, str_col_idx): 360 | result = {} 361 | rows = [row.split(cell_delimiter) for row in tsv.strip().split('\n')] 362 | if len(rows) < 2: 363 | return result 364 | 365 | header = rows.pop(0) 366 | length = len(header) 367 | if len(rows[-1]) < length: 368 | # Fixes bug that occurs when last text string in TSV is null, and 369 | # last row is missing a final cell in TSV file 370 | rows[-1].append('') 371 | 372 | if str_col_idx < 0: 373 | str_col_idx += length 374 | 375 | for i, head in enumerate(header): 376 | result[head] = list() 377 | for row in rows: 378 | if len(row) <= i: 379 | continue 380 | 381 | if i != str_col_idx: 382 | try: 383 | val = int(float(row[i])) 384 | except ValueError: 385 | val = row[i] 386 | else: 387 | val = row[i] 388 | 389 | result[head].append(val) 390 | 391 | return result 392 | 393 | 394 | def is_valid(val, _type): 395 | if _type is int: 396 | return val.isdigit() 397 | 398 | if _type is float: 399 | try: 400 | float(val) 401 | return True 402 | except ValueError: 403 | return False 404 | 405 | return True 406 | 407 | 408 | def osd_to_dict(osd): 409 | return { 410 | OSD_KEYS[kv[0]][0]: OSD_KEYS[kv[0]][1](kv[1]) 411 | for kv in (line.split(': ') for line in osd.split('\n')) 412 | if len(kv) == 2 and is_valid(kv[1], OSD_KEYS[kv[0]][1]) 413 | } 414 | 415 | 416 | @run_once 417 | def get_languages(config=''): 418 | cmd_args = [tesseract_cmd, '--list-langs'] 419 | if config: 420 | cmd_args += shlex.split(config) 421 | 422 | try: 423 | result = subprocess.run( 424 | cmd_args, 425 | stdout=subprocess.PIPE, 426 | stderr=subprocess.STDOUT, 427 | ) 428 | except OSError: 429 | raise TesseractNotFoundError() 430 | 431 | # tesseract 3.x 432 | if result.returncode not in (0, 1): 433 | raise TesseractNotFoundError() 434 | 435 | languages = [] 436 | if result.stdout: 437 | for line in result.stdout.decode(DEFAULT_ENCODING).split(linesep): 438 | lang = line.strip() 439 | if LANG_PATTERN.match(lang): 440 | languages.append(lang) 441 | 442 | return languages 443 | 444 | 445 | @run_once 446 | def get_tesseract_version(): 447 | """ 448 | Returns Version object of the Tesseract version 449 | """ 450 | try: 451 | output = subprocess.check_output( 452 | [tesseract_cmd, '--version'], 453 | stderr=subprocess.STDOUT, 454 | env=environ, 455 | stdin=subprocess.DEVNULL, 456 | ) 457 | except OSError: 458 | raise TesseractNotFoundError() 459 | 460 | raw_version = output.decode(DEFAULT_ENCODING) 461 | str_version, *_ = raw_version.lstrip(string.printable[10:]).partition(' ') 462 | str_version, *_ = str_version.partition('-') 463 | 464 | try: 465 | version = parse(str_version) 466 | assert version >= TESSERACT_MIN_VERSION 467 | except (AssertionError, InvalidVersion): 468 | raise SystemExit(f'Invalid tesseract version: "{raw_version}"') 469 | 470 | return version 471 | 472 | 473 | def image_to_string( 474 | image, 475 | lang=None, 476 | config='', 477 | nice=0, 478 | output_type=Output.STRING, 479 | timeout=0, 480 | ): 481 | """ 482 | Returns the result of a Tesseract OCR run on the provided image to string 483 | """ 484 | args = [image, 'txt', lang, config, nice, timeout] 485 | 486 | return { 487 | Output.BYTES: lambda: run_and_get_output(*(args + [True])), 488 | Output.DICT: lambda: {'text': run_and_get_output(*args)}, 489 | Output.STRING: lambda: run_and_get_output(*args), 490 | }[output_type]() 491 | 492 | 493 | def image_to_pdf_or_hocr( 494 | image, 495 | lang=None, 496 | config='', 497 | nice=0, 498 | extension='pdf', 499 | timeout=0, 500 | ): 501 | """ 502 | Returns the result of a Tesseract OCR run on the provided image to pdf/hocr 503 | """ 504 | 505 | if extension not in {'pdf', 'hocr'}: 506 | raise ValueError(f'Unsupported extension: {extension}') 507 | 508 | if extension == 'hocr': 509 | config = f'-c tessedit_create_hocr=1 {config.strip()}' 510 | 511 | args = [image, extension, lang, config, nice, timeout, True] 512 | 513 | return run_and_get_output(*args) 514 | 515 | 516 | def image_to_alto_xml( 517 | image, 518 | lang=None, 519 | config='', 520 | nice=0, 521 | timeout=0, 522 | ): 523 | """ 524 | Returns the result of a Tesseract OCR run on the provided image to ALTO XML 525 | """ 526 | 527 | if get_tesseract_version(cached=True) < TESSERACT_ALTO_VERSION: 528 | raise ALTONotSupported() 529 | 530 | config = f'-c tessedit_create_alto=1 {config.strip()}' 531 | args = [image, 'xml', lang, config, nice, timeout, True] 532 | 533 | return run_and_get_output(*args) 534 | 535 | 536 | def image_to_boxes( 537 | image, 538 | lang=None, 539 | config='', 540 | nice=0, 541 | output_type=Output.STRING, 542 | timeout=0, 543 | ): 544 | """ 545 | Returns string containing recognized characters and their box boundaries 546 | """ 547 | config = ( 548 | f'{config.strip()} -c tessedit_create_boxfile=1 batch.nochop makebox' 549 | ) 550 | args = [image, 'box', lang, config, nice, timeout] 551 | 552 | return { 553 | Output.BYTES: lambda: run_and_get_output(*(args + [True])), 554 | Output.DICT: lambda: file_to_dict( 555 | f'char left bottom right top page\n{run_and_get_output(*args)}', 556 | ' ', 557 | 0, 558 | ), 559 | Output.STRING: lambda: run_and_get_output(*args), 560 | }[output_type]() 561 | 562 | 563 | def get_pandas_output(args, config=None): 564 | if not pandas_installed: 565 | raise PandasNotSupported() 566 | 567 | kwargs = {'quoting': QUOTE_NONE, 'sep': '\t'} 568 | try: 569 | kwargs.update(config) 570 | except (TypeError, ValueError): 571 | pass 572 | 573 | return pd.read_csv(BytesIO(run_and_get_output(*args)), **kwargs) 574 | 575 | 576 | def image_to_data( 577 | image, 578 | lang=None, 579 | config='', 580 | nice=0, 581 | output_type=Output.STRING, 582 | timeout=0, 583 | pandas_config=None, 584 | ): 585 | """ 586 | Returns string containing box boundaries, confidences, 587 | and other information. Requires Tesseract 3.05+ 588 | """ 589 | 590 | if get_tesseract_version(cached=True) < TESSERACT_MIN_VERSION: 591 | raise TSVNotSupported() 592 | 593 | config = f'-c tessedit_create_tsv=1 {config.strip()}' 594 | args = [image, 'tsv', lang, config, nice, timeout] 595 | 596 | return { 597 | Output.BYTES: lambda: run_and_get_output(*(args + [True])), 598 | Output.DATAFRAME: lambda: get_pandas_output( 599 | args + [True], 600 | pandas_config, 601 | ), 602 | Output.DICT: lambda: file_to_dict(run_and_get_output(*args), '\t', -1), 603 | Output.STRING: lambda: run_and_get_output(*args), 604 | }[output_type]() 605 | 606 | 607 | def image_to_osd( 608 | image, 609 | lang='osd', 610 | config='', 611 | nice=0, 612 | output_type=Output.STRING, 613 | timeout=0, 614 | ): 615 | """ 616 | Returns string containing the orientation and script detection (OSD) 617 | """ 618 | config = f'--psm 0 {config.strip()}' 619 | args = [image, 'osd', lang, config, nice, timeout] 620 | 621 | return { 622 | Output.BYTES: lambda: run_and_get_output(*(args + [True])), 623 | Output.DICT: lambda: osd_to_dict(run_and_get_output(*args)), 624 | Output.STRING: lambda: run_and_get_output(*args), 625 | }[output_type]() 626 | 627 | 628 | def main(): 629 | if len(sys.argv) == 2: 630 | filename, lang = sys.argv[1], None 631 | elif len(sys.argv) == 4 and sys.argv[1] == '-l': 632 | filename, lang = sys.argv[3], sys.argv[2] 633 | else: 634 | print('Usage: pytesseract [-l lang] input_file\n', file=sys.stderr) 635 | return 2 636 | 637 | try: 638 | with Image.open(filename) as img: 639 | print(image_to_string(img, lang=lang)) 640 | except TesseractNotFoundError as e: 641 | print(f'{str(e)}\n', file=sys.stderr) 642 | return 1 643 | except OSError as e: 644 | print(f'{type(e).__name__}: {e}', file=sys.stderr) 645 | return 1 646 | 647 | 648 | if __name__ == '__main__': 649 | raise SystemExit(main()) 650 | --------------------------------------------------------------------------------