proof-pdf 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.
- proof_pdf/__init__.py +14 -0
- proof_pdf/cli.py +300 -0
- proof_pdf/colour.py +365 -0
- proof_pdf/content.py +177 -0
- proof_pdf/core.py +108 -0
- proof_pdf/models.py +114 -0
- proof_pdf/preview.py +170 -0
- proof_pdf/py.typed +0 -0
- proof_pdf/resolution.py +90 -0
- proof_pdf/structure.py +242 -0
- proof_pdf/verapdf.py +100 -0
- proof_pdf-0.1.0.dist-info/METADATA +101 -0
- proof_pdf-0.1.0.dist-info/RECORD +17 -0
- proof_pdf-0.1.0.dist-info/WHEEL +4 -0
- proof_pdf-0.1.0.dist-info/entry_points.txt +5 -0
- proof_pdf-0.1.0.dist-info/licenses/LICENSE +202 -0
- proof_pdf-0.1.0.dist-info/licenses/NOTICE +19 -0
proof_pdf/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Print-PDF conformance verification.
|
|
2
|
+
|
|
3
|
+
Wraps veraPDF (the PDF/A engine) and adds the checks veraPDF cannot make: the
|
|
4
|
+
PDF/X-4 structural identity (page boxes, ``/Trapped``, the X output intent and
|
|
5
|
+
XMP), the finished-page geometry, and best-effort colour sanity (total ink
|
|
6
|
+
coverage, overprint, rich-black small text, stray RGB, effective image DPI).
|
|
7
|
+
|
|
8
|
+
The entry point is :func:`proof_pdf.core.verify_file`; the CLI group lives in
|
|
9
|
+
:mod:`proof_pdf.cli` and is the ``proof-pdf`` console script.
|
|
10
|
+
|
|
11
|
+
The package takes PDF paths and nothing else. The finished-page geometry it checks
|
|
12
|
+
against is a parameter (:class:`PageGeometry`) with no default, so a file from a
|
|
13
|
+
printer is held to its own dimensions or the layer is skipped.
|
|
14
|
+
"""
|
proof_pdf/cli.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""The ``proof-pdf`` subcommands -- hold a print PDF to PDF/X-4 + PDF/A.
|
|
2
|
+
|
|
3
|
+
``verify`` wraps veraPDF and adds the PDF/X-4, geometry and colour checks
|
|
4
|
+
veraPDF cannot make. ``preview`` rasterises pages with the page-box guides drawn
|
|
5
|
+
on, so a geometry finding can be seen rather than only read.
|
|
6
|
+
|
|
7
|
+
Both take files or directories; a directory expands to every PDF beneath it,
|
|
8
|
+
minus anything matching ``--exclude`` (``core.expand_targets``).
|
|
9
|
+
The commands take paths only, so a file a printer sends back can be checked
|
|
10
|
+
the same way a freshly built one is.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import importlib.metadata
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import pathlib
|
|
19
|
+
import typing
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from . import core, preview, verapdf
|
|
24
|
+
from .models import (
|
|
25
|
+
DEFAULT_MAX_TAC,
|
|
26
|
+
DEFAULT_MIN_DPI,
|
|
27
|
+
DEFAULT_SMALL_TEXT_PT,
|
|
28
|
+
FileReport,
|
|
29
|
+
Finding,
|
|
30
|
+
PageGeometry,
|
|
31
|
+
Profile,
|
|
32
|
+
Severity,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
pdf_app = typer.Typer(
|
|
38
|
+
pretty_exceptions_enable=False,
|
|
39
|
+
help="Verify and proof print PDFs (PDF/X-4 + PDF/A).",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def version_string() -> str:
|
|
45
|
+
"""``1.2.3``, plus ``(commit abc123)`` when the image or build recorded one in PROOFPRESS_REVISION."""
|
|
46
|
+
version = importlib.metadata.version("proof-pdf")
|
|
47
|
+
revision = os.environ.get("PROOFPRESS_REVISION")
|
|
48
|
+
return f"{version} (commit {revision[:12]})" if revision and revision != "unknown" else version
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _show_version(value: bool) -> None:
|
|
52
|
+
# An eager option callback: click would otherwise reject a bare ``--version`` as a missing command.
|
|
53
|
+
if value:
|
|
54
|
+
typer.echo(f"proof-pdf {version_string()}")
|
|
55
|
+
raise typer.Exit()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pdf_app.callback()
|
|
59
|
+
def _main(
|
|
60
|
+
version: typing.Annotated[
|
|
61
|
+
bool, typer.Option("--version", help="Show the version and exit.", callback=_show_version, is_eager=True)
|
|
62
|
+
] = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# A hung Ghostscript on a 400 MB file would otherwise block a CI run for ever.
|
|
68
|
+
DEFAULT_TIMEOUT = 900.0
|
|
69
|
+
|
|
70
|
+
_MARK = {
|
|
71
|
+
Severity.ERROR: typer.style("x", fg=typer.colors.RED),
|
|
72
|
+
Severity.WARN: typer.style("!", fg=typer.colors.YELLOW),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
PathsArg = typing.Annotated[
|
|
76
|
+
list[pathlib.Path] | None,
|
|
77
|
+
typer.Argument(help="PDFs to process. A directory contributes every PDF beneath it."),
|
|
78
|
+
]
|
|
79
|
+
GsOpt = typing.Annotated[str, typer.Option("--gs", help="Ghostscript executable.")]
|
|
80
|
+
TimeoutOpt = typing.Annotated[float, typer.Option("--timeout", help="Per-tool timeout, in seconds.")]
|
|
81
|
+
ExcludeOpt = typing.Annotated[
|
|
82
|
+
list[str] | None,
|
|
83
|
+
typer.Option("--exclude", help="Glob, relative to a directory argument, of PDFs to skip. Repeatable."),
|
|
84
|
+
]
|
|
85
|
+
QuietOpt = typing.Annotated[bool, typer.Option("--quiet", help="Exit code only, for CI.")]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pdf_app.command("verify")
|
|
89
|
+
def verify_cmd(
|
|
90
|
+
files: PathsArg = None,
|
|
91
|
+
exclude: ExcludeOpt = None,
|
|
92
|
+
flavour: typing.Annotated[
|
|
93
|
+
str, typer.Option("--flavour", help="veraPDF PDF/A flavour: 2b, 2a, 2u, 1b, 3b, 4...")
|
|
94
|
+
] = "2b",
|
|
95
|
+
pdfa: typing.Annotated[bool, typer.Option("--pdfa/--no-pdfa", help="Run the veraPDF PDF/A layer.")] = True,
|
|
96
|
+
pdfx: typing.Annotated[bool, typer.Option("--pdfx/--no-pdfx", help="Check the PDF/X-4 identity.")] = True,
|
|
97
|
+
colour: typing.Annotated[
|
|
98
|
+
bool, typer.Option("--colour/--no-colour", help="Check ink, overprint, RGB, rich black and image DPI.")
|
|
99
|
+
] = True,
|
|
100
|
+
trim_mm: typing.Annotated[
|
|
101
|
+
str | None,
|
|
102
|
+
typer.Option(
|
|
103
|
+
"--trim-mm", help="Expected finished size as WxH in mm, e.g. 105x148. Enables the geometry layer."
|
|
104
|
+
),
|
|
105
|
+
] = None,
|
|
106
|
+
bleed_mm: typing.Annotated[float, typer.Option("--bleed-mm", help="Expected bleed per side, in mm.")] = 3.0,
|
|
107
|
+
tolerance_mm: typing.Annotated[
|
|
108
|
+
float, typer.Option("--tolerance-mm", help="Allowed geometry deviation, in mm.")
|
|
109
|
+
] = 0.25,
|
|
110
|
+
max_tac: typing.Annotated[
|
|
111
|
+
float, typer.Option("--max-tac", help="Total-ink-coverage ceiling, in percent.")
|
|
112
|
+
] = DEFAULT_MAX_TAC,
|
|
113
|
+
small_text_pt: typing.Annotated[
|
|
114
|
+
float, typer.Option("--small-text-pt", help="Rich-black cutoff (pt).")
|
|
115
|
+
] = DEFAULT_SMALL_TEXT_PT,
|
|
116
|
+
min_dpi: typing.Annotated[
|
|
117
|
+
float, typer.Option("--min-dpi", help="Minimum effective resolution for placed images.")
|
|
118
|
+
] = DEFAULT_MIN_DPI,
|
|
119
|
+
strict_cmyk: typing.Annotated[
|
|
120
|
+
bool, typer.Option("--strict-cmyk", help="Fail, rather than warn, on RGB vector fills.")
|
|
121
|
+
] = False,
|
|
122
|
+
verapdf_bin: typing.Annotated[str, typer.Option("--verapdf", help="veraPDF executable.")] = "verapdf",
|
|
123
|
+
gs_bin: GsOpt = "gs",
|
|
124
|
+
timeout: TimeoutOpt = DEFAULT_TIMEOUT,
|
|
125
|
+
as_json: typing.Annotated[
|
|
126
|
+
bool, typer.Option("--json", help="Emit machine-readable findings instead of a report.")
|
|
127
|
+
] = False,
|
|
128
|
+
quiet: QuietOpt = False,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Verify each PDF is a valid dual PDF/X-4 + PDF/A file."""
|
|
131
|
+
targets = core.expand_targets(files or [], exclude or [])
|
|
132
|
+
if not targets:
|
|
133
|
+
typer.echo("no PDFs to verify", err=True)
|
|
134
|
+
raise typer.Exit(1)
|
|
135
|
+
|
|
136
|
+
profile = Profile(
|
|
137
|
+
flavour=flavour,
|
|
138
|
+
pdfa=pdfa,
|
|
139
|
+
pdfx=pdfx,
|
|
140
|
+
colour=colour,
|
|
141
|
+
geometry=_page_geometry(trim_mm, bleed_mm, tolerance_mm) if trim_mm else None,
|
|
142
|
+
max_tac=max_tac,
|
|
143
|
+
small_text_pt=small_text_pt,
|
|
144
|
+
min_dpi=min_dpi,
|
|
145
|
+
strict_cmyk=strict_cmyk,
|
|
146
|
+
)
|
|
147
|
+
reports = [core.verify_file(t, profile, verapdf_bin=verapdf_bin, gs_bin=gs_bin, timeout=timeout) for t in targets]
|
|
148
|
+
|
|
149
|
+
if as_json:
|
|
150
|
+
typer.echo(json.dumps([_as_record(r) for r in reports], indent=2))
|
|
151
|
+
elif not quiet:
|
|
152
|
+
for report in reports:
|
|
153
|
+
_print_report(report)
|
|
154
|
+
_print_summary(reports, profile)
|
|
155
|
+
raise typer.Exit(1 if any(not r.ok for r in reports) else 0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pdf_app.command("preview")
|
|
159
|
+
def preview_cmd(
|
|
160
|
+
files: PathsArg = None,
|
|
161
|
+
exclude: ExcludeOpt = None,
|
|
162
|
+
dpi: typing.Annotated[int, typer.Option("--dpi", help="Raster resolution.")] = 300,
|
|
163
|
+
guides: typing.Annotated[
|
|
164
|
+
bool, typer.Option("--guides/--no-guides", help="Draw trim/bleed/safe guides from the page boxes.")
|
|
165
|
+
] = True,
|
|
166
|
+
safe_mm: typing.Annotated[float, typer.Option("--safe", help="Safe-area inset from the trim edge, in mm.")] = 3.0,
|
|
167
|
+
out: typing.Annotated[
|
|
168
|
+
pathlib.Path | None,
|
|
169
|
+
typer.Option("--out", help="Output directory (default: a preview/ folder beside each PDF)."),
|
|
170
|
+
] = None,
|
|
171
|
+
gs_bin: GsOpt = "gs",
|
|
172
|
+
timeout: TimeoutOpt = DEFAULT_TIMEOUT,
|
|
173
|
+
quiet: QuietOpt = False,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Rasterise each PDF to one PNG per page, overlaying the page-box guides."""
|
|
176
|
+
targets = core.expand_targets(files or [], exclude or [])
|
|
177
|
+
if not targets:
|
|
178
|
+
typer.echo("no PDFs to preview", err=True)
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
if not preview.gs_available(gs_bin):
|
|
181
|
+
typer.echo(f"Ghostscript not found ({gs_bin}) -- cannot render previews", err=True)
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
|
|
184
|
+
written: list[pathlib.Path] = []
|
|
185
|
+
for target in targets:
|
|
186
|
+
dst = out if out is not None else target.parent / "preview"
|
|
187
|
+
try:
|
|
188
|
+
pngs = preview.preview_file(
|
|
189
|
+
target, dst, dpi=dpi, guides=guides, safe_mm=safe_mm, gs_bin=gs_bin, timeout=timeout
|
|
190
|
+
)
|
|
191
|
+
except preview.GhostscriptError as exc:
|
|
192
|
+
typer.secho(f"FAIL -- Ghostscript could not render {_rel(target)}: {exc}", fg=typer.colors.RED, err=True)
|
|
193
|
+
raise typer.Exit(1) from exc
|
|
194
|
+
written += pngs
|
|
195
|
+
if not quiet:
|
|
196
|
+
for png in pngs:
|
|
197
|
+
typer.echo(f"wrote {_rel(png)}")
|
|
198
|
+
if not quiet:
|
|
199
|
+
note = " with trim/bleed/safe guides" if guides else ""
|
|
200
|
+
typer.secho(f"\nOK -- {len(written)} page(s){note} from {len(targets)} file(s)", fg=typer.colors.GREEN)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _page_geometry(trim_mm: str, bleed_mm: float, tolerance_mm: float) -> PageGeometry:
|
|
204
|
+
"""Parse ``--trim-mm WxH`` into a :class:`PageGeometry`."""
|
|
205
|
+
parts = trim_mm.lower().replace("*", "x").split("x")
|
|
206
|
+
if len(parts) != 2:
|
|
207
|
+
raise typer.BadParameter(f"--trim-mm must look like 105x148, got {trim_mm!r}")
|
|
208
|
+
try:
|
|
209
|
+
width, height = (float(p) for p in parts)
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
raise typer.BadParameter(f"--trim-mm must be two numbers, got {trim_mm!r}") from exc
|
|
212
|
+
if width <= 0 or height <= 0:
|
|
213
|
+
raise typer.BadParameter(f"--trim-mm must be positive, got {trim_mm!r}")
|
|
214
|
+
return PageGeometry(trim_w_mm=width, trim_h_mm=height, bleed_mm=bleed_mm, tolerance_mm=tolerance_mm)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _rel(path: pathlib.Path) -> str:
|
|
218
|
+
"""*path* relative to the working directory when that is shorter to read."""
|
|
219
|
+
try:
|
|
220
|
+
return str(path.resolve().relative_to(pathlib.Path.cwd()))
|
|
221
|
+
except ValueError: # outside the working tree -- an absolute path is the clearest thing to show
|
|
222
|
+
return str(path)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _as_record(report: FileReport) -> dict:
|
|
226
|
+
return {
|
|
227
|
+
"path": str(report.path),
|
|
228
|
+
"ok": report.ok,
|
|
229
|
+
"findings": [dataclasses.asdict(f) for f in report.findings],
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _print_report(report: FileReport) -> None:
|
|
234
|
+
status = typer.style("OK", fg=typer.colors.GREEN) if report.ok else typer.style("FAIL", fg=typer.colors.RED)
|
|
235
|
+
typer.echo(f" {_rel(report.path):<52} {status}")
|
|
236
|
+
for finding, pages in _collapse(report.findings):
|
|
237
|
+
typer.echo(f" {_MARK[finding.severity]} {_labelled(finding, pages)}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _collapse(findings: list[Finding]) -> list[tuple[Finding, list[int]]]:
|
|
241
|
+
"""Group findings that differ only by page number, preserving first-seen order.
|
|
242
|
+
|
|
243
|
+
A job says the same thing about all fifty of its cards. Printed one line per
|
|
244
|
+
page that is a hundred lines of noise burying whatever else was found, so the
|
|
245
|
+
identical ones collapse into a single line naming the pages.
|
|
246
|
+
"""
|
|
247
|
+
grouped: dict[tuple[str, Severity, str], list[int]] = {}
|
|
248
|
+
first: dict[tuple[str, Severity, str], Finding] = {}
|
|
249
|
+
for finding in findings:
|
|
250
|
+
key = (finding.check, finding.severity, finding.message)
|
|
251
|
+
first.setdefault(key, finding)
|
|
252
|
+
if finding.page is not None:
|
|
253
|
+
grouped.setdefault(key, []).append(finding.page)
|
|
254
|
+
else:
|
|
255
|
+
grouped.setdefault(key, [])
|
|
256
|
+
return [(first[key], pages) for key, pages in grouped.items()]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _labelled(finding: Finding, pages: list[int]) -> str:
|
|
260
|
+
"""One finding's line, with its page numbers folded into ranges."""
|
|
261
|
+
if not pages:
|
|
262
|
+
return finding.label()
|
|
263
|
+
where = "page" if len(pages) == 1 else "pages"
|
|
264
|
+
return f"[{finding.check}] ({where} {_ranges(pages)}) {finding.message}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _ranges(pages: list[int]) -> str:
|
|
268
|
+
"""``[1, 2, 3, 7]`` as ``"1-3, 7"``."""
|
|
269
|
+
spans: list[tuple[int, int]] = []
|
|
270
|
+
for page in sorted(set(pages)):
|
|
271
|
+
if spans and page == spans[-1][1] + 1:
|
|
272
|
+
spans[-1] = (spans[-1][0], page)
|
|
273
|
+
else:
|
|
274
|
+
spans.append((page, page))
|
|
275
|
+
return ", ".join(str(lo) if lo == hi else f"{lo}-{hi}" for lo, hi in spans)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _print_summary(reports: list[FileReport], profile: Profile) -> None:
|
|
279
|
+
layers = ", ".join(
|
|
280
|
+
name
|
|
281
|
+
for name, on in (
|
|
282
|
+
(f"PDF/A-{profile.flavour}", profile.pdfa),
|
|
283
|
+
("PDF/X-4", profile.pdfx),
|
|
284
|
+
("geometry", profile.geometry is not None),
|
|
285
|
+
("colour", profile.colour),
|
|
286
|
+
)
|
|
287
|
+
if on
|
|
288
|
+
)
|
|
289
|
+
failed = [r for r in reports if not r.ok]
|
|
290
|
+
typer.echo("")
|
|
291
|
+
if failed:
|
|
292
|
+
typer.secho(f"FAIL -- {len(failed)} of {len(reports)} file(s) failed: {layers}", fg=typer.colors.RED)
|
|
293
|
+
return
|
|
294
|
+
warnings = sum(len(r.warnings) for r in reports)
|
|
295
|
+
note = f" ({warnings} advisory warning(s))" if warnings else ""
|
|
296
|
+
typer.secho(f"OK -- all {len(reports)} file(s) passed: {layers}{note}", fg=typer.colors.GREEN)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# Re-exported so callers can special-case a genuine tool failure if they wish.
|
|
300
|
+
VeraPdfError = verapdf.VeraPdfError
|
proof_pdf/colour.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Best-effort colour sanity -- the things neither PDF/A nor PDF/X judge.
|
|
2
|
+
|
|
3
|
+
Four checks a printer cares about but no standard enforces:
|
|
4
|
+
|
|
5
|
+
* **Total area coverage** -- the summed ink of the four plates. Rasterise each
|
|
6
|
+
page to DeviceCMYK with Ghostscript and take the peak; over the FOGRA51
|
|
7
|
+
ceiling the ink will not dry and will set off. This is the objective one, so
|
|
8
|
+
it is an ``ERROR``.
|
|
9
|
+
* **Stray RGB** -- a raster still in an RGB space in a job destined for a CMYK
|
|
10
|
+
press. Every raster should already be converted to CMYK, so an
|
|
11
|
+
RGB image here means one escaped the workflow: an ``ERROR``. Vector RGB fills
|
|
12
|
+
are advisory by default, since a RIP will convert them against the output
|
|
13
|
+
intent; ``--strict-cmyk`` promotes them when that is not wanted.
|
|
14
|
+
* **Overprint** -- an applied ``ExtGState`` turning overprint on. Usually
|
|
15
|
+
deliberate, so advisory: it is flagged, not failed.
|
|
16
|
+
* **Rich-black small text** -- small text painted in a built black (K plus other
|
|
17
|
+
plates) mis-registers. Advisory.
|
|
18
|
+
|
|
19
|
+
The raster step shells out to Ghostscript (its absence or failure is surfaced as
|
|
20
|
+
a failure by the caller, not silently skipped); the rest read the
|
|
21
|
+
:class:`pikepdf.Pdf` through :mod:`.content`, so they see nested Form XObjects.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import pathlib
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
|
|
30
|
+
import pikepdf
|
|
31
|
+
from PIL import Image, ImageMath
|
|
32
|
+
|
|
33
|
+
from . import content
|
|
34
|
+
from .models import Finding, Severity
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GhostscriptError(RuntimeError):
|
|
40
|
+
"""Ghostscript exited non-zero, or timed out, while rasterising for ink coverage."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Rasterising DeviceCMYK: 150 dpi resolves solids and small type without the
|
|
44
|
+
# per-pixel cost of press resolution. TAC is an ink property, so more dpi buys
|
|
45
|
+
# nothing here.
|
|
46
|
+
_TAC_DPI = 150
|
|
47
|
+
# A built black: the K plate is heavy and at least this much other ink rides on
|
|
48
|
+
# top. Pure process black (0/0/0/K) never trips it.
|
|
49
|
+
_RICH_K_MIN = 0.5
|
|
50
|
+
_RICH_OTHER_MIN = 0.3
|
|
51
|
+
|
|
52
|
+
# Colour-space families, as classified from a PDF colour space object.
|
|
53
|
+
_RGB = "rgb"
|
|
54
|
+
_CMYK = "cmyk"
|
|
55
|
+
_GRAY = "gray"
|
|
56
|
+
_OTHER = "other"
|
|
57
|
+
|
|
58
|
+
_DEVICE_FAMILIES = {
|
|
59
|
+
"/DeviceRGB": _RGB,
|
|
60
|
+
"/CalRGB": _RGB,
|
|
61
|
+
"/Lab": _OTHER,
|
|
62
|
+
"/DeviceCMYK": _CMYK,
|
|
63
|
+
"/DeviceGray": _GRAY,
|
|
64
|
+
"/CalGray": _GRAY,
|
|
65
|
+
"/Pattern": _OTHER,
|
|
66
|
+
}
|
|
67
|
+
# Spot and multi-ink spaces carry an *alternate* space that is only a preview
|
|
68
|
+
# hint -- a Separation whose alternate is DeviceRGB is a correct spot colour, not
|
|
69
|
+
# stray RGB. Never look past these.
|
|
70
|
+
_SPOT_FAMILIES = ("/Separation", "/DeviceN")
|
|
71
|
+
_ICC_COMPONENTS = {1: _GRAY, 3: _RGB, 4: _CMYK}
|
|
72
|
+
|
|
73
|
+
# Text-showing operators: whatever fill is in force when one runs is the colour
|
|
74
|
+
# the glyphs are painted in.
|
|
75
|
+
_SHOW_TEXT = ("Tj", "TJ", "'", '"')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def gs_available(binary: str) -> bool:
|
|
79
|
+
"""Whether the Ghostscript *binary* resolves (TAC needs it, nothing else)."""
|
|
80
|
+
return shutil.which(binary) is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------ total ink ----
|
|
84
|
+
def check_tac(binary: str, path: str, max_tac: float, *, timeout: float | None = None) -> list[Finding]:
|
|
85
|
+
"""Flag any page whose peak total ink coverage exceeds *max_tac* percent."""
|
|
86
|
+
findings: list[Finding] = []
|
|
87
|
+
for i, peak in enumerate(_page_tac_peaks(binary, path, timeout=timeout), start=1):
|
|
88
|
+
if peak > max_tac:
|
|
89
|
+
findings.append(
|
|
90
|
+
Finding("tac", Severity.ERROR, f"peak ink coverage {peak:.0f}% exceeds the {max_tac:.0f}% limit", i)
|
|
91
|
+
)
|
|
92
|
+
return findings
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _page_tac_peaks(binary: str, path: str, *, timeout: float | None = None) -> list[float]:
|
|
96
|
+
"""The peak TAC (0-400%) of each page, via a DeviceCMYK Ghostscript raster."""
|
|
97
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
98
|
+
pattern = str(pathlib.Path(tmp) / "page-%03d.tif")
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
[
|
|
102
|
+
binary,
|
|
103
|
+
"-q",
|
|
104
|
+
"-dNOPAUSE",
|
|
105
|
+
"-dBATCH",
|
|
106
|
+
"-dSAFER",
|
|
107
|
+
"-sDEVICE=tiff32nc",
|
|
108
|
+
f"-r{_TAC_DPI}",
|
|
109
|
+
f"-sOutputFile={pattern}",
|
|
110
|
+
path,
|
|
111
|
+
],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
timeout=timeout,
|
|
115
|
+
)
|
|
116
|
+
except subprocess.TimeoutExpired as exc:
|
|
117
|
+
raise GhostscriptError(f"ghostscript timed out after {timeout:.0f}s") from exc
|
|
118
|
+
if result.returncode != 0:
|
|
119
|
+
message = result.stderr.strip() or "ghostscript failed"
|
|
120
|
+
logger.error(f"ghostscript failed (rc={result.returncode}): {message}")
|
|
121
|
+
raise GhostscriptError(message)
|
|
122
|
+
# Sort by the numeric page index, not lexicographically: `page-1000.tif`
|
|
123
|
+
# must not sort before `page-999.tif` (wrong page labels past 999).
|
|
124
|
+
pages = sorted(pathlib.Path(tmp).glob("page-*.tif"), key=lambda t: int(t.stem.rsplit("-", 1)[-1]))
|
|
125
|
+
peaks: list[float] = []
|
|
126
|
+
for tif in pages:
|
|
127
|
+
peaks.append(_peak_tac(tif))
|
|
128
|
+
# A 50-page file at 150 dpi is ~150 MB of TIFF if it all stays on
|
|
129
|
+
# disk; each raster is dead the moment its peak is read.
|
|
130
|
+
tif.unlink(missing_ok=True)
|
|
131
|
+
return peaks
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _peak_tac(tif: pathlib.Path) -> float:
|
|
135
|
+
"""Peak C+M+Y+K over every pixel of a CMYK TIFF, as a percentage."""
|
|
136
|
+
with Image.open(tif) as img:
|
|
137
|
+
img.load()
|
|
138
|
+
c, m, y, k = (band.convert("I") for band in img.split())
|
|
139
|
+
total = ImageMath.lambda_eval(lambda a: a["cc"] + a["mm"] + a["yy"] + a["kk"], cc=c, mm=m, yy=y, kk=k)
|
|
140
|
+
peak = total.getextrema()[1]
|
|
141
|
+
return peak / 255.0 * 100.0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------- overprint ----
|
|
145
|
+
def check_overprint(pdf: pikepdf.Pdf) -> list[Finding]:
|
|
146
|
+
"""Note pages that *apply* an ExtGState enabling overprint (advisory).
|
|
147
|
+
|
|
148
|
+
Fires on the ``gs`` operator rather than on the resource dictionary, so it
|
|
149
|
+
sees overprint set inside a Form XObject, and does not fire on a graphics
|
|
150
|
+
state that is declared but never invoked.
|
|
151
|
+
"""
|
|
152
|
+
findings: list[Finding] = []
|
|
153
|
+
for i, page in enumerate(pdf.pages, start=1):
|
|
154
|
+
if any(_sets_overprint(op) for op in content.iter_operations(page)):
|
|
155
|
+
findings.append(
|
|
156
|
+
Finding(
|
|
157
|
+
"overprint", Severity.WARN, "overprint is enabled -- confirm it is intentional, not knockout", i
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return findings
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sets_overprint(op: content.Operation) -> bool:
|
|
164
|
+
if op.operator != "gs":
|
|
165
|
+
return False
|
|
166
|
+
state = _lookup_resource(op.resources, "/ExtGState", op.operands)
|
|
167
|
+
if state is None:
|
|
168
|
+
return False
|
|
169
|
+
return _truthy(state.get("/OP")) or _truthy(state.get("/op"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _lookup_resource(resources: pikepdf.Object | None, category: str, operands: list) -> pikepdf.Object | None:
|
|
173
|
+
"""Resolve a name operand against one category of a resource dictionary."""
|
|
174
|
+
if resources is None or not operands:
|
|
175
|
+
return None
|
|
176
|
+
table = resources.get(category)
|
|
177
|
+
if table is None:
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
return table.get(str(operands[0]))
|
|
181
|
+
except KeyError, TypeError:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _truthy(value: object) -> bool:
|
|
186
|
+
"""A PDF boolean object read as a plain bool."""
|
|
187
|
+
return value is not None and str(value) == "True"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------- colour space ----
|
|
191
|
+
def check_colour_spaces(pdf: pikepdf.Pdf, *, strict: bool) -> list[Finding]:
|
|
192
|
+
"""Flag artwork still in an RGB colour space on a CMYK-destined page.
|
|
193
|
+
|
|
194
|
+
Images are an ``ERROR``: every raster is expected to have gone through CMYK
|
|
195
|
+
conversion, so one in RGB is an escape that will be converted by the
|
|
196
|
+
RIP with whatever profile it feels like. Vector ``rg``/``RG`` fills are a
|
|
197
|
+
``WARN`` unless *strict*, since the document legitimately declares an RGB
|
|
198
|
+
profile too.
|
|
199
|
+
"""
|
|
200
|
+
findings: list[Finding] = []
|
|
201
|
+
vector_severity = Severity.ERROR if strict else Severity.WARN
|
|
202
|
+
for i, page in enumerate(pdf.pages, start=1):
|
|
203
|
+
images: set[str] = set()
|
|
204
|
+
vector = False
|
|
205
|
+
for op in content.iter_operations(page):
|
|
206
|
+
if op.operator in ("rg", "RG"):
|
|
207
|
+
vector = True
|
|
208
|
+
elif op.operator == "Do":
|
|
209
|
+
name = _rgb_image_name(op)
|
|
210
|
+
if name is not None:
|
|
211
|
+
images.add(name)
|
|
212
|
+
for name in sorted(images):
|
|
213
|
+
findings.append(
|
|
214
|
+
Finding(
|
|
215
|
+
"colour-space",
|
|
216
|
+
Severity.ERROR,
|
|
217
|
+
f"image {name} is in an RGB colour space -- it must be converted to CMYK for a FOGRA51 press",
|
|
218
|
+
i,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
if vector:
|
|
222
|
+
findings.append(
|
|
223
|
+
Finding(
|
|
224
|
+
"colour-space",
|
|
225
|
+
vector_severity,
|
|
226
|
+
"vector artwork is filled in DeviceRGB -- confirm the RIP is meant to convert it",
|
|
227
|
+
i,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
return findings
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _rgb_image_name(op: content.Operation) -> str | None:
|
|
234
|
+
"""The name of the image this ``Do`` paints, if that image is in an RGB space."""
|
|
235
|
+
xobject = content.lookup_xobject(op.resources, op.operands)
|
|
236
|
+
if xobject is None or str(xobject.get("/Subtype")) != "/Image":
|
|
237
|
+
return None
|
|
238
|
+
if classify_colour_space(xobject.get("/ColorSpace"), op.resources) != _RGB:
|
|
239
|
+
return None
|
|
240
|
+
return str(op.operands[0])
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def classify_colour_space(space: object, resources: pikepdf.Object | None, *, depth: int = 0) -> str:
|
|
244
|
+
"""Which colour family a PDF colour space object belongs to.
|
|
245
|
+
|
|
246
|
+
Resolves named spaces through the resource dictionary and looks through
|
|
247
|
+
``/Indexed`` to its base, but deliberately stops at ``/Separation`` and
|
|
248
|
+
``/DeviceN``: their alternate space is a rendering hint for a spot ink, and
|
|
249
|
+
treating it as the artwork's own space produces false positives on entirely
|
|
250
|
+
correct print files.
|
|
251
|
+
"""
|
|
252
|
+
if space is None or depth > 4:
|
|
253
|
+
return _OTHER
|
|
254
|
+
if isinstance(space, pikepdf.Array) or (hasattr(space, "__len__") and not isinstance(space, pikepdf.Name)):
|
|
255
|
+
return _classify_array(space, resources, depth)
|
|
256
|
+
name = str(space)
|
|
257
|
+
if name in _DEVICE_FAMILIES:
|
|
258
|
+
return _DEVICE_FAMILIES[name]
|
|
259
|
+
named = _lookup_resource(resources, "/ColorSpace", [name])
|
|
260
|
+
if named is not None:
|
|
261
|
+
return classify_colour_space(named, resources, depth=depth + 1)
|
|
262
|
+
return _OTHER
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _classify_array(space, resources: pikepdf.Object | None, depth: int) -> str:
|
|
266
|
+
try:
|
|
267
|
+
head = str(space[0])
|
|
268
|
+
except IndexError, TypeError:
|
|
269
|
+
return _OTHER
|
|
270
|
+
if head in _SPOT_FAMILIES:
|
|
271
|
+
return _OTHER
|
|
272
|
+
if head == "/ICCBased":
|
|
273
|
+
return _classify_icc(space)
|
|
274
|
+
if head == "/Indexed":
|
|
275
|
+
base = space[1] if len(space) > 1 else None
|
|
276
|
+
return classify_colour_space(base, resources, depth=depth + 1)
|
|
277
|
+
if head in _DEVICE_FAMILIES:
|
|
278
|
+
return _DEVICE_FAMILIES[head]
|
|
279
|
+
return _OTHER
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _classify_icc(space) -> str:
|
|
283
|
+
"""An ``[/ICCBased stream]`` space, classified by the stream's ``/N``."""
|
|
284
|
+
try:
|
|
285
|
+
components = int(space[1].get("/N"))
|
|
286
|
+
except IndexError, TypeError, ValueError, AttributeError:
|
|
287
|
+
return _OTHER
|
|
288
|
+
return _ICC_COMPONENTS.get(components, _OTHER)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------ rich-black small text ----
|
|
292
|
+
def check_rich_black(pdf: pikepdf.Pdf, small_text_pt: float) -> list[Finding]:
|
|
293
|
+
"""Flag small text painted in a built (rich) black on any page (advisory)."""
|
|
294
|
+
findings: list[Finding] = []
|
|
295
|
+
for i, page in enumerate(pdf.pages, start=1):
|
|
296
|
+
smallest = _smallest_rich_black_text(page, small_text_pt)
|
|
297
|
+
if smallest is not None:
|
|
298
|
+
findings.append(
|
|
299
|
+
Finding(
|
|
300
|
+
"rich-black",
|
|
301
|
+
Severity.WARN,
|
|
302
|
+
f"text as small as {smallest:.1f}pt is set in rich black -- "
|
|
303
|
+
"use process black to avoid mis-registration",
|
|
304
|
+
i,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return findings
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _smallest_rich_black_text(page: pikepdf.Page, small_text_pt: float) -> float | None:
|
|
311
|
+
"""Effective point size of the smallest rich-black run on the page, if any.
|
|
312
|
+
|
|
313
|
+
Text state (font size, fill, text matrix) is tracked across the whole walk
|
|
314
|
+
rather than reset per Form XObject: a form that shows text essentially always
|
|
315
|
+
sets its own ``Tf`` and ``BT`` first, and this check is advisory, so the
|
|
316
|
+
extra reach costs nothing and buys visibility into nested content.
|
|
317
|
+
"""
|
|
318
|
+
text_matrix: content.Matrix = content.IDENTITY
|
|
319
|
+
font_size = 0.0
|
|
320
|
+
fill: tuple[float, float, float, float] | None = None
|
|
321
|
+
smallest: float | None = None
|
|
322
|
+
|
|
323
|
+
for op in content.iter_operations(page):
|
|
324
|
+
name, args = op.operator, op.operands
|
|
325
|
+
if name == "BT":
|
|
326
|
+
text_matrix = content.IDENTITY
|
|
327
|
+
elif name == "Tm":
|
|
328
|
+
text_matrix = content.matrix(args)
|
|
329
|
+
elif name in ("Td", "TD"):
|
|
330
|
+
text_matrix = content.mul(_translation(args), text_matrix)
|
|
331
|
+
elif name == "Tf":
|
|
332
|
+
font_size = _number(args, 1)
|
|
333
|
+
elif name == "k":
|
|
334
|
+
fill = _cmyk(args)
|
|
335
|
+
elif name in ("g", "rg"):
|
|
336
|
+
fill = None # grayscale / RGB black is never rich black
|
|
337
|
+
elif name in _SHOW_TEXT and _is_rich_black(fill):
|
|
338
|
+
size = font_size * content.vertical_scale(content.mul(text_matrix, op.ctm))
|
|
339
|
+
if size <= small_text_pt and (smallest is None or size < smallest):
|
|
340
|
+
smallest = size
|
|
341
|
+
return smallest
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _translation(args: list) -> content.Matrix:
|
|
345
|
+
return (1.0, 0.0, 0.0, 1.0, _number(args, 0), _number(args, 1))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _number(args: list, index: int) -> float:
|
|
349
|
+
try:
|
|
350
|
+
return float(args[index])
|
|
351
|
+
except IndexError, TypeError, ValueError:
|
|
352
|
+
return 0.0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _cmyk(args: list) -> tuple[float, float, float, float] | None:
|
|
356
|
+
if len(args) < 4:
|
|
357
|
+
return None
|
|
358
|
+
return (_number(args, 0), _number(args, 1), _number(args, 2), _number(args, 3))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _is_rich_black(fill: tuple[float, float, float, float] | None) -> bool:
|
|
362
|
+
if fill is None:
|
|
363
|
+
return False
|
|
364
|
+
c, m, y, k = fill
|
|
365
|
+
return k >= _RICH_K_MIN and (c + m + y) >= _RICH_OTHER_MIN
|