Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/233.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Standalone HTML supports deterministic gzip self-extraction while the default export retains the allocation-efficient single-join path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Mention the public opt-in explicitly so users know how to enable this feature: add compress=True to the release note.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At news/233.feature.md, line 1:

<comment>Mention the public opt-in explicitly so users know how to enable this feature: add `compress=True` to the release note.</comment>

<file context>
@@ -0,0 +1 @@
+Standalone HTML supports deterministic gzip self-extraction while the default export retains the allocation-efficient single-join path.
</file context>
Suggested change
Standalone HTML supports deterministic gzip self-extraction while the default export retains the allocation-efficient single-join path.
Standalone HTML exports support opt-in `compress=True` deterministic gzip self-extraction, while the default export retains the allocation-efficient single-join path.

8 changes: 7 additions & 1 deletion python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -2262,16 +2262,20 @@ def to_html(
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
compress: bool = False,
) -> str:
"""Standalone interactive HTML: JS client + spec + base64 buffers in
one self-contained file (base64 carries a ~33% size tax). `custom_css`
injects an author stylesheet so `class_names` utility classes
(e.g. Tailwind) resolve in the export."""
(e.g. Tailwind) resolve in the export. `compress=True` gzips the
embedded client and data — self-extracting in the browser via
DecompressionStream, roughly halving the file (export.to_html)."""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional nit: "roughly halving the file" holds only at small N. At 1M points I measured −10% (16.3 MB → 14.7 MB). Say "at small point counts" here and in components.py:3388, as the spec does.

Also, write_image("x.html") / write_images route to to_html but cannot pass compress. That is consistent with the surface list in the spec, so it is fine as long as it stays intentional.


Generated by Claude Code

return export.to_html(
self,
path,
custom_css=custom_css,
animation_progress=animation_progress,
compress=compress,
)

def html(
Expand All @@ -2280,12 +2284,14 @@ def html(
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
compress: bool = False,
) -> str:
"""Alias for ``to_html`` for component-style API symmetry."""
return self.to_html(
path,
custom_css=custom_css,
animation_progress=animation_progress,
compress=compress,
)

def _repr_html_(self) -> str:
Expand Down
22 changes: 18 additions & 4 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -4254,15 +4254,19 @@ def to_html(
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
compress: bool = False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The public API reference omits the new compress parameter from Chart.to_html() and .html(). Add compress=False and document the DecompressionStream compatibility behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python/xy/components.py, line 4257:

<comment>The public API reference omits the new `compress` parameter from `Chart.to_html()` and `.html()`. Add `compress=False` and document the `DecompressionStream` compatibility behavior.</comment>

<file context>
@@ -4254,15 +4254,19 @@ def to_html(
         *,
         custom_css: Optional[str] = None,
         animation_progress: Optional[float] = None,
+        compress: bool = False,
     ) -> str:
         """A self-contained HTML document for the chart.
</file context>

) -> str:
"""A self-contained HTML document for the chart.

Writes it to ``path`` when given; returns the HTML either way.
`compress=True` follows `export.to_html`: gzip self-extraction via
DecompressionStream, roughly halving the file.
"""
return self.figure().to_html(
path,
custom_css=custom_css,
animation_progress=animation_progress,
compress=compress,
)

def html(
Expand All @@ -4271,12 +4275,14 @@ def html(
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
compress: bool = False,
) -> str:
"""Alias of `to_html`."""
return self.to_html(
path,
custom_css=custom_css,
animation_progress=animation_progress,
compress=compress,
)

def _repr_html_(self) -> str:
Expand Down Expand Up @@ -5553,16 +5559,24 @@ def show(self, display: Optional[str] = None) -> Any:
return export.HtmlView(self._repr_html_())

def to_html(
self, path: Optional[str | PathLike[str]] = None, *, custom_css: Optional[str] = None
self,
path: Optional[str | PathLike[str]] = None,
*,
custom_css: Optional[str] = None,
compress: bool = False,
) -> str:
"""A self-contained HTML document laying the panels out as a grid."""
return self.figure().to_html(path, custom_css=custom_css)
return self.figure().to_html(path, custom_css=custom_css, compress=compress)

def html(
self, path: Optional[str | PathLike[str]] = None, *, custom_css: Optional[str] = None
self,
path: Optional[str | PathLike[str]] = None,
*,
custom_css: Optional[str] = None,
compress: bool = False,
) -> str:
"""Alias of `to_html`."""
return self.to_html(path, custom_css=custom_css)
return self.to_html(path, custom_css=custom_css, compress=compress)

def _repr_html_(self) -> str:
grid = self.figure()
Expand Down
161 changes: 161 additions & 0 deletions python/xy/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import base64
import gzip
import html as _html
import json
import math
Expand Down Expand Up @@ -215,6 +216,59 @@ def _base64_chunks(blob: bytes) -> list[str]:
"} return bytes.buffer; }"
)

# Inline gzip inflater for `compress=True` exports: browser-native
# DecompressionStream (baseline in all major browsers since mid-2023), no
# bundled inflate code. Returns a Promise<ArrayBuffer> sized exactly to the
# decompressed bytes, so `spec.columns[i].byte_offset` stays valid end-to-end.
_INFLATE_JS = (
"function xyInflate(buf) {"
'const ds = new DecompressionStream("gzip");'
"return new Response(new Blob([buf]).stream().pipeThrough(ds)).arrayBuffer(); }"
)

# Shown in the chart div (plain text node — no markup) when a compress=True
# export lands in a browser without DecompressionStream.
_COMPRESS_UNSUPPORTED_TEXT = (
"This chart export uses gzip compression (compress=True) and needs a "
"browser with DecompressionStream (all major browsers since mid-2023). "
"Re-export with compress=False for maximum compatibility."
)
# The same notice as a ready-to-embed JS string literal (assigned to
# textContent, so it renders as plain text, never markup).
_COMPRESS_UNSUPPORTED_JS = json.dumps(_COMPRESS_UNSUPPORTED_TEXT)


def _gzip_deterministic(data: bytes, *, level: int) -> bytes:
"""gzip with a pinned mtime so identical exports stay byte-identical."""
return gzip.compress(data, compresslevel=level, mtime=0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: _gzip_deterministic still emits a platform-dependent gzip OS header on Python 3.11/3.12, so compress=True is not byte-deterministic across supported environments. Normalize gzip header byte 9 before embedding.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python/xy/export.py, line 243:

<comment>`_gzip_deterministic` still emits a platform-dependent gzip OS header on Python 3.11/3.12, so `compress=True` is not byte-deterministic across supported environments. Normalize gzip header byte 9 before embedding.</comment>

<file context>
@@ -215,6 +216,59 @@ def _base64_chunks(blob: bytes) -> list[str]:
+
+def _gzip_deterministic(data: bytes, *, level: int) -> bytes:
+    """gzip with a pinned mtime so identical exports stay byte-identical."""
+    return gzip.compress(data, compresslevel=level, mtime=0)
+
+
</file context>
Suggested change
return gzip.compress(data, compresslevel=level, mtime=0)
compressed = gzip.compress(data, compresslevel=level, mtime=0)
return compressed[:9] + b"\xff" + compressed[10:]



def _chunk_scripts(var: str, blob: bytes) -> str:
"""One `<script>` push per base64 chunk (see the chunking note in to_html)."""
return "\n".join(f'<script>{var}.push("{c}");</script>' for c in _base64_chunks(blob))


def _client_assets(compress: bool) -> tuple[str, str]:
"""(client block HTML, async-boot prelude JS) for the inline render client.

With `compress` the bundle embeds as base64(gzip(bundle)) chunks; the
prelude inflates them and injects a script element (an inline script under
CSP, executed synchronously on append) so `window.xy` exists before first
render. The plain block needs no prelude: it has already run."""
if not compress:
return f"<script>{_javascript_for_inline_script(_bundled_js('standalone'))}</script>", ""
gz = _gzip_deterministic(_bundled_js("standalone").encode("utf-8"), level=9)
block = "<script>var __xyClient = [];</script>\n" + _chunk_scripts("__xyClient", gz)
prelude = (
"const __xyCode = new TextDecoder().decode("
f"await xyInflate(xyDecodeB64(__xyClient, {len(gz)})));\n"
" __xyClient.length = 0;\n"
' const __xyTag = document.createElement("script");\n'
" __xyTag.textContent = __xyCode;\n"
" document.head.appendChild(__xyTag);"
)
return block, prelude


def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None:
"""Write bytes through a same-directory temp file, then replace atomically."""
Expand Down Expand Up @@ -288,6 +342,113 @@ def to_html(
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
compress: bool = False,
) -> str:
"""Render `fig` to a standalone interactive HTML string (optionally saved).

User strings (title, names, labels) ride inside <script>/<title> blocks:
HTML-sensitive JSON characters are escaped so user text cannot alter the
script parse state, and the <title> text is entity-escaped.

`custom_css` injects an author stylesheet into the document <head> so the
utility classes referenced by `class_names` (e.g. Tailwind) resolve in the
standalone export; it must not contain a `</style>` breakout sequence.

`compress=True` makes the export self-extracting: the embedded client and
data payload ride as base64 of gzip bytes and are inflated in the browser
via DecompressionStream — roughly halving the file with no server-side
compression required. Needs a 2023+ browser; unsupported browsers get a
plain-text notice instead of a chart."""
compress = _bool_option(compress, "to_html compress")
if not compress:
return _to_html_plain(
fig, path, custom_css=custom_css, animation_progress=animation_progress
)
spec, blob = fig.build_payload()
if animation_progress is not None:
progress = float(animation_progress)
if not math.isfinite(progress) or not 0.0 <= progress <= 1.0:
raise ValueError("animation progress must be between 0 and 1")
spec["animation_capture_progress"] = progress
if len(blob) > EMBED_WARN_BYTES:
import warnings

warnings.warn(
f"embedding {len(blob) / 2**20:.0f} MB of data (+33% as base64) "
"into HTML; consider the aggregated embed once Tier 2 lands.",
RuntimeWarning,
stacklevel=3,
)
spec_js = _json_for_inline_script(spec)
title_html = _html.escape(fig.title or "xy")
client_html, client_prelude = _client_assets(compress)
# One <script> block PER chunk: a script element's source is itself a V8
# string, so folding every chunk into one block would rebuild the very
# ~512 MB single-string ceiling the chunking removed. Per-block sources
# stay at ~64 MB regardless of payload size. Chunk text is standard base64
# (`[A-Za-z0-9+/=]`) — it can hold no `"`, `\`, newline, or `<`, so it is a
# valid JS string literal verbatim and can never close the <script>. Quote
# it directly rather than via `json.dumps`, whose full-string escape scan
# dominated small-chart export cost.
wire = _gzip_deterministic(bytes(blob), level=6) if compress else blob

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required: this hunk conflicts with main. git merge-tree origin/main 4e14e2f reports content conflicts in python/xy/export.py and spec/api/export.md. On main, to_html now streams parts through _iter_base64_chunks so the document never holds a second full copy of the payload. Merge main, then route the compressed chunks through that same streamed-parts path.

Also drop the bytes(blob) copy here. gzip.compress accepts any bytes-like object, so pass the blob (or a memoryview) directly. Today the compressed path holds the blob, a full bytes copy, and the gzip output at once.


Generated by Claude Code

chunk_scripts = _chunk_scripts("__xyChunks", wire)
if compress:
boot = f"""<script>
{_DECODE_B64_JS}
{_INFLATE_JS}
(async () => {{
const el = document.getElementById("chart");
if (typeof DecompressionStream === "undefined") {{
el.textContent = {_COMPRESS_UNSUPPORTED_JS};
return;
}}
{client_prelude}
const spec = {spec_js};
const buf = await xyInflate(xyDecodeB64(__xyChunks, {len(wire)}));
__xyChunks.length = 0;
xy.renderStandalone(el, spec, buf);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: if the inflate rejects, the async IIFE has no catch, so the user gets a blank page and an uncaught rejection. Repro: make DecompressionStream return a TransformStream that errors. The page shows an empty #chart, and the console logs pageerror: Failed to fetch. A try/catch that writes a plain-text notice through textContent would match the unsupported-browser path. The same applies to the facet boot in facets.py.


Generated by Claude Code

}})();
</script>"""
else:
boot = f"""<script>
{_DECODE_B64_JS}
const spec = {spec_js};
const buf = xyDecodeB64(__xyChunks, {len(wire)});
__xyChunks.length = 0;
xy.renderStandalone(document.getElementById("chart"), spec, buf);
</script>"""
doc = f"""<!doctype html>
<html>
<head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="{_STANDALONE_CSP}">
<title>{title_html}</title>
<style>
html,body{{margin:0;width:100%;min-height:100%;font-family:system-ui,sans-serif;background:#fff;}}
#chart{{width:100%;}}
</style>
{_custom_css_block(custom_css)}</head>
<body>
<div id="chart"></div>
{client_html}
<script>var __xyChunks = [];</script>
{chunk_scripts}
{boot}
</body>
</html>"""
if path is not None:
_atomic_write_text(path, doc)
return doc


_NOTEBOOK_DIMENSION_RE = _re.compile(r"^[0-9]+(?:\.[0-9]+)?(?:px|%|vw|vh|rem|em)?$")


def _to_html_plain(
fig: "Figure",
path: Optional[str | PathLike[str]] = None,
*,
custom_css: Optional[str] = None,
animation_progress: Optional[float] = None,
) -> str:
"""Render `fig` to a standalone interactive HTML string (optionally saved).

Expand Down
54 changes: 40 additions & 14 deletions python/xy/facets.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,21 +175,26 @@ def to_html(
path: Optional[str | PathLike[str]] = None,
*,
custom_css: Optional[str] = None,
compress: bool = False,
) -> str:
"""A self-contained HTML document laying the panels out as a grid.

Writes it to ``path`` when given; returns the HTML either way.
`compress` follows `export.to_html`: gzip self-extraction via
DecompressionStream.
"""
compress = export._bool_option(compress, "to_html compress")
panels: list[str] = []
for i, fig in enumerate(self.figures):
spec, blob = fig.build_payload(px_width=self.panel_width)
wire = export._gzip_deterministic(bytes(blob), level=6) if compress else blob
panels.append(
"{" + f'"id":"xy-facet-{i}",'
f'"spec":{export._json_for_inline_script(spec)},'
f'"chunks":{export._json_for_inline_script(export._base64_chunks(blob))},'
f'"n":{len(blob)}' + "}"
f'"chunks":{export._json_for_inline_script(export._base64_chunks(wire))},'
f'"n":{len(wire)}' + "}"
)
js = export._javascript_for_inline_script(export._bundled_js("standalone"))
client_html, client_prelude = export._client_assets(compress)
title = export._html.escape(self.title or "xy facets")
# Grid title rendered once here; each panel's own chart title is its
# facet label, so labels are not duplicated in a separate strip.
Expand All @@ -199,6 +204,36 @@ def to_html(
else ""
)
css = export._custom_css_block(custom_css)
if compress:
boot = f"""<script>
{export._DECODE_B64_JS}
{export._INFLATE_JS}
(async () => {{
const grid=document.getElementById("xy-facet-grid");
if (typeof DecompressionStream === "undefined") {{
grid.textContent = {export._COMPRESS_UNSUPPORTED_JS};
return;
}}
{client_prelude}
const panels=[{",".join(panels)}];
for(const p of panels){{
const panel=document.createElement("div"); panel.className="xy-facet-panel";
const host=document.createElement("div"); host.id=p.id; panel.append(host); grid.appendChild(panel);
const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); xy.renderStandalone(host,p.spec,buf);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The compressed facet loop never releases p.chunks after inflation. Clear that array immediately after await; otherwise large facet exports retain every compressed base64 chunk while each ChartView already retains its decompressed payload, increasing peak memory and risking browser OOM.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python/xy/facets.py, line 222:

<comment>The compressed facet loop never releases `p.chunks` after inflation. Clear that array immediately after `await`; otherwise large facet exports retain every compressed base64 chunk while each `ChartView` already retains its decompressed payload, increasing peak memory and risking browser OOM.</comment>

<file context>
@@ -199,6 +204,36 @@ def to_html(
+  for(const p of panels){{
+    const panel=document.createElement("div"); panel.className="xy-facet-panel";
+    const host=document.createElement("div"); host.id=p.id; panel.append(host); grid.appendChild(panel);
+    const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); xy.renderStandalone(host,p.spec,buf);
+  }}
+}})();
</file context>
Suggested change
const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); xy.renderStandalone(host,p.spec,buf);
const buf=await xyInflate(xyDecodeB64(p.chunks,p.n)); p.chunks.length=0; xy.renderStandalone(host,p.spec,buf);

}}
}})();
</script>"""
else:
boot = f"""<script>
{export._DECODE_B64_JS}
const panels=[{",".join(panels)}];
const grid=document.getElementById("xy-facet-grid");
for(const p of panels){{
const panel=document.createElement("div"); panel.className="xy-facet-panel";
const host=document.createElement("div"); host.id=p.id; panel.append(host); grid.appendChild(panel);
const buf=xyDecodeB64(p.chunks,p.n); xy.renderStandalone(host,p.spec,buf);
}}
</script>"""
doc = f"""<!doctype html>
<html><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="{export._STANDALONE_CSP}">
Expand All @@ -211,17 +246,8 @@ def to_html(
</style>
{css}</head><body class="xy-facet-document">
{heading}<div class="xy-facet-grid" id="xy-facet-grid"></div>
<script>{js}</script>
<script>
{export._DECODE_B64_JS}
const panels=[{",".join(panels)}];
const grid=document.getElementById("xy-facet-grid");
for(const p of panels){{
const panel=document.createElement("div"); panel.className="xy-facet-panel";
const host=document.createElement("div"); host.id=p.id; panel.append(host); grid.appendChild(panel);
const buf=xyDecodeB64(p.chunks,p.n); xy.renderStandalone(host,p.spec,buf);
}}
</script></body></html>"""
{client_html}
{boot}</body></html>"""
if path is not None:
export._atomic_write_text(path, doc)
return doc
Expand Down
Loading
Loading