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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ jobs:
cargo check --no-default-features --features svg --all-targets
cargo check --no-default-features --features svg,png --all-targets

# The PDF backend needs no codec for a raster image — PDF takes raw
# samples — but it does reach for `png` to decode a bitmap color
# glyph, which is how most emoji ship. Same two-halves reasoning as
# the SVG job above.
- name: cargo check, the PDF backend with and without an image codec
run: |
cargo check --no-default-features --features pdf --all-targets
cargo check --no-default-features --features pdf,png --all-targets

# The sparse-strips backend must stand on its own: `--all-features`
# builds it beside the compute-shader one, which would hide anything it
# reaches for through the other backend's gates.
Expand Down Expand Up @@ -188,6 +197,13 @@ jobs:
cargo check --no-default-features --features document-read,svg \
--all-targets --ignore-rust-version

# And the same claim for the fixed vector backend, which adds only
# `flate2` over what `svg` needs.
- name: cargo check, document in / PDF out on the oldest rustc
run: |
cargo check --no-default-features --features document-read,pdf \
--all-targets --ignore-rust-version

# wasm32 is a supported target, so it gets its own job: it shares no compiled
# artifacts with the host build and needs none of the system libraries above
# (fontconfig and Vulkan are both gated to native targets). Catches
Expand Down Expand Up @@ -231,6 +247,13 @@ jobs:
cargo check --target wasm32-unknown-unknown --no-default-features \
--features svg,document-read

# A PDF download is the other obvious thing for a page to offer, and
# `flate2` has to resolve to its pure-Rust backend to get there.
- name: cargo check --target wasm32-unknown-unknown, PDF out
run: |
cargo check --target wasm32-unknown-unknown --no-default-features \
--features pdf,document-read

# The render client's exact configuration: the canvas host plus a
# read-only document reader, and nothing else. Checked on its own
# because it is the one combination a website actually ships, and
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ examples/*.png
# Example output, same as the PNGs: regenerated by running the example,
# and 30 kB of text that would churn on every change if tracked.
examples/*.svg
examples/*.pdf
examples/*.jpg
examples/*.tiff
examples/*.webp
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **PDF export, behind the `pdf` feature.** `hephaestus::pdf::write_pdf` / `write_pdf_to` / `encode_pdf` over `PdfScene`, which implements `SceneBuilder` and not `Renderer`, so `PlotComposition::render` feeds it unchanged. Where `svg` aims at editable output this aims at a fixed one, so every glyph a plot draws is embedded — as a subset TrueType synthesized from the outlines actually used, which is a few kB rather than the megabyte face and covers CFF/OTF sources, variable-font instances and `ttcf` collections in one path. Text stays selectable and searchable through a `/ToUnicode` CMap; markdown links become `/Link` annotations; a filled-and-stroked mark is one `B` operator; gradients are `ShadingType 2` / `3` patterns with a non-zero radial focal radius expressed natively; a mesh is a native `ShadingType 4` Gouraud shading rather than one fill per triangle; a gradient or mesh whose alpha varies across it — a confidence band that fades, say — is carried by a luminosity soft mask rather than flattened to one opacity; a translucent or blended layer is a real transparency group; and colour emoji render, both COLR paint graphs and bitmap strikes. Whatever PDF cannot express is reported by `PdfScene::warnings`. Adds only `skrifa` and `flate2`, both already in the tree, so `--no-default-features --features document-read,pdf` builds with no renderer on rustc 1.86; a *bitmap* colour glyph additionally needs `png`.
- **`primitives::RibbonOptions::seam_bleed` and `primitives::ribbon_band_mesh_with_bleed`** — control the overlap ribbon tessellation gives adjacent quads to hide the antialiased seam between independent fills. Both default to the existing values; `0.0` is for a consumer that paints the mesh as one object, where there is no seam and the overlap is distortion.
- **SVG export, behind the `svg` feature.** `hephaestus::svg::write_svg` / `write_svg_to` / `encode_svg` over `SvgScene`, which implements `SceneBuilder` and not `Renderer`, so `PlotComposition::render` feeds it unchanged. Labels are `<text>`: markdown spans become `<tspan>`s, `[text](url)` an `<a href>`, underline and strikethrough `text-decoration-line`, and a run is placed by one anchor plus `textLength`. A filled-and-stroked mark is one `<path>`, a wrapped label one `<text>`. The face request — family, size, weight, style, `font-stretch`, letter spacing, OpenType features and variable-font axes — is named on the root and inherited, with only the spans that differ naming their own; `@import` covers families resolved through `fetch_google_font`, and `SvgConfig::embed_fonts` inlines faces as `@font-face`. Whatever SVG cannot express is reported by `SvgScene::warnings`. Adds only `skrifa`, so `--no-default-features --features document-read,svg` builds with no renderer on rustc 1.86; embedding a raster image needs `png`.
- **`hephaestus::scene::TextSource`** — what a glyph run was shaped from, carried on `GlyphRun` and `OwnedGlyphRun`: the source substring, a `FontSpec`, the run's advance, its decorations, a link destination, and a `TextGroup` naming the runs laid out together. `None` where a caller positioned glyphs itself.
- **`hephaestus::style_vocab::FontSpec`**, beside `FontFamilyEntry`, `GenericFamilyKind`, `FontStyleKind`, `FontFeatureSetting` and `FontVariationSetting`, which moved there from `text`. Re-exported from `crate::text`, so every existing path still resolves.
Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Repo-level orientation for working in `hephaestus`. Architecture, module map, an

## Project

`hephaestus` is a 2D scene renderer for data visualization. The crate exposes a backend-agnostic scene API, two Vello backends over wgpu — Vello Classic (GPU compute) and Vello Hybrid (sparse strips: path processing on the CPU, a render pipeline on the GPU) — and an SVG backend that emits markup rather than pixels; future planned backends are Blend2D (CPU raster) and PDF. Performance for interactive / real-time updates on dense plots is the design driver. WASM must work.
`hephaestus` is a 2D scene renderer for data visualization. The crate exposes a backend-agnostic scene API, two Vello backends over wgpu — Vello Classic (GPU compute) and Vello Hybrid (sparse strips: path processing on the CPU, a render pipeline on the GPU) — and two vector backends that emit markup rather than pixels: SVG, aimed at editable output, and PDF, aimed at a fixed artifact with its fonts embedded. The one future planned backend is Blend2D (CPU raster). Performance for interactive / real-time updates on dense plots is the design driver. WASM must work.

The crate ships two API levels in the same source tree: a low-level scene API (`SceneBuilder` + primitives + layout) and a high-level plot API (`plot::*` — geoms, scales, and the `PlotComposition` orchestrator) built on top of it. See `src/CLAUDE.md` for the split and the rules that govern it.

Expand All @@ -30,13 +30,17 @@ cargo check --no-default-features --features window,vello-hybrid,png # presenta
cargo test --features document --test document_roundtrip # plot documents: reflow at unseen sizes
cargo test --features svg --test svg # the vector backend, with and without a codec
cargo test --features svg,png --test svg
cargo test --features pdf --test pdf # the fixed vector backend
cargo test --no-default-features --features pdf,png --test pdf # …and its one use for a codec: bitmap color glyphs

cargo clippy --all-features --all-targets -- -D warnings # treat warnings as errors
cargo fmt # rustfmt; always run before declaring a task done

cargo run --example hello # renders examples/hello.png — visual sanity check
cargo run --example svg_export --features svg # one plot as both SVG and PNG, for side-by-side review
cargo run --example pdf_export --features pdf,vello,png # the same plot as both PDF and PNG
cargo check --no-default-features --features document-read,svg # renderer-free: document in, SVG out
cargo check --no-default-features --features document-read,pdf # …and document in, PDF out
cargo run --example image_formats --features jpeg,tiff,webp # all four raster writers
cargo run --example image_geom # raster images placed in a panel, and in markdown
cargo run --example document_placeholder --features vello-hybrid,document-read,png # the static picture a page shows while the client boots
Expand Down Expand Up @@ -101,7 +105,8 @@ Style rules (apply everywhere, including comments in `tests/` and `examples/`):
- **`geom-wkt`**, **`geom-wkb`**, **`geom-geojson`** (off by default) — opt-in parsers for `crate::scales::Geometry`. Each gate enables one of `Geometry::from_wkt` / `from_wkb` / `from_geojson`. Hand-rolled and dependency-free, so toggling them only affects what constructors compile, not the dependency tree.
- **`document-read`**, **`document-write`**, **`document`** (off by default) — plot documents: capture a `PlotComposition` to a self-contained binary file and rebuild it elsewhere, so a wasm build on a website re-solves the layout at whatever size it has rather than scaling a frozen image. Hand-rolled and dependency-free, like the `geom-*` parsers. Split by direction because a consumer only ever reads; `document` enables both. See `src/document/CLAUDE.md`. Adding no dependency of their own, they are also the one useful configuration with no renderer at all: `--no-default-features --features document-write` builds a writer that compiles on rustc 1.86, which `vello` rules out.
- **`svg`** (off by default) — vector output: a `SceneBuilder` that emits SVG text instead of pixels, so it implements `SceneBuilder` and not `Renderer`. The point is *editable* output rather than merely vector output — text arrives as real `<text>` elements naming their font, markdown links as `<a href>`, decorations as `text-decoration`, and a filled-and-stroked mark as one `<path>` rather than two stacked ones. Needs no GPU and adds only `skrifa` (already in the tree via parley, for the glyph-outline fallback), which makes `--no-default-features --features document-read,svg` a renderer-free "document in, SVG out" build on rustc 1.86. Embedding a raster image additionally needs `png`; without it an image is reported and skipped. See `src/backend/svg/CLAUDE.md`.
- **`blend2d`**, **`pdf`** — feature placeholders only; no backend code behind them yet. Wired so dependent crates can write `features = ["blend2d"]` once they exist.
- **`pdf`** (off by default) — fixed vector output: a `SceneBuilder` that emits a PDF file. Where `svg` aims at output someone can *edit*, this aims at output that looks the same everywhere — a figure going into a paper, a print pipeline or an archive. So every glyph a plot draws is embedded, always, as a subset font synthesized from the outlines actually used: a few kB rather than the 2.4 MB collection macOS resolves `sans-serif` to, and one code path that also handles CFF faces, variable-font instances and collections, none of which `svg` can embed. Adds `skrifa` (already in the tree via parley) and `flate2` (already there via `png`), so `--no-default-features --features document-read,pdf` is a renderer-free "document in, PDF out" build on rustc 1.86. Unlike `svg` it does not need `png` for raster images — PDF takes raw samples — but it does reach for it to decode a *bitmap* color glyph, which is how most emoji ship; without it those report `PdfWarning::MissingPngFeature`. Three things this expresses that `svg` cannot: real transparency groups, a native Gouraud mesh shading, and color emoji. See `src/backend/pdf/CLAUDE.md`.
- **`blend2d`** — a feature placeholder only; no backend code behind it yet. Wired so dependent crates can write `features = ["blend2d"]` once it exists.

The core types and traits compile with `--no-default-features` (no wgpu pulled in), so downstream crates can build on top of `SceneBuilder` without GPU dependencies.

Expand Down Expand Up @@ -175,6 +180,7 @@ See `crates/hephaestus-wasm/CLAUDE.md` for the npm side in detail.
## Where to look next

- **`src/backend/svg/CLAUDE.md`** — the vector backend: why text is `textLength` rather than per-glyph positions, and what degrades.
- **`src/backend/pdf/CLAUDE.md`** — the fixed vector backend: why the embedded font is synthesized rather than sliced, the pattern-matrix trap, and why this is the first backend that draws a mesh correctly.
- **`src/CLAUDE.md`** — code architecture: API levels, two-trait split, intersection-of-backends rule, picking model, module map.
- **Per-module `CLAUDE.md` files** under `src/scene/`, `src/backend/`, `src/backend/vello/`, `src/backend/hybrid/`, `src/layout/`, `src/composition/`, `src/document/`, `src/primitives/`, `src/plot/`, `src/plot/geom/`, `src/plot/theme/`, `src/scales/`, `src/image/`, `src/text/`, `src/text/rich/`, `src/window/`.
- **`crates/hephaestus-wasm/CLAUDE.md`** — the wasm render client: the Rust/JS split, why WebGPU is required, and why fonts are the thing that surprises people.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 27 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,23 @@ blend2d = []
# reported through `SvgWarning::MissingPngFeature`, the same
# degradation a document without `png` produces.
svg = ["dep:skrifa"]
pdf = []
# Fixed vector output: implements `SceneBuilder` and emits a PDF file
# rather than pixels. Where `svg` aims at output someone can edit, this
# aims at output that looks the same everywhere — so the glyphs a plot
# draws are embedded, always, as a synthesized subset font a few kB in
# size rather than the megabyte face they came from. `skrifa` reads
# those outlines and drives the COLR paint traversal that puts color
# emoji on the page; both it and `flate2` are already in the tree, via
# parley and `png` respectively.
#
# A raster image needs no codec — PDF takes raw samples — so unlike
# `svg` this gate does not imply `png` for images. It does reach for it
# in one place: a *bitmap* color glyph (Apple Color Emoji and most
# Android emoji ship PNG strikes), which has to be decoded before it can
# be re-encoded as an image XObject. Without `png` those glyphs are
# reported through `PdfWarning::MissingPngFeature` and everything else
# still renders.
pdf = ["dep:skrifa", "dep:flate2"]

[dependencies]
kurbo = "0.13"
Expand Down Expand Up @@ -178,6 +194,10 @@ wgpu = { version = "29", optional = true, default-features = false, features = [
pollster = { version = "0.4", optional = true }
futures-intrusive = { version = "0.5", optional = true }
png = { version = "0.18", optional = true }
# `FlateDecode` for PDF streams. `default-features = false` plus
# `rust_backend` pins the pure-Rust miniz_oxide path, which is what
# builds for wasm; the same crate arrives transitively with `png`.
flate2 = { version = "1", optional = true, default-features = false, features = ["rust_backend"] }
jpeg-encoder = { version = "0.7", optional = true }
# The decode half of the `jpeg` feature — `jpeg-encoder` is write-only, so
# this is the one format whose two directions come from two crates.
Expand Down Expand Up @@ -260,6 +280,12 @@ required-features = ["vello", "png"]
name = "svg_export"
required-features = ["svg", "vello", "png"]

# The same shape as `svg_export`, and for the same reason: the PDF and
# the PNG come from one composition so the pair can be compared.
[[example]]
name = "pdf_export"
required-features = ["pdf", "vello", "png"]

[[example]]
name = "hello_hybrid"
required-features = ["vello-hybrid", "png"]
Expand Down
Loading
Loading