diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml
index 3301410d57a0..3cd3518cf95c 100644
--- a/.github/workflows/ci-release.yml
+++ b/.github/workflows/ci-release.yml
@@ -151,7 +151,6 @@ jobs:
target: ${{ matrix.target }}
attest-provenance: true
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml
index a1a6a8ddd6c7..f653ebfb8b2d 100644
--- a/.github/workflows/ci_cd.yml
+++ b/.github/workflows/ci_cd.yml
@@ -293,7 +293,6 @@ jobs:
python-version: ${{ matrix.python-version }}
target: ${{ matrix.target }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
diff --git a/.github/workflows/manual-build-wheelhouse.yml b/.github/workflows/manual-build-wheelhouse.yml
index 15687bd6a589..828b1ab5665c 100644
--- a/.github/workflows/manual-build-wheelhouse.yml
+++ b/.github/workflows/manual-build-wheelhouse.yml
@@ -85,7 +85,6 @@ jobs:
python-version: ${{ matrix.python-version }}
target: ${{ github.event.inputs.target }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
@@ -116,7 +115,6 @@ jobs:
python-version: ${{ github.event.inputs.python-version }}
target: ${{ github.event.inputs.target }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
@@ -151,7 +149,6 @@ jobs:
python-version: ${{ matrix.python-version }}
target: ${{ github.event.inputs.target }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
@@ -182,7 +179,6 @@ jobs:
python-version: ${{ github.event.inputs.python-version }}
target: ${{ github.event.inputs.target }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
diff --git a/.github/workflows/reusable-smoke-tests-rocky.yml b/.github/workflows/reusable-smoke-tests-rocky.yml
index 38f7f5d10d9e..9b4f9bd889ac 100644
--- a/.github/workflows/reusable-smoke-tests-rocky.yml
+++ b/.github/workflows/reusable-smoke-tests-rocky.yml
@@ -83,7 +83,6 @@ jobs:
python-version: ${{ matrix.python-version }}
target: ${{ matrix.extras }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
skip-python-setup: true
- name: Import python package
diff --git a/.github/workflows/sync-release.yml b/.github/workflows/sync-release.yml
index 96d18f9d4b4f..00fe247632e2 100644
--- a/.github/workflows/sync-release.yml
+++ b/.github/workflows/sync-release.yml
@@ -276,7 +276,6 @@ jobs:
python-version: ${{ matrix.python-version }}
target: ${{ matrix.target }}
use-python-cache: false
- whitelist-license-check: 'fpdf2'
- name: Import python package
env:
diff --git a/README.md b/README.md
index d44a94ec95c7..12bc3a5187a6 100644
--- a/README.md
+++ b/README.md
@@ -55,11 +55,6 @@ Install PyAEDT with all extra packages (matplotlib, numpy, pandas, pyvista, ...)
pip install pyaedt[all]
```
-> **Note:**
-The [all] installation target includes `fpdf2` for PDF export functionality, which is
-under license LGPL v3. For license-sensitive environments, install PyAEDT without [all]
-and add dependencies individually.
-
You can also install PyAEDT from Conda-Forge with this command:
```sh
@@ -332,11 +327,6 @@ with Hfss as hfss:
PyAEDT is licensed under the MIT license.
-PyAEDT provides an optional PDF export feature via the class `AnsysReport`.
-It requires the [fpdf2](https://github.com/py-pdf/fpdf2)
-library, which is licensed under the
-[GNU Lesser General Public License v3.0 (LGPLv3)](https://www.gnu.org/licenses/lgpl-3.0.en.html#license-text).
-
PyAEDT makes no commercial claim over Ansys whatsoever. This library extends the
functionality of AEDT by adding a Python interface to AEDT without changing the
core behavior or license of the original software. The use of PyAEDT requires a
diff --git a/doc/changelog.d/7934.added.md b/doc/changelog.d/7934.added.md
new file mode 100644
index 000000000000..a820e26d7a6f
--- /dev/null
+++ b/doc/changelog.d/7934.added.md
@@ -0,0 +1 @@
+Pdf_oxide report generation
diff --git a/doc/source/API/visualization/plot.rst b/doc/source/API/visualization/plot.rst
index a00e71715c34..39e45de401d6 100644
--- a/doc/source/API/visualization/plot.rst
+++ b/doc/source/API/visualization/plot.rst
@@ -52,7 +52,7 @@ PyAEDT benefits of `matplotlib `_ package and allows to
PDF
~~~
-PyAEDT benefits of `fpdf2 `_ package and allows to generate PDF files.
+PyAEDT uses the `pdf-oxide `_ package to generate PDF files.
.. currentmodule:: ansys.aedt.core.visualization.plot.pdf
diff --git a/doc/source/User_guide/postprocessing.rst b/doc/source/User_guide/postprocessing.rst
index 47372732b29c..4974c2831fad 100644
--- a/doc/source/User_guide/postprocessing.rst
+++ b/doc/source/User_guide/postprocessing.rst
@@ -107,7 +107,7 @@ PyAEDT leverages PyVista to export and plot fields outside AEDT, generating imag
:alt: Postprocessing features
-PyAEDT includes a powerful class to generate a PDF report that is based on the Python ``fpdf2`` package.
+PyAEDT includes a powerful class to generate a PDF report that is based on the Python ``pdf-oxide`` package.
To see the capabilities offered by PyAEDT, download a sample PDF report that uses this class:
diff --git a/pyproject.toml b/pyproject.toml
index cb3964b4ca31..5ce438bc2772 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,7 +55,7 @@ graphics = [
"pyvista[io]>=0.38.0,<0.49",
"matplotlib>=3.5.0",
"vtk>=9.0,<9.7",
- "fpdf2",
+ "pdf-oxide",
"pillow",
]
jupyter = [
@@ -75,7 +75,7 @@ all = [
"pyaedt[jupyter]",
"requests",
"typer>=0.20.0",
- "fpdf2",
+ "pdf-oxide",
"rpyc>=6.0.0",
]
all-dotnet = [
@@ -92,7 +92,7 @@ all-dotnet = [
"pyaedt[jupyter]",
"requests",
"typer>=0.20.0",
- "fpdf2",
+ "pdf-oxide",
"rpyc>=6.0.0",
# From 'dotnet' group
"ansys-pythonnet>=3.1.0rc3",
@@ -110,7 +110,7 @@ examples = [
"joblib>=1.4.0",
"plotly>=6.0",
"scikit-rf>=0.30.0",
- "fpdf2",
+ "pdf-oxide",
"rpyc>=6.0.0",
"tables; python_version >= '3.10'",
]
@@ -457,4 +457,3 @@ showcontent = true
directory = "test"
name = "Test"
showcontent = true
-
diff --git a/src/ansys/aedt/core/internal/checks.py b/src/ansys/aedt/core/internal/checks.py
index 131ace91bf08..a099eb12cf72 100644
--- a/src/ansys/aedt/core/internal/checks.py
+++ b/src/ansys/aedt/core/internal/checks.py
@@ -33,10 +33,10 @@
# NOTE: This should be updated for any modifications in pyaedt's graphics install target.
# Graphics dependencies cache
_GRAPHICS_DEPENDENCIES = {
- "fpdf": None,
"imageio": None,
"matplotlib": None,
"meshio": None,
+ "pdf_oxide": None,
"pillow": None,
"pyvista": None,
"visualization_interface": None,
@@ -183,8 +183,8 @@ def check_dependency_available(dependency: str, warning: bool = False) -> bool |
import imageio # noqa: F401
elif dependency == "meshio":
import meshio # noqa: F401
- elif dependency == "fpdf":
- import fpdf # noqa: F401
+ elif dependency == "pdf_oxide":
+ import pdf_oxide # noqa: F401
elif dependency == "pillow":
import PIL # noqa: F401
diff --git a/src/ansys/aedt/core/visualization/plot/pdf.py b/src/ansys/aedt/core/visualization/plot/pdf.py
index f14de48e9f87..e0676c9ef450 100644
--- a/src/ansys/aedt/core/visualization/plot/pdf.py
+++ b/src/ansys/aedt/core/visualization/plot/pdf.py
@@ -24,23 +24,16 @@
from dataclasses import dataclass
from dataclasses import field
+import io
import json
import os
-try:
- from fpdf import FPDF
- from fpdf import FontFace
-except ImportError as e: # pragma: no cover
- from ansys.aedt.core.internal.checks import install_message
-
- msg_error = install_message("fpdf", "graphics", level="module")
- raise ImportError(msg_error) from e
-
from ansys.aedt.core import __version__
from ansys.aedt.core.base import PyAedtBase
from ansys.aedt.core.generic.aedt_constants import DesignType
from ansys.aedt.core.generic.constants import unit_converter
from ansys.aedt.core.generic.file_utils import open_file
+from ansys.aedt.core.internal.checks import check_dependency_available
from ansys.aedt.core.internal.checks import requires_graphical_dependency
@@ -57,7 +50,7 @@ class ReportSpec(PyAedtBase):
document_prefix: str = "ANSS"
"""Value for document prefix."""
- ansys_version: str = "2025R1"
+ ansys_version: str = "2026.1"
"""Value for ansys version."""
revision: str = "Rev 1.0"
"""Value for revision."""
@@ -121,19 +114,202 @@ class ReportSpec(PyAedtBase):
"""Value for font caption color."""
-class AnsysReport(FPDF, PyAedtBase):
- """Provide ansys report."""
+@dataclass
+class _PdfOxideTextBlock:
+ text: str
+ font_size: int
+ color: tuple[int, int, int]
+ bold: bool = False
+ italic: bool = False
+ align: str = "left"
+ outline_level: int | None = None
+ leading: float = 1.35
+
+
+@dataclass
+class _PdfOxideSpacerBlock:
+ lines: int = 1
+
+
+@dataclass
+class _PdfOxideImageBlock:
+ path: str | None = None
+ image_bytes: bytes | None = None
+ caption: str = ""
+ width_mm: float | None = None
+ height_mm: float | None = None
+ max_width_mm: float | None = None
+ max_height_mm: float | None = None
+ keep_aspect_ratio: bool = False
+
+
+@dataclass
+class _PdfOxideTableBlock:
+ title: str
+ content: list[list]
+ formatting: list | None = None
+ col_widths: list | None = None
+ full_width: bool = False
+
+
+@dataclass
+class _PdfOxidePage:
+ portrait: bool
+ page_format: str | tuple[float, float]
+ blocks: list = field(default_factory=list)
+ kind: str = "content"
+
+
+def _pdf_oxide_imports():
+ check_dependency_available("pdf_oxide")
+ from pdf_oxide import DocumentBuilder
+
+ return DocumentBuilder
+
+
+def _mm_to_pt(value: float) -> float:
+ return value * 72.0 / 25.4
+
+
+def _pt_to_mm(value: float) -> float:
+ return value * 25.4 / 72.0
+
+
+def _page_size_mm(page_format: str | tuple[float, float], portrait: bool) -> tuple[float, float]:
+ formats = {
+ "a3": (297.0, 420.0),
+ "a4": (210.0, 297.0),
+ "a5": (148.0, 210.0),
+ "letter": (215.9, 279.4),
+ "legal": (215.9, 355.6),
+ }
+ if isinstance(page_format, tuple):
+ width_mm, height_mm = page_format
+ else:
+ key = page_format.lower()
+ if key not in formats:
+ raise ValueError(f"Unsupported page format '{page_format}'.")
+ width_mm, height_mm = formats[key]
+ if portrait:
+ return min(width_mm, height_mm), max(width_mm, height_mm)
+ return max(width_mm, height_mm), min(width_mm, height_mm)
+
+
+def _font_name(font: str, bold: bool = False, italic: bool = False) -> str:
+ base_font = font.lower()
+ families = {
+ "arial": "Helvetica",
+ "helvetica": "Helvetica",
+ "times": "Times",
+ "times-roman": "Times",
+ "courier": "Courier",
+ }
+ family = families.get(base_font, font)
+ if family == "Helvetica":
+ if bold and italic:
+ return "Helvetica-BoldOblique"
+ if bold:
+ return "Helvetica-Bold"
+ if italic:
+ return "Helvetica-Oblique"
+ return "Helvetica"
+ if family == "Times":
+ if bold and italic:
+ return "Times-BoldItalic"
+ if bold:
+ return "Times-Bold"
+ if italic:
+ return "Times-Italic"
+ return "Times-Roman"
+ if family == "Courier":
+ if bold and italic:
+ return "Courier-BoldOblique"
+ if bold:
+ return "Courier-Bold"
+ if italic:
+ return "Courier-Oblique"
+ return "Courier"
+ return font
+
+
+def _rgb_fraction(
+ color: list | tuple | None, default: tuple[float, float, float] = (0.0, 0.0, 0.0)
+) -> tuple[float, float, float]:
+ if not color:
+ return default
+ return tuple(channel / 255.0 for channel in color)
+
+
+def _read_image_bytes(path: str) -> tuple[bytes, int | None, int | None]:
+ with open_file(path, "rb") as image_file:
+ data = image_file.read()
+ try:
+ from PIL import Image
+
+ with Image.open(io.BytesIO(data)) as img:
+ width_px, height_px = img.size
+ except Exception: # pragma: no cover
+ width_px = None
+ height_px = None
+ return data, width_px, height_px
+
+
+def _wrap_text_lines(page_builder, text: str, max_width_pt: float) -> list[str]:
+ if not text:
+ return [""]
+ lines = []
+ for paragraph in text.splitlines():
+ if not paragraph:
+ lines.append("")
+ continue
+ current_line = ""
+ for word in paragraph.split():
+ if page_builder.measure(word) > max_width_pt:
+ if current_line:
+ lines.append(current_line)
+ current_line = ""
+ word_lines = []
+ word_line = ""
+ for character in word:
+ candidate = word_line + character
+ if word_line and page_builder.measure(candidate) > max_width_pt:
+ word_lines.append(word_line)
+ word_line = character
+ else:
+ word_line = candidate
+ if word_line:
+ word_lines.append(word_line)
+ lines.extend(word_lines[:-1])
+ current_line = word_lines[-1]
+ continue
+ candidate = word if not current_line else f"{current_line} {word}"
+ if current_line and page_builder.measure(candidate) > max_width_pt:
+ lines.append(current_line)
+ current_line = word
+ else:
+ current_line = candidate
+ if current_line:
+ lines.append(current_line)
+ return lines or [""]
+
+
+def _draw_colored_text(page_builder, x_pt: float, y_pt: float, text: str, color: tuple[int, int, int]) -> None:
+ red, green, blue = _rgb_fraction(color)
+ page_builder.at(x_pt, y_pt).inline_color(red, green, blue, str(text))
+
+
+class AnsysReport(PyAedtBase):
+ """Provide an Ansys report implementation based on pdf-oxide."""
def __init__(
self,
- version: str = "2025R1",
+ version: str = "2026.1",
design_name: str = "design1",
project_name: str = "AnsysProject",
- tempplate_json_file=None,
+ template_json_file: str | None = None,
) -> None:
- super().__init__()
self.report_specs = ReportSpec()
- self.read_template(tempplate_json_file)
+ self.read_template(template_json_file)
self.report_specs.ansys_version = version
self.report_specs.design_name = design_name
self.report_specs.project_name = project_name
@@ -142,30 +318,59 @@ def __init__(
self.__sub_chapter_idx = 0
self.__figure_idx = 1
self.__table_idx = 1
- self._left_margin = 0
- self.set_top_margin(unit_converter(self.report_specs.top_margin, input_units=self.report_specs.units))
- self.set_right_margin(unit_converter(self.report_specs.right_margin, input_units=self.report_specs.units))
- self.set_left_margin(unit_converter(self.report_specs.left_margin, input_units=self.report_specs.units))
- self.set_auto_page_break(
- True, margin=unit_converter(self.report_specs.bottom_margin, input_units=self.report_specs.units)
- )
- self.alias_nb_pages()
-
- def read_template(self, template_file: str = None) -> None:
- """Read pdf template.
-
- Parameters
- ----------
- template_file : str
- Path to the json template file.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.read_template()
-
- """
+ self._pages: list[_PdfOxidePage] = []
+ self._toc_requested = False
+ self._current_page: _PdfOxidePage | None = None
+ self._active_page_builder = None
+ self._active_page_width_mm: float | None = None
+ self._active_page_height_mm: float | None = None
+ self._active_page_number: int | None = None
+ self._active_total_pages: int | None = None
+
+ def _margin_mm(self, value: float) -> float:
+ converted_value = unit_converter(value, input_units=self.report_specs.units, output_units="mm")
+ if isinstance(converted_value, (int, float)):
+ return float(converted_value)
+ raise TypeError("Report margins must be scalar values.")
+
+ def _top_margin_mm(self) -> float:
+ return self._margin_mm(self.report_specs.top_margin)
+
+ def _bottom_margin_mm(self) -> float:
+ return self._margin_mm(self.report_specs.bottom_margin)
+
+ def _left_margin_mm(self) -> float:
+ return self._margin_mm(self.report_specs.left_margin)
+
+ def _right_margin_mm(self) -> float:
+ return self._margin_mm(self.report_specs.right_margin)
+
+ def _effective_width_mm(self, width_mm: float) -> float:
+ return width_mm - self._left_margin_mm() - self._right_margin_mm()
+
+ def _content_start_y_mm(self) -> float:
+ return self._top_margin_mm() + 2.0
+
+ def _footer_clearance_mm(self) -> float:
+ return max(self._bottom_margin_mm(), 24.0)
+
+ def _content_end_y_mm(self, page_height_mm: float) -> float:
+ return page_height_mm - self._footer_clearance_mm()
+
+ def _append_page(self, portrait: bool, page_format: str | tuple[float, float], kind: str = "content") -> None:
+ page = _PdfOxidePage(portrait=portrait, page_format=page_format, kind=kind)
+ self._pages.append(page)
+ self._current_page = page
+
+ def _ensure_current_page(self) -> _PdfOxidePage:
+ if self._current_page is None:
+ self.add_section()
+ if self._current_page is None: # pragma: no cover
+ raise RuntimeError("Unable to create a report page.")
+ return self._current_page
+
+ def read_template(self, template_file: str | None = None) -> None:
+ """Read pdf template."""
if template_file:
self.report_specs.template_name = template_file
if os.path.exists(self.report_specs.template_name):
@@ -173,190 +378,221 @@ def read_template(self, template_file: str = None) -> None:
tdata = json.load(f)
self.report_specs = ReportSpec(**tdata)
- def __add_cover_page(self) -> None:
- self.add_page("P" if self.use_portrait else "L")
- self.set_font(self.report_specs.font.lower(), "b", self.report_specs.cover_subtitle_font_size)
- self.y += 40
- self.cell(
- 0,
- 12,
- "Simulation Report",
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
- )
- self.ln(10)
- self.set_font(self.report_specs.font.lower(), "B", self.report_specs.cover_title_font_size)
- self.cell(
- 0,
- 16,
- f"Project Name: {self.report_specs.project_name}",
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
- )
- self.cell(
- 0,
- 16,
- f"Design Name: {self.report_specs.design_name}",
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
- )
-
def header(self) -> None:
- """Header.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.header()
+ """Render the page header.
+ Override this method to customize headers. It is called for every rendered page.
"""
- from datetime import date
-
- def add_field(field_name, field_value) -> None:
- self.set_font(self.report_specs.font.lower(), size=self.report_specs.header_font_size)
- self.cell(
- 0,
- 3,
- field_name,
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
- )
- self.set_x(line_x)
- self.cell(
- 0,
- 3,
- field_value,
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
+ if (
+ self._active_page_builder is not None
+ and self._active_page_width_mm is not None
+ and self._active_page_height_mm is not None
+ ):
+ self._render_header(
+ self._active_page_builder,
+ self._active_page_width_mm,
+ self._active_page_height_mm,
)
- # Logo
- self.set_y(15)
- if self._left_margin == 0:
- self._left_margin = self.l_margin
- self.set_x(self._left_margin)
- line_x = self.x
- line_y = self.y
- delta = (self.w - self.r_margin - self._left_margin) / 5 - 10
- self.set_text_color(*self.report_specs.font_header_color)
-
- add_field("Project Name", self.report_specs.project_name)
- self.set_y(line_y)
- line_x += delta
- self.set_x(line_x)
- add_field("Design Name", self.report_specs.design_name)
-
- self.set_y(line_y)
- line_x += delta
- self.set_x(line_x)
- add_field("Ansys Version", self.report_specs.ansys_version)
- self.set_y(line_y)
- line_x += delta
- self.set_x(line_x)
- add_field("PyAEDT Version", self.report_specs.pyaedt_version)
- self.set_y(line_y)
- line_x += delta
- self.set_x(line_x)
-
- add_field("Date", str(date.today()))
- self.set_y(line_y)
- line_x += delta
- self.set_x(line_x)
- add_field("Revision", self.report_specs.revision)
- self.set_y(10)
- line_x += delta
- self.set_x(self.w - self.r_margin - 33)
- self.image(
- self.report_specs.logo_name,
- self.x,
- self.y,
- unit_converter(self.report_specs.header_image_width, input_units=self.report_specs.units),
- )
- self.set_x(self._left_margin)
- self.set_y(self.t_margin)
- self.line(x1=self._left_margin, y1=self.t_margin - 7, x2=self.w - self.r_margin, y2=self.t_margin - 7)
-
- # Page footer
def footer(self) -> None:
- """Footer.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.footer()
+ """Render the page footer.
+ Override this method to customize footers. It is called for every rendered page.
"""
- # Position at 1.5 cm from bottom
- self.set_y(-15)
- self.set_x(self._left_margin)
- # Arial italic 8
- self.set_font("helvetica", "I", 8)
- self.set_text_color(*self.report_specs.font_header_color)
- # Page number
- self.cell(0, 10, self.report_specs.footer_text, 0, align="L")
- self.cell(0, 10, "Page " + str(self.page_no()) + "/{nb}", align="R")
+ if (
+ self._active_page_builder is not None
+ and self._active_page_width_mm is not None
+ and self._active_page_number is not None
+ and self._active_total_pages is not None
+ ):
+ self._render_footer(
+ self._active_page_builder,
+ self._active_page_width_mm,
+ self._active_page_number,
+ self._active_total_pages,
+ )
+
+ def _invoke_header(self, page_builder, page_width_mm: float, page_height_mm: float) -> None:
+ self._active_page_builder = page_builder
+ self._active_page_width_mm = page_width_mm
+ self._active_page_height_mm = page_height_mm
+ self.header()
+
+ def _invoke_footer(self, page_builder, page_width_mm: float, page_number: int, total_pages: int) -> None:
+ self._active_page_builder = page_builder
+ self._active_page_width_mm = page_width_mm
+ self._active_page_number = page_number
+ self._active_total_pages = total_pages
+ self.footer()
def create(self, add_cover_page: bool = True, add_new_section_after: bool = True) -> bool:
- """Create a new report using ``report_specs`` properties.
+ """Create a new report using ``report_specs`` properties."""
+ self._pages = []
+ self._current_page = None
+ self._toc_requested = False
+ self.__chapter_idx = 0
+ self.__sub_chapter_idx = 0
+ self.__figure_idx = 1
+ self.__table_idx = 1
+ if add_cover_page:
+ self._append_page(self.use_portrait, "a4", kind="cover")
+ if add_new_section_after:
+ self.add_section()
+ return True
- Parameters
- ----------
- add_cover_page : bool, optional
- Whether to add cover page or not. Default is ``True``.
- add_new_section_after : bool, optional
- Whether if add a new section after the cover page or not.
+ def add_section(self, portrait: bool | None = None, page_format: str = "a4") -> None:
+ """Add a new section to Pdf."""
+ if portrait is None:
+ portrait = self.use_portrait
+ self._append_page(portrait, page_format)
- Returns
- -------
- :class:`AnsysReport`
+ def add_page_break(self) -> None:
+ """Add a new page break line."""
+ page = self._ensure_current_page()
+ self._append_page(page.portrait, page.page_format)
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.create()
+ def add_chapter(self, chapter_name: str) -> bool:
+ """Add a new chapter."""
+ self.__chapter_idx += 1
+ self.__sub_chapter_idx = 0
+ self._ensure_current_page().blocks.append(
+ _PdfOxideTextBlock(
+ text=f"{self.__chapter_idx} {chapter_name}",
+ font_size=self.report_specs.title_font_size,
+ color=tuple(self.report_specs.font_chapter_color),
+ bold=True,
+ outline_level=0,
+ )
+ )
+ return True
- """
- if add_cover_page:
- self.__add_cover_page()
- if add_new_section_after:
- self.add_page("P" if self.use_portrait else "L")
- self._left_margin = self.l_margin
+ def add_sub_chapter(self, chapter_name: str) -> bool:
+ """Add a new sub-chapter."""
+ self.__sub_chapter_idx += 1
+ self._ensure_current_page().blocks.append(
+ _PdfOxideTextBlock(
+ text=f" {self.__chapter_idx}.{self.__sub_chapter_idx} {chapter_name}",
+ font_size=self.report_specs.subtitle_font_size,
+ color=tuple(self.report_specs.font_subchapter_color),
+ italic=True,
+ outline_level=1,
+ )
+ )
return True
- def add_project_info(self, design) -> bool:
- """Add project information.
+ def add_caption(self, content: str) -> None:
+ """Add a centered italic caption."""
+ self._ensure_current_page().blocks.append(
+ _PdfOxideTextBlock(
+ text=content,
+ font_size=self.report_specs.caption_font_size,
+ color=tuple(self.report_specs.font_caption_color),
+ italic=True,
+ align="center",
+ outline_level=1,
+ )
+ )
- Parameters
- ----------
- design : object
- Starting application object. For example, ``hfss1= HFSS3DLayout()``.
+ def add_text(self, content: str, bold: bool = False, italic: bool = False) -> None:
+ """Add a new text."""
+ self._ensure_current_page().blocks.append(
+ _PdfOxideTextBlock(
+ text=content,
+ font_size=self.report_specs.text_font_size,
+ color=tuple(self.report_specs.font_color),
+ bold=bold,
+ italic=italic,
+ )
+ )
- Returns
- -------
- bool
+ def add_empty_line(self, num_lines: int = 1) -> None:
+ """Add a new empty line."""
+ self._ensure_current_page().blocks.append(_PdfOxideSpacerBlock(lines=num_lines))
+
+ def add_image(self, path: str, caption: str = "", width: int = 0, height: int = 0) -> bool:
+ """Add a new image."""
+ self._ensure_current_page().blocks.append(
+ _PdfOxideImageBlock(
+ path=path,
+ caption=caption,
+ width_mm=None if width == 0 else float(width),
+ height_mm=None if height == 0 else float(height),
+ )
+ )
+ return True
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_project_info()
+ @requires_graphical_dependency("pillow")
+ def add_image_with_aspect_ratio(
+ self, path: str, caption: str = "", max_width: float | None = None, max_height: float | None = None
+ ) -> bool:
+ """Add an image while maintaining its aspect ratio."""
+ self._ensure_current_page().blocks.append(
+ _PdfOxideImageBlock(
+ path=path,
+ caption=caption,
+ max_width_mm=max_width,
+ max_height_mm=max_height,
+ keep_aspect_ratio=True,
+ )
+ )
+ return True
- """
- self.add_page("P" if self.use_portrait else "L")
+ def add_table(
+ self,
+ title: str,
+ content: list[list],
+ formatting: list | None = None,
+ col_widths: list | None = None,
+ full_width: bool = False,
+ ) -> None:
+ """Add a new table from a list of data."""
+ self._ensure_current_page().blocks.append(
+ _PdfOxideTableBlock(
+ title=title,
+ content=content,
+ formatting=formatting,
+ col_widths=col_widths,
+ full_width=full_width,
+ )
+ )
+
+ def add_toc(self) -> None:
+ """Add toc."""
+ self._toc_requested = True
+
+ @requires_graphical_dependency("matplotlib", "pillow")
+ def add_chart(self, x_values: list, y_values: list, x_caption: str, y_caption: str, title: str) -> None:
+ """Add a chart to the report using matplotlib."""
+ from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
+ from matplotlib.figure import Figure
+ import numpy as np
+
+ dpi = 100.0
+ figsize = (2000 / dpi, 2000 / dpi)
+ fig = Figure(figsize=figsize, dpi=dpi)
+ fig.subplots_adjust(top=0.8)
+ ax = fig.add_subplot(1, 1, 1)
+ ax.set_ylabel(y_caption)
+ ax.set_title(title)
+ x = np.array(x_values)
+ y = np.array(y_values)
+ ax.plot(x, y, color="blue", lw=2)
+ ax.set_xlabel(x_caption)
+ canvas = FigureCanvas(fig)
+ canvas.draw()
+ image_buffer = io.BytesIO()
+ fig.savefig(image_buffer, format="png", dpi=dpi)
+ self._ensure_current_page().blocks.append(_PdfOxideImageBlock(image_bytes=image_buffer.getvalue()))
+
+ def add_project_info(self, design) -> bool:
+ """Add project information."""
+ self.add_section()
self.add_chapter(f"Design {design.design_name} Info")
- msg = f"This section will contain information about project {design.project_name}"
- msg += f" for design {design.design_name}."
- self.add_text(msg)
- msg = f"The design is a {design.design_type} model."
- self.add_text(msg)
+ self.add_text(
+ "This section will contain information about project "
+ f"{design.project_name} for design {design.design_name}."
+ )
+ self.add_text(f"The design is a {design.design_type} model.")
if design.design_type in [
"Q3D Extractor",
"Maxwell 3D",
@@ -366,8 +602,7 @@ def add_project_info(self, design) -> bool:
"Maxwell 2D",
"2D Extractor",
]:
- msg = f"Simulation bounding box is {design.modeler.get_model_bounding_box()}."
- self.add_text(msg)
+ self.add_text(f"Simulation bounding box is {design.modeler.get_model_bounding_box()}.")
image_path = os.path.join(design.working_directory, "model.jpg")
design.plot(
show=False,
@@ -379,42 +614,27 @@ def add_project_info(self, design) -> bool:
self.add_image(image_path, "Model Image")
elif design.design_type in ["HFSS3DLayout", "HFSS 3D Layout Design"]:
stats = design.modeler.edb.get_statistics()
- msg = f"The layout has {stats.num_capacitors} capacitors, {stats.num_resistors} resistors,"
- msg += f"{stats.num_inductors} inductors. The design size is {stats.layout_size}."
- self.add_text(msg)
- msg = f"Furthermore, the layout has {stats.num_nets} nets, {stats.num_traces} traces,"
- msg += f" {stats.num_vias} vias. The stackup total thickness is {stats.stackup_thickness}."
+ self.add_text(
+ f"The layout has {stats.num_capacitors} capacitors, "
+ f"{stats.num_resistors} resistors, {stats.num_inductors} inductors. "
+ f"The design size is {stats.layout_size}."
+ )
+ self.add_text(
+ f"Furthermore, the layout has {stats.num_nets} nets, {stats.num_traces} traces, {stats.num_vias} vias. "
+ f"The stackup total thickness is {stats.stackup_thickness}."
+ )
image_path = os.path.join(design.working_directory, "model.jpg")
design.modeler.edb.nets.plot(show=False, save_plot=image_path)
if os.path.exists(image_path):
self.add_image(image_path, "Model Image")
elif design.design_type in ["Circuit Design"]:
- msg = f"The schematic has {len(design.modeler.schematic.components)} components."
- self.add_text(msg)
-
+ self.add_text(f"The schematic has {len(design.modeler.schematic.components)} components.")
if design.setups:
- msg = f"The design has {len(design.setups)} simulation setups."
- self.add_text(msg)
-
+ self.add_text(f"The design has {len(design.setups)} simulation setups.")
return True
@property
def template_name(self) -> str:
- """Name of the template to use.
-
- It can be a full json path or a string of a json contained in ``"Images"`` folder.
-
- Returns
- -------
- str
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.template_name
-
- """
return self.report_specs.template_name
@template_name.setter
@@ -423,19 +643,6 @@ def template_name(self, value: str) -> None:
@property
def design_name(self) -> str:
- """Get/set the design name for report header.
-
- Returns
- -------
- str
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.design_name
-
- """
return self.report_specs.design_name
@design_name.setter
@@ -444,19 +651,6 @@ def design_name(self, value: str) -> None:
@property
def project_name(self) -> str:
- """Get/set the project name for report header.
-
- Returns
- -------
- str
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.project_name
-
- """
return self.report_specs.project_name
@project_name.setter
@@ -465,479 +659,692 @@ def project_name(self, value: str) -> None:
@property
def aedt_version(self) -> str:
- """Get/set the aedt version for report header.
-
- Returns
- -------
- str
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.aedt_version
-
- """
return self.report_specs.ansys_version
@aedt_version.setter
def aedt_version(self, value: str) -> None:
self.report_specs.ansys_version = value
- def add_section(self, portrait: bool = None, page_format: str = "a4") -> None:
- """Add a new section to Pdf.
-
- Parameters
- ----------
- portrait : bool, optional
- Section orientation. Default ``True`` for portrait.
- page_format : str, optional
- Currently supported formats are a3, a4, a5, letter, legal or a tuple (width, height).
-
- Returns
- -------
- int,
- Section id.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_section()
-
- """
- orientation = "portrait"
- if portrait is False:
- orientation = "landscape"
- elif portrait is None:
- orientation = "P" if self.use_portrait else "L"
- self.add_page(orientation=orientation, format=page_format)
-
- def add_chapter(self, chapter_name: str) -> bool:
- """Add a new chapter.
-
- Parameters
- ----------
- chapter_name : str
- Chapter name.
+ def _render_header(self, page_builder, page_width_mm: float, page_height_mm: float) -> None:
+ from datetime import date
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_chapter("Chapter 1")
+ page_width_pt = _mm_to_pt(page_width_mm)
+ margin_left_pt = _mm_to_pt(self._left_margin_mm())
+ margin_right_pt = _mm_to_pt(self._right_margin_mm())
+ text_color = tuple(self.report_specs.font_header_color)
+ logo_width_pt = 0.0
+ logo_gap_pt = _mm_to_pt(4.0)
+ if os.path.exists(self.report_specs.logo_name):
+ logo_bytes, logo_width_px, logo_height_px = _read_image_bytes(self.report_specs.logo_name)
+ logo_width_mm = self._margin_mm(self.report_specs.header_image_width)
+ logo_width_pt = _mm_to_pt(logo_width_mm)
+ if logo_width_px and logo_height_px:
+ logo_height_mm = logo_width_mm * logo_height_px / logo_width_px
+ else:
+ logo_height_mm = 8.0
+ logo_x_pt = page_width_pt - margin_right_pt - logo_width_pt
+ logo_y_pt = _mm_to_pt(page_height_mm - 10.0 - logo_height_mm)
+ page_builder.image_artifact(
+ logo_bytes,
+ logo_x_pt,
+ logo_y_pt,
+ logo_width_pt,
+ _mm_to_pt(logo_height_mm),
+ )
- """
- self.__chapter_idx += 1
- self.__sub_chapter_idx = 0
- txt = f"{self.__chapter_idx} {chapter_name}"
- self.set_font(self.report_specs.font.lower(), "B", self.report_specs.title_font_size)
- self.start_section(txt)
- self.set_text_color(*self.report_specs.font_chapter_color)
- self.cell(
- 0,
- 12,
- txt,
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
+ fields_left_pt = margin_left_pt
+ fields_right_pt = page_width_pt - margin_right_pt - logo_width_pt - logo_gap_pt
+ fields = [
+ ("Project Name", self.report_specs.project_name),
+ ("Design Name", self.report_specs.design_name),
+ ("Ansys Version", self.report_specs.ansys_version),
+ ("PyAEDT Version", self.report_specs.pyaedt_version),
+ ("Date", str(date.today())),
+ ("Revision", self.report_specs.revision),
+ ]
+ column_width_pt = max(1.0, (fields_right_pt - fields_left_pt) / len(fields))
+ label_y_pt = _mm_to_pt(page_height_mm - 15.0)
+ value_y_pt = _mm_to_pt(page_height_mm - 18.2)
+ page_builder.font(_font_name(self.report_specs.font), self.report_specs.header_font_size)
+ for index, (label, value) in enumerate(fields):
+ x_pt = fields_left_pt + column_width_pt * index
+ _draw_colored_text(page_builder, x_pt, label_y_pt, label, text_color)
+ for line_index, line in enumerate(_wrap_text_lines(page_builder, str(value), column_width_pt)):
+ _draw_colored_text(
+ page_builder,
+ x_pt,
+ value_y_pt - line_index * self.report_specs.header_font_size,
+ line,
+ text_color,
+ )
+
+ line_y_pt = _mm_to_pt(page_height_mm - (self._top_margin_mm() - 7.0))
+ page_builder.stroke_line(margin_left_pt, line_y_pt, page_width_pt - margin_right_pt, line_y_pt)
+
+ def _render_footer(self, page_builder, page_width_mm: float, page_number: int, total_pages: int) -> None:
+ page_width_pt = _mm_to_pt(page_width_mm)
+ margin_left_pt = _mm_to_pt(self._left_margin_mm())
+ margin_right_pt = _mm_to_pt(self._right_margin_mm())
+ footer_color = tuple(self.report_specs.font_header_color)
+ page_text = f"Page {page_number}/{total_pages}"
+ footer_y_pt = _mm_to_pt(15.0)
+ page_builder.font("Helvetica-Oblique", 8.0)
+ page_text_width_pt = page_builder.measure(page_text)
+ _draw_colored_text(page_builder, margin_left_pt, footer_y_pt, self.report_specs.footer_text, footer_color)
+ _draw_colored_text(
+ page_builder, page_width_pt - margin_right_pt - page_text_width_pt, footer_y_pt, page_text, footer_color
)
- self.set_font(self.report_specs.font.lower(), "I", self.report_specs.text_font_size)
- self.set_text_color(*self.report_specs.font_color)
- return True
- def add_sub_chapter(self, chapter_name: str) -> bool:
- """Add a new sub-chapter.
-
- Parameters
- ----------
- chapter_name : str
- Chapter name.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_sub_chapter("Sub-chapter 1")
-
- """
- self.__sub_chapter_idx += 1
- txt = f" {self.__chapter_idx}.{self.__sub_chapter_idx} {chapter_name}"
- self.set_font(self.report_specs.font.lower(), "I", self.report_specs.subtitle_font_size)
- self.start_section(txt.strip(), level=1)
- self.set_text_color(*self.report_specs.font_subchapter_color)
- self.cell(
- 0,
- 10,
- txt,
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
+ def _render_cover_page(self, page_builder, page_width_mm: float, page_height_mm: float) -> None:
+ start_y_mm = 55.0
+ left_pt = _mm_to_pt(self._left_margin_mm())
+ page_builder.font(_font_name(self.report_specs.font, bold=True), self.report_specs.cover_subtitle_font_size)
+ page_builder.at(left_pt, _mm_to_pt(page_height_mm - start_y_mm)).text("Simulation Report")
+ start_y_mm += 22.0
+ page_builder.font(_font_name(self.report_specs.font, bold=True), self.report_specs.cover_title_font_size)
+ page_builder.at(left_pt, _mm_to_pt(page_height_mm - start_y_mm)).text(
+ f"Project Name: {self.report_specs.project_name}"
+ )
+ start_y_mm += 20.0
+ page_builder.at(left_pt, _mm_to_pt(page_height_mm - start_y_mm)).text(
+ f"Design Name: {self.report_specs.design_name}"
)
- self.set_font(self.report_specs.font.lower(), "I", self.report_specs.text_font_size)
- self.set_text_color(*self.report_specs.font_color)
-
- return True
- def add_image(
+ def _advance_content_page(
self,
- path: str,
- caption: str = "",
- width: int = 0,
- height: int = 0,
- ) -> bool:
- """Add a new image.
-
- Parameters
- ----------
- path : str
- Image path.
- caption : str, optional
- Image caption.
- width : int, optional
- Image width in millimeters.
- height : int, optional
- Image height in millimeters.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_image("image_path", "Image caption", width=100, height=100)
-
- """
- if width == 0:
- width = self.epw
+ page_builder,
+ page_width_mm: float,
+ page_height_mm: float,
+ page_number: int,
+ total_pages: int,
+ draw: bool,
+ ):
+ if draw:
+ self._invoke_footer(page_builder, page_width_mm, page_number, total_pages)
+ page_builder = page_builder.new_page_same_size()
+ self._invoke_header(page_builder, page_width_mm, page_height_mm)
+ else:
+ page_builder = page_builder.new_page_same_size()
+ return page_builder, self._content_start_y_mm(), page_number + 1
- self.image(path, h=height, w=width, x=self.epw / 2 - width / 2 + self._left_margin)
- if caption:
- caption = f"Figure {self.__figure_idx}. {caption}"
- self.add_caption(caption)
+ def _render_text_block(
+ self,
+ page_builder,
+ block: _PdfOxideTextBlock,
+ cursor_y_mm: float,
+ page_width_mm: float,
+ page_height_mm: float,
+ page_number: int,
+ total_pages: int,
+ draw: bool,
+ ):
+ page_builder.font(_font_name(self.report_specs.font, bold=block.bold, italic=block.italic), block.font_size)
+ available_width_pt = _mm_to_pt(self._effective_width_mm(page_width_mm))
+ lines = _wrap_text_lines(page_builder, block.text, available_width_pt)
+ line_height_mm = _pt_to_mm(block.font_size * block.leading)
+ left_pt = _mm_to_pt(self._left_margin_mm())
+ added_outline = []
+ for index, line in enumerate(lines):
+ if cursor_y_mm + line_height_mm > self._content_end_y_mm(page_height_mm):
+ page_builder, cursor_y_mm, page_number = self._advance_content_page(
+ page_builder, page_width_mm, page_height_mm, page_number, total_pages, draw
+ )
+ page_builder.font(
+ _font_name(self.report_specs.font, bold=block.bold, italic=block.italic),
+ block.font_size,
+ )
+ y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - line_height_mm)
+ x_pt = left_pt
+ if block.align == "center":
+ x_pt = left_pt + max(0.0, (available_width_pt - page_builder.measure(line)) / 2.0)
+ if draw:
+ _draw_colored_text(page_builder, x_pt, y_pt, line, block.color)
+ if index == 0 and block.outline_level is not None:
+ added_outline.append((block.outline_level, block.text.strip(), page_number))
+ cursor_y_mm += line_height_mm
+ return page_builder, cursor_y_mm + 1.5, page_number, added_outline
+
+ def _render_image_block(
+ self,
+ page_builder,
+ block: _PdfOxideImageBlock,
+ cursor_y_mm: float,
+ page_width_mm: float,
+ page_height_mm: float,
+ page_number: int,
+ total_pages: int,
+ draw: bool,
+ ):
+ if block.path:
+ image_bytes, image_width_px, image_height_px = _read_image_bytes(block.path)
+ elif block.image_bytes:
+ image_bytes = block.image_bytes
+ try:
+ from PIL import Image
+
+ with Image.open(io.BytesIO(image_bytes)) as img:
+ image_width_px, image_height_px = img.size
+ except Exception: # pragma: no cover
+ image_width_px = None
+ image_height_px = None
+ else:
+ return page_builder, cursor_y_mm, page_number, []
+
+ available_width_mm = self._effective_width_mm(page_width_mm)
+ width_mm = block.width_mm or block.max_width_mm or available_width_mm
+ height_mm = block.height_mm
+ page_capacity_mm = self._content_end_y_mm(page_height_mm) - self._content_start_y_mm()
+ caption_height_mm = _pt_to_mm(self.report_specs.caption_font_size * 1.35) + 3.0 if block.caption else 0.0
+ minimum_image_width_mm = 50.0
+ minimum_image_height_mm = 35.0
+ if image_width_px and image_height_px:
+ aspect_ratio = image_height_px / image_width_px
+ if block.keep_aspect_ratio:
+ max_width_mm = block.max_width_mm or max(0.0, available_width_mm - 50.0)
+ max_height_mm = block.max_height_mm or max(
+ 0.0, self._content_end_y_mm(page_height_mm) - cursor_y_mm - caption_height_mm
+ )
+ width_mm = max_width_mm
+ height_mm = width_mm * aspect_ratio
+ if max_height_mm and height_mm > max_height_mm:
+ height_mm = max_height_mm
+ width_mm = height_mm / aspect_ratio
+ elif height_mm is None:
+ height_mm = width_mm * aspect_ratio
+ elif height_mm is None:
+ height_mm = width_mm
+
+ if (
+ block.keep_aspect_ratio
+ and cursor_y_mm > self._content_start_y_mm()
+ and (width_mm < minimum_image_width_mm or height_mm < minimum_image_height_mm)
+ ):
+ page_builder, cursor_y_mm, page_number = self._advance_content_page(
+ page_builder, page_width_mm, page_height_mm, page_number, total_pages, draw
+ )
+ max_width_mm = block.max_width_mm or max(0.0, available_width_mm - 50.0)
+ max_height_mm = block.max_height_mm or max(
+ 0.0, self._content_end_y_mm(page_height_mm) - cursor_y_mm - caption_height_mm
+ )
+ width_mm = max_width_mm
+ height_mm = width_mm * aspect_ratio if image_width_px and image_height_px else width_mm
+ if max_height_mm and height_mm > max_height_mm and image_width_px and image_height_px:
+ height_mm = max_height_mm
+ width_mm = height_mm / aspect_ratio
+
+ if (
+ cursor_y_mm + height_mm > self._content_end_y_mm(page_height_mm)
+ and cursor_y_mm > self._content_start_y_mm()
+ ):
+ page_builder, cursor_y_mm, page_number = self._advance_content_page(
+ page_builder, page_width_mm, page_height_mm, page_number, total_pages, draw
+ )
+ if block.keep_aspect_ratio:
+ max_height_mm = block.max_height_mm or max(
+ 0.0, self._content_end_y_mm(page_height_mm) - cursor_y_mm - caption_height_mm
+ )
+ if height_mm > max_height_mm:
+ if image_width_px and image_height_px:
+ aspect_ratio = image_height_px / image_width_px
+ height_mm = max_height_mm
+ width_mm = height_mm / aspect_ratio
+ elif height_mm > page_capacity_mm and height_mm > 0:
+ scale = page_capacity_mm / height_mm
+ height_mm = page_capacity_mm
+ width_mm *= scale
+
+ if cursor_y_mm + height_mm > self._content_end_y_mm(page_height_mm) and height_mm > 0:
+ if image_width_px and image_height_px:
+ aspect_ratio = image_height_px / image_width_px
+ height_mm = max(1.0, self._content_end_y_mm(page_height_mm) - cursor_y_mm)
+ width_mm = height_mm / aspect_ratio
+ else:
+ height_mm = max(1.0, self._content_end_y_mm(page_height_mm) - cursor_y_mm)
+
+ x_mm = self._left_margin_mm() + max(0.0, (available_width_mm - width_mm) / 2.0)
+ y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - height_mm)
+ if draw:
+ page_builder.image_artifact(
+ image_bytes,
+ _mm_to_pt(x_mm),
+ y_pt,
+ _mm_to_pt(width_mm),
+ _mm_to_pt(height_mm),
+ )
+ cursor_y_mm += height_mm + 2.0
+ outline_entries = []
+ if block.caption:
+ caption = f"Figure {self.__figure_idx}. {block.caption}"
self.__figure_idx += 1
- return True
-
- @requires_graphical_dependency("pillow")
- def add_image_with_aspect_ratio(
- self, path: str, caption: str = "", max_width: float = None, max_height: float = None
- ) -> bool:
- """Add an image to the PDF while maintaining its aspect ratio and fitting within the specified dimensions.
-
- Parameters
- ----------
- path: str
- Path to the image file.
- caption: str, optional
- Image caption.
- max_width: float, int, optional
- Maximum width available for the image.
- max_height: float, int, optional
- Maximum height available for the image.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_image_with_aspect_ratio("path/to/image.png", caption="My Image", max_width=150, max_height=100)
-
- """
- from PIL import Image
+ outline_entries.append((2, caption, page_number))
+ page_builder, cursor_y_mm, page_number, _ = self._render_text_block(
+ page_builder,
+ _PdfOxideTextBlock(
+ text=caption,
+ font_size=self.report_specs.caption_font_size,
+ color=tuple(self.report_specs.font_caption_color),
+ italic=True,
+ align="center",
+ ),
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ page_number,
+ total_pages,
+ draw,
+ )
+ return page_builder, cursor_y_mm + 1.0, page_number, outline_entries
- # Get image dimensions
- with Image.open(path) as img:
- img_width, img_height = img.size
-
- # Get page dimensions
- if max_width is None:
- max_width = self.epw - 50 # Default to page width minus margins
- if max_height is None:
- max_height = self.eph - 30 - (self.y - self.t_margin) # Default to page height minus margins
- if max_height < 0:
- self.add_page_break()
- max_height = self.eph - 30 - (self.y - self.t_margin)
- # Calculate aspect ratio
- aspect_ratio = img_width / img_height
-
- # Scale dimensions to fit within max_width and max_height
- if max_width / aspect_ratio <= max_height:
- width = max_width
- height = max_width / aspect_ratio
+ def _render_table_block(
+ self,
+ page_builder,
+ block: _PdfOxideTableBlock,
+ cursor_y_mm: float,
+ page_width_mm: float,
+ page_height_mm: float,
+ page_number: int,
+ total_pages: int,
+ draw: bool,
+ ):
+ available_width_mm = self._effective_width_mm(page_width_mm)
+ normal_width_mm = min(available_width_mm, 160.0 if page_width_mm <= page_height_mm else 260.0)
+ table_total_width_mm = available_width_mm if block.full_width else normal_width_mm
+ if block.col_widths:
+ total_width = float(sum(block.col_widths))
+ scale = table_total_width_mm / total_width if total_width else 1.0
+ col_widths_mm = [float(width) * scale for width in block.col_widths]
else:
- height = max_height
- width = max_height * aspect_ratio
-
- # Add the image to the PDF
- return self.add_image(path, caption=caption, width=width, height=height)
-
- def add_caption(self, content: str) -> None:
- """Add a new caption.
-
- Parameters
- ----------
- content : str
- Caption name.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_caption("Figure 1: My Figure")
-
- """
- self.set_font(self.report_specs.font.lower(), "I", self.report_specs.caption_font_size)
- self.set_text_color(*self.report_specs.font_caption_color)
- self.start_section(content, level=1)
- self.cell(
- 0,
- 6,
- content,
- new_x="LMARGIN",
- new_y="NEXT",
- align="C",
+ col_widths_mm = [table_total_width_mm / len(block.content[0])] * len(block.content[0])
+
+ table_font = self.report_specs.table_font_size
+ page_builder.font(_font_name(self.report_specs.font), table_font)
+ line_counts = [
+ max(
+ 1,
+ max(
+ (
+ len(_wrap_text_lines(page_builder, str(row[index]), _mm_to_pt(col_widths_mm[index] - 2.0)))
+ for index in range(min(len(row), len(col_widths_mm)))
+ ),
+ default=1,
+ ),
+ )
+ for row in block.content
+ ]
+ sorted_counts = sorted(line_counts)
+ typical_lines = sorted_counts[len(sorted_counts) // 2] if sorted_counts else 1
+ while table_font > 6 and max(line_counts, default=1) > max(3, typical_lines * 2):
+ table_font -= 1
+ page_builder.font(_font_name(self.report_specs.font), table_font)
+ line_counts = [
+ max(
+ 1,
+ max(
+ (
+ len(_wrap_text_lines(page_builder, str(row[index]), _mm_to_pt(col_widths_mm[index] - 2.0)))
+ for index in range(min(len(row), len(col_widths_mm)))
+ ),
+ default=1,
+ ),
+ )
+ for row in block.content
+ ]
+ sorted_counts = sorted(line_counts)
+ typical_lines = sorted_counts[len(sorted_counts) // 2] if sorted_counts else 1
+ line_height_mm = _pt_to_mm(table_font * 1.3)
+ padding_mm = 1.0
+ cursor_y_mm += 1.0
+ outline_entries = [(2, f"Table {self.__table_idx}: {block.title}", page_number)]
+ rows = block.content
+ if not rows:
+ return page_builder, cursor_y_mm, page_number, outline_entries
+
+ header_row = rows[0]
+ if len(rows) > 1:
+ page_builder.font(_font_name(self.report_specs.font, bold=True), table_font)
+ header_lines = [
+ _wrap_text_lines(page_builder, str(datum), _mm_to_pt(col_widths_mm[index] - 2 * padding_mm))
+ for index, datum in enumerate(header_row)
+ ]
+ page_builder.font(_font_name(self.report_specs.font), table_font)
+ first_data_lines = [
+ _wrap_text_lines(page_builder, str(datum), _mm_to_pt(col_widths_mm[index] - 2 * padding_mm))
+ for index, datum in enumerate(rows[1])
+ ]
+ header_height_mm = max(
+ (max(len(lines), 1) * line_height_mm + 2 * padding_mm for lines in header_lines),
+ default=line_height_mm + 2 * padding_mm,
+ )
+ first_data_height_mm = max(
+ (max(len(lines), 1) * line_height_mm + 2 * padding_mm for lines in first_data_lines),
+ default=line_height_mm + 2 * padding_mm,
+ )
+ first_rows_height_mm = header_height_mm + first_data_height_mm
+ if (
+ cursor_y_mm + first_rows_height_mm > self._content_end_y_mm(page_height_mm)
+ and cursor_y_mm > self._content_start_y_mm()
+ ):
+ page_builder, cursor_y_mm, page_number = self._advance_content_page(
+ page_builder, page_width_mm, page_height_mm, page_number, total_pages, draw
+ )
+ row_index = 0
+ while row_index < len(rows):
+ row = rows[row_index]
+ row_font = _font_name(self.report_specs.font, bold=row_index == 0)
+ page_builder.font(row_font, table_font)
+ wrapped_cells = []
+ row_height_mm = line_height_mm + 2 * padding_mm
+ for col_index, datum in enumerate(row):
+ cell_width_pt = _mm_to_pt(col_widths_mm[col_index] - 2 * padding_mm)
+ wrapped = _wrap_text_lines(page_builder, str(datum), cell_width_pt)
+ wrapped_cells.append(wrapped)
+ row_height_mm = max(row_height_mm, len(wrapped) * line_height_mm + 2 * padding_mm)
+
+ if (
+ cursor_y_mm + row_height_mm > self._content_end_y_mm(page_height_mm)
+ and cursor_y_mm > self._content_start_y_mm()
+ ):
+ page_builder, cursor_y_mm, page_number = self._advance_content_page(
+ page_builder, page_width_mm, page_height_mm, page_number, total_pages, draw
+ )
+ if row_index > 0:
+ row = header_row
+ row_font = _font_name(self.report_specs.font, bold=True)
+ page_builder.font(row_font, table_font)
+ wrapped_cells = []
+ row_height_mm = line_height_mm + 2 * padding_mm
+ for col_index, datum in enumerate(row):
+ cell_width_pt = _mm_to_pt(col_widths_mm[col_index] - 2 * padding_mm)
+ wrapped = _wrap_text_lines(page_builder, str(datum), cell_width_pt)
+ wrapped_cells.append(wrapped)
+ row_height_mm = max(row_height_mm, len(wrapped) * line_height_mm + 2 * padding_mm)
+ row_index -= 1
+
+ x_mm = self._left_margin_mm() + max(0.0, (available_width_mm - table_total_width_mm) / 2.0)
+ for col_index, cell_lines in enumerate(wrapped_cells):
+ width_mm = col_widths_mm[col_index]
+ y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - row_height_mm)
+ formatting = None
+ if block.formatting and row_index < len(block.formatting):
+ formatting = block.formatting[row_index]
+
+ if row_index == 0:
+ background = (0.78, 0.78, 0.78)
+ else:
+ background = None
+ if formatting:
+ if isinstance(formatting, list) and len(formatting) > 1 and formatting[1]:
+ background = _rgb_fraction(formatting[1])
+
+ if draw and background:
+ page_builder.filled_rect(
+ _mm_to_pt(x_mm),
+ y_pt,
+ _mm_to_pt(width_mm),
+ _mm_to_pt(row_height_mm),
+ *background,
+ )
+ if draw:
+ page_builder.stroke_rect(_mm_to_pt(x_mm), y_pt, _mm_to_pt(width_mm), _mm_to_pt(row_height_mm))
+ page_builder.font(row_font, table_font)
+ cell_width_pt = _mm_to_pt(width_mm - 2 * padding_mm)
+ for line_index, line in enumerate(cell_lines):
+ text_x_pt = _mm_to_pt(x_mm + padding_mm) + max(
+ 0.0, (cell_width_pt - page_builder.measure(line)) / 2.0
+ )
+ text_y_pt = _mm_to_pt(
+ page_height_mm - cursor_y_mm - padding_mm - (line_index + 1) * line_height_mm
+ )
+ page_builder.at(text_x_pt, text_y_pt).text(line)
+ x_mm += width_mm
+ cursor_y_mm += row_height_mm
+ row_index += 1
+
+ page_builder, cursor_y_mm, page_number, _ = self._render_text_block(
+ page_builder,
+ _PdfOxideTextBlock(
+ text=f"Table {self.__table_idx}: {block.title}",
+ font_size=self.report_specs.caption_font_size,
+ color=tuple(self.report_specs.font_caption_color),
+ italic=True,
+ align="center",
+ ),
+ cursor_y_mm + 1.0,
+ page_width_mm,
+ page_height_mm,
+ page_number,
+ total_pages,
+ draw,
)
- self.set_font(self.report_specs.font.lower(), "I", self.report_specs.text_font_size)
- self.set_text_color(*self.report_specs.font_color)
-
- def add_empty_line(self, num_lines: int = 1) -> None:
- """Add a new empty line.
-
- Parameters
- ----------
- num_lines : int, optional
- Number of lines to add.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_empty_line(num_lines=2)
-
- """
- self.ln(num_lines * self.font_size)
-
- def add_page_break(self) -> None:
- """Add a new page break line.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_page_break()
-
- """
- self.add_page("P" if self.use_portrait else "L")
+ self.__table_idx += 1
+ return page_builder, cursor_y_mm + 1.0, page_number, outline_entries
- def add_table(
+ def _render_toc_page(
self,
- title: str,
- content: list[list],
- formatting: list = None,
- col_widths: list = None,
+ page_builder,
+ page_width_mm: float,
+ page_height_mm: float,
+ outline_entries: list[tuple[int, str, int]],
+ toc_page: int = 0,
) -> None:
- """Add a new table from a list of data.
-
- Data shall be a list of list where every line is either a row or a column.
-
- Parameters
- ----------
- title : str
- Table title.
- content : list of list
- Table content.
- formatting : list, optional
- List of formatting elements for the table rows. The length of the formatting has
- to be equal to the length of content. Every element is a list of two elements
- (color, background_color). Color is a RGB list.
- col_widths : list, optional
- List of column widths.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_table("Table Title", [["Header1", "Header2"], ["Row1Col1", "Row1Col2"], ["Row2Col1", "Row2Col2"]])
-
- """
- self.set_font(self.report_specs.font.lower(), size=self.report_specs.text_font_size)
- self.set_font(self.report_specs.font.lower(), size=self.report_specs.table_font_size)
- with self.table(
- borders_layout="MINIMAL",
- cell_fill_color=200, # grey
- cell_fill_mode="ROWS",
- line_height=self.font_size * 2.5,
- text_align="CENTER",
- width=160 if self.use_portrait else 260,
- col_widths=col_widths,
- num_heading_rows=1,
- repeat_headings=1,
- ) as table:
- for i, data_row in enumerate(content):
- fill_color = None
- font_color = self.report_specs.font_color
-
- if formatting:
- try:
- font_color = formatting[i][0] if formatting[i][0] else self.report_specs.font_color
- fill_color = formatting[i][1] if formatting[i][1] else None
- except IndexError:
- pass
- style = FontFace(color=font_color, fill_color=fill_color)
-
- row = table.row()
- for datum in data_row:
- row.cell(str(datum), style=style)
- self.add_caption(f"Table {self.__table_idx}: {title}")
- self.__table_idx += 1
-
- def add_text(self, content: str, bold: bool = False, italic: bool = False) -> None:
- """Add a new text.
-
- Parameters
- ----------
- content : str
- Text content.
- bold : bool, optional
- Whether if text is bold or not. Default is ``True``.
- italic : bool, optional
- Whether if text is italic or not. Default is ``True``.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_text("Some text content", bold=True, italic=False)
-
- """
- font_type = ""
- if bold:
- font_type = "B"
- if italic:
- font_type += "I"
- self.set_font(self.report_specs.font.lower(), font_type.lower(), self.report_specs.text_font_size)
- self.set_text_color(*self.report_specs.font_color)
-
- self.multi_cell(
+ cursor_y_mm = self._top_margin_mm() + 5.0
+ _, cursor_y_mm, _, _ = self._render_text_block(
+ page_builder,
+ _PdfOxideTextBlock(
+ text="Table of contents:",
+ font_size=self.report_specs.title_font_size,
+ color=tuple(self.report_specs.font_color),
+ bold=True,
+ ),
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ 0,
0,
- 6,
- content,
- new_x="LMARGIN",
- new_y="NEXT",
- align="L",
+ True,
)
-
- def add_toc(self) -> None:
- """Add toc.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_toc()
-
- """
-
- def p(section_toc, **kwargs) -> None:
- # Inserts a paragraph
- self.cell(w=self.epw, h=self.font_size, text=section_toc, new_x="LMARGIN", new_y="NEXT", **kwargs)
-
- self.add_page("P" if self.use_portrait else "L")
- self.set_font(self.report_specs.font.lower(), size=self.report_specs.title_font_size)
- self.set_text_color(*self.report_specs.font_color)
- self.underline = True
- self.x = self._left_margin
- p("Table of contents:")
- self.underline = False
- self.y += 10
- self.set_font(self.report_specs.font, size=12)
-
- for section in self._outline:
- link = self.add_link()
- self.set_link(link, page=section.page_number)
- string1 = f"{' ' * section.level * 2} {section.name}"[:70]
- string2 = f"Page {section.page_number}"
- self.set_x(self._left_margin * 2)
- self.cell(
- w=self.epw - self._left_margin - self.r_margin,
- h=self.font_size,
- text=string1,
- new_x="LMARGIN",
- new_y="LAST",
- align="L",
- link=link,
- )
- self.set_x(self._left_margin * 2)
- self.cell(
- w=self.epw - self._left_margin - self.r_margin,
- h=self.font_size,
- text=string2,
- new_x="LMARGIN",
- new_y="NEXT",
- align="R",
- link=link,
+ cursor_y_mm += 3.0
+ left_pt = _mm_to_pt(self._left_margin_mm())
+ available_width_pt = _mm_to_pt(self._effective_width_mm(page_width_mm))
+ toc_entry_font_size = 10.0
+ entries_per_page = max(1, int((self._content_end_y_mm(page_height_mm) - cursor_y_mm - 5.0) / 5.5))
+ start = toc_page * entries_per_page
+ end = start + entries_per_page
+ for level, title, page_number in outline_entries[start:end]:
+ entry_font_size = toc_entry_font_size + (1.0 if level == 0 else 0.0)
+ page_builder.font(_font_name(self.report_specs.font, bold=level == 0), entry_font_size)
+ y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - _pt_to_mm(entry_font_size * 1.2))
+ indent_pt = _mm_to_pt(6.0 * level)
+ page_builder.at(left_pt + indent_pt, y_pt).text(title).link_page(max(0, page_number - 1))
+ page_builder.font(_font_name(self.report_specs.font), entry_font_size)
+ page_builder.text_in_rect(
+ left_pt,
+ y_pt,
+ available_width_pt,
+ _pt_to_mm(entry_font_size * 1.2),
+ f"Page {page_number}",
+ "right",
)
+ cursor_y_mm += _pt_to_mm(entry_font_size * 1.2) + 1.5
+
+ def _toc_entry_count(self, pages: list[_PdfOxidePage]) -> int:
+ """Estimate the number of entries needed to paginate the table of contents."""
+ count = 0
+ for page in pages:
+ if page.kind != "content":
+ continue
+ for block in page.blocks:
+ if isinstance(block, _PdfOxideTextBlock) and block.outline_level is not None:
+ count += 1
+ elif isinstance(block, _PdfOxideImageBlock) and block.caption:
+ count += 1
+ elif isinstance(block, _PdfOxideTableBlock):
+ count += 1
+ return count
+
+ def _count_physical_pages(self, pages: list[_PdfOxidePage], DocumentBuilder) -> int:
+ figure_idx = self.__figure_idx
+ table_idx = self.__table_idx
+ total_pages = 0
+ try:
+ for page in pages:
+ total_pages += 1
+ if page.kind != "content":
+ continue
+ page_width_mm, page_height_mm = _page_size_mm(page.page_format, page.portrait)
+ page_builder = DocumentBuilder().page(_mm_to_pt(page_width_mm), _mm_to_pt(page_height_mm))
+ cursor_y_mm = self._content_start_y_mm()
+ current_page_number = total_pages
+ for block in page.blocks:
+ if isinstance(block, _PdfOxideTextBlock):
+ page_builder, cursor_y_mm, current_page_number, _ = self._render_text_block(
+ page_builder,
+ block,
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ 0,
+ False,
+ )
+ elif isinstance(block, _PdfOxideSpacerBlock):
+ cursor_y_mm += block.lines * _pt_to_mm(self.report_specs.text_font_size)
+ if cursor_y_mm > self._content_end_y_mm(page_height_mm):
+ page_builder, cursor_y_mm, current_page_number = self._advance_content_page(
+ page_builder, page_width_mm, page_height_mm, current_page_number, 0, False
+ )
+ elif isinstance(block, _PdfOxideImageBlock):
+ page_builder, cursor_y_mm, current_page_number, _ = self._render_image_block(
+ page_builder,
+ block,
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ 0,
+ False,
+ )
+ elif isinstance(block, _PdfOxideTableBlock):
+ page_builder, cursor_y_mm, current_page_number, _ = self._render_table_block(
+ page_builder,
+ block,
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ 0,
+ False,
+ )
+ total_pages = current_page_number
+ page_builder.done()
+ return total_pages
+ finally:
+ self.__figure_idx = figure_idx
+ self.__table_idx = table_idx
def save_pdf(self, file_path: str, file_name: str | None = None) -> str:
- """Save pdf.
-
- Parameters
- ----------
- file_path : str
- Pdf path.
- file_name : str, optional
- File name.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.save_pdf("path/to/save", "report.pdf")
-
- """
- self.output(os.path.join(file_path, file_name))
- return str(os.path.join(file_path, file_name))
-
- @requires_graphical_dependency("matplotlib", "pillow")
- def add_chart(self, x_values: list, y_values: list, x_caption: str, y_caption: str, title: str) -> None:
- """Add a chart to the report using matplotlib.
-
- Parameters
- ----------
- x_values : list
- list of float x values.
- y_values : list
- List of float y values.
- x_caption : str
- X axis caption.
- y_caption : str
- Y axis caption.
- title : str
- Chart title.
-
- Examples
- --------
- >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport
- >>> obj = AnsysReport()
- >>> obj.add_chart([1, 2, 3], [4, 5, 6], "X Axis", "Y Axis", "Sample Chart")
-
- """
- from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
- from matplotlib.figure import Figure
- import numpy as np
- from PIL import Image
+ """Save pdf."""
+ if file_name is None:
+ raise TypeError("file_name must be provided.")
+ DocumentBuilder = _pdf_oxide_imports()
+ output_path = os.path.join(file_path, file_name)
+ builder = (
+ DocumentBuilder()
+ .title("Simulation Report")
+ .author(self.report_specs.company_name)
+ .creator(f"PyAEDT {self.report_specs.pyaedt_version}")
+ .subject(f"{self.report_specs.project_name} / {self.report_specs.design_name}")
+ )
- dpi = 100.0
- figsize = (2000 / dpi, 2000 / dpi)
- fig = Figure(figsize=figsize, dpi=dpi)
- fig.subplots_adjust(top=0.8)
- ax = fig.add_subplot(1, 1, 1)
- ax.set_ylabel(y_caption)
- ax.set_title(title)
- x = np.array(x_values)
- y = np.array(y_values)
- ax.plot(x, y, color="blue", lw=2)
+ pages = list(self._pages)
+ if self._toc_requested:
+ toc_page_height_mm = _page_size_mm("a4", self.use_portrait)[1]
+ toc_start_y_mm = self._top_margin_mm() + 5.0 + _pt_to_mm(self.report_specs.title_font_size * 1.35) + 3.0
+ toc_entries_per_page = max(
+ 1, int((self._content_end_y_mm(toc_page_height_mm) - toc_start_y_mm - 5.0) / 5.5)
+ )
+ toc_page_count = max(1, (self._toc_entry_count(pages) + toc_entries_per_page - 1) // toc_entries_per_page)
+ pages.extend(
+ _PdfOxidePage(portrait=self.use_portrait, page_format="a4", kind="toc") for _ in range(toc_page_count)
+ )
- ax.set_xlabel(x_caption)
- # Converting Figure to an image:
- canvas = FigureCanvas(fig)
- canvas.draw()
- img = Image.fromarray(np.asarray(canvas.buffer_rgba()))
- self.image(img, w=self.epw) # Make the image full width
+ total_pages = self._count_physical_pages(pages, DocumentBuilder)
+ self.__figure_idx = 1
+ self.__table_idx = 1
+ outline_entries: list[tuple[int, str, int]] = []
+ page_number = 1
+ toc_page = 0
+ for page in pages:
+ page_width_mm, page_height_mm = _page_size_mm(page.page_format, page.portrait)
+ page_builder = builder.page(_mm_to_pt(page_width_mm), _mm_to_pt(page_height_mm))
+
+ if page.kind == "cover":
+ self._invoke_header(page_builder, page_width_mm, page_height_mm)
+ self._render_cover_page(page_builder, page_width_mm, page_height_mm)
+ self._invoke_footer(page_builder, page_width_mm, page_number, total_pages)
+ page_number += 1
+ elif page.kind == "toc":
+ self._invoke_header(page_builder, page_width_mm, page_height_mm)
+ self._render_toc_page(page_builder, page_width_mm, page_height_mm, outline_entries, toc_page)
+ self._invoke_footer(page_builder, page_width_mm, page_number, total_pages)
+ page_number += 1
+ toc_page += 1
+ else:
+ self._invoke_header(page_builder, page_width_mm, page_height_mm)
+ cursor_y_mm = self._content_start_y_mm()
+ current_page_number = page_number
+ for block in page.blocks:
+ if isinstance(block, _PdfOxideTextBlock):
+ page_builder, cursor_y_mm, current_page_number, added_outline = self._render_text_block(
+ page_builder,
+ block,
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ total_pages,
+ True,
+ )
+ outline_entries.extend(added_outline)
+ elif isinstance(block, _PdfOxideSpacerBlock):
+ cursor_y_mm += block.lines * _pt_to_mm(self.report_specs.text_font_size)
+ if cursor_y_mm > self._content_end_y_mm(page_height_mm):
+ page_builder, cursor_y_mm, current_page_number = self._advance_content_page(
+ page_builder,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ total_pages,
+ True,
+ )
+ elif isinstance(block, _PdfOxideImageBlock):
+ page_builder, cursor_y_mm, current_page_number, added_outline = self._render_image_block(
+ page_builder,
+ block,
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ total_pages,
+ True,
+ )
+ outline_entries.extend(added_outline)
+ elif isinstance(block, _PdfOxideTableBlock):
+ page_builder, cursor_y_mm, current_page_number, added_outline = self._render_table_block(
+ page_builder,
+ block,
+ cursor_y_mm,
+ page_width_mm,
+ page_height_mm,
+ current_page_number,
+ total_pages,
+ True,
+ )
+ outline_entries.extend(added_outline)
+ self._invoke_footer(page_builder, page_width_mm, current_page_number, total_pages)
+ page_number = current_page_number + 1
+
+ builder = page_builder.done()
+
+ builder.save(output_path)
+ return str(output_path)
diff --git a/src/ansys/aedt/core/visualization/post/compliance.py b/src/ansys/aedt/core/visualization/post/compliance.py
index 63195e374d4e..88a552aa361e 100644
--- a/src/ansys/aedt/core/visualization/post/compliance.py
+++ b/src/ansys/aedt/core/visualization/post/compliance.py
@@ -2060,7 +2060,11 @@ def _create_project_info(self, report):
report.add_table("Components", components, col_widths=[75, 275])
@pyaedt_function_handler()
- def create_compliance_report(self, file_name: str = "compliance_test.pdf", close_project: bool = True) -> str:
+ def create_compliance_report(
+ self,
+ file_name: str = "compliance_test.pdf",
+ close_project: bool = True,
+ ) -> str:
"""Create the Virtual Compliance report.
Parameters
@@ -2178,10 +2182,6 @@ def create_pdf(self, file_name: str, close_project: bool = True) -> str:
if content["type"] == 2:
report.add_image_with_aspect_ratio(**content["data"])
elif content["type"] == 3:
- y = report.get_y()
- table_height = report.font_size * 5 * len(content["data"]["content"])
- if y > report.h / 2 and y + table_height > (report.h - ((report.h - report.eph) / 2)):
- report.add_page_break()
report.add_table(**content["data"])
elif content["type"] == 1:
report.add_text(content["data"])
diff --git a/tests/system/solvers/test_pdf.py b/tests/system/solvers/test_pdf.py
index d9ce29d646ae..fddf1ad75b69 100644
--- a/tests/system/solvers/test_pdf.py
+++ b/tests/system/solvers/test_pdf.py
@@ -75,6 +75,7 @@ def test_create_pdf(test_tmp_dir) -> None:
report.add_section()
report.add_chapter("Chapter 1")
report.add_sub_chapter("C1")
+ report.add_caption("Figure 1: Coaxial cable")
report.add_text("ciao")
report.add_text("hola", True, True)
report.add_empty_line(2)
@@ -92,6 +93,52 @@ def test_create_pdf(test_tmp_dir) -> None:
assert output.exists()
+def test_create_pdf_with_long_table_cell_text(test_tmp_dir) -> None:
+ report = AnsysReport(design_name="Design1", project_name="Coaxial")
+ report.aedt_version = DESKTOP_VERSION
+ assert "AnsysTemplate" in report.template_name
+ report.template_name = "AnsysTemplate"
+ assert report.project_name == "Coaxial"
+ report.project_name = "Coaxial1"
+ assert report.project_name == "Coaxial1"
+ assert report.design_name == "Design1"
+ report.design_name = "Design2"
+ assert report.design_name == "Design2"
+ report.create()
+ report.add_section()
+ report.add_chapter("Chapter 1")
+ report.add_sub_chapter("C1")
+ report.add_text("ciao")
+ report.add_text("hola", True, True)
+ report.add_empty_line(2)
+ report.add_page_break()
+ report.add_image(
+ str(Path(TESTS_SOLVERS_PATH) / "example_models" / TEST_SUBFOLDER / "Coax_HFSS.jpg"), "Coaxial Cable"
+ )
+ report.add_section(portrait=False, page_format="a3")
+ long_text = (
+ "Some super long text that should be wrapped into multiple lines. It contains several sentences and should be "
+ "split into lines that do not exceed a certain length. The purpose of this test is to ensure that the wrapping "
+ "function works correctly and produces lines of appropriate length."
+ )
+ long_text_2 = (
+ "Some super long text that should be wrapped into multiple lines. It contains several sentences and should be "
+ "split into lines that do not exceed a certain length. The purpose of this test is to ensure that the wrapping "
+ "function works correctly and produces lines of appropriate length. This text is even longer than the previous "
+ "one and should be wrapped into multiple lines. It contains several sentences and should be "
+ )
+ report.add_table(
+ "MyTable", [["x", "y"], ["0", "1"], ["2", "3"], ["10", "20"], [long_text, long_text_2]], full_width=True
+ )
+ report.add_table("MyTable2", [["x", "y"], ["0", "1"], ["2", "3"], ["10", "20"]])
+ report.add_section()
+ report.add_chart([0, 1, 2, 3, 4, 5], [10, 20, 4, 30, 40, 12], "Freq", "Val", "MyTable")
+ report.add_toc()
+ report.save_pdf(test_tmp_dir, "my_firstpdf_oxide.pdf")
+ output = test_tmp_dir / "my_firstpdf_oxide.pdf"
+ assert output.exists()
+
+
def test_create_pdf_schematic(circuit_test) -> None:
report = AnsysReport()
report.create()
diff --git a/uv.lock b/uv.lock
index ccdce0ef8352..1e01d3ed0a20 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1311,20 +1311,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" },
]
-[[package]]
-name = "fpdf2"
-version = "2.8.7"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "defusedxml" },
- { name = "fonttools" },
- { name = "pillow" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/27/f2/72feae0b2827ed38013e4307b14f95bf0b3d124adfef4d38a7d57533f7be/fpdf2-2.8.7.tar.gz", hash = "sha256:7060ccee5a9c7ab0a271fb765a36a23639f83ef8996c34e3d46af0a17ede57f9", size = 362351, upload-time = "2026-02-28T05:39:16.456Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" },
-]
-
[[package]]
name = "fqdn"
version = "1.5.1"
@@ -3103,6 +3089,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" },
]
+[[package]]
+name = "pdf-oxide"
+version = "0.3.74"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/3f/ce4bfd761cf4de338ad5582a8d0488d60e1f91cbdf91b04929c723b33c11/pdf_oxide-0.3.74.tar.gz", hash = "sha256:1dee9fb9e534dfbd5f2d355c7c9531f562477bb048f3673e1657a4f778e9ffc2", size = 6669609, upload-time = "2026-07-14T03:59:25.288Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/c9/b5881db9242a353c38eced6b69a39ca9a09d02b3f8e3191b4840ba4ae9f4/pdf_oxide-0.3.74-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7ff7cfcf78c74be475fabee809c699c21f28dfe516c875024d9112ef4a906208", size = 10658599, upload-time = "2026-07-14T03:59:08.842Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/49/ca6a3b1bef6f429aed7c369d54472dffb4afd0ea4a2bb8642ba049e51316/pdf_oxide-0.3.74-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:2487c3a68840c3264c8c8a1477742ef979538a436f6dd8ca451b7db79c0df931", size = 10082756, upload-time = "2026-07-14T03:59:10.989Z" },
+ { url = "https://files.pythonhosted.org/packages/13/1c/6434ddcb34b3e0ae9205e367c64d8128d4402ebc7b8b80d93c935620819b/pdf_oxide-0.3.74-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d8fcc6f5c82e2053ac3d0287d1b83abe4b3ccf70b315b6a8669bce420adb8047", size = 10383542, upload-time = "2026-07-14T03:59:12.948Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/4e/a8ee8b9b19ef5e2970cd74091fa3350604aed8deb39053ac60b77fa813cb/pdf_oxide-0.3.74-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5644bdacc0b7f81facc656fef6732f413386f114534253cb807278df8afb588f", size = 11001863, upload-time = "2026-07-14T03:59:15.155Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/84/d5a08188c1dd0758dfa844c7fc56851750df248e328a371bad0f5dec3574/pdf_oxide-0.3.74-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0034f26294a3ba0003f65ad6df0243144c89dcd1bc5e65a898ead12432768594", size = 10570413, upload-time = "2026-07-14T03:59:17.204Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/f7/65f0098a6d41e28dcaaf35034fbfea7f2d82ee024947de904e1becc394e2/pdf_oxide-0.3.74-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e327f935c97827e424417cd4373aab7747afc960549a07809f305847ade7f144", size = 11244439, upload-time = "2026-07-14T03:59:19.207Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/b1/7628aff63753e0c4fbb04df222a1ffab57349ee7283c3490171ea0e50f6c/pdf_oxide-0.3.74-cp38-abi3-win_amd64.whl", hash = "sha256:a6a1492116971b20b9607d8ed11606f455d0d94296b32efbcaf110b7905e9e40", size = 11120657, upload-time = "2026-07-14T03:59:21.272Z" },
+ { url = "https://files.pythonhosted.org/packages/54/f5/24424d3f7951cfeb690af7998d8ca753dcdfc69ed447952779f120196c42/pdf_oxide-0.3.74-cp38-abi3-win_arm64.whl", hash = "sha256:eafa8c4b0f8fc8c46e61dec4c869ab1743e244cf99b1708d429ac1df4019675c", size = 10360350, upload-time = "2026-07-14T03:59:23.286Z" },
+]
+
[[package]]
name = "pdf2image"
version = "1.17.0"
@@ -3529,13 +3531,13 @@ dependencies = [
all = [
{ name = "ansys-tools-visualization-interface" },
{ name = "fast-simplification" },
- { name = "fpdf2" },
{ name = "ipython" },
{ name = "jupyterlab" },
{ name = "matplotlib" },
{ name = "openpyxl" },
{ name = "osmnx" },
{ name = "pandas" },
+ { name = "pdf-oxide" },
{ name = "pyvista", extra = ["io"] },
{ name = "requests" },
{ name = "rpyc" },
@@ -3549,13 +3551,13 @@ all-dotnet = [
{ name = "ansys-tools-visualization-interface" },
{ name = "cffi", marker = "sys_platform == 'linux'" },
{ name = "fast-simplification" },
- { name = "fpdf2" },
{ name = "ipython" },
{ name = "jupyterlab" },
{ name = "matplotlib" },
{ name = "openpyxl" },
{ name = "osmnx" },
{ name = "pandas" },
+ { name = "pdf-oxide" },
{ name = "pyvista", extra = ["io"] },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "requests" },
@@ -3572,13 +3574,13 @@ dotnet = [
]
examples = [
{ name = "fast-simplification" },
- { name = "fpdf2" },
{ name = "imageio" },
{ name = "joblib" },
{ name = "matplotlib" },
{ name = "openpyxl" },
{ name = "osmnx" },
{ name = "pandas" },
+ { name = "pdf-oxide" },
{ name = "plotly" },
{ name = "pyvista", extra = ["jupyter"] },
{ name = "rpyc" },
@@ -3588,8 +3590,8 @@ examples = [
]
graphics = [
{ name = "ansys-tools-visualization-interface" },
- { name = "fpdf2" },
{ name = "matplotlib" },
+ { name = "pdf-oxide" },
{ name = "pillow" },
{ name = "pyvista", extra = ["io"] },
{ name = "vtk" },
@@ -3663,10 +3665,6 @@ requires-dist = [
{ name = "fast-simplification", marker = "extra == 'all'", specifier = ">=0.1.7" },
{ name = "fast-simplification", marker = "extra == 'all-dotnet'", specifier = ">=0.1.7" },
{ name = "fast-simplification", marker = "extra == 'examples'", specifier = ">=0.1.7" },
- { name = "fpdf2", marker = "extra == 'all'" },
- { name = "fpdf2", marker = "extra == 'all-dotnet'" },
- { name = "fpdf2", marker = "extra == 'examples'" },
- { name = "fpdf2", marker = "extra == 'graphics'" },
{ name = "grpcio", specifier = ">=1.50.0" },
{ name = "imageio", marker = "extra == 'examples'", specifier = ">=2.34.0" },
{ name = "ipython", marker = "extra == 'jupyter'", specifier = ">=7.30.0" },
@@ -3687,6 +3685,10 @@ requires-dist = [
{ name = "pandas", marker = "extra == 'all'", specifier = ">=1.1.0" },
{ name = "pandas", marker = "extra == 'all-dotnet'", specifier = ">=1.1.0" },
{ name = "pandas", marker = "extra == 'examples'", specifier = ">=1.1.0" },
+ { name = "pdf-oxide", marker = "extra == 'all'" },
+ { name = "pdf-oxide", marker = "extra == 'all-dotnet'" },
+ { name = "pdf-oxide", marker = "extra == 'examples'" },
+ { name = "pdf-oxide", marker = "extra == 'graphics'" },
{ name = "pillow", marker = "extra == 'graphics'" },
{ name = "plotly", marker = "extra == 'examples'", specifier = ">=6.0" },
{ name = "psutil", specifier = ">=6.0.0" },