pdfsieve 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdfsieve/__init__.py +25 -0
- pdfsieve/__main__.py +107 -0
- pdfsieve/model.py +228 -0
- pdfsieve/py.typed +0 -0
- pdfsieve/reader.py +339 -0
- pdfsieve/segments.py +42 -0
- pdfsieve/words.py +107 -0
- pdfsieve-0.1.0.dist-info/METADATA +391 -0
- pdfsieve-0.1.0.dist-info/RECORD +15 -0
- pdfsieve-0.1.0.dist-info/WHEEL +5 -0
- pdfsieve-0.1.0.dist-info/entry_points.txt +2 -0
- pdfsieve-0.1.0.dist-info/licenses/LICENSE +202 -0
- pdfsieve-0.1.0.dist-info/licenses/NOTICE +7 -0
- pdfsieve-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +38 -0
- pdfsieve-0.1.0.dist-info/top_level.txt +1 -0
pdfsieve/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""pdfsieve: read characters, words, lines, shapes and images from PDF pages, with coordinates.
|
|
2
|
+
|
|
3
|
+
Pure Python on top of pdfminer.six. Coordinates are PDF points with the origin at the top-left of
|
|
4
|
+
the visible page, after the page's rotation.
|
|
5
|
+
|
|
6
|
+
>>> import pdfsieve
|
|
7
|
+
>>> with pdfsieve.open("statement.pdf") as doc:
|
|
8
|
+
... page = doc.page(0)
|
|
9
|
+
... for w in page.words():
|
|
10
|
+
... print(round(w.x0), round(w.y0), w.text)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .model import RGB, Glyph, Item, Page, Picture, Segment, Shape, Word, to_rgb
|
|
14
|
+
from .reader import Document, EmptyDocumentError, PasswordError, PdfSieveError, Recorder, open
|
|
15
|
+
from .segments import horizontal_segments, vertical_segments
|
|
16
|
+
from .words import LIGATURES, lines_from, words_from
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"open", "Document", "Page", "Glyph", "Shape", "Picture", "Word", "Segment", "Item", "RGB",
|
|
22
|
+
"words_from", "lines_from", "vertical_segments", "horizontal_segments", "to_rgb",
|
|
23
|
+
"LIGATURES", "Recorder", "PdfSieveError", "PasswordError", "EmptyDocumentError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
pdfsieve/__main__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Command line: ``pdfsieve file.pdf`` (or ``python -m pdfsieve file.pdf``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from . import PdfSieveError, __version__
|
|
11
|
+
from . import open as open_pdf
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_pages(spec: Optional[str], count: int) -> List[int]:
|
|
15
|
+
"""'1,3-5' -> [0, 2, 3, 4] (input is 1-based, output 0-based). None means every page."""
|
|
16
|
+
if not spec:
|
|
17
|
+
return list(range(count))
|
|
18
|
+
out: List[int] = []
|
|
19
|
+
for part in spec.split(","):
|
|
20
|
+
part = part.strip()
|
|
21
|
+
if not part:
|
|
22
|
+
continue
|
|
23
|
+
if "-" in part:
|
|
24
|
+
a, b = part.split("-", 1)
|
|
25
|
+
lo = int(a) if a.strip() else 1
|
|
26
|
+
hi = int(b) if b.strip() else count
|
|
27
|
+
else:
|
|
28
|
+
lo = hi = int(part)
|
|
29
|
+
for n in range(lo, hi + 1):
|
|
30
|
+
if not 1 <= n <= count:
|
|
31
|
+
raise ValueError(f"page {n} out of range (the PDF has {count} pages)")
|
|
32
|
+
if n - 1 not in out:
|
|
33
|
+
out.append(n - 1)
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
38
|
+
ap = argparse.ArgumentParser(
|
|
39
|
+
prog="pdfsieve",
|
|
40
|
+
description="Print the text, words or full content of PDF pages, with coordinates "
|
|
41
|
+
"(points, origin top-left).")
|
|
42
|
+
ap.add_argument("pdf", help="the PDF file")
|
|
43
|
+
ap.add_argument("-p", "--pages", help="pages to read, 1-based, e.g. 1,3-5 (default: all)")
|
|
44
|
+
ap.add_argument("-f", "--format", choices=("text", "words", "json", "info"), default="text",
|
|
45
|
+
help="text: plain text; words: one word per line with its box; json: words, "
|
|
46
|
+
"lines and page facts; info: document and page summary (default: text)")
|
|
47
|
+
ap.add_argument("--visible-only", action="store_true",
|
|
48
|
+
help="skip invisible text, such as the hidden OCR layer of a scan")
|
|
49
|
+
ap.add_argument("--password", default="", help="password for an encrypted PDF")
|
|
50
|
+
ap.add_argument("-V", "--version", action="version", version=f"pdfsieve {__version__}")
|
|
51
|
+
args = ap.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
doc = open_pdf(args.pdf, password=args.password)
|
|
55
|
+
except (OSError, PdfSieveError) as exc:
|
|
56
|
+
print(f"pdfsieve: {exc}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
with doc:
|
|
59
|
+
try:
|
|
60
|
+
pages = parse_pages(args.pages, doc.page_count)
|
|
61
|
+
except ValueError as exc:
|
|
62
|
+
print(f"pdfsieve: {exc}", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
vis = args.visible_only
|
|
65
|
+
if args.format == "info":
|
|
66
|
+
meta = doc.metadata()
|
|
67
|
+
print(f"File: {args.pdf}")
|
|
68
|
+
print(f"Pages: {doc.page_count} encrypted: {doc.encrypted}")
|
|
69
|
+
for k in ("title", "author", "producer", "creator"):
|
|
70
|
+
if meta.get(k):
|
|
71
|
+
print(f"{k.capitalize() + ':':<10} {meta[k]}")
|
|
72
|
+
for i in pages:
|
|
73
|
+
p = doc.page(i)
|
|
74
|
+
print(f"Page {i + 1}: {p.width:.1f} x {p.height:.1f} pt, rotate {p.rotation}, "
|
|
75
|
+
f"{len(p.words(visible_only=vis))} words, {p.images} images, "
|
|
76
|
+
f"{len(p.shapes)} shapes, {p.curves} curves, {len(p.fonts)} fonts"
|
|
77
|
+
+ (f", {p.undefined} unmapped chars" if p.undefined else "")
|
|
78
|
+
+ (f" [damaged: {doc.errors[i]}]" if i in doc.errors else ""))
|
|
79
|
+
return 0
|
|
80
|
+
if args.format == "json":
|
|
81
|
+
out = []
|
|
82
|
+
for i in pages:
|
|
83
|
+
p = doc.page(i)
|
|
84
|
+
out.append({
|
|
85
|
+
"page": i + 1, "width": round(p.width, 2), "height": round(p.height, 2),
|
|
86
|
+
"rotation": p.rotation, "images": p.images, "curves": p.curves,
|
|
87
|
+
"fonts": sorted(f for f in p.fonts if f),
|
|
88
|
+
"lines": [[[round(w.x0, 2), round(w.y0, 2), round(w.x1, 2), round(w.y1, 2), w.text]
|
|
89
|
+
for w in line] for line in p.lines(visible_only=vis)],
|
|
90
|
+
})
|
|
91
|
+
json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
|
|
92
|
+
print()
|
|
93
|
+
return 0
|
|
94
|
+
for n, i in enumerate(pages):
|
|
95
|
+
p = doc.page(i)
|
|
96
|
+
if args.format == "words":
|
|
97
|
+
for w in p.words(visible_only=vis):
|
|
98
|
+
print(f"{i + 1}\t{w.x0:.1f}\t{w.y0:.1f}\t{w.x1:.1f}\t{w.y1:.1f}\t{w.text}")
|
|
99
|
+
else:
|
|
100
|
+
if n:
|
|
101
|
+
print("\f", end="")
|
|
102
|
+
print(p.text(visible_only=vis))
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|
pdfsieve/model.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Plain data classes for what pdfsieve finds on a page.
|
|
2
|
+
|
|
3
|
+
Every coordinate is in *page space*: PDF points (1/72 inch), origin at the TOP-LEFT of the visible
|
|
4
|
+
page (the CropBox), x to the right, y downwards, after the page's /Rotate has been applied. That is
|
|
5
|
+
the same convention as most image libraries, so a box can be scaled straight onto a rendered image.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import NamedTuple, Optional, Tuple, Union
|
|
12
|
+
|
|
13
|
+
RGB = Tuple[int, int, int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Glyph:
|
|
18
|
+
"""One character of real text, as the PDF draws it."""
|
|
19
|
+
|
|
20
|
+
x0: float
|
|
21
|
+
y0: float
|
|
22
|
+
x1: float
|
|
23
|
+
y1: float
|
|
24
|
+
text: str
|
|
25
|
+
"""The Unicode text of the character (ligatures such as "fi" stay as one character here)."""
|
|
26
|
+
size: float
|
|
27
|
+
"""Font size in points, as it appears on the page."""
|
|
28
|
+
font: str
|
|
29
|
+
"""The PDF's font name, e.g. ``"ABCDEF+Arial-BoldMT"``."""
|
|
30
|
+
color: Optional[RGB]
|
|
31
|
+
"""Fill colour as (r, g, b) 0..255, or None when it can't be known (patterns, Lab, ...)."""
|
|
32
|
+
visible: bool
|
|
33
|
+
"""False for invisible text (render modes 3 and 7), e.g. the hidden OCR layer of a scan."""
|
|
34
|
+
upright: bool
|
|
35
|
+
"""True when the writing runs left to right (``turn == 0``)."""
|
|
36
|
+
run: int = 0
|
|
37
|
+
"""Which text-showing operation drew it; characters of one table cell usually share a run."""
|
|
38
|
+
base: float = 0.0
|
|
39
|
+
"""Baseline y in page space."""
|
|
40
|
+
turn: int = 0
|
|
41
|
+
"""Writing direction: 0 normal, 90 reads bottom-to-top, 180 upside down, 270 top-to-bottom."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Shape:
|
|
46
|
+
"""One painted path.
|
|
47
|
+
|
|
48
|
+
``subpaths`` is a list of ``(points, closed)``. ``points`` starts with ``("m", x, y)`` and
|
|
49
|
+
continues with ``("l", x, y)`` straight segments and ``("c", x1, y1, x2, y2, x3, y3)`` cubic
|
|
50
|
+
Bézier curves, all in page space.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
subpaths: list
|
|
54
|
+
fill: Optional[RGB]
|
|
55
|
+
"""Fill colour, or None when the path is not filled (or filled with a pattern/shading)."""
|
|
56
|
+
stroke: Optional[RGB]
|
|
57
|
+
"""Stroke colour, or None when the path is not stroked."""
|
|
58
|
+
width: float
|
|
59
|
+
"""Stroke width in points on the page."""
|
|
60
|
+
evenodd: bool
|
|
61
|
+
"""True for the even-odd fill rule, False for non-zero winding."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Picture:
|
|
66
|
+
"""One embedded image (an image XObject or inline image)."""
|
|
67
|
+
|
|
68
|
+
stream: object
|
|
69
|
+
"""The pdfminer ``PDFStream`` holding the image data."""
|
|
70
|
+
matrix: tuple
|
|
71
|
+
"""``(a, b, c, d, e, f)``: maps the unit square of the image onto page space."""
|
|
72
|
+
color: Optional[RGB]
|
|
73
|
+
"""Paint colour for stencil masks (1-bit images painted in the current fill colour)."""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def bbox(self) -> Tuple[float, float, float, float]:
|
|
77
|
+
"""The image's bounding box on the page as (x0, y0, x1, y1)."""
|
|
78
|
+
a, b, c, d, e, f = self.matrix
|
|
79
|
+
xs = (e, e + a, e + c, e + a + c)
|
|
80
|
+
ys = (f, f + b, f + d, f + b + d)
|
|
81
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Item = Union[Shape, Picture]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Word(NamedTuple):
|
|
88
|
+
"""A word: characters on one line with no space or wide gap between them."""
|
|
89
|
+
|
|
90
|
+
x0: float
|
|
91
|
+
y0: float
|
|
92
|
+
x1: float
|
|
93
|
+
y1: float
|
|
94
|
+
text: str
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def cx(self) -> float:
|
|
98
|
+
return (self.x0 + self.x1) / 2
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def cy(self) -> float:
|
|
102
|
+
return (self.y0 + self.y1) / 2
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def width(self) -> float:
|
|
106
|
+
return self.x1 - self.x0
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def height(self) -> float:
|
|
110
|
+
return self.y1 - self.y0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Segment(NamedTuple):
|
|
114
|
+
"""A straight horizontal or vertical stroke or edge, e.g. a table border.
|
|
115
|
+
|
|
116
|
+
For vertical segments ``a0``/``a1`` are the top and bottom y and ``pos`` is x;
|
|
117
|
+
for horizontal segments they are the left and right x and ``pos`` is y.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
pos: float
|
|
121
|
+
a0: float
|
|
122
|
+
a1: float
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def length(self) -> float:
|
|
126
|
+
return self.a1 - self.a0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class Page:
|
|
131
|
+
"""Everything pdfsieve recorded on one page."""
|
|
132
|
+
|
|
133
|
+
width: float
|
|
134
|
+
"""Width of the visible page in points, after rotation."""
|
|
135
|
+
height: float
|
|
136
|
+
"""Height of the visible page in points, after rotation."""
|
|
137
|
+
rotation: int
|
|
138
|
+
"""The page's /Rotate value (0, 90, 180 or 270); already applied to all coordinates."""
|
|
139
|
+
glyphs: list = field(default_factory=list)
|
|
140
|
+
"""Every character, in drawing order (a list of :class:`Glyph`)."""
|
|
141
|
+
items: list = field(default_factory=list)
|
|
142
|
+
"""Shapes and pictures in drawing order (:class:`Shape` and :class:`Picture`)."""
|
|
143
|
+
fonts: set = field(default_factory=set)
|
|
144
|
+
"""Names of the fonts used for text on the page."""
|
|
145
|
+
curves: int = 0
|
|
146
|
+
"""Number of Bézier curve segments; a large number with no text usually means text drawn as
|
|
147
|
+
outlines (e.g. "Microsoft Print to PDF" output)."""
|
|
148
|
+
images: int = 0
|
|
149
|
+
"""Number of images drawn on the page."""
|
|
150
|
+
undefined: int = 0
|
|
151
|
+
"""Characters the fonts could not map to Unicode (shown as U+FFFD in ``glyphs``)."""
|
|
152
|
+
index: int = 0
|
|
153
|
+
"""0-based page number."""
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def shapes(self) -> list:
|
|
157
|
+
"""Only the :class:`Shape` items."""
|
|
158
|
+
return [i for i in self.items if isinstance(i, Shape)]
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def pictures(self) -> list:
|
|
162
|
+
"""Only the :class:`Picture` items."""
|
|
163
|
+
return [i for i in self.items if isinstance(i, Picture)]
|
|
164
|
+
|
|
165
|
+
def words(self, *, visible_only: bool = False) -> list:
|
|
166
|
+
"""The page's words as :class:`Word` tuples, line by line, top to bottom.
|
|
167
|
+
|
|
168
|
+
``visible_only=True`` skips invisible text, such as the hidden OCR layer of a scanned page.
|
|
169
|
+
"""
|
|
170
|
+
from .words import words_from
|
|
171
|
+
|
|
172
|
+
glyphs = [g for g in self.glyphs if g.visible] if visible_only else self.glyphs
|
|
173
|
+
return words_from(glyphs)
|
|
174
|
+
|
|
175
|
+
def lines(self, *, visible_only: bool = False) -> list:
|
|
176
|
+
"""The page's text as lines of words: a list of lists of :class:`Word`."""
|
|
177
|
+
from .words import lines_from
|
|
178
|
+
|
|
179
|
+
glyphs = [g for g in self.glyphs if g.visible] if visible_only else self.glyphs
|
|
180
|
+
return lines_from(glyphs)
|
|
181
|
+
|
|
182
|
+
def text(self, *, visible_only: bool = False) -> str:
|
|
183
|
+
"""Plain text of the page: words joined by spaces, one line of text per line."""
|
|
184
|
+
return "\n".join(" ".join(w.text for w in line) for line in self.lines(visible_only=visible_only))
|
|
185
|
+
|
|
186
|
+
def vertical_lines(self, min_len: float = 4.0) -> list:
|
|
187
|
+
"""Straight vertical strokes and edges (table borders) as :class:`Segment` (x, top, bottom)."""
|
|
188
|
+
from .segments import vertical_segments
|
|
189
|
+
|
|
190
|
+
return vertical_segments(self, min_len)
|
|
191
|
+
|
|
192
|
+
def horizontal_lines(self, min_len: float = 4.0) -> list:
|
|
193
|
+
"""Straight horizontal strokes and edges as :class:`Segment` (y, left, right)."""
|
|
194
|
+
from .segments import horizontal_segments
|
|
195
|
+
|
|
196
|
+
return horizontal_segments(self, min_len)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def to_rgb(color, space=None) -> Optional[RGB]:
|
|
200
|
+
"""A PDF colour as (r, g, b) 0..255, or None when it can't be known (patterns, shadings)."""
|
|
201
|
+
name = getattr(space, "name", "") or ""
|
|
202
|
+
if name == "Pattern":
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
if isinstance(color, (int, float)):
|
|
206
|
+
vals = [float(color)]
|
|
207
|
+
else:
|
|
208
|
+
vals = [float(v) for v in color]
|
|
209
|
+
except (TypeError, ValueError):
|
|
210
|
+
return None
|
|
211
|
+
vals = [min(max(v, 0.0), 1.0) for v in vals]
|
|
212
|
+
if len(vals) == 1:
|
|
213
|
+
g = vals[0]
|
|
214
|
+
if name in ("Separation", "DeviceN"): # tint: 1 = full ink
|
|
215
|
+
g = 1 - g
|
|
216
|
+
elif name == "Indexed":
|
|
217
|
+
return None
|
|
218
|
+
v = round(g * 255)
|
|
219
|
+
return (v, v, v)
|
|
220
|
+
if len(vals) == 3:
|
|
221
|
+
if name == "Lab":
|
|
222
|
+
return None
|
|
223
|
+
return (round(vals[0] * 255), round(vals[1] * 255), round(vals[2] * 255))
|
|
224
|
+
if len(vals) == 4:
|
|
225
|
+
c, m, y, k = vals
|
|
226
|
+
return (round(255 * (1 - min(1, c + k))), round(255 * (1 - min(1, m + k))),
|
|
227
|
+
round(255 * (1 - min(1, y + k))))
|
|
228
|
+
return None
|
pdfsieve/py.typed
ADDED
|
File without changes
|