diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2fb58..3dde810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index d0c838c..d959eba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 053979a..186c4b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/README.md b/README.md index 84af93c..f74ce90 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/config.rs b/src/config.rs index 7bddaa8..02c5865 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 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 { @@ -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 { @@ -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 } } } @@ -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 diff --git a/src/lib.rs b/src/lib.rs index 50f66a8..e692087 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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. /// @@ -112,8 +114,8 @@ pub fn render(html: &str, config: Config) -> Result> { // 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), } } @@ -166,6 +168,8 @@ fn create_document(html: &str, config: &Config) -> Result { let doc_config = DocumentConfig { viewport: Some(viewport), + net_provider: Some(Arc::new(net::DataUrlNetProvider)), + style_threading: config.style_threading.into(), ..Default::default() }; diff --git a/src/net.rs b/src/net.rs new file mode 100644 index 0000000..971f945 --- /dev/null +++ b/src/net.rs @@ -0,0 +1,113 @@ +// In-process `NetProvider` implementations. +// +// blitz-dom routes every external resource (fonts, images, stylesheets, +// `` 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) { + 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>>, + } + + impl NetHandler for CapturingHandler { + fn bytes(self: Box, 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>> = 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); + } +} diff --git a/src/render/pdf.rs b/src/render/pdf.rs index b2a528e..62d08a2 100644 --- a/src/render/pdf.rs +++ b/src/render/pdf.rs @@ -103,7 +103,7 @@ type FontCache = HashMap; /// - Text rendering with embedded fonts /// - Nested layout positioning #[cfg(feature = "pdf")] -pub fn render_to_pdf(document: &HtmlDocument, config: &Config) -> Result> { +pub fn render_to_pdf(document: &mut HtmlDocument, config: &Config) -> Result> { let width = config.width as f32; let height = if config.auto_height { get_content_height(document).unwrap_or(config.height as f32) @@ -1292,6 +1292,6 @@ fn get_content_height(document: &HtmlDocument) -> Option { } #[cfg(not(feature = "pdf"))] -pub fn render_to_pdf(_document: &blitz_html::HtmlDocument, _config: &Config) -> Result> { +pub fn render_to_pdf(_document: &mut blitz_html::HtmlDocument, _config: &Config) -> Result> { Err(Error::FormatNotEnabled("pdf")) } diff --git a/src/render/png.rs b/src/render/png.rs index 3fce2c2..af468a6 100644 --- a/src/render/png.rs +++ b/src/render/png.rs @@ -14,7 +14,7 @@ use blitz_paint::paint_scene; /// Render a Blitz document to PNG bytes. #[cfg(feature = "png")] -pub fn render_to_png(document: &HtmlDocument, config: &Config) -> Result> { +pub fn render_to_png(document: &mut HtmlDocument, config: &Config) -> Result> { let scale = config.scale as f64; let width = config.width; let height = if config.auto_height { @@ -32,7 +32,15 @@ pub fn render_to_png(document: &HtmlDocument, config: &Config) -> Result let buffer = render_to_buffer::( |scene| { // Render the document - paint_scene(scene, document.as_ref(), scale, render_width, render_height); + paint_scene( + scene, + document.as_mut(), + scale, + render_width, + render_height, + 0, + 0, + ); }, render_width, render_height, @@ -74,6 +82,6 @@ fn get_content_height(document: &HtmlDocument) -> Option { } #[cfg(not(feature = "png"))] -pub fn render_to_png(_document: &blitz_html::HtmlDocument, _config: &Config) -> Result> { +pub fn render_to_png(_document: &mut blitz_html::HtmlDocument, _config: &Config) -> Result> { Err(Error::FormatNotEnabled("png")) } diff --git a/tests/font_face.rs b/tests/font_face.rs new file mode 100644 index 0000000..0eb61af --- /dev/null +++ b/tests/font_face.rs @@ -0,0 +1,90 @@ +// Integration tests for `@font-face` with `data:` URIs. +// +// These are smoke tests confirming that `DataUrlNetProvider` is wired into +// the render pipeline: HTML containing a `` +// declaration must render successfully and produce a valid PNG. The embedded +// font payload is intentionally a minimal OpenType preamble — blitz falls +// back to system fonts when the data is not a usable font, so rendering must +// still succeed. + +#![cfg(feature = "png")] + +use hyper_render::{render, Config}; + +/// PNG header magic bytes. +const PNG_SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + +/// Base64-encoded OpenType (`OTTO`) header preamble. The exact bytes are +/// not load-bearing for the smoke test — blitz must accept the `data:` URL, +/// hand the decoded bytes off to the font stack, and fall back gracefully +/// when the font is not actually loadable, still emitting a valid PNG. +const OTF_BASE64: &str = "AAEAAAAKAIAAAwAgT1RUTw=="; + +#[test] +fn test_font_face_data_url_renders_to_png() { + let html = format!( + r#" + + + + + +

Hello with @font-face

+ + + "# + ); + + let config = Config::new().width(400).height(300); + let result = render(&html, config); + + assert!( + result.is_ok(), + "render with @font-face data: URI should succeed: {:?}", + result.err() + ); + + let bytes = result.unwrap(); + assert!(!bytes.is_empty(), "output should not be empty"); + assert!( + bytes.starts_with(&PNG_SIGNATURE), + "output should be valid PNG" + ); +} + +#[test] +fn test_no_font_face_renders_to_png() { + // Control: identical HTML minus the @font-face block must also render. + // If this test fails alongside the @font-face one, the regression is + // elsewhere in the render pipeline — not in the data: URL plumbing. + let html = r#" + + + + + +

Hello without @font-face

+ + + "#; + + let config = Config::new().width(400).height(300); + let result = render(html, config); + + assert!(result.is_ok(), "render without @font-face should succeed"); + + let bytes = result.unwrap(); + assert!(!bytes.is_empty(), "output should not be empty"); + assert!( + bytes.starts_with(&PNG_SIGNATURE), + "output should be valid PNG" + ); +}