Skills › Scientific Agent Skills › scientific-visualization › image_metadata.py
image_metadata.py
756 lines · skills/scientific-visualization/scripts/image_metadata.py at 5f2bc03
1#!/usr/bin/env python3
2"""Inspect figure/image metadata and screen it against explicit constraints.
4The tool is network-free. Pillow is loaded only for raster inputs and pypdf is
5loaded only for PDF inputs. A successful screen is not a journal-compliance
6claim.
7"""
9from __future__ import annotations
11import argparse
12import math
13import re
14import sys
15import warnings
16import xml.etree.ElementTree as ET
17from pathlib import Path
18from typing import Any, Iterable
20from _common import (
21 MAX_INPUT_BYTES,
22 CliError,
23 checked_input_file,
24 emit_json,
25 positive_float,
26 positive_int,
27)
29SCHEMA_VERSION = "1.0"
30DEFAULT_MAX_PIXELS = 100_000_000
31MAX_XML_ELEMENTS = 100_000
32MAX_SVG_BYTES = 20 * 1024 * 1024
33FORMAT_ALIASES = {
34 "jpg": "JPEG",
35 "jpeg": "JPEG",
36 "tif": "TIFF",
37 "tiff": "TIFF",
38 "svg": "SVG",
39 "pdf": "PDF",
40 "eps": "EPS",
41 "ps": "PS",
42 "png": "PNG",
43 "webp": "WEBP",
44}
45VECTOR_SUFFIXES = {".svg", ".pdf", ".eps", ".ps"}
46LENGTH_RE = re.compile(
47 r"^\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"
48 r"\s*(px|pt|pc|in|cm|mm)?\s*$"
49)
52def _finite_number(value: Any) -> float | None:
53 """Convert a scalar or rational-like value to a finite float."""
54 try:
55 number = float(value)
56 except (TypeError, ValueError, OverflowError):
57 return None
58 return number if math.isfinite(number) else None
61def _dpi_pair(value: Any) -> tuple[float | None, float | None]:
62 """Normalize common Pillow DPI metadata representations."""
63 if isinstance(value, (tuple, list)) and len(value) >= 2:
64 return _finite_number(value[0]), _finite_number(value[1])
65 scalar = _finite_number(value)
66 return scalar, scalar
69def _length_to_inches(value: str | None) -> tuple[float | None, str | None]:
70 """Parse an SVG/CSS absolute length using the CSS 96 px/in convention."""
71 if value is None:
72 return None, None
73 match = LENGTH_RE.match(value)
74 if not match:
75 return None, None
76 number = float(match.group(1))
77 unit = (match.group(2) or "px").lower()
78 factors = {
79 "px": 1.0 / 96.0,
80 "pt": 1.0 / 72.0,
81 "pc": 1.0 / 6.0,
82 "in": 1.0,
83 "cm": 1.0 / 2.54,
84 "mm": 1.0 / 25.4,
85 }
86 if not math.isfinite(number) or number <= 0:
87 raise CliError("SVG dimensions must be finite and greater than zero")
88 return number * factors[unit], unit
91def _base_metadata(path: Path) -> dict[str, Any]:
92 info = path.stat()
93 return {
94 "path": str(path),
95 "name": path.name,
96 "suffix": path.suffix.lower(),
97 "size_bytes": info.st_size,
98 }
101def inspect_raster(path: Path, *, max_pixels: int) -> dict[str, Any]:
102 """Inspect a raster image with Pillow without decoding all pixel data."""
103 try:
104 from PIL import Image
105 except ImportError as exc:
106 raise CliError(
107 "Pillow is required for raster metadata; "
108 "run with --with 'pillow==12.3.0'"
109 ) from exc
111 previous_limit = Image.MAX_IMAGE_PIXELS
112 Image.MAX_IMAGE_PIXELS = max_pixels
113 try:
114 with warnings.catch_warnings():
115 warnings.simplefilter("error", Image.DecompressionBombWarning)
116 with Image.open(path) as image:
117 width, height = image.size
118 if width * height > max_pixels:
119 raise CliError(
120 f"image has {width * height} pixels; "
121 f"limit is {max_pixels}"
122 )
123 dpi_x, dpi_y = _dpi_pair(image.info.get("dpi"))
124 icc = image.info.get("icc_profile")
125 metadata = {
126 "format": (image.format or path.suffix.lstrip(".")).upper(),
127 "kind": "raster",
128 "width_px": int(width),
129 "height_px": int(height),
130 "pixel_count": int(width * height),
131 "mode": image.mode,
132 "bands": list(image.getbands()),
133 "has_alpha": "A" in image.getbands()
134 or "transparency" in image.info,
135 "frames": int(getattr(image, "n_frames", 1)),
136 "dpi_x": dpi_x,
137 "dpi_y": dpi_y,
138 "width_mm_from_metadata": (
139 width / dpi_x * 25.4 if dpi_x and dpi_x > 0 else None
140 ),
141 "height_mm_from_metadata": (
142 height / dpi_y * 25.4 if dpi_y and dpi_y > 0 else None
143 ),
144 "icc_profile_present": bool(icc),
145 "icc_profile_bytes": len(icc) if isinstance(icc, bytes) else 0,
146 "compression": image.info.get("compression"),
147 "exif_present": bool(image.info.get("exif")),
148 }
149 image.verify()
150 return metadata
151 except Image.DecompressionBombWarning as exc:
152 raise CliError(f"image exceeds safe pixel limits: {exc}") from exc
153 except Image.DecompressionBombError as exc:
154 raise CliError(f"image exceeds safe pixel limits: {exc}") from exc
155 except OSError as exc:
156 raise CliError(f"Pillow could not inspect {path}: {exc}") from exc
157 finally:
158 Image.MAX_IMAGE_PIXELS = previous_limit
161def inspect_svg(path: Path) -> dict[str, Any]:
162 """Inspect SVG dimensions and resource types with bounded XML parsing."""
163 if path.stat().st_size > MAX_SVG_BYTES:
164 raise CliError(
165 f"SVG is {path.stat().st_size} bytes; limit is {MAX_SVG_BYTES} bytes"
166 )
167 try:
168 payload = path.read_bytes()
169 if re.search(br"<!ENTITY\b", payload, flags=re.IGNORECASE) or re.search(
170 br"<!DOCTYPE[^>]*\[", payload, flags=re.IGNORECASE | re.DOTALL
171 ):
172 raise CliError("SVG internal DTD/entity declarations are not allowed")
173 root = ET.fromstring(payload)
174 except (ET.ParseError, OSError) as exc:
175 raise CliError(f"invalid SVG/XML input: {exc}") from exc
177 if root.tag not in {"svg", "{http://www.w3.org/2000/svg}svg"}:
178 raise CliError("XML root must be an SVG element")
179 width_raw = root.get("width")
180 height_raw = root.get("height")
181 width_in, width_unit = _length_to_inches(width_raw)
182 height_in, height_unit = _length_to_inches(height_raw)
183 viewbox_raw = root.get("viewBox")
184 viewbox: list[float] | None = None
185 if viewbox_raw:
186 try:
187 values = [float(item) for item in re.split(r"[\s,]+", viewbox_raw.strip())]
188 except ValueError:
189 values = []
190 if len(values) == 4 and all(math.isfinite(item) for item in values):
191 viewbox = values
193 element_count = 0
194 text_count = 0
195 image_count = 0
196 external_images = 0
197 for element in root.iter():
198 element_count += 1
199 if element_count > MAX_XML_ELEMENTS:
200 raise CliError(
201 f"SVG has more than {MAX_XML_ELEMENTS} elements; refusing input"
202 )
203 local_name = element.tag.rsplit("}", 1)[-1].lower()
204 if local_name == "text":
205 text_count += 1
206 elif local_name == "image":
207 image_count += 1
208 href = (
209 element.get("href")
210 or element.get("{http://www.w3.org/1999/xlink}href")
211 or ""
212 )
213 if href and not href.startswith("data:"):
214 external_images += 1
216 return {
217 "format": "SVG",
218 "kind": "vector",
219 "width_raw": width_raw,
220 "height_raw": height_raw,
221 "width_unit": width_unit,
222 "height_unit": height_unit,
223 "width_mm": width_in * 25.4 if width_in is not None else None,
224 "height_mm": height_in * 25.4 if height_in is not None else None,
225 "viewbox": viewbox,
226 "dpi_x": None,
227 "dpi_y": None,
228 "mode": None,
229 "element_count": element_count,
230 "text_element_count": text_count,
231 "image_element_count": image_count,
232 "external_image_count": external_images,
233 "note": (
234 "SVG is resolution-independent except for embedded raster images; "
235 "external image resources were counted but not fetched."
236 ),
237 }
240def _pdf_font_report(page: Any) -> dict[str, Any]:
241 """Return a conservative first-page PDF font resource report."""
242 try:
243 resources = page.get("/Resources")
244 resources = resources.get_object() if resources else None
245 fonts = resources.get("/Font") if resources else None
246 fonts = fonts.get_object() if fonts else None
247 except Exception:
248 fonts = None
249 if not fonts:
250 return {
251 "resource_count": 0,
252 "embedded_count": 0,
253 "unembedded_count": 0,
254 "unknown_count": 0,
255 "all_embedded": None,
256 "fonts": [],
257 }
259 def embedded_status(font: Any) -> bool | None:
260 try:
261 subtype = str(font.get("/Subtype", ""))
262 if subtype == "/Type3":
263 # Type 3 glyph programs live in the PDF CharProcs dictionary.
264 return True if font.get("/CharProcs") else None
265 candidates = [font]
266 descendants = font.get("/DescendantFonts")
267 if descendants:
268 candidates.extend(
269 reference.get_object() for reference in descendants
270 )
271 saw_descriptor = False
272 for candidate in candidates:
273 descriptor = candidate.get("/FontDescriptor")
274 descriptor = descriptor.get_object() if descriptor else None
275 if descriptor:
276 saw_descriptor = True
277 if any(
278 descriptor.get(key)
279 for key in ("/FontFile", "/FontFile2", "/FontFile3")
280 ):
281 return True
282 return False if saw_descriptor else None
283 except Exception:
284 return None
286 records: list[dict[str, Any]] = []
287 for resource_name, reference in fonts.items():
288 try:
289 font = reference.get_object()
290 embedded = embedded_status(font)
291 records.append(
292 {
293 "resource": str(resource_name),
294 "base_font": str(font.get("/BaseFont", "")),
295 "subtype": str(font.get("/Subtype", "")),
296 "embedded": embedded,
297 }
298 )
299 except Exception:
300 records.append(
301 {
302 "resource": str(resource_name),
303 "base_font": "",
304 "subtype": "",
305 "embedded": None,
306 }
307 )
308 definite = [item["embedded"] for item in records if item["embedded"] is not None]
309 unknown_count = sum(item["embedded"] is None for item in records)
310 return {
311 "resource_count": len(records),
312 "embedded_count": sum(item is True for item in definite),
313 "unembedded_count": sum(item is False for item in definite),
314 "unknown_count": unknown_count,
315 "all_embedded": (
316 None
317 if not definite or unknown_count
318 else all(item is True for item in definite)
319 ),
320 "fonts": records,
321 "scope": "first page resource dictionary only",
322 }
325def inspect_pdf(path: Path) -> dict[str, Any]:
326 """Inspect PDF page dimensions and first-page font resources with pypdf."""
327 try:
328 from pypdf import PdfReader
329 except ImportError as exc:
330 raise CliError(
331 "pypdf is required for PDF metadata; "
332 "run with --with 'pypdf==6.19.0'"
333 ) from exc
334 try:
335 reader = PdfReader(path, strict=False)
336 if reader.is_encrypted:
337 try:
338 unlocked = reader.decrypt("")
339 except Exception:
340 unlocked = 0
341 if not unlocked:
342 return {
343 "format": "PDF",
344 "kind": "vector-container",
345 "encrypted": True,
346 "page_count": None,
347 "width_mm": None,
348 "height_mm": None,
349 "dpi_x": None,
350 "dpi_y": None,
351 "mode": None,
352 "font_resources": None,
353 "note": "Encrypted PDF could not be inspected without a password.",
354 }
355 page_count = len(reader.pages)
356 if page_count < 1:
357 raise CliError("PDF has no pages")
358 page = reader.pages[0]
359 width_pt = float(page.mediabox.width)
360 height_pt = float(page.mediabox.height)
361 user_unit = float(page.user_unit)
362 rotation = int(page.rotation) % 360
363 crop_width = float(page.cropbox.width)
364 crop_height = float(page.cropbox.height)
365 if rotation not in {0, 90, 180, 270} or not all(
366 math.isfinite(value) and value > 0
367 for value in (width_pt, height_pt, crop_width, crop_height, user_unit)
368 ):
369 raise CliError("PDF page dimensions, UserUnit, or rotation are invalid")
370 display_width, display_height = width_pt * user_unit, height_pt * user_unit
371 crop_width, crop_height = crop_width * user_unit, crop_height * user_unit
372 if rotation in {90, 270}:
373 display_width, display_height = display_height, display_width
374 crop_width, crop_height = crop_height, crop_width
375 return {
376 "format": "PDF",
377 "kind": "vector-container",
378 "encrypted": bool(reader.is_encrypted),
379 "page_count": page_count,
380 "first_page_width_pt": width_pt,
381 "first_page_height_pt": height_pt,
382 "user_unit": user_unit,
383 "rotation_degrees": rotation,
384 "width_mm": display_width / 72.0 * 25.4,
385 "height_mm": display_height / 72.0 * 25.4,
386 "crop_width_mm": crop_width / 72.0 * 25.4,
387 "crop_height_mm": crop_height / 72.0 * 25.4,
388 "cropbox_differs_from_mediabox": list(page.cropbox) != list(page.mediabox),
389 "dpi_x": None,
390 "dpi_y": None,
391 "mode": None,
392 "font_resources": _pdf_font_report(page),
393 "note": (
394 "Dimensions use first-page MediaBox with UserUnit and rotation; "
395 "CropBox dimensions are reported separately. Raw first_page_*_pt "
396 "values are unscaled user coordinates. Page size and fonts do not establish the "
397 "resolution of embedded raster images."
398 ),
399 }
400 except CliError:
401 raise
402 except Exception as exc:
403 raise CliError(f"pypdf could not inspect {path}: {exc}") from exc
406def inspect_eps(path: Path) -> dict[str, Any]:
407 """Inspect an EPS/PS bounding box without invoking PostScript."""
408 size = path.stat().st_size
409 with path.open("rb") as handle:
410 prefix = handle.read(min(size, 128 * 1024))
411 suffix = b""
412 if size > len(prefix):
413 handle.seek(max(0, size - 128 * 1024))
414 suffix = handle.read(128 * 1024)
415 text = (prefix + b"\n" + suffix).decode("latin-1", errors="replace")
416 matches = re.findall(
417 r"^%%(?:HiRes)?BoundingBox:\s+"
418 r"([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s+"
419 r"([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s*$",
420 text,
421 flags=re.MULTILINE,
422 )
423 bbox = [float(value) for value in matches[-1]] if matches else None
424 width_pt = bbox[2] - bbox[0] if bbox else None
425 height_pt = bbox[3] - bbox[1] if bbox else None
426 return {
427 "format": "EPS" if path.suffix.lower() == ".eps" else "PS",
428 "kind": "vector-container",
429 "bounding_box_pt": bbox,
430 "width_mm": width_pt / 72.0 * 25.4 if width_pt is not None else None,
431 "height_mm": (
432 height_pt / 72.0 * 25.4 if height_pt is not None else None
433 ),
434 "dpi_x": None,
435 "dpi_y": None,
436 "mode": None,
437 "note": "PostScript was not executed; only DSC bounding-box text was read.",
438 }
441def inspect_file(
442 value: str | Path,
443 *,
444 max_bytes: int = MAX_INPUT_BYTES,
445 max_pixels: int = DEFAULT_MAX_PIXELS,
446) -> dict[str, Any]:
447 """Inspect a supported local figure/image and return structured metadata."""
448 path = checked_input_file(value, max_bytes=max_bytes)
449 suffix = path.suffix.lower()
450 if suffix == ".svg":
451 metadata = inspect_svg(path)
452 elif suffix == ".pdf":
453 metadata = inspect_pdf(path)
454 elif suffix in {".eps", ".ps"}:
455 metadata = inspect_eps(path)
456 else:
457 metadata = inspect_raster(path, max_pixels=max_pixels)
458 return {
459 "schema_version": SCHEMA_VERSION,
460 "input": _base_metadata(path),
461 "metadata": metadata,
462 }
465def _normalize_formats(values: Iterable[str]) -> list[str]:
466 normalized: list[str] = []
467 for value in values:
468 for item in value.split(","):
469 name = item.strip().lower().lstrip(".")
470 if not name:
471 continue
472 normalized.append(FORMAT_ALIASES.get(name, name.upper()))
473 return sorted(set(normalized))
476def _check(
477 name: str,
478 status: str,
479 *,
480 actual: Any,
481 expected: Any,
482 detail: str,
483) -> dict[str, Any]:
484 return {
485 "name": name,
486 "status": status,
487 "actual": actual,
488 "expected": expected,
489 "detail": detail,
490 }
493def screen_metadata(
494 report: dict[str, Any],
495 *,
496 expected_formats: Iterable[str] = (),
497 expected_modes: Iterable[str] = (),
498 min_dpi: float | None = None,
499 max_dpi: float | None = None,
500 min_width_px: int | None = None,
501 max_width_px: int | None = None,
502 min_height_px: int | None = None,
503 max_height_px: int | None = None,
504 max_file_bytes: int | None = None,
505 target_width_mm: float | None = None,
506 alpha_policy: str | None = None,
507) -> dict[str, Any]:
508 """Apply only caller-supplied constraints to an inspection report."""
509 metadata = report["metadata"]
510 checks: list[dict[str, Any]] = []
512 formats = _normalize_formats(expected_formats)
513 if formats:
514 actual = str(metadata.get("format", "")).upper()
515 checks.append(
516 _check(
517 "format",
518 "pass" if actual in formats else "fail",
519 actual=actual,
520 expected=formats,
521 detail="File format is compared with the explicit allow-list.",
522 )
523 )
525 modes = sorted(
526 {
527 item.strip().upper()
528 for value in expected_modes
529 for item in value.split(",")
530 if item.strip()
531 }
532 )
533 if modes:
534 actual_mode = metadata.get("mode")
535 status = (
536 "unknown"
537 if actual_mode is None
538 else ("pass" if str(actual_mode).upper() in modes else "fail")
539 )
540 checks.append(
541 _check(
542 "mode",
543 status,
544 actual=actual_mode,
545 expected=modes,
546 detail="Vector containers generally do not expose one image mode.",
547 )
548 )
550 width_px = metadata.get("width_px")
551 height_px = metadata.get("height_px")
552 for name, actual, minimum, maximum in (
553 ("width_px", width_px, min_width_px, max_width_px),
554 ("height_px", height_px, min_height_px, max_height_px),
555 ):
556 if minimum is None and maximum is None:
557 continue
558 if actual is None:
559 status = "unknown"
560 elif minimum is not None and actual < minimum:
561 status = "fail"
562 elif maximum is not None and actual > maximum:
563 status = "fail"
564 else:
565 status = "pass"
566 checks.append(
567 _check(
568 name,
569 status,
570 actual=actual,
571 expected={"min": minimum, "max": maximum},
572 detail="Pixel dimensions are available for raster inputs.",
573 )
574 )
576 dpi_x = metadata.get("dpi_x")
577 dpi_y = metadata.get("dpi_y")
578 effective_dpi = None
579 if target_width_mm is not None and width_px is not None:
580 effective_dpi = width_px / (target_width_mm / 25.4)
581 actual_dpi = effective_dpi
582 dpi_basis = "effective at target width"
583 if actual_dpi is None and dpi_x and dpi_y:
584 actual_dpi = min(float(dpi_x), float(dpi_y))
585 dpi_basis = "embedded metadata"
586 if min_dpi is not None or max_dpi is not None:
587 if actual_dpi is None:
588 status = "unknown"
589 elif (
590 min_dpi is not None
591 and actual_dpi < min_dpi
592 and not math.isclose(actual_dpi, min_dpi, rel_tol=1e-12, abs_tol=1e-9)
593 ):
594 status = "fail"
595 elif (
596 max_dpi is not None
597 and actual_dpi > max_dpi
598 and not math.isclose(actual_dpi, max_dpi, rel_tol=1e-12, abs_tol=1e-9)
599 ):
600 status = "fail"
601 else:
602 status = "pass"
603 checks.append(
604 _check(
605 "dpi",
606 status,
607 actual=actual_dpi,
608 expected={"min": min_dpi, "max": max_dpi},
609 detail=(
610 f"DPI basis: {dpi_basis}. Metadata DPI is not a quality "
611 "measure; effective DPI depends on final physical size."
612 ),
613 )
614 )
616 if max_file_bytes is not None:
617 actual_bytes = report["input"]["size_bytes"]
618 checks.append(
619 _check(
620 "file_size",
621 "pass" if actual_bytes <= max_file_bytes else "fail",
622 actual=actual_bytes,
623 expected={"max": max_file_bytes},
624 detail="File size is measured from the local input.",
625 )
626 )
628 if alpha_policy is not None:
629 if alpha_policy not in {"allow", "forbid", "require"}:
630 raise CliError("alpha_policy must be allow, forbid, or require")
631 has_alpha = metadata.get("has_alpha")
632 if has_alpha is None:
633 alpha_status = "unknown"
634 elif alpha_policy == "allow":
635 alpha_status = "pass"
636 elif alpha_policy == "forbid":
637 alpha_status = "pass" if not has_alpha else "fail"
638 else:
639 alpha_status = "pass" if has_alpha else "fail"
640 checks.append(
641 _check(
642 "alpha",
643 alpha_status,
644 actual=has_alpha,
645 expected=alpha_policy,
646 detail=(
647 "Alpha is detected for raster modes/transparency metadata. "
648 "Vector transparency requires rendered-content inspection."
649 ),
650 )
651 )
653 counts = {
654 status: sum(item["status"] == status for item in checks)
655 for status in ("pass", "fail", "warning", "unknown")
656 }
657 screened = dict(report)
658 screened["checks"] = checks
659 screened["screening_summary"] = counts
660 screened["notice"] = (
661 "This deterministic metadata screen checks only the supplied constraints. "
662 "It does not verify visual quality, scientific integrity, accessibility, "
663 "embedded-raster resolution in vector files, or journal compliance."
664 )
665 return screened
668def build_parser() -> argparse.ArgumentParser:
669 parser = argparse.ArgumentParser(
670 description=(
671 "Inspect local raster, SVG, PDF, EPS, or PS metadata and optionally "
672 "screen it against explicit constraints. No network access is used."
673 )
674 )
675 parser.add_argument("input", help="local figure/image file (symlinks rejected)")
676 parser.add_argument(
677 "--format",
678 dest="formats",
679 action="append",
680 default=[],
681 help="allowed format; repeat or use comma-separated values",
682 )
683 parser.add_argument(
684 "--mode",
685 dest="modes",
686 action="append",
687 default=[],
688 help="allowed raster mode (for example RGB,L); repeat as needed",
689 )
690 parser.add_argument("--min-dpi", type=positive_float)
691 parser.add_argument("--max-dpi", type=positive_float)
692 parser.add_argument(
693 "--target-width-mm",
694 type=positive_float,
695 help="calculate effective raster DPI at this final width",
696 )
697 parser.add_argument("--min-width-px", type=positive_int)
698 parser.add_argument("--max-width-px", type=positive_int)
699 parser.add_argument("--min-height-px", type=positive_int)
700 parser.add_argument("--max-height-px", type=positive_int)
701 parser.add_argument("--max-file-bytes", type=positive_int)
702 parser.add_argument(
703 "--alpha-policy",
704 choices=("allow", "forbid", "require"),
705 help="optional raster alpha/transparency screen",
706 )
707 parser.add_argument(
708 "--max-input-bytes",
709 type=positive_int,
710 default=MAX_INPUT_BYTES,
711 help=f"input byte limit (default: {MAX_INPUT_BYTES})",
712 )
713 parser.add_argument(
714 "--max-pixels",
715 type=positive_int,
716 default=DEFAULT_MAX_PIXELS,
717 help=f"raster pixel limit (default: {DEFAULT_MAX_PIXELS})",
718 )
719 parser.add_argument("--output", help="optional JSON report path")
720 parser.add_argument(
721 "--force", action="store_true", help="overwrite an existing JSON report"
722 )
723 return parser
726def main(argv: list[str] | None = None) -> int:
727 parser = build_parser()
728 try:
729 args = parser.parse_args(argv)
730 report = inspect_file(
731 args.input,
732 max_bytes=args.max_input_bytes,
733 max_pixels=args.max_pixels,
734 )
735 report = screen_metadata(
736 report,
737 expected_formats=args.formats,
738 expected_modes=args.modes,
739 min_dpi=args.min_dpi,
740 max_dpi=args.max_dpi,
741 min_width_px=args.min_width_px,
742 max_width_px=args.max_width_px,
743 min_height_px=args.min_height_px,
744 max_height_px=args.max_height_px,
745 max_file_bytes=args.max_file_bytes,
746 target_width_mm=args.target_width_mm,
747 alpha_policy=args.alpha_policy,
748 )
749 emit_json(report, output=args.output, force=args.force)
750 return 1 if report["screening_summary"]["fail"] else 0
751 except CliError as exc:
752 parser.exit(2, f"error: {exc}\n")
755if __name__ == "__main__":
756 sys.exit(main())