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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ in the README).
## [Unreleased]

### Added
- Every chart-, figure-, and module-level image-export API (`to_png`,
`to_svg`, `to_image`, `write_image`, `export.write_images`) accepts
`compatibility=` — facet-grid exports deliberately do not yet (their
per-panel preflight is tracked in the migration document): `"legacy"`
(default —
behavior unchanged), `"warn"` (one `StyleCompatibilityWarning` naming each
declaration the export would drop), or `"strict"`
(`StyleCompatibilityError` before emission, preflight report attached).
Modes never re-route an explicit engine; `"lossless"` is reserved and
rejected until preflight routing exists. The default flips only on the
published schedule in `spec/process/style-compatibility-migration.md`
(warn in 0.0.7, strict in 0.1.0, legacy removed in 0.2.0).
- `chart.style_compatibility_report(target=..., engine=..., custom_css=...)`:
a report-only export preflight that routes every declared slot style into
`survives`, `native-subset` (naming the kept and lost properties per
Expand Down
15 changes: 13 additions & 2 deletions python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -2244,13 +2244,17 @@ def to_svg(
*,
width: Optional[int] = None,
height: Optional[int] = None,
compatibility: str = "legacy",
) -> str:
"""Static SVG (_svg.py): a pure-Python render of the same decimated
payload the browser client consumes — resolution-independent, tiny
(screen-bounded regardless of source size), and dependency-free.
`width`/`height` override the figure's pixel size."""
`width`/`height` override the figure's pixel size. `compatibility`
stages the styling contract: "warn" surfaces any declaration this
vector export would drop, "strict" refuses to drop one."""
from . import _svg

export._enforce_compatibility(self, "svg", "native", None, compatibility)
return _svg.to_svg(self, path, width=width, height=height)

def to_png(
Expand All @@ -2265,6 +2269,7 @@ def to_png(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Static PNG (export.py). `engine=Engine.default` paints the
decimated payload with the built-in Rust rasterizer — no browser,
Expand All @@ -2286,6 +2291,7 @@ def to_png(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
)

def to_image(
Expand All @@ -2302,13 +2308,15 @@ def to_image(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> 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."""
policies, and `compatibility=` ("legacy"/"warn"/"strict") for the
staged styling contract."""
return export.to_image(
self,
format,
Expand All @@ -2322,6 +2330,7 @@ def to_image(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
)

def write_image(
Expand All @@ -2339,6 +2348,7 @@ def write_image(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Atomic file export with extension-inferred format (export.py):
.png/.jpg/.jpeg/.webp/.svg/.pdf, plus .html routing to `to_html`."""
Expand All @@ -2356,6 +2366,7 @@ def write_image(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
)

def memory_report(self) -> dict[str, Any]:
Expand Down
23 changes: 19 additions & 4 deletions python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -4199,9 +4199,15 @@ def to_svg(
*,
width: Optional[int] = None,
height: Optional[int] = None,
compatibility: str = "legacy",
) -> str:
"""A static SVG render of the chart (written to ``path`` if given)."""
return self.figure().to_svg(path, width=width, height=height)
"""A static SVG render of the chart (written to ``path`` if given).

``compatibility`` stages the styling contract: ``"warn"`` surfaces
any declaration this export would drop, ``"strict"`` refuses to drop
one; the default preserves current behavior.
"""
return self.figure().to_svg(path, width=width, height=height, compatibility=compatibility)

def to_png(
self,
Expand All @@ -4215,12 +4221,14 @@ def to_png(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""A PNG render of the chart, returned as bytes.

``scale`` multiplies the pixel density; ``engine`` picks the
raster path (native or headless Chromium). Written to ``path``
when given.
when given. ``compatibility`` stages the styling contract
(``"legacy"``/``"warn"``/``"strict"``).
"""
return self.figure().to_png(
path,
Expand All @@ -4232,6 +4240,7 @@ def to_png(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
)

def _export_defaults(
Expand Down Expand Up @@ -4281,12 +4290,14 @@ def to_image(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Unified static export: PNG/JPEG/WebP/SVG/PDF bytes.

Omitted width/height/scale/background/quality fall back to the
chart's `export_config` defaults; explicit arguments override them.
See `export.to_image` for the full format/engine/background policy."""
See `export.to_image` for the full format/engine/background policy
and `compatibility=` for the staged styling contract."""
fmt = export._normalize_format(format)
resolved = export._resolve_image_engine(engine, fmt, custom_css)
return self.figure().to_image(
Expand All @@ -4296,6 +4307,7 @@ def to_image(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
**self._export_defaults(
fmt,
width,
Expand All @@ -4322,6 +4334,7 @@ def write_image(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Atomic file export with extension-inferred format (.png/.jpg/
.jpeg/.webp/.svg/.pdf/.html). `export_config` defaults apply as in
Expand Down Expand Up @@ -4359,6 +4372,7 @@ def write_image(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
)
return self.figure().write_image(
path,
Expand All @@ -4368,6 +4382,7 @@ def write_image(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
**defaults,
)

Expand Down
75 changes: 72 additions & 3 deletions python/xy/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ class Engine(StrEnum):
chromium = "chromium"


def __getattr__(name: str) -> object:
# StyleCompatibilityError / StyleCompatibilityWarning are catchable from
# the module users already import for `Engine`, but resolved lazily: the
# preflight chain reaches the native library via the writers' constants,
# and importing this module must stay exactly as heavy as it was before
# the compatibility modes existed.
if name in ("StyleCompatibilityError", "StyleCompatibilityWarning"):
from .styling import preflight as _preflight

return getattr(_preflight, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


# Warn above this payload size; base64 carries a stated ~33% tax (§29).
EMBED_WARN_BYTES = 64 * 2**20

Expand Down Expand Up @@ -649,6 +662,7 @@ def write_images(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> list[bytes]:
"""Export many figures through ONE amortized pipeline (mixed formats OK).

Expand All @@ -665,7 +679,17 @@ def write_images(
exactly as in `Chart.to_image`. Writes are atomic per file; on error,
files already exported remain. Other options match `to_image`; quality
applies to JPEG and Chromium WebP and is ignored by the other formats
(native WebP stays lossless), so mixed batches stay ergonomic."""
(native WebP stays lossless), so mixed batches stay ergonomic.
`compatibility=` applies per figure while the plan is resolved, so a
strict batch refuses whole — before any file is written — rather than
after a partial export. Its vocabulary is validated once up front, so an
invalid mode fails even an all-HTML batch; HTML entries themselves are
exempt from the mode, because a document that renders the full cascade
has nothing to check."""
if compatibility != "legacy":
from .styling.preflight import validate_compatibility

validate_compatibility(compatibility)
if figures is not None:
if figs is not None:
raise ValueError("pass figs positionally or figures=, not both")
Expand Down Expand Up @@ -709,6 +733,9 @@ def write_images(
plan.append((fig, path, fmt, "html", {}, None, None))
continue
resolved = _resolve_image_engine(engine, fmt, custom_css)
# Per figure, up front with the rest of the plan: a strict batch
# fails whole before any file is written, never after a partial one.
_enforce_compatibility(fig, fmt, resolved, custom_css, compatibility)
Comment thread
Alek99 marked this conversation as resolved.
if callable(getattr(obj, "_export_defaults", None)):
settings = obj._export_defaults(
fmt,
Expand Down Expand Up @@ -792,6 +819,7 @@ def to_png(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Rasterize `fig` to a PNG (bytes, optionally saved).

Expand Down Expand Up @@ -819,9 +847,13 @@ def to_png(
optimize = _bool_option(optimize, "PNG optimize")
sandbox = _bool_option(sandbox, "PNG sandbox")
resolved_engine = _png_engine(engine)
# Resolution errors precede and outrank mode logic (the migration spec's
# contract): the custom_css/native refusal must stay a ValueError in
# every mode, so it fires before enforcement can warn or raise.
if resolved_engine == "native" and custom_css is not None:
raise ValueError("custom_css requires engine=Engine.chromium")
_enforce_compatibility(fig, "png", resolved_engine, custom_css, compatibility)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if resolved_engine == "native":
if custom_css is not None:
raise ValueError("custom_css requires engine=Engine.chromium")
from . import _raster

data = _raster.to_png(fig, None, width=w, height=h, scale=scale, fast=not optimize)
Expand Down Expand Up @@ -899,6 +931,35 @@ def _infer_format(path: str | PathLike[str]) -> str:
) from None


def _enforce_compatibility(
fig: "Figure",
fmt: str,
resolved_engine: str,
custom_css: Optional[str],
compatibility: str,
) -> None:
"""Apply the staged compatibility mode to one already-resolved export.

The literal-"legacy" short-circuit is the whole performance contract:
the default export path does one string comparison and never imports the
preflight machinery. Everything else — mode validation, the constant-time
unstyled path, warning versus refusing — lives in
`styling.preflight.enforce`. Modes never re-route an engine; they decide
whether to proceed, warn, or refuse on the engine the caller resolved.
"""
if compatibility == "legacy":
return
from .styling import preflight as _preflight

_preflight.enforce(
fig,
fmt=fmt,
resolved_engine=resolved_engine,
custom_css=custom_css,
compatibility=compatibility,
)


def _resolve_image_engine(engine: object, fmt: str, custom_css: Optional[str]) -> str:
"""Deterministic engine selection: -> "native" | "browser".

Expand Down Expand Up @@ -1137,6 +1198,7 @@ def to_image(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Render `fig` to image bytes in the requested `format`.

Expand All @@ -1154,6 +1216,7 @@ def to_image(
bounded rasters (the documented hybrid-vector policy)."""
fmt = _normalize_format(format)
resolved_engine = _resolve_image_engine(engine, fmt, custom_css)
_enforce_compatibility(fig, fmt, resolved_engine, custom_css, compatibility)
quality = _validated_quality(quality, fmt, resolved_engine)
background = _validated_background(background, fmt)
w, h = _export_dimensions(fig, width, height)
Expand Down Expand Up @@ -1201,6 +1264,7 @@ def write_image(
custom_css: Optional[str] = None,
sandbox: bool = True,
gl: str = "software",
compatibility: str = "legacy",
) -> bytes:
"""Export `fig` to `path`, inferring the format from the extension.

Expand All @@ -1221,6 +1285,10 @@ def write_image(
("background", background, None),
("quality", quality, None),
("optimize", optimize, False),
# HTML renders the full cascade in the browser — nothing can
# drop, so a compatibility mode has nothing to check and is
# rejected like the other options that cannot apply.
("compatibility", compatibility, "legacy"),
)
if value != default
]
Expand All @@ -1246,6 +1314,7 @@ def write_image(
custom_css=custom_css,
sandbox=sandbox,
gl=gl,
compatibility=compatibility,
)
_atomic_write_bytes(path, data)
return data
Loading
Loading