/report``.
+
+ Parameters
+ ----------
+ mooring : str
+ Mooring name.
+ outdir : str or Path, optional
+ Explicit output directory (``--output-dir``); takes precedence when set.
+ report_dir : str or Path, optional
+ Central report root (``--report-dir``); each mooring nests below it.
+ proc_root : str or Path
+ Cruise-level processed-data root, used for the default location.
+
+ Returns
+ -------
+ Path
+ The resolved report directory.
+
+ """
+ if outdir:
+ return Path(outdir)
+ if report_dir:
+ return Path(report_dir) / mooring
+ return mooring_proc_dir(proc_root, mooring) / "report"
+
+
def raw_mooring_dir(raw_root: _PathLike, mooring: str) -> Path:
"""Return the mooring-level raw-data directory.
diff --git a/oceanarray/report/__init__.py b/oceanarray/report/__init__.py
index 99ea4ac..5f078fe 100644
--- a/oceanarray/report/__init__.py
+++ b/oceanarray/report/__init__.py
@@ -1,5 +1,6 @@
"""Mooring report package — public API."""
from ._mooring import MooringReport
+from ._pdf import combine_mooring_pdf
-__all__ = ["MooringReport"]
+__all__ = ["MooringReport", "combine_mooring_pdf"]
diff --git a/oceanarray/report/_mooring.py b/oceanarray/report/_mooring.py
index 16aff85..f6195c7 100644
--- a/oceanarray/report/_mooring.py
+++ b/oceanarray/report/_mooring.py
@@ -684,7 +684,7 @@
| {% if loop.index0 == 0 %}{{ instr.instr_type }}{% endif %} |
{% if loop.index0 == 0 %}{{ instr.serial }}{% endif %} |
{{ sensor.sensor_type | title }} |
- {{ sensor.sensor_model }} |
+ {{ sensor.sensor_model }} |
{{ sensor.sensor_serial }} |
{{ sensor.cal_date }} |
@@ -1067,12 +1067,9 @@ def generate(
print(f"ERROR: Processing directory not found: {proc_dir}")
return None
- if outdir:
- out_dir = Path(outdir)
- elif self._report_dir is not None:
- out_dir = self._report_dir / mooring_name
- else:
- out_dir = proc_dir / "report"
+ out_dir = paths.resolve_report_dir(
+ mooring_name, outdir, self._report_dir, self._proc_dir
+ )
out_dir.mkdir(parents=True, exist_ok=True)
output_path = out_dir / f"{mooring_name}_report.html"
yaml_path = proc_dir / f"{mooring_name}.mooring.yaml"
diff --git a/oceanarray/report/_pdf.py b/oceanarray/report/_pdf.py
new file mode 100644
index 0000000..2910eb3
--- /dev/null
+++ b/oceanarray/report/_pdf.py
@@ -0,0 +1,217 @@
+"""Combine a mooring's per-report HTML files into a single A4 PDF.
+
+The HTML reports written by :class:`~oceanarray.report._mooring.MooringReport`
+are the single source of truth. This module post-processes those files with
+WeasyPrint — it does not touch report generation or the Jinja templates. Print
+layout (A4 page size, margins, page numbers, page-break avoidance, hidden nav
+buttons) is injected as an extra stylesheet at render time.
+
+WeasyPrint is an optional dependency; install it with ``pip install
+oceanarray[pdf]``.
+"""
+
+from __future__ import annotations
+
+from glob import escape as _glob_escape
+from pathlib import Path
+from typing import List, Optional
+
+# Print-only stylesheet applied to every source report at render time.
+# Kept here (not in the Jinja templates) so the HTML output is unchanged and the
+# templates remain the single source of truth for on-screen look/feel.
+_PRINT_CSS = """
+@page {
+ size: A4;
+ margin: 1.6cm 1.4cm;
+ @bottom-center {
+ content: counter(page) " / " counter(pages);
+ font-size: 8pt;
+ color: #666;
+ }
+}
+/* Keep figures, tables and cards from splitting across a page break. */
+figure, table, .card, .metric-card, .instrument-card {
+ break-inside: avoid;
+}
+/* Cap figures at the printable content width as an overflow ceiling, but do NOT
+ use !important: the templates give many figures an inline per-figure cap
+ (e.g. ) via the `.fig` convention, and
+ an !important here would clobber those and blow every plot up to full width. */
+img {
+ max-width: 100%;
+ height: auto;
+ break-inside: avoid;
+}
+/* WeasyPrint cannot resolve ``repeat(auto-fill, minmax(...))`` and collapses
+ such grids to a single column (the summary header's .meta-grid balloons as a
+ result). Force an explicit column count in print instead. */
+.meta-grid {
+ grid-template-columns: repeat(3, 1fr) !important;
+}
+/* Screen tables set ``white-space: nowrap`` on headers, which runs wide tables
+ off the page edge; let them wrap when paginated. */
+th {
+ white-space: normal !important;
+}
+/* Print tables denser than screen so more fits per row on A4: smaller type and
+ tighter cell padding. (Screen: 0.83rem, ~0.4-0.45rem padding.) */
+table {
+ font-size: 0.7rem !important;
+}
+th, td {
+ padding: 0.25rem 0.4rem !important;
+}
+/* Section 2 "Processing pipeline": in the narrower PDF column the status pills
+ (.badge) wrap onto several lines. Shrink the pill text and padding and stop
+ the pipeline from wrapping so each instrument's status sits on one line. */
+.pipeline {
+ flex-wrap: nowrap !important;
+ gap: 0.1rem !important;
+}
+.badge {
+ font-size: 0.55rem !important;
+ padding: 0.08em 0.3em !important;
+}
+.arrow {
+ font-size: 0.6rem !important;
+ margin: 0 !important;
+}
+/* Section 3.5 copy-paste boxes are |