eagleplot 3.0.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.
Files changed (73) hide show
  1. eagleplot/Home.py +362 -0
  2. eagleplot/__init__.py +19 -0
  3. eagleplot/annotations.py +215 -0
  4. eagleplot/assets/logo.ico +0 -0
  5. eagleplot/assets/logo.png +0 -0
  6. eagleplot/assets/nccu-horiz-logo.png +0 -0
  7. eagleplot/assets/nccu-wings.png +0 -0
  8. eagleplot/batch.py +446 -0
  9. eagleplot/cli.py +719 -0
  10. eagleplot/data_tools.py +849 -0
  11. eagleplot/example-data/Artificial/bimodal_distribution.csv +501 -0
  12. eagleplot/example-data/Artificial/correlation_matrix.npy +0 -0
  13. eagleplot/example-data/Artificial/gaussian_surface.npy +0 -0
  14. eagleplot/example-data/Artificial/grouped_categorical.csv +241 -0
  15. eagleplot/example-data/Artificial/ir_spectrum.csv +1802 -0
  16. eagleplot/example-data/Artificial/molecular_properties.csv +121 -0
  17. eagleplot/example-data/Artificial/normal_distribution.csv +401 -0
  18. eagleplot/example-data/Artificial/reaction_kinetics.csv +121 -0
  19. eagleplot/example-data/Artificial/scatter_correlation.csv +201 -0
  20. eagleplot/example-data/Artificial/sine_cosine_waves.csv +301 -0
  21. eagleplot/gallery/box_plot.png +0 -0
  22. eagleplot/gallery/contour_plot.png +0 -0
  23. eagleplot/gallery/distribution_plot.png +0 -0
  24. eagleplot/gallery/heatmap.png +0 -0
  25. eagleplot/gallery/histogram.png +0 -0
  26. eagleplot/gallery/interactive_3d_surface.png +0 -0
  27. eagleplot/gallery/interactive_heatmap.png +0 -0
  28. eagleplot/gallery/interactive_histogram.png +0 -0
  29. eagleplot/gallery/interactive_line.png +0 -0
  30. eagleplot/gallery/interactive_scatter.png +0 -0
  31. eagleplot/gallery/line_plot.png +0 -0
  32. eagleplot/gallery/manifest.json +196 -0
  33. eagleplot/gallery/regression_plot.png +0 -0
  34. eagleplot/gallery/scatter_plot.png +0 -0
  35. eagleplot/io.py +1008 -0
  36. eagleplot/math.py +1210 -0
  37. eagleplot/molecular/__init__.py +21 -0
  38. eagleplot/molecular/atom_data.py +287 -0
  39. eagleplot/molecular/parsers.py +747 -0
  40. eagleplot/nist.py +241 -0
  41. eagleplot/pages/10_Molecular_Viz.py +225 -0
  42. eagleplot/pages/11_Batch_Analysis.py +325 -0
  43. eagleplot/pages/12_Help.py +199 -0
  44. eagleplot/pages/13_Settings.py +388 -0
  45. eagleplot/pages/1_Data_Upload.py +521 -0
  46. eagleplot/pages/2_Quick_Plot.py +1693 -0
  47. eagleplot/pages/3_Analysis_Tools.py +2510 -0
  48. eagleplot/pages/4_Multi_Plot_Dashboard.py +791 -0
  49. eagleplot/pages/5_Advanced_Plotting.py +691 -0
  50. eagleplot/pages/6_Export_Results.py +442 -0
  51. eagleplot/pages/7_Gallery.py +201 -0
  52. eagleplot/pages/8_Data_Tools.py +434 -0
  53. eagleplot/pages/9_Spectroscopy.py +821 -0
  54. eagleplot/pages/__init__.py +8 -0
  55. eagleplot/peaks.py +513 -0
  56. eagleplot/plotting.py +2630 -0
  57. eagleplot/plugin_loader.py +208 -0
  58. eagleplot/report.py +448 -0
  59. eagleplot/signal.py +494 -0
  60. eagleplot/spectroscopy.py +830 -0
  61. eagleplot/utils/__init__.py +76 -0
  62. eagleplot/utils/access.py +77 -0
  63. eagleplot/utils/data_preview.py +352 -0
  64. eagleplot/utils/plot_config.py +2721 -0
  65. eagleplot/utils/session_state.py +535 -0
  66. eagleplot/utils/theming.py +312 -0
  67. eagleplot/utils/user_settings.py +294 -0
  68. eagleplot-3.0.0.dist-info/METADATA +304 -0
  69. eagleplot-3.0.0.dist-info/RECORD +73 -0
  70. eagleplot-3.0.0.dist-info/WHEEL +5 -0
  71. eagleplot-3.0.0.dist-info/entry_points.txt +2 -0
  72. eagleplot-3.0.0.dist-info/licenses/LICENSE +21 -0
  73. eagleplot-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,208 @@
1
+ """Plugin loader for EaglePlot.
2
+
3
+ Discovers and loads plugin_*.py files from the plugins/ directory.
4
+ Each plugin is a standard Python module that optionally exposes:
5
+
6
+ PLUGIN_NAME: str — human-readable name
7
+ PLUGIN_VERSION: str — semver string
8
+ PLUGIN_DESCRIPTION: str — one-line description
9
+
10
+ get_plot_types() -> list[dict]
11
+ Returns a list of plot-type registration dicts, each with keys:
12
+ - 'name': str (unique identifier)
13
+ - 'label': str (display label)
14
+ - 'function': callable (plotting function)
15
+ - 'description': str (optional)
16
+
17
+ get_analysis_tools() -> list[dict]
18
+ Returns a list of analysis-tool dicts with keys:
19
+ - 'name': str
20
+ - 'label': str
21
+ - 'function': callable
22
+ - 'description': str (optional)
23
+
24
+ Public API
25
+ ----------
26
+ discover_plugins(plugins_dir=None) -> dict[str, types.ModuleType]
27
+ Scan plugins_dir (default: repo-root/plugins/) for plugin_*.py files.
28
+ Import each valid plugin and return {filename_stem: module}.
29
+ Silently skips files that raise ImportError or other exceptions during load.
30
+
31
+ get_plugin_plot_types(plugins=None) -> list[dict]
32
+ Return all plot-type dicts from all discovered plugins.
33
+ plugins: pass result of discover_plugins() to avoid re-scanning.
34
+
35
+ get_plugin_analysis_tools(plugins=None) -> list[dict]
36
+ Return all analysis-tool dicts from all discovered plugins.
37
+
38
+ list_plugins(plugins=None) -> list[dict]
39
+ Return a summary list of loaded plugins:
40
+ [{'name': ..., 'version': ..., 'description': ..., 'file': ...}, ...]
41
+ """
42
+
43
+ import importlib.util
44
+ import types
45
+ from pathlib import Path
46
+ from typing import Optional
47
+
48
+ _PLUGINS_DIR = Path(__file__).parent.parent / "plugins"
49
+
50
+
51
+ def discover_plugins(
52
+ plugins_dir: Optional[Path] = None,
53
+ ) -> dict[str, types.ModuleType]:
54
+ """Scan plugins_dir for plugin_*.py files and import each one.
55
+
56
+ Parameters
57
+ ----------
58
+ plugins_dir : Path, optional
59
+ Directory to scan. Defaults to ``<repo-root>/plugins/``.
60
+
61
+ Returns
62
+ -------
63
+ dict[str, types.ModuleType]
64
+ Mapping of filename stem (e.g. ``"plugin_example"``) to the loaded
65
+ module object. Files that fail to import are silently skipped.
66
+ """
67
+ if plugins_dir is None:
68
+ plugins_dir = _PLUGINS_DIR
69
+
70
+ plugins_dir = Path(plugins_dir)
71
+
72
+ if not plugins_dir.is_dir():
73
+ return {}
74
+
75
+ loaded: dict[str, types.ModuleType] = {}
76
+
77
+ for path in sorted(plugins_dir.glob("plugin_*.py")):
78
+ stem = path.stem
79
+ try:
80
+ spec = importlib.util.spec_from_file_location(stem, path)
81
+ if spec is None or spec.loader is None:
82
+ continue
83
+ module = importlib.util.module_from_spec(spec)
84
+ spec.loader.exec_module(module) # type: ignore[union-attr]
85
+ loaded[stem] = module
86
+ except Exception:
87
+ # Bad plugins must never crash the app
88
+ pass
89
+
90
+ return loaded
91
+
92
+
93
+ def get_plugin_plot_types(
94
+ plugins: Optional[dict[str, types.ModuleType]] = None,
95
+ ) -> list[dict]:
96
+ """Return all plot-type dicts contributed by discovered plugins.
97
+
98
+ Parameters
99
+ ----------
100
+ plugins : dict, optional
101
+ Pre-loaded plugins dict from :func:`discover_plugins`. If *None*,
102
+ :func:`discover_plugins` is called automatically.
103
+
104
+ Returns
105
+ -------
106
+ list[dict]
107
+ Concatenated list of plot-type dicts from every plugin that exposes
108
+ ``get_plot_types()``. Each dict has at minimum ``name``, ``label``,
109
+ and ``function`` keys.
110
+ """
111
+ if plugins is None:
112
+ plugins = discover_plugins()
113
+
114
+ result: list[dict] = []
115
+
116
+ for module in plugins.values():
117
+ if not hasattr(module, "get_plot_types"):
118
+ continue
119
+ try:
120
+ items = module.get_plot_types()
121
+ if isinstance(items, list):
122
+ result.extend(items)
123
+ except Exception:
124
+ pass
125
+
126
+ return result
127
+
128
+
129
+ def get_plugin_analysis_tools(
130
+ plugins: Optional[dict[str, types.ModuleType]] = None,
131
+ ) -> list[dict]:
132
+ """Return all analysis-tool dicts contributed by discovered plugins.
133
+
134
+ Parameters
135
+ ----------
136
+ plugins : dict, optional
137
+ Pre-loaded plugins dict from :func:`discover_plugins`. If *None*,
138
+ :func:`discover_plugins` is called automatically.
139
+
140
+ Returns
141
+ -------
142
+ list[dict]
143
+ Concatenated list of analysis-tool dicts from every plugin that
144
+ exposes ``get_analysis_tools()``. Each dict has at minimum ``name``,
145
+ ``label``, and ``function`` keys.
146
+ """
147
+ if plugins is None:
148
+ plugins = discover_plugins()
149
+
150
+ result: list[dict] = []
151
+
152
+ for module in plugins.values():
153
+ if not hasattr(module, "get_analysis_tools"):
154
+ continue
155
+ try:
156
+ items = module.get_analysis_tools()
157
+ if isinstance(items, list):
158
+ result.extend(items)
159
+ except Exception:
160
+ pass
161
+
162
+ return result
163
+
164
+
165
+ def list_plugins(
166
+ plugins: Optional[dict[str, types.ModuleType]] = None,
167
+ ) -> list[dict]:
168
+ """Return a human-readable summary of all loaded plugins.
169
+
170
+ Parameters
171
+ ----------
172
+ plugins : dict, optional
173
+ Pre-loaded plugins dict from :func:`discover_plugins`. If *None*,
174
+ :func:`discover_plugins` is called automatically.
175
+
176
+ Returns
177
+ -------
178
+ list[dict]
179
+ One dict per plugin with keys:
180
+
181
+ - ``name`` — ``PLUGIN_NAME`` attribute, or the file stem if absent
182
+ - ``version`` — ``PLUGIN_VERSION`` attribute, or ``"unknown"``
183
+ - ``description`` — ``PLUGIN_DESCRIPTION`` attribute, or ``""``
184
+ - ``file`` — absolute :class:`~pathlib.Path` to the plugin file
185
+ """
186
+ if plugins is None:
187
+ plugins = discover_plugins()
188
+
189
+ summary: list[dict] = []
190
+
191
+ for stem, module in plugins.items():
192
+ name = getattr(module, "PLUGIN_NAME", stem)
193
+ version = getattr(module, "PLUGIN_VERSION", "unknown")
194
+ description = getattr(module, "PLUGIN_DESCRIPTION", "")
195
+
196
+ # Retrieve the file path from the module's __file__ attribute
197
+ file_path = Path(getattr(module, "__file__", ""))
198
+
199
+ summary.append(
200
+ {
201
+ "name": name,
202
+ "version": version,
203
+ "description": description,
204
+ "file": file_path,
205
+ }
206
+ )
207
+
208
+ return summary
eagleplot/report.py ADDED
@@ -0,0 +1,448 @@
1
+ """PDF Report Generator for EaglePlot sessions.
2
+
3
+ Builds a multi-page PDF from a session's plots, datasets, and analysis results
4
+ using matplotlib.backends.backend_pdf.PdfPages — no extra dependencies required.
5
+
6
+ Public API
7
+ ----------
8
+ generate_pdf_report(filepath, title, plot_entries, datasets, analysis_results,
9
+ author='', dpi=150) -> Path
10
+ Write a PDF report and return the output Path.
11
+
12
+ _make_title_page(title, author, n_plots, n_datasets, n_analyses) -> Figure
13
+ (internal) Render a title page as a matplotlib Figure.
14
+
15
+ _dataframe_to_figure(df, title, max_rows) -> Figure
16
+ (internal) Render a DataFrame as a matplotlib table Figure.
17
+
18
+ _analysis_result_to_figure(result) -> Figure
19
+ (internal) Render one analysis result dict as a text/table Figure.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+
27
+ import matplotlib.pyplot as plt
28
+ import numpy as np
29
+ import pandas as pd
30
+ from matplotlib.backends.backend_pdf import PdfPages
31
+ from matplotlib.figure import Figure
32
+
33
+
34
+ # ── Internal helpers ──────────────────────────────────────────────────────────
35
+
36
+
37
+ def _make_title_page(
38
+ title: str,
39
+ author: str,
40
+ n_plots: int,
41
+ n_datasets: int,
42
+ n_analyses: int,
43
+ ) -> Figure:
44
+ """Render a title page as a matplotlib Figure.
45
+
46
+ Parameters
47
+ ----------
48
+ title:
49
+ Report title string.
50
+ author:
51
+ Author name (may be empty).
52
+ n_plots:
53
+ Number of matplotlib plot entries included in the report.
54
+ n_datasets:
55
+ Number of DataFrame datasets included in the report.
56
+ n_analyses:
57
+ Number of analysis result entries included in the report.
58
+
59
+ Returns
60
+ -------
61
+ Figure
62
+ A matplotlib Figure (caller is responsible for closing it).
63
+ """
64
+ fig = plt.figure(figsize=(8.5, 11), facecolor="white")
65
+ ax = fig.add_axes((0.0, 0.0, 1.0, 1.0))
66
+ ax.set_axis_off()
67
+
68
+ # Decorative horizontal rules
69
+ ax.axhline(0.78, xmin=0.08, xmax=0.92, color="#333333", linewidth=1.5)
70
+ ax.axhline(0.20, xmin=0.08, xmax=0.92, color="#333333", linewidth=0.8)
71
+
72
+ # Title
73
+ fig.text(
74
+ 0.5,
75
+ 0.65,
76
+ title,
77
+ ha="center",
78
+ va="center",
79
+ fontsize=24,
80
+ fontweight="bold",
81
+ color="#111111",
82
+ wrap=True,
83
+ )
84
+
85
+ # Subtitle
86
+ fig.text(
87
+ 0.5,
88
+ 0.57,
89
+ "Generated by EaglePlot",
90
+ ha="center",
91
+ va="center",
92
+ fontsize=13,
93
+ color="#555555",
94
+ style="italic",
95
+ )
96
+
97
+ # Author
98
+ if author:
99
+ fig.text(
100
+ 0.5,
101
+ 0.50,
102
+ f"Author: {author}",
103
+ ha="center",
104
+ va="center",
105
+ fontsize=11,
106
+ color="#333333",
107
+ )
108
+
109
+ # Date
110
+ date_str = datetime.now().strftime("%Y-%m-%d %H:%M")
111
+ fig.text(
112
+ 0.5,
113
+ 0.44,
114
+ f"Date: {date_str}",
115
+ ha="center",
116
+ va="center",
117
+ fontsize=11,
118
+ color="#333333",
119
+ )
120
+
121
+ # Contents summary
122
+ fig.text(
123
+ 0.5,
124
+ 0.34,
125
+ "Contents",
126
+ ha="center",
127
+ va="center",
128
+ fontsize=13,
129
+ fontweight="bold",
130
+ color="#111111",
131
+ )
132
+ summary_lines = [
133
+ f"Plots (matplotlib): {n_plots}",
134
+ f"Datasets: {n_datasets}",
135
+ f"Analysis results: {n_analyses}",
136
+ ]
137
+ fig.text(
138
+ 0.5,
139
+ 0.27,
140
+ "\n".join(summary_lines),
141
+ ha="center",
142
+ va="center",
143
+ fontsize=11,
144
+ color="#333333",
145
+ family="monospace",
146
+ )
147
+
148
+ # Footer
149
+ fig.text(
150
+ 0.5,
151
+ 0.08,
152
+ "NCCU Schultz Lab · EaglePlot",
153
+ ha="center",
154
+ va="center",
155
+ fontsize=9,
156
+ color="#888888",
157
+ )
158
+
159
+ return fig
160
+
161
+
162
+ def _dataframe_to_figure(
163
+ df: pd.DataFrame,
164
+ title: str = "",
165
+ max_rows: int = 30,
166
+ ) -> Figure:
167
+ """Render a DataFrame as a matplotlib table Figure.
168
+
169
+ Parameters
170
+ ----------
171
+ df:
172
+ The DataFrame to display.
173
+ title:
174
+ Title shown above the table.
175
+ max_rows:
176
+ Maximum number of data rows to render (additional rows are omitted
177
+ with a note).
178
+
179
+ Returns
180
+ -------
181
+ Figure
182
+ A matplotlib Figure (caller is responsible for closing it).
183
+ """
184
+ max_cols = 10
185
+
186
+ truncated_rows = len(df) > max_rows
187
+ truncated_cols = len(df.columns) > max_cols
188
+
189
+ display_df = df.iloc[:max_rows, :max_cols].copy()
190
+
191
+ # Convert all values to short strings for display
192
+ for col in display_df.columns:
193
+ display_df[col] = display_df[col].apply(
194
+ lambda v: f"{v:.4g}" if isinstance(v, (float, np.floating)) else str(v)[:20]
195
+ )
196
+
197
+ n_rows, n_cols = display_df.shape
198
+
199
+ # Figure height: scale with number of rows, bounded
200
+ row_height = 0.35
201
+ fig_height = max(3.0, min(14.0, 1.5 + n_rows * row_height))
202
+ fig = plt.figure(figsize=(11, fig_height), facecolor="white")
203
+ ax = fig.add_axes((0.02, 0.02, 0.96, 0.96))
204
+ ax.set_axis_off()
205
+
206
+ # Title
207
+ notes: list[str] = []
208
+ if truncated_rows:
209
+ notes.append(f"showing first {max_rows} of {len(df)} rows")
210
+ if truncated_cols:
211
+ notes.append(f"showing first {max_cols} of {len(df.columns)} columns")
212
+ note_str = f" ({', '.join(notes)})" if notes else ""
213
+ ax.set_title(
214
+ f"{title}{note_str}",
215
+ fontsize=12,
216
+ fontweight="bold",
217
+ pad=8,
218
+ )
219
+
220
+ if display_df.empty:
221
+ ax.text(
222
+ 0.5,
223
+ 0.5,
224
+ "(empty DataFrame)",
225
+ ha="center",
226
+ va="center",
227
+ fontsize=11,
228
+ color="#888888",
229
+ transform=ax.transAxes,
230
+ )
231
+ return fig
232
+
233
+ col_labels = list(display_df.columns)
234
+ cell_data = display_df.values.tolist()
235
+
236
+ table = ax.table(
237
+ cellText=cell_data,
238
+ colLabels=col_labels,
239
+ loc="center",
240
+ cellLoc="center",
241
+ )
242
+ table.auto_set_font_size(False)
243
+ table.set_fontsize(8)
244
+ table.auto_set_column_width(list(range(n_cols)))
245
+
246
+ # Style header row
247
+ for col_idx in range(n_cols):
248
+ cell = table[0, col_idx]
249
+ cell.set_facecolor("#2c5f8a")
250
+ cell.set_text_props(color="white", fontweight="bold")
251
+
252
+ # Alternate row shading
253
+ for row_idx in range(1, n_rows + 1):
254
+ bg = "#f0f4f8" if row_idx % 2 == 0 else "white"
255
+ for col_idx in range(n_cols):
256
+ table[row_idx, col_idx].set_facecolor(bg)
257
+
258
+ return fig
259
+
260
+
261
+ def _analysis_result_to_figure(result: dict) -> Figure:
262
+ """Render one analysis result dict as a text Figure.
263
+
264
+ Parameters
265
+ ----------
266
+ result:
267
+ A dict with keys ``type``, ``dataset``, ``timestamp``, and
268
+ ``results`` (the actual analysis output dict).
269
+
270
+ Returns
271
+ -------
272
+ Figure
273
+ A matplotlib Figure (caller is responsible for closing it).
274
+ """
275
+ fig = plt.figure(figsize=(8.5, 6), facecolor="white")
276
+ ax = fig.add_axes((0.05, 0.05, 0.90, 0.90))
277
+ ax.set_axis_off()
278
+
279
+ r_type = result.get("type", "analysis")
280
+ r_dataset = result.get("dataset", "—")
281
+ r_ts = result.get("timestamp", "")[:19].replace("T", " ")
282
+
283
+ heading = f"{r_type} · dataset: {r_dataset} · {r_ts}"
284
+ ax.set_title(heading, fontsize=11, fontweight="bold", pad=10)
285
+
286
+ ax.plot([0, 1], [0.96, 0.96], color="#cccccc", linewidth=0.8, transform=ax.transAxes)
287
+
288
+ raw_results = result.get("results", {})
289
+
290
+ def _fmt_value(v) -> str:
291
+ """Convert a value to a display string, skipping callables."""
292
+ if callable(v):
293
+ return "<function>"
294
+ if isinstance(v, np.ndarray):
295
+ if v.size <= 8:
296
+ return np.array2string(v, precision=5, suppress_small=True)
297
+ return f"array(shape={v.shape}, dtype={v.dtype})"
298
+ if isinstance(v, float):
299
+ return f"{v:.6g}"
300
+ if isinstance(v, dict):
301
+ return (
302
+ "{" + ", ".join(f"{k}: {_fmt_value(val)}" for k, val in list(v.items())[:6]) + "}"
303
+ )
304
+ if isinstance(v, (list, tuple)) and len(v) > 8:
305
+ return f"[{type(v).__name__} of length {len(v)}]"
306
+ return str(v)[:120]
307
+
308
+ if not raw_results:
309
+ ax.text(
310
+ 0.5,
311
+ 0.5,
312
+ "(no results data)",
313
+ ha="center",
314
+ va="center",
315
+ fontsize=10,
316
+ color="#888888",
317
+ transform=ax.transAxes,
318
+ )
319
+ return fig
320
+
321
+ lines: list[str] = []
322
+ for key, val in raw_results.items():
323
+ if callable(val):
324
+ continue
325
+ lines.append(f"{key}: {_fmt_value(val)}")
326
+
327
+ # Render as evenly-spaced text lines
328
+ n = len(lines)
329
+ y_start = 0.88
330
+ y_step = min(0.07, 0.80 / max(n, 1))
331
+ for i, line in enumerate(lines):
332
+ ax.text(
333
+ 0.02,
334
+ y_start - i * y_step,
335
+ line,
336
+ fontsize=9,
337
+ family="monospace",
338
+ va="top",
339
+ transform=ax.transAxes,
340
+ color="#222222",
341
+ )
342
+
343
+ return fig
344
+
345
+
346
+ # ── Public API ────────────────────────────────────────────────────────────────
347
+
348
+
349
+ def generate_pdf_report(
350
+ filepath: str | Path,
351
+ title: str,
352
+ plot_entries: list[dict],
353
+ datasets: dict[str, pd.DataFrame],
354
+ analysis_results: list[dict],
355
+ author: str = "",
356
+ dpi: int = 150,
357
+ ) -> Path:
358
+ """Write a PDF report and return the output Path.
359
+
360
+ Parameters
361
+ ----------
362
+ filepath:
363
+ Destination path for the PDF file.
364
+ title:
365
+ Report title string (shown on the title page).
366
+ plot_entries:
367
+ List of plot history dicts (from ``st.session_state.plot_history``).
368
+ Each dict may have keys ``type``, ``dataset``, ``timestamp``, and
369
+ ``figure`` (a matplotlib Figure, a Plotly Figure, or None).
370
+ Plotly figures and ``None`` entries are silently skipped.
371
+ datasets:
372
+ Mapping of dataset name → value. Only ``pd.DataFrame`` values are
373
+ included; other types are skipped.
374
+ analysis_results:
375
+ List of analysis result dicts (from ``st.session_state.analysis_results``).
376
+ Each dict should have keys ``type``, ``dataset``, ``timestamp``, and
377
+ ``results``.
378
+ author:
379
+ Optional author name for the title page and PDF metadata.
380
+ dpi:
381
+ Resolution used when rasterising matplotlib figures (default 150).
382
+
383
+ Returns
384
+ -------
385
+ Path
386
+ The path of the written PDF file.
387
+ """
388
+ try:
389
+ from matplotlib.figure import Figure as MplFigure
390
+
391
+ _mpl_figure_cls: type | None = MplFigure
392
+ except ImportError:
393
+ _mpl_figure_cls = None
394
+
395
+ # Count how many matplotlib plots will actually be written
396
+ mpl_plot_count = sum(
397
+ 1
398
+ for e in plot_entries
399
+ if _mpl_figure_cls is not None and isinstance(e.get("figure"), _mpl_figure_cls)
400
+ )
401
+ df_count = sum(1 for v in datasets.values() if isinstance(v, pd.DataFrame))
402
+ analysis_count = len(analysis_results)
403
+
404
+ filepath = Path(filepath)
405
+
406
+ with PdfPages(str(filepath)) as pdf:
407
+ # ── Title page ────────────────────────────────────────────────────
408
+ title_fig = _make_title_page(
409
+ title=title,
410
+ author=author,
411
+ n_plots=mpl_plot_count,
412
+ n_datasets=df_count,
413
+ n_analyses=analysis_count,
414
+ )
415
+ pdf.savefig(title_fig, dpi=dpi, bbox_inches="tight")
416
+ plt.close(title_fig)
417
+
418
+ # ── Plot pages ────────────────────────────────────────────────────
419
+ for entry in plot_entries:
420
+ fig = entry.get("figure")
421
+ if fig is None:
422
+ continue
423
+ if _mpl_figure_cls is not None and isinstance(fig, _mpl_figure_cls):
424
+ pdf.savefig(fig, dpi=dpi, bbox_inches="tight")
425
+ # Plotly figures are silently skipped
426
+
427
+ # ── Dataset pages ─────────────────────────────────────────────────
428
+ for name, data in datasets.items():
429
+ if not isinstance(data, pd.DataFrame):
430
+ continue
431
+ df_fig = _dataframe_to_figure(data, title=f"Dataset: {name}")
432
+ pdf.savefig(df_fig, dpi=dpi, bbox_inches="tight")
433
+ plt.close(df_fig)
434
+
435
+ # ── Analysis result pages ─────────────────────────────────────────
436
+ for result in analysis_results:
437
+ res_fig = _analysis_result_to_figure(result)
438
+ pdf.savefig(res_fig, dpi=dpi, bbox_inches="tight")
439
+ plt.close(res_fig)
440
+
441
+ # ── PDF metadata ──────────────────────────────────────────────────
442
+ info = pdf.infodict()
443
+ info["Title"] = title
444
+ info["Author"] = author
445
+ info["Subject"] = "EaglePlot session report"
446
+ info["Creator"] = "EaglePlot"
447
+
448
+ return filepath