Skip to content

report

PDF reporting.

toric_spines_sim.report

PDF report builder wrapping ReportLab (matplotlib and Plotly figures).

Use PdfReport to accumulate titles, tables, and figures, then build().

PdfReport(output_path, *, pagesize=letter, left_margin=0.75 * inch, right_margin=0.75 * inch, top_margin=0.75 * inch, bottom_margin=0.75 * inch)

Accumulate a letter-size PDF of text, tables, and figures.

Parameters:

Name Type Description Default
output_path str

Destination .pdf path.

required
pagesize Tuple[float, float]

ReportLab page setup (defaults: letter, 0.75 in margins).

letter
left_margin Tuple[float, float]

ReportLab page setup (defaults: letter, 0.75 in margins).

letter
right_margin Tuple[float, float]

ReportLab page setup (defaults: letter, 0.75 in margins).

letter
top_margin Tuple[float, float]

ReportLab page setup (defaults: letter, 0.75 in margins).

letter
bottom_margin Tuple[float, float]

ReportLab page setup (defaults: letter, 0.75 in margins).

letter

Examples:

>>> report = PdfReport("out.pdf")
>>> report.add_title("TS1")
>>> report.add_figure(fig)  # matplotlib or plotly
>>> report.build()
Source code in toric_spines_sim/report.py
def __init__(
    self,
    output_path: str,
    *,
    pagesize: Tuple[float, float] = letter,
    left_margin: float = 0.75 * inch,
    right_margin: float = 0.75 * inch,
    top_margin: float = 0.75 * inch,
    bottom_margin: float = 0.75 * inch,
):
    self._doc = SimpleDocTemplate(
        output_path,
        pagesize=pagesize,
        leftMargin=left_margin,
        rightMargin=right_margin,
        topMargin=top_margin,
        bottomMargin=bottom_margin,
    )
    self._story: List[Any] = []
    self._styles = getSampleStyleSheet()
    self._resources: List[io.BytesIO] = []
    self._left_margin = left_margin
    self._right_margin = right_margin
    self._top_margin = top_margin
    self._bottom_margin = bottom_margin

content_width property

Usable page width in points (ReportLab units).

content_width_in property

Usable page width in inches.

content_max_height property

Maximum embeddable figure height in points.

content_max_height_in property

Maximum embeddable figure height in inches.

figure_size(fig_width, fig_height)

Matplotlib figsize (inches) scaled to this report's content area.

Source code in toric_spines_sim/report.py
def figure_size(
    self,
    fig_width: float,
    fig_height: float,
) -> Tuple[float, float]:
    """Matplotlib figsize (inches) scaled to this report's content area."""
    return figure_size_for_page(
        fig_width,
        fig_height,
        width_in=self.content_width_in,
        max_height_in=self.content_max_height_in,
    )

add_title(text)

Append a title paragraph.

Source code in toric_spines_sim/report.py
def add_title(self, text: str):
    """Append a title paragraph."""
    self._story.append(Paragraph(text, self._styles["Title"]))
    self._story.append(Spacer(1, 0.2 * inch))

add_heading(text, *, level=1)

Append a heading (level 1–3).

Source code in toric_spines_sim/report.py
def add_heading(self, text: str, *, level: int = 1):
    """Append a heading (level 1–3)."""
    style_name = {1: "Heading1", 2: "Heading2", 3: "Heading3"}.get(
        level, "Heading3"
    )
    self._story.append(Paragraph(text, self._styles[style_name]))
    self._story.append(Spacer(1, 0.1 * inch))

add_paragraph(text)

Append a body paragraph.

Source code in toric_spines_sim/report.py
def add_paragraph(self, text: str):
    """Append a body paragraph."""
    self._story.append(Paragraph(text, self._styles["BodyText"]))
    self._story.append(Spacer(1, 0.1 * inch))

add_spacer(height=0.1 * inch)

Append vertical space.

Source code in toric_spines_sim/report.py
def add_spacer(self, height: float = 0.1 * inch):
    """Append vertical space."""
    self._story.append(Spacer(1, height))

add_page_break()

Start a new page.

Source code in toric_spines_sim/report.py
def add_page_break(self):
    """Start a new page."""
    self._story.append(PageBreak())

add_table(data, *, col_widths=None, row_heights=None, grid=True, header_row=False)

Append a grid table. First row is styled as a header if header_row.

Source code in toric_spines_sim/report.py
def add_table(
    self,
    data: Sequence[Sequence[Any]],
    *,
    col_widths: Optional[Sequence[float]] = None,
    row_heights: Optional[Sequence[float]] = None,
    grid: bool = True,
    header_row: bool = False,
):
    """Append a grid table. First row is styled as a header if ``header_row``."""
    table = Table(data, colWidths=col_widths, rowHeights=row_heights)
    style_cmds: List[Tuple[str, Tuple[int, int], Tuple[int, int], Any]] = []
    if grid:
        style_cmds.append(("GRID", (0, 0), (-1, -1), 0.5, colors.grey))
    style_cmds.extend(
        [
            ("ALIGN", (0, 0), (-1, -1), "LEFT"),
            ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ]
    )
    if header_row and len(data) > 0:
        style_cmds.extend(
            [
                ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
                ("TEXTCOLOR", (0, 0), (-1, 0), colors.black),
                ("LINEBELOW", (0, 0), (-1, 0), 1, colors.black),
            ]
        )
    table.setStyle(TableStyle(style_cmds))
    self._story.append(table)
    self._story.append(Spacer(1, 0.1 * inch))

add_dict_table(data, *, column_names=('Key', 'Value'), columns=None, grid=True, header=True)

Append a key/value table, optionally split across even column pairs.

Source code in toric_spines_sim/report.py
def add_dict_table(
    self,
    data: Mapping[str, Any],
    *,
    column_names: Sequence[str] = ("Key", "Value"),
    columns: Optional[int] = None,
    grid: bool = True,
    header: bool = True,
):
    """Append a key/value table, optionally split across even column pairs."""
    items = list(data.items())
    if not items:
        return
    if columns is None or columns < 2:
        columns = 2
    if columns % 2 != 0:
        raise ValueError(
            "columns must be an even integer (pairs of key/value columns)"
        )
    groups = max(1, columns // 2)
    chunk_size = int(math.ceil(len(items) / groups))
    chunks: List[List[Tuple[Any, Any]]] = [
        items[i * chunk_size : (i + 1) * chunk_size] for i in range(groups)
    ]
    ncols = columns
    rows: List[List[str]] = []
    if header:
        header_row: List[str] = []
        for _ in range(groups):
            header_row.extend([str(column_names[0]), str(column_names[1])])
        rows.append(header_row)
    max_rows = max((len(c) for c in chunks), default=0)
    for r in range(max_rows):
        row: List[str] = []
        for chunk in chunks:
            if r < len(chunk):
                k, v = chunk[r]
                row.extend([str(k), str(v)])
            else:
                row.extend(["", ""])
        rows.append(row)
    cw = None
    if ncols > 0:
        fw = getattr(self._doc, "width", 6.0 * inch)
        cw = [fw / ncols] * ncols
    self.add_table(rows, col_widths=cw, grid=grid, header_row=header)

add_image(image_source, *, width=None, height=None, figure_spacer=0.1 * inch)

Embed a raster image, scaled to content width by default.

Source code in toric_spines_sim/report.py
def add_image(
    self,
    image_source: Any,
    *,
    width: Optional[float] = None,
    height: Optional[float] = None,
    figure_spacer: float = 0.1 * inch,
):
    """Embed a raster image, scaled to content width by default."""
    if width is None and height is None:
        width = self.content_width
        height = None
    img = Image(image_source, width=width, height=height)
    try:
        img.hAlign = "CENTER"
    except Exception:
        pass
    self._story.append(img)
    self._story.append(Spacer(1, figure_spacer))

add_figure(fig, *, width=None, height=None, dpi=300, figure_spacer=None)

Embed a matplotlib or Plotly figure as PNG.

Source code in toric_spines_sim/report.py
def add_figure(
    self,
    fig: Any,
    *,
    width: Optional[float] = None,
    height: Optional[float] = None,
    dpi: int = 300,
    figure_spacer: Optional[float] = None,
):
    """Embed a matplotlib or Plotly figure as PNG."""
    if figure_spacer is None:
        figure_spacer = 0.05 * inch
    buf = io.BytesIO()
    handled = False
    if hasattr(fig, "savefig"):
        fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
        handled = True
    elif (
        (_PLOTLY_FIGURE_CLS is not None and isinstance(fig, _PLOTLY_FIGURE_CLS))
        or hasattr(fig, "to_image")
        or hasattr(fig, "write_image")
    ):
        try:
            fw_in = self.content_width_in
            export_px = int(fw_in * dpi)
            export_px = max(600, min(2400, export_px))
            lw = getattr(getattr(fig, "layout", None), "width", None)
            lh = getattr(getattr(fig, "layout", None), "height", None)
            if isinstance(lw, (int, float)) and isinstance(lh, (int, float)) and lw > 0 and lh > 0:
                aspect = lh / lw
                export_h = int(export_px * aspect)
                png_bytes = fig.to_image(format="png", width=export_px, height=export_h, scale=1)  # type: ignore[attr-defined]
            else:
                png_bytes = fig.to_image(format="png", width=export_px, scale=1)  # type: ignore[attr-defined]
        except Exception as e:
            raise RuntimeError(
                "Plotly figure export requires the 'kaleido' package (pip install -U kaleido)."
            ) from e
        buf.write(png_bytes)
        handled = True
    if not handled:
        raise TypeError("Unsupported figure type; expected Matplotlib or Plotly figure.")
    buf.seek(0)
    self._resources.append(buf)
    if width is None and height is None:
        try:
            nat_w, nat_h = ImageReader(buf).getSize()
            width, height = _scale_image_to_page(
                nat_w,
                nat_h,
                page_width=self.content_width,
                max_height=self.content_max_height,
            )
        except Exception:
            width = self.content_width
            height = None
    self.add_image(buf, width=width, height=height, figure_spacer=figure_spacer)

add_section_figure(fig, *, heading=None, level=3, paragraph=None, page_break_after=False, **figure_kwargs)

Add optional heading/paragraph followed by a figure.

Source code in toric_spines_sim/report.py
def add_section_figure(
    self,
    fig: Any,
    *,
    heading: Optional[str] = None,
    level: int = 3,
    paragraph: Optional[str] = None,
    page_break_after: bool = False,
    **figure_kwargs: Any,
) -> None:
    """Add optional heading/paragraph followed by a figure."""
    if heading:
        self.add_heading(heading, level=level)
    if paragraph:
        self.add_paragraph(paragraph)
    self.add_figure(fig, **figure_kwargs)
    if page_break_after:
        self.add_page_break()

add_simulation(name, *, parameters=None, metrics=None, figures=None, page_break_after=False)

Append a named simulation block: parameter table, metrics, figures.

Source code in toric_spines_sim/report.py
def add_simulation(
    self,
    name: str,
    *,
    parameters: Optional[Mapping[str, Any]] = None,
    metrics: Optional[Mapping[str, Any]] = None,
    figures: Optional[Sequence[Any]] = None,
    page_break_after: bool = False,
):
    """Append a named simulation block: parameter table, metrics, figures."""
    self.add_heading(name, level=2)
    rows: List[List[str]] = []
    if parameters:
        rows.append(["Parameter", "Value"])
        for k, v in parameters.items():
            rows.append([str(k), str(v)])
        self.add_table(rows, header_row=True)
    if metrics:
        mrows: List[List[str]] = [["Metric", "Value"]]
        for k, v in metrics.items():
            mrows.append([str(k), str(v)])
        self.add_table(mrows, header_row=True)
    if figures:
        for f in figures:
            self.add_figure(f, width=None, height=None)
    if page_break_after:
        self.add_page_break()

build()

Write the PDF to output_path.

Source code in toric_spines_sim/report.py
def build(self):
    """Write the PDF to ``output_path``."""
    self._doc.build(self._story)

figure_size_for_page(fig_width, fig_height, *, width_in, max_height_in)

Return (width, height) in inches that fit the page content area.

Source code in toric_spines_sim/report.py
def figure_size_for_page(
    fig_width: float,
    fig_height: float,
    *,
    width_in: float,
    max_height_in: float,
) -> Tuple[float, float]:
    """Return (width, height) in inches that fit the page content area."""
    if fig_width <= 0 or fig_height <= 0:
        raise ValueError("fig_width and fig_height must be positive")
    aspect = fig_height / fig_width
    w = width_in
    h = w * aspect
    if h > max_height_in:
        h = max_height_in
        w = h / aspect
    return (w, h)