diff --git a/src/ansys/dynamicreporting/core/serverless/adr.py b/src/ansys/dynamicreporting/core/serverless/adr.py index 220ebdde3..1517a4c4a 100644 --- a/src/ansys/dynamicreporting/core/serverless/adr.py +++ b/src/ansys/dynamicreporting/core/serverless/adr.py @@ -138,8 +138,8 @@ def __init__( databases: dict | None = None, media_directory: str | None = None, static_directory: str | None = None, - media_url: str | None = None, - static_url: str | None = None, + media_url: str = "/media/", + static_url: str = "/static/", debug: bool | None = None, opts: dict | None = None, request: HttpRequest | None = None, @@ -686,15 +686,11 @@ def static_directory(self) -> str: @property def static_url(self) -> str: - from django.conf import settings - - return settings.STATIC_URL + return self._static_url @property def media_url(self) -> str: - from django.conf import settings - - return settings.MEDIA_URL + return self._media_url @property def session(self) -> Session: @@ -1038,8 +1034,10 @@ def export_report_as_html( exporter = ServerlessReportExporter( html_content=html_content, output_dir=output_dir, - static_dir=self._static_directory, media_dir=self._media_directory, + static_dir=self._static_directory, + media_url=self._media_url, + static_url=self._static_url, filename=filename, ansys_version=str(self._ansys_version), dark_mode=dark_mode, diff --git a/src/ansys/dynamicreporting/core/serverless/html_exporter.py b/src/ansys/dynamicreporting/core/serverless/html_exporter.py index 45590109c..09a2dc2cc 100644 --- a/src/ansys/dynamicreporting/core/serverless/html_exporter.py +++ b/src/ansys/dynamicreporting/core/serverless/html_exporter.py @@ -1,6 +1,7 @@ import base64 import os from pathlib import Path +import re from typing import Any from ..adr_utils import get_logger @@ -16,7 +17,6 @@ VIEWER_JS, VIEWER_UTILS, ) -from ..utils.report_download_html import ReportDownloadHTML class ServerlessReportExporter: @@ -34,8 +34,10 @@ def __init__( self, html_content: str, output_dir: Path, - static_dir: Path, media_dir: Path, + static_dir: Path, + media_url: str, + static_url: str, *, filename: str = "index.html", no_inline_files: bool = False, @@ -49,8 +51,10 @@ def __init__( """ self._html_content = html_content self._output_dir = output_dir - self._static_dir = static_dir self._media_dir = media_dir + self._static_dir = static_dir + self._media_url = media_url + self._static_url = static_url self._filename = filename self._debug = debug self._logger = logger or get_logger() @@ -58,7 +62,7 @@ def __init__( self._ansys_version = ansys_version self._dark_mode = dark_mode - # State tracking properties, functionally identical to ReportDownloadHTML + # State tracking properties self._filemap: dict[str, str] = {} self._replaced_file_ext: str | None = None self._collision_count = 0 @@ -96,7 +100,8 @@ def export(self) -> None: html = self._replace_blocks(html, "") html = self._replace_blocks(html, "") + html = self._replace_blocks(html, "") # any-order scripts (MathJax) html = self._replace_blocks(html, "") html = self._replace_blocks(html, "") html = self._replace_blocks(html, "") @@ -161,19 +166,24 @@ def _replace_files(self, text: str, inline: bool = False, size_check: bool = Fal current = 0 ver = str(self._ansys_version) if self._ansys_version is not None else "" - patterns = ( - f"/static/ansys{ver}/", - "/static/", - "/media/", - f"/ansys{ver}/", - ) + patterns = [] + if ver: + patterns.append(f"{self._static_url}ansys{ver}/") # custom + patterns.append(f"/static/ansys{ver}/") # legacy literal + + # custom first, then legacy literals + patterns.extend([self._static_url, self._media_url, "/static/", "/media/"]) + + if ver: + patterns.append(f"/ansys{ver}/") # server-root style while True: # Find the next match using the legacy priority order idx1 = -1 for pat in patterns: - idx1 = text.find(pat, current) - if idx1 != -1: + pos = text.find(pat, current) + if pos != -1: + idx1 = pos break if idx1 == -1: return text # nothing more to replace @@ -192,7 +202,6 @@ def _replace_files(self, text: str, inline: bool = False, size_check: bool = Fal self._replaced_file_ext = ext new_path = self._process_file(path_in_html, simple_path, inline=inline) - if size_check and self._inline_size_exception: new_path = "__SIZE_EXCEPTION__" @@ -309,9 +318,7 @@ def _copy_static_file(self, source_rel_path: str, target_rel_path: str): target_file.parent.mkdir(parents=True, exist_ok=True) content = source_file.read_bytes() # Patch some viewer JS internals (loader/paths) if needed - content = ReportDownloadHTML.fix_viewer_component_paths( - str(target_file), content, self._ansys_version - ) + content = self._fix_viewer_component_paths(str(target_file), content) target_file.write_bytes(content) else: self._logger.warning(f"Warning: Static source file not found: {source_file}") @@ -322,7 +329,7 @@ def _copy_static_files(self, files: list[str], source_prefix: str, target_prefix self._copy_static_file(source_prefix.lstrip("/") + f, target_prefix + f) def _make_unique_basename(self, name: str) -> str: - """Ensures a unique filename in the target media directory to avoid collisions.""" + """Ensures a unique filename in the target media directory to avoid collisions (legacy).""" if not self._no_inline: return name target_path = self._output_dir / "media" / name @@ -331,6 +338,10 @@ def _make_unique_basename(self, name: str) -> str: self._collision_count += 1 return f"{self._collision_count}_{name}" + @staticmethod + def is_scene_file(name: str) -> bool: + return name.upper().endswith((".AVZ", ".SCDOC", ".SCDOCX", ".GLB")) + def _process_file(self, path_in_html: str, pathname: str, inline: bool = False) -> str: """ Reads a file from local disk and either inlines it or copies it. @@ -348,12 +359,18 @@ def _process_file(self, path_in_html: str, pathname: str, inline: bool = False) return self._filemap[pathname] # Resolve source file location based on the raw pathname (no normalization) - if pathname.startswith("/media/"): + ver = str(self._ansys_version) if self._ansys_version is not None else "" + + # Source resolution: custom first, then legacy literals + if pathname.startswith(self._media_url): + source_file = self._media_dir / pathname.replace(self._media_url, "", 1) + elif pathname.startswith(self._static_url): + source_file = self._static_dir / pathname.replace(self._static_url, "", 1) + elif pathname.startswith("/media/"): # legacy literal source_file = self._media_dir / pathname.replace("/media/", "", 1) - elif pathname.startswith("/static/"): + elif pathname.startswith("/static/"): # legacy literal source_file = self._static_dir / pathname.replace("/static/", "", 1) - elif pathname.startswith(f"/ansys{self._ansys_version}/"): - # Legacy downloads these from the server root; serverless reads them from static dir + elif ver and pathname.startswith(f"/ansys{ver}/"): source_file = self._static_dir / pathname.lstrip("/") else: source_file = None @@ -375,7 +392,7 @@ def _process_file(self, path_in_html: str, pathname: str, inline: bool = False) # 4/3 is roughly the expansion factor of base64 encoding (3 bytes -> 4 chars) estimated_inline_size = int(len(content) * (4.0 / 3.0)) - if (inline or ReportDownloadHTML.is_scene_file(pathname)) and self._should_use_data_uri( + if (inline or self.is_scene_file(pathname)) and self._should_use_data_uri( estimated_inline_size ): # Inline as data URI @@ -398,39 +415,73 @@ def _process_file(self, path_in_html: str, pathname: str, inline: bool = False) # prefix with parent folder (GUID) like legacy basename = f"{source_file.parent.name}_{basename}" else: - content = ReportDownloadHTML.fix_viewer_component_paths( - basename, content, self._ansys_version - ) + content = self._fix_viewer_component_paths(basename, content) - # Output path (exact legacy behavior): - # - If /static/ansys{ver}/ -> keep ansys tree, remove '/static/' -> './ansys{ver}/.../' - # - Else -> './media/' - if pathname.startswith(f"/static/ansys{self._ansys_version}/"): - local_pathname = os.path.dirname(pathname).replace("/static/", "./", 1) - result = f"{local_pathname}/{basename}" + # Output path: + # keep ansys tree if the input path came from /static/ansys{ver}/ (custom OR legacy literal) + if ver and ( + pathname.startswith(f"{self._static_url}ansys{ver}/") + or pathname.startswith(f"/static/ansys{ver}/") + ): + local_pathname = os.path.dirname(pathname) + # normalize either custom or legacy /static/ to './' + if local_pathname.startswith(self._static_url): + local_pathname = local_pathname.replace(self._static_url, "./", 1) + elif local_pathname.startswith("/static/"): + local_pathname = local_pathname.replace("/static/", "./", 1) + result = f"{local_pathname}/{basename}" target_file = self._output_dir / local_pathname.lstrip("./") / basename - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_bytes(content) else: result = f"./media/{basename}" - target_file = self._output_dir / "media" / basename - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_bytes(content) + + target_file.parent.mkdir(parents=True, exist_ok=True) + target_file.write_bytes(content) self._filemap[pathname] = result return result + def _find_block(self, text: str, start: int, prefix: str, suffix: str) -> tuple[int, int, str]: + """ + Legacy-compatible: return the next [prefix ... suffix] block that contains at least + one asset-like reference. Accept both the literal legacy prefixes and the configured + custom prefixes. Also accept any '/ansys/' or generic '/ansys/'. + """ + # Normalize known prefixes (custom URLs may differ from /static/ and /media/) + custom_static = (self._static_url or "").strip() + custom_media = (self._media_url or "").strip() + + while True: + try: + idx1 = text.index(prefix, start) + except ValueError: + return -1, -1, "" + try: + idx2 = text.index(suffix, idx1 + len(prefix)) + except ValueError: + return -1, -1, "" + idx2 += len(suffix) + block = text[idx1:idx2] + + if ( + ("/static/" in block) + or ("/media/" in block) + or (custom_static and custom_static in block) + or (custom_media and custom_media in block) + or re.search(r"/ansys\d+/", block) is not None + ): + return idx1, idx2, block + + start = idx2 + def _replace_blocks( self, html: str, prefix: str, suffix: str, inline: bool = False, size_check: bool = False ) -> str: """Iteratively finds and replaces all asset references within matching blocks.""" current_pos = 0 while True: - start, end, text_block = ReportDownloadHTML.find_block( - html, current_pos, prefix, suffix - ) + start, end, text_block = self._find_block(html, current_pos, prefix, suffix) if start < 0: break processed_text = self._replace_files(text_block, inline=inline, size_check=size_check) @@ -438,28 +489,52 @@ def _replace_blocks( current_pos = start + len(processed_text) return html + def _fix_viewer_component_paths(self, filename: str, data: bytes) -> bytes: + """ + Adjust hard-coded viewer paths for offline export, honoring custom self._static_url. + Mirrors legacy behavior but replaces the '/static/' prefix with self._static_url. + """ + ver = str(self._ansys_version) if self._ansys_version is not None else "" + if filename.endswith("ANSYSViewer_min.js"): + s = data.decode("utf-8") + # Replace "/website/images/" with dynamic base to ./media/ + s = s.replace( + f'"{self._static_url}website/images/"', + r'document.URL.replace(/\\/g, "/").replace("index.html", "media/")', + ) + # Point ansys images to local ./ansys{ver}//nexus/images/ + if ver: + s = s.replace(f'"/ansys{ver}/nexus/images/', f'"./ansys{ver}//nexus/images/') + # Allow file:// loads in offline mode (legacy behavior) + s = s.replace('"FILE",delegate', '"arraybuffer",delegate') + return s.encode("utf-8") + + if filename.endswith("viewer-loader.js"): + s = data.decode("utf-8") + if ver: + s = s.replace(f'"/ansys{ver}/nexus/images/', f'"./ansys{ver}//nexus/images/') + return s.encode("utf-8") + + return data + def _inline_ansys_viewer(self, html: str) -> str: """Handles the special case of inlining assets for the component.""" current_pos = 0 while True: - start, end, text_block = ReportDownloadHTML.find_block( + start, end, text_block = self._find_block( html, current_pos, "" ) if start < 0: break - # Legacy parity: always inline viewer attributes text = self._replace_blocks(text_block, 'proxy_img="', '"', inline=True) text = self._replace_blocks(text, 'src="', '"', inline=True, size_check=True) - if "__SIZE_EXCEPTION__" in text: msg = "3D geometry too large for stand-alone HTML file" text = text.replace('src="__SIZE_EXCEPTION__"', f'src="" proxy_only="{msg}"') - if self._replaced_file_ext: ext = self._replaced_file_ext.replace(".", "").upper() text = text.replace("