Source code for patcher.models.reports.pdf_report
import os
from datetime import datetime
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import List, Union
import pandas as pd
from fpdf import FPDF
from pandas.errors import ParserError
from PIL import Image
from ...client.ui_manager import UIConfigManager
from ...utils import exceptions, logger
[docs]
class PDFReport(FPDF):
"""
Handles the generation of PDF reports from Excel files.
The ``PDFReport`` class extends FPDF to create a PDF report from an Excel file
containing patch data. It supports custom headers, footers, and font styles
based on the UI configuration.
"""
def __init__(
self,
ui_config: UIConfigManager,
orientation="L",
unit="mm",
format="A4",
date_format="%B %d %Y",
):
"""
Initializes the PDFReport with the provided parameters and UIConfigManager.
:param ui_config: An instance of ``UIConfigManager`` for managing UI configuration.
:type ui_config: UIConfigManager
:param orientation: Orientation of the PDF, default is "L" (landscape).
:type orientation: str
:param unit: Unit of measurement, default is "mm".
:type unit: str
:param format: Page format, default is "A4".
:type format: str
:param date_format: Date format string for the PDF report header, default is "%B %d %Y".
:type date_format: str
"""
self.log = logger.LogMe(self.__class__.__name__)
super().__init__(orientation=orientation, unit=unit, format=format) # type: ignore
self.date_format = date_format
self.ui_config = ui_config.get_ui_config()
self.logo_path = ui_config.get_logo_path()
self.add_font(self.ui_config.get("FONT_NAME"), "", self.ui_config.get("FONT_REGULAR_PATH"))
self.add_font(self.ui_config.get("FONT_NAME"), "B", self.ui_config.get("FONT_BOLD_PATH"))
self.table_headers = []
self.column_widths = []
[docs]
@staticmethod
def get_image_ratio(image_path: str) -> float:
"""
Gets the aspect ratio of the logo provided.
:param image_path: Path to the image file
:type image_path: str
:return: The width-to-height ratio of the image.
:rtype: float
"""
with Image.open(image_path) as img:
width, height = img.size
return width / height
[docs]
@staticmethod
def trim_transparency(image_path: str) -> str:
"""
Trims transparent padding from the logo and returns the path to a temporary file.
:param image_path: Path to the input image file.
:type image_path: str
:return: Path to the trimmed image.
:rtype: str
"""
with Image.open(image_path) as img:
bbox = img.getbbox()
trimmed = img.crop(bbox)
temp_file = NamedTemporaryFile(delete=False, suffix=".png")
trimmed.save(temp_file.name)
return temp_file.name
[docs]
def calculate_column_widths(self, data: pd.DataFrame) -> List[float]:
"""
Calculates column widths based on the longer of the header length or the longest content in each column,
ensuring they fit within the page width.
:param data: DataFrame containing dataset to be included in PDF.
:type data: pandas.DataFrame
:return: A list of column widths proportional to header lengths.
:rtype: List[float]
"""
# Assign widths based on header lengths
page_width = self.w - 20 # Account for left/right margins
max_lengths = [
max(len(str(header)), data[column].astype(str).map(len).max())
for header, column in zip(self.table_headers, data.columns)
]
total_length = sum(max_lengths)
# Calculate proportional widths
proportional_widths = [(length / total_length) * page_width for length in max_lengths]
# Enforce constraints to ensure columns fit the page
while sum(proportional_widths) > page_width:
excess = sum(proportional_widths) - page_width
for i in range(len(proportional_widths)):
if proportional_widths[i] > 20: # Avoid shrinking below a minimum width
adjustment = min(excess, proportional_widths[i] - 20)
proportional_widths[i] -= adjustment
excess -= adjustment
if excess <= 0:
break
return proportional_widths
[docs]
def export_excel_to_pdf(
self, excel_file: Union[str, Path], date_format: str = "%B %d %Y"
) -> None:
"""
Creates a PDF report from an Excel file containing patch data.
This method reads an Excel file, extracts the data, and populates it into a PDF
report using the defined headers and column widths. The PDF is then saved to
the same directory as the Excel file.
:param excel_file: Path to the Excel file to convert to PDF.
:type excel_file: Union[str, Path]
:param date_format: The date format string for the PDF report header.
:type date_format: str
"""
# Read excel file
try:
df = pd.read_excel(excel_file)
except ParserError as e:
self.log.error(f"Failed to parse the excel file: {e}")
raise exceptions.PatcherError(f"Failed to parse the excel file: {e}")
# Create instance of FPDF
pdf = PDFReport(ui_config=UIConfigManager(), date_format=date_format)
# Set headers and calculate column widths
pdf.table_headers = df.columns.tolist()
pdf.column_widths = pdf.calculate_column_widths(df)
pdf.add_page()
pdf.add_table_header()
# Data rows
pdf.set_font(self.ui_config.get("FONT_NAME"), "", 9)
for _, row in df.iterrows():
for data, width in zip(row.astype(str), pdf.column_widths):
pdf.cell(width, 10, str(data), border=1, align="C")
pdf.ln(10)
if pdf.get_y() > pdf.h - 20:
pdf.add_page()
pdf.add_table_header()
# Save PDF to a file
pdf_filename = os.path.splitext(excel_file)[0] + ".pdf"
try:
pdf.output(pdf_filename)
except (OSError, PermissionError) as e:
self.log.error(f"Error occurred trying to export to PDF: {e}")
raise exceptions.ExportError(file_path=pdf_filename)