Skip to content
Open
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
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-05-12

### Added

- `DataUrlNetProvider` — an in-process `NetProvider` that resolves `data:`
URIs synchronously for fonts, images, and stylesheets routed through
blitz-dom. Lifts the previously documented `@font-face` limitation: HTML
embedding base64 fonts inline now renders correctly. No real network
I/O is performed; non-`data:` URLs are still ignored.
- New `Config.style_threading: StyleThreading` field (with builder
method) that forwards to `DocumentConfig.style_threading` (introduced
in blitz-dom 0.3.0-alpha.4 via [DioxusLabs/blitz#437]). Defaults to
`StyleThreading::Parallel`, matching blitz's default — no behavior
change for existing callers.

Pass `StyleThreading::Sequential` when invoking `render` concurrently
from multiple OS threads (e.g. a server rendering per request via
`tokio::task::spawn_blocking`). Stylo's `STYLE_THREAD_POOL` is
process-global; two `Parallel` resolves that share a rayon worker can
panic on the worker's thread-local sharing cache (`already mutably
borrowed`). `Sequential` bypasses the global pool at the
`traverse_dom` call site, making concurrent invocation safe at the
cost of intra-render style parallelism (negligible at typical
hyper-render document sizes). The `StyleThreading` enum is re-exported
from the crate root.

### Changed

- Bumped `blitz-*` to `=0.3.0-alpha.4` (was `0.2`). Companion bumps:
`anyrender 0.10`, `anyrender_vello_cpu 0.12.1`, and the PDF-feature
deps `krilla 0.7`, `stylo 0.17`, `parley 0.9`. `stylo` MUST match
blitz's pin: it uses `links = "servo_style_crate"` which enforces a
single version per dependency graph.
- `render_to_png` and `render_to_pdf` now take `&mut HtmlDocument` (was
`&HtmlDocument`); `blitz-paint::paint_scene` requires a mutable
document reference as of `0.3.0-alpha.4`.
- MSRV is now Rust 1.92, required by the new vello stack.

### Fixed

- `paint_scene` API drift in `src/render/png.rs`: added the `x_offset` /
`y_offset` arguments introduced in `blitz-paint 0.3`.

### Known Limitations

- No JavaScript support (by design)
- External image loading not yet implemented
- HTML parser may emit warnings for non-standard CSS properties

[DioxusLabs/blitz#437]: https://github.com/DioxusLabs/blitz/pull/437

## [0.1.0] - 2024-01-29

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ Tests validate actual output (PNG headers, PDF structure) rather than just smoke
## Current Limitations

- No JavaScript support (by design)
- System fonts only (`@font-face` not yet supported)
- `@font-face` with `data:` URIs supported; external `url(...)` fetches are not (no real network I/O).
- External images not yet implemented
- HTML parser emits stderr warnings for non-standard CSS (e.g., `mso-font-alt`) but rendering works
19 changes: 10 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,25 @@ pdf = ["dep:krilla", "dep:stylo", "dep:parley", "dep:linebender_resource_handle"

[dependencies]
# Core HTML/CSS parsing and layout (always required)
blitz-dom = "0.2"
blitz-html = "0.2"
blitz-traits = "0.2"
blitz-paint = "0.2"
blitz-dom = "=0.3.0-alpha.4"
blitz-html = "=0.3.0-alpha.4"
blitz-traits = "=0.3.0-alpha.4"
blitz-paint = "=0.3.0-alpha.4"

# PNG rendering (optional, enabled by default)
anyrender = { version = "0.6", optional = true }
anyrender_vello_cpu = { version = "0.7", optional = true }
anyrender = { version = "0.10", optional = true }
anyrender_vello_cpu = { version = "0.12.1", optional = true }
png = { version = "0.17", optional = true }

# PDF rendering (optional, enabled by default)
krilla = { version = "0.6", optional = true }
stylo = { version = "0.8", optional = true } # For accessing computed styles in PDF rendering
parley = { version = "0.6", optional = true } # For text layout types
krilla = { version = "0.7", optional = true }
stylo = { version = "0.17", optional = true } # For accessing computed styles in PDF rendering
parley = { version = "0.9", optional = true } # For text layout types
linebender_resource_handle = { version = "0.1", optional = true } # For font data types

# Common dependencies
thiserror = "2"
data-url = "0.3"

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ Add to your `Cargo.toml`:

```toml
[dependencies]
hyper-render = "0.1"
hyper-render = "0.3"
```

Or with specific features:

```toml
[dependencies]
hyper-render = { version = "0.1", default-features = false, features = ["png"] }
hyper-render = { version = "0.3", default-features = false, features = ["png"] }
```

## Quick Start
Expand Down Expand Up @@ -235,7 +235,7 @@ cargo run --example from_file -- input.html output.png 2>/dev/null
## Limitations

- **JavaScript** — Not supported (by design)
- **Web fonts** — System fonts only; `@font-face` not yet supported
- **Web fonts** — `@font-face` with `data:` URIs supported; external `url(...)` fetches are not (no real network I/O).
- **Images** — External image loading not yet implemented
- **Some CSS** — Advanced features like `position: sticky`, complex transforms may not work

Expand Down
64 changes: 64 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

use crate::error::{Error, Result};

/// Strategy for Stylo's style traversal during rendering.
///
/// Mirrors [`blitz_dom::StyleThreading`] so hyper-render's public API
/// doesn't leak blitz types. See [`Config::style_threading`] for guidance
/// on which variant to pick.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StyleThreading {
/// Use Stylo's parallel traversal via its global rayon thread pool.
/// Fastest for rendering a single document; concurrent invocation
/// from multiple OS threads can panic on a worker's thread-local
/// sharing cache.
#[default]
Parallel,
/// Run style traversal sequentially on the calling thread, bypassing
/// Stylo's global pool. Safe to invoke concurrently from multiple
/// OS threads.
Sequential,
}

impl From<StyleThreading> for blitz_dom::StyleThreading {
fn from(threading: StyleThreading) -> Self {
match threading {
StyleThreading::Parallel => blitz_dom::StyleThreading::Parallel,
StyleThreading::Sequential => blitz_dom::StyleThreading::Sequential,
}
}
}

/// Output format for rendered content.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
Expand Down Expand Up @@ -82,6 +110,21 @@ pub struct Config {

/// Background color as RGBA (default: white).
pub background: [u8; 4],

/// Strategy for Stylo's style traversal during rendering.
///
/// - [`StyleThreading::Parallel`] (default): use Stylo's global rayon
/// thread pool for style traversal. Fastest for rendering a single
/// large document.
/// - [`StyleThreading::Sequential`]: run style traversal sequentially
/// on the calling thread, bypassing the global pool. Required when
/// `render` is invoked concurrently from multiple OS threads (e.g.
/// a server rendering documents per request via
/// `tokio::task::spawn_blocking`) — two `Parallel` resolves that
/// share a rayon worker can panic on the worker's thread-local
/// sharing cache (`already mutably borrowed`).
///
pub style_threading: StyleThreading,
}

impl Default for Config {
Expand All @@ -94,6 +137,7 @@ impl Default for Config {
color_scheme: ColorScheme::Light,
auto_height: false,
background: [255, 255, 255, 255], // White
style_threading: StyleThreading::default(), // Parallel
}
}
}
Expand Down Expand Up @@ -248,6 +292,26 @@ impl Config {
self.background([0, 0, 0, 0])
}

/// Set the Stylo style-traversal strategy.
///
/// Pass [`StyleThreading::Sequential`] when invoking `render` from
/// multiple OS threads concurrently — see the field docs on
/// [`Config::style_threading`] for the full rationale.
///
/// # Example
///
/// ```rust
/// use hyper_render::{Config, StyleThreading};
///
/// // Server context: one render per `spawn_blocking` thread.
/// let config = Config::new().style_threading(StyleThreading::Sequential);
/// assert_eq!(config.style_threading, StyleThreading::Sequential);
/// ```
pub fn style_threading(mut self, threading: StyleThreading) -> Self {
self.style_threading = threading;
self
}

/// Minimum supported width/height in pixels.
///
/// Very small dimensions can cause overflow issues in the underlying
Expand Down
10 changes: 7 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,16 @@

mod config;
mod error;
mod net;
mod render;

pub use config::{ColorScheme, Config, OutputFormat};
pub use config::{ColorScheme, Config, OutputFormat, StyleThreading};
pub use error::{Error, Result};

use blitz_dom::DocumentConfig;
use blitz_html::HtmlDocument;
use blitz_traits::shell::Viewport;
use std::sync::Arc;

/// Render HTML content to the specified output format.
///
Expand Down Expand Up @@ -112,8 +114,8 @@ pub fn render(html: &str, config: Config) -> Result<Vec<u8>> {

// Render to the specified format
match config.format {
OutputFormat::Png => render::png::render_to_png(&document, &config),
OutputFormat::Pdf => render::pdf::render_to_pdf(&document, &config),
OutputFormat::Png => render::png::render_to_png(&mut document, &config),
OutputFormat::Pdf => render::pdf::render_to_pdf(&mut document, &config),
}
}

Expand Down Expand Up @@ -166,6 +168,8 @@ fn create_document(html: &str, config: &Config) -> Result<HtmlDocument> {

let doc_config = DocumentConfig {
viewport: Some(viewport),
net_provider: Some(Arc::new(net::DataUrlNetProvider)),
style_threading: config.style_threading.into(),
..Default::default()
};

Expand Down
113 changes: 113 additions & 0 deletions src/net.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// In-process `NetProvider` implementations.
//
// blitz-dom routes every external resource (fonts, images, stylesheets,
// `<img>` sources, `@font-face` URLs) through a `NetProvider`. The default
// `DummyNetProvider` is a no-op, which silently drops every embedded asset.
//
// `DataUrlNetProvider` resolves `data:` URIs in-process — no real network
// I/O — so HTML documents that embed assets inline (base64 fonts, base64
// images) render correctly.

use blitz_traits::net::{Bytes, NetHandler, NetProvider, Request};

/// A `NetProvider` that resolves `data:` URIs synchronously.
///
/// Any non-`data:` URL is ignored (the handler is never called), which means
/// the resource silently fails to load. This is intentional: hyper-render is
/// designed for self-contained HTML documents and does no real network I/O.
pub struct DataUrlNetProvider;

impl NetProvider for DataUrlNetProvider {
fn fetch(&self, _doc_id: usize, request: Request, handler: Box<dyn NetHandler>) {
let url_str = request.url.as_str();
if !url_str.starts_with("data:") {
return;
}
let Ok(data_url) = data_url::DataUrl::process(url_str) else {
return;
};
let Ok((bytes, _fragment)) = data_url.decode_to_vec() else {
return;
};
handler.bytes(url_str.to_string(), Bytes::from(bytes));
}
}

#[cfg(test)]
mod tests {
use super::*;
use blitz_traits::net::Url;
use std::sync::{Arc, Mutex};

/// Test double for `NetHandler` that records the single `bytes(...)`
/// invocation it receives, so tests can assert what the provider passed
/// downstream (or that nothing was passed at all).
struct CapturingHandler {
captured: Arc<Mutex<Option<(String, Bytes)>>>,
}

impl NetHandler for CapturingHandler {
fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
*self.captured.lock().unwrap() = Some((resolved_url, bytes));
}
}

fn request_for(url: &str) -> Request {
Request::get(Url::parse(url).expect("test URL must be valid"))
}

fn run(provider: &DataUrlNetProvider, url: &str) -> Option<(String, Bytes)> {
let captured: Arc<Mutex<Option<(String, Bytes)>>> = Arc::new(Mutex::new(None));
let handler = Box::new(CapturingHandler {
captured: Arc::clone(&captured),
});
provider.fetch(0, request_for(url), handler);
let result = captured.lock().unwrap().take();
result
}

#[test]
fn resolves_base64_data_url() {
// base64 of "Hello" → SGVsbG8=
let result = run(&DataUrlNetProvider, "data:text/plain;base64,SGVsbG8=");
let (resolved_url, bytes) = result.expect("handler must be invoked for a valid data URL");
assert!(resolved_url.starts_with("data:"));
assert_eq!(bytes.as_ref(), b"Hello");
}

#[test]
fn resolves_percent_encoded_data_url() {
let result = run(&DataUrlNetProvider, "data:text/plain,Hello%20World");
let (_, bytes) = result.expect("handler must be invoked for a valid data URL");
assert_eq!(bytes.as_ref(), b"Hello World");
}

#[test]
fn ignores_non_data_scheme() {
// Any non-`data:` URL is a no-op: the handler is never called and
// the resource silently fails to load. This is the documented
// contract — hyper-render does no real network I/O.
let result = run(&DataUrlNetProvider, "https://example.com/font.ttf");
assert!(
result.is_none(),
"handler must not be invoked for non-data URLs"
);
}

#[test]
fn passes_binary_payload_through_unchanged() {
// Regression guard: when the payload is base64-encoded arbitrary
// bytes (the realistic `@font-face` case — a TTF as base64),
// those bytes must reach the handler verbatim.
//
// The base64 below decodes to the 10 bytes shown in `expected` —
// the first four are an OpenType (`OTTO`) header preamble, with
// 0xff 0xfe before it to verify high-bit bytes survive the round
// trip. Precomputed because `data-url` only exposes decoding.
let url = "data:font/otf;base64,AAEAAP/+T1RUTw==";
let expected: &[u8] = &[0x00, 0x01, 0x00, 0x00, 0xff, 0xfe, b'O', b'T', b'T', b'O'];

let (_, bytes) = run(&DataUrlNetProvider, url).expect("handler must be invoked");
assert_eq!(bytes.as_ref(), expected);
}
}
4 changes: 2 additions & 2 deletions src/render/pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ type FontCache = HashMap<u64, Font>;
/// - Text rendering with embedded fonts
/// - Nested layout positioning
#[cfg(feature = "pdf")]
pub fn render_to_pdf(document: &HtmlDocument, config: &Config) -> Result<Vec<u8>> {
pub fn render_to_pdf(document: &mut HtmlDocument, config: &Config) -> Result<Vec<u8>> {
let width = config.width as f32;
let height = if config.auto_height {
get_content_height(document).unwrap_or(config.height as f32)
Expand Down Expand Up @@ -1292,6 +1292,6 @@ fn get_content_height(document: &HtmlDocument) -> Option<f32> {
}

#[cfg(not(feature = "pdf"))]
pub fn render_to_pdf(_document: &blitz_html::HtmlDocument, _config: &Config) -> Result<Vec<u8>> {
pub fn render_to_pdf(_document: &mut blitz_html::HtmlDocument, _config: &Config) -> Result<Vec<u8>> {
Err(Error::FormatNotEnabled("pdf"))
}
Loading