From 28933806627ce47bf560702ba69011e6ff33d512 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 22 Jul 2026 12:47:10 +0200 Subject: [PATCH 1/7] Add pdf-oxide integration for enhanced PDF report generation --- pyproject.toml | 5 +- src/ansys/aedt/core/internal/checks.py | 3 + src/ansys/aedt/core/visualization/plot/pdf.py | 806 ++++++++++++++++++ uv.lock | 24 + 4 files changed, 837 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 744697d3623a..c3f5fa39e13a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ graphics = [ "matplotlib>=3.5.0", "vtk>=9.0,<9.7", "fpdf2", + "pdf-oxide", "pillow", ] jupyter = [ @@ -76,6 +77,7 @@ all = [ "requests", "typer>=0.20.0", "fpdf2", + "pdf-oxide", "rpyc>=6.0.0", ] all-dotnet = [ @@ -93,6 +95,7 @@ all-dotnet = [ "requests", "typer>=0.20.0", "fpdf2", + "pdf-oxide", "rpyc>=6.0.0", # From 'dotnet' group "ansys-pythonnet>=3.1.0rc3", @@ -111,6 +114,7 @@ examples = [ "plotly>=6.0", "scikit-rf>=0.30.0", "fpdf2", + "pdf-oxide", "rpyc>=6.0.0", "tables; python_version >= '3.10'", ] @@ -449,4 +453,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..4d58f9cc2de0 100644 --- a/src/ansys/aedt/core/internal/checks.py +++ b/src/ansys/aedt/core/internal/checks.py @@ -37,6 +37,7 @@ "imageio": None, "matplotlib": None, "meshio": None, + "pdf_oxide": None, "pillow": None, "pyvista": None, "visualization_interface": None, @@ -183,6 +184,8 @@ def check_dependency_available(dependency: str, warning: bool = False) -> bool | import imageio # noqa: F401 elif dependency == "meshio": import meshio # noqa: F401 + elif dependency == "pdf_oxide": + import pdf_oxide # noqa: F401 elif dependency == "fpdf": import fpdf # noqa: F401 elif dependency == "pillow": diff --git a/src/ansys/aedt/core/visualization/plot/pdf.py b/src/ansys/aedt/core/visualization/plot/pdf.py index f14de48e9f87..375f7d598590 100644 --- a/src/ansys/aedt/core/visualization/plot/pdf.py +++ b/src/ansys/aedt/core/visualization/plot/pdf.py @@ -24,6 +24,7 @@ from dataclasses import dataclass from dataclasses import field +import io import json import os @@ -41,6 +42,7 @@ 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 @@ -941,3 +943,807 @@ def add_chart(self, x_values: list, y_values: list, x_caption: str, y_caption: s canvas.draw() img = Image.fromarray(np.asarray(canvas.buffer_rgba())) self.image(img, w=self.epw) # Make the image full width + + +@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(): + 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 AnsysReportPdfOxide(PyAedtBase): + """Provide a pdf-oxide based Ansys report implementation.""" + + def __init__( + self, + version: str = "2025R1", + design_name: str = "design1", + project_name: str = "AnsysProject", + tempplate_json_file=None, + ) -> None: + self.report_specs = ReportSpec() + self.read_template(tempplate_json_file) + self.report_specs.ansys_version = version + self.report_specs.design_name = design_name + self.report_specs.project_name = project_name + self.use_portrait = True + self.__chapter_idx = 0 + self.__sub_chapter_idx = 0 + self.__figure_idx = 1 + self.__table_idx = 1 + self._pages: list[_PdfOxidePage] = [] + self._toc_requested = False + self._current_page: _PdfOxidePage | None = None + + def _margin_mm(self, value: float) -> float: + return unit_converter(value, input_units=self.report_specs.units, output_units="mm") + + 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 _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() + return self._current_page + + def read_template(self, template_file: str = None) -> None: + """Read pdf template.""" + if template_file: + self.report_specs.template_name = template_file + if os.path.exists(self.report_specs.template_name): + with open_file(self.report_specs.template_name, "r") as f: + tdata = json.load(f) + self.report_specs = ReportSpec(**tdata) + + def create(self, add_cover_page: bool = True, add_new_section_after: bool = True) -> bool: + """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 + + def add_section(self, portrait: bool = 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) + + 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) + + 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 + + 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_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, + ) + ) + + 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 + + @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 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 + + def add_table( + self, + title: str, + content: list[list], + formatting: list = None, + col_widths: list = 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") + 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", + "HFSS", + "Icepak", + DesignType.ICEPAKFEA, + "Maxwell 2D", + "2D Extractor", + ]: + 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, + output_file=image_path, + dark_mode=False, + show_grid=False, + show_bounding=False, + ) + self.add_image(image_path, "Model Image") + elif design.design_type in ["HFSS3DLayout", "HFSS 3D Layout Design"]: + stats = design.modeler.edb.get_statistics() + 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"]: + self.add_text(f"The schematic has {len(design.modeler.schematic.components)} components.") + if design.setups: + self.add_text(f"The design has {len(design.setups)} simulation setups.") + return True + + @property + def template_name(self) -> str: + return self.report_specs.template_name + + @template_name.setter + def template_name(self, value: str) -> None: + self.report_specs.template_name = value + + @property + def design_name(self) -> str: + return self.report_specs.design_name + + @design_name.setter + def design_name(self, value: str) -> None: + self.report_specs.design_name = value + + @property + def project_name(self) -> str: + return self.report_specs.project_name + + @project_name.setter + def project_name(self, value: str) -> None: + self.report_specs.project_name = value + + @property + def aedt_version(self) -> str: + return self.report_specs.ansys_version + + @aedt_version.setter + def aedt_version(self, value: str) -> None: + self.report_specs.ansys_version = value + + def _render_header(self, page_builder, page_width_mm: float, page_height_mm: float) -> None: + from datetime import date + + 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), + ) + + 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) + _draw_colored_text(page_builder, x_pt, value_y_pt, value, 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 + ) + + 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}" + ) + + def _render_text_block( + self, page_builder, block: _PdfOxideTextBlock, cursor_y_mm: float, page_width_mm: float, page_height_mm: float + ) -> tuple[float, list[tuple[int, str]]]: + 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): + 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) + _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())) + cursor_y_mm += line_height_mm + return cursor_y_mm + 1.5, added_outline + + def _render_image_block( + self, page_builder, block: _PdfOxideImageBlock, cursor_y_mm: float, page_width_mm: float, page_height_mm: float + ) -> tuple[float, list[tuple[int, str]]]: + 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 cursor_y_mm, [] + + 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 + 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 available_width_mm + max_height_mm = block.max_height_mm or (page_height_mm - cursor_y_mm - self._bottom_margin_mm() - 10.0) + width_mm = max_width_mm + height_mm = width_mm * aspect_ratio + if 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 + + 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) + 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 + outline_entries.append((1, caption)) + cursor_y_mm, _ = 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, + ) + return cursor_y_mm + 1.0, outline_entries + + def _render_table_block( + self, page_builder, block: _PdfOxideTableBlock, cursor_y_mm: float, page_width_mm: float, page_height_mm: float + ) -> tuple[float, list[tuple[int, str]]]: + 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: + col_widths_mm = [table_total_width_mm / len(block.content[0])] * len(block.content[0]) + + table_font = self.report_specs.table_font_size + line_height_mm = _pt_to_mm(table_font * 1.3) + padding_mm = 1.0 + cursor_y_mm += 1.0 + + for row_index, row in enumerate(block.content): + 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("\n".join(wrapped)) + row_height_mm = max(row_height_mm, len(wrapped) * line_height_mm + 2 * padding_mm) + + x_mm = self._left_margin_mm() + max(0.0, (available_width_mm - table_total_width_mm) / 2.0) + for col_index, cell_text 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 background: + page_builder.filled_rect( + _mm_to_pt(x_mm), + y_pt, + _mm_to_pt(width_mm), + _mm_to_pt(row_height_mm), + *background, + ) + 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) + page_builder.text_in_rect( + _mm_to_pt(x_mm + padding_mm), + y_pt + _mm_to_pt(padding_mm), + _mm_to_pt(width_mm - 2 * padding_mm), + _mm_to_pt(row_height_mm - 2 * padding_mm), + cell_text, + "center", + ) + x_mm += width_mm + cursor_y_mm += row_height_mm + + cursor_y_mm, _ = 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, + ) + outline_entries = [(1, f"Table {self.__table_idx}: {block.title}")] + self.__table_idx += 1 + return cursor_y_mm + 1.0, outline_entries + + def _render_toc_page( + self, page_builder, page_width_mm: float, page_height_mm: float, outline_entries: list[tuple[int, str, int]] + ) -> None: + 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, + ) + 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)) + page_builder.font(_font_name(self.report_specs.font), 12.0) + for level, title, page_number in outline_entries: + prefix = " " * level * 4 + y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - _pt_to_mm(12.0 * 1.2)) + page_builder.text_in_rect( + left_pt, + y_pt, + available_width_pt, + _mm_to_pt(5.0), + f"{prefix}{title}", + ) + page_builder.text_in_rect( + left_pt, + y_pt, + available_width_pt, + _mm_to_pt(5.0), + f"Page {page_number}", + "right", + ) + cursor_y_mm += 5.5 + + def save_pdf(self, file_path: str, file_name: str | None = None) -> str: + """Save pdf.""" + 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}") + ) + + pages = list(self._pages) + if self._toc_requested: + pages.append(_PdfOxidePage(portrait=self.use_portrait, page_format="a4", kind="toc")) + + self.__figure_idx = 1 + self.__table_idx = 1 + outline_entries: list[tuple[int, str, int]] = [] + total_pages = len(pages) + + for page_number, page in enumerate(pages, start=1): + 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._render_header(page_builder, page_width_mm, page_height_mm) + self._render_cover_page(page_builder, page_width_mm, page_height_mm) + self._render_footer(page_builder, page_width_mm, page_number, total_pages) + elif page.kind == "toc": + self._render_header(page_builder, page_width_mm, page_height_mm) + self._render_toc_page(page_builder, page_width_mm, page_height_mm, outline_entries) + self._render_footer(page_builder, page_width_mm, page_number, total_pages) + else: + self._render_header(page_builder, page_width_mm, page_height_mm) + cursor_y_mm = self._top_margin_mm() + 2.0 + for block in page.blocks: + if isinstance(block, _PdfOxideTextBlock): + cursor_y_mm, added_outline = self._render_text_block( + page_builder, block, cursor_y_mm, page_width_mm, page_height_mm + ) + for level, title in added_outline: + outline_entries.append((level, title, page_number)) + elif isinstance(block, _PdfOxideSpacerBlock): + cursor_y_mm += block.lines * _pt_to_mm(self.report_specs.text_font_size) + elif isinstance(block, _PdfOxideImageBlock): + cursor_y_mm, added_outline = self._render_image_block( + page_builder, block, cursor_y_mm, page_width_mm, page_height_mm + ) + outline_entries.extend((level, title, page_number) for level, title in added_outline) + elif isinstance(block, _PdfOxideTableBlock): + cursor_y_mm, added_outline = self._render_table_block( + page_builder, block, cursor_y_mm, page_width_mm, page_height_mm + ) + outline_entries.extend((level, title, page_number) for level, title in added_outline) + self._render_footer(page_builder, page_width_mm, page_number, total_pages) + + builder = page_builder.done() + + builder.save(output_path) + return str(output_path) diff --git a/uv.lock b/uv.lock index 315b9ec317e8..c05c45a52020 100644 --- a/uv.lock +++ b/uv.lock @@ -3105,6 +3105,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" @@ -3538,6 +3554,7 @@ all = [ { name = "openpyxl" }, { name = "osmnx" }, { name = "pandas" }, + { name = "pdf-oxide" }, { name = "pyvista", extra = ["io"] }, { name = "requests" }, { name = "rpyc" }, @@ -3558,6 +3575,7 @@ all-dotnet = [ { name = "openpyxl" }, { name = "osmnx" }, { name = "pandas" }, + { name = "pdf-oxide" }, { name = "pyvista", extra = ["io"] }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, @@ -3581,6 +3599,7 @@ examples = [ { name = "openpyxl" }, { name = "osmnx" }, { name = "pandas" }, + { name = "pdf-oxide" }, { name = "plotly" }, { name = "pyvista", extra = ["jupyter"] }, { name = "rpyc" }, @@ -3592,6 +3611,7 @@ graphics = [ { name = "ansys-tools-visualization-interface" }, { name = "fpdf2" }, { name = "matplotlib" }, + { name = "pdf-oxide" }, { name = "pillow" }, { name = "pyvista", extra = ["io"] }, { name = "vtk" }, @@ -3689,6 +3709,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" }, From d5f2705f1a5d9ad45040dcd2eb4ca6cf642a4934 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 22 Jul 2026 12:47:23 +0200 Subject: [PATCH 2/7] Added ut --- tests/system/solvers/test_pdf.py | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/system/solvers/test_pdf.py b/tests/system/solvers/test_pdf.py index d9ce29d646ae..46df72f4042f 100644 --- a/tests/system/solvers/test_pdf.py +++ b/tests/system/solvers/test_pdf.py @@ -31,6 +31,7 @@ from ansys.aedt.core import Circuit from ansys.aedt.core.generic.general_methods import is_windows from ansys.aedt.core.visualization.plot.pdf import AnsysReport +from ansys.aedt.core.visualization.plot.pdf import AnsysReportPdfOxide from ansys.aedt.core.visualization.post.compliance import VirtualCompliance from ansys.aedt.core.visualization.post.compliance import VirtualComplianceGenerator from tests import TESTS_SOLVERS_PATH @@ -92,6 +93,39 @@ def test_create_pdf(test_tmp_dir) -> None: assert output.exists() +def test_create_pdf_oxide(test_tmp_dir) -> None: + report = AnsysReportPdfOxide(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") + report.add_table("MyTable", [["x", "y"], ["0", "1"], ["2", "3"], ["10", "20"]], 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() From 44920c916e6fdf4c611ab8007d25d895780ed4a1 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:51:58 +0000 Subject: [PATCH 3/7] chore: adding changelog file 7934.added.md [dependabot-skip] --- doc/changelog.d/7934.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/7934.added.md 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 From 1656d8323d3d97543a6ab464e303e0a47522917b Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 22 Jul 2026 15:44:57 +0200 Subject: [PATCH 4/7] Added auto page break --- src/ansys/aedt/core/visualization/plot/pdf.py | 351 ++++++++++++++---- tests/system/solvers/test_pdf.py | 2 +- 2 files changed, 282 insertions(+), 71 deletions(-) diff --git a/src/ansys/aedt/core/visualization/plot/pdf.py b/src/ansys/aedt/core/visualization/plot/pdf.py index 375f7d598590..2119f228ba06 100644 --- a/src/ansys/aedt/core/visualization/plot/pdf.py +++ b/src/ansys/aedt/core/visualization/plot/pdf.py @@ -59,7 +59,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.""" @@ -128,7 +128,7 @@ class AnsysReport(FPDF, PyAedtBase): def __init__( self, - version: str = "2025R1", + version: str = "2026.1", design_name: str = "design1", project_name: str = "AnsysProject", tempplate_json_file=None, @@ -1116,16 +1116,16 @@ class AnsysReportPdfOxide(PyAedtBase): def __init__( self, - version: str = "2025R1", - design_name: str = "design1", - project_name: str = "AnsysProject", - tempplate_json_file=None, + version: str = "2026.1", + design: str = "design1", + project: str = "AnsysProject", + template_json=None, ) -> None: self.report_specs = ReportSpec() - self.read_template(tempplate_json_file) + self.read_template(template_json) self.report_specs.ansys_version = version - self.report_specs.design_name = design_name - self.report_specs.project_name = project_name + self.report_specs.design_name = design + self.report_specs.project_name = project self.use_portrait = True self.__chapter_idx = 0 self.__sub_chapter_idx = 0 @@ -1153,6 +1153,15 @@ def _right_margin_mm(self) -> float: 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) @@ -1477,9 +1486,33 @@ def _render_cover_page(self, page_builder, page_width_mm: float, page_height_mm: f"Design Name: {self.report_specs.design_name}" ) + def _advance_content_page( + self, + page_builder, + page_width_mm: float, + page_height_mm: float, + page_number: int, + total_pages: int, + draw: bool, + ): + if draw: + self._render_footer(page_builder, page_width_mm, page_number, total_pages) + self._render_header(page_builder.new_page_same_size(), 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 + def _render_text_block( - self, page_builder, block: _PdfOxideTextBlock, cursor_y_mm: float, page_width_mm: float, page_height_mm: float - ) -> tuple[float, list[tuple[int, str]]]: + 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) @@ -1487,19 +1520,36 @@ def _render_text_block( 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) - _draw_colored_text(page_builder, x_pt, y_pt, line, block.color) + 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())) + added_outline.append((block.outline_level, block.text.strip(), page_number)) cursor_y_mm += line_height_mm - return cursor_y_mm + 1.5, added_outline + 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 - ) -> tuple[float, list[tuple[int, str]]]: + 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: @@ -1513,16 +1563,17 @@ def _render_image_block( image_width_px = None image_height_px = None else: - return cursor_y_mm, [] + 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() 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 available_width_mm - max_height_mm = block.max_height_mm or (page_height_mm - cursor_y_mm - self._bottom_margin_mm() - 10.0) + max_height_mm = block.max_height_mm or (self._content_end_y_mm(page_height_mm) - cursor_y_mm) width_mm = max_width_mm height_mm = width_mm * aspect_ratio if height_mm > max_height_mm: @@ -1533,22 +1584,50 @@ def _render_image_block( elif height_mm is None: height_mm = width_mm + 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 (self._content_end_y_mm(page_height_mm) - cursor_y_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) - page_builder.image_artifact( - image_bytes, - _mm_to_pt(x_mm), - y_pt, - _mm_to_pt(width_mm), - _mm_to_pt(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 - outline_entries.append((1, caption)) - cursor_y_mm, _ = self._render_text_block( + outline_entries.append((2, caption, page_number)) + page_builder, cursor_y_mm, page_number, _ = self._render_text_block( page_builder, _PdfOxideTextBlock( text=caption, @@ -1560,12 +1639,23 @@ def _render_image_block( cursor_y_mm, page_width_mm, page_height_mm, + page_number, + total_pages, + draw, ) - return cursor_y_mm + 1.0, outline_entries + return page_builder, cursor_y_mm + 1.0, page_number, outline_entries def _render_table_block( - self, page_builder, block: _PdfOxideTableBlock, cursor_y_mm: float, page_width_mm: float, page_height_mm: float - ) -> tuple[float, list[tuple[int, str]]]: + 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 @@ -1580,8 +1670,15 @@ def _render_table_block( line_height_mm = _pt_to_mm(table_font * 1.3) padding_mm = 1.0 cursor_y_mm += 1.0 - - for row_index, row in enumerate(block.content): + 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] + 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 = [] @@ -1592,6 +1689,26 @@ def _render_table_block( wrapped_cells.append("\n".join(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("\n".join(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_text in enumerate(wrapped_cells): width_mm = col_widths_mm[col_index] @@ -1608,7 +1725,7 @@ def _render_table_block( if isinstance(formatting, list) and len(formatting) > 1 and formatting[1]: background = _rgb_fraction(formatting[1]) - if background: + if draw and background: page_builder.filled_rect( _mm_to_pt(x_mm), y_pt, @@ -1616,20 +1733,22 @@ def _render_table_block( _mm_to_pt(row_height_mm), *background, ) - 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) - page_builder.text_in_rect( - _mm_to_pt(x_mm + padding_mm), - y_pt + _mm_to_pt(padding_mm), - _mm_to_pt(width_mm - 2 * padding_mm), - _mm_to_pt(row_height_mm - 2 * padding_mm), - cell_text, - "center", - ) + 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) + page_builder.text_in_rect( + _mm_to_pt(x_mm + padding_mm), + y_pt + _mm_to_pt(padding_mm), + _mm_to_pt(width_mm - 2 * padding_mm), + _mm_to_pt(row_height_mm - 2 * padding_mm), + cell_text, + "center", + ) x_mm += width_mm cursor_y_mm += row_height_mm + row_index += 1 - cursor_y_mm, _ = self._render_text_block( + page_builder, cursor_y_mm, page_number, _ = self._render_text_block( page_builder, _PdfOxideTextBlock( text=f"Table {self.__table_idx}: {block.title}", @@ -1641,16 +1760,18 @@ def _render_table_block( cursor_y_mm + 1.0, page_width_mm, page_height_mm, + page_number, + total_pages, + draw, ) - outline_entries = [(1, f"Table {self.__table_idx}: {block.title}")] self.__table_idx += 1 - return cursor_y_mm + 1.0, outline_entries + return page_builder, cursor_y_mm + 1.0, page_number, outline_entries def _render_toc_page( self, page_builder, page_width_mm: float, page_height_mm: float, outline_entries: list[tuple[int, str, int]] ) -> None: cursor_y_mm = self._top_margin_mm() + 5.0 - cursor_y_mm, _ = self._render_text_block( + _, cursor_y_mm, _, _ = self._render_text_block( page_builder, _PdfOxideTextBlock( text="Table of contents:", @@ -1661,21 +1782,18 @@ def _render_toc_page( cursor_y_mm, page_width_mm, page_height_mm, + 0, + 0, + True, ) 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)) page_builder.font(_font_name(self.report_specs.font), 12.0) for level, title, page_number in outline_entries: - prefix = " " * level * 4 y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - _pt_to_mm(12.0 * 1.2)) - page_builder.text_in_rect( - left_pt, - y_pt, - available_width_pt, - _mm_to_pt(5.0), - f"{prefix}{title}", - ) + indent_pt = _mm_to_pt(6.0 * level) + _draw_colored_text(page_builder, left_pt + indent_pt, y_pt, title, tuple(self.report_specs.font_color)) page_builder.text_in_rect( left_pt, y_pt, @@ -1686,6 +1804,66 @@ def _render_toc_page( ) cursor_y_mm += 5.5 + 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.""" DocumentBuilder = _pdf_oxide_imports() @@ -1702,12 +1880,12 @@ def save_pdf(self, file_path: str, file_name: str | None = None) -> str: if self._toc_requested: pages.append(_PdfOxidePage(portrait=self.use_portrait, page_format="a4", kind="toc")) + total_pages = self._count_physical_pages(pages, DocumentBuilder) self.__figure_idx = 1 self.__table_idx = 1 outline_entries: list[tuple[int, str, int]] = [] - total_pages = len(pages) - - for page_number, page in enumerate(pages, start=1): + page_number = 1 + 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)) @@ -1715,33 +1893,66 @@ def save_pdf(self, file_path: str, file_name: str | None = None) -> str: self._render_header(page_builder, page_width_mm, page_height_mm) self._render_cover_page(page_builder, page_width_mm, page_height_mm) self._render_footer(page_builder, page_width_mm, page_number, total_pages) + page_number += 1 elif page.kind == "toc": self._render_header(page_builder, page_width_mm, page_height_mm) self._render_toc_page(page_builder, page_width_mm, page_height_mm, outline_entries) self._render_footer(page_builder, page_width_mm, page_number, total_pages) + page_number += 1 else: self._render_header(page_builder, page_width_mm, page_height_mm) - cursor_y_mm = self._top_margin_mm() + 2.0 + cursor_y_mm = self._content_start_y_mm() + current_page_number = page_number for block in page.blocks: if isinstance(block, _PdfOxideTextBlock): - cursor_y_mm, added_outline = self._render_text_block( - page_builder, block, cursor_y_mm, page_width_mm, page_height_mm + 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, ) - for level, title in added_outline: - outline_entries.append((level, title, page_number)) + 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): - cursor_y_mm, added_outline = self._render_image_block( - page_builder, block, cursor_y_mm, page_width_mm, page_height_mm + 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((level, title, page_number) for level, title in added_outline) + outline_entries.extend(added_outline) elif isinstance(block, _PdfOxideTableBlock): - cursor_y_mm, added_outline = self._render_table_block( - page_builder, block, cursor_y_mm, page_width_mm, page_height_mm + 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((level, title, page_number) for level, title in added_outline) - self._render_footer(page_builder, page_width_mm, page_number, total_pages) + outline_entries.extend(added_outline) + self._render_footer(page_builder, page_width_mm, current_page_number, total_pages) + page_number = current_page_number + 1 builder = page_builder.done() diff --git a/tests/system/solvers/test_pdf.py b/tests/system/solvers/test_pdf.py index 46df72f4042f..5e06b9f73ed1 100644 --- a/tests/system/solvers/test_pdf.py +++ b/tests/system/solvers/test_pdf.py @@ -94,7 +94,7 @@ def test_create_pdf(test_tmp_dir) -> None: def test_create_pdf_oxide(test_tmp_dir) -> None: - report = AnsysReportPdfOxide(design_name="Design1", project_name="Coaxial") + report = AnsysReportPdfOxide(design="Design1", project="Coaxial") report.aedt_version = DESKTOP_VERSION assert "AnsysTemplate" in report.template_name report.template_name = "AnsysTemplate" From 6280a09c75d6651a94bd414f02b3bc8335df4433 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Mon, 24 Aug 2026 13:20:53 +0200 Subject: [PATCH 5/7] Replaced fpdf2 by pdf_oxide --- .github/workflows/ci-release.yml | 1 - .github/workflows/ci_cd.yml | 1 - .github/workflows/manual-build-wheelhouse.yml | 4 - .../workflows/reusable-smoke-tests-rocky.yml | 1 - .github/workflows/sync-release.yml | 1 - README.md | 10 - doc/source/API/visualization/plot.rst | 2 +- doc/source/User_guide/postprocessing.rst | 2 +- pyproject.toml | 4 - src/ansys/aedt/core/internal/checks.py | 3 - src/ansys/aedt/core/visualization/plot/pdf.py | 989 +++--------------- .../core/visualization/post/compliance.py | 10 +- tests/system/solvers/test_pdf.py | 21 +- uv.lock | 152 ++- 14 files changed, 214 insertions(+), 987 deletions(-) diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index 95126ecb6f6f..c803cd5d5c16 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 8ba1330e7ce2..22a7e4f7daaf 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 d19ffb5c24b6..dbf9fb44336c 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 d480775a994b..cf9ea48a8afd 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 95f0d270383e..bc530493f2e6 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/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 c3f5fa39e13a..da735766e914 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,6 @@ graphics = [ "pyvista[io]>=0.38.0,<0.49", "matplotlib>=3.5.0", "vtk>=9.0,<9.7", - "fpdf2", "pdf-oxide", "pillow", ] @@ -76,7 +75,6 @@ all = [ "pyaedt[jupyter]", "requests", "typer>=0.20.0", - "fpdf2", "pdf-oxide", "rpyc>=6.0.0", ] @@ -94,7 +92,6 @@ all-dotnet = [ "pyaedt[jupyter]", "requests", "typer>=0.20.0", - "fpdf2", "pdf-oxide", "rpyc>=6.0.0", # From 'dotnet' group @@ -113,7 +110,6 @@ 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'", diff --git a/src/ansys/aedt/core/internal/checks.py b/src/ansys/aedt/core/internal/checks.py index 4d58f9cc2de0..a099eb12cf72 100644 --- a/src/ansys/aedt/core/internal/checks.py +++ b/src/ansys/aedt/core/internal/checks.py @@ -33,7 +33,6 @@ # 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, @@ -186,8 +185,6 @@ def check_dependency_available(dependency: str, warning: bool = False) -> bool | import meshio # noqa: F401 elif dependency == "pdf_oxide": import pdf_oxide # noqa: F401 - elif dependency == "fpdf": - import fpdf # 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 2119f228ba06..3442cde3da90 100644 --- a/src/ansys/aedt/core/visualization/plot/pdf.py +++ b/src/ansys/aedt/core/visualization/plot/pdf.py @@ -28,15 +28,6 @@ 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 @@ -123,828 +114,6 @@ class ReportSpec(PyAedtBase): """Value for font caption color.""" -class AnsysReport(FPDF, PyAedtBase): - """Provide ansys report.""" - - def __init__( - self, - version: str = "2026.1", - design_name: str = "design1", - project_name: str = "AnsysProject", - tempplate_json_file=None, - ) -> None: - super().__init__() - self.report_specs = ReportSpec() - self.read_template(tempplate_json_file) - self.report_specs.ansys_version = version - self.report_specs.design_name = design_name - self.report_specs.project_name = project_name - self.use_portrait = True - self.__chapter_idx = 0 - 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() - - """ - if template_file: - self.report_specs.template_name = template_file - if os.path.exists(self.report_specs.template_name): - with open_file(self.report_specs.template_name, "r") as f: - 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() - - """ - 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", - ) - - # 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() - - """ - # 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") - - def create(self, add_cover_page: bool = True, add_new_section_after: bool = True) -> bool: - """Create a new report using ``report_specs`` properties. - - 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. - - Returns - ------- - :class:`AnsysReport` - - Examples - -------- - >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport - >>> obj = AnsysReport() - >>> obj.create() - - """ - 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 - return True - - def add_project_info(self, design) -> bool: - """Add project information. - - Parameters - ---------- - design : object - Starting application object. For example, ``hfss1= HFSS3DLayout()``. - - Returns - ------- - bool - - Examples - -------- - >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport - >>> obj = AnsysReport() - >>> obj.add_project_info() - - """ - self.add_page("P" if self.use_portrait else "L") - 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) - if design.design_type in [ - "Q3D Extractor", - "Maxwell 3D", - "HFSS", - "Icepak", - DesignType.ICEPAKFEA, - "Maxwell 2D", - "2D Extractor", - ]: - msg = f"Simulation bounding box is {design.modeler.get_model_bounding_box()}." - self.add_text(msg) - image_path = os.path.join(design.working_directory, "model.jpg") - design.plot( - show=False, - output_file=image_path, - dark_mode=False, - show_grid=False, - show_bounding=False, - ) - 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}." - 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) - - if design.setups: - msg = f"The design has {len(design.setups)} simulation setups." - self.add_text(msg) - - 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 - def template_name(self, value: str) -> None: - self.report_specs.template_name = value - - @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 - def design_name(self, value: str) -> None: - self.report_specs.design_name = value - - @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 - def project_name(self, value: str) -> None: - self.report_specs.project_name = value - - @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. - - Examples - -------- - >>> from ansys.aedt.core.visualization.plot.pdf import AnsysReport - >>> obj = AnsysReport() - >>> obj.add_chapter("Chapter 1") - - """ - 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", - ) - 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", - ) - 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( - 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 - - 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) - 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 - - # 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 - 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", - ) - 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") - - def add_table( - self, - title: str, - content: list[list], - formatting: list = None, - col_widths: list = None, - ) -> 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( - 0, - 6, - content, - new_x="LMARGIN", - new_y="NEXT", - align="L", - ) - - 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, - ) - - 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 - - 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) - # 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 - - @dataclass class _PdfOxideTextBlock: text: str @@ -1095,6 +264,24 @@ def _wrap_text_lines(page_builder, text: str, max_width_pt: float) -> list[str]: 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) @@ -1111,21 +298,21 @@ def _draw_colored_text(page_builder, x_pt: float, y_pt: float, text: str, color: page_builder.at(x_pt, y_pt).inline_color(red, green, blue, str(text)) -class AnsysReportPdfOxide(PyAedtBase): - """Provide a pdf-oxide based Ansys report implementation.""" +class AnsysReport(PyAedtBase): + """Provide an Ansys report implementation based on pdf-oxide.""" def __init__( self, version: str = "2026.1", - design: str = "design1", - project: str = "AnsysProject", - template_json=None, + design_name: str = "design1", + project_name: str = "AnsysProject", + template_json_file: str | None = None, ) -> None: self.report_specs = ReportSpec() - self.read_template(template_json) + self.read_template(template_json_file) self.report_specs.ansys_version = version - self.report_specs.design_name = design - self.report_specs.project_name = project + self.report_specs.design_name = design_name + self.report_specs.project_name = project_name self.use_portrait = True self.__chapter_idx = 0 self.__sub_chapter_idx = 0 @@ -1134,9 +321,17 @@ def __init__( 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: - return unit_converter(value, input_units=self.report_specs.units, output_units="mm") + 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) @@ -1170,9 +365,11 @@ def _append_page(self, portrait: bool, page_format: str | tuple[float, float], k 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: + def read_template(self, template_file: str | None = None) -> None: """Read pdf template.""" if template_file: self.report_specs.template_name = template_file @@ -1181,6 +378,53 @@ def read_template(self, template_file: str = None) -> None: tdata = json.load(f) self.report_specs = ReportSpec(**tdata) + def header(self) -> None: + """Render the page header. + + Override this method to customize headers. It is called for every rendered page. + """ + 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, + ) + + def footer(self) -> None: + """Render the page footer. + + Override this method to customize footers. It is called for every rendered page. + """ + 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.""" self._pages = [] @@ -1196,7 +440,7 @@ def create(self, add_cover_page: bool = True, add_new_section_after: bool = True self.add_section() return True - def add_section(self, portrait: bool = None, page_format: str = "a4") -> None: + 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 @@ -1236,6 +480,19 @@ def add_sub_chapter(self, chapter_name: str) -> bool: ) return True + 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, + ) + ) + def add_text(self, content: str, bold: bool = False, italic: bool = False) -> None: """Add a new text.""" self._ensure_current_page().blocks.append( @@ -1266,7 +523,7 @@ def add_image(self, path: str, caption: str = "", width: int = 0, height: int = @requires_graphical_dependency("pillow") def add_image_with_aspect_ratio( - self, path: str, caption: str = "", max_width: float = None, max_height: float = None + 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( @@ -1284,8 +541,8 @@ def add_table( self, title: str, content: list[list], - formatting: list = None, - col_widths: list = None, + formatting: list | None = None, + col_widths: list | None = None, full_width: bool = False, ) -> None: """Add a new table from a list of data.""" @@ -1496,8 +753,9 @@ def _advance_content_page( draw: bool, ): if draw: - self._render_footer(page_builder, page_width_mm, page_number, total_pages) - self._render_header(page_builder.new_page_same_size(), page_width_mm, page_height_mm) + 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 @@ -1686,7 +944,7 @@ def _render_table_block( 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("\n".join(wrapped)) + wrapped_cells.append(wrapped) row_height_mm = max(row_height_mm, len(wrapped) * line_height_mm + 2 * padding_mm) if ( @@ -1705,12 +963,12 @@ def _render_table_block( 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("\n".join(wrapped)) + 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_text in enumerate(wrapped_cells): + 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 @@ -1736,14 +994,15 @@ def _render_table_block( 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) - page_builder.text_in_rect( - _mm_to_pt(x_mm + padding_mm), - y_pt + _mm_to_pt(padding_mm), - _mm_to_pt(width_mm - 2 * padding_mm), - _mm_to_pt(row_height_mm - 2 * padding_mm), - cell_text, - "center", - ) + 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 @@ -1866,6 +1125,8 @@ def _count_physical_pages(self, pages: list[_PdfOxidePage], DocumentBuilder) -> def save_pdf(self, file_path: str, file_name: str | None = None) -> str: """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 = ( @@ -1890,17 +1151,17 @@ def save_pdf(self, file_path: str, file_name: str | None = None) -> str: page_builder = builder.page(_mm_to_pt(page_width_mm), _mm_to_pt(page_height_mm)) if page.kind == "cover": - self._render_header(page_builder, page_width_mm, page_height_mm) + self._invoke_header(page_builder, page_width_mm, page_height_mm) self._render_cover_page(page_builder, page_width_mm, page_height_mm) - self._render_footer(page_builder, page_width_mm, page_number, total_pages) + self._invoke_footer(page_builder, page_width_mm, page_number, total_pages) page_number += 1 elif page.kind == "toc": - self._render_header(page_builder, page_width_mm, page_height_mm) + 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) - self._render_footer(page_builder, page_width_mm, page_number, total_pages) + self._invoke_footer(page_builder, page_width_mm, page_number, total_pages) page_number += 1 else: - self._render_header(page_builder, page_width_mm, page_height_mm) + 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: @@ -1951,7 +1212,7 @@ def save_pdf(self, file_path: str, file_name: str | None = None) -> str: True, ) outline_entries.extend(added_outline) - self._render_footer(page_builder, page_width_mm, current_page_number, total_pages) + self._invoke_footer(page_builder, page_width_mm, current_page_number, total_pages) page_number = current_page_number + 1 builder = page_builder.done() 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 5e06b9f73ed1..fddf1ad75b69 100644 --- a/tests/system/solvers/test_pdf.py +++ b/tests/system/solvers/test_pdf.py @@ -31,7 +31,6 @@ from ansys.aedt.core import Circuit from ansys.aedt.core.generic.general_methods import is_windows from ansys.aedt.core.visualization.plot.pdf import AnsysReport -from ansys.aedt.core.visualization.plot.pdf import AnsysReportPdfOxide from ansys.aedt.core.visualization.post.compliance import VirtualCompliance from ansys.aedt.core.visualization.post.compliance import VirtualComplianceGenerator from tests import TESTS_SOLVERS_PATH @@ -76,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) @@ -93,8 +93,8 @@ def test_create_pdf(test_tmp_dir) -> None: assert output.exists() -def test_create_pdf_oxide(test_tmp_dir) -> None: - report = AnsysReportPdfOxide(design="Design1", project="Coaxial") +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" @@ -116,7 +116,20 @@ def test_create_pdf_oxide(test_tmp_dir) -> None: str(Path(TESTS_SOLVERS_PATH) / "example_models" / TEST_SUBFOLDER / "Coax_HFSS.jpg"), "Coaxial Cable" ) report.add_section(portrait=False, page_format="a3") - report.add_table("MyTable", [["x", "y"], ["0", "1"], ["2", "3"], ["10", "20"]], full_width=True) + 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") diff --git a/uv.lock b/uv.lock index c05c45a52020..9c5b174cd6d5 100644 --- a/uv.lock +++ b/uv.lock @@ -772,7 +772,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -844,7 +844,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1201,7 +1201,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1314,20 +1314,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" @@ -3547,7 +3533,6 @@ dependencies = [ all = [ { name = "ansys-tools-visualization-interface" }, { name = "fast-simplification" }, - { name = "fpdf2" }, { name = "ipython" }, { name = "jupyterlab" }, { name = "matplotlib" }, @@ -3568,7 +3553,6 @@ all-dotnet = [ { name = "ansys-tools-visualization-interface" }, { name = "cffi", marker = "sys_platform == 'linux'" }, { name = "fast-simplification" }, - { name = "fpdf2" }, { name = "ipython" }, { name = "jupyterlab" }, { name = "matplotlib" }, @@ -3592,7 +3576,6 @@ dotnet = [ ] examples = [ { name = "fast-simplification" }, - { name = "fpdf2" }, { name = "imageio" }, { name = "joblib" }, { name = "matplotlib" }, @@ -3609,7 +3592,6 @@ examples = [ ] graphics = [ { name = "ansys-tools-visualization-interface" }, - { name = "fpdf2" }, { name = "matplotlib" }, { name = "pdf-oxide" }, { name = "pillow" }, @@ -3685,10 +3667,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" }, @@ -4053,7 +4031,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.11'" }, + { name = "certifi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/10/a8480ea27ea4bbe896c168808854d00f2a9b49f95c0319ddcbba693c8a90/pyproj-3.7.1.tar.gz", hash = "sha256:60d72facd7b6b79853f19744779abcd3f804c4e0d4fa8815469db20c9f640a47", size = 226339, upload-time = "2025-02-16T04:28:46.621Z" } wheels = [ @@ -4101,7 +4079,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.11'" }, + { name = "certifi" }, ] sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } wheels = [ @@ -4614,7 +4592,7 @@ name = "roman-numerals-py" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "roman-numerals", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } wheels = [ @@ -4805,7 +4783,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -4866,7 +4844,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -5071,23 +5049,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -5104,23 +5082,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -5135,12 +5113,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "starlette", marker = "python_full_version < '3.11'" }, - { name = "uvicorn", marker = "python_full_version < '3.11'" }, - { name = "watchfiles", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, + { name = "colorama" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } wheels = [ @@ -5157,12 +5135,12 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "starlette", marker = "python_full_version >= '3.11'" }, - { name = "uvicorn", marker = "python_full_version >= '3.11'" }, - { name = "watchfiles", marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, + { name = "colorama" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -5298,12 +5276,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "blosc2", marker = "python_full_version < '3.11'" }, - { name = "numexpr", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "py-cpuinfo", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "blosc2" }, + { name = "numexpr" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "py-cpuinfo" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/5d/96708a84e9fcd29d1f684d56d4c38a23d29b1c934599a072a49f27ccfa71/tables-3.10.1.tar.gz", hash = "sha256:4aa07ac734b9c037baeaf44aec64ec902ad247f57811b59f30c4e31d31f126cf", size = 4762413, upload-time = "2024-08-17T09:57:47.127Z" } wheels = [ @@ -5338,11 +5316,11 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "blosc2", marker = "python_full_version >= '3.11'" }, - { name = "numexpr", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "py-cpuinfo", marker = "python_full_version >= '3.11'" }, + { name = "blosc2" }, + { name = "numexpr" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "py-cpuinfo" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/c5/4462b8b5754b5d23a30424cbec3c3e2a91c10e91c17e6fbf098033faf5bb/tables-3.11.0.tar.gz", hash = "sha256:0e3d0f30f944b32df74cb942153e5af085a4b1709018637cdd93624e22e30fa5", size = 4790450, upload-time = "2026-02-21T20:42:38.237Z" } wheels = [ From 9cc01c69feaf9fc73666ceddf301aaba97a37dc6 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Tue, 25 Aug 2026 14:25:44 +0200 Subject: [PATCH 6/7] Fixed header overflow --- src/ansys/aedt/core/visualization/plot/pdf.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ansys/aedt/core/visualization/plot/pdf.py b/src/ansys/aedt/core/visualization/plot/pdf.py index 3442cde3da90..f6569c182406 100644 --- a/src/ansys/aedt/core/visualization/plot/pdf.py +++ b/src/ansys/aedt/core/visualization/plot/pdf.py @@ -709,7 +709,14 @@ def _render_header(self, page_builder, page_width_mm: float, page_height_mm: flo 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) - _draw_colored_text(page_builder, x_pt, value_y_pt, value, 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) From b500c4924e4faace760385d07cf27c6ee8cb8d55 Mon Sep 17 00:00:00 2001 From: Eduardo Blanco Date: Wed, 26 Aug 2026 16:39:30 +0200 Subject: [PATCH 7/7] Fixes --- src/ansys/aedt/core/visualization/plot/pdf.py | 148 ++++++++++++++++-- 1 file changed, 135 insertions(+), 13 deletions(-) diff --git a/src/ansys/aedt/core/visualization/plot/pdf.py b/src/ansys/aedt/core/visualization/plot/pdf.py index f6569c182406..e0676c9ef450 100644 --- a/src/ansys/aedt/core/visualization/plot/pdf.py +++ b/src/ansys/aedt/core/visualization/plot/pdf.py @@ -834,14 +834,19 @@ def _render_image_block( 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 available_width_mm - max_height_mm = block.max_height_mm or (self._content_end_y_mm(page_height_mm) - cursor_y_mm) + 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 height_mm > max_height_mm: + 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: @@ -849,6 +854,24 @@ def _render_image_block( 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() @@ -857,7 +880,9 @@ def _render_image_block( 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 (self._content_end_y_mm(page_height_mm) - cursor_y_mm) + 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 @@ -932,6 +957,40 @@ def _render_table_block( 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 @@ -941,6 +1000,33 @@ def _render_table_block( 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] @@ -1034,7 +1120,12 @@ def _render_table_block( return page_builder, cursor_y_mm + 1.0, page_number, outline_entries def _render_toc_page( - self, page_builder, page_width_mm: float, page_height_mm: float, outline_entries: list[tuple[int, str, int]] + self, + page_builder, + page_width_mm: float, + page_height_mm: float, + outline_entries: list[tuple[int, str, int]], + toc_page: int = 0, ) -> None: cursor_y_mm = self._top_margin_mm() + 5.0 _, cursor_y_mm, _, _ = self._render_text_block( @@ -1055,20 +1146,41 @@ def _render_toc_page( 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)) - page_builder.font(_font_name(self.report_specs.font), 12.0) - for level, title, page_number in outline_entries: - y_pt = _mm_to_pt(page_height_mm - cursor_y_mm - _pt_to_mm(12.0 * 1.2)) + 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) - _draw_colored_text(page_builder, left_pt + indent_pt, y_pt, title, tuple(self.report_specs.font_color)) + 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, - _mm_to_pt(5.0), + _pt_to_mm(entry_font_size * 1.2), f"Page {page_number}", "right", ) - cursor_y_mm += 5.5 + 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 @@ -1146,13 +1258,22 @@ def save_pdf(self, file_path: str, file_name: str | None = None) -> str: pages = list(self._pages) if self._toc_requested: - pages.append(_PdfOxidePage(portrait=self.use_portrait, page_format="a4", kind="toc")) + 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) + ) 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)) @@ -1164,9 +1285,10 @@ def save_pdf(self, file_path: str, file_name: str | None = None) -> str: 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) + 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()