Skills › Scientific Agent Skills › scientific-visualization › figure_export.py
figure_export.py
670 lines · skills/scientific-visualization/scripts/figure_export.py at 5f2bc03
1#!/usr/bin/env python3
2"""Safe Matplotlib export helpers with explicit, auditable settings.
4This module does not certify journal compliance. Publisher profiles are dated
5planning snapshots and must be confirmed against the target journal.
6"""
8from __future__ import annotations
10import argparse
11import importlib.metadata
12import json
13import math
14import os
15import sys
16import tempfile
17from pathlib import Path
18from typing import Any, Iterable
20from _common import CliError, checked_output_file, emit_json, positive_float
22SCHEMA_VERSION = "1.0"
23SUPPORTED_FORMATS = {
24 "pdf",
25 "svg",
26 "eps",
27 "ps",
28 "png",
29 "tiff",
30 "jpeg",
31 "webp",
32}
33FORMAT_ALIASES = {"jpg": "jpeg", "tif": "tiff"}
34VECTOR_FORMATS = {"pdf", "svg", "eps", "ps"}
35RASTER_FORMATS = {"png", "tiff", "jpeg", "webp"}
36METADATA_FORMATS = {"pdf", "svg", "png", "eps", "ps"}
37MAX_FORMATS = 8
40def _normalize_formats(formats: Iterable[str]) -> list[str]:
41 normalized: list[str] = []
42 for item in formats:
43 for value in item.split(","):
44 raw_name = value.strip().lower().lstrip(".")
45 name = FORMAT_ALIASES.get(raw_name, raw_name)
46 if not name:
47 continue
48 if name not in SUPPORTED_FORMATS:
49 raise CliError(
50 f"unsupported format {name!r}; "
51 f"available: {', '.join(sorted(SUPPORTED_FORMATS))}"
52 )
53 if name not in normalized:
54 normalized.append(name)
55 if not normalized:
56 raise CliError("at least one output format is required")
57 if len(normalized) > MAX_FORMATS:
58 raise CliError(f"at most {MAX_FORMATS} output formats are allowed")
59 return normalized
62def _base_output_path(value: str | os.PathLike[str]) -> Path:
63 path = Path(value).expanduser()
64 if path.suffix.lower().lstrip(".") in SUPPORTED_FORMATS | set(FORMAT_ALIASES):
65 path = path.with_suffix("")
66 if not path.name:
67 raise CliError("output must include a base filename")
68 return path
71def _font_rc(font_mode: str) -> dict[str, Any]:
72 if font_mode == "current":
73 return {}
74 if font_mode == "truetype":
75 return {
76 "pdf.fonttype": 42,
77 "ps.fonttype": 42,
78 # Editable SVG text; unlike PDF Type 42, this is not embedded.
79 "svg.fonttype": "none",
80 }
81 if font_mode == "paths":
82 return {
83 "pdf.fonttype": 3,
84 "ps.fonttype": 3,
85 "svg.fonttype": "path",
86 }
87 raise CliError("font_mode must be 'truetype', 'paths', or 'current'")
90def _package_version(distribution: str) -> str | None:
91 try:
92 return importlib.metadata.version(distribution)
93 except importlib.metadata.PackageNotFoundError:
94 return None
97def _atomic_savefig(
98 fig: Any,
99 destination: Path,
100 *,
101 format_name: str,
102 save_kwargs: dict[str, Any],
103 overwrite: bool,
104 tiff_rgb: bool = False,
105) -> None:
106 destination = checked_output_file(destination, force=overwrite)
107 descriptor, temporary_name = tempfile.mkstemp(
108 prefix=f".{destination.stem}.",
109 suffix=f".{format_name}",
110 dir=destination.parent,
111 )
112 os.close(descriptor)
113 temporary = Path(temporary_name)
114 try:
115 fig.savefig(temporary, format=format_name, **save_kwargs)
116 if tiff_rgb and format_name == "tiff":
117 from PIL import Image
119 with Image.open(temporary) as rendered:
120 if "A" in rendered.getbands() and rendered.getchannel("A").getextrema() != (255, 255):
121 raise CliError("RGB TIFF requires an opaque rendered background")
122 rgb = rendered.convert("RGB")
123 pillow_options = dict(save_kwargs.get("pil_kwargs", {}))
124 pillow_options.setdefault("dpi", rendered.info.get("dpi", (save_kwargs["dpi"],) * 2))
125 if rendered.info.get("icc_profile"):
126 pillow_options.setdefault("icc_profile", rendered.info["icc_profile"])
127 try:
128 rgb.save(temporary, format="TIFF", **pillow_options)
129 finally:
130 rgb.close()
131 if not temporary.exists() or temporary.stat().st_size == 0:
132 raise CliError(f"Matplotlib produced no data for {destination}")
133 if overwrite:
134 os.replace(temporary, destination)
135 else:
136 try:
137 os.link(temporary, destination)
138 except FileExistsError as exc:
139 raise CliError(f"refusing to overwrite existing output: {destination}") from exc
140 except CliError:
141 raise
142 except Exception as exc:
143 raise CliError(
144 f"failed to export {destination.name} as {format_name}: {exc}"
145 ) from exc
146 finally:
147 try:
148 temporary.unlink()
149 except FileNotFoundError:
150 pass
153def _validate_provenance(provenance: dict[str, Any] | None) -> dict[str, Any]:
154 if provenance is None:
155 return {}
156 if not isinstance(provenance, dict):
157 raise CliError("provenance must be a dictionary")
158 try:
159 encoded = json.dumps(provenance, sort_keys=True).encode("utf-8")
160 except (TypeError, ValueError) as exc:
161 raise CliError(f"provenance must be JSON serializable: {exc}") from exc
162 if len(encoded) > 1_000_000:
163 raise CliError("provenance metadata is limited to 1,000,000 bytes")
164 return provenance
167def export_figure(
168 fig: Any,
169 filename: str | os.PathLike[str],
170 *,
171 formats: Iterable[str] = ("pdf", "png"),
172 dpi: float = 300,
173 transparent: bool = False,
174 tiff_rgb: bool = False,
175 bbox_inches: str | None = None,
176 pad_inches: float = 0.1,
177 facecolor: str = "white",
178 edgecolor: str = "none",
179 font_mode: str = "truetype",
180 overwrite: bool = False,
181 mkdir: bool = False,
182 metadata: dict[str, Any] | None = None,
183 provenance: dict[str, Any] | None = None,
184 write_manifest: bool = False,
185 savefig_kwargs: dict[str, Any] | None = None,
186) -> dict[str, Any]:
187 """Export a Matplotlib figure atomically with explicit format settings.
189 ``dpi`` is passed to vector backends too because it controls rasterized
190 artists and embedded raster images. ``bbox_inches=None`` preserves the
191 figure's physical page dimensions; use ``"tight"`` only when that change is
192 intentional.
193 """
194 if not (float(dpi) > 0 and math.isfinite(float(dpi))):
195 raise CliError("dpi must be finite and greater than zero")
196 if not (float(pad_inches) >= 0 and math.isfinite(float(pad_inches))):
197 raise CliError("pad_inches must be finite and non-negative")
198 normalized_formats = _normalize_formats(formats)
199 if tiff_rgb and ("tiff" not in normalized_formats or transparent):
200 raise CliError("tiff_rgb requires TIFF output and transparent=False")
201 base = _base_output_path(filename)
202 parent = base.parent
203 if mkdir:
204 parent.mkdir(parents=True, exist_ok=True)
205 if not parent.exists() or not parent.is_dir():
206 raise CliError(f"output parent directory does not exist: {parent}")
207 if parent.is_symlink():
208 raise CliError(f"output parent must not be a symlink: {parent}")
209 for format_name in normalized_formats:
210 checked_output_file(
211 parent / f"{base.name}.{format_name}",
212 force=overwrite,
213 )
214 if write_manifest:
215 checked_output_file(
216 parent / f"{base.name}.export.json",
217 force=overwrite,
218 )
220 try:
221 import matplotlib as mpl
222 except ImportError as exc:
223 raise CliError(
224 "Matplotlib is required for figure export; "
225 "run with --with 'matplotlib==3.11.2'"
226 ) from exc
228 provenance_document = _validate_provenance(provenance)
229 user_metadata = dict(metadata or {})
230 creator = user_metadata.setdefault(
231 "Creator",
232 f"scientific-visualization figure_export.py; Matplotlib {mpl.__version__}",
233 )
234 if not isinstance(creator, str):
235 raise CliError("metadata Creator must be a string")
236 extra_kwargs = dict(savefig_kwargs or {})
237 if "format" in extra_kwargs:
238 raise CliError("savefig_kwargs must not override format")
239 protected = {
240 "dpi",
241 "transparent",
242 "bbox_inches",
243 "pad_inches",
244 "facecolor",
245 "edgecolor",
246 "metadata",
247 }
248 overlap = protected.intersection(extra_kwargs)
249 if overlap:
250 raise CliError(
251 "savefig_kwargs must not override explicit settings: "
252 + ", ".join(sorted(overlap))
253 )
255 figure_size = [float(value) for value in fig.get_size_inches()]
256 outputs: list[dict[str, Any]] = []
257 warnings_list: list[str] = []
258 export_rc = _font_rc(font_mode)
259 if bbox_inches is None:
260 # Prevent a user's global savefig.bbox="tight" from changing page size.
261 export_rc["savefig.bbox"] = None
262 with mpl.rc_context(export_rc):
263 for format_name in normalized_formats:
264 destination = parent / f"{base.name}.{format_name}"
265 checked_output_file(destination, force=overwrite)
266 if format_name in {"eps", "ps"}:
267 format_metadata = {"Creator": user_metadata["Creator"]}
268 elif format_name in METADATA_FORMATS:
269 format_metadata = user_metadata
270 else:
271 format_metadata = None
272 if user_metadata and format_name not in METADATA_FORMATS:
273 warnings_list.append(
274 f"{format_name}: Matplotlib does not support savefig metadata "
275 "for this format; metadata remains in the optional manifest."
276 )
277 save_kwargs: dict[str, Any] = {
278 "dpi": float(dpi),
279 "transparent": transparent,
280 "bbox_inches": bbox_inches,
281 "pad_inches": float(pad_inches),
282 "facecolor": "none" if transparent else facecolor,
283 "edgecolor": "none" if transparent else edgecolor,
284 **extra_kwargs,
285 }
286 if format_metadata is not None:
287 save_kwargs["metadata"] = format_metadata
288 if format_name == "tiff":
289 save_kwargs["pil_kwargs"] = {
290 **save_kwargs.get("pil_kwargs", {}),
291 "compression": "tiff_lzw",
292 }
294 _atomic_savefig(
295 fig,
296 destination,
297 format_name=format_name,
298 save_kwargs=save_kwargs,
299 overwrite=overwrite,
300 tiff_rgb=tiff_rgb,
301 )
302 outputs.append(
303 {
304 "path": str(destination.resolve()),
305 "format": format_name,
306 "kind": (
307 "vector-container"
308 if format_name in VECTOR_FORMATS
309 else "raster"
310 ),
311 "size_bytes": destination.stat().st_size,
312 }
313 )
315 report: dict[str, Any] = {
316 "schema_version": SCHEMA_VERSION,
317 "outputs": outputs,
318 "settings": {
319 "formats": normalized_formats,
320 "dpi": float(dpi),
321 "transparent": transparent,
322 "tiff_rgb": tiff_rgb,
323 "bbox_inches": bbox_inches,
324 "pad_inches": float(pad_inches),
325 "facecolor": facecolor,
326 "font_mode": font_mode,
327 "figure_size_inches": figure_size,
328 },
329 "versions": {
330 "matplotlib": mpl.__version__,
331 "pillow": _package_version("Pillow"),
332 },
333 "provenance": provenance_document,
334 "warnings": sorted(set(warnings_list)),
335 "notice": (
336 "Export completed with explicit settings. This report does not "
337 "establish visual, accessibility, scientific-integrity, or "
338 "journal-submission compliance."
339 ),
340 }
342 if write_manifest:
343 manifest_path = parent / f"{base.name}.export.json"
344 emit_json(report, output=manifest_path, force=overwrite)
345 report["manifest"] = str(manifest_path.resolve())
346 return report
349def save_publication_figure(
350 fig: Any,
351 filename: str | os.PathLike[str],
352 formats: Iterable[str] = ("pdf", "png"),
353 dpi: float = 300,
354 transparent: bool = False,
355 bbox_inches: str | None = None,
356 pad_inches: float = 0.1,
357 facecolor: str = "white",
358 *,
359 overwrite: bool = False,
360 provenance: dict[str, Any] | None = None,
361 write_manifest: bool = False,
362 **kwargs: Any,
363) -> list[Path]:
364 """Compatibility wrapper returning saved paths.
366 Unlike the previous implementation, this function refuses implicit
367 overwrite, does not silently swallow export errors, does not cap vector
368 DPI, and preserves figure dimensions unless ``bbox_inches="tight"`` is
369 requested.
370 """
371 report = export_figure(
372 fig,
373 filename,
374 formats=formats,
375 dpi=dpi,
376 transparent=transparent,
377 bbox_inches=bbox_inches,
378 pad_inches=pad_inches,
379 facecolor=facecolor,
380 overwrite=overwrite,
381 provenance=provenance,
382 write_manifest=write_manifest,
383 savefig_kwargs=kwargs,
384 )
385 return [Path(item["path"]) for item in report["outputs"]]
388def _load_profiles() -> dict[str, Any]:
389 profile_path = Path(__file__).resolve().parents[1] / "assets" / "publisher_profiles.json"
390 try:
391 document = json.loads(profile_path.read_text(encoding="utf-8"))
392 except (OSError, json.JSONDecodeError) as exc:
393 raise CliError(f"cannot load publisher profiles: {exc}") from exc
394 if not isinstance(document.get("profiles"), dict):
395 raise CliError("publisher profile asset is malformed")
396 return document
399def check_figure_size(
400 fig: Any,
401 journal: str = "nature",
402 *,
403 tolerance_mm: float = 1.0,
404) -> dict[str, Any]:
405 """Compare figure dimensions with a dated profile without claiming compliance."""
406 if not (tolerance_mm >= 0 and math.isfinite(tolerance_mm)):
407 raise CliError("tolerance_mm must be finite and non-negative")
408 document = _load_profiles()
409 try:
410 profile = document["profiles"][journal]
411 except KeyError as exc:
412 raise CliError(
413 f"unknown profile {journal!r}; "
414 f"available: {', '.join(sorted(document['profiles']))}"
415 ) from exc
416 width_inches, height_inches = (float(value) for value in fig.get_size_inches())
417 width_mm = width_inches * 25.4
418 height_mm = height_inches * 25.4
419 candidates = {
420 name: float(value)
421 for name, value in (profile.get("widths_mm") or {}).items()
422 }
423 nearest_name = None
424 nearest_width = None
425 difference = None
426 if candidates:
427 nearest_name, nearest_width = min(
428 candidates.items(), key=lambda item: abs(width_mm - item[1])
429 )
430 difference = abs(width_mm - nearest_width)
431 max_height = profile.get("max_height_mm")
432 return {
433 "schema_version": SCHEMA_VERSION,
434 "profile": journal,
435 "profile_label": profile["label"],
436 "profile_scope": profile["scope"],
437 "profile_accessed": profile.get("accessed", document["accessed"]),
438 "source_status": profile.get("source_status", "reviewed"),
439 "figure": {
440 "width_inches": width_inches,
441 "height_inches": height_inches,
442 "width_mm": width_mm,
443 "height_mm": height_mm,
444 },
445 "nearest_profile_width": {
446 "name": nearest_name,
447 "width_mm": nearest_width,
448 "difference_mm": difference,
449 "matches_tolerance": (
450 difference is not None and difference <= tolerance_mm
451 ),
452 },
453 "height_screen": {
454 "maximum_mm": max_height,
455 "within_snapshot": (
456 None if max_height is None else height_mm <= float(max_height)
457 ),
458 },
459 "notice": (
460 "A dimensional match to this dated snapshot is not a journal "
461 "compliance determination."
462 ),
463 }
466def save_for_journal(
467 fig: Any,
468 filename: str | os.PathLike[str],
469 journal: str,
470 figure_type: str = "combination",
471 *,
472 confirm_profile: bool = False,
473 format_name: str | None = None,
474 dpi: float | None = None,
475 overwrite: bool = False,
476) -> list[Path]:
477 """Use a dated profile only after explicit caller confirmation.
479 This compatibility helper intentionally requires ``confirm_profile=True``.
480 Prefer ``export_plan.py`` and ``export_figure`` for an auditable workflow.
481 """
482 if not confirm_profile:
483 raise CliError(
484 "publisher rules are date-sensitive; run export_plan.py, verify the "
485 "target journal, then pass confirm_profile=True if this snapshot is "
486 "appropriate"
487 )
488 document = _load_profiles()
489 try:
490 profile = document["profiles"][journal]
491 except KeyError as exc:
492 raise CliError(f"unknown publisher profile: {journal!r}") from exc
493 profile_formats = profile.get("formats") or {}
494 allowed = profile_formats.get(figure_type) or []
495 selected = (
496 _normalize_formats([format_name])[0]
497 if format_name is not None
498 else next((name for name in allowed if name in SUPPORTED_FORMATS), None)
499 )
500 if selected is None:
501 raise CliError(
502 f"profile {journal!r} has no directly exportable {figure_type!r} "
503 "format; choose settings manually"
504 )
505 if selected not in allowed:
506 raise CliError(
507 f"format {selected!r} is not recorded for {journal} "
508 f"{figure_type}; recorded formats: {', '.join(allowed)}"
509 )
510 if dpi is None:
511 raise CliError(
512 "provide an explicit dpi after reviewing embedded raster content; "
513 "the compatibility helper does not choose one automatically"
514 )
515 dpi_profile = (profile.get("raster_dpi") or {}).get(figure_type) or {}
516 if dpi_profile.get("min") is not None and dpi < float(dpi_profile["min"]):
517 raise CliError(f"dpi is below the profile snapshot minimum: {dpi_profile}")
518 if (
519 dpi_profile.get("min_exclusive") is not None
520 and dpi <= float(dpi_profile["min_exclusive"])
521 ):
522 raise CliError(
523 f"dpi does not exceed the profile snapshot threshold: {dpi_profile}"
524 )
525 if dpi_profile.get("max") is not None and dpi > float(dpi_profile["max"]):
526 raise CliError(f"dpi is above the profile snapshot maximum: {dpi_profile}")
527 return save_publication_figure(
528 fig,
529 filename,
530 formats=[selected],
531 dpi=dpi,
532 overwrite=overwrite,
533 provenance={
534 "publisher_profile": journal,
535 "profile_accessed": profile.get("accessed", document["accessed"]),
536 "source_status": profile.get("source_status", "reviewed"),
537 "profile_scope": profile["scope"],
538 "caller_confirmed_snapshot": True,
539 },
540 write_manifest=True,
541 )
544def verify_font_embedding(pdf_path: str | os.PathLike[str]) -> dict[str, Any]:
545 """Return a conservative PDF font-resource report using the metadata tool."""
546 try:
547 from image_metadata import inspect_file
548 except ImportError as exc:
549 raise CliError(f"cannot import image_metadata.py: {exc}") from exc
550 report = inspect_file(pdf_path)
551 if report["metadata"]["format"] != "PDF":
552 raise CliError("font embedding inspection requires a PDF input")
553 return {
554 "path": report["input"]["path"],
555 "font_resources": report["metadata"].get("font_resources"),
556 "notice": (
557 "Inspection covers declared first-page font resources only; "
558 "publisher preflight may apply additional checks."
559 ),
560 }
563def _demo_figure() -> Any:
564 try:
565 import matplotlib.pyplot as plt
566 except ImportError as exc:
567 raise CliError(
568 "Matplotlib is required for --demo; "
569 "run with --with 'matplotlib==3.11.2'"
570 ) from exc
571 x_values = [index / 20.0 for index in range(121)]
572 first = [math.sin(value) for value in x_values]
573 second = [math.cos(value) for value in x_values]
574 fig, ax = plt.subplots(figsize=(3.5, 2.5), layout="constrained")
575 ax.plot(x_values, first, label="sin(x)", marker="o", markevery=20)
576 ax.plot(
577 x_values,
578 second,
579 label="cos(x)",
580 linestyle="--",
581 marker="s",
582 markevery=20,
583 )
584 ax.axhline(0, color="0.35", linewidth=0.7)
585 ax.set(xlabel="Angle (radians)", ylabel="Amplitude (unitless)")
586 ax.legend()
587 return fig
590def build_parser() -> argparse.ArgumentParser:
591 parser = argparse.ArgumentParser(
592 description=(
593 "Render a deterministic Matplotlib export smoke-test. Programmatic "
594 "helpers in this module support explicit, safe figure export."
595 )
596 )
597 parser.add_argument(
598 "--demo",
599 metavar="BASE_PATH",
600 help="render a deterministic demo to this base path",
601 )
602 parser.add_argument(
603 "--formats",
604 default="pdf,png",
605 help="comma-separated formats (default: pdf,png)",
606 )
607 parser.add_argument("--dpi", type=positive_float, default=300.0)
608 parser.add_argument(
609 "--font-mode",
610 choices=("truetype", "paths", "current"),
611 default="truetype",
612 )
613 parser.add_argument("--transparent", action="store_true")
614 parser.add_argument(
615 "--tight",
616 action="store_true",
617 help="use bbox_inches='tight' (changes physical page dimensions)",
618 )
619 parser.add_argument("--manifest", action="store_true")
620 parser.add_argument("--force", action="store_true")
621 parser.add_argument(
622 "--style",
623 choices=("default", "nature", "science", "cell", "minimal", "presentation"),
624 default="default",
625 )
626 parser.add_argument("--palette", default="okabe_ito_on_white")
627 return parser
630def main(argv: list[str] | None = None) -> int:
631 parser = build_parser()
632 try:
633 args = parser.parse_args(argv)
634 if not args.demo:
635 parser.error("--demo BASE_PATH is required")
636 try:
637 from style_presets import style_context
638 except ImportError as exc:
639 raise CliError(f"cannot import style_presets.py: {exc}") from exc
640 with style_context(args.style, palette_name=args.palette):
641 fig = _demo_figure()
642 try:
643 report = export_figure(
644 fig,
645 args.demo,
646 formats=args.formats.split(","),
647 dpi=args.dpi,
648 transparent=args.transparent,
649 bbox_inches="tight" if args.tight else None,
650 font_mode=args.font_mode,
651 overwrite=args.force,
652 provenance={
653 "purpose": "deterministic export smoke test",
654 "raw_data": "analytical sin/cos values generated in code",
655 "transformations": ["none"],
656 },
657 write_manifest=args.manifest,
658 )
659 finally:
660 import matplotlib.pyplot as plt
662 plt.close(fig)
663 emit_json(report)
664 return 0
665 except CliError as exc:
666 parser.exit(2, f"error: {exc}\n")
669if __name__ == "__main__":
670 sys.exit(main())