diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d79f684..586c2b54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,29 @@ in the README).
to the internal engine object.
### Added
+- **Export format parity and a unified export API (ENG-10447).**
+ `to_image(format=...)` and extension-inferred, atomic `write_image(path)`
+ on charts, facet grids, and the internal figure cover PNG, JPEG/JPG, WebP,
+ SVG, and PDF alongside interactive HTML; `to_png`/`to_svg`/`to_html`
+ remain as compatibility conveniences. All five image formats export
+ browser-free by default: JPEG uses a new pure-numpy baseline encoder
+ (4:4:4, quality 1-100), WebP a new bit-exact lossless VP8L encoder with
+ alpha, and PDF a new vector backend that converts XY's own SVG output
+ (vector text via Helvetica metrics, axial-shading gradients, embedded
+ rasters for density/heatmap layers — the documented hybrid-vector
+ policy). `engine=Engine.auto` deterministically selects native per
+ format and switches to Chromium only for `custom_css`;
+ `Engine.chromium` adds browser-fidelity JPEG/WebP (CDP screenshots) and
+ PDF (`printToPDF`). A shared background policy spans every format
+ ("auto"/CSS color/"transparent", JPEG rejects transparent instead of
+ silently flattening). `xy.write_images(figures=..., files=...)` batches
+ mixed formats through one reused browser session with atomic per-file
+ writes. `xy.export_config()` declares formats/filename/dimensions/
+ scale/background/quality on the chart itself, governing both Python
+ defaults and the modebar's download menu, which now offers PNG, JPEG,
+ WebP, SVG, and CSV (client-safe subset) with the same filename and
+ background semantics — including in standalone HTML with no kernel and
+ in Reflex apps.
- **Declarative continuous colorbars.** `xy.colorbar()` derives the domain,
colormap, and default title from the last compatible heatmap, continuous
scatter, hexbin, contour, segment, or triangle-mesh mark, with explicit
diff --git a/README.md b/README.md
index fb996ce8..4c9f5d95 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,8 @@ Its Rust core and WebGL2 renderer keep work bounded by what the screen can show.
- **Interactive by default.** Pan, zoom, hover, select, and inspect exact source
rows without shipping the entire dataset as JSON.
- **One chart, many outputs.** Display in Jupyter, VS Code, Colab, and Marimo,
- or export self-contained HTML, browser-free PNG, and SVG.
+ or export self-contained HTML plus browser-free PNG, JPEG, WebP, SVG, and
+ PDF through one `to_image`/`write_image` API.
- **Designed for applications.** Layer marks and style both chart chrome and
marks with CSS/Tailwind-friendly hooks, gradients, strokes, and curves.
diff --git a/docs/api-reference/figure-methods.md b/docs/api-reference/figure-methods.md
index 53bede59..80fc72d6 100644
--- a/docs/api-reference/figure-methods.md
+++ b/docs/api-reference/figure-methods.md
@@ -50,6 +50,35 @@ document string or PNG bytes; with a path, they also write the result.
browser CSS/WebGL fidelity. `custom_css` works for HTML and Chromium PNG;
native PNG rejects author CSS because it has no browser cascade.
+## Unified Image Export
+
+~~~python
+chart.to_image(
+ format="png", # png | jpeg/jpg | webp | svg | pdf
+ *,
+ width=None,
+ height=None,
+ scale=None, # device-pixel-ratio for raster formats
+ background=None, # "auto" | CSS color | "transparent"
+ engine=xy.Engine.auto,
+ quality=None, # JPEG / Chromium-WebP, 1-100 (default 90)
+ optimize=False,
+ custom_css=None,
+ sandbox=True,
+ gl="software",
+) -> bytes
+chart.write_image(path, *, format=None, ...) -> bytes # same options
+~~~
+
+`write_image()` infers the format from the file extension (`.png`, `.jpg`,
+`.jpeg`, `.webp`, `.svg`, `.pdf`; `.html` routes to `to_html()`), writes
+atomically, and returns the written bytes. `Engine.auto` deterministically
+selects the native path per format, switching to Chromium only when
+`custom_css` is passed. Omitted width/height/scale/background/quality fall
+back to the chart's `export_config()` defaults. Module-level batch export is
+`xy.write_images(figures=..., files=...)` — mixed formats, one shared browser
+session for Chromium-resolved files, atomic per-file writes.
+
## Data Readout and Mutation
~~~python
@@ -83,7 +112,8 @@ objects. `reflex_components()` is an alias retained for adapter code.
## FacetChart Methods
`FacetChart` provides `figure()`, `widget()`, `show()`, `to_html()`/`html()`,
-`to_svg()`, `to_png()`, and `memory_report()`. Its widget methods return one
+`to_svg()`, `to_png()`, `to_image()`, `write_image()`, and `memory_report()`.
+Its widget methods return one
widget per panel, and its figure escape hatch returns an internal facet grid.
Grid dimensions come from `facet_chart()`, so the facet SVG/PNG methods do not
accept per-call width or height. Facets do not expose append, pick, or
diff --git a/docs/guides/display-and-export.md b/docs/guides/display-and-export.md
index 692ab5d3..49f9fbbe 100644
--- a/docs/guides/display-and-export.md
+++ b/docs/guides/display-and-export.md
@@ -1,12 +1,13 @@
---
title: Display and Export
-description: Display live charts and export standalone HTML, PNG, SVG, or image batches.
+description: Display live charts and export PNG, JPEG, WebP, SVG, PDF, HTML, or image batches.
---
# Display and Export
-The same composed chart can display as a live notebook widget or produce three
-standalone output families.
+The same composed chart can display as a live notebook widget or export
+through one unified static API covering PNG, JPEG, WebP, SVG, PDF, and
+standalone interactive HTML.
## Notebook Display
@@ -22,6 +23,87 @@ chart.widget()
See [Notebooks](/docs/xy/integrations/notebooks/) for callbacks, binary comms,
and supported hosts.
+## Unified Image Export
+
+`to_image()` returns bytes; `write_image()` writes a file atomically and
+infers the format from the extension:
+
+~~~python
+data = chart.to_image("pdf", width=1200, height=800, scale=2)
+
+chart.write_image("reports/revenue.webp") # format inferred from .webp
+chart.write_image("reports/revenue.bin", format="png") # explicit override
+~~~
+
+### Format and engine matrix
+
+| Format | Native (browser-free) | Chromium | Notes |
+| --- | --- | --- | --- |
+| `png` | yes (default) | yes | transparency supported |
+| `jpeg` / `jpg` | yes (default) | yes | no alpha; flattens onto `background` (default white); `quality` 1-100 (default 90) |
+| `webp` | yes (default) | yes | native output is **lossless** with alpha; Chromium output is lossy and honors `quality` |
+| `svg` | yes (always) | — | vector, browser-free; SVG cannot be produced by a screenshotting browser |
+| `pdf` | yes (default) | yes | native output keeps text/axes/marks as vectors; density/heatmap layers embed as bounded rasters (hybrid-vector policy). Chromium prints the page instead |
+| `html` | yes | — | via `to_html()`; `write_image("chart.html")` routes there |
+
+`engine="auto"` (the default) is deterministic: every format uses the native
+path unless `custom_css` is passed, which forces Chromium because utility-class
+CSS needs a real CSS engine. `engine=Engine.chromium` opts into browser CSS,
+font, and WebGL fidelity for any format except SVG. Native exports never
+install or launch a browser; when Chromium is requested but not found, the
+error names `XY_BROWSER` and the supported browsers.
+
+### Background policy
+
+`background` accepts `"auto"` (each renderer's default backdrop: opaque white
+for raster/browser output, transparent for SVG), any CSS color, or
+`"transparent"`:
+
+~~~python
+chart.to_image("png", background="transparent") # alpha-0 backdrop
+chart.to_image("webp", background="#0f172a") # explicit backdrop
+chart.to_image("jpeg") # flattened onto white
+~~~
+
+JPEG has no alpha channel, so `background="transparent"` is rejected there
+rather than silently flattened. An explicit color (or `"transparent"`)
+**replaces** the chart's theme backgrounds — both `theme(background=...)` and
+`theme(plot_background=...)` — painting one backdrop consistently in every
+format (raster canvas, SVG/PDF rect, browser page); `"auto"` keeps the theme
+paints untouched.
+
+`scale` is the device-pixel-ratio for raster formats and is ignored by
+SVG/PDF, which are resolution-independent. A 300×200 chart at `scale=2`
+produces a 600×400 raster.
+
+## Declarative Export Defaults
+
+`xy.export_config` describes export behavior as part of the chart — no I/O
+happens at build time. It governs the modebar's download menu and provides
+defaults for the Python export calls:
+
+~~~python
+xy.chart(
+ xy.line("date", "revenue", data=frame),
+ xy.export_config(
+ formats=["png", "webp", "svg", "csv"], # menu availability + order
+ filename="revenue",
+ width=1200,
+ height=800,
+ scale=2,
+ background="auto",
+ ),
+)
+~~~
+
+The browser modebar shows the client-safe subset (`png`, `jpeg`, `webp`,
+`svg`, `csv`) with the same filename, scale, background, and quality semantics
+as the Python exporters; `pdf`/`html` entries affect Python-side defaults
+only. `formats=[]` hides the download menu entirely. Standalone HTML exports
+keep the full download menu working without any Python kernel attached, and
+Reflex charts inherit the same spec-driven configuration. Explicit arguments
+to `to_image()`/`write_image()` always override the declarative defaults.
+
## Standalone HTML
~~~python
@@ -38,7 +120,10 @@ exported document. Standalone HTML uses inline scripts and styles by design;
read [Serving, CSP, and offline use](/docs/xy/guides/serving-csp-and-offline-use/)
before placing it inside a stricter application policy.
-## PNG
+## Compatibility Conveniences
+
+`to_png()`, `to_svg()`, and `to_html()` remain supported with their existing
+signatures:
~~~python
from xy import Engine
@@ -49,50 +134,68 @@ chart.to_png(
engine=Engine.chromium,
custom_css=".xy { font-family: Inter, sans-serif; }",
)
+svg = chart.to_svg(width=1200, height=630)
~~~
-The default engine is XY's browser-free native rasterizer. Set
-`optimize=True` to spend more time producing a smaller native PNG. Use
-`Engine.chromium` when browser fonts, injected CSS, or WebGL fidelity matters.
-XY searches for Chrome, Chromium, Edge, or `chrome-headless-shell`; set
-`XY_BROWSER` to select an executable explicitly.
+The default PNG engine is XY's browser-free native rasterizer; set
+`optimize=True` to spend more time producing a smaller native PNG. SVG export
+is browser-free and screen-bounded: long lines are decimated before vector
+generation, while density and heatmap representations embed compact raster
+data where appropriate. For Chromium exports, XY searches for Chrome,
+Chromium, Edge, or `chrome-headless-shell`; set `XY_BROWSER` to select an
+executable explicitly. The browser sandbox is enabled by default; disable it
+only for trusted input in an environment where the caller accepts that risk.
-`custom_css` is Chromium-only. The browser sandbox is enabled by default;
-disable it only for trusted input in an environment where the caller accepts
-that risk.
+## Batch Export
-## SVG
+Use one batch call instead of exporting in a loop — formats can be mixed, and
+every Chromium-resolved file in the batch shares a single browser session:
~~~python
-svg = chart.to_svg(width=1200, height=630)
-chart.to_svg("chart.svg")
+import xy
+
+xy.write_images(
+ figures=[overview, detail],
+ files=["overview.svg", "detail.pdf"],
+)
~~~
-SVG export is browser-free and screen-bounded. Long lines are decimated before
-vector generation, while density and heatmap representations embed compact
-raster data where appropriate.
+Per-file formats come from the extensions (`formats=` overrides them), and
+writes are atomic per file. With the native engine the same call loops the
+millisecond-fast browser-free renderers.
-## Batch PNG Export
+## Facets
-Use one batch call instead of repeatedly starting Chromium:
+Facet grids support the same format matrix as single charts:
~~~python
-from xy import Engine
-from xy.export import write_images
-
-write_images(
- [first.figure(), second.figure()],
- ["first.png", "second.png"],
- engine=Engine.chromium,
-)
+grid = xy.facet_chart(xy.scatter("x", "y"), data=frame, by="region")
+grid.write_image("regions.pdf") # vector panels, composed natively
+grid.to_image("webp", background="transparent")
~~~
-Chromium batches reuse one browser session. With the default native engine,
-the same function loops over the fast browser-free rasterizer.
+Native raster output composes the browser-free panel renders (the grid title
+strip is omitted there — the native rasterizer has no free-standing text
+path); SVG/PDF compose the vector panels, title included; Chromium renders
+the full HTML grid.
+
+## Migrating from Plotly
+
+| Plotly | XY |
+| --- | --- |
+| `fig.to_image(format="png", scale=2)` | `chart.to_image("png", scale=2)` |
+| `fig.write_image("out.webp")` | `chart.write_image("out.webp")` |
+| `fig.write_html("out.html")` | `chart.to_html("out.html")` |
+| `pio.write_images(figs, files)` | `xy.write_images(figures=..., files=...)` |
+| Kaleido/Chrome required for static export | browser-free by default; `Engine.chromium` opt-in |
+| EPS | not supported (dropped by modern Plotly/Kaleido as well) |
## Deterministic Dimensions
Interactive chart `width` and `height` accept positive pixel integers.
Ordinary charts also accept percentages such as `width="100%"`; the parent
-must define a height when using `height="100%"`. Static raster and facet output
-should use explicit dimensions for deterministic results.
+must define a height when using `height="100%"`. Static raster and facet
+output should use explicit dimensions for deterministic results; fluid
+(`"100%"`) charts fall back to 800×500 at export time. Exports are
+deterministic byte-for-byte for identical figures and options — no
+timestamps, transient hover chrome, or nondeterministic ids are embedded.
diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js
index dc6d53a9..0211337c 100644
--- a/js/src/53_interaction.js
+++ b/js/src/53_interaction.js
@@ -988,9 +988,25 @@ Object.assign(ChartView.prototype, {
exportMenuItems.push(button);
return button;
};
- mkExportItem("png", "Export PNG", () => this._exportPng());
- mkExportItem("svg", "Export SVG", () => this._exportSvg());
- mkExportItem("csv", "Export CSV", () => this._exportCsv());
+ // Declarative export config (spec.export, from xy.export_config): the
+ // formats list governs menu availability and order. Only the client-safe
+ // subset renders here — pdf/html entries are Python-side formats and are
+ // skipped. No config keeps the historical png/svg/csv menu; an explicit
+ // empty list hides the download items entirely.
+ const EXPORT_ITEMS = {
+ png: ["Export PNG", () => this._exportRaster("png")],
+ jpeg: ["Export JPEG", () => this._exportRaster("jpeg")],
+ webp: ["Export WebP", () => this._exportRaster("webp")],
+ svg: ["Export SVG", () => this._exportSvg()],
+ csv: ["Export CSV", () => this._exportCsv()],
+ };
+ const configuredFormats = Array.isArray(this._exportConfig().formats)
+ ? this._exportConfig().formats
+ : ["png", "svg", "csv"];
+ for (const name of configuredFormats) {
+ const item = EXPORT_ITEMS[name];
+ if (item) mkExportItem(name, item[0], item[1]);
+ }
setZoomMenuOpen = (open, restoreFocus = false) => {
const show = Boolean(open);
@@ -1058,7 +1074,9 @@ Object.assign(ChartView.prototype, {
selectMenu.style.visibility = "visible";
};
setExportMenuOpen = (open, restoreFocus = false) => {
- const show = Boolean(open);
+ // export_config(formats=[]) leaves nothing to show: the grip stays a
+ // pure drag handle rather than opening an empty menu.
+ const show = Boolean(open) && exportMenuItems.length > 0;
if (show) {
setZoomMenuOpen(false);
setSelectMenuOpen(false);
@@ -1149,6 +1167,9 @@ Object.assign(ChartView.prototype, {
}
this._listen(grip, "keydown", (e) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
+ // export_config(formats=[]) (or a PDF/HTML-only list) leaves no
+ // client-side items: nothing to open or focus.
+ if (!exportMenuItems.length) return;
e.preventDefault();
e.stopPropagation();
setExportMenuOpen(true);
@@ -1414,7 +1435,17 @@ Object.assign(ChartView.prototype, {
this._setView({ x0, x1, y0, y1 }, { animate });
},
+ // Declarative export defaults (spec.export, produced by xy.export_config).
+ // The same filename/scale/background/quality semantics as the Python
+ // exporters, so a chart downloads identically from either side.
+ _exportConfig() {
+ const config = this.spec && this.spec.export;
+ return config && typeof config === "object" ? config : {};
+ },
+
_exportFilename(extension) {
+ const configured = this._exportConfig().filename;
+ if (typeof configured === "string" && configured) return `${configured}.${extension}`;
const title = String(this.spec.title || "xy-chart")
.trim()
.toLowerCase()
@@ -1513,26 +1544,58 @@ Object.assign(ChartView.prototype, {
},
_exportPng() {
+ return this._exportRaster("png");
+ },
+
+ _exportRaster(format) {
const svg = this._exportSvgMarkup();
const sourceUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const image = new Image();
+ const config = this._exportConfig();
+ const mime = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" }[format];
+ if (!mime) return Promise.reject(new Error(`unsupported raster export ${format}`));
return new Promise((resolve, reject) => {
image.onload = () => {
- const scale = Math.max(1, window.devicePixelRatio || 1);
+ const scale = Number.isFinite(config.scale) && config.scale > 0
+ ? config.scale
+ : Math.max(1, window.devicePixelRatio || 1);
const canvas = document.createElement("canvas");
canvas.width = Math.round(this.size.w * scale);
canvas.height = Math.round(this.size.h * scale);
const ctx = canvas.getContext("2d");
+ // Background policy mirrors the Python exporters: JPEG has no alpha
+ // channel so it always flattens onto the configured backdrop (default
+ // white); PNG/WebP paint a backdrop only for an explicit opaque color
+ // and keep transparency otherwise.
+ const configured = typeof config.background === "string" &&
+ config.background !== "auto" ? config.background : null;
+ const transparent = configured === "transparent" || configured === "none";
+ if (format === "jpeg") {
+ ctx.fillStyle = configured && !transparent ? configured : "#ffffff";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ } else if (configured && !transparent) {
+ ctx.fillStyle = configured;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ }
ctx.scale(scale, scale);
ctx.drawImage(image, 0, 0, this.size.w, this.size.h);
+ const quality = Number.isFinite(config.quality)
+ ? Math.min(1, Math.max(0.01, config.quality / 100))
+ : 0.9;
canvas.toBlob((blob) => {
if (!blob) {
- reject(new Error("PNG encoding returned no data"));
+ reject(new Error(`${format.toUpperCase()} encoding returned no data`));
return;
}
- this._downloadExport(blob, this._exportFilename("png"));
+ // A browser without the requested encoder returns PNG instead
+ // (canvas.toBlob's specified fallback); name the download by what
+ // was actually produced so the extension never lies.
+ const actual = blob.type === "image/jpeg" ? "jpg"
+ : blob.type === "image/webp" ? "webp"
+ : "png";
+ this._downloadExport(blob, this._exportFilename(actual));
resolve();
- }, "image/png");
+ }, mime, format === "png" ? undefined : quality);
};
image.onerror = () => {
reject(new Error("chart SVG could not be rasterized"));
@@ -1645,6 +1708,13 @@ Object.assign(ChartView.prototype, {
case "png":
return svg('' +
'');
+ case "jpeg":
+ return svg('' +
+ '');
+ case "webp":
+ return svg('' +
+ '' +
+ '');
case "svg":
return svg('' +
'');
diff --git a/pyproject.toml b/pyproject.toml
index 5fb22a48..de13ab06 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,9 @@ dev = [
# Optional *input* format, never a runtime dependency: the Arrow ingest
# tests importorskip it, so it lives in dev where CI exercises them.
"pyarrow>=15",
+ # Decode oracle for the native JPEG/WebP encoders, never a runtime
+ # dependency: the image-export tests importorskip it.
+ "pillow>=10",
]
# Plotly comparison charts. Matplotlib is deliberately not a project extra:
# benchmark jobs that compare against it install it explicitly as an external
diff --git a/python/xy/__init__.py b/python/xy/__init__.py
index 8d5d7df3..8135e205 100644
--- a/python/xy/__init__.py
+++ b/python/xy/__init__.py
@@ -37,6 +37,7 @@
"ColumnStore": ".columns",
"Component": ".components",
"Engine": ".export",
+ "ExportConfig": ".components",
"FacetChart": ".components",
"Interaction": ".components",
"Legend": ".components",
@@ -67,6 +68,7 @@
"error_band_chart": ".components",
"errorbar": ".components",
"errorbar_chart": ".components",
+ "export_config": ".components",
"hexbin": ".components",
"hexbin_chart": ".components",
"heatmap": ".components",
@@ -101,6 +103,7 @@
"text": ".components",
"vline": ".components",
"x_band": ".components",
+ "write_images": ".export",
"x_axis": ".components",
"y_band": ".components",
"y_axis": ".components",
@@ -118,6 +121,7 @@
"ColumnStore",
"Component",
"Engine",
+ "ExportConfig",
"FacetChart",
"Interaction",
"Legend",
@@ -148,6 +152,7 @@
"error_band_chart",
"errorbar",
"errorbar_chart",
+ "export_config",
"facet_chart",
"heatmap",
"heatmap_chart",
@@ -184,6 +189,7 @@
"violin",
"violin_chart",
"vline",
+ "write_images",
"x_axis",
"x_band",
"y_axis",
@@ -244,6 +250,7 @@ def __dir__() -> list[str]:
error_band_chart,
errorbar,
errorbar_chart,
+ export_config,
facet_chart,
heatmap,
heatmap_chart,
@@ -282,4 +289,4 @@ def __dir__() -> list[str]:
y_band,
)
from .dom import CHART_DOM_SLOTS
- from .export import Engine
+ from .export import Engine, write_images
diff --git a/python/xy/_chromium.py b/python/xy/_chromium.py
index 7f6fb34f..14468982 100644
--- a/python/xy/_chromium.py
+++ b/python/xy/_chromium.py
@@ -217,25 +217,59 @@ def _wait_event(self, method: str, *, session_id: Optional[str], timeout_s: floa
reply.get("params", {})
)
- def render_png(
+ def _page_session(self, html: str, timeout_s: float) -> tuple[str, str, "Path"]:
+ """Open a fresh tab on `html` (written to disk) and return its ids."""
+ target = self._call("Target.createTarget", {"url": "about:blank"})
+ attached = self._call(
+ "Target.attachToTarget", {"targetId": target["targetId"], "flatten": True}
+ )
+ sid = attached["sessionId"]
+ page_path = Path(self._tmp.name) / f"chart-{target['targetId'][:8]}.html"
+ page_path.write_text(html, encoding="utf-8")
+ self._call("Page.enable", session_id=sid, timeout_s=timeout_s)
+ return target["targetId"], sid, page_path
+
+ def _navigate_and_settle(self, sid: str, page_path: "Path", timeout_s: float) -> None:
+ self._call(
+ "Page.navigate", {"url": page_path.as_uri()}, session_id=sid, timeout_s=timeout_s
+ )
+ self._wait_event("Page.loadEventFired", session_id=sid, timeout_s=timeout_s)
+ # First painted frame: the client draws via requestAnimationFrame
+ # after the inline decode; two frames guarantee the paint landed.
+ self._call(
+ "Runtime.evaluate",
+ {
+ "expression": (
+ "new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
+ ),
+ "awaitPromise": True,
+ },
+ session_id=sid,
+ timeout_s=timeout_s,
+ )
+
+ def render_image(
self,
html: str,
width: int,
height: int,
*,
+ format: str = "png",
scale: float = 2.0,
+ quality: Optional[int] = None,
+ transparent: bool = False,
timeout_s: float = 120.0,
) -> bytes:
- """Load a standalone chart page in a fresh tab and screenshot it."""
- target = self._call("Target.createTarget", {"url": "about:blank"})
- attached = self._call(
- "Target.attachToTarget", {"targetId": target["targetId"], "flatten": True}
- )
- sid = attached["sessionId"]
- page_path = Path(self._tmp.name) / f"chart-{target['targetId'][:8]}.html"
+ """Load a standalone chart page in a fresh tab and screenshot it.
+
+ `format` is a CDP screenshot format ("png", "jpeg", or "webp");
+ `quality` applies to the lossy formats (0-100, encoder-defined
+ default when omitted). `transparent` clears Chromium's default white
+ page backdrop so alpha-capable formats keep the page's transparency."""
+ if format not in ("png", "jpeg", "webp"):
+ raise ChromiumError(f"unsupported screenshot format {format!r}")
+ target_id, sid, page_path = self._page_session(html, timeout_s)
try:
- page_path.write_text(html, encoding="utf-8")
- self._call("Page.enable", session_id=sid, timeout_s=timeout_s)
self._call(
"Emulation.setDeviceMetricsOverride",
{
@@ -247,46 +281,105 @@ def render_png(
session_id=sid,
timeout_s=timeout_s,
)
+ if transparent:
+ self._call(
+ "Emulation.setDefaultBackgroundColorOverride",
+ {"color": {"r": 0, "g": 0, "b": 0, "a": 0}},
+ session_id=sid,
+ timeout_s=timeout_s,
+ )
+ self._navigate_and_settle(sid, page_path, timeout_s)
+ params: dict[str, Any] = {
+ "format": format,
+ "clip": {
+ "x": 0,
+ "y": 0,
+ "width": int(width),
+ "height": int(height),
+ "scale": 1,
+ },
+ "captureBeyondViewport": True,
+ }
+ if quality is not None and format in ("jpeg", "webp"):
+ params["quality"] = int(quality)
+ shot = self._call("Page.captureScreenshot", params, session_id=sid, timeout_s=timeout_s)
+ data = base64.b64decode(shot["data"])
+ magics = {
+ "png": (b"\x89PNG\r\n\x1a\n",),
+ "jpeg": (b"\xff\xd8\xff",),
+ "webp": (b"RIFF",),
+ }
+ if not any(data.startswith(m) for m in magics[format]):
+ raise ChromiumError(f"screenshot output was not a {format.upper()}")
+ return data
+ finally:
+ with contextlib.suppress(Exception):
+ self._call("Target.closeTarget", {"targetId": target_id})
+ page_path.unlink(missing_ok=True)
+
+ def render_png(
+ self,
+ html: str,
+ width: int,
+ height: int,
+ *,
+ scale: float = 2.0,
+ timeout_s: float = 120.0,
+ ) -> bytes:
+ """Compatibility wrapper: `render_image` with format="png"."""
+ return self.render_image(html, width, height, scale=scale, timeout_s=timeout_s)
+
+ def render_pdf(
+ self,
+ html: str,
+ width: int,
+ height: int,
+ *,
+ timeout_s: float = 120.0,
+ ) -> bytes:
+ """Print the standalone chart page to a single-page PDF.
+
+ The page box matches the chart's CSS pixel size at the standard
+ 96 px/in ↔ 72 pt/in mapping. `scale` deliberately does not apply:
+ PDF is resolution-independent, so device-pixel-ratio has no meaning
+ here (raster layers print at the page's natural DPR)."""
+ target_id, sid, page_path = self._page_session(html, timeout_s)
+ try:
self._call(
- "Page.navigate", {"url": page_path.as_uri()}, session_id=sid, timeout_s=timeout_s
- )
- self._wait_event("Page.loadEventFired", session_id=sid, timeout_s=timeout_s)
- # First painted frame: the client draws via requestAnimationFrame
- # after the inline decode; two frames guarantee the paint landed.
- self._call(
- "Runtime.evaluate",
+ "Emulation.setDeviceMetricsOverride",
{
- "expression": (
- "new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))"
- ),
- "awaitPromise": True,
+ "width": int(width),
+ "height": int(height),
+ "deviceScaleFactor": 1.0,
+ "mobile": False,
},
session_id=sid,
timeout_s=timeout_s,
)
- shot = self._call(
- "Page.captureScreenshot",
+ self._navigate_and_settle(sid, page_path, timeout_s)
+ printed = self._call(
+ "Page.printToPDF",
{
- "format": "png",
- "clip": {
- "x": 0,
- "y": 0,
- "width": int(width),
- "height": int(height),
- "scale": 1,
- },
- "captureBeyondViewport": True,
+ "printBackground": True,
+ "paperWidth": int(width) / 96.0,
+ "paperHeight": int(height) / 96.0,
+ "marginTop": 0,
+ "marginBottom": 0,
+ "marginLeft": 0,
+ "marginRight": 0,
+ "pageRanges": "1",
+ "preferCSSPageSize": False,
},
session_id=sid,
timeout_s=timeout_s,
)
- data = base64.b64decode(shot["data"])
- if data[:8] != b"\x89PNG\r\n\x1a\n":
- raise ChromiumError("screenshot output was not a PNG")
+ data = base64.b64decode(printed["data"])
+ if not data.startswith(b"%PDF-"):
+ raise ChromiumError("print output was not a PDF")
return data
finally:
with contextlib.suppress(Exception):
- self._call("Target.closeTarget", {"targetId": target["targetId"]})
+ self._call("Target.closeTarget", {"targetId": target_id})
page_path.unlink(missing_ok=True)
def close(self) -> None:
diff --git a/python/xy/_figure.py b/python/xy/_figure.py
index 7ffa8ed6..31e21752 100644
--- a/python/xy/_figure.py
+++ b/python/xy/_figure.py
@@ -113,6 +113,9 @@ def __init__(
# pyplot sets an explicit Matplotlib-style spine list.
self.frame_sides: Optional[list[str]] = None
self.colorbar_options: Optional[dict[str, Any]] = None
+ # Declarative export defaults (xy.export_config): governs the client
+ # modebar's format menu + filename and the Python export defaults.
+ self.export_options: Optional[dict[str, Any]] = None
self.show_modebar = True
self.show_tooltip = True
self.class_name: Optional[str] = None
@@ -1269,6 +1272,76 @@ def to_png(
gl=gl,
)
+ def to_image(
+ self,
+ format: str = "png",
+ *,
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+ scale: float = 2.0,
+ background: Optional[str] = None,
+ engine: export.Engine | str = export.Engine.auto,
+ quality: Optional[int] = None,
+ optimize: bool = False,
+ custom_css: Optional[str] = None,
+ sandbox: bool = True,
+ gl: str = "software",
+ ) -> bytes:
+ """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes (export.py).
+
+ `engine=Engine.auto` is deterministic — the browser-free native path
+ for every format, Chromium only when `custom_css` needs a real CSS
+ engine. See `export.to_image` for the format, quality, and background
+ policies."""
+ return export.to_image(
+ self,
+ format,
+ width=width,
+ height=height,
+ scale=scale,
+ background=background,
+ engine=engine,
+ quality=quality,
+ optimize=optimize,
+ custom_css=custom_css,
+ sandbox=sandbox,
+ gl=gl,
+ )
+
+ def write_image(
+ self,
+ path: str | PathLike[str],
+ *,
+ format: Optional[str] = None,
+ width: Optional[int] = None,
+ height: Optional[int] = None,
+ scale: float = 2.0,
+ background: Optional[str] = None,
+ engine: export.Engine | str = export.Engine.auto,
+ quality: Optional[int] = None,
+ optimize: bool = False,
+ custom_css: Optional[str] = None,
+ sandbox: bool = True,
+ gl: str = "software",
+ ) -> bytes:
+ """Atomic file export with extension-inferred format (export.py):
+ .png/.jpg/.jpeg/.webp/.svg/.pdf, plus .html routing to `to_html`."""
+ return export.write_image(
+ self,
+ path,
+ format=format,
+ width=width,
+ height=height,
+ scale=scale,
+ background=background,
+ engine=engine,
+ quality=quality,
+ optimize=optimize,
+ custom_css=custom_css,
+ sandbox=sandbox,
+ gl=gl,
+ )
+
def memory_report(self) -> dict[str, Any]:
"""Every byte class itemized; if it isn't in the report it isn't real."""
from . import interaction # method-local: no load-time cycle
diff --git a/python/xy/_jpeg.py b/python/xy/_jpeg.py
new file mode 100644
index 00000000..2a08b526
--- /dev/null
+++ b/python/xy/_jpeg.py
@@ -0,0 +1,382 @@
+"""Pure numpy/stdlib baseline JPEG encoder for static export format parity.
+
+Emits baseline sequential JFIF (SOI, APP0 v1.02 @ 96 DPI, DQT, SOF0, DHT,
+SOS, EOI), 8-bit YCbCr at **4:4:4 sampling**: charts are line graphics, and
+chroma subsampling smears one-pixel colored strokes into visible fringes, so
+we spend the extra chroma bytes instead. Quantization uses the Annex K
+tables scaled with the libjpeg quality curve; entropy coding uses the
+Annex K Huffman tables, so size/quality at a given `quality` tracks what
+users expect from libjpeg-based tooling.
+
+Everything through bitstream assembly is vectorized: 8×8 blocks are
+transformed as one (n, 8, 8) stack, run-length tokens are derived with
+segmented-cumsum tricks, and the entropy stream is packed via
+`np.packbits` — a per-block Python loop would dominate encode time at
+chart-export sizes (an 1800×1000 canvas is ~84k blocks).
+
+Stays numpy + stdlib on purpose (mirrors `_png.py`): the balanced static
+export path must not grow an imaging dependency.
+"""
+
+from __future__ import annotations
+
+import struct
+
+import numpy as np
+
+# --- Annex K quantization tables (natural row-major order) ------------------
+
+_QUANT_LUMA = np.array(
+ [
+ 16, 11, 10, 16, 24, 40, 51, 61,
+ 12, 12, 14, 19, 26, 58, 60, 55,
+ 14, 13, 16, 24, 40, 57, 69, 56,
+ 14, 17, 22, 29, 51, 87, 80, 62,
+ 18, 22, 37, 56, 68, 109, 103, 77,
+ 24, 35, 55, 64, 81, 104, 113, 92,
+ 49, 64, 78, 87, 103, 121, 120, 101,
+ 72, 92, 95, 98, 112, 100, 103, 99,
+ ],
+ dtype=np.int64,
+) # fmt: skip
+
+_QUANT_CHROMA = np.array(
+ [
+ 17, 18, 24, 47, 99, 99, 99, 99,
+ 18, 21, 26, 66, 99, 99, 99, 99,
+ 24, 26, 56, 99, 99, 99, 99, 99,
+ 47, 66, 99, 99, 99, 99, 99, 99,
+ 99, 99, 99, 99, 99, 99, 99, 99,
+ 99, 99, 99, 99, 99, 99, 99, 99,
+ 99, 99, 99, 99, 99, 99, 99, 99,
+ 99, 99, 99, 99, 99, 99, 99, 99,
+ ],
+ dtype=np.int64,
+) # fmt: skip
+
+
+def _zigzag_order() -> np.ndarray:
+ """Natural→zigzag index map, derived rather than typed (typo-proof).
+
+ Anti-diagonal ``s`` is walked top-right→bottom-left when odd and reversed
+ when even (T.81 Figure 5)."""
+ order: list[int] = []
+ for s in range(15):
+ span = range(max(0, s - 7), min(s, 7) + 1)
+ order.extend(i * 8 + (s - i) for i in (span if s % 2 else reversed(span)))
+ return np.array(order, dtype=np.int64)
+
+
+_ZIGZAG = _zigzag_order()
+
+# --- Annex K Huffman tables (BITS length counts + HUFFVAL symbol order) -----
+
+_DC_LUMA_BITS = bytes([0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0])
+_DC_LUMA_VALUES = bytes(range(12))
+_DC_CHROMA_BITS = bytes([0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0])
+_DC_CHROMA_VALUES = bytes(range(12))
+_AC_LUMA_BITS = bytes([0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7D])
+_AC_LUMA_VALUES = bytes.fromhex(
+ "0102030004110512"
+ "2131410613516107"
+ "227114328191a108"
+ "2342b1c11552d1f0"
+ "2433627282090a16"
+ "1718191a25262728"
+ "292a343536373839"
+ "3a43444546474849"
+ "4a53545556575859"
+ "5a63646566676869"
+ "6a73747576777879"
+ "7a83848586878889"
+ "8a92939495969798"
+ "999aa2a3a4a5a6a7"
+ "a8a9aab2b3b4b5b6"
+ "b7b8b9bac2c3c4c5"
+ "c6c7c8c9cad2d3d4"
+ "d5d6d7d8d9dae1e2"
+ "e3e4e5e6e7e8e9ea"
+ "f1f2f3f4f5f6f7f8"
+ "f9fa"
+)
+_AC_CHROMA_BITS = bytes([0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77])
+_AC_CHROMA_VALUES = bytes.fromhex(
+ "0001020311040521"
+ "3106124151076171"
+ "1322328108144291"
+ "a1b1c109233352f0"
+ "156272d10a162434"
+ "e125f11718191a26"
+ "2728292a35363738"
+ "393a434445464748"
+ "494a535455565758"
+ "595a636465666768"
+ "696a737475767778"
+ "797a828384858687"
+ "88898a9293949596"
+ "9798999aa2a3a4a5"
+ "a6a7a8a9aab2b3b4"
+ "b5b6b7b8b9bac2c3"
+ "c4c5c6c7c8c9cad2"
+ "d3d4d5d6d7d8d9da"
+ "e2e3e4e5e6e7e8e9"
+ "eaf2f3f4f5f6f7f8"
+ "f9fa"
+)
+
+
+def _huff_lookup(bits: bytes, values: bytes) -> tuple[np.ndarray, np.ndarray]:
+ """Canonical symbol→(code, length) arrays from a BITS/HUFFVAL spec."""
+ if len(values) != sum(bits):
+ raise AssertionError("Huffman spec mismatch: HUFFVAL count != sum(BITS)")
+ codes = np.zeros(256, dtype=np.int64)
+ lens = np.zeros(256, dtype=np.int64)
+ code = 0
+ k = 0
+ for length in range(1, 17):
+ for _ in range(bits[length - 1]):
+ codes[values[k]] = code
+ lens[values[k]] = length
+ code += 1
+ k += 1
+ code <<= 1
+ return codes, lens
+
+
+# Table index per token: 0 = DC luma, 1 = AC luma, 2 = DC chroma, 3 = AC chroma.
+_HUFF_SPECS = (
+ (_DC_LUMA_BITS, _DC_LUMA_VALUES),
+ (_AC_LUMA_BITS, _AC_LUMA_VALUES),
+ (_DC_CHROMA_BITS, _DC_CHROMA_VALUES),
+ (_AC_CHROMA_BITS, _AC_CHROMA_VALUES),
+)
+_HUFF_CODES = np.stack([_huff_lookup(b, v)[0] for b, v in _HUFF_SPECS])
+_HUFF_LENS = np.stack([_huff_lookup(b, v)[1] for b, v in _HUFF_SPECS])
+
+
+def _dct_matrix() -> np.ndarray:
+ """Orthonormal 8-point DCT-II matrix; `D @ X @ D.T` is the T.81 FDCT."""
+ k = np.arange(8, dtype=np.float64)
+ d = np.cos((2.0 * k[None, :] + 1.0) * k[:, None] * np.pi / 16.0) / 2.0
+ d[0] = 1.0 / np.sqrt(8.0)
+ return d.astype(np.float32)
+
+
+_DCT = _dct_matrix()
+
+# Magnitude-category boundaries: size k iff 2**(k-1) <= |v| < 2**k. Integer
+# searchsorted keeps this exact where a float log2 could round at the edges.
+_POW2 = np.int64(1) << np.arange(12, dtype=np.int64)
+
+
+def _bit_size(magnitude: np.ndarray) -> np.ndarray:
+ return np.searchsorted(_POW2, magnitude, side="right").astype(np.int64)
+
+
+def _scaled_quant(base: np.ndarray, quality: int) -> np.ndarray:
+ """Annex K table scaled with the libjpeg quality curve (integer math)."""
+ scale = 5000 // quality if quality < 50 else 200 - 2 * quality
+ return np.clip((base * scale + 50) // 100, 1, 255)
+
+
+def _component_tokens(
+ coef: np.ndarray, dc_tbl: int, ac_tbl: int
+) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
+ """Huffman tokens for one component's (n, 64) zigzag coefficients.
+
+ Returns parallel arrays (block, seq, table, symbol, amplitude, amp_bits);
+ `seq` orders tokens within a block (0 = DC, 255 = EOB) so a single sort on
+ (block, component, seq) later interleaves the MCU stream.
+ """
+ n = coef.shape[0]
+
+ # DC is coded differentially along the component's block sequence.
+ diff = np.diff(coef[:, 0], prepend=np.int64(0))
+ dsize = _bit_size(np.abs(diff))
+ # T.81 amplitude coding: negatives are sent as v + 2**size - 1.
+ dampl = np.where(diff < 0, diff + (np.int64(1) << dsize) - 1, diff)
+ dc_tok = (
+ np.arange(n, dtype=np.int64),
+ np.zeros(n, dtype=np.int64),
+ np.full(n, dc_tbl, dtype=np.int64),
+ dsize,
+ dampl,
+ dsize,
+ )
+
+ ac = coef[:, 1:]
+ blk, pos = np.nonzero(ac) # row-major: block-ascending, position-ascending
+ last = np.full(n, -1, dtype=np.int64)
+ if blk.size:
+ last[blk] = pos # row-major order → last write per block wins
+ val = ac[blk, pos]
+ first = np.empty(blk.size, dtype=bool)
+ first[0] = True
+ np.not_equal(blk[1:], blk[:-1], out=first[1:])
+ prev = np.where(first, np.int64(-1), np.concatenate((pos[:1] * 0 - 1, pos[:-1])))
+ run = pos - prev - 1
+ zrl = run >> 4 # each 16 zeros of run becomes a ZRL (0xF0) token
+ asize = _bit_size(np.abs(val))
+ sym = ((run & 15) << 4) | asize
+ aampl = np.where(val < 0, val + (np.int64(1) << asize) - 1, val)
+ # Expand each nonzero into its ZRL prefix + the coefficient token,
+ # numbering tokens within their block via a segmented cumsum: `start`
+ # is the global exclusive cumsum of token counts and `base` forward-
+ # fills each block's opening value, so `start - base` restarts at 0.
+ tot = zrl + 1
+ cum = np.cumsum(tot)
+ start = cum - tot
+ base = np.maximum.accumulate(np.where(first, start, 0))
+ rep = np.repeat(np.arange(blk.size, dtype=np.int64), tot)
+ j = np.arange(cum[-1], dtype=np.int64) - np.repeat(start, tot)
+ is_zrl = j < zrl[rep]
+ ac_tok = (
+ blk[rep].astype(np.int64),
+ 1 + (start - base)[rep] + j,
+ np.full(rep.size, ac_tbl, dtype=np.int64),
+ np.where(is_zrl, np.int64(0xF0), sym[rep]),
+ np.where(is_zrl, np.int64(0), aampl[rep]),
+ np.where(is_zrl, np.int64(0), asize[rep]),
+ )
+ else:
+ ac_tok = tuple(np.empty(0, dtype=np.int64) for _ in range(6))
+
+ # EOB unless the block's final zigzag coefficient (AC position 62) is set.
+ eob = np.flatnonzero(last != 62).astype(np.int64)
+ eob_tok = (
+ eob,
+ np.full(eob.size, 255, dtype=np.int64),
+ np.full(eob.size, ac_tbl, dtype=np.int64),
+ np.zeros(eob.size, dtype=np.int64),
+ np.zeros(eob.size, dtype=np.int64),
+ np.zeros(eob.size, dtype=np.int64),
+ )
+ merged = [np.concatenate(parts) for parts in zip(dc_tok, ac_tok, eob_tok, strict=True)]
+ return merged[0], merged[1], merged[2], merged[3], merged[4], merged[5]
+
+
+def _pack_entropy(chunk: np.ndarray, nbits: np.ndarray) -> bytes:
+ """MSB-first bit packing of (value, bit-count) chunks, with 1-padding to a
+ byte boundary and 0x00 stuffing after every 0xFF (both per T.81)."""
+ total = int(nbits.sum())
+ start = np.cumsum(nbits) - nbits
+ idx = np.repeat(np.arange(chunk.size, dtype=np.int64), nbits)
+ offset = np.arange(total, dtype=np.int64) - np.repeat(start, nbits)
+ bits = ((chunk[idx] >> (nbits[idx] - 1 - offset)) & 1).astype(np.uint8)
+ pad = (-total) % 8
+ if pad:
+ bits = np.concatenate((bits, np.ones(pad, dtype=np.uint8)))
+ stream = np.packbits(bits)
+ ff = np.flatnonzero(stream == 0xFF)
+ if ff.size:
+ stream = np.insert(stream, ff + 1, np.uint8(0))
+ return stream.tobytes()
+
+
+def _headers(h: int, w: int, qy_zz: np.ndarray, qc_zz: np.ndarray) -> bytes:
+ app0 = (
+ b"\xff\xe0"
+ + struct.pack(">H", 16)
+ + b"JFIF\x00\x01\x02" # identifier + version 1.02
+ + b"\x01" # density unit: dots per inch
+ + struct.pack(">HHBB", 96, 96, 0, 0) # 96 DPI, no thumbnail
+ )
+ dqt = (
+ b"\xff\xdb"
+ + struct.pack(">H", 2 + 65 * 2)
+ + b"\x00" # 8-bit precision, table 0 (luma)
+ + qy_zz.astype(np.uint8).tobytes()
+ + b"\x01" # table 1 (chroma)
+ + qc_zz.astype(np.uint8).tobytes()
+ )
+ # 4:4:4: every component samples at 1×1 (0x11); Y quantizes with table 0,
+ # Cb/Cr with table 1.
+ sof0 = (
+ b"\xff\xc0"
+ + struct.pack(">HBHHB", 17, 8, h, w, 3)
+ + bytes((1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1))
+ )
+ dht_payload = b"".join(
+ bytes([cls_id]) + bits + values
+ for cls_id, (bits, values) in zip((0x00, 0x10, 0x01, 0x11), _HUFF_SPECS, strict=True)
+ )
+ dht = b"\xff\xc4" + struct.pack(">H", 2 + len(dht_payload)) + dht_payload
+ sos = (
+ b"\xff\xda"
+ + struct.pack(">HB", 12, 3)
+ + bytes((1, 0x00, 2, 0x11, 3, 0x11)) # Y → tables 0/0, chroma → 1/1
+ + b"\x00\x3f\x00" # full spectral selection, no successive approx.
+ )
+ return b"\xff\xd8" + app0 + dqt + sof0 + dht + sos
+
+
+def encode(rgba: np.ndarray, *, quality: int = 90) -> bytes:
+ """Encode an `(h, w, 4)` RGBA (alpha ignored) or `(h, w, 3)` RGB uint8
+ image as a baseline JFIF JPEG. Deterministic for identical input."""
+ if isinstance(quality, bool) or not isinstance(quality, int):
+ raise ValueError(f"quality must be an int in 1..100, got {quality!r}")
+ if not 1 <= quality <= 100:
+ raise ValueError(f"quality must be in 1..100, got {quality}")
+ if not isinstance(rgba, np.ndarray):
+ raise ValueError(f"JPEG image must be a numpy array, got {type(rgba).__name__}")
+ if rgba.ndim != 3 or rgba.shape[2] not in (3, 4):
+ raise ValueError(f"JPEG image must be (h, w, 4) RGBA or (h, w, 3) RGB, got {rgba.shape}")
+ if rgba.dtype != np.uint8:
+ raise ValueError(f"JPEG image must be uint8, got {rgba.dtype}")
+ h, w = rgba.shape[:2]
+ if h == 0 or w == 0:
+ raise ValueError("JPEG image must be non-empty")
+ if h > 65535 or w > 65535:
+ raise ValueError("JPEG dimensions are limited to 65535")
+
+ # Alpha is ignored: the caller has already composited onto an opaque
+ # background, so only the RGB planes carry information.
+ rgb = rgba[..., :3].astype(np.float32)
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
+ # JFIF BT.601 full-range transform. The −128 DCT level shift cancels the
+ # +128 chroma offset, so Y is shifted here and Cb/Cr are left centered.
+ y = 0.299 * r + 0.587 * g + 0.114 * b - 128.0
+ cb = -0.168736 * r - 0.331264 * g + 0.5 * b
+ cr = 0.5 * r - 0.418688 * g - 0.081312 * b
+
+ qy = _scaled_quant(_QUANT_LUMA, quality)
+ qc = _scaled_quant(_QUANT_CHROMA, quality)
+ qy_zz = qy[_ZIGZAG]
+ qc_zz = qc[_ZIGZAG]
+
+ pad_h, pad_w = (-h) % 8, (-w) % 8
+ h8, w8 = h + pad_h, w + pad_w
+ fields: list[tuple[np.ndarray, ...]] = []
+ keys: list[np.ndarray] = []
+ for comp, (plane, q_zz) in enumerate(zip((y, cb, cr), (qy_zz, qc_zz, qc_zz), strict=True)):
+ # Edge replication avoids the ringing a zero/black pad would inject
+ # into every border block.
+ padded = np.pad(plane, ((0, pad_h), (0, pad_w)), mode="edge")
+ blocks = padded.reshape(h8 // 8, 8, w8 // 8, 8).swapaxes(1, 2).reshape(-1, 8, 8)
+ coef = _DCT @ blocks @ _DCT.T
+ scaled = coef.reshape(-1, 64)[:, _ZIGZAG] / q_zz.astype(np.float32)
+ # Round half away from zero: any deterministic tie rule is valid JPEG;
+ # this one matches the common integer implementations.
+ quant = (np.sign(scaled) * np.floor(np.abs(scaled) + 0.5)).astype(np.int64)
+ # Exact-math AC magnitudes cap at 1020 (category 10); clamp is one-LSB
+ # insurance against float rounding ever minting category 11, which the
+ # baseline AC tables cannot code.
+ quant[:, 1:] = np.clip(quant[:, 1:], -1023, 1023)
+ dc_tbl, ac_tbl = (0, 1) if comp == 0 else (2, 3)
+ toks = _component_tokens(quant, dc_tbl, ac_tbl)
+ # 4:4:4 → one block per component per MCU, so the MCU-interleaved
+ # order Y, Cb, Cr is a stable sort on (block, component, in-block seq).
+ keys.append(((toks[0] * 3 + comp) << 8) | toks[1])
+ fields.append(toks)
+
+ order = np.argsort(np.concatenate(keys), kind="stable")
+ tbl = np.concatenate([f[2] for f in fields])[order]
+ sym = np.concatenate([f[3] for f in fields])[order]
+ ampl = np.concatenate([f[4] for f in fields])[order]
+ abits = np.concatenate([f[5] for f in fields])[order]
+ code = _HUFF_CODES[tbl, sym]
+ clen = _HUFF_LENS[tbl, sym]
+ # Huffman code then amplitude bits, as one ≤27-bit chunk per token.
+ entropy = _pack_entropy((code << abits) | ampl, clen + abits)
+
+ return _headers(h, w, qy_zz, qc_zz) + entropy + b"\xff\xd9"
diff --git a/python/xy/_payload.py b/python/xy/_payload.py
index 739c24bc..4b7053df 100644
--- a/python/xy/_payload.py
+++ b/python/xy/_payload.py
@@ -252,6 +252,9 @@ def axis_range(axis_id: str) -> tuple[float, float]:
spec["colorbar"] = self.colorbar_options
if self.show_modebar is False:
spec["show_modebar"] = False
+ export_options = getattr(self, "export_options", None)
+ if export_options:
+ spec["export"] = export_options
if self.show_tooltip is False:
spec["show_tooltip"] = False
if self.padding is not None:
diff --git a/python/xy/_pdf.py b/python/xy/_pdf.py
new file mode 100644
index 00000000..a7e41dea
--- /dev/null
+++ b/python/xy/_pdf.py
@@ -0,0 +1,1262 @@
+"""Native vector PDF export — a converter for xy's OWN generated SVG.
+
+`svg_to_pdf` turns the output of `xy._svg.to_svg` (and the `FacetGrid.to_svg`
+composition wrapper) into a single-page vector PDF with no browser and no
+external dependencies (stdlib + numpy only).
+
+Because xy controls the SVG generator, the accepted SVG subset is CLOSED: this
+module handles exactly the elements/attributes `_svg.py` emits and raises
+``ValueError("unsupported SVG feature: ...")`` on anything else, so generator
+drift fails loudly instead of rendering wrong.
+
+Mapping decisions:
+
+- 1 CSS px = 0.75 pt (96dpi -> 72dpi). One top-level ``cm`` scales by 0.75 and
+ flips the y axis; all path coordinates stay in SVG user space.
+- Shapes stay vector: path construction ops with fills/strokes; opacities
+ (``fill-opacity``/``stroke-opacity``/``opacity`` and rgba() color alpha)
+ become deduplicated ExtGStates (/ca /CA). The generator only ever emits the
+ default nonzero winding rule, so even-odd variants are never produced.
+- Text stays text: BT/Tf/Tm/Tj/ET with the base-14 Helvetica family
+ (weight >= 600 selects Helvetica-Bold) in WinAnsiEncoding, using the
+ standard AFM width tables so ``text-anchor="middle"/"end"`` offsets come
+ from real metrics. Characters outside WinAnsi are replaced with "?"
+ (``cp1252`` + ``errors="replace"``) — a deterministic, locale-independent
+ substitution policy.
+- ```` becomes an axial shading (/ShadingType 2; exponential
+ function for 2 stops, stitching for more) painted inside the gradient
+ geometry's clip; per-stop alpha becomes a luminosity soft mask.
+- ```` (always a single rect in the subset) becomes ``re W n``
+ inside a q/Q scope.
+- Embedded ``data:image/png;base64`` rasters (truecolor or indexed+tRNS,
+ filters 0-4) are decoded with zlib and re-embedded as FlateDecode
+ /DeviceRGB Image XObjects with an /SMask when not fully opaque;
+ /Interpolate false preserves the generator's pixelated rendering intent.
+- Output is deterministic: no timestamps/ids, stable object numbering, and a
+ byte-accurate xref table.
+"""
+
+from __future__ import annotations
+
+import base64
+import math
+import re
+import struct
+import xml.etree.ElementTree as ET
+import zlib
+from itertools import pairwise
+from typing import Any, NoReturn, Optional
+
+import numpy as np
+
+from . import kernels
+
+__all__ = ["svg_to_pdf"]
+
+_SVG_NS = "http://www.w3.org/2000/svg"
+_PX_TO_PT = 0.75
+_DEFAULT_FONT_SIZE = 16.0 # CSS "medium" — the root