ansys / ansys/pydynamicreporting

Add strict self-contained single-file HTML export

Open
#594 0 comments 0 reactions 1 assignee Claimed by @viseshrp View on GitHub
enhancement serverless
Dominant language
Python
Stars
12
Forks
5
Avg merge
2d 12h
Merged PRs (30d)
10

Description

### 📝 Description of the feature

Add an explicit strict single-file mode to the serverless HTML exporter. The existing `ADR.export_report_as_html()` behavior must remain the backward-compatible default.

Today, `export_report_as_html()` creates an offline **directory bundle**:

```text
report/
├── index.html
├── media/
├── webfonts/
└── ansys/
```

That bundle can be used offline when kept intact, but `index.html` cannot be moved, attached, or archived by itself. The current implementation confirms this behavior:

- `ServerlessReportExporter.export()` always creates output directories and copies special files.
- `_copy_special_files()` writes MathJax, viewer, image, font, and JavaScript resources beside the HTML.
- `_process_file()` inlines only selected resources; most resources are copied and rewritten to relative paths.
- Missing local resources are currently logged and left unresolved in the HTML.
- Assets above the inlining limit can fall back to proxy-only viewer output.

The documentation accurately describes the current result as a main HTML file plus subdirectories. This issue requests a separate, opt-in contract for callers that need exactly one portable HTML file.

The strict contract should be:

- On success, the target HTML file is the only required output artifact.
- The file renders with networking disabled and without a running ADR service or access to the original database, media directory, or static directory.
- Every required local dependency is embedded in the HTML, including dependencies discovered recursively from CSS and known viewer/MathJax loaders.
- Unresolved, external, dynamic, unsupported, or over-limit resources cause an explicit exception. Strict mode must not silently leave a URL, create a sibling asset, or downgrade content.
- Failure occurs before replacing the target file, so callers never receive a partial artifact that appears successful.

This is different from existing issues:

- #362 requests that serverless ADR serve static assets to a hosting application; it does not request a single-file export.
- #497 fixed a specific MathJax export defect; it does not enforce a general no-external-assets contract.
- #507 concerns `.obj`/`.glb` loading in the existing bundle export.
- #328 introduced HTML export but did not add a strict one-file mode.

### 💡 Steps for implementing the feature

#### 1. Add an explicit, backward-compatible API mode

Prefer a named mode over changing the meaning of the existing exporter:

```python
from typing import Literal

def export_report_as_html(
self,
output_directory: str | Path,
*,
filename: str = "index.html",
output_mode: Literal["bundle", "single_file"] = "bundle",
max_inline_bytes: int = 500 * 1024 * 1024,
dark_mode: bool = False,
context: dict | None = None,
item_filter: str = "",
**kwargs: Any,
) -> Path:
...
```

`output_mode="bundle"` must preserve current output and compatibility. `output_mode="single_file"` activates the strict contract. Validate `max_inline_bytes` before rendering and document that base64 expansion makes the final HTML larger than the source payloads.

Pass the mode and limit into `ServerlessReportExporter` rather than implementing a second export stack in `ADR`:

```python
exporter = ServerlessReportExporter(
html_content=html_content,
output_dir=output_dir,
media_dir=self._media_directory,
static_dir=self._static_directory,
media_url=self._media_url,
static_url=self._static_url,
filename=filename,
output_mode=output_mode,
max_inline_bytes=max_inline_bytes,
ansys_version=str(self._ansys_version),
dark_mode=dark_mode,
debug=self._debug,
logger=self._logger,
)
```

#### 2. Resolve a complete asset graph before writing

Implement single-file export as a discovery/resolution phase followed by a write phase:

```python
class SelfContainedExportError(ADRException):
def __init__(self, problems: list[UnresolvedAsset]):
self.problems = problems
super().__init__(format_problems(problems))

def export_single_file(self) -> None:
document = self._parse_document(self._html_content)
graph = self._discover_asset_graph(document)
resolved = self._resolve_asset_graph(graph)
problems = self._validate_single_file_graph(resolved)
if problems:
raise SelfContainedExportError(problems)
html = self._embed_assets(document, resolved)
self._validate_final_document(html)
self._atomic_write(self._output_dir / self._filename, html)
```

Do not use regex alone for recursive CSS or HTML dependency discovery. Parse at least:

- `` and nested CSS `@import` statements;
- CSS `url(...)` references, including fonts and images;
- ``, module imports that can be resolved statically, and known ADR dynamic-loader paths;
- `<img src>` and `srcset`;
- `<source src>` and `srcset`, `<video poster>`, `<audio src>`, and downloadable local `<a href>` resources used by report items;
- favicons and other `<link>` resource types;
- known MathJax 2.x and 4.x runtime dependencies;
- known Nexus/Three.js/Draco/viewer resources, including workers, WASM/binary payloads, and resources currently copied by `_copy_special_files()`.

Use exact MIME types rather than the current generic `application/octet-stream` for all data URIs:

```python
mime_type, _ = mimetypes.guess_type(source_path.name)
mime_type = mime_type or "application/octet-stream"
uri = f"data:{mime_type};base64,{base64.b64encode(payload).decode('ascii')}"
```

Inline stylesheet contents into `<style>` after recursively rewriting their dependencies. Inline script contents into `<script>` when browser behavior permits it. For resources that require URL semantics, create deterministic `data:` or `blob:` loading logic in the document.

Maintain a canonical-path cache and a recursion stack so repeated assets are encoded once and cyclic CSS imports produce a clear error instead of looping.

#### 3. Make unsupported cases fail explicitly

In `single_file` mode, collect structured failures instead of logging warnings and preserving the original reference. Each failure should report at least:

```python
@dataclass(frozen=True)
class UnresolvedAsset:
reference: str
owner: str
reason: Literal[
"missing",
"external",
"dynamic",
"unsupported",
"cycle",
"size_limit",
]
```

Examples that must fail rather than produce a misleading success:

- `https://`, `http://`, or protocol-relative resources required to render;
- a referenced local file that does not exist or cannot be read;
- JavaScript-computed URLs that are not covered by a known ADR asset manifest;
- assets whose aggregate encoded size exceeds `max_inline_bytes`;
- 3D geometry that currently triggers the `proxy_only="3D geometry too large..."` fallback;
- resources that the browser refuses to load from a `data:`/`blob:` representation;
- unsupported arbitrary HTML whose dependencies cannot be proven self-contained.

The exception message should enumerate every discovered problem in one pass so users can correct the report without repeated export attempts.

#### 4. Write atomically and guarantee the output shape

Render and validate in memory or in a temporary file next to the target. Replace the requested target only after all validation succeeds:

```python
temporary_path = target.with_suffix(target.suffix + ".tmp")
try:
temporary_path.write_text(final_html, encoding="utf-8")
os.replace(temporary_path, target)
finally:
temporary_path.unlink(missing_ok=True)
```

Strict mode must not call the current directory-copy path and must not leave `media/`, `webfonts/`, `ansys<version>/`, `index.raw.html`, or any other sibling artifact. If the target directory already contains unrelated files, do not delete them; only guarantee that this export adds or replaces the requested HTML file.

#### 5. Verify the final artifact, not only the transformation steps

Add unit tests for asset-graph resolution and browser-level acceptance tests that open the exported file with all network requests blocked.

Minimum coverage:

1. string and HTML items with no assets;
2. images and animations;
3. nested CSS imports, font URLs, background images, and query strings;
4. MathJax 2.x and 4.x formulas;
5. table/tree layouts and common interactive JavaScript;
6. Nexus/Three.js/Draco 3D content below the configured size limit;
7. repeated references and filename collisions;
8. missing, unreadable, external, dynamic, cyclic, and oversized resources;
9. Unicode filenames and Windows paths;
10. an existing destination file, confirming atomic replacement and no partial write on failure.

Example browser assertion:

```python
from urllib.parse import urlsplit

def reject_network_request(request):
if urlsplit(request.url).scheme in {"http", "https"}:
pytest.fail(f"network request: {request.url}")

page.on("request", reject_network_request)
page.goto(exported_path.as_uri(), wait_until="networkidle")
assert page.locator("body").is_visible()
assert not list(exported_path.parent.glob("media/**"))
assert not list(exported_path.parent.glob("webfonts/**"))
```

#### Acceptance criteria

- The existing bundle mode is unchanged.
- Strict mode either emits one fully portable HTML file or raises `SelfContainedExportError`.
- No successful strict export contains a required `http:`, `https:`, protocol-relative, `/static/`, `/media/`, or sibling relative reference.
- No successful strict export creates companion asset directories.
- Network-blocked browser tests cover text, images, MathJax, and supported interactive/3D content.
- Missing, dynamic, unsupported, and oversized resources fail before the destination is replaced.
- Documentation clearly distinguishes "offline bundle" from "strict single file" and includes size/security limitations.

### 🔗 Useful links and references

- [`ADR.export_report_as_html()` current API](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/adr.py#L1674-L1769)
- [`ServerlessReportExporter.export()` current bundle flow](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/html_exporter.py#L117-L151)
- [Current special-asset copy behavior](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/html_exporter.py#L303-L404)
- [Current file inline/copy fallback](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/html_exporter.py#L434-L533)
- Related but non-duplicate issues: #328, #362, #497, and #507.

An open-and-closed issue audit found no existing PyDynamicReporting issue requesting a strict single-file export contract.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.