11import base64
22import os
33from pathlib import Path
4+ import re
45from typing import Any
56
67from ..adr_utils import get_logger
1617 VIEWER_JS ,
1718 VIEWER_UTILS ,
1819)
19- from ..utils .report_download_html import ReportDownloadHTML
2020
2121
2222class ServerlessReportExporter :
@@ -34,8 +34,10 @@ def __init__(
3434 self ,
3535 html_content : str ,
3636 output_dir : Path ,
37- static_dir : Path ,
3837 media_dir : Path ,
38+ static_dir : Path ,
39+ media_url : str ,
40+ static_url : str ,
3941 * ,
4042 filename : str = "index.html" ,
4143 no_inline_files : bool = False ,
@@ -49,16 +51,18 @@ def __init__(
4951 """
5052 self ._html_content = html_content
5153 self ._output_dir = output_dir
52- self ._static_dir = static_dir
5354 self ._media_dir = media_dir
55+ self ._static_dir = static_dir
56+ self ._media_url = media_url
57+ self ._static_url = static_url
5458 self ._filename = filename
5559 self ._debug = debug
5660 self ._logger = logger or get_logger ()
5761 self ._no_inline = no_inline_files
5862 self ._ansys_version = ansys_version
5963 self ._dark_mode = dark_mode
6064
61- # State tracking properties, functionally identical to ReportDownloadHTML
65+ # State tracking properties
6266 self ._filemap : dict [str , str ] = {}
6367 self ._replaced_file_ext : str | None = None
6468 self ._collision_count = 0
@@ -96,7 +100,8 @@ def export(self) -> None:
96100 html = self ._replace_blocks (html , "<link" , "/>" )
97101 html = self ._replace_blocks (html , "<img id='guiicon" , ">" )
98102 html = self ._replace_blocks (html , "e.src = '" , "';" )
99- html = self ._replace_blocks (html , "<script src=" , "</script>" )
103+ html = self ._replace_blocks (html , "<script src=" , "</script>" ) # src-first scripts
104+ html = self ._replace_blocks (html , "<script" , "</script>" ) # any-order scripts (MathJax)
100105 html = self ._replace_blocks (html , "<a href=" , ">" )
101106 html = self ._replace_blocks (html , "<img src=" , ">" )
102107 html = self ._replace_blocks (html , "<source src=" , ">" )
@@ -161,19 +166,24 @@ def _replace_files(self, text: str, inline: bool = False, size_check: bool = Fal
161166 current = 0
162167 ver = str (self ._ansys_version ) if self ._ansys_version is not None else ""
163168
164- patterns = (
165- f"/static/ansys{ ver } /" ,
166- "/static/" ,
167- "/media/" ,
168- f"/ansys{ ver } /" ,
169- )
169+ patterns = []
170+ if ver :
171+ patterns .append (f"{ self ._static_url } ansys{ ver } /" ) # custom
172+ patterns .append (f"/static/ansys{ ver } /" ) # legacy literal
173+
174+ # custom first, then legacy literals
175+ patterns .extend ([self ._static_url , self ._media_url , "/static/" , "/media/" ])
176+
177+ if ver :
178+ patterns .append (f"/ansys{ ver } /" ) # server-root style
170179
171180 while True :
172181 # Find the next match using the legacy priority order
173182 idx1 = - 1
174183 for pat in patterns :
175- idx1 = text .find (pat , current )
176- if idx1 != - 1 :
184+ pos = text .find (pat , current )
185+ if pos != - 1 :
186+ idx1 = pos
177187 break
178188 if idx1 == - 1 :
179189 return text # nothing more to replace
@@ -192,7 +202,6 @@ def _replace_files(self, text: str, inline: bool = False, size_check: bool = Fal
192202 self ._replaced_file_ext = ext
193203
194204 new_path = self ._process_file (path_in_html , simple_path , inline = inline )
195-
196205 if size_check and self ._inline_size_exception :
197206 new_path = "__SIZE_EXCEPTION__"
198207
@@ -309,9 +318,7 @@ def _copy_static_file(self, source_rel_path: str, target_rel_path: str):
309318 target_file .parent .mkdir (parents = True , exist_ok = True )
310319 content = source_file .read_bytes ()
311320 # Patch some viewer JS internals (loader/paths) if needed
312- content = ReportDownloadHTML .fix_viewer_component_paths (
313- str (target_file ), content , self ._ansys_version
314- )
321+ content = self ._fix_viewer_component_paths (str (target_file ), content )
315322 target_file .write_bytes (content )
316323 else :
317324 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
322329 self ._copy_static_file (source_prefix .lstrip ("/" ) + f , target_prefix + f )
323330
324331 def _make_unique_basename (self , name : str ) -> str :
325- """Ensures a unique filename in the target media directory to avoid collisions."""
332+ """Ensures a unique filename in the target media directory to avoid collisions (legacy) ."""
326333 if not self ._no_inline :
327334 return name
328335 target_path = self ._output_dir / "media" / name
@@ -331,6 +338,10 @@ def _make_unique_basename(self, name: str) -> str:
331338 self ._collision_count += 1
332339 return f"{ self ._collision_count } _{ name } "
333340
341+ @staticmethod
342+ def is_scene_file (name : str ) -> bool :
343+ return name .upper ().endswith ((".AVZ" , ".SCDOC" , ".SCDOCX" , ".GLB" ))
344+
334345 def _process_file (self , path_in_html : str , pathname : str , inline : bool = False ) -> str :
335346 """
336347 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)
348359 return self ._filemap [pathname ]
349360
350361 # Resolve source file location based on the raw pathname (no normalization)
351- if pathname .startswith ("/media/" ):
362+ ver = str (self ._ansys_version ) if self ._ansys_version is not None else ""
363+
364+ # Source resolution: custom first, then legacy literals
365+ if pathname .startswith (self ._media_url ):
366+ source_file = self ._media_dir / pathname .replace (self ._media_url , "" , 1 )
367+ elif pathname .startswith (self ._static_url ):
368+ source_file = self ._static_dir / pathname .replace (self ._static_url , "" , 1 )
369+ elif pathname .startswith ("/media/" ): # legacy literal
352370 source_file = self ._media_dir / pathname .replace ("/media/" , "" , 1 )
353- elif pathname .startswith ("/static/" ):
371+ elif pathname .startswith ("/static/" ): # legacy literal
354372 source_file = self ._static_dir / pathname .replace ("/static/" , "" , 1 )
355- elif pathname .startswith (f"/ansys{ self ._ansys_version } /" ):
356- # Legacy downloads these from the server root; serverless reads them from static dir
373+ elif ver and pathname .startswith (f"/ansys{ ver } /" ):
357374 source_file = self ._static_dir / pathname .lstrip ("/" )
358375 else :
359376 source_file = None
@@ -375,7 +392,7 @@ def _process_file(self, path_in_html: str, pathname: str, inline: bool = False)
375392 # 4/3 is roughly the expansion factor of base64 encoding (3 bytes -> 4 chars)
376393 estimated_inline_size = int (len (content ) * (4.0 / 3.0 ))
377394
378- if (inline or ReportDownloadHTML .is_scene_file (pathname )) and self ._should_use_data_uri (
395+ if (inline or self .is_scene_file (pathname )) and self ._should_use_data_uri (
379396 estimated_inline_size
380397 ):
381398 # Inline as data URI
@@ -398,68 +415,126 @@ def _process_file(self, path_in_html: str, pathname: str, inline: bool = False)
398415 # prefix with parent folder (GUID) like legacy
399416 basename = f"{ source_file .parent .name } _{ basename } "
400417 else :
401- content = ReportDownloadHTML .fix_viewer_component_paths (
402- basename , content , self ._ansys_version
403- )
418+ content = self ._fix_viewer_component_paths (basename , content )
404419
405- # Output path (exact legacy behavior):
406- # - If /static/ansys{ver}/ -> keep ansys tree, remove '/static/' -> './ansys{ver}/.../<basename>'
407- # - Else -> './media/<basename>'
408- if pathname .startswith (f"/static/ansys{ self ._ansys_version } /" ):
409- local_pathname = os .path .dirname (pathname ).replace ("/static/" , "./" , 1 )
410- result = f"{ local_pathname } /{ basename } "
420+ # Output path:
421+ # keep ansys tree if the input path came from /static/ansys{ver}/ (custom OR legacy literal)
422+ if ver and (
423+ pathname .startswith (f"{ self ._static_url } ansys{ ver } /" )
424+ or pathname .startswith (f"/static/ansys{ ver } /" )
425+ ):
426+ local_pathname = os .path .dirname (pathname )
427+ # normalize either custom or legacy /static/ to './'
428+ if local_pathname .startswith (self ._static_url ):
429+ local_pathname = local_pathname .replace (self ._static_url , "./" , 1 )
430+ elif local_pathname .startswith ("/static/" ):
431+ local_pathname = local_pathname .replace ("/static/" , "./" , 1 )
411432
433+ result = f"{ local_pathname } /{ basename } "
412434 target_file = self ._output_dir / local_pathname .lstrip ("./" ) / basename
413- target_file .parent .mkdir (parents = True , exist_ok = True )
414- target_file .write_bytes (content )
415435 else :
416436 result = f"./media/{ basename } "
417-
418437 target_file = self ._output_dir / "media" / basename
419- target_file .parent .mkdir (parents = True , exist_ok = True )
420- target_file .write_bytes (content )
438+
439+ target_file .parent .mkdir (parents = True , exist_ok = True )
440+ target_file .write_bytes (content )
421441
422442 self ._filemap [pathname ] = result
423443 return result
424444
445+ def _find_block (self , text : str , start : int , prefix : str , suffix : str ) -> tuple [int , int , str ]:
446+ """
447+ Legacy-compatible: return the next [prefix ... suffix] block that contains at least
448+ one asset-like reference. Accept both the literal legacy prefixes and the configured
449+ custom prefixes. Also accept any '/ansys<ver>/' or generic '/ansys<digits>/'.
450+ """
451+ # Normalize known prefixes (custom URLs may differ from /static/ and /media/)
452+ custom_static = (self ._static_url or "" ).strip ()
453+ custom_media = (self ._media_url or "" ).strip ()
454+
455+ while True :
456+ try :
457+ idx1 = text .index (prefix , start )
458+ except ValueError :
459+ return - 1 , - 1 , ""
460+ try :
461+ idx2 = text .index (suffix , idx1 + len (prefix ))
462+ except ValueError :
463+ return - 1 , - 1 , ""
464+ idx2 += len (suffix )
465+ block = text [idx1 :idx2 ]
466+
467+ if (
468+ ("/static/" in block )
469+ or ("/media/" in block )
470+ or (custom_static and custom_static in block )
471+ or (custom_media and custom_media in block )
472+ or re .search (r"/ansys\d+/" , block ) is not None
473+ ):
474+ return idx1 , idx2 , block
475+
476+ start = idx2
477+
425478 def _replace_blocks (
426479 self , html : str , prefix : str , suffix : str , inline : bool = False , size_check : bool = False
427480 ) -> str :
428481 """Iteratively finds and replaces all asset references within matching blocks."""
429482 current_pos = 0
430483 while True :
431- start , end , text_block = ReportDownloadHTML .find_block (
432- html , current_pos , prefix , suffix
433- )
484+ start , end , text_block = self ._find_block (html , current_pos , prefix , suffix )
434485 if start < 0 :
435486 break
436487 processed_text = self ._replace_files (text_block , inline = inline , size_check = size_check )
437488 html = html [:start ] + processed_text + html [end :]
438489 current_pos = start + len (processed_text )
439490 return html
440491
492+ def _fix_viewer_component_paths (self , filename : str , data : bytes ) -> bytes :
493+ """
494+ Adjust hard-coded viewer paths for offline export, honoring custom self._static_url.
495+ Mirrors legacy behavior but replaces the '/static/' prefix with self._static_url.
496+ """
497+ ver = str (self ._ansys_version ) if self ._ansys_version is not None else ""
498+ if filename .endswith ("ANSYSViewer_min.js" ):
499+ s = data .decode ("utf-8" )
500+ # Replace "<static>/website/images/" with dynamic base to ./media/
501+ s = s .replace (
502+ f'"{ self ._static_url } website/images/"' ,
503+ r'document.URL.replace(/\\/g, "/").replace("index.html", "media/")' ,
504+ )
505+ # Point ansys images to local ./ansys{ver}//nexus/images/
506+ if ver :
507+ s = s .replace (f'"/ansys{ ver } /nexus/images/' , f'"./ansys{ ver } //nexus/images/' )
508+ # Allow file:// loads in offline mode (legacy behavior)
509+ s = s .replace ('"FILE",delegate' , '"arraybuffer",delegate' )
510+ return s .encode ("utf-8" )
511+
512+ if filename .endswith ("viewer-loader.js" ):
513+ s = data .decode ("utf-8" )
514+ if ver :
515+ s = s .replace (f'"/ansys{ ver } /nexus/images/' , f'"./ansys{ ver } //nexus/images/' )
516+ return s .encode ("utf-8" )
517+
518+ return data
519+
441520 def _inline_ansys_viewer (self , html : str ) -> str :
442521 """Handles the special case of inlining assets for the <ansys-nexus-viewer> component."""
443522 current_pos = 0
444523 while True :
445- start , end , text_block = ReportDownloadHTML . find_block (
524+ start , end , text_block = self . _find_block (
446525 html , current_pos , "<ansys-nexus-viewer" , "</ansys-nexus-viewer>"
447526 )
448527 if start < 0 :
449528 break
450-
451529 # Legacy parity: always inline viewer attributes
452530 text = self ._replace_blocks (text_block , 'proxy_img="' , '"' , inline = True )
453531 text = self ._replace_blocks (text , 'src="' , '"' , inline = True , size_check = True )
454-
455532 if "__SIZE_EXCEPTION__" in text :
456533 msg = "3D geometry too large for stand-alone HTML file"
457534 text = text .replace ('src="__SIZE_EXCEPTION__"' , f'src="" proxy_only="{ msg } "' )
458-
459535 if self ._replaced_file_ext :
460536 ext = self ._replaced_file_ext .replace ("." , "" ).upper ()
461537 text = text .replace ("<ansys-nexus-viewer" , f'<ansys-nexus-viewer src_ext="{ ext } "' )
462-
463538 html = html [:start ] + text + html [end :]
464539 current_pos = start + len (text )
465540 return html
0 commit comments