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.
- inkgrid/__init__.py +100 -0
- inkgrid/__main__.py +5 -0
- inkgrid/api.py +167 -0
- inkgrid/cli.py +225 -0
- inkgrid/core/__init__.py +1 -0
- inkgrid/core/assemble.py +320 -0
- inkgrid/core/calls.py +184 -0
- inkgrid/core/furniture.py +292 -0
- inkgrid/core/glossary.py +305 -0
- inkgrid/core/joins.py +191 -0
- inkgrid/core/layout.py +211 -0
- inkgrid/core/lexicon.py +301 -0
- inkgrid/core/lines.py +159 -0
- inkgrid/core/notes.py +132 -0
- inkgrid/core/order.py +52 -0
- inkgrid/core/pipeline.py +135 -0
- inkgrid/core/prose.py +180 -0
- inkgrid/core/tables/__init__.py +1 -0
- inkgrid/core/tables/chart.py +251 -0
- inkgrid/core/tables/corridor.py +942 -0
- inkgrid/core/tables/grid.py +84 -0
- inkgrid/core/tables/lattice.py +294 -0
- inkgrid/core/tables/pages.py +19 -0
- inkgrid/core/tables/proto.py +122 -0
- inkgrid/core/tables/shape.py +65 -0
- inkgrid/core/text.py +34 -0
- inkgrid/core/view.py +49 -0
- inkgrid/errors.py +33 -0
- inkgrid/model/__init__.py +64 -0
- inkgrid/model/base.py +27 -0
- inkgrid/model/canonical.py +34 -0
- inkgrid/model/config.py +112 -0
- inkgrid/model/document.py +458 -0
- inkgrid/model/export.py +165 -0
- inkgrid/model/findings.py +106 -0
- inkgrid/model/geometry.py +211 -0
- inkgrid/model/invariants.py +297 -0
- inkgrid/model/lattice.py +73 -0
- inkgrid/model/page.py +194 -0
- inkgrid/model/verification.py +211 -0
- inkgrid/py.typed +0 -0
- inkgrid/read/__init__.py +1 -0
- inkgrid/read/camelot_reader.py +239 -0
- inkgrid/read/glyphs.py +296 -0
- inkgrid/read/page_findings.py +100 -0
- inkgrid/read/pymupdf_reader.py +508 -0
- inkgrid/read/raw.py +84 -0
- inkgrid/read/rules.py +135 -0
- inkgrid/read/source.py +67 -0
- inkgrid/read/words.py +290 -0
- inkgrid/render/__init__.py +1 -0
- inkgrid/render/inspector.py +247 -0
- inkgrid/verify/__init__.py +5 -0
- inkgrid/verify/checks.py +416 -0
- inkgrid/verify/ink.py +164 -0
- inkgrid/verify/ownership.py +410 -0
- inkgrid/verify/pdfium_reader.py +256 -0
- inkgrid/verify/report.py +197 -0
- inkgrid/verify/rules.py +108 -0
- inkgrid-0.1.0.dist-info/METADATA +404 -0
- inkgrid-0.1.0.dist-info/RECORD +64 -0
- inkgrid-0.1.0.dist-info/WHEEL +4 -0
- inkgrid-0.1.0.dist-info/entry_points.txt +3 -0
- 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
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
|
inkgrid/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The pipeline: pure functions over typed values. Imports only `inkgrid.model` and the stdlib."""
|