diff --git a/Cargo.lock b/Cargo.lock index 36db5085..7313e330 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,12 @@ 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" @@ -664,6 +670,7 @@ dependencies = [ "thread_local", "url", "web-time", + "wuff", ] [[package]] @@ -753,6 +760,15 @@ dependencies = [ "cfg_aliases", ] +[[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 = "bs58" version = "0.5.1" @@ -4184,6 +4200,7 @@ name = "moq-scene" version = "0.1.0" dependencies = [ "anyrender_vello", + "base64 0.23.1", "blitz-dom", "blitz-html", "blitz-paint", @@ -4202,6 +4219,7 @@ dependencies = [ "moq-video", "notosans", "parley", + "percent-encoding", "postcard", "rustix 1.1.5", "serde", @@ -4214,6 +4232,7 @@ dependencies = [ "tracing", "url", "vello", + "wuff", ] [[package]] @@ -9109,6 +9128,17 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[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 = "x11-dl" version = "2.21.0" diff --git a/README.md b/README.md index c041ca1a..b482fca4 100644 --- a/README.md +++ b/README.md @@ -280,19 +280,35 @@ drawn by the studio itself (through [Blitz](https://github.com/DioxusLabs/blitz) rather than a browser, on every platform. Its CSS animations and transitions run on the program clock, so they land on the same frame in the program and in a recording or replay of it, whatever the studio's load. It runs no scripts -and loads nothing: no images, stylesheets, or fonts from anywhere. Its text is -set in the bundled Noto Sans, regular, bold, or italic, whatever `font-family` -names, so it looks the same on every machine. An overlay outside what -overlays draw fails to start, naming the first element, attribute, rule, or -property it refused: +and never touches the network. Its text is set in the bundled Noto Sans, +regular, bold, or italic, unless it loads a face of its own, and never in the +machine's fonts, so it looks the same on every machine. + +An overlay's own images and fonts, such as a logo or a brand face, come from +`data:` URLs or from files in the scene file's directory, named relative to +it (`url(brand/logo.png)`), never from the web or anywhere else on disk. The +studio reads them all as the overlay starts, so its first frame has them, and +a file changed on disk changes nothing on air until the overlay starts again, +as when it is edited. Images are PNG or JPEG, at most 4096 pixels a side; +fonts are TrueType, OpenType, WOFF, or WOFF2, loaded with `@font-face`: + +```css +@font-face { font-family: Brand; src: url(brand/face.woff2); font-weight: bold } +.title { font-family: Brand; background: url(brand/logo.png) no-repeat } +``` + +An overlay outside what overlays draw fails to start, naming the first +element, attribute, rule, property, or asset it refused: - Elements: text and the boxes around it (`div`, `span`, `p`, headings, - lists, tables, and the like), plus `style`, `title`, and `meta`. Not - `script`, `img`, `link`, `iframe`, `svg`, `canvas`, or form controls. + lists, tables, and the like), `img`, plus `style`, `title`, and `meta`. Not + `script`, `link`, `iframe`, `svg`, `canvas`, or form controls. - Attributes: `id`, `class`, `style`, `lang`, `dir`, `title`, `hidden`, - table and list numbering ones, and `data-*` and `aria-*`. -- CSS rules: style rules, nested ones included, `@media`, and `@keyframes`. - Not `@import` or `@font-face`, nor any `url()` or `image-set()`. + table and list numbering ones, `data-*` and `aria-*`, and an `img`'s `src` + and `alt`. +- CSS rules: style rules, nested ones included, `@media`, `@keyframes`, and + `@font-face` with `font-family`, `src`, `font-weight`, `font-style`, and + `font-display`. Not `@import`, nor `local()` or `image-set()`. - CSS properties: box layout, flexbox, grid, positioning, margins and padding, colors, opacity, gradients, borders and their radii, `box-shadow`, outlines, transforms, `clip-path`, fonts and text layout, text decoration, diff --git a/quest/m1/README.md b/quest/m1/README.md index fab4c79f..b955fd20 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -25,7 +25,6 @@ than opening an m2 for them. - [Viewer-side composition](/quest/m1/compose/README.md) - sources as their own tracks plus a layout track, composed by viewers and by a server - [Overlays restart on air](/quest/m1/overlay-restart.md) - an overlay's CSS timeline restarts at each cut unless set to keep running -- [Overlay assets](/quest/m1/overlay-assets.md) - overlays use fonts and images from data: URLs or files beside the scene - [Hotkeys and editor preferences](/quest/m1/hotkeys.md) - system-wide hotkeys, arrow nudge, aspect lock, and persisted editor settings - [Isolated guest recordings](/quest/m1/guest-recording.md) - record-each-source also writes each guest's tracks to its own file - [Scene graph view](/quest/m1/scene-graph.md) - a node view of picture and audio scenes, their routing, volume, pan, and waveforms diff --git a/quest/m1/overlay-assets.md b/quest/m1/overlay-assets.md deleted file mode 100644 index 6f92fe65..00000000 --- a/quest/m1/overlay-assets.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] Overlay assets - -## Goal - -An overlay uses its own fonts and images, such as a logo or a brand face, -given as `data:` URLs or as files beside the scene file. Any other URL is -refused with an error naming it. - -## Plan - -From [moq-dev/studio#34](https://github.com/moq-dev/studio/pull/34). - -Decided 2026-11-04 with the maintainer (/quest-plan): - -- ✅ **Local files and `data:` URLs only.** With no network, what goes on air - is deterministic and private, and an asset can't fail or change mid-show. - Rejected: also fetching over https. - -Implementation: - -- Give Blitz a `NetProvider` that serves only `data:` URLs and files under - the scene file's directory, rejecting `..` escapes and symlinks out of it. -- Widen `overlay::check` (`rs/moq-scene/src/overlay.rs`) to accept - `@font-face` and `` / `background-image` that resolve that way. -- Load assets before the overlay first paints, so its first frame on program - has them. -- `README.md` documents where assets go. diff --git a/rs/moq-scene/Cargo.toml b/rs/moq-scene/Cargo.toml index 7d03c13e..aeb1af6f 100644 --- a/rs/moq-scene/Cargo.toml +++ b/rs/moq-scene/Cargo.toml @@ -35,12 +35,15 @@ cef = ["dep:cef"] # Overlays: Blitz lays HTML and CSS out and paints it into a Vello scene, which # Vello renders on the compositor's device. Pinned to the beta whose Vello and # wgpu match moq-video's: the stable 0.2 line is on wgpu 26, which cannot share -# the device. Fonts and network access are left off, so an overlay loads nothing. +# the device. System fonts and network access are left off, so an overlay loads +# only its own assets; `woff` decodes the WOFF and WOFF2 fonts it loads. anyrender_vello = "0.15" -blitz-dom = { version = "=0.3.0-beta.4", default-features = false } +blitz-dom = { version = "=0.3.0-beta.4", default-features = false, features = ["woff"] } blitz-html = "=0.3.0-beta.4" blitz-paint = { version = "=0.3.0-beta.4", default-features = false } blitz-traits = "=0.3.0-beta.4" +# Decodes an overlay's base64 `data:` URLs. +base64 = "0.23" bytemuck = { version = "1", features = ["derive"] } bytes = { workspace = true } # Reads the declarations an overlay's CSS checks, as Stylo serializes them. @@ -61,6 +64,8 @@ moq-net = { workspace = true } # `render` is the compositor's whole input path: it turns every decoded or # captured frame into a texture on the studio's device. moq-video = { workspace = true, features = ["render"] } +# Decodes an overlay's `data:` URLs. +percent-encoding = "2" serde = { workspace = true } serde_with = { workspace = true } # Stylo, Blitz's style engine, which parses an overlay's CSS to check it. @@ -71,6 +76,9 @@ toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } vello = "0.11" +# Decompresses an overlay's WOFF and WOFF2 fonts to check them. Already in the +# graph through Blitz's `woff` feature. +wuff = "0.2" # The helper process a browser source runs CEF in, and the socket between them. [target.'cfg(target_os = "linux")'.dependencies] diff --git a/rs/moq-scene/src/collection.rs b/rs/moq-scene/src/collection.rs index 6d5e23f4..55da088f 100644 --- a/rs/moq-scene/src/collection.rs +++ b/rs/moq-scene/src/collection.rs @@ -1455,8 +1455,9 @@ pub enum Source { /// A graphic written as HTML and CSS without scripts, such as a title or /// lower third. Its CSS animations run on the program clock, so they land /// on the same frame in the program and everything made from it. It loads - /// nothing, and HTML or CSS outside what overlays draw fails the source, - /// naming it. + /// only images and fonts from `data:` URLs and files under the scene + /// file's directory, and HTML, CSS, or an asset outside what overlays draw + /// fails the source, naming it. Overlay { /// The document, its CSS in `
" + .into(), + width: 4, + height: 4, + }, + ); + let fill = Rect::fill(&collection.canvas); + collection.scenes[0].items = vec![Item::new("red", fill), Item::new("overlay", fill)]; + let program = drawn(collection, dir).await; + assert_eq!(pixel(&program, 3, 3), BLUE); + }) + .await; + } + /// An overlay's CSS animation runs on the program clock from when the /// overlay opens: a frame shows it where the program time says, however late /// that frame is drawn, even the first one to show it. diff --git a/rs/moq-scene/src/overlay.rs b/rs/moq-scene/src/overlay.rs index 7d66cebc..782427d3 100644 --- a/rs/moq-scene/src/overlay.rs +++ b/rs/moq-scene/src/overlay.rs @@ -2,17 +2,24 @@ //! laid out by Blitz and painted by Vello into a texture on the compositor's //! device, on the program clock. //! -//! An overlay is untrusted, so it loads and runs nothing: Blitz has no script -//! engine, its document has no network or navigation provider, and [`check`] -//! refuses any element, attribute, CSS rule, or property outside the subset an -//! overlay draws, naming it. +//! An overlay is untrusted, so it runs nothing and loads only its own assets: +//! Blitz has no script engine or navigation provider, [`check`] refuses any +//! element, attribute, CSS rule, or property outside the subset an overlay +//! draws, naming it, and reads every image and font the overlay names up +//! front, from a `data:` URL or a file under the scene file's directory. Its +//! document fetches from those [`Assets`] alone, never the network. use std::cell::RefCell; -use std::collections::HashMap; -use std::sync::{Arc, mpsc}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use blitz_dom::net::{Resource, ResourceLoadResponse}; +use blitz_dom::util::ImageType; use blitz_dom::{BaseDocument, DocumentConfig, FontContext, Node, ns}; +use blitz_traits::net::{Bytes, NetHandler, NetProvider, Request as Fetch}; use blitz_traits::shell::{ColorScheme, Viewport}; +use cssparser::{ParseError, ParseErrorKind, Parser, Token}; use kio::clock::Instant; use moq_video::render::wgpu; use style::context::QuirksMode; @@ -20,6 +27,7 @@ use style::error_reporting::{ContextualParseError, ParseErrorReporter}; use style::properties::PropertyDeclarationBlock; use style::shared_lock::{SharedRwLock, SharedRwLockReadGuard, ToCssWithGuard}; use style::stylesheets::{AllowImportRules, CssRule, CssRuleType, Origin, StylesheetContents, UrlExtraData}; +use url::Url; use crate::Error; use crate::compositor::FORMAT; @@ -41,10 +49,10 @@ const PREMULTIPLY: &str = r#" } "#; -/// The elements an overlay may hold: text and the boxes around it. Anything -/// that loads a resource (`img`, `link`, `iframe`), runs code (`script`), -/// takes input (`input`, `button`), or draws outside CSS (`svg`, `canvas`) is -/// refused. +/// The elements an overlay may hold: text, the boxes around it, and images. +/// Anything that loads a document or stylesheet (`link`, `iframe`), runs code +/// (`script`), takes input (`input`, `button`), or draws outside CSS (`svg`, +/// `canvas`) is refused. const ELEMENTS: &[&str] = &[ "html", "head", @@ -114,6 +122,7 @@ const ELEMENTS: &[&str] = &[ "tr", "th", "td", + "img", ]; /// The attributes an overlay's elements may carry, besides `aria-*` and @@ -123,6 +132,16 @@ const ATTRIBUTES: &[&str] = &[ "reversed", "value", "datetime", ]; +/// The attributes an `` carries besides [`ATTRIBUTES`]: one image, sized +/// by CSS. +const IMAGE_ATTRIBUTES: &[&str] = &["src", "alt"]; + +/// The `@font-face` descriptors Blitz honors. It ignores the rest, such as +/// `unicode-range`, so a face would apply where its CSS says it does not. +/// `font-display` changes nothing, since every font loads before the first +/// paint. +const FONT_FACE: &[&str] = &["font-family", "src", "font-weight", "font-style", "font-display"]; + /// The CSS properties an overlay may set, as written, shorthands included: the /// ones Blitz lays out and paints through Vello. Missing are those it ignores /// or Vello cannot draw yet, such as `filter`, `backdrop-filter`, @@ -330,42 +349,406 @@ const PROPERTIES: &[&str] = &[ "transition-behavior", ]; -/// CSS functions that load a resource, which an overlay cannot. -const LOADS: &[&str] = &["url(", "image-set("]; +/// An overlay's HTML that passed [`check`], with every asset it loads. +pub(crate) struct Content { + html: String, + /// What its relative URLs resolve against: the scene file's directory. + base: Url, + /// Each image and font it loads, by the URL its document resolves to. + assets: HashMap, +} + +/// An image or font an overlay loads, decoded as far as Blitz needs it. +#[derive(Clone)] +enum Asset { + /// Straight RGBA, as Blitz holds a decoded image. + Image { + width: u32, + height: u32, + pixels: Arc>, + }, + /// A TrueType or OpenType font, a WOFF or WOFF2 one decompressed. + Font(Bytes), +} + +/// What an asset is meant to be. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Image, + Font, +} + +/// The properties whose `url()` Blitz draws as an image. It draws none other, +/// such as one in `content`. +const IMAGE_PROPERTIES: &[(&str, Kind)] = &[("background", Kind::Image), ("background-image", Kind::Image)]; + +/// The `@font-face` descriptor whose `url()` names a font. +const FONT_SOURCES: &[(&str, Kind)] = &[("src", Kind::Font)]; /// Refuse `html` unless every element, attribute, CSS rule, and CSS property -/// in it is one an overlay draws, naming the first that is not. -pub(crate) fn check(html: &str) -> Result<(), Error> { - let document = document(html, 1, 1); - check_node(&document, document.root_node()) +/// in it is one an overlay draws, and every image and font it names loads +/// from a `data:` URL or a file under `dir`, the scene file's directory, +/// naming the first that does not. +pub(crate) fn check(html: &str, dir: &Path) -> Result { + let dir = std::path::absolute(dir) + .map_err(|err| Error::Overlay(format!("cannot find the scene file's directory: {err}")))?; + let base = Url::from_directory_path(&dir) + .map_err(|()| Error::Overlay(format!("cannot name `{}` as a URL", dir.display())))?; + // Blitz panics as it parses an element whose `src` or `href` is no URL. + let document = std::panic::catch_unwind(|| document(html, 1, 1, &base, None)) + .map_err(|_| Error::Overlay("an element has a `src` or `href` that is no URL".into()))?; + let mut loader = Loader { + dir, + content: Content { + html: html.into(), + base, + assets: HashMap::new(), + }, + }; + loader.check_node(&document, document.root_node())?; + Ok(loader.content) } -fn check_node(document: &BaseDocument, node: &Node) -> Result<(), Error> { - if let Some(element) = node.element_data() { - let name: &str = &element.name.local; - if element.name.ns != ns!(html) || !ELEMENTS.contains(&name) { - return Err(refused(format!("the `<{name}>` element"))); +/// Walks an overlay's HTML and CSS for [`check`], reading each asset it names. +struct Loader { + dir: PathBuf, + content: Content, +} + +impl Loader { + fn check_node(&mut self, document: &BaseDocument, node: &Node) -> Result<(), Error> { + if let Some(element) = node.element_data() { + let name: &str = &element.name.local; + if element.name.ns != ns!(html) || !ELEMENTS.contains(&name) { + return Err(refused(format!("the `<{name}>` element"))); + } + let image = name == "img"; + for attribute in element.attrs.iter() { + let attribute: &str = &attribute.name.local; + let inert = attribute.starts_with("aria-") || attribute.starts_with("data-"); + let allowed = ATTRIBUTES.contains(&attribute) || (image && IMAGE_ATTRIBUTES.contains(&attribute)); + if !inert && !allowed { + return Err(refused(format!("the `{attribute}` attribute on `<{name}>`"))); + } + } + if image { + let src = node.attr(blitz_dom::local_name!("src")).unwrap_or_default(); + if src.is_empty() { + return Err(Error::Overlay("an `` has no `src`".into())); + } + self.load(src, Kind::Image)?; + } + if name == "style" { + self.check_sheet(&node.text_content())?; + } + if let Some(style) = node.attr(blitz_dom::local_name!("style")) { + self.check_attribute(style)?; + } } - for attribute in element.attrs.iter() { - let attribute: &str = &attribute.name.local; - let inert = attribute.starts_with("aria-") || attribute.starts_with("data-"); - if !inert && !ATTRIBUTES.contains(&attribute) { - return Err(refused(format!("the `{attribute}` attribute on `<{name}>`"))); + for &child in &node.children { + if let Some(child) = document.get_node(child) { + self.check_node(document, child)?; } } - if name == "style" { - check_sheet(&node.text_content())?; + Ok(()) + } + + fn check_sheet(&mut self, css: &str) -> Result<(), Error> { + let lock = SharedRwLock::new(); + let reporter = Reporter::default(); + let sheet = StylesheetContents::from_str( + css, + url(), + Origin::Author, + &lock, + None, + Some(&reporter), + QuirksMode::NoQuirks, + AllowImportRules::No, + None, + ); + reporter.result()?; + let guard = lock.read(); + self.check_rules(&sheet.rules.read_with(&guard).0, &guard, true) + } + + /// Check `rules`, which are a sheet's own when `top`, where Blitz looks + /// for `@font-face`. + fn check_rules(&mut self, rules: &[CssRule], guard: &SharedRwLockReadGuard, top: bool) -> Result<(), Error> { + for rule in rules { + match rule { + CssRule::Style(style) => { + let style = style.read_with(guard); + self.check_block(style.block.read_with(guard))?; + if let Some(nested) = &style.rules { + self.check_rules(&nested.read_with(guard).0, guard, false)?; + } + } + CssRule::NestedDeclarations(nested) => { + self.check_block(nested.read_with(guard).block.read_with(guard))? + } + CssRule::Media(media) => self.check_rules(&media.rules.read_with(guard).0, guard, false)?, + CssRule::Keyframes(keyframes) => { + for keyframe in &keyframes.read_with(guard).keyframes { + self.check_block(keyframe.read_with(guard).block.read_with(guard))?; + } + } + CssRule::FontFace(_) if top => self.check_font_face(&rule.to_css_string(guard))?, + CssRule::FontFace(_) => { + return Err(refused("an `@font-face` rule inside another rule".into())); + } + other => { + let css = other.to_css_string(guard); + let name = css.split(|c: char| c.is_whitespace() || c == '{' || c == ';').next(); + return Err(refused(format!("the `{}` rule", name.unwrap_or_default()))); + } + } } - if let Some(style) = node.attr(blitz_dom::local_name!("style")) { - check_attribute(style)?; + Ok(()) + } + + fn check_attribute(&mut self, css: &str) -> Result<(), Error> { + let reporter = Reporter::default(); + let block = style::properties::parse_style_attribute( + css, + &url(), + Some(&reporter), + QuirksMode::NoQuirks, + CssRuleType::Style, + ); + reporter.result()?; + self.check_block(&block) + } + + /// Check a block's declarations by the names they serialize under, which + /// are shorthands wherever one covers them, so `border: 1px solid` is + /// checked as `border` rather than as the `border-image` it resets. + fn check_block(&mut self, block: &PropertyDeclarationBlock) -> Result<(), Error> { + let mut css = String::new(); + block.to_css(&mut css).expect("writing to a string"); + self.check_declarations(&css, PROPERTIES, "property", IMAGE_PROPERTIES)?; + Ok(()) + } + + /// Check an `@font-face` rule, as Stylo serializes it, and read its fonts. + fn check_font_face(&mut self, css: &str) -> Result<(), Error> { + let body = css + .split_once('{') + .and_then(|(_, body)| body.rsplit_once('}')) + .ok_or_else(|| unreadable(css))? + .0; + let named = self.check_declarations(body, FONT_FACE, "`@font-face` descriptor", FONT_SOURCES)?; + for needed in ["font-family", "src"] { + if !named.contains(needed) { + return Err(Error::Overlay(format!("an `@font-face` rule has no `{needed}`"))); + } } + Ok(()) } - for &child in &node.children { - if let Some(child) = document.get_node(child) { - check_node(document, child)?; + + /// Check that `css`, a run of declarations, names only what `allowed` + /// lists or custom properties, loading each `url()` in the values of those + /// `loads` lists as the kind it gives, and refusing any other. Returns the + /// names. + fn check_declarations( + &mut self, + css: &str, + allowed: &[&str], + what: &str, + loads: &[(&str, Kind)], + ) -> Result, Error> { + let mut named = HashSet::new(); + let mut parser = Parser::new(css); + while !parser.is_exhausted() { + let name = parser.expect_ident_cloned().map_err(|_| unreadable(css))?; + parser.expect_colon().map_err(|_| unreadable(css))?; + if !name.starts_with("--") && !allowed.contains(&name.as_ref()) { + return Err(refused(format!("the `{name}` {what}"))); + } + let kind = loads.iter().find(|(loads, _)| *loads == &*name).map(|&(_, kind)| kind); + parser + .parse_until_after(cssparser::Delimiter::Semicolon, |value| { + self.load_value(&name, value, kind) + }) + .map_err(|err| match err.kind { + ParseErrorKind::Custom(err) => err, + ParseErrorKind::Basic(_) => unreadable(css), + })?; + named.insert(name.to_string()); } + Ok(named) + } + + /// Load each `url()` in `name`'s value as a `kind`, refusing one where + /// there is none, and the functions that load something else. + fn load_value(&mut self, name: &str, input: &mut Parser, kind: Option) -> Result<(), ParseError> { + while let Ok(token) = input.next() { + let url = match token.clone() { + Token::UnquotedUrl(url) => url, + Token::Function(function) if function.eq_ignore_ascii_case("url") => { + input.parse_nested_block(|input| Ok(input.expect_string()?.clone()))? + } + Token::Function(function) + if ["local", "image-set", "-webkit-image-set", "src"] + .iter() + .any(|loads| function.eq_ignore_ascii_case(loads)) => + { + let function = function.to_ascii_lowercase(); + return Err(ParseError::custom(Error::Overlay(format!( + "`{name}` uses `{function}()`, which an overlay does not load" + )))); + } + Token::Function(_) | Token::ParenthesisBlock | Token::SquareBracketBlock | Token::CurlyBracketBlock => { + input.parse_nested_block(|input| self.load_value(name, input, kind))?; + continue; + } + _ => continue, + }; + let Some(kind) = kind else { + return Err(ParseError::custom(Error::Overlay(format!( + "`{name}` uses `url()`, but an overlay loads images only for `background`, \ + `background-image`, and ``, and fonts only for `@font-face`" + )))); + }; + self.load(&url, kind).map_err(ParseError::custom)?; + } + Ok(()) + } + + /// Read and decode the asset `raw` names, resolved as the overlay's + /// document will, as a `kind`. + fn load(&mut self, raw: &str, kind: Kind) -> Result<(), Error> { + let failed = |err: String| Error::Overlay(format!("cannot load `{}`: {err}", shorten(raw))); + let url = self.content.base.join(raw).map_err(|err| failed(err.to_string()))?; + if let Some(asset) = self.content.assets.get(url.as_str()) { + let loaded = match asset { + Asset::Image { .. } => Kind::Image, + Asset::Font(_) => Kind::Font, + }; + if loaded != kind { + return Err(failed("it is named as both an image and a font".into())); + } + return Ok(()); + } + let bytes = match url.scheme() { + "data" => data(&url), + "file" => self.file(&url), + _ => Err("an overlay loads only `data:` URLs and files under the scene file's directory".into()), + } + .map_err(failed)?; + let asset = match kind { + Kind::Image => image(&bytes), + Kind::Font => font(bytes), + } + .map_err(failed)?; + self.content.assets.insert(url.into(), asset); + Ok(()) + } + + /// Read the file `url` names, if it lies under the scene file's directory + /// once every symlink and `..` is resolved. + fn file(&self, url: &Url) -> Result { + let path = url.to_file_path().map_err(|()| "it names no local file".to_string())?; + let path = std::fs::canonicalize(path).map_err(|err| err.to_string())?; + let dir = + std::fs::canonicalize(&self.dir).map_err(|err| format!("cannot find the scene file's directory: {err}"))?; + if !path.starts_with(&dir) { + return Err("it is outside the scene file's directory".into()); + } + std::fs::read(&path).map(Bytes::from).map_err(|err| err.to_string()) + } +} + +/// The bytes a `data:` URL holds, decoded as the Fetch standard does. +fn data(url: &Url) -> Result { + use base64::Engine; + use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; + + const FORGIVING: GeneralPurpose = GeneralPurpose::new( + &base64::alphabet::STANDARD, + GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::Indifferent) + .with_decode_allow_trailing_bits(true), + ); + + let (meta, body) = url[url::Position::BeforePath..url::Position::AfterQuery] + .split_once(',') + .ok_or("it has no `,` before its data")?; + let body: Vec = percent_encoding::percent_decode_str(body).collect(); + let meta = meta.trim_ascii(); + let base64 = meta + .len() + .checked_sub("base64".len()) + .and_then(|at| meta.split_at_checked(at)) + .is_some_and(|(head, tail)| tail.eq_ignore_ascii_case("base64") && head.trim_end_matches(' ').ends_with(';')); + if !base64 { + return Ok(body.into()); + } + let body: Vec = body.into_iter().filter(|byte| !byte.is_ascii_whitespace()).collect(); + FORGIVING + .decode(body) + .map(Bytes::from) + .map_err(|err| format!("its base64 is malformed: {err}")) +} + +/// Decode `bytes`, a PNG or JPEG no larger than an overlay, so it holds no +/// more than an overlay's own texture. +fn image(bytes: &[u8]) -> Result { + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .expect("reading from memory"); + if !matches!( + reader.format(), + Some(image::ImageFormat::Png | image::ImageFormat::Jpeg) + ) { + return Err("it is not a PNG or JPEG image".into()); + } + let (width, height) = reader.into_dimensions().map_err(|err| err.to_string())?; + let max = crate::browser::MAX_SIDE; + if width > max || height > max { + return Err(format!( + "it is {width}x{height}; each side must be at most {max} pixels" + )); + } + let pixels = image::load_from_memory(bytes).map_err(|err| err.to_string())?; + Ok(Asset::Image { + width, + height, + pixels: Arc::new(pixels.into_rgba8().into_raw()), + }) +} + +/// Decompress `bytes` if WOFF or WOFF2, and check that the font registers as +/// Blitz will register it. +fn font(bytes: Bytes) -> Result { + use parley::fontique::{Blob, Collection, CollectionOptions}; + + let bytes = match bytes.get(..4) { + Some(b"wOFF") => wuff::decompress_woff1(&bytes).map(Bytes::from), + Some(b"wOF2") => wuff::decompress_woff2(&bytes).map(Bytes::from), + _ => Ok(bytes), + } + .map_err(|err| format!("it does not decompress: {err:?}"))?; + let mut collection = Collection::new(CollectionOptions { + shared: false, + system_fonts: false, + }); + if collection + .register_fonts(Blob::new(Arc::new(bytes.clone())), None) + .is_empty() + { + return Err("it is not a TrueType, OpenType, WOFF, or WOFF2 font".into()); + } + Ok(Asset::Font(bytes)) +} + +/// `url` as an error names it, a long `data:` URL cut short. +fn shorten(url: &str) -> String { + const MAX: usize = 64; + match url.char_indices().nth(MAX) { + Some((end, _)) => format!("{}...", &url[..end]), + None => url.into(), } - Ok(()) } /// Collects the first error Stylo reports, such as an unknown property or an @@ -390,121 +773,97 @@ impl Reporter { } } +/// The base [`check`]'s CSS parses against; [`Loader::load`] resolves each +/// `url()` itself, as written. fn url() -> UrlExtraData { - url::Url::parse("about:blank").expect("a valid url").into() + Url::parse("about:blank").expect("a valid url").into() } -fn check_sheet(css: &str) -> Result<(), Error> { - let lock = SharedRwLock::new(); - let reporter = Reporter::default(); - let sheet = StylesheetContents::from_str( - css, - url(), - Origin::Author, - &lock, - None, - Some(&reporter), - QuirksMode::NoQuirks, - AllowImportRules::No, - None, - ); - reporter.result()?; - let guard = lock.read(); - check_rules(&sheet.rules.read_with(&guard).0, &guard) +fn unreadable(css: &str) -> Error { + Error::Overlay(format!("cannot read back the declarations `{css}`")) } -fn check_rules(rules: &[CssRule], guard: &SharedRwLockReadGuard) -> Result<(), Error> { - for rule in rules { - match rule { - CssRule::Style(style) => { - let style = style.read_with(guard); - check_block(style.block.read_with(guard))?; - if let Some(nested) = &style.rules { - check_rules(&nested.read_with(guard).0, guard)?; - } - } - CssRule::NestedDeclarations(nested) => check_block(nested.read_with(guard).block.read_with(guard))?, - CssRule::Media(media) => check_rules(&media.rules.read_with(guard).0, guard)?, - CssRule::Keyframes(keyframes) => { - for keyframe in &keyframes.read_with(guard).keyframes { - check_block(keyframe.read_with(guard).block.read_with(guard))?; - } - } - other => { - let css = other.to_css_string(guard); - let name = css.split(|c: char| c.is_whitespace() || c == '{' || c == ';').next(); - return Err(refused(format!("the `{}` rule", name.unwrap_or_default()))); - } - } - } - Ok(()) +fn refused(what: String) -> Error { + Error::Overlay(format!("{what} is not supported in an overlay")) } -fn check_attribute(css: &str) -> Result<(), Error> { - let reporter = Reporter::default(); - let block = style::properties::parse_style_attribute( - css, - &url(), - Some(&reporter), - QuirksMode::NoQuirks, - CssRuleType::Style, - ); - reporter.result()?; - check_block(&block) +/// Serves an overlay's document the assets [`check`] read, and nothing else. +struct Assets { + content: Content, + /// The first URL the document fetched that [`check`] did not read. + missing: Mutex>, } -/// Check a block's declarations by the names they serialize under, which are -/// shorthands wherever one covers them, so `border: 1px solid` is checked as -/// `border` rather than as the `border-image` it resets. -fn check_block(block: &PropertyDeclarationBlock) -> Result<(), Error> { - let mut css = String::new(); - block.to_css(&mut css).expect("writing to a string"); - - let mut parser = cssparser::Parser::new(&css); - while !parser.is_exhausted() { - let name = parser.expect_ident_cloned().map_err(|_| unreadable(&css))?; - parser.expect_colon().map_err(|_| unreadable(&css))?; - let start = parser.position(); - parser - .parse_until_after(cssparser::Delimiter::Semicolon, |value| { - while value.next().is_ok() {} - Ok::<_, cssparser::ParseError<()>>(()) - }) - .map_err(|_| unreadable(&css))?; - let value = parser.slice_from(start).to_ascii_lowercase(); - - if !name.starts_with("--") && !PROPERTIES.contains(&name.as_ref()) { - return Err(refused(format!("the `{name}` property"))); - } - if let Some(load) = LOADS.iter().find(|load| value.contains(*load)) { - return Err(Error::Overlay(format!( - "`{name}` uses `{load}`, but an overlay loads nothing" - ))); +impl NetProvider for Assets { + fn fetch(&self, _document: usize, request: Fetch, handler: Box) { + let url = request.url.as_str(); + match self.content.assets.get(url) { + Some(Asset::Font(bytes)) => handler.bytes(url.into(), bytes.clone()), + // Already in the document's cache from [`open`], waiting nodes + // included. + Some(Asset::Image { .. }) => {} + None => { + self.missing.lock().unwrap().get_or_insert_with(|| shorten(url)); + } } } - Ok(()) } -fn unreadable(css: &str) -> Error { - Error::Overlay(format!("cannot read back the declarations `{css}`")) -} - -fn refused(what: String) -> Error { - Error::Overlay(format!("{what} is not supported in an overlay")) -} - -/// Parse `html` into a `width` by `height` document with no network access. -fn document(html: &str, width: u32, height: u32) -> BaseDocument { +/// Parse `html` into a `width` by `height` document whose relative URLs +/// resolve against `base`, fetching through `assets` or, without, nothing. +fn document(html: &str, width: u32, height: u32, base: &Url, assets: Option>) -> BaseDocument { let config = DocumentConfig { viewport: Some(Viewport::new(width, height, 1.0, ColorScheme::Light)), font_ctx: Some(fonts()), + base_url: Some(base.to_string()), + net_provider: assets.map(|assets| assets as Arc), ..Default::default() }; blitz_html::HtmlDocument::from_html(html, config).into_inner() } -/// Noto Sans, regular, bold, and italic, as every family: an overlay loads no -/// fonts, and leaves the machine's out so that it looks the same on any. +impl Assets { + fn new(content: Content) -> Arc { + Arc::new(Self { + content, + missing: Mutex::default(), + }) + } +} + +/// Lay out the overlay `assets` serves at `width` by `height`, at the start of +/// its timeline. +fn open(assets: &Arc, width: u32, height: u32) -> BaseDocument { + let Content { + html, + base, + assets: loaded, + } = &assets.content; + let mut document = document(html, width, height, base, Some(assets.clone())); + // Every image goes in the document's cache, decoded, so one any style + // names, now or later in an animation, draws without a fetch. + for (url, asset) in loaded { + if let Asset::Image { width, height, pixels } = asset { + document.load_resource(ResourceLoadResponse { + // No request's, so it settles none. + request_id: usize::MAX, + node_id: None, + resolved_url: Some(url.clone()), + result: Ok(Resource::Image(ImageType::Image, *width, *height, pixels.clone())), + }); + } + } + // Stylo starts an animation at the time of the resolve that creates it, so + // this one starts every animation at the start of the timeline, however + // late the overlay is first drawn. The fonts its parse fetched land here + // too, served from memory. + document.resolve(0.0); + document +} + +/// Noto Sans, regular, bold, and italic, as every generic family and any +/// family an overlay loads no font for. The machine's fonts are left out, so +/// an overlay looks the same on any. fn fonts() -> FontContext { use parley::fontique::{Blob, Collection, CollectionOptions, GenericFamily, SourceCache}; @@ -557,7 +916,7 @@ pub(crate) struct Overlays { enum Request { Open { id: u64, - html: String, + assets: Arc, /// Where Vello paints, straight. paint: wgpu::TextureView, /// Where the paint lands premultiplied, for the compositor to sample. @@ -593,9 +952,9 @@ impl Overlays { }) } - /// Lay out `html` at `width` by `height`, its CSS timeline starting at - /// `start` on the program clock. The HTML has passed [`check`]. - pub fn open(&mut self, html: &str, width: u32, height: u32, start: Instant) -> Overlay { + /// Lay out `content` at `width` by `height`, its CSS timeline starting at + /// `start` on the program clock. + pub fn open(&mut self, content: Content, width: u32, height: u32, start: Instant) -> Overlay { let texture = |label, usage| { self.device.create_texture(&wgpu::TextureDescriptor { label: Some(label), @@ -621,7 +980,7 @@ impl Overlays { // A thread that stopped fails the next paint, which says so. let _ = self.requests.send(Request::Open { id, - html: html.to_string(), + assets: Assets::new(content), paint: paint.create_view(&Default::default()), view: texture.create_view(&Default::default()), start, @@ -693,19 +1052,16 @@ fn run( match request { Request::Open { id, - html, + assets, paint, view, start, } => { let size = view.texture().size(); - let mut document = document(&html, size.width, size.height); - // Stylo starts an animation at the time of the resolve that - // creates it, so this one starts every animation at the start of - // the timeline, however late the overlay is first drawn. - document.resolve(0.0); + let document = open(&assets, size.width, size.height); let document = Document { document, + assets, paint, view, premultiply: None, @@ -856,6 +1212,8 @@ impl Painter { /// An overlay's document on the overlay thread, and where it paints. struct Document { document: BaseDocument, + /// What it fetches from. + assets: Arc, /// Where Vello paints, straight. paint: wgpu::TextureView, /// Where the paint lands premultiplied. @@ -883,6 +1241,9 @@ impl Document { } self.document .resolve(now.saturating_duration_since(self.start).as_secs_f64()); + if let Some(url) = self.assets.missing.lock().unwrap().as_ref() { + return Err(format!("the overlay fetched `{url}`, which it did not name")); + } let size = self.paint.texture().size(); painter.scene.reset(); @@ -919,7 +1280,41 @@ mod tests { use super::*; fn refusal(html: &str) -> String { - check(html).unwrap_err().to_string() + refusal_in(html, Path::new(".")) + } + + /// Why `html` fails with its scene file in `dir`. + fn refusal_in(html: &str, dir: &Path) -> String { + match check(html, dir) { + Ok(_) => panic!("{html} passes"), + Err(err) => err.to_string(), + } + } + + /// A `width` by `height` PNG. + fn png(width: u32, height: u32) -> Vec { + let mut png = Vec::new(); + image::RgbaImage::new(width, height) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + png + } + + fn base64(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + /// The elements `selector` matches in `document`, in order. + fn select<'a>(document: &'a BaseDocument, selector: &str) -> Vec<&'a Node> { + let ids = document.query_selector_all(selector).unwrap(); + ids.into_iter().map(|id| document.get_node(id).unwrap()).collect() + } + + /// The widths of the elements `selector` matches in `document`, in order. + fn widths(document: &BaseDocument, selector: &str) -> Vec { + let nodes = select(document, selector); + nodes.iter().map(|node| node.final_layout().size.width).collect() } #[test] @@ -940,15 +1335,15 @@ mod tests { @media (min-width: 1000px) { .third { font-size: 48px } }
Ada Lovelace Analyst
"#, + Path::new("."), ) .unwrap(); } #[test] - fn what_loads_or_runs_is_refused() { + fn what_runs_or_loads_from_elsewhere_is_refused() { for (html, named) in [ ("", "`