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