From c47317f6b43cf47176c778ae16602ef03ea0c374 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Mon, 20 Jul 2026 12:04:57 -0700 Subject: [PATCH 1/2] Add export format parity and a unified export API (ENG-10447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 23 + README.md | 3 +- docs/api-reference/figure-methods.md | 32 +- docs/guides/display-and-export.md | 164 +++- js/src/53_interaction.js | 83 +- pyproject.toml | 3 + python/xy/__init__.py | 9 +- python/xy/_chromium.py | 165 +++- python/xy/_figure.py | 73 ++ python/xy/_jpeg.py | 382 ++++++++ python/xy/_payload.py | 3 + python/xy/_pdf.py | 1262 ++++++++++++++++++++++++++ python/xy/_raster.py | 64 +- python/xy/_svg.py | 13 +- python/xy/_webp.py | 322 +++++++ python/xy/components.py | 295 +++++- python/xy/export.py | 553 ++++++++++- python/xy/facets.py | 182 +++- python/xy/static/index.js | 64 +- python/xy/static/standalone.js | 64 +- tests/test_batch_export.py | 30 +- tests/test_image_export.py | 398 ++++++++ tests/test_jpeg.py | 172 ++++ tests/test_pdf_export.py | 220 +++++ tests/test_type_surface.py | 2 +- tests/test_webp.py | 142 +++ 26 files changed, 4536 insertions(+), 187 deletions(-) create mode 100644 python/xy/_jpeg.py create mode 100644 python/xy/_pdf.py create mode 100644 python/xy/_webp.py create mode 100644 tests/test_image_export.py create mode 100644 tests/test_jpeg.py create mode 100644 tests/test_pdf_export.py create mode 100644 tests/test_webp.py 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..a4ca6cb5 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,84 @@ 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 paints the same single +backdrop in every format (raster canvas, SVG/PDF rect, browser page). + +`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 +117,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 +131,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..a0d835ba 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); @@ -1414,7 +1432,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 +1541,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 +1705,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 normally overrides with font-size="11" +_KAPPA = 0.5522847498307936 +_PNG_SIG = b"\x89PNG\r\n\x1a\n" + + +def _unsupported(what: str) -> NoReturn: + raise ValueError(f"unsupported SVG feature: {what}") + + +# --------------------------------------------------------------------------- +# Helvetica metrics (AFM widths for WinAnsi codes 32..255, per mille) +# --------------------------------------------------------------------------- + +# fmt: off +_HELV = ( + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 350, + 556, 350, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 350, 611, 350, + 350, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 350, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, +) +_HELV_BOLD = ( + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 350, + 556, 350, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 350, 611, 350, + 350, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 350, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, +) +# fmt: on + + +def _text_width_px(data: bytes, size: float, bold: bool) -> float: + """String advance in px for WinAnsi bytes at `size` px, from AFM widths.""" + table = _HELV_BOLD if bold else _HELV + return size * sum(table[b - 32] for b in data if b >= 32) / 1000.0 + + +def _pdf_string(data: bytes) -> str: + """A PDF literal string: escape ()\\, octal-escape bytes outside 32..126.""" + out: list[str] = [] + for b in data: + if b in (0x28, 0x29, 0x5C): + out.append("\\" + chr(b)) + elif 32 <= b < 127: + out.append(chr(b)) + else: + out.append(f"\\{b:03o}") + return "(" + "".join(out) + ")" + + +def _f(v: float) -> str: + s = f"{v:.4f}".rstrip("0").rstrip(".") + return "0" if s in ("-0", "") else s + + +def _local(tag: Any) -> str: + if not isinstance(tag, str): # comments / processing instructions + _unsupported("non-element XML node") + if tag.startswith("{"): + ns, _, name = tag[1:].partition("}") + if ns != _SVG_NS: + _unsupported(f"foreign namespace {ns!r}") + return name + return tag + + +def _check_attrs(el: ET.Element, tag: str, allowed: frozenset[str]) -> None: + for name in el.attrib: + if name not in allowed: + _unsupported(f"<{tag}> attribute {name!r}") + + +def _float(value: Optional[str], default: float, what: str) -> float: + if value is None: + return default + try: + return float(value) + except ValueError: + pass + _unsupported(f"{what} {value!r}") + + +def _rgba(css: str) -> tuple[float, float, float, float]: + _status, rgba = kernels.css_check(kernels.CSS_COLOR, css) + if rgba is None: + _unsupported(f"color {css!r}") + red, green, blue, alpha = rgba + return float(red), float(green), float(blue), float(alpha) + + +_URL_RE = re.compile(r"^url\(#([^)]+)\)$") +_ROTATE_RE = re.compile(r"^rotate\(\s*(-?[\d.]+)(?:[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+))?\s*\)$") +_NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") +_PATH_TOKEN_RE = re.compile(r"[A-Za-z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") + +_PAINT_ATTRS = frozenset( + { + "fill", + "fill-opacity", + "opacity", + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + "stroke-linejoin", + } +) +_ALLOWED_ATTRS: dict[str, frozenset[str]] = { + "svg": frozenset({"width", "height", "viewBox", "font-family", "font-size"}), + "svg-nested": frozenset({"x", "y", "width", "height", "viewBox"}), + "defs": frozenset(), + "clipPath": frozenset({"id"}), + "clip-rect": frozenset({"x", "y", "width", "height"}), + "linearGradient": frozenset({"id", "x1", "y1", "x2", "y2", "gradientUnits"}), + "stop": frozenset({"offset", "stop-color", "stop-opacity"}), + "g": frozenset({"clip-path", "fill", "fill-opacity", "stroke-opacity", "opacity"}), + "rect": frozenset({"x", "y", "width", "height", "rx"}) | _PAINT_ATTRS, + "circle": frozenset({"cx", "cy", "r"}) | _PAINT_ATTRS, + "line": frozenset({"x1", "y1", "x2", "y2"}) | _PAINT_ATTRS, + "path": frozenset({"d"}) | _PAINT_ATTRS, + "polyline": frozenset({"points"}) | _PAINT_ATTRS, + "polygon": frozenset({"points"}) | _PAINT_ATTRS, + "text": frozenset( + {"x", "y", "transform", "text-anchor", "font-size", "font-weight", "fill", "fill-opacity"} + ), + "tspan": frozenset({"x", "y"}), + "image": frozenset({"x", "y", "width", "height", "preserveAspectRatio", "style", "href"}), +} + + +# --------------------------------------------------------------------------- +# Path data (M/L/C/A/H/V/Z, absolute — the only commands the generator emits) +# --------------------------------------------------------------------------- + + +def _arc_cubics( + x1: float, + y1: float, + rx: float, + ry: float, + phi_deg: float, + large: bool, + sweep: bool, + x2: float, + y2: float, +) -> list[tuple[float, ...]]: + """SVG endpoint arc -> cubic Bézier segments (W3C implementation notes).""" + if rx == 0 or ry == 0 or (x1 == x2 and y1 == y2): + return [(x2, y2)] # degenerate: straight line + rx, ry = abs(rx), abs(ry) + phi = math.radians(phi_deg) + cosp, sinp = math.cos(phi), math.sin(phi) + dx2, dy2 = (x1 - x2) / 2.0, (y1 - y2) / 2.0 + x1p = cosp * dx2 + sinp * dy2 + y1p = -sinp * dx2 + cosp * dy2 + lam = (x1p / rx) ** 2 + (y1p / ry) ** 2 + if lam > 1: + scale = math.sqrt(lam) + rx *= scale + ry *= scale + num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p + den = rx * rx * y1p * y1p + ry * ry * x1p * x1p + co = math.sqrt(max(0.0, num / den)) if den else 0.0 + if large == sweep: + co = -co + cxp = co * rx * y1p / ry + cyp = -co * ry * x1p / rx + cx = cosp * cxp - sinp * cyp + (x1 + x2) / 2.0 + cy = sinp * cxp + cosp * cyp + (y1 + y2) / 2.0 + + def angle(ux: float, uy: float, vx: float, vy: float) -> float: + dot = ux * vx + uy * vy + norm = math.hypot(ux, uy) * math.hypot(vx, vy) + a = math.acos(max(-1.0, min(1.0, dot / norm))) if norm else 0.0 + return -a if ux * vy - uy * vx < 0 else a + + theta1 = angle(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry) + dtheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry) % ( + 2 * math.pi + ) + if not sweep and dtheta > 0: + dtheta -= 2 * math.pi + elif sweep and dtheta < 0: + dtheta += 2 * math.pi + + n = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2)))) + out: list[tuple[float, ...]] = [] + step = dtheta / n + for i in range(n): + t0 = theta1 + i * step + t1 = theta1 + (i + 1) * step + alpha = 4.0 / 3.0 * math.tan((t1 - t0) / 4.0) + + def point(t: float) -> tuple[float, float]: + ct, st = math.cos(t), math.sin(t) + return ( + cx + rx * ct * cosp - ry * st * sinp, + cy + rx * ct * sinp + ry * st * cosp, + ) + + def deriv(t: float) -> tuple[float, float]: + ct, st = math.cos(t), math.sin(t) + return ( + -rx * st * cosp - ry * ct * sinp, + -rx * st * sinp + ry * ct * cosp, + ) + + p0x, p0y = point(t0) + p1x, p1y = point(t1) + d0x, d0y = deriv(t0) + d1x, d1y = deriv(t1) + out.append( + ( + p0x + alpha * d0x, + p0y + alpha * d0y, + p1x - alpha * d1x, + p1y - alpha * d1y, + p1x, + p1y, + ) + ) + return out + + +def _parse_path(d: str) -> list[tuple]: + """Parse the generator's absolute path subset into ("M"|"L"|"C"|"Z", ...).""" + tokens = _PATH_TOKEN_RE.findall(d) + segs: list[tuple] = [] + i = 0 + cx = cy = 0.0 + cmd: Optional[str] = None + + def take(n: int) -> list[float]: + nonlocal i + if i + n > len(tokens) or any(tokens[j].isalpha() for j in range(i, i + n)): + _unsupported(f"path data {d[:40]!r}") + vals = [float(t) for t in tokens[i : i + n]] + i += n + return vals + + while i < len(tokens): + tok = tokens[i] + if tok.isalpha(): + if tok not in ("M", "L", "C", "A", "H", "V", "Z"): + _unsupported(f"path command {tok!r}") + cmd = tok + i += 1 + if cmd == "Z": + segs.append(("Z",)) + cmd = None + continue + if cmd is None: + _unsupported(f"path data {d[:40]!r}") + if cmd == "M": + cx, cy = take(2) + segs.append(("M", cx, cy)) + cmd = "L" # implicit repetition after moveto is lineto (SVG spec) + elif cmd == "L": + cx, cy = take(2) + segs.append(("L", cx, cy)) + elif cmd == "H": + (cx,) = take(1) + segs.append(("L", cx, cy)) + elif cmd == "V": + (cy,) = take(1) + segs.append(("L", cx, cy)) + elif cmd == "C": + c1x, c1y, c2x, c2y, cx, cy = take(6) + segs.append(("C", c1x, c1y, c2x, c2y, cx, cy)) + else: # A + rx, ry, rot, large, sweep, ex, ey = take(7) + for piece in _arc_cubics(cx, cy, rx, ry, rot, bool(large), bool(sweep), ex, ey): + if len(piece) == 2: + segs.append(("L", *piece)) + else: + segs.append(("C", *piece)) + cx, cy = ex, ey + return segs + + +def _segments_bbox(segs: list[tuple]) -> tuple[float, float, float, float]: + xs: list[float] = [] + ys: list[float] = [] + px = py = 0.0 + for seg in segs: + if seg[0] in ("M", "L"): + px, py = seg[1], seg[2] + xs.append(px) + ys.append(py) + elif seg[0] == "C": + x0, y0 = px, py + c1x, c1y, c2x, c2y, ex, ey = seg[1:] + for t in (0.25, 0.5, 0.75): # deterministic samples: tight enough for gradients + mt = 1 - t + xs.append(mt**3 * x0 + 3 * mt * mt * t * c1x + 3 * mt * t * t * c2x + t**3 * ex) + ys.append(mt**3 * y0 + 3 * mt * mt * t * c1y + 3 * mt * t * t * c2y + t**3 * ey) + xs.append(ex) + ys.append(ey) + px, py = ex, ey + if not xs: + return (0.0, 0.0, 0.0, 0.0) + return (min(xs), min(ys), max(xs), max(ys)) + + +def _rect_segments(x: float, y: float, w: float, h: float, rx: float) -> list[tuple]: + if rx <= 0: + return [("M", x, y), ("L", x + w, y), ("L", x + w, y + h), ("L", x, y + h), ("Z",)] + r = min(rx, w / 2.0, h / 2.0) + k = _KAPPA * r + return [ + ("M", x + r, y), + ("L", x + w - r, y), + ("C", x + w - r + k, y, x + w, y + r - k, x + w, y + r), + ("L", x + w, y + h - r), + ("C", x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h), + ("L", x + r, y + h), + ("C", x + r - k, y + h, x, y + h - r + k, x, y + h - r), + ("L", x, y + r), + ("C", x, y + r - k, x + r - k, y, x + r, y), + ("Z",), + ] + + +def _circle_segments(cx: float, cy: float, r: float) -> list[tuple]: + k = _KAPPA * r + return [ + ("M", cx + r, cy), + ("C", cx + r, cy + k, cx + k, cy + r, cx, cy + r), + ("C", cx - k, cy + r, cx - r, cy + k, cx - r, cy), + ("C", cx - r, cy - k, cx - k, cy - r, cx, cy - r), + ("C", cx + k, cy - r, cx + r, cy - k, cx + r, cy), + ("Z",), + ] + + +def _parse_points(points: str) -> list[tuple[float, float]]: + values = [float(v) for v in _NUMBER_RE.findall(points)] + if len(values) % 2: + _unsupported(f"points list {points[:40]!r}") + return list(zip(values[0::2], values[1::2], strict=True)) + + +# --------------------------------------------------------------------------- +# Embedded PNG decode (xy._png output: truecolor RGBA8 or indexed+tRNS) +# --------------------------------------------------------------------------- + + +def _paeth(a: int, b: int, c: int) -> int: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + return b if pb <= pc else c + + +def _unfilter(arr: np.ndarray, h: int, stride: int, bpp: int) -> np.ndarray: + recon = np.empty((h, stride), dtype=np.uint8) + prev = np.zeros(stride, dtype=np.int32) + for r in range(h): + ftype = int(arr[r * (stride + 1)]) + line = arr[r * (stride + 1) + 1 : (r + 1) * (stride + 1)].astype(np.int32) + if ftype == 0: + cur = line + elif ftype == 2: # Up + cur = (line + prev) & 0xFF + elif ftype == 1: # Sub + cur = line + for i in range(bpp, stride): + cur[i] = (cur[i] + cur[i - bpp]) & 0xFF + elif ftype == 3: # Average + cur = line + for i in range(stride): + left = cur[i - bpp] if i >= bpp else 0 + cur[i] = (cur[i] + ((left + prev[i]) >> 1)) & 0xFF + elif ftype == 4: # Paeth + cur = line + for i in range(stride): + left = int(cur[i - bpp]) if i >= bpp else 0 + upleft = int(prev[i - bpp]) if i >= bpp else 0 + cur[i] = (cur[i] + _paeth(left, int(prev[i]), upleft)) & 0xFF + else: + _unsupported(f"PNG filter type {ftype}") + recon[r] = cur.astype(np.uint8) + prev = cur + return recon + + +def _decode_png(data: bytes) -> tuple[int, int, bytes, Optional[bytes]]: + """Decode an embedded PNG to (w, h, rgb_bytes, alpha_bytes_or_None).""" + if data[:8] != _PNG_SIG: + _unsupported("embedded image is not a PNG") + pos = 8 + ihdr = b"" + idat: list[bytes] = [] + plte = b"" + trns = b"" + while pos + 8 <= len(data): + (length,) = struct.unpack(">I", data[pos : pos + 4]) + tag = data[pos + 4 : pos + 8] + body = data[pos + 8 : pos + 8 + length] + pos += 12 + length + if tag == b"IHDR": + ihdr = body + elif tag == b"IDAT": + idat.append(body) + elif tag == b"PLTE": + plte = body + elif tag == b"tRNS": + trns = body + elif tag == b"IEND": + break + if len(ihdr) != 13: + _unsupported("embedded PNG missing IHDR") + w, h, bit_depth, color_type, _comp, _filt, interlace = struct.unpack(">IIBBBBB", ihdr) + if bit_depth != 8 or interlace != 0 or color_type not in (3, 6): + _unsupported(f"embedded PNG color type {color_type}/depth {bit_depth}") + bpp = 4 if color_type == 6 else 1 + stride = w * bpp + raw = np.frombuffer(zlib.decompress(b"".join(idat)), dtype=np.uint8) + if len(raw) != h * (stride + 1): + _unsupported("embedded PNG payload size") + recon = _unfilter(raw, h, stride, bpp) + if color_type == 6: + px = recon.reshape(h, w, 4) + rgb = np.ascontiguousarray(px[:, :, :3]) + alpha = np.ascontiguousarray(px[:, :, 3]) + else: + palette = np.frombuffer(plte, dtype=np.uint8).reshape(-1, 3) + pal_alpha = np.full(len(palette), 255, dtype=np.uint8) + trns_arr = np.frombuffer(trns, dtype=np.uint8) + pal_alpha[: len(trns_arr)] = trns_arr[: len(palette)] + idx = recon.reshape(h, w) + if int(idx.max(initial=0)) >= len(palette): + _unsupported("embedded PNG palette index out of range") + rgb = np.ascontiguousarray(palette[idx]) + alpha = np.ascontiguousarray(pal_alpha[idx]) + alpha_bytes = None if int(alpha.min(initial=255)) == 255 else alpha.tobytes() + return int(w), int(h), rgb.tobytes(), alpha_bytes + + +# --------------------------------------------------------------------------- +# PDF object store (deterministic numbering: 1..4 fixed, resources from 5) +# --------------------------------------------------------------------------- + + +class _Pdf: + def __init__(self) -> None: + self.objects: dict[int, bytes] = {} + self._next = 5 + + def reserve(self) -> int: + num = self._next + self._next += 1 + return num + + def put(self, num: int, body: str) -> None: + self.objects[num] = f"{num} 0 obj\n{body}\nendobj\n".encode("ascii") + + def put_stream(self, num: int, extra: str, payload: bytes) -> None: + data = zlib.compress(payload) + head = f"{num} 0 obj\n<< {extra}/Length {len(data)} /Filter /FlateDecode >>\nstream\n" + self.objects[num] = head.encode("ascii") + data + b"\nendstream\nendobj\n" + + def serialize(self) -> bytes: + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + numbers = sorted(self.objects) + offsets: dict[int, int] = {} + for num in numbers: + offsets[num] = len(out) + out += self.objects[num] + xref_pos = len(out) + size = numbers[-1] + 1 + out += f"xref\n0 {size}\n".encode("ascii") + out += b"0000000000 65535 f \n" + for num in range(1, size): + out += f"{offsets[num]:010d} 00000 n \n".encode("ascii") + out += f"trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF\n".encode( + "ascii" + ) + return bytes(out) + + +class _State: + """Inheritable presentation state (override semantics, like CSS).""" + + __slots__ = ("fill", "fill_opacity", "font_size", "font_weight", "opacity", "stroke_opacity") + + def __init__(self) -> None: + self.fill = "#000000" + self.fill_opacity = 1.0 + self.stroke_opacity = 1.0 + self.opacity = 1.0 + self.font_size = _DEFAULT_FONT_SIZE + self.font_weight = 400.0 + + def child(self) -> "_State": + out = _State.__new__(_State) + for name in _State.__slots__: + setattr(out, name, getattr(self, name)) + return out + + +def _weight(value: Optional[str], default: float) -> float: + if value is None: + return default + if value == "bold": + return 700.0 + if value == "normal": + return 400.0 + return _float(value, default, "font-weight") + + +def _grad_fraction(value: Optional[str], default: float) -> float: + if value is None: + return default + v = value.strip() + if v.endswith("%"): + return _float(v[:-1], default, "gradient coordinate") / 100.0 + return _float(v, default, "gradient coordinate") + + +# --------------------------------------------------------------------------- +# Converter +# --------------------------------------------------------------------------- + + +class _Converter: + def __init__(self) -> None: + self.pdf = _Pdf() + self.ops: list[str] = [] + self.clips: dict[str, tuple[float, float, float, float]] = {} + self.gradients: dict[str, dict[str, Any]] = {} + self.fonts: dict[str, tuple[str, int]] = {} # basefont -> (resname, obj) + self.gstates: dict[tuple, tuple[str, int]] = {} + self.shadings: dict[tuple, tuple[str, int, bool]] = {} + self.smask_forms: dict[tuple, int] = {} + self.images: list[tuple[str, int]] = [] + self._cache_stack: list[dict[str, Any]] = [{}] + + # -- content-stream state cache --------------------------------------- + + @property + def _cache(self) -> dict[str, Any]: + return self._cache_stack[-1] + + def _push(self) -> None: + self.ops.append("q") + self._cache_stack.append(dict(self._cache)) + + def _pop(self) -> None: + self.ops.append("Q") + self._cache_stack.pop() + + def _set(self, key: str, value: Any, op: str) -> None: + if self._cache.get(key) != value: + self.ops.append(op) + self._cache[key] = value + + def _set_gs(self, ca: float, CA: float) -> None: # noqa: N803 — PDF operand name + name = self._gs_plain(ca, CA) + self._set("gs", name, f"/{name} gs") + + def _set_fill_rgb(self, rgb: tuple[float, float, float]) -> None: + key = tuple(round(v, 4) for v in rgb) + self._set("fill_rgb", key, f"{_f(rgb[0])} {_f(rgb[1])} {_f(rgb[2])} rg") + + def _set_stroke_rgb(self, rgb: tuple[float, float, float]) -> None: + key = tuple(round(v, 4) for v in rgb) + self._set("stroke_rgb", key, f"{_f(rgb[0])} {_f(rgb[1])} {_f(rgb[2])} RG") + + def _set_stroke_params( + self, width: float, cap: int, join: int, dash: Optional[list[float]] + ) -> None: + self._set("w", round(width, 4), f"{_f(width)} w") + self._set("J", cap, f"{cap} J") + self._set("j", join, f"{join} j") + dash_key = tuple(round(v, 4) for v in dash) if dash else () + dash_op = f"[{' '.join(_f(v) for v in dash)}] 0 d" if dash else "[] 0 d" + self._set("d", dash_key, dash_op) + + # -- resource registration --------------------------------------------- + + def _font(self, bold: bool) -> str: + base = "Helvetica-Bold" if bold else "Helvetica" + if base not in self.fonts: + num = self.pdf.reserve() + name = f"F{len(self.fonts) + 1}" + self.pdf.put( + num, + f"<< /Type /Font /Subtype /Type1 /BaseFont /{base} /Encoding /WinAnsiEncoding >>", + ) + self.fonts[base] = (name, num) + return self.fonts[base][0] + + def _gs_plain(self, ca: float, CA: float) -> str: # noqa: N803 + key = ("plain", round(ca, 4), round(CA, 4)) + if key not in self.gstates: + num = self.pdf.reserve() + name = f"G{len(self.gstates) + 1}" + self.pdf.put(num, f"<< /Type /ExtGState /ca {_f(ca)} /CA {_f(CA)} >>") + self.gstates[key] = (name, num) + return self.gstates[key][0] + + @staticmethod + def _function_dict(stops: list[tuple[float, tuple[float, ...]]]) -> str: + """Type 2 exponential (2 stops) or Type 3 stitching (>2) function.""" + + def vals(v: tuple[float, ...]) -> str: + return " ".join(_f(c) for c in v) + + if len(stops) == 2: + return ( + f"<< /FunctionType 2 /Domain [0 1] /C0 [{vals(stops[0][1])}] " + f"/C1 [{vals(stops[1][1])}] /N 1 >>" + ) + pieces = [ + f"<< /FunctionType 2 /Domain [0 1] /C0 [{vals(v0)}] /C1 [{vals(v1)}] /N 1 >>" + for (_t0, v0), (_t1, v1) in pairwise(stops) + ] + bounds = " ".join(_f(t) for t, _v in stops[1:-1]) + encode = " ".join(["0 1"] * len(pieces)) + return ( + f"<< /FunctionType 3 /Domain [0 1] /Functions [{' '.join(pieces)}] " + f"/Bounds [{bounds}] /Encode [{encode}] >>" + ) + + @staticmethod + def _normalize_stops( + stops: list[tuple[float, tuple[float, ...]]], + ) -> list[tuple[float, tuple[float, ...]]]: + """Clamp to [0,1], enforce strictly increasing offsets, pad the ends.""" + out: list[tuple[float, tuple[float, ...]]] = [] + prev = -1.0 + for t, v in stops: + t = min(1.0, max(0.0, t)) + if t <= prev: + t = prev + 1e-4 + if t > 1.0: + continue # cannot nudge past the end: drop (deterministic) + out.append((t, v)) + prev = t + if not out: + out = [(0.0, stops[0][1]), (1.0, stops[-1][1])] + if out[0][0] > 0.0: + out.insert(0, (0.0, out[0][1])) + if out[-1][0] < 1.0: + out.append((1.0, out[-1][1])) + if len(out) == 1: + out.append((1.0, out[0][1])) + return out + + def _shading( + self, + coords: tuple[float, float, float, float], + stops: list[tuple[float, tuple[float, ...]]], + gray: bool, + ) -> tuple[str, int]: + stops = self._normalize_stops(stops) + key = ( + gray, + tuple(round(c, 4) for c in coords), + tuple((round(t, 6), tuple(round(c, 6) for c in v)) for t, v in stops), + ) + if key not in self.shadings: + num = self.pdf.reserve() + name = f"Sh{len(self.shadings) + 1}" + space = "/DeviceGray" if gray else "/DeviceRGB" + coords_s = " ".join(_f(c) for c in coords) + self.pdf.put( + num, + f"<< /ShadingType 2 /ColorSpace {space} /Coords [{coords_s}] " + f"/Extend [true true] /Function {self._function_dict(stops)} >>", + ) + self.shadings[key] = (name, num, gray) + return self.shadings[key][0], self.shadings[key][1] + + def _gs_gradient( + self, + coords: tuple[float, float, float, float], + alpha_stops: list[tuple[float, tuple[float, ...]]], + bbox: tuple[float, float, float, float], + ca: float, + ) -> str: + """ExtGState with a luminosity soft mask carrying the stop alphas.""" + gray_name, gray_num = self._shading(coords, alpha_stops, gray=True) + form_key = (gray_num, tuple(round(v, 4) for v in bbox)) + if form_key not in self.smask_forms: + form_num = self.pdf.reserve() + bbox_s = " ".join(_f(v) for v in (bbox[0], bbox[1], bbox[2], bbox[3])) + self.pdf.put_stream( + form_num, + f"/Type /XObject /Subtype /Form /BBox [{bbox_s}] " + f"/Group << /S /Transparency /CS /DeviceGray >> " + f"/Resources << /Shading << /{gray_name} {gray_num} 0 R >> >> ", + f"/{gray_name} sh".encode("ascii"), + ) + self.smask_forms[form_key] = form_num + form_num = self.smask_forms[form_key] + key = ("smask", form_num, round(ca, 4)) + if key not in self.gstates: + num = self.pdf.reserve() + name = f"G{len(self.gstates) + 1}" + self.pdf.put( + num, + f"<< /Type /ExtGState /ca {_f(ca)} /CA {_f(ca)} " + f"/SMask << /S /Luminosity /G {form_num} 0 R >> >>", + ) + self.gstates[key] = (name, num) + return self.gstates[key][0] + + def _image_xobject(self, w: int, h: int, rgb: bytes, alpha: Optional[bytes]) -> str: + smask_ref = "" + if alpha is not None: + smask_num = self.pdf.reserve() + self.pdf.put_stream( + smask_num, + f"/Type /XObject /Subtype /Image /Width {w} /Height {h} " + f"/ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate false ", + alpha, + ) + smask_ref = f"/SMask {smask_num} 0 R " + num = self.pdf.reserve() + self.pdf.put_stream( + num, + f"/Type /XObject /Subtype /Image /Width {w} /Height {h} " + f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Interpolate false {smask_ref}", + rgb, + ) + name = f"Im{len(self.images) + 1}" + self.images.append((name, num)) + return name + + # -- defs collection ---------------------------------------------------- + + def _collect_defs(self, root: ET.Element) -> None: + for el in root.iter(): + tag = _local(el.tag) + if tag == "clipPath": + _check_attrs(el, tag, _ALLOWED_ATTRS["clipPath"]) + cid = el.get("id") + children = list(el) + if cid is None or len(children) != 1 or _local(children[0].tag) != "rect": + _unsupported(" without a single ") + rect = children[0] + _check_attrs(rect, "clipPath rect", _ALLOWED_ATTRS["clip-rect"]) + self.clips[cid] = ( + _float(rect.get("x"), 0.0, "clip x"), + _float(rect.get("y"), 0.0, "clip y"), + _float(rect.get("width"), 0.0, "clip width"), + _float(rect.get("height"), 0.0, "clip height"), + ) + elif tag == "linearGradient": + _check_attrs(el, tag, _ALLOWED_ATTRS["linearGradient"]) + gid = el.get("id") + if gid is None: + _unsupported(" without id") + units = el.get("gradientUnits", "objectBoundingBox") + if units not in ("objectBoundingBox", "userSpaceOnUse"): + _unsupported(f"gradientUnits {units!r}") + stops: list[tuple[float, tuple[float, float, float], float]] = [] + for stop in el: + if _local(stop.tag) != "stop": + _unsupported(f" child <{_local(stop.tag)}>") + _check_attrs(stop, "stop", _ALLOWED_ATTRS["stop"]) + offset = _grad_fraction(stop.get("offset"), 0.0) + red, green, blue, alpha = _rgba(stop.get("stop-color", "#000000")) + alpha *= _float(stop.get("stop-opacity"), 1.0, "stop-opacity") + stops.append((offset, (red, green, blue), alpha)) + if not stops: + _unsupported(" without stops") + self.gradients[gid] = { + "units": units, + "x1": el.get("x1"), + "y1": el.get("y1"), + "x2": el.get("x2"), + "y2": el.get("y2"), + "stops": stops, + } + + # -- painting ----------------------------------------------------------- + + def _emit_segments(self, segs: list[tuple]) -> None: + for seg in segs: + if seg[0] == "M": + self.ops.append(f"{_f(seg[1])} {_f(seg[2])} m") + elif seg[0] == "L": + self.ops.append(f"{_f(seg[1])} {_f(seg[2])} l") + elif seg[0] == "C": + self.ops.append(" ".join(_f(v) for v in seg[1:]) + " c") + else: + self.ops.append("h") + + def _resolve_paint(self, raw: Optional[str]) -> Optional[tuple[str, Any]]: + if raw is None or raw.strip() == "none": + return None + m = _URL_RE.match(raw.strip()) + if m: + gid = m.group(1) + if gid not in self.gradients: + _unsupported(f"paint reference {raw!r}") + return ("gradient", gid) + return ("solid", _rgba(raw)) + + def _gradient_coords( + self, grad: dict[str, Any], bbox: tuple[float, float, float, float] + ) -> tuple[float, float, float, float]: + if grad["units"] == "userSpaceOnUse": + return ( + _float(grad["x1"], 0.0, "gradient x1"), + _float(grad["y1"], 0.0, "gradient y1"), + _float(grad["x2"], 1.0, "gradient x2"), + _float(grad["y2"], 0.0, "gradient y2"), + ) + x0, y0, x1, y1 = bbox + fx1 = _grad_fraction(grad["x1"], 0.0) + fy1 = _grad_fraction(grad["y1"], 0.0) + fx2 = _grad_fraction(grad["x2"], 1.0) + fy2 = _grad_fraction(grad["y2"], 0.0) + return ( + x0 + fx1 * (x1 - x0), + y0 + fy1 * (y1 - y0), + x0 + fx2 * (x1 - x0), + y0 + fy2 * (y1 - y0), + ) + + def _paint_gradient_fill(self, segs: list[tuple], gid: str, ca: float) -> None: + grad = self.gradients[gid] + bbox = _segments_bbox(segs) + coords = self._gradient_coords(grad, bbox) + color_stops = [(t, rgb) for t, rgb, _a in grad["stops"]] + name, _num = self._shading(coords, color_stops, gray=False) + has_alpha = any(a < 1.0 for _t, _rgb, a in grad["stops"]) + self._push() + self._emit_segments(segs) + self.ops.append("W n") + if has_alpha: + alpha_stops: list[tuple[float, tuple[float, ...]]] = [ + (t, (a,)) for t, _rgb, a in grad["stops"] + ] + gs = self._gs_gradient(coords, alpha_stops, bbox, ca) + self._set("gs", gs, f"/{gs} gs") + else: + self._set_gs(ca, ca) + self.ops.append(f"/{name} sh") + self._pop() + + def _render_shape(self, el: ET.Element, tag: str, state: _State) -> None: + _check_attrs(el, tag, _ALLOWED_ATTRS[tag]) + segs: list[tuple] + if tag == "rect": + x = _float(el.get("x"), 0.0, "x") + y = _float(el.get("y"), 0.0, "y") + w = _float(el.get("width"), 0.0, "width") + h = _float(el.get("height"), 0.0, "height") + rx = _float(el.get("rx"), 0.0, "rx") + segs = _rect_segments(x, y, w, h, rx) + fillable = True + elif tag == "circle": + segs = _circle_segments( + _float(el.get("cx"), 0.0, "cx"), + _float(el.get("cy"), 0.0, "cy"), + _float(el.get("r"), 0.0, "r"), + ) + fillable = True + elif tag == "line": + x1 = _float(el.get("x1"), 0.0, "x1") + y1 = _float(el.get("y1"), 0.0, "y1") + x2 = _float(el.get("x2"), 0.0, "x2") + y2 = _float(el.get("y2"), 0.0, "y2") + segs = [("M", x1, y1), ("L", x2, y2)] + fillable = False + elif tag == "path": + d = el.get("d") + if d is None: + _unsupported(" without d") + segs = _parse_path(d) + fillable = True + else: # polyline / polygon + points = el.get("points") + if points is None: + _unsupported(f"<{tag}> without points") + pts = _parse_points(points) + if not pts: + return + segs = [("M", *pts[0])] + [("L", *p) for p in pts[1:]] + if tag == "polygon": + segs.append(("Z",)) + fillable = True + + opacity = state.opacity * _float(el.get("opacity"), 1.0, "opacity") + fill_op = opacity * _float(el.get("fill-opacity"), state.fill_opacity, "fill-opacity") + stroke_op = opacity * _float( + el.get("stroke-opacity"), state.stroke_opacity, "stroke-opacity" + ) + fill = self._resolve_paint(el.get("fill", state.fill)) if fillable else None + stroke = self._resolve_paint(el.get("stroke")) + stroke_width = _float(el.get("stroke-width"), 1.0, "stroke-width") + if stroke is not None and stroke[0] == "gradient": + _unsupported("gradient stroke") + do_stroke = stroke is not None and stroke_width > 0 + + cap_name = el.get("stroke-linecap", "butt") + join_name = el.get("stroke-linejoin", "miter") + caps = {"butt": 0, "round": 1, "square": 2} + joins = {"miter": 0, "round": 1, "bevel": 2} + if cap_name not in caps: + _unsupported(f"stroke-linecap {cap_name!r}") + if join_name not in joins: + _unsupported(f"stroke-linejoin {join_name!r}") + dash_raw = el.get("stroke-dasharray") + dash = [float(v) for v in _NUMBER_RE.findall(dash_raw)] if dash_raw else None + if dash is not None and not any(v > 0 for v in dash): + dash = None + + if fill is not None and fill[0] == "gradient": + self._paint_gradient_fill(segs, fill[1], fill_op) + fill = None # gradient already painted; a stroke pass may follow + + if fill is None and not do_stroke: + return + ca = 1.0 + if fill is not None: + red, green, blue, alpha = fill[1] + ca = fill_op * alpha + self._set_fill_rgb((red, green, blue)) + CA = 1.0 # noqa: N806 — PDF operand name + if do_stroke: + red, green, blue, alpha = stroke[1] + CA = stroke_op * alpha # noqa: N806 + self._set_stroke_rgb((red, green, blue)) + self._set_stroke_params(stroke_width, caps[cap_name], joins[join_name], dash) + self._set_gs(ca, CA) + self._emit_segments(segs) + self.ops.append("B" if (fill is not None and do_stroke) else ("f" if fill else "S")) + + # -- text --------------------------------------------------------------- + + def _render_text(self, el: ET.Element, state: _State) -> None: + _check_attrs(el, "text", _ALLOWED_ATTRS["text"]) + font_size = _float(el.get("font-size"), state.font_size, "font-size") + bold = _weight(el.get("font-weight"), state.font_weight) >= 600 + anchor = el.get("text-anchor", "start") + if anchor not in ("start", "middle", "end"): + _unsupported(f"text-anchor {anchor!r}") + fill = self._resolve_paint(el.get("fill", state.fill)) + if fill is None or fill[0] != "solid": + _unsupported("text fill paint") + red, green, blue, alpha = fill[1] + ca = ( + state.opacity + * _float(el.get("fill-opacity"), state.fill_opacity, "fill-opacity") + * alpha + ) + + angle = 0.0 + center: Optional[tuple[float, float]] = None + transform = el.get("transform") + if transform is not None: + m = _ROTATE_RE.match(transform.strip()) + if m is None: + _unsupported(f"transform {transform!r}") + angle = float(m.group(1)) + if m.group(2) is not None: + center = (float(m.group(2)), float(m.group(3))) + + runs: list[tuple[float, float, str]] = [] + tspans = list(el) + if tspans: + if el.text and el.text.strip(): + _unsupported(" mixing direct text and ") + for ts in tspans: + if _local(ts.tag) != "tspan": + _unsupported(f" child <{_local(ts.tag)}>") + _check_attrs(ts, "tspan", _ALLOWED_ATTRS["tspan"]) + if list(ts): + _unsupported("nested ") + runs.append( + ( + _float(ts.get("x"), 0.0, "tspan x"), + _float(ts.get("y"), 0.0, "tspan y"), + ts.text or "", + ) + ) + else: + runs.append( + (_float(el.get("x"), 0.0, "x"), _float(el.get("y"), 0.0, "y"), el.text or "") + ) + + font_name = self._font(bold) + theta = math.radians(angle) + cos_t, sin_t = math.cos(theta), math.sin(theta) + for x, y, s in runs: + # Non-WinAnsi characters become "?" — deterministic replacement. + data = s.encode("cp1252", "replace") + if not data: + continue + if center is not None: + cx, cy = center + x, y = ( + cx + cos_t * (x - cx) - sin_t * (y - cy), + cy + sin_t * (x - cx) + cos_t * (y - cy), + ) + width = _text_width_px(data, font_size, bold) + dx = -width / 2.0 if anchor == "middle" else (-width if anchor == "end" else 0.0) + tx = x + dx * cos_t + ty = y + dx * sin_t + self._set_gs(ca, ca) + self._set_fill_rgb((red, green, blue)) + # Tm un-flips the top-level y flip so glyphs render upright; the + # rotation is the SVG angle (clockwise in screen space). + self.ops.append("BT") + self.ops.append(f"/{font_name} {_f(font_size)} Tf") + self.ops.append( + f"{_f(cos_t)} {_f(sin_t)} {_f(sin_t)} {_f(-cos_t)} {_f(tx)} {_f(ty)} Tm" + ) + self.ops.append(f"{_pdf_string(data)} Tj") + self.ops.append("ET") + + # -- images ------------------------------------------------------------- + + def _render_image(self, el: ET.Element, state: _State) -> None: + _check_attrs(el, "image", _ALLOWED_ATTRS["image"]) + if el.get("preserveAspectRatio", "none") != "none": + _unsupported(f"preserveAspectRatio {el.get('preserveAspectRatio')!r}") + style = (el.get("style") or "").strip().rstrip(";") + if style not in ("", "image-rendering:pixelated"): + _unsupported(f" style {style!r}") + href = el.get("href") or "" + prefix = "data:image/png;base64," + if not href.startswith(prefix): + _unsupported(" href (only embedded base64 PNG)") + w_px, h_px, rgb, alpha = _decode_png(base64.b64decode(href[len(prefix) :])) + name = self._image_xobject(w_px, h_px, rgb, alpha) + x = _float(el.get("x"), 0.0, "x") + y = _float(el.get("y"), 0.0, "y") + w = _float(el.get("width"), 0.0, "width") + h = _float(el.get("height"), 0.0, "height") + self._push() + ca = state.opacity + if ca < 1.0: + self._set_gs(ca, ca) + # Negative height keeps PNG row 0 at the top under the global y flip. + self.ops.append(f"{_f(w)} 0 0 {_f(-h)} {_f(x)} {_f(y + h)} cm") + self.ops.append(f"/{name} Do") + self._pop() + + # -- structure ---------------------------------------------------------- + + def _render_g(self, el: ET.Element, state: _State) -> None: + _check_attrs(el, "g", _ALLOWED_ATTRS["g"]) + child = state.child() + fill = el.get("fill") + if fill is not None: + child.fill = fill + child.fill_opacity = _float(el.get("fill-opacity"), state.fill_opacity, "fill-opacity") + child.stroke_opacity = _float( + el.get("stroke-opacity"), state.stroke_opacity, "stroke-opacity" + ) + child.opacity = state.opacity * _float(el.get("opacity"), 1.0, "opacity") + clip_ref = el.get("clip-path") + clipped = False + if clip_ref is not None: + m = _URL_RE.match(clip_ref.strip()) + if m is None or m.group(1) not in self.clips: + _unsupported(f"clip-path {clip_ref!r}") + x, y, w, h = self.clips[m.group(1)] + self._push() + self.ops.append(f"{_f(x)} {_f(y)} {_f(w)} {_f(h)} re") + self.ops.append("W n") + clipped = True + self._render_children(el, child) + if clipped: + self._pop() + + def _render_nested_svg(self, el: ET.Element, state: _State) -> None: + _check_attrs(el, "svg", _ALLOWED_ATTRS["svg-nested"]) + x = _float(el.get("x"), 0.0, "x") + y = _float(el.get("y"), 0.0, "y") + w = _float(el.get("width"), 0.0, "width") + h = _float(el.get("height"), 0.0, "height") + vb = [float(v) for v in _NUMBER_RE.findall(el.get("viewBox") or "")] + if len(vb) != 4 or vb[0] != 0 or vb[1] != 0 or vb[2] <= 0 or vb[3] <= 0: + _unsupported(f"viewBox {el.get('viewBox')!r}") + sx, sy = w / vb[2], h / vb[3] + self._push() + self.ops.append(f"{_f(sx)} 0 0 {_f(sy)} {_f(x)} {_f(y)} cm") + # Nested viewports clip to their bounds (SVG overflow:hidden default). + self.ops.append(f"0 0 {_f(vb[2])} {_f(vb[3])} re") + self.ops.append("W n") + self._render_children(el, state.child()) + self._pop() + + def _render_children(self, el: ET.Element, state: _State) -> None: + for child in el: + self._render_element(child, state) + if child.tail and child.tail.strip(): + _unsupported("stray text content") + + def _render_element(self, el: ET.Element, state: _State) -> None: + tag = _local(el.tag) + if tag in ("defs", "clipPath", "linearGradient"): + return # definitions were collected up front; never painted + if tag == "g": + self._render_g(el, state) + elif tag in ("rect", "circle", "line", "path", "polyline", "polygon"): + if el.text and el.text.strip(): + _unsupported("stray text content") + self._render_shape(el, tag, state) + elif tag == "text": + self._render_text(el, state) + elif tag == "image": + self._render_image(el, state) + elif tag == "svg": + self._render_nested_svg(el, state) + else: + _unsupported(f"<{tag}>") + if tag in ("g", "svg") and el.text and el.text.strip(): + _unsupported("stray text content") + + # -- document assembly --------------------------------------------------- + + def run(self, root: ET.Element) -> bytes: + if _local(root.tag) != "svg": + _unsupported(f"root <{_local(root.tag)}>") + _check_attrs(root, "svg", _ALLOWED_ATTRS["svg"]) + width = _float(root.get("width"), -1.0, "svg width") + height = _float(root.get("height"), -1.0, "svg height") + if width <= 0 or height <= 0: + _unsupported("svg without positive pixel width/height") + vb = [float(v) for v in _NUMBER_RE.findall(root.get("viewBox") or "")] + if vb and (len(vb) != 4 or vb != [0.0, 0.0, width, height]): + _unsupported(f"root viewBox {root.get('viewBox')!r}") + + self._collect_defs(root) + + state = _State() + state.font_size = _float(root.get("font-size"), _DEFAULT_FONT_SIZE, "font-size") + + page_w = width * _PX_TO_PT + page_h = height * _PX_TO_PT + self._push() + # 1 CSS px = 0.75 pt; the negative d flips SVG y-down to PDF y-up. + self.ops.append(f"{_f(_PX_TO_PT)} 0 0 {_f(-_PX_TO_PT)} 0 {_f(page_h)} cm") + if root.text and root.text.strip(): + _unsupported("stray text content") + self._render_children(root, state) + self._pop() + + content = "\n".join(self.ops).encode("ascii") + pdf = self.pdf + pdf.put(1, "<< /Type /Catalog /Pages 2 0 R >>") + pdf.put(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + resources: list[str] = [] + if self.fonts: + entries = " ".join(f"/{name} {num} 0 R" for name, num in self.fonts.values()) + resources.append(f"/Font << {entries} >>") + if self.gstates: + entries = " ".join(f"/{name} {num} 0 R" for name, num in self.gstates.values()) + resources.append(f"/ExtGState << {entries} >>") + color_shadings = [(n, num) for n, num, gray in self.shadings.values() if not gray] + if color_shadings: + entries = " ".join(f"/{name} {num} 0 R" for name, num in color_shadings) + resources.append(f"/Shading << {entries} >>") + if self.images: + entries = " ".join(f"/{name} {num} 0 R" for name, num in self.images) + resources.append(f"/XObject << {entries} >>") + pdf.put( + 3, + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_f(page_w)} {_f(page_h)}] " + f"/Resources << {' '.join(resources)} >> /Contents 4 0 R >>", + ) + pdf.put_stream(4, "", content) + return pdf.serialize() + + +def svg_to_pdf(svg: str) -> bytes: + """Convert an xy-generated SVG document into a single-page vector PDF. + + Raises ``ValueError("unsupported SVG feature: ...")`` for any element, + attribute, or value outside the closed subset `xy._svg` emits. + """ + try: + root = ET.fromstring(svg) + except ET.ParseError as exc: + raise ValueError(f"unsupported SVG feature: unparseable XML ({exc})") from None + return _Converter().run(root) diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 0740f02f..bd4ecf90 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1864,16 +1864,13 @@ def _emit_colorbar( ) -def to_png( +def _export_payload( fig: Any, - path: Optional[str | PathLike[str]] = None, - *, - width: Optional[int] = None, - height: Optional[int] = None, - scale: float = 2.0, - fast: bool = False, -) -> bytes: - """Render `fig` to PNG bytes with the native rasterizer (no browser).""" + width: Optional[int], + height: Optional[int], + background: Optional[str], +) -> tuple[dict[str, Any], bytes, tuple[np.ndarray, ...]]: + """Build the raster payload with export-time size/background overrides.""" eff_w = ( int(width) if width is not None @@ -1884,6 +1881,55 @@ def to_png( spec["width"] = int(width) if height is not None: spec["height"] = int(height) + if background is not None: + spec["canvas_background"] = background + # SVG paints the plot rect only when the theme sets --chart-bg; the + # rasterizer would otherwise default it to opaque white, which breaks + # cross-format background agreement (and any transparent export). An + # explicit export background therefore also becomes the plot-rect + # default — a theme-set --chart-bg still wins. + dom = spec.setdefault("dom", {}) + style = dom.setdefault("style", {}) if isinstance(dom, dict) else {} + if isinstance(style, dict): + style.setdefault("--chart-bg", background) + return spec, blob, borrowed + + +def to_rgba( + fig: Any, + *, + width: Optional[int] = None, + height: Optional[int] = None, + scale: float = 2.0, + background: Optional[str] = None, +) -> np.ndarray: + """Render `fig` to an ``(h, w, 4)`` RGBA8 array (no encode). + + The shared pixel source for every native raster format: PNG keeps its + fused Rust encode path in `to_png`, while JPEG/WebP export encodes this + array. `background` overrides the figure canvas color ("transparent" + yields alpha-0 pixels outside the plot rect).""" + spec, blob, borrowed = _export_payload(fig, width, height, background) + rendered = render_raster(spec, blob, float(scale), borrowed=borrowed) + assert isinstance(rendered, np.ndarray) # fast_png=False never returns bytes + return rendered + + +def to_png( + fig: Any, + path: Optional[str | PathLike[str]] = None, + *, + width: Optional[int] = None, + height: Optional[int] = None, + scale: float = 2.0, + fast: bool = False, + background: Optional[str] = None, +) -> bytes: + """Render `fig` to PNG bytes with the native rasterizer (no browser).""" + # The fused Rust PNG path initializes an opaque white canvas, so any + # non-default background must take the raw-RGBA encode branch. + fast = fast and background is None + spec, blob, borrowed = _export_payload(fig, width, height, background) rendered = render_raster(spec, blob, float(scale), fast_png=fast, borrowed=borrowed) data = rendered if isinstance(rendered, bytes) else _png.encode(rendered) if path is not None: diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 44deea7a..83aa90df 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1749,6 +1749,12 @@ def tick_span(style: dict[str, Any]) -> tuple[float, float, float]: # the --chart-bg token over the plot rect only. Solid colors only — # gradients stay browser-only, and an unset token stays transparent. backgrounds = "" + # 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") + if canvas_paint and canvas_paint not in ("transparent", "none"): + backgrounds += f'' figure_background = _solid_paint(dom_style.get("background")) if figure_background is not None: backgrounds += ( @@ -2613,13 +2619,16 @@ def to_svg( width: Optional[int] = None, height: Optional[int] = None, id_prefix: str = "", + background: Optional[str] = None, ) -> str: """Render `fig` to a standalone SVG string (optionally saved to `path`). `width`/`height` override the figure's pixel size (useful for fluid "100%" figures). Decimation runs at the export width, so output stays screen-bounded no matter the source size. `id_prefix` namespaces generated - element ids for composers that inline several exports in one document.""" + element ids for composers that inline several exports in one document. + `background` overrides the figure canvas color ("transparent" omits the + opaque backdrop, matching the raster exporters' alpha behavior).""" eff_w = ( int(width) if width is not None @@ -2630,6 +2639,8 @@ def to_svg( spec["width"] = int(width) if height is not None: spec["height"] = int(height) + if background is not None: + spec["canvas_background"] = background out = render_svg(spec, blob, id_prefix=id_prefix) if path is not None: from .export import _atomic_write_text diff --git a/python/xy/_webp.py b/python/xy/_webp.py new file mode 100644 index 00000000..40d68cb6 --- /dev/null +++ b/python/xy/_webp.py @@ -0,0 +1,322 @@ +"""Pure-Python/numpy lossless WebP (VP8L) encoder for static export. + +`encode` emits a RIFF/WEBP container holding a single VP8L chunk, using the +"simple lossless" subset of the format (RFC 9649): + +- no transforms, no color cache, one meta prefix group; +- five canonical, length-limited (<=15) prefix codes built from the actual + token histograms, in spec order: green+length (280), red, blue, alpha (256 + each), distance (40); +- pixels emitted in scan order as literals (green, red, blue, alpha), except + runs of identical consecutive pixels, which become LZ77 distance-1 backward + references — charts are mostly flat fills, so runs shrink output hugely. + +Round-trips are bit-exact, alpha included. Like `_png.py` this stays pure +Python/numpy + stdlib: static export favors zero extra dependencies, while the +latency-first pyplot path owns the Rust encoders. The bitstream is assembled +as parallel (value, nbits) arrays and packed LSB-first (VP8L bit order) with a +vectorized pass per bit position, so chart-sized rasters encode in well under +a second instead of minutes of per-symbol Python. +""" + +from __future__ import annotations + +import struct + +import numpy as np + +_MAX_DIM = 1 << 14 # VP8L stores (dim - 1) in 14 bits +_MAX_RUN = 4096 # length prefix code 23 tops out at 4096 pixels +_GREEN_ALPHABET = 256 + 24 # literals + length prefix codes (no color cache) +_DIST_ALPHABET = 40 +# Spec transmission order for the 19 code-length-code lengths (note 16 riding +# between 5 and 6 — codes whose lengths stay <=5 never reveal a wrong tail). +_CODE_LENGTH_ORDER = (17, 18, 0, 1, 2, 3, 4, 5, 16, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15) + + +def _length_prefix_lut() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """(prefix code, extra-bit count, extra-bit value) for lengths 1.._MAX_RUN. + + Spec mapping: codes 0-3 are lengths 1-4; beyond that the code stores the + two leading bits of (length - 1) and the remainder rides as extra bits. + """ + codes = np.zeros(_MAX_RUN + 1, np.uint16) + ebits = np.zeros(_MAX_RUN + 1, np.uint8) + extra = np.zeros(_MAX_RUN + 1, np.uint16) + for v in range(1, _MAX_RUN + 1): + if v <= 4: + codes[v] = v - 1 + else: + d = v - 1 + hb = d.bit_length() - 1 + codes[v] = 2 * hb + ((d >> (hb - 1)) & 1) + ebits[v] = hb - 1 + extra[v] = d & ((1 << (hb - 1)) - 1) + return codes, ebits, extra + + +_LP_CODE, _LP_EBITS, _LP_EXTRA = _length_prefix_lut() + + +def _limited_lengths(freqs: np.ndarray, limit: int) -> np.ndarray: + """Optimal length-limited prefix code lengths via package-merge. + + Optimal codes over all-positive frequencies are Kraft-complete, which the + libwebp table builder requires (an under-full code is a decode error). + Alphabets here are <=280 so the O(limit * n log n) list form is plenty. + """ + lengths = np.zeros(freqs.size, np.uint8) + used = np.flatnonzero(freqs) + if used.size == 0: + return lengths + if used.size == 1: + lengths[used[0]] = 1 + return lengths + # Items are (weight, symbols-tuple); tuple compare breaks weight ties + # deterministically, so output is stable across runs. + leaves = sorted((int(freqs[s]), (int(s),)) for s in used) + current = list(leaves) + for _ in range(limit - 1): + packages = [ + (current[i][0] + current[i + 1][0], current[i][1] + current[i + 1][1]) + for i in range(0, len(current) - 1, 2) + ] + current = sorted(leaves + packages) + for _, syms in current[: 2 * (used.size - 1)]: + for s in syms: + lengths[s] += 1 + return lengths + + +def _reverse_bits(code: int, nbits: int) -> int: + r = 0 + for _ in range(nbits): + r = (r << 1) | (code & 1) + code >>= 1 + return r + + +def _canonical_rev_codes(lengths: np.ndarray) -> np.ndarray: + """Canonical (RFC 1951 style) codes, pre-reversed for the LSB-first stream. + + VP8L reads a prefix code most-significant-bit first while the byte stream + fills LSB-first, so codes are written reversed — reversing here lets the + emitters use the table directly. + """ + codes = np.zeros(lengths.size, np.uint64) + max_len = int(lengths.max()) + bl_count = np.bincount(lengths, minlength=max_len + 1) + next_code = [0] * (max_len + 1) + code = 0 + for length in range(1, max_len + 1): + code = (code + int(bl_count[length - 1])) << 1 + next_code[length] = code + for sym in range(lengths.size): + n = int(lengths[sym]) + if n: + codes[sym] = _reverse_bits(next_code[n], n) + next_code[n] += 1 + return codes + + +def _write_normal_code(put, lengths: np.ndarray, alphabet_size: int) -> None: + """Emit a prefix code via the code-length-code path. + + Code lengths go out as plain literals (no 16/17/18 repeats — the spec does + not require them and skipping them keeps this path simple); the max-symbol + field truncates the trailing zeros instead. + """ + put(0, 1) # not a simple code + last = int(np.flatnonzero(lengths)[-1]) + emitted = lengths[: last + 1] + cl_hist = np.bincount(emitted, minlength=19) + cl_used = np.flatnonzero(cl_hist) + cl_len = np.zeros(19, np.uint8) + cl_code = np.zeros(19, np.uint64) + if cl_used.size == 1: + # Single-symbol code-length code: declare length 1; the decoder builds + # a trivial 0-bit code, so the literal emissions below cost nothing. + cl_declared = np.zeros(19, np.uint8) + cl_declared[cl_used[0]] = 1 + else: + cl_declared = _limited_lengths(cl_hist, 7) # declared in 3 bits: <=7 + cl_len = cl_declared + cl_code = _canonical_rev_codes(cl_declared) + last_pos = max(i for i, s in enumerate(_CODE_LENGTH_ORDER) if cl_declared[s]) + num_cl = max(4, last_pos + 1) + put(num_cl - 4, 4) + for i in range(num_cl): + put(int(cl_declared[_CODE_LENGTH_ORDER[i]]), 3) + if last + 1 == alphabet_size: + put(0, 1) # no max-symbol field; every length is spelled out + else: + put(1, 1) + val = (last + 1) - 2 # >= 0: single-symbol codes never reach here + sel = 0 + while val >= (1 << (2 + 2 * sel)): + sel += 1 + put(sel, 3) + put(val, 2 + 2 * sel) + for length in emitted: + put(int(cl_code[length]), int(cl_len[length])) + + +def _write_prefix_code(put, hist: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Write one prefix-code header; return (bit length, reversed code) LUTs. + + Codes with <=2 used symbols that fit in 8 bits take the simple-code path; + everything else goes through the code-length code. A 1-symbol code decodes + as 0 bits per read on either path, so its emission LUT stays all-zero. + """ + emit_len = np.zeros(hist.size, np.uint8) + emit_code = np.zeros(hist.size, np.uint64) + used = np.flatnonzero(hist) + if used.size == 0: + # Unused alphabet (distance, when nothing repeats): cheapest wellformed + # code is the 1-symbol simple code for symbol 0. + put(1, 1) + put(0, 1) + put(0, 1) + put(0, 1) + return emit_len, emit_code + if used.size <= 2 and used[-1] <= 255: + put(1, 1) # simple code + put(int(used.size - 1), 1) # num_symbols - 1 + s0 = int(used[0]) + if s0 <= 1: + put(0, 1) + put(s0, 1) + else: + put(1, 1) + put(s0, 8) + if used.size == 2: + put(int(used[1]), 8) + # Canonical over lengths [1, 1]: smaller symbol gets code 0. + emit_len[used] = 1 + emit_code[used[1]] = 1 + return emit_len, emit_code + if used.size == 1: + # One symbol above 255 (a green length code) cannot ride the simple + # path; declare it through the normal path and emit 0 bits per use. + lengths = np.zeros(hist.size, np.uint8) + lengths[used[0]] = 1 + else: + lengths = _limited_lengths(hist, 15) + emit_len = lengths + emit_code = _canonical_rev_codes(lengths) + _write_normal_code(put, lengths, hist.size) + return emit_len, emit_code + + +def _pack_lsb(values: np.ndarray, nbits: np.ndarray) -> bytes: + """Pack (value, nbits) pairs into an LSB-first byte stream. + + Expands to one uint8 per bit and lets packbits fold them: one vectorized + pass per bit *position* (<= ~40) instead of a Python loop per symbol. + """ + ends = np.cumsum(nbits, dtype=np.int64) + starts = ends - nbits + bits = np.zeros(int(ends[-1]), np.uint8) + for k in range(int(nbits.max())): + m = nbits > k + bits[starts[m] + k] = (values[m] >> np.uint64(k)) & np.uint64(1) + return np.packbits(bits, bitorder="little").tobytes() + + +def encode(rgba: np.ndarray) -> bytes: + """Encode an `(h, w, 4)` uint8 RGBA image (or `(h, w, 3)`, treated as + opaque) as a lossless WebP. Alpha survives bit-exact.""" + if not isinstance(rgba, np.ndarray) or rgba.dtype != np.uint8: + raise ValueError("WebP image must be a uint8 numpy array") + if rgba.ndim != 3 or rgba.shape[2] not in (3, 4): + raise ValueError("WebP image must be (h, w, 4) RGBA or (h, w, 3) RGB") + h, w = int(rgba.shape[0]), int(rgba.shape[1]) + if not (1 <= w <= _MAX_DIM and 1 <= h <= _MAX_DIM): + raise ValueError(f"WebP dimensions must be 1..{_MAX_DIM}, got {w}x{h}") + if rgba.shape[2] == 3: + rgba = np.concatenate([rgba, np.full((h, w, 1), 255, np.uint8)], axis=2) + flat = np.ascontiguousarray(rgba).reshape(-1, 4) + alpha_used = bool((flat[:, 3] != 255).any()) + + # --- tokenize: one literal per run of identical pixels, the remainder as + # distance-1 backward references chunked to the 4096-pixel length cap. + keys = flat.view(np.uint32).ravel() + change = np.flatnonzero(keys[:-1] != keys[1:]) + starts = np.empty(change.size + 1, np.int64) + starts[0] = 0 + starts[1:] = change + 1 + seg_len = np.diff(np.append(starts, keys.size)) + rem = seg_len - 1 + kfull = rem // _MAX_RUN + tail = rem % _MAX_RUN + nref = kfull + (tail > 0) + n_ref = int(nref.sum()) + if n_ref: + seg_of = np.repeat(np.arange(starts.size), nref) + rank = np.arange(n_ref) - np.repeat(np.cumsum(nref) - nref, nref) + run = np.where(rank < kfull[seg_of], _MAX_RUN, tail[seg_of]) + ref_sym = (256 + _LP_CODE[run]).astype(np.int64) + else: + run = ref_sym = np.zeros(0, np.int64) + + lit = flat[starts] + g_hist = np.bincount(lit[:, 1], minlength=_GREEN_ALPHABET) + if n_ref: + g_hist += np.bincount(ref_sym, minlength=_GREEN_ALPHABET) + d_hist = np.zeros(_DIST_ALPHABET, np.int64) + # Distance 1 maps to neighbor code 2 (offset (1, 0)), whose prefix code is + # 1 with zero extra bits — the only distance symbol this encoder emits. + d_hist[1] = n_ref + + # --- header + the five prefix-code descriptions (spec order). + head_vals: list[int] = [] + head_bits: list[int] = [] + + def put(v: int, n: int) -> None: + head_vals.append(v) + head_bits.append(n) + + put(0x2F, 8) # VP8L signature + put(w - 1, 14) + put(h - 1, 14) + put(1 if alpha_used else 0, 1) + put(0, 3) # version + put(0, 1) # no transforms + put(0, 1) # no color cache + put(0, 1) # no meta prefix image: one code group for the whole image + g_len, g_code = _write_prefix_code(put, g_hist) + r_len, r_code = _write_prefix_code(put, np.bincount(lit[:, 0], minlength=256)) + b_len, b_code = _write_prefix_code(put, np.bincount(lit[:, 2], minlength=256)) + a_len, a_code = _write_prefix_code(put, np.bincount(lit[:, 3], minlength=256)) + d_len, d_code = _write_prefix_code(put, d_hist) + + # --- token stream as (value, nbits) entries: two per literal (green+red, + # blue+alpha packed pairwise, <=30 bits each) and one per reference + # (length code + extra bits + distance code, <=40 bits). + per_seg = 2 + nref + offsets = np.cumsum(per_seg) - per_seg + ev = np.zeros(int(per_seg.sum()), np.uint64) + eb = np.zeros(ev.size, np.uint8) + gi, ri, bi, ai = lit[:, 1], lit[:, 0], lit[:, 2], lit[:, 3] + ev[offsets] = g_code[gi] | (r_code[ri] << g_len[gi].astype(np.uint64)) + eb[offsets] = g_len[gi] + r_len[ri] + ev[offsets + 1] = b_code[bi] | (a_code[ai] << b_len[bi].astype(np.uint64)) + eb[offsets + 1] = b_len[bi] + a_len[ai] + if n_ref: + pos = offsets[seg_of] + 2 + rank + shift = g_len[ref_sym].astype(np.uint64) + ev[pos] = ( + g_code[ref_sym] + | (_LP_EXTRA[run].astype(np.uint64) << shift) + | (d_code[1] << (shift + _LP_EBITS[run])) + ) + eb[pos] = g_len[ref_sym] + _LP_EBITS[run] + d_len[1] + + payload = _pack_lsb( + np.concatenate([np.asarray(head_vals, np.uint64), ev]), + np.concatenate([np.asarray(head_bits, np.uint8), eb]), + ) + chunk = b"VP8L" + struct.pack(" ExportConfig: + """Describe export defaults as part of the chart (no I/O at build time). + + Args: + formats: Download formats, in menu order. The modebar shows the + client-safe subset (png/jpeg/webp/svg/csv); an empty list hides + the download menu entirely. Also the default format list for + batch export helpers. + filename: Download/file basename (no extension or path separators). + width: Default export width in pixels. + height: Default export height in pixels. + scale: Default device-pixel-ratio for raster export. + background: Default export background ("auto", a CSS color, or + "transparent"; JPEG rejects transparent at export time). + quality: Default JPEG/lossy-WebP quality (1-100). + """ + validated_formats: Optional[tuple[str, ...]] = None + if formats is not None: + if isinstance(formats, str): + raise ValueError("export_config formats must be a sequence of format names") + seen: list[str] = [] + for value in formats: + fmt = export._FORMAT_ALIASES.get(str(value).lower(), str(value).lower()) + if fmt not in _EXPORT_CONFIG_FORMATS: + raise ValueError( + f"export_config format must be one of {_EXPORT_CONFIG_FORMATS}, got {value!r}" + ) + if fmt in seen: + raise ValueError(f"export_config formats repeats {fmt!r}") + seen.append(fmt) + validated_formats = tuple(seen) + if filename is not None: + filename = _optional_string(filename, "export_config filename") or "" + if not _EXPORT_FILENAME_RE.fullmatch(filename): + raise ValueError( + "export_config filename must be a plain basename " + f"(letters/digits/dot/dash/underscore/space), got {filename!r}" + ) + if quality is not None and ( + isinstance(quality, bool) or not isinstance(quality, int) or not 1 <= quality <= 100 + ): + raise ValueError(f"export_config quality must be an integer in 1..100, got {quality!r}") + return ExportConfig( + formats=validated_formats, + filename=filename, + width=None if width is None else export._positive_pixel_count(width, "export width"), + height=None if height is None else export._positive_pixel_count(height, "export height"), + scale=None if scale is None else export._positive_finite_float(scale, "export scale"), + background=None if background is None else _export_background(background), + quality=quality, + ) + + +def _export_background(background: str) -> str: + if not isinstance(background, str) or not background.strip(): + raise ValueError(f"export_config background must be a CSS color string, got {background!r}") + value = background.strip() + if value != "auto" and not export._BACKGROUND_RE.fullmatch(value): + raise ValueError(f"export_config background is not a safe CSS color: {background!r}") + return value + + def theme( style: Optional[dict[str, StyleValue]] = None, *, @@ -2494,6 +2591,7 @@ def figure(self) -> Figure: tooltips = [c for c in self.children if isinstance(c, Tooltip)] colorbars = [c for c in self.children if isinstance(c, Colorbar)] modebars = [c for c in self.children if isinstance(c, Modebar)] + export_configs = [c for c in self.children if isinstance(c, ExportConfig)] themes = [c for c in self.children if isinstance(c, Theme)] interactions = [c for c in self.children if isinstance(c, Interaction)] legend_shows = [_strict_bool(c.show, "legend show") for c in legends] @@ -2505,6 +2603,7 @@ def figure(self) -> Figure: Tooltip, Colorbar, Modebar, + ExportConfig, Theme, Interaction, ) @@ -2512,7 +2611,7 @@ def figure(self) -> Figure: if unknown: raise TypeError( f"{self.kind}() children must be marks/annotations/axes/legend/tooltip/" - f"colorbar/modebar/theme/interaction_config, got " + f"colorbar/modebar/export_config/theme/interaction_config, got " f"{[type(c).__name__ for c in unknown]}" ) @@ -2652,6 +2751,28 @@ def figure(self) -> Figure: _apply_chrome_node(fig, "modebar", node.class_name, node.style) _apply_chrome_node(fig, "modebar_button", node.button_class_name, node.button_style) fig.show_modebar = node.show + if export_configs: + node = export_configs[-1] + # Re-validate: ``ExportConfig`` is a public dataclass as well as + # the `export_config()` return type, so direct construction must + # not put malformed options on the wire. + validated = export_config( + formats=node.formats, + filename=node.filename, + width=node.width, + height=node.height, + scale=node.scale, + background=node.background, + quality=node.quality, + ) + export_options: dict[str, Any] = {} + if validated.formats is not None: + export_options["formats"] = list(validated.formats) + for key in ("filename", "width", "height", "scale", "background", "quality"): + value = getattr(validated, key) + if value is not None: + export_options[key] = value + fig.export_options = export_options or None if tooltips: node = tooltips[-1] _apply_chrome_node(fig, "tooltip", node.class_name, node.style) @@ -2815,6 +2936,122 @@ def to_png( gl=gl, ) + def _export_defaults( + self, + fmt: str, + width: Optional[int], + height: Optional[int], + scale: Optional[float], + background: Optional[str], + quality: Optional[int], + ) -> dict[str, Any]: + """Fill omitted export options from the chart's `export_config`. + + Direct arguments always win. Declarative defaults degrade gracefully + where a format cannot honor them (config quality is dropped for + 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": + quality = config.get("quality") + if background is None: + background = config.get("background") + if background == "auto" or (background == "transparent" and fmt == "jpeg"): + background = None + return { + "width": width if width is not None else config.get("width"), + "height": height if height is not None else config.get("height"), + "scale": scale if scale is not None else config.get("scale", 2.0), + "background": background, + "quality": quality, + } + + def to_image( + self, + format: str = "png", + *, + width: Optional[int] = None, + height: Optional[int] = None, + scale: Optional[float] = None, + 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. + + 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.""" + return self.figure().to_image( + format, + engine=engine, + optimize=optimize, + custom_css=custom_css, + sandbox=sandbox, + gl=gl, + **self._export_defaults( + export._normalize_format(format), width, height, scale, background, quality + ), + ) + + def write_image( + self, + path: str | PathLike[str], + *, + format: Optional[str] = None, + width: Optional[int] = None, + height: Optional[int] = None, + scale: Optional[float] = None, + 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 (.png/.jpg/ + .jpeg/.webp/.svg/.pdf/.html). `export_config` defaults apply as in + `to_image`; explicit arguments override them.""" + fmt = ( + export._normalize_format(format, allow_html=True) + if format is not None + else export._infer_format(path) + ) + defaults = self._export_defaults(fmt, width, height, scale, background, quality) + if fmt == "html": + # HTML routing rejects raster-only options; forward the user's own + # arguments (not the declarative defaults) so that rejection + # applies to what was actually passed. + return self.figure().write_image( + path, + format=format, + width=width, + height=height, + scale=scale if scale is not None else 2.0, + background=background, + engine=engine, + quality=quality, + optimize=optimize, + custom_css=custom_css, + sandbox=sandbox, + gl=gl, + ) + return self.figure().write_image( + path, + format=format, + engine=engine, + optimize=optimize, + custom_css=custom_css, + sandbox=sandbox, + gl=gl, + **defaults, + ) + def memory_report(self) -> dict[str, Any]: """Byte-level accounting of the chart's data and cache buffers.""" return self.figure().memory_report() @@ -3412,6 +3649,62 @@ def to_png( gl=gl, ) + def to_image( + self, + format: str = "png", + *, + 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 of the grid (same format matrix as + `Chart.to_image`; the grid's geometry is fixed by its panels).""" + return self.figure().to_image( + format, + 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, + 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 extension-inferred file export of the grid (see + `FacetGrid.write_image`).""" + return self.figure().write_image( + path, + format=format, + 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]: """Byte-level accounting of every panel's data and cache buffers.""" return self.figure().memory_report() diff --git a/python/xy/export.py b/python/xy/export.py index 6f55e654..fcf73c0f 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -18,20 +18,24 @@ from enum import StrEnum from os import PathLike from pathlib import Path -from typing import TYPE_CHECKING, Optional, SupportsFloat, SupportsIndex, cast +from typing import TYPE_CHECKING, Any, Optional, SupportsFloat, SupportsIndex, cast if TYPE_CHECKING: from ._figure import Figure class Engine(StrEnum): - """PNG export engine. + """Static-export engine. ``default`` is XY's fast, deterministic native renderer. ``chromium`` renders the standalone chart with an automatically discovered installed - Chromium-family browser for browser CSS/WebGL fidelity. + Chromium-family browser for browser CSS/WebGL fidelity. ``auto`` picks + deterministically per format: native for every natively supported format + (all of them — png/jpeg/webp/svg/pdf), chromium only when the request + needs a real CSS engine (``custom_css``). """ + auto = "auto" default = "default" chromium = "chromium" @@ -192,6 +196,27 @@ def _base64_chunks(blob: bytes) -> list[str]: ) +def _atomic_write_bytes(path: str | PathLike[str], data: bytes) -> None: + """Write bytes through a same-directory temp file, then replace atomically.""" + target = Path(path) + fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp") + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "wb") as f: + fd = -1 + f.write(data) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, target) + except Exception: + if fd != -1: + with suppress(OSError): + os.close(fd) + with suppress(FileNotFoundError): + tmp_path.unlink() + raise + + def _atomic_write_text(path: str | PathLike[str], text: str) -> None: """Write text through a same-directory temp file, then replace atomically.""" target = Path(path) @@ -506,61 +531,119 @@ def html_to_png( def write_images( - figs: "list[Figure]", - paths: list[str | PathLike[str]], + figs: Optional[list[Any]] = None, + paths: Optional[list[str | PathLike[str]]] = None, *, + figures: Optional[list[Any]] = None, + files: Optional[list[str | PathLike[str]]] = None, + formats: Optional[str | list[str]] = None, + width: Optional[int] = None, + height: Optional[int] = None, scale: float = 2.0, - engine: Engine = Engine.default, + background: Optional[str] = None, + engine: Engine | str = Engine.auto, + quality: Optional[int] = None, + optimize: bool = False, custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", ) -> list[bytes]: - """Export many figures to PNGs through ONE browser session. - - `html_to_png` launches a fresh browser per image, so a loop over figures - pays ~1-2 s of browser startup each time — the classic batch-export trap. - `engine=Engine.chromium` keeps one installed Chromium-family browser alive - (CDP; `_chromium.py`) and renders every figure as a tab navigation + - screenshot, amortizing startup across the list. The default - `engine=Engine.default` simply loops the millisecond-fast, browser-free - native rasterizer. - - Figures with fluid ("100%") sizes fall back to the same explicit export - dimensions as `to_png`. `custom_css` is available only with Chromium, - where it is injected into every standalone document.""" + """Export many figures through ONE amortized pipeline (mixed formats OK). + + Each file's format is inferred from its extension (`formats=` overrides: + one string for all files or one per file) across the full unified matrix — + PNG/JPEG/WebP/SVG/PDF plus standalone HTML. Native exports simply loop the + millisecond-fast, browser-free renderers; every Chromium-resolved export + in the batch shares a single persistent browser session (CDP; + `_chromium.py`) instead of paying ~1-2 s of startup per figure — the + classic batch-export trap. `figures=`/`files=` are keyword aliases for + the positional pair, and composed charts (anything with a `.figure()`) + are accepted directly. Writes are atomic per file; on error, files + already exported remain. Other options match `to_image`; `width`/ + `height`/`background`/`quality` apply to every file (quality is ignored + by non-lossy formats rather than rejected, so mixed PNG+JPEG batches + stay ergonomic).""" + if figures is not None: + if figs is not None: + raise ValueError("pass figs positionally or figures=, not both") + figs = figures + if files is not None: + if paths is not None: + raise ValueError("pass paths positionally or files=, not both") + 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] if len(figs) != len(paths): raise ValueError(f"write_images got {len(figs)} figures but {len(paths)} paths") - scale = _positive_finite_float(scale, "PNG scale") - sandbox = _bool_option(sandbox, "PNG sandbox") + if isinstance(formats, str): + fmts = [_normalize_format(formats, allow_html=True)] * len(paths) + elif formats is not None: + if len(formats) != len(paths): + raise ValueError(f"write_images got {len(formats)} formats but {len(paths)} paths") + fmts = [_normalize_format(f, allow_html=True) for f in formats] + else: + fmts = [_infer_format(p) for p in paths] + scale = _positive_finite_float(scale, "export scale") + optimize = _bool_option(optimize, "export optimize") + sandbox = _bool_option(sandbox, "export sandbox") gl = _gl_option(gl) - resolved_engine = _png_engine(engine) - if resolved_engine == "native": - if custom_css is not None: - raise ValueError("custom_css requires engine=Engine.chromium") - return [ - to_png(fig, path, scale=scale, engine=Engine.default) - for fig, path in zip(figs, paths, strict=True) - ] - _custom_css_block(custom_css) - exe = find_browser() - if exe is None: - raise RuntimeError( - "batch browser PNG export needs a supported Chrome/Chromium/Edge " - f"executable and none was found. Set ${_BROWSER_ENV} to select one." + + # Resolve the whole plan before any I/O so bad arguments fail the batch + # up front instead of after a partial export. + plan: list[tuple["Figure", str | PathLike[str], str, str, Optional[int], Optional[str]]] = [] + for fig, path, fmt in zip(figs, paths, fmts, strict=True): + if fmt == "html": + plan.append((fig, path, fmt, "html", None, None)) + continue + resolved = _resolve_image_engine(engine, fmt, custom_css) + file_quality = ( + _validated_quality(quality, fmt, resolved) if fmt in _LOSSY_QUALITY_FORMATS else None ) - from ._chromium import ChromiumSession + try: + file_background = _validated_background(background, fmt) + except ValueError as exc: + raise ValueError(f"{path}: {exc}") from None + plan.append((fig, path, fmt, resolved, file_quality, file_background)) out: list[bytes] = [] - with ChromiumSession(exe, gl=gl, sandbox=sandbox) as session: - for fig, path in zip(figs, paths, strict=True): - w = _positive_pixel_count(fig.width if isinstance(fig.width, int) else 800, "PNG width") - h = _positive_pixel_count( - fig.height if isinstance(fig.height, int) else 500, "PNG height" - ) - data = session.render_png(to_html(fig, custom_css=custom_css), w, h, scale=scale) - with open(path, "wb") as f: - f.write(data) + session: Optional[Any] = None + try: + for fig, path, fmt, resolved, file_quality, file_background in plan: + if resolved == "html": + out.append(to_html(fig, path, custom_css=custom_css).encode("utf-8")) + continue + w, h = _export_dimensions(fig, width, height) + if resolved == "native": + data = _native_image( + fig, + fmt, + width=w, + height=h, + scale=scale, + background=file_background, + quality=file_quality, + optimize=optimize, + ) + else: + if session is None: + session = _browser_session(gl=gl, sandbox=sandbox) + data = _browser_image( + session, + fig, + fmt, + width=w, + height=h, + scale=scale, + background=file_background, + quality=file_quality, + custom_css=custom_css, + ) + _atomic_write_bytes(path, data) out.append(data) + finally: + if session is not None: + session.close() return out @@ -623,3 +706,383 @@ def to_png( with open(path, "wb") as f: f.write(data) return data + + +# --------------------------------------------------------------------------- +# Unified format-selecting export (ENG-10447): one API across PNG/JPEG/WebP/ +# SVG/PDF (+ HTML routing in `write_image`), with deterministic per-format +# engine selection and a shared background policy. The per-format methods +# above (`to_png`, `to_html`, `_svg.to_svg`) remain the compatibility surface. +# --------------------------------------------------------------------------- + +# Formats `to_image` can produce. HTML is deliberately not an image format — +# `write_image("chart.html")` routes to `to_html`, and `to_image("html")` +# points there — matching the issue's "interactive/data, not image" split. +IMAGE_FORMATS = ("png", "jpeg", "webp", "svg", "pdf") +_FORMAT_ALIASES = {"jpg": "jpeg"} +_LOSSY_QUALITY_FORMATS = ("jpeg", "webp") +_DEFAULT_QUALITY = 90 + +# Conservative CSS shape for export backgrounds: enough for every +# color syntax (named, hex, rgb[a]/hsl[a]/oklch) while excluding anything able +# to escape a style declaration it is interpolated into ({}, ;, quotes, <). +_BACKGROUND_RE = _re.compile(r"^[A-Za-z0-9#().,%/\s+-]+$") + + +def _normalize_format(value: object, *, allow_html: bool = False) -> str: + if not isinstance(value, str): + raise ValueError(f"format must be one of {IMAGE_FORMATS}, got {value!r}") + fmt = _FORMAT_ALIASES.get(value.lower().lstrip("."), value.lower().lstrip(".")) + if fmt == "html": + if allow_html: + return fmt + raise ValueError( + "html is not an image format — use to_html() (or write_image('chart.html'), " + "which routes there)" + ) + if fmt not in IMAGE_FORMATS: + raise ValueError(f"format must be one of {IMAGE_FORMATS} (or 'jpg'), got {value!r}") + return fmt + + +def _infer_format(path: str | PathLike[str]) -> str: + suffix = Path(path).suffix.lower().lstrip(".") + if suffix in ("html", "htm"): + return "html" + if not suffix: + raise ValueError( + f"cannot infer an export format from {str(path)!r}: add a file extension " + f"({', '.join('.' + f for f in (*IMAGE_FORMATS, 'jpg', 'html'))}) " + "or pass format= explicitly" + ) + try: + return _normalize_format(suffix, allow_html=True) + except ValueError: + raise ValueError( + f"cannot infer an export format from {str(path)!r}: unknown extension " + f"{'.' + suffix!r}. Supported: " + f"{', '.join('.' + f for f in (*IMAGE_FORMATS, 'jpg', 'html'))}, " + "or pass format= explicitly" + ) from None + + +def _resolve_image_engine(engine: object, fmt: str, custom_css: Optional[str]) -> str: + """Deterministic engine selection: -> "native" | "browser". + + auto => native for every format (they are all natively supported; + browser-free is the architectural fast path), except that `custom_css` + forces chromium since utility-class CSS needs a real CSS engine. SVG is + native-only: a screenshotting browser cannot emit vector SVG. + """ + if engine in (Engine.auto, "auto", None): + resolved = "browser" if custom_css is not None else "native" + else: + resolved = _png_engine(engine, fmt.upper()) + if resolved == "browser" and fmt == "svg": + raise ValueError( + "SVG export is native-only (a browser screenshot cannot produce vector " + "SVG); drop engine=Engine.chromium and custom_css" + ) + if resolved == "native" and custom_css is not None: + raise ValueError("custom_css requires engine=Engine.chromium") + return resolved + + +def _validated_quality(quality: object, fmt: str, resolved_engine: str) -> Optional[int]: + if quality is None: + return _DEFAULT_QUALITY if fmt in _LOSSY_QUALITY_FORMATS else None + if fmt not in _LOSSY_QUALITY_FORMATS: + raise ValueError(f"quality applies to {'/'.join(_LOSSY_QUALITY_FORMATS)}, not {fmt}") + if isinstance(quality, bool) or not isinstance(quality, numbers.Integral): + raise ValueError(f"quality must be an integer in 1..100, got {quality!r}") + out = int(quality) + if not 1 <= out <= 100: + raise ValueError(f"quality must be an integer in 1..100, got {out}") + if fmt == "webp" and resolved_engine == "native": + raise ValueError( + "native WebP export is always lossless (deterministic policy); " + "drop quality=, or use engine=Engine.chromium for lossy WebP" + ) + return out + + +def _validated_background(background: object, fmt: str) -> Optional[str]: + """Shared background policy (documented in docs/export.md). + + None ("auto") keeps each renderer's default backdrop: opaque white for + raster/browser output, transparent for SVG/PDF vector output. A CSS color + paints one canvas backdrop consistently across formats. "transparent" is + valid everywhere alpha exists — JPEG has no alpha channel, so it is + rejected there rather than silently flattened.""" + if background in (None, "auto"): + return None + if not isinstance(background, str) or not background.strip(): + raise ValueError(f"background must be a CSS color or 'transparent', got {background!r}") + value = background.strip() + if not _BACKGROUND_RE.fullmatch(value): + raise ValueError(f"background is not a safe CSS color: {background!r}") + if value.lower() in ("transparent", "none"): + if fmt == "jpeg": + raise ValueError( + "JPEG has no alpha channel; pass an opaque background= (default white) " + "or export PNG/WebP for transparency" + ) + return "transparent" + return value + + +def _export_dimensions( + fig: "Figure", width: Optional[int], height: Optional[int] +) -> tuple[int, int]: + w = _positive_pixel_count( + width if width is not None else (fig.width if isinstance(fig.width, int) else 800), + "export width", + ) + h = _positive_pixel_count( + height if height is not None else (fig.height if isinstance(fig.height, int) else 500), + "export height", + ) + return w, h + + +def _flatten_alpha(rgba: "Any") -> "Any": + """Composite leftover alpha over white — the JPEG determinism backstop.""" + import numpy as np + + alpha = rgba[..., 3:4].astype(np.uint16) + rgb = (rgba[..., :3].astype(np.uint16) * alpha + 255 * (255 - alpha) + 127) // 255 + out = np.empty_like(rgba) + out[..., :3] = rgb.astype(np.uint8) + out[..., 3] = 255 + return out + + +def _browser_html(fig: "Figure", custom_css: Optional[str], background: Optional[str]) -> str: + """Standalone document for browser capture, with the export background + override injected as page CSS (validated by `_validated_background`).""" + css = "" + if background is not None: + css = f"html,body{{background:{background} !important;}}" + if custom_css: + css += custom_css + return to_html(fig, custom_css=css or None) + + +def _browser_session(*, gl: str, sandbox: bool) -> "Any": + """One launched ChromiumSession, mirroring `html_to_png`'s sandbox retry.""" + exe = find_browser() + if exe is None: + raise RuntimeError( + "browser image export needs a supported Chrome/Chromium/Edge executable " + f"and none was found. Set ${_BROWSER_ENV} to its executable path " + "or install a supported browser. Native export (engine=Engine.default) " + "and HTML export need nothing extra." + ) + from ._chromium import ChromiumError, ChromiumSession + + try: + return ChromiumSession(exe, gl=gl, sandbox=sandbox) + except ChromiumError: + if not sandbox: + raise + return ChromiumSession(exe, gl=gl, sandbox=False) + + +def _native_image( + fig: "Figure", + fmt: str, + *, + width: int, + height: int, + scale: float, + background: Optional[str], + quality: Optional[int], + optimize: bool, +) -> bytes: + from . import _raster + + if fmt == "png": + return _raster.to_png( + fig, + None, + width=width, + height=height, + scale=scale, + fast=not optimize, + background=background, + ) + if fmt == "svg": + from . import _svg + + return _svg.to_svg(fig, None, width=width, height=height, background=background).encode( + "utf-8" + ) + if fmt == "pdf": + from . import _pdf, _svg + + svg = _svg.to_svg(fig, None, width=width, height=height, background=background) + return _pdf.svg_to_pdf(svg) + rgba = _raster.to_rgba(fig, width=width, height=height, scale=scale, background=background) + if fmt == "jpeg": + from . import _jpeg + + return _jpeg.encode(_flatten_alpha(rgba), quality=quality or _DEFAULT_QUALITY) + if fmt == "webp": + from . import _webp + + return _webp.encode(rgba) + raise AssertionError(f"unreachable native format {fmt!r}") + + +def _browser_image( + session: "Any", + fig: "Figure", + fmt: str, + *, + width: int, + height: int, + scale: float, + background: Optional[str], + quality: Optional[int], + custom_css: Optional[str], +) -> bytes: + doc = _browser_html(fig, custom_css, background) + if fmt == "pdf": + return session.render_pdf(doc, width, height) + return session.render_image( + doc, + width, + height, + format=fmt, + scale=scale, + quality=quality, + transparent=background == "transparent", + ) + + +def to_image( + fig: "Figure", + format: str = "png", + *, + width: Optional[int] = None, + height: Optional[int] = None, + scale: float = 2.0, + background: Optional[str] = None, + engine: Engine | str = Engine.auto, + quality: Optional[int] = None, + optimize: bool = False, + custom_css: Optional[str] = None, + sandbox: bool = True, + gl: str = "software", +) -> bytes: + """Render `fig` to image bytes in the requested `format`. + + Formats: "png", "jpeg"/"jpg", "webp", "svg", "pdf" (SVG returns UTF-8 + bytes; for interactive HTML use `to_html`). `engine=Engine.auto` (default) + is deterministic: every format uses the browser-free native path unless + `custom_css` forces Chromium; `engine=Engine.chromium` renders the + standalone HTML in an installed browser for CSS/WebGL fidelity (all + formats except SVG, which is native-only). Native WebP is lossless; + `quality` (1-100, default 90) applies to JPEG and to Chromium's lossy + WebP. `background` is "auto" per-format, a CSS color, or "transparent" + (rejected for JPEG). `scale` is the device-pixel-ratio for raster output + and is ignored by the vector formats (SVG/PDF are resolution-independent). + PDF keeps text/axes/marks as vectors; density and heatmap layers embed as + bounded rasters (the documented hybrid-vector policy).""" + fmt = _normalize_format(format) + resolved_engine = _resolve_image_engine(engine, fmt, custom_css) + quality = _validated_quality(quality, fmt, resolved_engine) + background = _validated_background(background, fmt) + w, h = _export_dimensions(fig, width, height) + scale = _positive_finite_float(scale, "export scale") + optimize = _bool_option(optimize, "export optimize") + sandbox = _bool_option(sandbox, "export sandbox") + gl = _gl_option(gl) + if resolved_engine == "native": + return _native_image( + fig, + fmt, + width=w, + height=h, + scale=scale, + background=background, + quality=quality, + optimize=optimize, + ) + with _browser_session(gl=gl, sandbox=sandbox) as session: + return _browser_image( + session, + fig, + fmt, + width=w, + height=h, + scale=scale, + background=background, + quality=quality, + custom_css=custom_css, + ) + + +def write_image( + fig: "Figure", + 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: Engine | str = Engine.auto, + quality: Optional[int] = None, + optimize: bool = False, + custom_css: Optional[str] = None, + sandbox: bool = True, + gl: str = "software", +) -> bytes: + """Export `fig` to `path`, inferring the format from the extension. + + `format=` overrides inference (required when the path has no/unknown + extension). Writes are atomic: a same-directory temp file is fsynced then + renamed over the target, so readers never observe a partial image. + ".html" routes to `to_html` (interactive export; raster-only options are + rejected there). Returns the written bytes. All other options match + `to_image`.""" + fmt = _normalize_format(format, allow_html=True) if format is not None else _infer_format(path) + if fmt == "html": + rejected = [ + name + for name, value, default in ( + ("width", width, None), + ("height", height, None), + ("scale", scale, 2.0), + ("background", background, None), + ("quality", quality, None), + ("optimize", optimize, False), + ) + if value != default + ] + if engine not in (Engine.auto, "auto"): + rejected.append("engine") + if rejected: + raise ValueError( + f"HTML export is interactive and ignores {', '.join(sorted(rejected))}; " + "drop them or export an image format" + ) + doc = to_html(fig, path, custom_css=custom_css) + return doc.encode("utf-8") + data = to_image( + fig, + fmt, + width=width, + height=height, + scale=scale, + background=background, + engine=engine, + quality=quality, + optimize=optimize, + custom_css=custom_css, + sandbox=sandbox, + gl=gl, + ) + _atomic_write_bytes(path, data) + return data diff --git a/python/xy/facets.py b/python/xy/facets.py index c86df2e8..763170f6 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -226,7 +226,12 @@ def to_html( export._atomic_write_text(path, doc) return doc - def to_svg(self, path: Optional[str | PathLike[str]] = None) -> str: + def to_svg( + self, + path: Optional[str | PathLike[str]] = None, + *, + background: Optional[str] = None, + ) -> str: """Compose panel SVGs into one nested-SVG document.""" from . import _svg @@ -245,16 +250,53 @@ def to_svg(self, path: Optional[str | PathLike[str]] = None) -> str: f'{inner}' ) + backdrop = ( + f'' + if background and background not in ("transparent", "none") + else "" + ) title = ( f'{export._html.escape(self.title)}' if self.title else "" ) - doc = f'{title}{"".join(body)}' + doc = f'{backdrop}{title}{"".join(body)}' if path is not None: export._atomic_write_text(path, doc) return doc + def _compose_rgba(self, scale: float, background: Optional[str] = None) -> np.ndarray: + """Native panel renders composed into one grid RGBA canvas. + + The shared pixel source for the raster formats. No grid title strip: + the native rasterizer has no free-standing text path, so the composed + canvas is exactly panels + gaps.""" + from . import _raster + + if scale <= 0 or not np.isfinite(scale): + raise ValueError("facet export scale must be finite and positive") + panel_images: list[np.ndarray] = [] + for fig in self.figures: + spec, blob, borrowed = _raster._export_payload(fig, None, None, background) + image = render_raster(spec, blob, scale=scale, borrowed=borrowed) + if isinstance(image, bytes): + raise RuntimeError("facet rasterizer unexpectedly returned encoded PNG bytes") + panel_images.append(image) + panel_h, panel_w = panel_images[0].shape[:2] + width = int(round(self.width * scale)) + height = int(round(self.grid_height * scale)) + gap_fill = (255, 255, 255, 255) if background is None else _raster._parse_color(background) + canvas = np.empty((height, width, 4), dtype=np.uint8) + canvas[:] = np.asarray(gap_fill, dtype=np.uint8) + for i, image in enumerate(panel_images): + row, col = divmod(i, self.cols) + x = int(round(col * (self.panel_width + self.gap) * scale)) + y = int(round(row * (self.panel_height + self.gap) * scale)) + h, w = min(panel_h, height - y), min(panel_w, width - x) + if h > 0 and w > 0: + canvas[y : y + h, x : x + w] = image[:h, :w] + return canvas + def to_png( self, path: Optional[str | PathLike[str]] = None, @@ -287,34 +329,13 @@ def to_png( elif resolved_engine == "native": if custom_css is not None: raise ValueError("custom_css requires engine=Engine.chromium") - if scale <= 0 or not np.isfinite(scale): - raise ValueError("facet PNG scale must be finite and positive") - panel_images: list[np.ndarray] = [] - for fig in self.figures: - spec, blob, borrowed = fig._build_raster_payload() - image = render_raster(spec, blob, scale=scale, borrowed=borrowed) - if isinstance(image, bytes): - raise RuntimeError("facet rasterizer unexpectedly returned encoded PNG bytes") - panel_images.append(image) - panel_h, panel_w = panel_images[0].shape[:2] - width = int(round(self.width * scale)) - # No grid title strip: the native rasterizer has no text path, so - # the composed canvas is exactly panels + gaps. - height = int(round(self.grid_height * scale)) - canvas = np.full((height, width, 4), 255, dtype=np.uint8) - for i, image in enumerate(panel_images): - row, col = divmod(i, self.cols) - x = int(round(col * (self.panel_width + self.gap) * scale)) - y = int(round(row * (self.panel_height + self.gap) * scale)) - h, w = min(panel_h, height - y), min(panel_w, width - x) - if h > 0 and w > 0: - canvas[y : y + h, x : x + w] = image[:h, :w] + canvas = self._compose_rgba(scale) data = ( encode_png(canvas) if optimize else png_truecolor( - width, - height, + canvas.shape[1], + canvas.shape[0], np.ascontiguousarray(canvas).tobytes(), compression_level=1, ) @@ -325,6 +346,115 @@ def to_png( Path(path).write_bytes(data) return data + def to_image( + self, + format: str = "png", + *, + 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 of the composed grid (same matrix as single + charts): PNG/JPEG/WebP/SVG/PDF bytes. + + The grid's pixel geometry is fixed by its panels, so there are no + width/height overrides here; `scale` still multiplies raster density. + Native raster output composes the browser-free panel renders (no grid + title strip — the native rasterizer has no free-standing text path); + SVG/PDF compose the vector panels, title included. Engine, quality, + and background policies match `export.to_image`.""" + fmt = export._normalize_format(format) + resolved_engine = export._resolve_image_engine(engine, fmt, custom_css) + quality = export._validated_quality(quality, fmt, resolved_engine) + background = export._validated_background(background, fmt) + scale = export._positive_finite_float(scale, "export scale") + optimize = export._bool_option(optimize, "export optimize") + sandbox = export._bool_option(sandbox, "export sandbox") + gl = export._gl_option(gl) + if resolved_engine == "native": + if fmt == "svg": + return self.to_svg(background=background).encode("utf-8") + if fmt == "pdf": + from . import _pdf + + return _pdf.svg_to_pdf(self.to_svg(background=background)) + if fmt == "png": + if background is None: + return self.to_png(scale=scale, optimize=optimize) + canvas = self._compose_rgba(scale, background) + if optimize: + return encode_png(canvas) + return png_truecolor( + canvas.shape[1], + canvas.shape[0], + np.ascontiguousarray(canvas).tobytes(), + compression_level=1, + ) + canvas = self._compose_rgba(scale, background) + if fmt == "jpeg": + from . import _jpeg + + return _jpeg.encode(export._flatten_alpha(canvas), quality=quality or 90) + from . import _webp + + return _webp.encode(canvas) + doc = self.to_html(custom_css=custom_css) + total_h = self.grid_height + self._title_height + with export._browser_session(gl=gl, sandbox=sandbox) as session: + if fmt == "pdf": + return session.render_pdf(doc, self.width, total_h) + return session.render_image( + doc, + self.width, + total_h, + format=fmt, + scale=scale, + quality=quality, + transparent=background == "transparent", + ) + + def write_image( + self, + path: str | PathLike[str], + *, + format: Optional[str] = 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; ".html" routes + to `to_html`. Options match `to_image`.""" + fmt = ( + export._normalize_format(format, allow_html=True) + if format is not None + else export._infer_format(path) + ) + if fmt == "html": + return self.to_html(path, custom_css=custom_css).encode("utf-8") + data = self.to_image( + fmt, + scale=scale, + background=background, + engine=engine, + quality=quality, + optimize=optimize, + custom_css=custom_css, + sandbox=sandbox, + gl=gl, + ) + export._atomic_write_bytes(path, data) + return data + def widget(self) -> list[Any]: """Live notebook widgets, one per facet panel.""" from .widget import FigureWidget diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 2fef1684..1c23ef08 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -6707,9 +6707,20 @@ exportMenu.appendChild(button); exportMenuItems.push(button); return button; }; -mkExportItem("png", "Export PNG", () => this._exportPng()); -mkExportItem("svg", "Export SVG", () => this._exportSvg()); -mkExportItem("csv", "Export CSV", () => this._exportCsv()); +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); if (show) { @@ -6776,7 +6787,7 @@ selectMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px` selectMenu.style.visibility = "visible"; }; setExportMenuOpen = (open, restoreFocus = false) => { -const show = Boolean(open); +const show = Boolean(open) && exportMenuItems.length > 0; if (show) { setZoomMenuOpen(false); setSelectMenuOpen(false); @@ -7103,7 +7114,13 @@ const y0 = yReversed ? yhi : ylo; const y1 = yReversed ? ylo : yhi; this._setView({ x0, x1, y0, y1 }, { animate }); }, +_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() @@ -7193,26 +7210,50 @@ this._exportFilename("svg") ); }, _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"); +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")); +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")); @@ -7321,6 +7362,13 @@ return svg(''); case "png": return svg('' + ''); +case "jpeg": +return svg('' + +''); +case "webp": +return svg('' + +'' + +''); case "svg": return svg('' + ''); diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 1db67384..6972f96c 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -6708,9 +6708,20 @@ exportMenu.appendChild(button); exportMenuItems.push(button); return button; }; -mkExportItem("png", "Export PNG", () => this._exportPng()); -mkExportItem("svg", "Export SVG", () => this._exportSvg()); -mkExportItem("csv", "Export CSV", () => this._exportCsv()); +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); if (show) { @@ -6777,7 +6788,7 @@ selectMenu.style.top = `${Math.max(-rootTop, Math.min(maxTop, preferredTop))}px` selectMenu.style.visibility = "visible"; }; setExportMenuOpen = (open, restoreFocus = false) => { -const show = Boolean(open); +const show = Boolean(open) && exportMenuItems.length > 0; if (show) { setZoomMenuOpen(false); setSelectMenuOpen(false); @@ -7104,7 +7115,13 @@ const y0 = yReversed ? yhi : ylo; const y1 = yReversed ? ylo : yhi; this._setView({ x0, x1, y0, y1 }, { animate }); }, +_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() @@ -7194,26 +7211,50 @@ this._exportFilename("svg") ); }, _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"); +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")); +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")); @@ -7322,6 +7363,13 @@ return svg(''); case "png": return svg('' + ''); +case "jpeg": +return svg('' + +''); +case "webp": +return svg('' + +'' + +''); case "svg": return svg('' + ''); diff --git a/tests/test_batch_export.py b/tests/test_batch_export.py index 81fe0cf4..0e682363 100644 --- a/tests/test_batch_export.py +++ b/tests/test_batch_export.py @@ -41,6 +41,20 @@ def test_write_images_rejects_bad_engine_and_gl(tmp_path): with pytest.raises(ValueError, match="gl"): export.write_images([_fig(1)], [tmp_path / "x.png"], gl="metal") with pytest.raises(ValueError, match=r"custom_css requires engine=Engine.chromium"): + export.write_images( + [_fig(1)], + [tmp_path / "x.png"], + engine=export.Engine.default, + custom_css=".xy { color: red; }", + ) + + +def test_write_images_auto_engine_routes_custom_css_to_browser(tmp_path, monkeypatch): + # Engine.auto is deterministic: custom_css needs a real CSS engine, so the + # batch resolves to the browser path (and reports the dependency clearly + # when no browser is installed) instead of rejecting the argument. + monkeypatch.setattr(export, "find_browser", lambda explicit=None: None) + with pytest.raises(RuntimeError, match="browser image export"): export.write_images( [_fig(1)], [tmp_path / "x.png"], @@ -52,7 +66,7 @@ def test_write_images_chromium_engine_is_deprecated_alias(tmp_path, monkeypatch) monkeypatch.setattr(export, "find_browser", lambda explicit=None: None) with ( pytest.warns(DeprecationWarning, match="string export engines"), - pytest.raises(RuntimeError, match="browser PNG export"), + pytest.raises(RuntimeError, match="browser image export"), ): export.write_images( [_fig(1)], @@ -70,14 +84,11 @@ class FakeSession: def __init__(self, *_args, **_kwargs): pass - def __enter__(self): - return self - - def __exit__(self, *_args): - return None + def close(self): + pass - def render_png(self, html, _width, _height, *, scale): - seen.append((html, scale)) + def render_image(self, html, _width, _height, *, format, scale, quality, transparent): + seen.append((html, format, scale, quality, transparent)) return b"\x89PNG\r\n\x1a\nbatch" monkeypatch.setattr(export, "find_browser", lambda explicit=None: "/fake/chrome") @@ -94,4 +105,5 @@ def render_png(self, html, _width, _height, *, scale): assert result == [path.read_bytes()] assert f"" in seen[0][0] - assert seen[0][1] == 2.0 + assert seen[0][1] == "png" + assert seen[0][2] == 2.0 diff --git a/tests/test_image_export.py b/tests/test_image_export.py new file mode 100644 index 00000000..0723d7ae --- /dev/null +++ b/tests/test_image_export.py @@ -0,0 +1,398 @@ +"""Unified export API (ENG-10447): `to_image`/`write_image`/`write_images` +format matrix, extension inference, deterministic engine selection, the shared +background policy, declarative `export_config` defaults, and facet parity. + +Native formats are exercised for real (no browser); the Chromium branch is +pinned through a fake CDP session, matching the repo convention that real +browser paths are validated by scripts/*smoke*. +""" + +from __future__ import annotations + +import io +import re +import struct +import zlib + +import numpy as np +import pytest + +import xy +from xy import export +from xy._figure import Figure + + +def _fig(width: int = 300, height: int = 200) -> Figure: + rng = np.random.default_rng(11) + return Figure(width=width, height=height, title="t").scatter( + rng.uniform(0, 1, 200), rng.uniform(0, 1, 200) + ) + + +def _pil(): + return pytest.importorskip("PIL.Image") + + +def _decode(data: bytes): + image = _pil().open(io.BytesIO(data)) + image.load() + return image + + +# -- format selection ------------------------------------------------------- + + +def test_format_normalization_and_aliases(): + assert export._normalize_format("PNG") == "png" + assert export._normalize_format("jpg") == "jpeg" + assert export._normalize_format(".webp") == "webp" + with pytest.raises(ValueError, match="format must be one of"): + export._normalize_format("tiff") + with pytest.raises(ValueError, match="to_html"): + export._normalize_format("html") + + +def test_extension_inference(): + assert export._infer_format("a/b/chart.JPG") == "jpeg" + assert export._infer_format("chart.pdf") == "pdf" + assert export._infer_format("chart.htm") == "html" + with pytest.raises(ValueError, match="add a file extension"): + export._infer_format("chart") + with pytest.raises(ValueError, match="unknown extension"): + export._infer_format("chart.tiff") + + +def test_to_image_png_matches_to_png(): + fig = _fig() + assert export.to_image(fig, "png") == export.to_png(fig) + + +def test_every_image_format_produces_its_magic_bytes(): + fig = _fig() + outputs = {fmt: export.to_image(fig, fmt) for fmt in export.IMAGE_FORMATS} + assert outputs["png"][:8] == b"\x89PNG\r\n\x1a\n" + assert outputs["jpeg"][:3] == b"\xff\xd8\xff" + assert outputs["webp"][:4] == b"RIFF" and outputs["webp"][8:12] == b"WEBP" + assert outputs["svg"][:5] == b" 240 and corner[1] < 15 and corner[2] < 15 + + +def test_transparent_background_where_alpha_exists(): + fig = _fig() + png = np.asarray(_decode(export.to_image(fig, "png", background="transparent")).convert("RGBA")) + webp = np.asarray( + _decode(export.to_image(fig, "webp", background="transparent")).convert("RGBA") + ) + assert png[0, 0, 3] == 0 + assert webp[0, 0, 3] == 0 + svg = export.to_image(fig, "svg", background="transparent") + assert b"transparent" not in svg # no backdrop rect at all + with pytest.raises(ValueError, match="JPEG has no alpha channel"): + export.to_image(fig, "jpeg", background="transparent") + + +def test_svg_background_paints_one_backdrop_rect(): + svg = export.to_image(_fig(), "svg", background="#112233").decode() + assert re.search(r'', svg) + + +def test_background_rejects_unsafe_strings(): + with pytest.raises(ValueError, match="safe CSS color"): + export.to_image(_fig(), "png", background="url(javascript:1)}{") + + +# -- quality policy --------------------------------------------------------- + + +def test_jpeg_quality_orders_size(): + fig = _fig() + small = export.to_image(fig, "jpeg", quality=30) + large = export.to_image(fig, "jpeg", quality=95) + assert len(small) < len(large) + + +def test_quality_rejected_outside_lossy_formats(): + with pytest.raises(ValueError, match="quality applies to jpeg/webp"): + export.to_image(_fig(), "png", quality=80) + with pytest.raises(ValueError, match="lossless"): + export.to_image(_fig(), "webp", quality=80) + with pytest.raises(ValueError, match=r"1\.\.100"): + export.to_image(_fig(), "jpeg", quality=0) + + +# -- engine selection ------------------------------------------------------- + + +def test_svg_is_native_only(): + with pytest.raises(ValueError, match="native-only"): + export.to_image(_fig(), "svg", engine=export.Engine.chromium) + + +def test_custom_css_forces_browser_or_rejects_native(monkeypatch): + monkeypatch.setattr(export, "find_browser", lambda explicit=None: None) + with pytest.raises(RuntimeError, match="browser image export"): + export.to_image(_fig(), "png", custom_css=".x{}") + with pytest.raises(ValueError, match="custom_css requires"): + export.to_image(_fig(), "png", engine=export.Engine.default, custom_css=".x{}") + + +class _FakeSession: + def __init__(self): + self.calls = [] + + def close(self): + self.calls.append(("close",)) + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + def render_image(self, html, width, height, *, format, scale, quality, transparent): + self.calls.append(("image", format, width, height, scale, quality, transparent)) + return { + "png": b"\x89PNG\r\n\x1a\nx", + "jpeg": b"\xff\xd8\xffx", + "webp": b"RIFF\x00\x00\x00\x00WEBPx", + }[format] + + def render_pdf(self, html, width, height): + self.calls.append(("pdf", width, height)) + return b"%PDF-1.4 fake" + + +def test_chromium_engine_routes_formats_through_cdp(monkeypatch): + session = _FakeSession() + monkeypatch.setattr(export, "_browser_session", lambda **kw: session) + fig = _fig() + jpeg = export.to_image(fig, "jpeg", engine=export.Engine.chromium, quality=70) + assert jpeg[:3] == b"\xff\xd8\xff" + webp = export.to_image( + fig, "webp", engine=export.Engine.chromium, quality=55, background="transparent" + ) + assert webp[:4] == b"RIFF" + pdf = export.to_image(fig, "pdf", engine=export.Engine.chromium) + assert pdf[:5] == b"%PDF-" + kinds = [c[0] for c in session.calls] + assert kinds.count("image") == 2 and kinds.count("pdf") == 1 + image_calls = [c for c in session.calls if c[0] == "image"] + assert image_calls[0][1] == "jpeg" and image_calls[0][5] == 70 + assert image_calls[1][1] == "webp" and image_calls[1][6] is True # transparent + + +# -- write_image ------------------------------------------------------------ + + +def test_write_image_infers_writes_atomically_and_returns_bytes(tmp_path): + fig = _fig() + path = tmp_path / "out.webp" + data = fig.write_image(path) + assert path.read_bytes() == data and data[:4] == b"RIFF" + # No same-directory temp residue (atomic replace cleaned up). + assert [p.name for p in tmp_path.iterdir()] == ["out.webp"] + + +def test_write_image_format_override_beats_extension(tmp_path): + path = tmp_path / "chart.bin" + data = _fig().write_image(path, format="png") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + + +def test_write_image_html_routes_and_rejects_raster_options(tmp_path): + fig = _fig() + data = fig.write_image(tmp_path / "chart.html") + assert b"" in data + with pytest.raises(ValueError, match="HTML export is interactive"): + fig.write_image(tmp_path / "chart.html", width=800) + + +# -- write_images (batch) --------------------------------------------------- + + +def test_write_images_mixed_formats_and_chart_objects(tmp_path): + chart = xy.chart( + xy.line("x", "y", data={"x": np.arange(10.0), "y": np.arange(10.0)}), + width=200, + height=140, + ) + paths = [tmp_path / "a.png", tmp_path / "b.svg", tmp_path / "c.jpg", tmp_path / "d.html"] + out = xy.write_images(figures=[_fig(), _fig(), chart, _fig()], files=paths) + assert out[0][:8] == b"\x89PNG\r\n\x1a\n" + assert out[1][:5] == b"" in out[3] + for path, data in zip(paths, out, strict=True): + assert path.read_bytes() == data + + +def test_write_images_quality_ignored_by_non_lossy_batch_members(tmp_path): + out = export.write_images( + [_fig(), _fig()], [tmp_path / "a.png", tmp_path / "b.jpg"], quality=50 + ) + assert out[0][:8] == b"\x89PNG\r\n\x1a\n" and out[1][:3] == b"\xff\xd8\xff" + + +def test_write_images_formats_override_and_mismatch(tmp_path): + out = export.write_images([_fig()], [tmp_path / "chart.dat"], formats="png") + assert out[0][:8] == b"\x89PNG\r\n\x1a\n" + with pytest.raises(ValueError, match="1 formats but 2 paths"): + export.write_images( + [_fig(), _fig()], [tmp_path / "a.png", tmp_path / "b.png"], formats=["png"] + ) + + +def test_write_images_alias_conflicts_rejected(tmp_path): + with pytest.raises(ValueError, match="not both"): + export.write_images([_fig()], [tmp_path / "a.png"], figures=[_fig()]) + with pytest.raises(ValueError, match="needs both"): + export.write_images(figures=[_fig()]) + + +# -- declarative export_config --------------------------------------------- + + +def test_export_config_reaches_spec(): + chart = xy.chart( + xy.line("x", "y", data={"x": np.arange(10.0), "y": np.arange(10.0)}), + xy.export_config( + formats=["png", "jpg", "csv"], + filename="report", + width=640, + height=360, + scale=1.0, + background="#fff", + quality=75, + ), + ) + spec, _ = chart.figure().build_payload() + assert spec["export"] == { + "formats": ["png", "jpeg", "csv"], + "filename": "report", + "width": 640, + "height": 360, + "scale": 1.0, + "background": "#fff", + "quality": 75, + } + + +def test_export_config_defaults_apply_and_explicit_args_win(): + chart = xy.chart( + xy.line("x", "y", data={"x": np.arange(10.0), "y": np.arange(10.0)}), + xy.export_config(width=640, height=360, scale=1.0, quality=75), + ) + assert _decode(chart.to_image("png")).size == (640, 360) + assert _decode(chart.to_image("png", width=320, height=180)).size == (320, 180) + # Config quality applies to JPEG but must not leak into non-lossy formats. + assert chart.to_image("svg")[:5] == b"" in data + + +# -- compatibility ---------------------------------------------------------- + + +def test_legacy_methods_unchanged(): + fig = _fig() + assert fig.to_png()[:8] == b"\x89PNG\r\n\x1a\n" + assert fig.to_svg().startswith("" in fig.to_html() + + +def test_png_ihdr_dimensions_honor_scale(): + png = export.to_image(_fig(300, 200), "png", scale=1.0) + width, height = struct.unpack(">II", png[16:24]) + assert (width, height) == (300, 200) + png2x = export.to_image(_fig(300, 200), "png", scale=2.0) + width2, height2 = struct.unpack(">II", png2x[16:24]) + assert (width2, height2) == (600, 400) + + +def test_pdf_content_is_vector_not_one_big_image(): + pdf = export.to_image(_fig(), "pdf") + streams = re.findall(rb"stream\r?\n(.*?)\r?\nendstream", pdf, re.DOTALL) + text = b"".join( + zlib.decompress(s) if s[:2] in (b"\x78\x9c", b"\x78\xda", b"\x78\x01") else s + for s in streams + ) + assert b"BT" in text and b"ET" in text # vector text survived + assert b" re" in text or b" l" in text # vector geometry survived diff --git a/tests/test_jpeg.py b/tests/test_jpeg.py new file mode 100644 index 00000000..75b4af0e --- /dev/null +++ b/tests/test_jpeg.py @@ -0,0 +1,172 @@ +"""Baseline JPEG encoder (`xy._jpeg`) — Pillow is the decode oracle.""" + +from __future__ import annotations + +import io + +import numpy as np +import pytest + +from xy import _jpeg + +Image = pytest.importorskip("PIL.Image") + + +def chart_rgb(h: int = 240, w: int = 320) -> np.ndarray: + """Synthetic chart-like image: flat background, gridlines, dark axis + lines, a color gradient region, and an antialiased-ish sine stroke.""" + img = np.full((h, w, 3), 250, dtype=np.float64) + img[::40, :] = 230 # gridlines + img[:, ::40] = 230 + gh0, gh1 = h // 6, h // 2 + gw0, gw1 = w // 5, w - w // 8 + ramp_x = np.linspace(60, 220, gw1 - gw0) + ramp_y = np.linspace(40, 200, gh1 - gh0) + img[gh0:gh1, gw0:gw1, 0] = ramp_x[None, :] + img[gh0:gh1, gw0:gw1, 1] = ramp_y[:, None] + img[gh0:gh1, gw0:gw1, 2] = 90 + yy = np.arange(h, dtype=np.float64)[:, None] + xx = np.arange(w, dtype=np.float64)[None, :] + center = h * 0.65 + h * 0.22 * np.sin(xx / w * 4 * np.pi) + cov = np.clip(1.5 - np.abs(yy - center), 0.0, 1.0)[..., None] # soft edges + img = img * (1 - cov) + np.array([30.0, 60.0, 180.0]) * cov + img[:, 24:26] = 40 # y axis + img[h - 26 : h - 24, :] = 40 # x axis + return np.round(img).astype(np.uint8) + + +def with_alpha(rgb: np.ndarray, alpha: int | np.ndarray = 255) -> np.ndarray: + a = np.broadcast_to(np.asarray(alpha, dtype=np.uint8), rgb.shape[:2]) + return np.dstack([rgb, a]) + + +def decode(data: bytes) -> np.ndarray: + with Image.open(io.BytesIO(data)) as im: + return np.asarray(im.convert("RGB")) + + +def psnr(a: np.ndarray, b: np.ndarray) -> float: + mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2) + return float(10 * np.log10(255.0**2 / mse)) + + +def iter_markers(data: bytes) -> list[tuple[int, bytes]]: + """Parse marker segments up to (and including) SOS.""" + assert data[:2] == b"\xff\xd8", "must start with SOI" + segments = [] + i = 2 + while i < len(data): + assert data[i] == 0xFF, f"expected marker at byte {i}" + marker = data[i + 1] + length = int.from_bytes(data[i + 2 : i + 4], "big") + segments.append((marker, data[i + 4 : i + 2 + length])) + i += 2 + length + if marker == 0xDA: # entropy-coded data follows; stop walking + break + return segments + + +@pytest.mark.parametrize( + "size", + [(1, 1), (3, 5), (17, 9), (16, 100), (40, 100), (64, 80)], + ids=lambda s: f"{s[0]}x{s[1]}", +) +def test_pil_decodes_mode_and_size(size): + h, w = size + img = chart_rgb(240, 320)[:h, :w] + data = _jpeg.encode(with_alpha(img)) + with Image.open(io.BytesIO(data)) as im: + assert im.size == (w, h) + assert im.mode == "RGB" + + +def test_flat_pixel_roundtrip(): + img = np.full((1, 1, 4), 100, dtype=np.uint8) + img[..., 3] = 255 + out = decode(_jpeg.encode(img)) + assert out.shape == (1, 1, 3) + assert np.all(np.abs(out.astype(int) - 100) <= 3) + + +def test_psnr_at_quality_90(): + img = chart_rgb() + out = decode(_jpeg.encode(with_alpha(img), quality=90)) + assert psnr(img, out) >= 30.0 + + +def test_quality_ordering(): + img = chart_rgb() + lo = _jpeg.encode(with_alpha(img), quality=30) + hi = _jpeg.encode(with_alpha(img), quality=95) + assert psnr(img, decode(hi)) > psnr(img, decode(lo)) + assert len(hi) > len(lo) + + +def test_alpha_ignored(): + img = chart_rgb(64, 80) + rng = np.random.default_rng(42) + noisy_alpha = rng.integers(0, 256, size=(64, 80), dtype=np.uint8) + assert _jpeg.encode(with_alpha(img, noisy_alpha)) == _jpeg.encode(with_alpha(img)) + + +def test_rgb_input_matches_rgba(): + img = chart_rgb(48, 56) + assert _jpeg.encode(img) == _jpeg.encode(with_alpha(img)) + + +@pytest.mark.parametrize("quality", [0, 101, True, 3.5], ids=repr) +def test_invalid_quality(quality): + img = with_alpha(chart_rgb(8, 8)) + with pytest.raises(ValueError, match="quality"): + _jpeg.encode(img, quality=quality) + + +@pytest.mark.parametrize( + "bad", + [ + np.zeros((8, 8, 2), dtype=np.uint8), # not RGB/RGBA + np.zeros((8, 8), dtype=np.uint8), # missing channel axis + np.zeros((8, 8, 4), dtype=np.float64), # wrong dtype + np.zeros((0, 8, 4), dtype=np.uint8), # empty + [[[0, 0, 0, 255]]], # not an ndarray + ], + ids=["chans", "ndim", "dtype", "empty", "list"], +) +def test_invalid_image(bad): + with pytest.raises(ValueError): + _jpeg.encode(bad) + + +def test_deterministic(): + img = with_alpha(chart_rgb(96, 120)) + assert _jpeg.encode(img) == _jpeg.encode(img) + + +def test_extreme_blocks_at_quality_100(): + # Pixel-level checkerboard maximizes high-frequency DCT energy; must + # still decode after quantization at quality=100 (all-ones tables). + grid = ((np.arange(16)[:, None] + np.arange(16)[None, :]) % 2) * 255 + img = np.repeat(grid.astype(np.uint8)[..., None], 3, axis=2) + out = decode(_jpeg.encode(img, quality=100)) + assert out.shape == (16, 16, 3) + + +def test_marker_structure(): + data = _jpeg.encode(with_alpha(chart_rgb(40, 52))) + assert data[:2] == b"\xff\xd8" + assert data[-2:] == b"\xff\xd9" + segments = iter_markers(data) + sof0 = [payload for marker, payload in segments if marker == 0xC0] + assert len(sof0) == 1 + payload = sof0[0] + assert payload[0] == 8 # bit precision + assert int.from_bytes(payload[1:3], "big") == 40 # height + assert int.from_bytes(payload[3:5], "big") == 52 # width + assert payload[5] == 3 # components + for c in range(3): + assert payload[6 + 3 * c + 1] == 0x11 # 4:4:4 sampling factors + # And the rest of the required marker set is present, in order. + markers = [m for m, _ in segments] + assert markers.index(0xE0) < markers.index(0xDB) < markers.index(0xC0) + assert markers.index(0xC4) < markers.index(0xDA) + assert markers[0] == 0xE0 and segments[0][1][:5] == b"JFIF\x00" diff --git a/tests/test_pdf_export.py b/tests/test_pdf_export.py new file mode 100644 index 00000000..8a210973 --- /dev/null +++ b/tests/test_pdf_export.py @@ -0,0 +1,220 @@ +"""Native PDF export (_pdf.py): structural validity (xref/trailer), vector +fidelity (text as text, shapes as paths, rasters as image XObjects, gradients +as axial shadings), the closed-subset drift guard, and determinism.""" + +from __future__ import annotations + +import base64 +import re +import shutil +import struct +import subprocess +import xml.etree.ElementTree as ET +import zlib +from pathlib import Path + +import numpy as np +import pytest + +import xy +from xy._figure import Figure +from xy._pdf import svg_to_pdf + + +def _basic_figure() -> Figure: + x = np.linspace(0.0, 10.0, 40) + fig = Figure(title="combo", x_label="time", y_label="value") + fig.scatter(x, np.cos(x), name="pts") + fig.line(x, np.sin(x), name="ln") + fig.bar(["a", "b", "c"], [1.0, 3.0, 2.0], name="bars") + return fig + + +def _objects(pdf: bytes) -> dict[int, bytes]: + """Every `N 0 obj ... endobj` body keyed by object number.""" + return { + int(m.group(1)): m.group(2) + for m in re.finditer(rb"(\d+) 0 obj\n(.*?)\nendobj\n", pdf, re.S) + } + + +def _stream(obj: bytes) -> bytes: + head, _, rest = obj.partition(b"stream\n") + assert b"/FlateDecode" in head + return zlib.decompress(rest.rsplit(b"\nendstream", 1)[0]) + + +def _content(pdf: bytes) -> bytes: + objs = _objects(pdf) + page = next(body for body in objs.values() if re.search(rb"/Type /Page(?!s)", body)) + match = re.search(rb"/Contents (\d+) 0 R", page) + assert match is not None + return _stream(objs[int(match.group(1))]) + + +def _xref_offsets(pdf: bytes) -> dict[int, int]: + """Parse the xref table, verifying each offset points at `N 0 obj`.""" + startxref = int(pdf[pdf.rindex(b"startxref") + len(b"startxref") :].split()[0]) + header = re.match(rb"xref\n0 (\d+)\n", pdf[startxref:]) + assert header is not None, "startxref must point at the xref keyword" + size = int(header.group(1)) + table = pdf[startxref + header.end() : startxref + header.end() + 20 * size] + assert table[:20] == b"0000000000 65535 f \n" + offsets: dict[int, int] = {} + for num in range(1, size): + entry = table[20 * num : 20 * num + 20] + assert entry[10:11] == b" " and entry[16:18] == b" n", entry + offset = int(entry[:10]) + assert pdf[offset:].startswith(f"{num} 0 obj".encode()), ( + f"xref offset for object {num} is not byte-accurate" + ) + offsets[num] = offset + return offsets + + +def test_pdf_structure_xref_and_trailer() -> None: + pdf = svg_to_pdf(_basic_figure().to_svg()) + assert pdf.startswith(b"%PDF-1.") + assert len(re.findall(rb"/Type /Page(?![s])", pdf)) == 1 # single page + # 900x420 px at 0.75 pt/px. + assert b"/MediaBox [0 0 675 315]" in pdf + + offsets = _xref_offsets(pdf) + root_match = re.search(rb"trailer\n<< /Size \d+ /Root (\d+) 0 R >>", pdf) + assert root_match is not None + root_num = int(root_match.group(1)) + catalog = pdf[offsets[root_num] : offsets[root_num] + 120] + assert b"/Type /Catalog" in catalog # /Root resolves + + +def test_pdf_content_is_vector_text_and_paths() -> None: + svg = _basic_figure().to_svg() + pdf = svg_to_pdf(svg) + content = _content(pdf) + + # Text stays text: BT/ET blocks with Tj strings using the Helvetica family. + assert b"BT" in content and b"ET" in content + assert b"/BaseFont /Helvetica" in pdf + root = ET.fromstring(svg) + tick_labels = [ + el.text + for el in root.iter() + if el.tag.endswith("text") and el.get("text-anchor") == "end" and el.text + ] + assert tick_labels, "figure should carry y tick labels" + assert any(f"({label}) Tj".encode() in content for label in tick_labels) + + # Vector shapes, not one big image: path construction + paint operators. + for op in (b" re\n", b" m\n", b" l\n", b"\nf\n", b"\nS\n", b"W n"): + assert op in content, f"missing content-stream op {op!r}" + assert b"/Subtype /Image" not in pdf # nothing was rasterized + + +def test_heatmap_embeds_matching_image_xobject() -> None: + rng = np.random.default_rng(2) + fig = Figure() + fig.heatmap(rng.random((8, 6))) + svg = fig.to_svg() + pdf = svg_to_pdf(svg) + + root = ET.fromstring(svg) + image = next(el for el in root.iter() if el.tag.endswith("image")) + png = base64.b64decode(image.get("href").split(",", 1)[1]) + w, h = struct.unpack(">II", png[16:24]) + + dims = [ + (int(m.group(1)), int(m.group(2))) + for m in re.finditer( + rb"/Subtype /Image /Width (\d+) /Height (\d+) /ColorSpace /DeviceRGB", pdf + ) + ] + assert (w, h) in dims, f"no /DeviceRGB image XObject with {w}x{h}, found {dims}" + assert b"/Interpolate false" in pdf # pixelated rendering intent + + +def test_gradient_becomes_axial_shading() -> None: + x = np.linspace(0.0, 10.0, 30) + fig = Figure() + fig.area(x, np.abs(np.sin(x)) + 0.1, fill="linear-gradient(currentColor, transparent)") + svg = fig.to_svg() + if "linearGradient" not in svg: + pytest.skip("generator emitted no gradient for this figure") + pdf = svg_to_pdf(svg) + assert b"/ShadingType 2" in pdf or b"/ShadingType 3" in pdf + assert b"/SMask << /S /Luminosity" in pdf # transparent stop -> soft mask + assert b" sh" in _content(pdf) # painted inside the geometry's clip + assert b"W n" in _content(pdf) + + +def test_unsupported_svg_features_raise() -> None: + ns = 'xmlns="http://www.w3.org/2000/svg"' + with pytest.raises(ValueError, match="unsupported SVG feature"): + svg_to_pdf(f'') + # Attribute drift on a known element fails loudly too. + with pytest.raises(ValueError, match="unsupported SVG feature"): + svg_to_pdf( + f'' + '' + ) + # ...as does a path command outside the generator's subset. + with pytest.raises(ValueError, match="unsupported SVG feature"): + svg_to_pdf(f'') + + +def test_pdf_output_is_deterministic() -> None: + svg = _basic_figure().to_svg() + assert svg_to_pdf(svg) == svg_to_pdf(svg) + + rng = np.random.default_rng(3) + fig = Figure() + fig.heatmap(rng.random((6, 5))) + heatmap_svg = fig.to_svg() + assert svg_to_pdf(heatmap_svg) == svg_to_pdf(heatmap_svg) + + +def test_facet_grid_converts_with_clipped_panels() -> None: + x = np.linspace(0.0, 10.0, 50) + data = { + "x": np.tile(x, 3), + "y": np.concatenate([np.sin(x), np.cos(x), np.sin(2 * x)]), + "g": np.repeat(["a", "b", "c"], 50), + } + chart = xy.facet_chart(xy.line(x="x", y="y"), by="g", cols=2, title="grid", data=data) + svg = chart.to_svg() + panels = svg.count(" 1 + + content = _content(svg_to_pdf(svg)) + # Each panel viewport becomes a translated group clipped to its bounds. + translated = re.findall(rb"\n1 0 0 1 [\d.]+ [\d.]+ cm\n", content) + assert len(translated) == panels + assert content.count(b"W n") >= panels + + +def test_round_trip_via_external_oracle(tmp_path: Path) -> None: + pdf = svg_to_pdf(_basic_figure().to_svg()) + path = tmp_path / "figure.pdf" + path.write_bytes(pdf) + qpdf = shutil.which("qpdf") + mutool = shutil.which("mutool") + pdftoppm = shutil.which("pdftoppm") + if qpdf: + proc = subprocess.run([qpdf, "--check", str(path)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stdout + proc.stderr + elif mutool: + out = tmp_path / "page.png" + proc = subprocess.run( + [mutool, "draw", "-o", str(out), str(path), "1"], capture_output=True, text=True + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert out.exists() and out.stat().st_size > 0 + elif pdftoppm: + proc = subprocess.run( + [pdftoppm, "-png", "-r", "72", str(path), str(tmp_path / "page")], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert list(tmp_path.glob("page*.png")) + else: + pytest.skip("no external PDF oracle (qpdf/mutool/pdftoppm) on PATH") diff --git a/tests/test_type_surface.py b/tests/test_type_surface.py index fb129e90..a067aa86 100644 --- a/tests/test_type_surface.py +++ b/tests/test_type_surface.py @@ -173,7 +173,7 @@ def test_component_types_are_lazy_public_root_exports() -> None: def test_export_engine_is_lazy_public_enum() -> None: assert "Engine" in xy.__all__ assert xy.Engine is Engine - assert tuple(Engine) == (Engine.default, Engine.chromium) + assert tuple(Engine) == (Engine.auto, Engine.default, Engine.chromium) def test_chart_dom_slots_are_public_styling_contract() -> None: diff --git a/tests/test_webp.py b/tests/test_webp.py new file mode 100644 index 00000000..23846ba0 --- /dev/null +++ b/tests/test_webp.py @@ -0,0 +1,142 @@ +"""Lossless WebP (VP8L) encoder tests: exact round-trips via Pillow's libwebp +decoder (the reference implementation), container framing, and input +validation.""" + +from __future__ import annotations + +import struct +from io import BytesIO + +import numpy as np +import pytest + +from xy import _webp + +Image = pytest.importorskip("PIL.Image") + + +def _decode(data: bytes) -> np.ndarray: + return np.array(Image.open(BytesIO(data)).convert("RGBA")) + + +def _assert_roundtrip(img: np.ndarray) -> bytes: + out = _webp.encode(img) + expected = img + if img.shape[2] == 3: + expected = np.concatenate([img, np.full((*img.shape[:2], 1), 255, np.uint8)], axis=2) + np.testing.assert_array_equal(_decode(out), expected) + return out + + +def _chart_like(h: int = 200, w: int = 300) -> np.ndarray: + """Flat background + gridlines + a couple of line series.""" + img = np.full((h, w, 4), 255, np.uint8) + img[:, :, :3] = 250 + img[::25, :, :3] = 220 + img[:, ::25, :3] = 220 + x = np.arange(w) + y = (h / 2 + (h / 3) * np.sin(x / 17)).astype(np.int64).clip(1, h - 2) + img[y, x] = (31, 119, 180, 255) + img[y + 1, x] = (100, 150, 200, 255) # a soft "antialiased" fringe + y2 = (h / 2 + (h / 4) * np.cos(x / 29)).astype(np.int64).clip(0, h - 1) + img[y2, x] = (255, 127, 14, 255) + return img + + +def test_roundtrip_1x1(): + _assert_roundtrip(np.array([[[10, 20, 30, 255]]], np.uint8)) + + +def test_roundtrip_2x3_distinct(): + img = np.arange(2 * 3 * 4, dtype=np.uint8).reshape(2, 3, 4) * 7 + img[:, :, 3] = 255 + _assert_roundtrip(img) + + +def test_roundtrip_single_color(): + # One-symbol prefix codes for every channel, plus long distance-1 runs. + img = np.full((64, 64, 4), (12, 200, 34, 255), np.uint8) + _assert_roundtrip(img) + + +def test_roundtrip_checkerboard(): + yy, xx = np.mgrid[0:32, 0:32] + img = np.where( + ((yy + xx) % 2 == 0)[..., None], + np.array([255, 0, 0, 255], np.uint8), + np.array([0, 0, 255, 255], np.uint8), + ).astype(np.uint8) + _assert_roundtrip(img) + + +def test_roundtrip_gradient(): + img = np.zeros((40, 300, 4), np.uint8) + img[:, :, 0] = np.linspace(0, 255, 300, dtype=np.uint8) + img[:, :, 1] = np.linspace(255, 0, 300, dtype=np.uint8) + img[:, :, 2] = 128 + img[:, :, 3] = 255 + _assert_roundtrip(img) + + +def test_roundtrip_noise(): + rng = np.random.default_rng(42) + _assert_roundtrip(rng.integers(0, 256, (50, 50, 4), dtype=np.uint8)) + + +def test_roundtrip_alpha_gradient(): + rng = np.random.default_rng(7) + img = rng.integers(0, 256, (30, 60, 4), dtype=np.uint8) + img[:, :, 3] = np.linspace(0, 255, 60, dtype=np.uint8) # alpha must survive + _assert_roundtrip(img) + + +def test_roundtrip_chart_like(): + _assert_roundtrip(_chart_like()) + + +def test_rgb_input_treated_as_opaque(): + rng = np.random.default_rng(3) + img = rng.integers(0, 256, (20, 20, 3), dtype=np.uint8) + decoded = _decode(_webp.encode(img)) + np.testing.assert_array_equal(decoded[:, :, :3], img) + assert (decoded[:, :, 3] == 255).all() + + +def test_riff_container_framing(): + out = _webp.encode(_chart_like(40, 30)) + assert out[:4] == b"RIFF" + assert struct.unpack(" Date: Mon, 20 Jul 2026 13:52:33 -0700 Subject: [PATCH 2/2] Address PR #115 review: background layering, batch defaults, quality 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. --- docs/guides/display-and-export.md | 7 ++- js/src/53_interaction.js | 3 + python/xy/_raster.py | 13 +--- python/xy/_svg.py | 30 ++++++++-- python/xy/components.py | 33 +++++++++-- python/xy/export.py | 91 ++++++++++++++++++++-------- python/xy/facets.py | 15 ++++- python/xy/static/index.js | 1 + python/xy/static/standalone.js | 1 + tests/test_image_export.py | 98 ++++++++++++++++++++++++++++++- uv.lock | 87 +++++++++++++++++++++++++++ 11 files changed, 328 insertions(+), 51 deletions(-) diff --git a/docs/guides/display-and-export.md b/docs/guides/display-and-export.md index a4ca6cb5..49f9fbbe 100644 --- a/docs/guides/display-and-export.md +++ b/docs/guides/display-and-export.md @@ -66,8 +66,11 @@ 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 paints the same single -backdrop in every format (raster canvas, SVG/PDF rect, browser page). +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` diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js index a0d835ba..0211337c 100644 --- a/js/src/53_interaction.js +++ b/js/src/53_interaction.js @@ -1167,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); diff --git a/python/xy/_raster.py b/python/xy/_raster.py index bd4ecf90..0ce3baf5 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -45,6 +45,7 @@ _solid_paint, _step_arrays, _tick_label_anchor, + apply_export_background, axis_ticks, hexbin_ring, layout, @@ -1881,17 +1882,7 @@ def _export_payload( spec["width"] = int(width) if height is not None: spec["height"] = int(height) - if background is not None: - spec["canvas_background"] = background - # SVG paints the plot rect only when the theme sets --chart-bg; the - # rasterizer would otherwise default it to opaque white, which breaks - # cross-format background agreement (and any transparent export). An - # explicit export background therefore also becomes the plot-rect - # default — a theme-set --chart-bg still wins. - dom = spec.setdefault("dom", {}) - style = dom.setdefault("style", {}) if isinstance(dom, dict) else {} - if isinstance(style, dict): - style.setdefault("--chart-bg", background) + apply_export_background(spec, background) return spec, blob, borrowed diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 83aa90df..be4d3a4c 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -618,17 +618,40 @@ def _px_size(value: Any, default: float) -> float: return default +def apply_export_background(spec: dict[str, Any], background: Optional[str]) -> None: + """Apply the unified export API's `background=` override to a payload spec. + + An explicit export background replaces the ENTIRE painted backdrop — the + canvas underlay, the theme figure patch (`theme(background=)`), and the + plot-rect fill (`--chart-bg`) — so the requested color (or transparency) + is what actually shows regardless of chart theme, instead of being buried + under the theme paints. The plot token becomes "transparent" rather than + the override color so translucent backgrounds composite exactly once. + Shared by the raster exporter and (via SVG) the PDF exporter.""" + if background is None: + return + spec["canvas_background"] = background + dom = spec.setdefault("dom", {}) + if isinstance(dom, dict): + style = dom.setdefault("style", {}) + if isinstance(style, dict): + style.pop("background", None) + style["--chart-bg"] = "transparent" + + def _solid_paint(css: Any) -> Optional[str]: """A parseable solid CSS color string, or None when unset/unpaintable (var(), gradients) — for background rects that must be omitted rather - than fallback-painted.""" + than fallback-painted. Fully transparent colors (alpha 0, e.g. the + export background override's plot token) are pure no-op fills and are + omitted as well.""" from . import kernels s = _css(css, "") if not s: return None _status, rgba = kernels.css_check(kernels.CSS_COLOR, s) - if rgba is None: + if rgba is None or rgba[3] == 0: return None return s @@ -2639,8 +2662,7 @@ def to_svg( spec["width"] = int(width) if height is not None: spec["height"] = int(height) - if background is not None: - spec["canvas_background"] = background + apply_export_background(spec, background) out = render_svg(spec, blob, id_prefix=id_prefix) if path is not None: from .export import _atomic_write_text diff --git a/python/xy/components.py b/python/xy/components.py index 647015f6..33b0d1fa 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -2944,15 +2944,18 @@ def _export_defaults( scale: Optional[float], background: Optional[str], quality: Optional[int], + *, + lossy_webp: bool = False, ) -> dict[str, Any]: """Fill omitted export options from the chart's `export_config`. Direct arguments always win. Declarative defaults degrade gracefully - where a format cannot honor them (config quality is dropped for - non-lossy formats; a config "transparent" background is dropped for - JPEG) — only *explicit* arguments produce hard errors downstream.""" + where a format cannot honor them (config quality applies only where + output is actually lossy — JPEG, plus WebP when the resolved engine + is Chromium; 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": + if quality is None and (fmt == "jpeg" or (fmt == "webp" and lossy_webp)): quality = config.get("quality") if background is None: background = config.get("background") @@ -2986,6 +2989,8 @@ def to_image( 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.""" + fmt = export._normalize_format(format) + resolved = export._resolve_image_engine(engine, fmt, custom_css) return self.figure().to_image( format, engine=engine, @@ -2994,7 +2999,13 @@ def to_image( sandbox=sandbox, gl=gl, **self._export_defaults( - export._normalize_format(format), width, height, scale, background, quality + fmt, + width, + height, + scale, + background, + quality, + lossy_webp=resolved == "browser", ), ) @@ -3022,7 +3033,17 @@ def write_image( if format is not None else export._infer_format(path) ) - defaults = self._export_defaults(fmt, width, height, scale, background, quality) + if fmt != "html": + resolved = export._resolve_image_engine(engine, fmt, custom_css) + defaults = self._export_defaults( + fmt, + width, + height, + scale, + background, + quality, + lossy_webp=resolved == "browser", + ) if fmt == "html": # HTML routing rejects raster-only options; forward the user's own # arguments (not the declarative defaults) so that rejection diff --git a/python/xy/export.py b/python/xy/export.py index fcf73c0f..ca9837c2 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -539,7 +539,7 @@ def write_images( formats: Optional[str | list[str]] = None, width: Optional[int] = None, height: Optional[int] = None, - scale: float = 2.0, + scale: Optional[float] = None, background: Optional[str] = None, engine: Engine | str = Engine.auto, quality: Optional[int] = None, @@ -558,11 +558,12 @@ def write_images( `_chromium.py`) instead of paying ~1-2 s of startup per figure — the classic batch-export trap. `figures=`/`files=` are keyword aliases for the positional pair, and composed charts (anything with a `.figure()`) - are accepted directly. Writes are atomic per file; on error, files - already exported remain. Other options match `to_image`; `width`/ - `height`/`background`/`quality` apply to every file (quality is ignored - by non-lossy formats rather than rejected, so mixed PNG+JPEG batches - stay ergonomic).""" + are accepted directly — a chart's `export_config` defaults fill any + omitted width/height/scale/background/quality for that chart's files, + 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.""" if figures is not None: if figs is not None: raise ValueError("pass figs positionally or figures=, not both") @@ -573,7 +574,6 @@ def write_images( 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] if len(figs) != len(paths): raise ValueError(f"write_images got {len(figs)} figures but {len(paths)} paths") if isinstance(formats, str): @@ -584,43 +584,74 @@ def write_images( fmts = [_normalize_format(f, allow_html=True) for f in formats] else: fmts = [_infer_format(p) for p in paths] - scale = _positive_finite_float(scale, "export scale") + if scale is not None: + scale = _positive_finite_float(scale, "export scale") + if quality is not None: + # Range-check once up front; per-file policy below decides where the + # value actually applies (JPEG + Chromium WebP). + _validated_quality(quality, "jpeg", "native") optimize = _bool_option(optimize, "export optimize") sandbox = _bool_option(sandbox, "export sandbox") gl = _gl_option(gl) # Resolve the whole plan before any I/O so bad arguments fail the batch - # up front instead of after a partial export. - plan: list[tuple["Figure", str | PathLike[str], str, str, Optional[int], Optional[str]]] = [] - for fig, path, fmt in zip(figs, paths, fmts, strict=True): + # up front instead of after a partial export. Chart wrappers are kept + # long enough to resolve their declarative export_config defaults; only + # then are they compiled down to figures. + plan: list[ + tuple["Figure", str | PathLike[str], str, str, dict[str, Any], Optional[int], Optional[str]] + ] = [] + for obj, path, fmt in zip(figs, paths, fmts, strict=True): + fig = obj.figure() if callable(getattr(obj, "figure", None)) else obj if fmt == "html": - plan.append((fig, path, fmt, "html", None, None)) + plan.append((fig, path, fmt, "html", {}, None, None)) continue resolved = _resolve_image_engine(engine, fmt, custom_css) + if callable(getattr(obj, "_export_defaults", None)): + settings = obj._export_defaults( + fmt, + width, + height, + scale, + background, + quality, + lossy_webp=resolved == "browser", + ) + else: + settings = { + "width": width, + "height": height, + "scale": scale if scale is not None else 2.0, + "background": background, + "quality": quality, + } file_quality = ( - _validated_quality(quality, fmt, resolved) if fmt in _LOSSY_QUALITY_FORMATS else None + _validated_quality(settings["quality"], fmt, resolved) + if fmt == "jpeg" or (fmt == "webp" and resolved == "browser") + else None ) try: - file_background = _validated_background(background, fmt) + file_background = _validated_background(settings["background"], fmt) except ValueError as exc: raise ValueError(f"{path}: {exc}") from None - plan.append((fig, path, fmt, resolved, file_quality, file_background)) + plan.append((fig, path, fmt, resolved, settings, file_quality, file_background)) out: list[bytes] = [] session: Optional[Any] = None try: - for fig, path, fmt, resolved, file_quality, file_background in plan: + for fig, path, fmt, resolved, settings, file_quality, file_background in plan: if resolved == "html": out.append(to_html(fig, path, custom_css=custom_css).encode("utf-8")) continue - w, h = _export_dimensions(fig, width, height) + w, h = _export_dimensions(fig, settings["width"], settings["height"]) + file_scale = _positive_finite_float(settings["scale"], "export scale") if resolved == "native": data = _native_image( fig, fmt, width=w, height=h, - scale=scale, + scale=file_scale, background=file_background, quality=file_quality, optimize=optimize, @@ -634,7 +665,7 @@ def write_images( fmt, width=w, height=h, - scale=scale, + scale=file_scale, background=file_background, quality=file_quality, custom_css=custom_css, @@ -857,14 +888,26 @@ def _flatten_alpha(rgba: "Any") -> "Any": return out +def _background_css(background: Optional[str]) -> str: + """Page CSS for the export `background=` override in browser capture. + + Mirrors `_svg.apply_export_background`: the override replaces the whole + painted backdrop, so it must beat the chart root's inline theme background + and the `--chart-bg` plot token the render client reads from computed + style (`!important` outranks inline styles and the token becomes + transparent so translucent overrides composite exactly once).""" + if background is None: + return "" + return ( + f"html,body{{background:{background} !important;}}" + f".xy{{background:{background} !important;--chart-bg:transparent !important;}}" + ) + + def _browser_html(fig: "Figure", custom_css: Optional[str], background: Optional[str]) -> str: """Standalone document for browser capture, with the export background override injected as page CSS (validated by `_validated_background`).""" - css = "" - if background is not None: - css = f"html,body{{background:{background} !important;}}" - if custom_css: - css += custom_css + css = _background_css(background) + (custom_css or "") return to_html(fig, custom_css=css or None) diff --git a/python/xy/facets.py b/python/xy/facets.py index 763170f6..92e22e30 100644 --- a/python/xy/facets.py +++ b/python/xy/facets.py @@ -237,8 +237,13 @@ def to_svg( # Per-panel id prefixes keep clipPath/gradient ids unique in the # composed document; each panel's title is its facet label, and the - # grid title is drawn exactly once below. - panel_svgs = [_svg.to_svg(fig, id_prefix=f"xy{i}-") for i, fig in enumerate(self.figures)] + # grid title is drawn exactly once below. The export background flows + # into every panel too, so panel theme paints cannot bury the grid + # backdrop (each panel then paints backdrop-colored/transparent). + panel_svgs = [ + _svg.to_svg(fig, id_prefix=f"xy{i}-", background=background) + for i, fig in enumerate(self.figures) + ] total_h = self.grid_height + self._title_height body: list[str] = [] for i, svg in enumerate(panel_svgs): @@ -403,7 +408,11 @@ def to_image( from . import _webp return _webp.encode(canvas) - doc = self.to_html(custom_css=custom_css) + # The background override must actually reach the captured document, + # exactly as in the single-chart browser path — the CDP transparency + # flag below only clears Chromium's default white page backdrop. + bg_css = export._background_css(background) + doc = self.to_html(custom_css=(bg_css + (custom_css or "")) or None) total_h = self.grid_height + self._title_height with export._browser_session(gl=gl, sandbox=sandbox) as session: if fmt == "pdf": diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 1c23ef08..09255030 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -6878,6 +6878,7 @@ selectMenuItems[next].focus(); } this._listen(grip, "keydown", (e) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; +if (!exportMenuItems.length) return; e.preventDefault(); e.stopPropagation(); setExportMenuOpen(true); diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 6972f96c..57bc349e 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -6879,6 +6879,7 @@ selectMenuItems[next].focus(); } this._listen(grip, "keydown", (e) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; +if (!exportMenuItems.length) return; e.preventDefault(); e.stopPropagation(); setExportMenuOpen(true); diff --git a/tests/test_image_export.py b/tests/test_image_export.py index 0723d7ae..e61776a3 100644 --- a/tests/test_image_export.py +++ b/tests/test_image_export.py @@ -115,6 +115,43 @@ def test_svg_background_paints_one_backdrop_rect(): assert re.search(r'', svg) +def _themed_chart(): + return xy.chart( + xy.line("x", "y", data={"x": np.arange(20.0), "y": np.arange(20.0)}), + xy.theme(background="#ff0000", plot_background="#00ff00"), + width=300, + height=200, + ) + + +def _corner_center(data: bytes): + image = np.asarray(_decode(data).convert("RGBA")) + h, w = image.shape[:2] + return tuple(int(v) for v in image[2, 2]), tuple(int(v) for v in image[h // 2, w // 2]) + + +def test_explicit_background_replaces_theme_paints(): + # An explicit export background must replace the theme figure patch AND + # the plot-rect fill — not be buried underneath them (PR #115 review). + chart = _themed_chart() + corner, center = _corner_center(chart.to_image("png", scale=1.0)) + assert corner == (255, 0, 0, 255) and center == (0, 255, 0, 255) # theme intact + corner, center = _corner_center(chart.to_image("png", scale=1.0, background="#112233")) + assert corner == (0x11, 0x22, 0x33, 255) and center == (0x11, 0x22, 0x33, 255) + corner, center = _corner_center(chart.to_image("png", scale=1.0, background="transparent")) + assert corner[3] == 0 and center[3] == 0 + svg = chart.to_image("svg", background="#112233").decode() + assert "#ff0000" not in svg and "#00ff00" not in svg + assert svg.count("#112233") == 1 # exactly one backdrop, no double-composite + + +def test_browser_background_css_overrides_theme_tokens(): + css = export._background_css("#112233") + assert "html,body{background:#112233 !important;}" in css + assert ".xy{background:#112233 !important;--chart-bg:transparent !important;}" in css + assert export._background_css(None) == "" + + def test_background_rejects_unsafe_strings(): with pytest.raises(ValueError, match="safe CSS color"): export.to_image(_fig(), "png", background="url(javascript:1)}{") @@ -246,10 +283,32 @@ def test_write_images_mixed_formats_and_chart_objects(tmp_path): def test_write_images_quality_ignored_by_non_lossy_batch_members(tmp_path): + # PNG and native (lossless) WebP members must not abort a batch whose + # quality only targets the lossy members (PR #115 review). out = export.write_images( - [_fig(), _fig()], [tmp_path / "a.png", tmp_path / "b.jpg"], quality=50 + [_fig(), _fig(), _fig()], + [tmp_path / "a.png", tmp_path / "b.jpg", tmp_path / "c.webp"], + quality=50, ) assert out[0][:8] == b"\x89PNG\r\n\x1a\n" and out[1][:3] == b"\xff\xd8\xff" + assert out[2][:4] == b"RIFF" + with pytest.raises(ValueError, match=r"1\.\.100"): + export.write_images([_fig()], [tmp_path / "d.png"], quality=500) + + +def test_write_images_resolves_chart_export_config_defaults(tmp_path): + # Converting charts to figures must not drop their declarative export + # defaults (PR #115 review): a chart configured for 123x77 at scale 1 + # exports at exactly 123x77 through the batch API too. + chart = xy.chart( + xy.line("x", "y", data={"x": np.arange(10.0), "y": np.arange(10.0)}), + xy.export_config(width=123, height=77, scale=1.0), + ) + out = export.write_images([chart], [tmp_path / "configured.png"]) + assert _decode(out[0]).size == (123, 77) + # Batch-level arguments still override the declarative defaults. + out = export.write_images([chart], [tmp_path / "explicit.png"], width=64, height=32, scale=1.0) + assert _decode(out[0]).size == (64, 32) def test_write_images_formats_override_and_mismatch(tmp_path): @@ -320,6 +379,43 @@ def test_export_config_empty_formats_and_validation(): xy.export_config(quality=101) +def test_export_config_quality_reaches_chromium_webp(monkeypatch): + # Declarative quality must flow to Chromium's lossy WebP, not just JPEG + # (PR #115 review); native WebP continues to ignore it (lossless). + session = _FakeSession() + monkeypatch.setattr(export, "_browser_session", lambda **kw: session) + chart = xy.chart( + xy.line("x", "y", data={"x": np.arange(10.0), "y": np.arange(10.0)}), + xy.export_config(quality=37), + ) + chart.to_image("webp", engine=export.Engine.chromium) + image_calls = [c for c in session.calls if c[0] == "image"] + assert image_calls[0][1] == "webp" and image_calls[0][5] == 37 + assert chart.to_image("webp")[:4] == b"RIFF" # native stays lossless, no error + + +def test_facet_browser_background_reaches_document(monkeypatch): + # The facet Chromium path must inject the background override into the + # captured document, as the single-chart path does (PR #115 review). + session = _FakeSession() + captured: list[str] = [] + + def fake_session(**kw): + return session + + original = session.render_image + + def spying_render_image(html, *args, **kwargs): + captured.append(html) + return original(html, *args, **kwargs) + + session.render_image = spying_render_image + monkeypatch.setattr(export, "_browser_session", fake_session) + grid = _grid().figure() + grid.to_image("png", engine=export.Engine.chromium, background="#112233") + assert ".xy{background:#112233 !important;--chart-bg:transparent !important;}" in captured[0] + + def test_export_config_component_is_revalidated_at_compile(): chart = xy.chart( xy.line("x", "y", data={"x": np.arange(4.0), "y": np.arange(4.0)}), diff --git a/uv.lock b/uv.lock index 53b90935..9ad6ddad 100644 --- a/uv.lock +++ b/uv.lock @@ -413,6 +413,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "plotly" version = "6.9.0" @@ -772,6 +857,7 @@ codspeed = [ ] dev = [ { name = "hypothesis" }, + { name = "pillow" }, { name = "pyarrow" }, { name = "pytest" }, { name = "ruff" }, @@ -783,6 +869,7 @@ requires-dist = [ { name = "anywidget", specifier = ">=0.9" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6" }, { name = "numpy", specifier = ">=1.24" }, + { name = "pillow", marker = "extra == 'dev'", specifier = ">=10" }, { name = "plotly", marker = "extra == 'bench'", specifier = ">=5" }, { name = "pyarrow", marker = "extra == 'dev'", specifier = ">=15" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },