Skill Tracer
Skills › Scientific Agent Skills › scientific-visualization › export_plan.py

export_plan.py

541 lines · skills/scientific-visualization/scripts/export_plan.py at 5f2bc03

1#!/usr/bin/env python3
2"""Build and screen against dated publication-export planning snapshots."""
3
4from __future__ import annotations
5
6import argparse
7import json
8import math
9import sys
10from pathlib import Path
11from typing import Any
13from _common import CliError, emit_json, positive_float
15SCHEMA_VERSION = "1.0"
16PROFILE_PATH = Path(__file__).resolve().parents[1] / "assets" / "publisher_profiles.json"
19def load_profiles() -> dict[str, Any]:
20 try:
21 document = json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
22 except (OSError, json.JSONDecodeError) as exc:
23 raise CliError(f"cannot load publisher profile asset: {exc}") from exc
24 if not isinstance(document.get("profiles"), dict):
25 raise CliError("publisher profile asset does not contain profiles")
26 return document
29def build_plan(
30 publisher: str,
31 *,
32 figure_type: str,
33 width: str | None = None,
34 phase: str | None = None,
35) -> dict[str, Any]:
36 """Build an explicit plan without exporting or claiming compliance."""
37 document = load_profiles()
38 try:
39 profile = document["profiles"][publisher]
40 except KeyError as exc:
41 raise CliError(
42 f"unknown publisher profile {publisher!r}; "
43 f"available: {', '.join(sorted(document['profiles']))}"
44 ) from exc
45 widths = profile.get("widths_mm") or {}
46 if width is not None and width not in widths:
47 raise CliError(
48 f"unknown width {width!r} for {publisher}; "
49 f"available: {', '.join(sorted(widths))}"
50 )
51 format_map = profile.get("formats") or {}
52 dpi_map = profile.get("raster_dpi") or {}
53 available_types = sorted(set(format_map) | set(dpi_map))
54 if figure_type not in available_types and available_types:
55 raise CliError(
56 f"unknown figure type {figure_type!r} for {publisher}; "
57 f"available: {', '.join(available_types)}"
58 )
60 profile_phase = profile.get("phase")
61 phase_matches = phase is None or phase == profile_phase
62 selected_width = float(widths[width]) if width is not None else None
63 return {
64 "schema_version": SCHEMA_VERSION,
65 "publisher": publisher,
66 "label": profile["label"],
67 "scope": profile["scope"],
68 "profile_accessed": profile.get("accessed", document["accessed"]),
69 "source_status": profile.get("source_status", "reviewed"),
70 "profile_phase": profile_phase,
71 "requested_phase": phase,
72 "phase_matches_snapshot": phase_matches,
73 "journal_specific": profile.get("journal_specific"),
74 "figure_type": figure_type,
75 "width": {
76 "name": width,
77 "millimeters": selected_width,
78 "available": widths,
79 },
80 "max_height_mm": profile.get("max_height_mm"),
81 "recommended_max_height_mm": profile.get("recommended_max_height_mm"),
82 "max_height_includes_legend": profile.get("max_height_includes_legend", False),
83 "tiff_requirements": profile.get("tiff_requirements"),
84 "formats": format_map.get(figure_type) if format_map else None,
85 "raster_dpi": dpi_map.get(figure_type) if dpi_map else None,
86 "color_modes": profile.get("color_modes"),
87 "preferred_color_mode": profile.get("preferred_color_mode"),
88 "max_file_bytes": profile.get("max_file_bytes"),
89 "max_file_bytes_exclusive": profile.get("max_file_bytes_exclusive"),
90 "width_range_px_at_300_dpi": profile.get("width_range_px_at_300_dpi"),
91 "max_height_px_at_300_dpi": profile.get("max_height_px_at_300_dpi"),
92 "sources": profile["sources"],
93 "notes": profile.get("notes", []),
94 "notice": document["notice"],
95 }
98def _finding(
99 name: str,
100 status: str,
101 *,
102 actual: Any,
103 expected: Any,
104 detail: str,
105) -> dict[str, Any]:
106 return {
107 "name": name,
108 "status": status,
109 "actual": actual,
110 "expected": expected,
111 "detail": detail,
112 }
115def _normalized_mode(mode: str | None) -> str | None:
116 if mode is None:
117 return None
118 if mode in {"RGB", "RGBA"}:
119 return "RGB"
120 if mode in {"L", "LA", "1", "I", "I;16", "F"}:
121 return "L"
122 return mode
125def _effective_dimensions(
126 metadata: dict[str, Any], target_width_mm: float | None
127) -> tuple[float | None, float | None, float | None]:
128 """Return effective DPI, width mm, and height mm."""
129 width_px = metadata.get("width_px")
130 height_px = metadata.get("height_px")
131 if target_width_mm is not None and width_px:
132 dpi = float(width_px) / (target_width_mm / 25.4)
133 height_mm = (
134 float(height_px) / float(width_px) * target_width_mm
135 if height_px
136 else None
137 )
138 return dpi, target_width_mm, height_mm
139 width_mm = metadata.get("width_mm") or metadata.get("width_mm_from_metadata")
140 height_mm = metadata.get("height_mm") or metadata.get("height_mm_from_metadata")
141 dpi_values = [
142 float(value)
143 for value in (metadata.get("dpi_x"), metadata.get("dpi_y"))
144 if value
145 ]
146 return (
147 min(dpi_values) if dpi_values else None,
148 float(width_mm) if width_mm is not None else None,
149 float(height_mm) if height_mm is not None else None,
150 )
153def validate_against_plan(
154 plan: dict[str, Any],
155 input_path: str | Path,
156 *,
157 width_tolerance_mm: float = 1.0,
158) -> dict[str, Any]:
159 """Screen inspectable file properties against one explicit plan."""
160 try:
161 from image_metadata import inspect_file
162 except ImportError as exc:
163 raise CliError(f"cannot import image_metadata.py: {exc}") from exc
164 inspected = inspect_file(input_path)
165 metadata = inspected["metadata"]
166 findings: list[dict[str, Any]] = []
167 if not plan.get("phase_matches_snapshot", True):
168 findings.append(_finding(
169 "submission_phase", "review", actual=plan.get("requested_phase"),
170 expected=plan.get("profile_phase"),
171 detail="Requested phase differs; these snapshot rules may not apply.",
172 ))
173 if plan.get("source_status", "reviewed") != "reviewed":
174 findings.append(_finding(
175 "source_currency", "review", actual=plan.get("source_status"),
176 expected="current target-journal instructions",
177 detail="Historical or legacy profile requires current journal verification.",
178 ))
179 if metadata.get("cropbox_differs_from_mediabox"):
180 findings.append(_finding(
181 "pdf_page_box", "review", actual="CropBox differs from MediaBox",
182 expected="confirm intended displayed/production page box",
183 detail="Width screening below uses the rotated MediaBox, not the cropped view.",
184 ))
186 formats = plan.get("formats")
187 actual_format = str(metadata.get("format", "")).lower()
188 if formats is None:
189 findings.append(
190 _finding(
191 "format",
192 "unknown",
193 actual=actual_format,
194 expected=None,
195 detail="The profile does not state a universal format for this type.",
196 )
197 )
198 elif not formats:
199 findings.append(
200 _finding(
201 "format",
202 "unknown",
203 actual=actual_format,
204 expected=[],
205 detail="No raster format was stated on the cited profile page.",
206 )
207 )
208 else:
209 findings.append(
210 _finding(
211 "format",
212 "pass" if actual_format in formats else "fail",
213 actual=actual_format,
214 expected=formats,
215 detail="Compared with formats explicitly recorded in the snapshot.",
216 )
217 )
219 target_width = plan["width"]["millimeters"]
220 effective_dpi, effective_width, effective_height = _effective_dimensions(
221 metadata, target_width
222 )
223 dpi_rule = plan.get("raster_dpi")
224 if metadata.get("kind") == "raster" and dpi_rule:
225 if effective_dpi is None:
226 dpi_status = "unknown"
227 elif (
228 dpi_rule.get("min") is not None
229 and effective_dpi < float(dpi_rule["min"])
230 and not math.isclose(
231 effective_dpi,
232 float(dpi_rule["min"]),
233 rel_tol=1e-12,
234 abs_tol=1e-9,
235 )
236 ):
237 dpi_status = "fail"
238 elif (
239 dpi_rule.get("min_exclusive") is not None
240 and effective_dpi <= float(dpi_rule["min_exclusive"])
241 ):
242 dpi_status = "fail"
243 elif (
244 dpi_rule.get("max") is not None
245 and effective_dpi > float(dpi_rule["max"])
246 and not math.isclose(
247 effective_dpi,
248 float(dpi_rule["max"]),
249 rel_tol=1e-12,
250 abs_tol=1e-9,
251 )
252 ):
253 dpi_status = "fail"
254 else:
255 dpi_status = (
256 "review" if set(dpi_rule) == {"target"} else "pass"
257 )
258 findings.append(
259 _finding(
260 "effective_raster_dpi",
261 dpi_status,
262 actual=effective_dpi,
263 expected=dpi_rule,
264 detail=(
265 "Calculated at the selected final width when supplied; "
266 "otherwise uses embedded DPI metadata. Upsampling is not "
267 "evidence of added detail. A target without a defined tolerance "
268 "is a recommendation requiring review, not a pass threshold."
269 ),
270 )
271 )
272 elif dpi_rule and metadata.get("kind") != "raster":
273 findings.append(
274 _finding(
275 "effective_raster_dpi",
276 "unknown",
277 actual=None,
278 expected=dpi_rule,
279 detail=(
280 "Vector-container DPI is not meaningful and embedded raster "
281 "objects were not individually inspected."
282 ),
283 )
284 )
285 else:
286 findings.append(
287 _finding(
288 "effective_raster_dpi",
289 "unknown",
290 actual=effective_dpi,
291 expected=dpi_rule,
292 detail="No binding DPI rule was recorded for this figure type.",
293 )
294 )
296 if target_width is not None:
297 if effective_width is None:
298 width_status = "unknown"
299 else:
300 width_status = (
301 "pass"
302 if abs(effective_width - target_width) <= width_tolerance_mm
303 else "fail"
304 )
305 findings.append(
306 _finding(
307 "final_width_mm",
308 width_status,
309 actual=effective_width,
310 expected={
311 "target": target_width,
312 "tolerance": width_tolerance_mm,
313 },
314 detail=(
315 "Raster inputs are evaluated at the requested target width; "
316 "vector containers use their page dimensions."
317 ),
318 )
319 )
321 max_height = plan.get("max_height_mm")
322 recommended_height = plan.get("recommended_max_height_mm")
323 if max_height is None and recommended_height is not None:
324 max_height = recommended_height
325 if max_height is not None:
326 findings.append(
327 _finding(
328 "final_height_mm",
329 (
330 "unknown"
331 if effective_height is None
332 else (
333 ("review" if plan.get("max_height_includes_legend") else "pass")
334 if effective_height <= float(max_height)
335 else ("review" if recommended_height is not None else "fail")
336 )
337 ),
338 actual=effective_height,
339 expected={"max": max_height},
340 detail=(
341 "Measured exported page/canvas height; legends outside the file "
342 "are not included. Recommended maxima are advisory; limits "
343 "including an external legend require manual review."
344 ),
345 )
346 )
348 pixel_range = plan.get("width_range_px_at_300_dpi")
349 if pixel_range and metadata.get("width_px") is not None:
350 # The published pixel range is a size equivalence at 300 dpi, not a
351 # cap on pixels in a valid 600 dpi image at the same physical size.
352 actual_width_px = effective_width / 25.4 * 300 if effective_width else None
353 findings.append(
354 _finding(
355 "pixel_width_snapshot",
356 (
357 "unknown" if actual_width_px is None else (
358 "pass" if pixel_range[0] - 0.5 <= actual_width_px <= pixel_range[1] + 0.5
359 else "fail"
360 )
361 ),
362 actual=actual_width_px,
363 expected={"min": pixel_range[0], "max": pixel_range[1]},
364 detail="Equivalent width at 300 dpi, computed from final physical size; half-pixel rounding tolerance.",
365 )
366 )
368 max_bytes = plan.get("max_file_bytes")
369 max_bytes_exclusive = plan.get("max_file_bytes_exclusive")
370 if max_bytes is not None or max_bytes_exclusive is not None:
371 actual_bytes = inspected["input"]["size_bytes"]
372 if max_bytes_exclusive is not None:
373 size_status = (
374 "pass" if actual_bytes < int(max_bytes_exclusive) else "fail"
375 )
376 expected_size = {"less_than": max_bytes_exclusive}
377 else:
378 size_status = "pass" if actual_bytes <= int(max_bytes) else "fail"
379 expected_size = {"max": max_bytes}
380 findings.append(
381 _finding(
382 "file_size",
383 size_status,
384 actual=actual_bytes,
385 expected=expected_size,
386 detail="Compared with the dated snapshot's file-size limit.",
387 )
388 )
390 allowed_modes = plan.get("color_modes")
391 actual_mode = metadata.get("mode")
392 if allowed_modes:
393 normalized_mode = _normalized_mode(actual_mode)
394 findings.append(
395 _finding(
396 "color_mode",
397 (
398 "unknown"
399 if normalized_mode is None
400 else (
401 "pass"
402 if normalized_mode in allowed_modes
403 else "fail"
404 )
405 ),
406 actual=actual_mode,
407 expected=allowed_modes,
408 detail=(
409 "Raster mode is screened coarsely. ICC profiles and print "
410 "conversion require separate color-management review."
411 ),
412 )
413 )
415 tiff_rules = plan.get("tiff_requirements") or {}
416 if actual_format == "tiff":
417 for field, expected in tiff_rules.items():
418 actual = metadata.get(field)
419 findings.append(_finding(
420 f"tiff_{field}",
421 "unknown" if actual is None else ("pass" if actual == expected else "fail"),
422 actual=actual, expected=expected,
423 detail="Explicit TIFF property recorded by the selected publisher profile.",
424 ))
426 if metadata.get("has_alpha") is True and "has_alpha" not in tiff_rules:
427 findings.append(
428 _finding(
429 "transparency",
430 "review",
431 actual=True,
432 expected="verify against target journal/background",
433 detail=(
434 "The raster has alpha/transparency. Blending can change "
435 "contrast, and no universal publisher rule is assumed."
436 ),
437 )
438 )
440 counts = {
441 status: sum(item["status"] == status for item in findings)
442 for status in ("pass", "fail", "review", "unknown")
443 }
444 return {
445 "schema_version": SCHEMA_VERSION,
446 "plan": plan,
447 "inspection": inspected,
448 "findings": findings,
449 "screening_summary": counts,
450 "notice": (
451 "This screen compares only machine-readable properties with a dated "
452 "profile. It cannot certify journal acceptance, font appearance, "
453 "embedded-raster quality, accessibility, or scientific integrity."
454 ),
455 }
458def build_parser() -> argparse.ArgumentParser:
459 parser = argparse.ArgumentParser(
460 description=(
461 "Plan publication export from dated official-source snapshots and "
462 "optionally screen a local file. No network access is used."
463 )
464 )
465 parser.add_argument("--list", action="store_true", help="list profiles")
466 parser.add_argument("--publisher", help="publisher/profile key")
467 parser.add_argument(
468 "--figure-type",
469 default="combination",
470 help="figure type in the selected profile (default: combination)",
471 )
472 parser.add_argument("--width", help="named final width from the profile")
473 parser.add_argument(
474 "--phase", help="your submission phase, for an explicit scope check"
475 )
476 parser.add_argument("--input", help="optional local file to screen")
477 parser.add_argument(
478 "--width-tolerance-mm",
479 type=positive_float,
480 default=1.0,
481 help="vector/page width tolerance (default: 1.0)",
482 )
483 parser.add_argument("--output", help="optional JSON output path")
484 parser.add_argument("--force", action="store_true")
485 return parser
488def main(argv: list[str] | None = None) -> int:
489 parser = build_parser()
490 try:
491 args = parser.parse_args(argv)
492 document = load_profiles()
493 if args.list:
494 emit_json(
495 {
496 "schema_version": SCHEMA_VERSION,
497 "accessed": document["accessed"],
498 "profiles": {
499 name: {
500 "label": profile["label"],
501 "scope": profile["scope"],
502 "phase": profile["phase"],
503 "accessed": profile.get("accessed", document["accessed"]),
504 "source_status": profile.get("source_status", "reviewed"),
505 "sources": profile["sources"],
506 }
507 for name, profile in sorted(document["profiles"].items())
508 },
509 "notice": document["notice"],
510 },
511 output=args.output,
512 force=args.force,
513 )
514 return 0
515 if not args.publisher:
516 parser.error("--publisher is required unless --list is used")
517 plan = build_plan(
518 args.publisher,
519 figure_type=args.figure_type,
520 width=args.width,
521 phase=args.phase,
522 )
523 result = (
524 validate_against_plan(
525 plan,
526 args.input,
527 width_tolerance_mm=args.width_tolerance_mm,
528 )
529 if args.input
530 else plan
531 )
532 emit_json(result, output=args.output, force=args.force)
533 if args.input:
534 return 1 if result["screening_summary"]["fail"] else 0
535 return 0
536 except CliError as exc:
537 parser.exit(2, f"error: {exc}\n")
540if __name__ == "__main__":
541 sys.exit(main())