render: load preloaded web fonts
This commit is contained in:
1 parent
9b0b138fb9
commit
058e3e98a0
6 files changed
+423
-27
No files matched your search
Generated
+12
@@ -2075,6 +2075,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"url",
|
||||
"usvg",
|
||||
"wuff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4435,6 +4436,17 @@ version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "wuff"
|
||||
version = "0.2.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f625c6894838ab68e8c1c6e0f728e67363c9d4391cfa82333e58e6e30bc3a627"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"bytes",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyz"
|
||||
version = "0.5.1"
|
||||
|
||||
@@ -11,7 +11,7 @@ default = []
|
||||
# Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text
|
||||
# shaping + line breaking, ab_glyph fallback) and images.
|
||||
# Off by default; the layout core (getBoundingClientRect etc.) works without it.
|
||||
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url"]
|
||||
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"]
|
||||
|
||||
[dependencies]
|
||||
taffy = { workspace = true }
|
||||
@@ -32,6 +32,7 @@ resvg = { version = "0.47", default-features = false, features = ["text", "raste
|
||||
usvg = { version = "0.47", default-features = false, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
cssparser = "0.34"
|
||||
wuff = { version = "0.2.7", optional = true }
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
|
||||
@@ -392,6 +392,17 @@ pub fn layout_dom_with_images(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
intrinsic: &HashMap<NodeId, (f32, f32)>,
|
||||
) -> DomLayout {
|
||||
layout_dom_with_resources(tree, viewport, intrinsic, &[])
|
||||
}
|
||||
|
||||
/// Like [`layout_dom_with_images`], with decoded OpenType web-font data loaded
|
||||
/// into the shaping database for this render pass.
|
||||
pub fn layout_dom_with_resources(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
intrinsic: &HashMap<NodeId, (f32, f32)>,
|
||||
fonts: &[Vec<u8>],
|
||||
) -> DomLayout {
|
||||
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
||||
|
||||
@@ -449,7 +460,7 @@ pub fn layout_dom_with_images(
|
||||
let mut taffy_tree: TaffyTree<usize> = TaffyTree::new();
|
||||
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
|
||||
let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new();
|
||||
let mut engine = crate::inline::TextEngine::new();
|
||||
let mut engine = crate::inline::TextEngine::new_with_fonts(fonts);
|
||||
let mut ifc_items = IfcRegistry::default();
|
||||
|
||||
// The document node itself is not an element; lay out from the first
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! Fonts are loaded from embedded bytes only, never the OS, so layout is
|
||||
//! byte-for-byte deterministic across hosts (the whole engine's guarantee).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
|
||||
|
||||
@@ -77,6 +77,24 @@ fn resolve_font_family(fam: Option<&str>) -> &'static str {
|
||||
}
|
||||
FAMILY
|
||||
}
|
||||
|
||||
fn resolve_loaded_font_family(
|
||||
fam: Option<&str>,
|
||||
loaded: &HashMap<String, Arc<str>>,
|
||||
) -> Arc<str> {
|
||||
if let Some(stack) = fam {
|
||||
for token in stack.split(',') {
|
||||
let name = token
|
||||
.trim()
|
||||
.trim_matches(|c| c == '"' || c == '\'')
|
||||
.trim();
|
||||
if let Some(family) = loaded.get(&name.to_ascii_lowercase()) {
|
||||
return Arc::clone(family);
|
||||
}
|
||||
}
|
||||
}
|
||||
Arc::from(resolve_font_family(fam))
|
||||
}
|
||||
/// Resolve `line-height: normal` from the selected face's horizontal header.
|
||||
///
|
||||
/// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and
|
||||
@@ -148,6 +166,7 @@ pub struct InlineItem {
|
||||
/// [`crate::DomLayout`] so paint can rasterize the shaped glyphs.
|
||||
pub struct TextEngine {
|
||||
font_system: FontSystem,
|
||||
loaded_families: HashMap<String, Arc<str>>,
|
||||
swash: SwashCache,
|
||||
items: Vec<InlineItem>,
|
||||
replaced: Vec<ReplacedItem>,
|
||||
@@ -215,6 +234,10 @@ impl Default for TextEngine {
|
||||
|
||||
impl TextEngine {
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_fonts(&[])
|
||||
}
|
||||
|
||||
pub fn new_with_fonts(fonts: &[Vec<u8>]) -> Self {
|
||||
// Build a database from our four embedded faces only. Never call
|
||||
// load_system_fonts: a host's font set would make layout differ
|
||||
// machine to machine and add a multi-millisecond startup scan.
|
||||
@@ -227,10 +250,24 @@ impl TextEngine {
|
||||
] {
|
||||
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes)));
|
||||
}
|
||||
for bytes in fonts {
|
||||
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(
|
||||
bytes.clone(),
|
||||
)));
|
||||
}
|
||||
let mut loaded_families = HashMap::new();
|
||||
for face in db.faces() {
|
||||
for (name, _) in &face.families {
|
||||
loaded_families
|
||||
.entry(name.to_ascii_lowercase())
|
||||
.or_insert_with(|| Arc::from(name.as_str()));
|
||||
}
|
||||
}
|
||||
db.set_sans_serif_family(FAMILY);
|
||||
let font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
|
||||
TextEngine {
|
||||
font_system,
|
||||
loaded_families,
|
||||
swash: SwashCache::new(),
|
||||
items: Vec::new(),
|
||||
replaced: Vec::new(),
|
||||
@@ -256,9 +293,21 @@ impl TextEngine {
|
||||
}
|
||||
let base = styles.get(&id)?;
|
||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
||||
let ctx = base_span_ctx(base, &mut collector);
|
||||
let ctx = base_span_ctx(
|
||||
base,
|
||||
resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families),
|
||||
&mut collector,
|
||||
);
|
||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||
collect_spans(tree, id, styles, ctx, &mut spans, &mut collector);
|
||||
collect_spans(
|
||||
tree,
|
||||
id,
|
||||
styles,
|
||||
ctx,
|
||||
&mut spans,
|
||||
&mut collector,
|
||||
&self.loaded_families,
|
||||
);
|
||||
self.push_shaped_item(base, spans, collector.clip_fills)
|
||||
}
|
||||
|
||||
@@ -287,10 +336,22 @@ impl TextEngine {
|
||||
}
|
||||
let base = styles.get(&parent)?;
|
||||
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
|
||||
let ctx = base_span_ctx(base, &mut collector);
|
||||
let ctx = base_span_ctx(
|
||||
base,
|
||||
resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families),
|
||||
&mut collector,
|
||||
);
|
||||
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
|
||||
for &cid in run {
|
||||
collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector);
|
||||
collect_node_spans(
|
||||
tree,
|
||||
cid,
|
||||
styles,
|
||||
ctx.clone(),
|
||||
&mut spans,
|
||||
&mut collector,
|
||||
&self.loaded_families,
|
||||
);
|
||||
}
|
||||
self.push_shaped_item(base, spans, collector.clip_fills)
|
||||
}
|
||||
@@ -452,13 +513,13 @@ struct SpanAttrs {
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
color: [u8; 4],
|
||||
family: &'static str,
|
||||
family: Arc<str>,
|
||||
clip_fill: Option<usize>,
|
||||
}
|
||||
|
||||
impl SpanAttrs {
|
||||
fn to_attrs(&self) -> Attrs<'_> {
|
||||
let mut a = Attrs::new().family(Family::Name(self.family));
|
||||
let mut a = Attrs::new().family(Family::Name(self.family.as_ref()));
|
||||
a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL });
|
||||
a = a.style(if self.italic { Style::Italic } else { Style::Normal });
|
||||
// Clip-text glyphs must be shaped with an opaque fill so their coverage
|
||||
@@ -473,14 +534,14 @@ impl SpanAttrs {
|
||||
}
|
||||
|
||||
/// Inherited inline context threaded down the subtree while collecting spans.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
struct SpanCtx {
|
||||
color: [u8; 4],
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
transform: TextTransform,
|
||||
family: &'static str,
|
||||
family: Arc<str>,
|
||||
clip_fill: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -502,7 +563,11 @@ struct Collector {
|
||||
/// shape the glyphs in opaque white so their coverage renders, then recolor
|
||||
/// them from the background at paint time; otherwise transparent text stays
|
||||
/// transparent (and invisible), unchanged.
|
||||
fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx {
|
||||
fn base_span_ctx(
|
||||
base: &LayoutStyle,
|
||||
family: Arc<str>,
|
||||
collector: &mut Collector,
|
||||
) -> SpanCtx {
|
||||
let clip_fill = clip_text_fill(base).map(|fill| {
|
||||
let index = collector.clip_fills.len();
|
||||
collector.clip_fills.push(fill);
|
||||
@@ -514,7 +579,7 @@ fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx {
|
||||
italic: base.font_style_italic.unwrap_or(false),
|
||||
underline: base.underline.unwrap_or(false),
|
||||
transform: base.text_transform.unwrap_or(TextTransform::None),
|
||||
family: resolve_font_family(base.font_family.as_deref()),
|
||||
family,
|
||||
clip_fill,
|
||||
}
|
||||
}
|
||||
@@ -526,9 +591,18 @@ fn collect_spans(
|
||||
ctx: SpanCtx,
|
||||
out: &mut Vec<(String, SpanAttrs)>,
|
||||
c: &mut Collector,
|
||||
loaded_families: &HashMap<String, Arc<str>>,
|
||||
) {
|
||||
for cid in tree.children(id) {
|
||||
collect_node_spans(tree, cid, styles, ctx, out, c);
|
||||
collect_node_spans(
|
||||
tree,
|
||||
cid,
|
||||
styles,
|
||||
ctx.clone(),
|
||||
out,
|
||||
c,
|
||||
loaded_families,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +617,7 @@ fn collect_node_spans(
|
||||
ctx: SpanCtx,
|
||||
out: &mut Vec<(String, SpanAttrs)>,
|
||||
c: &mut Collector,
|
||||
loaded_families: &HashMap<String, Arc<str>>,
|
||||
) {
|
||||
let Some(node) = tree.get_node(cid) else { return };
|
||||
match &node.data {
|
||||
@@ -552,7 +627,7 @@ fn collect_node_spans(
|
||||
italic: ctx.italic,
|
||||
underline: ctx.underline,
|
||||
color: ctx.color,
|
||||
family: ctx.family,
|
||||
family: Arc::clone(&ctx.family),
|
||||
clip_fill: ctx.clip_fill,
|
||||
};
|
||||
push_text(contents, ctx.transform, &attrs, out, c);
|
||||
@@ -569,7 +644,7 @@ fn collect_node_spans(
|
||||
italic: ctx.italic,
|
||||
underline: ctx.underline,
|
||||
color: ctx.color,
|
||||
family: ctx.family,
|
||||
family: Arc::clone(&ctx.family),
|
||||
clip_fill: ctx.clip_fill,
|
||||
}));
|
||||
c.last_was_space = true;
|
||||
@@ -597,11 +672,21 @@ fn collect_node_spans(
|
||||
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
|
||||
family: style
|
||||
.and_then(|s| s.font_family.as_deref())
|
||||
.map(|f| resolve_font_family(Some(f)))
|
||||
.unwrap_or(ctx.family),
|
||||
.map(|family| {
|
||||
resolve_loaded_font_family(Some(family), loaded_families)
|
||||
})
|
||||
.unwrap_or_else(|| Arc::clone(&ctx.family)),
|
||||
clip_fill,
|
||||
};
|
||||
collect_spans(tree, cid, styles, child, out, c);
|
||||
collect_spans(
|
||||
tree,
|
||||
cid,
|
||||
styles,
|
||||
child,
|
||||
out,
|
||||
c,
|
||||
loaded_families,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub mod style;
|
||||
pub use style::compute_style;
|
||||
|
||||
pub mod dom;
|
||||
pub use dom::{layout_dom, layout_dom_with_images, DomLayout};
|
||||
pub use dom::{layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout};
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
mod paint;
|
||||
|
||||
@@ -11,7 +11,7 @@ use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
||||
|
||||
static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
|
||||
|
||||
use crate::layout_dom_with_images;
|
||||
use crate::layout_dom_with_resources;
|
||||
|
||||
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
|
||||
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
||||
@@ -33,7 +33,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// and never paint). This seeds the same cache the paint pass reads, so
|
||||
// each URL is still fetched at most once.
|
||||
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
|
||||
let mut laid = layout_dom_with_images(tree, viewport, &intrinsic);
|
||||
let fonts = collect_web_fonts(tree, base_url);
|
||||
let mut laid = layout_dom_with_resources(tree, viewport, &intrinsic, &fonts);
|
||||
// Nodes that live inside an inline `<svg>` we rasterized as one document;
|
||||
// their painting is owned by that raster, so they are skipped in both the
|
||||
// box/text loop below and the inline-formatting loop after it (an svg
|
||||
@@ -757,13 +758,12 @@ fn fetch_bytes(
|
||||
base_url: Option<&str>,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
if let Some(rest) = src.strip_prefix("data:image/") {
|
||||
if let Some(rest) = src.strip_prefix("data:") {
|
||||
let comma_idx = rest.find(',')?;
|
||||
let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]);
|
||||
// Inline SVGs are very commonly authored as data:image/svg+xml;utf8,
|
||||
// (or with no encoding label at all, which is equivalent): plain,
|
||||
// percent-escaped text, not base64. Only decode as base64 when the
|
||||
// URI actually says so.
|
||||
// Data-backed SVGs and web fonts may be base64 or percent-escaped.
|
||||
// Decode from the encoding label rather than assuming every data URI
|
||||
// is base64.
|
||||
return if meta.contains("base64") {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(data).ok()
|
||||
@@ -805,6 +805,257 @@ fn fetch_bytes(
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Fetch the Latin/ASCII face from each authored `@font-face` rule and decode
|
||||
/// WOFF/WOFF2 into the sfnt bytes consumed by fontdb/cosmic-text. Unicode-range
|
||||
/// filtering is load-bearing for performance: generated font packages commonly
|
||||
/// emit six or seven script subsets per face, while an English page needs only
|
||||
/// the subset containing ASCII.
|
||||
fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<Vec<u8>> {
|
||||
let mut cache = std::collections::HashMap::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut fonts = Vec::new();
|
||||
|
||||
// Critical web fonts are normally preloaded from the document with a URL
|
||||
// already resolved relative to the HTML. Prefer that browser-authored list:
|
||||
// CSS gathered from external stylesheets is flattened into <style> text in
|
||||
// the live DOM, where a relative src no longer carries its stylesheet URL.
|
||||
let mut preloads = Vec::new();
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
let Some(node) = tree.get_node(nid) else { continue };
|
||||
if node
|
||||
.as_element()
|
||||
.map(|element| element.local.as_ref() != "link")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let rel = node.get_attribute("rel").unwrap_or("");
|
||||
let as_value = node.get_attribute("as").unwrap_or("");
|
||||
if rel
|
||||
.split_ascii_whitespace()
|
||||
.any(|token| token.eq_ignore_ascii_case("preload"))
|
||||
&& as_value.eq_ignore_ascii_case("font")
|
||||
{
|
||||
if let Some(href) = node.get_attribute("href") {
|
||||
preloads.push(href.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for src in preloads.iter().take(16) {
|
||||
if !seen.insert(src.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(decoded) = fetch_and_decode_font(src, base_url, &mut cache) {
|
||||
fonts.push(decoded);
|
||||
}
|
||||
}
|
||||
if !preloads.is_empty() {
|
||||
return fonts;
|
||||
}
|
||||
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
let Some(node) = tree.get_node(nid) else { continue };
|
||||
if node
|
||||
.as_element()
|
||||
.map(|element| element.local.as_ref() != "style")
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let css = tree.text_content(nid);
|
||||
for face in font_face_blocks(&css) {
|
||||
if !font_face_covers_ascii(face) {
|
||||
continue;
|
||||
}
|
||||
let Some(src) = font_face_urls(face).into_iter().next() else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(src.clone()) {
|
||||
continue;
|
||||
}
|
||||
// Bound pathological pages without penalizing normal generated
|
||||
// font packages (family x weight x style usually stays under 20).
|
||||
if fonts.len() >= 12 {
|
||||
return fonts;
|
||||
}
|
||||
if let Some(decoded) = fetch_and_decode_font(&src, base_url, &mut cache) {
|
||||
fonts.push(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
fonts
|
||||
}
|
||||
|
||||
fn fetch_and_decode_font(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let compressed = fetch_bytes(src, base_url, cache)?;
|
||||
if compressed.len() > 8 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let decoded = match compressed.get(..4) {
|
||||
Some(b"wOF2") => wuff::decompress_woff2(&compressed).ok(),
|
||||
Some(b"wOFF") => wuff::decompress_woff1(&compressed).ok(),
|
||||
// TrueType/OpenType collections and raw sfnt fonts already have the
|
||||
// representation fontdb expects.
|
||||
Some(b"\0\x01\0\0" | b"OTTO" | b"ttcf") => Some(compressed),
|
||||
_ => None,
|
||||
}?;
|
||||
(decoded.len() <= 32 * 1024 * 1024).then_some(decoded)
|
||||
}
|
||||
|
||||
fn font_face_blocks(css: &str) -> Vec<&str> {
|
||||
let lower = css.to_ascii_lowercase();
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = lower[cursor..].find("@font-face") {
|
||||
let at = cursor + relative;
|
||||
let Some(open_relative) = lower[at..].find('{') else {
|
||||
break;
|
||||
};
|
||||
let open = at + open_relative;
|
||||
let mut depth = 1i32;
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
let mut close = None;
|
||||
for (offset, ch) in css[open + 1..].char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(active) = quote {
|
||||
if ch == active {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '"' | '\'') {
|
||||
quote = Some(ch);
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close = Some(open + 1 + offset);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(close) = close else { break };
|
||||
out.push(&css[open + 1..close]);
|
||||
cursor = close + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn font_face_declaration<'a>(face: &'a str, name: &str) -> Option<&'a str> {
|
||||
split_css_top_level(face, ';').into_iter().find_map(|declaration| {
|
||||
let (property, value) = declaration.split_once(':')?;
|
||||
property.trim().eq_ignore_ascii_case(name).then_some(value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
fn font_face_covers_ascii(face: &str) -> bool {
|
||||
let Some(range) = font_face_declaration(face, "unicode-range") else {
|
||||
return true;
|
||||
};
|
||||
range.split(',').any(|part| {
|
||||
let token = part.trim().to_ascii_lowercase();
|
||||
let Some(value) = token.strip_prefix("u+") else {
|
||||
return false;
|
||||
};
|
||||
let (start, end) = if value.contains('?') {
|
||||
(
|
||||
u32::from_str_radix(&value.replace('?', "0"), 16).ok(),
|
||||
u32::from_str_radix(&value.replace('?', "f"), 16).ok(),
|
||||
)
|
||||
} else if let Some((start, end)) = value.split_once('-') {
|
||||
(
|
||||
u32::from_str_radix(start, 16).ok(),
|
||||
u32::from_str_radix(end, 16).ok(),
|
||||
)
|
||||
} else {
|
||||
let point = u32::from_str_radix(value, 16).ok();
|
||||
(point, point)
|
||||
};
|
||||
matches!((start, end), (Some(start), Some(end)) if start <= 0x7e && end >= 0x20)
|
||||
})
|
||||
}
|
||||
|
||||
fn font_face_urls(face: &str) -> Vec<String> {
|
||||
let Some(src) = font_face_declaration(face, "src") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let lower = src.to_ascii_lowercase();
|
||||
let mut out = Vec::new();
|
||||
let mut cursor = 0;
|
||||
while let Some(relative) = lower[cursor..].find("url(") {
|
||||
let start = cursor + relative + 4;
|
||||
let Some(end_relative) = src[start..].find(')') else {
|
||||
break;
|
||||
};
|
||||
let end = start + end_relative;
|
||||
let value = src[start..end]
|
||||
.trim()
|
||||
.trim_matches(|ch| ch == '"' || ch == '\'')
|
||||
.trim();
|
||||
if !value.is_empty() {
|
||||
out.push(value.to_string());
|
||||
}
|
||||
cursor = end + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_css_top_level(value: &str, separator: char) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut depth = 0i32;
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
for (index, ch) in value.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(active) = quote {
|
||||
if ch == active {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '"' | '\'') {
|
||||
quote = Some(ch);
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => depth = (depth - 1).max(0),
|
||||
ch if ch == separator && depth == 0 => {
|
||||
out.push(&value[start..index]);
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(&value[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Fetch `url` with a descriptive User-Agent and a bounded timeout, retrying on
|
||||
/// rate-limit / transient errors with backoff. Real pages pull dozens of images
|
||||
/// from one CDN in a burst (a Wikipedia article references ~60); hosts like
|
||||
@@ -2382,4 +2633,40 @@ mod tests {
|
||||
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
||||
assert_eq!(markup, before, "same-document use must be untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_parser_selects_ascii_subset_and_preserves_functional_src() {
|
||||
let css = r#"
|
||||
@font-face {
|
||||
font-family: "Example";
|
||||
src: local("Example"), url("./example-cyrillic.woff2") format("woff2");
|
||||
unicode-range: U+0400-04FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Example";
|
||||
src: url(data:font/woff2;base64,d09GMg==) format("woff2"),
|
||||
url("./example-latin.woff") format("woff");
|
||||
unicode-range: U+??, U+2000-206F;
|
||||
}
|
||||
"#;
|
||||
let faces = font_face_blocks(css);
|
||||
assert_eq!(faces.len(), 2);
|
||||
assert!(!font_face_covers_ascii(faces[0]));
|
||||
assert!(font_face_covers_ascii(faces[1]));
|
||||
assert_eq!(
|
||||
font_face_urls(faces[1]),
|
||||
vec![
|
||||
"data:font/woff2;base64,d09GMg==".to_string(),
|
||||
"./example-latin.woff".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_without_unicode_range_is_general_purpose() {
|
||||
let css = r#"@font-face{font-family:Example;src:url(example.otf)}"#;
|
||||
let face = font_face_blocks(css)[0];
|
||||
assert!(font_face_covers_ascii(face));
|
||||
assert_eq!(font_face_urls(face), vec!["example.otf"]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user