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 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