|
1 |
| -# Import libraries necessary for report printing |
2 |
| -from __future__ import annotations # Allows more recent type hints features |
| 1 | +""" |
| 2 | +reporting.py |
| 3 | +------------ |
| 4 | +Generates HTML or PDF reports for Pynite FE models. |
| 5 | +
|
| 6 | +Key points: |
| 7 | +- Uses Jinja2 to render an HTML template. |
| 8 | +- Optionally converts that HTML into a PDF using pdfkit + wkhtmltopdf. |
| 9 | +- Provides a single entry point: `create_report()`. |
| 10 | +""" |
| 11 | + |
| 12 | +# --- Standard library imports --- |
| 13 | +import os |
| 14 | +import platform |
| 15 | +import shutil |
| 16 | +import logging |
| 17 | +from pathlib import Path |
3 | 18 | from typing import TYPE_CHECKING
|
4 | 19 |
|
| 20 | +# --- Third-party imports --- |
5 | 21 | from jinja2 import Environment, PackageLoader
|
6 |
| -import pdfkit |
7 | 22 |
|
8 | 23 | if TYPE_CHECKING:
|
9 | 24 | from Pynite import FEModel3D
|
10 | 25 |
|
11 |
| -# Determine the filepath to the local Pynite installation |
12 |
| -from pathlib import Path |
| 26 | + |
| 27 | +# ============================================================================= |
| 28 | +# Logging Setup |
| 29 | +# ============================================================================= |
| 30 | + |
| 31 | +# Create a module-level logger |
| 32 | +logger = logging.getLogger(__name__) |
| 33 | + |
| 34 | +# Only configure logging if this file is run directly. |
| 35 | +# When imported as a library, the caller controls logging configuration. |
| 36 | +if __name__ == "__main__" and not logger.handlers: |
| 37 | + logging.basicConfig( |
| 38 | + level=logging.INFO, |
| 39 | + format="%(levelname)s [%(name)s]: %(message)s" |
| 40 | + ) |
| 41 | + |
| 42 | + |
| 43 | +# ============================================================================= |
| 44 | +# Jinja2 Template Setup |
| 45 | +# ============================================================================= |
| 46 | + |
| 47 | +# Determine the directory where this file lives |
13 | 48 | path = Path(__file__).parent
|
14 | 49 |
|
15 |
| -# Set up the jinja2 template environment |
| 50 | +# Set up Jinja2 environment |
| 51 | +# - PackageLoader tells Jinja2 where to find the HTML templates |
16 | 52 | env = Environment(
|
17 | 53 | loader=PackageLoader('Pynite', '.'),
|
18 | 54 | )
|
19 | 55 |
|
20 |
| -# Get the report template |
| 56 | +# Load the main HTML report template |
21 | 57 | template = env.get_template('Report_Template.html')
|
22 | 58 |
|
23 |
| -def create_report(model: FEModel3D, output_filepath:Path=path/'./Pynite Report.pdf', **kwargs): |
24 |
| - """Creates a pdf report for a given finite element model. |
| 59 | + |
| 60 | +# ============================================================================= |
| 61 | +# Helper: Find wkhtmltopdf |
| 62 | +# ============================================================================= |
| 63 | + |
| 64 | +def get_wkhtmltopdf_path() -> str | None: |
| 65 | + """ |
| 66 | + Try to locate wkhtmltopdf across platforms. |
| 67 | + Returns the full path if found, else None. |
| 68 | + """ |
| 69 | + |
| 70 | + # 1. First check PATH (most common case) |
| 71 | + path = shutil.which("wkhtmltopdf") |
| 72 | + if path: |
| 73 | + return path |
| 74 | + |
| 75 | + # 2. Check platform-specific common install locations |
| 76 | + system = platform.system() |
| 77 | + |
| 78 | + if system == "Windows": |
| 79 | + candidates = [ |
| 80 | + r"C:\Program Files\wkhtmltopdf\bin\wkhtmltopdf.exe", |
| 81 | + r"C:\Program Files (x86)\wkhtmltopdf\bin\wkhtmltopdf.exe", |
| 82 | + ] |
| 83 | + elif system == "Darwin": # macOS |
| 84 | + candidates = [ |
| 85 | + "/usr/local/bin/wkhtmltopdf", |
| 86 | + "/opt/homebrew/bin/wkhtmltopdf", |
| 87 | + ] |
| 88 | + elif system == "Linux": |
| 89 | + candidates = [ |
| 90 | + "/usr/bin/wkhtmltopdf", |
| 91 | + "/usr/local/bin/wkhtmltopdf", |
| 92 | + ] |
| 93 | + else: |
| 94 | + candidates = [] |
| 95 | + |
| 96 | + for candidate in candidates: |
| 97 | + if os.path.isfile(candidate): |
| 98 | + return candidate |
| 99 | + |
| 100 | + # None found |
| 101 | + return None |
| 102 | + |
| 103 | + |
| 104 | +# ============================================================================= |
| 105 | +# PDFKit Setup |
| 106 | +# ============================================================================= |
| 107 | + |
| 108 | +try: |
| 109 | + import pdfkit |
| 110 | + |
| 111 | + wkhtmltopdf_path = get_wkhtmltopdf_path() |
| 112 | + if wkhtmltopdf_path: |
| 113 | + config = pdfkit.configuration(executable=wkhtmltopdf_path) |
| 114 | + logger.info(f"wkhtmltopdf found at: {wkhtmltopdf_path}") |
| 115 | + else: |
| 116 | + raise FileNotFoundError("wkhtmltopdf not found on system. Install wkhtmltopdf.") |
| 117 | +except ImportError: |
| 118 | + logger.error("pdfkit not installed. Run: pip install pdfkit") |
| 119 | + pdfkit = None |
| 120 | + config = None |
| 121 | +except Exception as e: |
| 122 | + logger.error(f"PDFKit setup failed: {e}") |
| 123 | + pdfkit = None |
| 124 | + config = None |
| 125 | + |
| 126 | + |
| 127 | +# ============================================================================= |
| 128 | +# Main Report Function |
| 129 | +# ============================================================================= |
| 130 | + |
| 131 | +def create_report(model: FEModel3D, |
| 132 | + output_filepath: Path | str = path / 'Pynite Report.pdf', |
| 133 | + format: str = 'pdf', |
| 134 | + **kwargs) -> None: |
| 135 | + """ |
| 136 | + Creates a report for a given finite element model. |
25 | 137 |
|
26 | 138 | :param model: The model to generate the report for.
|
27 |
| - :type model: ``FEModel3D`` |
28 |
| - :param output_filepath: The filepath to send the report to. Defaults to 'Pynite Report.pdf' in your ``PYTHONPATH`` |
29 |
| - :type output_filepath: ``str``, optional |
30 |
| - :param \\**kwargs: See below for a list of valid arguments. |
31 |
| - :Keyword Arguments: |
32 |
| - * *node_table* (``bool``) -- Set to ``True`` if you want node data included in the report. Defaults to ``True``. |
33 |
| - * *member_table* (``bool``) -- Set to ``True`` if you want member data included in the report. Defaults to ``True``. |
34 |
| - * *member_releases* (``bool``) -- Set to ``True`` if you want member end release data included in the report. Defaults to ``True``. |
35 |
| - * *plate_table* (``bool``) -- Set to ``True if you want plate/quad data included in the report. Defaults to ``True``. |
36 |
| - * *node_reactions* (``bool``) -- Set to ``True`` if you want node reactions included in the report. Defaults to ``True`` |
37 |
| - * *node_displacements* (``bool``) -- Set to ``True`` if you want node displacement results included in the report. Defaults to ``True``. |
38 |
| - * *member_end_forces* (``bool``) -- Set to ``True`` if you want member end force results included in the report. Defaults to ``True``. |
39 |
| - * *member_internal_forces* (``bool``) -- Set to ``True`` if you want member internal force results included in the report. Defaults to ``True`` |
40 |
| - * *plate_corner_forces* (``bool``) -- Set to ``True`` if you want plate/quad corner force results (out-of-plane/bending) included in the report. Defaults to ``True``. |
41 |
| - * *plate_center_forces* (``bool``) -- Set to ``True`` if you want plate/quad center force results (out-of-plane/bending) included in the report. Defaults to ``True``. |
42 |
| - * *plate_corner_membrane* (``bool``) -- Set to ``True`` if you want plate/quad corner membrane (in-plane) force results included in the report. Defaults to ``True``. |
43 |
| - * *plate_center_membrane* (``bool``) -- Set to ``True`` if you want plate/quad center membrane (in-plane) force results included in the report. Defaults to ``True``. |
| 139 | + :param output_filepath: Filepath for the output file. |
| 140 | + :param format: Output format: 'pdf' or 'html'. |
| 141 | + :param kwargs: Report options (see below). |
| 142 | +
|
| 143 | + Keyword Arguments: |
| 144 | + * node_table (bool): Include node data (default: True). |
| 145 | + * member_table (bool): Include member data (default: True). |
| 146 | + * member_releases (bool): Include member end releases (default: True). |
| 147 | + * plate_table (bool): Include plate/quad data (default: True). |
| 148 | + * node_reactions (bool): Include node reactions (default: True). |
| 149 | + * node_displacements (bool): Include node displacements (default: True). |
| 150 | + * member_end_forces (bool): Include member end forces (default: True). |
| 151 | + * member_internal_forces (bool): Include member internal forces (default: True). |
| 152 | + * plate_corner_forces (bool): Include plate corner out-of-plane forces (default: True). |
| 153 | + * plate_center_forces (bool): Include plate center out-of-plane forces (default: True). |
| 154 | + * plate_corner_membrane (bool): Include plate corner in-plane forces (default: True). |
| 155 | + * plate_center_membrane (bool): Include plate center in-plane forces (default: True). |
44 | 156 | """
|
45 | 157 |
|
46 |
| - # Create default report settings |
47 |
| - if 'node_table' not in kwargs: kwargs['node_table'] = True |
48 |
| - if 'member_table' not in kwargs: kwargs['member_table'] = True |
49 |
| - if 'member_releases' not in kwargs: kwargs['member_releases'] = True |
50 |
| - if 'plate_table' not in kwargs: kwargs['plate_table'] = True |
51 |
| - if 'node_reactions' not in kwargs: kwargs['node_reactions'] = True |
52 |
| - if 'node_displacements' not in kwargs: kwargs['node_displacements'] = True |
53 |
| - if 'member_end_forces' not in kwargs: kwargs['member_end_forces'] = True |
54 |
| - if 'member_internal_forces' not in kwargs: kwargs['member_internal_forces'] = True |
55 |
| - if 'plate_corner_forces' not in kwargs: kwargs['plate_corner_forces'] = True |
56 |
| - if 'plate_center_forces' not in kwargs: kwargs['plate_center_forces'] = True |
57 |
| - if 'plate_corner_membrane' not in kwargs: kwargs['plate_corner_membrane'] = True |
58 |
| - if 'plate_center_membrane' not in kwargs: kwargs['plate_center_membrane'] = True |
59 |
| - |
60 |
| - # Pass the dictionaries to the report template |
| 158 | + # ------------------------------------------------------------------------- |
| 159 | + # Default Settings |
| 160 | + # ------------------------------------------------------------------------- |
| 161 | + defaults = { |
| 162 | + 'node_table': True, |
| 163 | + 'member_table': True, |
| 164 | + 'member_releases': True, |
| 165 | + 'plate_table': True, |
| 166 | + 'node_reactions': True, |
| 167 | + 'node_displacements': True, |
| 168 | + 'member_end_forces': True, |
| 169 | + 'member_internal_forces': True, |
| 170 | + 'plate_corner_forces': True, |
| 171 | + 'plate_center_forces': True, |
| 172 | + 'plate_corner_membrane': True, |
| 173 | + 'plate_center_membrane': True, |
| 174 | + } |
| 175 | + |
| 176 | + # Fill missing kwargs with defaults |
| 177 | + for key, val in defaults.items(): |
| 178 | + kwargs.setdefault(key, val) |
| 179 | + |
| 180 | + # ------------------------------------------------------------------------- |
| 181 | + # Add Model Data to Context |
| 182 | + # ------------------------------------------------------------------------- |
61 | 183 | kwargs['nodes'] = model.nodes.values()
|
62 | 184 | kwargs['members'] = model.members.values()
|
63 | 185 | kwargs['plates'] = model.plates.values()
|
64 | 186 | kwargs['quads'] = model.quads.values()
|
65 | 187 | kwargs['load_combos'] = model.load_combos.values()
|
66 | 188 |
|
67 |
| - # Create the report HTML using jinja2 |
68 |
| - HTML = template.render(**kwargs) |
| 189 | + # ------------------------------------------------------------------------- |
| 190 | + # Render HTML from Template |
| 191 | + # ------------------------------------------------------------------------- |
| 192 | + try: |
| 193 | + HTML = template.render(**kwargs) |
| 194 | + except Exception as e: |
| 195 | + logger.error(f"Template rendering failed: {e}") |
| 196 | + raise RuntimeError("Failed to render report template.") from e |
| 197 | + |
| 198 | + # ------------------------------------------------------------------------- |
| 199 | + # Export Report |
| 200 | + # ------------------------------------------------------------------------- |
| 201 | + try: |
| 202 | + if format == 'pdf': |
| 203 | + if pdfkit is None or config is None: |
| 204 | + raise RuntimeError("pdfkit/wkhtmltopdf not available. Cannot create PDF report.") |
| 205 | + |
| 206 | + pdfkit.from_string( |
| 207 | + HTML, |
| 208 | + str(output_filepath), |
| 209 | + css=str(path / 'MainStyleSheet.css'), |
| 210 | + configuration=config |
| 211 | + ) |
| 212 | + logger.info(f"PDF report generated at: {output_filepath}") |
| 213 | + |
| 214 | + elif format == 'html': |
| 215 | + with open(output_filepath, "w", encoding="utf-8") as file: |
| 216 | + file.write(HTML) |
| 217 | + logger.info(f"HTML report generated at: {output_filepath}") |
| 218 | + |
| 219 | + else: |
| 220 | + raise ValueError("Invalid format. Use 'pdf' or 'html'.") |
| 221 | + |
| 222 | + except Exception as e: |
| 223 | + logger.error(f"Report creation failed: {e}") |
| 224 | + raise |
| 225 | + |
| 226 | + |
| 227 | +# ============================================================================= |
| 228 | +# Script Entry Point |
| 229 | +# ============================================================================= |
69 | 230 |
|
70 |
| - # Convert the HTML to pdf format using PDFKit |
71 |
| - # Note that wkhtmltopdf must be installed on the system, and included on the system's PATH environment variable for PDFKit to work |
72 |
| - pdfkit.from_string(HTML, output_filepath, css=path / './MainStyleSheet.css') |
73 |
| - |
74 |
| - return |
| 231 | +if __name__ == "__main__": |
| 232 | + # Example: Run a demo if this file is executed directly |
| 233 | + logger.info("This module is intended to be imported, not run directly.") |
0 commit comments