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