Skip to content

feat(export): self-extracting gzip standalone HTML via compress=True - #233

Open
FarhanAliRaza wants to merge 3 commits into
mainfrom
feat/compress-html-export
Open

FarhanAliRaza wants to merge 3 commits into
mainfrom
feat/compress-html-export

Conversation

@FarhanAliRaza

Copy link
Copy Markdown
Contributor

What

Opt-in compress=True on the standalone HTML export surfaces (export.to_html, Figure.to_html/html, Chart.to_html/html, FacetGrid.to_html, FacetChart.to_html): the embedded render client and data blob ride as base64-of-gzip chunks and are inflated in-browser via native DecompressionStream("gzip") before first render.

Why

At small N the inlined client dominates the file — measured 73.6% of a 10k-point export (304 KB client / 413 KB file). Entry-trimmed bundle experiments showed client surgery can't fix this (severable widget-comm code is ~16 KB; the core renderer every path needs is ~187 KB), so the lever is compression at the container level.

Measured

  • 10k-point scatter export: 413 KB → 215 KB (~49% smaller)
  • Headless-Chromium screenshot of the compressed export is byte-identical to the plain one (single figure and facet grid)
  • compress=False (default) output is byte-for-byte the pre-existing format
  • gzip pins mtime=0, so identical exports stay byte-identical (determinism contract, spec §4)

Notes

  • Browsers without DecompressionStream (pre-mid-2023) get a plain-text notice via textContent (never markup) instead of a chart.
  • CSP is unchanged: the inflated client is injected as an inline script element under the existing _STANDALONE_CSP.
  • A shared-external-client mode (plotly's include_plotlyjs='directory' shape) was evaluated, validated, and deliberately dropped — the reflex adapter already provides cached-shared-client economics, and it would widen the CSP and add sibling-file lifecycle.
  • Spec updated: spec/api/export.md §9 (new) and the §29 static-export transport row.

Tests

  • tests/test_html_export_modes.py: loader/feature-check pinning, exact-bytes Python-side gzip roundtrip of client + data chunks, determinism, default-output invariance, facet parity.
  • Full local suite: 616 passed; 1 pre-existing failure on clean main (tests/reflex_adapter/test_assets.py::test_client_source_is_the_installed_bundle expects unminified markers in the vite-minified bundle — unrelated).
  • Hooks: pre-commit run --all-files, ruff check ., ruff format --check . all green.

At small point counts the inlined render client dominates a standalone
export (~304 KB of a ~413 KB file at 10k scatter points). compress=True
embeds the client and the data blob as base64 of gzip bytes (pinned
mtime, so exports stay deterministic) and inflates them in-browser with
DecompressionStream — no bundled inflate code, no server-side
compression needed. Measured: 413 KB -> 215 KB at 10k points, with the
Chromium screenshot pixel-identical to the plain export (single figure
and facet grid). Browsers without DecompressionStream (pre-mid-2023)
get a plain-text notice; the default output stays byte-identical.

An external shared-client mode (plotly's include_plotlyjs='directory'
shape) was evaluated and deliberately not shipped — the reflex adapter
already serves the multi-chart economics, and it would widen the CSP.
Documented in spec/api/export.md §9 and the §29 static-export row.
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds opt-in gzip compression for standalone HTML exports. The main changes are:

  • Adds compress=True support to export.to_html, Figure, Chart, FacetGrid, and FacetChart HTML APIs.
  • Embeds the render client and data payload as deterministic gzip-compressed base64 chunks.
  • Inflates compressed exports in the browser with DecompressionStream before rendering.
  • Shows a plain-text compatibility notice when compressed exports run in browsers without DecompressionStream.
  • Updates export documentation and adds tests for roundtrips, determinism, fallback behavior, default output, and facet parity.

Confidence Score: 5/5

Safe to merge with low risk.

The change is localized to standalone HTML export paths, keeps compress=False as the default, validates the new option, and includes focused tests for single-chart and facet-grid compressed exports.

No files require special attention.

T-Rex T-Rex Logs

What T-Rex did

  • Recorded the uncompressed and compressed HTML payload sizes (465,743 bytes and 193,801 bytes) and the reported compression ratio (0.4161).
  • Verified the browser results for file:// exports: status 200 for both, with 3 canvases and 21 SVGs, and chart rects sized 1000x520.
  • Validated that the two exports produced identical screenshot SHA256 hashes, matching b0a333d6e162ec8a031b27255f82b869f8b6218c52433eb9824ca7bb053c1bad.
  • Collected and indexed artifacts including HTML payloads, render screenshots, browser logs, JSON metadata, and validation scripts to support independent inspection.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
python/xy/export.py Implements deterministic gzip chunking, compressed client embedding, browser-side inflation, and fallback text for single-figure standalone HTML exports.
python/xy/facets.py Extends facet grid HTML export to gzip panel payloads and the shared client when compress=True, while retaining the existing uncompressed path.
tests/test_html_export_modes.py Adds tests for compressed chunk roundtrips, loader and fallback presence, determinism, default invariance, validation, and facet parity.
python/xy/_figure.py Adds and forwards the compress flag from Figure.to_html and html to the shared export implementation without changing default behavior.
python/xy/components.py Adds compress passthroughs for chart and facet chart HTML helpers, preserving existing defaults.
spec/api/export.md Documents the standalone HTML compression mode, compatibility floor, determinism, and rationale.
spec/design-dossier.md Updates the static HTML transport row to mention the opt-in gzip self-extracting export path.

Reviews (1): Last reviewed commit: "feat(export): self-extracting gzip stand..." | Re-trigger Greptile

@codspeed

codspeed Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 13 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 109 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing feat/compress-html-export (0632795) with main (6dfae96)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@Alek99

Alek99 commented Jul 23, 2026

Copy link
Copy Markdown
Member
Screenshot 2026-07-23 at 3 26 01 PM

Perf gain doesn't seem to be enough to justify it

@Alek99 Alek99 added the revisit label Jul 23, 2026

@FarhanAliRaza FarhanAliRaza left a comment

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.

Changes needed

I validated this head (4e14e2f) by building the core and bundles and exporting a set of charts. I loaded each export over file:// in headless Chromium. The feature works: the compressed and plain screenshots are byte-identical for 1k, 10k and 1M scatter, a line chart with custom_css, a 4-panel facet grid, and an empty chart. Wheel-zoom works on the compressed export. With DecompressionStream removed, the plain-text notice shows. Output is deterministic, and compress="yes" is rejected. The console showed no errors.

Required changes:

  1. Merge main and resolve the conflicts. The branch is 149 commits behind main. python/xy/export.py conflicts with the streamed to_html parts on main, and spec/api/export.md conflicts with the §9 already on main. See the inline comments.
  2. Add a news fragment. main now requires one for changes under python/ (make news NAME=233.feature.md). The CI news check will fail once the branch is merged up.
  3. Get CI green. On this head, Test (Rust + Python + JS) and Python 3.11 floor both failed in the "Python tests (native core)" step. Those logs have expired, so re-run CI after the merge and fix what remains. Locally, tests/test_html_export_modes.py passes (6 tests), and the only full-suite failures also fail on the base commit.
  4. Add a browser test that pins the render and the fallback notice (inline comment on the test file).
  5. Record the large-N trade-off in spec §9. At 1M points the file is only 10% smaller and the export is 50% slower (inline comment on the spec). This matches the earlier comment that the gain may not justify the feature. With the numbers in the spec, that decision can be made from the data.

The two other inline comments are optional.


Generated by Claude Code

Comment thread python/xy/export.py
chunk_scripts = "\n".join(
f'<script>__xyChunks.push("{c}");</script>' for c in _base64_chunks(blob)
)
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

Comment thread spec/api/export.md Outdated
files already written stay on disk. The return value is the list of written
byte strings, in input order.

## 9. Standalone HTML delivery (`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.

Required: main already has a ## 9. What styling survives which export path in this file, so this heading conflicts. After the merge, renumber this section and update the §9 references (line 36 here, the design-dossier.md §29 row, and the docstrings).

Please also record the large-N result here, so the spec states the trade-off and not only the 10k win. Measured on this head (scatter with a color channel, gzip as shipped):

points plain compress=True export time plain → gz
1k 322 KB 130 KB —
10k 466 KB 261 KB 0.018 s → 0.022 s
1M 16.3 MB 14.7 MB (−10%) 0.73 s → 1.10 s (+50%)

At massive N the f32 data does not compress much, and the export pays the gzip cost.


Generated by Claude Code

the embedded client and data ride as base64-of-gzip chunks and are inflated
in-browser via DecompressionStream. These pin the embedded loader, the
exact-bytes Python-side roundtrip of every compressed chunk, deterministic
output (pinned gzip mtime), and that the default output is untouched."""

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: add one browser test. These tests check the Python-side bytes only. Nothing in CI proves that a compress=True document actually inflates and renders, or that the fallback notice shows. Those are the two user-visible claims of this PR. The repo already has conftest.run_browser_probe for this.

Suggested assertions, which all held in my local headless Chromium run:

  • fig.to_html(compress=True) renders the same canvas/SVG count as the plain export. In my run the screenshots were byte-identical for 1k, 10k and 1M scatter, custom_css, a 4-panel facet grid, and an empty chart.
  • With delete window.DecompressionStream in an init script, the chart div's text equals _COMPRESS_UNSUPPORTED_TEXT, and the page has no canvases.

Generated by Claude Code

Comment thread python/xy/export.py
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

Comment thread python/xy/_figure.py
(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

Preserve PR behavior while integrating the shared CI build and selection changes.
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 41 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1b1562df-10a7-4b3b-8d79-ff4f7663d393
Commits

Reviewing files that changed from the base of the PR and between 6dfae96 and 0632795.

Files selected for processing: 8
  • news/233.feature.md
  • python/xy/_figure.py
  • python/xy/components.py
  • python/xy/export.py
  • python/xy/facets.py
  • spec/api/export.md
  • spec/design-dossier.md
  • tests/test_html_export_modes.py
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

9 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="spec/api/export.md">

<violation number="1" location="spec/api/export.md:36">
P3: This new cross-reference points `to_html` readers to §9, which documents styling rather than standalone HTML delivery. Point it to §10 so the API reference reaches the new `compress` contract.</violation>
</file>

<file name="news/233.feature.md">

<violation number="1" location="news/233.feature.md:1">
P3: Mention the public opt-in explicitly so users know how to enable this feature: add `compress=True` to the release note.</violation>
</file>

<file name="python/xy/facets.py">

<violation number="1" location="python/xy/facets.py:222">
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.</violation>
</file>

<file name="tests/test_html_export_modes.py">

<violation number="1" location="tests/test_html_export_modes.py:44">
P2: This test verifies only source-text presence, so a runtime error in `DecompressionStream` handling, async client injection, or the fallback branch would still pass. Add a headless-browser probe that exercises both supported and unsupported browser paths.</violation>

<violation number="2" location="tests/test_html_export_modes.py:64">
P2: This does not pin the claimed byte-for-byte default compatibility: changes to the plain client, spec, chunk formatting, or script order would still pass. Compare the default export against an independent canonical baseline.</violation>
</file>

<file name="spec/design-dossier.md">

<violation number="1" location="spec/design-dossier.md:1132">
P3: The `compress=True` contract is documented in `spec/api/export.md` §10, not §9; §9 covers styling and sends readers to unrelated content. Change this reference to §10.</violation>

<violation number="2" location="spec/design-dossier.md:1132">
P2: The row now documents `compress=True` but leaves its copies and re-encoding columns describing only the uncompressed base64 path. Distinguish the default `1 base64 decode` from compressed `base64 decode + gzip inflate`, and document `gzip + base64` re-encoding for the compressed mode.</violation>
</file>

<file name="python/xy/components.py">

<violation number="1" location="python/xy/components.py:4257">
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.</violation>
</file>

<file name="python/xy/export.py">

<violation number="1" location="python/xy/export.py:243">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread python/xy/facets.py
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);


def test_default_output_carries_no_loader_and_pinned_csp() -> None:
doc = _fig().to_html()
assert "xyInflate" not in doc

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: This does not pin the claimed byte-for-byte default compatibility: changes to the plain client, spec, chunk formatting, or script order would still pass. Compare the default export against an independent canonical baseline.

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 tests/test_html_export_modes.py, line 64:

<comment>This does not pin the claimed byte-for-byte default compatibility: changes to the plain client, spec, chunk formatting, or script order would still pass. Compare the default export against an independent canonical baseline.</comment>

<file context>
@@ -0,0 +1,93 @@
+
+def test_default_output_carries_no_loader_and_pinned_csp() -> None:
+    doc = _fig().to_html()
+    assert "xyInflate" not in doc
+    assert "__xyClient" not in doc
+    assert f'content="{export._STANDALONE_CSP}"' in doc
</file context>


def test_compress_embeds_the_inflate_loader_and_feature_check() -> None:
doc = _fig().to_html(compress=True)
assert "function xyInflate(buf)" in doc

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: This test verifies only source-text presence, so a runtime error in DecompressionStream handling, async client injection, or the fallback branch would still pass. Add a headless-browser probe that exercises both supported and unsupported browser paths.

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 tests/test_html_export_modes.py, line 44:

<comment>This test verifies only source-text presence, so a runtime error in `DecompressionStream` handling, async client injection, or the fallback branch would still pass. Add a headless-browser probe that exercises both supported and unsupported browser paths.</comment>

<file context>
@@ -0,0 +1,93 @@
+
+def test_compress_embeds_the_inflate_loader_and_feature_check() -> None:
+    doc = _fig().to_html(compress=True)
+    assert "function xyInflate(buf)" in doc
+    assert "DecompressionStream" in doc
+    # Unsupported browsers get a plain-text notice via textContent, not markup.
</file context>

Comment thread spec/design-dossier.md
| **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract |
| **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option |
| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) |
| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |

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 row now documents compress=True but leaves its copies and re-encoding columns describing only the uncompressed base64 path. Distinguish the default 1 base64 decode from compressed base64 decode + gzip inflate, and document gzip + base64 re-encoding for the compressed mode.

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 spec/design-dossier.md, line 1132:

<comment>The row now documents `compress=True` but leaves its copies and re-encoding columns describing only the uncompressed base64 path. Distinguish the default `1 base64 decode` from compressed `base64 decode + gzip inflate`, and document `gzip + base64` re-encoding for the compressed mode.</comment>

<file context>
@@ -1129,7 +1129,7 @@ user's data structure**, and whether any step re-encodes.
 | **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract |
 | **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option |
-| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) |
+| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |
 | **Native static export** | in-process | 0 | none | — |
 
</file context>

Comment thread python/xy/export.py

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:]

Comment thread spec/api/export.md
and no `export_config` defaulting — grid geometry is fixed by its panels.

Legacy per-format surfaces remain: `to_html` (interactive standalone document),
Legacy per-format surfaces remain: `to_html` (interactive standalone document, §9),

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: This new cross-reference points to_html readers to §9, which documents styling rather than standalone HTML delivery. Point it to §10 so the API reference reaches the new compress contract.

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 spec/api/export.md, line 36:

<comment>This new cross-reference points `to_html` readers to §9, which documents styling rather than standalone HTML delivery. Point it to §10 so the API reference reaches the new `compress` contract.</comment>

<file context>
@@ -33,7 +33,7 @@ adds one behavior the `Figure` methods do not have: omitted
 and no `export_config` defaulting — grid geometry is fixed by its panels.
 
-Legacy per-format surfaces remain: `to_html` (interactive standalone document),
+Legacy per-format surfaces remain: `to_html` (interactive standalone document, §9),
 `Figure.to_png` (`engine=Engine.default` by default), `_svg.to_svg` (carries
 `id_prefix=` for composing several exports into one document). `to_png` routes
</file context>
Suggested change
Legacy per-format surfaces remain: `to_html` (interactive standalone document, §9),
Legacy per-format surfaces remain: `to_html` (interactive standalone document, §10),

Comment thread news/233.feature.md
@@ -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.

Comment thread spec/design-dossier.md
| **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract |
| **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option |
| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) |
| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |

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 compress=True contract is documented in spec/api/export.md §10, not §9; §9 covers styling and sends readers to unrelated content. Change this reference to §10.

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 spec/design-dossier.md, line 1132:

<comment>The `compress=True` contract is documented in `spec/api/export.md` §10, not §9; §9 covers styling and sends readers to unrelated content. Change this reference to §10.</comment>

<file context>
@@ -1129,7 +1129,7 @@ user's data structure**, and whether any step re-encodes.
 | **Jupyter kernel → browser** | xy's GPU-ready column blob over **binary** anywidget comm frames | **2** / 3 (payload assembly; socket transit; JS ArrayBuffer landing) | **never** — the compact f32/u8 blob lands as typed views; base64/JSON is forbidden on the live path | old frontends without binary comms: explicit unsupported/error rather than silently changing the performance contract |
 | **Server app (Dash-style / Reflex)** | versioned `XYBF` frame over binary HTTP/WebSocket; strict JSON metadata + aligned raw buffers | 1–2 / 2–3 depending on server scatter/gather support; JS decode returns spans into `Response.arrayBuffer()` | never | control requests stay small JSON; SSE carries invalidations only; HTTP range requests into Parquet/Arrow files remain a Tier-3 option |
-| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points) |
+| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |
 | **Native static export** | in-process | 0 | none | — |
 
</file context>
Suggested change
| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §9) |
| **Static HTML export (interactive)** | xy blob base64-embedded in the one-file artifact | 1 decode + stated 33% text expansion | base64, because HTML is a text container | size warning above threshold; offer aggregate-only embed (ship pyramid, not points). Opt-in `compress=True` makes the file self-extracting: client + blob embed as base64 of gzip (pinned mtime, deterministic) and inflate in-browser via `DecompressionStream` — roughly half the artifact at small N; needs a 2023+ browser, older ones get a plain-text notice (`spec/api/export.md` §10) |

Comment thread python/xy/components.py
*,
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants