diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md index a9e17a1f4..15b59d443 100644 --- a/.github/ISSUE_TEMPLATE/release.md +++ b/.github/ISSUE_TEMPLATE/release.md @@ -21,6 +21,8 @@ assignees: '' - `version` in `[project]` section in `ggsql-jupyter/pyproject.toml`. - ggsql-vscode - `version` in `ggsql-vscode/package.json`. + - ggsql-wasm + - `version` in `ggsql-wasm/pkg/package.json`. - [ ] Update lock files: - Run `cargo build`, ensure `Cargo.lock` updates. - Run `(cd ggsql-wasm && ./build-wasm.sh && cd demo && npm install)`, ensure that `ggsql-wasm/demo/package-lock.json` updates. diff --git a/.github/workflows/autofmt.yaml b/.github/workflows/autofmt.yaml index e7a08867c..3119ee1d4 100644 --- a/.github/workflows/autofmt.yaml +++ b/.github/workflows/autofmt.yaml @@ -51,7 +51,7 @@ jobs: gh pr checkout ${{ github.event.issue.number }} --repo "$GITHUB_REPOSITORY" - name: Install Rust (rustfmt) - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable with: components: rustfmt diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6e046598a..ba4e7f8aa 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -40,32 +40,36 @@ jobs: # which invokes `protoc` at build time. Required for the ADBC test path. run: sudo apt-get install -y protobuf-compiler - - name: Install png writer system libraries - # The png writer renders via wgpu/Vello, which needs a Vulkan - # adapter. ubuntu-latest has no GPU, so install Mesa's lavapipe software - # device. If no adapter is found anyway, the writer test skips its render - # assertion gracefully rather than failing. - # Text layout goes through parley/fontique, which links the system - # fontconfig on Linux to enumerate fonts, so its development files - # (fontconfig.pc plus headers) must be present at build time. - run: sudo apt-get install -y mesa-vulkan-drivers libfontconfig1-dev + - name: Install renderer system libraries + # Two separate needs, conflated easily: + # + # * mesa-vulkan-drivers is for the *raster* writers and the plot + # viewer, which render through wgpu and need a Vulkan adapter. + # ubuntu-latest has no GPU, so this installs Mesa's lavapipe software + # device. Without an adapter those tests skip their render assertion + # rather than fail. + # * fontconfig is for *every* renderer-backed feature including svg, + # pdf and hep: text layout goes through parley/fontique, which + # enumerates fonts through the system fontconfig whatever draws. + # + # Only the fontconfig *runtime* library is installed, deliberately: + # ggsql enables fontique's `fontconfig-dlopen`, so the `-dev` package + # is not needed at build time, and leaving it out makes every CI run a + # regression test for that. fonts-dejavu-core gives fontconfig + # something to find. + run: sudo apt-get install -y mesa-vulkan-drivers libfontconfig1 fonts-dejavu-core - name: Install Rust - # 1.86 is the MSRV (declared as `rust-version` in /Cargo.toml, see - # /CLAUDE.md); this sets it as the default toolchain so plain `cargo` - # uses it. fmt, clippy and the library build run on 1.86 to guarantee - # the shipped crate builds there. - uses: dtolnay/rust-toolchain@1.86.0 + # Stable, not the MSRV — clippy reads `rust-version` from /Cargo.toml, + # so running it here still guards the 1.86 floor. Why the default + # cannot be 1.86 itself: /CLAUDE.md, "Rust version (MSRV)". + uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - name: Install Rust (test toolchain) - # The experimental `adbc` feature's test path pulls `adbc_datafusion` → - # `datafusion` ≥53.1.0, which requires rustc ≥1.88. Steps that compile - # tests/dev-dependencies use this newer toolchain via `cargo +stable`. - # Installing it here does not change the default — plain `cargo` stays - # on 1.86. - run: rustup toolchain install stable --profile minimal + - name: Install Rust (CRAN toolchain) + # For the MSRV check below only. Does not change the default. + run: rustup toolchain install 1.86 --profile minimal - name: Caching uses: Swatinem/rust-cache@v2 @@ -80,25 +84,35 @@ jobs: - name: Run Clippy run: cargo clippy -- -D warnings - - name: Install wasm-pack - # `cargo install` runs at the repo root, where 1.86 is the default; - # `+stable` builds the tool on the newer toolchain. The actual wasm build - # below runs in ggsql-wasm/, whose nested rust-toolchain.toml selects stable. - run: cargo +stable install wasm-pack + - name: Install wasm-bindgen CLI + # The wasm build itself runs in ggsql-wasm/, whose nested + # rust-toolchain.toml selects stable. + run: | + cargo install -f wasm-bindgen-cli --version "$(awk '/^name = "wasm-bindgen"$/{f=1;next} f&&/^version = /{gsub(/[",]/,"");print $3;exit}' Cargo.lock)" + + - name: Install wasm-opt + run: cargo install wasm-opt - - name: Build library (MSRV 1.86) + - name: Build library run: cargo build - name: Build all targets - # --all-targets compiles dev-dependencies (datafusion), so it needs ≥1.88. - run: cargo +stable build --all-targets + run: cargo build --all-targets + + - name: Check the library builds on CRAN's toolchain (MSRV 1.86) + # The one step that actually proves the 1.86 floor — delete it and the + # MSRV silently rots. Keep the two flags: `--ignore-rust-version` + # bypasses `parley`'s *declared* 1.88, and library-only avoids the + # `adbc` dev-dependency path, which needs 1.88 for real. Background: + # /CLAUDE.md, "Rust version (MSRV)". + run: cargo +1.86 check --ignore-rust-version -p ggsql - name: Run tree-sitter tests working-directory: tree-sitter-ggsql run: tree-sitter test - name: Run Rust tests - run: cargo +stable test --lib --bins + run: cargo test --lib --bins - name: Install dbc CLI and SQLite ADBC driver run: | @@ -106,22 +120,31 @@ jobs: "$HOME/.local/bin/dbc" install sqlite - name: Run ADBC unit tests - run: cargo +stable test --features "adbc sqlite" --lib + run: cargo test --features "adbc sqlite" --lib - name: Run ADBC SQLite equivalence tests - run: cargo +stable test --features "adbc sqlite" --lib -- --ignored equivalence + run: cargo test --features "adbc sqlite" --lib -- --ignored equivalence + + - name: Run GPU-free writer tests + # svg, pdf and hep are default features and need no adapter, so these + # are hard assertions on every runner. hep-read adds the document + # round trip. Fast, and its failure is unambiguous, so it runs first. + run: 'cargo test --features hep-read --lib writer::' + + - name: Run raster writer tests + # Non-default features needing a GPU adapter. Skips its render + # assertions gracefully if lavapipe did not come up. + run: 'cargo test --features "png jpeg tiff webp" --lib writer::' - - name: Run png writer tests - # Non-default feature. The hephaestus crate it renders through needs - # rustc ≥1.88 (wgpu), so it builds on +stable and is excluded from the - # 1.86 library build. Default features (incl. duckdb) supply the - # in-memory reader the test uses. - run: cargo +stable test --features png --lib writer::hephaestus + - name: Check the plot viewer builds + # Not a writer and it blocks on a window, so it is compiled, not run. + run: cargo check --features window - - name: Build WASM library - working-directory: ggsql-wasm/library - run: npm install && npm run build + - name: Check the visual-test harness builds + # `required-features` keeps it out of every other build here, so + # nothing else would catch it failing to compile. Checked, not run: + # it renders the whole doc corpus and wants an adapter to do it. + run: cargo check -p ggsql-cli --features png --example visual_test - name: Build WASM package - working-directory: ggsql-wasm - run: wasm-pack build --target web --profile wasm --no-opt + run: ./ggsql-wasm/build-wasm.sh --skip-opt diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 568e1193a..4120f2796 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -40,16 +40,9 @@ jobs: run: sudo apt-get install -y unixodbc-dev - name: Install Rust - # 1.86 is the MSRV (see /CLAUDE.md); this is the default toolchain, and - # the Jupyter kernel build below runs on it. - uses: dtolnay/rust-toolchain@1.86.0 - - - name: Install Rust (wasm toolchain) - # The wasm build is exempt from the 1.86 MSRV (R doesn't use wasm); some - # wasm-only deps need a newer rustc. Installing stable here does not - # change the default — plain `cargo` stays on 1.86, and - # ggsql-wasm/rust-toolchain.toml selects stable for the wasm build. - run: rustup toolchain install stable --profile minimal + # Stable. The 1.86 MSRV is checked by its own step in build.yaml + # rather than by pinning here; see /CLAUDE.md, "Rust version (MSRV)". + uses: dtolnay/rust-toolchain@stable - name: Caching uses: Swatinem/rust-cache@v2 @@ -58,13 +51,12 @@ jobs: cache-on-failure: true save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Install wasm-pack - # `cargo install` runs at the repo root (pinned to 1.86); `+stable` - # builds these tools on the newer toolchain instead. - run: cargo +stable install wasm-pack + - name: Install wasm-bindgen CLI + run: | + cargo install -f wasm-bindgen-cli --version "$(awk '/^name = "wasm-bindgen"$/{f=1;next} f&&/^version = /{gsub(/[",]/,"");print $3;exit}' Cargo.lock)" - name: Install wasm-opt - run: cargo +stable install wasm-opt + run: cargo install wasm-opt - name: Setup quarto uses: quarto-dev/quarto-actions/setup@v2 diff --git a/.github/workflows/release-jupyter.yml b/.github/workflows/release-jupyter.yml index f6182ed12..2b64ff312 100644 --- a/.github/workflows/release-jupyter.yml +++ b/.github/workflows/release-jupyter.yml @@ -55,6 +55,13 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} + # No feature flags: `raster-plots` is default, precisely so that a + # plain build matches the released wheel. It adds the png/jpeg/tiff + # writers, which need a GPU adapter at *run* time and fall back to + # SVG without one, so a wheel carrying them still works on a + # headless machine. No before-script-linux either: ggsql enables + # fontique's `fontconfig-dlopen`, so the build needs no + # fontconfig-devel, which this image does not ship. args: --release --out dist --auditwheel=skip working-directory: ggsql-jupyter manylinux: 2_28 diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index ce5335885..6dc722dba 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -27,7 +27,7 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable with: targets: x86_64-pc-windows-msvc @@ -110,7 +110,7 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable with: targets: x86_64-apple-darwin @@ -224,7 +224,7 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin @@ -338,7 +338,7 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable with: targets: x86_64-unknown-linux-gnu @@ -392,7 +392,7 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable with: targets: aarch64-unknown-linux-gnu @@ -450,7 +450,7 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + uses: dtolnay/rust-toolchain@stable - name: Get crates.io publish token uses: rust-lang/crates-io-auth-action@v1 @@ -511,9 +511,8 @@ jobs: run: npm install -g tree-sitter-cli - name: Install Rust toolchain - # This job only builds wasm, which is exempt from the 1.86 MSRV (R - # doesn't use wasm) and needs a newer rustc, so the whole job runs on - # stable. ggsql-wasm/rust-toolchain.toml also selects stable for the build. + # Stable. wasm is exempt from the MSRV (see /CLAUDE.md, "Rust version + # (MSRV)"), and ggsql-wasm/rust-toolchain.toml selects stable anyway. uses: dtolnay/rust-toolchain@stable - name: Update apt package index @@ -522,8 +521,9 @@ jobs: - name: Install LLVM run: sudo apt-get install -y llvm - - name: Install wasm-pack - run: cargo install wasm-pack + - name: Install wasm-bindgen CLI + run: | + cargo install -f wasm-bindgen-cli --version "$(awk '/^name = "wasm-bindgen"$/{f=1;next} f&&/^version = /{gsub(/[",]/,"");print $3;exit}' Cargo.lock)" - name: Install wasm-opt run: cargo install wasm-opt diff --git a/.github/workflows/test-extension.yaml b/.github/workflows/test-extension.yaml index e7fde9868..89ed7fa84 100644 --- a/.github/workflows/test-extension.yaml +++ b/.github/workflows/test-extension.yaml @@ -106,7 +106,10 @@ jobs: cache-dependency-path: ggsql-vscode/package-lock.json - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@1.86.0 + # Stable, matching every other job that builds a real binary — and the + # kernel released to PyPI. The 1.86 MSRV covers the library only and is + # checked in build.yaml; see /CLAUDE.md, "Rust version (MSRV)". + uses: dtolnay/rust-toolchain@stable # Use publish.yaml's cargo cache with `save-if: false`. - name: Restore the cargo cache built by publish.yaml diff --git a/.gitignore b/.gitignore index 37e2531c2..b3c5035a9 100644 --- a/.gitignore +++ b/.gitignore @@ -80,9 +80,8 @@ perf.data* criterion/ # WASM artifacts -/ggsql-wasm/pkg/ +/ggsql-wasm/pkg/dist/ /ggsql-wasm/demo/dist/ -/ggsql-wasm/library/dist/ # C/C++ build artifacts (from tree-sitter) *.o diff --git a/CHANGELOG.md b/CHANGELOG.md index f9aa35d11..5b6ae8d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,68 @@ ## [Unreleased] ### Added - +- Seven new writers render a plot directly, through + [hephaestus](https://github.com/posit-dev/hephaestus): `svg`, `pdf` and `hep` + as default features, and `png`, `jpeg`, `tiff` and `webp` off by default and + needing a GPU adapter at render time. Each takes the canvas settings `width`, + `height`, `units` (`px`, `in`, `cm`, `mm`, `pt`), `dpi` and `background`, + plus what its own format offers: `compression` for `png` and `tiff`, + `quality` for `jpeg`, `text`, `embed-fonts` and `id-prefix` for `svg`, + `compress` and `links` for `pdf`, `lossy` and `embed-fonts` for `hep`. + `webp` has none — it is lossless with no rate control. +- `LABEL caption` is honored by the new writers. It has no Vega-Lite equivalent + and is ignored there. +- New `ggsql view` subcommand shows a query's plot in a native window, blocking + until it is closed. Resizing re-lays-out the plot rather than stretching it. + `-D` (`--viewer-option`) takes `width`, `height`, `background` and `title`; + `units` and `dpi` are refused, since a window is sized in logical pixels and + its resolution belongs to the display. Behind a new off-by-default `window` + feature, and needs a GPU adapter; the subcommand exists either way and says + what would enable it. - New `equal_earth` map projection (`+proj=eqearth`). - - New caching layer that wraps any `Reader` with an in-memory, writeable cache backend (currently duckdb or sqlite), making write-constrained databases usable and avoiding repeated remote reads during interactive iteration. Memoized reads are bounded by a TTL and an LRU byte budget, configurable per - connection. The cache can be cleared mid-session with the `-- @uncache` meta-command. -- New `PngWriter` renders a plot to a PNG raster image via - [hephaestus](https://github.com/posit-dev/hephaestus), behind a new - off-by-default `png` feature (`--writer png` in the CLI). `LABEL caption` - and the new `minor_breaks` setting have no Vega-Lite equivalent and render - only here. Requires a working GPU adapter — hardware or software, e.g. - lavapipe — at render time. + connection. Selected by the composite connection scheme + `+://…` (e.g. `duckdb+odbc://…`) or, in the CLI, by + `--cache ` on `exec`, `run` and `view`. The cache can be + cleared mid-session with the `-- @uncache` meta-command. - Writers can be configured from key–value options: `Writer::from_options` takes a `WriterOptions` set, and the CLI collects them from a repeatable `--writer-option key=value` flag on `exec` and `run` (short `-D`, also spellable `--writer-options`). Several settings can be collapsed into one flag separated by `;` — `-D 'width=1600;dpi=150'`, quoted because shells read `;` - themselves — and the two forms mix. The png writer - takes `width`, `height`, `units` (`px`, `in`, `cm`, `mm`, `pt`), `dpi`, and - `background` (any CSS color, including `transparent`), defaulting to a - 1500×1000 px white canvas at 300 dpi; the Vega-Lite writer takes none. An - unknown key or unusable value is an error naming the option, not a silently - ignored setting. + themselves — and the two forms mix. An unknown key or unusable value is an + error naming the option, not a silently ignored setting. - `--reader`, `--writer`, and `--output` gained the short forms `-r`, `-w`, and `-o` on `exec` and `run`; `validate --reader` also takes `-r`. -- Text is rendered as rich text (markdown) by the png writer. A text layer's +- `--output`'s extension picks the writer when `--writer` is omitted: `svg`, + `pdf`, `hep`, `png`, `jpg`/`jpeg`, `tif`/`tiff`, `webp` and `json`/`vl.json` + each name their own. An explicit `--writer` still wins, warning on stderr if + it disagrees with the extension. An unrecognised extension falls back to + Vega-Lite; an extension naming a writer the build lacks is an error. +- New `ggsql::fonts::{register_font, registered_font_families, + set_generic_family}` let a host register font faces itself, for a platform + with no font database to enumerate. A browser is the case that needs it: it + enumerates nothing, so the wasm package ships four Roboto faces and registers + them before drawing — without them a plot has no text at all, and, since text + is what sets the layout, the wrong margins with it. A page wanting its own + typography calls `registerFontFromUrl(url, { genericFor })` instead, which + registers the face and points a generic at whatever family name the file + turned out to carry — the one place that name exists. +- New off-by-default `webfonts` feature: `fonts::register_font` also accepts the + WOFF and WOFF2 containers a font CDN serves a browser, unwrapping them to the + sfnt inside. That is how a font arrives at a web page, so `ggsql-wasm` turns + it on. Without the feature such a container is refused by name rather than + reaching the shaper and registering nothing, which would draw a plot with no + text and no indication why. +- Text is rendered as rich text (markdown) by the new writers. A text layer's `label` is parsed for `**bold**`, `*italic*`, `_underline_`, `~~strike~~`, `` `code` `` and marquee-style `{selector body}` spans that set a colour or size (`{.red hot}`, `{#0072B2 blue}`, `{.20 big}`), and so are the plot title, - subtitle, caption and axis titles set with `LABEL`. Legend titles and break - labels (axis tick labels, legend keys) do not parse yet and show their markers. - The new `parse` setting on the text layer turns it off for that layer + subtitle, caption, axis titles, legend titles and break labels. The new + `parse` setting on the text layer turns it off for that layer (`SETTING parse => false`), drawing the label exactly as given; it defaults to `true`. Chrome text has no switch yet. The Vega-Lite writer has no rich-text equivalent and ignores `parse`, always drawing text literally. @@ -42,8 +70,7 @@ subdivisions between breaks: a whole number of minor breaks *per interval between two breaks* (`0` removes them), an array of exact positions, or — for temporal scales — an interval such as `'week'`. Defaults to a value chosen by the - transformation. This has no Vega-Lite equivalent and is ignored by that writer; - the png writer draws them. + transformation. This has no Vega-Lite equivalent and is ignored by that writer. - The VS Code / Positron extension now ships the `ggsql-jupyter` kernel, so installing the extension is all that is needed to run queries. It is offered @@ -56,6 +83,38 @@ - `ggsql-jupyter` accepts `--version`. ### Changed +- The wasm bundle draws plots with ggsql's own renderer instead of emitting + Vega-Lite. A query is executed in the browser and drawn straight to SVG, so + the playground and the live examples on the docs site look like every other + ggsql output rather than like a second implementation, and `vega`, + `vega-lite` and `vega-embed` are gone from the page — about 1.8 MB less + JavaScript. A plot re-solves its layout when its box changes size, so a wider + pane gets more tick labels rather than stretched ones. **Breaking:** + `GgsqlContext.execute` returns a `GgsqlPlot` to draw rather than a Vega-Lite + JSON string; the npm package is entered through a new `ggsql.js` wrapper that + adds `PlotView` and `registerDefaultFonts` beside it. `init()` now wires the + extension loader itself, so `initExtensionLoader` is gone. +- Plots in a Positron console now open a `positron.plot` comm, so the Plots pane + renders them at its own size, re-renders sharp when resized, and its save, + copy and zoom affordances work on them. A new `--max-plots` (default 32) caps + the retained history, closing the oldest first. Once the pane has reported a + size, a new plot arrives already rendered at it. +- Plots in notebooks and documents are now rendered by the kernel and no longer + need network access. A `VISUALISE` query in JupyterLab, a Positron notebook or + a Quarto render previously emitted HTML that fetched vega, vega-lite and + vega-embed from a CDN on every render; it now emits a rendered image. +- Quarto's figure settings are honoured: `QUARTO_FIG_FORMAT` selects the writer + (`png`, `jpeg`, `svg`, `pdf`) and `QUARTO_FIG_WIDTH`/`_HEIGHT` are read as + inches at `QUARTO_FIG_DPI`, so `fig-width: 6` means six inches and a PDF + document gets a vector figure. +- Kernel plots render as SVG wherever raster output is unavailable — no GPU + adapter, or a build with the new `raster-plots` feature turned off — so a GPU + is needed for raster output, not to see a plot. +- An unknown writer, a writer whose feature is off, and an unusable writer + setting are now reported **before** the query runs rather than after. + `--writer` and `-D` list every writer and its settings in their long help, + marking the ones this build does not have and naming the feature that would + add each. - A `FROM` on the `VISUALISE` clause now takes exactly one bare source. It previously reused the SQL `FROM` grammar while only ever reading the first source, so `VISUALISE FROM a, b` silently plotted `a` alone and @@ -75,17 +134,16 @@ - Categorical `y` axes now run bottom-up, so the first level sits at the bottom of the panel as it does in ggplot2. This affects every plot with a discrete or ordinal `y` — horizontal bars, boxplots and violins by category, points and - 2D jitter — and brings the Vega-Lite writer in line with the raster one, which - already read this way. -- Banded marks now measure against the full step in the VegaLite writer. A band fraction - (a bar's `width`, a dodge displacement, a jitter spread, a violin or boxplot - half-width, a discrete tile's extent) is a fraction of the whole category step, - so `width => 0.9` leaves a 10% gap — ggplot2's convention. Vega-Lite previously - subtracted its own default band padding first, making every banded mark there - narrower than the same query rendered as a raster. This applies to dodged, - jittered and half-sided layers too, where Vega-Lite reserved a further 20% of - every step: their marks were narrower, their displacements smaller, and their - category ticks pulled toward the middle of the panel. + 2D jitter — and brings the Vega-Lite writer in line with the new writers, + which already read this way. +- Banded marks now measure against the full step in the Vega-Lite writer. A band + fraction (a bar's `width`, a dodge displacement, a jitter spread, a violin or + boxplot half-width, a discrete tile's extent) is a fraction of the whole + category step, so `width => 0.9` leaves a 10% gap — ggplot2's convention. + Vega-Lite previously subtracted its own default band padding first, and a + further 20% of every step for dodged, jittered and half-sided layers, so + their marks were narrower, their displacements smaller and their category + ticks pulled toward the middle of the panel. - Position scales like `SCALE lon` and `SCALE lat` transfer their limits to map projections, and transfer their `breaks` setting to the graticule (#492). @@ -151,12 +209,6 @@ - In plain VS Code, the extension no longer offers run buttons, keybindings or Command Palette entries for commands that need the Positron runtime and so had no handler there. -- Plots in Positron notebooks no longer come out blank when the cell output is - rendered before Positron has laid the slot out, which happened on the first - execution after a kernel started and when reopening a saved notebook. The - plot sizes itself from its container, so a zero-width first measurement drew - it at zero size with nothing left to correct it. It now recovers once the - container has a real width. - ggsql interpreter sessions in Positron now come back after an extension host restart as well as after a window reload. A session the user renamed also keeps its name across the restore, and ggsql runtimes are rediscovered on diff --git a/CLAUDE.md b/CLAUDE.md index 8ce276632..acdc28ab4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,9 @@ ggsql query ──► parser ──► Plot AST ──► executor ── - The parser splits the query at the `VISUALISE` boundary. SQL goes to a pluggable `Reader` (DuckDB, SQLite, ODBC); the VISUALISE part becomes a typed `Plot`. - The executor ties the two together: SQL → DataFrame, AST resolved against actual schema, stats and scales applied per layer. -- The writer renders the resolved `Spec` to an output format: Vega-Lite JSON (default), or PNG (non-default `png` feature). The PNG writer is implemented on top of the hephaestus renderer — a name that stays internal; users see `png`. +- The writer renders the resolved `Spec` to an output format: Vega-Lite JSON (the default writer), SVG, PDF or a `.hep` plot document (all default features), or one of the four raster formats — PNG, JPEG, TIFF, WebP (non-default; they need a GPU adapter). Everything but Vega-Lite is implemented on top of the hephaestus renderer — a name that stays internal; users see the format names. -For details — module layout, traits, where extension points live — see [`src/CLAUDE.md`](src/CLAUDE.md). For a specific renderer, [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md) (Vega-Lite) or [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md) (PNG). For the AST types, [`src/plot/CLAUDE.md`](src/plot/CLAUDE.md). +For details — module layout, traits, where extension points live — see [`src/CLAUDE.md`](src/CLAUDE.md). For a specific renderer, [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md) (Vega-Lite) or [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md) (the other seven). For the AST types, [`src/plot/CLAUDE.md`](src/plot/CLAUDE.md). ## Building @@ -73,14 +73,27 @@ cd tree-sitter-ggsql && npx tree-sitter generate ### Rust version (MSRV) -The MSRV is **Rust 1.86**, declared as `rust-version` in `/Cargo.toml`. This is the maximum Rust version CRAN ships, and the R bindings must build against it — **only bump it when CRAN does.** `rust-version` also turns on clippy's MSRV-aware lints, so accidental use of a newer std API is flagged by `clippy` with a clear message instead of a cryptic `E0658`. +The MSRV is **Rust 1.86**, declared as `rust-version` in `/Cargo.toml`. This is the maximum Rust version CRAN ships, and the R package's vendored copy of ggsql has to build against it — **only bump it when CRAN does.** `rust-version` also points clippy's MSRV-aware lints at 1.86, so an accidental 1.87+ std API is flagged as a lint rather than surfacing as a cryptic `E0658` at vendoring time. Keeping that guard is the main reason not to raise the declaration to match a dependency's. -There is deliberately **no root `rust-toolchain.toml`**: pinning the toolchain to 1.86 would force local `cargo test`/rust-analyzer onto 1.86, where the `adbc` test path can't build (see below). Instead, the MSRV is *enforced in CI* — `build.yaml` installs 1.86 as the default toolchain and runs fmt, clippy and the library build on it. Locally, use your normal toolchain; run `cargo +1.86 build` if you want to check MSRV compatibility directly. +**`parley` declares 1.88 while compiling fine on 1.86.** It is non-optional for the default `svg`/`pdf`/`hep` writers, so cargo's floor check refuses on 1.86 until `--ignore-rust-version` bypasses the *declaration*. That flag is not papering over a real incompatibility — the library genuinely compiles, which CI proves: + +```sh +# The CRAN-toolchain claim, checked rather than asserted. Library only: +# --all-targets pulls the `adbc` dev-dependency path, and datafusion uses +# let-chains, so 1.86 fails there for real. +cargo +1.86 check --ignore-rust-version -p ggsql +``` + +There is deliberately **no root `rust-toolchain.toml`**: pinning to 1.86 would force local `cargo test` and rust-analyzer onto a toolchain where the `adbc` test path can't build. CI's default toolchain is stable — which still guards the MSRV, because clippy reads `rust-version`, not the toolchain — with the 1.86 check as its own step. Two things are exempt from the 1.86 MSRV: -- **The `adbc` test path.** The experimental `adbc` feature depends (dev-only) on `adbc_datafusion` → `datafusion` ≥53.1.0, which requires rustc ≥1.88. The shipped library still builds on 1.86 (it uses only `adbc_core`); only the test / `--all-targets` build pulls `datafusion`. CI runs the test-compiling steps with `cargo +stable`. -- **The wasm bindings (`ggsql-wasm`).** R doesn't use wasm, and some wasm-only dependencies require a newer rustc, so the crate has no `rust-version` and a nested `ggsql-wasm/rust-toolchain.toml` selects **stable** for any build done from that directory (`./build-wasm.sh`, `wasm-pack`, `library/`). +- **The `adbc` test path.** The experimental `adbc` feature depends (dev-only) on `adbc_datafusion` → `datafusion` ≥53.1.0, which uses let-chains and so requires rustc ≥1.88 for real. The shipped library still builds on 1.86 (it uses only `adbc_core`); only the test / `--all-targets` build pulls `datafusion`. +- **The wasm bindings (`ggsql-wasm`).** R doesn't use wasm, and some wasm-only dependencies require a newer rustc, so the crate has no `rust-version` and a nested `ggsql-wasm/rust-toolchain.toml` selects **stable** for builds run from that directory (`./build-wasm.sh`, Cargo, or `pkg/`). + +### Rendering plots on Linux + +`svg`, `pdf` and `hep` are **default features** and need no GPU adapter, no wgpu and — importantly — **no `libfontconfig1-dev`**: `src/Cargo.toml` names `fontique` directly to enable `fontconfig-dlopen`, so fontconfig is loaded at run time rather than linked at build time. That is what makes them safe to have on by default; see [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md). The four raster writers (`png`, `jpeg`, `tiff`, `webp`) and `ggsql view` are **not** default and need an adapter at run time. Cross-platform installers (NSIS / MSI / DMG / Deb): see [`INSTALLERS.md`](INSTALLERS.md). Releases are tag-driven via `.github/workflows/`. @@ -117,7 +130,8 @@ Per-folder CLAUDE.md files cover component-specific test guidance. - *How does the parser work? How is a `Plot` built?* → [`src/CLAUDE.md`](src/CLAUDE.md), then `src/parser/`. - *How do I add a new geom / scale type / coord?* → [`src/plot/CLAUDE.md`](src/plot/CLAUDE.md). - *How does Vega-Lite output get assembled?* → [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md). -- *How does the raster (PNG) writer work?* → [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md), which also lists its known gaps. +- *How do the raster, SVG, PDF and `.hep` writers work?* → [`src/writer/hephaestus/CLAUDE.md`](src/writer/hephaestus/CLAUDE.md), which also lists their known gaps. +- *How does `ggsql view` show a plot in a window?* → [`ggsql-cli/CLAUDE.md`](ggsql-cli/CLAUDE.md), then `PlotViewer` in [`src/writer/hephaestus/window.rs`](src/writer/hephaestus/window.rs). - *How does a query become rendered output end-to-end?* → [`src/CLAUDE.md`](src/CLAUDE.md) (execution pipeline), then `src/execute/`. - *How does the Jupyter kernel route messages?* → [`ggsql-jupyter/CLAUDE.md`](ggsql-jupyter/CLAUDE.md). - *How does the VS Code / Positron extension talk to the kernel?* → [`ggsql-vscode/CLAUDE.md`](ggsql-vscode/CLAUDE.md). diff --git a/Cargo.lock b/Cargo.lock index 788e814dd..773113aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adbc_core" version = "0.23.0" @@ -98,12 +114,43 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -113,6 +160,17 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width 0.2.2", +] + [[package]] name = "anstream" version = "1.0.0" @@ -187,6 +245,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -381,7 +445,7 @@ version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" dependencies = [ - "bitflags", + "bitflags 2.11.1", "serde_core", "serde_json", ] @@ -417,6 +481,21 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -531,6 +610,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -558,13 +643,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -597,6 +691,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -663,12 +766,44 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cast" version = "0.3.0" @@ -787,6 +922,9 @@ name = "color" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] [[package]] name = "colorchoice" @@ -794,6 +932,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "comfy-table" version = "7.1.4" @@ -805,6 +953,25 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-random" version = "0.1.18" @@ -864,12 +1031,46 @@ dependencies = [ "crossterm 0.29.0", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -985,7 +1186,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "parking_lot", "rustix 0.38.44", @@ -998,7 +1199,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crossterm_winapi", "derive_more", "document-features", @@ -1068,6 +1269,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "dashmap" version = "5.5.3" @@ -1577,7 +1784,7 @@ dependencies = [ "itertools", "parking_lot", "paste", - "petgraph", + "petgraph 0.8.3", "tokio", ] @@ -1763,6 +1970,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -1776,14 +1984,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.11.1", + "objc2 0.6.4", ] [[package]] @@ -1815,6 +2029,18 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "duckdb" version = "1.10502.0" @@ -1855,6 +2081,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1924,6 +2159,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "filetime" version = "0.2.29" @@ -1952,7 +2193,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "rustc_version", ] @@ -1978,6 +2219,12 @@ dependencies = [ "serde", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -2017,10 +2264,10 @@ dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-text", - "objc2-foundation", + "objc2-foundation 0.3.2", "parlance", "read-fonts 0.39.2", "roxmltree", @@ -2030,6 +2277,33 @@ dependencies = [ "yeslogic-fontconfig-sys", ] +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2201,6 +2475,16 @@ dependencies = [ "wkt", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2254,6 +2538,7 @@ dependencies = [ "const_format", "csscolorparser", "duckdb", + "fontique", "geozero", "hephaestus", "jsonschema", @@ -2273,6 +2558,7 @@ dependencies = [ "tree-sitter-ggsql", "ureq", "uuid", + "wuff", ] [[package]] @@ -2282,6 +2568,7 @@ dependencies = [ "anyhow", "clap", "ggsql", + "png", "regex", "serde_json", "termimad", @@ -2294,6 +2581,7 @@ version = "0.4.1" dependencies = [ "anyhow", "arrow", + "base64", "bytes", "chrono", "clap", @@ -2317,6 +2605,7 @@ name = "ggsql-wasm" version = "0.4.1" dependencies = [ "arrow", + "console_error_panic_hook", "getrandom 0.2.17", "ggsql", "js-sys", @@ -2339,6 +2628,22 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa 0.44.0", + "smallvec", + "vello_common", +] + [[package]] name = "glob" version = "0.3.3" @@ -2386,7 +2691,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags", + "bitflags 2.11.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -2397,7 +2702,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2407,7 +2712,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ "euclid", - "svg_fmt", ] [[package]] @@ -2428,7 +2732,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "551ed25397e4b444e89686602877d5cf3a7f6e3d548dcac37a8357d1e195f4df" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytemuck", "core_maths", "read-fonts 0.39.2", @@ -2505,24 +2809,39 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hephaestus" -version = "0.1.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca883b65468b673aab28f1c7a86690ad5a00c31a4547c93577ecfd569c47e9b4" +checksum = "755ec8c5f3f62917c498d1fdbc6704dad9432c5f523696337895601342aa8c43" dependencies = [ "bytemuck", "clipper2-rust", + "flate2", "futures-intrusive", + "glifo", + "image-webp", + "jpeg-decoder", + "jpeg-encoder", "kurbo", "parley", "peniko", "png", "pollster", "pulldown-cmark", + "skrifa 0.44.0", "thiserror 2.0.18", - "vello", + "tiff", + "vello_common", + "vello_hybrid", "wgpu", + "winit", ] +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -2826,6 +3145,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2871,6 +3200,36 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2909,6 +3268,18 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + [[package]] name = "js-sys" version = "0.3.98" @@ -2948,6 +3319,15 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2993,14 +3373,45 @@ dependencies = [ ] [[package]] -name = "lazy-regex" -version = "3.6.0" +name = "lalrpop" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", + "ascii-canvas", + "bit-set 0.8.0", + "ena", + "itertools", + "lalrpop-util", + "petgraph 0.7.1", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", ] [[package]] @@ -3027,6 +3438,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexical" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc8a009b2ff1f419ccc62706f04fe0ca6e67b37460513964a3dfdb919bb37d6" +dependencies = [ + "lexical-core", +] + [[package]] name = "lexical-core" version = "1.0.6" @@ -3123,6 +3543,18 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + [[package]] name = "libsqlite3-sys" version = "0.36.0" @@ -3179,6 +3611,38 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "logos" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970" +dependencies = [ + "fnv", + "proc-macro2", + "quote", + "regex-automata", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31" +dependencies = [ + "logos-codegen", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3263,7 +3727,7 @@ checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" dependencies = [ "arrayvec", "bit-set 0.9.1", - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -3281,6 +3745,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3290,6 +3775,12 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3379,6 +3870,44 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3388,15 +3917,91 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dispatch2", - "objc2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", ] [[package]] @@ -3405,7 +4010,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2-core-foundation", ] @@ -3415,27 +4020,77 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.11.1", + "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-metal" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-foundation", + "bitflags 2.11.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -3444,11 +4099,66 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", - "objc2", + "bitflags 2.11.1", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -3489,6 +4199,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -3513,6 +4233,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "palette" version = "0.7.6" @@ -3554,7 +4283,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -3675,6 +4404,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ + "bytemuck", "color", "kurbo", "linebender_resource_handle", @@ -3687,6 +4417,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -3743,6 +4483,15 @@ dependencies = [ "uncased", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -3762,6 +4511,26 @@ dependencies = [ "uncased", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3774,19 +4543,39 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "pollster" version = "0.4.0" @@ -3837,6 +4626,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -3897,7 +4692,7 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", "prost", "prost-types", @@ -3954,11 +4749,26 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags", + "bitflags 2.11.1", "memchr", "unicase", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4118,10 +4928,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -4145,13 +4955,31 @@ dependencies = [ "once_cell", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.11.1", ] [[package]] @@ -4351,7 +5179,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.11.1", "chrono", "fallible-iterator", "fallible-streaming-iterator", @@ -4405,7 +5233,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4418,7 +5246,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -4506,6 +5334,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4518,6 +5352,19 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04c565b551bafbef4157586fa379538366e4385d42082f255bfd96e4fe8519da" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4652,6 +5499,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -4704,6 +5561,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -4755,7 +5622,41 @@ dependencies = [ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] [[package]] name = "snap" @@ -4779,7 +5680,7 @@ version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -4847,6 +5748,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4905,12 +5824,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "svg_fmt" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" - [[package]] name = "syn" version = "1.0.109" @@ -4933,6 +5846,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4983,6 +5907,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -5068,6 +6001,18 @@ dependencies = [ "ordered-float 2.10.1", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "flate2", + "half", + "quick-error", + "weezl", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5077,6 +6022,31 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -5261,7 +6231,7 @@ version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -5380,6 +6350,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "twox-hash" version = "2.1.2" @@ -5595,48 +6571,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "vello" -version = "0.10.0" +name = "vello_common" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af76ceb17b2869be23598baef40a9c8db4de86b87c60510c3bc245559275a617" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", - "futures-intrusive", + "fearless_simd", + "guillotiere", "log", "peniko", - "png", - "skrifa 0.44.0", - "static_assertions", + "smallvec", "thiserror 2.0.18", - "vello_encoding", - "vello_shaders", - "wgpu", ] [[package]] -name = "vello_encoding" -version = "0.10.0" +name = "vello_hybrid" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e31cd622201690d8dfe9fd8fea8d1ae59db1bfeea414856a617d1d03438418c" +checksum = "dccbb4221070e7ef92486abef0002a651702f00a52d6be532249a0fa0eb3e9ba" dependencies = [ "bytemuck", - "guillotiere", - "peniko", - "skrifa 0.44.0", - "smallvec", + "glifo", + "hashbrown 0.17.1", + "log", + "thiserror 2.0.18", + "vello_common", + "vello_sparse_shaders", + "wgpu", ] [[package]] -name = "vello_shaders" -version = "0.10.0" +name = "vello_sparse_shaders" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abf943bd2920bfd22928a9c1bad39866f7ffcc1109b6ed924ab52773e3868a83" +checksum = "86e5fd6b8d73d641ffe8522f05e2d5a7f23620005dfc1a7f49488790b9970267" dependencies = [ - "bytemuck", - "log", - "naga", - "thiserror 2.0.18", - "vello_encoding", + "wesl", + "wesl-macros", + "wgsl-parse", + "wgsl-types", ] [[package]] @@ -5778,12 +6752,109 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + [[package]] name = "wayland-sys" version = "0.31.11" @@ -5825,6 +6896,41 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wesl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3857a39e7220245e4ec1e2f6230e1b5ccd3542545bc1ee5c13d79597e6d193" +dependencies = [ + "annotate-snippets", + "derive_more", + "half", + "itertools", + "num-traits", + "thiserror 2.0.18", + "wesl-macros", + "wgsl-parse", + "wgsl-types", +] + +[[package]] +name = "wesl-macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8662ee0b2ef199c31f486b869e5272ac364898c09d352483370d37b8c3abf9f9" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "wgpu" version = "29.0.3" @@ -5832,7 +6938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" dependencies = [ "arrayvec", - "bitflags", + "bitflags 2.11.1", "bytemuck", "cfg-if", "cfg_aliases", @@ -5863,7 +6969,7 @@ dependencies = [ "arrayvec", "bit-set 0.9.1", "bit-vec 0.9.1", - "bitflags", + "bitflags 2.11.1", "bytemuck", "cfg_aliases", "document-features", @@ -5881,7 +6987,6 @@ dependencies = [ "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", - "wgpu-core-deps-wasm", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -5906,15 +7011,6 @@ dependencies = [ "wgpu-hal", ] -[[package]] -name = "wgpu-core-deps-wasm" -version = "29.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2f2fb042f36920771deb0b966543c5751b18f3d327760ffc90f74e20b2dcd4" -dependencies = [ - "wgpu-hal", -] - [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.3" @@ -5934,8 +7030,8 @@ dependencies = [ "arrayvec", "ash", "bit-set 0.9.1", - "bitflags", - "block2", + "bitflags 2.11.1", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", @@ -5951,11 +7047,11 @@ dependencies = [ "log", "naga", "ndk-sys", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", - "objc2-metal", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float 5.3.0", "parking_lot", @@ -5994,7 +7090,7 @@ version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytemuck", "js-sys", "log", @@ -6002,6 +7098,34 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgsl-parse" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2141e2425fbb5aefd13e875adc7247e5dc889a3bbbe5a016dd1546211405f89f" +dependencies = [ + "annotate-snippets", + "derive_more", + "itertools", + "lalrpop", + "lalrpop-util", + "lexical", + "logos", + "thiserror 2.0.18", + "wgsl-types", +] + +[[package]] +name = "wgsl-types" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3cf8623d173060d5a9e1465b89954ea42f3ac21af2f533c7975d003f8d82b98" +dependencies = [ + "half", + "itertools", + "num-traits", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6319,6 +7443,58 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash 0.8.12", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.7.15" @@ -6401,7 +7577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -6449,6 +7625,17 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wuff" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d" +dependencies = [ + "brotli-decompressor", + "bytes", + "flate2", +] + [[package]] name = "wyz" version = "0.5.1" @@ -6458,6 +7645,38 @@ dependencies = [ "tap", ] +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -6468,6 +7687,31 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "xml-rs" version = "0.8.28" diff --git a/Cargo.toml b/Cargo.toml index beaa88a36..b0678c3fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,9 +17,16 @@ resolver = "2" [workspace.package] version = "0.4.1" edition = "2021" -# Pinned to CRAN's maximum supported Rust version for the R bindings. Only bump -# this when CRAN does. Setting it here also turns on clippy's `incompatible_msrv` -# lint, which catches accidental use of std APIs newer than this version. +# CRAN's maximum Rust version, which the R package's vendored copy of ggsql has +# to build against — **only bump this when CRAN does.** Declaring it here also +# points clippy's MSRV-aware lints at 1.86, so an accidental 1.87+ std API is a +# clear lint rather than a cryptic `E0658` at vendoring time. That guard is the +# main reason not to raise this to match a dependency. +# +# `parley` — non-optional for the default svg/pdf/hep writers — *declares* 1.88 +# while compiling fine on 1.86, so cargo's floor check refuses on 1.86 until +# `--ignore-rust-version` bypasses the declaration. CI proves the code itself +# still builds there; see the CRAN-toolchain step in build.yaml. rust-version = "1.86" authors = ["ggsql Team"] license = "MIT" @@ -70,6 +77,7 @@ chrono = "0.4" rand = "0.8" const_format = "0.2" uuid = { version = "1.0", features = ["v4"] } +base64 = "0.22" tokio = { version = "1.35", default-features = false } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/INSTALLERS.md b/INSTALLERS.md index f2a15b907..caa9d3bdd 100644 --- a/INSTALLERS.md +++ b/INSTALLERS.md @@ -17,6 +17,29 @@ ggsql ships native installers for Windows, macOS, and Linux. Windows (NSIS / MSI - **macOS**: Xcode Command Line Tools, plus [`dylibbundler`](https://github.com/auriamg/macdylibbundler) (`brew install dylibbundler`) for bundling Arrow / DuckDB dynamic libraries - **Linux**: `sudo apt-get install libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf` +3. **Rendering plots** (the default `svg`, `pdf` and `hep` writers, and the + optional raster ones): + + - **A GPU is needed for *raster* output, not to see a plot.** `svg`, `pdf` + and `hep` need no adapter and no wgpu at all, and they are on by default. + The optional `png`, `jpeg`, `tiff` and `webp` writers, and `ggsql view`, + render through wgpu and need a Vulkan/Metal/DX12 adapter at run time — + hardware or software (Mesa's lavapipe, `mesa-vulkan-drivers`). + - **Linux needs fontconfig at *run* time only.** Text layout enumerates + fonts through the system fontconfig, but ggsql enables fontique's + `fontconfig-dlopen`, so `libfontconfig.so.1` is loaded when it is used + rather than linked when it is built: **no `libfontconfig1-dev` / + `fontconfig-devel` is required to build**, which is what keeps + `cargo install ggsql-cli` working on a bare box. Verified on + `debian:bookworm-slim` and in `manylinux_2_28`, neither of which ships the + `-dev` package. + - **A box with no fontconfig at all still renders, but silently drops every + label.** The geometry is drawn and the exit code is 0, so a container + built down to nothing produces a plot with no title, no axis labels and no + tick labels rather than an error. Install `libfontconfig1` (or + `fontconfig`) and at least one font — `fonts-dejavu-core` is enough. + - **macOS and Windows need nothing extra**; they use CoreText and DirectWrite. + ### Build Installers Locally ```bash diff --git a/doc/get_started/tooling/cli.qmd b/doc/get_started/tooling/cli.qmd index b22931b14..47b79250f 100644 --- a/doc/get_started/tooling/cli.qmd +++ b/doc/get_started/tooling/cli.qmd @@ -34,6 +34,8 @@ Such files can be rendered as images using tools that work with Vega-Lite specs, vl-convert vl2png -i chart.vl.json -o chart.png ``` +`ggsql` can also render an image itself, without a second tool — see [Output format](#output-format) below. + A standard SQL query can also be provided to ggsql. If the query returns a table, the resulting values will be written to `stdout`. ## Validating a query @@ -47,7 +49,7 @@ $ ggsql validate "VISUALISE x, y FROM table DRAW point" ## Database connections -Both `ggsql exec` and `ggsql run` accept a `--reader` flag (short `-r`) that can be used to specify a connection string to be used when executing the query. If not provided, ggsql will use an empty in-memory duckdb connection, equivalent to `--reader duckdb://memory`. +`ggsql exec`, `ggsql run` and `ggsql view` all accept a `--reader` flag (short `-r`) that can be used to specify a connection string to be used when executing the query. If not provided, ggsql will use an empty in-memory duckdb connection, equivalent to `--reader duckdb://memory`. ```bash $ ggsql exec --reader sqlite://sample/ggsql_test.sqlite \ @@ -65,9 +67,54 @@ col_a, col_b, col_c 12.5, 29.48, gamma ``` +### Caching reads + +A remote or write-constrained database can be wrapped in an in-memory cache with `--cache`, naming the backend to cache into (`duckdb` or `sqlite`). It is off by default. Repeated reads of the same query — several layers over one table, or the same plot re-run while you iterate on it — are then served from memory instead of the remote: + +```bash +$ ggsql view --reader odbc://DSN=ggsql-pg-test --cache duckdb \ + "SELECT * FROM test_table VISUALISE col_a AS x, col_b AS y DRAW point" +``` + +The same thing can be spelled as a composite connection string, `+://…`, which is what `--cache` is rewritten to: + +```bash +$ ggsql exec --reader duckdb+odbc://DSN=ggsql-pg-test \ + "SELECT * FROM test_table LIMIT 3" +``` + +The two forms cannot be combined — there would be no saying which cache was meant. + ## Output format -`ggsql exec` and `ggsql run` render with the writer named by `--writer` (short `-w`), defaulting to `--writer vegalite` (the Vega-Lite JSON above). A build that includes the optional `png` writer can also render straight to a PNG image with `--writer png`, which needs a GPU adapter available where it runs. +`ggsql exec` and `ggsql run` render with the writer named by `--writer` (short `-w`), defaulting to `--writer vegalite` — the Vega-Lite JSON above. A standard build also writes three other formats directly, with nothing to enable: + +| `--writer` | Output | Needs a GPU | +| --- | --- | --- | +| `vegalite` | A Vega-Lite JSON spec | no | +| `svg` | An SVG image; text stays selectable and editable | no | +| `pdf` | A one-page PDF with embedded subset fonts; text stays selectable | no | +| `hep` | A `.hep` plot document — the resolved plot rather than a picture | no | +| `png` | A PNG image | **yes** | +| `jpeg` (`jpg`) | A JPEG image | **yes** | +| `tiff` | A TIFF image | **yes** | +| `webp` | A lossless WebP image | **yes** | + +**`--output` picks the writer for you.** If you name an output file and leave `--writer` off, the extension decides — so `-o chart.svg` writes SVG and `-o chart.pdf` writes a PDF, with no `-w` needed: + +```bash +ggsql exec -o chart.pdf "VISUALISE species AS fill FROM ggsql:penguins DRAW bar" +``` + +`json` and `vl.json` mean Vega-Lite; `svg`, `pdf`, `hep`, `png`, `jpg`/`jpeg`, `tif`/`tiff` and `webp` indicate file extensions. An extension `ggsql` doesn't recognise, or no `--output` at all, falls back to Vega-Lite. An explicit `--writer` always wins: if it disagrees with the extension, the file holds the format `--writer` named — a `.png` written by `-w svg` is an SVG — and the mismatch is noted on `stderr`. + +**A binary format needs somewhere to go.** `ggsql` will not print the bytes of a `pdf`, `hep` or raster format to a terminal: it says so on `stderr` and exits non-zero. Give the bytes a destination with `--output`, or pipe them somewhere; to just look at the plot, [`ggsql view`](#viewing-a-plot) draws it in a window and writes no file at all. A query with no `VISUALISE` prints its table, and honours `--output` the same way. + +The four raster formats render through the GPU, so they need a graphics adapter — hardware or software — available where `ggsql` runs, and they are not compiled into every build. `svg` and `pdf` need neither, which makes them the ones to reach for on a server, in a container or in CI. Run `ggsql exec --help` to see which writers your build has; asking for one it doesn't says so, and names the feature that would add it. + +`hep` is the odd one out: it produces no picture at all. It captures the *resolved* plot — scales, breaks, labels, theme, geometry and data — so a host can draw it itself at any size, and redraw it on resize, without re-running the query. + +### Writer settings A writer is configured with `--writer-option key=value`, repeated once per setting: @@ -92,7 +139,9 @@ ggsql exec -w png -D 'width=6;height=4;units=in;dpi=150' -o chart.png \ `;` is the only separator; `,` is not, because values contain commas — `background='rgb(255, 0, 0)'` has to survive intact. -The png writer understands these options: +#### The canvas + +Every writer above except `vegalite` takes the same five canvas settings: | Option | Value | Default | | --- | --- | --- | @@ -102,9 +151,44 @@ The png writer understands these options: | `dpi` | Pixels per inch. Sets the print resolution of a physical size, and how large text and other chrome are relative to the canvas | `300` | | `background` | Any CSS color, e.g. `white`, `#faf3e0`, `rgb(0 0 0 / 50%)`, or `transparent` | `white` | -`units` applies to the `width` and `height` you supply — the defaults are pixel counts either way, so `--writer-option width=6 --writer-option units=in` gives a canvas 6 inches wide and 1000 pixels tall. +`units` applies to the `width` and `height` you supply — the defaults are pixel counts either way, so `-D 'width=6;units=in'` gives a canvas 6 inches wide and 1000 pixels tall. + +`svg` and `pdf` honour a physical size in the file itself, so `-D 'width=6;height=4;units=in;dpi=300'` yields a file that *prints* six inches wide as well as carrying an 1800-pixel coordinate space. For `hep`, the canvas is recorded as a hint — the aspect and resolution a consumer should default to — rather than a fixed size. + +#### Per-format settings + +Each format offers additional settings: + +| Writer | Option | Value | Default | +| --- | --- | --- | --- | +| `png` | `compression` | `none`, `fast`, `balanced`, `small` | `balanced` | +| `jpeg` | `quality` | `1`–`100` | `90` | +| `tiff` | `compression` | `none`, `deflate`, `lzw`, `packbits` | `deflate` | +| `svg` | `text` | `text` keeps real text; `outline` converts it to paths | `text` | +| `svg` | `embed-fonts` | Inline font faces so the file stands alone | `false` | +| `svg` | `id-prefix` | Prefix for generated element ids | none | +| `pdf` | `compress` | Compress the content stream; `false` makes it readable | `true` | +| `pdf` | `links` | Turn markdown links into PDF link annotations | `true` | +| `hep` | `lossy` | Allow writing a plot the format cannot fully express | `false` | +| `hep` | `embed-fonts` | Embed the fonts the plot uses | `false` | + +`jpeg` has no alpha channel, so it refuses `background=transparent` rather than quietly compositing onto black. `svg`'s `id-prefix` matters when two SVGs are inlined in one page: without it, both define the same gradient ids and each resolves to the other's. + +`webp` is lossless, with no rate control to set, so it takes the canvas settings and nothing else. The Vega-Lite writer takes no options at all: its output is resolution-independent, so size, resolution and background belong to whatever renders the spec. Passing an option a writer doesn't understand or can't honour is an error rather than a setting quietly ignored. Such a misunderstanding is reported before the query runs rather than after. + +When a plot asks for a graphic a format cannot express, `ggsql` says so on `stderr` and still writes the file. This is a defect in the file you produced, so the defect is reported whether or not `--verbose` is set, and it stays out of a piped `svg`. + +## Viewing a plot + +`ggsql view` shows a query's plot in a native window instead of writing a file, and blocks until the window is closed: + +```bash +ggsql view "VISUALISE species AS fill FROM ggsql:penguins DRAW bar" +``` + +Resizing the window re-lays-out the plot rather than stretching it, so labels and gridlines stay correct at any size. `-D` (`--viewer-option`) takes `width`, `height`, `background` and `title`; `units` and `dpi` are refused, since a window is sized in logical pixels and its resolution belongs to the display it is on. -The Vega-Lite writer takes no options: its output is resolution-independent, so size, resolution and background belong to whatever renders the spec. Passing an option a writer doesn't understand is an error rather than a setting quietly ignored. +Like the raster writers, the viewer renders through the GPU and is not in every build. The subcommand exists either way and says what would enable it. ## Documentation diff --git a/doc/syntax/clause/label.qmd b/doc/syntax/clause/label.qmd index 3117dc2a4..619394d92 100644 --- a/doc/syntax/clause/label.qmd +++ b/doc/syntax/clause/label.qmd @@ -17,12 +17,11 @@ There are a few additional labels beside the aesthetics that govern the differen * `title`: The main title of the plot * `subtitle`: An additional, often longer and more descriptive, title beneath the main title -* `caption`: A string placed below the plot, often used to add additional information about the data source etc. Not supported by the Vega-Lite writer, which has no equivalent; the png (raster) writer renders it. +* `caption`: A string placed below the plot, often used to add additional information about the data source etc. Supported by every writer except Vega-Lite, which has no equivalent. ## Rich text -The png writer reads the strings you provide here as markdown, so `LABEL title => 'Sales in **2024**'` renders the year in bold. -This currently applies to the plot title, subtitle and caption, and to the axis titles. -Legend titles and break labels (axis tick labels and legend keys) are still drawn literally — the renderer has no rich-text support on those slots yet, so markdown in them shows its markers. This is a gap rather than a design choice, and they will parse once the renderer catches up. +Every writer except Vega-Lite interprets the strings you provide here as markdown, so `LABEL title => 'Sales in **2024**'` renders the year in bold. +This applies to the plot title, subtitle and caption, to the axis and legend titles, and to break labels (axis tick labels and legend keys). The Vega-Lite writer has no rich-text support at all and draws every label exactly as given. The recognised markdown is the same as for the [text layer](../layer/type/text.qmd#parse). diff --git a/doc/syntax/layer/type/text.qmd b/doc/syntax/layer/type/text.qmd index 4b75c4eb2..c27a3213b 100644 --- a/doc/syntax/layer/type/text.qmd +++ b/doc/syntax/layer/type/text.qmd @@ -81,8 +81,7 @@ The most useful parts for a label are: * `` `code` ``, rendered in the monospace typeface. * `{selector body}` spans, which style a fragment without a dedicated marker. The selector is a single token: a colour name or CSS colour (`{.red hot}`), a hex colour (`{#0072B2 blue}`), or a size in points (`{.20 big}`). Combine them by nesting: `{.red {.20 big and red}}`. -Note that `parse` is only honoured by the png writer. -The Vega-Lite writer has no rich-text support and always draws the label literally, so a query meant for both writers should either avoid markdown in its labels or set `parse => false`. +Note that `parse` is not honoured by the Vega-Lite writer, which has no rich text and always draws the label literally, so a query meant for both writers should either avoid markdown in its labels or set `parse => false`. ## Data transformation This layer supports aggregation through the `aggregate` setting. Aggregation groups are defined by `PARTITION BY` and all discrete mappings. Within each group, every numeric mapping is replaced in place by its aggregated value. Use a default like `'mean'` or target individual aesthetics with `':'`. See [the `DRAW` documentation](../../clause/draw.qmd#aggregate) for the full setting shape. @@ -122,7 +121,7 @@ DRAW text SCALE fontsize TO (6, 20) ``` -Labels are read as markdown, so a `format` template can style part of the label. This only shows up in the png writer. +Labels are read as markdown, so a `format` template can style part of the label. This does not show up in Vega-Lite output, which has no rich text. ```{ggsql} VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins diff --git a/doc/syntax/scale/type/continuous.qmd b/doc/syntax/scale/type/continuous.qmd index 188e093c6..8a6127879 100644 --- a/doc/syntax/scale/type/continuous.qmd +++ b/doc/syntax/scale/type/continuous.qmd @@ -113,7 +113,7 @@ Automatically derived minor breaks are always placed relative to the breaks, so * `minor_breaks => `: for `date`/`datetime`/`time` only, an interval (e.g. `week` or `6 hours`) aligned at the interval boundary, exactly as `breaks` treats an interval ::: {.callout-note} -Minor breaks are only drawn by writers that support them. The Vega-Lite writer has no concept of a minor break and ignores the setting; the png (raster) writer draws them. +Minor breaks are only drawn by writers that support them. The Vega-Lite writer has no concept of a minor break and ignores the setting; every other writer draws them. ::: ### The size aesthetic diff --git a/doc/vendor/SKILL.md b/doc/vendor/SKILL.md index 0849914e8..5db8b79cd 100644 --- a/doc/vendor/SKILL.md +++ b/doc/vendor/SKILL.md @@ -428,7 +428,7 @@ Line segments between two endpoints. Required: x, y, xend, yend. For axis-aligne Reference lines spanning the full panel. Required: x or y. Optional: `slope` (for diagonal: `y = a + slope * x`). ### text -Text labels. Required: x, y, label. Settings: `offset` (number or `(h, v)`), `format` (string interpolation like RENAMING), `parse` (boolean, default `true`: read the label as markdown — `**bold**`, `*italic*`, `~~strike~~`, `` `code` ``, `{.red span}` — set `false` to draw it literally; png writer only). `hjust`: `'left'`/`'right'`/`'centre'` or 0-1. `vjust`: `'top'`/`'bottom'`/`'middle'` or 0-1. +Text labels. Required: x, y, label. Settings: `offset` (number or `(h, v)`), `format` (string interpolation like RENAMING), `parse` (boolean, default `true`: read the label as markdown — `**bold**`, `*italic*`, `~~strike~~`, `` `code` ``, `{.red span}` — set `false` to draw it literally; not Vega-Lite, which has no rich text). `hjust`: `'left'`/`'right'`/`'centre'` or 0-1. `vjust`: `'top'`/`'bottom'`/`'middle'` or 0-1. ### rect Rectangles. Required: pick 2 per axis from center (x/y), min (xmin/ymin), max (xmax/ymax), width, height. Or just center (defaults width/height to 1). @@ -514,12 +514,17 @@ DRAW ribbon ## CLI -The `ggsql` CLI should be on the PATH. Subcommands: `exec `, `run `, `validate `, `parse `. Common options: `--reader ` (default `duckdb://memory`), `--writer ` (default `vegalite`), `--output `, `-v` (verbose). +The `ggsql` CLI should be on the PATH. Subcommands: `exec `, `run `, `validate `, `parse `, `view ` (native window, blocks until closed). Common options: `--reader ` (default `duckdb://memory`), `--writer ` (default `vegalite`), `--output ` (its extension picks the writer when `--writer` is omitted), `-D key=value` (writer settings), `-v` (verbose). Writers: `vegalite`, `svg`, `pdf`, `hep` (no GPU needed) and `png`, `jpeg`, `tiff`, `webp` (rasterise on the GPU, not in every build). + +**Do not run `ggsql view` unless the user asked for a window.** It blocks until a person closes the window, and you cannot close it yourself. Write a file with `--output` and look at that instead. + +**Prefer `svg` or `pdf` when you need a picture**, since they need no GPU adapter. The raster writers do, and discover it only at render time. `ggsql exec --help` lists the writers this build has and names the feature that would add a missing one; it cannot tell you whether an adapter is present. ```bash ggsql validate "VISUALISE x, y FROM data DRAW point" ggsql exec "VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins DRAW point" -v ggsql run query.sql --output chart.vl.json +ggsql exec "VISUALISE species AS fill FROM ggsql:penguins DRAW bar" -o chart.svg ``` --- diff --git a/ggsql-cli/CLAUDE.md b/ggsql-cli/CLAUDE.md index eee23793d..cf78d13b4 100644 --- a/ggsql-cli/CLAUDE.md +++ b/ggsql-cli/CLAUDE.md @@ -13,30 +13,58 @@ ggsql-cli/ ├── examples/ │ └── visual_test.rs Dev harness: renders the doc examples into an HTML report └── src/ - └── main.rs clap CLI: exec, run, parse, validate, docs, skill + ├── main.rs clap CLI: exec, run, parse, validate, docs, skill + └── writers.rs The writer registry — one row per writer, plus dispatch ``` The binary name is `ggsql` (not `ggsql-cli`) — that's what release artifacts and `$PATH` see. `build.rs` finds `/doc/` via `CARGO_MANIFEST_DIR/..` (workspace root). It walks `/doc/syntax/*.qmd` to embed clause/layer/scale/aesthetic/coord docs as constants in `OUT_DIR/docs_data.rs`, and reads `/doc/vendor/SKILL.md` (with optional `GGSQL_UPDATE_SKILL=1` to refresh from GitHub) for the `skill` subcommand. The `docs` and `skill` commands therefore work offline once the binary is built. +**`doc/vendor/SKILL.md` is a *cache*, not a source.** `GGSQL_UPDATE_SKILL=1` overwrites it wholesale from [`posit-dev/skills`](https://github.com/posit-dev/skills/blob/main/ggsql/ggsql/SKILL.md), so an edit made here survives only until the next refresh. Anything that has to stick — a new writer, a changed setting, a corrected claim about a format — belongs in that repository; editing the cache is how the local build sees it in the meantime, and the upstream PR is what keeps it. + ## Subcommands | Command | Purpose | | --- | --- | | `exec` | Run a ggsql query string (default reader `duckdb://memory`, writer `vegalite`) | | `run` | Like `exec`, but reads the query from a file | +| `view` | Show a query's plot in a native window; blocks until it closes (`window` feature) | | `parse` | Print the parsed AST (formats: `pretty`, `debug`, `json`) — debugging aid | | `validate` | Syntax + semantic check without executing SQL | | `docs` | Render embedded ggsql syntax docs (TTY → ANSI via termimad, pipe → markdown, `--format json` → structured) | | `skill` | Render the AI-assistant skill from `/doc/vendor/SKILL.md` | | `agent-info` | Alias for `skill` | +The subcommand list does not change with features: `view` is always defined, and every writer is always a `--writer` name. What changes is whether it can do anything, and it says so. + Only public `ggsql::*` API is used (`reader`, `writer`, `validate`, `parser`, `VERSION`) — this crate has no awareness of internal modules. -`exec`/`run` build their reader via the library factory `ggsql::reader::connection::reader_from_uri`. They accept an in-memory caching layer (off by default) selected either by the composite connection scheme `+://…` (e.g. `duckdb+odbc://…`) or the `--cache ` flag; the two cannot be combined. +`exec` and `run` share their flags through one `#[derive(Args)] RenderArgs` that both subcommands `#[command(flatten)]`, so a flag's help text and default exist once. `RenderArgs::writer()` resolves them into a `WriterSpec` **in `main`, before any SQL runs**, so a bad writer name, an uncompiled writer or a malformed `-D` pair fails before the query costs anything. + +Which keys a writer accepts is the writer's business, and an unknown one is its error to report — so adding a setting needs no CLI change. User-facing keys are documented in [`/doc/get_started/tooling/cli.qmd`](../doc/get_started/tooling/cli.qmd). + +### The writer registry + +[`src/writers.rs`](src/writers.rs) holds one `WriterInfo` row per writer: its name and aliases, the filename extensions that imply it, the cargo feature that compiles it, the `label` used in messages ("PNG", "Vega-Lite JSON"), a `blurb` and an `options` line for help, `compiled: cfg!(feature = "…")`, and a `render` function pointer. Dispatch, `--writer`'s long help, `-D`'s long help and the "unknown writer" message are all generated from that list, so **adding a writer means adding a row and its render function** — nothing else in the CLI changes. Because `compiled` is a field rather than a `#[cfg]` around the row, the help and the error can name a writer this build lacks and say which feature would bring it in, which is the more common mistake than a misspelled name. + +Render functions return `Result<(Output, Vec), String>`: the output plus anything the writer had to degrade to produce it. They report failure rather than exiting, so `render_spec` owns how a problem is presented. **Warnings go to stderr unconditionally, not behind `-v`** — something the writer could not express is a defect in the file the user is about to ship, and stderr keeps it out of a piped artifact. + +**`RenderArgs::resolve_writer` decides which writer runs**: an explicit `--writer`, else `--output`'s extension, else `writers::DEFAULT_WRITER`. Two consequences are worth knowing before reading it — an extension that names a writer this build lacks is an *error* rather than a fallback, and `--writer` is `Option` rather than a clap `default_value` so that "unset" stays distinguishable from "explicitly vegalite". -`exec` and `run` share a `WriterSpec { name, options }`: `--writer` names the writer and repeated `--writer-option key=value` flags (short `-D`, visible alias `--writer-options`, several settings per flag when separated by `;`) become a `ggsql::writer::WriterOptions`, parsed up front in `main` so a malformed pair fails before any SQL runs. The two travel together down `cmd_exec` → `exec_with_reader` → `render_spec`, which dispatches on the name and hands the options to `Writer::from_options`. Adding a setting to a writer therefore needs no CLI change; which keys exist is the writer's business, and an unknown one is its error to report. User-facing keys are documented in [`/doc/get_started/tooling/cli.qmd`](../doc/get_started/tooling/cli.qmd). +`open_reader(uri, cache) -> Result, String>` is the matching single place for connection strings, and it delegates to the library factory `ggsql::reader::connection::reader_from_uri`. Which schemes exist, which of them this build has, and how a cache wraps a primary are the library's business; `ggsql::reader::Reader` is object-safe on purpose, so `exec`, `run` and `view` all go through this one function. + +`--cache ` wraps the reader in an in-memory caching layer, off by default. It is sugar for the composite connection scheme `+://…` (e.g. `duckdb+odbc://…`) that `reader_from_uri` already understands, so `open_reader` rewrites the flag into that URI and refuses the two forms together — there would be no saying which cache was meant. + +**Both flags live in one `ReaderArgs`**, flattened into `RenderArgs` and `ViewArgs` in turn, so `exec`, `run` and `view` cannot drift apart: where a plot's data comes from does not depend on whether the plot ends up in a file or in a window. The flag names stay flat; only the field access is nested (`args.source.reader`). + +### `view`, and why the window code is not here + +`view` flattens its own `ViewArgs` rather than `RenderArgs`: there is no `--writer` to pick and no `--output` to write, and its `-D` (`--viewer-option`) carries the viewer's settings rather than a writer's. What it does share is `ReaderArgs`, so `--reader` and `--cache` behave identically here. + +**The window itself lives in the library, as `ggsql::writer::PlotViewer`** — and that is the decision most likely to be re-litigated, so: *only public `ggsql::*` API is used; this crate has no awareness of internal modules.* For the CLI to call the renderer's `window::run` itself it would have to take a direct hephaestus dependency, name `PlotComposition` and `WindowConfig` in its own source, and pin hephaestus in a second place — breaking that invariant three ways. So the *behaviour* goes public as a type instead, and `cmd_view` stays thin: parse options, open the reader, execute, call `show`. `show` blocks on the main thread until the window closes. + +**The subcommand is defined unconditionally.** Without the `window` feature it prints what would bring it back. A subcommand that vanishes between builds is worse than one that explains itself — the same reasoning as `WriterInfo::compiled`. ## Build & install @@ -59,10 +87,12 @@ The macOS codesign step uses [`/entitlements.plist`](../entitlements.plist) at t ## Features ```toml -default = ["duckdb", "sqlite", "vegalite", "ipc", "parquet", "builtin-data", "odbc"] +default = ["duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "svg", "pdf", "hep"] ``` -Each feature passes through to `ggsql/`. The `vegalite` flag also gates the writer-rendering path in `main.rs` via `#[cfg(feature = "vegalite")]`. +Each feature passes through to `ggsql/`. A writer feature gates only its own row's render function in `writers.rs`; the row itself is always present. + +`svg`, `pdf` and `hep` are default because they cost nothing to have: no GPU adapter, no wgpu, and on Linux no `libfontconfig1-dev` at build time. `png`, `jpeg`, `tiff`, `webp` and `window` are not, since those do need an adapter at run time. ## Testing diff --git a/ggsql-cli/Cargo.toml b/ggsql-cli/Cargo.toml index 20c96f7c5..49b9f5904 100644 --- a/ggsql-cli/Cargo.toml +++ b/ggsql-cli/Cargo.toml @@ -21,6 +21,12 @@ name = "visual_test" path = "examples/visual_test.rs" required-features = ["png", "duckdb", "vegalite", "builtin-data"] +[dev-dependencies] +# Decoding renders back to pixels, so `visual_test --baseline` can compare +# pictures rather than files. Dev-only: nothing in the shipped binary reads a +# PNG. Same major as the encoder hephaestus uses, so no second copy is built. +png = "0.18" + [dependencies] ggsql = { workspace = true } @@ -44,15 +50,32 @@ regex.workspace = true ureq = "3" [features] -default = ["duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc"] +default = ["duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "svg", "pdf", "hep"] duckdb = ["ggsql/duckdb"] parquet = ["ggsql/parquet"] sqlite = ["ggsql/sqlite"] odbc = ["ggsql/odbc"] -vegalite = ["ggsql/vegalite"] -png = ["ggsql/png"] +# Internal, and enabled by each writer feature rather than named directly: on +# when at least one writer is compiled in. It lets the code that exists only to +# serve a writer be gated on one name instead of an `any(...)` list that has to +# grow with every format. +any-writer = [] + +vegalite = ["ggsql/vegalite", "any-writer"] +png = ["ggsql/png", "any-writer"] +jpeg = ["ggsql/jpeg", "any-writer"] +tiff = ["ggsql/tiff", "any-writer"] +webp = ["ggsql/webp", "any-writer"] +svg = ["ggsql/svg", "any-writer"] +pdf = ["ggsql/pdf", "any-writer"] +hep = ["ggsql/hep", "any-writer"] + +# The plot viewer. Not a writer, so deliberately not in `all-writers`: it +# blocks, it is native-only, and it produces no output. +window = ["ggsql/window"] builtin-data = ["ggsql/builtin-data"] all-readers = ["duckdb", "sqlite", "odbc"] +all-writers = ["vegalite", "png", "jpeg", "tiff", "webp", "svg", "pdf", "hep"] # cargo-packager configuration for cross-platform installers [package.metadata.packager] @@ -75,3 +98,10 @@ binaries = [ { path = "ggsql-jupyter", main = false }, ] +# The default `svg`/`pdf`/`hep` writers lay text out through fontconfig, loaded +# at run time (see /CLAUDE.md). Nothing is needed to *build* against it, but +# without the runtime library and a font a plot renders with no labels at all +# and no error, so the package declares both rather than degrading silently. +[package.metadata.packager.deb] +depends = ["libfontconfig1", "fonts-dejavu-core"] + diff --git a/ggsql-cli/examples/visual_test.rs b/ggsql-cli/examples/visual_test.rs index 7dbd7fe59..bbe054e7d 100644 --- a/ggsql-cli/examples/visual_test.rs +++ b/ggsql-cli/examples/visual_test.rs @@ -5,14 +5,26 @@ Every executable ```` ```{ggsql} ```` cell in the Quarto docs is a query the project already vouches for, which makes them a ready-made corpus for exercising a writer. This example runs them — in document order, against one reader per source file so `CREATE TABLE` setup cells still apply — renders each -visualisation with the [`PngWriter`], and emits a single HTML report -pairing every query with its rendered output. +visualisation, and emits a single HTML report pairing every query with its +rendered output. ```sh cargo run -p ggsql-cli --features png --example visual_test open target/visual-test/index.html ``` +`--writer` picks what draws the corpus. `png` is the default and what a visual +check wants; `svg` needs **no GPU adapter**, so it is the mode that runs on a +headless box, and its `--baseline` comparison reads coordinates rather than +pixels. The SVG writer also reports what it could not express, which is the +regression signal +[`writer/hephaestus/CLAUDE.md`](../../src/writer/hephaestus/CLAUDE.md) says +should be empty for everything in this corpus. + +```sh +cargo run -p ggsql-cli --features png --example visual_test -- --writer svg +``` + Failures never abort the run: an execution error, a render error, or a panic inside a writer is captured against its cell and the harness moves on, so one report shows every problem in the corpus at once. @@ -23,10 +35,10 @@ the two side by side — the comparison when checking visual correctness. */ -use clap::Parser; +use clap::{Parser, ValueEnum}; use ggsql::reader::{DuckDBReader, Reader}; use ggsql::validate::validate; -use ggsql::writer::{PngWriter, VegaLiteWriter, Writer}; +use ggsql::writer::{PngWriter, SvgWriter, VegaLiteWriter, Writer}; use std::fmt::Write as _; use std::fs; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -36,13 +48,17 @@ use std::time::Instant; #[derive(Parser)] #[command( name = "visual_test", - about = "Render every {ggsql} documentation example with the png writer into an HTML report" + about = "Render every {ggsql} documentation example into an HTML report" )] struct Args { /// Directories to scan for `.qmd` files, or individual `.qmd` files #[arg(default_values = ["doc/syntax", "doc/gallery"])] paths: Vec, + /// Which writer draws the corpus. `svg` needs no GPU adapter. + #[arg(long, value_enum, default_value_t = Renderer::Png)] + writer: Renderer, + /// Directory to write the report and its images into #[arg(short, long, default_value = "target/visual-test")] out: PathBuf, @@ -66,6 +82,63 @@ struct Args { /// Render resolution, which also scales the plot chrome #[arg(long, default_value_t = 300.0)] dpi: f64, + + /// A previous `--out` directory to diff this run's renders against. + /// + /// Every cell is labelled unchanged / changed / new, with counts in the + /// header — a renderer bump changes behaviour on purpose, so the point is to + /// narrow the eyeballing to the cells that moved. + #[arg(long, value_name = "DIR")] + baseline: Option, +} + +/// Which writer draws the corpus. +/// +/// Both go through the same composition. PNG is the picture a human eyeballs and +/// needs an adapter; SVG needs none, carries readable coordinates, and reports +/// what the format could not express. +#[derive(Copy, Clone, PartialEq, Eq, ValueEnum)] +enum Renderer { + Png, + Svg, +} + +impl Renderer { + /// The extension a render is written under, which is also what tells the + /// report's `` what it is looking at. + fn extension(self) -> &'static str { + match self { + Renderer::Png => "png", + Renderer::Svg => "svg", + } + } + + /// Draw one spec, reporting anything the format could not express. + /// + /// The SVG list should be empty for everything in this corpus, so it goes + /// beside the execution warnings rather than into the render error. + fn render( + self, + spec: &ggsql::reader::Spec, + args: &Args, + ) -> ggsql::Result<(Vec, Vec)> { + match self { + Renderer::Png => PngWriter::new(args.width, args.height, args.dpi) + .render(spec) + .map(|bytes| (bytes, Vec::new())), + Renderer::Svg => SvgWriter::new(args.width, args.height, args.dpi) + .render_reporting(spec) + .map(|(svg, warnings)| (svg.into_bytes(), warnings)), + } + } + + /// Compare a render with the baseline's, the way this format wants. + fn compare(self, baseline: &Path, name: &str, bytes: &[u8]) -> Delta { + match self { + Renderer::Png => Delta::against_pixels(baseline, name, bytes), + Renderer::Svg => Delta::against_markup(baseline, name, bytes), + } + } } // ============================================================================ @@ -199,12 +272,222 @@ fn front_matter_title(text: &str) -> Option { // Running the corpus // ============================================================================ +/// How a render compares with the same cell in a `--baseline` run. +#[derive(Clone, Copy)] +enum Delta { + /// Same pixels, exactly. + Identical, + /// Different pixels, but the whole picture matches under a small + /// translation — the signature of a chrome-width change nudging the panel. + /// Carries the residual left at the best alignment. + Shifted(f64), + /// Differs by more than a shift explains. These are the cells to look at. + Changed(f64), + /// The baseline has no render for this cell. + New, +} + +/// Mean absolute grey difference, 0–255, below which an aligned pair counts as +/// the same picture. Antialiasing puts a genuine match a little above zero, so +/// this cannot be `0.0`; it comes from the observed spread across the corpus. +const SHIFT_TOLERANCE: f64 = 2.0; + +/// How far to search for an alignment, in pixels of the full-size render. +const MAX_SHIFT: i32 = 24; + +/// Factor the comparison downsamples by before searching. The search cost is +/// quadratic in both the shift range and the resolution, and a panel shift is a +/// whole-image effect that survives a box filter. +const COMPARE_SCALE: u32 = 8; + +impl Delta { + /// Compare freshly rendered bytes with the baseline's copy of `name`. + /// + /// Exact equality is useless across a dependency bump: a few pixels of + /// chrome-width change shifts every panel. Aligning first — searching a + /// small translation for the best residual — collapses a uniform shift to + /// nearly nothing and leaves anything structural large. + fn against_pixels(baseline: &Path, name: &str, bytes: &[u8]) -> Self { + let Ok(old) = fs::read(baseline.join("assets").join(name)) else { + return Delta::New; + }; + if old == bytes { + return Delta::Identical; + } + match (Grey::decode(&old), Grey::decode(bytes)) { + (Some(a), Some(b)) => match a.aligned_residual(&b) { + // Different dimensions: not a shift, and not comparable. + None => Delta::Changed(f64::INFINITY), + Some(r) if r <= SHIFT_TOLERANCE => Delta::Shifted(r), + Some(r) => Delta::Changed(r), + }, + // Undecodable, so fall back to saying it moved rather than + // claiming a match we cannot support. + _ => Delta::Changed(f64::INFINITY), + } + } + + /// Compare freshly drawn markup with the baseline's copy of `name`. + /// + /// As a multiset of elements, not as bytes: a query with no `ORDER BY` emits + /// the same marks in a different order each run. Sorting first leaves a real + /// change — a moved mark, a different label, an element gained or lost — + /// showing. A jittered layer genuinely differs each run and stays "changed". + /// + /// No shift alignment: markup carries its coordinates, so a moved panel + /// shows up as changed coordinates rather than as a translated image. + fn against_markup(baseline: &Path, name: &str, bytes: &[u8]) -> Self { + let Ok(old) = fs::read(baseline.join("assets").join(name)) else { + return Delta::New; + }; + if old == bytes { + return Delta::Identical; + } + match (std::str::from_utf8(&old), std::str::from_utf8(bytes)) { + (Ok(a), Ok(b)) if sorted_elements(a) == sorted_elements(b) => Delta::Identical, + _ => Delta::Changed(f64::INFINITY), + } + } + + fn label(self) -> String { + match self { + Delta::Identical => "identical".to_string(), + Delta::Shifted(r) => format!("shifted · {r:.2}"), + Delta::Changed(r) if r.is_finite() => format!("changed · {r:.2}"), + Delta::Changed(_) => "changed".to_string(), + Delta::New => "new".to_string(), + } + } + + /// CSS class, and the bucket the header counts by. + fn class(self) -> &'static str { + match self { + Delta::Identical => "identical", + Delta::Shifted(_) => "shifted", + Delta::Changed(_) => "changed", + Delta::New => "new", + } + } + + /// Whether a human still needs to look at this cell. + fn needs_review(self) -> bool { + matches!(self, Delta::Changed(_)) + } +} + +/// An SVG's elements in a comparable order. +fn sorted_elements(svg: &str) -> Vec<&str> { + let mut parts: Vec<&str> = svg.split('>').collect(); + parts.sort_unstable(); + parts +} + +/// A render reduced to one grey byte per pixel, downsampled for comparison. +struct Grey { + width: usize, + height: usize, + px: Vec, +} + +impl Grey { + /// Decode a PNG and reduce it to a downsampled grey plane. + fn decode(bytes: &[u8]) -> Option { + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let mut reader = decoder.read_info().ok()?; + let mut buf = vec![0; reader.output_buffer_size()?]; + let info = reader.next_frame(&mut buf).ok()?; + let channels = match info.color_type { + png::ColorType::Rgba => 4, + png::ColorType::Rgb => 3, + png::ColorType::Grayscale => 1, + png::ColorType::GrayscaleAlpha => 2, + png::ColorType::Indexed => return None, + }; + // Composite onto white as a viewer would, so a transparent background + // does not read as black and swamp the residual. + let grey_at = |i: usize| -> f64 { + let p = &buf[i * channels..]; + let (r, g, b, a) = match channels { + 1 => (p[0], p[0], p[0], 255), + 2 => (p[0], p[0], p[0], p[1]), + 3 => (p[0], p[1], p[2], 255), + _ => (p[0], p[1], p[2], p[3]), + }; + let lum = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64; + let a = a as f64 / 255.0; + lum * a + 255.0 * (1.0 - a) + }; + + let scale = COMPARE_SCALE as usize; + let width = (info.width as usize).div_ceil(scale); + let height = (info.height as usize).div_ceil(scale); + let mut px = vec![0u8; width * height]; + for by in 0..height { + for bx in 0..width { + let mut sum = 0.0; + let mut n = 0.0; + for y in by * scale..((by + 1) * scale).min(info.height as usize) { + for x in bx * scale..((bx + 1) * scale).min(info.width as usize) { + sum += grey_at(y * info.width as usize + x); + n += 1.0; + } + } + px[by * width + bx] = (sum / n).round() as u8; + } + } + Some(Grey { width, height, px }) + } + + /// The smallest mean absolute difference over a search of translations. + /// + /// `None` when the two have different dimensions, which no translation + /// reconciles. + fn aligned_residual(&self, other: &Grey) -> Option { + if self.width != other.width || self.height != other.height { + return None; + } + let reach = MAX_SHIFT / COMPARE_SCALE as i32; + let mut best = f64::INFINITY; + for dy in -reach..=reach { + for dx in -reach..=reach { + // Only the overlap is compared, so a shift is not penalised for + // the sliver it moves off the canvas. + let mut sum = 0.0f64; + let mut n = 0usize; + for y in 0..self.height as i32 { + let oy = y + dy; + if oy < 0 || oy >= self.height as i32 { + continue; + } + for x in 0..self.width as i32 { + let ox = x + dx; + if ox < 0 || ox >= self.width as i32 { + continue; + } + let a = self.px[y as usize * self.width + x as usize] as f64; + let b = other.px[oy as usize * self.width + ox as usize] as f64; + sum += (a - b).abs(); + n += 1; + } + } + if n > 0 { + best = best.min(sum / n as f64); + } + } + } + Some(best) + } +} + /// What a cell turned out to be, and what came of running it. enum Outcome { /// A query with a `VISUALISE` clause: the renders it produced. Plot { - png: Option, - png_error: Option, + /// Filename of the render in `assets/`, whatever writer produced it. + image: Option, + image_error: Option, + /// How the render compares with `--baseline`, when one was given. + delta: Option, /// Vega-Lite JSON, inlined into the report when `--compare` is on vegalite: Option, vegalite_error: Option, @@ -228,10 +511,10 @@ impl CellResult { match &self.outcome { Outcome::Failed(_) => true, Outcome::Plot { - png_error, + image_error, vegalite_error, .. - } => png_error.is_some() || vegalite_error.is_some(), + } => image_error.is_some() || vegalite_error.is_some(), Outcome::Setup { .. } => false, } } @@ -245,13 +528,10 @@ struct SourceResult { /// Run every cell of one source file against a fresh reader. /// -/// The reader is per file, not per cell, because the docs rely on it: a page -/// may build a table in one cell and plot it in the next. -/// -/// The cells run in their own file's directory, because a page reads its data -/// by a path relative to itself (`FROM 'minard_troops.csv'`) — that is how -/// Quarto executes them. `assets` is therefore resolved to an absolute path by -/// the caller, since it outlives that switch. +/// The reader is per file, not per cell: a page may build a table in one cell +/// and plot it in the next. Cells run in their own file's directory, since a +/// page reads its data by a relative path, so the caller resolves `assets` to +/// an absolute path first. fn run_source(source: Source, args: &Args, assets: &Path) -> SourceResult { let restore = std::env::current_dir().ok(); if let Err(e) = std::env::set_current_dir(&source.dir) { @@ -294,7 +574,6 @@ fn run_cells(source: Source, args: &Args, assets: &Path) -> SourceResult { } }; - let png_writer = PngWriter::new(args.width, args.height, args.dpi); let vegalite = VegaLiteWriter::new(); let mut results = Vec::new(); @@ -312,12 +591,25 @@ fn run_cells(source: Source, args: &Args, assets: &Path) -> SourceResult { Ok(spec) => { warnings.extend(spec.warnings().iter().map(|w| w.message.clone())); - let (png, png_error) = match capture(|| png_writer.render(&spec)) { - Ok(bytes) => { - let name = format!("{}-{:02}.png", slug(&label), cell.index); + let mut delta = None; + let (image, image_error) = match capture(|| args.writer.render(&spec, args)) { + Ok((bytes, degraded)) => { + // Beside the execution warnings, not folded into the + // render error: the render succeeded. + warnings.extend(degraded); + let name = format!( + "{}-{:02}.{}", + slug(&label), + cell.index, + args.writer.extension() + ); + delta = args + .baseline + .as_deref() + .map(|b| args.writer.compare(b, &name, &bytes)); match fs::write(assets.join(&name), &bytes) { Ok(()) => (Some(name), None), - Err(e) => (None, Some(format!("could not write PNG: {e}"))), + Err(e) => (None, Some(format!("could not write the render: {e}"))), } } Err(e) => (None, Some(e)), @@ -333,8 +625,9 @@ fn run_cells(source: Source, args: &Args, assets: &Path) -> SourceResult { }; Outcome::Plot { - png, - png_error, + image, + image_error, + delta, vegalite: vl, vegalite_error: vl_error, } @@ -380,7 +673,8 @@ fn status_word(outcome: &Outcome) -> &'static str { Outcome::Failed(_) => "FAILED", Outcome::Setup { .. } => "setup", Outcome::Plot { - png_error: Some(_), .. + image_error: Some(_), + .. } => "RENDER FAILED", Outcome::Plot { vegalite_error: Some(_), @@ -429,9 +723,8 @@ fn escape(text: &str) -> String { /// Prepare a Vega-Lite spec for inlining in a ` "#; @@ -739,6 +1092,20 @@ fn main() { // path that does not move with them. let assets = fs::canonicalize(&assets).unwrap_or(assets); + // Read from inside that same directory switch, so a relative `--baseline` + // would silently resolve to nothing and report every cell as new. + let mut args = args; + if let Some(baseline) = args.baseline.take() { + match fs::canonicalize(&baseline) { + Ok(path) => args.baseline = Some(path), + Err(e) => { + eprintln!("Could not read baseline {}: {e}", baseline.display()); + std::process::exit(1); + } + } + } + let args = args; + let mut sources = Vec::new(); for path in paths { let text = match fs::read_to_string(&path) { diff --git a/ggsql-cli/src/main.rs b/ggsql-cli/src/main.rs index f04592cd7..cf3c76354 100644 --- a/ggsql-cli/src/main.rs +++ b/ggsql-cli/src/main.rs @@ -4,19 +4,16 @@ ggsql Command Line Interface Provides commands for executing ggsql queries with various data sources and output formats. */ -use clap::{Parser, Subcommand, ValueEnum}; -use ggsql::reader::{Reader, Spec}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use ggsql::reader::{connection, Reader, Spec}; use ggsql::validate::validate; -use ggsql::writer::{Writer, WriterOptions}; +use ggsql::writer::WriterOptions; use ggsql::{parser, VERSION}; use std::io::{IsTerminal, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use writers::{Output, WriterInfo}; -#[cfg(feature = "vegalite")] -use ggsql::writer::VegaLiteWriter; - -#[cfg(feature = "png")] -use ggsql::writer::PngWriter; +mod writers; mod docs { include!(concat!(env!("OUT_DIR"), "/docs_data.rs")); @@ -31,29 +28,145 @@ pub struct Cli { pub command: Commands, } -enum Output { - Text(String), - /// Only a raster writer produces bytes, so nothing constructs this when no - /// such writer is compiled in. - #[cfg_attr(not(feature = "png"), allow(dead_code))] - Bin(Vec), -} - /// The writer to render with, plus the `--writer-option` settings for it. struct WriterSpec { - name: String, + info: &'static WriterInfo, options: WriterOptions, } -impl WriterSpec { - /// Build from the raw flags, exiting with the parse error if an option is - /// not `key=value`. - fn new(name: String, options: Vec) -> Self { - let options = WriterOptions::parse(options).unwrap_or_else(|e| { +/// Where a subcommand's data comes from. +/// +/// Flattened into both [`RenderArgs`] and [`ViewArgs`], so the two flags are +/// declared — and helped, and defaulted — once: where a plot's data comes from +/// does not depend on whether the plot ends up in a file or in a window. +#[derive(Args)] +pub struct ReaderArgs { + /// Data source connection string (duckdb://, sqlite://, odbc://) + #[arg(short, long, default_value = "duckdb://memory")] + pub reader: String, + + /// In-memory cache backend wrapping the reader (duckdb, sqlite). Off by default. + #[arg(long)] + pub cache: Option, +} + +/// The flags shared by `exec` and `run`: where the data comes from, which +/// writer renders it, and where the result goes. +#[derive(Args)] +pub struct RenderArgs { + #[command(flatten)] + pub source: ReaderArgs, + + /// Output format — run with --help for the writers this build has + /// + /// Left unset, `--output`'s extension picks the writer, falling back to + /// vegalite. `Option` rather than a clap `default_value` so "unset" stays + /// distinguishable from "explicitly vegalite"; the long help states the + /// default instead. + #[arg(short, long, long_help = writers::writer_help())] + pub writer: Option, + + /// Settings for the chosen writer, as `key=value` (repeatable) + #[arg( + short = 'D', + long = "writer-option", + visible_alias = "writer-options", + value_name = "KEY=VALUE[;...]", + long_help = writers::option_help() + )] + pub writer_options: Vec, + + /// Output file path + #[arg(short, long)] + pub output: Option, + + /// Show verbose output (execution details, statistics) + #[arg(short, long)] + pub verbose: bool, +} + +/// The flags `view` takes: where the data comes from and how the window looks. +/// +/// Deliberately not [`RenderArgs`]: there is no `--writer` to choose and no +/// `--output` to write, and `-D` carries the viewer's own settings rather than +/// a writer's. The reader flags are the same ones, so they come from the same +/// [`ReaderArgs`]. +#[derive(Args)] +pub struct ViewArgs { + #[command(flatten)] + pub source: ReaderArgs, + + /// Viewer settings, as `key=value` (repeatable) + #[arg( + short = 'D', + long = "viewer-option", + visible_alias = "viewer-options", + value_name = "KEY=VALUE[;...]", + long_help = "Settings for the viewer window, as `key=value`. Repeatable, and one flag \ + may carry several settings separated by `;` (quote it, as most shells read \ + `;` themselves): `-D 'width=1280;title=My plot'`.\n\nSettings:\n \ + width, height, background, title" + )] + pub viewer_options: Vec, + + /// Show verbose output (execution details, statistics) + #[arg(short, long)] + pub verbose: bool, +} + +impl RenderArgs { + /// Resolve `--writer` and its settings, exiting on an unknown name or a + /// malformed setting rather than discovering either after the SQL has run. + fn writer(&self) -> WriterSpec { + let info = self.resolve_writer(); + if !info.compiled { + eprintln!("{}", writers::not_compiled_message(info)); + std::process::exit(1); + } + let options = WriterOptions::parse(self.writer_options.clone()).unwrap_or_else(|e| { eprintln!("{}", e); std::process::exit(1); }); - Self { name, options } + if let Err(e) = (info.check)(&options) { + eprintln!("{}", e); + std::process::exit(1); + } + WriterSpec { info, options } + } + + /// Which writer to use: `--writer` if given, else what `--output`'s + /// extension implies, else the default. + /// + /// An explicit `--writer` always wins; disagreeing with the extension only + /// warns on stderr, since writing SVG to a `.txt` is legitimate. + fn resolve_writer(&self) -> &'static writers::WriterInfo { + if let Some(name) = &self.writer { + let info = writers::find(name).unwrap_or_else(|| { + eprintln!("{}", writers::unknown_writer(name)); + std::process::exit(1); + }); + if let Some(implied) = self.output.as_deref().and_then(writers::for_extension) { + if !std::ptr::eq(implied, info) { + eprintln!( + "Warning: writing {} to '{}', whose extension says {}", + info.label, + self.output.as_deref().unwrap_or(Path::new("")).display(), + implied.name + ); + } + } + return info; + } + // No --writer, so the extension decides. A writer it names but this + // build lacks is an error, not a silent fallback to Vega-Lite JSON in + // a file called `.png`. + self.output + .as_deref() + .and_then(writers::for_extension) + .unwrap_or_else(|| { + writers::find(writers::DEFAULT_WRITER) + .expect("the default writer has a registry row") + }) } } @@ -64,39 +177,8 @@ pub enum Commands { /// The ggsql query to execute query: String, - /// Data source connection string (duckdb://, sqlite://, odbc://) - #[arg(short, long, default_value = "duckdb://memory")] - reader: String, - - /// In-memory cache backend wrapping the reader (duckdb, sqlite). Off by default. - #[arg(long)] - cache: Option, - - /// Output format: vegalite (JSON), or png (raster image; requires the - /// `png` feature and a GPU adapter) - #[arg(short, long, default_value = "vegalite")] - writer: String, - - /// Settings for the chosen writer, as `key=value`. Repeatable, and one - /// flag may carry several settings separated by `;` (quote it, as most - /// shells read `;` themselves): `-D 'width=1600;dpi=150'`. The - /// png writer takes width, height, units, dpi, and background; - /// the vegalite writer takes none. - #[arg( - short = 'D', - long = "writer-option", - visible_alias = "writer-options", - value_name = "KEY=VALUE[;...]" - )] - writer_options: Vec, - - /// Output file path - #[arg(short, long)] - output: Option, - - /// Show verbose output (execution details, statistics) - #[arg(short, long)] - verbose: bool, + #[command(flatten)] + render: RenderArgs, }, /// Execute a ggsql query from a file @@ -104,39 +186,22 @@ pub enum Commands { /// Path to .sql file containing ggsql query file: PathBuf, - /// Data source connection string (duckdb://, sqlite://, odbc://) - #[arg(short, long, default_value = "duckdb://memory")] - reader: String, - - /// In-memory cache backend wrapping the reader (duckdb, sqlite). Off by default. - #[arg(long)] - cache: Option, - - /// Output format: vegalite (JSON), or png (raster image; requires the - /// `png` feature and a GPU adapter) - #[arg(short, long, default_value = "vegalite")] - writer: String, - - /// Settings for the chosen writer, as `key=value`. Repeatable, and one - /// flag may carry several settings separated by `;` (quote it, as most - /// shells read `;` themselves): `-D 'width=1600;dpi=150'`. The - /// png writer takes width, height, units, dpi, and background; - /// the vegalite writer takes none. - #[arg( - short = 'D', - long = "writer-option", - visible_alias = "writer-options", - value_name = "KEY=VALUE[;...]" - )] - writer_options: Vec, - - /// Output file path - #[arg(short, long)] - output: Option, + #[command(flatten)] + render: RenderArgs, + }, - /// Show verbose output (execution details, statistics) - #[arg(short, long)] - verbose: bool, + /// Show a ggsql query's plot in a window + /// + /// Blocks until the window is closed. Resizing the window re-lays-out the + /// plot rather than stretching it. + /// + /// Requires the `window` feature and a working GPU adapter. + View { + /// The ggsql query to show + query: String, + + #[command(flatten)] + view: ViewArgs, }, /// Parse a query and show the AST (for debugging) @@ -210,36 +275,27 @@ fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Exec { - query, - reader, - cache, - writer, - writer_options, - output, - verbose, - } => { - if verbose { + Commands::Exec { query, render } => { + if render.verbose { eprintln!("Executing query: {}", query); } - let writer = WriterSpec::new(writer, writer_options); - cmd_exec(query, reader, cache, &writer, output, verbose); - } - - Commands::Run { - file, - reader, - cache, - writer, - writer_options, - output, - verbose, - } => { - if verbose { + let writer = render.writer(); + cmd_exec(query, &render, &writer); + } + + Commands::Run { file, render } => { + if render.verbose { eprintln!("Running query from file: {}", file.display()); } - let writer = WriterSpec::new(writer, writer_options); - cmd_run(file, reader, cache, &writer, output, verbose); + let writer = render.writer(); + cmd_run(file, &render, &writer); + } + + Commands::View { query, view } => { + if view.verbose { + eprintln!("Showing query: {}", query); + } + cmd_view(query, &view); } Commands::Parse { query, format } => { @@ -266,16 +322,9 @@ fn main() -> anyhow::Result<()> { Ok(()) } -fn cmd_run( - file: PathBuf, - reader: String, - cache: Option, - writer: &WriterSpec, - output: Option, - verbose: bool, -) { +fn cmd_run(file: PathBuf, args: &RenderArgs, writer: &WriterSpec) { match std::fs::read_to_string(&file) { - Ok(query) => cmd_exec(query, reader, cache, writer, output, verbose), + Ok(query) => cmd_exec(query, args, writer), Err(e) => { eprintln!("Failed to read file {}: {}", file.display(), e); std::process::exit(1); @@ -283,70 +332,56 @@ fn cmd_run( } } -fn cmd_exec( - query: String, - reader: String, - cache: Option, - writer: &WriterSpec, - output: Option, - verbose: bool, -) { - use ggsql::reader::connection; - - if verbose { - eprintln!("Reader: {}", reader); - if let Some(ref cache) = cache { +fn cmd_exec(query: String, args: &RenderArgs, writer: &WriterSpec) { + if args.verbose { + eprintln!("Reader: {}", args.source.reader); + if let Some(ref cache) = args.source.cache { eprintln!("Cache: {}", cache); } - eprintln!("Writer: {}", writer.name); - if let Some(ref output_file) = output { + eprintln!("Writer: {}", writer.info.name); + if let Some(ref output_file) = args.output { eprintln!("Output: {}", output_file.display()); } } - // Build the reader. A composite `+://` URI is handled by - // `reader_from_uri`; the `--cache` flag is an explicit alternative and may - // not be combined with a composite URI. - let built = match cache { + let reader = + open_reader(&args.source.reader, args.source.cache.as_deref()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + exec_with_reader(&query, reader.as_ref(), args, writer); +} + +/// Open the reader named by a connection string, wrapped in a cache if asked +/// for one. +/// +/// Which schemes exist, which of them this build has, and how a cache wraps a +/// primary all live in the library, as `connection::reader_from_uri`. What is +/// left here is the CLI's own spelling of it: `ggsql::reader::Reader` is +/// object-safe on purpose, so `exec`, `run` and `view` share one function. +/// +/// `--cache ` is sugar for the composite `+://` URI +/// `reader_from_uri` already understands. The two forms may not be combined — +/// there would be no saying which cache was meant. +fn open_reader(uri: &str, cache: Option<&str>) -> Result, String> { + let uri = match cache { Some(cache_scheme) => { - if connection::split_cache_uri(&reader).is_some() { - eprintln!( - "Cannot combine --cache with a composite '+://' connection string" - ); - std::process::exit(1); - } - // `--cache ` is sugar for the composite `+://` URI. - match reader.split_once("://") { - Some((scheme, rest)) => { - connection::reader_from_uri(&format!("{cache_scheme}+{scheme}://{rest}")) - } - None => { - eprintln!("Invalid --reader connection string: {reader}"); - std::process::exit(1); - } + if connection::split_cache_uri(uri).is_some() { + return Err("Cannot combine --cache with a composite \'+://\' connection string".to_string()); } + let Some((scheme, rest)) = uri.split_once("://") else { + return Err(format!("Invalid --reader connection string: {uri}")); + }; + format!("{cache_scheme}+{scheme}://{rest}") } - None => connection::reader_from_uri(&reader), + None => uri.to_string(), }; - let reader = match built { - Ok(r) => r, - Err(e) => { - eprintln!("Failed to create reader: {}", e); - std::process::exit(1); - } - }; - - exec_with_reader(&query, reader.as_ref(), writer, output, verbose); + connection::reader_from_uri(&uri).map_err(|e| format!("Failed to create reader: {e}")) } -fn exec_with_reader( - query: &str, - reader: &R, - writer: &WriterSpec, - output: Option, - verbose: bool, -) { +fn exec_with_reader(query: &str, reader: &dyn Reader, args: &RenderArgs, writer: &WriterSpec) { // Use validate() to check if query has visualization let validated = match validate(query) { Ok(v) => v, @@ -357,10 +392,10 @@ fn exec_with_reader( }; if !validated.has_visual() { - if verbose { + if args.verbose { eprintln!("Visualisation is empty. Printing table instead."); } - print_table_fallback(query, reader, 100); + print_table_fallback(query, reader, 100, args.output.as_deref()); return; } @@ -373,11 +408,11 @@ fn exec_with_reader( } }; - render_spec(spec, writer, output, verbose); + render_spec(spec, args, writer); } -fn render_spec(spec: Spec, writer: &WriterSpec, output: Option, verbose: bool) { - if verbose { +fn render_spec(spec: Spec, args: &RenderArgs, writer: &WriterSpec) { + if args.verbose { let metadata = spec.metadata(); eprintln!("\nQuery executed:"); eprintln!(" Rows: {}", metadata.rows); @@ -390,24 +425,26 @@ fn render_spec(spec: Spec, writer: &WriterSpec, output: Option, verbose std::process::exit(1); } - let render = match writer.name.as_str() { - "vegalite" => render_vegalite(&spec, &writer.options), - "png" => render_png(&spec, &writer.options), - other => { - eprintln!("Unknown writer '{}'", other); - eprintln!("Available writers: png, vegalite"); - std::process::exit(1) - } - }; + let info = writer.info; + let (render, warnings) = (info.render)(&spec, &writer.options).unwrap_or_else(|e| { + eprintln!("Failed to generate {} output: {}", info.label, e); + std::process::exit(1); + }); + + // Not behind -v: a degraded render is a defect in the file about to be + // shipped. stderr keeps it out of a piped artifact. + for warning in &warnings { + eprintln!("warning: {}", warning); + } - match (render, output) { + match (render, &args.output) { (Output::Text(txt), None) => { println!("{}", txt); } - (Output::Text(txt), Some(path)) => match std::fs::write(&path, txt) { + (Output::Text(txt), Some(path)) => match std::fs::write(path, txt) { Ok(_) => { - if verbose { - eprintln!("\nVega-Lite JSON written to: {}", path.display()); + if args.verbose { + eprintln!("\n{} written to: {}", info.label, path.display()); } } Err(e) => { @@ -417,7 +454,10 @@ fn render_spec(spec: Spec, writer: &WriterSpec, output: Option, verbose }, (Output::Bin(buf), None) => { if std::io::stdout().is_terminal() { + // Non-zero, since nothing was produced: `… && publish` must + // not carry on as though it had a file. eprintln!("Suppressing output in terminal. Pipe output to another process or use --output to save to a file."); + std::process::exit(1); } else { std::io::stdout().write_all(&buf).unwrap_or_else(|e| { eprintln!("Failed to write buffer with the error: {}", e); @@ -425,10 +465,10 @@ fn render_spec(spec: Spec, writer: &WriterSpec, output: Option, verbose }); } } - (Output::Bin(buf), Some(path)) => match std::fs::write(&path, buf) { + (Output::Bin(buf), Some(path)) => match std::fs::write(path, buf) { Ok(_) => { - if verbose { - eprintln!("\nPNG written to: {}", path.display()); + if args.verbose { + eprintln!("\n{} written to: {}", info.label, path.display()); } } Err(e) => { @@ -439,6 +479,72 @@ fn render_spec(spec: Spec, writer: &WriterSpec, output: Option, verbose }; } +/// Show a query's plot in a window, blocking until it closes. +/// +/// The subcommand exists whether or not the feature does, so a build without +/// it says what would bring it back rather than dropping the command. +fn cmd_view(query: String, args: &ViewArgs) { + #[cfg(feature = "window")] + { + use ggsql::writer::PlotViewer; + + let options = WriterOptions::parse(args.viewer_options.clone()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + let viewer = PlotViewer::from_options(&options).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + if args.verbose { + eprintln!("Reader: {}", args.source.reader); + if let Some(ref cache) = args.source.cache { + eprintln!("Cache: {}", cache); + } + } + + let reader = + open_reader(&args.source.reader, args.source.cache.as_deref()).unwrap_or_else(|e| { + eprintln!("{}", e); + std::process::exit(1); + }); + + let validated = validate(&query).unwrap_or_else(|e| { + eprintln!("Failed to validate query: {}", e); + std::process::exit(1); + }); + if !validated.has_visual() { + eprintln!("This query has no VISUALISE clause, so there is no plot to show."); + std::process::exit(1); + } + + let spec = reader.execute(&query).unwrap_or_else(|e| { + eprintln!("Failed to execute query: {}", e); + std::process::exit(1); + }); + + if args.verbose { + let metadata = spec.metadata(); + eprintln!(" Rows: {}", metadata.rows); + eprintln!(" Layers: {}", metadata.layer_count); + eprintln!("Close the window to exit."); + } + + // Blocks on the main thread until the window closes. + if let Err(e) = viewer.show(&spec) { + eprintln!("{}", e); + std::process::exit(1); + } + } + #[cfg(not(feature = "window"))] + { + let _ = (query, args); + eprintln!("The plot viewer is not compiled in. Rebuild with --features window"); + std::process::exit(1); + } +} + fn cmd_parse(query: String, format: String) { println!("Parsing query: {}", query); println!("Format: {}", format); @@ -502,8 +608,18 @@ fn cmd_validate(query: String, _reader: Option) { } } -// Prints a CSV-like output to stdout with aligned columns -fn print_table_fallback(query: &str, reader: &R, max_rows: usize) { +/// Print a query's table, for a query with nothing to draw. +/// +/// CSV-like, with the columns aligned. +/// +/// Honours `--output` like every other result — printing to stdout while +/// leaving the named file absent is hard to notice from a script. +fn print_table_fallback( + query: &str, + reader: &dyn Reader, + max_rows: usize, + output: Option<&std::path::Path>, +) { let source_tree = match parser::SourceTree::new(query) { Ok(st) => st, Err(e) => { @@ -560,8 +676,16 @@ fn print_table_fallback(query: &str, reader: &R, max_rows: u } } - let output = rows.join("\n"); - println!("{}", output); + let table = rows.join("\n"); + match output { + None => println!("{}", table), + Some(path) => { + if let Err(e) = std::fs::write(path, format!("{table}\n")) { + eprintln!("Failed to write to output file: {}", e); + std::process::exit(1); + } + } + } } fn cmd_docs(first: Option, second: Option, format: Option) { @@ -783,55 +907,3 @@ fn cmd_skill(format: Option) { } } } - -fn render_vegalite(spec: &Spec, options: &WriterOptions) -> Output { - #[cfg(feature = "vegalite")] - { - // Configure from --writer-option, then render - let vl_writer = unwrap_writer(VegaLiteWriter::from_options(options)); - match vl_writer.render(spec) { - Ok(json) => Output::Text(json), - Err(e) => { - eprintln!("Failed to generate Vega-Lite output: {}", e); - std::process::exit(1); - } - } - } - #[cfg(not(feature = "vegalite"))] - { - let _ = (spec, options); - eprintln!("VegaLite writer not compiled in. Rebuild with --features vegalite"); - std::process::exit(1) - } -} - -fn render_png(spec: &Spec, options: &WriterOptions) -> Output { - #[cfg(feature = "png")] - { - // Configure from --writer-option, then render - let png_writer = unwrap_writer(PngWriter::from_options(options)); - match png_writer.render(spec) { - Ok(png) => Output::Bin(png), - Err(e) => { - eprintln!("Failed to generate PNG output: {}", e); - std::process::exit(1); - } - } - } - #[cfg(not(feature = "png"))] - { - let _ = (spec, options); - eprintln!("PNG writer not compiled in. Rebuild with --features png"); - std::process::exit(1) - } -} - -/// A writer built from its options, or the option error on stderr and a -/// non-zero exit — an unusable setting is the user's mistake, not a warning. -#[cfg(any(feature = "vegalite", feature = "png"))] -fn unwrap_writer(writer: ggsql::Result) -> W { - writer.unwrap_or_else(|e| { - eprintln!("{}", e); - std::process::exit(1); - }) -} diff --git a/ggsql-cli/src/writers.rs b/ggsql-cli/src/writers.rs new file mode 100644 index 000000000..0ba809742 --- /dev/null +++ b/ggsql-cli/src/writers.rs @@ -0,0 +1,666 @@ +/*! +The writer registry. + +Every writer the CLI can drive is one [`WriterInfo`] row in [`WRITERS`]: its +name, the feature that compiles it, the words used for it in help and verbose +output, and the function that renders a [`Spec`] with it. Dispatch, the +`--writer` long help, the `-D` long help and the "unknown writer" message are +all derived from that one list, so adding a writer means adding a row and its +render function — nothing else in the CLI changes. + +The render functions return their error as a `String` rather than exiting, so +the caller decides how a failure is reported. +*/ + +use ggsql::reader::Spec; +use ggsql::writer::WriterOptions; +use std::path::Path; +use std::sync::LazyLock; + +// Reached only through a writer's own `check`/`render`, so a build with no +// writer at all would otherwise carry an unused import. +#[cfg(feature = "any-writer")] +use ggsql::writer::Writer; + +#[cfg(feature = "vegalite")] +use ggsql::writer::VegaLiteWriter; + +#[cfg(feature = "jpeg")] +use ggsql::writer::JpegWriter; + +#[cfg(feature = "png")] +use ggsql::writer::PngWriter; + +#[cfg(feature = "tiff")] +use ggsql::writer::TiffWriter; + +#[cfg(feature = "hep")] +use ggsql::writer::HepWriter; + +#[cfg(feature = "pdf")] +use ggsql::writer::PdfWriter; + +#[cfg(feature = "svg")] +use ggsql::writer::SvgWriter; + +#[cfg(feature = "webp")] +use ggsql::writer::WebpWriter; + +/// What a writer produced: text to print, or bytes to pipe. +// A build with none of the listed writers compiled in has an unused variant; +// extend the gates when a writer of that shape is added. +pub enum Output { + #[cfg_attr(not(any(feature = "vegalite", feature = "svg")), allow(dead_code))] + Text(String), + #[cfg_attr( + not(any( + feature = "png", + feature = "jpeg", + feature = "tiff", + feature = "webp", + feature = "pdf", + feature = "hep" + )), + allow(dead_code) + )] + Bin(Vec), +} + +/// A render outcome: the output, plus anything the writer had to degrade to +/// produce it. Most writers report nothing; the vector formats can. +type Rendered = Result<(Output, Vec), String>; + +/// One writer, as the CLI sees it. +pub struct WriterInfo { + /// The name `--writer` takes. + pub name: &'static str, + /// Alternative spellings accepted for `name`. + pub aliases: &'static [&'static str], + /// Filename extensions that imply this writer, without the dot and in + /// lowercase. `--output`'s extension picks a writer from these when + /// `--writer` was not given; see [`for_extension`]. + pub extensions: &'static [&'static str], + /// The cargo feature that compiles this writer in. + pub feature: &'static str, + /// How the format is named in messages: "PNG", "Vega-Lite JSON". + pub label: &'static str, + /// One line describing the format, for `--writer`'s long help. + pub blurb: &'static str, + /// The `-D` settings this writer accepts, for `-D`'s long help. The + /// writer itself remains the authority — an unknown key is its error. + pub options: &'static str, + /// Whether this build has the writer's feature enabled. + pub compiled: bool, + /// Build this writer from `options` and discard it, so a bad setting is + /// reported before any SQL runs rather than after. + pub check: fn(&WriterOptions) -> Result<(), String>, + /// Render a spec with this writer. + pub render: fn(&Spec, &WriterOptions) -> Rendered, +} + +/// Build `W` from `options` and throw it away — the whole of what a row's +/// `check` does once its feature is known to be on. +#[cfg(feature = "any-writer")] +fn check_options(options: &WriterOptions) -> Result<(), String> { + W::from_options(options) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +pub const WRITERS: &[WriterInfo] = &[ + WriterInfo { + name: "vegalite", + aliases: &["vl", "vega-lite"], + extensions: &["json", "vl.json"], + feature: "vegalite", + label: "Vega-Lite JSON", + blurb: "Vega-Lite specification as JSON", + options: "none", + compiled: cfg!(feature = "vegalite"), + check: check_vegalite, + render: render_vegalite, + }, + WriterInfo { + name: "png", + aliases: &[], + extensions: &["png"], + feature: "png", + label: "PNG", + blurb: "PNG image — lossless, alpha preserved", + options: "width, height, units, dpi, background, compression", + compiled: cfg!(feature = "png"), + check: check_png, + render: render_png, + }, + WriterInfo { + name: "jpeg", + aliases: &["jpg"], + extensions: &["jpg", "jpeg"], + feature: "jpeg", + label: "JPEG", + blurb: "JPEG image — lossy; prefer png or webp for plots", + options: "width, height, units, dpi, background, quality", + compiled: cfg!(feature = "jpeg"), + check: check_jpeg, + render: render_jpeg, + }, + WriterInfo { + name: "tiff", + aliases: &["tif"], + extensions: &["tif", "tiff"], + feature: "tiff", + label: "TIFF", + blurb: "TIFF image — lossless, choice of compressor", + options: "width, height, units, dpi, background, compression", + compiled: cfg!(feature = "tiff"), + check: check_tiff, + render: render_tiff, + }, + WriterInfo { + name: "webp", + aliases: &[], + extensions: &["webp"], + feature: "webp", + label: "WebP", + blurb: "WebP image — lossless, and the smallest of the four", + options: "width, height, units, dpi, background", + compiled: cfg!(feature = "webp"), + check: check_webp, + render: render_webp, + }, + WriterInfo { + name: "svg", + aliases: &[], + extensions: &["svg"], + feature: "svg", + label: "SVG", + blurb: "SVG vector graphic — scalable, and its text stays text", + options: "width, height, units, dpi, background, text, embed-fonts, id-prefix", + compiled: cfg!(feature = "svg"), + check: check_svg, + render: render_svg, + }, + WriterInfo { + name: "pdf", + aliases: &[], + extensions: &["pdf"], + feature: "pdf", + label: "PDF", + blurb: "PDF page — vector, with the fonts subset in", + options: "width, height, units, dpi, background, compress, links", + compiled: cfg!(feature = "pdf"), + check: check_pdf, + render: render_pdf, + }, + WriterInfo { + name: "hep", + aliases: &[], + extensions: &["hep"], + feature: "hep", + label: "plot document", + blurb: "Self-contained plot document, for a host that renders it itself", + options: "width, height, units, dpi, background, lossy, embed-fonts", + compiled: cfg!(feature = "hep"), + check: check_hep, + render: render_hep, + }, +]; + +/// The writer used when neither `--writer` nor `--output`'s extension names +/// one. Vega-Lite is the only one with no system requirements at all. +pub const DEFAULT_WRITER: &str = "vegalite"; + +/// Closes `--writer`'s long help: the image writers all rasterise through the +/// GPU, stated once rather than in four blurbs. +const WRITER_FOOTER: &str = "png, jpeg, tiff and webp rasterise on the GPU and need a working \ + adapter at render time. svg, pdf and hep do not.\n\n\ + Left unset, --output's extension picks the writer \ + (chart.pdf writes a PDF), falling back to vegalite. \ + Set explicitly, this wins, and disagreeing with the \ + extension is a warning rather than an error."; + +/// Look up a writer by name or alias, case-insensitively. +pub fn find(name: &str) -> Option<&'static WriterInfo> { + WRITERS.iter().find(|w| { + w.name.eq_ignore_ascii_case(name) || w.aliases.iter().any(|a| a.eq_ignore_ascii_case(name)) + }) +} + +/// The writer a filename implies, from its extension. +/// +/// Matches the longest extension first, so `chart.vl.json` picks Vega-Lite +/// rather than stopping at `json`, and a future two-part extension is not +/// shadowed by its own tail. +/// +/// `None` for a path with no extension, an unrecognised one, or a bare `-` — +/// none of which is an error; they leave `--writer`'s default in place. +pub fn for_extension(path: &Path) -> Option<&'static WriterInfo> { + let name = path.file_name()?.to_str()?.to_ascii_lowercase(); + // Longest first: "vl.json" before "json". + let mut candidates: Vec<(&'static str, &'static WriterInfo)> = WRITERS + .iter() + .flat_map(|w| w.extensions.iter().map(move |e| (*e, w))) + .collect(); + candidates.sort_by_key(|(e, _)| std::cmp::Reverse(e.len())); + candidates + .into_iter() + .find(|(e, _)| name.len() > e.len() + 1 && name.ends_with(&format!(".{e}"))) + .map(|(_, w)| w) +} + +/// The message for a `--writer` name that matches no row. Lists every writer, +/// marking the ones this build lacks — naming a real writer that isn't +/// compiled in is the commoner mistake. +pub fn unknown_writer(name: &str) -> String { + let mut msg = format!("Unknown writer '{name}'\nAvailable writers:\n"); + for info in WRITERS { + msg.push_str(&format!(" {:<9} {}", info.name, info.blurb)); + if !info.compiled { + msg.push_str(&format!(" [not compiled in: --features {}]", info.feature)); + } + msg.push('\n'); + } + msg.pop(); + msg +} + +/// The message for a writer that exists but is not in this build. +fn not_compiled(label: &str, feature: &str) -> String { + format!("The {label} writer is not compiled in. Rebuild with --features {feature}") +} + +/// The same message for a registry row, so the caller can refuse the writer +/// before running any SQL rather than after. +pub fn not_compiled_message(info: &WriterInfo) -> String { + not_compiled(info.label, info.feature) +} + +static WRITER_HELP: LazyLock = LazyLock::new(|| { + let mut help = String::from("Output format. Available writers:"); + for info in WRITERS { + help.push_str(&format!("\n {:<9} {}", info.name, info.blurb)); + if !info.compiled { + help.push_str(&format!( + " [not in this build: --features {}]", + info.feature + )); + } + } + help.push_str("\n\n"); + help.push_str(WRITER_FOOTER); + help +}); + +static OPTION_HELP: LazyLock = LazyLock::new(|| { + let mut help = String::from( + "Settings for the chosen writer, as `key=value`. Repeatable, and one flag \ + may carry several settings separated by `;` (quote it, as most shells read \ + `;` themselves): `-D 'width=1600;dpi=150'`.\n\nSettings by writer:", + ); + for info in WRITERS { + help.push_str(&format!("\n {:<9} {}", info.name, info.options)); + } + help +}); + +/// `--writer`'s long help, listing every writer in the registry. +pub fn writer_help() -> String { + WRITER_HELP.clone() +} + +/// `-D`'s long help, listing each writer's settings. +pub fn option_help() -> String { + OPTION_HELP.clone() +} + +fn check_vegalite(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "vegalite")] + return check_options::(options); + #[cfg(not(feature = "vegalite"))] + { + let _ = options; + Ok(()) + } +} + +fn render_vegalite(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "vegalite")] + { + let writer = VegaLiteWriter::from_options(options).map_err(|e| e.to_string())?; + let json = writer.render(spec).map_err(|e| e.to_string())?; + Ok((Output::Text(json), Vec::new())) + } + #[cfg(not(feature = "vegalite"))] + { + let _ = (spec, options); + Err(not_compiled("Vega-Lite JSON", "vegalite")) + } +} + +fn check_png(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "png")] + return check_options::(options); + #[cfg(not(feature = "png"))] + { + let _ = options; + Ok(()) + } +} + +fn render_png(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "png")] + { + let writer = PngWriter::from_options(options).map_err(|e| e.to_string())?; + let png = writer.render(spec).map_err(|e| e.to_string())?; + Ok((Output::Bin(png), Vec::new())) + } + #[cfg(not(feature = "png"))] + { + let _ = (spec, options); + Err(not_compiled("PNG", "png")) + } +} + +fn check_jpeg(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "jpeg")] + return check_options::(options); + #[cfg(not(feature = "jpeg"))] + { + let _ = options; + Ok(()) + } +} + +fn render_jpeg(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "jpeg")] + { + let writer = JpegWriter::from_options(options).map_err(|e| e.to_string())?; + let jpeg = writer.render(spec).map_err(|e| e.to_string())?; + Ok((Output::Bin(jpeg), Vec::new())) + } + #[cfg(not(feature = "jpeg"))] + { + let _ = (spec, options); + Err(not_compiled("JPEG", "jpeg")) + } +} + +fn check_tiff(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "tiff")] + return check_options::(options); + #[cfg(not(feature = "tiff"))] + { + let _ = options; + Ok(()) + } +} + +fn render_tiff(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "tiff")] + { + let writer = TiffWriter::from_options(options).map_err(|e| e.to_string())?; + let tiff = writer.render(spec).map_err(|e| e.to_string())?; + Ok((Output::Bin(tiff), Vec::new())) + } + #[cfg(not(feature = "tiff"))] + { + let _ = (spec, options); + Err(not_compiled("TIFF", "tiff")) + } +} + +fn check_webp(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "webp")] + return check_options::(options); + #[cfg(not(feature = "webp"))] + { + let _ = options; + Ok(()) + } +} + +fn render_webp(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "webp")] + { + let writer = WebpWriter::from_options(options).map_err(|e| e.to_string())?; + let webp = writer.render(spec).map_err(|e| e.to_string())?; + Ok((Output::Bin(webp), Vec::new())) + } + #[cfg(not(feature = "webp"))] + { + let _ = (spec, options); + Err(not_compiled("WebP", "webp")) + } +} + +fn check_svg(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "svg")] + return check_options::(options); + #[cfg(not(feature = "svg"))] + { + let _ = options; + Ok(()) + } +} + +fn render_svg(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "svg")] + { + let writer = SvgWriter::from_options(options).map_err(|e| e.to_string())?; + let (svg, warnings) = writer.render_reporting(spec).map_err(|e| e.to_string())?; + Ok((Output::Text(svg), warnings)) + } + #[cfg(not(feature = "svg"))] + { + let _ = (spec, options); + Err(not_compiled("SVG", "svg")) + } +} + +fn check_pdf(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "pdf")] + return check_options::(options); + #[cfg(not(feature = "pdf"))] + { + let _ = options; + Ok(()) + } +} + +fn render_pdf(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "pdf")] + { + let writer = PdfWriter::from_options(options).map_err(|e| e.to_string())?; + let (pdf, warnings) = writer.render_reporting(spec).map_err(|e| e.to_string())?; + Ok((Output::Bin(pdf), warnings)) + } + #[cfg(not(feature = "pdf"))] + { + let _ = (spec, options); + Err(not_compiled("PDF", "pdf")) + } +} + +fn check_hep(options: &WriterOptions) -> Result<(), String> { + #[cfg(feature = "hep")] + return check_options::(options); + #[cfg(not(feature = "hep"))] + { + let _ = options; + Ok(()) + } +} + +fn render_hep(spec: &Spec, options: &WriterOptions) -> Rendered { + #[cfg(feature = "hep")] + { + let writer = HepWriter::from_options(options).map_err(|e| e.to_string())?; + let (bytes, warnings) = writer.render_reporting(spec).map_err(|e| e.to_string())?; + Ok((Output::Bin(bytes), warnings)) + } + #[cfg(not(feature = "hep"))] + { + let _ = (spec, options); + Err(not_compiled("plot document", "hep")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_writer_is_findable_by_name_and_alias() { + for info in WRITERS { + assert_eq!(find(info.name).map(|w| w.name), Some(info.name)); + for alias in info.aliases { + assert_eq!(find(alias).map(|w| w.name), Some(info.name)); + } + } + } + + #[test] + fn lookup_ignores_case() { + assert_eq!(find("PNG").map(|w| w.name), Some("png")); + assert!(find("furlongs").is_none()); + } + + #[test] + fn every_writer_declares_at_least_one_extension() { + for info in WRITERS { + assert!( + !info.extensions.is_empty(), + "{} declares no extension, so --output could never pick it", + info.name + ); + } + } + + #[test] + fn extensions_are_lowercase_and_dotless() { + for info in WRITERS { + for ext in info.extensions { + assert_eq!(*ext, ext.to_ascii_lowercase(), "{}", info.name); + assert!(!ext.starts_with('.'), "{}: {ext}", info.name); + } + } + } + + #[test] + fn extensions_are_unique_across_writers() { + let mut seen: Vec<&str> = WRITERS + .iter() + .flat_map(|w| w.extensions.iter().copied()) + .collect(); + let count = seen.len(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen.len(), count, "two writers claim the same extension"); + } + + #[test] + fn an_extension_picks_its_writer() { + for (path, expected) in [ + ("chart.svg", "svg"), + ("chart.pdf", "pdf"), + ("chart.hep", "hep"), + ("chart.png", "png"), + ("chart.jpg", "jpeg"), + ("chart.jpeg", "jpeg"), + ("chart.tif", "tiff"), + ("chart.tiff", "tiff"), + ("chart.webp", "webp"), + ("chart.json", "vegalite"), + ] { + assert_eq!( + for_extension(Path::new(path)).map(|w| w.name), + Some(expected), + "{path}" + ); + } + } + + #[test] + fn a_two_part_extension_beats_its_own_tail() { + // Both spellings reach vegalite, so this asserts the ordering rather + // than the destination: the longer match wins. + let mut candidates: Vec<&str> = WRITERS + .iter() + .flat_map(|w| w.extensions.iter().copied()) + .filter(|e| "chart.vl.json".ends_with(&format!(".{e}"))) + .collect(); + candidates.sort_by_key(|e| std::cmp::Reverse(e.len())); + assert_eq!(candidates.first(), Some(&"vl.json")); + assert_eq!( + for_extension(Path::new("chart.vl.json")).map(|w| w.name), + Some("vegalite") + ); + } + + #[test] + fn extension_matching_ignores_case() { + assert_eq!( + for_extension(Path::new("C.SVG")).map(|w| w.name), + Some("svg") + ); + assert_eq!( + for_extension(Path::new("c.Pdf")).map(|w| w.name), + Some("pdf") + ); + } + + #[test] + fn an_unrecognised_or_absent_extension_picks_nothing() { + // None is not a failure — it leaves the default writer in place. + for path in ["notes.txt", "chart", "-", "chart.", "archive.tar.gz"] { + assert!(for_extension(Path::new(path)).is_none(), "{path}"); + } + } + + #[test] + fn a_dotfile_is_a_name_not_an_extension() { + // `.svg` is a hidden file called "svg", not an SVG, so it picks + // nothing rather than silently choosing a writer from a bare suffix. + assert!(for_extension(Path::new(".svg")).is_none()); + assert!(for_extension(Path::new("dir/.pdf")).is_none()); + } + + #[test] + fn a_directory_in_the_path_is_not_read_as_an_extension() { + assert!(for_extension(Path::new("out.svg/chart")).is_none()); + assert_eq!( + for_extension(Path::new("out.pdf/chart.svg")).map(|w| w.name), + Some("svg") + ); + } + + #[test] + fn the_default_writer_has_a_row() { + assert!(find(DEFAULT_WRITER).is_some()); + } + + #[test] + fn names_and_aliases_are_unique() { + let mut seen = Vec::new(); + for info in WRITERS { + seen.push(info.name); + seen.extend(info.aliases); + } + let mut sorted = seen.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!(sorted.len(), seen.len(), "duplicate writer name or alias"); + } + + #[test] + fn help_mentions_every_writer() { + let writer_help = writer_help(); + let option_help = option_help(); + let unknown = unknown_writer("nope"); + for info in WRITERS { + assert!(writer_help.contains(info.name), "{} missing", info.name); + assert!(option_help.contains(info.name), "{} missing", info.name); + assert!(unknown.contains(info.name), "{} missing", info.name); + } + } +} diff --git a/ggsql-jupyter/CLAUDE.md b/ggsql-jupyter/CLAUDE.md index c0be6a632..7a099b221 100644 --- a/ggsql-jupyter/CLAUDE.md +++ b/ggsql-jupyter/CLAUDE.md @@ -1,6 +1,6 @@ # `ggsql-jupyter/` — Jupyter kernel -Standalone Rust binary that speaks the Jupyter messaging protocol over ZeroMQ. Embeds the `ggsql` library and renders results as Vega-Lite (visual queries) or HTML tables (pure SQL). Workspace member; published to crates.io and to PyPI (as a binary wheel via maturin). +Standalone Rust binary that speaks the Jupyter messaging protocol over ZeroMQ. Embeds the `ggsql` library and renders results as images (visual queries) or HTML tables (pure SQL). Workspace member; published to crates.io and to PyPI (as a binary wheel via maturin). End-user installation and usage live in [`README.md`](README.md). End-user notebook docs live in [`/doc/get_started/tooling.qmd`](../doc/get_started/tooling.qmd). This file describes the *implementation*. @@ -8,7 +8,7 @@ End-user installation and usage live in [`README.md`](README.md). End-user noteb ``` ggsql-jupyter/ -├── Cargo.toml Rust binary + library, depends on ggsql with duckdb + vegalite +├── Cargo.toml Rust binary + library, depends on ggsql with duckdb + svg/pdf + raster-plots ├── pyproject.toml maturin config (bindings = "bin") for the PyPI wheel ├── README.md User-facing install + usage ├── src/ @@ -18,7 +18,7 @@ ggsql-jupyter/ │ ├── executor.rs Runs queries via ggsql::Reader, returns rendered output │ ├── connection.rs Reader lifecycle, connection-string parsing │ ├── data_explorer.rs Positron data-explorer comm channel -│ ├── display.rs Output formatting (Vega-Lite + vega-embed HTML, SQL → HTML table) +│ ├── display.rs Output formatting (image bundles, SQL → HTML table) │ ├── message.rs Jupyter message structs (ZMQ frames, HMAC signing) │ └── util.rs └── tests/ @@ -32,12 +32,147 @@ ggsql-jupyter/ 1. `ggsql-jupyter --install` writes a kernelspec into the active Python environment (Jupyter, conda, uv, virtualenv — auto-detected). 2. `ggsql-jupyter ` is the entry point Jupyter invokes; it reads the connection JSON, opens the five ZMQ sockets (shell, control, iopub, stdin, heartbeat), and runs `kernel.rs`'s message loop. -3. Each `execute_request` is dispatched through `executor.rs` → `ggsql::reader::DuckDBReader::execute(...)`. The kernel keeps a single persistent in-memory DuckDB session so cells share state. Readers are built via the library factory `ggsql::reader::connection::reader_from_uri`, so a composite `+://` connection string (e.g. via `-- @connect:`) wraps the reader in an in-memory caching layer — the persistent kernel session means repeated cells reuse cached remote reads. -4. The result is wrapped by `display.rs` into a Jupyter `display_data` message — Vega-Lite specs go through vega-embed in an HTML payload (works in classic Jupyter, JupyterLab, and Positron); pure SQL goes out as an HTML table. +3. Each `execute_request` is dispatched through `executor.rs` → `Reader::execute(...)`. The kernel keeps a single persistent in-memory DuckDB session so cells share state. Readers are built by the library factory `ggsql::reader::connection::reader_from_uri`, so a composite `+://` connection string (via `-- @connect:`, say) wraps the reader in an in-memory caching layer — the persistent session is what makes that worth having, since later cells reuse cached remote reads. `-- @uncache` clears it mid-session. +4. The result is wrapped by `display.rs` into a Jupyter message. A plot is **rendered here, in the kernel** (see [Rendering](#rendering)); pure SQL goes out as an HTML table. + +## Where output goes: `SessionKind` + +`display.rs`'s `SessionKind` decides which of three output slots a result is aimed at, and it is the thing every rendering decision keys off: + +| `SessionKind` | Slot | +| --- | --- | +| `PositronConsole` | Positron's Plots pane | +| `PositronNotebook` | The notebook cell | +| `Standalone` | A static document — Jupyter, Quarto, nbconvert | + +**The console/notebook split is not cosmetic.** Positron routes a plot comm to the Plots pane whatever kind of session opened it, so a notebook session that used one would put its picture in the pane and leave its cell empty. The two need different output paths, and this is what tells them apart. + +`SessionKind::resolve(session, mode)` prefers what the frontend *declared* over what its session id looks like: + +- **`--session-mode console|notebook|background`** is authoritative. Only a frontend that creates the session can say, which in practice means the ggsql extension — `manager.ts`'s `createKernelSpec` appends it from `sessionMetadata.sessionMode`. The enum's values are already the flag's spelling, so nothing is translated. `background` maps to `Standalone`: it is Positron's session, but attached to no UI, so there is no Positron slot to render into. +- **The session-id heuristic** is the fallback: a `ggsql-` prefix means Positron (its supervisor tags every session it manages), and `notebook` in the id means a notebook. It exists for external Jupyter and Quarto, which pass no flag, and for extension versions predating it. + +Two places deliberately **do not** pass the flag. `writeKernelJson` and `ggsql-jupyter --install` write kernelspecs for *external* frontends, which are exactly the ones that should classify as `Standalone`. And `restoreSession` doesn't rebuild the spec at all — the supervisor replays the argv the session was created with, and a session's mode never changes. + +## Rendering + +Plots are rendered in the kernel and travel as images. **Nothing fetches a renderer from a CDN**, so a plot works offline, in CI, and behind a firewall. + +`plot/` holds the whole of it: + +| File | Role | +| --- | --- | +| `plot/mod.rs` | `Format`, `RenderRequest`, and `choose` — the one function that decides what a plot becomes | +| `plot/backend.rs` | `PlotBackend`: the render thread, and the GPU probe | +| `plot/sizing.rs` | `Canvas`: logical size × device pixel ratio → device pixels + dpi | +| `plot/quarto.rs` | `QUARTO_FIG_*` → a format and a canvas | + +### `choose` + +Three things have to agree — where the output is going, what this build and machine can produce, and what the frontend asked for — so they are reconciled in one readable function rather than spread through the formatting code: + +| `SessionKind` | Result | +| --- | --- | +| `PositronConsole` | A `positron.plot` comm, with **no `execute_result`** — whatever this build can render | +| `PositronNotebook` | A static image bundle in the cell | +| `Standalone` | What `QUARTO_FIG_FORMAT` asked for, else a static image | + +**SVG is the fallback wherever raster output is unavailable** — no GPU adapter, or a build with `raster-plots` off. That is why `ggsql/svg` and `ggsql/pdf` are *non-optional* dependencies: the path that always works must always be compiled in, and it costs nothing to hold that line since the vector writers pull in no wgpu. + +`raster-plots` itself **is** default, because the Plots pane asks for `png` and every shipped build enables it — a plain `cargo build` quietly producing SVG-only plots differed from the released kernel in the way hardest to notice from the outside. `--no-default-features --features all-readers` builds without wgpu. The fallback is a change of *format*, never of delivery: `Format::available` substitutes SVG for a raster request and `mime_type` reports what was produced, so a console keeps its comm either way. + +### The render thread + +`kernel.rs` awaits `handle_shell_message` **inline** in its `select!`, so anything blocking there stalls the heartbeat, the control channel and the SIGINT handler alike. So `PlotBackend` owns a thread, probes for an adapter once at startup (10 s ceiling), and keeps **one** renderer for the session, which handles a changing frame size internally. + +The probe is eager rather than lazy because a lazy one would leave the *first* plot unable to choose a path. + +### What the cold start actually costs + +Measured on a release build, Apple GPU, vello-hybrid: + +| | warm | cold (first process after a build) | +| --- | --- | --- | +| `RasterRenderer::new()` | 14 ms | ~185 ms | +| **first render** | **~85 ms** | **~1.35 s** | +| later renders, 3-point plot at 1200×800 | 5 ms | 5 ms | +| later renders, 50k points | ~200 ms | ~200 ms | +| a render at a size not seen before | 9–22 ms | — | + +**Constructing the renderer is not the expensive part — the first render is**, and most of that is text: parley/fontique enumerating and loading system faces. Rendering an SVG first (no GPU, same text work) drops the first raster render from ~85 ms to ~20 ms, which is what identifies the cost. It is per *process*, not per renderer, so the SVG fallback pays it too. + +That is why the thread renders a **throwaway 64×64 SVG frame at startup**, before anything is waiting on it: with the warm-up, the first plot of a session renders in ~14 ms rather than ~85 ms, and far better than that on a genuinely cold start. `backend::warm_up` builds its own in-memory database to do it — never the session's reader, since executing a query through that would materialise ggsql's internal views in the user's session. + +### The plot comm + +A console session gets one `positron.plot` comm per plot, modelled on `positron.dataExplorer` rather than the singleton connection comm — the pane shows plots as a history. `plot/comm.rs` holds the protocol (all pure, so it is tested without a Positron host); `kernel.rs` holds the transport. + +Five things about it are load-bearing: + +- **The comm alone creates the pane entry, so the kernel emits no `execute_result` alongside it.** Positron's `createActivityItemOutput` inlines *any* output whose data carries an `image/*` mime, consulting neither `output_location` nor the output kind, so a bundle sent as well would appear in the console *and* leave a second, fixed-size entry in the pane. It is also why a console with no raster writer keeps the comm and answers its renders in SVG instead of falling back to a bundle: there is no way to put a picture in the pane alone without one. +- **`comm_open` must follow `execute_input`** and be parented to the `execute_request`. Positron populates `_recentExecutions` from `execute_input`, and that is where the plot's `code` metadata comes from. +- **`render` is answered asynchronously; `get_metadata` is not.** A render goes to the thread and its reply comes back through the `select!` outcome arm, so the message loop stays free. `get_metadata` is answered from `plot_comms` because it gets Positron's default 5 s timeout where `render` gets 30 s — it must never queue behind a render. +- **`get_intrinsic_size` returns `null`.** ggsql has no figure-size syntax, so there is no intrinsic size, and `null` stops Positron offering an "Intrinsic" option that would be a lie. Sizing then follows whichever policy the Plots pane has selected — **`Auto` by default, which caps the aspect ratio at the golden ratio**, so a pane wider than 1.618:1 asks for less width than it has. The pane applies its policy *before* the kernel hears anything (`PlotSizingPolicyAuto.getPlotSize` feeds `setPlotsRenderSettings`, which arrives as `did_change_plots_render_settings`), so that shape is Positron's and not ours. `Fill` uses the whole viewport. +- **`show`/`update` are never sent, and are `MethodNotFound` inbound.** They mean "the backend mutated this figure, re-fetch it"; a ggsql `Spec` is immutable per execution, so re-running a cell opens a *new* comm, as the R and matplotlib backends do. An unknown method is an error rather than `result: null`, so a future Positron method fails visibly instead of being silently satisfied with garbage. + +Plots are retained on the render thread and capped by `--max-plots` (default 32), evicted oldest-first with `comm_close` on iopub — which cleanly removes the plot from the pane, the right semantic for "the kernel no longer keeps that plot". Positron imposes no cap of its own, so this is where a long console session's memory is bounded. + +### Why renders are asynchronous + +Verified rather than assumed: with a large dense render in flight, a `kernel_info_request` issued immediately after it was answered in **1 ms**, while the render's own reply arrived much later. The message loop — and with it the heartbeat, the control channel and the interrupt handler — is genuinely free during a render. + +That matters most for the interactive path, where dragging the Plots pane asks for a frame per event. Positron's `PositronPlotRenderQueue` already serialises renders per session and cancels superseded ones, so the kernel never sees overlapping renders for the same comm and needs no cancellation of its own. + +### Pre-rendering, and the one notification we subscribe to + +`manager.ts` sets `uiSubscriptions: ['did_change_plots_render_settings']`. Without it the frontend never tells the kernel how large the Plots pane is, and the kernel needs that for exactly one thing: rendering a **new** plot at the right size so `comm_open` can carry it as `pre_render`, and the pane shows it immediately instead of blank until its own render request lands. Every other render already carries its own size on the request. + +Three details are easy to get wrong: + +- **It is a notification, not a request.** It has no JSON-RPC `id`, and replying to one is a protocol error — so the ui branch checks `rpc_id.is_null()` before building a reply. It previously answered *everything* on that comm with `result: null`. +- **The first plot of a session carries no pre-render.** The pane has not reported yet, and rendering at a guessed size would show the wrong-sized picture and have it replaced the moment the pane asks properly. The flash is worse than the wait; the reference Python backend skips it for the same reason. +- **A pre-render without `settings` is silently discarded** (`languageRuntimePlotClient.ts` gates on `pre_render?.settings`), so it always goes through `RenderParams::to_result`, which includes them. + +`plot_render_settings` has the same shape as a `render` request's params, so the same parser handles both — which is what keeps a pre-render identical to what a render would have produced. + +### Base64, twice over, differently + +The two transports disagree, and both are right: + +| Transport | `image/svg+xml` | Binary formats | +| --- | --- | --- | +| Static display bundle | as text | base64 | +| Plot comm reply, and `pre_render` | **base64** | base64 | + +The comm's convention is forced: Positron builds `data:{mime_type};base64,{data}` from the reply, so text sent as itself yields an invalid URI. The reference Python backend encodes unconditionally for the same reason. `RenderParams::encode` is the comm's one entry point so this cannot drift. + +### The size clamp + +`sizing::MAX_PX` is **16384**, matching `ggsql::writer::MAX_RASTER_DIMENSION` — +the most a GPU is asked to grant, and beyond what any pane can ask for. So the +clamp is a guard against a nonsense `size` or `pixel_ratio` arriving over the +comm rather than a limit a real plot meets: a large pane at 2x renders at its +full device size. A GPU whose own limit is lower rejects the frame and names +it. The number is spelled out rather than imported because a default kernel +build has no raster writer to import it from. + +### Sizing + +`Canvas::from_logical` scales pixels **and** dpi by the device pixel ratio together. Scaling the pixels alone renders the same chrome into more pixels — a blurry plot at the right size; scaling dpi alone grows the chrome instead of the resolution. This matches matplotlib's Positron backend. `metadata[mime].width/height` then carries the CSS size to display at, so a 2× render appears sharp rather than twice as big. + +**Two different channels report size, and they report different things:** + +| Channel | Carries | Sizes | +| --- | --- | --- | +| `execute_request`'s `positron` dict | `output_width_px`, `output_pixel_ratio` | A **cell output** slot — as wide as the cell, as tall as whatever it is given | +| plot comm `render` params, and the ui comm's `did_change_plots_render_settings` | a required `{width, height}` plus `pixel_ratio` and `format` | The **Plots pane**, which is why a plot in the pane fits it exactly | + +So `RenderHints::canvas` picks a height (golden ratio, close to ggplot2's default figure) because a cell genuinely reports none — while the pane never comes through that function at all, since its size arrives per render rather than per execution. + +**A static bundle carries no `output_location`.** That key routes an output to Positron's plot widget, which would show the picture in the Plots pane *as well as* in the cell — one plot arriving twice. ## Positron-specific bits -- Kernel info advertises `"output_location": "plot"` so visualizations route to Positron's Plot pane. - `data_explorer.rs` implements Positron's data-explorer comm channel (registered query results become explorable tables). - The companion VS Code extension (`ggsql-vscode/`) ships a copy of this binary and also discovers installed ones via the `ggsql.kernelPath` setting, the Jupyter kernelspec directories, the native install locations, or `PATH`. It runs each one with `--version` to name the runtime it registers, so **keep `--version` working**: a kernel that does not answer it is still offered, but without a version in the picker. See [Finding the kernel](../ggsql-vscode/CLAUDE.md#finding-the-kernel). @@ -61,11 +196,13 @@ pip install ggsql-jupyter && ggsql-jupyter --install ## Features ```toml -default = ["all-readers"] +default = ["all-readers", "raster-plots"] all-readers = ["sqlite", "odbc", "duckdb"] ``` -Each feature passes through to `ggsql/`. The default install therefore supports DuckDB, SQLite, and ODBC connection strings. +The reader features pass through to `ggsql/`, so the default install supports DuckDB, SQLite and ODBC connection strings. `raster-plots` adds `ggsql/png`, `ggsql/jpeg` and `ggsql/tiff` — **default on purpose**, for the reason in [`choose`](#choose): the Plots pane asks for `png`, every shipped build enables it, and a plain `cargo build` that quietly produced SVG-only plots differed from the released kernel in the way hardest to notice. `--no-default-features --features all-readers` builds without wgpu; plots then render as SVG. The release workflow passes no feature flags at all, so a wheel is exactly what a plain build produces. + +The vector writers are not features here: `ggsql`'s `svg` and `pdf` are named unconditionally in `[dependencies]`, because the fallback path has to be compiled in whatever else is. ## Testing diff --git a/ggsql-jupyter/Cargo.toml b/ggsql-jupyter/Cargo.toml index 6be601eae..47590aef3 100644 --- a/ggsql-jupyter/Cargo.toml +++ b/ggsql-jupyter/Cargo.toml @@ -18,14 +18,19 @@ name = "ggsql_jupyter" path = "src/lib.rs" [dependencies] -# Core ggsql library -ggsql = { workspace = true, features = ["duckdb", "vegalite"] } +# Core ggsql library. +# +# `svg` and `pdf` are not optional: SVG is the fallback whenever raster output +# is unavailable, so the path that always works must always be compiled in. +# They cost no GPU stack and no wgpu, which is what makes that affordable. +ggsql = { workspace = true, features = ["duckdb", "svg", "pdf"] } # Arrow for DataFrame array types arrow = { workspace = true } -# Async runtime -tokio = { workspace = true } +# Async runtime. `sync` is named explicitly for the render thread's outcome +# channel rather than relying on another dependency to enable it. +tokio = { workspace = true, features = ["sync"] } # CLI clap = { workspace = true } @@ -57,12 +62,24 @@ hex = "0.4" # UUID for message IDs uuid = { version = "1.0", features = ["v4"] } +# Binary output travels base64-encoded in a display bundle +base64 = { workspace = true } + [features] -default = ["all-readers"] +default = ["all-readers", "raster-plots"] all-readers = ["sqlite", "odbc", "duckdb"] odbc = ["ggsql/odbc"] sqlite = ["ggsql/sqlite"] duckdb = ["ggsql/duckdb"] +# The raster plot formats, which need the wgpu stack at build time and a GPU +# adapter at render time. **Default**, because Positron's Plots pane asks for +# `png` and every shipped build enables it: a plain `cargo build` that quietly +# produced SVG-only plots differed from the released kernel in exactly the way +# that is hardest to notice from the outside. For a build without wgpu, use +# `--no-default-features --features all-readers`; plots then render as SVG, +# which needs no adapter. +raster-plots = ["ggsql/png", "ggsql/jpeg", "ggsql/tiff"] + [dev-dependencies] tempfile = "3.8" diff --git a/ggsql-jupyter/README.md b/ggsql-jupyter/README.md index 52f7c23c3..2efde0168 100644 --- a/ggsql-jupyter/README.md +++ b/ggsql-jupyter/README.md @@ -1,15 +1,15 @@ # ggsql Jupyter Kernel -A Jupyter kernel for executing ggsql queries with rich inline Vega-Lite visualizations. +A Jupyter kernel for executing ggsql queries with rich inline visualizations. ## Overview -The ggsql Jupyter kernel enables you to run ggsql queries directly in Jupyter notebooks, with automatic rendering of visualizations using Vega-Lite. It maintains a persistent DuckDB session across cells, allowing you to build up datasets and create visualizations interactively. +The ggsql Jupyter kernel enables you to run ggsql queries directly in Jupyter notebooks, with plots rendered in the kernel and delivered as images. It maintains a persistent DuckDB session across cells, allowing you to build up datasets and create visualizations interactively. ## Features - **Execute ggsql queries** in Jupyter notebooks -- **Rich visualizations** with Vega-Lite rendered inline +- **Rich visualizations** rendered in the kernel and shown inline - **Persistent DuckDB session** across cells - **Pure SQL support** with HTML table output - **Grammar of Graphics** syntax for declarative visualization diff --git a/ggsql-jupyter/src/display.rs b/ggsql-jupyter/src/display.rs index 3733e96c7..6d7fb3e43 100644 --- a/ggsql-jupyter/src/display.rs +++ b/ggsql-jupyter/src/display.rs @@ -5,49 +5,125 @@ use crate::executor::ExecutionResult; use crate::message::MessageHeader; +use crate::plot::{self, Canvas, Delivery, PlotBackend, RenderRequest}; +use anyhow::Result; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use clap::ValueEnum; +use ggsql::reader::Spec; use ggsql::DataFrame; use serde_json::{json, Value}; -/// Frontend-supplied hints about the output rendering slot. +/// What the frontend declared itself to be, via `--session-mode`. /// -/// Three render targets, identified by the Jupyter session id on the -/// incoming execute_request: +/// Only a frontend that knows what it is launching passes this — in practice +/// the ggsql extension. Everything else is classified by the heuristic below. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SessionMode { + /// A Positron console session: plots belong in the Plots pane. + Console, + /// A Positron notebook session: plots belong in the cell. + Notebook, + /// A Positron background session, attached to no UI at all. Output has + /// nowhere special to go, so it is treated exactly like a session Positron + /// is not driving. + Background, +} + +/// Where a plot this kernel produces is meant to end up. /// -/// - **Positron notebook** (`ggsql-notebook-…`): inline code-chunk output -/// in an editor view. Rendered into a plain 400px container that watches -/// layout only when the first measurement collapsed, because Positron -/// animates the slot during its reveal transition. -/// - **Positron console** (`ggsql-…`): output lands in the Plots pane. The -/// container upgrades to `100vh` inside `.positron-output-container`, so -/// Vega-Lite's own container observer tracks pane resizes. -/// - **Standalone** (anything else — Jupyter notebook, Quarto render, …): -/// the HTML embeds in a static document. An outer/inner div wrapper with -/// a 450px design width applies a uniform CSS-transform scale when the -/// viewport is narrower, so the plot shrinks in proportion instead of -/// squashing. +/// Positron routes a plot comm to the Plots pane whatever kind of session +/// opened it, so a notebook using the comm would leave its cell empty. Console +/// and notebook therefore need different output paths. +/// +/// - `PositronConsole`: a `positron.plot` comm and no `execute_result` — the +/// comm alone creates the pane entry, and the pane re-asks on resize. +/// - `PositronNotebook`: a static image bundle in the cell, sized from +/// `output_width_px`. +/// - `Standalone`: anything else — Jupyter, Quarto, nbconvert, and a Positron +/// background session. A static bundle in `QUARTO_FIG_FORMAT`'s format. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionKind { + PositronConsole, + PositronNotebook, + #[default] + Standalone, +} + +impl SessionKind { + /// Classify a session, preferring what the frontend declared. + /// + /// `--session-mode` is authoritative; the session-id heuristic is the + /// fallback for Jupyter, Quarto and extensions predating the flag. + pub fn resolve(session: &str, mode: Option) -> Self { + match mode { + Some(SessionMode::Console) => Self::PositronConsole, + Some(SessionMode::Notebook) => Self::PositronNotebook, + // A background session has no pane and no cell, so there is no + // Positron-specific slot to render into. + Some(SessionMode::Background) => Self::Standalone, + // Positron's supervisor prefixes every session it manages with + // `ggsql-`; Jupyter and Quarto use bare UUIDs. + None if !session.starts_with("ggsql-") => Self::Standalone, + None if session.contains("notebook") => Self::PositronNotebook, + None => Self::PositronConsole, + } + } +} + +/// Frontend-supplied hints about the output rendering slot. #[derive(Default, Debug, Clone, Copy)] pub struct RenderHints { - pub is_notebook: bool, - pub is_positron: bool, + pub kind: SessionKind, + /// Width of the output slot in CSS pixels, when the frontend says. pub output_width_px: Option, + /// Device pixel ratio of the display, when the frontend says. + pub pixel_ratio: Option, } impl RenderHints { - pub fn from_request(header: &MessageHeader, content: &Value) -> Self { - let session = header.session.as_str(); - // Positron's supervisor tags every session it manages with a - // `ggsql-` prefix; standalone Jupyter/Quarto uses UUIDs without one. - let is_positron = session.starts_with("ggsql-"); - let is_notebook = session.contains("notebook"); - let output_width_px = content - .get("positron") + pub fn from_request( + header: &MessageHeader, + content: &Value, + mode: Option, + ) -> Self { + // Positron puts both on the execute request for a notebook or inline + // cell — see `runtimeNotebookKernel.ts`. + let positron = content.get("positron"); + let output_width_px = positron .and_then(|p| p.get("output_width_px")) .and_then(|v| v.as_u64()) .and_then(|v| u32::try_from(v).ok()); + let pixel_ratio = positron + .and_then(|p| p.get("output_pixel_ratio")) + .and_then(|v| v.as_f64()) + .filter(|v| *v > 0.0); Self { - is_notebook, - is_positron, + kind: SessionKind::resolve(header.session.as_str(), mode), output_width_px, + pixel_ratio, + } + } + + /// The canvas a static render should use. + /// + /// An execute request reports a width but no height: a cell output is as + /// wide as the cell and as tall as it is given. The height is therefore + /// ours to pick, and the golden ratio is close to ggplot2's default figure. + /// + /// The Plots pane is sized elsewhere — it reports a full `{width, height}` + /// per render, not per execution, so none of it reaches here. + pub fn canvas(&self) -> Canvas { + let ratio = self.pixel_ratio.unwrap_or(1.0); + match self.output_width_px { + Some(width) if width > 0 => { + let width = f64::from(width); + Canvas::from_logical(width, width / 1.618, ratio) + } + _ => { + let default = Canvas::default(); + Canvas::from_logical(f64::from(default.width), f64::from(default.height), ratio) + } } } } @@ -69,19 +145,43 @@ impl RenderHints { /// "transient": { ... } /// } /// ``` -pub fn format_display_data(result: ExecutionResult, hints: &RenderHints) -> Option { +/// What the kernel should do with a formatted result. +pub enum Formatted { + /// Emit this as the cell's `execute_result`. + Bundle(Value), + /// Open a `positron.plot` comm for this plot and emit **no** + /// `execute_result` — the comm alone creates the pane entry. + PlotComm(Box), + /// Nothing to show, as for a DDL statement. + Nothing, +} + +pub fn format_display_data( + result: ExecutionResult, + hints: &RenderHints, + backend: &PlotBackend, +) -> Result { match result { - ExecutionResult::Visualization { spec } => Some(format_vegalite(spec, hints)), + // Rendered here rather than at execution time, so the format is chosen + // where the destination is known — and can fail here too. + ExecutionResult::Visualization(spec) => { + match plot::choose(hints.kind, backend.raster(), hints.canvas()) { + Delivery::Comm => Ok(Formatted::PlotComm(spec)), + Delivery::Static(request) => { + Ok(Formatted::Bundle(format_static(spec, request, backend)?)) + } + } + } ExecutionResult::DataFrame(df) => { // DDL statements return DataFrames with 0 columns - don't display anything if df.width() == 0 { - None + Ok(Formatted::Nothing) } else { - Some(format_dataframe(df)) + Ok(Formatted::Bundle(format_dataframe(df))) } } ExecutionResult::ConnectionChanged { display_name, .. } => { - Some(format_connection_changed(&display_name)) + Ok(Formatted::Bundle(format_connection_changed(&display_name))) } } } @@ -98,224 +198,44 @@ fn format_connection_changed(display_name: &str) -> Value { }) } -/// Format Vega-Lite visualization as display_data -fn format_vegalite(spec: String, hints: &RenderHints) -> Value { - let html = vegalite_html(&spec, hints); - json!({ - "data": { - "text/html": html, - "text/plain": "Vega-Lite visualization".to_string() - }, - "metadata": {}, - "transient": {}, - "output_location": "plot" - }) -} - -/// Generate the HTML wrapper that embeds a Vega-Lite spec via vega-embed. -pub fn vegalite_html(spec: &str, hints: &RenderHints) -> String { - let spec_value: Value = serde_json::from_str(spec).unwrap_or_else(|e| { - tracing::error!("Failed to parse Vega-Lite JSON: {}", e); - json!({"error": "Invalid Vega-Lite JSON"}) - }); - - let spec_json = serde_json::to_string(&spec_value).unwrap_or_else(|_| "{}".to_string()); - - use std::time::{SystemTime, UNIX_EPOCH}; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(); - let vis_id = format!("vis-{}", timestamp); - - if hints.is_positron { - positron_vegalite_html(&spec_json, &vis_id, hints.is_notebook) - } else { - standalone_vegalite_html(&spec_json, &vis_id) - } -} - -/// Positron template: plain 400px container. Console sessions additionally -/// upgrade the container to `100vh` when it lives inside -/// `.positron-output-container`, letting Vega-Lite's own container observer -/// keep the Plots pane responsive. Notebook sessions skip that override and -/// keep a stable 400px box. +/// Render a plot to an image and wrap it as a static display bundle. +/// +/// No `output_location`: it routes the output to the Plots pane as well as the +/// cell, so the plot would arrive twice. /// -/// Container sizing compiles to `width`/`height` signals that re-read -/// `containerSize()` only on `window:resize`, and `isFinite(0)` holds, so a plot -/// measured before Positron lays the slot out stays zero-sized with no error. -/// `recoverIfCollapsed` fixes that, and two details are load-bearing: it gates -/// on `view.width()` rather than the container's `clientWidth`, which can -/// already be non-zero while the view is not, and it dispatches `resize` rather -/// than calling `view.resize()`, which re-lays out from the same zero. It -/// watches only a collapsed plot and stops once the view has a size, so it -/// cannot re-lay out on every frame of Positron's reveal transition. -fn positron_vegalite_html(spec_json: &str, vis_id: &str, is_notebook: bool) -> String { - let pane_override_js = if is_notebook { - "" +/// `metadata[mime].width/height` is the CSS-pixel size to display at, honoured +/// by JupyterLab and nbconvert; without it a 2x render appears twice as large. +fn format_static(spec: Box, request: RenderRequest, backend: &PlotBackend) -> Result { + let metadata = spec.metadata(); + let summary = format!( + "", + metadata.layer_count, + if metadata.layer_count == 1 { "" } else { "s" }, + metadata.rows, + if metadata.rows == 1 { "" } else { "s" }, + ); + + let bytes = backend.render_once(spec, request)?; + let mime = request.format.mime(); + // SVG is text and travels as itself; everything else is bytes and travels + // base64-encoded, which is what a display bundle expects for binary data. + let payload = if request.format.is_text() { + String::from_utf8(bytes)? } else { - "var container = document.getElementById(visId);\n\ - if (container && container.closest('.positron-output-container')) {\n\ - container.style.height = '100vh';\n\ - }\n" + BASE64.encode(&bytes) }; - format!( - r#"
- -"#, - vis_id = vis_id, - spec_json = spec_json, - pane_override_js = pane_override_js - ) -} - -/// Standalone template: outer/inner div wrapper driving a uniform -/// scale-to-fit. The inner div holds a 450px design width; when the outer -/// container measures narrower, a CSS transform scales the inner block -/// proportionally and the outer height follows the scaled content. A -/// `ResizeObserver` on the outer div keeps the transform current as the -/// document viewport resizes. -fn standalone_vegalite_html(spec_json: &str, vis_id: &str) -> String { - format!( - r#"
-
-
- -"#, - vis_id = vis_id, - spec_json = spec_json - ) + let (css_width, css_height) = request.canvas.css_size(); + Ok(json!({ + "data": { + mime: payload, + "text/plain": summary, + }, + "metadata": { + mime: { "width": css_width, "height": css_height } + }, + "transient": {}, + })) } /// Format DataFrame as HTML table @@ -402,15 +322,133 @@ fn escape_html(s: &str) -> String { mod tests { use super::*; + /// A resolved plot, from a real query — the display layer renders it now, + /// so a hand-written Vega-Lite string is no longer a stand-in for one. + fn a_spec() -> Spec { + use ggsql::reader::{DuckDBReader, Reader}; + DuckDBReader::from_connection_string("duckdb://memory") + .unwrap() + .execute("SELECT 1 AS x, 2 AS y VISUALISE x, y DRAW point") + .unwrap() + } + + fn render(hints: &RenderHints) -> Value { + match format_display_data( + ExecutionResult::Visualization(Box::new(a_spec())), + hints, + &backend(), + ) + .expect("rendering should succeed") + { + Formatted::Bundle(bundle) => bundle, + Formatted::PlotComm(_) => panic!("expected a bundle, not a comm"), + Formatted::Nothing => panic!("expected output"), + } + } + + #[test] + fn test_a_console_gets_a_comm_even_without_an_adapter() { + // No GPU here, and the console still opens a comm — Positron would + // inline a static bundle on its `image/*` mime alone. The comm falls + // back to SVG and says so in `mime_type`. + let formatted = format_display_data( + ExecutionResult::Visualization(Box::new(a_spec())), + &positron_console(), + &backend(), + ) + .expect("rendering should succeed"); + assert!( + matches!(formatted, Formatted::PlotComm(_)), + "a console session must not produce an inline bundle" + ); + } + + #[test] + fn test_a_notebook_gets_a_static_image_in_its_cell() { + let display = render(&positron_notebook()); + + // SVG, because this backend has no GPU — and the fallback is the point: + // a plot still arrives. + let svg = display["data"]["image/svg+xml"].as_str().unwrap(); + assert!(svg.starts_with("::new())); let df = DataFrame::new(vec![("x", empty)]).unwrap(); let result = ExecutionResult::DataFrame(df); - let display = format_display_data(result, &RenderHints::default()); + let display = format_display_data(result, &RenderHints::default(), &backend()).unwrap(); assert!( - display.is_some(), - "DataFrame with columns but 0 rows should return Some" + matches!(display, Formatted::Bundle(_)), + "DataFrame with columns but 0 rows should produce a bundle" ); } @@ -451,181 +489,87 @@ mod tests { ); } + /// A render backend with no GPU, so tests are fast and identical + /// everywhere. The SVG path it leaves is the one that always works. + fn backend() -> PlotBackend { + PlotBackend::without_raster() + } + fn positron_console() -> RenderHints { RenderHints { - is_notebook: false, - is_positron: true, + kind: SessionKind::PositronConsole, output_width_px: None, + pixel_ratio: None, } } fn positron_notebook() -> RenderHints { RenderHints { - is_notebook: true, - is_positron: true, + kind: SessionKind::PositronNotebook, output_width_px: Some(589), + pixel_ratio: None, } } - #[test] - fn test_positron_html_has_no_observer_feedback_loop() { - // Positron animates the output slot during reveal, so the templates - // must not watch layout once the plot has drawn. The only observer is - // the collapsed-render recovery, and it disconnects as soon as the - // view has a size. - for hints in [positron_console(), positron_notebook()] { - let html = vegalite_html(r#"{"mark": "point"}"#, &hints); - assert!( - !html.contains("scaleToFit"), - "Positron HTML must not include scaleToFit (hints={:?})", - hints - ); - assert_eq!( - html.matches("new ResizeObserver").count(), - 1, - "the collapsed-render recovery is the only observer (hints={:?})", - hints - ); - assert!( - html.contains("ro.disconnect();"), - "the observer must disconnect once the view has a size (hints={:?})", - hints - ); - } - } - - #[test] - fn test_positron_recovery_gates_on_rendered_view_size() { - // The container can report a width while the view is still laid out at - // the zero it captured earlier, so gating on `clientWidth` here would - // skip a plot that did collapse. - for hints in [positron_console(), positron_notebook()] { - let html = vegalite_html(r#"{"mark": "point"}"#, &hints); - assert!( - html.contains( - "if (result.view.width() > 0 && result.view.height() > 0) { return; }" - ), - "recovery must gate on the size the view rendered at (hints={:?})", - hints - ); - } - } - - #[test] - fn test_positron_recovery_dispatches_a_resize_event() { - // Container sizing compiles to width/height signals that re-read - // `containerSize()` only on `window:resize`. `view.resize()` re-lays out - // from the current signal value, which is still zero, so it cannot - // recover a collapsed plot. - for hints in [positron_console(), positron_notebook()] { - let html = vegalite_html(r#"{"mark": "point"}"#, &hints); - assert!( - html.contains("window.dispatchEvent(new Event('resize'));"), - "recovery must dispatch the event the signal listens for (hints={:?})", - hints - ); - assert!( - !html.contains("view.resize()"), - "view.resize() does not re-read containerSize (hints={:?})", - hints - ); + fn header(session: &str) -> MessageHeader { + MessageHeader { + msg_id: String::new(), + session: session.to_string(), + username: String::new(), + date: String::new(), + msg_type: String::new(), + version: String::new(), } } - #[test] - fn test_positron_html_recovers_on_both_load_paths() { - // vega-embed is reached either through requirejs or through direct - // script loading; a plot that collapsed must recover either way. - for hints in [positron_console(), positron_notebook()] { - let html = vegalite_html(r#"{"mark": "point"}"#, &hints); - assert_eq!( - html.matches("recoverIfCollapsed").count(), - 3, - "recovery must be defined once and wired into both load paths (hints={:?})", - hints - ); - } + fn kind(session: &str, mode: Option) -> SessionKind { + RenderHints::from_request(&header(session), &json!({}), mode).kind } #[test] - fn test_console_html_fills_positron_plots_pane() { - let html = vegalite_html(r#"{"mark": "point"}"#, &positron_console()); - assert!( - html.contains(".positron-output-container"), - "HTML must detect Positron's plots pane for responsive height" - ); - assert!( - html.contains("100vh"), - "HTML must scale to 100vh inside the plots pane" - ); - assert!( - html.contains("height: 400px"), - "HTML must set a 400px baseline height for console output" + fn test_from_request_detects_positron_sessions() { + // The fallback path, for a frontend that passes no `--session-mode`. + assert_eq!(kind("ggsql-c2a5a97b", None), SessionKind::PositronConsole); + assert_eq!( + kind("ggsql-notebook-abc", None), + SessionKind::PositronNotebook ); + assert_eq!(kind("abcd-efgh-1234", None), SessionKind::Standalone); } #[test] - fn test_notebook_html_skips_pane_override() { - let html = vegalite_html(r#"{"mark": "point"}"#, &positron_notebook()); - assert!( - html.contains("height: 400px"), - "notebook container uses the shared 400px baseline" + fn test_session_mode_overrides_the_heuristic() { + // A declared mode is believed whatever the session id looks like. + assert_eq!( + kind("abcd-efgh-1234", Some(SessionMode::Console)), + SessionKind::PositronConsole ); - assert!( - !html.contains(".positron-output-container"), - "notebook HTML must not carry the plots-pane 100vh override" + assert_eq!( + kind("ggsql-c2a5a97b", Some(SessionMode::Notebook)), + SessionKind::PositronNotebook ); - assert!( - !html.contains("100vh"), - "notebook HTML must not reach for 100vh" + assert_eq!( + kind("ggsql-notebook-abc", Some(SessionMode::Console)), + SessionKind::PositronConsole ); } #[test] - fn test_standalone_html_uses_scale_to_fit() { - // Standalone (Jupyter/Quarto) renders into a static document and - // wraps the plot in the outer/inner div + min-width scale-to-fit so - // narrow viewports shrink the plot proportionally. - let html = vegalite_html(r#"{"mark": "point"}"#, &RenderHints::default()); - assert!( - html.contains("min-width: 450px"), - "standalone HTML must use the 450px design width" - ); - assert!( - html.contains("scaleToFit"), - "standalone HTML must uniformly scale narrow viewports" - ); - assert!( - html.contains("new ResizeObserver"), - "standalone HTML must observe container resizes" - ); - assert!( - html.contains("-outer"), - "standalone HTML must wrap the inner div in an overflow-hidden outer div" - ); - assert!( - !html.contains(".positron-output-container"), - "standalone HTML must not carry the Positron plots-pane branch" + fn test_a_background_session_has_no_positron_slot() { + // Positron's session, but attached to no UI — the heuristic's answer + // (console, from the prefix) would aim output at a pane nobody sees. + assert_eq!( + kind("ggsql-bg-4471", Some(SessionMode::Background)), + SessionKind::Standalone ); + assert_eq!(kind("ggsql-bg-4471", None), SessionKind::PositronConsole); } #[test] - fn test_from_request_detects_positron_sessions() { - let header = |session: &str| MessageHeader { - msg_id: String::new(), - session: session.to_string(), - username: String::new(), - date: String::new(), - msg_type: String::new(), - version: String::new(), - }; - let console = RenderHints::from_request(&header("ggsql-c2a5a97b"), &json!({})); - assert!(console.is_positron && !console.is_notebook); - - let notebook = RenderHints::from_request(&header("ggsql-notebook-abc"), &json!({})); - assert!(notebook.is_positron && notebook.is_notebook); - - let standalone = RenderHints::from_request(&header("abcd-efgh-1234"), &json!({})); - assert!(!standalone.is_positron && !standalone.is_notebook); + fn test_a_non_positron_session_is_standalone_whatever_its_id_says() { + // The heuristic keys on the "ggsql-" prefix, so a foreign id containing + // "notebook" is still standalone. + assert_eq!(kind("jupyter-notebook-9f2c", None), SessionKind::Standalone); + assert_eq!(kind("notebook", None), SessionKind::Standalone); } } diff --git a/ggsql-jupyter/src/executor.rs b/ggsql-jupyter/src/executor.rs index 18f6e0116..ac7adbddd 100644 --- a/ggsql-jupyter/src/executor.rs +++ b/ggsql-jupyter/src/executor.rs @@ -1,34 +1,69 @@ //! Query execution module for ggsql Jupyter kernel //! //! This module handles the execution of ggsql queries using the existing -//! ggsql library components (parser, DuckDB reader, Vega-Lite writer). -//! It supports leading `--` meta-command lines. Each occupies its own comment +//! ggsql library components (parser and reader). Formatting the result — and +//! rendering a plot — is `display.rs`'s, since the format depends on where the +//! output is going. +//! +//! Supports leading `--` meta-command lines. Each occupies its own comment //! line, so a cell may stack them above a query that then runs as normal. use anyhow::Result; use ggsql::{ reader::{ connection::{extract_odbc_value, reader_from_uri}, - Reader, + Reader, Spec, }, validate::validate, - writer::{VegaLiteWriter, Writer}, DataFrame, }; +/// A resolved plot has to reach a render thread, so the design rests on this. +const _: () = { + fn assert_send() {} + let _ = assert_send::; +}; + /// Result of executing a ggsql query -#[derive(Debug)] pub enum ExecutionResult { /// Pure SQL query with no visualization DataFrame(DataFrame), - /// Query with visualization specification - Visualization { - spec: String, // Vega-Lite JSON - }, + /// A query carrying a `VISUALISE` clause, as the resolved plot rather than + /// as rendered output. + /// + /// Not pre-rendered: the format depends on where the output is going, and + /// once a plot comm is open it is asked again on every resize. Boxed because + /// a `Spec` carries the post-stat DataFrames and dwarfs the other variants. + Visualization(Box), /// Connection changed via meta-command ConnectionChanged { display_name: String }, } +// `Spec` is neither `Debug` nor `Clone`, so this summarises rather than +// deriving. What a log wants from a result is its shape and size anyway. +impl std::fmt::Debug for ExecutionResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DataFrame(df) => f + .debug_struct("DataFrame") + .field("rows", &df.height()) + .field("columns", &df.width()) + .finish(), + Self::Visualization(spec) => { + let metadata = spec.metadata(); + f.debug_struct("Visualization") + .field("rows", &metadata.rows) + .field("layers", &metadata.layer_count) + .finish() + } + Self::ConnectionChanged { display_name } => f + .debug_struct("ConnectionChanged") + .field("display_name", display_name) + .finish(), + } + } +} + /// Generate a human-readable display name for a connection URI. pub fn display_name_for_uri(uri: &str) -> String { if uri == "duckdb://memory" { @@ -150,7 +185,6 @@ pub fn take_leading_meta(code: &str) -> Option<(MetaCommand, &str)> { /// Query executor maintaining persistent database connection pub struct QueryExecutor { reader: Box, - writer: VegaLiteWriter, reader_uri: String, } @@ -159,11 +193,9 @@ impl QueryExecutor { pub fn new_with_uri(uri: &str) -> Result { tracing::info!("Initializing query executor with reader: {}", uri); let reader = reader_from_uri(uri)?; - let writer = VegaLiteWriter::new(); Ok(Self { reader, - writer, reader_uri: uri.to_string(), }) } @@ -253,13 +285,9 @@ impl QueryExecutor { spec.metadata().layer_count ); - // 4. Render to output format - let vega_json = self.writer.render(&spec)?; - - tracing::debug!("Generated Vega-Lite spec: {} chars", vega_json.len()); - - // 5. Return result - Ok(ExecutionResult::Visualization { spec: vega_json }) + // 4. Hand back the resolved plot. Choosing a format is the display + // layer's job, because only it knows where the output is going. + Ok(ExecutionResult::Visualization(Box::new(spec))) } } @@ -273,7 +301,7 @@ mod tests { let code = "SELECT 1 as x, 2 as y VISUALISE x, y DRAW point"; let result = executor.execute(code).unwrap(); - assert!(matches!(result, ExecutionResult::Visualization { .. })); + assert!(matches!(result, ExecutionResult::Visualization(_))); } #[test] @@ -325,6 +353,7 @@ mod tests { take_leading_meta("-- @uncache \r\nSELECT 1"), Some((MetaCommand::Uncache, "SELECT 1")) ); + // `-- @uncache foo` on one line is an ordinary SQL comment, not the directive. assert_eq!(take_leading_meta("-- @uncache foo"), None); } diff --git a/ggsql-jupyter/src/kernel.rs b/ggsql-jupyter/src/kernel.rs index 2936e56d7..369061d74 100644 --- a/ggsql-jupyter/src/kernel.rs +++ b/ggsql-jupyter/src/kernel.rs @@ -5,14 +5,17 @@ use crate::connection; use crate::data_explorer::{DataExplorerState, RpcResponse}; -use crate::display::{format_display_data, RenderHints}; +use crate::display::{format_display_data, Formatted, RenderHints, SessionMode}; use crate::executor::{self, ExecutionResult, QueryExecutor}; use crate::message::{ConnectionInfo, JupyterMessage, MessageHeader}; +use crate::plot::comm::{PlotMetadata, RenderParams, RpcError}; +use crate::plot::{PlotBackend, RenderOutcome, RenderTicket}; use anyhow::Result; +use ggsql::reader::Spec; use hmac::{Hmac, Mac}; use serde_json::{json, Value}; use sha2::Sha256; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use zeromq::{PubSocket, RepSocket, RouterSocket, Socket, SocketRecv, SocketSend}; type HmacSha256 = Hmac; @@ -28,20 +31,50 @@ pub struct KernelServer { #[allow(dead_code)] connection: ConnectionInfo, executor: QueryExecutor, + /// The render thread, and whether it found a GPU adapter. + plots: PlotBackend, + /// Finished comm renders, coming back from the render thread. + /// + /// Taken out of `self` before the event loop, because `select!` cannot + /// borrow `self` mutably for this arm while the other arms do the same. + render_outcomes: Option>, + /// Open plot comms, metadata only — each plot's `Spec` lives on the render + /// thread, keeping the retained `DataFrame`s off the async task. + plot_comms: HashMap, + /// Comm ids in the order they were opened, for oldest-first eviction. + plot_order: VecDeque, + /// How many plots to keep. Positron imposes no cap of its own, so a long + /// console session would otherwise retain every post-stat `DataFrame`. + max_plots: usize, + /// Counts plots this session, so an untitled one can be named. + plot_seq: u32, + /// The size, ratio and format the Plots pane last reported, from the ui + /// comm's `did_change_plots_render_settings`. + /// + /// `None` until the pane has reported once, which is why the first plot of + /// a session carries no pre-render. + plot_render_settings: Option, session: String, + /// What the frontend declared this session to be, if it declared anything. + /// `None` leaves classification to the session-id heuristic. + session_mode: Option, execution_count: u32, key: Vec, // Positron comm IDs variables_comm_id: Option, ui_comm_id: Option, - plot_comm_id: Option, connection_comm_id: Option, data_explorer_comms: HashMap, } impl KernelServer { /// Create a new kernel server from connection info - pub async fn new(connection: ConnectionInfo, reader_uri: &str) -> Result { + pub async fn new( + connection: ConnectionInfo, + reader_uri: &str, + session_mode: Option, + max_plots: usize, + ) -> Result { tracing::info!("Initializing kernel server"); // Initialize sockets @@ -83,6 +116,8 @@ impl KernelServer { let key = connection.key.as_bytes().to_vec(); + let (render_tx, render_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut kernel = Self { shell, iopub, @@ -91,12 +126,19 @@ impl KernelServer { heartbeat, connection, executor, + plots: PlotBackend::spawn(render_tx), + render_outcomes: Some(render_rx), + plot_comms: HashMap::new(), + plot_order: VecDeque::new(), + max_plots: max_plots.max(1), + plot_seq: 0, + plot_render_settings: None, session, + session_mode, execution_count: 0, key, variables_comm_id: None, ui_comm_id: None, - plot_comm_id: None, connection_comm_id: None, data_explorer_comms: HashMap::new(), }; @@ -105,6 +147,11 @@ impl KernelServer { // This is required by Jupyter protocol - exactly once at process startup kernel.send_status_initial("starting").await?; + // Only after `starting`: the probe can block for ten seconds, and a + // kernel that has bound its sockets but announced nothing looks to a + // supervisor like a failed launch. + kernel.plots.finish_probe(); + // Open initial connection comm so the Connections pane shows the database kernel.open_connection_comm(reader_uri).await?; @@ -115,8 +162,32 @@ impl KernelServer { pub async fn run(&mut self) -> Result<()> { tracing::info!("Starting kernel event loop"); + // Out of `self`, so the outcome arm below can borrow it while the + // other arms borrow `self` mutably. + let mut render_outcomes = self + .render_outcomes + .take() + .expect("the event loop runs once"); + + // The render thread holds the only sender, so `None` means it is gone. + // The arm is disabled rather than left to complete instantly on every + // iteration and spin at 100% CPU; the session carries on serving SQL. + let mut renders_alive = true; + loop { tokio::select! { + outcome = render_outcomes.recv(), if renders_alive => { + match outcome { + Some(outcome) => self.finish_render(outcome).await?, + None => { + tracing::error!( + "the render thread has stopped; plots will fail for the \ + rest of this session" + ); + renders_alive = false; + } + } + } msg = self.shell.recv() => { if let Ok(msg) = msg { self.handle_shell_message(msg).await?; @@ -137,11 +208,10 @@ impl KernelServer { } } _ = tokio::signal::ctrl_c() => { - // With `interrupt_mode: "signal"`, the frontend sends SIGINT to - // interrupt a running cell, not to stop the kernel. Emit a busy - // -> idle pair to acknowledge the interrupt. - // TODO: When cell execution becomes async, cancel any in-flight - // request here instead. + // With `interrupt_mode: "signal"` a SIGINT interrupts the + // running cell rather than stopping the kernel, so + // acknowledge it with a busy -> idle pair. + // TODO: cancel the in-flight request here once execution is async. tracing::debug!("Received SIGINT; acknowledging."); self.send_status_initial("busy").await?; self.send_status_initial("idle").await?; @@ -288,13 +358,18 @@ impl KernelServer { let content = &parent.content; let code = content["code"].as_str().unwrap_or(""); let silent = content["silent"].as_bool().unwrap_or(false); - let hints = RenderHints::from_request(&parent.header, content); + let hints = RenderHints::from_request(&parent.header, content, self.session_mode); + // `kind` decides where a plot goes, so log its two inputs: the declared + // mode and the session id the heuristic falls back to. A plot in the + // wrong slot is otherwise indistinguishable from one rendered wrongly. tracing::info!( - "Executing code ({} chars, silent={}, notebook={}, width_px={:?})", + "Executing code ({} chars, silent={}, kind={:?} from mode={:?} session={}, width_px={:?})", code.len(), silent, - hints.is_notebook, + hints.kind, + self.session_mode, + parent.header.session, hints.output_width_px ); @@ -319,41 +394,58 @@ impl KernelServer { .await?; } - // Execute the query + // A cell may connect and then go on to query, so a connection change is + // read from the executor's URI rather than from the result variant, + // which only reports a cell that was nothing but meta-commands. let uri_before = self.executor.reader_uri().to_string(); - let result = self.executor.execute(code); - let connection_changed = self.executor.reader_uri() != uri_before; - if connection_changed { + let executed = self.executor.execute(code); + if self.executor.reader_uri() != uri_before { let uri = self.executor.reader_uri().to_string(); self.open_connection_comm(&uri).await?; } - match result { + // Formatting renders the plot, so it is fallible and its failures are + // the user's. Both stages feed one `Result` and share the error path + // below; propagating out of the loop would end the process and take + // the session's database with it. + let result = match executed { Ok(exec_result) => { // A bare connection change renders nothing. - let suppress_output = + let is_connection_changed = matches!(&exec_result, ExecutionResult::ConnectionChanged { .. }); + // Nothing to display for a silent cell, for a connection + // change, or for DDL (which formats to `Nothing`). + if silent || is_connection_changed { + Ok(Formatted::Nothing) + } else { + format_display_data(exec_result, &hints, &self.plots) + } + } + Err(err) => Err(err), + }; + + match result { + Ok(formatted) => { // Send execute_result (not display_data) // Per Jupyter spec: execute_result includes execution_count - // Only send if there's something to display (DDL returns None) - if !silent && !suppress_output { - if let Some(display_data) = format_display_data(exec_result, &hints) { - // Build message content, including output_location if present - let mut content = json!({ + match formatted { + // No `output_location`: it routes the output to the Plots + // pane as well as the cell, so the plot would arrive twice. + Formatted::Bundle(display_data) => { + let content = json!({ "execution_count": self.execution_count, "data": display_data["data"], "metadata": display_data["metadata"] }); - - // Add output_location for Positron routing (e.g., to Plots pane) - if let Some(location) = display_data.get("output_location") { - content["output_location"] = location.clone(); - tracing::info!("Setting output_location: {}", location); - } - self.send_iopub("execute_result", content, parent).await?; } + // No `execute_result`: the comm alone creates the pane + // entry, and an output message would duplicate it. + Formatted::PlotComm(spec) => { + self.open_plot_comm(spec, code, parent).await?; + } + Formatted::Nothing => {} } // Send execute_reply @@ -371,6 +463,8 @@ impl KernelServer { .await?; } Err(err) => { + // Both stages report here: whether the query or the rendering + // of its result failed, the cell failed. tracing::error!("Execution error: {}", err); // Send error message @@ -613,37 +707,40 @@ impl KernelServer { } // Handle positron.ui requests else if Some(comm_id.to_string()) == self.ui_comm_id { - self.send_shell_reply( - "comm_msg", - json!({ - "comm_id": comm_id, - "data": { - "jsonrpc": "2.0", - "id": rpc_id, - "result": null - } - }), - parent, - identities, - ) - .await?; + // A notification carries no `id` and replying to one is a + // protocol error, so check the shape before building a reply. + if rpc_id.is_null() { + self.handle_ui_notification(method, &data["params"]); + } else { + self.send_shell_reply( + "comm_msg", + json!({ + "comm_id": comm_id, + "data": { + "jsonrpc": "2.0", + "id": rpc_id, + "result": null + } + }), + parent, + identities, + ) + .await?; + } } // Handle positron.plot requests - else if Some(comm_id.to_string()) == self.plot_comm_id { - self.send_shell_reply( - "comm_msg", - json!({ - "comm_id": comm_id, - "data": { - "jsonrpc": "2.0", - "id": rpc_id, - "result": null - } - }), - parent, - identities, - ) - .await?; + else if self.plot_comms.contains_key(comm_id) { + let rpc_id = rpc_id.clone(); + let deferred = self + .handle_plot_rpc(method, &rpc_id, comm_id, parent, identities) + .await?; + // A render in flight has its `busy` closed by `finish_render`, + // so return rather than close it here. The flag reports whether + // one was actually dispatched, not whether `render` was asked + // for, so a rejected request stays paired too. + if deferred { + return Ok(()); + } } // Handle positron.connection requests else if Some(comm_id.to_string()) == self.connection_comm_id { @@ -708,8 +805,10 @@ impl KernelServer { comms[id] = json!({"target_name": "positron.ui"}); } } - if let Some(id) = &self.plot_comm_id { - if target_name.is_none() || target_name == Some("positron.plot") { + // Positron calls `listClients(Plot)` on reconnect, so every open plot + // has to be listed — not just one. + if target_name.is_none() || target_name == Some("positron.plot") { + for id in self.plot_comms.keys() { comms[id] = json!({"target_name": "positron.plot"}); } } @@ -763,9 +862,12 @@ impl KernelServer { } else if Some(comm_id.to_string()) == self.ui_comm_id { tracing::info!("Closing positron.ui comm"); self.ui_comm_id = None; - } else if Some(comm_id.to_string()) == self.plot_comm_id { - tracing::info!("Closing positron.plot comm"); - self.plot_comm_id = None; + } else if self.plot_comms.remove(comm_id).is_some() { + tracing::info!("Closing positron.plot comm {}", comm_id); + self.plot_order.retain(|id| id != comm_id); + // Drop the retained plot too, or closing a comm would leak its + // post-stat DataFrames for the rest of the session. + self.plots.forget(comm_id); } else if Some(comm_id.to_string()) == self.connection_comm_id { tracing::info!("Closing positron.connection comm"); self.connection_comm_id = None; @@ -826,6 +928,303 @@ impl KernelServer { Ok(()) } + /// Open a `positron.plot` comm for a freshly executed plot. + /// + /// Backend-initiated, one comm per plot like `positron.dataExplorer`, since + /// the pane shows a session's plots as a history. + /// + /// Must follow `execute_input`: Positron takes the plot's `code` metadata + /// from the `_recentExecutions` map that message populates. + async fn open_plot_comm( + &mut self, + spec: Box, + code: &str, + parent: &JupyterMessage, + ) -> Result<()> { + let comm_id = uuid::Uuid::new_v4().to_string(); + self.plot_seq = self.plot_seq.saturating_add(1); + + let title = spec + .plot() + .labels + .as_ref() + .and_then(|labels| labels.labels.get("title")) + .and_then(|title| title.as_deref()); + let metadata = PlotMetadata { + name: crate::plot::comm::plot_name(title, self.plot_seq), + execution_id: parent.header.msg_id.clone(), + code: code.to_string(), + }; + + tracing::info!("Opening positron.plot comm {} ({})", comm_id, metadata.name); + + // The plot itself goes to the render thread, which owns it from here. + self.plots.store(comm_id.clone(), spec); + self.plot_comms.insert(comm_id.clone(), metadata); + self.plot_order.push_back(comm_id.clone()); + + // Rendered at the size the pane last reported; `None` for the first + // plot of a session, before the pane has reported at all. + let data = match self.pre_render(&comm_id) { + Some(pre_render) => json!({ "pre_render": pre_render }), + None => Value::Null, + }; + + // Parented to the `execute_request`, as every iopub message during an + // execution must be — it ties the plot to the cell that produced it. + let msg = self.create_message( + "comm_open", + json!({ + "comm_id": comm_id, + "target_name": "positron.plot", + "data": data, + }), + Some(parent), + ); + let zmq_msg = self.serialize_message_with_topic(&msg, "comm_open")?; + self.iopub.send(zmq_msg).await?; + + self.evict_old_plots(parent).await + } + + /// Act on a notification pushed at us over the ui comm. + /// + /// Nothing is replied to — see the call site. + fn handle_ui_notification(&mut self, method: &str, params: &Value) { + match method { + // The Plots pane reporting its size, ratio and preferred format on + // resize. The only place a pane's size reaches us outside a render + // request, and so the only thing that makes a pre-render possible. + "did_change_plots_render_settings" => { + match RenderParams::from_rpc(¶ms["settings"]) { + Ok(settings) => { + // The pane asks for `png`; a build with no raster + // writer stores the SVG it can actually pre-render. + let settings = settings.available(self.plots.raster()); + tracing::debug!( + "plots pane render settings: {}x{} @ {}x", + settings.request.canvas.width, + settings.request.canvas.height, + settings.pixel_ratio + ); + self.plot_render_settings = Some(settings); + } + Err(e) => tracing::warn!("unusable plot render settings: {:?}", e), + } + } + other => tracing::debug!("ignoring ui notification '{other}'"), + } + } + + /// Render a plot at the pane's last known settings, for `comm_open`. + /// + /// `None` before the pane has reported, i.e. the first plot of a session: + /// a guessed size would flash a wrong-sized picture and be replaced at + /// once. The reference Python backend skips it for the same reason. + /// + /// Positron discards a pre-render without `settings` + /// (`languageRuntimePlotClient.ts`), so this goes through `to_result`. + fn pre_render(&self, comm_id: &str) -> Option { + let settings = self.plot_render_settings?; + // A pre-render is a nicety: if it fails or times out the comm still + // opens, and the pane's own render request follows a moment later. + match self.plots.render_stored(comm_id, settings.request) { + Ok(bytes) => Some(settings.to_result(RenderParams::encode(&bytes))), + Err(e) => { + tracing::debug!("no pre-render for this plot: {e}"); + None + } + } + } + + /// Close the oldest plot comms until the history is back within its cap. + /// + /// `comm_close` on iopub removes the plot from the pane, which is what "the + /// kernel no longer keeps that plot" means; leaving it listed and failing + /// its renders would be worse. Parented to the `execute_request` that + /// pushed the history over its cap — a frontend may drop an unparented + /// message mid-execution. + async fn evict_old_plots(&mut self, parent: &JupyterMessage) -> Result<()> { + while self.plot_order.len() > self.max_plots { + let Some(comm_id) = self.plot_order.pop_front() else { + break; + }; + tracing::info!("Evicting plot comm {} (history is full)", comm_id); + self.plot_comms.remove(&comm_id); + self.plots.forget(&comm_id); + + let msg = + self.create_message("comm_close", json!({ "comm_id": comm_id }), Some(parent)); + let zmq_msg = self.serialize_message_with_topic(&msg, "comm_close")?; + self.iopub.send(zmq_msg).await?; + } + Ok(()) + } + + /// Serve a JSON-RPC request on a plot comm. + /// + /// `render` is dispatched to the render thread and answered later from + /// [`Self::finish_render`]; the others are answered from state held here, + /// because `get_metadata` gets Positron's default 5 s timeout where + /// `render` gets 30 s and so must never queue behind one. + /// + /// Returns whether a render was dispatched. `true` means the reply — and + /// the `idle` closing the request's `busy` — is [`Self::finish_render`]'s + /// to send; every other outcome is answered here. + async fn handle_plot_rpc( + &mut self, + method: &str, + rpc_id: &Value, + comm_id: &str, + parent: &JupyterMessage, + identities: &[Vec], + ) -> Result { + match method { + "render" => { + let params = match RenderParams::from_rpc(&parent.content["data"]["params"]) { + Ok(params) => params.available(self.plots.raster()), + Err(e) => { + self.reply_plot_error(&e, rpc_id, comm_id, parent, identities) + .await?; + return Ok(false); + } + }; + let ticket = RenderTicket { + comm_id: comm_id.to_string(), + rpc_id: rpc_id.clone(), + params, + parent: parent.clone(), + identities: identities.to_vec(), + }; + // Answered when the thread reports back — unless it is gone, in + // which case no outcome will arrive and Positron would sit out + // its 30 s timeout against a kernel that never returns to `idle`. + match self.plots.request_render(comm_id, params.request, ticket) { + Ok(()) => Ok(true), + Err(e) => { + tracing::error!("could not dispatch a render: {e}"); + let e = RpcError::Internal(format!("could not render this plot: {e}")); + self.reply_plot_error(&e, rpc_id, comm_id, parent, identities) + .await?; + Ok(false) + } + } + } + // ggsql has no figure-size syntax, so there is no intrinsic size. + // `null` makes Positron use its fill policy rather than offer an + // "Intrinsic" option that would be a lie. + "get_intrinsic_size" => { + self.reply_plot_result(Value::Null, rpc_id, comm_id, parent, identities) + .await?; + Ok(false) + } + "get_metadata" => { + match self.plot_comms.get(comm_id) { + Some(metadata) => { + let result = metadata.to_result(); + self.reply_plot_result(result, rpc_id, comm_id, parent, identities) + .await?; + } + None => { + let e = RpcError::Internal("this plot is no longer available".into()); + self.reply_plot_error(&e, rpc_id, comm_id, parent, identities) + .await?; + } + } + Ok(false) + } + // An error rather than `result: null`, so a future Positron method + // fails visibly instead of being satisfied with garbage. `show` and + // `update` belong here: they mean "re-fetch this figure", but a + // `Spec` is immutable per execution, so re-running a cell opens a + // new comm as the R and matplotlib backends do. Do not add them. + other => { + let e = RpcError::MethodNotFound(format!("the plot comm has no '{other}' method")); + self.reply_plot_error(&e, rpc_id, comm_id, parent, identities) + .await?; + Ok(false) + } + } + } + + /// Send a finished render back as its comm's RPC reply. + async fn finish_render(&mut self, outcome: RenderOutcome) -> Result<()> { + let RenderOutcome { ticket, result } = outcome; + let RenderTicket { + comm_id, + rpc_id, + params, + parent, + identities, + } = *ticket; + + match result { + Ok(bytes) => { + // Always base64 here, SVG included — see `RenderParams::encode`. + let encoded = RenderParams::encode(&bytes); + self.reply_plot_result( + params.to_result(encoded), + &rpc_id, + &comm_id, + &parent, + &identities, + ) + .await?; + } + Err(e) => { + tracing::warn!("plot render failed: {e}"); + // Reply with an error rather than switching output kind + // mid-comm; Positron shows a render error in the pane. + let e = RpcError::Internal(format!("could not render this plot: {e}")); + self.reply_plot_error(&e, &rpc_id, &comm_id, &parent, &identities) + .await?; + } + } + + // The `busy` that went out when the request arrived is now answered. + self.send_status("idle", &parent).await + } + + async fn reply_plot_result( + &mut self, + result: Value, + rpc_id: &Value, + comm_id: &str, + parent: &JupyterMessage, + identities: &[Vec], + ) -> Result<()> { + self.send_shell_reply( + "comm_msg", + json!({ + "comm_id": comm_id, + "data": { "jsonrpc": "2.0", "id": rpc_id, "result": result } + }), + parent, + identities, + ) + .await + } + + async fn reply_plot_error( + &mut self, + error: &RpcError, + rpc_id: &Value, + comm_id: &str, + parent: &JupyterMessage, + identities: &[Vec], + ) -> Result<()> { + self.send_shell_reply( + "comm_msg", + json!({ + "comm_id": comm_id, + "data": { "jsonrpc": "2.0", "id": rpc_id, "error": error.to_json() } + }), + parent, + identities, + ) + .await + } + /// Handle JSON-RPC requests on the connection comm async fn handle_connection_rpc( &mut self, diff --git a/ggsql-jupyter/src/lib.rs b/ggsql-jupyter/src/lib.rs index c0e9c42fd..7b06b0960 100644 --- a/ggsql-jupyter/src/lib.rs +++ b/ggsql-jupyter/src/lib.rs @@ -7,6 +7,7 @@ pub mod data_explorer; pub mod display; pub mod executor; pub mod message; +pub mod plot; // Re-export commonly used types pub use display::format_display_data; pub use executor::{ExecutionResult, QueryExecutor}; diff --git a/ggsql-jupyter/src/main.rs b/ggsql-jupyter/src/main.rs index fc2c44cea..2cd08240a 100644 --- a/ggsql-jupyter/src/main.rs +++ b/ggsql-jupyter/src/main.rs @@ -1,6 +1,7 @@ //! ggsql Jupyter Kernel //! -//! A Jupyter kernel for executing ggsql queries with rich Vega-Lite visualizations. +//! A Jupyter kernel for executing ggsql queries. Plots are rendered here, in +//! the kernel, and travel as images — nothing fetches a renderer from a CDN. mod connection; mod data_explorer; @@ -8,8 +9,10 @@ mod display; mod executor; mod kernel; mod message; +mod plot; use anyhow::{Context, Result}; use clap::Parser; +use display::SessionMode; use message::ConnectionInfo; use std::env; use std::fs; @@ -28,6 +31,23 @@ struct Args { #[arg(long, default_value = "duckdb://memory")] reader: String, + /// What kind of session this is, when the frontend knows. + /// + /// Only a frontend creating the session can say — in practice the ggsql + /// extension. Left unset, the kernel classifies the session from its id, + /// which is what external Jupyter and Quarto rely on. + #[arg(long, value_enum)] + session_mode: Option, + + /// How many plots to keep available for re-rendering. + /// + /// Each retained plot holds its post-stat data so the Plots pane can + /// re-draw it at any size without re-running the query. Positron imposes + /// no cap of its own, so this is where a long console session's memory is + /// bounded. The oldest plots are closed once the limit is passed. + #[arg(long, default_value_t = 32, value_parser = clap::value_parser!(u32).range(1..))] + max_plots: u32, + /// Install the kernel spec #[arg(long)] install: bool, @@ -75,7 +95,13 @@ async fn main() -> Result<()> { tracing::info!("Creating kernel server"); // Create and run kernel - let mut kernel = kernel::KernelServer::new(connection, &args.reader).await?; + let mut kernel = kernel::KernelServer::new( + connection, + &args.reader, + args.session_mode, + args.max_plots as usize, + ) + .await?; tracing::info!("Kernel ready, starting event loop"); diff --git a/ggsql-jupyter/src/message.rs b/ggsql-jupyter/src/message.rs index 05bdf49da..855a9c744 100644 --- a/ggsql-jupyter/src/message.rs +++ b/ggsql-jupyter/src/message.rs @@ -36,7 +36,7 @@ impl ConnectionInfo { } /// A Jupyter protocol message -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct JupyterMessage { pub header: MessageHeader, pub parent_header: Value, @@ -47,7 +47,7 @@ pub struct JupyterMessage { } /// Message header -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct MessageHeader { pub msg_id: String, pub session: String, diff --git a/ggsql-jupyter/src/plot/backend.rs b/ggsql-jupyter/src/plot/backend.rs new file mode 100644 index 000000000..e72482f6d --- /dev/null +++ b/ggsql-jupyter/src/plot/backend.rs @@ -0,0 +1,595 @@ +//! The render thread. +//! +//! Rendering does not happen on the message loop: `kernel.rs` awaits +//! `handle_shell_message` inline in its `select!`, so anything blocking there +//! stalls the heartbeat, the control channel and the SIGINT handler alike. +//! +//! # What the cold start actually costs +//! +//! Measured on a release build, Apple GPU, vello-hybrid: +//! +//! | | warm | cold (first process after a build) | +//! | --- | --- | --- | +//! | `RasterRenderer::new()` | 14 ms | ~185 ms | +//! | **first render** | **~85 ms** | **~1.35 s** | +//! | later renders, 3-point plot at 1200×800 | 5 ms | 5 ms | +//! | later renders, 50k points | ~200 ms | ~200 ms | +//! +//! The first render, not the renderer's construction, is the expensive part, +//! and most of it is parley/fontique loading system faces — a per-process cost. +//! Rendering an SVG first does the same text work with no GPU and drops the +//! first raster render from ~85 ms to ~20 ms. +//! +//! So the thread builds the renderer and renders a throwaway frame at startup, +//! before anyone is waiting. The renderer is `Send` but not `Sync`, which suits +//! a thread that owns it and never shares it. + +use std::collections::HashMap; +use std::sync::mpsc::{self, Receiver, Sender}; + +use anyhow::{anyhow, Result}; +use ggsql::reader::Spec; + +use super::{Format, RenderRequest, RenderTicket}; + +/// How long to wait for a GPU adapter before deciding there isn't one. +/// +/// Eager, because a lazy probe would leave the first plot unable to choose a +/// path — but a driver silent for ten seconds is not one to render through. +const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// How long the message loop will wait for a pre-render before opening the +/// comm without one. +/// +/// [`PlotBackend::render_stored`] blocks the loop and can queue behind the +/// pane's own jobs, so the wait is bounded — a stalled heartbeat costs more +/// than a missing pre-render. +const PRE_RENDER_BUDGET: std::time::Duration = std::time::Duration::from_millis(500); + +/// A finished render, on its way back to the message loop. +pub struct RenderOutcome { + pub ticket: Box, + pub result: Result>, +} + +/// Work for the render thread. +enum Job { + /// Keep `spec` so the plot can be re-drawn at a new size without re-running + /// the query. Its `DataFrame`s stay here rather than on the async task. + Store { comm_id: String, spec: Box }, + /// Forget a stored plot, because its comm closed or it was evicted. + Forget { comm_id: String }, + /// Re-render a stored plot and answer `reply` directly. + /// + /// The blocking counterpart to `Render`, used once per plot for the + /// pre-render that rides on `comm_open`. + RenderStored { + comm_id: String, + request: RenderRequest, + reply: Sender>>, + }, + /// Re-render a stored plot and report the result asynchronously. + Render { + comm_id: String, + request: RenderRequest, + // Boxed to keep the variants a similar size: a ticket carries a whole + // Jupyter message, and every job would otherwise be that large. + ticket: Box, + }, + /// Render a plot we were handed and will not keep, answering `reply` + /// directly. The one-shot path, for a static output bundle. + RenderOnce { + spec: Box, + request: RenderRequest, + reply: Sender>>, + }, + /// Stop the thread. + Shutdown, +} + +/// A handle to the render thread. +pub struct PlotBackend { + jobs: Sender, + /// Whether a GPU adapter was found at startup, and so whether the raster + /// formats are available. Decided once. `false` until [`Self::finish_probe`] + /// answers, which is the safe direction to be wrong in — it means SVG. + raster: bool, + /// The probe's answer, until it is collected. `None` once collected, and + /// for a backend that never probes. + probe: Option>, +} + +impl PlotBackend { + /// Start the render thread and begin probing for a GPU adapter. + /// + /// Returns immediately; [`Self::finish_probe`] collects the answer. Eager, + /// so the first plot can choose a path, but non-blocking so it does not + /// delay the `starting` status. + pub fn spawn(outcomes: tokio::sync::mpsc::UnboundedSender) -> Self { + Self::start(true, Some(outcomes)) + } + + /// Wait for the GPU probe, so every later [`Self::raster`] is answered. + /// + /// Blocks for up to [`PROBE_TIMEOUT`]. Call it *after* announcing + /// `starting`: a wedged driver would otherwise leave the kernel silent on a + /// bound socket, which a supervisor reads as a failed launch. + pub fn finish_probe(&mut self) { + let Some(probe) = self.probe.take() else { + return; + }; + + // A thread that died before probing, or a driver that never answered, + // both mean the same thing to us. + self.raster = probe.recv_timeout(PROBE_TIMEOUT).unwrap_or_else(|_| { + tracing::warn!("GPU probe did not finish within {PROBE_TIMEOUT:?}"); + false + }); + + if self.raster { + tracing::info!("GPU adapter available; raster plot formats enabled"); + } else if cfg!(feature = "raster-plots") { + tracing::info!("no GPU adapter; plots will render as SVG"); + } else { + // Distinct from the line above: the machine may well have an + // adapter, and saying otherwise misdirects the debugging. + tracing::info!("built without the raster-plots feature; plots will render as SVG"); + } + } + + /// A backend that never builds a GPU renderer. + /// + /// For tests: the probe is the one slow, machine-dependent part of startup, + /// and the SVG path it leaves is the one that always works. + #[cfg(test)] + pub fn without_raster() -> Self { + Self::start(false, None) + } + + fn start( + allow_raster: bool, + outcomes: Option>, + ) -> Self { + let (jobs, inbox) = mpsc::channel(); + let (probed, probe_result) = mpsc::channel(); + + std::thread::Builder::new() + .name("ggsql-render".to_string()) + .spawn(move || render_loop(inbox, probed, allow_raster, outcomes)) + .expect("failed to spawn the render thread"); + + Self { + jobs, + raster: false, + // Nothing to wait for in a build or a test that never probes. + probe: allow_raster.then_some(probe_result), + } + } + + /// Whether this build and this machine can produce raster output. + pub fn raster(&self) -> bool { + self.raster + } + + /// Render a plot once, blocking until the thread answers. + /// + /// Blocking, unlike [`Self::request_render`]: the static path runs once per + /// execution and its output has to be ordered between `execute_input` and + /// `execute_reply`, which a few milliseconds buys cheaply. + /// + /// # Errors + /// + /// Returns an error if the render thread has stopped, or if the render + /// itself failed. + pub fn render_once(&self, spec: Box, request: RenderRequest) -> Result> { + let (reply, answer) = mpsc::channel(); + self.jobs + .send(Job::RenderOnce { + spec, + request, + reply, + }) + .map_err(|_| anyhow!("the render thread has stopped"))?; + answer + .recv() + .map_err(|_| anyhow!("the render thread stopped while rendering"))? + } + + /// Keep a plot so its comm can re-render it at any size. + pub fn store(&self, comm_id: String, spec: Box) { + let _ = self.jobs.send(Job::Store { comm_id, spec }); + } + + /// Forget a stored plot. + pub fn forget(&self, comm_id: &str) { + let _ = self.jobs.send(Job::Forget { + comm_id: comm_id.to_string(), + }); + } + + /// Render a stored plot, blocking until the thread answers or + /// [`PRE_RENDER_BUDGET`] runs out. + /// + /// The pre-render riding on `comm_open`, and the one blocking call made + /// from the message loop. The pane's earlier jobs can queue ahead of it, so + /// the wait is capped: giving up costs a comm without a pre-render, waiting + /// costs the heartbeat. The pane's own renders go through + /// [`Self::request_render`]. + /// + /// # Errors + /// + /// Returns an error if the thread has stopped, the plot is unknown, the + /// render failed, or the budget expired. + pub fn render_stored(&self, comm_id: &str, request: RenderRequest) -> Result> { + let (reply, answer) = mpsc::channel(); + self.jobs + .send(Job::RenderStored { + comm_id: comm_id.to_string(), + request, + reply, + }) + .map_err(|_| anyhow!("the render thread has stopped"))?; + // The job stays on the queue and its result is dropped, which the + // thread treats as a caller that gave up — not as an error. + answer + .recv_timeout(PRE_RENDER_BUDGET) + .map_err(|_| anyhow!("the render did not finish within {PRE_RENDER_BUDGET:?}"))? + } + + /// Ask for a stored plot to be re-rendered, and return immediately. + /// + /// Why the thread exists. The reply arrives later on the outcome channel, + /// so a render — up to a few hundred milliseconds, and asked for once per + /// frame while a pane is dragged — never blocks the message loop. + /// + /// # Errors + /// + /// Returns an error if the render thread has stopped, in which case no + /// outcome will ever arrive — so the caller has to answer the request + /// itself rather than wait for one. + pub fn request_render( + &self, + comm_id: &str, + request: RenderRequest, + ticket: RenderTicket, + ) -> Result<()> { + self.jobs + .send(Job::Render { + comm_id: comm_id.to_string(), + request, + ticket: Box::new(ticket), + }) + .map_err(|_| anyhow!("the render thread has stopped")) + } +} + +impl Drop for PlotBackend { + fn drop(&mut self) { + let _ = self.jobs.send(Job::Shutdown); + } +} + +/// The render thread's body: probe once, then serve jobs until told to stop. +fn render_loop( + inbox: Receiver, + probed: Sender, + allow_raster: bool, + outcomes: Option>, +) { + // One renderer for the whole session; it handles a changing frame size + // internally, so it serves every render whatever size is asked for. + let mut renderer = if allow_raster { + raster_renderer() + } else { + None + }; + let _ = probed.send(renderer.is_some()); + + // Pay the first-render cost now, while nobody is waiting on it. See the + // module docs: it is mostly font loading, and it is per process. + warm_up(renderer.as_mut()); + + // The retained plots. They live here rather than beside the comm state so + // the post-stat `DataFrame`s stay off the async task entirely. + let mut stored: HashMap> = HashMap::new(); + + while let Ok(job) = inbox.recv() { + match job { + Job::Shutdown => break, + Job::Store { comm_id, spec } => { + stored.insert(comm_id, spec); + } + Job::Forget { comm_id } => { + stored.remove(&comm_id); + } + Job::RenderOnce { + spec, + request, + reply, + } => { + let result = render_one(&spec, &request, renderer.as_mut(), &one_shot_namespace()); + // A caller that gave up before we finished is not an error. + let _ = reply.send(result); + } + Job::RenderStored { + comm_id, + request, + reply, + } => { + let result = match stored.get(&comm_id) { + Some(spec) => { + render_one(spec, &request, renderer.as_mut(), &comm_namespace(&comm_id)) + } + None => Err(anyhow!("this plot is no longer available")), + }; + let _ = reply.send(result); + } + Job::Render { + comm_id, + request, + ticket, + } => { + let result = match stored.get(&comm_id) { + Some(spec) => { + render_one(spec, &request, renderer.as_mut(), &comm_namespace(&comm_id)) + } + // The comm closed, or the plot was evicted, between the + // request arriving and us reaching it. + None => Err(anyhow!("this plot is no longer available")), + }; + if let Some(outcomes) = &outcomes { + let _ = outcomes.send(RenderOutcome { ticket, result }); + } + } + } + } +} + +/// Render a throwaway frame so the first real plot does not pay for the +/// process's font enumeration and pipeline setup. +/// +/// Uses a throwaway in-memory database rather than the session's reader, which +/// would materialise ggsql's internal views in the user's session. Rendered +/// tiny, since the cost is not proportional to area, and best-effort: a failed +/// warm-up costs a slower first plot and nothing else. +fn warm_up(renderer: Option<&mut Renderer>) { + const QUERY: &str = "SELECT 1 AS x, 1 AS y VISUALISE x AS x, y AS y DRAW point"; + + let started = std::time::Instant::now(); + let spec = match ggsql::reader::connection::reader_from_uri("duckdb://memory") + .and_then(|reader| reader.execute(QUERY)) + { + Ok(spec) => spec, + Err(e) => { + tracing::debug!("renderer warm-up skipped: {e}"); + return; + } + }; + + let request = RenderRequest { + // SVG warms the text stack with or without a GPU, and that is most of + // the cost; a raster pass on top would save only ~15 ms more. + format: Format::Svg, + canvas: super::Canvas { + width: 64, + height: 64, + dpi: 96.0, + }, + }; + match render_one(&spec, &request, renderer, "warmup-") { + Ok(_) => tracing::debug!("renderer warmed up in {:?}", started.elapsed()), + Err(e) => tracing::debug!("renderer warm-up failed: {e}"), + } +} + +/// Build the GPU renderer, or report that there isn't one. +#[cfg(feature = "raster-plots")] +fn raster_renderer() -> Option { + match ggsql::writer::RasterRenderer::new() { + Ok(renderer) => Some(renderer), + Err(e) => { + tracing::info!("no GPU renderer: {e}"); + None + } + } +} + +/// Without the feature there is nothing to build, and the probe is a constant. +#[cfg(not(feature = "raster-plots"))] +fn raster_renderer() -> Option { + None +} + +/// Stands in for the renderer in a build that has none, so `render_one` keeps +/// one signature. Uninhabited, so the raster arms are unreachable rather than +/// merely unused. +#[cfg(not(feature = "raster-plots"))] +pub enum Never {} + +#[cfg(feature = "raster-plots")] +type Renderer = ggsql::writer::RasterRenderer; +#[cfg(not(feature = "raster-plots"))] +type Renderer = Never; + +/// The id namespace for a plot rendered once and not kept. +/// +/// SVG element ids are per-document counters (`c0`, `lg1`), so two plots in one +/// notebook both define `#lg1` and the second's `url(#lg1)` resolves to the +/// first's gradient — a silently wrong figure, in every browser. A namespace +/// per plot is what keeps them apart, since every cell's output shares one DOM. +/// +/// A uuid rather than a counter, because a counter restarts with the kernel +/// while the notebook still shows the outputs it already handed out. The cost +/// is that re-running a cell rewrites the ids of an otherwise identical plot. +fn one_shot_namespace() -> String { + format!("p{}-", uuid::Uuid::new_v4()) +} + +/// The id namespace for a plot the render thread keeps, from its comm id. +/// +/// One namespace for the plot's whole life, so resizing redraws it to the same +/// ids. The comm id is already a uuid, and already unique per plot. +fn comm_namespace(comm_id: &str) -> String { + format!("p{comm_id}-") +} + +/// Render one plot in whichever format was asked for. +/// +/// `id_namespace` prefixes the ids the SVG writer generates; the other formats +/// have no such thing and ignore it. +fn render_one( + spec: &Spec, + request: &RenderRequest, + renderer: Option<&mut Renderer>, + id_namespace: &str, +) -> Result> { + let canvas = request.canvas; + match request.format { + Format::Svg => { + let writer = ggsql::writer::SvgWriter::new(canvas.width, canvas.height, canvas.dpi) + .id_prefix(id_namespace); + let (svg, warnings) = writer.render_reporting(spec)?; + report(&warnings, "svg"); + Ok(svg.into_bytes()) + } + Format::Pdf => { + let writer = ggsql::writer::PdfWriter::new(canvas.width, canvas.height, canvas.dpi); + let (pdf, warnings) = writer.render_reporting(spec)?; + report(&warnings, "pdf"); + Ok(pdf) + } + #[cfg(feature = "raster-plots")] + Format::Png | Format::Jpeg | Format::Tiff => { + let renderer = renderer.ok_or_else(|| { + anyhow!("this plot needs a GPU adapter, and none was found at startup") + })?; + match request.format { + Format::Png => { + Ok( + ggsql::writer::PngWriter::new(canvas.width, canvas.height, canvas.dpi) + // The interactive path re-encodes on every resize, + // so trade bytes for latency there. + .compression(ggsql::writer::PngCompression::Fast) + .render_with(spec, renderer)?, + ) + } + Format::Jpeg => { + Ok( + ggsql::writer::JpegWriter::new(canvas.width, canvas.height, canvas.dpi) + .render_with(spec, renderer)?, + ) + } + Format::Tiff => { + Ok( + ggsql::writer::TiffWriter::new(canvas.width, canvas.height, canvas.dpi) + .render_with(spec, renderer)?, + ) + } + Format::Svg | Format::Pdf => unreachable!("handled above"), + } + } + #[cfg(not(feature = "raster-plots"))] + Format::Png | Format::Jpeg | Format::Tiff => { + let _ = renderer; + Err(anyhow!( + "this build has no raster plot formats; rebuild with --features raster-plots" + )) + } + } +} + +/// Put anything a format could not express in front of a human. +/// +/// Not behind a verbosity flag: a dropped gradient is a defect in the figure a +/// document is about to embed, and the list is empty for everything ggsql draws. +fn report(warnings: &[String], format: &str) { + for warning in warnings { + tracing::warn!("{format}: {warning}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A plot with a colour scale, so the SVG carries a gradient — the ids that + /// collide are the ones a legend gradient defines and references. + fn a_spec() -> Box { + use ggsql::reader::{DuckDBReader, Reader}; + let query = "SELECT * FROM (VALUES (1,2,10),(2,3,50),(3,1,90)) t(x,y,c) \ + VISUALISE x AS x, y AS y, c AS color DRAW point"; + Box::new( + DuckDBReader::from_connection_string("duckdb://memory") + .unwrap() + .execute(query) + .unwrap(), + ) + } + + fn an_svg_request() -> RenderRequest { + RenderRequest { + format: Format::Svg, + canvas: super::super::Canvas { + width: 400, + height: 300, + dpi: 96.0, + }, + } + } + + fn ids(svg: &[u8]) -> Vec { + let svg = std::str::from_utf8(svg).unwrap(); + svg.match_indices("id=\"") + .map(|(at, marker)| { + let rest = &svg[at + marker.len()..]; + rest[..rest.find('"').unwrap()].to_string() + }) + .collect() + } + + #[test] + fn a_static_plot_gets_its_own_id_namespace() { + let backend = PlotBackend::without_raster(); + let first = backend + .render_once(a_spec(), an_svg_request()) + .expect("the SVG path needs no adapter"); + let second = backend + .render_once(a_spec(), an_svg_request()) + .expect("the SVG path needs no adapter"); + + let (first, second) = (ids(&first), ids(&second)); + assert!(!first.is_empty(), "the plot defines no ids to namespace"); + // The whole point: the same plot twice in one notebook shares no id. + for id in &first { + assert!( + !second.contains(id), + "'{id}' would collide across two cells" + ); + } + } + + #[test] + fn a_stored_plot_keeps_one_namespace_across_renders() { + let backend = PlotBackend::without_raster(); + backend.store("comm-1".to_string(), a_spec()); + + let first = backend.render_stored("comm-1", an_svg_request()).unwrap(); + let second = backend.render_stored("comm-1", an_svg_request()).unwrap(); + + // A resize must not renumber the ids of a plot already on screen. + assert_eq!(ids(&first), ids(&second)); + assert!(ids(&first).iter().all(|id| id.starts_with("pcomm-1-"))); + } + + #[test] + fn a_namespace_is_a_valid_xml_name() { + // An id may not start with a digit, and a uuid can. + for namespace in [one_shot_namespace(), comm_namespace("8-4-4-4-12")] { + let first = namespace.chars().next().unwrap(); + assert!( + first.is_ascii_alphabetic() || first == '_', + "'{namespace}' is not a valid XML name" + ); + } + } +} diff --git a/ggsql-jupyter/src/plot/comm.rs b/ggsql-jupyter/src/plot/comm.rs new file mode 100644 index 000000000..907ad1070 --- /dev/null +++ b/ggsql-jupyter/src/plot/comm.rs @@ -0,0 +1,378 @@ +//! The `positron.plot` comm: parsing its requests and shaping its replies. +//! +//! Everything here is pure — it turns JSON into typed values and back — so the +//! protocol can be tested without a Positron host, a GPU, or a running kernel. +//! +//! The schema is Positron's, from `positron/comms/plot-backend-openrpc.json`: +//! +//! | Request | Params | Result | +//! | --- | --- | --- | +//! | `render` | `size: {width, height}` *optional*; `pixel_ratio` required; `format` required | `{ data, mime_type, settings? }` | +//! | `get_intrinsic_size` | none | a size, **or `null`** | +//! | `get_metadata` | none | `{ name, kind, execution_id, code }` | + +use serde_json::{json, Value}; + +use super::{Canvas, Format, RenderRequest}; + +/// A JSON-RPC error, as Positron's frontend expects to receive it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RpcError { + /// The method is not one this comm implements. + MethodNotFound(String), + /// The method is right but its params are not usable. + InvalidParams(String), + /// The request was understood and could not be served. + Internal(String), +} + +impl RpcError { + /// The JSON-RPC code, from the spec's reserved range. + fn code(&self) -> i32 { + match self { + Self::MethodNotFound(_) => -32601, + Self::InvalidParams(_) => -32602, + Self::Internal(_) => -32603, + } + } + + fn message(&self) -> &str { + match self { + Self::MethodNotFound(m) | Self::InvalidParams(m) | Self::Internal(m) => m, + } + } + + /// The `data.error` half of a reply. + pub fn to_json(&self) -> Value { + json!({ "code": self.code(), "message": self.message() }) + } +} + +/// A `render` request, resolved to something the renderer can act on. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RenderParams { + pub request: RenderRequest, + /// The ratio the frontend reported, echoed back in the reply's `settings` + /// so it can tell which settings a frame was produced for. + pub pixel_ratio: f64, +} + +impl RenderParams { + /// Read a `render` request's params. + /// + /// `size` is optional in the schema, so an absent one falls back to the + /// default canvas. `pixel_ratio` and `format` are required, and an unknown + /// format is `InvalidParams` rather than a guess. + /// + /// # Errors + /// + /// Returns [`RpcError::InvalidParams`] for a missing or unusable + /// `pixel_ratio` or `format`. + pub fn from_rpc(params: &Value) -> Result { + let pixel_ratio = params + .get("pixel_ratio") + .and_then(Value::as_f64) + .ok_or_else(|| RpcError::InvalidParams("render needs a pixel_ratio".into()))?; + + let format = match params.get("format").and_then(Value::as_str) { + Some("png") => Format::Png, + Some("jpeg") => Format::Jpeg, + Some("svg") => Format::Svg, + Some("pdf") => Format::Pdf, + Some("tiff") => Format::Tiff, + Some(other) => { + return Err(RpcError::InvalidParams(format!( + "render cannot produce the format '{other}'" + ))) + } + None => return Err(RpcError::InvalidParams("render needs a format".into())), + }; + + let size = params.get("size").filter(|s| !s.is_null()); + let canvas = match size { + Some(size) => { + let dimension = |key: &str| size.get(key).and_then(Value::as_f64).unwrap_or(0.0); + Canvas::from_logical(dimension("width"), dimension("height"), pixel_ratio) + } + // No size means "use your own", and ggsql plots have no intrinsic + // size — so the default canvas is the honest answer. + None => { + let default = Canvas::default(); + Canvas::from_logical( + f64::from(default.width), + f64::from(default.height), + pixel_ratio, + ) + } + }; + + Ok(Self { + request: RenderRequest { format, canvas }, + pixel_ratio, + }) + } + + /// The same request, in a format this build and machine can produce. + /// + /// The pane always asks for `png`, so a build with no raster writer answers + /// in SVG rather than leaving the pane empty, and says so in `mime_type`. + /// See [`Format::available`](crate::plot::Format::available). + pub fn available(mut self, backend_raster: bool) -> Self { + self.request.format = self.request.format.available(backend_raster); + self + } + + /// Encode rendered bytes the way a comm reply carries them. + /// + /// Always base64, even for SVG: Positron builds + /// `data:{mime_type};base64,{data}` from the reply + /// (`languageRuntimePlotClient.ts`), so text sent as itself is an invalid + /// URI. A static display bundle is the other way round, which is why this + /// is the comm's one entry point. + pub fn encode(bytes: &[u8]) -> String { + use base64::engine::general_purpose::STANDARD; + use base64::Engine; + STANDARD.encode(bytes) + } + + /// The `result` half of a successful `render` reply. + /// + /// `mime_type` names the format actually produced. Positron builds its data + /// URI from it but takes the save extension from its own request, so a + /// substituted format would write the wrong bytes into the wrong file. + pub fn to_result(self, encoded: String) -> Value { + json!({ + "data": encoded, + "mime_type": self.request.format.mime(), + "settings": { + "size": { + "width": self.request.canvas.css_size().0, + "height": self.request.canvas.css_size().1, + }, + "pixel_ratio": self.pixel_ratio, + "format": format_name(self.request.format), + } + }) + } +} + +/// The name Positron uses for a format on the wire. +pub fn format_name(format: Format) -> &'static str { + match format { + Format::Png => "png", + Format::Jpeg => "jpeg", + Format::Svg => "svg", + Format::Pdf => "pdf", + Format::Tiff => "tiff", + } +} + +/// What a plot tells Positron about itself. +#[derive(Debug, Clone)] +pub struct PlotMetadata { + /// Shown in the Plots pane's history. + pub name: String, + /// The `msg_id` of the execute_request that produced the plot. + pub execution_id: String, + /// The cell text, which the pane offers as "show the code". + pub code: String, +} + +impl PlotMetadata { + /// The `result` half of a `get_metadata` reply. + /// + /// Answered from state the kernel already holds, never through the render + /// thread: `get_metadata` gets a 5 s RPC timeout where `render` gets 30 s, + /// so it must not queue behind one. + pub fn to_result(&self) -> Value { + json!({ + "name": self.name, + "kind": "ggsql", + "execution_id": self.execution_id, + "code": self.code, + }) + } +} + +/// A plot's name for the pane's history list. +/// +/// The `LABEL title` if the query set one, else a running count, matching how +/// the other language backends name an untitled figure. +pub fn plot_name(title: Option<&str>, sequence: u32) -> String { + match title.map(str::trim).filter(|t| !t.is_empty()) { + Some(title) => title.to_string(), + None => format!("ggsql {sequence}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_render_request_becomes_a_canvas() { + let params = + json!({"size": {"width": 800, "height": 600}, "pixel_ratio": 2.0, "format": "png"}); + let parsed = RenderParams::from_rpc(¶ms).unwrap(); + assert_eq!(parsed.request.format, Format::Png); + assert_eq!(parsed.request.canvas.width, 1600); + assert_eq!(parsed.request.canvas.height, 1200); + assert_eq!(parsed.request.canvas.dpi, 192.0); + } + + #[test] + fn a_ratio_of_one_leaves_the_size_alone() { + let params = + json!({"size": {"width": 800, "height": 600}, "pixel_ratio": 1, "format": "png"}); + let parsed = RenderParams::from_rpc(¶ms).unwrap(); + assert_eq!(parsed.request.canvas.width, 800); + assert_eq!(parsed.request.canvas.dpi, 96.0); + } + + #[test] + fn an_absent_size_falls_back_rather_than_failing() { + // The schema makes `size` optional; ggsql has no intrinsic size, so + // the default canvas is the honest answer. + let parsed = RenderParams::from_rpc(&json!({"pixel_ratio": 1, "format": "png"})).unwrap(); + assert_eq!(parsed.request.canvas.width, Canvas::default().width); + // And an explicit null is the same as absent. + let with_null = + RenderParams::from_rpc(&json!({"size": null, "pixel_ratio": 1, "format": "png"})) + .unwrap(); + assert_eq!(with_null.request.canvas, parsed.request.canvas); + } + + #[test] + fn a_transient_zero_size_is_clamped_not_rendered() { + // Positron reports this while laying out a pane, and a zero-sized GPU + // target is a hard error. + let parsed = RenderParams::from_rpc( + &json!({"size": {"width": 0, "height": 0}, "pixel_ratio": 1, "format": "png"}), + ) + .unwrap(); + assert_eq!(parsed.request.canvas.width, 32); + assert_eq!(parsed.request.canvas.height, 32); + } + + #[test] + fn every_format_positron_defines_is_accepted() { + let cases = [ + ("png", Format::Png), + ("jpeg", Format::Jpeg), + ("svg", Format::Svg), + ("pdf", Format::Pdf), + ("tiff", Format::Tiff), + ]; + for (name, expected) in cases { + let params = json!({"pixel_ratio": 1, "format": name}); + assert_eq!( + RenderParams::from_rpc(¶ms).unwrap().request.format, + expected, + "{name}" + ); + // And the name round-trips, so a reply's `settings.format` matches + // what was asked for. + assert_eq!(format_name(expected), name); + } + } + + #[test] + fn a_format_we_cannot_produce_is_invalid_params() { + let err = RenderParams::from_rpc(&json!({"pixel_ratio": 1, "format": "webp"})).unwrap_err(); + assert!(matches!(err, RpcError::InvalidParams(_))); + assert_eq!(err.code(), -32602); + assert!(err.message().contains("webp"), "{}", err.message()); + } + + #[test] + fn the_required_params_are_required() { + for params in [json!({"format": "png"}), json!({"pixel_ratio": 1})] { + let err = RenderParams::from_rpc(¶ms).unwrap_err(); + assert!(matches!(err, RpcError::InvalidParams(_)), "{params}"); + } + } + + #[test] + fn a_reply_names_the_format_it_actually_produced() { + // Positron builds its data URI from `mime_type` but records the format + // from its own request, so a substituted format would save the wrong + // bytes into the wrong extension. + let parsed = RenderParams::from_rpc( + &json!({"size": {"width": 400, "height": 300}, "pixel_ratio": 2, "format": "png"}), + ) + .unwrap(); + let result = parsed.to_result("AAAA".into()); + assert_eq!(result["mime_type"], "image/png"); + assert_eq!(result["data"], "AAAA"); + // The echoed settings are in CSS pixels, not device pixels. + assert_eq!(result["settings"]["size"]["width"], 400); + assert_eq!(result["settings"]["size"]["height"], 300); + assert_eq!(result["settings"]["pixel_ratio"], 2.0); + assert_eq!(result["settings"]["format"], "png"); + } + + #[test] + fn the_panes_render_settings_parse_as_a_render_request() { + // `did_change_plots_render_settings` carries a `plot_render_settings`, + // which has a `render` request's shape — parsed by the same function so + // a pre-render matches what a render would produce. + let settings = json!({ + "size": {"width": 500, "height": 400}, + "pixel_ratio": 2, + "format": "png" + }); + let parsed = RenderParams::from_rpc(&settings).unwrap(); + assert_eq!(parsed.request.canvas.width, 1000); + assert_eq!(parsed.request.canvas.height, 800); + assert_eq!(parsed.request.format, Format::Png); + // And the echoed settings match what the pane asked for, which is what + // Positron gates a pre-render on. + let result = parsed.to_result("AAAA".into()); + assert_eq!(result["settings"]["size"]["width"], 500); + assert_eq!(result["settings"]["size"]["height"], 400); + assert!(result["settings"].get("pixel_ratio").is_some()); + } + + #[test] + fn svg_is_base64_encoded_like_every_other_format() { + // Positron builds `data:;base64,` from a comm reply, so + // text sent as itself would be an invalid URI. + let encoded = RenderParams::encode(b""); + assert_eq!(encoded, "PHN2Zy8+"); + assert!( + !encoded.starts_with('<'), + "svg must not travel as text here" + ); + } + + #[test] + fn an_unknown_method_is_method_not_found() { + // An error rather than `result: null`, so a future method fails loudly + // instead of being satisfied with garbage. + let err = RpcError::MethodNotFound("hover".into()); + assert_eq!(err.code(), -32601); + assert_eq!(err.to_json()["code"], -32601); + } + + #[test] + fn metadata_names_a_titled_plot_by_its_title() { + assert_eq!(plot_name(Some("Sales by region"), 3), "Sales by region"); + assert_eq!(plot_name(None, 3), "ggsql 3"); + // A blank title is not a name. + assert_eq!(plot_name(Some(" "), 7), "ggsql 7"); + } + + #[test] + fn metadata_declares_its_kind() { + let metadata = PlotMetadata { + name: "ggsql 1".into(), + execution_id: "abc-123".into(), + code: "SELECT 1 VISUALISE …".into(), + }; + let result = metadata.to_result(); + assert_eq!(result["kind"], "ggsql"); + assert_eq!(result["name"], "ggsql 1"); + assert_eq!(result["execution_id"], "abc-123"); + assert!(result["code"].as_str().unwrap().starts_with("SELECT")); + } +} diff --git a/ggsql-jupyter/src/plot/mod.rs b/ggsql-jupyter/src/plot/mod.rs new file mode 100644 index 000000000..3b5774796 --- /dev/null +++ b/ggsql-jupyter/src/plot/mod.rs @@ -0,0 +1,228 @@ +//! Deciding what a plot should become, and producing it. +//! +//! Three things have to agree: where the output is going +//! ([`SessionKind`](crate::display::SessionKind)), what this build and machine +//! can produce ([`PlotBackend::raster`]), and what the frontend asked for. +//! [`choose`] is the single place that reconciles them. + +pub mod backend; +pub mod comm; +pub mod quarto; +pub mod sizing; + +pub use backend::{PlotBackend, RenderOutcome}; +pub use sizing::Canvas; + +use crate::display::SessionKind; + +/// An output format a plot can be rendered to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Png, + Jpeg, + Svg, + Pdf, + /// Only the plot comm asks for this; nothing else routes to it. + Tiff, +} + +impl Format { + /// The MIME type this format travels under. + /// + /// Copied from Positron's own backend (`plots.py`) so both ends agree. + pub fn mime(self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::Svg => "image/svg+xml", + Self::Pdf => "application/pdf", + Self::Tiff => "image/tiff", + } + } + + /// Whether the output is text rather than bytes, and so travels in a + /// display bundle unencoded. + pub fn is_text(self) -> bool { + matches!(self, Self::Svg) + } + + /// Whether producing this needs a GPU adapter. + pub fn needs_raster(self) -> bool { + matches!(self, Self::Png | Self::Jpeg | Self::Tiff) + } + + /// The nearest format this build and this machine can actually produce. + /// + /// The Plots pane hard-codes `png`, so without a raster writer the request + /// degrades to SVG rather than leaving the pane empty. Nothing is hidden: + /// the reply's `mime_type` names what was actually produced. + pub fn available(self, backend_raster: bool) -> Self { + if self.needs_raster() && !backend_raster { + Self::Svg + } else { + self + } + } +} + +/// What a comm render needs to send its answer back where it came from. +/// +/// The render thread treats this as opaque and echoes it with the result. It +/// lives here rather than in `backend` so the thread carries no knowledge of +/// the messaging layer beyond moving this along. +#[derive(Debug, Clone)] +pub struct RenderTicket { + pub comm_id: String, + /// The JSON-RPC `id` to answer. + pub rpc_id: serde_json::Value, + /// The request being answered, which decides the reply's `mime_type` and + /// echoed `settings`. + pub params: comm::RenderParams, + /// The `comm_msg` this is a reply to, and the socket identities to route + /// it back along. + pub parent: crate::message::JupyterMessage, + pub identities: Vec>, +} + +/// A plot to render, at a size, in a format. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RenderRequest { + pub format: Format, + pub canvas: Canvas, +} + +/// Where a rendered plot should be delivered. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Delivery { + /// A static bundle in the cell's output, which is what a notebook, a + /// Quarto render and plain Jupyter all want. + Static(RenderRequest), + /// A `positron.plot` comm, which the kernel opens and then serves render + /// requests on. No `execute_result` accompanies it — the comm alone creates + /// the pane entry. + Comm, +} + +/// Decide what to do with a plot. +/// +/// The rules, in the order they apply: +/// +/// 1. A Positron console session opens a plot comm, whatever this build can +/// render. Positron inlines any output carrying an `image/*` mime, so a +/// static bundle would land in the console *and* leave a fixed-size pane +/// entry; the comm is the only delivery that reaches the pane alone. +/// 2. Quarto is obeyed: `QUARTO_FIG_FORMAT` and friends say exactly what figure +/// the document wants. Standalone sessions only — Quarto never drives a +/// Positron console. +/// 3. Everything else gets a PNG at the frontend's size, falling back to SVG +/// where there is no adapter or no raster writer. +pub fn choose(kind: SessionKind, backend_raster: bool, canvas: Canvas) -> Delivery { + if kind == SessionKind::PositronConsole { + return Delivery::Comm; + } + + if kind == SessionKind::Standalone { + if let Some(figure) = quarto::from_env() { + // A document asking for a raster figure on a machine with no + // adapter still gets a figure, just a vector one. + return Delivery::Static(RenderRequest { + format: figure.format.available(backend_raster), + canvas: figure.canvas, + }); + } + } + + Delivery::Static(RenderRequest { + format: Format::Png.available(backend_raster), + canvas, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CANVAS: Canvas = Canvas { + width: 800, + height: 600, + dpi: 96.0, + }; + + #[test] + fn a_console_session_always_opens_a_plot_comm() { + // Whatever this build can render: a static bundle would be inlined on + // its mime alone and arrive twice. The comm answers in SVG where raster + // is unavailable. + for backend_raster in [true, false] { + assert_eq!( + choose(SessionKind::PositronConsole, backend_raster, CANVAS), + Delivery::Comm, + "backend_raster={backend_raster}" + ); + } + } + + #[test] + fn a_format_degrades_only_when_it_needs_a_writer_this_build_lacks() { + for format in [Format::Png, Format::Jpeg, Format::Tiff] { + assert_eq!(format.available(false), Format::Svg, "{format:?}"); + assert_eq!(format.available(true), format, "{format:?}"); + } + // The vector formats need no adapter, so they are never substituted. + for format in [Format::Svg, Format::Pdf] { + assert_eq!(format.available(false), format, "{format:?}"); + } + } + + #[test] + fn a_notebook_gets_a_static_image() { + // A plot comm would put the picture in the Plots pane and leave the + // cell empty, so a notebook needs a bundle whatever else is true. + let Delivery::Static(request) = choose(SessionKind::PositronNotebook, true, CANVAS) else { + panic!("a notebook should get a static bundle"); + }; + assert_eq!(request.format, Format::Png); + assert_eq!(request.canvas, CANVAS); + } + + #[test] + fn svg_is_the_fallback_wherever_raster_is_unavailable() { + for kind in [SessionKind::PositronNotebook, SessionKind::Standalone] { + let Delivery::Static(request) = choose(kind, false, CANVAS) else { + panic!("{kind:?} should get a static bundle"); + }; + assert_eq!(request.format, Format::Svg, "{kind:?}"); + // The fallback keeps the size it was asked for; only the format + // changes. + assert_eq!(request.canvas, CANVAS, "{kind:?}"); + } + } + + #[test] + fn every_format_has_positrons_mime_type() { + // Copied from `plots.py`; both ends have to agree or a frontend + // decodes the wrong thing. + assert_eq!(Format::Png.mime(), "image/png"); + assert_eq!(Format::Jpeg.mime(), "image/jpeg"); + assert_eq!(Format::Svg.mime(), "image/svg+xml"); + assert_eq!(Format::Pdf.mime(), "application/pdf"); + assert_eq!(Format::Tiff.mime(), "image/tiff"); + } + + #[test] + fn only_svg_travels_as_text() { + assert!(Format::Svg.is_text()); + for format in [Format::Png, Format::Jpeg, Format::Pdf, Format::Tiff] { + assert!(!format.is_text(), "{format:?}"); + } + } + + #[test] + fn the_vector_formats_need_no_adapter() { + assert!(!Format::Svg.needs_raster()); + assert!(!Format::Pdf.needs_raster()); + for format in [Format::Png, Format::Jpeg, Format::Tiff] { + assert!(format.needs_raster(), "{format:?}"); + } + } +} diff --git a/ggsql-jupyter/src/plot/quarto.rs b/ggsql-jupyter/src/plot/quarto.rs new file mode 100644 index 000000000..d505807b2 --- /dev/null +++ b/ggsql-jupyter/src/plot/quarto.rs @@ -0,0 +1,150 @@ +//! Reading Quarto's figure settings out of the environment. +//! +//! Quarto's jupyter engine sets four environment variables on the kernel +//! process (`quarto/share/jupyter/notebook.py`) saying exactly what figure it +//! wants. They map one-to-one onto what the writers take, so a PDF document +//! gets a vector figure with embedded fonts, an HTML one a correctly-sized +//! raster at the requested dpi, and `fig-width: 6` means six inches. + +use super::sizing::Canvas; +use super::Format; + +/// What Quarto asked for, when it asked. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct QuartoFigure { + pub format: Format, + pub canvas: Canvas, +} + +/// Read Quarto's figure settings from the process environment. +/// +/// `None` when `QUARTO_FIG_FORMAT` is unset or names something we cannot +/// produce — which includes the plain-Jupyter case, where nothing is set at +/// all. The caller falls back to its own default. +pub fn from_env() -> Option { + from_vars(|key| std::env::var(key).ok()) +} + +/// [`from_env`] against an arbitrary source, so the mapping is testable without +/// touching the global process environment. +pub fn from_vars(get: impl Fn(&str) -> Option) -> Option { + let format = match get("QUARTO_FIG_FORMAT")?.trim().to_lowercase().as_str() { + "png" => Format::Png, + "jpeg" | "jpg" => Format::Jpeg, + "svg" => Format::Svg, + "pdf" => Format::Pdf, + // Quarto normalises `retina` to `png` at doubled dpi before it reaches + // us; seeing it here means a version that does not, and png still fits. + "retina" => Format::Png, + _ => return None, + }; + + let number = |key: &str| get(key).and_then(|v| v.trim().parse::().ok()); + // Quarto's width and height are inches; its dpi is what to render them at. + let width = number("QUARTO_FIG_WIDTH").filter(|v| *v > 0.0); + let height = number("QUARTO_FIG_HEIGHT").filter(|v| *v > 0.0); + let dpi = number("QUARTO_FIG_DPI").filter(|v| *v > 0.0); + + // The only one of the three with a real default, resolved once here so + // `fig-dpi: 300` works with or without a size alongside it. + let dpi = dpi.unwrap_or(super::sizing::CSS_DPI); + let canvas = match (width, height) { + (Some(w), Some(h)) => Canvas::from_inches(w, h, dpi), + // A document that sets one dimension and not the other is unusual, but + // the golden ratio is a better guess than refusing the figure. + (Some(w), None) => Canvas::from_inches(w, w / 1.618, dpi), + (None, Some(h)) => Canvas::from_inches(h * 1.618, h, dpi), + (None, None) => Canvas::default_at(dpi), + }; + + Some(QuartoFigure { format, canvas }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn vars(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |key: &str| map.get(key).cloned() + } + + #[test] + fn nothing_set_is_not_a_quarto_render() { + // The plain-Jupyter case: no variables at all. + assert!(from_vars(vars(&[])).is_none()); + } + + #[test] + fn each_format_maps_to_its_writer() { + let cases = [ + ("png", Format::Png), + ("jpeg", Format::Jpeg), + ("jpg", Format::Jpeg), + ("svg", Format::Svg), + ("pdf", Format::Pdf), + // Quarto doubles the dpi itself, so this must not be doubled again. + ("retina", Format::Png), + ]; + for (value, expected) in cases { + let figure = from_vars(vars(&[("QUARTO_FIG_FORMAT", value)])).unwrap(); + assert_eq!(figure.format, expected, "{value}"); + } + } + + #[test] + fn a_format_we_cannot_produce_is_declined_rather_than_guessed() { + assert!(from_vars(vars(&[("QUARTO_FIG_FORMAT", "gif")])).is_none()); + assert!(from_vars(vars(&[("QUARTO_FIG_FORMAT", "")])).is_none()); + } + + #[test] + fn inches_and_dpi_become_a_canvas() { + let figure = from_vars(vars(&[ + ("QUARTO_FIG_FORMAT", "png"), + ("QUARTO_FIG_WIDTH", "6"), + ("QUARTO_FIG_HEIGHT", "4"), + ("QUARTO_FIG_DPI", "150"), + ])) + .unwrap(); + // `fig-width: 6` means six inches, so 900 px at 150 dpi. + assert_eq!(figure.canvas.width, 900); + assert_eq!(figure.canvas.height, 600); + assert_eq!(figure.canvas.dpi, 150.0); + } + + #[test] + fn a_format_without_a_size_still_renders() { + let figure = from_vars(vars(&[("QUARTO_FIG_FORMAT", "pdf")])).unwrap(); + assert_eq!(figure.format, Format::Pdf); + assert_eq!(figure.canvas, Canvas::default()); + } + + #[test] + fn a_dpi_without_a_size_still_sets_the_resolution() { + // `fig-dpi: 300` alone: the default figure at print resolution rather + // than a silent 96 dpi. + let figure = from_vars(vars(&[ + ("QUARTO_FIG_FORMAT", "png"), + ("QUARTO_FIG_DPI", "300"), + ])) + .unwrap(); + assert_eq!(figure.canvas.dpi, 300.0); + assert_eq!(figure.canvas.css_size(), Canvas::default().css_size()); + } + + #[test] + fn unusable_numbers_fall_back_rather_than_failing_the_render() { + let figure = from_vars(vars(&[ + ("QUARTO_FIG_FORMAT", "png"), + ("QUARTO_FIG_WIDTH", "not-a-number"), + ("QUARTO_FIG_DPI", "0"), + ])) + .unwrap(); + assert_eq!(figure.canvas, Canvas::default()); + } +} diff --git a/ggsql-jupyter/src/plot/sizing.rs b/ggsql-jupyter/src/plot/sizing.rs new file mode 100644 index 000000000..0b55d5dc9 --- /dev/null +++ b/ggsql-jupyter/src/plot/sizing.rs @@ -0,0 +1,200 @@ +//! Turning a frontend's logical size into a canvas. +//! +//! Everything here is pure arithmetic, so it is all directly testable — which +//! matters, because getting it wrong is invisible until a plot arrives blurry +//! or with chrome the wrong size relative to the panel. + +/// Dots per inch a device pixel ratio of 1.0 corresponds to. +/// +/// One CSS pixel is 1/96 in by definition, so rendering at 96 dpi makes the +/// renderer's point→pixel conversion agree with the browser's. +pub const CSS_DPI: f64 = 96.0; + +/// Smallest canvas dimension we will render, in device pixels. +/// +/// Positron can transiently report a zero-width pane while it lays out, and a +/// zero-sized GPU target is a hard error rather than an empty picture. +const MIN_PX: u32 = 32; + +/// Largest canvas dimension we will render, in device pixels. +/// +/// The same number as `ggsql::writer::MAX_RASTER_DIMENSION`, spelled out rather +/// than imported because a default kernel build has no raster writer. No pane is +/// this large, so it really guards against a nonsense `size` or `pixel_ratio` +/// over the comm. Vector formats are capped too, which costs nothing. +const MAX_PX: u32 = 16_384; + +/// Device pixel ratios we will honour. Beyond this a frontend is either +/// confused or asking for a texture we should not allocate. +const MIN_RATIO: f64 = 0.5; +const MAX_RATIO: f64 = 4.0; + +/// A canvas to render at: device pixels plus the resolution to render them at. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Canvas { + pub width: u32, + pub height: u32, + pub dpi: f64, +} + +impl Canvas { + /// The canvas for a logical size at a device pixel ratio. + /// + /// All three scale together: the renderer converts the theme's point sizes + /// at render dpi, so scaling the pixels alone gives a blurry plot at the + /// right size and scaling dpi alone grows the chrome. Matches matplotlib's + /// Positron backend, which holds physical size invariant the same way. + pub fn from_logical(width: f64, height: f64, pixel_ratio: f64) -> Self { + let ratio = if pixel_ratio.is_finite() { + pixel_ratio.clamp(MIN_RATIO, MAX_RATIO) + } else { + 1.0 + }; + Self { + width: clamp_px(width * ratio), + height: clamp_px(height * ratio), + dpi: CSS_DPI * ratio, + } + } + + /// The canvas for a physical size in inches at a given resolution — what + /// Quarto asks for, and what a print workflow means. + pub fn from_inches(width: f64, height: f64, dpi: f64) -> Self { + let dpi = if dpi.is_finite() && dpi > 0.0 { + dpi + } else { + CSS_DPI + }; + Self { + width: clamp_px(width * dpi), + height: clamp_px(height * dpi), + dpi, + } + } + + /// The default figure at a given resolution. + /// + /// The default is a physical size, not a pixel count, so that a caller who + /// knows the resolution but not the size — Quarto with `fig-dpi` and no + /// `fig-width` — still gets the resolution it asked for. + pub fn default_at(dpi: f64) -> Self { + Self::from_inches(DEFAULT_WIDTH_IN, DEFAULT_HEIGHT_IN, dpi) + } + + /// The size a frontend should display this at, in CSS pixels. + /// + /// A 2× render has to be shown at half its pixel dimensions or it appears + /// at twice its intended size. This is what `metadata[mime].width/height` + /// carries, and what JupyterLab and nbconvert honour. + pub fn css_size(&self) -> (u32, u32) { + let scale = CSS_DPI / self.dpi; + ( + ((self.width as f64) * scale).round().max(1.0) as u32, + ((self.height as f64) * scale).round().max(1.0) as u32, + ) + } +} + +/// The default figure's physical size: 1000×618 CSS pixels, close to +/// ggplot2's default and at the golden ratio. +const DEFAULT_WIDTH_IN: f64 = 1000.0 / CSS_DPI; +const DEFAULT_HEIGHT_IN: f64 = 618.0 / CSS_DPI; + +impl Default for Canvas { + /// A reasonable figure for a frontend that told us nothing at all. + fn default() -> Self { + Self::default_at(CSS_DPI) + } +} + +/// Round to whole device pixels and hold the result inside what we will render. +fn clamp_px(value: f64) -> u32 { + if !value.is_finite() { + return MIN_PX; + } + (value.round().max(0.0) as u32).clamp(MIN_PX, MAX_PX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_ratio_of_one_is_the_logical_size_at_css_dpi() { + let canvas = Canvas::from_logical(800.0, 600.0, 1.0); + assert_eq!((canvas.width, canvas.height), (800, 600)); + assert_eq!(canvas.dpi, 96.0); + assert_eq!(canvas.css_size(), (800, 600)); + } + + #[test] + fn a_retina_ratio_scales_pixels_and_dpi_together() { + let canvas = Canvas::from_logical(800.0, 600.0, 2.0); + assert_eq!((canvas.width, canvas.height), (1600, 1200)); + assert_eq!(canvas.dpi, 192.0); + // Twice the pixels, shown at the original size — so it is sharp rather + // than twice as big. + assert_eq!(canvas.css_size(), (800, 600)); + } + + #[test] + fn a_zero_size_is_clamped_rather_than_rendered() { + // Positron reports this while laying out a pane, and a zero-sized GPU + // target is an error rather than an empty picture. + let canvas = Canvas::from_logical(0.0, 0.0, 1.0); + assert_eq!((canvas.width, canvas.height), (MIN_PX, MIN_PX)); + } + + #[test] + fn an_absurd_size_is_clamped_rather_than_allocated() { + let canvas = Canvas::from_logical(99_999.0, 99_999.0, 1.0); + assert_eq!((canvas.width, canvas.height), (MAX_PX, MAX_PX)); + } + + #[test] + fn a_large_pane_at_two_x_renders_at_its_full_size() { + // 2400 logical px at 2x is 4800 device px, well inside the cap — so + // the plot is sharp rather than upscaled from a clamped render. + let canvas = Canvas::from_logical(2400.0, 1400.0, 2.0); + assert_eq!((canvas.width, canvas.height), (4800, 2800)); + assert_eq!(canvas.css_size(), (2400, 1400)); + } + + #[test] + fn a_nonsense_ratio_falls_back_rather_than_propagating() { + for ratio in [f64::NAN, f64::INFINITY] { + let canvas = Canvas::from_logical(800.0, 600.0, ratio); + assert_eq!((canvas.width, canvas.height), (800, 600), "{ratio}"); + assert_eq!(canvas.dpi, 96.0); + } + // A ratio outside what any display reports is clamped, not honoured. + assert_eq!(Canvas::from_logical(100.0, 100.0, 99.0).dpi, CSS_DPI * 4.0); + assert_eq!(Canvas::from_logical(100.0, 100.0, 0.01).dpi, CSS_DPI * 0.5); + } + + #[test] + fn inches_go_through_the_requested_resolution() { + let canvas = Canvas::from_inches(6.0, 4.0, 150.0); + assert_eq!((canvas.width, canvas.height), (900, 600)); + assert_eq!(canvas.dpi, 150.0); + // Six inches at 150 dpi is 900 px, shown at 576 CSS px (6 in × 96). + assert_eq!(canvas.css_size(), (576, 384)); + } + + #[test] + fn the_default_figure_holds_its_size_across_resolutions() { + assert_eq!(Canvas::default(), Canvas::default_at(CSS_DPI)); + let dense = Canvas::default_at(300.0); + assert_eq!(dense.dpi, 300.0); + // Same figure, more dots: the displayed size is unchanged. + assert_eq!(dense.css_size(), Canvas::default().css_size()); + } + + #[test] + fn a_bad_dpi_falls_back_to_css_dpi() { + for dpi in [0.0, -100.0, f64::NAN] { + let canvas = Canvas::from_inches(6.0, 4.0, dpi); + assert_eq!(canvas.dpi, CSS_DPI, "{dpi}"); + } + } +} diff --git a/ggsql-jupyter/tests/test_compliance.py b/ggsql-jupyter/tests/test_compliance.py index 2b618ec71..e5e48b1bc 100644 --- a/ggsql-jupyter/tests/test_compliance.py +++ b/ggsql-jupyter/tests/test_compliance.py @@ -86,7 +86,7 @@ def test_execute_request(self): # Test visualization output def test_execute_visualization(self): - """Test that visualization output includes Vega-Lite MIME type.""" + """A plot arrives as a rendered image bundle, in this build's format.""" self.flush_channels() code = """ @@ -109,14 +109,21 @@ def test_execute_visualization(self): self.assertIsNotNone(execute_result, "No execute_result message found") - # Check MIME types + # A plot arrives as a rendered image, in whichever format this build can + # produce: PNG with the raster writers and a GPU adapter, SVG otherwise. + # Both are static bundles that need no network. data = execute_result["content"]["data"] - self.assertIn("application/vnd.vegalite.v6+json", data) + self.assertTrue( + "image/png" in data or "image/svg+xml" in data, + f"expected a rendered plot, got {sorted(data)}", + ) + + # And a plain-text summary, for a frontend that renders neither. + self.assertIn("text/plain", data) - # Verify Vega-Lite spec structure - vega_spec = data["application/vnd.vegalite.v6+json"] - self.assertIn("$schema", vega_spec) - self.assertIn("data", vega_spec) + # The bundle must not claim a plot slot: `output_location` would route + # it to Positron's Plots pane as well as the cell, showing it twice. + self.assertNotIn("output_location", execute_result["content"]) # Test error handling def test_execute_error(self): diff --git a/ggsql-jupyter/tests/test_integration.py b/ggsql-jupyter/tests/test_integration.py index 216e15cd6..4bee05069 100644 --- a/ggsql-jupyter/tests/test_integration.py +++ b/ggsql-jupyter/tests/test_integration.py @@ -37,10 +37,10 @@ def kernel_binary(): return str(binary_path) -@pytest.fixture -def kernel_manager(kernel_binary): - """Create and start a kernel manager.""" +def _launch(kernel_binary, extra_args=()): + """Start a kernel and yield its manager. Shared by the fixtures below.""" kernel_process = None + km = None try: # Use KernelManager to write connection file with proper ports km = KernelManager() @@ -49,7 +49,7 @@ def kernel_manager(kernel_binary): # Start our kernel process directly kernel_process = subprocess.Popen( - [kernel_binary, "-f", connection_file], + [kernel_binary, "-f", connection_file, *extra_args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -80,10 +80,30 @@ def kernel_manager(kernel_binary): kernel_process.kill() except: pass - try: - os.unlink(km.connection_file) - except: - pass + if km is not None: + try: + os.unlink(km.connection_file) + except: + pass + + +@pytest.fixture +def kernel_manager(kernel_binary): + """Create and start a kernel manager.""" + yield from _launch(kernel_binary) + + +@pytest.fixture +def console_kernel_manager(kernel_binary): + """A kernel that believes it is a Positron console session. + + `--session-mode` is what the extension's `createKernelSpec` appends, and it + is authoritative over the session-id heuristic — so this is the only way to + reach the plot-comm path from a test. Everything driven through plain + `jupyter_client` lands in `SessionKind::Standalone`, because its session id + is a bare UUID. + """ + yield from _launch(kernel_binary, ["--session-mode", "console"]) @pytest.fixture @@ -96,6 +116,32 @@ def client(kernel_manager): kc.stop_channels() +@pytest.fixture +def console_client(console_kernel_manager): + """A client talking to a console-mode kernel.""" + kc = console_kernel_manager.client() + kc.start_channels() + kc.wait_for_ready(timeout=10) + yield kc + kc.stop_channels() + + +def drain_iopub(client, timeout=5): + """Collect iopub messages up to and including the closing `idle` status.""" + messages = [] + while True: + try: + msg = client.get_iopub_msg(timeout=timeout) + except Exception: + return messages + messages.append(msg) + if ( + msg["msg_type"] == "status" + and msg["content"]["execution_state"] == "idle" + ): + return messages + + class TestKernelInfo: """Test kernel_info_request/reply messages.""" @@ -183,15 +229,32 @@ def test_visualization_execution(self, client): content = execute_result["content"] assert "data" in content - # Should have Vega-Lite MIME type + # A plot arrives as a rendered image: PNG where this build has the + # raster writers and the machine has a GPU adapter, SVG otherwise. data = content["data"] - assert "application/vnd.vegalite.v6+json" in data - - # Check Vega-Lite spec structure - vega_spec = data["application/vnd.vegalite.v6+json"] - assert "$schema" in vega_spec - assert "data" in vega_spec - assert "mark" in vega_spec or "layer" in vega_spec + assert ( + "image/png" in data or "image/svg+xml" in data + ), f"expected a rendered plot, got {sorted(data)}" + assert "text/plain" in data + + if "image/svg+xml" in data: + assert data["image/svg+xml"].startswith(" 0 + assert content["metadata"][mime]["height"] > 0 + + # Nothing in the bundle reaches for a CDN — this is what lets a plot + # render offline, in CI, and behind a firewall. + assert "jsdelivr" not in str(data) + assert "vega-embed" not in str(data) def test_error_handling(self, client): """Test that syntax errors are reported correctly.""" @@ -275,6 +338,168 @@ def test_persistent_state(self, client): assert reply["content"]["status"] == "ok" +PLOT_QUERY = "SELECT 1 as x, 2 as y VISUALISE x, y DRAW point" + + +class TestPlotComm: + """The console session's plot path: a `positron.plot` comm, and its RPCs. + + This is the only route to `open_plot_comm`, `pre_render`, `finish_render` + and eviction — everything else in this file is a `Standalone` session, which + takes the static-bundle path instead. + """ + + def _open_plot(self, console_client): + """Run a plot cell and return its comm_id.""" + msg_id = console_client.execute(PLOT_QUERY, silent=False, store_history=True) + messages = drain_iopub(console_client) + + opens = [m for m in messages if m["msg_type"] == "comm_open"] + plots = [ + m for m in opens if m["content"]["target_name"] == "positron.plot" + ] + assert plots, ( + "a console session must open a positron.plot comm; got " + f"{[m['msg_type'] for m in messages]}" + ) + assert len(plots) == 1, "one comm per plot" + + # The comm alone creates the pane entry. An output message as well + # would put a second, fixed-size copy of the plot in the pane. + assert not [m for m in messages if m["msg_type"] == "execute_result"] + + # Parented to the execute_request: that is how the frontend ties the + # plot to the cell that produced it, and where its `code` comes from. + assert plots[0]["parent_header"]["msg_id"] == msg_id + + # `execute_input` must come first — Positron fills `_recentExecutions` + # from it, so a comm arriving earlier has no execution to attach to. + order = [m["msg_type"] for m in messages] + assert order.index("execute_input") < order.index("comm_open") + + reply = console_client.get_shell_msg(timeout=5) + assert reply["msg_type"] == "execute_reply" + assert reply["content"]["status"] == "ok" + + return plots[0]["content"]["comm_id"] + + def test_console_opens_a_plot_comm_and_no_output(self, console_client): + self._open_plot(console_client) + + def test_render_is_answered_and_the_kernel_returns_to_idle(self, console_client): + comm_id = self._open_plot(console_client) + + console_client.session.send( + console_client.shell_channel.socket, + "comm_msg", + { + "comm_id": comm_id, + "data": { + "jsonrpc": "2.0", + "id": "render-1", + "method": "render", + "params": { + "size": {"width": 400, "height": 300}, + "pixel_ratio": 1.0, + "format": "png", + }, + }, + }, + ) + + # The reply comes back on shell, from `finish_render` rather than from + # the handler — the render is dispatched to a thread. + reply = console_client.get_shell_msg(timeout=30) + assert reply["msg_type"] == "comm_msg" + data = reply["content"]["data"] + assert "error" not in data, data + result = data["result"] + assert result["data"], "a render must carry bytes" + # Whatever this build and machine can produce; SVG is the fallback. + assert result["mime_type"] in ("image/png", "image/svg+xml") + + # And the `busy` that the request opened is closed. Missing this is how + # the kernel gets stuck busy forever. + states = [ + m["content"]["execution_state"] + for m in drain_iopub(console_client) + if m["msg_type"] == "status" + ] + assert states[-1] == "idle", states + + def test_a_bad_render_request_still_returns_to_idle(self, console_client): + """A rejected `render` is answered here, not by `finish_render`. + + No ticket is queued for it, so nothing will ever arrive on the outcome + channel — the `idle` has to be sent by the handler, or the kernel stays + busy with the frontend waiting out its 30 s timeout. + """ + comm_id = self._open_plot(console_client) + + console_client.session.send( + console_client.shell_channel.socket, + "comm_msg", + { + "comm_id": comm_id, + "data": { + "jsonrpc": "2.0", + "id": "render-bad", + "method": "render", + # No pixel_ratio and no format: InvalidParams. + "params": {"size": {"width": 400, "height": 300}}, + }, + }, + ) + + reply = console_client.get_shell_msg(timeout=10) + assert reply["msg_type"] == "comm_msg" + assert "error" in reply["content"]["data"], reply["content"]["data"] + + states = [ + m["content"]["execution_state"] + for m in drain_iopub(console_client) + if m["msg_type"] == "status" + ] + assert states[-1] == "idle", states + + def test_get_metadata_carries_the_cell_that_made_the_plot(self, console_client): + comm_id = self._open_plot(console_client) + + console_client.session.send( + console_client.shell_channel.socket, + "comm_msg", + { + "comm_id": comm_id, + "data": {"jsonrpc": "2.0", "id": "meta-1", "method": "get_metadata"}, + }, + ) + + reply = console_client.get_shell_msg(timeout=5) + result = reply["content"]["data"]["result"] + assert result["code"] == PLOT_QUERY + assert result["kind"] == "ggsql" + assert result["name"] + assert result["execution_id"] + + def test_an_unknown_method_is_an_error_not_a_null_result(self, console_client): + """`show` and `update` land here on purpose — see `handle_plot_rpc`.""" + comm_id = self._open_plot(console_client) + + console_client.session.send( + console_client.shell_channel.socket, + "comm_msg", + { + "comm_id": comm_id, + "data": {"jsonrpc": "2.0", "id": "show-1", "method": "show"}, + }, + ) + + reply = console_client.get_shell_msg(timeout=5) + data = reply["content"]["data"] + assert "error" in data, data + assert "result" not in data + + class TestStatus: """Test status messages.""" diff --git a/ggsql-vscode/CLAUDE.md b/ggsql-vscode/CLAUDE.md index 4917b426a..f478754c4 100644 --- a/ggsql-vscode/CLAUDE.md +++ b/ggsql-vscode/CLAUDE.md @@ -90,7 +90,16 @@ The extension declares `contributes.languageRuntimes` for `ggsql` (see `package. 1. Discovers `ggsql-jupyter` binaries as described in [Finding the kernel](#finding-the-kernel) below. 2. Registers each as a Positron language runtime so `▶ Run` and the Console route to the kernel. -3. Routes plot output to Positron's Plot pane via metadata coming back from the kernel (`output_location: "plot"`). +3. Tells the kernel what kind of session this is, and subscribes to the one notification it needs — see below. + +### The two things the extension has to tell the kernel + +**Plots are rendered in the kernel and reach the Plots pane over a `positron.plot` comm.** The extension routes nothing itself: there is no `output_location` metadata and no client-side renderer. What it does do is supply the two pieces of context the kernel cannot work out for itself. + +- **`createKernelSpec` appends `--session-mode `** from `sessionMetadata.sessionMode`. The kernel keys every output decision off that: a console session gets the comm, a notebook session gets a static image in its cell, and background gets the standalone path. Without the flag the kernel falls back to guessing from its session id, which works but is a heuristic. `restoreSession` deliberately does *not* rebuild the spec — the supervisor replays the argv the session was created with, and a session's mode never changes. +- **`uiSubscriptions: ['did_change_plots_render_settings']`** on the runtime metadata. Without it the frontend never tells the kernel how large the Plots pane is, and the kernel needs that for exactly one thing: rendering a *new* plot at the right size so its `comm_open` can carry it, and the pane shows it immediately instead of blank. Every other render carries its own size on the request. + +Both live in `manager.ts`. The kernel side of the contract — why the comm alone creates the pane entry, and why an output bundle alongside it would show the plot twice — is in [`/ggsql-jupyter/CLAUDE.md`](../ggsql-jupyter/CLAUDE.md). Outside Positron there is no way to execute a query: `activate()` returns early, so every command that runs code stays unregistered. To avoid offering actions that cannot work, everything execution-related gates on Positron's built-in **`isPositron`** context key ([extension development docs](https://positron.posit.co/extension-development.html#option-1-context-keys)): diff --git a/ggsql-vscode/src/manager.ts b/ggsql-vscode/src/manager.ts index 2e378b1ff..83585c910 100644 --- a/ggsql-vscode/src/manager.ts +++ b/ggsql-vscode/src/manager.ts @@ -525,20 +525,41 @@ export function generateMetadata( base64EncodedIconSvg: base64Icon, startupBehavior: 'explicit' as positron.LanguageRuntimeStartupBehavior, sessionLocation: 'workspace' as positron.LanguageRuntimeSessionLocation, - extraRuntimeData: {} + extraRuntimeData: {}, + // Without this the frontend never tells the kernel how large the Plots + // pane is, which it needs only to pre-render a new plot at the right + // size; every other render carries its own size. A string cast because + // `positron` is imported as a type — as with `startupBehavior` above. + uiSubscriptions: ['did_change_plots_render_settings' as positron.UiRuntimeNotifications] }; } /** * Create a Jupyter kernel spec for ggsql-jupyter * + * `--session-mode` tells the kernel where its output goes, which it cannot work + * out for itself: a plot comm always lands in the Plots pane, so a notebook + * session using one would leave its cell empty. Left off, the kernel guesses + * from the session id — which is why `writeKernelJson` does not pass it. + * * @param kernelPath - Path to the ggsql-jupyter executable + * @param readerUri - Data source the kernel should open, if not the default + * @param sessionMode - What kind of session this is, when known */ -function createKernelSpec(kernelPath: string, readerUri?: string): JupyterKernelSpec { +function createKernelSpec( + kernelPath: string, + readerUri?: string, + sessionMode?: positron.LanguageRuntimeSessionMode +): JupyterKernelSpec { const argv = [kernelPath, '-f', '{connection_file}']; if (readerUri) { argv.push('--reader', readerUri); } + if (sessionMode) { + // The enum's values are already the kernel's spelling (`console`, + // `notebook`, `background`), so there is nothing to translate. + argv.push('--session-mode', sessionMode); + } return { argv, @@ -818,7 +839,11 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager { const supervisorApi = await getSupervisorApi(); // Create the kernel spec using the runtime's kernel path - const kernelSpec = createKernelSpec(runtimeMetadata.runtimePath); + const kernelSpec = createKernelSpec( + runtimeMetadata.runtimePath, + undefined, + sessionMetadata.sessionMode + ); const dynState = createDynState(); @@ -848,6 +873,9 @@ export class GgsqlRuntimeManager implements positron.LanguageRuntimeManager { const dynState = createDynState(sessionName); + // No kernel spec here on purpose: the supervisor replays the argv the + // session was created with, and a session's mode never changes, so the + // restored kernel keeps the `--session-mode` it started with. // Re-advertise this kernel on restore ensureKernelSpecInstalled(runtimeMetadata.runtimePath); diff --git a/ggsql-wasm/CLAUDE.md b/ggsql-wasm/CLAUDE.md index 96f7ac1d9..580222d90 100644 --- a/ggsql-wasm/CLAUDE.md +++ b/ggsql-wasm/CLAUDE.md @@ -1,83 +1,153 @@ # `ggsql-wasm/` — WebAssembly bindings -Compiles the `ggsql` core to WebAssembly so it can run in browsers. Used by the playground at [`/doc/wasm/`](../doc/wasm/) and published as an npm package. Workspace member. +Compiles the `ggsql` core to WebAssembly for browsers. It powers the playground +at [`/doc/wasm/`](../doc/wasm/) and is published as the `ggsql-wasm` npm +package. Plots use ggsql's own SVG renderer; there is no JavaScript charting +library. -End-user playground: . This file describes the *build*. +End-user playground: . This file describes the build. ## Layout ``` ggsql-wasm/ -├── Cargo.toml cdylib; ggsql with default-features = false + vegalite, sqlite, builtin-data -├── build-wasm.sh End-to-end build orchestrator (library + wasm + demo → doc/wasm) -├── src/ -│ └── lib.rs wasm-bindgen entry points (the only Rust here) -├── library/ TypeScript wrapper distributed on npm -│ ├── package.json npm package (build with `npm run build`) -│ ├── build.mjs esbuild script -│ └── src/ -├── demo/ Browser demo + playground used by the doc site -│ ├── package.json -│ ├── build.mjs -│ └── src/ UI code (editor + Vega-Lite preview) -└── pkg/ wasm-pack output (committed; consumed by library/ and demo/) - ├── ggsql_wasm_bg.wasm - ├── mod_spatialite.wasm - ├── ggsql_wasm.js, .d.ts - └── package.json +├── Cargo.toml cdylib; ggsql with svg, webfonts, sqlite, builtin-data and spatial +├── build-wasm.sh cargo → wasm-bindgen → wasm-opt → package → demo → doc/wasm +├── src/lib.rs wasm-bindgen API; receives JS converters during init +├── fonts/ Roboto faces served beside the wasm +├── pkg/ checked-in source for the published npm package +│ ├── package.json package manifest, including the release version +│ ├── build.mjs bundles the TypeScript client and copies fonts +│ ├── tsconfig.json emits declarations into dist/ +│ ├── src/ client, converters and extension loader +│ └── dist/ generated publish contents (gitignored) +└── demo/ browser demo and Quarto integration + ├── package.json consumes file:../pkg + ├── build.mjs + └── src/ ``` -`pkg/` is generated but committed so contributors don't need a wasm toolchain just to run the docs. +`pkg/` is the npm package, not generated wasm-pack output. Its source, manifest, +license and lock file are committed. Only `pkg/dist/`, `demo/dist/` and +`doc/wasm/` are generated and ignored. + +The package entry point is `pkg/src/ggsql.ts`. It wraps wasm-bindgen's `init` and +`initSync`, supplies the CSV and Parquet converters to Rust through +`setConverters`, wires the extension loader, re-exports the generated API, and +adds `PlotView` and font loading. wasm-bindgen writes its glue directly into +`pkg/dist/`, next to the client bundle and generated declarations. ## Toolchain -- **Rust stable, not the workspace 1.86 MSRV.** The rest of the workspace targets the 1.86 MSRV for the R/CRAN bindings (see [`/CLAUDE.md`](../CLAUDE.md)), but R doesn't use wasm and some wasm-only deps need a newer rustc. A nested [`rust-toolchain.toml`](rust-toolchain.toml) selects stable for any build run from this directory; this crate has no `rust-version`. In CI, the multi-purpose jobs (`build.yaml`, `publish.yaml`) default to 1.86, so their wasm tool installs (`cargo install wasm-pack`/`wasm-opt`, run at the repo root) use `cargo +stable`; the wasm-only release job runs entirely on stable. -- Rust target `wasm32-unknown-unknown` and [`wasm-pack`](https://rustwasm.github.io/wasm-pack/) for compilation. -- A clang/llvm with wasm backend support (the build script verifies this with a one-line probe). -- `wasm-opt` (from binaryen) for the `-Oz` optimization step. -- Node.js for `library/` and `demo/`. +- **Rust stable, not the workspace 1.86 MSRV.** The nested + [`rust-toolchain.toml`](rust-toolchain.toml) selects stable for builds run from + this directory. The wasm crate has no `rust-version`. +- Rust target `wasm32-unknown-unknown`. +- `wasm-bindgen-cli` with exactly the version recorded for `wasm-bindgen` in + `/Cargo.lock`. The build checks this because the CLI and crate schema versions + must agree. Install the required version with: + + ```sh + cargo install -f wasm-bindgen-cli --version + ``` + +- A clang/LLVM with wasm backend support. The build script verifies it with a + one-line compile probe. +- `wasm-opt` from binaryen for the `-Oz` optimization step. This is not + `wasm-tools`; use `brew install binaryen` or `cargo install wasm-opt`. +- Node.js for `pkg/` and `demo/`. + +On macOS, the system clang may lack the wasm backend. A Homebrew LLVM setup is: + +```sh +export PATH=/opt/homebrew/opt/llvm/bin:$PATH +export CC=/opt/homebrew/opt/llvm/bin/clang +export AR=/opt/homebrew/opt/llvm/bin/llvm-ar +``` ## Build -The full build: +The full build is: ```sh cd ggsql-wasm ./build-wasm.sh ``` -This sequentially: - -1. `npm install && npm run build` in `library/` — produces the typed JS wrapper. -2. `wasm-pack build --target web --profile wasm --no-opt` — compiles `src/lib.rs` to `pkg/`. The `wasm` profile is defined in the workspace `Cargo.toml` (release-style, `opt-level = "z"`, LTO, `panic = "abort"`). -3. `wasm-opt pkg/ggsql_wasm_bg.wasm -o pkg/ggsql_wasm_bg.wasm -Oz` — shrinks the binary further. -4. Downloads the prebuilt `mod_spatialite.wasm` from the [ggsql-dev/sqlite-wasm-rs releases](https://github.com/ggsql-dev/sqlite-wasm-rs/releases) into `pkg/`, caching it under `/target/wasm-extensions/`. -5. `npm install && npm run build` in `demo/` — bundles the playground UI (copies extension wasm from `pkg/` into `dist/`). -6. Copies `demo/dist/` to `/doc/wasm/` so Quarto can serve it under the docs site. +It runs these phases in order: + +1. `cargo build --target wasm32-unknown-unknown --profile wasm -p ggsql-wasm`. +2. `wasm-bindgen --target web --keep-lld-exports` into `pkg/dist/`. + `--keep-lld-exports` preserves symbols needed by loadable SQLite extensions. +3. `wasm-opt -Oz` on `pkg/dist/ggsql_wasm_bg.wasm`. +4. `npm install && npm run build` in `pkg/`. esbuild bundles the TypeScript + client and hyparquet, TypeScript emits declarations, and the fonts are copied + into `dist/fonts/`. +5. Downloads or reuses `mod_spatialite.wasm` under + `/target/wasm-extensions/`. +6. `npm install && npm run build` in `demo/`, then copies SpatiaLite into + `demo/dist/`. +7. Copies `demo/dist/` to `/doc/wasm/`. Flags: -- `--skip-binary` — reuse the existing `pkg/` (skip steps 2–3); useful when iterating on `library/` or `demo/`. -- `--skip-opt` — compile but skip `wasm-opt` (faster, larger binary). +- `--skip-binary` reuses `pkg/dist/ggsql_wasm_bg.wasm` and rebuilds the client + and demo. Use it while iterating on TypeScript or demo code. It fails until a + full build has produced the glue and wasm once. +- `--skip-opt` compiles the binary but skips the optimization pass. + +`pkg/tsconfig.json` uses `rootDirs: ["./src", "./dist"]` so the client import +of `./ggsql_wasm.js` resolves to wasm-bindgen's declaration in `dist/`. +Consequently `npm run typecheck` in `pkg/` requires a prior wasm-bindgen run; +the glue is deliberately not hand-maintained. + +## Fonts are the thing that surprises people + +**A browser enumerates no system fonts.** The font collection starts empty, +`sans-serif` resolves to nothing, and a plot is drawn with no text and incorrect +layout measurements. + +`registerDefaultFonts()` fetches the four Roboto faces in `fonts/`, registers +them with the Rust shaper, points `sans-serif` at the family names returned by +the font files, and declares matching browser `@font-face` rules. `PlotView` +then names the registered family on the SVG root so browser rendering uses the +same face that ggsql measured. + +Keep one file per weight and style. The shaper selects faces by those attributes +and does not understand CSS `unicode-range`; multiple subsets with the same +family name can select a subset that lacks the required glyphs. + +`registerFontFromUrl(url, { genericFor })` supports a page's own typography. +The `webfonts` feature accepts WOFF and WOFF2 as well as sfnt formats. Font +registration is process-global and must happen before the first draw. + +## Wasm-specific constraints -## Wasm-specific feature constraints +`Cargo.toml` contains wasm32-only dependency overrides: -`Cargo.toml` carves out wasm32-only dependency overrides: +- `getrandom` and `uuid` use their JavaScript randomness support. +- `sqlite-wasm-rs` provides SQLite in the browser. +- `tokio` disables its default host I/O features. -- `getrandom` and `uuid` are forced to the `js` feature so they get randomness from the browser. -- `sqlite-wasm-rs` replaces `rusqlite` for SQLite support in the browser. -- `tokio` is reduced to `default-features = false` (no I/O reactor on wasm). +ODBC is unavailable in the browser. The SVG writer is used because it needs no +GPU or WebGL context and permits any number of plots on a page. -ODBC is not enabled here — it requires host APIs that aren't available in the browser. +The CSV and Parquet converters live in TypeScript. The package's init wrapper +passes them to Rust using `setConverters`; constructing a context through the +package after `init()` is therefore required before registering those formats +or builtin datasets. ## Distribution -- **npm**: `library/` is published as the user-facing JS/TS wrapper. The `pkg/` artifact is bundled with it. -- **GitHub Releases**: the wasm binary is also attached to releases (see commit `071cff6`). -- **Docs site**: `demo/dist/` is committed into [`/doc/wasm/`](../doc/wasm/) by `build-wasm.sh` and embedded in Quarto pages via `_quarto.yml`. +- **npm:** publish `pkg/`. `package.json` includes only `dist/`, while npm + automatically includes `LICENSE` and the manifest. The committed package + version is bumped in the release checklist. +- **GitHub Releases:** the wasm binary is also attached to releases. +- **Docs:** `build-wasm.sh` copies `demo/dist/` to + [`/doc/wasm/`](../doc/wasm/), which Quarto serves. Both are generated. ## See also - [`/CLAUDE.md`](../CLAUDE.md) — workspace overview. - [`/src/CLAUDE.md`](../src/CLAUDE.md) — the underlying `ggsql` library. -- [`/doc/CLAUDE.md`](../doc/CLAUDE.md) — how the playground gets embedded into the Quarto site. +- [`/doc/CLAUDE.md`](../doc/CLAUDE.md) — docs and playground embedding. diff --git a/ggsql-wasm/Cargo.toml b/ggsql-wasm/Cargo.toml index 9969ee1e9..41f378ef9 100644 --- a/ggsql-wasm/Cargo.toml +++ b/ggsql-wasm/Cargo.toml @@ -15,11 +15,16 @@ description = "WebAssembly bindings for ggsql." crate-type = ["cdylib"] [dependencies] +# A panic aborts the module under the `wasm` profile's `panic = "abort"`, and +# the message goes nowhere without a hook — leaving a page with a trapped +# instance and no way to tell what tripped it. A panic hook still runs before +# the abort, so this costs a few kB and buys the message. +console_error_panic_hook = "0.1" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" arrow = { workspace = true } -ggsql = { path = "../src", default-features = false, features = ["vegalite", "sqlite", "builtin-data", "spatial"] } +ggsql = { path = "../src", default-features = false, features = ["svg", "webfonts", "sqlite", "builtin-data", "spatial"] } serde_json = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/ggsql-wasm/build-wasm.sh b/ggsql-wasm/build-wasm.sh index b8ffeb387..8417a5d81 100755 --- a/ggsql-wasm/build-wasm.sh +++ b/ggsql-wasm/build-wasm.sh @@ -14,7 +14,7 @@ for arg in "$@"; do esac done -check_wasm32_support() { +check_prerequisites() { local cc="${CC:-clang}" if ! echo "int main(){return 0;}" | \ "$cc" -target wasm32-unknown-unknown -c -o /dev/null -x c - 2>/dev/null; then @@ -22,73 +22,92 @@ check_wasm32_support() { echo "Install an LLVM/clang toolchain with wasm backend support (e.g. 'sudo apt-get install llvm' on Debian/Ubuntu)." >&2 exit 1 fi - if ! command -v wasm-pack >/dev/null 2>&1; then - echo "Error: wasm-pack not found. Install with: cargo install wasm-pack" >&2 + + if ! command -v wasm-opt >/dev/null 2>&1; then + echo "Error: wasm-opt not found. Install binaryen or run: cargo install wasm-opt" >&2 + exit 1 + fi + + local expected_version + expected_version="$(awk '/^name = "wasm-bindgen"$/{f=1;next} f&&/^version = /{gsub(/[",]/,"");print $3;exit}' "$REPO_ROOT/Cargo.lock")" + if [ -z "$expected_version" ]; then + echo "Error: could not find the wasm-bindgen version in Cargo.lock." >&2 exit 1 fi + if ! command -v wasm-bindgen >/dev/null 2>&1 || \ + [ "$(wasm-bindgen --version 2>/dev/null | awk '{print $2}')" != "$expected_version" ]; then + echo "Error: wasm-bindgen $expected_version is required." >&2 + echo "Install it with: cargo install -f wasm-bindgen-cli --version $expected_version" >&2 + exit 1 + fi + echo "Using wasm-bindgen $expected_version" } -echo "Building WASM library..." -(cd "$SCRIPT_DIR/library" && npm install && npm run build) +echo "Checking wasm build prerequisites..." +check_prerequisites if [ "$SKIP_BINARY" = false ]; then - echo "Checking wasm build prerequisites..." - check_wasm32_support - echo "Building WASM binary..." - rm -rf "$SCRIPT_DIR/pkg" # start clean so stale wasm-bindgen snippets don't accumulate - (cd "$SCRIPT_DIR" && wasm-pack build --target web --profile wasm --no-opt) + (cd "$SCRIPT_DIR" && cargo build \ + --target wasm32-unknown-unknown \ + --profile wasm \ + -p ggsql-wasm) - # wasm-bindgen is invoked directly so we can pass --keep-lld-exports, - # which preserves the LLD symbols that loadable extensions import. - # wasm-pack cannot forward that flag (rustwasm/wasm-pack#1092). - echo "Re-running wasm-bindgen with --keep-lld-exports..." - WASM_BINDGEN="$(find "$HOME/Library/Caches/.wasm-pack" "$HOME/.cache/.wasm-pack" -name wasm-bindgen -type f 2>/dev/null | sort -V | tail -1 || true)" - if [ -z "$WASM_BINDGEN" ]; then - echo "Error: could not locate wasm-pack's cached wasm-bindgen." >&2 - exit 1 - fi - "$WASM_BINDGEN" \ + rm -rf "$SCRIPT_DIR/pkg/dist" + wasm-bindgen \ --target web \ --keep-lld-exports \ - --out-dir "$SCRIPT_DIR/pkg" \ + --out-dir "$SCRIPT_DIR/pkg/dist" \ "$REPO_ROOT/target/wasm32-unknown-unknown/wasm/ggsql_wasm.wasm" if [ "$SKIP_OPT" = false ]; then echo "Optimising WASM binary..." - (cd "$SCRIPT_DIR" && wasm-opt pkg/ggsql_wasm_bg.wasm -o pkg/ggsql_wasm_bg.wasm -Oz --all-features) + wasm-opt \ + "$SCRIPT_DIR/pkg/dist/ggsql_wasm_bg.wasm" \ + -o "$SCRIPT_DIR/pkg/dist/ggsql_wasm_bg.wasm" \ + -Oz \ + --enable-bulk-memory \ + --enable-nontrapping-float-to-int \ + --enable-reference-types \ + --enable-sign-ext \ + --enable-mutable-globals \ + --enable-multivalue else echo "Skipping wasm-opt (--skip-opt)." fi - - echo "Adding snippets/ to package files..." - (cd "$SCRIPT_DIR/pkg" && npm pkg set 'files[]=snippets/') else echo "Skipping WASM binary build (--skip-binary)." + if [ ! -f "$SCRIPT_DIR/pkg/dist/ggsql_wasm_bg.wasm" ]; then + echo "Error: --skip-binary needs pkg/dist/ggsql_wasm_bg.wasm; run once without it first." >&2 + exit 1 + fi fi +echo "Building npm package..." +(cd "$SCRIPT_DIR/pkg" && npm install && npm run build) + SPATIALITE_TAG="spatialite-5.1.0-wasm" SPATIALITE_URL="https://github.com/ggsql-dev/sqlite-wasm-rs/releases/download/$SPATIALITE_TAG/mod_spatialite.wasm" # SPATIALITE_WASM overrides the download with a locally built binary. if [ -n "${SPATIALITE_WASM:-}" ]; then echo "Using local mod_spatialite.wasm: $SPATIALITE_WASM" - cp "$SPATIALITE_WASM" "$SCRIPT_DIR/pkg/mod_spatialite.wasm" + SPATIALITE_SOURCE="$SPATIALITE_WASM" else - CACHED="$REPO_ROOT/target/wasm-extensions/$SPATIALITE_TAG/mod_spatialite.wasm" - if [ ! -f "$CACHED" ]; then + SPATIALITE_SOURCE="$REPO_ROOT/target/wasm-extensions/$SPATIALITE_TAG/mod_spatialite.wasm" + if [ ! -f "$SPATIALITE_SOURCE" ]; then echo "Downloading mod_spatialite.wasm ($SPATIALITE_TAG)..." - mkdir -p "$(dirname "$CACHED")" - curl -sSfL -o "$CACHED.tmp" "$SPATIALITE_URL" - mv "$CACHED.tmp" "$CACHED" + mkdir -p "$(dirname "$SPATIALITE_SOURCE")" + curl -sSfL -o "$SPATIALITE_SOURCE.tmp" "$SPATIALITE_URL" + mv "$SPATIALITE_SOURCE.tmp" "$SPATIALITE_SOURCE" else - echo "Using cached mod_spatialite.wasm: $CACHED" + echo "Using cached mod_spatialite.wasm: $SPATIALITE_SOURCE" fi - cp "$CACHED" "$SCRIPT_DIR/pkg/mod_spatialite.wasm" fi echo "Building WASM demo and Quarto integration..." (cd "$SCRIPT_DIR/demo" && npm install && npm run build) +cp "$SPATIALITE_SOURCE" "$SCRIPT_DIR/demo/dist/mod_spatialite.wasm" echo "Copying output to doc/wasm..." rm -rf "$REPO_ROOT/doc/wasm" diff --git a/ggsql-wasm/demo/build.mjs b/ggsql-wasm/demo/build.mjs index f47f999c5..94ab2957c 100644 --- a/ggsql-wasm/demo/build.mjs +++ b/ggsql-wasm/demo/build.mjs @@ -1,5 +1,5 @@ import * as esbuild from "esbuild"; -import { copyFileSync, mkdirSync } from "fs"; +import { copyFileSync, existsSync, mkdirSync, readdirSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -14,7 +14,7 @@ mkdirSync(distDir, { recursive: true }); console.log("Copying static files..."); copyFileSync(join(__dirname, "src/index.qmd"), join(distDir, "index.qmd")); copyFileSync( - join(__dirname, "../pkg/ggsql_wasm_bg.wasm"), + join(__dirname, "../pkg/dist/ggsql_wasm_bg.wasm"), join(distDir, "ggsql_wasm_bg.wasm"), ); copyFileSync( @@ -25,13 +25,19 @@ copyFileSync( join(__dirname, "../../ggsql-vscode/syntaxes/ggsql.tmLanguage.json"), join(distDir, "ggsql.tmLanguage.json"), ); -for (const ext of ["mod_spatialite"]) { - try { - copyFileSync( - join(__dirname, `../pkg/${ext}.wasm`), - join(distDir, `${ext}.wasm`), - ); - } catch (_) {} +// A browser enumerates no fonts of its own, so the faces have to be served +// alongside the wasm — see `registerDefaultFonts` in the client. Worth stopping +// for: the demo would build and every plot would come out textless. +const fontsDir = join(__dirname, "../pkg/dist/fonts"); +if (!existsSync(fontsDir)) { + console.error( + `No fonts in ${fontsDir}. Run ggsql-wasm/build-wasm.sh, which puts them there.`, + ); + process.exit(1); +} +mkdirSync(join(distDir, "fonts"), { recursive: true }); +for (const face of readdirSync(fontsDir)) { + copyFileSync(join(fontsDir, face), join(distDir, "fonts", face)); } // Build Monaco editor web worker diff --git a/ggsql-wasm/demo/package-lock.json b/ggsql-wasm/demo/package-lock.json index 8168bdc33..a7e25dec2 100644 --- a/ggsql-wasm/demo/package-lock.json +++ b/ggsql-wasm/demo/package-lock.json @@ -15,30 +15,20 @@ "esbuild": "^0.27.0", "monaco-editor": "^0.55.0", "typescript": "^5.9.0", - "vega": "^6.2.0", - "vega-embed": "^7.1.0", - "vega-lite": "6.4.1", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.0" } }, - "../library": { - "name": "ggsql-wasm-lib", - "version": "0.0.0", - "extraneous": true, - "dependencies": { - "hyparquet": "^1.25.0" - }, + "../pkg": { + "name": "ggsql-wasm", + "version": "0.4.1", + "license": "MIT", "devDependencies": { "esbuild": "^0.27.0", + "hyparquet": "^1.25.0", "typescript": "^5.9.0" } }, - "../pkg": { - "name": "ggsql-wasm", - "version": "0.4.1", - "license": "MIT" - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -481,20 +471,6 @@ "node": ">=18" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -503,322 +479,6 @@ "license": "MIT", "optional": true }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dev": true, - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "dev": true, - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", - "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-geo-projection": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", - "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", - "dev": true, - "license": "ISC", - "dependencies": { - "commander": "7", - "d3-array": "1 - 3", - "d3-geo": "1.12.0 - 3" - }, - "bin": { - "geo2svg": "bin/geo2svg.js", - "geograticule": "bin/geograticule.js", - "geoproject": "bin/geoproject.js", - "geoquantize": "bin/geoquantize.js", - "geostitch": "bin/geostitch.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dev": true, - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "dev": true, - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -829,13 +489,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -878,46 +531,6 @@ "@esbuild/win32-x64": "0.27.3" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fast-json-patch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", - "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ggsql-wasm": { "resolved": "../pkg", "link": true @@ -928,36 +541,6 @@ "integrity": "sha512-isJx+RplYT3aJc5yhaG5CeOZSBJecHZgYsUi7NE6P/nAbxxA0hZcyul0tUsWCQLc9QXYQ2uFyYBrk61JbJO0cg==", "license": "MIT" }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/json-stringify-pretty-compact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", - "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -982,103 +565,6 @@ "marked": "14.0.0" } }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "dev": true, - "license": "Unlicense" - }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/topojson-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", - "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "commander": "2" - }, - "bin": { - "topo2geo": "bin/topo2geo", - "topomerge": "bin/topomerge", - "topoquantize": "bin/topoquantize" - } - }, - "node_modules/topojson-client/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1093,510 +579,6 @@ "node": ">=14.17" } }, - "node_modules/vega": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vega/-/vega-6.2.0.tgz", - "integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-crossfilter": "~5.1.0", - "vega-dataflow": "~6.1.0", - "vega-encode": "~5.1.0", - "vega-event-selector": "~4.0.0", - "vega-expression": "~6.1.0", - "vega-force": "~5.1.0", - "vega-format": "~2.1.0", - "vega-functions": "~6.1.0", - "vega-geo": "~5.1.0", - "vega-hierarchy": "~5.1.0", - "vega-label": "~2.1.0", - "vega-loader": "~5.1.0", - "vega-parser": "~7.1.0", - "vega-projection": "~2.1.0", - "vega-regression": "~2.1.0", - "vega-runtime": "~7.1.0", - "vega-scale": "~8.1.0", - "vega-scenegraph": "~5.1.0", - "vega-statistics": "~2.0.0", - "vega-time": "~3.1.0", - "vega-transforms": "~5.1.0", - "vega-typings": "~2.1.0", - "vega-util": "~2.1.0", - "vega-view": "~6.1.0", - "vega-view-transforms": "~5.1.0", - "vega-voronoi": "~5.1.0", - "vega-wordcloud": "~5.1.0" - }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" - } - }, - "node_modules/vega-canvas": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-2.0.0.tgz", - "integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/vega-crossfilter": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz", - "integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-dataflow": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz", - "integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-format": "^2.1.0", - "vega-loader": "^5.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-embed": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/vega-embed/-/vega-embed-7.1.0.tgz", - "integrity": "sha512-ZmEIn5XJrQt7fSh2lwtSdXG/9uf3yIqZnvXFEwBJRppiBgrEWZcZbj6VK3xn8sNTFQ+sQDXW5sl/6kmbAW3s5A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "fast-json-patch": "^3.1.1", - "json-stringify-pretty-compact": "^4.0.0", - "semver": "^7.7.2", - "tslib": "^2.8.1", - "vega-interpreter": "^2.0.0", - "vega-schema-url-parser": "^3.0.2", - "vega-themes": "3.0.0", - "vega-tooltip": "1.0.0" - }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" - }, - "peerDependencies": { - "vega": "*", - "vega-lite": "*" - } - }, - "node_modules/vega-encode": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-encode/-/vega-encode-5.1.0.tgz", - "integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-interpolate": "^3.0.1", - "vega-dataflow": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-event-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz", - "integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/vega-expression": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-6.1.0.tgz", - "integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@types/estree": "^1.0.8", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-force": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-force/-/vega-force-5.1.0.tgz", - "integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-force": "^3.0.0", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-format": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vega-format/-/vega-format-2.1.0.tgz", - "integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-format": "^3.1.0", - "d3-time-format": "^4.1.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-functions": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.1.tgz", - "integrity": "sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-color": "^3.1.0", - "d3-geo": "^3.1.1", - "vega-dataflow": "^6.1.0", - "vega-expression": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-scenegraph": "^5.1.0", - "vega-selections": "^6.1.0", - "vega-statistics": "^2.0.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-geo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-geo/-/vega-geo-5.1.0.tgz", - "integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-color": "^3.1.0", - "d3-geo": "^3.1.1", - "vega-canvas": "^2.0.0", - "vega-dataflow": "^6.1.0", - "vega-projection": "^2.1.0", - "vega-statistics": "^2.0.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-hierarchy": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz", - "integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-hierarchy": "^3.1.2", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-interpreter": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/vega-interpreter/-/vega-interpreter-2.2.1.tgz", - "integrity": "sha512-o+4ZEme2mdFLewlpF76dwPWW2VkZ3TAF3DMcq75/NzA5KPvnN4wnlCM8At2FVawbaHRyGdVkJSS5ROF5KwpHPQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-label": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vega-label/-/vega-label-2.1.0.tgz", - "integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-canvas": "^2.0.0", - "vega-dataflow": "^6.1.0", - "vega-scenegraph": "^5.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-lite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.1.tgz", - "integrity": "sha512-KO3ybHNouRK4A0al/+2fN9UqgTEfxrd/ntGLY933Hg5UOYotDVQdshR3zn7OfXwQ7uj0W96Vfa5R+QxO8am3IQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "json-stringify-pretty-compact": "~4.0.0", - "tslib": "~2.8.1", - "vega-event-selector": "~4.0.0", - "vega-expression": "~6.1.0", - "vega-util": "~2.1.0", - "yargs": "~18.0.0" - }, - "bin": { - "vl2pdf": "bin/vl2pdf", - "vl2png": "bin/vl2png", - "vl2svg": "bin/vl2svg", - "vl2vg": "bin/vl2vg" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" - }, - "peerDependencies": { - "vega": "^6.0.0" - } - }, - "node_modules/vega-loader": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-loader/-/vega-loader-5.1.0.tgz", - "integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-dsv": "^3.0.1", - "topojson-client": "^3.1.0", - "vega-format": "^2.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-7.1.0.tgz", - "integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-dataflow": "^6.1.0", - "vega-event-selector": "^4.0.0", - "vega-functions": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-projection": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vega-projection/-/vega-projection-2.1.0.tgz", - "integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-geo": "^3.1.1", - "d3-geo-projection": "^4.0.0", - "vega-scale": "^8.1.0" - } - }, - "node_modules/vega-regression": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vega-regression/-/vega-regression-2.1.0.tgz", - "integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "vega-dataflow": "^6.1.0", - "vega-statistics": "^2.0.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-runtime": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/vega-runtime/-/vega-runtime-7.1.0.tgz", - "integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-scale": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-8.1.0.tgz", - "integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-scale-chromatic": "^3.1.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-scenegraph": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz", - "integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "^3.1.0", - "d3-shape": "^3.2.0", - "vega-canvas": "^2.0.0", - "vega-loader": "^5.1.0", - "vega-scale": "^8.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-schema-url-parser": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/vega-schema-url-parser/-/vega-schema-url-parser-3.0.2.tgz", - "integrity": "sha512-xAnR7KAvNPYewI3O0l5QGdT8Tv0+GCZQjqfP39cW/hbe/b3aYMAQ39vm8O2wfXUHzm04xTe7nolcsx8WQNVLRQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/vega-selections": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.2.tgz", - "integrity": "sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "3.2.4", - "vega-expression": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-statistics": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-2.0.0.tgz", - "integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4" - } - }, - "node_modules/vega-themes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vega-themes/-/vega-themes-3.0.0.tgz", - "integrity": "sha512-1iFiI3BNmW9FrsLnDLx0ZKEddsCitRY3XmUAwp6qmp+p+IXyJYc9pfjlVj9E6KXBPfm4cQyU++s0smKNiWzO4g==", - "dev": true, - "license": "BSD-3-Clause", - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" - }, - "peerDependencies": { - "vega": "*", - "vega-lite": "*" - } - }, - "node_modules/vega-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vega-time/-/vega-time-3.1.0.tgz", - "integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-time": "^3.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-tooltip": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/vega-tooltip/-/vega-tooltip-1.0.0.tgz", - "integrity": "sha512-P1R0JP29v0qnTuwzCQ0SPJlkjAzr6qeyj+H4VgUFSykHmHc1OBxda//XBaFDl/bZgIscEMvjKSjZpXd84x3aZQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-util": "^2.0.0" - }, - "funding": { - "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" - } - }, - "node_modules/vega-transforms": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-5.1.0.tgz", - "integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "vega-dataflow": "^6.1.0", - "vega-statistics": "^2.0.0", - "vega-time": "^3.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-typings": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-2.1.0.tgz", - "integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@types/geojson": "7946.0.16", - "vega-event-selector": "^4.0.0", - "vega-expression": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-util": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/vega-util/-/vega-util-2.1.0.tgz", - "integrity": "sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/vega-view": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vega-view/-/vega-view-6.1.0.tgz", - "integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "^3.2.4", - "d3-timer": "^3.0.1", - "vega-dataflow": "^6.1.0", - "vega-format": "^2.1.0", - "vega-functions": "^6.1.0", - "vega-runtime": "^7.1.0", - "vega-scenegraph": "^5.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-view-transforms": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz", - "integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-dataflow": "^6.1.0", - "vega-scenegraph": "^5.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-voronoi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz", - "integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "d3-delaunay": "^6.0.4", - "vega-dataflow": "^6.1.0", - "vega-util": "^2.1.0" - } - }, - "node_modules/vega-wordcloud": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz", - "integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "vega-canvas": "^2.0.0", - "vega-dataflow": "^6.1.0", - "vega-scale": "^8.1.0", - "vega-statistics": "^2.0.0", - "vega-util": "^2.1.0" - } - }, "node_modules/vscode-oniguruma": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz", @@ -1610,62 +592,6 @@ "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", "dev": true, "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } } } } diff --git a/ggsql-wasm/demo/package.json b/ggsql-wasm/demo/package.json index 936d129b5..35fa32590 100644 --- a/ggsql-wasm/demo/package.json +++ b/ggsql-wasm/demo/package.json @@ -16,9 +16,6 @@ "esbuild": "^0.27.0", "monaco-editor": "^0.55.0", "typescript": "^5.9.0", - "vega": "^6.2.0", - "vega-embed": "^7.1.0", - "vega-lite": "6.4.1", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.0" } diff --git a/ggsql-wasm/demo/src/context.ts b/ggsql-wasm/demo/src/context.ts index 24b33354b..c3aad3dbb 100644 --- a/ggsql-wasm/demo/src/context.ts +++ b/ggsql-wasm/demo/src/context.ts @@ -1,25 +1,41 @@ import init, { GgsqlContext, - initExtensionLoader, + GgsqlPlot, installExtension, + registerDefaultFonts, } from "ggsql-wasm"; import { WASM_BASE } from "./wasmBase"; +/** + * What a page can do once the module has trapped, which is nothing but reload. + * + * A panic in wasm aborts the instance, not the call: the module is dead for the + * whole page and re-initialising does not bring it back. Saying so once beats + * repeating an opaque trap for every subsequent cell. + */ +const TRAPPED = + "the ggsql engine stopped and cannot recover — reload the page to continue"; + export class WasmContextManager { private context: GgsqlContext | null = null; private initialized = false; + private trapped = false; async initialize(): Promise { if (this.initialized) return; - const wasmExports = await init(WASM_BASE + "ggsql_wasm_bg.wasm"); - initExtensionLoader(wasmExports); - this.context = new GgsqlContext(); + await this.guardAsync(async () => { + await init(WASM_BASE + "ggsql_wasm_bg.wasm"); + // Before any plot is drawn: a browser enumerates no system fonts, so a + // plot rendered without this has no text and the wrong margins. + await registerDefaultFonts(WASM_BASE + "fonts/"); + this.context = new GgsqlContext(); + }); this.initialized = true; } async installExtension(name: string, url: string): Promise { - await installExtension(name, url); + await this.guardAsync(() => installExtension(name, url)); } private getContext(): GgsqlContext { @@ -29,39 +45,86 @@ export class WasmContextManager { return this.context; } - execute(query: string): string { - return this.getContext().execute(query); + /** + * Run one wasm call, latching a trap. + * + * A `WebAssembly.RuntimeError` is a dead instance, not a failed call, so it is + * recorded rather than merely reported. + */ + private guard(call: () => T): T { + if (this.trapped) throw new Error(TRAPPED); + try { + return call(); + } catch (err) { + throw this.noteError(err); + } + } + + /** {@link WasmContextManager.guard} for a call that settles later. */ + private async guardAsync(call: () => Promise): Promise { + if (this.trapped) throw new Error(TRAPPED); + try { + return await call(); + } catch (err) { + throw this.noteError(err); + } + } + + /** + * Classify an error that came out of wasm, latching a trap, and return what + * should be reported for it. + * + * Public because drawing goes through `PlotView` rather than this class, so a + * caller catching a panic there hands it here. + */ + noteError(err: unknown): unknown { + if (err instanceof WebAssembly.RuntimeError) { + this.trapped = true; + return new Error(`${TRAPPED} (${err.message})`); + } + return err; + } + + execute(query: string): GgsqlPlot { + return this.guard(() => this.getContext().execute(query)); } hasVisual(query: string): boolean { - return this.getContext().has_visual(query); + return this.guard(() => this.getContext().has_visual(query)); } executeSql(query: string): string { - return this.getContext().execute_sql(query); + return this.guard(() => this.getContext().execute_sql(query)); } registerCSV(name: string, data: Uint8Array): void { - this.getContext().register_csv(name, data); + this.guard(() => this.getContext().register_csv(name, data)); } async registerParquet(name: string, data: Uint8Array): Promise { - await this.getContext().register_parquet(name, data); + await this.guardAsync(() => this.getContext().register_parquet(name, data)); } async registerBuiltinDatasets(): Promise { - await this.getContext().register_builtin_datasets(); + await this.guardAsync(() => this.getContext().register_builtin_datasets()); } unregister(name: string): void { - this.getContext().unregister(name); + this.guard(() => this.getContext().unregister(name)); } listTables(): string[] { - return Array.from(this.getContext().list_tables() as Iterable); + return this.guard(() => + Array.from(this.getContext().list_tables() as Iterable), + ); } isInitialized(): boolean { return this.initialized; } + + /** Whether the module has trapped, and so can no longer serve anything. */ + isTrapped(): boolean { + return this.trapped; + } } diff --git a/ggsql-wasm/demo/src/main.ts b/ggsql-wasm/demo/src/main.ts index 4e3da015a..386ecf0da 100644 --- a/ggsql-wasm/demo/src/main.ts +++ b/ggsql-wasm/demo/src/main.ts @@ -1,6 +1,5 @@ import "./styles.css"; -import vegaEmbed from "vega-embed"; -import { Warn } from "vega"; +import { PlotView } from "ggsql-wasm"; import { WasmContextManager } from "./context"; import { EditorManager } from "./editor"; import { TableManager } from "./tableManager"; @@ -16,6 +15,11 @@ let tableManager: TableManager; const statusEl = document.getElementById("status")!; const editorContainer = document.getElementById("editor-container")!; const vizOutput = document.getElementById("viz-output")!; + +// One view for the life of the page. It observes `vizOutput` and redraws the +// current plot whenever the pane changes size, so the layout follows the box +// rather than being scaled into it. +const plotView = new PlotView(vizOutput, { idPrefix: "playground-" }); const errorMessages = document.getElementById("error-messages")!; const tableList = document.getElementById("table-list")!; const csvUpload = document.getElementById("csv-upload") as HTMLInputElement; @@ -111,9 +115,15 @@ async function ensureExtensions(names: string[] | undefined): Promise { } } +// Hand `vizOutput` back from the plot view, so a table or a message can use it. +function clearPlot() { + plotView.setPlot(null); +} + async function executeQuery(query: string) { if (!query.trim()) { showProblems([], []); + clearPlot(); vizOutput.innerHTML = '

Enter a query to visualize

'; return; @@ -123,34 +133,12 @@ async function executeQuery(query: string) { setStatus("Executing query...", "loading"); if (contextManager.hasVisual(query)) { - const result = contextManager.execute(query); - const spec = JSON.parse(result); - - vizOutput.innerHTML = ""; - - const warnings: string[] = []; - let _level = Warn; - const logger = { - level(_: number) { if (arguments.length) { _level = _; return this; } return _level; }, - error: (...args: any[]) => { console.error(...args); return logger; }, - warn: (...args: any[]) => { warnings.push(args.map(String).join(" ")); return logger; }, - info: () => logger, - debug: () => logger, - }; - - await vegaEmbed(vizOutput, spec, { - actions: { - export: true, - source: false, - compiled: false, - editor: false, - }, - renderer: "svg", - logger: logger as any, - }); - - showProblems([], warnings); + // The view is kept across queries: it owns the ResizeObserver, and + // re-solving the layout on resize is the point of drawing at all. + plotView.setPlot(contextManager.execute(query)); + showProblems([], plotView.warnings); } else { + clearPlot(); const result = JSON.parse(contextManager.executeSql(query)); vizOutput.innerHTML = renderTable(result); showProblems([], []); diff --git a/ggsql-wasm/demo/src/quarto/main.ts b/ggsql-wasm/demo/src/quarto/main.ts index f0170ff15..7712bd342 100644 --- a/ggsql-wasm/demo/src/quarto/main.ts +++ b/ggsql-wasm/demo/src/quarto/main.ts @@ -1,5 +1,5 @@ import "./styles.css"; -import vegaEmbed from "vega-embed"; +import { PlotView, type GgsqlPlot } from "ggsql-wasm"; import { WasmContextManager } from "../context"; import { WASM_BASE } from "../wasmBase"; import { createEditor, type EditorInstance } from "./editor"; @@ -13,9 +13,14 @@ interface CellInfo { rewrittenQuery: string; cellDiv: HTMLElement; codeScaffold: HTMLElement; - visId: string | null; + /** + * The cell's output box. Holds whatever the kernel rendered at build time, + * which stays until wasm has a plot to replace it — so a failed load leaves + * the picture rather than a blank gap. + */ visContainer: HTMLElement | null; - result: string | null; + view: PlotView | null; + plot: GgsqlPlot | null; succeeded: boolean; error: string | null; editor: EditorInstance | null; @@ -96,15 +101,6 @@ async function installRequestedExtensions( } } -// --------------------------------------------------------------------------- -// Vega embed options -// --------------------------------------------------------------------------- - -const VEGA_EMBED_OPTS = { - actions: { export: true, source: false, compiled: false, editor: false }, - renderer: "svg" as const, -}; - // --------------------------------------------------------------------------- // Phase 1: Gather cell metadata from the DOM (no mutations) // --------------------------------------------------------------------------- @@ -128,33 +124,18 @@ function gatherCells(): CellInfo[] { cellDiv.querySelector(".sourceCode.cell-code"); if (!codeScaffold) continue; - const outputDiv = cellDiv.querySelector( + const visContainer = cellDiv.querySelector( ".cell-output.cell-output-display" ); - let visId: string | null = null; - let visContainer: HTMLElement | null = null; - - if (outputDiv) { - const visCandidates = outputDiv.querySelectorAll( - 'div[id^="vis-"]' - ); - const match = Array.from(visCandidates).find((el) => - /^vis-\d+$/.test(el.id) - ); - if (match) { - visContainer = match; - visId = match.id; - } - } cells.push({ query, rewrittenQuery: rewriteCsvRefs(query), cellDiv, codeScaffold, - visId, visContainer, - result: null, + view: null, + plot: null, succeeded: false, error: null, editor: null, @@ -214,10 +195,10 @@ async function initAndExecute( await installRequestedExtensions(ctx, cell.query); try { if (ctx.hasVisual(cell.rewrittenQuery)) { - cell.result = ctx.execute(cell.rewrittenQuery); + cell.plot = ctx.execute(cell.rewrittenQuery); } else { ctx.executeSql(cell.rewrittenQuery); - cell.result = null; + cell.plot = null; } cell.succeeded = true; } catch (e: any) { @@ -243,6 +224,45 @@ async function initAndExecute( const DEBOUNCE_MS = 100; +// Fallback aspect for an output box that has no measurable one — an image the +// browser has not laid out yet. Matches Quarto's own default figure shape. +const DEFAULT_ASPECT = 7 / 5; + +/** + * Draw a cell's plot into its output box, creating the view on first use. + * + * The view is kept for the life of the cell: it owns the `ResizeObserver`, and + * an editor re-running on every keystroke would otherwise build one per edit. + * Nothing touches the box until there is a plot for it. + */ +function showPlot(cell: CellInfo): void { + if (!cell.plot) return; + // Nowhere to draw it, but the plot still has to be released — nothing else + // holds a reference, so a cell with no output box would leak a result set + // per keystroke. + if (!cell.visContainer) { + cell.plot.free(); + cell.plot = null; + return; + } + if (!cell.view) { + // Measured before the box is emptied, so the inline SVG keeps the shape + // the page already reserved and nothing shifts under the reader. + const rect = cell.visContainer.getBoundingClientRect(); + const aspect = + rect.width > 0 && rect.height > 0 ? rect.width / rect.height : DEFAULT_ASPECT; + cell.view = new PlotView(cell.visContainer, { + idPrefix: `ggsql-cell-${cellCounter++}-`, + aspect, + }); + } + cell.view.setPlot(cell.plot); + // The view owns it now, and freeing it twice would be an error. + cell.plot = null; +} + +let cellCounter = 0; + async function applyEditors( cells: CellInfo[], ctx: WasmContextManager @@ -276,14 +296,12 @@ async function applyEditors( const editorInst = await createEditor(editorContainer, cell.query, SITE_ROOT); cell.editor = editorInst; - if (cell.result && cell.visId && cell.visContainer) { - try { - const spec = JSON.parse(cell.result); - cell.visContainer.innerHTML = ""; - await vegaEmbed("#" + cell.visId, spec, VEGA_EMBED_OPTS); - } catch (e) { - console.warn("[ggsql-quarto] vegaEmbed failed for", cell.visId, e); - } + // The first draw, and the first thing that can panic in the renderer, so + // one bad plot must not stop the page wiring up the rest. + try { + showPlot(cell); + } catch (e) { + showError(cell, String(ctx.noteError(e))); } // Re-execute on every edit, debounced @@ -347,18 +365,15 @@ async function executeCell( try { if (ctx.hasVisual(currentQuery)) { - const result = ctx.execute(currentQuery); - const spec = JSON.parse(result); - - if (cell.visContainer && cell.visId) { - cell.visContainer.innerHTML = ""; - await vegaEmbed("#" + cell.visId, spec, VEGA_EMBED_OPTS); - } + cell.plot = ctx.execute(currentQuery); + showPlot(cell); } else { ctx.executeSql(currentQuery); } } catch (e: any) { - showError(cell, String(e)); + // Through `noteError` because drawing bypasses the manager: a composition + // panic escapes `showPlot` and kills the module for the whole page. + showError(cell, String(ctx.noteError(e))); } } diff --git a/ggsql-wasm/demo/src/styles.css b/ggsql-wasm/demo/src/styles.css index 7638e9de9..ec16051e5 100644 --- a/ggsql-wasm/demo/src/styles.css +++ b/ggsql-wasm/demo/src/styles.css @@ -187,15 +187,6 @@ body { justify-content: center; } -#viz-output .vega-embed { - width: 100%; -} - -#viz-output .chart-wrapper { - min-width: 400px; - min-height: 400px; -} - #viz-output table.ggsql-table { border-collapse: separate; border-spacing: 0; diff --git a/ggsql-wasm/fonts/OFL-Roboto.txt b/ggsql-wasm/fonts/OFL-Roboto.txt new file mode 100644 index 000000000..9c48e05a2 --- /dev/null +++ b/ggsql-wasm/fonts/OFL-Roboto.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ggsql-wasm/fonts/roboto-bold.ttf b/ggsql-wasm/fonts/roboto-bold.ttf new file mode 100644 index 000000000..524dd8167 Binary files /dev/null and b/ggsql-wasm/fonts/roboto-bold.ttf differ diff --git a/ggsql-wasm/fonts/roboto-bolditalic.ttf b/ggsql-wasm/fonts/roboto-bolditalic.ttf new file mode 100644 index 000000000..c84c506a3 Binary files /dev/null and b/ggsql-wasm/fonts/roboto-bolditalic.ttf differ diff --git a/ggsql-wasm/fonts/roboto-italic.ttf b/ggsql-wasm/fonts/roboto-italic.ttf new file mode 100644 index 000000000..45e204fe7 Binary files /dev/null and b/ggsql-wasm/fonts/roboto-italic.ttf differ diff --git a/ggsql-wasm/fonts/roboto-regular.ttf b/ggsql-wasm/fonts/roboto-regular.ttf new file mode 100644 index 000000000..e7332d092 Binary files /dev/null and b/ggsql-wasm/fonts/roboto-regular.ttf differ diff --git a/ggsql-wasm/library/package.json b/ggsql-wasm/library/package.json deleted file mode 100644 index 884cb3d02..000000000 --- a/ggsql-wasm/library/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "ggsql-wasm-lib", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "dist/lib.js", - "scripts": { - "build": "node build.mjs", - "dev": "node build.mjs --watch", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "hyparquet": "^1.25.0" - }, - "devDependencies": { - "esbuild": "^0.27.0", - "typescript": "^5.9.0" - } -} diff --git a/ggsql-wasm/LICENSE b/ggsql-wasm/pkg/LICENSE similarity index 100% rename from ggsql-wasm/LICENSE rename to ggsql-wasm/pkg/LICENSE diff --git a/ggsql-wasm/library/build.mjs b/ggsql-wasm/pkg/build.mjs similarity index 50% rename from ggsql-wasm/library/build.mjs rename to ggsql-wasm/pkg/build.mjs index 77df5bcd6..4c2b660cf 100644 --- a/ggsql-wasm/library/build.mjs +++ b/ggsql-wasm/pkg/build.mjs @@ -1,27 +1,36 @@ import * as esbuild from "esbuild"; +import { copyFileSync, mkdirSync, readdirSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const isWatch = process.argv.includes("--watch"); +const distDir = join(__dirname, "dist"); +const fontsDir = join(distDir, "fonts"); + +mkdirSync(fontsDir, { recursive: true }); +for (const file of readdirSync(join(__dirname, "../fonts"))) { + copyFileSync(join(__dirname, "../fonts", file), join(fontsDir, file)); +} const buildOptions = { - entryPoints: [join(__dirname, "src/index.ts")], + entryPoints: [join(__dirname, "src/ggsql.ts")], bundle: true, - outfile: join(__dirname, "dist/lib.js"), + outfile: join(distDir, "ggsql.js"), format: "esm", platform: "browser", target: "es2022", sourcemap: true, + external: ["./ggsql_wasm.js"], }; if (isWatch) { - console.log("Starting watch mode..."); + console.log("Starting package watch mode..."); const ctx = await esbuild.context(buildOptions); await ctx.watch(); console.log("Watching for changes..."); } else { - console.log("Building library..."); + console.log("Building package client..."); await esbuild.build(buildOptions); console.log("Build complete!"); } diff --git a/ggsql-wasm/library/package-lock.json b/ggsql-wasm/pkg/package-lock.json similarity index 98% rename from ggsql-wasm/library/package-lock.json rename to ggsql-wasm/pkg/package-lock.json index e1fd2798c..5b598fcc4 100644 --- a/ggsql-wasm/library/package-lock.json +++ b/ggsql-wasm/pkg/package-lock.json @@ -1,17 +1,16 @@ { - "name": "ggsql-wasm-lib", - "version": "0.0.0", + "name": "ggsql-wasm", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ggsql-wasm-lib", - "version": "0.0.0", - "dependencies": { - "hyparquet": "^1.25.0" - }, + "name": "ggsql-wasm", + "version": "0.4.1", + "license": "MIT", "devDependencies": { "esbuild": "^0.27.0", + "hyparquet": "^1.25.0", "typescript": "^5.9.0" } }, @@ -503,6 +502,7 @@ "version": "1.25.1", "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.25.1.tgz", "integrity": "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==", + "dev": true, "license": "MIT" }, "node_modules/typescript": { diff --git a/ggsql-wasm/pkg/package.json b/ggsql-wasm/pkg/package.json new file mode 100644 index 000000000..d1546a39b --- /dev/null +++ b/ggsql-wasm/pkg/package.json @@ -0,0 +1,32 @@ +{ + "name": "ggsql-wasm", + "version": "0.4.1", + "description": "WebAssembly bindings for ggsql.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/posit-dev/ggsql" + }, + "homepage": "https://ggsql.org", + "type": "module", + "main": "dist/ggsql.js", + "module": "dist/ggsql.js", + "types": "dist/ggsql.d.ts", + "exports": { + ".": "./dist/ggsql.js", + "./fonts/*": "./dist/fonts/*" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "node build.mjs && tsc --emitDeclarationOnly", + "dev": "node build.mjs --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "esbuild": "^0.27.0", + "hyparquet": "^1.25.0", + "typescript": "^5.9.0" + } +} diff --git a/ggsql-wasm/library/src/index.ts b/ggsql-wasm/pkg/src/columns.ts similarity index 70% rename from ggsql-wasm/library/src/index.ts rename to ggsql-wasm/pkg/src/columns.ts index a6de6a9fa..b52d4af89 100644 --- a/ggsql-wasm/library/src/index.ts +++ b/ggsql-wasm/pkg/src/columns.ts @@ -1,10 +1,3 @@ -// Converters -export { convert_csv } from "./csv"; -export { convert_parquet } from "./parquet"; - -// Extension loading -export { initExtensionLoader, installExtension } from "./extensions"; - // Types export interface ColumnDescriptor { name: string; diff --git a/ggsql-wasm/library/src/csv.ts b/ggsql-wasm/pkg/src/csv.ts similarity index 98% rename from ggsql-wasm/library/src/csv.ts rename to ggsql-wasm/pkg/src/csv.ts index 68162075b..e05e3a45a 100644 --- a/ggsql-wasm/library/src/csv.ts +++ b/ggsql-wasm/pkg/src/csv.ts @@ -1,4 +1,4 @@ -import type { ColumnDescriptor, ColumnType } from "./index"; +import type { ColumnDescriptor, ColumnType } from "./columns"; /** * Convert CSV bytes to column descriptors. diff --git a/ggsql-wasm/library/src/extensions.ts b/ggsql-wasm/pkg/src/extensions.ts similarity index 99% rename from ggsql-wasm/library/src/extensions.ts rename to ggsql-wasm/pkg/src/extensions.ts index 3e01e53c3..a575fc1fe 100644 --- a/ggsql-wasm/library/src/extensions.ts +++ b/ggsql-wasm/pkg/src/extensions.ts @@ -75,7 +75,7 @@ export async function installExtension( wasmSource: BufferSource | Response | string, ): Promise { if (!sharedMemory || !sharedTable || !hostExports) { - throw new Error("Call initExtensionLoader() before installExtension()"); + throw new Error("Call the ggsql-wasm init() function before installExtension()"); } if (registry.has(name)) { diff --git a/ggsql-wasm/pkg/src/ggsql.ts b/ggsql-wasm/pkg/src/ggsql.ts new file mode 100644 index 000000000..f29e967e9 --- /dev/null +++ b/ggsql-wasm/pkg/src/ggsql.ts @@ -0,0 +1,373 @@ +// The ggsql wasm client. +// +// The Rust side draws a plot to SVG markup and stops there. Everything +// browser-shaped — measuring a container, observing resizes, fetching fonts, +// putting markup in the page — lives here, because wasm bytes are expensive +// and JavaScript is not. + +import glueInit, { + initSync as glueInitSync, + registerFont, + setConverters, + setGenericFamily, +} from "./ggsql_wasm.js"; +import type { + GgsqlPlot, + InitInput, + InitOutput, + SyncInitInput, +} from "./ggsql_wasm.js"; +import { convert_csv } from "./csv"; +import { convert_parquet } from "./parquet"; +import { initExtensionLoader } from "./extensions"; + +export * from "./ggsql_wasm.js"; +export { convert_csv } from "./csv"; +export { convert_parquet } from "./parquet"; +export { installExtension } from "./extensions"; +export type { ColumnDescriptor, ColumnType } from "./columns"; + +type AsyncInitInput = + | { module_or_path: InitInput | Promise } + | InitInput + | Promise; + +function isAsyncInitOptions( + input: AsyncInitInput, +): input is { module_or_path: InitInput | Promise } { + return ( + typeof input === "object" && + input !== null && + Object.getPrototypeOf(input) === Object.prototype && + "module_or_path" in input + ); +} + +/** + * Instantiate ggsql and connect the package's converters and extension loader. + */ +export default async function init(input?: AsyncInitInput): Promise { + const output = + input === undefined + ? await glueInit() + : await glueInit( + isAsyncInitOptions(input) ? input : { module_or_path: input }, + ); + setConverters(convert_csv, convert_parquet); + initExtensionLoader(output as unknown as WebAssembly.Exports); + return output; +} + +/** + * Synchronously instantiate ggsql and connect the package helpers. + */ +export function initSync( + input: { module: SyncInitInput } | SyncInitInput, +): InitOutput { + const options = + typeof input === "object" && + input !== null && + Object.getPrototypeOf(input) === Object.prototype && + "module" in input + ? input + : { module: input }; + const output = glueInitSync(options); + setConverters(convert_csv, convert_parquet); + initExtensionLoader(output as unknown as WebAssembly.Exports); + return output; +} + +interface BundledFace { + file: string; + weight: number; + style: string; +} + +// The faces shipped with the package, one file per (weight, style). +// +// One file per weight and style is a rule: the shaper selects within a family +// by weight, width and style and knows nothing of `unicode-range`, so two +// subset files sharing a family name let the wrong one win the attribute match. +const FACES: BundledFace[] = [ + { file: "roboto-regular.ttf", weight: 400, style: "normal" }, + { file: "roboto-bold.ttf", weight: 700, style: "normal" }, + { file: "roboto-italic.ttf", weight: 400, style: "italic" }, + { file: "roboto-bolditalic.ttf", weight: 700, style: "italic" }, +]; + +const DEFAULT_FAMILY = "Roboto"; + +let fontsPromise: Promise | null = null; + +// What each generic has been pointed at, so a drawn plot can be told which +// concrete family its theme's generic resolves to. Mirrors the font context's +// own state, which is not readable back out of it. +const genericFamilies = new Map(); + +// The generics a plot's theme can name. Only these get redirected at the faces +// this module registered; a theme naming a family outright asked for it. +const GENERICS = new Set([ + "sans-serif", + "serif", + "monospace", + "cursive", + "fantasy", + "system-ui", +]); + +/** + * Register the bundled faces, and tell the browser about them too. + * + * Both halves are needed. The shaper measures every string to lay the plot out, + * so without the faces a plot has no text and the wrong margins. The browser + * needs them because the SVG places each run with one anchor plus `textLength`, + * and a face other than the measured one gets squeezed into the measured box. + * + * Process-global and permanent, so this is once per page. Safe to call + * repeatedly; the work happens once. + */ +export function registerDefaultFonts(baseUrl?: string): Promise { + if (fontsPromise) return fontsPromise; + // Resolved against the document first, since a URL base has to be absolute + // and a caller naturally passes a relative path. The trailing slash matters: + // to `new URL`, `/assets` names a file, not a directory. + const base = baseUrl + ? new URL( + baseUrl.replace(/\/?$/, "/"), + typeof document !== "undefined" ? document.baseURI : import.meta.url, + ).href + : new URL("./fonts/", import.meta.url).href; + + const attempt = (async () => { + const families = new Set(); + for (const face of FACES) { + const url = new URL(face.file, base).href; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`could not fetch ${url}: ${response.status}`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + for (const family of registerFont(bytes)) families.add(family); + injectFontFace(face, url); + } + // A generic is an indirection through the font context rather than a name, + // so registering Roboto does not on its own make `sans-serif` mean Roboto. + const names = [...families]; + if (names.length) pointGenericAt("sans-serif", names); + return names; + })(); + + // Only success is memoised: a cached failure would make one offline moment + // permanent and leave every later plot textless. Registration is idempotent, + // so retrying after a partial attempt costs nothing. + fontsPromise = attempt.catch((error: unknown) => { + fontsPromise = null; + throw error; + }); + return fontsPromise; +} + +/** Point a generic at concrete families, remembering it for the SVG fixup. */ +function pointGenericAt(kind: string, families: string[]): void { + setGenericFamily(kind, families); + genericFamilies.set(kind, families); +} + +export interface RegisterFontOptions { + /** + * Generic family to point at the registered face — `sans-serif`, `serif`, + * `monospace`, `cursive`, `fantasy` or `system-ui`. + */ + genericFor?: string; +} + +/** + * Register a font from a URL, and optionally make a generic mean it. + * + * WOFF and WOFF2 are accepted, so a font CDN's URL works directly. + * + * Process-global, permanent, and must precede the first draw — a plot shaped + * without a font has no text and the wrong layout. + */ +export async function registerFontFromUrl( + url: string, + opts: RegisterFontOptions = {}, +): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`could not fetch the font at ${url}: ${response.status}`); + } + const families = registerFont(new Uint8Array(await response.arrayBuffer())); + if (opts.genericFor) pointGenericAt(opts.genericFor, families); + return families; +} + +/** Give the browser the same face the shaper just measured from. */ +function injectFontFace(face: BundledFace, url: string): void { + if (typeof document === "undefined") return; + const id = `ggsql-font-${face.file}`; + if (document.getElementById(id)) return; + const style = document.createElement("style"); + style.id = id; + style.textContent = + `@font-face{font-family:'${DEFAULT_FAMILY}';` + + `src:url('${url}') format('truetype');` + + `font-weight:${face.weight};font-style:${face.style};font-display:block}`; + document.head.appendChild(style); +} + +/** + * Point the drawn SVG at the face its advances were measured from. + */ +function nameRegisteredFamily(root: Element | null): void { + if (!root || root.tagName.toLowerCase() !== "svg") return; + const current = root.getAttribute("font-family"); + // Only a generic is ambiguous. A theme that named a family outright asked + // for it, and the browser can resolve that name as well as we can. + if (!current || !GENERICS.has(current)) return; + const families = genericFamilies.get(current); + if (!families?.length) return; + const named = families.map((family) => `'${family}'`).join(", "); + root.setAttribute("font-family", `${named}, ${current}`); +} + +/** + * The container's content box, for the first draw — before the observer has + * reported one. + */ +function contentBox(element: HTMLElement): [number, number] { + const style = getComputedStyle(element); + const x = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + const y = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); + return [element.clientWidth - (x || 0), element.clientHeight - (y || 0)]; +} + +let nextViewId = 0; + +export interface PlotViewOptions { + /** Namespace for generated element ids. Defaults to a per-view counter. */ + idPrefix?: string; + /** Width divided by height; height follows the container width when set. */ + aspect?: number; +} + +/** + * One plot bound to one container element. + * + * Redraws on resize rather than scaling: the layout is re-solved at the new + * size, so a wider box gets more tick labels instead of stretched ones. + */ +export class PlotView { + private readonly container: HTMLElement; + private readonly aspect: number | null; + private readonly idPrefix: string; + private plot: GgsqlPlot | null = null; + private _warnings: string[] = []; + private frame: number | null = null; + private lastSize: [number, number] | null = null; + private freed = false; + private box: [number, number] | null = null; + private readonly observer: ResizeObserver; + + constructor(container: HTMLElement, opts: PlotViewOptions = {}) { + this.container = container; + // Without this the height comes from the container, which is fine when CSS + // gives it one. A container sized by its content instead feeds back on + // itself and collapses; deriving height from width breaks that loop. + this.aspect = opts.aspect && opts.aspect > 0 ? opts.aspect : null; + // Inline SVGs share the page's id space, so two plots on one page collide + // on gradient and clip-path ids without this. + this.idPrefix = opts.idPrefix || `ggsql-${nextViewId++}-`; + + // `contentRect` is the content box; `clientHeight` includes padding, so + // each draw would otherwise be taller than its space. + this.observer = new ResizeObserver((entries) => { + const rect = entries[entries.length - 1]?.contentRect; + if (rect) this.box = [rect.width, rect.height]; + this.schedule(); + }); + this.observer.observe(this.container); + } + + /** Whatever the renderer had to degrade or drop on the last draw. */ + get warnings(): string[] { + return this._warnings; + } + + /** + * Show a plot, or clear the view when given `null`. + * + * Takes ownership: the previous plot is freed, and so is this one if the + * view has already been freed. + */ + setPlot(plot: GgsqlPlot | null): void { + if (this.freed) { + plot?.free(); + return; + } + if (this.plot && this.plot !== plot) this.plot.free(); + this.plot = plot; + this.lastSize = null; + this._warnings = []; + if (!plot) { + this.container.replaceChildren(); + return; + } + this.renderNow(); + } + + /** Redraw at the container's current size. */ + redraw(): void { + this.lastSize = null; + this.renderNow(); + } + + private schedule(): void { + if (this.freed || !this.plot || this.frame !== null) return; + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.renderNow(); + }); + } + + private renderNow(): void { + if (this.freed || !this.plot) return; + const [boxWidth, boxHeight] = this.box || contentBox(this.container); + const width = Math.round(boxWidth); + const height = this.aspect + ? Math.round(width / this.aspect) + : Math.round(boxHeight); + if (width < 1 || height < 1) return; + if ( + this.lastSize && + this.lastSize[0] === width && + this.lastSize[1] === height + ) { + return; + } + + const render = this.plot.toSvg(width, height, this.idPrefix); + try { + this._warnings = render.warnings; + this.container.innerHTML = render.svg; + const root = this.container.firstElementChild; + if (root instanceof HTMLElement || root instanceof SVGElement) { + root.style.display = "block"; + } + nameRegisteredFamily(root); + this.lastSize = [width, height]; + } finally { + render.free(); + } + } + + /** Stop observing and release the plot. */ + free(): void { + if (this.freed) return; + this.freed = true; + if (this.frame !== null) cancelAnimationFrame(this.frame); + this.observer.disconnect(); + this.plot?.free(); + this.plot = null; + } +} diff --git a/ggsql-wasm/library/src/parquet.ts b/ggsql-wasm/pkg/src/parquet.ts similarity index 97% rename from ggsql-wasm/library/src/parquet.ts rename to ggsql-wasm/pkg/src/parquet.ts index f09763961..c542d0f7a 100644 --- a/ggsql-wasm/library/src/parquet.ts +++ b/ggsql-wasm/pkg/src/parquet.ts @@ -1,5 +1,5 @@ -import type { ColumnDescriptor, ColumnType } from "./index"; -import { EPOCH, MS_PER_DAY } from "./index"; +import type { ColumnDescriptor, ColumnType } from "./columns"; +import { EPOCH, MS_PER_DAY } from "./columns"; import { parquetReadObjects } from "hyparquet"; /** diff --git a/ggsql-wasm/library/tsconfig.json b/ggsql-wasm/pkg/tsconfig.json similarity index 87% rename from ggsql-wasm/library/tsconfig.json rename to ggsql-wasm/pkg/tsconfig.json index b0842e053..c2bf79065 100644 --- a/ggsql-wasm/library/tsconfig.json +++ b/ggsql-wasm/pkg/tsconfig.json @@ -11,7 +11,8 @@ "declaration": true, "sourceMap": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "rootDirs": ["./src", "./dist"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/ggsql-wasm/rust-toolchain.toml b/ggsql-wasm/rust-toolchain.toml index af1e3482a..6a0952edb 100644 --- a/ggsql-wasm/rust-toolchain.toml +++ b/ggsql-wasm/rust-toolchain.toml @@ -1,7 +1,7 @@ # The wasm bindings are exempt from the workspace's 1.86 MSRV pin (R/CRAN, see # /rust-toolchain.toml): R doesn't use wasm, and some wasm-only dependencies -# require a newer rustc. Building from this directory (./build-wasm.sh, -# wasm-pack, library/) picks up this file and uses stable instead. +# require a newer rustc. Building from this directory (./build-wasm.sh, cargo, +# or pkg/) picks up this file and uses stable instead. [toolchain] channel = "stable" targets = ["wasm32-unknown-unknown"] diff --git a/ggsql-wasm/src/lib.rs b/ggsql-wasm/src/lib.rs index 6c7c16c4e..28ef908c5 100644 --- a/ggsql-wasm/src/lib.rs +++ b/ggsql-wasm/src/lib.rs @@ -6,8 +6,9 @@ use ggsql::array_util::value_to_string; use ggsql::naming::DATA_PREFIX; use ggsql::reader::sqlite::SqliteReader; use ggsql::reader::Reader; +use ggsql::reader::Spec; use ggsql::validate::validate; -use ggsql::writer::{VegaLiteWriter, Writer}; +use ggsql::writer::{rgba, SvgWriter}; use ggsql::DataFrame; use serde_json::json; use std::cell::RefCell; @@ -15,48 +16,59 @@ use std::sync::Arc; use wasm_bindgen::prelude::*; +/// Report a panic to the console before the module aborts. +/// +/// Composition asserts in a handful of documented cases and the `wasm` profile +/// sets `panic = "abort"`, so one bad plot traps the instance for the whole +/// page. The hook turns a bare `RuntimeError: unreachable executed` into a +/// console message naming the assertion. Runs on module instantiation. +#[wasm_bindgen(start)] +pub fn start() { + console_error_panic_hook::set_once(); +} + // ============================================================================ // JS bridge declarations // ============================================================================ -#[wasm_bindgen(module = "/library/dist/lib.js")] -extern "C" { - #[wasm_bindgen(catch, js_name = convert_parquet)] - async fn convert_parquet_js(data: &[u8]) -> Result; - - #[wasm_bindgen(catch, js_name = convert_csv)] - fn convert_csv_js(data: &[u8]) -> Result; - - #[wasm_bindgen(catch, js_name = initExtensionLoader)] - fn init_extension_loader_js(exports: &JsValue) -> Result<(), JsValue>; - - #[wasm_bindgen(catch, js_name = installExtension)] - async fn install_extension_js(name: &str, source: JsValue) -> Result; +thread_local! { + static CONVERTERS: RefCell> = + const { RefCell::new(None) }; } -// ============================================================================ -// Package exports — forward to the JS helpers above -// ============================================================================ - -#[wasm_bindgen(js_name = convert_csv)] -pub fn convert_csv_export(data: &[u8]) -> Result { - convert_csv_js(data) +/// Supply the JavaScript CSV and Parquet converters. +/// +/// Called by the npm package's `init` wrapper; not intended for direct use. +#[wasm_bindgen(js_name = setConverters)] +pub fn set_converters(csv: js_sys::Function, parquet: js_sys::Function) { + CONVERTERS.with(|converters| { + *converters.borrow_mut() = Some((csv, parquet)); + }); } -#[wasm_bindgen(js_name = convert_parquet)] -pub async fn convert_parquet_export(data: &[u8]) -> Result { - convert_parquet_js(data).await +fn converters() -> Result<(js_sys::Function, js_sys::Function), JsValue> { + CONVERTERS.with(|converters| { + converters.borrow().clone().ok_or_else(|| { + JsValue::from_str( + "CSV and Parquet converters are not configured; initialize through ggsql-wasm", + ) + }) + }) } -#[wasm_bindgen(js_name = initExtensionLoader)] -pub fn init_extension_loader(exports: JsValue) -> Result<(), JsValue> { - init_extension_loader_js(&exports) +fn convert_csv_js(data: &[u8]) -> Result { + let (convert_csv, _) = converters()?; + let bytes = js_sys::Uint8Array::from(data); + convert_csv.call1(&JsValue::UNDEFINED, &bytes) } -#[wasm_bindgen(js_name = installExtension)] -pub async fn install_extension(name: String, source: JsValue) -> Result<(), JsValue> { - install_extension_js(&name, source).await?; - Ok(()) +async fn convert_parquet_js(data: &[u8]) -> Result { + let (_, convert_parquet) = converters()?; + let bytes = js_sys::Uint8Array::from(data); + let promise = convert_parquet + .call1(&JsValue::UNDEFINED, &bytes)? + .dyn_into::()?; + wasm_bindgen_futures::JsFuture::from(promise).await } // ============================================================================ @@ -216,7 +228,6 @@ fn columns_js_to_dataframe(columns_js: JsValue) -> Result { #[wasm_bindgen] pub struct GgsqlContext { reader: RefCell, - writer: VegaLiteWriter, } #[wasm_bindgen] @@ -228,28 +239,21 @@ impl GgsqlContext { let reader = SqliteReader::new() .map_err(|e| JsValue::from_str(&format!("Failed to create SQLite reader: {:?}", e)))?; - let writer = VegaLiteWriter::new(); Ok(GgsqlContext { reader: RefCell::new(reader), - writer, }) } - /// Execute a ggsql query and return Vega-Lite JSON - pub fn execute(&self, query: &str) -> Result { - let spec = { - let reader = self.reader.borrow(); - reader - .execute(query) - .map_err(|e| JsValue::from_str(&format!("Execute error: {:?}", e)))? - }; - - let result = self - .writer - .render(&spec) - .map_err(|e| JsValue::from_str(&format!("Render error: {:?}", e)))?; - - Ok(result) + /// Run a ggsql query and keep the resolved plot, ready to draw. + /// + /// Drawing is separate because a resize re-solves the layout, and doing + /// that through the query would put SQL behind every frame of a drag. + pub fn execute(&self, query: &str) -> Result { + let reader = self.reader.borrow(); + let spec = reader + .execute(query) + .map_err(|e| JsValue::from_str(&format!("Execute error: {:?}", e)))?; + Ok(GgsqlPlot { spec }) } /// Check whether a query contains a VISUALISE clause @@ -383,3 +387,98 @@ impl GgsqlContext { array.into() } } + +// ============================================================================ +// Drawing +// ============================================================================ + +/// A resolved plot, ready to be drawn at whatever size the page has. +/// +/// Held across redraws so a resize costs a layout pass and not a database +/// query — see [`GgsqlContext::execute`]. +#[wasm_bindgen] +pub struct GgsqlPlot { + spec: Spec, +} + +#[wasm_bindgen] +impl GgsqlPlot { + /// Draw the plot as SVG at the given size in CSS pixels. + /// + /// The layout is re-solved at this size rather than scaled to it, so a wider + /// box gets more tick labels rather than stretched ones — which is why a + /// resize calls this again instead of setting a `viewBox`. + /// + /// `id_prefix` namespaces every generated element id: inline SVGs share the + /// page's id space, so two plots on one page collide without it. + /// + /// The background is left transparent so the page's own shows through. + #[wasm_bindgen(js_name = toSvg)] + pub fn to_svg(&self, width: u32, height: u32, id_prefix: &str) -> Result { + // 96 dpi: the caller measured its box in CSS pixels, and an SVG scales + // for a retina screen by itself. + let writer = SvgWriter::new(width.max(1), height.max(1), 96.0) + .background(rgba(0.0, 0.0, 0.0, 0.0)) + .id_prefix(id_prefix); + let (svg, warnings) = writer + .render_reporting(&self.spec) + .map_err(|e| JsValue::from_str(&format!("Render error: {:?}", e)))?; + Ok(SvgRender { svg, warnings }) + } +} + +/// One drawn plot, plus whatever the format could not express. +#[wasm_bindgen] +pub struct SvgRender { + svg: String, + warnings: Vec, +} + +#[wasm_bindgen] +impl SvgRender { + /// The SVG markup. + #[wasm_bindgen(getter)] + pub fn svg(&self) -> String { + self.svg.clone() + } + + /// What the renderer had to degrade or drop, if anything. + #[wasm_bindgen(getter)] + pub fn warnings(&self) -> Vec { + self.warnings.clone() + } +} + +// ============================================================================ +// Fonts +// ============================================================================ + +/// Register every font face in `bytes`, returning the family names they landed +/// under. +/// +/// A page must call this before drawing anything: a browser enumerates no system +/// fonts, so the shaper starts empty and a plot comes out with no text and no +/// warning — and with wrong margins too, since text sets the layout. +/// +/// Takes sfnt bytes (TTF, OTF, TTC, OTC); a WOFF or WOFF2 file has to be decoded +/// first. The returned names are what [`set_generic_family`] takes — registering +/// a face does not on its own make `sans-serif` mean it. +#[wasm_bindgen(js_name = registerFont)] +pub fn register_font(bytes: Vec) -> Result, JsValue> { + ggsql::fonts::register_font(bytes).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Whether any font family is available to shape with. +/// +/// `false` means the next plot drawn will have no text in it. +#[wasm_bindgen(js_name = hasFonts)] +pub fn has_fonts() -> bool { + !ggsql::fonts::registered_font_families().is_empty() +} + +/// Point a generic family — `sans-serif`, `serif`, `monospace`, … — at concrete +/// families, in preference order. +#[wasm_bindgen(js_name = setGenericFamily)] +pub fn set_generic_family(kind: &str, families: Vec) -> Result<(), JsValue> { + ggsql::fonts::set_generic_family(kind, &families).map_err(|e| JsValue::from_str(&e.to_string())) +} diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 06d8f93e9..35d2ace40 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -19,6 +19,7 @@ src/ ├── naming.rs Internal column-name conventions (__ggsql_*) ├── util.rs String helpers (and_list, or_list, …) ├── validate.rs validate(): syntax + semantic checks without SQL execution +├── fonts.rs Font registration, for hosts with no font database │ ├── parser/ Tree-sitter integration → typed AST (Plot) ├── plot/ AST: Plot, Layer, Geom, Scale, Facet, Projection, Mappings (see plot/CLAUDE.md) @@ -70,12 +71,34 @@ The pipeline that takes a parsed `Plot` plus a `Reader` and produces a fully-res ### `writer/` -`Writer` trait in `mod.rs` (associated `Output` type so writers can return text or bytes, and `from_options` for configuration a frontend collects as key–value pairs — `options.rs`'s `WriterOptions`, parsed from the CLI's `--writer-option`). Two implementations: +`Writer` trait in `mod.rs` (associated `Output` type so writers can return text or bytes, and `from_options` for configuration a frontend collects as key–value pairs — `options.rs`'s `WriterOptions`, parsed from the CLI's `--writer-option`). Two families: - **Vega-Lite** (`vegalite` feature, default) — emits Vega-Lite JSON. Deep-dive: [`writer/vegalite/CLAUDE.md`](writer/vegalite/CLAUDE.md). -- **PNG** (`png` feature, non-default) — `PngWriter` renders PNG bytes via a GPU (wgpu/vello) backend. The module implementing it is `writer/hephaestus/`, after the renderer it wraps; that name is internal, and the module is private so only `PngWriter` is public. Deep-dive (architecture + known gaps): [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md). Excluded from the MSRV 1.86 build (hephaestus needs 1.88) and needs a GPU adapter at render time. +- **The renderer-backed writers** (seven of them; `svg`, `pdf` and `hep` default, the four raster ones not) — all live in `writer/hephaestus/`, named after the renderer they wrap; that name is internal, and the module is private so only the writers, `Canvas` and `RasterRenderer` are public. They share their whole pipeline — `Canvas` for configuration, `compose` for the plot composition, then either `raster` for pixels or `vector` for drawing commands — and differ only in what they do with the result. Deep-dive (architecture + known gaps): [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md). -`ggplot2` and `plotters` are reserved feature flags with no implementation. + | Feature | Default | Writer | Output | GPU | + | --- | --- | --- | --- | --- | + | `png` / `jpeg` / `tiff` / `webp` | — | `PngWriter`, `JpegWriter`, `TiffWriter`, `WebpWriter` | image bytes | required | + | `svg` / `pdf` | ✓ | `SvgWriter`, `PdfWriter` | vector text / one PDF page | **none** | + | `hep` | ✓ | `HepWriter` | a `.hep` plot document — no picture | **none** | + + Plus `PlotViewer` behind the `window` feature — not a writer, since it returns no output, blocks, and must run on the main thread. It shows the same composition in a native window, re-laying-out on resize. + + The three GPU-free writers go through the same composition and the same `render` call (which takes `&mut dyn SceneBuilder`), so they need no adapter and pull in no wgpu. **That is why they are default**: nothing about them has to be opted into, including on Linux, where `fontconfig-dlopen` removes the build-time `libfontconfig1-dev` requirement (see [`writer/hephaestus/CLAUDE.md`](writer/hephaestus/CLAUDE.md)). They also still compile on the CRAN MSRV — `cargo +1.86 check --ignore-rust-version -p ggsql`, where the flag is needed only because `parley` *declares* 1.88 while compiling fine on 1.86. Only the raster writers need an adapter and are genuinely 1.88+, and a raster dimension is capped at what the GPU grants, up to 16384 px. + +Three **internal** features carry the split, enabled by the writer features rather than named directly: `graphics` is the shared composition layer, and `raster = graphics + hephaestus/vello-hybrid` adds the GPU rasteriser. Only `raster` pulls in wgpu, vello_hybrid and pollster, which is what lets a vector-only build skip them — `cargo tree --features graphics` shows none of the three, `--features png` shows 19. `raster-writer` then narrows `raster` once more, to "some writer actually reads pixels back" — the viewer needs the rasteriser without ever doing that. `graphics` is the single module gate for `writer/hephaestus/`, so adding a format needs no change there. + +### `fonts.rs` + +Registering font faces with the shaper, behind `graphics`. Natively the operating +system enumerates fonts and nothing here is needed; a browser enumerates none, so +a wasm host has to hand the faces over itself or every plot comes out with no +text and — since text is what sets the margins — the wrong layout too. + +`register_font` takes sfnt bytes, and with the optional `webfonts` feature the +WOFF and WOFF2 containers a font CDN serves a browser as well. Without it those +are refused by name, because compressed bytes hold no recognisable face and +registering nothing is a silent failure. ### `plot/` @@ -108,11 +131,23 @@ Defined in `Cargo.toml`: | `parquet` | ✓ | Parquet support in readers/data | | `spatial` | ✓ | Spatial/geometry support (geozero for WKT↔GeoJSON) | | `vegalite` | ✓ | Vega-Lite writer | -| `png` | — | PNG raster writer (GPU; excluded from the MSRV build) | +| `graphics` | — | *Internal.* The shared plot-composition layer; no GPU | +| `raster` | — | *Internal.* `graphics` + the GPU rasteriser (wgpu/vello-hybrid) | +| `png` | — | PNG writer (`raster`; genuinely 1.88+, excluded from the MSRV check) | +| `jpeg` | — | JPEG writer (`raster`) | +| `tiff` | — | TIFF writer (`raster`) | +| `webp` | — | WebP writer (`raster`) | +| `svg` | ✓ | SVG writer (`graphics`; no GPU, MSRV-clean) | +| `pdf` | ✓ | PDF writer (`graphics`; no GPU, MSRV-clean) | +| `hep` | ✓ | `.hep` plot-document writer (`graphics`; no GPU, MSRV-clean) | +| `hep-read` | — | **Test-only.** Reading a `.hep` back, for the round-trip test | +| `window` | — | `PlotViewer` — a native plot window (`raster`; not a writer) | +| `webfonts` | — | `fonts::register_font` also accepts WOFF / WOFF2 (`graphics`) | | `builtin-data` | ✓ | Bundled penguins/airquality datasets | | `all-readers` | — | `duckdb` + `sqlite` + `odbc` | +| `all-writers` | — | every writer above except the test-only `hep-read` | -`ggsql-wasm` builds with `default-features = false` plus `vegalite`, `sqlite`, `builtin-data`. `ggsql-jupyter` builds with `duckdb`, `vegalite`. +`ggsql-wasm` builds with `default-features = false` plus `svg`, `webfonts`, `sqlite`, `builtin-data`, `spatial` — it draws plots in the browser with `SvgWriter`, which needs no GPU adapter, and `webfonts` is what lets a page hand it the WOFF/WOFF2 a font CDN serves. `ggsql-jupyter` builds with `duckdb`, `svg`, `pdf` plus ggsql's defaults, and its own default `raster-plots` feature adds `png`, `jpeg` and `tiff`; `svg` and `pdf` are non-optional there because the no-adapter fallback has to be compiled in whatever else is. ## Testing diff --git a/src/Cargo.toml b/src/Cargo.toml index 3c8c8ec58..e77ef2d51 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -39,8 +39,15 @@ adbc_core = { version = "0.23", optional = true } # Spatial geozero = { workspace = true, optional = true, features = ["with-wkb", "with-wkt", "with-geojson"] } -# Backend for the PNG writer (non-default; gated, excluded from the MSRV 1.86 build) -hephaestus = { version = "0.1.0", optional = true, default-features = false, features = ["vello", "png", "geom-wkb", "geom-wkt"] } +# Backend for the renderer-backed writers (non-default; gated, excluded from the +# MSRV 1.86 build). The GPU rasteriser is *not* requested here: it arrives with +# the `raster` feature, so a build wanting only vector output pulls no wgpu. +hephaestus = { version = "0.4.1", optional = true, default-features = false, features = ["geom-wkb", "geom-wkt"] } + +# WOFF / WOFF2 decoding for `fonts::register_font`, behind `webfonts`. Pure +# Rust and no build script; `brotli` here is `brotli-decompressor`, the +# decode-only half, so the gate costs a decompressor and nothing else. +wuff = { version = "0.2", optional = true, default-features = false, features = ["brotli", "z"] } # Serialization serde.workspace = true @@ -57,6 +64,16 @@ sprintf = "0.4" const_format.workspace = true uuid.workspace = true +# Named only to reach one feature parley does not re-export. `fontconfig-dlopen` +# makes fontique load `libfontconfig.so.1` at runtime instead of asking +# pkg-config for it at build time, so building any renderer-backed writer needs +# only the runtime library and not the `-dev` package — which is what keeps +# `cargo install ggsql-cli` working on a bare box now that `svg`, `pdf` and +# `hep` are default. Cargo unions features, so this applies to the `fontique` +# parley resolves; Linux-only because that is the only target with fontconfig. +[target.'cfg(target_os = "linux")'.dependencies] +fontique = { version = "0.9", optional = true, default-features = false, features = ["fontconfig-dlopen"] } + [dev-dependencies] jsonschema = { version = "0.44", default-features = false, features = ["resolve-file"] } tempfile = "3.8" @@ -65,7 +82,7 @@ adbc_datafusion = "0.23" adbc_driver_manager = "0.23" [features] -default = ["adbc", "duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "spatial"] +default = ["adbc", "duckdb", "sqlite", "vegalite", "parquet", "builtin-data", "odbc", "spatial", "svg", "pdf", "hep"] duckdb = ["dep:duckdb"] parquet = ["dep:parquet"] sqlite = ["dep:rusqlite"] @@ -73,6 +90,57 @@ adbc = ["dep:adbc_core"] odbc = ["dep:toml_edit", "dep:libloading"] spatial = ["dep:geozero", "rusqlite?/load_extension"] vegalite = [] -png = ["dep:hephaestus"] +# Internal, enabled by the writer features below rather than named directly. +# `graphics` is the shared plot-composition layer; `raster` adds the GPU +# rasteriser on top of it. Splitting them is what lets a vector-only build skip +# wgpu, vello_hybrid and pollster entirely — hephaestus gates only its GPU +# backends behind that feature, not the plot layer. +graphics = ["dep:hephaestus", "dep:fontique"] +# The GPU rasteriser. `vello-hybrid` rather than vello classic: it computes +# coverage on the CPU and hands the GPU a plain render pipeline. Its GPU buffers +# are sized to the scene's actual content rather than to fixed caps, so there is +# no draw-count ceiling to budget against on a dense plot. +# +# It transitively enables `hephaestus/png`, so a build wanting only, say, webp +# still compiles the PNG codec. A few kB, and not worth working around. +raster = ["graphics", "hephaestus/vello-hybrid"] +# On when at least one writer reads pixels back. Distinct from `raster`, which +# only says the GPU rasteriser is available — the plot viewer needs that and +# presents its frames straight to a window, never through a buffer. +raster-writer = ["raster"] + +# One feature per output format, each pulling in exactly one codec. The knob +# each writer exposes is the axis its format actually has — png trades encode +# time for size, jpeg trades quality for size, tiff picks a compressor, and +# webp is lossless with no rate control at all — so they share no setting they +# would each have to reinterpret. +png = ["raster-writer", "hephaestus/png"] +jpeg = ["raster-writer", "hephaestus/jpeg"] +tiff = ["raster-writer", "hephaestus/tiff"] +webp = ["raster-writer", "hephaestus/webp"] + +# The vector writers need only `graphics`: they record the composition's own +# drawing commands rather than rasterising them, so they pull in no wgpu, need +# no GPU adapter at render time, and compile on the MSRV toolchain. +svg = ["graphics", "hephaestus/svg"] +pdf = ["graphics", "hephaestus/pdf"] + +# The plot-document writer. Named after the format, which ggsql does not own — +# see `src/writer/hephaestus/CLAUDE.md` on why that is accurate rather than a +# leak. `hep-read` is test-only: it is what lets the round trip be asserted. +hep = ["graphics", "hephaestus/document-write"] +hep-read = ["hep", "hephaestus/document-read"] + +# The plot viewer, which is not a writer: it shows a window and returns nothing. +# Needs `raster` because a window is presented by the GPU rasteriser. +window = ["raster", "hephaestus/window"] + +# Accept the font containers a CDN serves a browser — WOFF and WOFF2 — in +# `fonts::register_font`, unwrapping them to the sfnt inside before the shaper +# sees it. Off by default: a host reading fonts off a filesystem has sfnt +# already, and this is the one place the decompressor is worth its bytes. +webfonts = ["graphics", "dep:wuff"] + builtin-data = [] all-readers = ["duckdb", "sqlite", "odbc"] +all-writers = ["vegalite", "png", "jpeg", "tiff", "webp", "svg", "pdf", "hep"] diff --git a/src/doc/API.md b/src/doc/API.md index 09e0c2ba4..1c8e8cbb1 100644 --- a/src/doc/API.md +++ b/src/doc/API.md @@ -5,7 +5,7 @@ This document provides a comprehensive reference for the ggsql public API. ## Overview - **Stage 1: `reader.execute()`** - Parse query, execute SQL, resolve mappings, create Spec -- **Stage 2: `writer.render()`** - Generate output (Vega-Lite JSON, etc.) +- **Stage 2: `writer.render()`** - Generate output (Vega-Lite JSON, SVG, PDF, PNG, …) ### API Functions @@ -389,7 +389,8 @@ pub trait Reader { ```rust pub trait Writer { - /// What this writer produces — `String` for Vega-Lite JSON, `Vec` for PNG + /// What this writer produces — `String` for Vega-Lite JSON and SVG, + /// `Vec` for the binary formats type Output; /// Build the writer from key–value options (see `WriterOptions`) @@ -416,7 +417,7 @@ key=value`). Keys are normalised: trimmed, lowercased, `-` folded to `_`. ```rust let options = WriterOptions::parse(["width=1600", "height=1200", "units=px"])?; -let png = PngWriter::from_options(&options)?.render(&spec)?; +let svg = SvgWriter::from_options(&options)?.render(&spec)?; // One string may carry several options, separated by `;`. Equivalent to the above: let options = WriterOptions::parse(["width=1600;height=1200;units=px"])?; @@ -434,6 +435,7 @@ let options = WriterOptions::new().set("dpi", "150"); | `new()` / `set(key, value)` | Build programmatically | | `get(key)` | Raw value, if supplied | | `number(key)` | Value as a finite `f64`, erroring with the option's name | +| `boolean(key)` | Value as a `bool`, accepting `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off` | | `one_of(key, allowed)` | Value checked against a closed set | | `reject_unknown(known)` | Error naming keys the writer doesn't understand | | `is_empty()` | Whether any option was supplied | diff --git a/src/fonts.rs b/src/fonts.rs new file mode 100644 index 000000000..56e4a04af --- /dev/null +++ b/src/fonts.rs @@ -0,0 +1,104 @@ +//! Font registration. +//! +//! Every renderer-backed writer shapes text before it can lay a plot out — tick +//! labels set the margins, a title wraps against the space it has. Natively +//! that resolves against the fonts the OS enumerates and nothing here is needed. +//! +//! A browser enumerates none, so `fontique` falls back to a dummy backend and a +//! plot comes out with its chrome drawn and no text at all, with no error. A +//! wasm host has to hand the faces over itself. +//! +//! Registration is process-global and permanent, so it is once per process, not +//! once per plot, and it must happen before the first render. + +use crate::{GgsqlError, Result}; + +/// Register every font face in `bytes`, returning the family names they landed +/// under. +/// +/// Accepts the sfnt formats — TTF, OTF, TTC and OTC — and, with the `webfonts` +/// feature, the WOFF and WOFF2 containers a font CDN serves a browser, which +/// are unwrapped to the sfnt inside before the shaper sees them. Without that +/// feature a container is refused by name rather than reaching the shaper and +/// registering nothing. +/// +/// The return value is the point: registering a face does not make `sans-serif` +/// mean it — that takes [`set_generic_family`], which takes names, and the only +/// place a family's name exists is inside the file. Guessing it from the +/// filename resolves to nothing at shaping time, i.e. a plot with no text. +/// +/// Bytes holding no recognisable face are an error rather than an empty list. +pub fn register_font(bytes: impl Into>) -> Result> { + let families = hephaestus::text::register_font_families(decode_webfont(bytes.into())?); + if families.is_empty() { + return Err(GgsqlError::WriterError( + "no font faces found: the bytes are not a TTF, OTF, TTC or OTC file \ + (a WOFF or WOFF2 container has to be decoded first)" + .to_string(), + )); + } + Ok(families) +} + +/// Every family available to shape with. +/// +/// Empty is the answer that matters: it means the next plot rendered will have +/// no text in it. +pub fn registered_font_families() -> Vec { + hephaestus::text::registered_families() +} + +/// Point a generic family at concrete families, in preference order. +/// +/// `kind` is one of `serif`, `sans-serif`, `monospace`, `cursive`, `fantasy` or +/// `system-ui`, matching the CSS generics. `families` are names as +/// [`register_font`] reported them. +pub fn set_generic_family(kind: &str, families: &[String]) -> Result<()> { + use hephaestus::text::GenericFamilyKind as K; + let kind = match kind { + "serif" => K::Serif, + "sans-serif" => K::SansSerif, + "monospace" | "mono" => K::Mono, + "cursive" => K::Cursive, + "fantasy" => K::Fantasy, + "system-ui" => K::SystemUi, + other => { + return Err(GgsqlError::WriterError(format!( + "unknown generic family {other:?}: expected one of serif, \ + sans-serif, monospace, cursive, fantasy, system-ui" + ))) + } + }; + hephaestus::text::set_generic_family(kind, families); + Ok(()) +} + +/// Unwrap a WOFF or WOFF2 container to the sfnt inside, or pass bytes through. +/// +/// Taken by value so an sfnt — the common case — moves straight through to the +/// shaper rather than being copied to be handed on unchanged. +#[cfg(feature = "webfonts")] +fn decode_webfont(bytes: Vec) -> Result> { + match bytes.get(..4) { + Some(b"wOF2") => wuff::decompress_woff2(&bytes).map_err(|e| { + GgsqlError::WriterError(format!("could not decode the WOFF2 font: {e:?}")) + }), + Some(b"wOFF") => wuff::decompress_woff1(&bytes) + .map_err(|e| GgsqlError::WriterError(format!("could not decode the WOFF font: {e:?}"))), + _ => Ok(bytes), + } +} + +/// Refuse a container this build cannot open: compressed bytes hold no +/// recognisable face, so registration would silently report nothing. +#[cfg(not(feature = "webfonts"))] +fn decode_webfont(bytes: Vec) -> Result> { + match bytes.get(..4) { + Some(b"wOF2") | Some(b"wOFF") => Err(GgsqlError::WriterError( + "this build cannot decode WOFF or WOFF2: use TTF, OTF, TTC or OTC, \ + or rebuild with the `webfonts` feature" + .to_string(), + )), + _ => Ok(bytes), + } +} diff --git a/src/lib.rs b/src/lib.rs index 17cabcef1..baddef8e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,7 +22,7 @@ LABEL title => 'Sales by Region' ggsql splits queries at the `VISUALISE` boundary: - **SQL portion** → passed to pluggable readers (DuckDB, PostgreSQL, CSV, etc.) - **VISUALISE portion** → parsed and compiled into visualization specifications -- **Output** → rendered via pluggable writers (ggplot2, PNG, Vega-Lite, etc.) +- **Output** → rendered via pluggable writers (Vega-Lite JSON, SVG, PDF, PNG, etc.) ## Core Components @@ -46,7 +46,14 @@ pub mod util; pub mod reader; -#[cfg(feature = "vegalite")] +// Shaping a plot's text needs fonts, and a browser supplies none of its own. +// Gated with the composition layer, which is what does the shaping. +#[cfg(feature = "graphics")] +pub mod fonts; + +// `graphics` is the gate the renderer-backed writers share, so an `svg`-only +// build reaches `SvgWriter` without dragging the Vega-Lite one in. +#[cfg(any(feature = "vegalite", feature = "graphics"))] pub mod writer; pub mod execute; diff --git a/src/plot/scale/transform/geographic.rs b/src/plot/scale/transform/geographic.rs index a64329475..7d29a7c52 100644 --- a/src/plot/scale/transform/geographic.rs +++ b/src/plot/scale/transform/geographic.rs @@ -55,7 +55,7 @@ mod tests { let breaks = t.calculate_breaks(-180.0, 180.0, 7, true); assert!(!breaks.is_empty()); for &b in &breaks { - assert!(b >= -180.0 && b <= 180.0); + assert!((-180.0..=180.0).contains(&b)); } // Should pick nice degree intervals (multiples of 30° or 45°) assert!(breaks.iter().all(|b| b % 30.0 == 0.0 || b % 45.0 == 0.0)); diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index 3360d5718..3e7e716e7 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -1,15 +1,59 @@ -# `writer/hephaestus/` — PNG writer internals +# `writer/hephaestus/` — renderer-backed writer internals -`PngWriter` renders a resolved ggsql `Spec` to **PNG bytes** via +The writers here render a resolved ggsql `Spec` through [hephaestus](https://github.com/posit-dev/hephaestus), a 2D scene renderer with a -grammar-of-graphics plot API. Behind the non-default `png` cargo feature. - -**hephaestus is not a public name.** The user-facing writer is `png` -(`--writer png`, `--features png`, `ggsql::writer::PngWriter`); the module is -named after the renderer it wraps and is private, so nothing but `PngWriter`, -`Color` and `rgba` leaves the crate. More hephaestus-backed writers (svg, pdf, -window) are expected, each with its own public name. Keep the renderer's name out -of anything a user reads — CLI help, error messages, `/doc/`. +grammar-of-graphics plot API. Seven of them exist, each behind its own cargo +feature — the three GPU-free ones on by default, the four raster ones not: + +| Writer | Feature | Default | GPU | Output | Its own options | +| --- | --- | --- | --- | --- | --- | +| `PngWriter` | `png` | — | yes | PNG bytes, lossless, alpha preserved | `compression` = `none`/`fast`/`balanced`/`small` | +| `JpegWriter` | `jpeg` | — | yes | JPEG bytes, lossy, **no alpha** | `quality` 1–100 | +| `TiffWriter` | `tiff` | — | yes | TIFF bytes, lossless, alpha preserved | `compression` = `none`/`deflate`/`lzw`/`packbits` | +| `WebpWriter` | `webp` | — | yes | WebP bytes, lossless VP8L, alpha preserved | — | +| `SvgWriter` | `svg` | ✓ | **no** | SVG text, resolution independent | `text` = `text`/`outline`, `embed-fonts`, `id-prefix` | +| `PdfWriter` | `pdf` | ✓ | **no** | one PDF page, fonts subset in | `compress`, `links` | +| `HepWriter` | `hep` | ✓ | **no** | a `.hep` plot document — no picture at all | `lossy`, `embed-fonts` | + +**Each format exposes the axis it actually has, and they share no knob they +would have to reinterpret.** PNG's `compression` trades encode time for size, +TIFF's trades reader compatibility for size (all four of its compressors are +lossless, so `deflate` simply *is* the small one), JPEG's `quality` is a rate +knob, and VP8L has no rate control at all. A shared `compression` across the +four raster writers would mean four different things. + +**The three GPU-free writers are the important ones architecturally.** They go +through the same `PlotComposition` and the same `render` call — the composition +takes `&mut dyn SceneBuilder`, so a vector scene slots in exactly where the +rasteriser does. That is why they need no adapter, pull in no wgpu and need no +`-dev` package to build, which is what lets them be **default features**. It is +also what makes them the test surface: see [Testing](#testing). + +**hephaestus is not a public name.** The user-facing names are the formats +(`--writer png`, `--features webp`, `ggsql::writer::TiffWriter`); the module is +named after the renderer it wraps and is private. What leaves the crate is the +seven writers, `PlotViewer`, and this list ([`../mod.rs`](../mod.rs)): + +| Re-export | Origin | +| --- | --- | +| `Canvas` | ggsql's own | +| `MAX_RASTER_DIMENSION` | ggsql's own | +| `Color`, `rgba` | **the renderer's** (`hephaestus::color`) | +| `PngCompression`, `TiffCompression` | **the renderer's** | + +The bottom two rows are why a hephaestus bump is a breaking change to ggsql +even when the renderer's own API holds still: those types are in ggsql's public +API by re-export, so a rename or an added variant upstream lands here. Anything +new that has to go public should be ggsql's own type unless there is a reason +it cannot be. Keep the renderer's name out of anything a user reads — CLI help, +error messages, `/doc/`. + +The one carve-out: **a foreign format may be called by its own name.** +`HepWriter` writes hephaestus's own `.hep` plot-document format, whose magic +bytes are `HEPHPLOT`. ggsql does not define that format, so naming it after its +owner is accurate rather than a leak — and "document" would have been the worse +name, implying a generic container ggsql defines. ggsql's *own* writers still +may not be named after the renderer. This file is the **architecture**: the abstractions, the invariants, and how to extend them. For how the writer's behaviour got here, read @@ -40,15 +84,25 @@ debt that would disappear if ggsql resolved more: | Exception | Where | Why | | --- | --- | --- | | Free facet dimensions | `scales::{free_position_scale, free_binned_scale}` | ggsql resolves one global domain; a `free` panel needs its own. Only the *extent* is computed — the padding around it is still ggsql's, via `Scale::expand_range`. | -| Spatial `pos1`/`pos2` | `mod.rs::map_bbox` | A spatial layer positions by geometry, so ggsql resolves no position scales. The bbox still comes from ggsql (`Projection.computed["bbox"]`), falling back to the geometry extent only for a bare `spatial` geom. | +| Spatial `pos1`/`pos2` | `compose.rs::map_bbox` | A map's frame is `Projection.computed["bbox"]`, in the target CRS, with `SCALE lon`/`lat` limits already folded in. A resolved `pos1`/`pos2` is *not* the alternative: for a map, ggsql resolves those against the graticule extent in EPSG:4326, so their domain is degrees and their breaks are graticule positions, not the frame. Only a bare `spatial` geom with no `PROJECT` falls back to the geometry extent. | ## Configuration -Raster output needs concrete dimensions, so unlike the Vega-Lite writer this one -carries state: `width`, `height` (both pixels), `dpi`, and `background`. -`PngWriter::new` + `.background()` set them directly; -`Writer::from_options` builds the same thing from the frontend-agnostic -key–value [`WriterOptions`](../options.rs) (`-D width=1600` on the CLI). The user-facing table of keys lives in the struct's rustdoc and in +Rendering needs concrete dimensions, so unlike the Vega-Lite writer these +carry state — and they carry the *same* state, in [`canvas.rs`](canvas.rs): +`Canvas { width, height, dpi, background, physical }`. Each writer's `new` + +`.background()` set it directly; `Writer::from_options` builds the same thing +from the frontend-agnostic key–value [`WriterOptions`](../options.rs) +(`-D width=1600` on the CLI), via `Canvas::from_options(options, extra)` where +`extra` is the writer's own keys. + +`CANVAS_OPTIONS` leads the concatenation `reject_unknown` sees, so the shared +keys come first in the "supported options" list — the nearest miss for a +mistyped key is almost always one of them. `physical` records that `units` +resolved to a physical unit; only a vector backend consults it, to decide +whether to declare a print size. + +The user-facing table of keys lives in each writer's rustdoc and in [`/doc/get_started/tooling/cli.qmd`](../../../doc/get_started/tooling/cli.qmd); what matters here: @@ -70,7 +124,7 @@ runtime do layout and scale application, hephaestus **is** the runtime. So `write` builds a live object graph and renders it. ``` -PngWriter::write(&Plot, &HashMap) +compose::build_composition(&Plot, &HashMap) │ ├─ facet::build_panels(spec, data) → (Composition, Vec) │ 1×1 grid + one Panel when unfaceted; else grid(nrow, ncol, cells) @@ -93,17 +147,50 @@ PngWriter::write(&Plot, &HashMap) │ └─ view.attach_plot(plot) │ ├─ legend_sink (captured from the *first* panel) → view.add_legend(..) - ├─ view.validate() - └─ render_png: VelloRenderer → RGBA8 buffer → hephaestus::png::encode_png + └─ view.validate() + +raster::pixels(spec, data, canvas, renderer) + ├─ compose::validate_plot ← rejects a zero-layer plot, and + │ the `arrow` stub no writer draws + ├─ compose::build_composition ← the diagram above + └─ raster::render_rgba8: HybridRenderer → straight-alpha RGBA8 buffer + +::write_with = raster::pixels, then one encoder call +::write_reporting = vector::draw into an SvgScene / PdfScene, then encode +HepWriter::write_reporting = write_composition — no scene, no pixels ``` +A writer is therefore its option parsing plus one line. `PlotComposition` is +where the work is, and it is format-independent — which is why the module gate +is the internal `graphics` feature and only `raster` pulls in wgpu. + +**Degradation is reported, not returned.** `SvgScene` and `PdfScene` collect +what the format could not express, and the `hep` writer can list what the +document cannot carry. `Writer::write` has nowhere to put that, and widening +the trait for three of eight writers would be wrong, so those three add an +inherent `write_reporting` / `render_reporting` returning `(output, Vec)` +and `Writer::write` discards the second half. `Vec` rather than a ggsql +enum: the renderer's variants are `#[non_exhaustive]`, so mirroring them means +re-deriving a growing list every release and re-exporting them leaks its type +names — each writer's `describe()` translates at the boundary instead, which is +also where the renderer's name gets scrubbed. The list should be empty for +everything ggsql draws, and the corpus tests assert exactly that. + Layers draw in `spec.layers` order, which is DRAW order, which is z-order. ## Module map | File | Role | | --- | --- | -| [`mod.rs`](mod.rs) | `PngWriter` (size / dpi / background), `Writer` impl including `from_options`, the orchestration above, `map_bbox`, `render_png`, and the writer's test suite. | +| [`mod.rs`](mod.rs) | Module wiring and the public re-exports, plus the shared `renders_*` corpus driven through the PNG writer. No writer lives here. | +| [`canvas.rs`](canvas.rs) | `Canvas`, `CANVAS_OPTIONS`, unit conversion and the dimension bound — the configuration every writer shares. Plus the test-only `Canvased` / `assert_canvas_semantics`, so the shared option behaviour is asserted once per writer rather than restated per writer. | +| [`compose.rs`](compose.rs) | `validate_plot` and `build_composition` — the orchestration above, and `map_bbox` / `map_range`. Format-independent, and where nearly all the code is. | +| [`raster.rs`](raster.rs) | `RasterRenderer`, `render_rgba8`, and `pixels`. **The only file that names a GPU renderer**, and the only part needing an adapter. | +| [`vector.rs`](vector.rs) | `draw` — the same three steps as `raster::pixels`, into a `&mut dyn SceneBuilder` instead of a pixel buffer. No GPU. | +| [`png.rs`](png.rs), [`jpeg.rs`](jpeg.rs), [`tiff.rs`](tiff.rs), [`webp.rs`](webp.rs) | One raster writer each: its rustdoc option table, `from_options`, and one encoder call. | +| [`svg.rs`](svg.rs), [`pdf.rs`](pdf.rs) | One vector writer each, plus a `describe()` translating what the format could not express into ggsql's vocabulary. | +| [`hep.rs`](hep.rs) | The plot-document writer. Serialises the composition; builds no scene at all. | +| [`window.rs`](window.rs) | `PlotViewer` — **not a writer.** Shows the composition in a native window and blocks until it closes. | | [`wiring.rs`](wiring.rs) | The shared, geom-generic machinery: `Ctx`, `GeomSpec` + its parts, `build_and_add`, `wire_positions`, `wire_material`, `MaterialSource`/`resolve_material`, `BandAxes`, `side`/band helpers, `material_legend`, label resolution. | | [`scales.rs`](scales.rs) | ggsql `Scale` → hephaestus `Scale`. `RangeKind`, transform + palette + break mapping, temporal scales, free-panel scales, `binned_bins`/`bin_at_centre`. | | [`channels.rs`](channels.rs) | DataFrame column → typed channel data (`ChannelData`, `column_to_*`), group keys, WKB/WKT geometry decoding. | @@ -410,6 +497,33 @@ suppressing the colorbar frame hephaestus otherwise inherits from its default `RectElement`. Anything the two writers must agree on that is neither a scale nor a channel belongs there. +## The viewer is not a writer + +[`window.rs`](window.rs) holds `PlotViewer`, which produces no output at all. It +is not a `Writer` impl on purpose: `Output = ()` would put "blocks, main thread +only, native only" into that trait's contract for one implementor's sake. +`from_options` plus `show(&Spec)` gives the same option ergonomics without +claiming it writes anything. + +It lives in this crate rather than in `ggsql-cli` because the CLI uses only +public `ggsql::*` API and has no renderer dependency — see +[`/ggsql-cli/CLAUDE.md`](../../../ggsql-cli/CLAUDE.md). So the *behaviour* goes +public as a type instead of `build_composition` going public. + +Two things follow from what a window is: + +- **Resize needs no code.** `Frame::parts()` reports the surface's own size and + dpi each frame, and the composition re-solves its layout for them — so a + resize is a re-layout, not a rescale. That is the same property the `hep` + format exists to preserve. +- **`units` and `dpi` are rejected, with a reason.** A window is sized in + logical pixels and its resolution belongs to the display (`frame.dpi()` wins + every draw), so accepting either would be accepting a setting that is then + ignored — exactly the silent failure `reject_unknown` exists to prevent. The + error says so rather than reporting them as typos. Option parsing reuses + `canvas::{whole_pixels, parse_background}`, which is why those are free + functions rather than `Canvas` methods. + ## Adding a geom 1. Add a module under [`geom/`](geom/) returning a `GeomSpec`, and dispatch it in @@ -436,24 +550,79 @@ a channel belongs there. ## Testing -Tests live at the bottom of [`mod.rs`](mod.rs): +The shared corpus lives at the bottom of [`mod.rs`](mod.rs); each writer's own +option tests live beside it in its own file: ```sh -cargo test --features png --lib writer::hephaestus +# Everything. `hep-read` is test-only and unlocks the round trip. +cargo test --features all-writers,hep-read --lib writer::hephaestus + +# The GPU-free subset — hard assertions, and what CI can rely on. +cargo test --features svg,pdf,hep,hep-read --lib writer::hephaestus ``` -Two kinds, plus a third that doesn't exist yet: +**Option tests do not repeat themselves.** `canvas::assert_canvas_semantics::()` +covers the five shared keys — defaults, unit conversion, the `MAX_DIMENSION` +bound, the background spellings, and that a bad value names its own option — and +is called once per writer, which is what catches a writer that parses a canvas +key itself or forgets to pass its own keys through. Transparency is separate +(`assert_transparent_background`), because JPEG has no alpha channel and refuses +it. A writer's own file then tests only the keys its format adds. + +### The corpus runs through every writer + +`assert_renders(query)` drives **each compiled writer** over one query, so a +corpus entry is written once and checked by all of them. The ~78 `renders_*` +tests are that corpus: one query per geom, facet mode, scale kind, position +adjustment and projection. + +**The vector assertions are what makes this a regression net.** They need no +adapter, so they run in CI and on a headless box: `` opened and closed, +a non-zero `` count, `%PDF-` and `%%EOF`, and — the real one — +**`warnings()` empty**, meaning nothing in the whole corpus reached a case a +vector format cannot express. The raster assertion still skips where there is +no adapter (`assert_png_or_skip` matches on the substring `"GPU renderer"`), so +a green run has never proved a *raster* render happened. Before the vector +writers existed, that was the only kind of end-to-end assertion there was. + +### The assertions only readable output can make + +`mod svg_text` checks the [governing principle](#the-governing-principle) +*directly*, which no raster test can: SVG output is text, so the breaks, labels +and titles ggsql resolved can be read back out of it. + +- Tick labels appear verbatim, in ggsql's own number formatting. +- Facet strip labels appear **once each, in panel order** — previously asserted + only against `build_panels`, never against rendered output. +- `RENAMING` reaches both an axis rail and a legend key. +- A binned scale's resolved edges reach the colorbar. +- Every `LABEL` slot appears, and markdown is **parsed** — no literal `*`, and + the emphasised run carries a style. +- `text=outline` → zero `` and more ``; `id-prefix` rewrites every + id *and* every `url(#…)` reference. +- `units=in` → a `pt` root over a pixel `viewBox`, so the file prints at the + size it was asked for. + +`mod pdf_structure` does the same for what PDF's structure exposes: the +`/MediaBox` at 72 pt per inch, `compress=false` leaving no `/FlateDecode`, and +`/FontFile2` proving the fonts are subset in. + +`mod hep_roundtrip` (behind the test-only `hep-read` feature) is the strongest +single test here: write a document, read it back into a **new** composition, +render both to SVG and compare **byte for byte**. Any loss anywhere in the +format — a scale, a break, a theme entry, a channel column, a geom — shows up as +different drawing commands. SVG is the comparison surface precisely because it +is deterministic text; a raster comparison would be at the mercy of GPU +antialiasing, which is not bit-reproducible even between two runs of the same +code. + +### Still eyeballing -- **`renders_*` smoke tests** — render succeeds and the output carries the PNG - signature. `assert_png_or_skip` tolerates a headless machine with no GPU - adapter (it skips rather than fails), so a green run does not prove a render - happened locally. - **Exact-text assertions** — `facet_strips_*` and the `binned_bins` / - `bin_at_centre` / temporal-scale unit tests need no GPU and are the real - regression net. -- **Snapshot PNG tests do not exist.** Visual correctness is - verified by eyeballing, usually against the Vega-Lite render of the same - query. Assume a hephaestus version bump needs re-eyeballing: + `bin_at_centre` / temporal-scale unit tests need no GPU either. +- **Snapshot tests do not exist.** Whole-picture correctness is still verified + by eye, usually against the Vega-Lite render of the same query. Assume a + hephaestus version bump needs re-eyeballing: ```sh cargo run -p ggsql-cli --features png -- exec "" \ @@ -478,23 +647,64 @@ so one run inventories every gap at once. Implementation notes: ## Operational constraints -- **A GPU adapter is required at render time.** Vello/wgpu is hephaestus's only - working backend. CI installs Mesa's lavapipe; headless containers need - something equivalent. -- **fontconfig is a build-time dependency on Linux.** Text layout goes through - parley/fontique, which links the system fontconfig to enumerate fonts, so - `libfontconfig1-dev` (or the distro equivalent supplying `fontconfig.pc`) must - be installed before building with `--features png`. macOS uses CoreText and - needs nothing extra. -- **Raster only.** No SVG/PDF — hephaestus's other backends are declared - placeholders. -- **MSRV split.** hephaestus needs rustc ≥1.88; ggsql's MSRV is CRAN-locked at - 1.86. The feature is therefore non-default and excluded from the MSRV job (CI - runs the png steps with `cargo +stable`), which also means this writer - is not viable for the R/CRAN target and is not the wasm default. Always check a - change still builds under `cargo +1.86 build` *without* the feature. -- **The dependency is the published `0.1.0` crate** (`src/Cargo.toml`), so - nothing here blocks publishing ggsql. hephaestus's own semver contract extends +- **A GPU adapter is required by the four raster writers**, at render time, and + by nothing else. CI installs Mesa's lavapipe; headless containers need + something equivalent. The vector and document writers need neither an adapter + nor wgpu, which is why they are the default ones. +- **The backend is Vello Hybrid, not vello classic**, and the choice is named + in exactly one place ([`raster.rs`](raster.rs)) so it stays swappable. Hybrid + computes coverage on the CPU and gives the GPU a plain render pipeline, which + buys two things: its GPU buffers are sized to the scene's actual content + instead of fixed caps, so a dense plot has **no draw-count ceiling**; and it + can paint binary coverage, so a hit test reports exactly one id per pixel + rather than a blend of two — vello classic antialiases its pick pass and can + report an id that was never drawn. The second matters only once interaction + lands, but it is the reason not to defer the choice. Output differs from + vello classic by antialiasing alone; geometry is identical. + `hephaestus/vello-hybrid` transitively enables `hephaestus/png`, so a + webp-only build still compiles the PNG codec. +- **The raster ceiling is the GPU's, not the renderer's.** The device is asked + for as much as it grants up to 16384 px per dimension, which is + `MAX_RASTER_DIMENSION` and what `check_size` guards before anything is + allocated — so the error can name the limit and point at `svg`/`pdf`, which + have none. A device offering less rejects the frame itself with its own limit + named. +- **fontconfig is a *runtime* dependency on Linux, not a build-time one.** Text + layout goes through parley/fontique, which enumerates fonts through the + system fontconfig whatever backend draws — so this applies to `svg` and `pdf` + just as much as to `png`. But `src/Cargo.toml` names `fontique` directly for + one reason: to turn on `fontconfig-dlopen`, which parley does not re-export. + With it, `yeslogic-fontconfig-sys`'s build script makes no pkg-config call at + all and `libfontconfig.so.1` is loaded on use, so **`libfontconfig1-dev` is + not needed to build** — which is what lets `svg`/`pdf`/`hep` be default + features without breaking `cargo install ggsql-cli` on a bare box. CI and + the release images install only the runtime library, never the `-dev` + package, so every build re-checks that. macOS uses CoreText and needs + nothing. + + **With no fontconfig at all, a render silently loses all text**: geometry + draws, every `` disappears, and the exit code is 0. Worth knowing when + a minimal container produces an unlabelled plot. +- **The vector and document writers are default features *and* MSRV-clean.** + `svg`, `pdf` and `hep` need no adapter, no wgpu and no `-dev` package, so + there is nothing to opt into — and they still compile on CRAN's 1.86, which + is what keeps them available to the R package's vendored copy. What refuses + on 1.86 is cargo's *declaration* check, because `parley` declares 1.88 while + compiling fine on it, and `--ignore-rust-version` bypasses a declaration: + + ```sh + cargo +1.86 check --ignore-rust-version -p ggsql # default features; passes + ``` + + CI runs exactly that, so the claim is checked rather than asserted. The four + raster writers are genuinely 1.88+ (wgpu), which is one more reason they are + not default. **Do not use a 1.87+ std API anywhere in the library** — + `rust-version = "1.86"` keeps clippy flagging one as a lint, and that guard + is why the declaration stays at 1.86 rather than following `parley`'s. +- **The dependency is the published `0.4.1` crate** (`src/Cargo.toml`), pinned + with `default-features = false` so the GPU rasteriser arrives only with + `raster`, and nothing here blocks publishing ggsql. hephaestus's own semver + contract extends to the `kurbo`, `peniko` and `wgpu` types in its public API, so a bump in any of those is a breaking change to this writer even when hephaestus's own API holds still. @@ -503,33 +713,24 @@ so one run inventories every gap at once. Implementation notes: Deliberately not done, in rough order of how likely they are to bite: -- **No snapshot PNG tests** (see [Testing](#testing)) — visual correctness is - checked by eyeballing, with the harness for doing it at scale. +- **No committed snapshot fixtures** (see [Testing](#testing)) — whole-picture + correctness is checked by eyeballing, with the harness's `--baseline` for + doing it at scale. The SVG corpus is the natural fixture surface, being + deterministic text where a 2 px panel shift reads as a hunk rather than as a + changed hash; whether to commit it is still open. +- **Log-scale tick labels are wrong, and not because of this writer.** ggsql + resolves a 1–100 `log10` domain to breaks of + `[5e-308, 2e-256, …, 100]`, and both writers faithfully print those. The fix + is in scale resolution; nothing changes here. Recorded as an ignored test + (`svg_text::log_tick_labels_should_be_decades`). - **No axis label thinning.** ggsql's resolved breaks are drawn as-is, so a narrow facet panel can crowd or overlap long labels — which is why `free_continuous_scale` narrows the *global* breaks to a panel rather than letting hephaestus invent per-panel ones. -- **Legend titles and break labels don't parse markdown.** [`ggsql_theme`](wiring.rs) - sets `markdown` on the root text element, so the flag cascades to every slot — - but hephaestus only consults it where a slot goes through - `chrome::text::measure_for_element` / `draw_text_element_in_rect` (plot title, - subtitle, caption, axis titles, strip labels). Legend titles - (`chrome/legend/mod.rs`, `chrome/legend/colorbar.rs`), legend key labels - (`chrome/legend/measure.rs`, `chrome/legend/render_keys.rs`) and tick labels - (`chrome/axis.rs`, `chrome/linear_axis.rs`, `chrome/polar.rs`) build a - `TextRun::new` directly and draw their markers literally. **Fixing this is - upstream work**; nothing changes in this writer when it lands. - **No switch on rich-text chrome.** [`ggsql_theme`](wiring.rs) turns markdown on for the whole chrome cascade, so a title that wants a literal `*` has no way to ask for one. The text layer has `parse`; chrome waits for ggsql to grow a theme concept, which is where the same switch belongs. -- **Rich text costs ~1pt of layout.** A plain string measures slightly larger - through the rich shaper than through the plain one, so every axis title claims a - little more room and the panel comes out a few px smaller than it did before - markdown was on. Aligning the sheet's line height with the theme's (see - `ggsql_theme`) removed the bulk of it; the ~1pt that remains is the rich block - model's own box, which no sheet entry reaches. Visually imperceptible, but it is - why a residual diff over the harness shows nearly every cell as "changed". ## See also diff --git a/src/writer/hephaestus/canvas.rs b/src/writer/hephaestus/canvas.rs new file mode 100644 index 000000000..a01ab1c72 --- /dev/null +++ b/src/writer/hephaestus/canvas.rs @@ -0,0 +1,338 @@ +//! The canvas configuration every renderer-backed writer carries. +//! +//! Raster and vector output both need concrete dimensions and a resolution, so +//! the size, DPI and background live here rather than in each writer. A writer +//! adds only its own format's keys: a JPEG quality, a TIFF compression. + +use hephaestus::color::{rgba, Color}; +use hephaestus::geometry::Size; + +use super::scales::parse_color; +use crate::writer::WriterOptions; +use crate::{GgsqlError, Result}; + +/// Default canvas width in pixels. +pub(super) const DEFAULT_WIDTH: u32 = 1500; +/// Default canvas height in pixels. +pub(super) const DEFAULT_HEIGHT: u32 = 1000; +/// Default resolution. DPI converts the theme's physical sizes (text, stroke +/// widths, spacing — all in points) to pixels, so it sets how large the chrome +/// is relative to the canvas as well as the print size of a physical figure. +pub(super) const DEFAULT_DPI: f64 = 300.0; + +/// Largest canvas dimension accepted, in pixels: 2^15, which is a memory budget +/// rather than a format limit. A square canvas at this size is a gigapixel — +/// 4 GB of RGBA — so it is the point past which a slipped unit conversion +/// should fail with a message instead of trying to allocate. Far beyond any +/// real figure: 32768px is 109 inches at 300 dpi. +const MAX_DIMENSION: f64 = 32_768.0; + +/// Option keys every renderer-backed writer understands. +/// +/// Concatenated ahead of a writer's own keys when rejecting unknown options, so +/// the shared ones lead the "supported options" list in the error. +pub const CANVAS_OPTIONS: &[&str] = &["width", "height", "units", "dpi", "background"]; + +/// The canvas keys that describe a *size* rather than an appearance. +/// +/// A writer whose canvas is only a hint tells "no size given" from "a size that +/// happens to equal the default" by these keys. `dpi` is its own hint and so is +/// not among them. +#[cfg(feature = "hep")] +pub const CANVAS_SIZE_OPTIONS: &[&str] = &["width", "height", "units"]; + +/// Units a `width` / `height` option may be given in. +const UNITS: &[&str] = &["px", "in", "cm", "mm", "pt"]; + +/// Size, resolution and background for one rendered figure. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Canvas { + pub width: u32, + pub height: u32, + pub dpi: f64, + pub background: Color, + /// Whether the dimensions were given in a physical unit rather than pixels. + /// + /// Only the vector backends consult it: a file asked for in inches declares + /// a physical size so it prints at that size; one in pixels stays in pixels. + pub physical: bool, +} + +impl Canvas { + /// A canvas of the given pixel dimensions and DPI, on white. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + width, + height, + dpi, + background: rgba(1.0, 1.0, 1.0, 1.0), + physical: false, + } + } + + /// Set the background painted before anything is drawn. + pub fn background(mut self, color: Color) -> Self { + self.background = color; + self + } + + /// Parse the shared keys, rejecting anything outside them or `extra` first. + /// + /// `extra` is the calling writer's own option names. Rejection happens + /// before any value is read, so a mistyped key is reported rather than + /// silently ignored. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` for an unknown key, an unusable value, + /// or a dimension outside the renderable range. + pub fn from_options(options: &WriterOptions, extra: &[&str]) -> Result { + let known: Vec<&str> = CANVAS_OPTIONS.iter().chain(extra).copied().collect(); + options.reject_unknown(&known)?; + + let dpi = match options.number("dpi")? { + Some(dpi) if dpi > 0.0 => dpi, + Some(dpi) => { + return Err(GgsqlError::WriterError(format!( + "writer option 'dpi' expects a positive number, got '{dpi}'" + ))) + } + None => DEFAULT_DPI, + }; + // `units` interprets the dimensions the caller supplies; the defaults are + // pixel counts, so they stand whatever the unit is. + let units = options.one_of("units", UNITS)?.unwrap_or("px"); + let width = match options.number("width")? { + Some(width) => to_pixels(width, units, dpi, "width")?, + None => DEFAULT_WIDTH, + }; + let height = match options.number("height")? { + Some(height) => to_pixels(height, units, dpi, "height")?, + None => DEFAULT_HEIGHT, + }; + + let mut canvas = Self::new(width, height, dpi); + canvas.physical = units != "px"; + if let Some(raw) = options.get("background") { + canvas = canvas.background(parse_background(raw)?); + } + Ok(canvas) + } + + /// The canvas as a hephaestus size, for `PlotComposition::render`. + pub fn size(&self) -> Size { + Size::new(self.width as f64, self.height as f64) + } + + /// The resolution to record in an output that can carry one. + /// + /// A file that declares nothing is read as 72 dpi by whatever opens it, so + /// an image rendered at a higher resolution would claim the wrong physical + /// size. + pub fn dpi_hint(&self) -> Option { + Some(self.dpi) + } + + /// The background as a vector backend wants it: `None` when fully + /// transparent. + /// + /// A rasteriser is always handed a colour to clear with; a vector backend + /// takes `None` to mean "emit no background element", which is what a + /// transparent canvas should become rather than a transparent-black rect. + pub fn vector_background(&self) -> Option { + if self.background.components[3] <= 0.0 { + None + } else { + Some(self.background) + } + } +} + +impl Default for Canvas { + fn default() -> Self { + Self::new(DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI) + } +} + +/// Read a `background` option's value as a color. +/// +/// A free function rather than a `Canvas` method because the plot viewer takes +/// a background without taking a canvas. +/// +/// # Errors +/// +/// Returns `GgsqlError::WriterError` if the value is not a color. +pub(super) fn parse_background(raw: &str) -> Result { + // `none` is a familiar spelling of a transparent canvas that CSS itself + // doesn't accept as a color. + match raw.trim().to_lowercase().as_str() { + "none" => Ok(rgba(0.0, 0.0, 0.0, 0.0)), + _ => parse_color(raw).ok_or_else(|| { + GgsqlError::WriterError(format!( + "writer option 'background' expects a CSS color, got '{raw}'" + )) + }), + } +} + +/// Convert a canvas dimension given in `units` to whole pixels at `dpi`. +/// +/// A physical unit goes through inches, so the same figure grows with DPI; `px` +/// is already the canvas unit, where DPI only scales the chrome. +fn to_pixels(value: f64, units: &str, dpi: f64, key: &str) -> Result { + let per_inch = match units { + "in" => 1.0, + "cm" => 2.54, + "mm" => 25.4, + "pt" => 72.0, + _ => return whole_pixels(value, key), + }; + whole_pixels(value / per_inch * dpi, key) +} + +/// Round a pixel count and reject one outside the renderable range. +pub(super) fn whole_pixels(pixels: f64, key: &str) -> Result { + let rounded = pixels.round(); + if !(1.0..=MAX_DIMENSION).contains(&rounded) { + return Err(GgsqlError::WriterError(format!( + "writer option '{key}' resolves to {rounded} px, outside the supported range 1–{MAX_DIMENSION} px" + ))); + } + Ok(rounded as u32) +} + +/// Test-only access to a writer's canvas. +/// +/// Implemented by every renderer-backed writer so the shared option behaviour +/// can be asserted generically instead of once per format. +#[cfg(all( + test, + any( + feature = "png", + feature = "jpeg", + feature = "tiff", + feature = "webp", + feature = "svg", + feature = "pdf", + feature = "hep" + ) +))] +pub(super) trait Canvased { + fn canvas(&self) -> &Canvas; +} + +/// Assert the five shared canvas options behave identically for `W`. +/// +/// Parsed in one place, so asserted in one place; a writer's own tests cover +/// only the keys its format adds. Catches a writer that parses a canvas key +/// itself or forgets to pass its own keys to [`Canvas::from_options`]. +/// +/// Transparency is not covered here — see [`assert_transparent_background`]. +#[cfg(all( + test, + any( + feature = "png", + feature = "jpeg", + feature = "tiff", + feature = "webp", + feature = "svg", + feature = "pdf", + feature = "hep" + ) +))] +pub(super) fn assert_canvas_semantics() { + let build = |pairs: &[&str]| -> Result { W::from_options(&WriterOptions::parse(pairs)?) }; + let dims = |pairs: &[&str]| -> (u32, u32, f64) { + let writer = build(pairs).unwrap(); + let c = writer.canvas(); + (c.width, c.height, c.dpi) + }; + + // No options: the documented defaults, on an opaque white canvas. + assert_eq!(dims(&[]), (DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI)); + let white = *build(&[]).unwrap().canvas(); + assert_eq!(white.background.components, [1.0, 1.0, 1.0, 1.0]); + assert!(!white.physical, "a pixel canvas is not a physical one"); + + // A pixel canvas is taken verbatim, and DPI only scales the chrome on it. + assert_eq!( + dims(&["width=1600", "height=1200"]), + (1600, 1200, DEFAULT_DPI) + ); + assert_eq!( + dims(&["width=800", "units=px", "dpi=72"]), + (800, DEFAULT_HEIGHT, 72.0) + ); + + // A physical canvas goes through inches, so it grows with DPI. + assert_eq!( + dims(&["width=8", "height=6", "units=in", "dpi=100"]), + (800, 600, 100.0) + ); + // 2.54 cm = 1 in; 25.4 mm = 1 in; 72 pt = 1 in. + assert_eq!(dims(&["width=2.54", "units=cm", "dpi=96"]).0, 96); + assert_eq!(dims(&["width=25.4", "units=mm", "dpi=96"]).0, 96); + assert_eq!(dims(&["width=72", "units=pt", "dpi=96"]).0, 96); + // An unset dimension stays a pixel count even when the caller works in inches. + assert_eq!(dims(&["width=5", "units=in", "dpi=200"]).1, DEFAULT_HEIGHT); + assert!( + build(&["width=5", "units=in"]).unwrap().canvas().physical, + "inches are a physical unit" + ); + + // An opaque CSS color, in the spellings a user reaches for. + let red = *build(&["background=#ff0000"]).unwrap().canvas(); + assert_eq!(red.background.components, [1.0, 0.0, 0.0, 1.0]); + assert!(build(&["background=rgb(0, 0, 255)"]).is_ok()); + assert!(build(&["background=white"]).is_ok()); + + // Every bad value names the option that carries it. + let cases = [ + ("units=furlongs", "'units' expects"), + ("dpi=0", "'dpi' expects a positive number"), + ("dpi=high", "'dpi' expects a number"), + ("width=0", "'width' resolves to 0 px"), + ("width=-4", "'width' resolves to -4 px"), + ("height=1e9", "'height' resolves to"), + ("background=nope", "'background' expects a CSS color"), + ]; + for (option, expected) in cases { + let err = build(&[option]).unwrap_err().to_string(); + assert!(err.contains(expected), "{option}: {err}"); + } + + // And an unknown key is reported rather than ignored, with the shared keys + // leading the list so the nearest miss is the first thing read. + let err = build(&["with=1600"]).unwrap_err().to_string(); + assert!(err.contains("unknown writer option 'with'"), "{err}"); + assert!(err.contains("supported options: width, height"), "{err}"); +} + +/// Assert `W` accepts a transparent canvas, in both spellings. +/// +/// Separate from [`assert_canvas_semantics`] because a format with no alpha +/// channel refuses one instead — see `JpegWriter`. +// Every writer but `jpeg`, which refuses transparency — so a jpeg-only build +// is the one config where this has no caller. +#[cfg(all( + test, + any( + feature = "png", + feature = "tiff", + feature = "webp", + feature = "svg", + feature = "pdf", + feature = "hep" + ) +))] +pub(super) fn assert_transparent_background() { + for spelling in ["background=transparent", "background=none"] { + let options = WriterOptions::parse([spelling]).unwrap(); + let writer = W::from_options(&options).unwrap(); + assert_eq!( + writer.canvas().background.components[3], + 0.0, + "{spelling} should be fully transparent" + ); + } +} diff --git a/src/writer/hephaestus/compose.rs b/src/writer/hephaestus/compose.rs new file mode 100644 index 000000000..137e50a02 --- /dev/null +++ b/src/writer/hephaestus/compose.rs @@ -0,0 +1,354 @@ +//! Turning a resolved ggsql `Plot` into a live hephaestus composition. +//! +//! Everything up to the point where an output format matters. Each writer calls +//! [`build_composition`] and then rasterises, renders or serialises the result, +//! so this lives here rather than in any one writer. + +use std::collections::HashMap; + +use hephaestus::plot::{scale, AspectMode, Plot as HPlot, PlotComposition}; +use hephaestus::scales::chrome::AxisSide; +use hephaestus::shape::ShapeRegistry; + +use super::projection::apply_projection; +use super::scales::build_scale; +use super::wiring::Ctx; +use super::{channels, facet, geom, projection, scales, wiring}; +use crate::naming; +use crate::plot::layer::geom::GeomType; +use crate::plot::layer::is_transposed; +use crate::plot::ParameterValue; +use crate::{DataFrame, GgsqlError, Layer, Plot, Result}; + +/// Fraction of a map's bounding-box span added as breathing room around it, so +/// marks on the boundary are not drawn against the panel edge. Matches the +/// Vega-Lite writer's projection fit (`span * 1.1`). +const MAP_PADDING: f64 = 0.1; + +/// Reject a plot no renderer-backed writer can draw. +/// +/// Phrased without naming a format: what cannot be drawn here is a limit of the +/// composition layer, not of the encoder the caller happened to pick. +/// +/// # Errors +/// +/// Returns `GgsqlError::WriterError` for a plot with no layers, or one whose +/// geom the composition layer cannot build. +pub fn validate_plot(spec: &Plot) -> Result<()> { + if spec.layers.is_empty() { + return Err(GgsqlError::WriterError( + "a plot needs at least one layer".into(), + )); + } + for layer in &spec.layers { + let geom_type = layer.geom.geom_type(); + if !geom::is_supported(geom_type) { + return Err(GgsqlError::WriterError(format!( + "the plot renderer does not support the '{geom_type}' geom yet" + ))); + } + } + Ok(()) +} + +/// Validate `spec` and build its composition — the two steps every writer runs +/// before it does anything of its own. +/// +/// One function so the pair cannot drift: skipping the validation lets a +/// refused plot reach the renderer and fail in the renderer's words. +/// +/// # Errors +/// +/// As [`validate_plot`] and [`build_composition`]. +pub fn prepare(spec: &Plot, data: &HashMap) -> Result { + validate_plot(spec)?; + build_composition(spec, data) +} + +/// Build the composition for `spec`, ready to render at any size. +/// +/// Layers are built in `spec.layers` order, which is DRAW order, which is +/// z-order. Callers go through [`prepare`], which validates first. +/// +/// # Errors +/// +/// Returns `GgsqlError::WriterError` if a layer's data is missing, a geom +/// cannot be built, or the assembled composition fails hephaestus's own +/// validation. +pub fn build_composition( + spec: &Plot, + data: &HashMap, +) -> Result { + // FACET → a grid of named panels (a single panel when unfaceted). Each + // panel becomes one hephaestus `Plot` sharing the composition's scales. + let (composition, panels) = facet::build_panels(spec, data)?; + // The composition owns the shape registry backing composition-level legend + // glyphs (point markers, line dashes). + let mut view = PlotComposition::new(&composition) + .shape_registry(ShapeRegistry::with_builtins()) + .theme(wiring::ggsql_theme()); + + // LABEL title/subtitle/caption live on the composition rather than the + // panels, so one label spans the whole figure — also correct unfaceted, + // where a panel-level title would resolve to the same layout row. + if let Some(text) = wiring::plot_label(spec, "title") { + view = view.title(text); + } + if let Some(text) = wiring::plot_label(spec, "subtitle") { + view = view.subtitle(text); + } + if let Some(text) = wiring::plot_label(spec, "caption") { + view = view.caption(text); + } + + // Axis titles are composition chrome too: one centred title per + // dimension for the whole figure, rather than one per panel rail. + for (side, text) in projection::composition_axis_titles(spec) { + view = view.axis_title(side, text); + } + + // Register the fixed (shared) scales once, globally. Every panel binds + // its position channels to these names, giving fixed-scale faceting. + for scale in &spec.scales { + let kind = match scale.aesthetic.as_str() { + "fill" | "stroke" => scales::RangeKind::Color, + "shape" => scales::RangeKind::Shape, + "linetype" => scales::RangeKind::Linetype, + // The text geom's font aesthetics: a scale over them resolves a + // range of family names / weights, not numbers. + "typeface" => scales::RangeKind::Text, + "fontweight" => scales::RangeKind::FontWeight, + "italic" => scales::RangeKind::Bool, + _ => { + if scale.aesthetic.starts_with("pos") { + scales::RangeKind::Position + } else { + scales::RangeKind::Number + } + } + }; + if let Some(hs) = build_scale(scale, kind) { + view.insert_scale(scale.aesthetic.clone(), hs); + } + } + + // Frame a map to its bounding box. Marks, clip boundary and graticules + // share one pre-projected data space, so the position scales must span the + // map's extent rather than the marks' or the data drifts off the boundary. + // A spatial layer has no `pos1`/`pos2` at all, so these are its only + // scales. The bbox is ggsql's, per the "never invent extents" principle, + // and it is the authority here rather than a resolved `pos1`/`pos2`: a map + // resolves those against the *graticule* extent in EPSG:4326, so their + // domain is in degrees while the data is in the target CRS. Any + // `SCALE lon`/`lat` limits are already folded into the bbox, and the breaks + // those scales carry arrive as projected geometry in `computed`. + let map_bbox = map_bbox(spec, data)?; + if let Some((xmin, ymin, xmax, ymax)) = map_bbox { + view.insert_scale("pos1".to_string(), scale::continuous(map_range(xmin, xmax))); + view.insert_scale("pos2".to_string(), scale::continuous(map_range(ymin, ymax))); + } + + // Collected from the first panel only and registered once on the + // composition's legend ring, so a faceted plot gets one shared legend. + // Every panel builds the same legends from the same global scales. + let legend_sink = std::cell::RefCell::new(Vec::new()); + let mut legends_captured = false; + + for panel in &panels { + // Slice each layer's data to this panel. A Grid cell with no matching + // rows still becomes a panel — framed, axed and strip-labelled, just + // without marks — so the grid stays rectangular (the ggplot2 look). + let slices: Vec<(&Layer, DataFrame)> = spec + .layers + .iter() + .enumerate() + .map(|(idx, layer)| { + Ok(( + layer, + facet::panel_dataframe(layer_dataframe(layer, idx, data)?, panel)?, + )) + }) + .collect::>()?; + let empty = slices.iter().all(|(_, df)| df.height() == 0); + + // Fixed dimensions bind the shared `pos1`/`pos2`; free ones get a + // per-panel scale over this panel's slices — the one place the writer + // computes an extent of its own. + let mut ps = facet::PanelScales::new(spec, panel); + let layer_dfs: Vec<&DataFrame> = slices.iter().map(|(_, df)| df).collect(); + if ps.free_x { + match scales::free_position_scale(spec.find_scale("pos1"), &layer_dfs, "pos1") { + Some(hs) => view.insert_scale(ps.pos1.clone(), hs), + // An empty cell has no extent to free the dimension over, so + // fall back to the shared scale rather than leave the axis and + // channels bound to a scale that was never inserted. + None => ps.use_shared("pos1"), + } + } + if ps.free_y { + match scales::free_position_scale(spec.find_scale("pos2"), &layer_dfs, "pos2") { + Some(hs) => view.insert_scale(ps.pos2.clone(), hs), + None => ps.use_shared("pos2"), + } + } + + // Build every layer's geom into this panel, in DRAW = z-order; geoms + // bind channels and record legends into `legend_sink`. An empty panel + // builds no geoms, so it cannot be the legend-capturing one. + let panel_legends = (!legends_captured).then_some(&legend_sink); + let mut plot = HPlot::new(&composition, panel.id.as_str()) + .shape_registry(ShapeRegistry::with_builtins()); + if !empty { + for (layer, df) in &slices { + let ctx = Ctx { + spec, + layer, + df, + transposed: is_transposed(layer), + pos1_scale: &ps.pos1, + pos2_scale: &ps.pos2, + legends: panel_legends, + }; + geom::build_into_plot(&mut plot, &ctx)?; + } + legends_captured = true; + } else { + // Grid lines come from the scales bound to the projection's + // channels, which a geom would normally bind. With no geoms, bind + // them here so an empty cell keeps its neighbours' grid. A position + // with no resolved scale stays unbound — binding one that was never + // registered fails validation. + for (channel, name) in [("x", &ps.pos1), ("y", &ps.pos2)] { + if view.scale(name).is_some() { + plot.set_binding(channel, name.clone()); + } + } + } + + // Axes are created per coordinate system, edge-only for fixed scales. + plot = apply_projection(plot, spec, panel, &ps); + + // Lock a map panel to square units so the projection keeps its + // proportions (a globe stays round). `aspect_ratio` is the data-space + // x-unit : y-unit ratio, not width:height — map coordinates arrive + // pre-projected, so one unit is the same length on both axes. + if map_bbox.is_some() { + plot = plot.aspect_ratio(1.0).aspect_mode(AspectMode::Range); + } + + // Facet strip labels (Wrap/Grid-column header on top, Grid-row on right). + if let Some(text) = &panel.strip_top { + plot = plot.strip(AxisSide::Top, text.clone()); + } + if let Some(text) = &panel.strip_right { + plot = plot.strip(AxisSide::Right, text.clone()); + } + + view.attach_plot(plot); + } + + // One shared legend for the whole composition (see `legend_sink` above). + for legend in legend_sink.into_inner() { + view.add_legend(legend); + } + + let issues = view.validate(); + if !issues.is_empty() { + return Err(GgsqlError::WriterError(format!( + "the plot renderer could not lay this plot out: {issues:?}" + ))); + } + Ok(view) +} + +/// The map bounding box `(xmin, ymin, xmax, ymax)`, or `None` when the plot is +/// not a map. ggsql's resolved `computed["bbox"]` (set under a `PROJECT map`) +/// wins; a bare `spatial` geom with no projection falls back to the union extent +/// of its geometry data. +fn map_bbox( + spec: &Plot, + data: &HashMap, +) -> Result> { + if let Some(proj) = &spec.project { + if let Some(ParameterValue::Array(arr)) = proj.computed.get("bbox") { + let nums: Vec = arr.iter().filter_map(|e| e.to_f64()).collect(); + if let [xmin, ymin, xmax, ymax] = nums[..] { + if [xmin, ymin, xmax, ymax].iter().all(|v| v.is_finite()) { + return Ok(Some((xmin, ymin, xmax, ymax))); + } + } + } + } + + let is_spatial = |layer: &Layer| layer.geom.geom_type() == GeomType::Spatial; + if !spec.layers.iter().any(is_spatial) { + return Ok(None); + } + + let geom_col = naming::aesthetic_column("geometry"); + let (mut xmin, mut ymin, mut xmax, mut ymax) = ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + for (idx, layer) in spec + .layers + .iter() + .enumerate() + .filter(|(_, l)| is_spatial(l)) + { + let df = layer_dataframe(layer, idx, data)?; + if df.column(&geom_col).is_err() { + continue; + } + for g in channels::column_to_geometry(df, &geom_col)? { + if let Some((x0, y0, x1, y1)) = g.bounds() { + xmin = xmin.min(x0); + ymin = ymin.min(y0); + xmax = xmax.max(x1); + ymax = ymax.max(y1); + } + } + } + Ok( + (xmin.is_finite() && ymin.is_finite() && xmax.is_finite() && ymax.is_finite()) + .then_some((xmin, ymin, xmax, ymax)), + ) +} + +/// A non-degenerate inclusive range for a map's continuous position scale. +/// +/// The extent is padded by [`MAP_PADDING`] around its centre, matching the +/// Vega-Lite writer, which fits the projection to `span * 1.1` centred on the +/// bbox (`vegalite/projection/map.rs`). An inverted extent is oriented first +/// and a zero-width one widened, so the scale can always map the result. +pub(super) fn map_range(min: f64, max: f64) -> std::ops::RangeInclusive { + // Orient first: `map_bbox` only checks its four numbers for finiteness, and + // padding a reversed bbox leaves a range no scale can map. + let (min, max) = if min <= max { (min, max) } else { (max, min) }; + let span = max - min; + if span > f64::EPSILON { + let pad = span * MAP_PADDING / 2.0; + (min - pad)..=(max + pad) + } else { + (min - 0.5)..=(max + 0.5) + } +} + +/// Look up the DataFrame backing a layer by its execution-assigned data key, +/// falling back to the conventional key for its index as the Vega-Lite writer +/// does. Execution always assigns the key; the fallback is for a hand-built +/// `Plot`. +pub(super) fn layer_dataframe<'a>( + layer: &Layer, + idx: usize, + data: &'a HashMap, +) -> Result<&'a DataFrame> { + let key = layer + .data_key + .clone() + .unwrap_or_else(|| naming::layer_key(idx)); + data.get(&key) + .ok_or_else(|| GgsqlError::WriterError(format!("no data found for layer key '{key}'"))) +} diff --git a/src/writer/hephaestus/facet.rs b/src/writer/hephaestus/facet.rs index 78ed23086..baf5733af 100644 --- a/src/writer/hephaestus/facet.rs +++ b/src/writer/hephaestus/facet.rs @@ -99,7 +99,7 @@ pub fn build_panels( let Some(facet) = &spec.facet else { return Ok(single_panel()); }; - let layer0 = super::layer_dataframe(&spec.layers[0], 0, data)?; + let layer0 = super::compose::layer_dataframe(&spec.layers[0], 0, data)?; match &facet.layout { FacetLayout::Wrap { .. } => build_wrap(spec, facet, layer0), FacetLayout::Grid { .. } => build_grid(spec, layer0), diff --git a/src/writer/hephaestus/geom/densified.rs b/src/writer/hephaestus/geom/densified.rs index ec4181949..3a179470c 100644 --- a/src/writer/hephaestus/geom/densified.rs +++ b/src/writer/hephaestus/geom/densified.rs @@ -40,7 +40,7 @@ pub fn build(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { build_and_add::(plot, polygon::spec(ctx), ctx) } other => Err(GgsqlError::WriterError(format!( - "png writer cannot draw a densified '{other}' geom" + "the plot renderer cannot draw a densified '{other}' geom" ))), } } diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index ad5cdc677..d1cda8905 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -60,7 +60,7 @@ pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { GeomType::Boxplot => boxplot::build(plot, ctx), GeomType::Violin => violin::build(plot, ctx), other => Err(GgsqlError::WriterError(format!( - "png writer does not support the '{other}' geom yet" + "the plot renderer does not support the '{other}' geom yet" ))), } } diff --git a/src/writer/hephaestus/hep.rs b/src/writer/hephaestus/hep.rs new file mode 100644 index 000000000..72cdfcfd3 --- /dev/null +++ b/src/writer/hephaestus/hep.rs @@ -0,0 +1,333 @@ +//! The `hep` plot-document writer. + +use std::collections::HashMap; + +use hephaestus::document::{ + unsupported_items_for, write_composition, UnsupportedItem, WriteOptions, +}; + +use super::canvas::Canvas; +use super::{compose, CANVAS_SIZE_OPTIONS}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, GgsqlError, Plot, Result}; + +/// Option keys [`HepWriter`] adds to the canvas set. +const HEP_OPTIONS: &[&str] = &["lossy", "embed-fonts"]; + +/// Writer that captures a ggsql plot as a self-contained **`.hep`** plot +/// document. +/// +/// Unlike every other writer here, this one produces no picture. It records the +/// resolved plot — scales, breaks, labels, theme, geometry and data channels — +/// so a consumer can render it itself at any size and re-render on resize +/// without going back to the query, which is what makes it the format for an +/// interactive host. +/// +/// The name is the format's; ggsql does not define `.hep`. Needs no GPU adapter +/// and no encoder — it serialises the composition the other writers draw. +/// +/// [`HepWriter::from_options`] takes: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width **hint**, in `units` | none | +/// | `height` | Canvas height **hint**, in `units` | none | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Resolution **hint** | none | +/// | `background` | Background a consumer should paint behind the plot | `white` | +/// | `lossy` | Drop what the format cannot carry instead of refusing | `false` | +/// | `embed-fonts` | Inline the font files the plot's text needs | `false` | +/// +/// The size is a hint, not a canvas: `width`/`height`/`dpi` record what a +/// consumer should default to rather than fixing anything. +/// +/// `lossy` decides what happens to a plot the format cannot fully carry. +/// Refusing is the default; with `lossy` on the same list comes back as +/// warnings from [`HepWriter::write_reporting`]. Nothing ggsql builds should +/// trip it, so a non-empty list is a bug here rather than a format limit. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct HepWriter { + canvas: Canvas, + /// Whether a size was asked for at all, since an unset hint and a hint that + /// happens to match the canvas default are different things to record. + size_asked: bool, + /// The same for `dpi`: the two are independent hints, so neither may record + /// the other on the caller's behalf. + dpi_asked: bool, + lossy: bool, + embed_fonts: bool, +} + +impl HepWriter { + /// A writer recording the given size and resolution as the consumer's + /// default. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + size_asked: true, + dpi_asked: true, + ..Self::default() + } + } + + /// Set the background a consumer should paint behind the plot. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Drop what the format cannot carry instead of refusing to write. + pub fn lossy(mut self, lossy: bool) -> Self { + self.lossy = lossy; + self + } + + /// Inline the font files the plot's text needs. + /// + /// Off by default: a system family is often megabytes, and a consumer that + /// can register its own fonts should. + pub fn embed_fonts(mut self, embed: bool) -> Self { + self.embed_fonts = embed; + self + } + + /// Write the document, reporting anything the format could not carry. + /// + /// With `lossy` off the same list is an error instead, so the report is + /// non-empty only when the caller asked to degrade. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed, if it + /// carries something the format cannot express and `lossy` is off, or if + /// serialising fails. + pub fn write_reporting( + &self, + spec: &Plot, + data: &HashMap, + ) -> Result<(Vec, Vec)> { + let view = compose::prepare(spec, data)?; + let options = self.options(); + + // Checked here rather than left to `write_composition` so the error is + // ggsql's own and names no renderer. The list is the same either way. + let problems = unsupported_items_for(&view, &options); + if !problems.is_empty() && !self.lossy { + return Err(GgsqlError::WriterError(format!( + "this plot cannot be captured as a document: {}. Pass lossy=true to write it \ + anyway, dropping what cannot be carried", + describe(&problems).join("; ") + ))); + } + + let bytes = write_composition(&view, &options) + .map_err(|e| GgsqlError::WriterError(format!("hep write failed: {e}")))?; + Ok((bytes, describe(&problems))) + } + + /// [`Self::write_reporting`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_reporting`]. + pub fn render_reporting(&self, spec: &crate::reader::Spec) -> Result<(Vec, Vec)> { + self.write_reporting(spec.plot(), spec.data()) + } + + /// The write options this writer's settings amount to. + /// + /// The canvas becomes hints. `embed_images` is left off: nothing ggsql + /// builds registers an image, so the option cannot take effect. + fn options(&self) -> WriteOptions { + let mut options = WriteOptions::default(); + options.lossy = self.lossy; + // Recorded unconditionally, unlike the size. `None` means unspecified + // rather than transparent, so a transparent canvas has to travel as a + // colour with zero alpha or a consumer paints white behind the plot. + options.background = Some(self.canvas.background); + options.size_hint = self + .size_asked + .then_some((self.canvas.width as f64, self.canvas.height as f64)); + options.dpi_hint = self.dpi_asked.then_some(self.canvas.dpi); + options.embed_fonts = self.embed_fonts; + options.embed_images = false; + options + } +} + +impl Writer for HepWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + let canvas = Canvas::from_options(options, HEP_OPTIONS)?; + // Each hint is recorded only when that hint was actually asked for. + let size_asked = CANVAS_SIZE_OPTIONS + .iter() + .any(|key| options.get(key).is_some()); + Ok(Self { + canvas, + size_asked, + dpi_asked: options.get("dpi").is_some(), + lossy: options.boolean("lossy")?.unwrap_or(false), + embed_fonts: options.boolean("embed-fonts")?.unwrap_or(false), + }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + self.write_reporting(spec, data).map(|(bytes, _)| bytes) + } +} + +/// Put what the format could not carry into ggsql's own words. +/// +/// As `svg::describe` and `pdf::describe`: the renderer's variants are +/// `#[non_exhaustive]`, and its `Display` text names renderer API and cargo +/// features a ggsql user cannot act on. Nothing ggsql builds should reach any +/// of these, so the wording aims at a writer bug rather than a format limit. +fn describe(problems: &[UnsupportedItem]) -> Vec { + problems + .iter() + .map(|problem| match problem { + UnsupportedItem::CustomFormatter { scale } => format!( + "the {scale} scale's tick labels are computed rather than listed, so a \ + consumer cannot reproduce them" + ), + UnsupportedItem::UnnameableGeom { patch, index } => format!( + "layer {} of panel {patch:?} is a mark the document cannot name, so nothing \ + records how to draw it again", + index + 1 + ), + UnsupportedItem::TrackReference { location } => format!( + "{location} is sized relative to another part of the layout, which only means \ + something while this figure is being laid out" + ), + UnsupportedItem::UnnameableShape { patch, name } => format!( + "the {name:?} marker on panel {patch:?} is a glyph with no source text, so a \ + consumer cannot rebuild it" + ), + UnsupportedItem::UnembeddableImage { patch, name } => format!( + "the {name:?} image on panel {patch:?} cannot be embedded by this build, so a \ + consumer would have to supply it" + ), + // Non-exhaustive upstream, so report a problem this build has no + // words for — without naming the renderer. + _ => "something in this plot cannot be captured as a document".to_string(), + }) + .collect() +} + +#[cfg(test)] +impl super::canvas::Canvased for HepWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::{ + assert_canvas_semantics, assert_transparent_background, + }; + + fn writer(pairs: &[&str]) -> Result { + HepWriter::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + assert_transparent_background::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let default = HepWriter::default(); + assert_eq!(writer(&[]).unwrap(), default); + assert!(!default.lossy); + assert!(!default.embed_fonts); + } + + #[test] + fn a_hint_is_recorded_only_when_that_hint_was_asked_for() { + // Any size works, so an unrecorded hint and a hint that happens to + // equal the default are different things. + let unset = writer(&[]).unwrap().options(); + assert_eq!(unset.size_hint, None); + assert_eq!(unset.dpi_hint, None); + + // The two are independent: neither implies the other, or the document + // would claim a default the caller never gave. + let sized = writer(&["width=1600", "height=900"]).unwrap().options(); + assert_eq!(sized.size_hint, Some((1600.0, 900.0))); + assert_eq!(sized.dpi_hint, None); + + let dense = writer(&["dpi=150"]).unwrap().options(); + assert_eq!(dense.size_hint, None); + assert_eq!(dense.dpi_hint, Some(150.0)); + + // A physical size resolves to pixels first, as it does everywhere — + // and needs the dpi to do it, so both are asked for here. + let physical = writer(&["width=6", "units=in", "dpi=100"]) + .unwrap() + .options(); + assert_eq!(physical.size_hint.map(|(w, _)| w), Some(600.0)); + assert_eq!(physical.dpi_hint, Some(100.0)); + } + + #[test] + fn a_background_is_always_recorded_transparent_included() { + // `None` means unspecified rather than transparent, so a transparent + // canvas travels as a colour with zero alpha. + assert_eq!( + writer(&[]).unwrap().options().background, + Some(Canvas::default().background) + ); + let transparent = writer(&["background=transparent"]).unwrap().options(); + let background = transparent.background.expect("recorded, not dropped"); + assert_eq!(background.components[3], 0.0); + } + + #[test] + fn a_problem_is_reported_without_naming_the_renderer() { + let problems = [ + UnsupportedItem::CustomFormatter { + scale: "pos1".into(), + }, + UnsupportedItem::UnnameableGeom { + patch: "panel".into(), + index: 0, + }, + ]; + for message in describe(&problems) { + for leak in ["with_named_format", "Geom::kind", "hephaestus"] { + assert!(!message.contains(leak), "{message} leaks {leak}"); + } + } + // And the layer is named in ggsql's own 1-based draw order. + assert!(describe(&problems[1..])[0].contains("layer 1")); + } + + #[test] + fn the_flags_take_the_boolean_spellings() { + assert!(writer(&["lossy=true"]).unwrap().lossy); + assert!(writer(&["lossy=yes"]).unwrap().lossy); + assert!(writer(&["embed-fonts=1"]).unwrap().embed_fonts); + assert!(writer(&["embed_fonts=on"]).unwrap().embed_fonts); + let err = writer(&["lossy=sometimes"]).unwrap_err().to_string(); + assert!(err.contains("'lossy' expects true or false"), "{err}"); + } + + #[test] + fn images_are_not_an_option_to_ask_for() { + // Nothing ggsql builds registers an image, so the setting provably + // cannot take effect and is not offered. + let err = writer(&["embed-images=true"]).unwrap_err().to_string(); + assert!(err.contains("unknown writer option"), "{err}"); + assert!(!writer(&[]).unwrap().options().embed_images); + } +} diff --git a/src/writer/hephaestus/jpeg.rs b/src/writer/hephaestus/jpeg.rs new file mode 100644 index 000000000..42d6d5d19 --- /dev/null +++ b/src/writer/hephaestus/jpeg.rs @@ -0,0 +1,217 @@ +//! The JPEG writer. + +use std::collections::HashMap; + +use hephaestus::image::encode_jpeg; + +use super::canvas::Canvas; +use super::{compose, raster, RasterRenderer}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, GgsqlError, Plot, Result}; + +/// Option keys [`JpegWriter`] adds to the shared canvas set. +const JPEG_OPTIONS: &[&str] = &["quality"]; + +/// Default JPEG quality. High enough that the ringing around a plot's thin dark +/// strokes and text stays out of the way, without being the pointless end of +/// the scale. +const DEFAULT_QUALITY: u8 = 90; + +/// Writer that renders a ggsql plot to a JPEG image. +/// +/// JPEG is the wrong codec for most plots: its ringing lands on exactly the thin +/// dark strokes and small text a plot is made of. Use it when something +/// downstream insists; `png` and `webp` are lossless and usually smaller on +/// plot content. +/// +/// [`JpegWriter::from_options`] takes: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any **opaque** CSS color | `white` | +/// | `quality` | 1–100; higher is larger and less lossy | 90 | +/// +/// `background` must be opaque: JPEG has no alpha channel, so a transparent +/// canvas has nowhere to go. Rather than silently composite the plot onto black, +/// the writer refuses the setting. +/// +/// Rendering requires a working wgpu adapter (hardware or software, e.g. +/// lavapipe) at render time. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct JpegWriter { + canvas: Canvas, + quality: u8, +} + +impl JpegWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + quality: DEFAULT_QUALITY, + } + } + + /// Set the background the plot is composited onto. + /// + /// Any alpha is ignored, since the format has no channel for it. + /// [`JpegWriter::from_options`] rejects a transparent `background` rather + /// than dropping it silently; a caller building the writer has chosen. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Set the quality, from 1 to 100. Values outside that range are clamped. + pub fn quality(mut self, quality: u8) -> Self { + self.quality = quality.clamp(1, 100); + self + } + + /// Render through a renderer the caller keeps, rather than building one. + /// + /// Constructing a [`RasterRenderer`] creates a GPU device and compiles the + /// rasteriser's shaders, so a host rendering more than one figure should + /// build one once and pass it here. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed, the + /// render fails, or the encode fails. + pub fn write_with( + &self, + spec: &Plot, + data: &HashMap, + renderer: &mut RasterRenderer, + ) -> Result> { + let pixels = raster::pixels(spec, data, &self.canvas, renderer)?; + encode_jpeg( + self.canvas.width, + self.canvas.height, + &pixels, + self.quality, + self.canvas.background, + self.canvas.dpi_hint(), + ) + .map_err(|e| GgsqlError::WriterError(format!("jpeg encode failed: {e}"))) + } + + /// [`Self::write_with`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_with`]. + pub fn render_with( + &self, + spec: &crate::reader::Spec, + renderer: &mut RasterRenderer, + ) -> Result> { + self.write_with(spec.plot(), spec.data(), renderer) + } +} + +impl Default for JpegWriter { + fn default() -> Self { + Self { + canvas: Canvas::default(), + quality: DEFAULT_QUALITY, + } + } +} + +impl Writer for JpegWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + let canvas = Canvas::from_options(options, JPEG_OPTIONS)?; + if canvas.background.components[3] < 1.0 { + return Err(GgsqlError::WriterError( + "writer option 'background' resolves to a translucent color, but jpeg has no \ + alpha channel; give an opaque background" + .to_string(), + )); + } + let quality = match options.number("quality")? { + Some(quality) if (1.0..=100.0).contains(&quality) => quality.round() as u8, + Some(quality) => { + return Err(GgsqlError::WriterError(format!( + "writer option 'quality' expects a number from 1 to 100, got '{quality}'" + ))) + } + None => DEFAULT_QUALITY, + }; + Ok(Self { canvas, quality }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + let mut renderer = RasterRenderer::new()?; + self.write_with(spec, data, &mut renderer) + } +} + +#[cfg(test)] +impl super::canvas::Canvased for JpegWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::assert_canvas_semantics; + + fn writer(pairs: &[&str]) -> Result { + JpegWriter::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let default = JpegWriter::default(); + assert_eq!(writer(&[]).unwrap(), default); + assert_eq!(default.quality, DEFAULT_QUALITY); + } + + #[test] + fn quality_spans_one_to_a_hundred() { + assert_eq!(writer(&["quality=1"]).unwrap().quality, 1); + assert_eq!(writer(&["quality=100"]).unwrap().quality, 100); + for bad in ["quality=0", "quality=101", "quality=-5"] { + let err = writer(&[bad]).unwrap_err().to_string(); + assert!( + err.contains("'quality' expects a number from 1 to 100"), + "{bad}: {err}" + ); + } + } + + #[test] + fn a_transparent_background_is_refused_rather_than_dropped() { + for spelling in [ + "background=none", + "background=transparent", + "background=#00000000", + ] { + let err = writer(&[spelling]).unwrap_err().to_string(); + assert!( + err.contains("jpeg has no alpha channel"), + "{spelling}: {err}" + ); + } + // An opaque color is fine, whichever way it is spelled. + assert!(writer(&["background=black"]).is_ok()); + } +} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 4d8edf7a1..73894f506 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -1,635 +1,169 @@ -//! PNG raster writer. +//! Renderer-backed writers. //! -//! Renders a resolved ggsql `Spec` to PNG bytes via the [`hephaestus`] 2D scene -//! renderer. Only [`PngWriter`] is public; the renderer behind it is an -//! implementation detail. +//! Every writer here renders a resolved ggsql `Spec` through the [`hephaestus`] +//! 2D scene renderer. Only the writers themselves are public; the renderer +//! behind them is an implementation detail, and this module is private. +//! +//! The work splits three ways, which is what keeps one writer per format small: +//! +//! - [`compose`] turns a `Plot` into a live `PlotComposition`. Format-independent, +//! and where nearly all the code is. +//! - [`canvas`] carries the size, resolution and background, and parses the +//! options they come from. +//! - [`raster`] rasterises a composition to pixels. **The only part that needs a +//! GPU adapter** — a vector writer builds a scene from the same composition +//! and never comes through here. +//! +//! A format's own module is then just its option parsing and one encoder call: +//! [`png`], [`jpeg`], [`tiff`], [`webp`]. What differs between them is the axis +//! each format actually has — PNG trades encode time for size, JPEG trades +//! quality for size, TIFF picks a compressor, and WebP is lossless with no rate +//! control at all — so they do not share a knob they would each have to +//! reinterpret. //! //! **Scope**: multi-layer plots under Cartesian, Polar, and Map projections, //! with `FACET` faceting (Wrap/Grid, fixed + free scales); every geom except -//! `arrow`; all scale types and transforms, material aesthetics, plot and axis -//! titles, and legends. A geom outside [`geom::is_supported`] is rejected by -//! [`PngWriter::validate`]. +//! `arrow`, which is a stub no writer implements; all scale types and +//! transforms, material aesthetics, plot and axis titles, and legends. //! //! Architecture — the abstractions and the invariants they keep — and the //! inventory of deferred work are documented in //! `src/writer/hephaestus/CLAUDE.md`. -//! -//! Rendering uses hephaestus's Vello (GPU) backend, so a working wgpu adapter -//! (hardware or software, e.g. lavapipe) is required at render time. +mod canvas; mod channels; +mod compose; mod facet; mod geom; mod projection; +#[cfg(feature = "raster-writer")] +mod raster; mod scales; +#[cfg(any(feature = "svg", feature = "pdf"))] +mod vector; mod wiring; -use std::collections::HashMap; +#[cfg(feature = "hep")] +mod hep; +#[cfg(feature = "jpeg")] +mod jpeg; +#[cfg(feature = "pdf")] +mod pdf; +#[cfg(feature = "png")] +mod png; +#[cfg(feature = "svg")] +mod svg; +#[cfg(feature = "tiff")] +mod tiff; +#[cfg(feature = "webp")] +mod webp; +#[cfg(feature = "window")] +mod window; -use hephaestus::backend::vello::VelloRenderer; pub use hephaestus::color::{rgba, Color}; -use hephaestus::geometry::Size; -use hephaestus::plot::{scale, AspectMode, Plot as HPlot, PlotComposition}; -use hephaestus::png::encode_png; -use hephaestus::scales::chrome::AxisSide; -use hephaestus::shape::ShapeRegistry; -use hephaestus::{Renderer, SceneBuilder}; - -use crate::naming; -use crate::plot::layer::geom::GeomType; -use crate::plot::layer::is_transposed; -use crate::plot::ParameterValue; -use crate::writer::hephaestus::projection::apply_projection; -use crate::writer::hephaestus::scales::build_scale; -use crate::writer::{Writer, WriterOptions}; -use crate::{DataFrame, GgsqlError, Layer, Plot, Result}; - -use wiring::Ctx; - -/// Default canvas width in pixels. -const DEFAULT_WIDTH: u32 = 1500; -/// Default canvas height in pixels. -const DEFAULT_HEIGHT: u32 = 1000; -/// Default resolution. DPI converts the theme's physical sizes (text, stroke -/// widths, spacing — all in points) to pixels, so it sets how large the chrome -/// is relative to the canvas as well as the print size of a physical figure. -const DEFAULT_DPI: f64 = 300.0; - -/// Largest canvas dimension accepted, in pixels. Far beyond any real figure, but -/// small enough that a slipped unit conversion fails with a message instead of -/// exhausting GPU memory. -const MAX_DIMENSION: f64 = 32_768.0; - -/// Fraction of a map's bounding-box span added as breathing room around it, so -/// marks on the boundary are not drawn against the panel edge. Matches the -/// Vega-Lite writer's projection fit (`span * 1.1`). -const MAP_PADDING: f64 = 0.1; - -/// Option keys [`PngWriter::from_options`] understands. -const OPTIONS: &[&str] = &["width", "height", "units", "dpi", "background"]; - -/// Units a `width` / `height` option may be given in. -const UNITS: &[&str] = &["px", "in", "cm", "mm", "pt"]; - -/// Writer that renders a ggsql plot to a PNG image. -/// -/// Configured with a target pixel size and DPI because raster rendering needs -/// concrete dimensions, unlike the resolution-independent Vega-Lite writer. -/// [`PngWriter::from_options`] builds the same configuration from -/// key–value [`WriterOptions`]: -/// -/// | Option | Value | Default | -/// | --- | --- | --- | -/// | `width` | Canvas width, in `units` | 1500 px | -/// | `height` | Canvas height, in `units` | 1000 px | -/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | -/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | -/// | `background` | Any CSS color, e.g. `white`, `#ff0000`, `transparent` | `white` | -#[derive(Debug, Clone, PartialEq)] -pub struct PngWriter { - width: u32, - height: u32, - dpi: f64, - background: Color, -} - -impl PngWriter { - /// Create a writer for the given pixel dimensions and DPI, white background. - pub fn new(width: u32, height: u32, dpi: f64) -> Self { - Self { - width, - height, - dpi, - background: rgba(1.0, 1.0, 1.0, 1.0), - } - } - - /// Set the background color used to clear the canvas before rendering. - pub fn background(mut self, color: Color) -> Self { - self.background = color; - self - } -} - -impl Default for PngWriter { - fn default() -> Self { - Self::new(DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI) - } -} - -impl Writer for PngWriter { - type Output = Vec; - fn from_options(options: &WriterOptions) -> Result { - options.reject_unknown(OPTIONS)?; - - let dpi = match options.number("dpi")? { - Some(dpi) if dpi > 0.0 => dpi, - Some(dpi) => { - return Err(GgsqlError::WriterError(format!( - "writer option 'dpi' expects a positive number, got '{dpi}'" - ))) - } - None => DEFAULT_DPI, - }; - // `units` interprets the dimensions the caller supplies; the defaults are - // pixel counts, so they stand whatever the unit is. - let units = options.one_of("units", UNITS)?.unwrap_or("px"); - let width = match options.number("width")? { - Some(width) => to_pixels(width, units, dpi, "width")?, - None => DEFAULT_WIDTH, - }; - let height = match options.number("height")? { - Some(height) => to_pixels(height, units, dpi, "height")?, - None => DEFAULT_HEIGHT, - }; - - let mut writer = Self::new(width, height, dpi); - if let Some(raw) = options.get("background") { - // `none` is a familiar spelling of a transparent canvas that CSS - // itself doesn't accept as a color. - let color = match raw.trim().to_lowercase().as_str() { - "none" => rgba(0.0, 0.0, 0.0, 0.0), - _ => scales::parse_color(raw).ok_or_else(|| { - GgsqlError::WriterError(format!( - "writer option 'background' expects a CSS color, got '{raw}'" - )) - })?, - }; - writer = writer.background(color); - } - Ok(writer) - } - - fn validate(&self, spec: &Plot) -> Result<()> { - if spec.layers.is_empty() { - return Err(GgsqlError::WriterError( - "png writer requires at least one layer".into(), - )); - } - for layer in &spec.layers { - let geom_type = layer.geom.geom_type(); - if !geom::is_supported(geom_type) { - return Err(GgsqlError::WriterError(format!( - "png writer does not support the '{geom_type}' geom yet" - ))); - } - } - Ok(()) - } - - fn write(&self, spec: &Plot, data: &HashMap) -> Result { - self.validate(spec)?; - - // FACET → a grid of named panels (a single panel when unfaceted). Each - // panel becomes one hephaestus `Plot` sharing the composition's scales. - let (composition, panels) = facet::build_panels(spec, data)?; - // The composition owns the shape registry backing composition-level legend - // glyphs (point markers, line dashes). - let mut view = PlotComposition::new(&composition) - .shape_registry(ShapeRegistry::with_builtins()) - .theme(wiring::ggsql_theme()); - - // Plot title/subtitle/caption from the LABEL clause. These live on the - // composition, not the per-panel plots, so one label spans the whole - // figure — which is also correct for the unfaceted 1x1 case (a plot-level - // title would resolve to the same layout row and be painted over). - if let Some(text) = wiring::plot_label(spec, "title") { - view = view.title(text); - } - if let Some(text) = wiring::plot_label(spec, "subtitle") { - view = view.subtitle(text); - } - if let Some(text) = wiring::plot_label(spec, "caption") { - view = view.caption(text); - } - - // Axis titles are composition chrome too: one centred title per - // dimension for the whole figure, rather than one per panel rail. - for (side, text) in projection::composition_axis_titles(spec) { - view = view.axis_title(side, text); - } - - // Register the fixed (shared) scales once, globally. Every panel binds - // its position channels to these names, giving fixed-scale faceting. - for scale in &spec.scales { - let kind = match scale.aesthetic.as_str() { - "fill" | "stroke" => scales::RangeKind::Color, - "shape" => scales::RangeKind::Shape, - "linetype" => scales::RangeKind::Linetype, - // The text geom's font aesthetics: a scale over them resolves a - // range of family names / weights, not numbers. - "typeface" => scales::RangeKind::Text, - "fontweight" => scales::RangeKind::FontWeight, - "italic" => scales::RangeKind::Bool, - _ => { - if scale.aesthetic.starts_with("pos") { - scales::RangeKind::Position - } else { - scales::RangeKind::Number - } - } - }; - if let Some(hs) = build_scale(scale, kind) { - view.insert_scale(scale.aesthetic.clone(), hs); - } - } - - // Frame a map to its bounding box. Under a `PROJECT map` every mark, the - // clip boundary and the graticules share one pre-projected data space, so - // the position scales must span the map's extent rather than the marks' - // — otherwise the data is zoomed in and drifts off the boundary. A - // spatial layer additionally has no `pos1`/`pos2` columns at all (it - // positions by geometry), so ggsql resolves no position scales for it and - // these are the only ones. The bbox comes from ggsql - // (`computed["bbox"]` when projected, else the geometry extent), keeping - // the "writer never invents extents" principle. - let map_bbox = map_bbox(spec, data)?; - if let Some((xmin, ymin, xmax, ymax)) = map_bbox { - view.insert_scale("pos1".to_string(), scale::continuous(map_range(xmin, xmax))); - view.insert_scale("pos2".to_string(), scale::continuous(map_range(ymin, ymax))); - } - - // Legends are collected from the first panel only and registered once on - // the composition's own legend ring, so a faceted plot gets a single shared - // legend rather than one per panel. Every panel produces the same legends - // (all built from the globally resolved scales), so one capture suffices. - let legend_sink = std::cell::RefCell::new(Vec::new()); - let mut legends_captured = false; - - for panel in &panels { - // Slice each layer's data to this panel. A Grid cell whose facet - // combination doesn't occur in the data still becomes a panel — framed, - // axed and strip-labelled like any other, just with no marks — so the - // grid stays rectangular and its strips keep describing every row and - // column (the ggplot2 look). - let slices: Vec<(&Layer, DataFrame)> = spec - .layers - .iter() - .enumerate() - .map(|(idx, layer)| { - Ok(( - layer, - facet::panel_dataframe(layer_dataframe(layer, idx, data)?, panel)?, - )) - }) - .collect::>()?; - let empty = slices.iter().all(|(_, df)| df.height() == 0); - - // Fixed dimensions bind the shared `pos1`/`pos2`; free dimensions get - // a per-panel scale whose domain is computed from this panel's slices - // (the one place the writer computes extents — free facets only). - let mut ps = facet::PanelScales::new(spec, panel); - let layer_dfs: Vec<&DataFrame> = slices.iter().map(|(_, df)| df).collect(); - if ps.free_x { - match scales::free_position_scale(spec.find_scale("pos1"), &layer_dfs, "pos1") { - Some(hs) => view.insert_scale(ps.pos1.clone(), hs), - // No panel extent to free the dimension over (an empty cell), - // so read the shared scale rather than leave the axis and the - // channel bindings pointing at a scale that was never inserted. - None => ps.use_shared("pos1"), - } - } - if ps.free_y { - match scales::free_position_scale(spec.find_scale("pos2"), &layer_dfs, "pos2") { - Some(hs) => view.insert_scale(ps.pos2.clone(), hs), - None => ps.use_shared("pos2"), - } - } - - // Build every layer's geom into this panel; geoms bind channels and - // record legends (first panel only) into `legend_sink`, drawing in - // layer (DRAW) = z-order. An empty panel builds no geoms — a hephaestus - // geom over zero rows has nothing to draw — and so must not count as - // the legend-capturing panel either. - let panel_legends = (!legends_captured).then_some(&legend_sink); - let mut plot = HPlot::new(&composition, panel.id.as_str()) - .shape_registry(ShapeRegistry::with_builtins()); - if !empty { - for (layer, df) in &slices { - let ctx = Ctx { - spec, - layer, - df, - transposed: is_transposed(layer), - pos1_scale: &ps.pos1, - pos2_scale: &ps.pos2, - legends: panel_legends, - }; - geom::build_into_plot(&mut plot, &ctx)?; - } - legends_captured = true; - } else { - // hephaestus draws a panel's grid lines from the scales bound to - // the projection's channels — which a geom would have bound. With - // no geoms to do it, bind the position channels here so an empty - // cell carries the same grid as its populated neighbours. A - // position ggsql resolved no scale for stays unbound, since a - // binding to an unregistered scale fails validation. - for (channel, name) in [("x", &ps.pos1), ("y", &ps.pos2)] { - if view.scale(name).is_some() { - plot.set_binding(channel, name.clone()); - } - } - } - - // Axes are created per coordinate system, edge-only for fixed scales. - plot = apply_projection(plot, spec, panel, &ps); - - // Lock a map panel to square units so the projection keeps its - // proportions (a globe stays round), the raster analog of the - // Vega-Lite writer's single uniform projection scale. - // - // `aspect_ratio` is the *data-space* x-unit : y-unit ratio, not a - // panel width:height ratio. Map coordinates arrive pre-projected, so - // one unit means the same length on both axes and the ratio is 1 — - // passing the bbox's own height/width instead stretches every map by - // exactly that factor. - if map_bbox.is_some() { - plot = plot.aspect_ratio(1.0).aspect_mode(AspectMode::Range); - } - - // Facet strip labels (Wrap/Grid-column header on top, Grid-row on right). - if let Some(text) = &panel.strip_top { - plot = plot.strip(AxisSide::Top, text.clone()); - } - if let Some(text) = &panel.strip_right { - plot = plot.strip(AxisSide::Right, text.clone()); - } - - view.attach_plot(plot); - } - - // One shared legend for the whole composition (see `legend_sink` above). - for legend in legend_sink.into_inner() { - view.add_legend(legend); - } - - let issues = view.validate(); - if !issues.is_empty() { - return Err(GgsqlError::WriterError(format!( - "png writer composition validation failed: {issues:?}" - ))); - } - - render_png( - &mut view, - self.width, - self.height, - self.dpi, - self.background, - ) - } -} - -/// Convert a canvas dimension given in `units` to whole pixels at `dpi`. -/// -/// A physical unit goes through inches, so the same figure grows with DPI; `px` -/// is already the canvas unit, where DPI only scales the chrome. -fn to_pixels(value: f64, units: &str, dpi: f64, key: &str) -> Result { - let per_inch = match units { - "in" => 1.0, - "cm" => 2.54, - "mm" => 25.4, - "pt" => 72.0, - _ => return whole_pixels(value, key), - }; - whole_pixels(value / per_inch * dpi, key) -} - -/// Round a pixel count and reject one outside the renderable range. -fn whole_pixels(pixels: f64, key: &str) -> Result { - let rounded = pixels.round(); - if !(1.0..=MAX_DIMENSION).contains(&rounded) { - return Err(GgsqlError::WriterError(format!( - "writer option '{key}' resolves to {rounded} px, outside the supported range 1–{MAX_DIMENSION} px" - ))); - } - Ok(rounded as u32) -} - -/// The map bounding box `(xmin, ymin, xmax, ymax)`, or `None` when the plot is -/// not a map. ggsql's resolved `computed["bbox"]` (set under a `PROJECT map`) -/// wins; a bare `spatial` geom with no projection falls back to the union extent -/// of its geometry data. -fn map_bbox( - spec: &Plot, - data: &HashMap, -) -> Result> { - if let Some(proj) = &spec.project { - if let Some(ParameterValue::Array(arr)) = proj.computed.get("bbox") { - let nums: Vec = arr.iter().filter_map(|e| e.to_f64()).collect(); - if let [xmin, ymin, xmax, ymax] = nums[..] { - if [xmin, ymin, xmax, ymax].iter().all(|v| v.is_finite()) { - return Ok(Some((xmin, ymin, xmax, ymax))); - } - } - } - } - - let is_spatial = |layer: &Layer| layer.geom.geom_type() == GeomType::Spatial; - if !spec.layers.iter().any(is_spatial) { - return Ok(None); - } - - let geom_col = naming::aesthetic_column("geometry"); - let (mut xmin, mut ymin, mut xmax, mut ymax) = ( - f64::INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, - f64::NEG_INFINITY, - ); - for (idx, layer) in spec - .layers - .iter() - .enumerate() - .filter(|(_, l)| is_spatial(l)) - { - let df = layer_dataframe(layer, idx, data)?; - if df.column(&geom_col).is_err() { - continue; - } - for g in channels::column_to_geometry(df, &geom_col)? { - if let Some((x0, y0, x1, y1)) = g.bounds() { - xmin = xmin.min(x0); - ymin = ymin.min(y0); - xmax = xmax.max(x1); - ymax = ymax.max(y1); - } - } - } - Ok( - (xmin.is_finite() && ymin.is_finite() && xmax.is_finite() && ymax.is_finite()) - .then_some((xmin, ymin, xmax, ymax)), - ) -} - -/// A non-degenerate inclusive range for a map's continuous position scale. -/// -/// The extent is padded by [`MAP_PADDING`] around its centre, matching the -/// Vega-Lite writer, which fits the projection to `span * 1.1` centred on the -/// bbox (`vegalite/projection/map.rs`). A zero-width or inverted extent is -/// widened instead, so the scale can still map it. -fn map_range(min: f64, max: f64) -> std::ops::RangeInclusive { - let span = max - min; - if span > f64::EPSILON { - let pad = span * MAP_PADDING / 2.0; - (min - pad)..=(max + pad) - } else { - (min - 0.5)..=(max + 0.5) - } -} - -/// Look up the DataFrame backing a layer by its execution-assigned data key, -/// falling back to the conventional key for its index as the Vega-Lite writer -/// does. Execution always assigns the key; the fallback is for a hand-built -/// `Plot`. -fn layer_dataframe<'a>( - layer: &Layer, - idx: usize, - data: &'a HashMap, -) -> Result<&'a DataFrame> { - let key = layer - .data_key - .clone() - .unwrap_or_else(|| naming::layer_key(idx)); - data.get(&key) - .ok_or_else(|| GgsqlError::WriterError(format!("no data found for layer key '{key}'"))) -} - -/// Render the composition to an RGBA8 buffer and encode it as PNG bytes. -fn render_png( - view: &mut PlotComposition, - width: u32, - height: u32, - dpi: f64, - background: Color, -) -> Result> { - let mut renderer = VelloRenderer::new().map_err(|e| { - GgsqlError::WriterError(format!("could not initialise the GPU renderer: {e}")) - })?; - { - let scene = renderer.scene(); - scene.clear(); - view.render(scene, Size::new(width as f64, height as f64), dpi); - } - let mut pixels = vec![0u8; (width as usize) * (height as usize) * 4]; - renderer - .render_to_buffer(width, height, background, &mut pixels) - .map_err(|e| GgsqlError::WriterError(format!("png render failed: {e}")))?; - - // `render_to_buffer` hands out straight (un-premultiplied) alpha, which is - // exactly what PNG stores, so the buffer encodes as-is. - encode_png(width, height, &pixels) - .map_err(|e| GgsqlError::WriterError(format!("PNG encode failed: {e}"))) -} - -/// `from_options` tests. Separate from the render suite below because they need -/// neither a reader nor a GPU. -#[cfg(test)] -mod option_tests { +pub use canvas::Canvas; +#[cfg(feature = "hep")] +use canvas::CANVAS_SIZE_OPTIONS; + +#[cfg(feature = "raster-writer")] +pub use raster::{RasterRenderer, MAX_RASTER_DIMENSION}; + +#[cfg(feature = "hep")] +pub use hep::HepWriter; +#[cfg(feature = "jpeg")] +pub use jpeg::JpegWriter; +#[cfg(feature = "pdf")] +pub use pdf::PdfWriter; +#[cfg(feature = "png")] +pub use png::PngWriter; +#[cfg(feature = "svg")] +pub use svg::SvgWriter; +#[cfg(feature = "tiff")] +pub use tiff::{TiffCompression, TiffWriter}; +#[cfg(feature = "webp")] +pub use webp::WebpWriter; +#[cfg(feature = "window")] +pub use window::PlotViewer; + +// Re-exported so a caller can name the setting without depending on the +// renderer crate; the variants are the format's own vocabulary. +#[cfg(feature = "png")] +pub use hephaestus::png::PngCompression; + +// The shared corpus: every `renders_*` test is one query the composition layer +// must handle, driven through every writer this build has. The vector writers +// need no GPU adapter, so they run in CI; the raster assertion skips without one. +#[cfg(all( + test, + feature = "duckdb", + any(feature = "png", feature = "svg", feature = "pdf") +))] +mod tests { use super::*; - - fn writer(pairs: &[&str]) -> Result { - PngWriter::from_options(&WriterOptions::parse(pairs)?) - } - - /// The writer's canvas as `(width, height, dpi)`. - fn canvas(pairs: &[&str]) -> (u32, u32, f64) { - let writer = writer(pairs).unwrap(); - (writer.width, writer.height, writer.dpi) - } - - #[test] - fn no_options_gives_the_defaults() { - assert_eq!(canvas(&[]), (DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_DPI)); - let default = PngWriter::default(); - assert_eq!(canvas(&[]), (default.width, default.height, default.dpi)); - // White, as `new()` sets it. - let background = writer(&[]).unwrap().background; - assert_eq!(background.components, [1.0, 1.0, 1.0, 1.0]); - } - - #[test] - fn pixel_dimensions_are_taken_verbatim() { - assert_eq!(canvas(&["width=1600", "height=1200"]).0, 1600); - assert_eq!(canvas(&["width=1600", "height=1200"]).1, 1200); - // `units=px` is the default, and DPI does not rescale a pixel canvas. - assert_eq!( - canvas(&["width=800", "units=px", "dpi=72"]), - (800, 1000, 72.0) - ); - } - - #[test] - fn physical_dimensions_scale_with_dpi() { - assert_eq!( - canvas(&["width=8", "height=6", "units=in", "dpi=100"]).0, - 800 - ); - assert_eq!( - canvas(&["width=8", "height=6", "units=in", "dpi=100"]).1, - 600 - ); - // 2.54 cm = 1 in; 25.4 mm = 1 in; 72 pt = 1 in. - assert_eq!(canvas(&["width=2.54", "units=cm", "dpi=96"]).0, 96); - assert_eq!(canvas(&["width=25.4", "units=mm", "dpi=96"]).0, 96); - assert_eq!(canvas(&["width=72", "units=pt", "dpi=96"]).0, 96); - // Defaults stay pixel counts even when the caller works in inches. - assert_eq!( - canvas(&["width=5", "units=in", "dpi=200"]).1, - DEFAULT_HEIGHT - ); - } - - #[test] - fn background_accepts_css_colors() { - let red = writer(&["background=#ff0000"]).unwrap().background; - assert_eq!(red.components, [1.0, 0.0, 0.0, 1.0]); - for spelling in ["background=transparent", "background=none"] { - let clear = writer(&[spelling]).unwrap().background; - assert_eq!( - clear.components[3], 0.0, - "{spelling} should be fully transparent" + use crate::reader::{DuckDBReader, Reader}; + // Only the raster branch of `assert_renders` calls a trait method; the + // vector writers report through their own inherent `render_reporting`. + #[cfg(feature = "png")] + use crate::writer::Writer; + use crate::GgsqlError; + #[cfg(feature = "png")] + use crate::Result; + use hephaestus::scales::chrome::AxisSide; + + /// The canvas every corpus render uses. Small, since none of these tests + /// look at the picture — only that the whole pipeline ran. + const CORPUS_SIZE: (u32, u32, f64) = (640, 480, 96.0); + + fn spec_for(query: &str) -> crate::reader::Spec { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader.execute(query).unwrap() + } + + /// Render `query` through every compiled writer, asserting each output + /// carries its own format's signature and that nothing was degraded. + /// + /// The vector assertions never skip, so they keep proving the composition + /// built and drew on a machine where the raster one can't. Warnings must be + /// empty: ggsql draws nothing a vector format cannot express. + fn assert_renders(query: &str) { + let (w, h, dpi) = CORPUS_SIZE; + let spec = spec_for(query); + + #[cfg(feature = "svg")] + { + let (svg, warnings) = SvgWriter::new(w, h, dpi) + .render_reporting(&spec) + .unwrap_or_else(|e| panic!("svg render failed: {e}")); + assert!(svg.starts_with(""); + assert!( + svg.contains(""), + "svg output should be closed: {}", + &svg[..svg.len().min(200)] ); + assert!( + svg.matches(" 0, + "an svg with no drew nothing" + ); + assert!(warnings.is_empty(), "svg degraded the plot: {warnings:?}"); } - assert!(writer(&["background=rgb(0, 0, 255)"]).is_ok()); - } - #[test] - fn bad_values_are_reported_per_option() { - let cases = [ - ("units=furlongs", "'units' expects"), - ("dpi=0", "'dpi' expects a positive number"), - ("dpi=high", "'dpi' expects a number"), - ("width=0", "'width' resolves to 0 px"), - ("width=-4", "'width' resolves to -4 px"), - ("height=1e9", "'height' resolves to"), - ("background=nope", "'background' expects a CSS color"), - ]; - for (option, expected) in cases { - let err = writer(&[option]).unwrap_err().to_string(); - assert!(err.contains(expected), "{option}: {err}"); + #[cfg(feature = "pdf")] + { + let (pdf, warnings) = PdfWriter::new(w, h, dpi) + .render_reporting(&spec) + .unwrap_or_else(|e| panic!("pdf render failed: {e}")); + assert!(pdf.starts_with(b"%PDF-"), "pdf output should be a PDF"); + assert!( + pdf.ends_with(b"%%EOF\n") || pdf.ends_with(b"%%EOF"), + "pdf output should be terminated" + ); + assert!(warnings.is_empty(), "pdf degraded the plot: {warnings:?}"); } - } - - #[test] - fn unknown_options_are_rejected() { - let err = writer(&["with=1600"]).unwrap_err().to_string(); - assert!(err.contains("unknown writer option 'with'"), "{err}"); - assert!(err.contains("supported options: width, height"), "{err}"); - } -} - -#[cfg(all(test, feature = "duckdb"))] -mod tests { - use super::*; - use crate::reader::{DuckDBReader, Reader}; - fn render(query: &str) -> Result> { - let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader.execute(query).unwrap(); - PngWriter::new(640, 480, 96.0).render(&spec) + // Last, and the only one that tolerates a headless box. + #[cfg(feature = "png")] + assert_png_or_skip(PngWriter::new(w, h, dpi).render(&spec)); } /// The panels' `(top, right)` strip labels, in panel order. Exercises the @@ -660,6 +194,7 @@ mod tests { } /// Assert a PNG was produced, tolerating headless CI with no GPU adapter. + #[cfg(feature = "png")] fn assert_png_or_skip(result: Result>) { match result { Ok(png) => assert!( @@ -675,154 +210,150 @@ mod tests { #[test] fn renders_basic_point_plot() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 UNION ALL SELECT 3, 1 \ VISUALISE x AS x, y AS y DRAW point", - )); + ); } #[test] fn renders_categorical_color_with_legend() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS grp UNION ALL SELECT 2, 3, 'b' \ UNION ALL SELECT 3, 1, 'a' \ VISUALISE x AS x, y AS y, grp AS color DRAW point", - )); + ); } #[test] fn renders_continuous_size() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 10 AS w UNION ALL SELECT 2, 3, 40 \ UNION ALL SELECT 3, 1, 90 \ VISUALISE x AS x, y AS y, w AS size DRAW point", - )); + ); } #[test] fn renders_shape_legend() { // A non-color legend key must be given a color to paint, else the // swatches come out empty next to their labels. - assert_png_or_skip(render( + assert_renders( "SELECT x, y, g FROM (VALUES (1,2,'a'),(2,3,'b'),(3,1,'c')) t(x,y,g) \ VISUALISE x AS x, y AS y, g AS shape DRAW point", - )); + ); } #[test] fn renders_linetype_legend() { - assert_png_or_skip(render( + assert_renders( "SELECT x, y, g FROM (VALUES (1,2,'a'),(2,3,'a'),(1,1,'b'),(2,2,'b')) t(x,y,g) \ VISUALISE x AS x, y AS y, g AS linetype DRAW line", - )); + ); } - /// An identity column is a per-row literal, so a `linetype` column holds ggsql - /// names or hex patterns and must go through `map_linetype` exactly as the - /// literal does — the channel takes dash patterns, not strings, so passing the - /// names through drew a solid line. + /// An identity `linetype` column holds ggsql names or hex patterns, so it must + /// go through `map_linetype` like a literal — the channel takes dash patterns. #[test] fn renders_identity_linetype() { - assert_png_or_skip(render( + assert_renders( "SELECT x, y, lt FROM (VALUES (1,2,'dashed'),(2,3,'dashed'),(1,1,'dotted'),(2,2,'dotted')) t(x,y,lt) \ - VISUALISE x AS x, y AS y, lt AS linetype DRAW line SCALE IDENTITY linetype", - )); + VISUALISE x AS x, y AS y, lt AS linetype DRAW line SCALE IDENTITY linetype",); } #[test] fn renders_colorbar_beside_size_legend() { - // Two distinct scales: a merged colorbar for `color` plus a keyed size - // legend whose glyphs fall back to a neutral color (the mapped `fill` - // column holds domain values, not a constant to borrow). - assert_png_or_skip(render( + // A merged colorbar for `color` beside a keyed size legend whose glyphs + // fall back to a neutral color (`fill` holds domain values, not a constant). + assert_renders( "SELECT x, y, c, w FROM (VALUES (1,2,10,100),(2,3,50,200),(3,1,90,300)) t(x,y,c,w) \ VISUALISE x AS x, y AS y, c AS color, w AS size DRAW point", - )); + ); } #[test] fn renders_log_scale() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y UNION ALL SELECT 10, 3 UNION ALL SELECT 100, 1 \ VISUALISE x AS x, y AS y DRAW point SCALE x VIA log", - )); + ); } #[test] fn renders_grouped_line() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS g UNION ALL SELECT 2, 3, 'a' \ UNION ALL SELECT 1, 1, 'b' UNION ALL SELECT 2, 2, 'b' \ VISUALISE x AS x, y AS y, g AS color DRAW line", - )); + ); } #[test] fn renders_bar() { - assert_png_or_skip(render( + assert_renders( "SELECT 'a' AS cat, 3 AS v UNION ALL SELECT 'b', 5 UNION ALL SELECT 'c', 2 \ VISUALISE cat AS x, v AS y DRAW bar", - )); + ); } #[test] fn renders_dodged_bar() { - assert_png_or_skip(render( + assert_renders( "SELECT x, grp, v FROM (VALUES ('a','p',3),('a','q',5),('b','p',2),('b','q',4)) \ t(x, grp, v) \ VISUALISE x AS x, v AS y, grp AS fill DRAW bar SETTING position => 'dodge'", - )); + ); } #[test] fn renders_histogram() { - assert_png_or_skip(render( + assert_renders( "SELECT x FROM (VALUES (1),(2),(2),(3),(3),(3),(4),(4),(5)) t(x) \ VISUALISE x AS x DRAW histogram", - )); + ); } #[test] fn renders_area() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 4 UNION ALL SELECT 3, 3 \ VISUALISE x AS x, y AS y DRAW area", - )); + ); } #[test] fn renders_ribbon() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS lo, 3 AS hi UNION ALL SELECT 2, 2, 5 \ UNION ALL SELECT 3, 1, 4 \ VISUALISE x AS x, lo AS ymin, hi AS ymax DRAW ribbon", - )); + ); } #[test] fn renders_segment() { - assert_png_or_skip(render( + assert_renders( "SELECT 0 AS x, 0 AS y, 1 AS xend, 2 AS yend UNION ALL SELECT 1, 1, 2, 0 \ VISUALISE x AS x, y AS y, xend AS xend, yend AS yend DRAW segment", - )); + ); } #[test] fn renders_text() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'hi' AS lab UNION ALL SELECT 2, 3, 'there' \ VISUALISE x AS x, y AS y, lab AS label DRAW text", - )); + ); } #[test] fn renders_text_styled() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS y, 'a' AS lab UNION ALL SELECT 2, 2, 'Hello' \ UNION ALL SELECT 3, 3, 'z' \ VISUALISE x AS x, y AS y, lab AS label, 30 AS rotation, \ 'bold' AS fontweight, 22 AS fontsize DRAW text", - )); + ); } /// A scaled `fontsize` on a layer whose face is set: the legend key is @@ -830,210 +361,208 @@ mod tests { /// `weight` / `italic` / `angle` all have to reach it. #[test] fn renders_text_font_legend() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS y, 'a' AS lab, 10 AS sz UNION ALL SELECT 2, 2, 'b', 20 \ UNION ALL SELECT 3, 3, 'c', 30 \ VISUALISE x AS x, y AS y, lab AS label, sz AS fontsize \ DRAW text SETTING typeface => 'Times New Roman', fontweight => 'bold', \ italic => true, rotation => 20 SCALE fontsize TO (10, 30)", - )); + ); } /// A label carrying markdown: `parse` defaults on, so the row goes through /// hephaestus's rich-text shaper rather than being drawn with its markers. #[test] fn renders_text_markdown() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS y, '**bold** and {.red red}' AS lab \ UNION ALL SELECT 2, 2, '`code` and ~~strike~~' \ VISUALISE x AS x, y AS y, lab AS label DRAW text", - )); + ); } /// `SETTING parse => false` opts the layer out, drawing the markers literally. #[test] fn renders_text_markdown_off() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS y, '**bold** and {.red red}' AS lab \ VISUALISE x AS x, y AS y, lab AS label DRAW text SETTING parse => false", - )); + ); } /// The glyph outline survives the markdown path: hephaestus folds the row's /// `text_stroke` onto the rich sheet's root selector rather than dropping it. #[test] fn renders_text_markdown_with_stroke() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS y, '**bold**' AS lab \ VISUALISE x AS x, y AS y, lab AS label \ DRAW text SETTING fontsize => 30, stroke => 'red', rotation => 20", - )); + ); } /// Markdown chrome: a `LABEL` string is rich text too, so the title, subtitle, /// caption and axis titles all shape through the rich pipeline. #[test] fn renders_markdown_chrome() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 \ VISUALISE x AS x, y AS y DRAW point \ LABEL title => 'A **bold** title', subtitle => '{.red red} subtitle', \ caption => '*italic* caption', x => 'axis *italic*'", - )); + ); } /// The same aesthetics as *columns*, which take the identity path rather than /// the literal one: strings, booleans and degrees, each converted per row. #[test] fn renders_text_mapped_font() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 1 AS y, 'a' AS lab, 'Times New Roman' AS face, 'bold' AS wt, \ true AS it, 0 AS rot \ UNION ALL SELECT 2, 2, 'b', 'Helvetica', 'light', false, 45 \ VISUALISE x AS x, y AS y, lab AS label, face AS typeface, wt AS fontweight, \ it AS italic, rot AS rotation DRAW text", - )); + ); } #[test] fn renders_polygon() { - assert_png_or_skip(render( + assert_renders( "SELECT x, y FROM (VALUES (0,0),(2,0),(1,2)) t(x, y) \ VISUALISE x AS x, y AS y DRAW polygon", - )); + ); } #[test] fn renders_boxplot() { - assert_png_or_skip(render( + assert_renders( "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2),('a',20), \ ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ VISUALISE g AS x, y AS y DRAW boxplot", - )); + ); } #[test] fn renders_boxplot_fill_by_group() { - assert_png_or_skip(render( + assert_renders( "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2), \ ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ VISUALISE g AS x, y AS y, g AS fill DRAW boxplot", - )); + ); } #[test] fn renders_diagonal_rule() { - assert_png_or_skip(render( + assert_renders( "SELECT 0 AS i VISUALISE i AS y DRAW rule \ SETTING slope => 1 SCALE x FROM (0, 10) SCALE y FROM (0, 10)", - )); + ); // The dash pattern is honored on the computed segment. - assert_png_or_skip(render( + assert_renders( "SELECT 0 AS i VISUALISE i AS y DRAW rule \ SETTING slope => 1, linetype => 'dashed', linewidth => 2 \ SCALE x FROM (0, 10) SCALE y FROM (0, 10)", - )); + ); // One line per row: three intercepts → three parallel lines. - assert_png_or_skip(render( + assert_renders( "SELECT * FROM (VALUES (0),(2),(4)) t(i) VISUALISE i AS y DRAW rule \ SETTING slope => 1 SCALE x FROM (0, 10) SCALE y FROM (0, 15)", - )); + ); } #[test] fn renders_multiple_diagonal_rules() { - // Per-row slope + intercept + a data-mapped material aesthetic: three - // differently-sloped, differently-colored ablines over a scatter (the - // Vega-Lite writer's `test_rule_renderer_multiple_diagonal_lines` query). - assert_png_or_skip(render( + // Per-row slope + intercept + a mapped color: three differently-sloped + // ablines over a scatter. + assert_renders( "WITH points AS (SELECT * FROM (VALUES (0, 5), (5, 15), (10, 25)) t(x, y)), \ lines AS (SELECT * FROM (VALUES (2, 5, 'A'), (1, 10, 'B'), (3, 0, 'C')) \ t(slope, y, line_id)) \ SELECT * FROM points VISUALISE \ DRAW point MAPPING x AS x, y AS y \ DRAW rule MAPPING slope AS slope, y AS y, line_id AS color FROM lines", - )); + ); } #[test] fn renders_constant_aesthetics() { // Constant material values from `SETTING` arrive as `AestheticValue::Literal` // and must be honored (color/size on points, linetype/linewidth on a line). - assert_png_or_skip(render( + assert_renders( "SELECT * FROM (VALUES (1,1),(2,3),(3,2)) t(a,b) \ VISUALISE a AS x, b AS y DRAW point SETTING color => 'red', size => 8", - )); - assert_png_or_skip(render( + ); + assert_renders( "SELECT * FROM (VALUES (1,1),(2,3),(3,2)) t(a,b) \ VISUALISE a AS x, b AS y DRAW line \ SETTING color => 'steelblue', linetype => 'dashed', linewidth => 2", - )); + ); } #[test] fn renders_multilayer_point_line() { // Two layers share one pair of axes / position scales. - assert_png_or_skip(render( + assert_renders( "SELECT * FROM (VALUES (1,2),(2,4),(3,5),(4,4),(5,7)) t(a,b) \ VISUALISE a AS x, b AS y DRAW point DRAW line", - )); + ); } #[test] fn renders_multilayer_overlay() { // Bar + point overlay (point drawn over bar) over a shared discrete x. - assert_png_or_skip(render( + assert_renders( "SELECT g, b FROM (VALUES ('a',2),('b',4),('c',5),('d',3)) t(g,b) \ VISUALISE g AS x, b AS y DRAW bar DRAW point SETTING color => 'red'", - )); + ); } #[test] fn renders_multilayer_abline() { // A diagonal reference line overlaid on a scatter spans the shared // resolved x/y domain. - assert_png_or_skip(render( + assert_renders( "SELECT * FROM (VALUES (1,2),(2,4),(3,5),(4,4),(5,7)) t(a,b) \ VISUALISE a AS x, b AS y DRAW point PLACE rule SETTING slope => 1, y => 0", - )); + ); } #[test] fn renders_multilayer_shared_legend() { // Two layers both colored by the same variable → one collapsed legend. - assert_png_or_skip(render( + assert_renders( "SELECT g, a, b FROM (VALUES ('p',1,2),('p',2,4),('q',3,5),('q',4,4)) t(g,a,b) \ VISUALISE a AS x, b AS y, g AS color DRAW point DRAW line", - )); + ); } #[test] fn renders_boxplot_styled() { - assert_png_or_skip(render( + assert_renders( "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2), \ ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ VISUALISE g AS x, y AS y, 'navy' AS stroke DRAW boxplot", - )); + ); } #[test] fn renders_boxplot_stroke_by_group() { // Data-mapped stroke colors every component (box/whisker/median/outlier) // per group and registers one collapsed legend. - assert_png_or_skip(render( + assert_renders( "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2),('a',40), \ ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ VISUALISE g AS x, y AS y, g AS stroke DRAW boxplot", - )); + ); } #[test] fn renders_tile_sized() { // `width`/`height` settings shrink discrete tiles within their band. - assert_png_or_skip(render( + assert_renders( "SELECT a, b, v FROM (VALUES ('x','p',1),('y','q',2),('x','q',3),('y','p',4)) t(a,b,v) \ - VISUALISE a AS x, b AS y, v AS fill DRAW tile SETTING width => 0.5, height => 0.5", - )); + VISUALISE a AS x, b AS y, v AS fill DRAW tile SETTING width => 0.5, height => 0.5",); } #[test] @@ -1042,122 +571,120 @@ mod tests { // banded on one axis and spanned by extents on the other. let data = "SELECT c, n, v FROM (VALUES ('x',1.0,1),('y',2.0,2),('x',2.0,3),('y',1.0,4)) t(c,n,v)"; - assert_png_or_skip(render(&format!( + assert_renders(&format!( "{data} VISUALISE c AS x, n AS y, v AS fill DRAW tile" - ))); - assert_png_or_skip(render(&format!( + )); + assert_renders(&format!( "{data} VISUALISE n AS x, c AS y, v AS fill DRAW tile" - ))); + )); } #[test] fn renders_text_keyword_justification_column() { // A `vjust` column of keywords is read as keywords: casting it to numbers // first would silently make every anchor NaN. - assert_png_or_skip(render( + assert_renders( "SELECT x, y, l, j FROM (VALUES (1,1,'one','top'),(2,2,'two','bottom')) t(x,y,l,j) \ VISUALISE x AS x, y AS y, l AS label, j AS vjust DRAW text", - )); + ); } #[test] fn renders_violin() { - assert_png_or_skip(render( + assert_renders( "SELECT g, y FROM (VALUES ('a',1),('a',5),('a',3),('a',9),('a',2), \ ('b',4),('b',6),('b',5),('b',7),('b',3)) t(g, y) \ VISUALISE g AS x, y AS y DRAW violin", - )); + ); } #[test] fn renders_polar_pie() { - // A stacked bar under polar becomes a pie: pos2 (count) → theta, - // pos1 (dummy) → radius. Includes a 180° slice, which exercises the - // wide-wedge path. - assert_png_or_skip(render( + // A stacked bar under polar becomes a pie: pos2 (count) → theta, pos1 + // (dummy) → radius. The 180° slice exercises the wide-wedge path. + assert_renders( "SELECT c FROM (VALUES ('a'),('a'),('a'),('b'),('b'),('c')) t(c) \ VISUALISE c AS fill DRAW bar PROJECT TO polar", - )); + ); } #[test] fn renders_polar_donut() { // `inner` opens a centre hole (donut). - assert_png_or_skip(render( + assert_renders( "SELECT c FROM (VALUES ('a'),('a'),('a'),('b'),('b'),('c')) t(c) \ VISUALISE c AS fill DRAW bar PROJECT TO polar SETTING inner => 0.5", - )); + ); } #[test] fn renders_wrap_facet() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS g UNION ALL SELECT 2, 3, 'b' \ UNION ALL SELECT 3, 1, 'a' UNION ALL SELECT 4, 5, 'c' \ VISUALISE x AS x, y AS y DRAW point FACET g", - )); + ); } #[test] fn renders_grid_facet() { - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS r, 'p' AS c UNION ALL SELECT 2, 3, 'b', 'p' \ UNION ALL SELECT 3, 1, 'a', 'q' UNION ALL SELECT 4, 5, 'b', 'q' \ VISUALISE x AS x, y AS y DRAW point FACET r BY c", - )); + ); } #[test] fn renders_sparse_grid_facet() { - // A grid whose row × column combinations are not all present: the absent - // cells are still drawn — framed, gridded, axed and strip-labelled — so the - // grid stays rectangular. `('b','q')` has no rows here. - assert_png_or_skip(render( + // `('b','q')` has no rows, but the cell is still framed, gridded, axed and + // strip-labelled so the grid stays rectangular. + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS r, 'p' AS c UNION ALL SELECT 2, 3, 'b', 'p' \ UNION ALL SELECT 3, 1, 'a', 'q' \ VISUALISE x AS x, y AS y DRAW point FACET r BY c", - )); + ); } #[test] fn renders_sparse_grid_facet_free() { // An empty cell has no extent of its own, so a free dimension falls back to // the shared scale there — the axis and channel bindings must still resolve. - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS r, 'p' AS c UNION ALL SELECT 2, 3, 'b', 'p' \ UNION ALL SELECT 3, 1, 'a', 'q' \ VISUALISE x AS x, y AS y DRAW point FACET r BY c SETTING free => ['x','y']", - )); + ); } #[test] fn renders_faceted_bar_with_color() { - assert_png_or_skip(render( + assert_renders( "SELECT g, k FROM (VALUES ('a','x'),('a','y'),('b','x'),('b','y'),('a','x')) t(g, k) \ VISUALISE k AS x, k AS fill DRAW bar FACET g", - )); + ); } #[test] fn renders_free_scale_facet() { // Panels with very different data ranges: free scales give each panel its // own per-panel domain and axes. - assert_png_or_skip(render( + assert_renders( "SELECT x, y, g FROM (VALUES (1,1,'a'),(2,2,'a'),(3,3,'a'),\ (100,100,'b'),(200,200,'b'),(300,300,'b')) t(x,y,g) \ VISUALISE x AS x, y AS y DRAW point FACET g SETTING free => ['x','y']", - )); + ); } #[test] fn renders_polar_facet() { // A pie per panel, sharing the fill scale; proportions differ per panel. - assert_png_or_skip(render( + assert_renders( "SELECT c, panel FROM (VALUES \ ('a','one'),('a','one'),('b','one'),('c','one'),\ ('a','two'),('b','two'),('b','two'),('b','two'),('c','two')) t(c, panel) \ VISUALISE c AS fill DRAW bar PROJECT TO polar FACET panel", - )); + ); } #[cfg(feature = "spatial")] @@ -1165,13 +692,13 @@ mod tests { fn renders_spatial() { // A bare `spatial` geom (no PROJECT): two polygons filled by a value, // framed to the geometry bbox under Cartesian with equal aspect. - assert_png_or_skip(render( + assert_renders( "INSTALL spatial; LOAD spatial; \ SELECT ST_GeomFromText('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))') AS geom, \ 200 AS population \ UNION ALL SELECT ST_GeomFromText('POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))'), 150 \ VISUALISE DRAW spatial MAPPING population AS fill", - )); + ); } #[cfg(feature = "spatial")] @@ -1179,13 +706,13 @@ mod tests { fn renders_spatial_mapped_opacity() { // A data-mapped scalar aesthetic (opacity) must vary per feature and // register a legend, not collapse to a constant. - assert_png_or_skip(render( + assert_renders( "INSTALL spatial; LOAD spatial; \ SELECT ST_GeomFromText('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))') AS geom, \ 10 AS v \ UNION ALL SELECT ST_GeomFromText('POLYGON ((1 0, 2 0, 2 1, 1 1, 1 0))'), 90 \ VISUALISE DRAW spatial MAPPING v AS opacity", - )); + ); } #[cfg(feature = "spatial")] @@ -1193,9 +720,7 @@ mod tests { fn renders_map() { // A projected world map: pre-projected geometry + Custom projection // boundary + graticules from `computed`. - assert_png_or_skip(render( - "VISUALISE FROM ggsql:world DRAW spatial PROJECT TO orthographic", - )); + assert_renders("VISUALISE FROM ggsql:world DRAW spatial PROJECT TO orthographic"); } /// Under a map `PROJECT`, ggsql expands these layers into per-vertex rows and @@ -1206,45 +731,45 @@ mod tests { #[cfg(feature = "spatial")] #[test] fn renders_densified_segment() { - assert_png_or_skip(render( + assert_renders( "INSTALL spatial; LOAD spatial; \ SELECT * FROM (VALUES (-100,30,20,60),(-50,-20,100,10)) t(x1,y1,x2,y2) \ VISUALISE x1 AS x, y1 AS y, x2 AS xend, y2 AS yend DRAW segment \ SETTING stroke => 'firebrick', linewidth => 2 PROJECT x, y TO robinson", - )); + ); } #[cfg(feature = "spatial")] #[test] fn renders_densified_ribbon() { - assert_png_or_skip(render( + assert_renders( "INSTALL spatial; LOAD spatial; \ SELECT * FROM (VALUES (-160,-20,20),(-80,0,40),(0,10,50),(80,-10,30)) t(x,lo,hi) \ VISUALISE x AS x, lo AS ymin, hi AS ymax DRAW ribbon \ SETTING fill => 'steelblue' PROJECT x, y TO robinson", - )); + ); } #[cfg(feature = "spatial")] #[test] fn renders_densified_rule() { // A rule spans the clip bbox, so its meridians curve with the projection. - assert_png_or_skip(render( + assert_renders( "INSTALL spatial; LOAD spatial; \ SELECT * FROM (VALUES (-100),(0),(100)) t(x) VISUALISE x AS x DRAW rule \ SETTING stroke => 'darkgreen', linetype => 'dashed' PROJECT x, y TO robinson", - )); + ); } #[cfg(feature = "spatial")] #[test] fn renders_densified_tile() { - assert_png_or_skip(render( + assert_renders( "INSTALL spatial; LOAD spatial; \ SELECT * FROM (VALUES (-120,-30,5),(-40,20,9),(40,-10,3)) t(x,y,v) \ VISUALISE x AS x, y AS y, v AS fill DRAW tile \ SETTING width => 40, height => 30 PROJECT x, y TO robinson", - )); + ); } #[cfg(feature = "spatial")] @@ -1252,7 +777,7 @@ mod tests { fn renders_map_over_spatial_base() { // A non-spatial layer over a spatial base map: both must frame to ggsql's // bbox so the segments land on the boundary, not on their own extent. - assert_png_or_skip(render( + assert_renders( "WITH routes AS (SELECT * FROM (VALUES (-74,40,2,48,'a'),(151,-34,18,-34,'b')) \ t(x1,y1,x2,y2,route)) \ VISUALISE \ @@ -1260,7 +785,7 @@ mod tests { DRAW segment MAPPING x1 AS x, y1 AS y, x2 AS xend, y2 AS yend, route AS stroke \ FROM routes \ PROJECT x, y TO robinson", - )); + ); } /// A 6-row fixture whose `g` is categorical and `v` numeric. @@ -1269,10 +794,8 @@ mod tests { #[test] fn axis_titles_are_one_per_dimension() { - // Axis titles are outer chrome: exactly one per dimension for the whole - // figure, however many panels there are and whether or not a dimension - // is free (a free dimension draws its rail on every panel, but still - // gets a single centred title). + // Axis titles are outer chrome: one per dimension for the whole figure, + // however many panels, free or not. let expected = vec![ (AxisSide::Bottom, "v".to_string()), (AxisSide::Left, "y".to_string()), @@ -1374,8 +897,6 @@ mod tests { #[test] fn facet_strips_binned_squish() { // `oob => 'squish'` opens the terminal bins: "< upper" / "≥ lower". - // Two breaks-interior bins here, both terminal — matches the Vega-Lite - // writer's labelExpr for the same query. assert_eq!( top_strips(&format!( "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ @@ -1423,9 +944,8 @@ mod tests { #[test] fn facet_strips_binned_temporal() { - // Temporal binned facets label as date ranges. Vega-Lite silently fails - // this case (its midpoint-string comparison never matches); computing the - // label from typed values here avoids that whole class of bug. + // Temporal binned facets label as date ranges, computed from the typed + // values rather than from a midpoint string. let data = "SELECT CAST(d AS DATE) AS d, v FROM (VALUES \ ('1973-05-04', 1), ('1973-05-20', 2), ('1973-06-08', 3)) t(d, v)"; assert_eq!( @@ -1440,8 +960,7 @@ mod tests { #[test] fn facet_strips_null_and_empty_are_separate_panels() { // `column_to_strings` renders both a NULL and an empty category as "", - // so they need the null flag to stay apart — the Vega-Lite writer gives - // them a panel each. + // so the null flag is what keeps them in separate panels. let data = "SELECT g, v FROM (VALUES ('', 1), (NULL, 2), ('a', 3)) t(g, v)"; assert_eq!( top_strips(&format!( @@ -1487,260 +1006,260 @@ mod tests { #[test] fn renders_binned_facet() { - assert_png_or_skip(render(&format!( + assert_renders(&format!( "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ SCALE panel SETTING breaks => (0, 10, 20, 30)" - ))); + )); } #[test] fn renders_free_binned_facet() { // A free binned position dimension: each panel keeps ggsql's global bin // edges but shows only the bins its own data occupies. - assert_png_or_skip(render( + assert_renders( "VISUALISE body_mass AS x FROM ggsql:penguins DRAW bar \ SCALE BINNED x SETTING breaks => (2500, 3500, 4500, 5500, 6500) \ FACET species SETTING free => 'x'", - )); + ); } #[test] fn renders_binned_size_legend() { // A binned *keyed* legend: one key per bin, sized at the bin's midpoint, // with ggsql's edge labels on the rail between keys. - assert_png_or_skip(render( + assert_renders( "VISUALISE bill_len AS x, bill_dep AS y, body_mass AS size \ FROM ggsql:penguins DRAW point \ SCALE BINNED size SETTING breaks => (2500, 3500, 4500, 5500, 6500)", - )); + ); } #[test] fn renders_binned_color_legend() { // The same ladder driving color: a stepped colorbar, one block per bin. - assert_png_or_skip(render( + assert_renders( "VISUALISE bill_len AS x, bill_dep AS y, body_mass AS color \ FROM ggsql:penguins DRAW point \ SCALE BINNED color SETTING breaks => (2500, 3500, 4500, 5500, 6500)", - )); + ); } #[test] fn renders_boxplot_linewidth() { // `linewidth` thickens box, whiskers and median alike (VL puts // strokeWidth in the boxplot's shared encoding). - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ VISUALISE g AS x, v AS y DRAW boxplot SETTING linewidth => 3", - )); + ); } #[test] fn renders_boxplot_dashed() { - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('b',2),('b',3),('b',5)) t(g,v) \ VISUALISE g AS x, v AS y DRAW boxplot \ SETTING linetype => 'dashed', linewidth => 2", - )); + ); } #[test] fn renders_boxplot_hinge() { // `hinge` caps the whiskers with a fixed-size (pt) tick at each fence. - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ VISUALISE g AS x, v AS y DRAW boxplot SETTING hinge => 20", - )); + ); } #[test] fn renders_boxplot_side() { // `side` halves the box, median and caps onto one side of the band, // leaving whiskers and outliers on the centreline. - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ VISUALISE g AS x, v AS y DRAW boxplot \ SETTING side => 'right', hinge => 20", - )); + ); } #[test] fn renders_transposed_boxplot() { // A horizontal boxplot: ggsql flips the position columns, so the // categories are on `pos2` and the summary values in the `pos1` family. - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',3),('a',9),\ ('b',2),('b',3),('b',4),('b',5)) t(g,v) \ VISUALISE v AS x, g AS y DRAW boxplot SETTING hinge => 15", - )); + ); } #[test] fn renders_half_violin_with_half_boxplot() { // Opposite `side` values pair the two composites on one band, the // documented raincloud-style layout (transposed, so top/bottom). - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',2),('a',3),('a',4),\ ('b',2),('b',3),('b',3),('b',4),('b',6)) t(g,v) \ VISUALISE v AS x, g AS y \ DRAW violin SETTING side => 'top' \ DRAW boxplot SETTING side => 'bottom', width => 0.3", - )); + ); } #[test] fn renders_jittered_points() { // `position => 'jitter'` spreads the points across their category band; // `side` (folded into the offsets by ggsql) keeps them on one half. - assert_png_or_skip(render( + assert_renders( "VISUALISE species AS x, bill_len AS y FROM ggsql:penguins DRAW point \ SETTING position => 'jitter'", - )); - assert_png_or_skip(render( + ); + assert_renders( "VISUALISE species AS x, bill_len AS y FROM ggsql:penguins DRAW point \ SETTING position => 'jitter', side => 'right'", - )); + ); } #[test] fn renders_dodged_points() { // Dodge on a geom that doesn't derive its own band edges: the offsets // reach the point's band channel. - assert_png_or_skip(render( + assert_renders( "SELECT x, g, v FROM (VALUES ('a','p',3),('a','q',5),('b','p',2),('b','q',4)) \ t(x,g,v) \ VISUALISE x AS x, v AS y, g AS color DRAW point SETTING position => 'dodge'", - )); + ); } #[test] fn renders_dodged_range_with_hinges() { // A dodged interval and its end caps share one offset, so they stay // aligned in the dodge slot. - assert_png_or_skip(render( + assert_renders( "SELECT g, s, lo, hi FROM (VALUES ('a','p',1,5),('a','q',2,6),('b','p',2,7)) \ t(g,s,lo,hi) \ VISUALISE g AS x, lo AS ymin, hi AS ymax, s AS stroke DRAW range \ SETTING position => 'dodge'", - )); + ); } #[test] fn renders_jitter_with_half_boxplot() { // The documented raincloud layout: a one-sided jitter above the // centreline, a half-boxplot below it. - assert_png_or_skip(render( + assert_renders( "VISUALISE bill_len AS x, species AS y FROM ggsql:penguins \ DRAW point SETTING position => 'jitter', side => 'top', width => 0.4 \ DRAW boxplot SETTING side => 'bottom', width => 0.4", - )); + ); } #[test] fn renders_range_hinges() { // A range carries 10pt end caps by default; `hinge => null` drops them. - assert_png_or_skip(render( + assert_renders( "SELECT g, lo, hi FROM (VALUES ('a',1,5),('b',2,7)) t(g,lo,hi) \ VISUALISE g AS x, lo AS ymin, hi AS ymax DRAW range", - )); - assert_png_or_skip(render( + ); + assert_renders( "SELECT g, lo, hi FROM (VALUES ('a',1,5),('b',2,7)) t(g,lo,hi) \ VISUALISE g AS y, lo AS xmin, hi AS xmax DRAW range \ SETTING hinge => 40", - )); - assert_png_or_skip(render( + ); + assert_renders( "SELECT g, lo, hi FROM (VALUES ('a',1,5),('b',2,7)) t(g,lo,hi) \ VISUALISE g AS x, lo AS ymin, hi AS ymax DRAW range \ SETTING hinge => null", - )); + ); } #[test] fn renders_violin_linewidth() { - assert_png_or_skip(render( + assert_renders( "SELECT g, v FROM (VALUES ('a',1),('a',2),('a',2),('a',3),('a',4),\ ('b',2),('b',3),('b',3),('b',4),('b',6)) t(g,v) \ VISUALISE g AS x, v AS y DRAW violin \ SETTING linewidth => 3, linetype => 'dashed'", - )); + ); } #[test] fn renders_dodged_violin() { // Two fill groups per category: each must be its own contour (keyed on the // category *and* the partition columns), not one merged blob. - assert_png_or_skip(render( + assert_renders( "SELECT g, f, v FROM (VALUES ('a','x',1),('a','x',2),('a','x',3),\ ('a','y',5),('a','y',6),('a','y',7),\ ('b','x',2),('b','x',3),('b','x',4),('b','y',6),('b','y',7),('b','y',8)) t(g,f,v) \ VISUALISE g AS x, v AS y, f AS fill DRAW violin", - )); + ); } #[test] fn renders_text_stroke() { // A constant `stroke` outlines the glyphs; white-on-dark legibility. - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'peak' AS lbl UNION ALL SELECT 2, 3, 'trough' \ VISUALISE x AS x, y AS y, lbl AS label DRAW text \ SETTING fontsize => 28, fontweight => 'bold', color => 'black', \ stroke => 'white'", - )); + ); } #[test] fn renders_text_stroke_by_group() { // A data-mapped outline color: one scale + legend, per-row outline. - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y, 'a' AS lbl, 'one' AS g \ UNION ALL SELECT 2, 3, 'b', 'two' \ VISUALISE x AS x, y AS y, lbl AS label, g AS stroke DRAW text \ SETTING fontsize => 30, fontweight => 'bold'", - )); + ); } #[test] fn renders_titled_plot() { // Title, subtitle and caption all sit on the composition, above/below the // single panel. - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 UNION ALL SELECT 3, 1 \ VISUALISE x AS x, y AS y DRAW point \ LABEL title => 'Sales by Region', subtitle => 'FY 2024', \ caption => 'Source: internal'", - )); + ); } #[test] fn renders_suppressed_title() { // `LABEL title => NULL` suppresses; the subtitle still renders. - assert_png_or_skip(render( + assert_renders( "SELECT 1 AS x, 2 AS y UNION ALL SELECT 2, 3 \ VISUALISE x AS x, y AS y DRAW point \ LABEL title => NULL, subtitle => 'no title above me'", - )); + ); } #[test] fn renders_titled_facet() { // One composition-spanning title over the whole 3-panel strip, not one // title per panel. - assert_png_or_skip(render( + assert_renders( "SELECT x, y, g FROM (VALUES (1,1,'a'),(2,2,'a'),(1,2,'b'),(2,3,'b'),\ (1,3,'c'),(2,1,'c')) t(x,y,g) \ VISUALISE x AS x, y AS y DRAW point FACET g \ LABEL title => 'One title for all panels'", - )); + ); } #[test] fn map_range_pads_like_vegalite() { // 10% of the span, split evenly around the centre — the same framing // Vega-Lite's projection fit produces from `span * 1.1`. - let r = map_range(0.0, 10.0); + let r = compose::map_range(0.0, 10.0); assert_eq!(*r.start(), -0.5); assert_eq!(*r.end(), 10.5); assert_eq!((r.end() - r.start()) / 10.0, 1.1); @@ -1749,24 +1268,527 @@ mod tests { #[test] fn map_range_widens_a_degenerate_extent() { // A single point has no span to pad, so it is widened to a mappable one. - let r = map_range(3.0, 3.0); + let r = compose::map_range(3.0, 3.0); assert_eq!(*r.start(), 2.5); assert_eq!(*r.end(), 3.5); } + #[test] + fn map_range_orients_an_inverted_extent() { + // A reversed bbox reaches here — `map_bbox` checks its four numbers + // for finiteness, not for order — and a scale cannot map an inverted + // range at all, so it is oriented rather than passed through. + let r = compose::map_range(10.0, 0.0); + assert_eq!(*r.start(), -0.5); + assert_eq!(*r.end(), 10.5); + } + + /// `arrow` is a stub in both writers, so the rejection lives in the shared + /// composition layer rather than in any one of them. #[test] fn rejects_unsupported_geom() { + let spec = spec_for( + "SELECT 0 AS x, 0 AS y, 1 AS xend, 1 AS yend \ + VISUALISE x AS x, y AS y, xend AS xend, yend AS yend DRAW arrow", + ); + let err = compose::validate_plot(spec.plot()).unwrap_err(); + assert!(matches!(err, GgsqlError::WriterError(_))); + assert!(err.to_string().contains("'arrow' geom"), "{err}"); + } +} + +// SVG output is readable text, so these check directly that the breaks, labels +// and titles ggsql resolved are the ones reaching the output — which a PNG +// cannot show. None of it needs a GPU. +#[cfg(all(test, feature = "duckdb", feature = "svg"))] +mod svg_text { + use super::*; + use crate::reader::{DuckDBReader, Reader}; + use crate::writer::{Writer, WriterOptions}; + + const FACET_DATA: &str = "SELECT g, v, y FROM (VALUES \ + ('a',5,1),('a',7,2),('b',15,3),('b',18,1),('c',25,2),('c',28,3)) t(g,v,y)"; + + fn svg(query: &str) -> String { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader.execute(query).unwrap(); + let (svg, warnings) = SvgWriter::new(640, 480, 96.0) + .render_reporting(&spec) + .unwrap_or_else(|e| panic!("svg render failed: {e}")); + assert!(warnings.is_empty(), "svg degraded the plot: {warnings:?}"); + svg + } + + /// Every `` element's text, in document order. + /// + /// A run of styled spans is one string, so a markdown-emphasised title + /// reads as the sentence a user typed rather than as its pieces. + fn texts(svg: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = svg; + while let Some(open) = rest.find("") else { + break; + }; + let element = &rest[..end]; + let mut label = String::new(); + let mut span = element; + while let Some(at) = span.find("') else { break }; + let Some(close) = span.find("") else { + break; + }; + label.push_str(&span[gt + 1..close]); + span = &span[close..]; + } + out.push(label); + rest = &rest[end..]; + } + out + } + + fn contains(svg: &str, label: &str) -> bool { + texts(svg).iter().any(|t| t == label) + } + + #[test] + fn tick_labels_are_the_ones_ggsql_resolved() { + // ggsql's own break spacing and number formatting: the trailing `.0` + // shows these are pass-throughs, not the renderer's idea of a nice tick. + let linear = svg("SELECT x, y FROM (VALUES (1,2),(2,3),(3,1)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW point"); + for label in ["1.0", "1.5", "2.0", "2.5", "3.0"] { + assert!(contains(&linear, label), "missing tick '{label}'"); + } + + // A `RENAMING` on a discrete axis reaches the rail, and the axis does + // not shift: the break is kept and only its label replaced. + let renamed = svg("SELECT c, v FROM (VALUES ('a',3),('b',5)) t(c,v) \ + VISUALISE c AS x, v AS y DRAW bar SCALE x RENAMING 'a' => 'Alpha'"); + assert!(contains(&renamed, "Alpha")); + assert!(contains(&renamed, "b")); + } + + /// A log axis should carry decade ticks, but `Scale::numeric_breaks()` + /// resolves `[5e-308, 2e-256, …, 100]` for a 1–100 log10 domain and both + /// writers pass that through. Kept as a failing expectation so it turns + /// green on its own once the scale is fixed. + #[test] + #[ignore = "ggsql resolves log-scale breaks to denormals; not a writer bug"] + fn log_tick_labels_should_be_decades() { + let log = svg("SELECT x, y FROM (VALUES (1,2),(10,3),(100,1)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW point SCALE x VIA log"); + for label in ["1", "10", "100"] { + assert!(contains(&log, label), "missing log tick '{label}'"); + } + } + + #[test] + fn a_binned_scales_edge_labels_reach_the_legend() { + // The renderer cannot derive a bin ladder, so all five edges appearing + // verbatim on the colorbar rail is the pass-through. + let binned = svg( + "VISUALISE bill_len AS x, bill_dep AS y, body_mass AS color \ + FROM ggsql:penguins DRAW point \ + SCALE BINNED color SETTING breaks => (2500, 3500, 4500, 5500, 6500)", + ); + for label in ["2500", "3500", "4500", "5500", "6500"] { + assert!(contains(&binned, label), "missing bin edge '{label}'"); + } + } + + #[test] + fn facet_strip_labels_appear_once_each_in_panel_order() { + let faceted = svg(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET g" + )); + let labels = texts(&faceted); + for level in ["a", "b", "c"] { + assert_eq!( + labels.iter().filter(|t| *t == level).count(), + 1, + "strip '{level}' should appear exactly once in {labels:?}" + ); + } + // In panel order, which is the facet scale's resolved order. + let order: Vec<&String> = labels + .iter() + .filter(|t| ["a", "b", "c"].contains(&t.as_str())) + .collect(); + assert_eq!(order, vec!["a", "b", "c"]); + + // And `RENAMING` reaches the strip, since the label is ggsql's. + let renamed = svg(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET g \ + SCALE panel RENAMING 'a' => 'Alpha'" + )); + assert!(contains(&renamed, "Alpha")); + assert!(!contains(&renamed, "a")); + } + + #[test] + fn binned_facet_strips_show_ggsqls_range_labels() { + let binned = svg(&format!( + "{FACET_DATA} VISUALISE v AS x, y AS y DRAW point FACET v \ + SCALE panel SETTING breaks => (0, 10, 20, 30)" + )); + for label in ["0 – 10", "10 – 20", "20 – 30"] { + assert!(contains(&binned, label), "missing strip '{label}'"); + } + } + + #[test] + fn every_plot_label_reaches_the_output() { + let labelled = svg("SELECT x, y FROM (VALUES (1,2),(2,3)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW point \ + LABEL title => 'The title', subtitle => 'The subtitle', \ + caption => 'The caption', x => 'Across', y => 'Up'"); + for label in ["The title", "The subtitle", "The caption", "Across", "Up"] { + assert!(contains(&labelled, label), "missing label '{label}'"); + } + } + + #[test] + fn markdown_in_a_label_is_parsed_rather_than_printed() { + let emphasised = svg("SELECT x, y FROM (VALUES (1,2),(2,3)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW point LABEL title => 'A *bold* title'"); + // The words survive, the markers do not. + assert!(contains(&emphasised, "A bold title")); + assert!( + !texts(&emphasised).iter().any(|t| t.contains('*')), + "a literal '*' means the markdown was not parsed" + ); + // And the emphasised run is styled, not merely re-joined. + assert!( + emphasised.contains("font-style=\"italic\""), + "the emphasised span carries no style" + ); + } + + #[test] + fn a_legends_title_and_key_labels_appear() { + // The title is the mapped column, and the keys are the categorical + // domain ggsql trained, in its resolved order. + let keyed = svg( + "SELECT x, y, g FROM (VALUES (1,2,'alpha'),(2,3,'beta'),(3,1,'alpha')) t(x,y,g) \ + VISUALISE x AS x, y AS y, g AS color DRAW point", + ); + for label in ["g", "alpha", "beta"] { + assert!(contains(&keyed, label), "missing legend text '{label}'"); + } + + // `RENAMING` relabels a key without dropping the others. + let renamed = svg( + "SELECT x, y, g FROM (VALUES (1,2,'alpha'),(2,3,'beta'),(3,1,'alpha')) t(x,y,g) \ + VISUALISE x AS x, y AS y, g AS color DRAW point \ + SCALE color RENAMING 'alpha' => 'First'", + ); + assert!(contains(&renamed, "First")); + assert!(contains(&renamed, "beta")); + assert!(!contains(&renamed, "alpha")); + } + + #[test] + fn outline_mode_turns_text_into_paths() { + let query = "SELECT x, y FROM (VALUES (1,2),(2,3)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW point LABEL title => 'Outlined'"; + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader.execute(query).unwrap(); + + let as_text = SvgWriter::new(640, 480, 96.0).render(&spec).unwrap(); + let as_paths = SvgWriter::new(640, 480, 96.0) + .outline_text(true) + .render(&spec) + .unwrap(); + + assert!(as_text.contains(""); + assert!( + !as_paths.contains("" + ); + assert!( + as_paths.matches(" as_text.matches(" crate::reader::Spec { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); - let spec = reader + reader .execute( - "SELECT 0 AS x, 0 AS y, 1 AS xend, 1 AS yend \ - VISUALISE x AS x, y AS y, xend AS xend, yend AS yend DRAW arrow", + "SELECT x, y FROM (VALUES (1,2),(2,3),(3,1)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW point LABEL title => 'A page'", ) + .unwrap() + } + + #[test] + fn the_page_box_is_the_canvas_at_seventy_two_points_per_inch() { + // 640 px at 96 dpi is 6⅔ in, which is 480 pt; 480 px is 360 pt. + let pdf = PdfWriter::new(640, 480, 96.0) + .compress(false) + .render(&spec()) .unwrap(); - let writer = PngWriter::new(320, 240, 96.0); - assert!(matches!( - writer.validate(spec.plot()), - Err(GgsqlError::WriterError(_)) - )); + let text = String::from_utf8_lossy(&pdf); + assert!( + text.contains("/MediaBox [0 0 480 360]"), + "unexpected page box" + ); + } + + #[test] + fn an_uncompressed_page_is_readable_and_a_compressed_one_is_smaller() { + let readable = PdfWriter::new(640, 480, 96.0) + .compress(false) + .render(&spec()) + .unwrap(); + let compressed = PdfWriter::new(640, 480, 96.0).render(&spec()).unwrap(); + + assert!(readable.starts_with(b"%PDF-")); + assert!(compressed.starts_with(b"%PDF-")); + // `compress=false` exists so a user can read or diff the stream. + assert!(!String::from_utf8_lossy(&readable).contains("/FlateDecode")); + assert!(String::from_utf8_lossy(&compressed).contains("/FlateDecode")); + assert!(compressed.len() < readable.len()); + } + + #[test] + fn a_font_is_subset_into_the_page() { + // The page must carry its own glyphs, or a figure in a paper renders + // in whatever the reader substitutes. + let pdf = PdfWriter::new(640, 480, 96.0) + .compress(false) + .render(&spec()) + .unwrap(); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/FontFile2"), "no embedded font programme"); + assert!(text.contains("/Type /Font")); + } +} + +// The `hep` round trip: a document is written from a live composition, read +// back into a new one, and both are rendered to SVG and compared byte for byte. +// Any scale, break, label, theme entry, channel or geom that fails to survive +// shows up as different drawing commands. SVG rather than raster because it is +// deterministic; GPU antialiasing is not. Test-only — the library only writes. +#[cfg(all( + test, + feature = "duckdb", + feature = "hep-read", + feature = "svg", + feature = "builtin-data" +))] +mod hep_roundtrip { + use super::*; + use crate::reader::{DuckDBReader, Reader}; + // Driven directly rather than through the writer, so a loss shows up as a + // drawing difference rather than as writer configuration. + use hephaestus::document::{ + read_composition, read_hints, unsupported_items_for, write_composition, ReadContext, + WriteOptions, + }; + + /// Deliberately broad: several geoms, a facet, a legend, markdown chrome, + /// and a transform — so the round trip is not proved on a scatter plot. + const QUERIES: &[(&str, &str)] = &[ + ( + "faceted scatter with a legend", + "VISUALISE bill_len AS x, bill_dep AS y, species AS color \ + FROM ggsql:penguins DRAW point FACET island \ + LABEL title => 'Penguin *bills*', caption => 'From ggsql:penguins'", + ), + ( + "multi-layer with a colorbar", + "VISUALISE bill_len AS x, bill_dep AS y, body_mass AS color \ + FROM ggsql:penguins DRAW point DRAW line", + ), + ( + "boxplot with a free facet", + "VISUALISE species AS x, body_mass AS y, species AS fill \ + FROM ggsql:penguins DRAW boxplot FACET island \ + SETTING free => ('y')", + ), + ]; + + fn compose_for(query: &str) -> hephaestus::plot::PlotComposition { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader.execute(query).unwrap(); + compose::validate_plot(spec.plot()).unwrap(); + compose::build_composition(spec.plot(), spec.data()).unwrap() + } + + fn to_svg(view: &mut hephaestus::plot::PlotComposition) -> String { + use hephaestus::geometry::Size; + use hephaestus::svg::{encode_svg, SvgScene}; + let size = Size::new(640.0, 480.0); + let mut scene = SvgScene::new(size, 96.0); + view.render(&mut scene, size, 96.0); + assert!( + scene.warnings().is_empty(), + "svg degraded the plot: {:?}", + scene.warnings() + ); + encode_svg(&scene) + } + + #[test] + fn a_document_rebuilds_the_plot_it_captured() { + for (label, query) in QUERIES { + let mut live = compose_for(query); + let bytes = write_composition(&live, &WriteOptions::default()) + .unwrap_or_else(|e| panic!("{label}: write failed: {e}")); + let mut rebuilt = read_composition(&bytes, ReadContext::builtin()) + .unwrap_or_else(|e| panic!("{label}: read failed: {e}")); + + assert_eq!( + to_svg(&mut rebuilt), + to_svg(&mut live), + "{label}: the rebuilt composition draws differently" + ); + } + } + + /// The projection has to be restored before the axes are attached, or a + /// polar placement is validated against the Cartesian default and rejected. + #[test] + fn a_polar_document_rebuilds_too() { + let query = "SELECT c FROM (VALUES ('a'),('a'),('a'),('b'),('b'),('c')) t(c) \ + VISUALISE c AS fill DRAW bar PROJECT TO polar"; + let mut live = compose_for(query); + let bytes = write_composition(&live, &WriteOptions::default()).unwrap(); + let mut rebuilt = read_composition(&bytes, ReadContext::builtin()).unwrap(); + assert_eq!(to_svg(&mut rebuilt), to_svg(&mut live)); + } + + #[test] + fn nothing_ggsql_draws_is_beyond_the_format() { + // A failure means the writer grew something the format cannot name. + for (label, query) in QUERIES { + let view = compose_for(query); + let problems = unsupported_items_for(&view, &WriteOptions::default()); + assert!(problems.is_empty(), "{label}: {problems:?}"); + } + } + + #[test] + fn the_writers_hints_travel_with_the_document() { + let spec = { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + reader.execute(QUERIES[0].1).unwrap() + }; + let (bytes, warnings) = HepWriter::new(1600, 900, 150.0) + .background(rgba(0.0, 0.0, 0.0, 1.0)) + .render_reporting(&spec) + .unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(bytes.starts_with(b"HEPHPLOT"), "missing the format's magic"); + + let hints = read_hints(&bytes).unwrap(); + assert_eq!(hints.size, Some((1600.0, 900.0))); + assert_eq!(hints.dpi, Some(150.0)); + assert_eq!( + hints.background.map(|c| c.components), + Some([0.0, 0.0, 0.0, 1.0]) + ); + + // A transparent background is a request, not the absence of one: read + // back as `None` a consumer paints white behind the plot instead. + let (transparent, _) = HepWriter::new(1600, 900, 150.0) + .background(rgba(0.0, 0.0, 0.0, 0.0)) + .render_reporting(&spec) + .unwrap(); + let hints = read_hints(&transparent).unwrap(); + assert_eq!( + hints.background.map(|c| c.components), + Some([0.0, 0.0, 0.0, 0.0]) + ); + } + + #[test] + fn a_document_survives_a_render_at_a_different_size() { + // The whole point of the format: the consumer picks the size, and the + // composition re-solves its layout for it. + use hephaestus::geometry::Size; + use hephaestus::svg::{encode_svg, SvgScene}; + + let live = compose_for(QUERIES[0].1); + let bytes = write_composition(&live, &WriteOptions::default()).unwrap(); + let mut rebuilt = read_composition(&bytes, ReadContext::builtin()).unwrap(); + + for size in [Size::new(320.0, 240.0), Size::new(1600.0, 900.0)] { + let mut scene = SvgScene::new(size, 96.0); + rebuilt.render(&mut scene, size, 96.0); + let svg = encode_svg(&scene); + assert!(svg.contains(&format!("viewBox=\"0 0 {} {}\"", size.width, size.height))); + assert!(svg.matches(" 0); + } } } diff --git a/src/writer/hephaestus/pdf.rs b/src/writer/hephaestus/pdf.rs new file mode 100644 index 000000000..aeff21406 --- /dev/null +++ b/src/writer/hephaestus/pdf.rs @@ -0,0 +1,237 @@ +//! The PDF writer. + +use std::collections::HashMap; + +use hephaestus::pdf::{encode_pdf, PdfConfig, PdfScene, PdfWarning}; + +use super::canvas::Canvas; +use super::{compose, vector}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, Plot, Result}; + +/// Option keys [`PdfWriter`] adds to the shared canvas set. +const PDF_OPTIONS: &[&str] = &["compress", "links"]; + +/// Writer that renders a ggsql plot to a PDF page. +/// +/// Needs no GPU adapter — like the SVG writer, it records the same drawing +/// commands the rasteriser would have executed. The page carries vector geometry +/// and subset-embedded fonts, so a figure prints without resampling. +/// +/// [`PdfWriter::from_options`] takes: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any CSS color; `transparent` leaves the page unpainted | `white` | +/// | `compress` | Deflate the content streams | `true` | +/// | `links` | Emit link annotations for text carrying a destination | `true` | +/// +/// The page box is derived from the canvas at 72 points per inch, so +/// `width=6;height=4;units=in;dpi=300` produces a 432×288 pt page — six inches +/// wide on paper — from an 1800×1200 rendering. `units=px` gives a page sized +/// as if those pixels were rendered at `dpi`. +/// +/// `compress=false` leaves the content stream as readable text, which is how +/// you inspect what was emitted or diff two figures. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PdfWriter { + canvas: Canvas, + compress: bool, + links: bool, +} + +impl PdfWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + ..Self::default() + } + } + + /// Set the background painted behind the plot. + /// + /// A fully transparent color leaves the page unpainted rather than adding a + /// full-page rect in transparent black. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Deflate the content streams. On by default; off leaves them readable. + pub fn compress(mut self, compress: bool) -> Self { + self.compress = compress; + self + } + + /// Emit link annotations for text carrying a destination. + pub fn links(mut self, links: bool) -> Self { + self.links = links; + self + } + + /// Render, reporting anything PDF could not express. + /// + /// [`Writer::write`] discards the report. Take it when the output is an + /// artifact someone will ship — a dropped gradient is a defect in the file. + /// The list is empty for everything ggsql draws. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed. + pub fn write_reporting( + &self, + spec: &Plot, + data: &HashMap, + ) -> Result<(Vec, Vec)> { + let mut scene = PdfScene::with_config(self.canvas.size(), self.canvas.dpi, self.config()); + vector::draw(spec, data, &self.canvas, &mut scene)?; + Ok((encode_pdf(&scene), describe(scene.warnings()))) + } + + /// [`Self::write_reporting`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_reporting`]. + pub fn render_reporting(&self, spec: &crate::reader::Spec) -> Result<(Vec, Vec)> { + self.write_reporting(spec.plot(), spec.data()) + } + + /// The emission options this writer's settings amount to. + fn config(&self) -> PdfConfig { + PdfConfig::new() + .background(self.canvas.vector_background()) + .compress(self.compress) + .links(self.links) + } +} + +impl Default for PdfWriter { + fn default() -> Self { + Self { + canvas: Canvas::default(), + compress: true, + links: true, + } + } +} + +impl Writer for PdfWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + Ok(Self { + canvas: Canvas::from_options(options, PDF_OPTIONS)?, + compress: options.boolean("compress")?.unwrap_or(true), + links: options.boolean("links")?.unwrap_or(true), + }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + self.write_reporting(spec, data).map(|(pdf, _)| pdf) + } +} + +/// Put what the format could not express into ggsql's own words. +/// +/// See `svg::describe` for why this translates rather than re-exports. +fn describe(warnings: &[PdfWarning]) -> Vec { + warnings + .iter() + .map(|warning| match warning { + PdfWarning::SweepGradient => { + "a sweep gradient was flattened to a solid colour; PDF has no conic shading".into() + } + PdfWarning::UnsupportedExtend => { + "a gradient asked to repeat or reflect; PDF shadings only pad, so the end colours \ + were held" + .into() + } + PdfWarning::UnsupportedCompose => { + "a blend mode PDF cannot express was drawn as normal compositing".into() + } + PdfWarning::AsymmetricCaps => { + "a stroke asked for different start and end caps; PDF has one, so both took the \ + start cap" + .into() + } + PdfWarning::ImageBrushUnsupported => { + "an image used as a fill or stroke was dropped; PDF cannot paint with one".into() + } + PdfWarning::UnembeddableImage => "an image's pixel layout could not be embedded".into(), + PdfWarning::MissingPngFeature => { + "a colour glyph's bitmap could not be decoded: this build has no PNG decoder".into() + } + PdfWarning::GlyphNotDrawable => { + "a glyph had no outline this backend could draw and did not appear".into() + } + PdfWarning::NonFiniteCoordinate => { + "a coordinate was not a finite number and was written as zero".into() + } + // Unbalanced layers are a defect in the writer rather than a limit + // of the format, and the variants are non-exhaustive. + other => format!("the plot renderer reported '{other:?}'"), + }) + .collect() +} + +#[cfg(test)] +impl super::canvas::Canvased for PdfWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::{ + assert_canvas_semantics, assert_transparent_background, + }; + + fn writer(pairs: &[&str]) -> Result { + PdfWriter::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + assert_transparent_background::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let default = PdfWriter::default(); + assert_eq!(writer(&[]).unwrap(), default); + assert!(default.compress); + assert!(default.links); + } + + #[test] + fn the_flags_take_the_boolean_spellings() { + assert!(!writer(&["compress=false"]).unwrap().compress); + assert!(!writer(&["compress=no"]).unwrap().compress); + assert!(!writer(&["links=off"]).unwrap().links); + assert!(writer(&["compress=1", "links=yes"]).unwrap().compress); + let err = writer(&["compress=maybe"]).unwrap_err().to_string(); + assert!(err.contains("'compress' expects true or false"), "{err}"); + } + + #[test] + fn a_transparent_canvas_leaves_the_page_unpainted() { + assert_eq!( + writer(&["background=none"]).unwrap().config().background, + None + ); + assert!(writer(&[]).unwrap().config().background.is_some()); + } +} diff --git a/src/writer/hephaestus/png.rs b/src/writer/hephaestus/png.rs new file mode 100644 index 000000000..4cab1b14f --- /dev/null +++ b/src/writer/hephaestus/png.rs @@ -0,0 +1,192 @@ +//! The PNG writer. + +use std::collections::HashMap; + +use hephaestus::png::{encode_png, PngCompression}; + +use super::canvas::Canvas; +use super::{compose, raster, RasterRenderer}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, GgsqlError, Plot, Result}; + +/// Option keys [`PngWriter`] adds to the shared canvas set. +const PNG_OPTIONS: &[&str] = &["compression"]; + +/// How hard the PNG encoder works to make the file small. +const COMPRESSION_VALUES: &[&str] = &["none", "fast", "balanced", "small"]; + +/// Writer that renders a ggsql plot to a PNG image. +/// +/// Configured with a target pixel size and DPI because raster rendering needs +/// concrete dimensions, unlike the resolution-independent Vega-Lite writer. +/// [`PngWriter::from_options`] builds the same configuration from +/// key–value [`WriterOptions`]: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any CSS color, e.g. `white`, `#ff0000`, `transparent` | `white` | +/// | `compression` | `none`, `fast`, `balanced`, or `small` | `balanced` | +/// +/// `compression` trades encode time against file size, losslessly either way. +/// `balanced` is what a file wants; `fast` suits a caller on a frame deadline, +/// costing a fraction of the time for about half again the bytes. +/// +/// Rendering requires a working wgpu adapter (hardware or software, e.g. +/// lavapipe) at render time. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PngWriter { + canvas: Canvas, + compression: PngCompression, +} + +impl PngWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + compression: PngCompression::Balanced, + } + } + + /// Set the background color used to clear the canvas before rendering. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Set how hard the encoder works to make the file small. + pub fn compression(mut self, compression: PngCompression) -> Self { + self.compression = compression; + self + } + + /// Render through a renderer the caller keeps, rather than building one. + /// + /// Constructing a [`RasterRenderer`] creates a GPU device and compiles the + /// rasteriser's shaders, so a host rendering more than one figure should + /// build one once and pass it here. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed, the + /// render fails, or the encode fails. + pub fn write_with( + &self, + spec: &Plot, + data: &HashMap, + renderer: &mut RasterRenderer, + ) -> Result> { + let pixels = raster::pixels(spec, data, &self.canvas, renderer)?; + // `render_to_buffer` hands out straight (un-premultiplied) alpha, which + // is exactly what PNG stores, so the buffer encodes as-is. + encode_png( + self.canvas.width, + self.canvas.height, + &pixels, + self.compression, + self.canvas.dpi_hint(), + ) + .map_err(|e| GgsqlError::WriterError(format!("png encode failed: {e}"))) + } + + /// [`Self::write_with`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_with`]. + pub fn render_with( + &self, + spec: &crate::reader::Spec, + renderer: &mut RasterRenderer, + ) -> Result> { + self.write_with(spec.plot(), spec.data(), renderer) + } +} + +impl Default for PngWriter { + fn default() -> Self { + Self { + canvas: Canvas::default(), + compression: PngCompression::Balanced, + } + } +} + +impl Writer for PngWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + let canvas = Canvas::from_options(options, PNG_OPTIONS)?; + let compression = match options.one_of("compression", COMPRESSION_VALUES)? { + Some("none") => PngCompression::None, + Some("fast") => PngCompression::Fast, + Some("small") => PngCompression::Small, + _ => PngCompression::Balanced, + }; + Ok(Self { + canvas, + compression, + }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + let mut renderer = RasterRenderer::new()?; + self.write_with(spec, data, &mut renderer) + } +} + +#[cfg(test)] +impl super::canvas::Canvased for PngWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::{ + assert_canvas_semantics, assert_transparent_background, + }; + + fn writer(pairs: &[&str]) -> Result { + PngWriter::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + assert_transparent_background::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let default = PngWriter::default(); + assert_eq!(writer(&[]).unwrap(), default); + assert_eq!(default.compression, PngCompression::Balanced); + } + + #[test] + fn compression_takes_the_four_named_levels() { + let cases = [ + ("none", PngCompression::None), + ("fast", PngCompression::Fast), + ("balanced", PngCompression::Balanced), + ("small", PngCompression::Small), + ]; + for (value, expected) in cases { + let w = writer(&[&format!("compression={value}")]).unwrap(); + assert_eq!(w.compression, expected, "compression={value}"); + } + let err = writer(&["compression=furlongs"]).unwrap_err().to_string(); + assert!(err.contains("'compression' expects"), "{err}"); + } +} diff --git a/src/writer/hephaestus/projection.rs b/src/writer/hephaestus/projection.rs index 839d9a5b1..79368ee6a 100644 --- a/src/writer/hephaestus/projection.rs +++ b/src/writer/hephaestus/projection.rs @@ -180,7 +180,7 @@ fn apply_proj_polar(mut plot: HPlot, proj: &Projection, spec: &Plot, ps: &PanelS /// performs no reprojection: a `Custom` projection uses the projected clip /// boundary as its drawing surface (clip + background) and the projected /// graticule lines as its grid. Position scales are the bbox-framed `pos1`/`pos2` -/// registered in `PngWriter::write`. Mirrors the Vega-Lite writer's +/// registered in `compose::build_composition`. Mirrors the Vega-Lite writer's /// identity `MapProjection` (`panel_boundary` + `graticule_*` from `computed`). fn apply_proj_map(mut plot: HPlot, proj: &Projection) -> HPlot { // A map has no Cartesian rails; the boundary + graticules are the chrome. diff --git a/src/writer/hephaestus/raster.rs b/src/writer/hephaestus/raster.rs new file mode 100644 index 000000000..3c0906507 --- /dev/null +++ b/src/writer/hephaestus/raster.rs @@ -0,0 +1,120 @@ +//! Rasterising a composition to pixels. +//! +//! The one module that names a GPU renderer, and the only part of the writer +//! needing an adapter: the vector and document writers never come through here. +//! +//! The backend is Vello Hybrid — CPU coverage, a plain GPU render pipeline — +//! chosen over vello classic because its buffers are sized to the scene rather +//! than capped, so a dense plot has no draw-count ceiling. Which backend that +//! is stays inside this file. + +use std::collections::HashMap; + +use hephaestus::backend::hybrid::HybridRenderer; +use hephaestus::backend::MAX_TEXTURE_DIMENSION; +use hephaestus::plot::PlotComposition; +use hephaestus::{Renderer, SceneBuilder}; + +use super::canvas::Canvas; +use super::compose; +use crate::{DataFrame, GgsqlError, Plot, Result}; + +/// A GPU renderer held across renders. +/// +/// Constructing one creates a wgpu device and builds the rasteriser's +/// pipelines, too expensive to repeat per figure. A host rendering more than +/// one plot should keep one and hand it to `render_with`; a one-shot caller can +/// let the writer make its own. `Send` but not `Sync`, so it moves to a render +/// thread rather than being shared. +/// +/// Sizing is internal: the renderer rebuilds whatever is bound to the frame +/// dimensions when they change, so one serves differently-sized renders. +pub struct RasterRenderer(HybridRenderer); + +impl RasterRenderer { + /// Initialise the renderer, which requires a working GPU adapter. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` when no adapter is available, or when + /// one is but the rasteriser could not be set up on it — the message names + /// which, since a caller may fall back on the first but not the second. + pub fn new() -> Result { + // Not `with_picking`: indexing costs CPU per draw call and nothing here + // hit-tests. A host that wants picking wants a live scene, not a file. + HybridRenderer::new().map(Self).map_err(|e| { + GgsqlError::WriterError(format!("could not initialise the GPU renderer: {e}")) + }) + } +} + +/// Largest canvas dimension a rasterising build can be asked for, in pixels. +/// +/// The true ceiling is the device's `max_texture_dimension_2d`; this is the +/// most the renderer will ask for. A device offering less fails the render +/// naming its own limit, so this is a cheap guard rather than a promise. +pub const MAX_RASTER_DIMENSION: u32 = MAX_TEXTURE_DIMENSION; + +/// Reject a canvas no GPU could rasterise, before anything is allocated. +/// +/// A device with a lower limit rejects the frame itself, naming its own; this +/// catches the sizes no device would take, and points at the writers with no +/// ceiling at all. +/// +/// # Errors +/// +/// Returns `GgsqlError::WriterError` naming the limit and the alternatives. +fn check_size(canvas: &Canvas) -> Result<()> { + if canvas.width > MAX_RASTER_DIMENSION || canvas.height > MAX_RASTER_DIMENSION { + return Err(GgsqlError::WriterError(format!( + "{}x{} is too large to rasterise: no dimension may exceed {} px, and a \ + given GPU may allow less. The svg and pdf writers have no such limit \ + and are resolution independent, so they are the better choice at this size", + canvas.width, canvas.height, MAX_RASTER_DIMENSION + ))); + } + Ok(()) +} + +/// Draw `view` at the canvas's size and resolution and read the pixels back. +/// +/// Returns RGBA8 with straight (un-premultiplied) alpha, `width * height * 4` +/// bytes, which is what every raster encoder here expects. +pub fn render_rgba8( + view: &mut PlotComposition, + canvas: &Canvas, + renderer: &mut RasterRenderer, +) -> Result> { + check_size(canvas)?; + { + let scene = renderer.0.scene(); + scene.clear(); + view.render(scene, canvas.size(), canvas.dpi); + } + let mut pixels = vec![0u8; (canvas.width as usize) * (canvas.height as usize) * 4]; + renderer + .0 + .render_to_buffer(canvas.width, canvas.height, canvas.background, &mut pixels) + .map_err(|e| GgsqlError::WriterError(format!("render failed: {e}")))?; + Ok(pixels) +} + +/// Everything a raster writer does before its encoder: check the plot, compose +/// it, and rasterise it at the canvas's size and resolution. +/// +/// The four raster writers differ only in the encoder they hand the result to, +/// so this is the whole of what they share. +/// +/// # Errors +/// +/// Returns `GgsqlError::WriterError` if the plot cannot be drawn by this +/// renderer, if composing it fails, or if the render does. +pub fn pixels( + spec: &Plot, + data: &HashMap, + canvas: &Canvas, + renderer: &mut RasterRenderer, +) -> Result> { + let mut view = compose::prepare(spec, data)?; + render_rgba8(&mut view, canvas, renderer) +} diff --git a/src/writer/hephaestus/svg.rs b/src/writer/hephaestus/svg.rs new file mode 100644 index 000000000..4c22682d0 --- /dev/null +++ b/src/writer/hephaestus/svg.rs @@ -0,0 +1,301 @@ +//! The SVG writer. + +use std::collections::HashMap; + +use hephaestus::svg::{encode_svg, SvgConfig, SvgScene, SvgUnits, SvgWarning, TextMode}; + +use super::canvas::Canvas; +use super::{compose, vector}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, Plot, Result}; + +/// Option keys [`SvgWriter`] adds to the shared canvas set. +const SVG_OPTIONS: &[&str] = &["text", "embed-fonts", "id-prefix"]; + +/// How text is written into the file. +const TEXT_VALUES: &[&str] = &["text", "outline"]; + +/// Writer that renders a ggsql plot to SVG. +/// +/// Needs no GPU adapter: SVG records the same drawing commands the rasteriser +/// would have executed, so this works on a headless box and in CI. The output +/// is resolution independent and its text is real, selectable text. +/// +/// [`SvgWriter::from_options`] takes: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any CSS color; `transparent` emits no background at all | `white` | +/// | `text` | `text` (real ``) or `outline` (glyphs as ``) | `text` | +/// | `embed-fonts` | Inline the font files, so the file renders identically anywhere | `false` | +/// | `id-prefix` | Prefix for generated element ids | none | +/// +/// `units` is honoured in the output as well as in the input: a canvas given in +/// a physical unit declares its size in points, so +/// `width=6;height=4;units=in;dpi=300` yields an 1800×1200 `viewBox` on a +/// `432pt` root — a file that *prints* six inches wide. A pixel canvas stays in +/// pixels. +/// +/// `text=outline` makes the file self-contained without embedding a font, at +/// the cost of selectable text; `embed-fonts` keeps the text but can take a +/// 30 kB plot past 3 MB. Hence neither is the default. +/// +/// `id-prefix` is a correctness setting: two SVGs inlined into one HTML page +/// that both define `#lg0` will have the second's `url(#lg0)` resolve to the +/// first's gradient, in every browser. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SvgWriter { + canvas: Canvas, + text: TextMode, + embed_fonts: bool, + id_prefix: Option, +} + +impl SvgWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + ..Self::default() + } + } + + /// Set the background painted behind the plot. + /// + /// A fully transparent color emits no background element at all, rather + /// than a full-canvas rect painted in transparent black. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Write glyph outlines as `` instead of `` elements. + pub fn outline_text(mut self, outline: bool) -> Self { + self.text = if outline { + TextMode::Outline + } else { + TextMode::Text + }; + self + } + + /// Inline the font files the plot uses. + pub fn embed_fonts(mut self, embed: bool) -> Self { + self.embed_fonts = embed; + self + } + + /// Prefix every generated element id, so two inlined files cannot collide. + pub fn id_prefix(mut self, prefix: impl Into) -> Self { + self.id_prefix = Some(prefix.into()); + self + } + + /// Render, reporting anything SVG could not express. + /// + /// [`Writer::write`] discards the report. Take it when the output is an + /// artifact someone will ship — a dropped gradient is a defect in the file. + /// The list is empty for everything ggsql draws. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed. + pub fn write_reporting( + &self, + spec: &Plot, + data: &HashMap, + ) -> Result<(String, Vec)> { + let mut scene = SvgScene::with_config(self.canvas.size(), self.canvas.dpi, self.config()); + vector::draw(spec, data, &self.canvas, &mut scene)?; + Ok((encode_svg(&scene), describe(scene.warnings()))) + } + + /// [`Self::write_reporting`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_reporting`]. + pub fn render_reporting(&self, spec: &crate::reader::Spec) -> Result<(String, Vec)> { + self.write_reporting(spec.plot(), spec.data()) + } + + /// The emission options this writer's settings amount to. + fn config(&self) -> SvgConfig { + let units = if self.canvas.physical { + // A canvas asked for in inches should print at that size, which is + // what a `pt` root declares. `SvgUnits::Pt` leaves the viewBox in + // pixels and only suffixes `width`/`height`. + SvgUnits::Pt + } else { + SvgUnits::Px + }; + let mut config = SvgConfig::new() + .background(self.canvas.vector_background()) + .units(units) + .text(self.text) + .embed_fonts(self.embed_fonts); + if let Some(prefix) = &self.id_prefix { + config = config.id_prefix(prefix.clone()); + } + config + } +} + +impl Writer for SvgWriter { + type Output = String; + + fn from_options(options: &WriterOptions) -> Result { + let canvas = Canvas::from_options(options, SVG_OPTIONS)?; + let text = match options.one_of("text", TEXT_VALUES)? { + Some("outline") => TextMode::Outline, + _ => TextMode::Text, + }; + Ok(Self { + canvas, + text, + embed_fonts: options.boolean("embed-fonts")?.unwrap_or(false), + id_prefix: options.get("id-prefix").map(str::to_string), + }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + self.write_reporting(spec, data).map(|(svg, _)| svg) + } +} + +/// Put what the format could not express into ggsql's own words. +/// +/// The renderer's warning variants are `#[non_exhaustive]`, and re-exporting +/// them would leak its type names into ggsql's API. Translating at the boundary +/// is also where those names get scrubbed. +fn describe(warnings: &[SvgWarning]) -> Vec { + warnings + .iter() + .map(|warning| match warning { + SvgWarning::SweepGradient => { + "a sweep gradient was flattened to a solid colour; SVG has no conic gradient".into() + } + SvgWarning::UnsupportedCompose => { + "a blend mode SVG cannot express was drawn as normal compositing".into() + } + SvgWarning::AsymmetricCaps => { + "a stroke asked for different start and end caps; SVG has one, so both took the \ + start cap" + .into() + } + SvgWarning::RadialFocalRadius => { + "a radial gradient's focal radius was written as SVG 2's 'fr', which older \ + viewers ignore" + .into() + } + SvgWarning::ImageBrushUnsupported => { + "an image used as a fill or stroke was dropped; SVG cannot paint with one".into() + } + SvgWarning::NonFiniteCoordinate => { + "a coordinate was not a finite number and was written as zero".into() + } + SvgWarning::TextWithoutSource => { + "some text arrived with neither a string nor an outline and was not drawn".into() + } + SvgWarning::MissingPngFeature => { + "an image could not be embedded: this build has no PNG encoder".into() + } + SvgWarning::UnembeddableImage => "an image's pixel layout could not be embedded".into(), + SvgWarning::FontNotEmbeddable => { + "a font could not be inlined — font collections cannot be — so its text will \ + render in whatever font the viewer resolves" + .into() + } + // Unbalanced layers or scopes are a defect in the writer rather + // than a limit of the format, and the variants are non-exhaustive. + other => format!("the plot renderer reported '{other:?}'"), + }) + .collect() +} + +#[cfg(test)] +impl super::canvas::Canvased for SvgWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::{ + assert_canvas_semantics, assert_transparent_background, + }; + + fn writer(pairs: &[&str]) -> Result { + SvgWriter::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + assert_transparent_background::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let default = SvgWriter::default(); + assert_eq!(writer(&[]).unwrap(), default); + assert_eq!(default.text, TextMode::Text); + assert!(!default.embed_fonts); + assert_eq!(default.id_prefix, None); + } + + #[test] + fn text_takes_the_two_modes() { + assert_eq!(writer(&["text=text"]).unwrap().text, TextMode::Text); + assert_eq!(writer(&["text=outline"]).unwrap().text, TextMode::Outline); + let err = writer(&["text=fancy"]).unwrap_err().to_string(); + assert!(err.contains("'text' expects 'text' or 'outline'"), "{err}"); + } + + #[test] + fn the_flags_read_either_spelling_of_their_key() { + for key in ["embed-fonts", "embed_fonts"] { + assert!( + writer(&[&format!("{key}=true")]).unwrap().embed_fonts, + "{key}" + ); + assert!( + !writer(&[&format!("{key}=no")]).unwrap().embed_fonts, + "{key}" + ); + } + for key in ["id-prefix", "id_prefix"] { + let w = writer(&[&format!("{key}=fig1-")]).unwrap(); + assert_eq!(w.id_prefix.as_deref(), Some("fig1-"), "{key}"); + } + let err = writer(&["embed-fonts=maybe"]).unwrap_err().to_string(); + assert!(err.contains("'embed_fonts' expects true or false"), "{err}"); + } + + #[test] + fn a_transparent_canvas_emits_no_background_element() { + let clear = writer(&["background=none"]).unwrap(); + assert_eq!(clear.config().background, None); + let white = writer(&[]).unwrap(); + assert!(white.config().background.is_some()); + } + + #[test] + fn a_physical_canvas_declares_its_size_in_points() { + assert_eq!( + writer(&["units=in", "width=6"]).unwrap().config().units, + SvgUnits::Pt + ); + assert_eq!(writer(&["width=600"]).unwrap().config().units, SvgUnits::Px); + } +} diff --git a/src/writer/hephaestus/tiff.rs b/src/writer/hephaestus/tiff.rs new file mode 100644 index 000000000..7fd55dcd1 --- /dev/null +++ b/src/writer/hephaestus/tiff.rs @@ -0,0 +1,182 @@ +//! The TIFF writer. + +use std::collections::HashMap; + +use hephaestus::image::encode_tiff; +pub use hephaestus::image::TiffCompression; + +use super::canvas::Canvas; +use super::{compose, raster, RasterRenderer}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, GgsqlError, Plot, Result}; + +/// Option keys [`TiffWriter`] adds to the shared canvas set. +const TIFF_OPTIONS: &[&str] = &["compression"]; + +/// How a TIFF's image data is compressed. All four are lossless. +const COMPRESSION_VALUES: &[&str] = &["none", "deflate", "lzw", "packbits"]; + +/// Writer that renders a ggsql plot to a TIFF image. +/// +/// Lossless with alpha preserved, and the format a print workflow or an older +/// imaging tool is most likely to insist on. [`TiffWriter::from_options`] takes: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any CSS color, e.g. `white`, `#ff0000`, `transparent` | `white` | +/// | `compression` | `none`, `deflate`, `lzw`, or `packbits` | `deflate` | +/// +/// All four are lossless, so `compression` is a compatibility choice: `deflate` +/// is smallest, `lzw` opens in the widest range of old readers, `packbits` is +/// cheap and does well on flat fills, and `none` stores rows verbatim. +/// +/// Rendering requires a working wgpu adapter (hardware or software, e.g. +/// lavapipe) at render time. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct TiffWriter { + canvas: Canvas, + compression: TiffCompression, +} + +impl TiffWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + compression: TiffCompression::default(), + } + } + + /// Set the background color used to clear the canvas before rendering. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Set how the image data is compressed. + pub fn compression(mut self, compression: TiffCompression) -> Self { + self.compression = compression; + self + } + + /// Render through a renderer the caller keeps, rather than building one. + /// + /// Constructing a [`RasterRenderer`] creates a GPU device and compiles the + /// rasteriser's shaders, so a host rendering more than one figure should + /// build one once and pass it here. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed, the + /// render fails, or the encode fails. + pub fn write_with( + &self, + spec: &Plot, + data: &HashMap, + renderer: &mut RasterRenderer, + ) -> Result> { + let pixels = raster::pixels(spec, data, &self.canvas, renderer)?; + encode_tiff( + self.canvas.width, + self.canvas.height, + &pixels, + self.compression, + self.canvas.dpi_hint(), + ) + .map_err(|e| GgsqlError::WriterError(format!("tiff encode failed: {e}"))) + } + + /// [`Self::write_with`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_with`]. + pub fn render_with( + &self, + spec: &crate::reader::Spec, + renderer: &mut RasterRenderer, + ) -> Result> { + self.write_with(spec.plot(), spec.data(), renderer) + } +} + +impl Writer for TiffWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + let canvas = Canvas::from_options(options, TIFF_OPTIONS)?; + let compression = match options.one_of("compression", COMPRESSION_VALUES)? { + Some("none") => TiffCompression::None, + Some("lzw") => TiffCompression::Lzw, + Some("packbits") => TiffCompression::Packbits, + _ => TiffCompression::Deflate, + }; + Ok(Self { + canvas, + compression, + }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + let mut renderer = RasterRenderer::new()?; + self.write_with(spec, data, &mut renderer) + } +} + +#[cfg(test)] +impl super::canvas::Canvased for TiffWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::{ + assert_canvas_semantics, assert_transparent_background, + }; + + fn writer(pairs: &[&str]) -> Result { + TiffWriter::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + assert_transparent_background::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let default = TiffWriter::default(); + assert_eq!(writer(&[]).unwrap(), default); + assert_eq!(default.compression, TiffCompression::Deflate); + } + + #[test] + fn compression_takes_the_four_named_compressors() { + let cases = [ + ("none", TiffCompression::None), + ("deflate", TiffCompression::Deflate), + ("lzw", TiffCompression::Lzw), + ("packbits", TiffCompression::Packbits), + ]; + for (value, expected) in cases { + let w = writer(&[&format!("compression={value}")]).unwrap(); + assert_eq!(w.compression, expected, "compression={value}"); + } + // A png level is not a tiff compressor, and the error says which are. + let err = writer(&["compression=fast"]).unwrap_err().to_string(); + assert!(err.contains("'compression' expects"), "{err}"); + assert!(err.contains("packbits"), "{err}"); + } +} diff --git a/src/writer/hephaestus/vector.rs b/src/writer/hephaestus/vector.rs new file mode 100644 index 000000000..b34ff8cd8 --- /dev/null +++ b/src/writer/hephaestus/vector.rs @@ -0,0 +1,34 @@ +//! Drawing a composition into a vector scene. +//! +//! The counterpart to [`raster`](super::raster) for the backends that emit +//! drawing commands rather than pixels. `PlotComposition::render` takes +//! `&mut dyn SceneBuilder`, so this is the same call the rasteriser makes — +//! which is why SVG and PDF need no GPU adapter, no wgpu and no encoder. + +use std::collections::HashMap; + +use hephaestus::SceneBuilder; + +use super::canvas::Canvas; +use super::compose; +use crate::{DataFrame, Plot, Result}; + +/// Check the plot, compose it, and draw it into `scene`. +/// +/// Everything a vector writer does before serialising; what differs is the +/// scene type and how it is turned into bytes. +/// +/// # Errors +/// +/// Returns `GgsqlError::WriterError` if the plot cannot be drawn by this +/// renderer, or if composing it fails. +pub fn draw( + spec: &Plot, + data: &HashMap, + canvas: &Canvas, + scene: &mut dyn SceneBuilder, +) -> Result<()> { + let mut view = compose::prepare(spec, data)?; + view.render(scene, canvas.size(), canvas.dpi); + Ok(()) +} diff --git a/src/writer/hephaestus/webp.rs b/src/writer/hephaestus/webp.rs new file mode 100644 index 000000000..427b72b08 --- /dev/null +++ b/src/writer/hephaestus/webp.rs @@ -0,0 +1,150 @@ +//! The WebP writer. + +use std::collections::HashMap; + +use hephaestus::image::encode_webp; + +use super::canvas::Canvas; +use super::{compose, raster, RasterRenderer}; +use crate::writer::{Writer, WriterOptions}; +use crate::{DataFrame, GgsqlError, Plot, Result}; + +/// Writer that renders a ggsql plot to a lossless WebP image. +/// +/// The best default for a raster plot delivered over a wire: lossless like PNG, +/// alpha included, about as fast as `png` at `compression=fast`, and roughly +/// half the bytes on plot content. +/// +/// No `quality` and no `compression` — the VP8L lossless bitstream has no rate +/// control to expose. Every other option is the shared canvas set: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Canvas width, in `units` | 1500 px | +/// | `height` | Canvas height, in `units` | 1000 px | +/// | `units` | `px`, `in`, `cm`, `mm`, or `pt` — how `width`/`height` are read | `px` | +/// | `dpi` | Pixels per inch; converts physical sizes, including `units` | 300 | +/// | `background` | Any CSS color, e.g. `white`, `#ff0000`, `transparent` | `white` | +/// +/// Rendering requires a working wgpu adapter (hardware or software, e.g. +/// lavapipe) at render time. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct WebpWriter { + canvas: Canvas, +} + +impl WebpWriter { + /// Create a writer for the given pixel dimensions and DPI, white background. + pub fn new(width: u32, height: u32, dpi: f64) -> Self { + Self { + canvas: Canvas::new(width, height, dpi), + } + } + + /// Set the background color used to clear the canvas before rendering. + pub fn background(mut self, color: super::Color) -> Self { + self.canvas = self.canvas.background(color); + self + } + + /// Render through a renderer the caller keeps, rather than building one. + /// + /// Constructing a [`RasterRenderer`] creates a GPU device and compiles the + /// rasteriser's shaders, so a host rendering more than one figure should + /// build one once and pass it here. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed, the + /// render fails, or the encode fails. + pub fn write_with( + &self, + spec: &Plot, + data: &HashMap, + renderer: &mut RasterRenderer, + ) -> Result> { + let pixels = raster::pixels(spec, data, &self.canvas, renderer)?; + // Straight alpha in, straight alpha out — VP8L stores exactly the + // buffer the renderer read back. + encode_webp( + self.canvas.width, + self.canvas.height, + &pixels, + self.canvas.dpi_hint(), + ) + .map_err(|e| GgsqlError::WriterError(format!("webp encode failed: {e}"))) + } + + /// [`Self::write_with`] from a resolved `Spec`. + /// + /// # Errors + /// + /// As [`Self::write_with`]. + pub fn render_with( + &self, + spec: &crate::reader::Spec, + renderer: &mut RasterRenderer, + ) -> Result> { + self.write_with(spec.plot(), spec.data(), renderer) + } +} + +impl Writer for WebpWriter { + type Output = Vec; + + fn from_options(options: &WriterOptions) -> Result { + Ok(Self { + canvas: Canvas::from_options(options, &[])?, + }) + } + + fn validate(&self, spec: &Plot) -> Result<()> { + compose::validate_plot(spec) + } + + fn write(&self, spec: &Plot, data: &HashMap) -> Result { + let mut renderer = RasterRenderer::new()?; + self.write_with(spec, data, &mut renderer) + } +} + +#[cfg(test)] +impl super::canvas::Canvased for WebpWriter { + fn canvas(&self) -> &Canvas { + &self.canvas + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + use crate::writer::hephaestus::canvas::{ + assert_canvas_semantics, assert_transparent_background, + }; + + #[test] + fn canvas_options_behave_as_they_do_for_every_writer() { + assert_canvas_semantics::(); + assert_transparent_background::(); + } + + #[test] + fn the_default_writer_matches_no_options() { + let options = WriterOptions::parse(&[] as &[&str]).unwrap(); + assert_eq!( + WebpWriter::from_options(&options).unwrap(), + WebpWriter::default() + ); + } + + #[test] + fn there_is_no_rate_knob_to_mistype() { + // VP8L has no quality or compression setting, so naming one is an error + // rather than a silently ignored request for a smaller file. + for absent in ["quality=80", "compression=fast"] { + let options = WriterOptions::parse([absent]).unwrap(); + let err = WebpWriter::from_options(&options).unwrap_err().to_string(); + assert!(err.contains("unknown writer option"), "{absent}: {err}"); + } + } +} diff --git a/src/writer/hephaestus/window.rs b/src/writer/hephaestus/window.rs new file mode 100644 index 000000000..d2a56d62b --- /dev/null +++ b/src/writer/hephaestus/window.rs @@ -0,0 +1,231 @@ +//! The plot viewer. +//! +//! Not a writer: it produces no output. It lives here because it needs the same +//! composition every writer builds, and because `ggsql-cli` uses only public +//! `ggsql::*` API and has no renderer dependency of its own. + +use hephaestus::plot::PlotComposition; +use hephaestus::window::{self, Event, EventCtx, Frame, WindowApp, WindowConfig}; + +use super::canvas::{parse_background, whole_pixels}; +use crate::reader::Spec; +use crate::writer::WriterOptions; +use crate::{GgsqlError, Result}; + +/// Option keys the viewer understands. +/// +/// Notably not `units` or `dpi`: a window's size is logical pixels and its +/// resolution belongs to the display it opens on. +const VIEWER_OPTIONS: &[&str] = &["width", "height", "background", "title"]; + +/// Default window size, matching the renderer's own. +const DEFAULT_WIDTH: u32 = 800; +const DEFAULT_HEIGHT: u32 = 600; + +/// Shows a ggsql plot in a native window. +/// +/// Resizing needs no code: the composition re-solves its layout at the size and +/// resolution the window reports each frame, so the plot re-lays-out rather +/// than stretching. +/// +/// [`PlotViewer::from_options`] takes: +/// +/// | Option | Value | Default | +/// | --- | --- | --- | +/// | `width` | Window width in logical pixels | 800 | +/// | `height` | Window height in logical pixels | 600 | +/// | `background` | Any CSS color, e.g. `white`, `#ff0000`, `transparent` | `white` | +/// | `title` | Window title | `ggsql` | +/// +/// Not a [`Writer`](crate::writer::Writer): it returns no output, blocks, and +/// must run on the main thread. `from_options` plus `show` gives the same +/// option ergonomics without claiming otherwise. +/// +/// Requires a working GPU adapter, like the raster writers. +#[derive(Debug, Clone, PartialEq)] +pub struct PlotViewer { + width: u32, + height: u32, + background: super::Color, + title: String, +} + +impl PlotViewer { + /// A viewer for a window of the given size in logical pixels. + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + ..Self::default() + } + } + + /// Set the window title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// Set the color the window is cleared to before each frame. + pub fn background(mut self, color: super::Color) -> Self { + self.background = color; + self + } + + /// Build a viewer from free-form key–value options. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` for an unknown key or an unusable + /// value. `units` and `dpi` are rejected with a reason rather than as typos. + pub fn from_options(options: &WriterOptions) -> Result { + for (key, why) in [ + ("units", "a window is sized in logical pixels"), + ( + "dpi", + "a window's resolution belongs to the display it opens on", + ), + ] { + if options.get(key).is_some() { + return Err(GgsqlError::WriterError(format!( + "the plot viewer takes no '{key}' option: {why}. Render to a file if you \ + need to choose one" + ))); + } + } + options.reject_unknown(VIEWER_OPTIONS)?; + + let mut viewer = Self::default(); + if let Some(width) = options.number("width")? { + viewer.width = whole_pixels(width, "width")?; + } + if let Some(height) = options.number("height")? { + viewer.height = whole_pixels(height, "height")?; + } + if let Some(raw) = options.get("background") { + viewer.background = parse_background(raw)?; + } + if let Some(title) = options.get("title") { + viewer.title = title.to_string(); + } + Ok(viewer) + } + + /// Show the plot and **block until the window closes.** + /// + /// Must be called from the main thread, as the platform event loops require. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the plot cannot be composed, if no + /// GPU adapter can drive a window, or if the event loop fails. + pub fn show(&self, spec: &Spec) -> Result<()> { + let view = super::compose::prepare(spec.plot(), spec.data())?; + + let config = WindowConfig::new(self.title.clone()) + .size(self.width, self.height) + .background(self.background); + + window::run(config, SpecApp { view }) + .map_err(|e| GgsqlError::WriterError(format!("the plot viewer failed: {e}"))) + } +} + +impl Default for PlotViewer { + fn default() -> Self { + Self { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + background: super::rgba(1.0, 1.0, 1.0, 1.0), + title: "ggsql".to_string(), + } + } +} + +/// One composition, redrawn at whatever size the window currently is. +struct SpecApp { + view: PlotComposition, +} + +impl WindowApp for SpecApp { + fn draw(&mut self, frame: &mut Frame<'_>) { + // The frame reports its own size and dpi, which is what makes a resize + // a re-layout rather than a rescale. + let (scene, size, dpi) = frame.parts(); + self.view.render(scene, size, dpi); + } + + fn event(&mut self, ctx: &mut EventCtx<'_>, event: Event) { + // The window stays open until the app says otherwise, so closing it is + // the one event that needs handling. + if matches!(event, Event::CloseRequested) { + ctx.exit(); + } + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + + fn viewer(pairs: &[&str]) -> Result { + PlotViewer::from_options(&WriterOptions::parse(pairs)?) + } + + #[test] + fn no_options_gives_the_defaults() { + let default = PlotViewer::default(); + assert_eq!(viewer(&[]).unwrap(), default); + assert_eq!((default.width, default.height), (800, 600)); + assert_eq!(default.title, "ggsql"); + assert_eq!(default.background.components, [1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn size_title_and_background_are_taken_as_given() { + let v = viewer(&["width=1280", "height=720", "title=My plot"]).unwrap(); + assert_eq!((v.width, v.height), (1280, 720)); + assert_eq!(v.title, "My plot"); + assert_eq!( + viewer(&["background=#ff0000"]) + .unwrap() + .background + .components, + [1.0, 0.0, 0.0, 1.0] + ); + assert_eq!( + viewer(&["background=none"]).unwrap().background.components[3], + 0.0 + ); + } + + #[test] + fn a_physical_size_is_refused_with_a_reason() { + // Accepting a `dpi` the viewer ignores is the silent failure + // `reject_unknown` exists to prevent, so these say why. + for (option, expected) in [ + ("units=in", "sized in logical pixels"), + ("dpi=300", "belongs to the display"), + ] { + let err = viewer(&[option]).unwrap_err().to_string(); + assert!(err.contains(expected), "{option}: {err}"); + assert!(err.contains("the plot viewer takes no"), "{option}: {err}"); + } + } + + #[test] + fn other_bad_values_are_reported_per_option() { + let cases = [ + ("width=0", "'width' resolves to 0 px"), + ("height=abc", "'height' expects a number"), + ("background=nope", "'background' expects a CSS color"), + ]; + for (option, expected) in cases { + let err = viewer(&[option]).unwrap_err().to_string(); + assert!(err.contains(expected), "{option}: {err}"); + } + let err = viewer(&["compression=fast"]).unwrap_err().to_string(); + assert!(err.contains("unknown writer option 'compression'"), "{err}"); + assert!(err.contains("width, height, background, title"), "{err}"); + } +} diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index c10b34591..35d3758f8 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -7,11 +7,10 @@ use std::collections::HashSet; use hephaestus::color::{rgb8, Color}; use hephaestus::plot::chrome::legend::{Legend, LegendKeySpec}; use hephaestus::plot::geom::{BuildableGeom, Geom, GeomBuilder, Raw}; -use hephaestus::plot::theme::{Element, Length, RectElement, Theme, DEFAULT_TEXT_LINEHEIGHT}; +use hephaestus::plot::theme::{Element, Length, RectElement, Theme}; use hephaestus::plot::Plot as HPlot; use hephaestus::scales::chrome::LegendSide; use hephaestus::scales::value::{DataColumn, Value as HValue}; -use hephaestus::text::rich::{LineHeightSpec, StyleDelta}; use super::channels::{ aesthetic_column_name, build_group_keys, column_to_bool, column_to_channel, column_to_colors, @@ -43,15 +42,6 @@ use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; /// `TextRun::new` regardless, so they still draw their markers; they start /// parsing with no change here once hephaestus reads the flag at those sites /// (see [Known gaps](CLAUDE.md)). -/// - **One line height for both text paths.** hephaestus's rich-text sheet gives -/// its root selector marquee's `1.6` line height, while the plain path uses the -/// theme's `1.2`. Chrome slots are one-liners whose measured box sets how much -/// room the layout reserves, so the mismatch made every axis title claim ~0.4 -/// lines more than it draws — shrinking the panel, and by a *different* amount -/// horizontally, since the y title measures rotated. Folding the theme's line -/// height onto the sheet's root brings a plain string back to nearly the layout -/// it had unparsed: ~1pt of the ~3pt it was claiming remains, which is the -/// rich block model's own box and not something a sheet entry reaches. pub fn ggsql_theme() -> Theme { let mut theme = Theme::default(); theme.legend.bar.frame = Element::Set(RectElement { @@ -61,16 +51,6 @@ pub fn ggsql_theme() -> Theme { ..RectElement::default() }); theme.text.markdown = Some(true); - let mut sheet = (*theme.rich_text).clone(); - let base = sheet.get("base").cloned().unwrap_or_default(); - sheet.set( - "base", - StyleDelta { - lineheight: Some(LineHeightSpec::Mult(DEFAULT_TEXT_LINEHEIGHT)), - ..base - }, - ); - theme.rich_text = std::sync::Arc::new(sheet); theme } diff --git a/src/writer/mod.rs b/src/writer/mod.rs index a0aad469a..02d235a2b 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -42,14 +42,56 @@ pub mod vegalite; #[cfg(feature = "vegalite")] pub use vegalite::VegaLiteWriter; -// The raster writer is backed by the hephaestus renderer, which the module name -// records. That is an implementation detail: the writer is public as `PngWriter` -// and the module itself is not part of the API. -#[cfg(feature = "png")] +// The renderer-backed writers live in one private module named after the +// renderer they share; each is public under its own format's name. Gated on +// `graphics`, the shared composition layer, rather than on any one format. +#[cfg(feature = "graphics")] +// `graphics` and `raster-writer` are internal features the writer features turn +// on. Selecting one alone is legitimate — `cargo tree --features graphics` +// proves the vector path pulls in no wgpu — but leaves the composition layer +// with no consumer, so silence that case only. Any build with an actual writer +// still reports real dead code. +#[cfg_attr( + not(any( + feature = "png", + feature = "jpeg", + feature = "tiff", + feature = "webp", + feature = "svg", + feature = "pdf", + feature = "hep", + feature = "window" + )), + allow(dead_code) +)] mod hephaestus; +#[cfg(feature = "graphics")] +pub use hephaestus::{rgba, Canvas, Color}; + +#[cfg(feature = "raster-writer")] +pub use hephaestus::{RasterRenderer, MAX_RASTER_DIMENSION}; + +#[cfg(feature = "jpeg")] +pub use hephaestus::JpegWriter; +#[cfg(feature = "webp")] +pub use hephaestus::WebpWriter; + +#[cfg(feature = "hep")] +pub use hephaestus::HepWriter; +#[cfg(feature = "pdf")] +pub use hephaestus::PdfWriter; +#[cfg(feature = "svg")] +pub use hephaestus::SvgWriter; + +// Not a writer — it produces no output — but it needs the same composition, so +// it lives beside them. See its own docs for why it is not a `Writer` impl. +#[cfg(feature = "window")] +pub use hephaestus::PlotViewer; #[cfg(feature = "png")] -pub use hephaestus::{rgba, Color, PngWriter}; +pub use hephaestus::{PngCompression, PngWriter}; +#[cfg(feature = "tiff")] +pub use hephaestus::{TiffCompression, TiffWriter}; /// Trait for visualization output writers /// @@ -58,8 +100,9 @@ pub use hephaestus::{rgba, Color, PngWriter}; /// /// # Associated Types /// -/// * `Output` - The type returned by `write()` and `render()`. Use `Option` -/// for text output, `Option>` for binary, `()` for void writers, etc. +/// * `Output` - The type returned by `write()` and `render()`: `String` for a +/// text format, `Vec` for a binary one. Never an `Option` — failure is +/// the `Result`'s business — and a type producing nothing is not a writer. pub trait Writer { /// The output type produced by this writer. type Output; diff --git a/src/writer/options.rs b/src/writer/options.rs index aa9bc411f..9f7986f9e 100644 --- a/src/writer/options.rs +++ b/src/writer/options.rs @@ -1,10 +1,9 @@ //! Free-form key–value options for writers. //! -//! A frontend collects `key=value` pairs from its user (`-D width=1600`, or -//! `-D 'width=1600;dpi=150'`, on the CLI) and hands them to -//! [`Writer::from_options`](super::Writer::from_options). Each writer therefore -//! exposes its own configuration without any frontend needing to know the -//! writer's shape, and a writer that takes no options needs no special casing. +//! A frontend collects `key=value` pairs from its user (`-D width=1600` on the +//! CLI) and hands them to +//! [`Writer::from_options`](super::Writer::from_options), so each writer +//! exposes its configuration without the frontend knowing its shape. use std::collections::BTreeMap; @@ -40,10 +39,9 @@ impl WriterOptions { /// ["width=1600;height=1200"] // collapsed into one /// ``` /// - /// `;` is the only separator. `,` is not, because it is common *inside* a - /// value — `background=rgba(0,0,0,0)` has to survive. The value is - /// everything from the first `=` to the next `;`, so values may contain `=` - /// themselves, and a later occurrence of a key overrides an earlier one. + /// `;` is the only separator; `,` is common inside a value + /// (`background=rgba(0,0,0,0)`). The value runs from the first `=` to the + /// next `;`, and a later occurrence of a key overrides an earlier one. /// /// # Errors /// @@ -110,6 +108,30 @@ impl WriterOptions { } } + /// The value of `key` parsed as a boolean. + /// + /// Accepts `true`/`false`, `yes`/`no`, `on`/`off` and `1`/`0`, ignoring case + /// and surrounding whitespace. Unsupplied is `None`, so a flag defaulting to + /// `true` can tell that apart from an explicit `false`. + /// + /// # Errors + /// + /// Returns `GgsqlError::WriterError` if the value is not one of those + /// spellings. + pub fn boolean(&self, key: &str) -> Result> { + let Some(raw) = self.get(key) else { + return Ok(None); + }; + match raw.trim().to_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Ok(Some(true)), + "false" | "no" | "off" | "0" => Ok(Some(false)), + _ => Err(GgsqlError::WriterError(format!( + "writer option '{}' expects true or false, got '{raw}'", + normalise_key(key) + ))), + } + } + /// The value of `key`, checked against a closed set of allowed values. /// /// Matching ignores case and surrounding whitespace, mirroring how keys are @@ -143,11 +165,15 @@ impl WriterOptions { /// Returns `GgsqlError::WriterError` naming the unknown keys and listing /// the supported ones. pub fn reject_unknown(&self, known: &[&str]) -> Result<()> { + // The declared names are normalised too, so a writer may declare the + // hyphenated spelling its docs use and still match either form. The + // error lists them as declared. + let canonical: Vec = known.iter().map(|key| normalise_key(key)).collect(); let unknown: Vec<&str> = self .values .keys() .map(String::as_str) - .filter(|key| !known.contains(key)) + .filter(|key| !canonical.iter().any(|k| k == key)) .collect(); if unknown.is_empty() { return Ok(()); @@ -244,6 +270,31 @@ mod tests { assert!(options.number("width").is_err()); } + #[test] + fn boolean_accepts_the_usual_spellings() { + for yes in ["true", "TRUE", " yes ", "on", "1"] { + let options = WriterOptions::new().set("embed_fonts", yes); + assert_eq!(options.boolean("embed_fonts").unwrap(), Some(true), "{yes}"); + } + for no in ["false", "No", "off", "0"] { + let options = WriterOptions::new().set("embed_fonts", no); + assert_eq!(options.boolean("embed_fonts").unwrap(), Some(false), "{no}"); + } + // Unsupplied stays distinct from an explicit `false`, so a writer whose + // default is `true` can tell them apart. + assert_eq!(WriterOptions::new().boolean("embed_fonts").unwrap(), None); + } + + #[test] + fn boolean_rejects_anything_else() { + let options = WriterOptions::new().set("embed_fonts", "maybe"); + let err = options.boolean("embed_fonts").unwrap_err().to_string(); + assert!( + err.contains("'embed_fonts' expects true or false, got 'maybe'"), + "{err}" + ); + } + #[test] fn one_of_matches_case_insensitively() { let options = WriterOptions::parse(["units=CM"]).unwrap(); diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 24cca45f1..db59e01f5 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -1064,15 +1064,14 @@ impl TextRenderer { // First select which value to use (prefer literal) let value = if let Some(ParameterValue::Boolean(b)) = literal { *b - } else if let Some(s) = column_value { + } else { + let s = column_value?; // Parse string to boolean match s.to_lowercase().as_str() { "true" | "1" => true, "false" | "0" => false, _ => return None, } - } else { - return None; }; // Convert boolean to fontStyle