Source code for wolfhece.report.common

import sys
import wx
import os
import platform

import pandas as pd
import pymupdf as pdf
from tempfile import NamedTemporaryFile
from tempfile import TemporaryDirectory
from functools import cached_property
from PIL import Image

import matplotlib.pyplot as plt
import numpy as np
import logging
from pathlib import Path
from datetime import datetime as dt

from .. import __version__ as wolfhece_version
try:
    from wolfgpu.version import __version__ as wolfgpu_version
except ImportError:
[docs] wolfgpu_version = "not installed"
from ..PyVertexvectors import vector, zone, Zones, wolfvertex as wv from ..PyTranslate import _
[docs] def pts2cm(pts): """ Convert points to centimeters for PyMuPDF. One point equals 1/72 inches. """ return pts / 28.346456692913385 # 1 point = 1/28.346456692913385 cm = 2.54/72
[docs] def pt2inches(pts): """ Convert points to inches for PyMuPDF. One point equals 1/72 inches. """ return pts / 72.0 # 1 point = 1/72 inches
[docs] def inches2cm(inches): """ Convert inches to centimeters. One inch equals 2.54 centimeters. """ return inches * 2.54 # 1 inch = 2.54 cm
[docs] def cm2pts(cm): """ Convert centimeters to points for PyMuPDF. One point equals 1/72 inches. """ return cm * 28.346456692913385 # 1 cm = 28.346456692913385 points = 72/2.54
[docs] def cm2inches(cm): """ Convert centimeters to inches. One inch equals 2.54 centimeters. """ return cm / 2.54 # 1 cm = 1/2.54 inches
[docs] def A4_rect(): """ Return the A4 rectangle in PyMuPDF units. (0, 0) is the top-left corner in PyMuPDF coordinates. """ return pdf.Rect(0, 0, cm2pts(21), cm2pts(29.7)) # A4 size in points (PDF units)
[docs] def rect_cm(x, y, width, height): """ Create a rectangle in PyMuPDF units from centimeters. (0, 0) is the top-left corner in PyMuPDF coordinates. """ return pdf.Rect(cm2pts(x), cm2pts(y), cm2pts(x) + cm2pts(width), cm2pts(y) + cm2pts(height))
[docs] def get_rect_from_text(text, width, fontsize=10, padding=5): """ Get a rectangle that fits the text in PyMuPDF units. :param text: The text to fit in the rectangle. :param width: The width of the rectangle in centimeters. :param fontsize: The font size in points. :param padding: Padding around the text in points. :return: A PyMuPDF rectangle that fits the text. """ # Create a temporary PDF document to measure the text size with NamedTemporaryFile(delete=True, suffix='.pdf') as temp_pdf: doc = pdf.Document() page = doc.new_page(A4_rect()) text_rect = page.insert_text((0, 0), text, fontsize=fontsize, width=cm2pts(width)) doc.save(temp_pdf.name) # Get the size of the text rectangle text_width = text_rect.width + padding * 2 text_height = text_rect.height + padding * 2 # Create a rectangle with the specified width and height rect = pdf.Rect(0, 0, cm2pts(width), text_height) # Adjust the rectangle to fit the text rect.x0 -= padding rect.y0 -= padding rect.x1 += padding rect.y1 += padding return rect
[docs] def single_line_to_html(text, font_size="10pt", font_family="Helvetica"): # Génère le CSS css = f""" p {{font-size:{font_size}; font-family:{font_family}; color:#000000; align-text:center}} """ # Génère le HTML html = f"<p>{text}</p>" return html, css
[docs] def list_to_html(list_items, font_size="10pt", font_family="Helvetica"): # Génère le CSS css = f""" p {{font-size:{font_size}; font-family:{font_family}; color:#BEBEBE; align-text:center}} ul.list {{ font-size: {font_size}; font-family: {font_family}; color: #2C3E50; padding-left: 20px; }} li {{ margin-bottom: 5px; }} """ # Génère le HTML html = "<ul class='list'>" for item in list_items: html += f" <li>{item}</li>\n" html += "</ul>" css = css.replace('\n', ' ') # Remove newlines in CSS for better readability return html, css
[docs] def list_to_html_aligned(list_items, font_size="10pt", font_family="Helvetica"): # Génère le CSS css = f""" p {{font-size:{font_size}; font-family:{font_family}; color:#BEBEBE; align-text:left}} div.list {{ font-size: {font_size}; font-family: {font_family}; color: #2C3E50; padding-left: 8px; align-text:left }} li {{ margin-bottom: 5px; }} """ # Génère le HTML html = "<div class='list'>" html += " - ".join(list_items) # Join the items with a hyphen html += "</div>" css = css.replace('\n', ' ') # Remove newlines in CSS for better readability return html, css
[docs] def dict_to_html(data_dict:dict, font_size="10pt", font_family="Helvetica"): """ Convert a dictionary to an HTML table with dataframe_image """ df = pd.DataFrame.from_dict(data_dict) # Convert the DataFrame to an HTML table html = df.to_html(index=False, border=0, justify='left') # Generate the CSS css = f""" table {{ font-size: {font_size}; font-family: {font_family}; color: #2C3E50; border-collapse: collapse; }} th, td {{ padding: 8px 12px; border: 1px solid #BDC3C7; }} """ return html, css
[docs] def dataframe_to_html(data:pd.DataFrame, font_size="10pt", font_family="Helvetica"): """ Convert a DataFrame to an HTML table with dataframe_image """ # Convert the DataFrame to an HTML table html = data.to_html(index=False, border=0, justify='left') # Generate the CSS css = f""" table {{ font-size: {font_size}; font-family: {font_family}; color: #2C3E50; border-collapse: collapse; }} th, td {{ padding: 8px 12px; border: 1px solid #BDC3C7; }} """ return html, css
[docs] def convert_report_to_images(pdf_path: str | Path, dpi=150) -> list[Image.Image]: """ Convert the PDF report to a list of images (one per page). :param dpi: Dots per inch for the output images. :return: List of Images, one per page. """ if pdf_path is None: raise ValueError("PDF report has not been saved yet. Please save the report before converting to images.") pdf_path = Path(pdf_path) if not pdf_path.exists(): raise FileNotFoundError(f"PDF report not found at {pdf_path}. Please check the path.") doc = pdf.open(pdf_path) images = [] zoom = dpi / 72 # 72 dpi is the default resolution mat = pdf.Matrix(zoom, zoom) for page_num in range(len(doc)): page = doc.load_page(page_num) pix = page.get_pixmap(matrix=mat, alpha=False) img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) images.append(img) return images
# A4 format
[docs] PAGE_WIDTH = 21 # cm
[docs] PAGE_HEIGHT = 29.7 # cm
# Default Powerpoint 16:9 slide dimensions
[docs] SLIDE_HEIGHT = inches2cm(7.5) # cm
[docs] SLIDE_WIDTH = inches2cm(13.3333) # cm
[docs] class DefaultLayoutA4(Zones): """ Global layout for A4 report. This class inherits from Zones and defines a standard layout for A4 reports. It includes predefined areas for the title, versions, logo, and footer. """ def __init__(self, title:str, filename = '', ox = 0, oy = 0, tx = 0, ty = 0, parent=None, is2D=True, idx = '', plotted = True, mapviewer=None, need_for_wx = False, bbox = None, find_minmax = True, shared = False, colors = None): super().__init__(filename, ox, oy, tx, ty, parent, is2D, idx, plotted, mapviewer, need_for_wx, bbox, find_minmax, shared, colors)
[docs] self.title = title
[docs] self.left_right_margin = 1 # cm
[docs] self.top_bottom_margin = 0.5 # cm
[docs] self.padding = 0.5 # cm
WIDTH_TITLE = 16 # cm HEIGHT_TITLE = 1.5 # cm WIDTH_VERSIONS = 16 # cm HEIGHT_VERSIONS = .5 # cm X_LOGO = 18.5 # Logo starts after the title and versions WIDTH_LOGO = 1.5 # cm HEIGHT_LOGO = 1.5 # cm HEIGHT_FOOTER = 1.2 # cm page = zone(name='Page') elts = zone(name='Elements') self.add_zone(page, forceparent= True) self.add_zone(elts, forceparent= True) vec_page = vector(name=_("Page")) vec_page.add_vertex(wv(0, 0)) vec_page.add_vertex(wv(PAGE_WIDTH, 0)) vec_page.add_vertex(wv(PAGE_WIDTH, PAGE_HEIGHT)) vec_page.add_vertex(wv(0, PAGE_HEIGHT)) vec_page.force_to_close() page.add_vector(vec_page, forceparent=True) vec_title = vector(name=_("Title")) y_from_top = PAGE_HEIGHT - self.top_bottom_margin vec_title.add_vertex(wv(self.left_right_margin, y_from_top)) vec_title.add_vertex(wv(self.left_right_margin + WIDTH_TITLE, y_from_top)) vec_title.add_vertex(wv(self.left_right_margin + WIDTH_TITLE, y_from_top - HEIGHT_TITLE)) vec_title.add_vertex(wv(self.left_right_margin, y_from_top - HEIGHT_TITLE)) vec_title.force_to_close() vec_title.set_legend_text(_("Title of the report")) vec_title.set_legend_position_to_centroid() vec_title.myprop.legendvisible = True vec_title.find_minmax() elts.add_vector(vec_title, forceparent=True) vec_versions = vector(name=_("Versions")) y_from_top = PAGE_HEIGHT - self.top_bottom_margin - HEIGHT_TITLE - self.padding vec_versions.add_vertex(wv(self.left_right_margin, y_from_top)) vec_versions.add_vertex(wv(self.left_right_margin + WIDTH_VERSIONS, y_from_top)) vec_versions.add_vertex(wv(self.left_right_margin + WIDTH_VERSIONS, y_from_top - HEIGHT_VERSIONS)) vec_versions.add_vertex(wv(self.left_right_margin, y_from_top - HEIGHT_VERSIONS)) vec_versions.force_to_close() vec_versions.set_legend_text(_("Versions of the software")) vec_versions.set_legend_position_to_centroid() vec_versions.myprop.legendvisible = True vec_versions.find_minmax() elts.add_vector(vec_versions, forceparent=True) vec_logo = vector(name=_("Logo")) # Logo is placed at the top right corner, after the title and versions # Adjust the position based on the logo size y_from_top = PAGE_HEIGHT - self.top_bottom_margin vec_logo.add_vertex(wv(X_LOGO, y_from_top)) vec_logo.add_vertex(wv(X_LOGO + WIDTH_LOGO, y_from_top)) vec_logo.add_vertex(wv(X_LOGO + WIDTH_LOGO, y_from_top - HEIGHT_LOGO)) vec_logo.add_vertex(wv(X_LOGO, y_from_top - HEIGHT_LOGO)) vec_logo.force_to_close() vec_logo.set_legend_text(_("Logo")) vec_logo.set_legend_position_to_centroid() vec_logo.myprop.legendvisible = True vec_logo.find_minmax() elts.add_vector(vec_logo, forceparent=True) vec_footer = vector(name=_("Footer")) vec_footer.add_vertex(wv(self.left_right_margin, self.top_bottom_margin)) vec_footer.add_vertex(wv(PAGE_WIDTH - self.left_right_margin, self.top_bottom_margin)) vec_footer.add_vertex(wv(PAGE_WIDTH - self.left_right_margin, self.top_bottom_margin + HEIGHT_FOOTER)) vec_footer.add_vertex(wv(self.left_right_margin, self.top_bottom_margin + HEIGHT_FOOTER)) vec_footer.force_to_close() vec_footer.set_legend_text(_("Footer of the report")) vec_footer.set_legend_position_to_centroid() vec_footer.myprop.legendvisible = True vec_footer.find_minmax() elts.add_vector(vec_footer, forceparent=True)
[docs] self._doc = None # Placeholder for the PDF document
[docs] self._pdf_path = None # Placeholder for the PDF file path
[docs] def add_element(self, name:str, width:float, height:float, x:float = 0, y:float = 0) -> vector: """ Add a single element to the page. :param name: Name of the element. :param width: Width of the element in cm. :param height: Height of the element in cm. :param x: X coordinate of the bottom-left corner in cm. :param y: Y coordinate of the bottom-left corner in cm. :return: The created vector representing the element. """ vec = vector(name=name) vec.add_vertex(wv(x, y)) vec.add_vertex(wv(x + width, y)) vec.add_vertex(wv(x + width, y + height)) vec.add_vertex(wv(x, y + height)) vec.force_to_close() vec.find_minmax() vec.set_legend_text(name) vec.set_legend_position_to_centroid() vec.myprop.legendvisible = True self['Elements'].add_vector(vec, forceparent=True) return vec
[docs] def add_element_repeated(self, name:str, width:float, height:float, first_x:float = 0, first_y:float = 0, count_x:int = 1, count_y:int = 1, padding:float = None) -> zone: """ Add multiple elements to the page in a grid. :param name: Base name for the elements. :param width: Width of each element in cm. :param height: Height of each element in cm. :param first_x: X coordinate of the first element in cm. :param first_y: Y coordinate of the first element in cm. :param count_x: Number of elements in the X direction (positive for right, negative for left). :param count_y: Number of elements in the Y direction (positive for up, negative for down). :param padding: Padding between elements in cm. If None, use self.padding. :return: A zone containing the added elements. """ if padding is None: padding = self.padding delta_x = width + padding if count_x > 0 else -(padding + width) delta_y = height + padding if count_y > 0 else -(padding + height) count_x = abs(count_x) count_y = abs(count_y) x = first_x y = first_y if delta_y > 0 else first_y - height elements = zone(name=name + '_elements') for j in range(count_y): for i in range(count_x): elements.add_vector(self.add_element(name + f"_{i}-{j}", width, height, x, y), forceparent=False) x += delta_x x = first_x y += delta_y elements.find_minmax() return elements
[docs] def check_if_overlap(self, vec:vector) -> bool: """ Check if the vector overlaps with any existing vector in the page layout. :param vec: The vector to check. :return: True if there is an overlap, False otherwise. """ for existing_vec in self['Elements'].myvectors: if vec.linestring.overlaps(existing_vec.linestring): return True return False
@property
[docs] def useful_part(self) -> vector: """ Get the useful part of the page, excluding margins. """ vec = self[('Page', _('Page'))] vec.find_minmax() version = self[('Elements', _('Versions'))] version.find_minmax() footer = self[('Elements', _('Footer'))] footer.find_minmax() useful_part = vector(name=_("Useful part of the page")) useful_part.add_vertex(wv(vec.xmin + self.left_right_margin, version.ymin - self.padding)) useful_part.add_vertex(wv(vec.xmax - self.left_right_margin, version.ymin - self.padding)) useful_part.add_vertex(wv(vec.xmax - self.left_right_margin, footer.ymax + self.padding)) useful_part.add_vertex(wv(vec.xmin + self.left_right_margin, footer.ymax + self.padding)) useful_part.force_to_close() useful_part.find_minmax() useful_part.set_legend_text(_("Useful part of the page")) useful_part.set_legend_position_to_centroid() useful_part.myprop.legendvisible = True return useful_part
@property
[docs] def page_dimension(self) -> tuple[float, float]: """ Get the dimensions of the page in centimeters. """ vec = self[('Page', _('Page'))] vec.find_minmax() width = vec.xmax - vec.xmin height = vec.ymax - vec.ymin return width, height
@property
[docs] def keys(self) -> list[str]: """ Get the keys of the page layout. """ return [vec.myname for vec in self['Elements'].myvectors]
@cached_property
[docs] def layout(self) -> dict: """ Get the layout as a dictionary. """ return self._to_dict()
[docs] def get_item_in_layout(self, name:str): """ Override the indexing to access layout elements by name. """ if isinstance(name, str): if name not in self.layout: raise KeyError(f"Element '{name}' not found in layout.") return self.layout[name]
[docs] def _to_dict(self): """ Convert the layout Zones to a dictionary. """ layout = {} for vec in self['Elements'].myvectors: vec.find_minmax() layout[vec.myname] = rect_cm(vec.xmin, PAGE_HEIGHT - vec.ymax, vec.xmax - vec.xmin, vec.ymax - vec.ymin) return layout
[docs] def plot(self, scale=1.): """ Plot the page layout using matplotlib. :param scale: Scale factor for the plot. """ fig, ax = plt.subplots(figsize=(cm2inches(PAGE_WIDTH) * scale, cm2inches(PAGE_HEIGHT)*scale)) self['Elements'].plot_matplotlib(ax = ax) ax.set_aspect('equal') ax.set_xlim(0, PAGE_WIDTH) ax.set_ylim(0, PAGE_HEIGHT) ax.set_yticks(list(np.arange(0, PAGE_HEIGHT, 1))+[PAGE_HEIGHT]) ax.set_xticks(list(np.arange(0, PAGE_WIDTH + 1, 1))) plt.title(_("Layout of the report")) plt.xlabel(_("Width (cm)")) plt.ylabel(_("Height (cm)")) # plt.grid(True) return fig, ax
[docs] def _summary_versions(self): """ Find the versions of the simulation, wolfhece and the wolfgpu package """ import json group_title = "Versions" text = [f"Wolfhece : {wolfhece_version}", f"Wolfgpu : {wolfgpu_version}", f"Python : {sys.version.split()[0]}", f"Operating System: {os.name}" ] return group_title, text
[docs] def _insert_to_page(self, page: pdf.Page): """ Insert the layout into the PDF page. """ page.insert_htmlbox(self.layout['Title'], f"<h1>{self.title}</h1>", css='h1 {font-size:16pt; font-family:Helvetica; color:#333}') # versions box try: text = self._summary_versions() html, css = list_to_html_aligned(text[1], font_size="10pt", font_family="Helvetica") spare_height, scale = page.insert_htmlbox(self.layout['Versions'], html, css=css, scale_low=0.1) if spare_height < 0.: logging.warning("Text overflow in versions box. Adjusting scale.") except: logging.error("Failed to insert versions text. Using fallback method.") rect = self.layout['Logo'] # Add the logo to the top-right corner logo_path = Path(__file__).parent / 'wolf_report.png' if logo_path.exists(): page.insert_image(rect, filename=str(logo_path), keep_proportion=True, overlay=True) # Footer # ------ # Insert the date and time of the report generation, the user and the PC name footer_rect = self.layout['Footer'] footer_text = f"<p>Report generated on {dt.now()} by {os.getlogin()} on {platform.uname().node} - {platform.uname().machine} - {platform.uname().release} - {platform.uname().version}</br> \ This report does not guarantee the quality of the model and in no way commits the software developers.</p>" page.insert_htmlbox(footer_rect, footer_text, css='p {font-size:10pt; font-family:Helvetica; color:#BEBEBE; align-text:center}',)
[docs] def create_report(self) -> pdf.Document: """ Create the PDF report for the default LayoutA4. """ # Create a new PDF document self._doc = pdf.Document() # Add a page self._page = self._doc.new_page() # Insert the layout into the page self._insert_to_page(self._page) return self._doc
[docs] def save_report(self, output_path: Path | str): """ Save the report to a PDF file """ if self._doc is None: self.create_report() try: self._doc.subset_fonts() self._doc.save(output_path, garbage=3, deflate=True) self._pdf_path = output_path except Exception as e: logging.error(f"Failed to save the report to {output_path}: {e}") logging.error("Please check if the file is already opened.") self._pdf_path = None return
@property
[docs] def pdf_path(self): """ Return the PDF document """ return self._pdf_path