Skip to content

Add export format parity and a unified export API (ENG-10447)#115

Merged
Alek99 merged 2 commits into
mainfrom
alek/eng-10447-add-export-format-parity-and-a-unified-export-api-to-xy
Jul 20, 2026
Merged

Add export format parity and a unified export API (ENG-10447)#115
Alek99 merged 2 commits into
mainfrom
alek/eng-10447-add-export-format-parity-and-a-unified-export-api-to-xy

Conversation

@Alek99

@Alek99 Alek99 commented Jul 20, 2026

Copy link
Copy Markdown
Member

Closes ENG-10447.

Brings XY's export surface to practical format parity with Plotly — PNG, JPEG/JPG, WebP, SVG, PDF, and interactive HTML — behind one unified API, while preserving the browser-free native fast path for every format.

API

chart.to_image("pdf", width=1200, height=800, scale=2)     # bytes
chart.write_image("reports/revenue.webp")                  # inferred + atomic
xy.write_images(figures=[overview, detail],
                files=["overview.svg", "detail.pdf"])      # mixed batch

xy.chart(
    xy.line("date", "revenue", data=frame),
    xy.export_config(formats=["png", "webp", "svg", "csv"],
                     filename="revenue", width=1200, height=800, scale=2),
)

to_png / to_svg / to_html are unchanged compatibility conveniences.

Format & engine matrix

Format Native (browser-free) Chromium Notes
png ✅ default transparency
jpeg/jpg ✅ default — new pure-numpy baseline encoder (_jpeg.py, ~63 ms @ 1800×1000) ✅ CDP screenshot no alpha → flattens onto background (default white), quality 1–100
webp ✅ default — new bit-exact lossless VP8L encoder with alpha (_webp.py) ✅ CDP screenshot (lossy, honors quality) documented lossless-native policy
svg ✅ always native-only (a screenshot can't be vector)
pdf ✅ default — new vector backend (_pdf.py) converting XY's own SVG subset printToPDF vector text (Helvetica AFM metrics), axial-shading gradients incl. alpha soft-masks, density/heatmap layers embedded as bounded rasters (hybrid-vector policy); strict whitelist so SVG-generator drift fails loudly
html to_html write_image("chart.html") routes there

engine="auto" is deterministic: native for everything, Chromium only when custom_css needs a real CSS engine. Missing-browser errors name XY_BROWSER and the supported executables. Batch export shares one Chromium session across all browser-resolved files.

Background & quality policy

background="auto" | <css color> | "transparent" behaves identically across raster/vector/browser output; JPEG rejects transparent instead of silently flattening. An explicit color now paints the same single backdrop in the rasterizer and SVG (plot-rect default harmonized). quality applies to JPEG and Chromium-WebP; native WebP is always lossless by policy.

Modebar / declarative / Reflex

export_config rides the spec: the modebar download menu is now config-driven (client-safe subset png/jpeg/webp/svg/csv, ordered; formats=[] hides it) with the same filename/scale/background/quality semantics as Python — verified working in kernel-free standalone HTML via headless CDP (menu items, configured filename, correct extensions, toBlob-fallback naming). Reflex charts inherit the same spec-driven config.

Facets

FacetGrid/FacetChart gain to_image/write_image over the same matrix: native raster composes panel renders; SVG/PDF compose vector panels; Chromium renders the HTML grid.

Verification

  • 2018 passed / 5 skipped full suite; new suites: test_jpeg.py (23), test_webp.py (19, bit-exact PIL round-trips incl. alpha), test_pdf_export.py (xref-offset parser, vector-text assertions, image XObjects, determinism), test_image_export.py (31, full matrix + engine/background/quality/batch/config/facet + fake-CDP chromium plumbing).
  • Pillow added to dev extras only, as the encoder decode oracle.
  • Native PDF cross-validated against two independent renderers (macOS Quartz + headless Chromium pixel-diffs of 20 figure kinds).
  • render_smoke_nonumpy.py, abi_smoke.py, pre-commit hooks, ruff check/format, ty (no new diagnostics), cargo test all green. No Rust changes; JS bundle rebuilt via node js/build.mjs.

Follow-ups deliberately out of scope: EPS (dropped by modern Plotly/Kaleido too), golden-image CI corpus for the new formats, benchmark harness rows for JPEG/WebP/PDF export.

One format-selecting surface — to_image(format=...) and extension-inferred,
atomically-written write_image(path) — on charts, facet grids, and the
internal Figure, covering PNG, JPEG/JPG, WebP, SVG, and PDF alongside
interactive HTML, with to_png/to_svg/to_html kept as compatibility
conveniences.

Every image format exports browser-free by default, preserving the
architectural fast path:

- JPEG: new pure-numpy baseline encoder (_jpeg.py) — 4:4:4 sampling for
  line-graphics fidelity, Annex-K tables with the libjpeg quality curve,
  fully vectorized (~63 ms for 1800x1000).
- WebP: new bit-exact lossless VP8L encoder (_webp.py) with alpha,
  histogram-built length-limited prefix codes.
- PDF: new vector backend (_pdf.py) converting xy's own SVG subset —
  vector text via embedded Helvetica AFM metrics, axial-shading gradients
  (alpha stops via luminosity soft masks), density/heatmap layers embedded
  as bounded raster XObjects per the documented hybrid-vector policy,
  byte-accurate xref, deterministic output. The subset is strictly
  whitelisted so SVG-generator drift fails loudly.

Engine.auto selects deterministically: native for every format, Chromium
only when custom_css needs a real CSS engine. Engine.chromium adds
browser-fidelity JPEG/WebP (CDP captureScreenshot) and PDF (printToPDF) to
the existing PNG path, and SVG stays native-only. A shared background
policy ("auto" / CSS color / "transparent") spans raster, vector, and
browser output; JPEG rejects transparent instead of silently flattening,
and an explicit color now paints the same single backdrop in the
rasterizer and the SVG/PDF exporters.

xy.write_images(figures=..., files=...) batches mixed formats with
per-file extension inference, atomic writes, and one reused Chromium
session for every browser-resolved file.

xy.export_config() declares formats/filename/width/height/scale/
background/quality on the chart itself (no I/O at build time): it fills
Python export defaults (explicit arguments win) and rides the spec to
govern the modebar download menu, which now offers PNG, JPEG, WebP, SVG,
and CSV with the same filename/scale/background/quality semantics as the
Python exporters — including in kernel-free standalone HTML and Reflex.
formats=[] hides the menu.

Tests cover the encoder round-trips (Pillow oracles), PDF structure and
vector-text preservation, the format/engine/background/quality matrices,
batch and facet parity, declarative defaults, and back-compat; docs gain
a format/engine capability table, the background policy, and a Plotly
migration table.
@linear-code

linear-code Bot commented Jul 20, 2026

Copy link
Copy Markdown

ENG-10447

@codspeed-hq

codspeed-hq Bot commented Jul 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 94 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing alek/eng-10447-add-export-format-parity-and-a-unified-export-api-to-xy (49db00b) with main (8a0a5ca)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@Alek99 Alek99 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I found seven actionable contract gaps in the new export paths. Existing CI and the full local suite pass, but the cases called out inline are not currently covered.

Comment thread pyproject.toml
"pyarrow>=15",
# Decode oracle for the native JPEG/WebP encoders, never a runtime
# dependency: the image-export tests importorskip it.
"pillow>=10",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P1] Regenerate uv.lock after adding Pillow. The committed lockfile does not contain Pillow or include it in the xy dev extra, so uv sync --frozen --extra dev omits the decoder oracle and the new JPEG/WebP suites silently skip via pytest.importorskip. Please run uv lock and commit the result.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00b — ran uv lock; Pillow now resolves in the lock (pillow v12.3.0) and is wired into the xy dev extra, so uv sync --frozen --extra dev installs the oracle and the JPEG/WebP suites run for real.

Comment thread python/xy/_svg.py
# Export-time canvas override (unified export API `background=`): one
# backdrop rect behind the figure patch. "transparent"/"none" mean "no
# backdrop", which is already SVG's default — nothing to paint.
canvas_paint = spec.get("canvas_background")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P1] The explicit export backdrop is painted underneath the figure and plot theme backgrounds, so background= is invisible whenever theme(background=...) or plot_background is set. I reproduced background="#112233" producing the theme's red margins and green plot in both native and Chromium PNGs; transparent remains opaque too. Explicit export backgrounds need to replace/suppress those theme paints across the native SVG/PDF/raster and browser paths.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00b. Explicit background= now replaces the theme paints instead of layering under them: a shared _svg.apply_export_background (used by raster, SVG, and thereby PDF) drops the theme figure patch and turns --chart-bg transparent so the override composites exactly once; the browser paths inject the same override as !important CSS that beats the root's inline theme background and the --chart-bg token the client reads. Verified with your repro (theme red margins + green plot): native PNG/WebP/SVG and real-Chromium PNG all show the override color edge-to-edge, and transparent yields alpha-0 in both regions. Covered by test_explicit_background_replaces_theme_paints and test_browser_background_css_overrides_theme_tokens.

Comment thread python/xy/export.py Outdated
paths = files
if figs is None or paths is None:
raise ValueError("write_images needs both figures and files")
figs = [f.figure() if callable(getattr(f, "figure", None)) else f for f in figs]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] Converting chart objects to Figure here drops Chart._export_defaults. A chart configured for 123×77 at scale 1 exports at 123×77 directly, but write_images([chart], ...) produces 600×400 from the chart's 300×200 dimensions and the batch scale default. Please resolve per-chart declarative defaults before discarding the wrapper.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00bwrite_images keeps the chart wrapper long enough to resolve its _export_defaults per file (width/height/scale/background/quality, including the engine-aware quality rule) before compiling to a Figure; batch-level arguments still override. Your 123×77@1 case is pinned in test_write_images_resolves_chart_export_config_defaults.

Comment thread python/xy/facets.py Outdated
from . import _webp

return _webp.encode(canvas)
doc = self.to_html(custom_css=custom_css)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] The validated facet background is never added to the HTML passed to Chromium; it is used only for the transparent CDP flag below. A real Chrome export with background="#112233" still had a white corner. Please build the document with the background override, as the single-chart path does, including PDF.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00b — the facet browser branch now injects the validated background into the captured document via the same _background_css helper as the single-chart path (PDF included), and the native facet SVG passes the override into each panel so panel theme paints can't bury the grid backdrop. Verified against real Chromium (corner pixel = override color); pinned by test_facet_browser_background_reaches_document.

Comment thread python/xy/components.py Outdated
non-lossy formats; a config "transparent" background is dropped for
JPEG) — only *explicit* arguments produce hard errors downstream."""
config = self.figure().export_options or {}
if quality is None and fmt == "jpeg":

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] Declarative quality is copied only for JPEG. export_config(quality=37) followed by to_image("webp", engine=Engine.chromium) passes the generic default 90 to Chromium. Chromium WebP should inherit the configured quality while native lossless WebP continues ignoring it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00b_export_defaults now takes a lossy_webp flag derived from the resolved engine, so export_config(quality=37) reaches Chromium WebP while native WebP stays lossless and ignores it. Pinned by test_export_config_quality_reaches_chromium_webp.

Comment thread js/src/53_interaction.js
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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] This makes exportMenuItems empty for export_config(formats=[]) (and PDF/HTML-only lists), but the existing ArrowDown/ArrowUp handler still indexes that array and calls .focus() on undefined, causing a runtime TypeError. Please guard the keyboard handler when there are no client-side export items.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00b — the grip's ArrowDown/ArrowUp handler returns early when there are no client-side export items, matching the pointer path's guard. Verified via CDP with export_config(formats=[]): no page errors, menu stays closed.

Comment thread python/xy/export.py
plan.append((fig, path, fmt, "html", None, None))
continue
resolved = _resolve_image_engine(engine, fmt, custom_css)
file_quality = (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

[P2] The batch contract says quality is ignored for non-lossy members, but a mixed native JPEG+WebP batch with quality=50 fails here because native WebP is routed through _validated_quality. Apply quality to JPEG and Chromium WebP only; native lossless WebP should not abort an otherwise valid mixed batch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 49db00b — batch quality now applies to JPEG and Chromium-WebP members only; native lossless WebP no longer aborts a mixed batch (the value range itself is still validated once up front, so quality=500 fails fast). Pinned by the extended test_write_images_quality_ignored_by_non_lossy_batch_members.

…routing

- Regenerate uv.lock so the Pillow dev-extra oracle actually installs and
  the JPEG/WebP suites stop skipping under --frozen syncs.
- An explicit export background= now REPLACES the theme paints instead of
  being buried under them: apply_export_background (shared by raster, SVG,
  and thereby PDF) drops the theme figure patch and turns the plot token
  transparent so the override composites exactly once; the browser paths
  inject the same override as !important CSS that beats the chart root's
  inline theme background and the --chart-bg token the client reads.
  Fully-transparent paints are now skipped as no-op fills. Verified against
  real Chromium for both single charts and facet grids (which previously
  never received the override in their captured document).
- write_images keeps chart wrappers long enough to resolve their
  export_config defaults (width/height/scale/background/quality) per file
  before compiling to figures; batch-level arguments still win.
- Declarative quality now reaches Chromium's lossy WebP, not just JPEG,
  while native WebP stays lossless; in batches, quality applies to JPEG and
  Chromium WebP only, so a native mixed JPEG+WebP batch no longer aborts
  (value range still validated up front).
- Guard the modebar grip's ArrowDown/ArrowUp handler when export_config
  leaves no client-side menu items (formats=[] or PDF/HTML-only), which
  previously threw focusing undefined; verified error-free via CDP.
- Regression tests for each case: theme-replacement pixels/SVG, browser
  CSS injection (single + facet), batch config defaults, chromium-webp
  quality, and the mixed-batch quality contract.
@Alek99
Alek99 merged commit 599f8f0 into main Jul 20, 2026
25 checks passed
Alek99 added a commit that referenced this pull request Jul 21, 2026
The move in this PR promoted docs/engineering/ to spec/ and CLAUDE.md
declares that tree the source of truth. An audit of all 24 documents
against the code found the tree had drifted at PR #96: five behavior
PRs (#103, #105, #106, #108, #115) had landed with no spec update, and
~100 claims contradicted shipped code.

Accuracy: correct claims that no longer match the implementation,
including the Rust ABI version (v31 -> v36), the "zero-crate cdylib"
claim (png is a real dependency), the pyramid ABI function names, the
panic-shield status, the R2/VAO claim in the renderer doc, the
WASM/Web-Worker architecture in dossier §3/§8 (superseded by §32's
C-ABI cdylib), the count-only tier rule, the matplotlib shim overhead
figures (the cited guardrail says +60% at 10k, not +9%), and the
Plotly Scattergl 10M figure that appeared as two different numbers
from the same run.

Coverage: add spec/api/export.md, spec/api/interaction.md and
spec/design/wire-protocol.md for three surfaces that had no coverage
at all, plus the config.py threshold table, full JS and Rust module
inventories, and the hexbin centers-only wire contract that three
renderers must expand identically.

Structure: group 15 top-level files into api/, design/, matplotlib/,
benchmarks/ and process/, and rewrite spec/README.md as a real index
(it previously listed only the two SVG assets, leaving 21 of 22 docs
unreachable). All 150 consumer references repointed, including the CI
--emit-md target and the compat-matrix generator.

Guard: verify_sdist.py pinned only 5 top-level spec files, so the
sdist could silently lose spec/design/ or spec/assets/ and still
pass; it now asserts markdown under every subdirectory and both SVGs.
Alek99 added a commit that referenced this pull request Jul 21, 2026
…roup the tree (#122)

* Move engineering docs to root spec directory

* Resync spec/ with the implementation and group the tree

The move in this PR promoted docs/engineering/ to spec/ and CLAUDE.md
declares that tree the source of truth. An audit of all 24 documents
against the code found the tree had drifted at PR #96: five behavior
PRs (#103, #105, #106, #108, #115) had landed with no spec update, and
~100 claims contradicted shipped code.

Accuracy: correct claims that no longer match the implementation,
including the Rust ABI version (v31 -> v36), the "zero-crate cdylib"
claim (png is a real dependency), the pyramid ABI function names, the
panic-shield status, the R2/VAO claim in the renderer doc, the
WASM/Web-Worker architecture in dossier §3/§8 (superseded by §32's
C-ABI cdylib), the count-only tier rule, the matplotlib shim overhead
figures (the cited guardrail says +60% at 10k, not +9%), and the
Plotly Scattergl 10M figure that appeared as two different numbers
from the same run.

Coverage: add spec/api/export.md, spec/api/interaction.md and
spec/design/wire-protocol.md for three surfaces that had no coverage
at all, plus the config.py threshold table, full JS and Rust module
inventories, and the hexbin centers-only wire contract that three
renderers must expand identically.

Structure: group 15 top-level files into api/, design/, matplotlib/,
benchmarks/ and process/, and rewrite spec/README.md as a real index
(it previously listed only the two SVG assets, leaving 21 of 22 docs
unreachable). All 150 consumer references repointed, including the CI
--emit-md target and the compat-matrix generator.

Guard: verify_sdist.py pinned only 5 top-level spec files, so the
sdist could silently lose spec/design/ or spec/assets/ and still
pass; it now asserts markdown under every subdirectory and both SVGs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant