inkgrid 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.
Files changed (64) hide show
  1. inkgrid/__init__.py +100 -0
  2. inkgrid/__main__.py +5 -0
  3. inkgrid/api.py +167 -0
  4. inkgrid/cli.py +225 -0
  5. inkgrid/core/__init__.py +1 -0
  6. inkgrid/core/assemble.py +320 -0
  7. inkgrid/core/calls.py +184 -0
  8. inkgrid/core/furniture.py +292 -0
  9. inkgrid/core/glossary.py +305 -0
  10. inkgrid/core/joins.py +191 -0
  11. inkgrid/core/layout.py +211 -0
  12. inkgrid/core/lexicon.py +301 -0
  13. inkgrid/core/lines.py +159 -0
  14. inkgrid/core/notes.py +132 -0
  15. inkgrid/core/order.py +52 -0
  16. inkgrid/core/pipeline.py +135 -0
  17. inkgrid/core/prose.py +180 -0
  18. inkgrid/core/tables/__init__.py +1 -0
  19. inkgrid/core/tables/chart.py +251 -0
  20. inkgrid/core/tables/corridor.py +942 -0
  21. inkgrid/core/tables/grid.py +84 -0
  22. inkgrid/core/tables/lattice.py +294 -0
  23. inkgrid/core/tables/pages.py +19 -0
  24. inkgrid/core/tables/proto.py +122 -0
  25. inkgrid/core/tables/shape.py +65 -0
  26. inkgrid/core/text.py +34 -0
  27. inkgrid/core/view.py +49 -0
  28. inkgrid/errors.py +33 -0
  29. inkgrid/model/__init__.py +64 -0
  30. inkgrid/model/base.py +27 -0
  31. inkgrid/model/canonical.py +34 -0
  32. inkgrid/model/config.py +112 -0
  33. inkgrid/model/document.py +458 -0
  34. inkgrid/model/export.py +165 -0
  35. inkgrid/model/findings.py +106 -0
  36. inkgrid/model/geometry.py +211 -0
  37. inkgrid/model/invariants.py +297 -0
  38. inkgrid/model/lattice.py +73 -0
  39. inkgrid/model/page.py +194 -0
  40. inkgrid/model/verification.py +211 -0
  41. inkgrid/py.typed +0 -0
  42. inkgrid/read/__init__.py +1 -0
  43. inkgrid/read/camelot_reader.py +239 -0
  44. inkgrid/read/glyphs.py +296 -0
  45. inkgrid/read/page_findings.py +100 -0
  46. inkgrid/read/pymupdf_reader.py +508 -0
  47. inkgrid/read/raw.py +84 -0
  48. inkgrid/read/rules.py +135 -0
  49. inkgrid/read/source.py +67 -0
  50. inkgrid/read/words.py +290 -0
  51. inkgrid/render/__init__.py +1 -0
  52. inkgrid/render/inspector.py +247 -0
  53. inkgrid/verify/__init__.py +5 -0
  54. inkgrid/verify/checks.py +416 -0
  55. inkgrid/verify/ink.py +164 -0
  56. inkgrid/verify/ownership.py +410 -0
  57. inkgrid/verify/pdfium_reader.py +256 -0
  58. inkgrid/verify/report.py +197 -0
  59. inkgrid/verify/rules.py +108 -0
  60. inkgrid-0.1.0.dist-info/METADATA +404 -0
  61. inkgrid-0.1.0.dist-info/RECORD +64 -0
  62. inkgrid-0.1.0.dist-info/WHEEL +4 -0
  63. inkgrid-0.1.0.dist-info/entry_points.txt +3 -0
  64. inkgrid-0.1.0.dist-info/licenses/LICENSE +21 -0
inkgrid/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """inkgrid: exact tables and text from born-digital PDFs.
2
+
3
+ `read()` turns a PDF into a validated `Document`; `verify()` grades it against its PDF with a second
4
+ engine; `read_pages()` returns the raw page model, for debugging a reading.
5
+ """
6
+
7
+ import importlib.metadata
8
+
9
+ from inkgrid.api import read, read_pages, verify
10
+ from inkgrid.errors import (
11
+ InkgridError,
12
+ InvariantError,
13
+ PasswordRequired,
14
+ PdfOpenError,
15
+ SourceMismatch,
16
+ StrictModeError,
17
+ WrongPassword,
18
+ )
19
+ from inkgrid.model import (
20
+ Block,
21
+ Cell,
22
+ Defect,
23
+ DefectCode,
24
+ Definition,
25
+ Document,
26
+ Finding,
27
+ FindingCode,
28
+ Footnote,
29
+ Furniture,
30
+ Grid,
31
+ Heading,
32
+ Interval,
33
+ Ledger,
34
+ Lexicon,
35
+ Link,
36
+ LinkEnd,
37
+ ListItem,
38
+ PageCheck,
39
+ PageInfo,
40
+ PageModel,
41
+ Paragraph,
42
+ Producer,
43
+ Profile,
44
+ Reading,
45
+ Rect,
46
+ Region,
47
+ Rule,
48
+ Severity,
49
+ Table,
50
+ VerificationReport,
51
+ Word,
52
+ )
53
+
54
+ __version__ = importlib.metadata.version("inkgrid")
55
+
56
+ __all__ = [
57
+ "Block",
58
+ "Cell",
59
+ "Defect",
60
+ "DefectCode",
61
+ "Definition",
62
+ "Document",
63
+ "Finding",
64
+ "FindingCode",
65
+ "Footnote",
66
+ "Furniture",
67
+ "Grid",
68
+ "Heading",
69
+ "InkgridError",
70
+ "Interval",
71
+ "InvariantError",
72
+ "Ledger",
73
+ "Lexicon",
74
+ "Link",
75
+ "LinkEnd",
76
+ "ListItem",
77
+ "PageCheck",
78
+ "PageInfo",
79
+ "PageModel",
80
+ "Paragraph",
81
+ "PasswordRequired",
82
+ "PdfOpenError",
83
+ "Producer",
84
+ "Profile",
85
+ "Reading",
86
+ "Rect",
87
+ "Region",
88
+ "Rule",
89
+ "Severity",
90
+ "SourceMismatch",
91
+ "StrictModeError",
92
+ "Table",
93
+ "VerificationReport",
94
+ "Word",
95
+ "WrongPassword",
96
+ "__version__",
97
+ "read",
98
+ "read_pages",
99
+ "verify",
100
+ ]
inkgrid/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m inkgrid`: the same as the `inkgrid` command."""
2
+
3
+ from inkgrid.cli import main
4
+
5
+ raise SystemExit(main())
inkgrid/api.py ADDED
@@ -0,0 +1,167 @@
1
+ """The public entry points: the only module where the shell meets the core."""
2
+
3
+ import importlib.metadata
4
+
5
+ from inkgrid.core.pipeline import build_document
6
+ from inkgrid.core.tables.pages import lattice_pages
7
+ from inkgrid.errors import SourceMismatch, StrictModeError
8
+ from inkgrid.model.canonical import sha256_hex
9
+ from inkgrid.model.config import Lexicon, Profile
10
+ from inkgrid.model.document import Document, Lattice
11
+ from inkgrid.model.findings import Severity, summarize
12
+ from inkgrid.model.page import Reading
13
+ from inkgrid.model.verification import VerificationReport
14
+ from inkgrid.read.camelot_reader import read_lattice
15
+ from inkgrid.read.pymupdf_reader import lattice_copy, page_frames, read_pdf
16
+ from inkgrid.read.source import SourceLike, load_source
17
+ from inkgrid.verify.pdfium_reader import read_ink
18
+ from inkgrid.verify.report import verify_document
19
+
20
+
21
+ def read_pages(source: SourceLike, *, password: str | None = None) -> Reading:
22
+ """Read a PDF's text layer and drawings into the raw page model.
23
+
24
+ This is the debugging view of a reading: words, rules and page measurements, before any layout
25
+ decision. `read()` builds blocks on top of it.
26
+
27
+ Args:
28
+ source: a path, bytes-like object, or binary stream holding a PDF.
29
+ password: the user password, for an encrypted PDF.
30
+
31
+ Raises:
32
+ TypeError: `source` is not a supported input type.
33
+ PdfOpenError: the input is missing, empty, or not a readable PDF.
34
+ PasswordRequired: the PDF needs a password and none was given.
35
+ WrongPassword: the password does not open the PDF.
36
+ """
37
+ loaded = load_source(source)
38
+ return read_pdf(loaded.data, file_name=loaded.file_name, password=password)
39
+
40
+
41
+ def _lattice(value: str) -> Lattice:
42
+ match value:
43
+ case "combined":
44
+ return "combined"
45
+ case "vector":
46
+ return "vector"
47
+ case "raster":
48
+ return "raster"
49
+ case _:
50
+ msg = f"lattice must be 'combined', 'vector', or 'raster', not {value!r}"
51
+ raise ValueError(msg)
52
+
53
+
54
+ def _profile(value: object) -> Profile:
55
+ """The profile to run, revalidated: callers without type checking can pass anything."""
56
+ if value is None:
57
+ return Profile.default()
58
+ if not isinstance(value, Profile):
59
+ msg = f"profile must be an inkgrid.Profile, not {type(value).__name__}"
60
+ raise TypeError(msg)
61
+ return Profile.model_validate(value)
62
+
63
+
64
+ def _lexicon(value: object) -> Lexicon:
65
+ """The lexicon to run, revalidated: callers without type checking can pass anything."""
66
+ if value is None:
67
+ return Lexicon.default()
68
+ if not isinstance(value, Lexicon):
69
+ msg = f"lexicon must be an inkgrid.Lexicon, not {type(value).__name__}"
70
+ raise TypeError(msg)
71
+ return Lexicon.model_validate(value)
72
+
73
+
74
+ def read(
75
+ source: SourceLike,
76
+ *,
77
+ password: str | None = None,
78
+ lexicon: Lexicon | None = None,
79
+ profile: Profile | None = None,
80
+ lattice: str = "combined",
81
+ strict: bool = False,
82
+ ) -> Document:
83
+ """Read a PDF into a validated `Document`: every word in exactly one block, in reading order.
84
+
85
+ Args:
86
+ source: a path, bytes-like object, or binary stream holding a PDF.
87
+ password: the user password, for an encrypted PDF.
88
+ lexicon: token classes; `Lexicon.default()` when omitted.
89
+ profile: geometry tolerances; `Profile.default()` when omitted.
90
+ lattice: Camelot's lattice engine for ruled tables: `combined`, `vector`, or `raster`.
91
+ strict: raise instead of returning a document that carries an error-severity finding.
92
+
93
+ Raises:
94
+ TypeError: `source`, `lexicon`, or `profile` is of the wrong type.
95
+ ValueError: `lattice` is unknown, or a configuration fails revalidation.
96
+ PdfOpenError, PasswordRequired, WrongPassword: as for `read_pages`.
97
+ StrictModeError: `strict` is set and the document carries an error-severity finding.
98
+ InvariantError: a guarantee failed to hold, which is a bug in inkgrid.
99
+ """
100
+ checked_lexicon = _lexicon(lexicon)
101
+ checked_profile = _profile(profile)
102
+ checked_lattice = _lattice(lattice)
103
+ loaded = load_source(source)
104
+ reading = read_pdf(loaded.data, file_name=loaded.file_name, password=password)
105
+ # Camelot renders every page it reads at 300 DPI, so it reads only pages ruled both ways.
106
+ pages = lattice_pages(reading)
107
+ grids = (
108
+ read_lattice(
109
+ lattice_copy(loaded.data, password),
110
+ page_frames(loaded.data, password),
111
+ pages,
112
+ engine=checked_lattice,
113
+ password=None,
114
+ )
115
+ if pages
116
+ else None
117
+ )
118
+ doc = build_document(
119
+ reading,
120
+ lexicon=checked_lexicon,
121
+ profile=checked_profile,
122
+ lattice=checked_lattice,
123
+ grids=grids,
124
+ )
125
+ if strict and not doc.complete:
126
+ errors = [f for f in doc.findings if f.severity is Severity.ERROR]
127
+ msg = f"strict mode: the document carries error findings: {summarize(errors)}"
128
+ raise StrictModeError(msg)
129
+ return doc
130
+
131
+
132
+ def _document(value: object) -> Document:
133
+ """The document to verify: callers without type checking can pass anything."""
134
+ if not isinstance(value, Document):
135
+ msg = f"doc must be an inkgrid.Document, not {type(value).__name__}"
136
+ raise TypeError(msg)
137
+ return value
138
+
139
+
140
+ def verify(doc: Document, source: SourceLike, *, password: str | None = None) -> VerificationReport:
141
+ """Grade `doc` against the PDF it was read from, as a second engine (PDFium) reads it.
142
+
143
+ Every character PDFium reads must be owned by exactly one word that holds it, every table's
144
+ cells must hold the ink inside them, and every value must be bound to one block and one cell
145
+ (docs/specs/10-verify.md). A defect is reported, never raised.
146
+
147
+ Args:
148
+ doc: the document `read` returned for `source`.
149
+ source: a path, bytes-like object, or binary stream holding the same PDF.
150
+ password: the user password, for an encrypted PDF.
151
+
152
+ Raises:
153
+ TypeError: `doc` is not a `Document`, or `source` is not a supported input type.
154
+ SourceMismatch: `source` is not the PDF `doc` was read from.
155
+ PdfOpenError, PasswordRequired, WrongPassword: as for `read_pages`.
156
+ """
157
+ checked = _document(doc)
158
+ loaded = load_source(source)
159
+ digest = sha256_hex(loaded.data)
160
+ if digest != checked.source.sha256:
161
+ msg = (
162
+ f"the PDF's SHA-256 {digest[:16]}... differs from the document's "
163
+ f"{checked.source.sha256[:16]}...; it was read from another PDF"
164
+ )
165
+ raise SourceMismatch(msg)
166
+ ink = read_ink(loaded.data, password)
167
+ return verify_document(checked, ink, inkgrid_version=importlib.metadata.version("inkgrid"))
inkgrid/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """The `inkgrid` command line: `read`, `verify`, and `words`.
2
+
3
+ `read` prints the `Document`, `verify` its report against the PDF, and `words` the page model. Exit
4
+ codes: 0 success; 1 strict mode found an error-severity finding, or verification found a
5
+ defect; 2 usage errors and unreadable input. Output is always UTF-8 bytes, whatever the console's
6
+ encoding.
7
+ """
8
+
9
+ import argparse
10
+ import importlib.metadata
11
+ import os
12
+ import sys
13
+ from collections import Counter
14
+ from collections.abc import Callable, Sequence
15
+ from pathlib import Path
16
+
17
+ from inkgrid.api import read, read_pages, verify
18
+ from inkgrid.errors import PasswordRequired, PdfOpenError, SourceMismatch, WrongPassword
19
+ from inkgrid.model.document import Document, document_from_json
20
+ from inkgrid.model.findings import Severity, summarize
21
+ from inkgrid.model.page import Reading
22
+ from inkgrid.model.verification import DefectCode, VerificationReport
23
+ from inkgrid.render.inspector import build_inspector
24
+
25
+ EXIT_OK = 0
26
+ EXIT_STRICT = 1
27
+ EXIT_DEFECTS = 1
28
+ EXIT_USAGE = 2
29
+ INPUT_ERRORS = (PdfOpenError, PasswordRequired, WrongPassword)
30
+
31
+
32
+ class _UsageError(Exception):
33
+ """A problem with the arguments or the input: one line on stderr, exit 2."""
34
+
35
+
36
+ def _add_common(command: argparse.ArgumentParser) -> None:
37
+ command.add_argument("pdf", type=Path, help="the PDF to read")
38
+ command.add_argument("-o", "--output", type=Path, help="write the JSON here instead of stdout")
39
+ command.add_argument("--pretty", action="store_true", help="indent the JSON for reading")
40
+ command.add_argument(
41
+ "--password-stdin",
42
+ action="store_true",
43
+ help="read the password from the first line of stdin (never from argv)",
44
+ )
45
+
46
+
47
+ def _parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(
49
+ prog="inkgrid",
50
+ description="Exact tables and text from born-digital PDFs.",
51
+ )
52
+ parser.add_argument(
53
+ "--version", action="version", version=f"inkgrid {importlib.metadata.version('inkgrid')}"
54
+ )
55
+ commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
56
+ read_command = commands.add_parser("read", help="print the Document (blocks, links) as JSON")
57
+ _add_common(read_command)
58
+ read_command.add_argument("--markdown", type=Path, help="also write the text as Markdown")
59
+ read_command.add_argument("--inspector", type=Path, help="also write the HTML inspector")
60
+ read_command.add_argument(
61
+ "--strict", action="store_true", help="exit 1 when an error-severity finding is present"
62
+ )
63
+ read_command.add_argument(
64
+ "--lattice",
65
+ choices=("combined", "vector", "raster"),
66
+ default="combined",
67
+ help="Camelot's engine for ruled tables (default: combined)",
68
+ )
69
+ verify_command = commands.add_parser(
70
+ "verify", help="grade a Document against its PDF; exit 1 on any defect"
71
+ )
72
+ _add_common(verify_command)
73
+ verify_command.add_argument(
74
+ "document", type=Path, help="the Document JSON `inkgrid read` wrote"
75
+ )
76
+ verify_command.add_argument(
77
+ "--inspector", type=Path, help="also write the HTML inspector with the defects drawn"
78
+ )
79
+ words = commands.add_parser("words", help="print the raw page model (words, rules) as JSON")
80
+ _add_common(words)
81
+ return parser
82
+
83
+
84
+ def _password_from_stdin() -> str:
85
+ """The first stdin line, read as UTF-8 bytes, with only its line ending removed.
86
+
87
+ Raises:
88
+ UnicodeDecodeError: the line is not UTF-8.
89
+ """
90
+ buffer = getattr(sys.stdin, "buffer", None)
91
+ line: str = sys.stdin.readline() if buffer is None else buffer.readline().decode("utf-8")
92
+ return line.removesuffix("\n").removesuffix("\r")
93
+
94
+
95
+ def _password(args: argparse.Namespace) -> str | None:
96
+ if not args.password_stdin:
97
+ return None
98
+ try:
99
+ return _password_from_stdin()
100
+ except UnicodeDecodeError as exc:
101
+ msg = "the password on stdin is not UTF-8"
102
+ raise _UsageError(msg) from exc
103
+
104
+
105
+ def _write_stdout(data: bytes) -> None:
106
+ buffer = getattr(sys.stdout, "buffer", None)
107
+ if buffer is None:
108
+ sys.stdout.write(data.decode("utf-8"))
109
+ sys.stdout.flush()
110
+ else:
111
+ sys.stdout.flush()
112
+ buffer.write(data)
113
+ buffer.flush()
114
+
115
+
116
+ def _discard_stdout() -> None:
117
+ """Point stdout at the null device, so the interpreter's final flush cannot fail again."""
118
+ devnull = os.open(os.devnull, os.O_WRONLY)
119
+ try:
120
+ os.dup2(devnull, sys.stdout.fileno())
121
+ finally:
122
+ os.close(devnull)
123
+
124
+
125
+ def _write_file(path: Path, data: bytes) -> None:
126
+ try:
127
+ path.write_bytes(data)
128
+ except OSError as exc:
129
+ msg = f"cannot write {path}: {exc.strerror or exc}"
130
+ raise _UsageError(msg) from exc
131
+
132
+
133
+ def _emit(model: Reading | Document | VerificationReport, args: argparse.Namespace) -> None:
134
+ text = model.model_dump_json(indent=2) if args.pretty else model.model_dump_json()
135
+ data = text.encode("utf-8") + b"\n"
136
+ if args.output is not None:
137
+ _write_file(args.output, data)
138
+ return
139
+ try:
140
+ _write_stdout(data)
141
+ except BrokenPipeError:
142
+ # The reader stopped early (`inkgrid read f.pdf | head`); what it read was correct.
143
+ _discard_stdout()
144
+
145
+
146
+ def _words(args: argparse.Namespace) -> int:
147
+ _emit(read_pages(args.pdf, password=_password(args)), args)
148
+ return EXIT_OK
149
+
150
+
151
+ def _read(args: argparse.Namespace) -> int:
152
+ password = _password(args)
153
+ doc = read(args.pdf, password=password, lattice=args.lattice)
154
+ _emit(doc, args)
155
+ if args.markdown is not None:
156
+ _write_file(args.markdown, doc.to_markdown().encode("utf-8"))
157
+ if args.inspector is not None:
158
+ try:
159
+ html = build_inspector(doc, args.pdf, password)
160
+ except ValueError as exc:
161
+ raise _UsageError(str(exc)) from exc
162
+ _write_file(args.inspector, html.encode("utf-8"))
163
+ if args.strict and not doc.complete:
164
+ errors = [f for f in doc.findings if f.severity is Severity.ERROR]
165
+ sys.stderr.write(f"inkgrid: strict mode: error findings: {summarize(errors)}\n")
166
+ return EXIT_STRICT
167
+ return EXIT_OK
168
+
169
+
170
+ def _load_document(path: Path) -> Document:
171
+ try:
172
+ text = path.read_bytes()
173
+ except OSError as exc:
174
+ msg = f"cannot read {path}: {exc.strerror or exc}"
175
+ raise _UsageError(msg) from exc
176
+ try:
177
+ return document_from_json(text)
178
+ except ValueError as exc:
179
+ msg = f"{path} is not an inkgrid document: {exc}"
180
+ raise _UsageError(msg) from exc
181
+
182
+
183
+ def _defect_summary(report: VerificationReport) -> str:
184
+ counts = Counter(d.code for d in report.defects)
185
+ total = len(report.defects)
186
+ codes = ", ".join(f"{code.value} {counts[code]}" for code in DefectCode if counts[code])
187
+ return f"{total} defect{'' if total == 1 else 's'}: {codes}"
188
+
189
+
190
+ def _verify(args: argparse.Namespace) -> int:
191
+ password = _password(args)
192
+ doc = _load_document(args.document)
193
+ try:
194
+ report = verify(doc, args.pdf, password=password)
195
+ except SourceMismatch as exc:
196
+ raise _UsageError(str(exc)) from exc
197
+ _emit(report, args)
198
+ if args.inspector is not None:
199
+ html = build_inspector(doc, args.pdf, password, report)
200
+ _write_file(args.inspector, html.encode("utf-8"))
201
+ if not report.ok:
202
+ sys.stderr.write(f"inkgrid: verify: {_defect_summary(report)}\n")
203
+ return EXIT_DEFECTS
204
+ return EXIT_OK
205
+
206
+
207
+ COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = {
208
+ "read": _read,
209
+ "verify": _verify,
210
+ "words": _words,
211
+ }
212
+
213
+
214
+ def main(argv: Sequence[str] | None = None) -> int:
215
+ """Run the command line and return its exit code."""
216
+ try:
217
+ args = _parser().parse_args(argv)
218
+ except SystemExit as exc:
219
+ code = exc.code
220
+ return code if isinstance(code, int) else (EXIT_OK if code is None else EXIT_USAGE)
221
+ try:
222
+ return COMMANDS[args.command](args)
223
+ except (*INPUT_ERRORS, _UsageError) as exc:
224
+ sys.stderr.write(f"inkgrid: {exc}\n")
225
+ return EXIT_USAGE
@@ -0,0 +1 @@
1
+ """The pipeline: pure functions over typed values. Imports only `inkgrid.model` and the stdlib."""