diff --git a/.gitignore b/.gitignore index 3c2bfc7..6ed25b9 100644 --- a/.gitignore +++ b/.gitignore @@ -212,3 +212,7 @@ data/moor/raw/msm76_2018/sbe56/DSE18_SBE05606397_2018_08_27.cnv data/moor/raw/msm76_2018/sbe56/DSE18_SBE05606401_2018_08_27.cnv data/moor/raw/msm76_2018/sbe56/DSE18_SBE05606402_2018_08_27.cnv data/moor/raw/msm76_2018/sbe56/DSE18_SBE05606409_2018_08_27.cnv + +# Generated report output under test fixtures (HTML + multi-MB PDFs) — regenerated +# by `oceanarray report ... --pdf`; never commit into history. +tests/fixtures/proc/*/report/ diff --git a/docs/source/cli_reference.rst b/docs/source/cli_reference.rst index 02888c2..2473f0a 100644 --- a/docs/source/cli_reference.rst +++ b/docs/source/cli_reference.rst @@ -225,9 +225,9 @@ description of what each report page contains. oceanarray report MOORING [--raw-dir DIR] [--proc-dir DIR] [-o DIR] [--report-dir DIR] [--instruments] [--stack] [--grid] [--all] - [--serial SN ...] [--array] [--cruise-table] - [--sig-level SIG ...] [-n] [--force] - [--skip-existing] + [--pdf] [--serial SN ...] [--array] + [--cruise-table] [--sig-level SIG ...] + [-n] [--force] [--skip-existing] **Flags** @@ -273,7 +273,16 @@ description of what each report page contains. * - ``--all`` / ``-A`` - flag - off - - Generate all report pages. Equivalent to ``--stack --grid --instruments``. + - Generate all report pages and the combined PDF. Equivalent to + ``--stack --grid --instruments --pdf``. + * - ``--pdf`` + - flag + - off + - Combine the generated HTML report pages into a single A4 PDF + (``{mooring}_report.pdf``), in reading order + summary → instruments → stack → grid. Requires the optional ``pdf`` + extra (``pip install oceanarray[pdf]``, which installs WeasyPrint). + Implied by ``--all``. * - ``--serial SN`` - string (repeat) - (all) diff --git a/docs/source/project_structure.md b/docs/source/project_structure.md index 9430a79..0a0fa03 100644 --- a/docs/source/project_structure.md +++ b/docs/source/project_structure.md @@ -60,7 +60,8 @@ oceanarray/ │ │ ├── _array.py # Array-level multi-mooring summary report │ │ ├── _plots.py # Tier 3: report-level figure wrappers (base64 PNGs) │ │ ├── _html_helpers.py # HTML/QC constants, base64 helpers, NC metadata readers -│ │ └── _recovery_table.py # Per-mooring cruise-report recovery table +│ │ ├── _recovery_table.py # Per-mooring cruise-report recovery table +│ │ └── _pdf.py # combine_mooring_pdf: HTML pages → single A4 PDF (WeasyPrint) │ │ │ ├── tools/ # [core] Shared I/O infrastructure │ │ ├── readers.py # NetCDF and legacy-format instrument readers diff --git a/docs/source/reports.rst b/docs/source/reports.rst index 64ce6fc..00be7af 100644 --- a/docs/source/reports.rst +++ b/docs/source/reports.rst @@ -138,6 +138,34 @@ Generated when ``--grid`` is passed. Requires ``{mooring}_grid.nc`` (run ---- +PDF output +---------- + +The HTML reports can be combined into a single A4 PDF for printing or +archiving. The HTML pages remain the single source of truth — the PDF is a +post-processing step (via `WeasyPrint `_) that applies +a print stylesheet (A4 page size, page numbers, page-break avoidance, hidden +navigation buttons) without altering report generation. + +PDF output requires the optional ``pdf`` extra:: + + pip install oceanarray[pdf] + +Build the PDF alongside the HTML pages with ``--pdf`` (or ``--all``, which +implies it):: + + # Combine whatever HTML pages exist into MOORING_report.pdf + oceanarray report MOORING --raw-dir $RAW --proc-dir $PROC --pdf + + # Generate every page and the combined PDF in one go + oceanarray report MOORING --raw-dir $RAW --proc-dir $PROC --all + +Pages are concatenated in reading order — summary → per-instrument → stack → +grid — and only pages that exist on disk are included. The result is written +to ``{mooring}_report.pdf`` in the same directory as the HTML pages. + +---- + Report colour conventions -------------------------- diff --git a/oceanarray/cli.py b/oceanarray/cli.py index 72f2cb3..5eab7b6 100644 --- a/oceanarray/cli.py +++ b/oceanarray/cli.py @@ -336,11 +336,23 @@ def cmd_report(args: argparse.Namespace) -> int: print( f" Instrument {instr_type:12s} s/n {serial:8s} {p.name} ({'exists' if p.exists() else 'new'})" ) + if getattr(args, "pdf", False) or all_reports: + _html_dir = paths.resolve_report_dir( + args.mooring, getattr(args, "outdir", None), report_dir, proc_root + ) + pdf_path = _html_dir / f"{args.mooring}_report.pdf" + print(f"PDF: {pdf_path} (combined from the HTML pages above)") return 0 if getattr(args, "array", False): from .report._array import generate_array_report + if getattr(args, "pdf", False) or getattr(args, "all_reports", False): + _status( + "error", + "--pdf is not supported in --array mode; the array index is " + "HTML-only. Run 'report MOORING --pdf' per mooring instead.", + ) # Resolve the YAML path: try as-given first, then relative to proc_dir. _yaml_path = Path(args.mooring) if not _yaml_path.exists() and not _yaml_path.is_absolute(): @@ -388,6 +400,25 @@ def cmd_report(args: argparse.Namespace) -> int: out_path=out_dir / f"{args.mooring}_recovery_table.html", force=args.force, ) + if getattr(args, "pdf", False) or all_reports: + from .report import combine_mooring_pdf + + # Combine reads the same directory generate() wrote to; both resolve it + # through paths.resolve_report_dir so they can never drift. + html_dir = paths.resolve_report_dir( + args.mooring, getattr(args, "outdir", None), report_dir, proc_root + ) + try: + pdf_path = combine_mooring_pdf(html_dir, args.mooring) + _status("file", str(pdf_path)) + except (ImportError, FileNotFoundError) as exc: + # An explicit --pdf request that cannot be honoured is a failure. + # When the PDF was only implied by --all, treat it as best-effort: + # warn but keep the (successful) HTML result and do not fail, so + # `report --all` still works on machines without the pdf extra. + _status("error", str(exc)) + if getattr(args, "pdf", False): + return 1 return 0 if result else 1 finally: if _sigma_restore is not None: @@ -1215,7 +1246,16 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", default=False, dest="all_reports", - help="Generate all report pages: equivalent to --stack --grid --instruments", + help="Generate all report pages: equivalent to --stack --grid --instruments " + "--pdf (also builds the combined PDF)", + ) + p_report.add_argument( + "--pdf", + action="store_true", + default=False, + help="Combine the generated HTML report pages into a single A4 PDF " + "({mooring}_report.pdf). Requires the 'pdf' extra: pip install " + "oceanarray[pdf]. Implied by --all.", ) p_report.add_argument( "--array", diff --git a/oceanarray/paths.py b/oceanarray/paths.py index 1fa3a82..ecf6c20 100644 --- a/oceanarray/paths.py +++ b/oceanarray/paths.py @@ -9,7 +9,7 @@ import re import sys from pathlib import Path -from typing import Any, Union +from typing import Any, Optional, Union _PathLike = Union[str, Path] @@ -39,6 +39,44 @@ def mooring_proc_dir(proc_root: _PathLike, mooring: str) -> Path: return Path(proc_root) / mooring +def resolve_report_dir( + mooring: str, + outdir: Optional[_PathLike], + report_dir: Optional[_PathLike], + proc_root: _PathLike, +) -> Path: + """Return the directory a mooring's HTML report pages are written to. + + Single source of truth for report output-dir resolution, mirrored by both + :meth:`oceanarray.report.MooringReport.generate` and the PDF combiner so the + two never drift. Priority: explicit *outdir* wins; otherwise a central + *report_dir* nests each mooring under ``report_dir/``; otherwise the + default ``proc_root//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