67 Commits

Author SHA1 Message Date
Vinayak Mehta 6744e0525a Fix #21 2019-10-15 15:24:21 +05:30
Vinayak Mehta f1879726d9 Merge pull request #86 from pravarag/add-opencollective
[MRG] Update python-tk link
2019-10-15 12:19:07 +05:30
Pravar Agrawal 56f3b54f62 [PyConIndia] Update python-tk link in README 2019-10-15 11:25:57 +05:30
Vinayak Mehta 11fadb16fd Merge pull request #65 from pravarag/add-opencollective
[MRG] Add opencollective url
2019-10-14 23:58:12 +05:30
Vinayak Mehta 2340833bb8 Merge branch 'master' into add-opencollective 2019-10-14 23:57:40 +05:30
Vinayak Mehta 7ce4cb5050 Merge pull request #57 from dcorriveau/master
Update README.md
2019-10-14 23:56:40 +05:30
Vinayak Mehta 78e5dd1f4e Fix #56 2019-10-14 23:55:51 +05:30
Vinayak Mehta 857f68ef6b Update README.md 2019-10-14 23:52:38 +05:30
Vinayak Mehta 14c3cb49b9 Merge pull request #70 from kishvanchee/fix67
[MRG] Add 3.7 version to installation docs
2019-10-14 23:50:44 +05:30
Kishore Vancheeshwaran a6d32ecddb added 3.7 version to installation docs 2019-10-14 12:52:51 +05:30
Pravar Agrawal e5e02401da [DevSprint] convert camelot to uppercase in README 2019-10-14 12:51:09 +05:30
Pravar Agrawal ea3eac3c40 [DevSprint] push new branch, fix typo in index.rst change 2019-10-14 12:33:13 +05:30
Pravar Agrawal 28a8112c6d [DevSprint] Fix typo in index.rst for opencollective link 2019-10-14 12:25:17 +05:30
Pravar Agrawal 45384106c8 [DevSprint] Add opencollective link to README and Docs 2019-10-14 12:21:09 +05:30
Dylan Corriveau 81729f57cc Update README.md 2019-10-13 16:00:04 -04:00
Vinayak Mehta 7ecfcad239 Update HISTORY.md 2019-07-28 21:46:55 +10:00
Dimiter Naydenov b2929a9e92 Merge pull request #34 from KOLANICH/win_ghostscript_callback_fix
Fixed calling convention of callback functions
2019-07-24 13:39:18 +03:00
Dimiter Naydenov 6d33c7ff1e Merge pull request #32 from KOLANICH/ghostscript_discovery_win
Fixed library discovery on Windows
2019-07-24 13:38:51 +03:00
KOLANICH 5687fbc8b2 Fixed calling convention of callback functions 2019-07-16 21:08:34 +03:00
KOLANICH 9e356b1b0a Fixed library discovery on Windows 2019-07-16 21:07:23 +03:00
Vinayak Mehta f7b94b3e57 Add black badge to index.rst 2019-07-07 16:15:04 +05:30
Vinayak Mehta 0efb3ca1b0 Update HISTORY.md and bump version 2019-07-07 16:07:28 +05:30
Vinayak Mehta 098f7c6727 Merge pull request #26 from camelot-dev/fix-25
[MRG] Update flavor kwargs
2019-07-06 23:42:09 +05:30
Vinayak Mehta a97b50ef21 Update flavor kwargs 2019-07-06 22:59:51 +05:30
Vinayak Mehta e0e4eeb6d4 Rebuild 2019-07-06 04:36:16 +05:30
Vinayak Mehta b8c55383ea Update docs 2019-07-06 04:28:32 +05:30
Dimiter Naydenov 0f8cda4793 Merge pull request #5 from camelot-dev/fix-cli-group-name
[MRG] No need to monkey-patch Click.HelpFormatter
2019-07-04 18:26:35 +03:00
Dimiter Naydenov e81e818b0e Merge pull request #4 from camelot-dev/fix-strip-text-arg
[MRG] Fixed strip_text argument getting ignored
2019-07-04 18:26:11 +03:00
Dimiter Naydenov 13616c2fb4 No need to monkey-patch Click.HelpFormatter 2019-07-04 13:13:32 +03:00
Dimiter Naydenov 240ea6c411 Fixed strip_text argument getting ignored 2019-07-04 12:12:52 +03:00
Vinayak Mehta d5df93635e Merge pull request #3 from camelot-dev/code-style-black-badge
[MRG] Add code style: black badge to README.md
2019-07-04 01:10:51 +05:30
Dimiter Naydenov 245731345c Add code style: black badge to README.md
Now PR #1 got merged, we can wear this proudly :)
2019-07-03 21:58:22 +03:00
Vinayak Mehta 9137df2f6c Merge pull request #1 from camelot-dev/blacken-code
[MRG] Blacken code
2019-07-04 00:20:57 +05:30
Vinayak Mehta 16ddd10644 Update image_processing.py 2019-07-04 00:06:46 +05:30
Vinayak Mehta 2115a0e177 Blacken code 2019-07-03 23:47:42 +05:30
Vinayak Mehta 27d55d056c Merge pull request #2 from camelot-dev/fix-pytest-dep-warning
[MRG] Fix pytest deprecation warning
2019-07-03 23:20:43 +05:30
Vinayak Mehta 8866eaa3b6 Fix pytest deprecation warning 2019-07-03 22:07:10 +05:30
Vinayak Mehta 78d80555d8 Update LICENSE and fix travis 2019-07-03 20:46:18 +05:30
Himanshu Sikaria 69767beb7b Update README.md 2019-06-30 21:57:10 +05:30
Vinayak Mehta de97be23a4 Merge pull request #332 from socialcopsdev/fix-312
[MRG] Fix #312
2019-05-27 22:49:04 +05:30
Vinayak Mehta 8d9fdb740e Update HISTORY.md 2019-05-27 22:48:45 +05:30
Vinayak Mehta 477568dea7 Fix test 2019-05-27 22:29:50 +05:30
Vinayak Mehta de3281c1b6 Add test 2019-05-27 22:18:23 +05:30
Vinayak Mehta b2a8348f13 Fix #312 2019-05-26 17:13:59 +05:30
Vinayak Mehta 857edcd86e Merge pull request #250 from davidkong0987/patch-1
[MRG] Update advanced.rst
2019-05-26 16:57:09 +05:30
Vinayak Mehta 8c11608078 Update advanced.rst 2019-05-26 16:44:26 +05:30
Vinayak Mehta a1b85d2c91 Merge pull request #319 from Suyash458/fix-#298
[MRG] add -strip to cli docs
2019-05-08 12:14:18 -04:00
Vinayak Mehta 4355bc98ab Update cli.rst 2019-05-08 12:13:47 -04:00
Suyash458 3ac9318300 add -strip to cli docs 2019-04-27 14:46:47 +05:30
Vinayak Mehta 934065ada6 Merge pull request #294 from socialcopsdev/fix-split-bug
[MRG] Fix split text bug
2019-04-20 21:30:21 +05:30
Vinayak Mehta 355ae818a0 Merge branch 'master' into fix-split-bug 2019-04-20 21:06:47 +05:30
Vinayak Mehta 7ff8b5b89c Update HISTORY.md 2019-04-20 21:05:05 +05:30
Vinayak Mehta 3071548898 Update HISTORY.md 2019-04-20 21:04:09 +05:30
Vinayak Mehta ce727d9558 Fix split text bug 2019-03-22 02:28:29 +05:30
Vinayak Mehta ecf6febaa7 Update HISTORY.md 2019-03-08 21:20:43 +05:30
Vinayak Mehta a5343dcc25 Merge pull request #283 from symroe/277_table_sorting
[MRG] Sort TableList by order of tables in PDF
2019-03-08 21:18:51 +05:30
Vinayak Mehta 88466b8c4e Rename _mk_table to _make_table 2019-03-08 21:04:34 +05:30
Sym Roe 8446271aa4 Always sort TableList after reading PDF 2019-02-25 09:48:47 +00:00
Sym Roe c019e582bf Add __lt__ to Table to allow sorting
Refs #277
2019-02-25 09:20:09 +00:00
Yatin Taluja 8ea4ec3de8 Merge pull request #257 from yatintaluja/fix-245
[MRG] Fix AttributeError for encrypted files
2019-01-16 16:55:31 +05:30
yatintaluja 6c4b468800 Fix #245 2019-01-16 16:33:17 +05:30
yatintaluja 5330620ea2 Bump version 2019-01-16 16:30:05 +05:30
davidkong0987 24e58d0759 Update advanced.rst
added .. note:: in front
2019-01-10 00:49:42 -05:00
davidkong0987 88a6d0b761 Update advanced.rst
added .. note:: in front
2019-01-10 00:48:34 -05:00
davidkong0987 ddec47964c Update advanced.rst 2019-01-09 10:05:51 -05:00
Vinayak Mehta 45ae980988 Bump version 2019-01-06 13:00:08 +05:30
Vinayak Mehta 215e5ea2a5 Move ghostscript import 2019-01-06 01:50:54 +05:30
33 changed files with 3790 additions and 968 deletions
+4 -1
View File
@@ -12,5 +12,8 @@ coverage.xml
.pytest_cache/ .pytest_cache/
_build/ _build/
.venv/
htmlcov/
# vscode # vscode
.vscode .vscode
+3 -3
View File
@@ -16,7 +16,7 @@ As the [Requests Code Of Conduct](http://docs.python-requests.org/en/master/dev/
## Your first contribution ## Your first contribution
A great way to start contributing to Camelot is to pick an issue tagged with the [help wanted](https://github.com/socialcopsdev/camelot/labels/help%20wanted) tag or the [good first issue](https://github.com/socialcopsdev/camelot/labels/good%20first%20issue) tag. If you're unable to find a good first issue, feel free to contact the maintainer. A great way to start contributing to Camelot is to pick an issue tagged with the [help wanted](https://github.com/camelot-dev/camelot/labels/help%20wanted) tag or the [good first issue](https://github.com/camelot-dev/camelot/labels/good%20first%20issue) tag. If you're unable to find a good first issue, feel free to contact the maintainer.
## Setting up a development environment ## Setting up a development environment
@@ -36,7 +36,7 @@ $ pip install ".[dev]"
### Submit a pull request ### Submit a pull request
The preferred workflow for contributing to Camelot is to fork the [project repository](https://github.com/socialcopsdev/camelot) on GitHub, clone, develop on a branch and then finally submit a pull request. Here are the steps: The preferred workflow for contributing to Camelot is to fork the [project repository](https://github.com/camelot-dev/camelot) on GitHub, clone, develop on a branch and then finally submit a pull request. Here are the steps:
1. Fork the project repository. Click on the Fork button near the top of the page. This creates a copy of the code under your account on the GitHub. 1. Fork the project repository. Click on the Fork button near the top of the page. This creates a copy of the code under your account on the GitHub.
@@ -106,7 +106,7 @@ The function docstrings are written using the [numpydoc](https://numpydoc.readth
## Filing Issues ## Filing Issues
We use [GitHub issues](https://github.com/socialcopsdev/camelot/issues) to keep track of all issues and pull requests. Before opening an issue (which asks a question or reports a bug), please use GitHub search to look for existing issues (both open and closed) that may be similar. We use [GitHub issues](https://github.com/camelot-dev/camelot/issues) to keep track of all issues and pull requests. Before opening an issue (which asks a question or reports a bug), please use GitHub search to look for existing issues (both open and closed) that may be similar.
### Questions ### Questions
+37
View File
@@ -4,6 +4,43 @@ Release History
master master
------ ------
**Bugfixes**
* Fix library discovery on Windows. [#32](https://github.com/camelot-dev/camelot/pull/32) by [KOLANICH](https://github.com/KOLANICH).
* Fix calling convention of callback functions. [#34](https://github.com/camelot-dev/camelot/pull/34) by [KOLANICH](https://github.com/KOLANICH).
0.7.3 (2019-07-07)
------------------
**Improvements**
* Camelot now follows the Black code style! [#1](https://github.com/camelot-dev/camelot/pull/1) and [#3](https://github.com/camelot-dev/camelot/pull/3).
**Bugfixes**
* Fix Click.HelpFormatter monkey-patch. [#5](https://github.com/camelot-dev/camelot/pull/5) by [Dimiter Naydenov](https://github.com/dimitern).
* Fix strip_text argument getting ignored. [#4](https://github.com/camelot-dev/camelot/pull/4) by [Dimiter Naydenov](https://github.com/dimitern).
* [#25](https://github.com/camelot-dev/camelot/issues/25) edge_tol skipped in read_pdf. [#26](https://github.com/camelot-dev/camelot/pull/26) by Vinayak Mehta.
* Fix pytest deprecation warning. [#2](https://github.com/camelot-dev/camelot/pull/2) by Vinayak Mehta.
* [#293](https://github.com/socialcopsdev/camelot/issues/293) Split text ignores all text to the right of last cut. [#294](https://github.com/socialcopsdev/camelot/pull/294) by Vinayak Mehta.
* [#277](https://github.com/socialcopsdev/camelot/issues/277) Sort TableList by order of tables in PDF. [#283](https://github.com/socialcopsdev/camelot/pull/283) by [Sym Roe](https://github.com/symroe).
* [#312](https://github.com/socialcopsdev/camelot/issues/312) `table_regions` throws `ValueError` when `flavor='stream'`. [#332](https://github.com/socialcopsdev/camelot/pull/332) by Vinayak Mehta.
0.7.2 (2019-01-10)
------------------
**Bugfixes**
* [#245](https://github.com/socialcopsdev/camelot/issues/245) Fix AttributeError for encrypted files. [#251](https://github.com/socialcopsdev/camelot/pull/251) by Yatin Taluja.
0.7.1 (2019-01-06)
------------------
**Bugfixes**
* Move ghostscript import to inside the function so Anaconda builds don't fail.
0.7.0 (2019-01-05) 0.7.0 (2019-01-05)
------------------ ------------------
+6
View File
@@ -1,5 +1,11 @@
MIT License MIT License
Modifications:
Copyright (c) 2019 Camelot Developers
Original project:
Copyright (c) 2018 Peeply Private Ltd (Singapore) Copyright (c) 2018 Peeply Private Ltd (Singapore)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
+18 -10
View File
@@ -1,12 +1,14 @@
<p align="center"> <p align="center">
<img src="https://raw.githubusercontent.com/socialcopsdev/camelot/master/docs/_static/camelot.png" width="200"> <img src="https://raw.githubusercontent.com/camelot-dev/camelot/master/docs/_static/camelot.png" width="200">
</p> </p>
# Camelot: PDF Table Extraction for Humans # Camelot: PDF Table Extraction for Humans
[![Build Status](https://travis-ci.org/socialcopsdev/camelot.svg?branch=master)](https://travis-ci.org/socialcopsdev/camelot) [![Documentation Status](https://readthedocs.org/projects/camelot-py/badge/?version=master)](https://camelot-py.readthedocs.io/en/master/) [![Build Status](https://travis-ci.org/camelot-dev/camelot.svg?branch=master)](https://travis-ci.org/camelot-dev/camelot) [![Documentation Status](https://readthedocs.org/projects/camelot-py/badge/?version=master)](https://camelot-py.readthedocs.io/en/master/)
[![codecov.io](https://codecov.io/github/socialcopsdev/camelot/badge.svg?branch=master&service=github)](https://codecov.io/github/socialcopsdev/camelot?branch=master) [![codecov.io](https://codecov.io/github/camelot-dev/camelot/badge.svg?branch=master&service=github)](https://codecov.io/github/camelot-dev/camelot?branch=master)
[![image](https://img.shields.io/pypi/v/camelot-py.svg)](https://pypi.org/project/camelot-py/) [![image](https://img.shields.io/pypi/l/camelot-py.svg)](https://pypi.org/project/camelot-py/) [![image](https://img.shields.io/pypi/pyversions/camelot-py.svg)](https://pypi.org/project/camelot-py/) [![Gitter chat](https://badges.gitter.im/camelot-dev/Lobby.png)](https://gitter.im/camelot-dev/Lobby) [![image](https://img.shields.io/pypi/v/camelot-py.svg)](https://pypi.org/project/camelot-py/) [![image](https://img.shields.io/pypi/l/camelot-py.svg)](https://pypi.org/project/camelot-py/) [![image](https://img.shields.io/pypi/pyversions/camelot-py.svg)](https://pypi.org/project/camelot-py/) [![Gitter chat](https://badges.gitter.im/camelot-dev/Lobby.png)](https://gitter.im/camelot-dev/Lobby)
[![image](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)
**Camelot** is a Python library that makes it easy for *anyone* to extract tables from PDF files! **Camelot** is a Python library that makes it easy for *anyone* to extract tables from PDF files!
@@ -14,7 +16,7 @@
--- ---
**Here's how you can extract tables from PDF files.** Check out the PDF used in this example [here](https://github.com/socialcopsdev/camelot/blob/master/docs/_static/pdf/foo.pdf). **Here's how you can extract tables from PDF files.** Check out the PDF used in this example [here](https://github.com/camelot-dev/camelot/blob/master/docs/_static/pdf/foo.pdf).
<pre> <pre>
>>> import camelot >>> import camelot
@@ -55,7 +57,7 @@ There's a [command-line interface](https://camelot-py.readthedocs.io/en/master/u
- Each table is a **pandas DataFrame**, which seamlessly integrates into [ETL and data analysis workflows](https://gist.github.com/vinayak-mehta/e5949f7c2410a0e12f25d3682dc9e873). - Each table is a **pandas DataFrame**, which seamlessly integrates into [ETL and data analysis workflows](https://gist.github.com/vinayak-mehta/e5949f7c2410a0e12f25d3682dc9e873).
- **Export** to multiple formats, including JSON, Excel, HTML and Sqlite. - **Export** to multiple formats, including JSON, Excel, HTML and Sqlite.
See [comparison with other PDF table extraction libraries and tools](https://github.com/socialcopsdev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools). See [comparison with other PDF table extraction libraries and tools](https://github.com/camelot-dev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools).
## Installation ## Installation
@@ -69,7 +71,7 @@ $ conda install -c conda-forge camelot-py
### Using pip ### Using pip
After [installing the dependencies](https://camelot-py.readthedocs.io/en/master/user/install-deps.html) ([tk](https://packages.ubuntu.com/trusty/python-tk) and [ghostscript](https://www.ghostscript.com/)), you can simply use pip to install Camelot: After [installing the dependencies](https://camelot-py.readthedocs.io/en/master/user/install-deps.html) ([tk](https://packages.ubuntu.com/bionic/python/python-tk) and [ghostscript](https://www.ghostscript.com/)), you can simply use pip to install Camelot:
<pre> <pre>
$ pip install camelot-py[cv] $ pip install camelot-py[cv]
@@ -80,7 +82,7 @@ $ pip install camelot-py[cv]
After [installing the dependencies](https://camelot-py.readthedocs.io/en/master/user/install.html#using-pip), clone the repo using: After [installing the dependencies](https://camelot-py.readthedocs.io/en/master/user/install.html#using-pip), clone the repo using:
<pre> <pre>
$ git clone https://www.github.com/socialcopsdev/camelot $ git clone https://www.github.com/camelot-dev/camelot
</pre> </pre>
and install Camelot using pip: and install Camelot using pip:
@@ -103,7 +105,7 @@ The [Contributor's Guide](https://camelot-py.readthedocs.io/en/master/dev/contri
You can check the latest sources with: You can check the latest sources with:
<pre> <pre>
$ git clone https://www.github.com/socialcopsdev/camelot $ git clone https://www.github.com/camelot-dev/camelot
</pre> </pre>
### Setting up a development environment ### Setting up a development environment
@@ -124,8 +126,14 @@ $ python setup.py test
## Versioning ## Versioning
Camelot uses [Semantic Versioning](https://semver.org/). For the available versions, see the tags on this repository. For the changelog, you can check out [HISTORY.md](https://github.com/socialcopsdev/camelot/blob/master/HISTORY.md). Camelot uses [Semantic Versioning](https://semver.org/). For the available versions, see the tags on this repository. For the changelog, you can check out [HISTORY.md](https://github.com/camelot-dev/camelot/blob/master/HISTORY.md).
## License ## License
This project is licensed under the MIT License, see the [LICENSE](https://github.com/socialcopsdev/camelot/blob/master/LICENSE) file for details. This project is licensed under the MIT License, see the [LICENSE](https://github.com/camelot-dev/camelot/blob/master/LICENSE) file for details.
## Support the development
You can support our work on Camelot with a one-time or monthly donation [on OpenCollective](https://opencollective.com/camelot). Organizations who use camelot can also sponsor the project for an acknowledgement on [our documentation site](https://camelot-py.readthedocs.io/en/master/) and this README.
Special thanks to all the users, organizations and contributors that support Camelot!
+3 -13
View File
@@ -2,26 +2,16 @@
import logging import logging
from click import HelpFormatter
from .__version__ import __version__ from .__version__ import __version__
from .io import read_pdf from .io import read_pdf
from .plotting import PlotMethods from .plotting import PlotMethods
def _write_usage(self, prog, args='', prefix='Usage: '):
return self._write_usage('camelot', args, prefix=prefix)
# monkey patch click.HelpFormatter
HelpFormatter._write_usage = HelpFormatter.write_usage
HelpFormatter.write_usage = _write_usage
# set up logging # set up logging
logger = logging.getLogger('camelot') logger = logging.getLogger("camelot")
format_string = '%(asctime)s - %(levelname)s - %(message)s' format_string = "%(asctime)s - %(levelname)s - %(message)s"
formatter = logging.Formatter(format_string, datefmt='%Y-%m-%dT%H:%M:%S') formatter = logging.Formatter(format_string, datefmt="%Y-%m-%dT%H:%M:%S")
handler = logging.StreamHandler() handler = logging.StreamHandler()
handler.setFormatter(formatter) handler.setFormatter(formatter)
+1 -1
View File
@@ -3,7 +3,7 @@
from __future__ import absolute_import from __future__ import absolute_import
__all__ = ('main',) __all__ = ("main",)
def main(): def main():
+12 -12
View File
@@ -1,23 +1,23 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
VERSION = (0, 7, 0) VERSION = (0, 7, 3)
PRERELEASE = None # alpha, beta or rc PRERELEASE = None # alpha, beta or rc
REVISION = None REVISION = None
def generate_version(version, prerelease=None, revision=None): def generate_version(version, prerelease=None, revision=None):
version_parts = ['.'.join(map(str, version))] version_parts = [".".join(map(str, version))]
if prerelease is not None: if prerelease is not None:
version_parts.append('-{}'.format(prerelease)) version_parts.append("-{}".format(prerelease))
if revision is not None: if revision is not None:
version_parts.append('.{}'.format(revision)) version_parts.append(".{}".format(revision))
return ''.join(version_parts) return "".join(version_parts)
__title__ = 'camelot-py' __title__ = "camelot-py"
__description__ = 'PDF Table Extraction for Humans.' __description__ = "PDF Table Extraction for Humans."
__url__ = 'http://camelot-py.readthedocs.io/' __url__ = "http://camelot-py.readthedocs.io/"
__version__ = generate_version(VERSION, prerelease=PRERELEASE, revision=REVISION) __version__ = generate_version(VERSION, prerelease=PRERELEASE, revision=REVISION)
__author__ = 'Vinayak Mehta' __author__ = "Vinayak Mehta"
__author_email__ = 'vmehta94@gmail.com' __author_email__ = "vmehta94@gmail.com"
__license__ = 'MIT License' __license__ = "MIT License"
+226 -117
View File
@@ -3,6 +3,7 @@
import logging import logging
import click import click
try: try:
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
except ImportError: except ImportError:
@@ -13,7 +14,7 @@ else:
from . import __version__, read_pdf, plot from . import __version__, read_pdf, plot
logger = logging.getLogger('camelot') logger = logging.getLogger("camelot")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@@ -28,25 +29,49 @@ class Config(object):
pass_config = click.make_pass_decorator(Config) pass_config = click.make_pass_decorator(Config)
@click.group() @click.group(name="camelot")
@click.version_option(version=__version__) @click.version_option(version=__version__)
@click.option('-q', '--quiet', is_flag=False, help='Suppress logs and warnings.') @click.option("-q", "--quiet", is_flag=False, help="Suppress logs and warnings.")
@click.option('-p', '--pages', default='1', help='Comma-separated page numbers.' @click.option(
' Example: 1,3,4 or 1,4-end or all.') "-p",
@click.option('-pw', '--password', help='Password for decryption.') "--pages",
@click.option('-o', '--output', help='Output file path.') default="1",
@click.option('-f', '--format', help="Comma-separated page numbers." " Example: 1,3,4 or 1,4-end or all.",
type=click.Choice(['csv', 'json', 'excel', 'html', 'sqlite']), )
help='Output file format.') @click.option("-pw", "--password", help="Password for decryption.")
@click.option('-z', '--zip', is_flag=True, help='Create ZIP archive.') @click.option("-o", "--output", help="Output file path.")
@click.option('-split', '--split_text', is_flag=True, @click.option(
help='Split text that spans across multiple cells.') "-f",
@click.option('-flag', '--flag_size', is_flag=True, help='Flag text based on' "--format",
' font size. Useful to detect super/subscripts.') type=click.Choice(["csv", "json", "excel", "html", "sqlite"]),
@click.option('-strip', '--strip_text', help='Characters that should be stripped from a string before' help="Output file format.",
' assigning it to a cell.') )
@click.option('-M', '--margins', nargs=3, default=(1.0, 0.5, 0.1), @click.option("-z", "--zip", is_flag=True, help="Create ZIP archive.")
help='PDFMiner char_margin, line_margin and word_margin.') @click.option(
"-split",
"--split_text",
is_flag=True,
help="Split text that spans across multiple cells.",
)
@click.option(
"-flag",
"--flag_size",
is_flag=True,
help="Flag text based on" " font size. Useful to detect super/subscripts.",
)
@click.option(
"-strip",
"--strip_text",
help="Characters that should be stripped from a string before"
" assigning it to a cell.",
)
@click.option(
"-M",
"--margins",
nargs=3,
default=(1.0, 0.5, 0.1),
help="PDFMiner char_margin, line_margin and word_margin.",
)
@click.pass_context @click.pass_context
def cli(ctx, *args, **kwargs): def cli(ctx, *args, **kwargs):
"""Camelot: PDF Table Extraction for Humans""" """Camelot: PDF Table Extraction for Humans"""
@@ -55,79 +80,131 @@ def cli(ctx, *args, **kwargs):
ctx.obj.set_config(key, value) ctx.obj.set_config(key, value)
@cli.command('lattice') @cli.command("lattice")
@click.option('-R', '--table_regions', default=[], multiple=True, @click.option(
help='Page regions to analyze. Example: x1,y1,x2,y2' "-R",
' where x1, y1 -> left-top and x2, y2 -> right-bottom.') "--table_regions",
@click.option('-T', '--table_areas', default=[], multiple=True, default=[],
help='Table areas to process. Example: x1,y1,x2,y2' multiple=True,
' where x1, y1 -> left-top and x2, y2 -> right-bottom.') help="Page regions to analyze. Example: x1,y1,x2,y2"
@click.option('-back', '--process_background', is_flag=True, " where x1, y1 -> left-top and x2, y2 -> right-bottom.",
help='Process background lines.') )
@click.option('-scale', '--line_scale', default=15, @click.option(
help='Line size scaling factor. The larger the value,' "-T",
' the smaller the detected lines.') "--table_areas",
@click.option('-copy', '--copy_text', default=[], type=click.Choice(['h', 'v']), default=[],
multiple=True, help='Direction in which text in a spanning cell' multiple=True,
' will be copied over.') help="Table areas to process. Example: x1,y1,x2,y2"
@click.option('-shift', '--shift_text', default=['l', 't'], " where x1, y1 -> left-top and x2, y2 -> right-bottom.",
type=click.Choice(['', 'l', 'r', 't', 'b']), multiple=True, )
help='Direction in which text in a spanning cell will flow.') @click.option(
@click.option('-l', '--line_tol', default=2, "-back", "--process_background", is_flag=True, help="Process background lines."
help='Tolerance parameter used to merge close vertical' )
' and horizontal lines.') @click.option(
@click.option('-j', '--joint_tol', default=2, "-scale",
help='Tolerance parameter used to decide whether' "--line_scale",
' the detected lines and points lie close to each other.') default=15,
@click.option('-block', '--threshold_blocksize', default=15, help="Line size scaling factor. The larger the value,"
help='For adaptive thresholding, size of a pixel' " the smaller the detected lines.",
' neighborhood that is used to calculate a threshold value for' )
' the pixel. Example: 3, 5, 7, and so on.') @click.option(
@click.option('-const', '--threshold_constant', default=-2, "-copy",
help='For adaptive thresholding, constant subtracted' "--copy_text",
' from the mean or weighted mean. Normally, it is positive but' default=[],
' may be zero or negative as well.') type=click.Choice(["h", "v"]),
@click.option('-I', '--iterations', default=0, multiple=True,
help='Number of times for erosion/dilation will be applied.') help="Direction in which text in a spanning cell" " will be copied over.",
@click.option('-res', '--resolution', default=300, )
help='Resolution used for PDF to PNG conversion.') @click.option(
@click.option('-plot', '--plot_type', "-shift",
type=click.Choice(['text', 'grid', 'contour', 'joint', 'line']), "--shift_text",
help='Plot elements found on PDF page for visual debugging.') default=["l", "t"],
@click.argument('filepath', type=click.Path(exists=True)) type=click.Choice(["", "l", "r", "t", "b"]),
multiple=True,
help="Direction in which text in a spanning cell will flow.",
)
@click.option(
"-l",
"--line_tol",
default=2,
help="Tolerance parameter used to merge close vertical" " and horizontal lines.",
)
@click.option(
"-j",
"--joint_tol",
default=2,
help="Tolerance parameter used to decide whether"
" the detected lines and points lie close to each other.",
)
@click.option(
"-block",
"--threshold_blocksize",
default=15,
help="For adaptive thresholding, size of a pixel"
" neighborhood that is used to calculate a threshold value for"
" the pixel. Example: 3, 5, 7, and so on.",
)
@click.option(
"-const",
"--threshold_constant",
default=-2,
help="For adaptive thresholding, constant subtracted"
" from the mean or weighted mean. Normally, it is positive but"
" may be zero or negative as well.",
)
@click.option(
"-I",
"--iterations",
default=0,
help="Number of times for erosion/dilation will be applied.",
)
@click.option(
"-res",
"--resolution",
default=300,
help="Resolution used for PDF to PNG conversion.",
)
@click.option(
"-plot",
"--plot_type",
type=click.Choice(["text", "grid", "contour", "joint", "line"]),
help="Plot elements found on PDF page for visual debugging.",
)
@click.argument("filepath", type=click.Path(exists=True))
@pass_config @pass_config
def lattice(c, *args, **kwargs): def lattice(c, *args, **kwargs):
"""Use lines between text to parse the table.""" """Use lines between text to parse the table."""
conf = c.config conf = c.config
pages = conf.pop('pages') pages = conf.pop("pages")
output = conf.pop('output') output = conf.pop("output")
f = conf.pop('format') f = conf.pop("format")
compress = conf.pop('zip') compress = conf.pop("zip")
quiet = conf.pop('quiet') quiet = conf.pop("quiet")
plot_type = kwargs.pop('plot_type') plot_type = kwargs.pop("plot_type")
filepath = kwargs.pop('filepath') filepath = kwargs.pop("filepath")
kwargs.update(conf) kwargs.update(conf)
table_regions = list(kwargs['table_regions']) table_regions = list(kwargs["table_regions"])
kwargs['table_regions'] = None if not table_regions else table_regions kwargs["table_regions"] = None if not table_regions else table_regions
table_areas = list(kwargs['table_areas']) table_areas = list(kwargs["table_areas"])
kwargs['table_areas'] = None if not table_areas else table_areas kwargs["table_areas"] = None if not table_areas else table_areas
copy_text = list(kwargs['copy_text']) copy_text = list(kwargs["copy_text"])
kwargs['copy_text'] = None if not copy_text else copy_text kwargs["copy_text"] = None if not copy_text else copy_text
kwargs['shift_text'] = list(kwargs['shift_text']) kwargs["shift_text"] = list(kwargs["shift_text"])
if plot_type is not None: if plot_type is not None:
if not _HAS_MPL: if not _HAS_MPL:
raise ImportError('matplotlib is required for plotting.') raise ImportError("matplotlib is required for plotting.")
else: else:
if output is None: if output is None:
raise click.UsageError('Please specify output file path using --output') raise click.UsageError("Please specify output file path using --output")
if f is None: if f is None:
raise click.UsageError('Please specify output file format using --format') raise click.UsageError("Please specify output file format using --format")
tables = read_pdf(filepath, pages=pages, flavor='lattice', tables = read_pdf(
suppress_stdout=quiet, **kwargs) filepath, pages=pages, flavor="lattice", suppress_stdout=quiet, **kwargs
click.echo('Found {} tables'.format(tables.n)) )
click.echo("Found {} tables".format(tables.n))
if plot_type is not None: if plot_type is not None:
for table in tables: for table in tables:
plot(table, kind=plot_type) plot(table, kind=plot_type)
@@ -136,57 +213,89 @@ def lattice(c, *args, **kwargs):
tables.export(output, f=f, compress=compress) tables.export(output, f=f, compress=compress)
@cli.command('stream') @cli.command("stream")
@click.option('-R', '--table_regions', default=[], multiple=True, @click.option(
help='Page regions to analyze. Example: x1,y1,x2,y2' "-R",
' where x1, y1 -> left-top and x2, y2 -> right-bottom.') "--table_regions",
@click.option('-T', '--table_areas', default=[], multiple=True, default=[],
help='Table areas to process. Example: x1,y1,x2,y2' multiple=True,
' where x1, y1 -> left-top and x2, y2 -> right-bottom.') help="Page regions to analyze. Example: x1,y1,x2,y2"
@click.option('-C', '--columns', default=[], multiple=True, " where x1, y1 -> left-top and x2, y2 -> right-bottom.",
help='X coordinates of column separators.') )
@click.option('-e', '--edge_tol', default=50, help='Tolerance parameter' @click.option(
' for extending textedges vertically.') "-T",
@click.option('-r', '--row_tol', default=2, help='Tolerance parameter' "--table_areas",
' used to combine text vertically, to generate rows.') default=[],
@click.option('-c', '--column_tol', default=0, help='Tolerance parameter' multiple=True,
' used to combine text horizontally, to generate columns.') help="Table areas to process. Example: x1,y1,x2,y2"
@click.option('-plot', '--plot_type', " where x1, y1 -> left-top and x2, y2 -> right-bottom.",
type=click.Choice(['text', 'grid', 'contour', 'textedge']), )
help='Plot elements found on PDF page for visual debugging.') @click.option(
@click.argument('filepath', type=click.Path(exists=True)) "-C",
"--columns",
default=[],
multiple=True,
help="X coordinates of column separators.",
)
@click.option(
"-e",
"--edge_tol",
default=50,
help="Tolerance parameter" " for extending textedges vertically.",
)
@click.option(
"-r",
"--row_tol",
default=2,
help="Tolerance parameter" " used to combine text vertically, to generate rows.",
)
@click.option(
"-c",
"--column_tol",
default=0,
help="Tolerance parameter"
" used to combine text horizontally, to generate columns.",
)
@click.option(
"-plot",
"--plot_type",
type=click.Choice(["text", "grid", "contour", "textedge"]),
help="Plot elements found on PDF page for visual debugging.",
)
@click.argument("filepath", type=click.Path(exists=True))
@pass_config @pass_config
def stream(c, *args, **kwargs): def stream(c, *args, **kwargs):
"""Use spaces between text to parse the table.""" """Use spaces between text to parse the table."""
conf = c.config conf = c.config
pages = conf.pop('pages') pages = conf.pop("pages")
output = conf.pop('output') output = conf.pop("output")
f = conf.pop('format') f = conf.pop("format")
compress = conf.pop('zip') compress = conf.pop("zip")
quiet = conf.pop('quiet') quiet = conf.pop("quiet")
plot_type = kwargs.pop('plot_type') plot_type = kwargs.pop("plot_type")
filepath = kwargs.pop('filepath') filepath = kwargs.pop("filepath")
kwargs.update(conf) kwargs.update(conf)
table_regions = list(kwargs['table_regions']) table_regions = list(kwargs["table_regions"])
kwargs['table_regions'] = None if not table_regions else table_regions kwargs["table_regions"] = None if not table_regions else table_regions
table_areas = list(kwargs['table_areas']) table_areas = list(kwargs["table_areas"])
kwargs['table_areas'] = None if not table_areas else table_areas kwargs["table_areas"] = None if not table_areas else table_areas
columns = list(kwargs['columns']) columns = list(kwargs["columns"])
kwargs['columns'] = None if not columns else columns kwargs["columns"] = None if not columns else columns
if plot_type is not None: if plot_type is not None:
if not _HAS_MPL: if not _HAS_MPL:
raise ImportError('matplotlib is required for plotting.') raise ImportError("matplotlib is required for plotting.")
else: else:
if output is None: if output is None:
raise click.UsageError('Please specify output file path using --output') raise click.UsageError("Please specify output file path using --output")
if f is None: if f is None:
raise click.UsageError('Please specify output file format using --format') raise click.UsageError("Please specify output file format using --format")
tables = read_pdf(filepath, pages=pages, flavor='stream', tables = read_pdf(
suppress_stdout=quiet, **kwargs) filepath, pages=pages, flavor="stream", suppress_stdout=quiet, **kwargs
click.echo('Found {} tables'.format(tables.n)) )
click.echo("Found {} tables".format(tables.n))
if plot_type is not None: if plot_type is not None:
for table in tables: for table in tables:
plot(table, kind=plot_type) plot(table, kind=plot_type)
+122 -86
View File
@@ -42,7 +42,8 @@ class TextEdge(object):
TEXTEDGE_REQUIRED_ELEMENTS horizontal text rows. TEXTEDGE_REQUIRED_ELEMENTS horizontal text rows.
""" """
def __init__(self, x, y0, y1, align='left'):
def __init__(self, x, y0, y1, align="left"):
self.x = x self.x = x
self.y0 = y0 self.y0 = y0
self.y1 = y1 self.y1 = y1
@@ -51,8 +52,13 @@ class TextEdge(object):
self.is_valid = False self.is_valid = False
def __repr__(self): def __repr__(self):
return '<TextEdge x={} y0={} y1={} align={} valid={}>'.format( return "<TextEdge x={} y0={} y1={} align={} valid={}>".format(
round(self.x, 2), round(self.y0, 2), round(self.y1, 2), self.align, self.is_valid) round(self.x, 2),
round(self.y0, 2),
round(self.y1, 2),
self.align,
self.is_valid,
)
def update_coords(self, x, y0, edge_tol=50): def update_coords(self, x, y0, edge_tol=50):
"""Updates the text edge's x and bottom y coordinates and sets """Updates the text edge's x and bottom y coordinates and sets
@@ -73,9 +79,10 @@ class TextEdges(object):
the PDF page. The dict has three keys based on the alignments, the PDF page. The dict has three keys based on the alignments,
and each key's value is a list of camelot.core.TextEdge objects. and each key's value is a list of camelot.core.TextEdge objects.
""" """
def __init__(self, edge_tol=50): def __init__(self, edge_tol=50):
self.edge_tol = edge_tol self.edge_tol = edge_tol
self._textedges = {'left': [], 'right': [], 'middle': []} self._textedges = {"left": [], "right": [], "middle": []}
@staticmethod @staticmethod
def get_x_coord(textline, align): def get_x_coord(textline, align):
@@ -85,7 +92,7 @@ class TextEdges(object):
x_left = textline.x0 x_left = textline.x0
x_right = textline.x1 x_right = textline.x1
x_middle = x_left + (x_right - x_left) / 2.0 x_middle = x_left + (x_right - x_left) / 2.0
x_coord = {'left': x_left, 'middle': x_middle, 'right': x_right} x_coord = {"left": x_left, "middle": x_middle, "right": x_right}
return x_coord[align] return x_coord[align]
def find(self, x_coord, align): def find(self, x_coord, align):
@@ -109,21 +116,22 @@ class TextEdges(object):
def update(self, textline): def update(self, textline):
"""Updates an existing text edge in the current dict. """Updates an existing text edge in the current dict.
""" """
for align in ['left', 'right', 'middle']: for align in ["left", "right", "middle"]:
x_coord = self.get_x_coord(textline, align) x_coord = self.get_x_coord(textline, align)
idx = self.find(x_coord, align) idx = self.find(x_coord, align)
if idx is None: if idx is None:
self.add(textline, align) self.add(textline, align)
else: else:
self._textedges[align][idx].update_coords( self._textedges[align][idx].update_coords(
x_coord, textline.y0, edge_tol=self.edge_tol) x_coord, textline.y0, edge_tol=self.edge_tol
)
def generate(self, textlines): def generate(self, textlines):
"""Generates the text edges dict based on horizontal text """Generates the text edges dict based on horizontal text
rows. rows.
""" """
for tl in textlines: for tl in textlines:
if len(tl.get_text().strip()) > 1: # TODO: hacky if len(tl.get_text().strip()) > 1: # TODO: hacky
self.update(tl) self.update(tl)
def get_relevant(self): def get_relevant(self):
@@ -132,9 +140,15 @@ class TextEdges(object):
the most. the most.
""" """
intersections_sum = { intersections_sum = {
'left': sum(te.intersections for te in self._textedges['left'] if te.is_valid), "left": sum(
'right': sum(te.intersections for te in self._textedges['right'] if te.is_valid), te.intersections for te in self._textedges["left"] if te.is_valid
'middle': sum(te.intersections for te in self._textedges['middle'] if te.is_valid) ),
"right": sum(
te.intersections for te in self._textedges["right"] if te.is_valid
),
"middle": sum(
te.intersections for te in self._textedges["middle"] if te.is_valid
),
} }
# TODO: naive # TODO: naive
@@ -147,6 +161,7 @@ class TextEdges(object):
"""Returns a dict of interesting table areas on the PDF page """Returns a dict of interesting table areas on the PDF page
calculated using relevant text edges. calculated using relevant text edges.
""" """
def pad(area, average_row_height): def pad(area, average_row_height):
x0 = area[0] - TABLE_AREA_PADDING x0 = area[0] - TABLE_AREA_PADDING
y0 = area[1] - TABLE_AREA_PADDING y0 = area[1] - TABLE_AREA_PADDING
@@ -175,7 +190,11 @@ class TextEdges(object):
else: else:
table_areas.pop(found) table_areas.pop(found)
updated_area = ( updated_area = (
found[0], min(te.y0, found[1]), max(found[2], te.x), max(found[3], te.y1)) found[0],
min(te.y0, found[1]),
max(found[2], te.x),
max(found[3], te.y1),
)
table_areas[updated_area] = None table_areas[updated_area] = None
# extend table areas based on textlines that overlap # extend table areas based on textlines that overlap
@@ -196,7 +215,11 @@ class TextEdges(object):
if found is not None: if found is not None:
table_areas.pop(found) table_areas.pop(found)
updated_area = ( updated_area = (
min(tl.x0, found[0]), min(tl.y0, found[1]), max(found[2], tl.x1), max(found[3], tl.y1)) min(tl.x0, found[0]),
min(tl.y0, found[1]),
max(found[2], tl.x1),
max(found[3], tl.y1),
)
table_areas[updated_area] = None table_areas[updated_area] = None
average_textline_height = sum_textline_height / float(len(textlines)) average_textline_height = sum_textline_height / float(len(textlines))
@@ -265,11 +288,12 @@ class Cell(object):
self.bottom = False self.bottom = False
self.hspan = False self.hspan = False
self.vspan = False self.vspan = False
self._text = '' self._text = ""
def __repr__(self): def __repr__(self):
return '<Cell x1={} y1={} x2={} y2={}>'.format( return "<Cell x1={} y1={} x2={} y2={}>".format(
round(self.x1, 2), round(self.y1, 2), round(self.x2, 2), round(self.y2, 2)) round(self.x1, 2), round(self.y1, 2), round(self.x2, 2), round(self.y2, 2)
)
@property @property
def text(self): def text(self):
@@ -277,7 +301,7 @@ class Cell(object):
@text.setter @text.setter
def text(self, t): def text(self, t):
self._text = ''.join([self._text, t]) self._text = "".join([self._text, t])
@property @property
def bound(self): def bound(self):
@@ -314,11 +338,11 @@ class Table(object):
PDF page number. PDF page number.
""" """
def __init__(self, cols, rows): def __init__(self, cols, rows):
self.cols = cols self.cols = cols
self.rows = rows self.rows = rows
self.cells = [[Cell(c[0], r[1], c[1], r[0]) self.cells = [[Cell(c[0], r[1], c[1], r[0]) for c in cols] for r in rows]
for c in cols] for r in rows]
self.df = None self.df = None
self.shape = (0, 0) self.shape = (0, 0)
self.accuracy = 0 self.accuracy = 0
@@ -327,7 +351,14 @@ class Table(object):
self.page = None self.page = None
def __repr__(self): def __repr__(self):
return '<{} shape={}>'.format(self.__class__.__name__, self.shape) return "<{} shape={}>".format(self.__class__.__name__, self.shape)
def __lt__(self, other):
if self.page == other.page:
if self.order < other.order:
return True
if self.page < other.page:
return True
@property @property
def data(self): def data(self):
@@ -345,10 +376,10 @@ class Table(object):
""" """
# pretty? # pretty?
report = { report = {
'accuracy': round(self.accuracy, 2), "accuracy": round(self.accuracy, 2),
'whitespace': round(self.whitespace, 2), "whitespace": round(self.whitespace, 2),
'order': self.order, "order": self.order,
'page': self.page "page": self.page,
} }
return report return report
@@ -376,12 +407,21 @@ class Table(object):
for v in vertical: for v in vertical:
# find closest x coord # find closest x coord
# iterate over y coords and find closest start and end points # iterate over y coords and find closest start and end points
i = [i for i, t in enumerate(self.cols) i = [
if np.isclose(v[0], t[0], atol=joint_tol)] i
j = [j for j, t in enumerate(self.rows) for i, t in enumerate(self.cols)
if np.isclose(v[3], t[0], atol=joint_tol)] if np.isclose(v[0], t[0], atol=joint_tol)
k = [k for k, t in enumerate(self.rows) ]
if np.isclose(v[1], t[0], atol=joint_tol)] j = [
j
for j, t in enumerate(self.rows)
if np.isclose(v[3], t[0], atol=joint_tol)
]
k = [
k
for k, t in enumerate(self.rows)
if np.isclose(v[1], t[0], atol=joint_tol)
]
if not j: if not j:
continue continue
J = j[0] J = j[0]
@@ -427,12 +467,21 @@ class Table(object):
for h in horizontal: for h in horizontal:
# find closest y coord # find closest y coord
# iterate over x coords and find closest start and end points # iterate over x coords and find closest start and end points
i = [i for i, t in enumerate(self.rows) i = [
if np.isclose(h[1], t[0], atol=joint_tol)] i
j = [j for j, t in enumerate(self.cols) for i, t in enumerate(self.rows)
if np.isclose(h[0], t[0], atol=joint_tol)] if np.isclose(h[1], t[0], atol=joint_tol)
k = [k for k, t in enumerate(self.cols) ]
if np.isclose(h[2], t[0], atol=joint_tol)] j = [
j
for j, t in enumerate(self.cols)
if np.isclose(h[0], t[0], atol=joint_tol)
]
k = [
k
for k, t in enumerate(self.cols)
if np.isclose(h[2], t[0], atol=joint_tol)
]
if not j: if not j:
continue continue
J = j[0] J = j[0]
@@ -530,12 +579,7 @@ class Table(object):
Output filepath. Output filepath.
""" """
kw = { kw = {"encoding": "utf-8", "index": False, "header": False, "quoting": 1}
'encoding': 'utf-8',
'index': False,
'header': False,
'quoting': 1
}
kw.update(kwargs) kw.update(kwargs)
self.df.to_csv(path, **kw) self.df.to_csv(path, **kw)
@@ -550,12 +594,10 @@ class Table(object):
Output filepath. Output filepath.
""" """
kw = { kw = {"orient": "records"}
'orient': 'records'
}
kw.update(kwargs) kw.update(kwargs)
json_string = self.df.to_json(**kw) json_string = self.df.to_json(**kw)
with open(path, 'w') as f: with open(path, "w") as f:
f.write(json_string) f.write(json_string)
def to_excel(self, path, **kwargs): def to_excel(self, path, **kwargs):
@@ -570,8 +612,8 @@ class Table(object):
""" """
kw = { kw = {
'sheet_name': 'page-{}-table-{}'.format(self.page, self.order), "sheet_name": "page-{}-table-{}".format(self.page, self.order),
'encoding': 'utf-8' "encoding": "utf-8",
} }
kw.update(kwargs) kw.update(kwargs)
writer = pd.ExcelWriter(path) writer = pd.ExcelWriter(path)
@@ -590,7 +632,7 @@ class Table(object):
""" """
html_string = self.df.to_html(**kwargs) html_string = self.df.to_html(**kwargs)
with open(path, 'w') as f: with open(path, "w") as f:
f.write(html_string) f.write(html_string)
def to_sqlite(self, path, **kwargs): def to_sqlite(self, path, **kwargs):
@@ -604,13 +646,10 @@ class Table(object):
Output filepath. Output filepath.
""" """
kw = { kw = {"if_exists": "replace", "index": False}
'if_exists': 'replace',
'index': False
}
kw.update(kwargs) kw.update(kwargs)
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
table_name = 'page-{}-table-{}'.format(self.page, self.order) table_name = "page-{}-table-{}".format(self.page, self.order)
self.df.to_sql(table_name, conn, **kw) self.df.to_sql(table_name, conn, **kw)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -626,12 +665,12 @@ class TableList(object):
Number of tables in the list. Number of tables in the list.
""" """
def __init__(self, tables): def __init__(self, tables):
self._tables = tables self._tables = tables
def __repr__(self): def __repr__(self):
return '<{} n={}>'.format( return "<{} n={}>".format(self.__class__.__name__, self.n)
self.__class__.__name__, self.n)
def __len__(self): def __len__(self):
return len(self._tables) return len(self._tables)
@@ -641,37 +680,39 @@ class TableList(object):
@staticmethod @staticmethod
def _format_func(table, f): def _format_func(table, f):
return getattr(table, 'to_{}'.format(f)) return getattr(table, "to_{}".format(f))
@property @property
def n(self): def n(self):
return len(self) return len(self)
def _write_file(self, f=None, **kwargs): def _write_file(self, f=None, **kwargs):
dirname = kwargs.get('dirname') dirname = kwargs.get("dirname")
root = kwargs.get('root') root = kwargs.get("root")
ext = kwargs.get('ext') ext = kwargs.get("ext")
for table in self._tables: for table in self._tables:
filename = os.path.join('{}-page-{}-table-{}{}'.format( filename = os.path.join(
root, table.page, table.order, ext)) "{}-page-{}-table-{}{}".format(root, table.page, table.order, ext)
)
filepath = os.path.join(dirname, filename) filepath = os.path.join(dirname, filename)
to_format = self._format_func(table, f) to_format = self._format_func(table, f)
to_format(filepath) to_format(filepath)
def _compress_dir(self, **kwargs): def _compress_dir(self, **kwargs):
path = kwargs.get('path') path = kwargs.get("path")
dirname = kwargs.get('dirname') dirname = kwargs.get("dirname")
root = kwargs.get('root') root = kwargs.get("root")
ext = kwargs.get('ext') ext = kwargs.get("ext")
zipname = os.path.join(os.path.dirname(path), root) + '.zip' zipname = os.path.join(os.path.dirname(path), root) + ".zip"
with zipfile.ZipFile(zipname, 'w', allowZip64=True) as z: with zipfile.ZipFile(zipname, "w", allowZip64=True) as z:
for table in self._tables: for table in self._tables:
filename = os.path.join('{}-page-{}-table-{}{}'.format( filename = os.path.join(
root, table.page, table.order, ext)) "{}-page-{}-table-{}{}".format(root, table.page, table.order, ext)
)
filepath = os.path.join(dirname, filename) filepath = os.path.join(dirname, filename)
z.write(filepath, os.path.basename(filepath)) z.write(filepath, os.path.basename(filepath))
def export(self, path, f='csv', compress=False): def export(self, path, f="csv", compress=False):
"""Exports the list of tables to specified file format. """Exports the list of tables to specified file format.
Parameters Parameters
@@ -690,33 +731,28 @@ class TableList(object):
if compress: if compress:
dirname = tempfile.mkdtemp() dirname = tempfile.mkdtemp()
kwargs = { kwargs = {"path": path, "dirname": dirname, "root": root, "ext": ext}
'path': path,
'dirname': dirname,
'root': root,
'ext': ext
}
if f in ['csv', 'json', 'html']: if f in ["csv", "json", "html"]:
self._write_file(f=f, **kwargs) self._write_file(f=f, **kwargs)
if compress: if compress:
self._compress_dir(**kwargs) self._compress_dir(**kwargs)
elif f == 'excel': elif f == "excel":
filepath = os.path.join(dirname, basename) filepath = os.path.join(dirname, basename)
writer = pd.ExcelWriter(filepath) writer = pd.ExcelWriter(filepath)
for table in self._tables: for table in self._tables:
sheet_name = 'page-{}-table-{}'.format(table.page, table.order) sheet_name = "page-{}-table-{}".format(table.page, table.order)
table.df.to_excel(writer, sheet_name=sheet_name, encoding='utf-8') table.df.to_excel(writer, sheet_name=sheet_name, encoding="utf-8")
writer.save() writer.save()
if compress: if compress:
zipname = os.path.join(os.path.dirname(path), root) + '.zip' zipname = os.path.join(os.path.dirname(path), root) + ".zip"
with zipfile.ZipFile(zipname, 'w', allowZip64=True) as z: with zipfile.ZipFile(zipname, "w", allowZip64=True) as z:
z.write(filepath, os.path.basename(filepath)) z.write(filepath, os.path.basename(filepath))
elif f == 'sqlite': elif f == "sqlite":
filepath = os.path.join(dirname, basename) filepath = os.path.join(dirname, basename)
for table in self._tables: for table in self._tables:
table.to_sqlite(filepath) table.to_sqlite(filepath)
if compress: if compress:
zipname = os.path.join(os.path.dirname(path), root) + '.zip' zipname = os.path.join(os.path.dirname(path), root) + ".zip"
with zipfile.ZipFile(zipname, 'w', allowZip64=True) as z: with zipfile.ZipFile(zipname, "w", allowZip64=True) as z:
z.write(filepath, os.path.basename(filepath)) z.write(filepath, os.path.basename(filepath))
+11 -8
View File
@@ -24,10 +24,10 @@ ghostscript - A Python interface for the Ghostscript interpreter C-API
from . import _gsprint as gs from . import _gsprint as gs
__author__ = 'Hartmut Goebel <h.goebel@crazy-compilers.com>' __author__ = "Hartmut Goebel <h.goebel@crazy-compilers.com>"
__copyright__ = 'Copyright 2010-2018 by Hartmut Goebel <h.goebel@crazy-compilers.com>' __copyright__ = "Copyright 2010-2018 by Hartmut Goebel <h.goebel@crazy-compilers.com>"
__license__ = 'GNU General Public License version 3 (GPL v3)' __license__ = "GNU General Public License version 3 (GPL v3)"
__version__ = '0.6' __version__ = "0.6"
class __Ghostscript(object): class __Ghostscript(object):
@@ -87,10 +87,13 @@ def Ghostscript(*args, **kwargs):
# Ghostscript only supports a single instance # Ghostscript only supports a single instance
if __instance__ is None: if __instance__ is None:
__instance__ = gs.new_instance() __instance__ = gs.new_instance()
return __Ghostscript(__instance__, args, return __Ghostscript(
stdin=kwargs.get('stdin', None), __instance__,
stdout=kwargs.get('stdout', None), args,
stderr=kwargs.get('stderr', None)) stdin=kwargs.get("stdin", None),
stdout=kwargs.get("stdout", None),
stderr=kwargs.get("stderr", None),
)
__instance__ = None __instance__ = None
+42 -19
View File
@@ -42,10 +42,10 @@ e_Info = -110
# #
e_Quit = -101 e_Quit = -101
__author__ = 'Hartmut Goebel <h.goebel@crazy-compilers.com>' __author__ = "Hartmut Goebel <h.goebel@crazy-compilers.com>"
__copyright__ = 'Copyright 2010-2018 by Hartmut Goebel <h.goebel@crazy-compilers.com>' __copyright__ = "Copyright 2010-2018 by Hartmut Goebel <h.goebel@crazy-compilers.com>"
__license__ = 'GNU General Public License version 3 (GPL v3)' __license__ = "GNU General Public License version 3 (GPL v3)"
__version__ = '0.6' __version__ = "0.6"
gs_main_instance = c_void_p gs_main_instance = c_void_p
display_callback = c_void_p display_callback = c_void_p
@@ -55,7 +55,7 @@ display_callback = c_void_p
class GhostscriptError(Exception): class GhostscriptError(Exception):
def __init__(self, ecode): def __init__(self, ecode):
self.code = ecode self.code = ecode
def new_instance(): def new_instance():
@@ -81,14 +81,17 @@ def delete_instance(instance):
""" """
return libgs.gsapi_delete_instance(instance) return libgs.gsapi_delete_instance(instance)
if sys.platform == "win32":
c_stdstream_call_t = CFUNCTYPE(c_int, gs_main_instance, POINTER(c_char), c_int) c_stdstream_call_t = WINFUNCTYPE(c_int, gs_main_instance, POINTER(c_char), c_int)
else:
c_stdstream_call_t = CFUNCTYPE(c_int, gs_main_instance, POINTER(c_char), c_int)
def _wrap_stdin(infp): def _wrap_stdin(infp):
"""Wrap a filehandle into a C function to be used as `stdin` callback """Wrap a filehandle into a C function to be used as `stdin` callback
for ``set_stdio``. The filehandle has to support the readline() method. for ``set_stdio``. The filehandle has to support the readline() method.
""" """
def _wrap(instance, dest, count): def _wrap(instance, dest, count):
try: try:
data = infp.readline(count) data = infp.readline(count)
@@ -110,6 +113,7 @@ def _wrap_stdout(outfp):
`stderr` callback for ``set_stdio``. The filehandle has to support the `stderr` callback for ``set_stdio``. The filehandle has to support the
write() and flush() methods. write() and flush() methods.
""" """
def _wrap(instance, str, count): def _wrap(instance, str, count):
outfp.write(str[:count]) outfp.write(str[:count])
outfp.flush() outfp.flush()
@@ -187,11 +191,23 @@ def __win32_finddll():
import winreg import winreg
except ImportError: except ImportError:
# assume Python 2 # assume Python 2
from _winreg import OpenKey, CloseKey, EnumKey, QueryValueEx, \ from _winreg import (
QueryInfoKey, HKEY_LOCAL_MACHINE OpenKey,
CloseKey,
EnumKey,
QueryValueEx,
QueryInfoKey,
HKEY_LOCAL_MACHINE,
)
else: else:
from winreg import OpenKey, CloseKey, EnumKey, QueryValueEx, \ from winreg import (
QueryInfoKey, HKEY_LOCAL_MACHINE OpenKey,
CloseKey,
EnumKey,
QueryValueEx,
QueryInfoKey,
HKEY_LOCAL_MACHINE,
)
from distutils.version import LooseVersion from distutils.version import LooseVersion
import os import os
@@ -199,15 +215,19 @@ def __win32_finddll():
dlls = [] dlls = []
# Look up different variants of Ghostscript and take the highest # Look up different variants of Ghostscript and take the highest
# version for which the DLL is to be found in the filesystem. # version for which the DLL is to be found in the filesystem.
for key_name in ('AFPL Ghostscript', 'Aladdin Ghostscript', for key_name in (
'GNU Ghostscript', 'GPL Ghostscript'): "AFPL Ghostscript",
"Aladdin Ghostscript",
"GNU Ghostscript",
"GPL Ghostscript",
):
try: try:
k1 = OpenKey(HKEY_LOCAL_MACHINE, "Software\\%s" % key_name) k1 = OpenKey(HKEY_LOCAL_MACHINE, "Software\\%s" % key_name)
for num in range(0, QueryInfoKey(k1)[0]): for num in range(0, QueryInfoKey(k1)[0]):
version = EnumKey(k1, num) version = EnumKey(k1, num)
try: try:
k2 = OpenKey(k1, version) k2 = OpenKey(k1, version)
dll_path = QueryValueEx(k2, 'GS_DLL')[0] dll_path = QueryValueEx(k2, "GS_DLL")[0]
CloseKey(k2) CloseKey(k2)
if os.path.exists(dll_path): if os.path.exists(dll_path):
dlls.append((LooseVersion(version), dll_path)) dlls.append((LooseVersion(version), dll_path))
@@ -223,21 +243,24 @@ def __win32_finddll():
return None return None
if sys.platform == 'win32': if sys.platform == "win32":
libgs = __win32_finddll() libgs = __win32_finddll()
if not libgs: if not libgs:
raise RuntimeError('Please make sure that Ghostscript is installed') import ctypes.util
libgs = ctypes.util.find_library("".join(("gsdll", str(ctypes.sizeof(ctypes.c_voidp) * 8), ".dll"))) # finds in %PATH%
if not libgs:
raise RuntimeError("Please make sure that Ghostscript is installed")
libgs = windll.LoadLibrary(libgs) libgs = windll.LoadLibrary(libgs)
else: else:
try: try:
libgs = cdll.LoadLibrary('libgs.so') libgs = cdll.LoadLibrary("libgs.so")
except OSError: except OSError:
# shared object file not found # shared object file not found
import ctypes.util import ctypes.util
libgs = ctypes.util.find_library('gs') libgs = ctypes.util.find_library("gs")
if not libgs: if not libgs:
raise RuntimeError('Please make sure that Ghostscript is installed') raise RuntimeError("Please make sure that Ghostscript is installed")
libgs = cdll.LoadLibrary(libgs) libgs = cdll.LoadLibrary(libgs)
del __win32_finddll del __win32_finddll
+77 -60
View File
@@ -2,13 +2,20 @@
import os import os
import sys import sys
import copy
from PyPDF2 import PdfFileReader, PdfFileWriter from PyPDF2 import PdfFileReader, PdfFileWriter
from .core import TableList from .core import TableList
from .parsers import Stream, Lattice from .parsers import Stream, Lattice
from .utils import (TemporaryDirectory, get_page_layout, get_text_objects, from .utils import (
get_rotation, is_url, download_url) TemporaryDirectory,
get_page_layout,
get_text_objects,
get_rotation,
is_url,
download_url,
)
class PDFHandler(object): class PDFHandler(object):
@@ -27,19 +34,21 @@ class PDFHandler(object):
Password for decryption. Password for decryption.
""" """
def __init__(self, filepath, pages='1', password=None):
def __init__(self, filepath, pages="1", password=None):
if is_url(filepath): if is_url(filepath):
filepath = download_url(filepath) filepath = download_url(filepath)
self.filepath = filepath self.filepath = filepath
if not filepath.lower().endswith('.pdf'): if not filepath.lower().endswith(".pdf"):
raise NotImplementedError("File format not supported") raise NotImplementedError("File format not supported")
self.pages = self._get_pages(self.filepath, pages)
if password is None: if password is None:
self.password = '' self.password = ""
else: else:
self.password = password self.password = password
if sys.version_info[0] < 3: if sys.version_info[0] < 3:
self.password = self.password.encode('ascii') self.password = self.password.encode("ascii")
self.pages = self._get_pages(self.filepath, pages)
def _get_pages(self, filepath, pages): def _get_pages(self, filepath, pages):
"""Converts pages string to list of ints. """Converts pages string to list of ints.
@@ -59,75 +68,82 @@ class PDFHandler(object):
""" """
page_numbers = [] page_numbers = []
if pages == '1': if pages == "1":
page_numbers.append({'start': 1, 'end': 1}) page_numbers.append({"start": 1, "end": 1})
else: else:
infile = PdfFileReader(open(filepath, 'rb'), strict=False) infile = PdfFileReader(open(filepath, "rb"), strict=False)
if infile.isEncrypted: if infile.isEncrypted:
infile.decrypt(self.password) infile.decrypt(self.password)
if pages == 'all': if pages == "all":
page_numbers.append({'start': 1, 'end': infile.getNumPages()}) page_numbers.append({"start": 1, "end": infile.getNumPages()})
else: else:
for r in pages.split(','): for r in pages.split(","):
if '-' in r: if "-" in r:
a, b = r.split('-') a, b = r.split("-")
if b == 'end': if b == "end":
b = infile.getNumPages() b = infile.getNumPages()
page_numbers.append({'start': int(a), 'end': int(b)}) page_numbers.append({"start": int(a), "end": int(b)})
else: else:
page_numbers.append({'start': int(r), 'end': int(r)}) page_numbers.append({"start": int(r), "end": int(r)})
P = [] P = []
for p in page_numbers: for p in page_numbers:
P.extend(range(p['start'], p['end'] + 1)) P.extend(range(p["start"], p["end"] + 1))
return sorted(set(P)) return sorted(set(P))
def _save_page(self, filepath, page, temp): def _save_pages(self, filepath, pages, temp):
"""Saves specified page from PDF into a temporary directory. """Saves specified page from PDF into a temporary directory.
Parameters Parameters
---------- ----------
filepath : str filepath : str
Filepath or URL of the PDF file. Filepath or URL of the PDF file.
page : int pages : int
Page number. Page numbers.
temp : str temp : str
Tmp directory. Tmp directory.
""" """
with open(filepath, 'rb') as fileobj: with open(filepath, "rb") as fileobj:
infile = PdfFileReader(fileobj, strict=False) infile_original = PdfFileReader(fileobj, strict=False)
if infile.isEncrypted: if infile_original.isEncrypted:
infile.decrypt(self.password) infile_original.decrypt(self.password)
fpath = os.path.join(temp, 'page-{0}.pdf'.format(page))
froot, fext = os.path.splitext(fpath) for page in pages:
p = infile.getPage(page - 1) # Ensure PdfFileReader object is unmodified
outfile = PdfFileWriter() infile = copy.copy(infile_original)
outfile.addPage(p) fpath = os.path.join(temp, 'page-{0}.pdf'.format(page))
with open(fpath, 'wb') as f: froot, fext = os.path.splitext(fpath)
outfile.write(f) p = infile.getPage(page - 1)
layout, dim = get_page_layout(fpath)
# fix rotated PDF
chars = get_text_objects(layout, ltype="char")
horizontal_text = get_text_objects(layout, ltype="horizontal_text")
vertical_text = get_text_objects(layout, ltype="vertical_text")
rotation = get_rotation(chars, horizontal_text, vertical_text)
if rotation != '':
fpath_new = ''.join([froot.replace('page', 'p'), '_rotated', fext])
os.rename(fpath, fpath_new)
infile = PdfFileReader(open(fpath_new, 'rb'), strict=False)
if infile.isEncrypted:
infile.decrypt(self.password)
outfile = PdfFileWriter() outfile = PdfFileWriter()
p = infile.getPage(0)
if rotation == 'anticlockwise':
p.rotateClockwise(90)
elif rotation == 'clockwise':
p.rotateCounterClockwise(90)
outfile.addPage(p) outfile.addPage(p)
with open(fpath, 'wb') as f: with open(fpath, 'wb') as f:
outfile.write(f) outfile.write(f)
def parse(self, flavor='lattice', suppress_stdout=False, layout_kwargs={}, **kwargs): # Orient rotated pages correctly
layout, dim = get_page_layout(fpath)
chars = get_text_objects(layout, ltype="char")
horizontal_text = get_text_objects(layout, ltype="horizontal_text")
vertical_text = get_text_objects(layout, ltype="vertical_text")
rotation = get_rotation(chars, horizontal_text, vertical_text)
if rotation != '':
fpath_new = ''.join([froot.replace('page', 'p'), '_rotated', fext])
os.rename(fpath, fpath_new)
infile = PdfFileReader(open(fpath_new, 'rb'), strict=False)
if infile.isEncrypted:
infile.decrypt(self.password)
outfile = PdfFileWriter()
p = infile.getPage(0)
if rotation == 'anticlockwise':
p.rotateClockwise(90)
elif rotation == 'clockwise':
p.rotateCounterClockwise(90)
outfile.addPage(p)
with open(fpath, 'wb') as f:
outfile.write(f)
def parse(
self, flavor="lattice", suppress_stdout=False, layout_kwargs={}, **kwargs
):
"""Extracts tables by calling parser.get_tables on all single """Extracts tables by calling parser.get_tables on all single
page PDFs. page PDFs.
@@ -151,13 +167,14 @@ class PDFHandler(object):
""" """
tables = [] tables = []
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
for p in self.pages: self._save_pages(self.filepath, self.pages, tempdir)
self._save_page(self.filepath, p, tempdir) pages = [
pages = [os.path.join(tempdir, 'page-{0}.pdf'.format(p)) os.path.join(tempdir, "page-{0}.pdf".format(p)) for p in self.pages
for p in self.pages] ]
parser = Lattice(**kwargs) if flavor == 'lattice' else Stream(**kwargs) parser = Lattice(**kwargs) if flavor == "lattice" else Stream(**kwargs)
for p in pages: for p in pages:
t = parser.extract_tables(p, suppress_stdout=suppress_stdout, t = parser.extract_tables(
layout_kwargs=layout_kwargs) p, suppress_stdout=suppress_stdout, layout_kwargs=layout_kwargs
)
tables.extend(t) tables.extend(t)
return TableList(tables) return TableList(sorted(tables))
+29 -18
View File
@@ -39,17 +39,23 @@ def adaptive_threshold(imagename, process_background=False, blocksize=15, c=-2):
if process_background: if process_background:
threshold = cv2.adaptiveThreshold( threshold = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blocksize, c
cv2.THRESH_BINARY, blocksize, c) )
else: else:
threshold = cv2.adaptiveThreshold( threshold = cv2.adaptiveThreshold(
np.invert(gray), 255, np.invert(gray),
cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blocksize, c) 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
blocksize,
c,
)
return img, threshold return img, threshold
def find_lines(threshold, regions=None, direction='horizontal', def find_lines(
line_scale=15, iterations=0): threshold, regions=None, direction="horizontal", line_scale=15, iterations=0
):
"""Finds horizontal and vertical lines by applying morphological """Finds horizontal and vertical lines by applying morphological
transformations on an image. transformations on an image.
@@ -87,15 +93,14 @@ def find_lines(threshold, regions=None, direction='horizontal',
""" """
lines = [] lines = []
if direction == 'vertical': if direction == "vertical":
size = threshold.shape[0] // line_scale size = threshold.shape[0] // line_scale
el = cv2.getStructuringElement(cv2.MORPH_RECT, (1, size)) el = cv2.getStructuringElement(cv2.MORPH_RECT, (1, size))
elif direction == 'horizontal': elif direction == "horizontal":
size = threshold.shape[1] // line_scale size = threshold.shape[1] // line_scale
el = cv2.getStructuringElement(cv2.MORPH_RECT, (size, 1)) el = cv2.getStructuringElement(cv2.MORPH_RECT, (size, 1))
elif direction is None: elif direction is None:
raise ValueError("Specify direction as either 'vertical' or" raise ValueError("Specify direction as either 'vertical' or 'horizontal'")
" 'horizontal'")
if regions is not None: if regions is not None:
region_mask = np.zeros(threshold.shape) region_mask = np.zeros(threshold.shape)
@@ -110,19 +115,21 @@ def find_lines(threshold, regions=None, direction='horizontal',
try: try:
_, contours, _ = cv2.findContours( _, contours, _ = cv2.findContours(
threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
except ValueError: except ValueError:
# for opencv backward compatibility # for opencv backward compatibility
contours, _ = cv2.findContours( contours, _ = cv2.findContours(
threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) threshold.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
for c in contours: for c in contours:
x, y, w, h = cv2.boundingRect(c) x, y, w, h = cv2.boundingRect(c)
x1, x2 = x, x + w x1, x2 = x, x + w
y1, y2 = y, y + h y1, y2 = y, y + h
if direction == 'vertical': if direction == "vertical":
lines.append(((x1 + x2) // 2, y2, (x1 + x2) // 2, y1)) lines.append(((x1 + x2) // 2, y2, (x1 + x2) // 2, y1))
elif direction == 'horizontal': elif direction == "horizontal":
lines.append((x1, (y1 + y2) // 2, x2, (y1 + y2) // 2)) lines.append((x1, (y1 + y2) // 2, x2, (y1 + y2) // 2))
return dmask, lines return dmask, lines
@@ -150,11 +157,13 @@ def find_contours(vertical, horizontal):
try: try:
__, contours, __ = cv2.findContours( __, contours, __ = cv2.findContours(
mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
except ValueError: except ValueError:
# for opencv backward compatibility # for opencv backward compatibility
contours, __ = cv2.findContours( contours, __ = cv2.findContours(
mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
# sort in reverse based on contour area and use first 10 contours # sort in reverse based on contour area and use first 10 contours
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:10] contours = sorted(contours, key=cv2.contourArea, reverse=True)[:10]
@@ -196,11 +205,13 @@ def find_joints(contours, vertical, horizontal):
roi = joints[y : y + h, x : x + w] roi = joints[y : y + h, x : x + w]
try: try:
__, jc, __ = cv2.findContours( __, jc, __ = cv2.findContours(
roi.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) roi.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
)
except ValueError: except ValueError:
# for opencv backward compatibility # for opencv backward compatibility
jc, __ = cv2.findContours( jc, __ = cv2.findContours(
roi.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE) roi.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
)
if len(jc) <= 4: # remove contours with less than 4 joints if len(jc) <= 4: # remove contours with less than 4 joints
continue continue
joint_coords = [] joint_coords = []
+19 -7
View File
@@ -6,8 +6,15 @@ from .handlers import PDFHandler
from .utils import validate_input, remove_extra from .utils import validate_input, remove_extra
def read_pdf(filepath, pages='1', password=None, flavor='lattice', def read_pdf(
suppress_stdout=False, layout_kwargs={}, **kwargs): filepath,
pages="1",
password=None,
flavor="lattice",
suppress_stdout=False,
layout_kwargs={},
**kwargs
):
"""Read PDF and return extracted tables. """Read PDF and return extracted tables.
Note: kwargs annotated with ^ can only be used with flavor='stream' Note: kwargs annotated with ^ can only be used with flavor='stream'
@@ -91,9 +98,10 @@ def read_pdf(filepath, pages='1', password=None, flavor='lattice',
tables : camelot.core.TableList tables : camelot.core.TableList
""" """
if flavor not in ['lattice', 'stream']: if flavor not in ["lattice", "stream"]:
raise NotImplementedError("Unknown flavor specified." raise NotImplementedError(
" Use either 'lattice' or 'stream'") "Unknown flavor specified." " Use either 'lattice' or 'stream'"
)
with warnings.catch_warnings(): with warnings.catch_warnings():
if suppress_stdout: if suppress_stdout:
@@ -102,6 +110,10 @@ def read_pdf(filepath, pages='1', password=None, flavor='lattice',
validate_input(kwargs, flavor=flavor) validate_input(kwargs, flavor=flavor)
p = PDFHandler(filepath, pages=pages, password=password) p = PDFHandler(filepath, pages=pages, password=password)
kwargs = remove_extra(kwargs, flavor=flavor) kwargs = remove_extra(kwargs, flavor=flavor)
tables = p.parse(flavor=flavor, suppress_stdout=suppress_stdout, tables = p.parse(
layout_kwargs=layout_kwargs, **kwargs) flavor=flavor,
suppress_stdout=suppress_stdout,
layout_kwargs=layout_kwargs,
**kwargs
)
return tables return tables
+5 -5
View File
@@ -8,13 +8,13 @@ from ..utils import get_page_layout, get_text_objects
class BaseParser(object): class BaseParser(object):
"""Defines a base parser. """Defines a base parser.
""" """
def _generate_layout(self, filename, layout_kwargs): def _generate_layout(self, filename, layout_kwargs):
self.filename = filename self.filename = filename
self.layout_kwargs = layout_kwargs self.layout_kwargs = layout_kwargs
self.layout, self.dimensions = get_page_layout( self.layout, self.dimensions = get_page_layout(filename, **layout_kwargs)
filename, **layout_kwargs) self.images = get_text_objects(self.layout, ltype="image")
self.images = get_text_objects(self.layout, ltype='image') self.horizontal_text = get_text_objects(self.layout, ltype="horizontal_text")
self.horizontal_text = get_text_objects(self.layout, ltype='horizontal_text') self.vertical_text = get_text_objects(self.layout, ltype="vertical_text")
self.vertical_text = get_text_objects(self.layout, ltype='vertical_text')
self.pdf_width, self.pdf_height = self.dimensions self.pdf_width, self.pdf_height = self.dimensions
self.rootname, __ = os.path.splitext(self.filename) self.rootname, __ = os.path.splitext(self.filename)
+116 -62
View File
@@ -14,15 +14,25 @@ import pandas as pd
from .base import BaseParser from .base import BaseParser
from ..core import Table from ..core import Table
from ..ext.ghostscript import Ghostscript from ..utils import (
from ..utils import (scale_image, scale_pdf, segments_in_bbox, text_in_bbox, scale_image,
merge_close_lines, get_table_index, compute_accuracy, scale_pdf,
compute_whitespace) segments_in_bbox,
from ..image_processing import (adaptive_threshold, find_lines, text_in_bbox,
find_contours, find_joints) merge_close_lines,
get_table_index,
compute_accuracy,
compute_whitespace,
)
from ..image_processing import (
adaptive_threshold,
find_lines,
find_contours,
find_joints,
)
logger = logging.getLogger('camelot') logger = logging.getLogger("camelot")
class Lattice(BaseParser): class Lattice(BaseParser):
@@ -84,11 +94,26 @@ class Lattice(BaseParser):
Resolution used for PDF to PNG conversion. Resolution used for PDF to PNG conversion.
""" """
def __init__(self, table_regions=None, table_areas=None, process_background=False,
line_scale=15, copy_text=None, shift_text=['l', 't'], def __init__(
split_text=False, flag_size=False, strip_text='', line_tol=2, self,
joint_tol=2, threshold_blocksize=15, threshold_constant=-2, table_regions=None,
iterations=0, resolution=300, **kwargs): table_areas=None,
process_background=False,
line_scale=15,
copy_text=None,
shift_text=["l", "t"],
split_text=False,
flag_size=False,
strip_text="",
line_tol=2,
joint_tol=2,
threshold_blocksize=15,
threshold_constant=-2,
iterations=0,
resolution=300,
**kwargs
):
self.table_regions = table_regions self.table_regions = table_regions
self.table_areas = table_areas self.table_areas = table_areas
self.process_background = process_background self.process_background = process_background
@@ -131,19 +156,19 @@ class Lattice(BaseParser):
indices = [] indices = []
for r_idx, c_idx, text in idx: for r_idx, c_idx, text in idx:
for d in shift_text: for d in shift_text:
if d == 'l': if d == "l":
if t.cells[r_idx][c_idx].hspan: if t.cells[r_idx][c_idx].hspan:
while not t.cells[r_idx][c_idx].left: while not t.cells[r_idx][c_idx].left:
c_idx -= 1 c_idx -= 1
if d == 'r': if d == "r":
if t.cells[r_idx][c_idx].hspan: if t.cells[r_idx][c_idx].hspan:
while not t.cells[r_idx][c_idx].right: while not t.cells[r_idx][c_idx].right:
c_idx += 1 c_idx += 1
if d == 't': if d == "t":
if t.cells[r_idx][c_idx].vspan: if t.cells[r_idx][c_idx].vspan:
while not t.cells[r_idx][c_idx].top: while not t.cells[r_idx][c_idx].top:
r_idx -= 1 r_idx -= 1
if d == 'b': if d == "b":
if t.cells[r_idx][c_idx].vspan: if t.cells[r_idx][c_idx].vspan:
while not t.cells[r_idx][c_idx].bottom: while not t.cells[r_idx][c_idx].bottom:
r_idx += 1 r_idx += 1
@@ -172,23 +197,26 @@ class Lattice(BaseParser):
if f == "h": if f == "h":
for i in range(len(t.cells)): for i in range(len(t.cells)):
for j in range(len(t.cells[i])): for j in range(len(t.cells[i])):
if t.cells[i][j].text.strip() == '': if t.cells[i][j].text.strip() == "":
if t.cells[i][j].hspan and not t.cells[i][j].left: if t.cells[i][j].hspan and not t.cells[i][j].left:
t.cells[i][j].text = t.cells[i][j - 1].text t.cells[i][j].text = t.cells[i][j - 1].text
elif f == "v": elif f == "v":
for i in range(len(t.cells)): for i in range(len(t.cells)):
for j in range(len(t.cells[i])): for j in range(len(t.cells[i])):
if t.cells[i][j].text.strip() == '': if t.cells[i][j].text.strip() == "":
if t.cells[i][j].vspan and not t.cells[i][j].top: if t.cells[i][j].vspan and not t.cells[i][j].top:
t.cells[i][j].text = t.cells[i - 1][j].text t.cells[i][j].text = t.cells[i - 1][j].text
return t return t
def _generate_image(self): def _generate_image(self):
self.imagename = ''.join([self.rootname, '.png']) from ..ext.ghostscript import Ghostscript
gs_call = '-q -sDEVICE=png16m -o {} -r300 {}'.format(
self.imagename, self.filename) self.imagename = "".join([self.rootname, ".png"])
gs_call = "-q -sDEVICE=png16m -o {} -r300 {}".format(
self.imagename, self.filename
)
gs_call = gs_call.encode().split() gs_call = gs_call.encode().split()
null = open(os.devnull, 'wb') null = open(os.devnull, "wb")
with Ghostscript(*gs_call, stdout=null) as gs: with Ghostscript(*gs_call, stdout=null) as gs:
pass pass
null.close() null.close()
@@ -207,8 +235,11 @@ class Lattice(BaseParser):
return scaled_areas return scaled_areas
self.image, self.threshold = adaptive_threshold( self.image, self.threshold = adaptive_threshold(
self.imagename, process_background=self.process_background, self.imagename,
blocksize=self.threshold_blocksize, c=self.threshold_constant) process_background=self.process_background,
blocksize=self.threshold_blocksize,
c=self.threshold_constant,
)
image_width = self.image.shape[1] image_width = self.image.shape[1]
image_height = self.image.shape[0] image_height = self.image.shape[0]
@@ -225,21 +256,35 @@ class Lattice(BaseParser):
regions = scale_areas(self.table_regions) regions = scale_areas(self.table_regions)
vertical_mask, vertical_segments = find_lines( vertical_mask, vertical_segments = find_lines(
self.threshold, regions=regions, direction='vertical', self.threshold,
line_scale=self.line_scale, iterations=self.iterations) regions=regions,
direction="vertical",
line_scale=self.line_scale,
iterations=self.iterations,
)
horizontal_mask, horizontal_segments = find_lines( horizontal_mask, horizontal_segments = find_lines(
self.threshold, regions=regions, direction='horizontal', self.threshold,
line_scale=self.line_scale, iterations=self.iterations) regions=regions,
direction="horizontal",
line_scale=self.line_scale,
iterations=self.iterations,
)
contours = find_contours(vertical_mask, horizontal_mask) contours = find_contours(vertical_mask, horizontal_mask)
table_bbox = find_joints(contours, vertical_mask, horizontal_mask) table_bbox = find_joints(contours, vertical_mask, horizontal_mask)
else: else:
vertical_mask, vertical_segments = find_lines( vertical_mask, vertical_segments = find_lines(
self.threshold, direction='vertical', line_scale=self.line_scale, self.threshold,
iterations=self.iterations) direction="vertical",
line_scale=self.line_scale,
iterations=self.iterations,
)
horizontal_mask, horizontal_segments = find_lines( horizontal_mask, horizontal_segments = find_lines(
self.threshold, direction='horizontal', line_scale=self.line_scale, self.threshold,
iterations=self.iterations) direction="horizontal",
line_scale=self.line_scale,
iterations=self.iterations,
)
areas = scale_areas(self.table_areas) areas = scale_areas(self.table_areas)
table_bbox = find_joints(areas, vertical_mask, horizontal_mask) table_bbox = find_joints(areas, vertical_mask, horizontal_mask)
@@ -247,18 +292,20 @@ class Lattice(BaseParser):
self.table_bbox_unscaled = copy.deepcopy(table_bbox) self.table_bbox_unscaled = copy.deepcopy(table_bbox)
self.table_bbox, self.vertical_segments, self.horizontal_segments = scale_image( self.table_bbox, self.vertical_segments, self.horizontal_segments = scale_image(
table_bbox, vertical_segments, horizontal_segments, pdf_scalers) table_bbox, vertical_segments, horizontal_segments, pdf_scalers
)
def _generate_columns_and_rows(self, table_idx, tk): def _generate_columns_and_rows(self, table_idx, tk):
# select elements which lie within table_bbox # select elements which lie within table_bbox
t_bbox = {} t_bbox = {}
v_s, h_s = segments_in_bbox( v_s, h_s = segments_in_bbox(
tk, self.vertical_segments, self.horizontal_segments) tk, self.vertical_segments, self.horizontal_segments
t_bbox['horizontal'] = text_in_bbox(tk, self.horizontal_text) )
t_bbox['vertical'] = text_in_bbox(tk, self.vertical_text) t_bbox["horizontal"] = text_in_bbox(tk, self.horizontal_text)
t_bbox["vertical"] = text_in_bbox(tk, self.vertical_text)
t_bbox['horizontal'].sort(key=lambda x: (-x.y0, x.x0)) t_bbox["horizontal"].sort(key=lambda x: (-x.y0, x.x0))
t_bbox['vertical'].sort(key=lambda x: (x.x0, -x.y0)) t_bbox["vertical"].sort(key=lambda x: (x.x0, -x.y0))
self.t_bbox = t_bbox self.t_bbox = t_bbox
@@ -267,23 +314,19 @@ class Lattice(BaseParser):
cols.extend([tk[0], tk[2]]) cols.extend([tk[0], tk[2]])
rows.extend([tk[1], tk[3]]) rows.extend([tk[1], tk[3]])
# sort horizontal and vertical segments # sort horizontal and vertical segments
cols = merge_close_lines( cols = merge_close_lines(sorted(cols), line_tol=self.line_tol)
sorted(cols), line_tol=self.line_tol) rows = merge_close_lines(sorted(rows, reverse=True), line_tol=self.line_tol)
rows = merge_close_lines(
sorted(rows, reverse=True), line_tol=self.line_tol)
# make grid using x and y coord of shortlisted rows and cols # make grid using x and y coord of shortlisted rows and cols
cols = [(cols[i], cols[i + 1]) cols = [(cols[i], cols[i + 1]) for i in range(0, len(cols) - 1)]
for i in range(0, len(cols) - 1)] rows = [(rows[i], rows[i + 1]) for i in range(0, len(rows) - 1)]
rows = [(rows[i], rows[i + 1])
for i in range(0, len(rows) - 1)]
return cols, rows, v_s, h_s return cols, rows, v_s, h_s
def _generate_table(self, table_idx, cols, rows, **kwargs): def _generate_table(self, table_idx, cols, rows, **kwargs):
v_s = kwargs.get('v_s') v_s = kwargs.get("v_s")
h_s = kwargs.get('h_s') h_s = kwargs.get("h_s")
if v_s is None or h_s is None: if v_s is None or h_s is None:
raise ValueError('No segments found on {}'.format(self.rootname)) raise ValueError("No segments found on {}".format(self.rootname))
table = Table(cols, rows) table = Table(cols, rows)
# set table edges to True using ver+hor lines # set table edges to True using ver+hor lines
@@ -296,14 +339,21 @@ class Lattice(BaseParser):
pos_errors = [] pos_errors = []
# TODO: have a single list in place of two directional ones? # TODO: have a single list in place of two directional ones?
# sorted on x-coordinate based on reading order i.e. LTR or RTL # sorted on x-coordinate based on reading order i.e. LTR or RTL
for direction in ['vertical', 'horizontal']: for direction in ["vertical", "horizontal"]:
for t in self.t_bbox[direction]: for t in self.t_bbox[direction]:
indices, error = get_table_index( indices, error = get_table_index(
table, t, direction, split_text=self.split_text, table,
flag_size=self.flag_size, strip_text=self.strip_text) t,
direction,
split_text=self.split_text,
flag_size=self.flag_size,
strip_text=self.strip_text,
)
if indices[:2] != (-1, -1): if indices[:2] != (-1, -1):
pos_errors.append(error) pos_errors.append(error)
indices = Lattice._reduce_index(table, indices, shift_text=self.shift_text) indices = Lattice._reduce_index(
table, indices, shift_text=self.shift_text
)
for r_idx, c_idx, text in indices: for r_idx, c_idx, text in indices:
table.cells[r_idx][c_idx].text = text table.cells[r_idx][c_idx].text = text
accuracy = compute_accuracy([[100, pos_errors]]) accuracy = compute_accuracy([[100, pos_errors]])
@@ -316,11 +366,11 @@ class Lattice(BaseParser):
table.shape = table.df.shape table.shape = table.df.shape
whitespace = compute_whitespace(data) whitespace = compute_whitespace(data)
table.flavor = 'lattice' table.flavor = "lattice"
table.accuracy = accuracy table.accuracy = accuracy
table.whitespace = whitespace table.whitespace = whitespace
table.order = table_idx + 1 table.order = table_idx + 1
table.page = int(os.path.basename(self.rootname).replace('page-', '')) table.page = int(os.path.basename(self.rootname).replace("page-", ""))
# for plotting # for plotting
_text = [] _text = []
@@ -336,15 +386,18 @@ class Lattice(BaseParser):
def extract_tables(self, filename, suppress_stdout=False, layout_kwargs={}): def extract_tables(self, filename, suppress_stdout=False, layout_kwargs={}):
self._generate_layout(filename, layout_kwargs) self._generate_layout(filename, layout_kwargs)
if not suppress_stdout: if not suppress_stdout:
logger.info('Processing {}'.format(os.path.basename(self.rootname))) logger.info("Processing {}".format(os.path.basename(self.rootname)))
if not self.horizontal_text: if not self.horizontal_text:
if self.images: if self.images:
warnings.warn('{} is image-based, camelot only works on' warnings.warn(
' text-based pages.'.format(os.path.basename(self.rootname))) "{} is image-based, camelot only works on"
" text-based pages.".format(os.path.basename(self.rootname))
)
else: else:
warnings.warn('No tables found on {}'.format( warnings.warn(
os.path.basename(self.rootname))) "No tables found on {}".format(os.path.basename(self.rootname))
)
return [] return []
self._generate_image() self._generate_image()
@@ -352,8 +405,9 @@ class Lattice(BaseParser):
_tables = [] _tables = []
# sort tables based on y-coord # sort tables based on y-coord
for table_idx, tk in enumerate(sorted( for table_idx, tk in enumerate(
self.table_bbox.keys(), key=lambda x: x[1], reverse=True)): sorted(self.table_bbox.keys(), key=lambda x: x[1], reverse=True)
):
cols, rows, v_s, h_s = self._generate_columns_and_rows(table_idx, tk) cols, rows, v_s, h_s = self._generate_columns_and_rows(table_idx, tk)
table = self._generate_table(table_idx, cols, rows, v_s=v_s, h_s=h_s) table = self._generate_table(table_idx, cols, rows, v_s=v_s, h_s=h_s)
table._bbox = tk table._bbox = tk
+78 -45
View File
@@ -10,11 +10,10 @@ import pandas as pd
from .base import BaseParser from .base import BaseParser
from ..core import TextEdges, Table from ..core import TextEdges, Table
from ..utils import (text_in_bbox, get_table_index, compute_accuracy, from ..utils import text_in_bbox, get_table_index, compute_accuracy, compute_whitespace
compute_whitespace)
logger = logging.getLogger('camelot') logger = logging.getLogger("camelot")
class Stream(BaseParser): class Stream(BaseParser):
@@ -55,9 +54,20 @@ class Stream(BaseParser):
to generate columns. to generate columns.
""" """
def __init__(self, table_regions=None, table_areas=None, columns=None, split_text=False,
flag_size=False, strip_text='', edge_tol=50, row_tol=2, def __init__(
column_tol=0, **kwargs): self,
table_regions=None,
table_areas=None,
columns=None,
split_text=False,
flag_size=False,
strip_text="",
edge_tol=50,
row_tol=2,
column_tol=0,
**kwargs
):
self.table_regions = table_regions self.table_regions = table_regions
self.table_areas = table_areas self.table_areas = table_areas
self.columns = columns self.columns = columns
@@ -150,8 +160,9 @@ class Stream(BaseParser):
else: else:
lower = merged[-1] lower = merged[-1]
if column_tol >= 0: if column_tol >= 0:
if (higher[0] <= lower[1] or if higher[0] <= lower[1] or np.isclose(
np.isclose(higher[0], lower[1], atol=column_tol)): higher[0], lower[1], atol=column_tol
):
upper_bound = max(lower[1], higher[1]) upper_bound = max(lower[1], higher[1])
lower_bound = min(lower[0], higher[0]) lower_bound = min(lower[0], higher[0])
merged[-1] = (lower_bound, upper_bound) merged[-1] = (lower_bound, upper_bound)
@@ -186,13 +197,14 @@ class Stream(BaseParser):
List of continuous row y-coordinate tuples. List of continuous row y-coordinate tuples.
""" """
row_mids = [sum([(t.y0 + t.y1) / 2 for t in r]) / len(r) row_mids = [
if len(r) > 0 else 0 for r in rows_grouped] sum([(t.y0 + t.y1) / 2 for t in r]) / len(r) if len(r) > 0 else 0
for r in rows_grouped
]
rows = [(row_mids[i] + row_mids[i - 1]) / 2 for i in range(1, len(row_mids))] rows = [(row_mids[i] + row_mids[i - 1]) / 2 for i in range(1, len(row_mids))]
rows.insert(0, text_y_max) rows.insert(0, text_y_max)
rows.append(text_y_min) rows.append(text_y_min)
rows = [(rows[i], rows[i + 1]) rows = [(rows[i], rows[i + 1]) for i in range(0, len(rows) - 1)]
for i in range(0, len(rows) - 1)]
return rows return rows
@staticmethod @staticmethod
@@ -217,8 +229,9 @@ class Stream(BaseParser):
if text: if text:
text = Stream._group_rows(text, row_tol=row_tol) text = Stream._group_rows(text, row_tol=row_tol)
elements = [len(r) for r in text] elements = [len(r) for r in text]
new_cols = [(t.x0, t.x1) new_cols = [
for r in text if len(r) == max(elements) for t in r] (t.x0, t.x1) for r in text if len(r) == max(elements) for t in r
]
cols.extend(Stream._merge_columns(sorted(new_cols))) cols.extend(Stream._merge_columns(sorted(new_cols)))
return cols return cols
@@ -243,15 +256,13 @@ class Stream(BaseParser):
cols = [(cols[i][0] + cols[i - 1][1]) / 2 for i in range(1, len(cols))] cols = [(cols[i][0] + cols[i - 1][1]) / 2 for i in range(1, len(cols))]
cols.insert(0, text_x_min) cols.insert(0, text_x_min)
cols.append(text_x_max) cols.append(text_x_max)
cols = [(cols[i], cols[i + 1]) cols = [(cols[i], cols[i + 1]) for i in range(0, len(cols) - 1)]
for i in range(0, len(cols) - 1)]
return cols return cols
def _validate_columns(self): def _validate_columns(self):
if self.table_areas is not None and self.columns is not None: if self.table_areas is not None and self.columns is not None:
if len(self.table_areas) != len(self.columns): if len(self.table_areas) != len(self.columns):
raise ValueError("Length of table_areas and columns" raise ValueError("Length of table_areas and columns" " should be equal")
" should be equal")
def _nurminen_table_detection(self, textlines): def _nurminen_table_detection(self, textlines):
"""A general implementation of the table detection algorithm """A general implementation of the table detection algorithm
@@ -286,7 +297,11 @@ class Stream(BaseParser):
# filter horizontal text # filter horizontal text
hor_text = [] hor_text = []
for region in self.table_regions: for region in self.table_regions:
x1, y1, x2, y2 = region x1, y1, x2, y2 = region.split(",")
x1 = float(x1)
y1 = float(y1)
x2 = float(x2)
y2 = float(y2)
region_text = text_in_bbox((x1, y2, x2, y1), self.horizontal_text) region_text = text_in_bbox((x1, y2, x2, y1), self.horizontal_text)
hor_text.extend(region_text) hor_text.extend(region_text)
# find tables based on nurminen's detection algorithm # find tables based on nurminen's detection algorithm
@@ -305,16 +320,16 @@ class Stream(BaseParser):
def _generate_columns_and_rows(self, table_idx, tk): def _generate_columns_and_rows(self, table_idx, tk):
# select elements which lie within table_bbox # select elements which lie within table_bbox
t_bbox = {} t_bbox = {}
t_bbox['horizontal'] = text_in_bbox(tk, self.horizontal_text) t_bbox["horizontal"] = text_in_bbox(tk, self.horizontal_text)
t_bbox['vertical'] = text_in_bbox(tk, self.vertical_text) t_bbox["vertical"] = text_in_bbox(tk, self.vertical_text)
t_bbox['horizontal'].sort(key=lambda x: (-x.y0, x.x0)) t_bbox["horizontal"].sort(key=lambda x: (-x.y0, x.x0))
t_bbox['vertical'].sort(key=lambda x: (x.x0, -x.y0)) t_bbox["vertical"].sort(key=lambda x: (x.x0, -x.y0))
self.t_bbox = t_bbox self.t_bbox = t_bbox
text_x_min, text_y_min, text_x_max, text_y_max = self._text_bbox(self.t_bbox) text_x_min, text_y_min, text_x_max, text_y_max = self._text_bbox(self.t_bbox)
rows_grouped = self._group_rows(self.t_bbox['horizontal'], row_tol=self.row_tol) rows_grouped = self._group_rows(self.t_bbox["horizontal"], row_tol=self.row_tol)
rows = self._join_rows(rows_grouped, text_y_max, text_y_min) rows = self._join_rows(rows_grouped, text_y_max, text_y_min)
elements = [len(r) for r in rows_grouped] elements = [len(r) for r in rows_grouped]
@@ -323,7 +338,7 @@ class Stream(BaseParser):
# take (0, pdf_width) by default # take (0, pdf_width) by default
# similar to else condition # similar to else condition
# len can't be 1 # len can't be 1
cols = self.columns[table_idx].split(',') cols = self.columns[table_idx].split(",")
cols = [float(c) for c in cols] cols = [float(c) for c in cols]
cols.insert(0, text_x_min) cols.insert(0, text_x_min)
cols.append(text_x_max) cols.append(text_x_max)
@@ -342,20 +357,29 @@ class Stream(BaseParser):
if len(elements): if len(elements):
ncols = max(set(elements), key=elements.count) ncols = max(set(elements), key=elements.count)
else: else:
warnings.warn("No tables found in table area {}".format( warnings.warn(
table_idx + 1)) "No tables found in table area {}".format(table_idx + 1)
)
cols = [(t.x0, t.x1) for r in rows_grouped if len(r) == ncols for t in r] cols = [(t.x0, t.x1) for r in rows_grouped if len(r) == ncols for t in r]
cols = self._merge_columns(sorted(cols), column_tol=self.column_tol) cols = self._merge_columns(sorted(cols), column_tol=self.column_tol)
inner_text = [] inner_text = []
for i in range(1, len(cols)): for i in range(1, len(cols)):
left = cols[i - 1][1] left = cols[i - 1][1]
right = cols[i][0] right = cols[i][0]
inner_text.extend([t for direction in self.t_bbox inner_text.extend(
for t in self.t_bbox[direction] [
if t.x0 > left and t.x1 < right]) t
outer_text = [t for direction in self.t_bbox for direction in self.t_bbox
for t in self.t_bbox[direction] for t in self.t_bbox[direction]
if t.x0 > cols[-1][1] or t.x1 < cols[0][0]] if t.x0 > left and t.x1 < right
]
)
outer_text = [
t
for direction in self.t_bbox
for t in self.t_bbox[direction]
if t.x0 > cols[-1][1] or t.x1 < cols[0][0]
]
inner_text.extend(outer_text) inner_text.extend(outer_text)
cols = self._add_columns(cols, inner_text, self.row_tol) cols = self._add_columns(cols, inner_text, self.row_tol)
cols = self._join_columns(cols, text_x_min, text_x_max) cols = self._join_columns(cols, text_x_min, text_x_max)
@@ -369,11 +393,16 @@ class Stream(BaseParser):
pos_errors = [] pos_errors = []
# TODO: have a single list in place of two directional ones? # TODO: have a single list in place of two directional ones?
# sorted on x-coordinate based on reading order i.e. LTR or RTL # sorted on x-coordinate based on reading order i.e. LTR or RTL
for direction in ['vertical', 'horizontal']: for direction in ["vertical", "horizontal"]:
for t in self.t_bbox[direction]: for t in self.t_bbox[direction]:
indices, error = get_table_index( indices, error = get_table_index(
table, t, direction, split_text=self.split_text, table,
flag_size=self.flag_size, strip_text=self.strip_text) t,
direction,
split_text=self.split_text,
flag_size=self.flag_size,
strip_text=self.strip_text,
)
if indices[:2] != (-1, -1): if indices[:2] != (-1, -1):
pos_errors.append(error) pos_errors.append(error)
for r_idx, c_idx, text in indices: for r_idx, c_idx, text in indices:
@@ -385,11 +414,11 @@ class Stream(BaseParser):
table.shape = table.df.shape table.shape = table.df.shape
whitespace = compute_whitespace(data) whitespace = compute_whitespace(data)
table.flavor = 'stream' table.flavor = "stream"
table.accuracy = accuracy table.accuracy = accuracy
table.whitespace = whitespace table.whitespace = whitespace
table.order = table_idx + 1 table.order = table_idx + 1
table.page = int(os.path.basename(self.rootname).replace('page-', '')) table.page = int(os.path.basename(self.rootname).replace("page-", ""))
# for plotting # for plotting
_text = [] _text = []
@@ -405,23 +434,27 @@ class Stream(BaseParser):
def extract_tables(self, filename, suppress_stdout=False, layout_kwargs={}): def extract_tables(self, filename, suppress_stdout=False, layout_kwargs={}):
self._generate_layout(filename, layout_kwargs) self._generate_layout(filename, layout_kwargs)
if not suppress_stdout: if not suppress_stdout:
logger.info('Processing {}'.format(os.path.basename(self.rootname))) logger.info("Processing {}".format(os.path.basename(self.rootname)))
if not self.horizontal_text: if not self.horizontal_text:
if self.images: if self.images:
warnings.warn('{} is image-based, camelot only works on' warnings.warn(
' text-based pages.'.format(os.path.basename(self.rootname))) "{} is image-based, camelot only works on"
" text-based pages.".format(os.path.basename(self.rootname))
)
else: else:
warnings.warn('No tables found on {}'.format( warnings.warn(
os.path.basename(self.rootname))) "No tables found on {}".format(os.path.basename(self.rootname))
)
return [] return []
self._generate_table_bbox() self._generate_table_bbox()
_tables = [] _tables = []
# sort tables based on y-coord # sort tables based on y-coord
for table_idx, tk in enumerate(sorted( for table_idx, tk in enumerate(
self.table_bbox.keys(), key=lambda x: x[1], reverse=True)): sorted(self.table_bbox.keys(), key=lambda x: x[1], reverse=True)
):
cols, rows = self._generate_columns_and_rows(table_idx, tk) cols, rows = self._generate_columns_and_rows(table_idx, tk)
table = self._generate_table(table_idx, cols, rows) table = self._generate_table(table_idx, cols, rows)
table._bbox = tk table._bbox = tk
+26 -47
View File
@@ -10,7 +10,7 @@ else:
class PlotMethods(object): class PlotMethods(object):
def __call__(self, table, kind='text', filename=None): def __call__(self, table, kind="text", filename=None):
"""Plot elements found on PDF page based on kind """Plot elements found on PDF page based on kind
specified, useful for debugging and playing with different specified, useful for debugging and playing with different
parameters to get the best output. parameters to get the best output.
@@ -31,14 +31,16 @@ class PlotMethods(object):
""" """
if not _HAS_MPL: if not _HAS_MPL:
raise ImportError('matplotlib is required for plotting.') raise ImportError("matplotlib is required for plotting.")
if table.flavor == 'lattice' and kind in ['textedge']: if table.flavor == "lattice" and kind in ["textedge"]:
raise NotImplementedError("Lattice flavor does not support kind='{}'".format( raise NotImplementedError(
kind)) "Lattice flavor does not support kind='{}'".format(kind)
elif table.flavor == 'stream' and kind in ['joint', 'line']: )
raise NotImplementedError("Stream flavor does not support kind='{}'".format( elif table.flavor == "stream" and kind in ["joint", "line"]:
kind)) raise NotImplementedError(
"Stream flavor does not support kind='{}'".format(kind)
)
plot_method = getattr(self, kind) plot_method = getattr(self, kind)
return plot_method(table) return plot_method(table)
@@ -57,18 +59,12 @@ class PlotMethods(object):
""" """
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(111, aspect='equal') ax = fig.add_subplot(111, aspect="equal")
xs, ys = [], [] xs, ys = [], []
for t in table._text: for t in table._text:
xs.extend([t[0], t[2]]) xs.extend([t[0], t[2]])
ys.extend([t[1], t[3]]) ys.extend([t[1], t[3]])
ax.add_patch( ax.add_patch(patches.Rectangle((t[0], t[1]), t[2] - t[0], t[3] - t[1]))
patches.Rectangle(
(t[0], t[1]),
t[2] - t[0],
t[3] - t[1]
)
)
ax.set_xlim(min(xs) - 10, max(xs) + 10) ax.set_xlim(min(xs) - 10, max(xs) + 10)
ax.set_ylim(min(ys) - 10, max(ys) + 10) ax.set_ylim(min(ys) - 10, max(ys) + 10)
return fig return fig
@@ -87,21 +83,17 @@ class PlotMethods(object):
""" """
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(111, aspect='equal') ax = fig.add_subplot(111, aspect="equal")
for row in table.cells: for row in table.cells:
for cell in row: for cell in row:
if cell.left: if cell.left:
ax.plot([cell.lb[0], cell.lt[0]], ax.plot([cell.lb[0], cell.lt[0]], [cell.lb[1], cell.lt[1]])
[cell.lb[1], cell.lt[1]])
if cell.right: if cell.right:
ax.plot([cell.rb[0], cell.rt[0]], ax.plot([cell.rb[0], cell.rt[0]], [cell.rb[1], cell.rt[1]])
[cell.rb[1], cell.rt[1]])
if cell.top: if cell.top:
ax.plot([cell.lt[0], cell.rt[0]], ax.plot([cell.lt[0], cell.rt[0]], [cell.lt[1], cell.rt[1]])
[cell.lt[1], cell.rt[1]])
if cell.bottom: if cell.bottom:
ax.plot([cell.lb[0], cell.rb[0]], ax.plot([cell.lb[0], cell.rb[0]], [cell.lb[1], cell.rb[1]])
[cell.lb[1], cell.rb[1]])
return fig return fig
def contour(self, table): def contour(self, table):
@@ -124,7 +116,7 @@ class PlotMethods(object):
img, table_bbox = (None, {table._bbox: None}) img, table_bbox = (None, {table._bbox: None})
_FOR_LATTICE = False _FOR_LATTICE = False
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(111, aspect='equal') ax = fig.add_subplot(111, aspect="equal")
xs, ys = [], [] xs, ys = [], []
if not _FOR_LATTICE: if not _FOR_LATTICE:
@@ -133,21 +125,14 @@ class PlotMethods(object):
ys.extend([t[1], t[3]]) ys.extend([t[1], t[3]])
ax.add_patch( ax.add_patch(
patches.Rectangle( patches.Rectangle(
(t[0], t[1]), (t[0], t[1]), t[2] - t[0], t[3] - t[1], color="blue"
t[2] - t[0],
t[3] - t[1],
color='blue'
) )
) )
for t in table_bbox.keys(): for t in table_bbox.keys():
ax.add_patch( ax.add_patch(
patches.Rectangle( patches.Rectangle(
(t[0], t[1]), (t[0], t[1]), t[2] - t[0], t[3] - t[1], fill=False, color="red"
t[2] - t[0],
t[3] - t[1],
fill=False,
color='red'
) )
) )
if not _FOR_LATTICE: if not _FOR_LATTICE:
@@ -173,25 +158,19 @@ class PlotMethods(object):
""" """
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(111, aspect='equal') ax = fig.add_subplot(111, aspect="equal")
xs, ys = [], [] xs, ys = [], []
for t in table._text: for t in table._text:
xs.extend([t[0], t[2]]) xs.extend([t[0], t[2]])
ys.extend([t[1], t[3]]) ys.extend([t[1], t[3]])
ax.add_patch( ax.add_patch(
patches.Rectangle( patches.Rectangle((t[0], t[1]), t[2] - t[0], t[3] - t[1], color="blue")
(t[0], t[1]),
t[2] - t[0],
t[3] - t[1],
color='blue'
)
) )
ax.set_xlim(min(xs) - 10, max(xs) + 10) ax.set_xlim(min(xs) - 10, max(xs) + 10)
ax.set_ylim(min(ys) - 10, max(ys) + 10) ax.set_ylim(min(ys) - 10, max(ys) + 10)
for te in table._textedges: for te in table._textedges:
ax.plot([te.x, te.x], ax.plot([te.x, te.x], [te.y0, te.y1])
[te.y0, te.y1])
return fig return fig
@@ -210,14 +189,14 @@ class PlotMethods(object):
""" """
img, table_bbox = table._image img, table_bbox = table._image
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(111, aspect='equal') ax = fig.add_subplot(111, aspect="equal")
x_coord = [] x_coord = []
y_coord = [] y_coord = []
for k in table_bbox.keys(): for k in table_bbox.keys():
for coord in table_bbox[k]: for coord in table_bbox[k]:
x_coord.append(coord[0]) x_coord.append(coord[0])
y_coord.append(coord[1]) y_coord.append(coord[1])
ax.plot(x_coord, y_coord, 'ro') ax.plot(x_coord, y_coord, "ro")
ax.imshow(img) ax.imshow(img)
return fig return fig
@@ -235,7 +214,7 @@ class PlotMethods(object):
""" """
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(111, aspect='equal') ax = fig.add_subplot(111, aspect="equal")
vertical, horizontal = table._segments vertical, horizontal = table._segments
for v in vertical: for v in vertical:
ax.plot([v[0], v[2]], [v[1], v[3]]) ax.plot([v[0], v[2]], [v[1], v[3]])
+214 -94
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import division from __future__ import division
import re
import os import os
import sys import sys
import random import random
@@ -19,8 +20,14 @@ from pdfminer.pdfpage import PDFTextExtractionNotAllowed
from pdfminer.pdfinterp import PDFResourceManager from pdfminer.pdfinterp import PDFResourceManager
from pdfminer.pdfinterp import PDFPageInterpreter from pdfminer.pdfinterp import PDFPageInterpreter
from pdfminer.converter import PDFPageAggregator from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import (LAParams, LTAnno, LTChar, LTTextLineHorizontal, from pdfminer.layout import (
LTTextLineVertical, LTImage) LAParams,
LTAnno,
LTChar,
LTTextLineHorizontal,
LTTextLineVertical,
LTImage,
)
PY3 = sys.version_info[0] >= 3 PY3 = sys.version_info[0] >= 3
@@ -35,7 +42,7 @@ else:
_VALID_URLS = set(uses_relative + uses_netloc + uses_params) _VALID_URLS = set(uses_relative + uses_netloc + uses_params)
_VALID_URLS.discard('') _VALID_URLS.discard("")
# https://github.com/pandas-dev/pandas/blob/master/pandas/io/common.py # https://github.com/pandas-dev/pandas/blob/master/pandas/io/common.py
@@ -59,9 +66,11 @@ def is_url(url):
def random_string(length): def random_string(length):
ret = '' ret = ""
while length: while length:
ret += random.choice(string.digits + string.ascii_lowercase + string.ascii_uppercase) ret += random.choice(
string.digits + string.ascii_lowercase + string.ascii_uppercase
)
length -= 1 length -= 1
return ret return ret
@@ -79,14 +88,14 @@ def download_url(url):
Temporary filepath. Temporary filepath.
""" """
filename = '{}.pdf'.format(random_string(6)) filename = "{}.pdf".format(random_string(6))
with tempfile.NamedTemporaryFile('wb', delete=False) as f: with tempfile.NamedTemporaryFile("wb", delete=False) as f:
obj = urlopen(url) obj = urlopen(url)
if PY3: if PY3:
content_type = obj.info().get_content_type() content_type = obj.info().get_content_type()
else: else:
content_type = obj.info().getheader('Content-Type') content_type = obj.info().getheader("Content-Type")
if content_type != 'application/pdf': if content_type != "application/pdf":
raise NotImplementedError("File format not supported") raise NotImplementedError("File format not supported")
f.write(obj.read()) f.write(obj.read())
filepath = os.path.join(os.path.dirname(f.name), filename) filepath = os.path.join(os.path.dirname(f.name), filename)
@@ -94,39 +103,39 @@ def download_url(url):
return filepath return filepath
stream_kwargs = [ stream_kwargs = ["columns", "edge_tol", "row_tol", "column_tol"]
'columns',
'row_tol',
'column_tol'
]
lattice_kwargs = [ lattice_kwargs = [
'process_background', "process_background",
'line_scale', "line_scale",
'copy_text', "copy_text",
'shift_text', "shift_text",
'line_tol', "line_tol",
'joint_tol', "joint_tol",
'threshold_blocksize', "threshold_blocksize",
'threshold_constant', "threshold_constant",
'iterations' "iterations",
"resolution",
] ]
def validate_input(kwargs, flavor='lattice'): def validate_input(kwargs, flavor="lattice"):
def check_intersection(parser_kwargs, input_kwargs): def check_intersection(parser_kwargs, input_kwargs):
isec = set(parser_kwargs).intersection(set(input_kwargs.keys())) isec = set(parser_kwargs).intersection(set(input_kwargs.keys()))
if isec: if isec:
raise ValueError("{} cannot be used with flavor='{}'".format( raise ValueError(
",".join(sorted(isec)), flavor)) "{} cannot be used with flavor='{}'".format(
",".join(sorted(isec)), flavor
)
)
if flavor == 'lattice': if flavor == "lattice":
check_intersection(stream_kwargs, kwargs) check_intersection(stream_kwargs, kwargs)
else: else:
check_intersection(lattice_kwargs, kwargs) check_intersection(lattice_kwargs, kwargs)
def remove_extra(kwargs, flavor='lattice'): def remove_extra(kwargs, flavor="lattice"):
if flavor == 'lattice': if flavor == "lattice":
for key in kwargs.keys(): for key in kwargs.keys():
if key in stream_kwargs: if key in stream_kwargs:
kwargs.pop(key) kwargs.pop(key)
@@ -256,15 +265,19 @@ def scale_image(tables, v_segments, h_segments, factors):
v_segments_new = [] v_segments_new = []
for v in v_segments: for v in v_segments:
x1, x2 = scale(v[0], scaling_factor_x), scale(v[2], scaling_factor_x) x1, x2 = scale(v[0], scaling_factor_x), scale(v[2], scaling_factor_x)
y1, y2 = scale(abs(translate(-img_y, v[1])), scaling_factor_y), scale( y1, y2 = (
abs(translate(-img_y, v[3])), scaling_factor_y) scale(abs(translate(-img_y, v[1])), scaling_factor_y),
scale(abs(translate(-img_y, v[3])), scaling_factor_y),
)
v_segments_new.append((x1, y1, x2, y2)) v_segments_new.append((x1, y1, x2, y2))
h_segments_new = [] h_segments_new = []
for h in h_segments: for h in h_segments:
x1, x2 = scale(h[0], scaling_factor_x), scale(h[2], scaling_factor_x) x1, x2 = scale(h[0], scaling_factor_x), scale(h[2], scaling_factor_x)
y1, y2 = scale(abs(translate(-img_y, h[1])), scaling_factor_y), scale( y1, y2 = (
abs(translate(-img_y, h[3])), scaling_factor_y) scale(abs(translate(-img_y, h[1])), scaling_factor_y),
scale(abs(translate(-img_y, h[3])), scaling_factor_y),
)
h_segments_new.append((x1, y1, x2, y2)) h_segments_new.append((x1, y1, x2, y2))
return tables_new, v_segments_new, h_segments_new return tables_new, v_segments_new, h_segments_new
@@ -291,13 +304,13 @@ def get_rotation(chars, horizontal_text, vertical_text):
rotated 90 degree clockwise. rotated 90 degree clockwise.
""" """
rotation = '' rotation = ""
hlen = len([t for t in horizontal_text if t.get_text().strip()]) hlen = len([t for t in horizontal_text if t.get_text().strip()])
vlen = len([t for t in vertical_text if t.get_text().strip()]) vlen = len([t for t in vertical_text if t.get_text().strip()])
if hlen < vlen: if hlen < vlen:
clockwise = sum(t.matrix[1] < 0 and t.matrix[2] > 0 for t in chars) clockwise = sum(t.matrix[1] < 0 and t.matrix[2] > 0 for t in chars)
anticlockwise = sum(t.matrix[1] > 0 and t.matrix[2] < 0 for t in chars) anticlockwise = sum(t.matrix[1] > 0 and t.matrix[2] < 0 for t in chars)
rotation = 'anticlockwise' if clockwise < anticlockwise else 'clockwise' rotation = "anticlockwise" if clockwise < anticlockwise else "clockwise"
return rotation return rotation
@@ -325,10 +338,16 @@ def segments_in_bbox(bbox, v_segments, h_segments):
""" """
lb = (bbox[0], bbox[1]) lb = (bbox[0], bbox[1])
rt = (bbox[2], bbox[3]) rt = (bbox[2], bbox[3])
v_s = [v for v in v_segments if v[1] > lb[1] - 2 and v_s = [
v[3] < rt[1] + 2 and lb[0] - 2 <= v[0] <= rt[0] + 2] v
h_s = [h for h in h_segments if h[0] > lb[0] - 2 and for v in v_segments
h[2] < rt[0] + 2 and lb[1] - 2 <= h[1] <= rt[1] + 2] if v[1] > lb[1] - 2 and v[3] < rt[1] + 2 and lb[0] - 2 <= v[0] <= rt[0] + 2
]
h_s = [
h
for h in h_segments
if h[0] > lb[0] - 2 and h[2] < rt[0] + 2 and lb[1] - 2 <= h[1] <= rt[1] + 2
]
return v_s, h_s return v_s, h_s
@@ -351,9 +370,12 @@ def text_in_bbox(bbox, text):
""" """
lb = (bbox[0], bbox[1]) lb = (bbox[0], bbox[1])
rt = (bbox[2], bbox[3]) rt = (bbox[2], bbox[3])
t_bbox = [t for t in text if lb[0] - 2 <= (t.x0 + t.x1) / 2.0 t_bbox = [
<= rt[0] + 2 and lb[1] - 2 <= (t.y0 + t.y1) / 2.0 t
<= rt[1] + 2] for t in text
if lb[0] - 2 <= (t.x0 + t.x1) / 2.0 <= rt[0] + 2
and lb[1] - 2 <= (t.y0 + t.y1) / 2.0 <= rt[1] + 2
]
return t_bbox return t_bbox
@@ -385,12 +407,33 @@ def merge_close_lines(ar, line_tol=2):
return ret return ret
def text_strip(text, strip=""):
"""Strips any characters in `strip` that are present in `text`.
Parameters
----------
text : str
Text to process and strip.
strip : str, optional (default: '')
Characters that should be stripped from `text`.
Returns
-------
stripped : str
"""
if not strip:
return text
stripped = re.sub(
r"[{}]".format("".join(map(re.escape, strip))), "", text, re.UNICODE
)
return stripped
# TODO: combine the following functions into a TextProcessor class which # TODO: combine the following functions into a TextProcessor class which
# applies corresponding transformations sequentially # applies corresponding transformations sequentially
# (inspired from sklearn.pipeline.Pipeline) # (inspired from sklearn.pipeline.Pipeline)
def flag_font_size(textline, direction, strip_text=''): def flag_font_size(textline, direction, strip_text=""):
"""Flags super/subscripts in text by enclosing them with <s></s>. """Flags super/subscripts in text by enclosing them with <s></s>.
May give false positives. May give false positives.
@@ -409,10 +452,18 @@ def flag_font_size(textline, direction, strip_text=''):
fstring : string fstring : string
""" """
if direction == 'horizontal': if direction == "horizontal":
d = [(t.get_text(), np.round(t.height, decimals=6)) for t in textline if not isinstance(t, LTAnno)] d = [
elif direction == 'vertical': (t.get_text(), np.round(t.height, decimals=6))
d = [(t.get_text(), np.round(t.width, decimals=6)) for t in textline if not isinstance(t, LTAnno)] for t in textline
if not isinstance(t, LTAnno)
]
elif direction == "vertical":
d = [
(t.get_text(), np.round(t.width, decimals=6))
for t in textline
if not isinstance(t, LTAnno)
]
l = [np.round(size, decimals=6) for text, size in d] l = [np.round(size, decimals=6) for text, size in d]
if len(set(l)) > 1: if len(set(l)) > 1:
flist = [] flist = []
@@ -420,21 +471,21 @@ def flag_font_size(textline, direction, strip_text=''):
for key, chars in groupby(d, itemgetter(1)): for key, chars in groupby(d, itemgetter(1)):
if key == min_size: if key == min_size:
fchars = [t[0] for t in chars] fchars = [t[0] for t in chars]
if ''.join(fchars).strip(): if "".join(fchars).strip():
fchars.insert(0, '<s>') fchars.insert(0, "<s>")
fchars.append('</s>') fchars.append("</s>")
flist.append(''.join(fchars)) flist.append("".join(fchars))
else: else:
fchars = [t[0] for t in chars] fchars = [t[0] for t in chars]
if ''.join(fchars).strip(): if "".join(fchars).strip():
flist.append(''.join(fchars)) flist.append("".join(fchars))
fstring = ''.join(flist).strip(strip_text) fstring = "".join(flist)
else: else:
fstring = ''.join([t.get_text() for t in textline]).strip(strip_text) fstring = "".join([t.get_text() for t in textline])
return fstring return text_strip(fstring, strip_text)
def split_textline(table, textline, direction, flag_size=False, strip_text=''): def split_textline(table, textline, direction, flag_size=False, strip_text=""):
"""Splits PDFMiner LTTextLine into substrings if it spans across """Splits PDFMiner LTTextLine into substrings if it spans across
multiple rows/columns. multiple rows/columns.
@@ -464,38 +515,70 @@ def split_textline(table, textline, direction, flag_size=False, strip_text=''):
cut_text = [] cut_text = []
bbox = textline.bbox bbox = textline.bbox
try: try:
if direction == 'horizontal' and not textline.is_empty(): if direction == "horizontal" and not textline.is_empty():
x_overlap = [i for i, x in enumerate(table.cols) if x[0] <= bbox[2] and bbox[0] <= x[1]] x_overlap = [
r_idx = [j for j, r in enumerate(table.rows) if r[1] <= (bbox[1] + bbox[3]) / 2 <= r[0]] i
for i, x in enumerate(table.cols)
if x[0] <= bbox[2] and bbox[0] <= x[1]
]
r_idx = [
j
for j, r in enumerate(table.rows)
if r[1] <= (bbox[1] + bbox[3]) / 2 <= r[0]
]
r = r_idx[0] r = r_idx[0]
x_cuts = [(c, table.cells[r][c].x2) for c in x_overlap if table.cells[r][c].right] x_cuts = [
(c, table.cells[r][c].x2) for c in x_overlap if table.cells[r][c].right
]
if not x_cuts: if not x_cuts:
x_cuts = [(x_overlap[0], table.cells[r][-1].x2)] x_cuts = [(x_overlap[0], table.cells[r][-1].x2)]
for obj in textline._objs: for obj in textline._objs:
row = table.rows[r] row = table.rows[r]
for cut in x_cuts: for cut in x_cuts:
if isinstance(obj, LTChar): if isinstance(obj, LTChar):
if (row[1] <= (obj.y0 + obj.y1) / 2 <= row[0] and if (
(obj.x0 + obj.x1) / 2 <= cut[1]): row[1] <= (obj.y0 + obj.y1) / 2 <= row[0]
and (obj.x0 + obj.x1) / 2 <= cut[1]
):
cut_text.append((r, cut[0], obj)) cut_text.append((r, cut[0], obj))
break break
else:
# TODO: add test
if cut == x_cuts[-1]:
cut_text.append((r, cut[0] + 1, obj))
elif isinstance(obj, LTAnno): elif isinstance(obj, LTAnno):
cut_text.append((r, cut[0], obj)) cut_text.append((r, cut[0], obj))
elif direction == 'vertical' and not textline.is_empty(): elif direction == "vertical" and not textline.is_empty():
y_overlap = [j for j, y in enumerate(table.rows) if y[1] <= bbox[3] and bbox[1] <= y[0]] y_overlap = [
c_idx = [i for i, c in enumerate(table.cols) if c[0] <= (bbox[0] + bbox[2]) / 2 <= c[1]] j
for j, y in enumerate(table.rows)
if y[1] <= bbox[3] and bbox[1] <= y[0]
]
c_idx = [
i
for i, c in enumerate(table.cols)
if c[0] <= (bbox[0] + bbox[2]) / 2 <= c[1]
]
c = c_idx[0] c = c_idx[0]
y_cuts = [(r, table.cells[r][c].y1) for r in y_overlap if table.cells[r][c].bottom] y_cuts = [
(r, table.cells[r][c].y1) for r in y_overlap if table.cells[r][c].bottom
]
if not y_cuts: if not y_cuts:
y_cuts = [(y_overlap[0], table.cells[-1][c].y1)] y_cuts = [(y_overlap[0], table.cells[-1][c].y1)]
for obj in textline._objs: for obj in textline._objs:
col = table.cols[c] col = table.cols[c]
for cut in y_cuts: for cut in y_cuts:
if isinstance(obj, LTChar): if isinstance(obj, LTChar):
if (col[0] <= (obj.x0 + obj.x1) / 2 <= col[1] and if (
(obj.y0 + obj.y1) / 2 >= cut[1]): col[0] <= (obj.x0 + obj.x1) / 2 <= col[1]
and (obj.y0 + obj.y1) / 2 >= cut[1]
):
cut_text.append((cut[0], c, obj)) cut_text.append((cut[0], c, obj))
break break
else:
# TODO: add test
if cut == y_cuts[-1]:
cut_text.append((cut[0] - 1, c, obj))
elif isinstance(obj, LTAnno): elif isinstance(obj, LTAnno):
cut_text.append((cut[0], c, obj)) cut_text.append((cut[0], c, obj))
except IndexError: except IndexError:
@@ -503,15 +586,26 @@ def split_textline(table, textline, direction, flag_size=False, strip_text=''):
grouped_chars = [] grouped_chars = []
for key, chars in groupby(cut_text, itemgetter(0, 1)): for key, chars in groupby(cut_text, itemgetter(0, 1)):
if flag_size: if flag_size:
grouped_chars.append((key[0], key[1], grouped_chars.append(
flag_font_size([t[2] for t in chars], direction, strip_text=strip_text))) (
key[0],
key[1],
flag_font_size(
[t[2] for t in chars], direction, strip_text=strip_text
),
)
)
else: else:
gchars = [t[2].get_text() for t in chars] gchars = [t[2].get_text() for t in chars]
grouped_chars.append((key[0], key[1], ''.join(gchars).strip(strip_text))) grouped_chars.append(
(key[0], key[1], text_strip("".join(gchars), strip_text))
)
return grouped_chars return grouped_chars
def get_table_index(table, t, direction, split_text=False, flag_size=False, strip_text='',): def get_table_index(
table, t, direction, split_text=False, flag_size=False, strip_text=""
):
"""Gets indices of the table cell where given text object lies by """Gets indices of the table cell where given text object lies by
comparing their y and x-coordinates. comparing their y and x-coordinates.
@@ -550,8 +644,9 @@ def get_table_index(table, t, direction, split_text=False, flag_size=False, stri
""" """
r_idx, c_idx = [-1] * 2 r_idx, c_idx = [-1] * 2
for r in range(len(table.rows)): for r in range(len(table.rows)):
if ((t.y0 + t.y1) / 2.0 < table.rows[r][0] and if (t.y0 + t.y1) / 2.0 < table.rows[r][0] and (t.y0 + t.y1) / 2.0 > table.rows[
(t.y0 + t.y1) / 2.0 > table.rows[r][1]): r
][1]:
lt_col_overlap = [] lt_col_overlap = []
for c in table.cols: for c in table.cols:
if c[0] <= t.x1 and c[1] >= t.x0: if c[0] <= t.x1 and c[1] >= t.x0:
@@ -561,11 +656,14 @@ def get_table_index(table, t, direction, split_text=False, flag_size=False, stri
else: else:
lt_col_overlap.append(-1) lt_col_overlap.append(-1)
if len(list(filter(lambda x: x != -1, lt_col_overlap))) == 0: if len(list(filter(lambda x: x != -1, lt_col_overlap))) == 0:
text = t.get_text().strip('\n') text = t.get_text().strip("\n")
text_range = (t.x0, t.x1) text_range = (t.x0, t.x1)
col_range = (table.cols[0][0], table.cols[-1][1]) col_range = (table.cols[0][0], table.cols[-1][1])
warnings.warn("{} {} does not lie in column range {}".format( warnings.warn(
text, text_range, col_range)) "{} {} does not lie in column range {}".format(
text, text_range, col_range
)
)
r_idx = r r_idx = r
c_idx = lt_col_overlap.index(max(lt_col_overlap)) c_idx = lt_col_overlap.index(max(lt_col_overlap))
break break
@@ -586,12 +684,26 @@ def get_table_index(table, t, direction, split_text=False, flag_size=False, stri
error = ((X * (y0_offset + y1_offset)) + (Y * (x0_offset + x1_offset))) / charea error = ((X * (y0_offset + y1_offset)) + (Y * (x0_offset + x1_offset))) / charea
if split_text: if split_text:
return split_textline(table, t, direction, flag_size=flag_size, strip_text=strip_text), error return (
split_textline(
table, t, direction, flag_size=flag_size, strip_text=strip_text
),
error,
)
else: else:
if flag_size: if flag_size:
return [(r_idx, c_idx, flag_font_size(t._objs, direction, strip_text=strip_text))], error return (
[
(
r_idx,
c_idx,
flag_font_size(t._objs, direction, strip_text=strip_text),
)
],
error,
)
else: else:
return [(r_idx, c_idx, t.get_text().strip(strip_text))], error return [(r_idx, c_idx, text_strip(t.get_text(), strip_text))], error
def compute_accuracy(error_weights): def compute_accuracy(error_weights):
@@ -642,14 +754,20 @@ def compute_whitespace(d):
r_nempty_cells, c_nempty_cells = [], [] r_nempty_cells, c_nempty_cells = [], []
for i in d: for i in d:
for j in i: for j in i:
if j.strip() == '': if j.strip() == "":
whitespace += 1 whitespace += 1
whitespace = 100 * (whitespace / float(len(d) * len(d[0]))) whitespace = 100 * (whitespace / float(len(d) * len(d[0])))
return whitespace return whitespace
def get_page_layout(filename, char_margin=1.0, line_margin=0.5, word_margin=0.1, def get_page_layout(
detect_vertical=True, all_texts=True): filename,
char_margin=1.0,
line_margin=0.5,
word_margin=0.1,
detect_vertical=True,
all_texts=True,
):
"""Returns a PDFMiner LTPage object and page dimension of a single """Returns a PDFMiner LTPage object and page dimension of a single
page pdf. See https://euske.github.io/pdfminer/ to get definitions page pdf. See https://euske.github.io/pdfminer/ to get definitions
of kwargs. of kwargs.
@@ -672,16 +790,18 @@ def get_page_layout(filename, char_margin=1.0, line_margin=0.5, word_margin=0.1,
Dimension of pdf page in the form (width, height). Dimension of pdf page in the form (width, height).
""" """
with open(filename, 'rb') as f: with open(filename, "rb") as f:
parser = PDFParser(f) parser = PDFParser(f)
document = PDFDocument(parser) document = PDFDocument(parser)
if not document.is_extractable: if not document.is_extractable:
raise PDFTextExtractionNotAllowed raise PDFTextExtractionNotAllowed
laparams = LAParams(char_margin=char_margin, laparams = LAParams(
line_margin=line_margin, char_margin=char_margin,
word_margin=word_margin, line_margin=line_margin,
detect_vertical=detect_vertical, word_margin=word_margin,
all_texts=all_texts) detect_vertical=detect_vertical,
all_texts=all_texts,
)
rsrcmgr = PDFResourceManager() rsrcmgr = PDFResourceManager()
device = PDFPageAggregator(rsrcmgr, laparams=laparams) device = PDFPageAggregator(rsrcmgr, laparams=laparams)
interpreter = PDFPageInterpreter(rsrcmgr, device) interpreter = PDFPageInterpreter(rsrcmgr, device)
@@ -713,13 +833,13 @@ def get_text_objects(layout, ltype="char", t=None):
List of PDFMiner text objects. List of PDFMiner text objects.
""" """
if ltype == 'char': if ltype == "char":
LTObject = LTChar LTObject = LTChar
elif ltype == 'image': elif ltype == "image":
LTObject = LTImage LTObject = LTImage
elif ltype == 'horizontal_text': elif ltype == "horizontal_text":
LTObject = LTTextLineHorizontal LTObject = LTTextLineHorizontal
elif ltype == 'vertical_text': elif ltype == "vertical_text":
LTObject = LTTextLineVertical LTObject = LTTextLineVertical
if t is None: if t is None:
t = [] t = []
+4 -4
View File
@@ -4,13 +4,13 @@
</a> </a>
</p> </p>
<p> <p>
<iframe src="https://ghbtns.com/github-btn.html?user=socialcopsdev&repo=camelot&type=watch&count=true&size=large" <iframe src="https://ghbtns.com/github-btn.html?user=camelot-dev&repo=camelot&type=watch&count=true&size=large"
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe> allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
</p> </p>
<h3>Useful Links</h3> <h3>Useful Links</h3>
<ul> <ul>
<li><a href="https://github.com/socialcopsdev/camelot">Camelot @ GitHub</a></li> <li><a href="https://github.com/camelot-dev/camelot">Camelot @ GitHub</a></li>
<li><a href="https://pypi.org/project/camelot-py/">Camelot @ PyPI</a></li> <li><a href="https://pypi.org/project/camelot-py/">Camelot @ PyPI</a></li>
<li><a href="https://github.com/socialcopsdev/camelot/issues">Issue Tracker</a></li> <li><a href="https://github.com/camelot-dev/camelot/issues">Issue Tracker</a></li>
</ul> </ul>
+2 -2
View File
@@ -4,6 +4,6 @@
</a> </a>
</p> </p>
<p> <p>
<iframe src="https://ghbtns.com/github-btn.html?user=socialcopsdev&repo=camelot&type=watch&count=true&size=large" <iframe src="https://ghbtns.com/github-btn.html?user=camelot-dev&repo=camelot&type=watch&count=true&size=large"
allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe> allowtransparency="true" frameborder="0" scrolling="0" width="200px" height="35px"></iframe>
</p> </p>
+2 -2
View File
@@ -63,7 +63,7 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = u'Camelot' project = u'Camelot'
copyright = u'2018, <a href="https://socialcops.com" target="_blank">SocialCops</a>' copyright = u'2019, Camelot Developers'
author = u'Vinayak Mehta' author = u'Vinayak Mehta'
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
@@ -137,7 +137,7 @@ html_theme = 'alabaster'
# documentation. # documentation.
html_theme_options = { html_theme_options = {
'show_powered_by': False, 'show_powered_by': False,
'github_user': 'socialcopsdev', 'github_user': 'camelot-dev',
'github_repo': 'camelot', 'github_repo': 'camelot',
'github_banner': True, 'github_banner': True,
'show_related': False, 'show_related': False,
+4 -4
View File
@@ -29,8 +29,8 @@ Your first contribution
A great way to start contributing to Camelot is to pick an issue tagged with the `help wanted`_ or the `good first issue`_ tags. If you're unable to find a good first issue, feel free to contact the maintainer. A great way to start contributing to Camelot is to pick an issue tagged with the `help wanted`_ or the `good first issue`_ tags. If you're unable to find a good first issue, feel free to contact the maintainer.
.. _help wanted: https://github.com/socialcopsdev/camelot/labels/help%20wanted .. _help wanted: https://github.com/camelot-dev/camelot/labels/help%20wanted
.. _good first issue: https://github.com/socialcopsdev/camelot/labels/good%20first%20issue .. _good first issue: https://github.com/camelot-dev/camelot/labels/good%20first%20issue
Setting up a development environment Setting up a development environment
------------------------------------ ------------------------------------
@@ -51,7 +51,7 @@ Submit a pull request
The preferred workflow for contributing to Camelot is to fork the `project repository`_ on GitHub, clone, develop on a branch and then finally submit a pull request. Here are the steps: The preferred workflow for contributing to Camelot is to fork the `project repository`_ on GitHub, clone, develop on a branch and then finally submit a pull request. Here are the steps:
.. _project repository: https://github.com/socialcopsdev/camelot .. _project repository: https://github.com/camelot-dev/camelot
1. Fork the project repository. Click on the Fork button near the top of the page. This creates a copy of the code under your account on the GitHub. 1. Fork the project repository. Click on the Fork button near the top of the page. This creates a copy of the code under your account on the GitHub.
@@ -134,7 +134,7 @@ Filing Issues
We use `GitHub issues`_ to keep track of all issues and pull requests. Before opening an issue (which asks a question or reports a bug), please use GitHub search to look for existing issues (both open and closed) that may be similar. We use `GitHub issues`_ to keep track of all issues and pull requests. Before opening an issue (which asks a question or reports a bug), please use GitHub search to look for existing issues (both open and closed) that may be similar.
.. _GitHub issues: https://github.com/socialcopsdev/camelot/issues .. _GitHub issues: https://github.com/camelot-dev/camelot/issues
Questions Questions
^^^^^^^^^ ^^^^^^^^^
+15 -5
View File
@@ -8,15 +8,15 @@ Camelot: PDF Table Extraction for Humans
Release v\ |version|. (:ref:`Installation <install>`) Release v\ |version|. (:ref:`Installation <install>`)
.. image:: https://travis-ci.org/socialcopsdev/camelot.svg?branch=master .. image:: https://travis-ci.org/camelot-dev/camelot.svg?branch=master
:target: https://travis-ci.org/socialcopsdev/camelot :target: https://travis-ci.org/camelot-dev/camelot
.. image:: https://readthedocs.org/projects/camelot-py/badge/?version=master .. image:: https://readthedocs.org/projects/camelot-py/badge/?version=master
:target: https://camelot-py.readthedocs.io/en/master/ :target: https://camelot-py.readthedocs.io/en/master/
:alt: Documentation Status :alt: Documentation Status
.. image:: https://codecov.io/github/socialcopsdev/camelot/badge.svg?branch=master&service=github .. image:: https://codecov.io/github/camelot-dev/camelot/badge.svg?branch=master&service=github
:target: https://codecov.io/github/socialcopsdev/camelot?branch=master :target: https://codecov.io/github/camelot-dev/camelot?branch=master
.. image:: https://img.shields.io/pypi/v/camelot-py.svg .. image:: https://img.shields.io/pypi/v/camelot-py.svg
:target: https://pypi.org/project/camelot-py/ :target: https://pypi.org/project/camelot-py/
@@ -30,6 +30,9 @@ Release v\ |version|. (:ref:`Installation <install>`)
.. image:: https://badges.gitter.im/camelot-dev/Lobby.png .. image:: https://badges.gitter.im/camelot-dev/Lobby.png
:target: https://gitter.im/camelot-dev/Lobby :target: https://gitter.im/camelot-dev/Lobby
.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/ambv/black
**Camelot** is a Python library that makes it easy for *anyone* to extract tables from PDF files! **Camelot** is a Python library that makes it easy for *anyone* to extract tables from PDF files!
.. note:: You can also check out `Excalibur`_, which is a web interface for Camelot! .. note:: You can also check out `Excalibur`_, which is a web interface for Camelot!
@@ -81,7 +84,14 @@ Why Camelot?
See `comparison with other PDF table extraction libraries and tools`_. See `comparison with other PDF table extraction libraries and tools`_.
.. _ETL and data analysis workflows: https://gist.github.com/vinayak-mehta/e5949f7c2410a0e12f25d3682dc9e873 .. _ETL and data analysis workflows: https://gist.github.com/vinayak-mehta/e5949f7c2410a0e12f25d3682dc9e873
.. _comparison with other PDF table extraction libraries and tools: https://github.com/socialcopsdev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools .. _comparison with other PDF table extraction libraries and tools: https://github.com/camelot-dev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools
Support us on OpenCollective
----------------------------
If Camelot helped you extract tables from PDFs, please consider supporting its development by `becoming a backer or a sponsor on OpenCollective`_!
.. _becoming a backer or a sponsor on OpenCollective: https://opencollective.com/camelot
The User Guide The User Guide
-------------- --------------
+7 -5
View File
@@ -224,10 +224,12 @@ Table areas that you want Camelot to analyze can be passed as a list of comma-se
.. csv-table:: .. csv-table::
:file: ../_static/csv/table_areas.csv :file: ../_static/csv/table_areas.csv
.. note:: ``table_areas`` accepts strings of the form x1,y1,x2,y2 where (x1, y1) -> top-left and (x2, y2) -> bottom-right in PDF coordinate space. In PDF coordinate space, the bottom-left corner of the page is the origin, with coordinates (0, 0).
Specify table regions Specify table regions
--------------------- ---------------------
However there may be cases like `[1] <../_static/pdf/table_regions.pdf>`__ and `[2] <https://github.com/socialcopsdev/camelot/blob/master/tests/files/tableception.pdf>`__, where the table might not lie at the exact coordinates every time but in an approximate region. However there may be cases like `[1] <../_static/pdf/table_regions.pdf>`__ and `[2] <https://github.com/camelot-dev/camelot/blob/master/tests/files/tableception.pdf>`__, where the table might not lie at the exact coordinates every time but in an approximate region.
You can use the ``table_regions`` keyword argument to :meth:`read_pdf() <camelot.read_pdf>` to solve for such cases. When ``table_regions`` is specified, Camelot will only analyze the specified regions to look for tables. You can use the ``table_regions`` keyword argument to :meth:`read_pdf() <camelot.read_pdf>` to solve for such cases. When ``table_regions`` is specified, Camelot will only analyze the specified regions to look for tables.
@@ -314,7 +316,7 @@ In this case, the text that `other tools`_ return, will be ``24.912``. This is r
You can solve this by passing ``flag_size=True``, which will enclose the superscripts and subscripts with ``<s></s>``, based on font size, as shown below. You can solve this by passing ``flag_size=True``, which will enclose the superscripts and subscripts with ``<s></s>``, based on font size, as shown below.
.. _other tools: https://github.com/socialcopsdev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools .. _other tools: https://github.com/camelot-dev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools
:: ::
@@ -338,7 +340,7 @@ You can solve this by passing ``flag_size=True``, which will enclose the supersc
Strip characters from text Strip characters from text
-------------------------- --------------------------
You can strip unwanted characters like spaces, dots and newlines from a string using the ``strip_text`` keyword argument. Take a look at `this PDF <https://github.com/socialcopsdev/camelot/blob/master/tests/files/tabula/12s0324.pdf>`_ as an example, the text at the start of each row contains a lot of unwanted spaces, dots and newlines. You can strip unwanted characters like spaces, dots and newlines from a string using the ``strip_text`` keyword argument. Take a look at `this PDF <https://github.com/camelot-dev/camelot/blob/master/tests/files/tabula/12s0324.pdf>`_ as an example, the text at the start of each row contains a lot of unwanted spaces, dots and newlines.
:: ::
@@ -364,7 +366,7 @@ You can strip unwanted characters like spaces, dots and newlines from a string u
Improve guessed table areas Improve guessed table areas
--------------------------- ---------------------------
While using :ref:`Stream <stream>`, automatic table detection can fail for PDFs like `this one <https://github.com/socialcopsdev/camelot/blob/master/tests/files/edge_tol.pdf>`_. That's because the text is relatively far apart vertically, which can lead to shorter textedges being calculated. While using :ref:`Stream <stream>`, automatic table detection can fail for PDFs like `this one <https://github.com/camelot-dev/camelot/blob/master/tests/files/edge_tol.pdf>`_. That's because the text is relatively far apart vertically, which can lead to shorter textedges being calculated.
.. note:: To know more about how textedges are calculated to guess table areas, you can see pages 20, 35 and 40 of `Anssi Nurminen's master's thesis <http://dspace.cc.tut.fi/dpub/bitstream/handle/123456789/21520/Nurminen.pdf?sequence=3>`_. .. note:: To know more about how textedges are calculated to guess table areas, you can see pages 20, 35 and 40 of `Anssi Nurminen's master's thesis <http://dspace.cc.tut.fi/dpub/bitstream/handle/123456789/21520/Nurminen.pdf?sequence=3>`_.
@@ -624,7 +626,7 @@ We don't need anything else. Now, let's pass ``copy_text=['v']`` to copy text in
Tweak layout generation Tweak layout generation
----------------------- -----------------------
Camelot is built on top of PDFMiner's functionality of grouping characters on a page into words and sentences. In some cases (such as `#170 <https://github.com/socialcopsdev/camelot/issues/170>`_ and `#215 <https://github.com/socialcopsdev/camelot/issues/215>`_), PDFMiner can group characters that should belong to the same sentence into separate sentences. Camelot is built on top of PDFMiner's functionality of grouping characters on a page into words and sentences. In some cases (such as `#170 <https://github.com/camelot-dev/camelot/issues/170>`_ and `#215 <https://github.com/camelot-dev/camelot/issues/215>`_), PDFMiner can group characters that should belong to the same sentence into separate sentences.
To deal with such cases, you can tweak PDFMiner's `LAParams kwargs <https://github.com/euske/pdfminer/blob/master/pdfminer/layout.py#L33>`_ to improve layout generation, by passing the keyword arguments as a dict using ``layout_kwargs`` in :meth:`read_pdf() <camelot.read_pdf>`. To know more about the parameters you can tweak, you can check out `PDFMiner docs <https://euske.github.io/pdfminer/>`_. To deal with such cases, you can tweak PDFMiner's `LAParams kwargs <https://github.com/euske/pdfminer/blob/master/pdfminer/layout.py#L33>`_ to improve layout generation, by passing the keyword arguments as a dict using ``layout_kwargs`` in :meth:`read_pdf() <camelot.read_pdf>`. To know more about the parameters you can tweak, you can check out `PDFMiner docs <https://euske.github.io/pdfminer/>`_.
+2
View File
@@ -26,6 +26,8 @@ You can print the help for the interface by typing ``camelot --help`` in your fa
-split, --split_text Split text that spans across multiple cells. -split, --split_text Split text that spans across multiple cells.
-flag, --flag_size Flag text based on font size. Useful to -flag, --flag_size Flag text based on font size. Useful to
detect super/subscripts. detect super/subscripts.
-strip, --strip_text Characters that should be stripped from a
string before assigning it to a cell.
-M, --margins <FLOAT FLOAT FLOAT>... -M, --margins <FLOAT FLOAT FLOAT>...
PDFMiner char_margin, line_margin and PDFMiner char_margin, line_margin and
word_margin. word_margin.
+2 -2
View File
@@ -13,7 +13,7 @@ The easiest way to install Camelot is to install it with `conda`_, which is a pa
$ conda install -c conda-forge camelot-py $ conda install -c conda-forge camelot-py
.. note:: Camelot is available for Python 2.7, 3.5 and 3.6 on Linux, macOS and Windows. For Windows, you will need to install ghostscript which you can get from their `downloads page`_. .. note:: Camelot is available for Python 2.7, 3.5, 3.6 and 3.7 on Linux, macOS and Windows. For Windows, you will need to install ghostscript which you can get from their `downloads page`_.
.. _conda: https://conda.io/docs/ .. _conda: https://conda.io/docs/
.. _Anaconda: http://docs.continuum.io/anaconda/ .. _Anaconda: http://docs.continuum.io/anaconda/
@@ -38,7 +38,7 @@ After :ref:`installing the dependencies <install_deps>`, you can install from th
1. Cloning the GitHub repository. 1. Cloning the GitHub repository.
:: ::
$ git clone https://www.github.com/socialcopsdev/camelot $ git clone https://www.github.com/camelot-dev/camelot
2. Then simply using pip again. 2. Then simply using pip again.
:: ::
+1 -1
View File
@@ -27,7 +27,7 @@ Here is a `comparison`_ of Camelot's output with outputs from other open-source
.. _pdf-table-extract: https://github.com/ashima/pdf-table-extract .. _pdf-table-extract: https://github.com/ashima/pdf-table-extract
.. _PDFTables: https://pdftables.com/ .. _PDFTables: https://pdftables.com/
.. _Smallpdf: https://smallpdf.com .. _Smallpdf: https://smallpdf.com
.. _comparison: https://github.com/socialcopsdev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools .. _comparison: https://github.com/camelot-dev/camelot/wiki/Comparison-with-other-PDF-Table-Extraction-libraries-and-tools
What's in a name? What's in a name?
----------------- -----------------
+2519 -256
View File
File diff suppressed because it is too large Load Diff
+105 -51
View File
@@ -9,109 +9,163 @@ from camelot.utils import TemporaryDirectory
testdir = os.path.dirname(os.path.abspath(__file__)) testdir = os.path.dirname(os.path.abspath(__file__))
testdir = os.path.join(testdir, 'files') testdir = os.path.join(testdir, "files")
def test_help_output():
runner = CliRunner()
prog_name = runner.get_default_prog_name(cli)
result = runner.invoke(cli, ["--help"])
output = result.output
assert prog_name == "camelot"
assert result.output.startswith("Usage: %(prog_name)s [OPTIONS] COMMAND" % locals())
assert all(
v in result.output
for v in ["Options:", "--version", "--help", "Commands:", "lattice", "stream"]
)
def test_cli_lattice(): def test_cli_lattice():
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
infile = os.path.join(testdir, 'foo.pdf') infile = os.path.join(testdir, "foo.pdf")
outfile = os.path.join(tempdir, 'foo.csv') outfile = os.path.join(tempdir, "foo.csv")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ['--format', 'csv', '--output', outfile, result = runner.invoke(
'lattice', infile]) cli, ["--format", "csv", "--output", outfile, "lattice", infile]
)
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == 'Found 1 tables\n' assert result.output == "Found 1 tables\n"
result = runner.invoke(cli, ['--format', 'csv', result = runner.invoke(cli, ["--format", "csv", "lattice", infile])
'lattice', infile]) output_error = "Error: Please specify output file path using --output"
output_error = 'Error: Please specify output file path using --output'
assert output_error in result.output assert output_error in result.output
result = runner.invoke(cli, ['--output', outfile, result = runner.invoke(cli, ["--output", outfile, "lattice", infile])
'lattice', infile]) format_error = "Please specify output file format using --format"
format_error = 'Please specify output file format using --format'
assert format_error in result.output assert format_error in result.output
def test_cli_stream(): def test_cli_stream():
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
infile = os.path.join(testdir, 'budget.pdf') infile = os.path.join(testdir, "budget.pdf")
outfile = os.path.join(tempdir, 'budget.csv') outfile = os.path.join(tempdir, "budget.csv")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ['--format', 'csv', '--output', outfile, result = runner.invoke(
'stream', infile]) cli, ["--format", "csv", "--output", outfile, "stream", infile]
)
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == 'Found 1 tables\n' assert result.output == "Found 1 tables\n"
result = runner.invoke(cli, ['--format', 'csv', 'stream', infile]) result = runner.invoke(cli, ["--format", "csv", "stream", infile])
output_error = 'Error: Please specify output file path using --output' output_error = "Error: Please specify output file path using --output"
assert output_error in result.output assert output_error in result.output
result = runner.invoke(cli, ['--output', outfile, 'stream', infile]) result = runner.invoke(cli, ["--output", outfile, "stream", infile])
format_error = 'Please specify output file format using --format' format_error = "Please specify output file format using --format"
assert format_error in result.output assert format_error in result.output
def test_cli_password(): def test_cli_password():
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
infile = os.path.join(testdir, 'health_protected.pdf') infile = os.path.join(testdir, "health_protected.pdf")
outfile = os.path.join(tempdir, 'health_protected.csv') outfile = os.path.join(tempdir, "health_protected.csv")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ['--password', 'userpass', result = runner.invoke(
'--format', 'csv', '--output', outfile, cli,
'stream', infile]) [
"--password",
"userpass",
"--format",
"csv",
"--output",
outfile,
"stream",
infile,
],
)
assert result.exit_code == 0 assert result.exit_code == 0
assert result.output == 'Found 1 tables\n' assert result.output == "Found 1 tables\n"
output_error = 'file has not been decrypted' output_error = "file has not been decrypted"
# no password # no password
result = runner.invoke(cli, ['--format', 'csv', '--output', outfile, result = runner.invoke(
'stream', infile]) cli, ["--format", "csv", "--output", outfile, "stream", infile]
)
assert output_error in str(result.exception) assert output_error in str(result.exception)
# bad password # bad password
result = runner.invoke(cli, ['--password', 'wrongpass', result = runner.invoke(
'--format', 'csv', '--output', outfile, cli,
'stream', infile]) [
"--password",
"wrongpass",
"--format",
"csv",
"--output",
outfile,
"stream",
infile,
],
)
assert output_error in str(result.exception) assert output_error in str(result.exception)
def test_cli_output_format(): def test_cli_output_format():
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
infile = os.path.join(testdir, 'health.pdf') infile = os.path.join(testdir, "health.pdf")
outfile = os.path.join(tempdir, 'health.{}') outfile = os.path.join(tempdir, "health.{}")
runner = CliRunner() runner = CliRunner()
# json # json
result = runner.invoke(cli, ['--format', 'json', '--output', outfile.format('json'), result = runner.invoke(
'stream', infile]) cli,
["--format", "json", "--output", outfile.format("json"), "stream", infile],
)
assert result.exit_code == 0 assert result.exit_code == 0
# excel # excel
result = runner.invoke(cli, ['--format', 'excel', '--output', outfile.format('xlsx'), result = runner.invoke(
'stream', infile]) cli,
["--format", "excel", "--output", outfile.format("xlsx"), "stream", infile],
)
assert result.exit_code == 0 assert result.exit_code == 0
# html # html
result = runner.invoke(cli, ['--format', 'html', '--output', outfile.format('html'), result = runner.invoke(
'stream', infile]) cli,
["--format", "html", "--output", outfile.format("html"), "stream", infile],
)
assert result.exit_code == 0 assert result.exit_code == 0
# zip # zip
result = runner.invoke(cli, ['--zip', '--format', 'csv', '--output', outfile.format('csv'), result = runner.invoke(
'stream', infile]) cli,
[
"--zip",
"--format",
"csv",
"--output",
outfile.format("csv"),
"stream",
infile,
],
)
assert result.exit_code == 0 assert result.exit_code == 0
def test_cli_quiet(): def test_cli_quiet():
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
infile = os.path.join(testdir, 'blank.pdf') infile = os.path.join(testdir, "blank.pdf")
outfile = os.path.join(tempdir, 'blank.csv') outfile = os.path.join(tempdir, "blank.csv")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ['--format', 'csv', '--output', outfile, result = runner.invoke(
'stream', infile]) cli, ["--format", "csv", "--output", outfile, "stream", infile]
assert 'No tables found on page-1' in result.output )
assert "No tables found on page-1" in result.output
result = runner.invoke(cli, ['--quiet', '--format', 'csv', result = runner.invoke(
'--output', outfile, 'stream', infile]) cli, ["--quiet", "--format", "csv", "--output", outfile, "stream", infile]
assert 'No tables found on page-1' not in result.output )
assert "No tables found on page-1" not in result.output
+72 -22
View File
@@ -5,6 +5,7 @@ import os
import pandas as pd import pandas as pd
import camelot import camelot
from camelot.core import Table, TableList
from .data import * from .data import *
@@ -13,12 +14,7 @@ testdir = os.path.join(testdir, "files")
def test_parsing_report(): def test_parsing_report():
parsing_report = { parsing_report = {"accuracy": 99.02, "whitespace": 12.24, "order": 1, "page": 1}
'accuracy': 99.02,
'whitespace': 12.24,
'order': 1,
'page': 1
}
filename = os.path.join(testdir, "foo.pdf") filename = os.path.join(testdir, "foo.pdf")
tables = camelot.read_pdf(filename) tables = camelot.read_pdf(filename)
@@ -61,18 +57,30 @@ def test_stream_two_tables():
df2 = pd.DataFrame(data_stream_two_tables_2) df2 = pd.DataFrame(data_stream_two_tables_2)
filename = os.path.join(testdir, "tabula/12s0324.pdf") filename = os.path.join(testdir, "tabula/12s0324.pdf")
tables = camelot.read_pdf(filename, flavor='stream') tables = camelot.read_pdf(filename, flavor="stream")
assert len(tables) == 2 assert len(tables) == 2
assert df1.equals(tables[0].df) assert df1.equals(tables[0].df)
assert df2.equals(tables[1].df) assert df2.equals(tables[1].df)
def test_stream_table_regions():
df = pd.DataFrame(data_stream_table_areas)
filename = os.path.join(testdir, "tabula/us-007.pdf")
tables = camelot.read_pdf(
filename, flavor="stream", table_regions=["320,460,573,335"]
)
assert df.equals(tables[0].df)
def test_stream_table_areas(): def test_stream_table_areas():
df = pd.DataFrame(data_stream_table_areas) df = pd.DataFrame(data_stream_table_areas)
filename = os.path.join(testdir, "tabula/us-007.pdf") filename = os.path.join(testdir, "tabula/us-007.pdf")
tables = camelot.read_pdf(filename, flavor="stream", table_areas=["320,500,573,335"]) tables = camelot.read_pdf(
filename, flavor="stream", table_areas=["320,500,573,335"]
)
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
@@ -81,7 +89,8 @@ def test_stream_columns():
filename = os.path.join(testdir, "mexican_towns.pdf") filename = os.path.join(testdir, "mexican_towns.pdf")
tables = camelot.read_pdf( tables = camelot.read_pdf(
filename, flavor="stream", columns=["67,180,230,425,475"], row_tol=10) filename, flavor="stream", columns=["67,180,230,425,475"], row_tol=10
)
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
@@ -90,7 +99,11 @@ def test_stream_split_text():
filename = os.path.join(testdir, "tabula/m27.pdf") filename = os.path.join(testdir, "tabula/m27.pdf")
tables = camelot.read_pdf( tables = camelot.read_pdf(
filename, flavor="stream", columns=["72,95,209,327,442,529,566,606,683"], split_text=True) filename,
flavor="stream",
columns=["72,95,209,327,442,529,566,606,683"],
split_text=True,
)
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
@@ -106,7 +119,7 @@ def test_stream_strip_text():
df = pd.DataFrame(data_stream_strip_text) df = pd.DataFrame(data_stream_strip_text)
filename = os.path.join(testdir, "detect_vertical_false.pdf") filename = os.path.join(testdir, "detect_vertical_false.pdf")
tables = camelot.read_pdf(filename, flavor="stream", strip_text="\n") tables = camelot.read_pdf(filename, flavor="stream", strip_text=" ,\n")
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
@@ -123,7 +136,8 @@ def test_stream_layout_kwargs():
filename = os.path.join(testdir, "detect_vertical_false.pdf") filename = os.path.join(testdir, "detect_vertical_false.pdf")
tables = camelot.read_pdf( tables = camelot.read_pdf(
filename, flavor="stream", layout_kwargs={"detect_vertical": False}) filename, flavor="stream", layout_kwargs={"detect_vertical": False}
)
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
@@ -131,7 +145,8 @@ def test_lattice():
df = pd.DataFrame(data_lattice) df = pd.DataFrame(data_lattice)
filename = os.path.join( filename = os.path.join(
testdir, "tabula/icdar2013-dataset/competition-dataset-us/us-030.pdf") testdir, "tabula/icdar2013-dataset/competition-dataset-us/us-030.pdf"
)
tables = camelot.read_pdf(filename, pages="2") tables = camelot.read_pdf(filename, pages="2")
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
@@ -200,10 +215,10 @@ def test_lattice_shift_text():
tables = camelot.read_pdf(filename, line_scale=40) tables = camelot.read_pdf(filename, line_scale=40)
assert df_lt.equals(tables[0].df) assert df_lt.equals(tables[0].df)
tables = camelot.read_pdf(filename, line_scale=40, shift_text=['']) tables = camelot.read_pdf(filename, line_scale=40, shift_text=[""])
assert df_disable.equals(tables[0].df) assert df_disable.equals(tables[0].df)
tables = camelot.read_pdf(filename, line_scale=40, shift_text=['r', 'b']) tables = camelot.read_pdf(filename, line_scale=40, shift_text=["r", "b"])
assert df_rb.equals(tables[0].df) assert df_rb.equals(tables[0].df)
@@ -212,7 +227,9 @@ def test_repr():
tables = camelot.read_pdf(filename) tables = camelot.read_pdf(filename)
assert repr(tables) == "<TableList n=1>" assert repr(tables) == "<TableList n=1>"
assert repr(tables[0]) == "<Table shape=(7, 7)>" assert repr(tables[0]) == "<Table shape=(7, 7)>"
assert repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>" assert (
repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>"
)
def test_pages(): def test_pages():
@@ -220,17 +237,23 @@ def test_pages():
tables = camelot.read_pdf(url) tables = camelot.read_pdf(url)
assert repr(tables) == "<TableList n=1>" assert repr(tables) == "<TableList n=1>"
assert repr(tables[0]) == "<Table shape=(7, 7)>" assert repr(tables[0]) == "<Table shape=(7, 7)>"
assert repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>" assert (
repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>"
)
tables = camelot.read_pdf(url, pages='1-end') tables = camelot.read_pdf(url, pages="1-end")
assert repr(tables) == "<TableList n=1>" assert repr(tables) == "<TableList n=1>"
assert repr(tables[0]) == "<Table shape=(7, 7)>" assert repr(tables[0]) == "<Table shape=(7, 7)>"
assert repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>" assert (
repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>"
)
tables = camelot.read_pdf(url, pages='all') tables = camelot.read_pdf(url, pages="all")
assert repr(tables) == "<TableList n=1>" assert repr(tables) == "<TableList n=1>"
assert repr(tables[0]) == "<Table shape=(7, 7)>" assert repr(tables[0]) == "<Table shape=(7, 7)>"
assert repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>" assert (
repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>"
)
def test_url(): def test_url():
@@ -238,7 +261,9 @@ def test_url():
tables = camelot.read_pdf(url) tables = camelot.read_pdf(url)
assert repr(tables) == "<TableList n=1>" assert repr(tables) == "<TableList n=1>"
assert repr(tables[0]) == "<Table shape=(7, 7)>" assert repr(tables[0]) == "<Table shape=(7, 7)>"
assert repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>" assert (
repr(tables[0].cells[0][0]) == "<Cell x1=120.48 y1=218.43 x2=164.64 y2=233.77>"
)
def test_arabic(): def test_arabic():
@@ -247,3 +272,28 @@ def test_arabic():
filename = os.path.join(testdir, "tabula/arabic.pdf") filename = os.path.join(testdir, "tabula/arabic.pdf")
tables = camelot.read_pdf(filename) tables = camelot.read_pdf(filename)
assert df.equals(tables[0].df) assert df.equals(tables[0].df)
def test_table_order():
def _make_table(page, order):
t = Table([], [])
t.page = page
t.order = order
return t
table_list = TableList(
[_make_table(2, 1), _make_table(1, 1), _make_table(3, 4), _make_table(1, 2)]
)
assert [(t.page, t.order) for t in sorted(table_list)] == [
(1, 1),
(1, 2),
(2, 1),
(3, 4),
]
assert [(t.page, t.order) for t in sorted(table_list, reverse=True)] == [
(3, 4),
(2, 1),
(1, 2),
(1, 1),
]
+6 -6
View File
@@ -16,27 +16,27 @@ filename = os.path.join(testdir, 'foo.pdf')
def test_unknown_flavor(): def test_unknown_flavor():
message = ("Unknown flavor specified." message = ("Unknown flavor specified."
" Use either 'lattice' or 'stream'") " Use either 'lattice' or 'stream'")
with pytest.raises(NotImplementedError, message=message): with pytest.raises(NotImplementedError, match=message):
tables = camelot.read_pdf(filename, flavor='chocolate') tables = camelot.read_pdf(filename, flavor='chocolate')
def test_input_kwargs(): def test_input_kwargs():
message = "columns cannot be used with flavor='lattice'" message = "columns cannot be used with flavor='lattice'"
with pytest.raises(ValueError, message=message): with pytest.raises(ValueError, match=message):
tables = camelot.read_pdf(filename, columns=['10,20,30,40']) tables = camelot.read_pdf(filename, columns=['10,20,30,40'])
def test_unsupported_format(): def test_unsupported_format():
message = 'File format not supported' message = 'File format not supported'
filename = os.path.join(testdir, 'foo.csv') filename = os.path.join(testdir, 'foo.csv')
with pytest.raises(NotImplementedError, message=message): with pytest.raises(NotImplementedError, match=message):
tables = camelot.read_pdf(filename) tables = camelot.read_pdf(filename)
def test_stream_equal_length(): def test_stream_equal_length():
message = ("Length of table_areas and columns" message = ("Length of table_areas and columns"
" should be equal") " should be equal")
with pytest.raises(ValueError, message=message): with pytest.raises(ValueError, match=message):
tables = camelot.read_pdf(filename, flavor='stream', tables = camelot.read_pdf(filename, flavor='stream',
table_areas=['10,20,30,40'], columns=['10,20,30,40', '10,20,30,40']) table_areas=['10,20,30,40'], columns=['10,20,30,40', '10,20,30,40'])
@@ -86,12 +86,12 @@ def test_no_tables_found_warnings_suppressed():
def test_no_password(): def test_no_password():
filename = os.path.join(testdir, 'health_protected.pdf') filename = os.path.join(testdir, 'health_protected.pdf')
message = 'file has not been decrypted' message = 'file has not been decrypted'
with pytest.raises(Exception, message=message): with pytest.raises(Exception, match=message):
tables = camelot.read_pdf(filename) tables = camelot.read_pdf(filename)
def test_bad_password(): def test_bad_password():
filename = os.path.join(testdir, 'health_protected.pdf') filename = os.path.join(testdir, 'health_protected.pdf')
message = 'file has not been decrypted' message = 'file has not been decrypted'
with pytest.raises(Exception, message=message): with pytest.raises(Exception, match=message):
tables = camelot.read_pdf(filename, password='wrongpass') tables = camelot.read_pdf(filename, password='wrongpass')