###############################################################################
## ##
## LIBRARIES ##
## ##
###############################################################################
# PDF document creation and structure
from reportlab.platypus import (
SimpleDocTemplate,
Paragraph,
Spacer,
PageBreak,
Image,
Flowable,
Table,
TableStyle,
HRFlowable,
)
from reportlab.platypus.tableofcontents import TableOfContents
# PDF styling and layout
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.colors import HexColor
from reportlab.lib import colors
from reportlab.platypus import Image as RLImage
# Image handling
from PIL import Image as PILImage
# Metadata and versioning
from importlib.metadata import version, PackageNotFoundError
# Date and time handling
from datetime import datetime
# Standard library utilities
import calendar
import re
from pathlib import Path
# Data handling
import pandas as pd
###############################################################################
## ##
## CLASSES ##
## ##
###############################################################################
[docs]
class MyDocTemplate(SimpleDocTemplate):
"""
Custom document template that extends ReportLab's SimpleDocTemplate.
This class overrides the afterFlowable method to detect headings in the
flowables added to the document. When a heading with an outlineLevel attribute
is encountered, it registers the heading with the Table of Contents (TOC)
system by notifying the TOCEntry event. This enables automatic building
of the Table of Contents with correct page numbers.
Attributes:
_headings (list): Internal list to keep track of headings added (optional use).
"""
def __init__(self, filename, **kwargs):
"""
Initialize the custom document template.
Args:
filename (str): The path to the output PDF file.
**kwargs: Additional keyword arguments passed to SimpleDocTemplate.
"""
super().__init__(filename, **kwargs)
# Initialize an internal list for headings if needed for tracking or future use
self._headings = []
[docs]
def afterFlowable(self, flowable):
"""
Hook method called by ReportLab after each flowable is processed.
Checks if the flowable has an 'outlineLevel' attribute. If so,
it considers the flowable as a heading and notifies the TOC system
with the heading level, text content, and current page number.
This notification allows the Table of Contents to be built dynamically
during PDF generation.
Args:
flowable (Flowable): The flowable object just added to the document.
"""
# Only process flowables that have an outlineLevel attribute, meaning they are headings
if hasattr(flowable, 'outlineLevel') and flowable.outlineLevel is not None:
level = flowable.outlineLevel # Extract the heading level (e.g., 0 for main, 1 for subheading)
text = flowable.getPlainText() # Get plain text of the heading for TOC display
page_num = self.page # Current page number in the document for linking in TOC
# Notify the ReportLab TOC system that a new heading was added
# This allows the TOC to register the heading text, level, and page for automatic entries
self.notify('TOCEntry', (level, text, page_num))
###############################################################################
###############################################################################
[docs]
class PDFReportBuilder:
"""
A builder class for creating PDF reports using ReportLab.
This class manages the document structure, styles, and content
to generate professional-looking PDF reports with title pages,
table of contents, and formatted sections.
Attributes:
pdf_path (str): Path where the PDF will be saved.
styles (StyleSheet1): ReportLab stylesheet object for styling text.
story (list): List of flowables representing the PDF content.
toc (TableOfContents): Table of contents flowable.
"""
def __init__(self, pdf_path):
"""
Initialize the PDFReportBuilder with the target output path.
Sets up default and custom styles, configures table of contents styles,
and prepares the empty story list for content.
Args:
pdf_path (str): Path for saving the generated PDF file.
"""
self.pdf_path = pdf_path
self.styles = getSampleStyleSheet()
self.story = []
# Center headings to improve title and section readability on the page
self.styles['Heading1'].alignment = TA_CENTER
self.styles['Heading2'].alignment = TA_CENTER
# Add custom paragraph styles tailored for titles and section headers
self.styles.add(ParagraphStyle(name='CenteredTitle', parent=self.styles['Title'], alignment=TA_CENTER))
self.styles.add(ParagraphStyle(name='SectionTitle', fontSize=16, leading=20, spaceAfter=12))
# Initialize a Table of Contents object with hierarchical styling for two heading levels
# Styles define indentation and font to visually differentiate TOC levels
self.toc = TableOfContents()
self.toc.levelStyles = [
ParagraphStyle(fontName='Times-Bold', fontSize=14, name='TOCHeading1', leftIndent=20, firstLineIndent=-20, spaceBefore=5, leading=16),
ParagraphStyle(fontSize=12, name='TOCHeading2', leftIndent=40, firstLineIndent=-20, spaceBefore=0, leading=12),
]
[docs]
def build_title_page(
self,
title="Model Evaluation Report",
subtitle="Comprehensive model performance evaluation",
author="Alessandro Gozzoli",
organization="Alma Mater Studiorum - Università di Bologna",
email="alessandro.gozzoli4@studio.unibo.it",
cellphone="",
logo_path=None,
package_name="hydrological_model_validator"
):
"""
Build and append the title page content to the story.
This method resets the current story content and adds styled
paragraphs including the title, subtitle, date, author info, version,
and an optional logo image. Visual separators and spacing are also added
for professional layout.
Args:
title (str): Main report title text.
subtitle (str): Subtitle or descriptive text.
author (str): Name of the report author.
organization (str): Affiliated organization or institution.
email (str): Contact email address.
cellphone (str): Contact phone number (optional).
logo_path (str or None): Path to an image file for logo inclusion (optional).
package_name (str): Package name for version info lookup.
Side Effects:
Clears and appends multiple flowables (paragraphs, images, spacers, lines)
to the internal story list representing the PDF content.
"""
# Reset content to ensure title page is generated fresh without leftover elements
self.story = []
# Letter size dimensions used for centering and positioning elements on the page
PAGE_WIDTH, PAGE_HEIGHT = letter
# Define consistent colors to unify visual style and emphasize sections accordingly
primary_color = HexColor("#2E5A99") # Used for main titles to draw attention
line_color = HexColor("#CCCCCC") # Subtle lines for separation without distraction
subtitle_color = HexColor("#555555") # Medium gray for subtitles and meta info to avoid harsh contrast
# Attempt to fetch package version to include in report footer, fallback if not installed
try:
pkg_version = version(package_name)
except PackageNotFoundError:
pkg_version = "Unknown"
# Helper function for vertical spacing to keep layout adjustments straightforward
def vspace(height):
self.story.append(Spacer(1, height))
# Define a Flowable subclass to draw horizontal lines consistently across the document
# Lines visually separate sections, enhancing structure and readability
class HorizontalLine(Flowable):
def __init__(self, width, thickness=1, color=line_color):
super().__init__()
self.width = width
self.thickness = thickness
self.color = color
self.height = thickness
def draw(self):
self.canv.setStrokeColor(self.color)
self.canv.setLineWidth(self.thickness)
self.canv.line(0, self.height / 2, self.width, self.height / 2)
# Conditionally add a logo image, scaled and centered to enhance brand identity without disrupting layout
if logo_path:
try:
img = Image(str(logo_path))
img.drawHeight = 1.0 * inch
img.drawWidth = 1.0 * inch
self.story.append(Spacer(1, 0.5 * inch)) # Add space above logo for balance
self.story.append(img)
vspace(0.2 * inch) # Small gap after logo for visual separation
except Exception as e:
# Failure to load logo does not prevent report generation; warn user instead
print(f"Warning: Could not load logo at {logo_path}: {e}")
# Insert a horizontal line near the top to anchor the title visually
self.story.append(HorizontalLine(PAGE_WIDTH - 2*inch, thickness=1.5))
vspace(0.3 * inch)
# Define a bold, large font style for the main title to establish hierarchy and attract attention
title_style = ParagraphStyle(
name="TitleStyle",
parent=self.styles['Title'],
alignment=TA_CENTER,
fontSize=36,
leading=42,
textColor=primary_color,
spaceAfter=12,
spaceBefore=12,
fontName="Helvetica-Bold"
)
self.story.append(Paragraph(title, title_style))
# Subtitle in italic and smaller font provides context without overshadowing title
subtitle_style = ParagraphStyle(
name="SubtitleStyle",
parent=self.styles['Normal'],
alignment=TA_CENTER,
fontSize=16,
leading=20,
textColor=subtitle_color,
italic=True,
spaceAfter=6
)
self.story.append(Paragraph(subtitle, subtitle_style))
# Display current date to timestamp report generation
date_str = datetime.now().strftime("%B %d, %Y")
date_style = ParagraphStyle(
name="DateStyle",
parent=self.styles['Normal'],
alignment=TA_CENTER,
fontSize=12,
leading=14,
textColor=subtitle_color,
spaceAfter=6
)
self.story.append(Paragraph(f"Date: {date_str}", date_style))
# Version info shows the tool's version generating the report, providing traceability
version_style = ParagraphStyle(
name="VersionStyle",
parent=self.styles['Normal'],
alignment=TA_CENTER,
fontSize=12,
leading=14,
textColor=subtitle_color,
spaceAfter=18
)
self.story.append(Paragraph(f"Report generated by Hydrological Model Validator v{pkg_version}", version_style))
# Another horizontal line to visually separate the header section from the footer info block
vspace(0.4 * inch)
self.story.append(HorizontalLine(PAGE_WIDTH - 2*inch, thickness=1.5))
# Push author/contact information to the bottom of the page by adding flexible vertical space
self.story.append(Spacer(1, PAGE_HEIGHT - 6*inch))
# Footer style for contact info is smaller and subtle to keep focus on main content
info_style = ParagraphStyle(
name="InfoStyle",
parent=self.styles['Normal'],
alignment=TA_CENTER,
fontSize=10,
leading=12,
textColor=subtitle_color,
spaceAfter=4
)
# Add author, organization, and contact details to provide proper attribution and contact info
self.story.append(Paragraph(f"<b>Author:</b> {author}", info_style))
self.story.append(Paragraph(f"<b>Organization:</b> {organization}", info_style))
self.story.append(Spacer(1, 12)) # Adds visual gap before contacts
self.story.append(Paragraph("<b>Contacts:</b>", info_style))
self.story.append(Paragraph(email, info_style))
self.story.append(Paragraph(cellphone, info_style))
# Finalize the title page by forcing a page break, isolating it from following content
self.story.append(PageBreak())
[docs]
def build_toc(self):
"""
Build and append a styled Table of Contents page to the story.
This method adds a title with custom styling, a horizontal line,
and the TableOfContents flowable configured in the constructor.
A page break is appended at the end to separate the TOC from following content.
Side Effects:
Appends flowables to the internal story list representing the PDF content.
"""
PAGE_WIDTH, _ = letter
primary_color = HexColor("#2E5A99")
line_color = HexColor("#CCCCCC")
# Create a visually distinct title for the Table of Contents page for clarity and structure
toc_title_style = ParagraphStyle(
name="TOCTitleStyle",
parent=self.styles['Heading1'],
alignment=TA_CENTER,
fontSize=28,
leading=32,
textColor=primary_color,
spaceAfter=12,
spaceBefore=24,
fontName="Helvetica-Bold"
)
self.story.append(Paragraph("Table of Contents", toc_title_style))
# Horizontal line below TOC title visually separates header from content
class HorizontalLine(Flowable):
def __init__(self, width, thickness=1.5, color=line_color):
super().__init__()
self.width = width
self.thickness = thickness
self.color = color
self.height = thickness
def draw(self):
self.canv.setStrokeColor(self.color)
self.canv.setLineWidth(self.thickness)
self.canv.line(0, self.height / 2, self.width, self.height / 2)
self.story.append(HorizontalLine(PAGE_WIDTH - 2 * inch))
self.story.append(Spacer(1, 20))
# Insert the TOC flowable object which dynamically generates entries from added headings
self.story.append(self.toc)
# End TOC page with a break to start new content cleanly on the following page
self.story.append(PageBreak())
[docs]
def add_heading(self, text, level=0):
"""
Add a heading paragraph to the story with the specified level.
This method creates a paragraph with either Heading1 or Heading2 style,
assigns a unique bookmark name, and sets outlineLevel and bookmark attributes
to enable navigation and inclusion in the Table of Contents.
Args:
text (str): The heading text.
level (int): Heading level (0 for main heading, 1 for subheading).
"""
# Select heading style based on level; level 0 is major, level 1 is subheading
style_name = 'Heading1' if level == 0 else 'Heading2'
style = self.styles[style_name]
# Unique bookmark name ensures that each heading can be individually referenced in the document outline and TOC
bookmark_name = f"heading_{len(self.story)}"
# Create a paragraph for the heading, attaching metadata for outline level and bookmark to integrate with TOC
para = Paragraph(text, style)
setattr(para, "outlineLevel", level)
setattr(para, "bookmark", bookmark_name)
# Add the heading paragraph to the story to be rendered in the PDF
self.story.append(para)
[docs]
def save(self):
doc = MyDocTemplate(self.pdf_path, pagesize=letter)
doc.multiBuild(self.story)
###############################################################################
###############################################################################
[docs]
class PositionedTable(Flowable):
"""
A wrapper Flowable that allows precise positioning of another Flowable
(e.g., a Table) by applying horizontal and vertical offsets when drawn.
This is useful when you need to control the exact position of a table
or any flowable element on the canvas, beyond the normal flow layout.
Attributes:
flowable (Flowable): The flowable object to be positioned.
x_offset (float): Horizontal shift from the current origin in points.
y_offset (float): Vertical shift from the current origin in points.
"""
def __init__(self, flowable, x_offset=0, y_offset=0):
"""
Initialize the PositionedTable with the flowable to position and offsets.
Args:
flowable (Flowable): The flowable element to wrap and position.
x_offset (float, optional): Horizontal offset in points (default 0).
y_offset (float, optional): Vertical offset in points (default 0).
"""
super().__init__()
self.flowable = flowable
self.x_offset = x_offset
self.y_offset = y_offset
[docs]
def wrap(self, availWidth, availHeight):
"""
Calculate the space required by the wrapped flowable.
Delegates the wrap calculation to the wrapped flowable, since the
positioning offsets do not affect the required size.
Args:
availWidth (float): Available width for the flowable.
availHeight (float): Available height for the flowable.
Returns:
(width, height): The size needed by the wrapped flowable.
"""
# Use the wrapped flowable's own wrap method to get its required size
return self.flowable.wrap(availWidth, availHeight)
[docs]
def draw(self):
"""
Draw the wrapped flowable at the specified offsets.
The canvas state is saved and restored to avoid affecting other drawings.
The canvas origin is translated by the given offsets, then the flowable
is drawn at the translated origin (0, 0), effectively positioning it
precisely relative to the original drawing point.
"""
self.canv.saveState() # Save the current canvas state (position, styles)
# Move the origin by x_offset horizontally and y_offset vertically
self.canv.translate(self.x_offset, self.y_offset)
# Draw the wrapped flowable at the new origin (0,0) after translation
self.flowable.drawOn(self.canv, 0, 0)
self.canv.restoreState() # Restore the canvas state to avoid side effects
###############################################################################
###############################################################################
[docs]
class RotatedImage(Flowable):
"""
A Flowable that loads an image from disk and draws it rotated by 90 degrees
within a fixed frame size, scaling the image to fit while preserving aspect ratio.
The image is rotated clockwise by 90 degrees and positioned inside a rectangular
frame defined by frame_width and frame_height, with margins and optional vertical offset.
Attributes:
img_path (str): Path to the image file.
frame_width (float): Width of the frame where the image is drawn (in points).
frame_height (float): Height of the frame where the image is drawn (in points).
margin (float): Margin around the image inside the frame (in points).
header_height (float): Additional space reserved for a header above the image (in points).
custom_offset_y (float or None): Optional custom vertical offset for positioning.
orig_width (int): Original width of the image in pixels.
orig_height (int): Original height of the image in pixels.
draw_width (float): Width of the scaled image for drawing (in points).
draw_height (float): Height of the scaled image for drawing (in points).
"""
def __init__(self, img_path, frame_width=456.0, frame_height=636.0,
margin=0.1 * inch, header_height=0.25 * inch, custom_offset_y=None):
"""
Initialize the RotatedImage with path, frame size, margins, and optional vertical offset.
Args:
img_path (str): Path to the image file to load and draw.
frame_width (float, optional): Width of the frame box (default 456 points).
frame_height (float, optional): Height of the frame box (default 636 points).
margin (float, optional): Margin around the image inside the frame (default 0.1 inch).
header_height (float, optional): Reserved space for header above the image (default 0.25 inch).
custom_offset_y (float or None, optional): Override vertical positioning if provided.
"""
super().__init__()
self.img_path = img_path
self.margin = margin
self.header_height = header_height
self.frame_width = frame_width
self.frame_height = frame_height
self.custom_offset_y = custom_offset_y
# Open the image to get its original size (width, height) in pixels
with PILImage.open(img_path) as img:
self.orig_width, self.orig_height = img.size
# Calculate the max dimensions available for the rotated image inside the frame
# Note: The image will be rotated 90°, so width and height swap roles for scaling
max_rotated_width = frame_width - 2 * margin
max_rotated_height = frame_height - 2 * margin - header_height
# Calculate scale factors based on the rotated image's dimensions:
# Since the image is rotated, the original height corresponds to the width after rotation, and vice versa
scale_w = max_rotated_width / self.orig_height # width available / image height
scale_h = max_rotated_height / self.orig_width # height available / image width
# Choose the smaller scale to fit the image entirely within the frame without distortion
scale = min(scale_w, scale_h, 1.0) # Don't upscale if image is smaller than frame
# Calculate the final drawn dimensions after scaling, keeping original orientation (unrotated)
self.draw_height = self.orig_height * scale # height before rotation
self.draw_width = self.orig_width * scale # width before rotation
[docs]
def wrap(self, availWidth, availHeight):
"""
Calculate the space required by the rotated image with margins and header.
The required width is the scaled image width plus horizontal margins.
The required height is the scaled image height plus vertical margins and header space.
Args:
availWidth (float): Available width for layout (ignored here).
availHeight (float): Available height for layout (ignored here).
Returns:
tuple: (required width, required height) for layout.
"""
wrapped_width = self.draw_width + 2 * self.margin
wrapped_height = self.draw_height + 2 * self.margin + self.header_height
return wrapped_width, wrapped_height
[docs]
def draw(self):
"""
Draw the rotated image on the canvas.
The image is rotated by 90 degrees clockwise and translated so that it fits
centered within the frame, considering margins and optional vertical offset.
The canvas state is saved and restored to prevent side effects on subsequent drawings.
"""
self.canv.saveState() # Save the current canvas state before transformations
# Calculate horizontal offset to center the rotated image inside the frame width
# After rotation, image width is 'draw_height' because of 90° rotation
offset_x = (self.frame_width - self.draw_height) / 2
# Determine vertical offset:
# Use the provided custom offset if available;
# otherwise, compute a default offset that accounts for header height and image width after rotation
if self.custom_offset_y is not None:
offset_y = self.custom_offset_y
else:
# Complex offset calculation likely tuned for specific layout needs
offset_y = (self.frame_height + (self.header_height * 7) - self.draw_width) * 2
# Move the origin to the calculated offset position before rotation
self.canv.translate(offset_x, -offset_y)
# Rotate the canvas coordinate system by 90 degrees clockwise
self.canv.rotate(90)
# After rotation, translate upwards by the image height to align drawing origin
self.canv.translate(0, -self.draw_height)
# Create a ReportLab Image flowable for the image file
img = RLImage(self.img_path)
# Set the image size to the scaled dimensions
img.drawHeight = self.draw_height
img.drawWidth = self.draw_width
img.wrapOn(self.canv, self.draw_width, self.draw_height)
# Draw the image at the transformed origin (0, 0)
img.drawOn(self.canv, 0, 0)
self.canv.restoreState() # Restore canvas to original state after drawing
###############################################################################
## ##
## FUNCTIONS ##
## ##
###############################################################################
[docs]
def add_plot_to_pdf(pdf, img_path, section_title, width=6*inch):
"""
Add a titled image section to a PDF document with proper spacing and page break.
This function inserts a section heading followed by an image into a PDF report. It
adjusts the image size to a specified width while maintaining its aspect ratio.
Spacers are added before and after the image to improve layout, and a page break
is appended after the image to separate sections cleanly.
Parameters
----------
pdf : ReportLab PDF document object
The PDF document to which the image and heading are added. Must support
methods like add_heading and have a story attribute (a list of flowables).
img_path : str or pathlib.Path
Path to the image file to insert into the PDF.
section_title : str
Title text to be added as a heading before the image.
width : float, optional
Desired width of the image in points (default is 6 inches).
Returns
-------
None
The function modifies the PDF object in-place by adding flowables to its story.
Example
-------
>>> add_plot_to_pdf(pdf, "plots/plot1.png", "Section 1: Overview")
"""
# Add section heading at level 0 (top level heading)
pdf.add_heading(section_title, level=0)
flowables = []
# Add vertical space before the image
flowables.append(Spacer(1, 0.2 * inch))
# Load image using ReportLab's Image flowable
img = Image(str(img_path))
# Get original image dimensions (width, height) from PIL
orig_width, orig_height = PILImage.open(img_path).size
# Calculate aspect ratio to preserve image proportions
aspect_ratio = orig_height / orig_width
# Set image width to desired width, adjust height to maintain aspect ratio
img.drawWidth = width
img.drawHeight = width * aspect_ratio
# Append the image to flowables
flowables.append(img)
# Add more vertical space after the image
flowables.append(Spacer(1, 0.5 * inch))
# Extend the PDF story with the flowables (heading, spacer, image, spacer)
pdf.story.extend(flowables)
# Add a page break after the image section to separate from next content
pdf.story.append(PageBreak())
###############################################################################
###############################################################################
[docs]
def add_rotated_image_page(pdf, img_path, section_title=None, custom_offset_y=None):
"""
Add a page with a rotated image to the PDF document, optionally with a section heading.
This function inserts an optional section heading followed by a rotated image that fits
a predefined frame size. The image is rotated 90 degrees clockwise and positioned with
optional vertical offset adjustment. A page break is added after the image to separate
it from subsequent content.
Parameters
----------
pdf : ReportLab PDF document object
The PDF document to which the rotated image and optional heading are added. Must
support add_heading and have a story attribute (list of flowables).
img_path : str or pathlib.Path
Path to the image file to be added, which will be rotated and scaled to fit.
section_title : str, optional
Title text to add as a heading before the image. If None, no heading is added.
custom_offset_y : float, optional
Custom vertical offset for positioning the rotated image. Overrides default positioning.
Returns
-------
None
Modifies the PDF object in-place by appending flowables to its story.
Example
-------
>>> add_rotated_image_page(pdf, "figures/diagram.png", "Rotated Diagram")
"""
from reportlab.platypus import Spacer, PageBreak
from reportlab.lib.units import inch
# Add heading if provided, with some vertical spacing after
if section_title:
pdf.add_heading(section_title, level=0)
pdf.story.append(Spacer(1, 0.3 * inch))
# Create a RotatedImage flowable instance with optional vertical offset
rotated_img = RotatedImage(str(img_path), custom_offset_y=custom_offset_y)
# Append the rotated image to the PDF story
pdf.story.append(rotated_img)
# Add a page break to separate this page from the next content
pdf.story.append(PageBreak())
###############################################################################
###############################################################################
[docs]
def add_tables_page(
pdf,
tables_dict,
section_title="Summary Tables",
columns=2,
rows=None,
cell_padding=6,
spacing=0.2 * inch,
max_table_width=3.0 * inch,
):
"""
Add a page with multiple tables arranged in a grid layout to the PDF document.
This function creates a grid of summary tables, each with a title and metric-value pairs.
The tables are arranged in a specified number of columns per row, with styling applied
for readability. If a section title is provided, it is added at the top. The function
handles incomplete rows by padding with empty spaces. A horizontal separator line is
added after the first row if multiple rows exist. Finally, a page break is appended.
Parameters
----------
pdf : PDF document object
PDF builder object which must have `.story` (a list of flowables) and `.add_heading` method.
tables_dict : dict of str to dict of str to float
Dictionary mapping each table title (str) to a dictionary of metric names and values.
section_title : str, optional
Title for the entire page section containing the tables. Default is "Summary Tables".
columns : int, optional
Number of tables to display per row. Default is 2.
rows : int or None, optional
Optional maximum number of rows to display. If None, all tables are shown.
cell_padding : int, optional
Padding inside each table cell in points. Default is 6.
spacing : float, optional
Vertical spacing between elements, in points. Default is 0.2 inch.
max_table_width : float, optional
Maximum width for each individual table in points. Default is 3.0 inch.
Returns
-------
None
The function modifies the `pdf` object in place by appending flowables.
Example
-------
>>> tables = {
... "Correlation Metrics": {"NSE": 0.85, "RMSE": 1.23},
... "Efficiency Metrics": {"KGE": 0.90, "R2": 0.88},
... }
>>> add_tables_page(pdf, tables, columns=2)
"""
from reportlab.platypus import (
Table as RLTable, TableStyle, Spacer, Paragraph
)
# Get base styles for text
styles = getSampleStyleSheet()
normal_style = styles["Normal"]
# Define header style for table column headers (bold, centered)
header_style = ParagraphStyle(
name="TableHeader",
parent=styles["Normal"],
alignment=1, # center alignment
fontSize=10,
spaceAfter=2,
leading=12,
fontName="Helvetica-Bold"
)
# Define style for table titles (larger, bold, centered)
title_style = ParagraphStyle(
name="TableTitle",
parent=styles["Normal"],
alignment=1, # center
fontSize=11,
leading=13,
fontName="Helvetica-Bold"
)
# Add section title heading and spacing if provided
if section_title:
pdf.add_heading(section_title, level=0)
pdf.story.append(Spacer(1, spacing))
# Build flowables for each table: title, spacer, table, spacer
table_blocks = []
for table_title, table_data in tables_dict.items():
# Create a paragraph for the table title
title_para = Paragraph(f"<b>{table_title}</b>", title_style)
# Prepare the table data with column headers
data = [
[Paragraph("Metric", header_style), Paragraph("Correlation", header_style)]
]
# Append each metric-value row with normal style
for key, val in table_data.items():
data.append([
Paragraph(str(key), normal_style),
Paragraph(f"{val:.4f}", normal_style)
])
# Create ReportLab table with fixed column widths proportional to max_table_width
tbl = RLTable(data, colWidths=[max_table_width * 0.6, max_table_width * 0.4])
# Apply table styling: grid, background color for header, alignment, and padding
tbl.setStyle(TableStyle([
("GRID", (0, 0), (-1, -1), 0.5, colors.black),
("BACKGROUND", (0, 0), (-1, 0), colors.lightsteelblue),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), cell_padding),
("RIGHTPADDING", (0, 0), (-1, -1), cell_padding),
("TOPPADDING", (0, 0), (-1, -1), cell_padding),
("BOTTOMPADDING", (0, 0), (-1, -1), cell_padding),
]))
# Append the title, small spacer, table, and larger spacer as a block
table_blocks.append([title_para, Spacer(1, 0.1 * inch), tbl, Spacer(1, 0.25 * inch)])
# Split tables into rows of 'columns' number of tables
chunks = [table_blocks[i:i + columns] for i in range(0, len(table_blocks), columns)]
for row_idx, row in enumerate(chunks):
# Pad the last row with empty spacers if it has fewer than 'columns' tables
while len(row) < columns:
row.append([Spacer(1, max_table_width)] * 4) # fill all 4 layers with spacer
# Transpose the blocks so we can add all titles in one table row, then all spacers, etc.
for layer in zip(*row): # Each layer corresponds to title, spacer, table, spacer
pdf.story.append(RLTable([list(layer)], colWidths=[max_table_width] * columns, hAlign="CENTER"))
pdf.story.append(Spacer(1, 0.05 * inch))
# Add horizontal separator line after the first row if multiple rows exist
if row_idx == 0 and len(chunks) > 1:
pdf.story.append(Spacer(1, 0.2 * inch))
pdf.story.append(HRFlowable(width="100%", color=colors.grey, thickness=1))
pdf.story.append(Spacer(1, 0.3 * inch))
# Add a page break at the end to separate from next page content
pdf.story.append(PageBreak())
###############################################################################
###############################################################################
[docs]
def add_multiple_images_grid(pdf, img_paths, section_title, columns=2, rows=None, max_width=3*inch, spacing=0.2*inch):
"""
Add multiple images to the PDF in a neat grid layout with optional limits on rows and columns.
Parameters
----------
pdf : PDF builder object
The PDF document builder that supports `.story` (list of flowables) and `.add_heading`.
img_paths : list of str or pathlib.Path
List of file paths to images to be added.
section_title : str
Title text displayed above the image grid.
columns : int, optional
Number of columns in the grid (default is 2).
rows : int or None, optional
Maximum number of rows to include; if None, include all images.
max_width : float, optional
Maximum width of each image in points (default 3 inches).
spacing : float, optional
Vertical spacing above and below the image grid (default 0.2 inch).
Returns
-------
None
Modifies the `pdf` object in place by appending image grid and spacing flowables.
"""
# Add the section heading and top spacing
pdf.add_heading(section_title, level=0)
pdf.story.append(Spacer(1, spacing))
# Load images, resize while maintaining aspect ratio, and collect as flowables
images = []
for img_path in img_paths:
img = Image(str(img_path))
orig_width, orig_height = PILImage.open(img_path).size
aspect_ratio = orig_height / orig_width
img.drawWidth = max_width
img.drawHeight = max_width * aspect_ratio
images.append(img)
# If row limit specified, truncate images accordingly
if rows is not None:
images = images[:rows * columns]
# Pad images list so last row is complete with invisible Spacers
while len(images) % columns != 0:
images.append(Spacer(1, 0.1 * inch))
# Break images into rows for the table layout
table_data = [images[i:i + columns] for i in range(0, len(images), columns)]
# Create a ReportLab Table to hold the image grid
table = Table(table_data, hAlign='CENTER')
# Apply padding and center alignment to the table cells
table.setStyle(TableStyle([
('LEFTPADDING', (0, 0), (-1, -1), 2),
('RIGHTPADDING', (0, 0), (-1, -1), 2),
('TOPPADDING', (0, 0), (-1, -1), 2),
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
]))
# Append the image grid table, bottom spacing, and a page break
pdf.story.append(table)
pdf.story.append(Spacer(1, spacing))
pdf.story.append(PageBreak())
###############################################################################
###############################################################################
[docs]
def add_multiple_rotated_images_grid(
pdf,
img_paths,
cols=2,
section_title=None,
margin=0.25 * inch,
frame_width=456.0,
frame_height=636.0,
header_height=0.5 * inch,
):
"""
Adds a grid of rotated images to the PDF, arranging them in specified columns,
centered within a defined frame size, with optional section title.
Parameters
----------
pdf : PDF builder object
The PDF document builder supporting `.story` and `.add_heading`.
img_paths : list of str or pathlib.Path
Paths to images that will be rotated and added.
cols : int, optional
Number of columns in the grid (default 2).
section_title : str or None, optional
Optional heading displayed above the image grid.
margin : float, optional
Margin (in points) around each rotated image (default 0.25 inch).
frame_width : float, optional
Width of the frame container for the grid (default 456 points).
frame_height : float, optional
Height of the frame container for the grid (default 636 points).
header_height : float, optional
Space allocated for header above the grid (default 0.5 inch).
Returns
-------
None
Appends positioned rotated images grid and page break to `pdf.story`.
"""
if section_title:
pdf.add_heading(section_title, level=0)
pdf.story.append(Spacer(1, 0.2 * inch))
# Create rotated image flowables with specified frame and margin
rotated_images = [
RotatedImage(
str(path),
frame_width=frame_width,
frame_height=frame_height,
margin=margin,
header_height=header_height
)
for path in img_paths
]
# Because images are rotated, width is draw_height and height is draw_width
cell_widths = [img.draw_height for img in rotated_images[:cols]] # widths of first row
max_cell_width = max(cell_widths) if cell_widths else 0
# Total table dimensions
table_width = cols * max_cell_width
# Arrange images into rows
table_data = [rotated_images[i:i + cols] for i in range(0, len(rotated_images), cols)]
# Pad last row with invisible spacers if incomplete
if len(table_data[-1]) < cols:
table_data[-1].extend([Spacer(1, 1)] * (cols - len(table_data[-1])))
# Create ReportLab table for layout
table = Table(table_data, hAlign='LEFT', spaceBefore=0, spaceAfter=0)
# Style: center alignment, no padding for tight layout
table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
]))
# Calculate offsets to center the table within the frame + header space
x_offset = ((frame_width - table_width) * 0.5) + (3 * margin)
y_offset = ((frame_height * 0.5) + header_height)
# Wrap and position the table flowable with offsets
positioned = PositionedTable(table, x_offset=x_offset, y_offset=y_offset)
pdf.story.append(positioned)
pdf.story.append(PageBreak())
###############################################################################
###############################################################################
[docs]
def add_seasonal_scatter_page(pdf, main_img_path, sub_img_paths, section_title="Seasonal Scatterplots"):
"""
Adds a page to the PDF with a main seasonal scatterplot image on top,
followed by a 2x2 grid of smaller subplots below.
Args:
pdf: PDF builder object with `.story` and `.add_heading`.
main_img_path: Path to the main seasonal plot image.
sub_img_paths: List of 4 image paths for the 2x2 grid of subplots.
section_title: Title of the page section.
"""
getSampleStyleSheet()
pdf.add_heading(section_title, level=0)
pdf.story.append(Spacer(1, 0.2 * inch))
# --- 1. Add main seasonal plot ---
max_main_width = 4 * inch
main_img = Image(str(main_img_path))
orig_width, orig_height = PILImage.open(main_img_path).size
aspect_ratio = orig_height / orig_width
main_img.drawWidth = max_main_width
main_img.drawHeight = max_main_width * aspect_ratio
pdf.story.append(main_img)
pdf.story.append(Spacer(1, 0.1 * inch))
# --- 2. Add 2x2 grid of subplots ---
max_sub_width = 2.8 * inch
sub_images = []
for img_path in sub_img_paths:
img = Image(str(img_path))
orig_width, orig_height = PILImage.open(img_path).size
aspect_ratio = orig_height / orig_width
img.drawWidth = max_sub_width
img.drawHeight = max_sub_width * aspect_ratio
sub_images.append(img)
# Build 2x2 table (assuming 4 subplots)
table_data = [sub_images[i:i + 2] for i in range(0, len(sub_images), 2)]
table = Table(table_data, hAlign='CENTER', spaceBefore=1)
pdf.story.append(table)
pdf.story.append(PageBreak())
###############################################################################
###############################################################################
[docs]
def add_efficiency_pages(pdf, efficiency_df, plot_titles, plots_path):
"""
Adds multiple pages to the PDF, each with a section for an efficiency metric:
a heading, the corresponding plot image, a monthly values table, and a total value table.
Args:
pdf: PDF builder object with `.story`, `.add_heading`, and `.styles`.
efficiency_df: pandas DataFrame indexed by metric keys, columns ['Total', month names].
plot_titles: dict mapping metric_key to human-readable title.
plots_path: Path to directory containing plot images named '<metric_key>.png'.
"""
months = list(calendar.month_name)[1:] # January to December
for metric_key, title in plot_titles.items():
# Clean title (remove any parenthesis and content inside)
clean_title = re.sub(r'\s*\([^)]*\)', '', title)
pdf.add_heading(clean_title, level=0)
pdf.story.append(Spacer(1, 0.2 * inch))
# --- Add Plot Image ---
plot_path = Path(plots_path) / f"{metric_key}.png"
if plot_path.exists():
img = Image(str(plot_path), width=6 * inch, height=3.5 * inch)
pdf.story.append(img)
else:
pdf.story.append(Paragraph(f"[Missing plot for {metric_key}]", pdf.styles['Normal']))
pdf.story.append(Spacer(1, 0.3 * inch))
# --- Prepare Monthly Table Data ---
values = efficiency_df.loc[metric_key, ['Total'] + months].values
total_val = values[0]
month_vals = values[1:]
# Two rows of 6 months each for months and their values
month_table_data = [
months[:6],
[f"{v:.3f}" if pd.notna(v) else "—" for v in month_vals[:6]],
months[6:],
[f"{v:.3f}" if pd.notna(v) else "—" for v in month_vals[6:]],
]
month_table = Table(month_table_data, hAlign='CENTER')
month_table.setStyle(TableStyle([
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('BACKGROUND', (0, 0), (-1, 0), colors.lightsteelblue), # First month label row
('BACKGROUND', (0, 2), (-1, 2), colors.lightsteelblue), # Second month label row
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTNAME', (0, 2), (-1, 2), 'Helvetica-Bold'),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
pdf.story.append(month_table)
pdf.story.append(Spacer(1, 0.2 * inch))
# --- Prepare Total Table ---
total_table = Table(
[['Total', f"{total_val:.3f}" if pd.notna(total_val) else "—"]],
colWidths=[2 * inch, 1.5 * inch], hAlign='CENTER'
)
total_table.setStyle(TableStyle([
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTNAME', (0, 0), (-1, -1), 'Helvetica-Bold'),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
pdf.story.append(total_table)
pdf.story.append(PageBreak())