Skip to main content

blitz_dom/
net.rs

1use blitz_traits::node_id::NodeId;
2use selectors::context::QuirksMode;
3use std::sync::atomic::Ordering as Ao;
4use std::{
5    io::Cursor,
6    sync::{Arc, atomic::AtomicUsize, mpsc::Sender},
7};
8use style::{
9    font_face::{FontFaceSourceFormat, FontFaceSourceFormatKeyword, FontStyleRange, Source},
10    media_queries::MediaList,
11    servo_arc::Arc as ServoArc,
12    shared_lock::SharedRwLock,
13    shared_lock::{Locked, SharedRwLockReadGuard},
14    stylesheets::{
15        AllowImportRules, CssRule, DocumentStyleSheet, ImportRule, Origin, Stylesheet,
16        StylesheetInDocument, StylesheetLoader as ServoStylesheetLoader, UrlExtraData,
17        import_rule::{ImportLayer, ImportSheet, ImportSupportsCondition},
18    },
19    values::{CssUrl, SourceLocation},
20};
21
22use blitz_traits::net::{AbortSignal, Bytes, NetHandler, NetProvider, Request};
23use blitz_traits::shell::ShellProvider;
24
25use url::Url;
26
27use crate::{document::DocumentEvent, util::ImageType};
28
29pub(crate) fn stamped_request(url: Url, signal: Option<&AbortSignal>) -> Request {
30    let mut req = Request::get(url);
31    if let Some(sig) = signal {
32        req = req.signal(sig.clone());
33    }
34    req
35}
36
37/// Carries `@font-face` descriptors from CSS parsing through to font
38/// registration so `parley::Collection::register_fonts` can alias the bytes
39/// under the `font-family` declared in CSS rather than whatever family name
40/// the TTF's own `name` table reports.
41///
42/// All fields are `Option` because each descriptor is independently optional
43/// at the CSS level. Missing fields fall back to the values parley reads
44/// from the font's own metadata.
45#[derive(Clone, Debug, Default)]
46pub struct FontFaceOverrides {
47    /// `font-family` descriptor (the alias the rest of the stylesheet uses).
48    pub family_name: Option<String>,
49    /// `font-weight` descriptor as a single CSS weight (100–900). Stylo
50    /// parses this as a range; we record the lower bound, which equals the
51    /// upper bound in the common single-value case.
52    pub weight: Option<f32>,
53    /// `font-style` descriptor mapped to fontique's `FontStyle`.
54    pub style: Option<parley::fontique::FontStyle>,
55}
56
57#[derive(Clone, Debug)]
58pub enum Resource {
59    Image(ImageType, u32, u32, Arc<Vec<u8>>),
60    #[cfg(feature = "svg")]
61    Svg(ImageType, crate::node::SvgImageData),
62    Css(DocumentStyleSheet),
63    Font(Bytes, FontFaceOverrides),
64    /// HTML fetched for an `<iframe>` element's `src`
65    DocumentSrc(String),
66    None,
67}
68
69pub(crate) struct ResourceHandler<T: Send + Sync + 'static> {
70    doc_id: usize,
71    request_id: usize,
72    node_id: Option<NodeId>,
73    tx: Sender<DocumentEvent>,
74    shell_provider: Arc<dyn ShellProvider>,
75    data: T,
76}
77
78impl<T: Send + Sync + 'static> ResourceHandler<T> {
79    pub(crate) fn new(
80        tx: Sender<DocumentEvent>,
81        doc_id: usize,
82        node_id: Option<NodeId>,
83        shell_provider: Arc<dyn ShellProvider>,
84        data: T,
85    ) -> Self {
86        static REQUEST_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
87        Self {
88            request_id: REQUEST_ID_COUNTER.fetch_add(1, Ao::Relaxed),
89            doc_id,
90            node_id,
91            tx,
92            shell_provider,
93            data,
94        }
95    }
96
97    pub(crate) fn boxed(
98        tx: Sender<DocumentEvent>,
99        doc_id: usize,
100        node_id: Option<NodeId>,
101        shell_provider: Arc<dyn ShellProvider>,
102        data: T,
103    ) -> Box<dyn NetHandler>
104    where
105        ResourceHandler<T>: NetHandler,
106    {
107        Box::new(Self::new(tx, doc_id, node_id, shell_provider, data)) as _
108    }
109
110    pub(crate) fn request_id(&self) -> usize {
111        self.request_id
112    }
113
114    fn respond(&self, resolved_url: String, result: Result<Resource, String>) {
115        let response = ResourceLoadResponse {
116            request_id: self.request_id,
117            node_id: self.node_id,
118            resolved_url: Some(resolved_url),
119            result,
120        };
121        let _ = self.tx.send(DocumentEvent::ResourceLoad(response));
122        self.shell_provider.request_redraw();
123    }
124}
125
126#[allow(unused)]
127pub struct ResourceLoadResponse {
128    pub request_id: usize,
129    pub node_id: Option<NodeId>,
130    pub resolved_url: Option<String>,
131    pub result: Result<Resource, String>,
132}
133
134pub struct StylesheetHandler {
135    pub source_url: Url,
136    pub guard: SharedRwLock,
137    pub net_provider: Arc<dyn NetProvider>,
138    pub abort_signal: Option<AbortSignal>,
139}
140
141impl NetHandler for ResourceHandler<StylesheetHandler> {
142    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
143        let Ok(css) = std::str::from_utf8(&bytes) else {
144            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
145        };
146
147        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
148        // let escaped_css = html_escape::decode_html_entities(css);
149
150        let sheet = Stylesheet::from_str(
151            css,
152            self.data.source_url.clone().into(),
153            Origin::Author,
154            ServoArc::new(self.data.guard.wrap(MediaList::empty())),
155            self.data.guard.clone(),
156            Some(&StylesheetLoader {
157                tx: self.tx.clone(),
158                doc_id: self.doc_id,
159                net_provider: self.data.net_provider.clone(),
160                shell_provider: self.shell_provider.clone(),
161                abort_signal: self.data.abort_signal.clone(),
162            }),
163            None, // error_reporter
164            QuirksMode::NoQuirks,
165            AllowImportRules::Yes,
166        );
167
168        self.respond(
169            resolved_url,
170            Ok(Resource::Css(DocumentStyleSheet(ServoArc::new(sheet)))),
171        );
172    }
173}
174
175#[derive(Clone)]
176pub(crate) struct StylesheetLoader {
177    pub(crate) tx: Sender<DocumentEvent>,
178    pub(crate) doc_id: usize,
179    pub(crate) net_provider: Arc<dyn NetProvider>,
180    pub(crate) shell_provider: Arc<dyn ShellProvider>,
181    pub(crate) abort_signal: Option<AbortSignal>,
182}
183impl ServoStylesheetLoader for StylesheetLoader {
184    fn request_stylesheet(
185        &self,
186        url: CssUrl,
187        location: SourceLocation,
188        lock: &SharedRwLock,
189        media: ServoArc<Locked<MediaList>>,
190        supports: Option<ImportSupportsCondition>,
191        layer: ImportLayer,
192    ) -> ServoArc<Locked<ImportRule>> {
193        if !supports.as_ref().is_none_or(|s| s.enabled) {
194            return ServoArc::new(lock.wrap(ImportRule {
195                url,
196                stylesheet: ImportSheet::new_refused(),
197                supports,
198                layer,
199                source_location: location,
200            }));
201        }
202
203        let import = ImportRule {
204            url,
205            stylesheet: ImportSheet::new_pending(),
206            supports,
207            layer,
208            source_location: location,
209        };
210
211        let url = import.url.url().unwrap().clone();
212        let import = ServoArc::new(lock.wrap(import));
213        self.net_provider.fetch(
214            self.doc_id,
215            stamped_request(url.as_ref().clone(), self.abort_signal.as_ref()),
216            ResourceHandler::boxed(
217                self.tx.clone(),
218                self.doc_id,
219                None, // node_id
220                self.shell_provider.clone(),
221                NestedStylesheetHandler {
222                    url: url.clone(),
223                    loader: self.clone(),
224                    lock: lock.clone(),
225                    media,
226                    import_rule: import.clone(),
227                    net_provider: self.net_provider.clone(),
228                },
229            ),
230        );
231
232        import
233    }
234}
235
236struct NestedStylesheetHandler {
237    loader: StylesheetLoader,
238    lock: SharedRwLock,
239    url: ServoArc<Url>,
240    media: ServoArc<Locked<MediaList>>,
241    import_rule: ServoArc<Locked<ImportRule>>,
242    net_provider: Arc<dyn NetProvider>,
243}
244
245impl NetHandler for ResourceHandler<NestedStylesheetHandler> {
246    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
247        let Ok(css) = std::str::from_utf8(&bytes) else {
248            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
249        };
250
251        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
252        // let escaped_css = html_escape::decode_html_entities(css);
253
254        let sheet = ServoArc::new(Stylesheet::from_str(
255            css,
256            UrlExtraData(self.data.url.clone()),
257            Origin::Author,
258            self.data.media.clone(),
259            self.data.lock.clone(),
260            Some(&self.data.loader),
261            None, // error_reporter
262            QuirksMode::NoQuirks,
263            AllowImportRules::Yes,
264        ));
265
266        // Fetch @font-face fonts
267        fetch_font_face(
268            self.tx.clone(),
269            self.doc_id,
270            self.node_id,
271            &sheet,
272            &self.data.net_provider,
273            &self.shell_provider,
274            &self.data.lock.read(),
275            self.data.loader.abort_signal.as_ref(),
276        );
277
278        let mut guard = self.data.lock.write();
279        self.data.import_rule.write_with(&mut guard).stylesheet = ImportSheet::Sheet(sheet);
280        drop(guard);
281
282        self.respond(resolved_url, Ok(Resource::None))
283    }
284}
285
286struct FontFaceHandler {
287    format: FontFaceSourceFormatKeyword,
288    overrides: FontFaceOverrides,
289}
290impl NetHandler for ResourceHandler<FontFaceHandler> {
291    fn bytes(mut self: Box<Self>, resolved_url: String, bytes: Bytes) {
292        let result = self.data.parse(bytes);
293        self.respond(resolved_url, result)
294    }
295}
296impl FontFaceHandler {
297    fn parse(&mut self, bytes: Bytes) -> Result<Resource, String> {
298        if self.format == FontFaceSourceFormatKeyword::None && bytes.len() >= 4 {
299            self.format = match &bytes.as_ref()[0..4] {
300                // WOFF (v1) files begin with 0x774F4646 ('wOFF' in ascii)
301                // See: <https://w3c.github.io/woff/woff1/spec/Overview.html#WOFFHeader>
302                #[cfg(feature = "woff")]
303                b"wOFF" => FontFaceSourceFormatKeyword::Woff,
304                // WOFF2 files begin with 0x774F4632 ('wOF2' in ascii)
305                // See: <https://w3c.github.io/woff/woff2/#woff20Header>
306                #[cfg(feature = "woff")]
307                b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
308                // Opentype fonts with CFF data begin with 0x4F54544F ('OTTO' in ascii)
309                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
310                b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
311                // Opentype fonts truetype outlines begin with 0x00010000
312                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
313                &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
314                // Truetype fonts begin with 0x74727565 ('true' in ascii)
315                // See: <https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6.html#ScalerTypeNote>
316                b"true" => FontFaceSourceFormatKeyword::Truetype,
317                _ => FontFaceSourceFormatKeyword::None,
318            }
319        }
320
321        // Satisfy rustc's mutability linting with woff feature both enabled/disabled
322        #[cfg(feature = "woff")]
323        let mut bytes = bytes;
324
325        match self.format {
326            #[cfg(feature = "woff")]
327            FontFaceSourceFormatKeyword::Woff => {
328                #[cfg(feature = "tracing")]
329                tracing::info!("Decompressing woff1 font");
330
331                // Use wuff crate to decompress font
332                let decompressed = wuff::decompress_woff1(&bytes).ok();
333
334                if let Some(decompressed) = decompressed {
335                    bytes = Bytes::from(decompressed);
336                } else {
337                    #[cfg(feature = "tracing")]
338                    tracing::warn!("Failed to decompress woff1 font");
339                }
340            }
341            #[cfg(feature = "woff")]
342            FontFaceSourceFormatKeyword::Woff2 => {
343                #[cfg(feature = "tracing")]
344                tracing::info!("Decompressing woff2 font");
345
346                // Use wuff crate to decompress font
347                let decompressed = wuff::decompress_woff2(&bytes).ok();
348
349                if let Some(decompressed) = decompressed {
350                    bytes = Bytes::from(decompressed);
351                } else {
352                    #[cfg(feature = "tracing")]
353                    tracing::warn!("Failed to decompress woff2 font");
354                }
355            }
356            FontFaceSourceFormatKeyword::None => {
357                // Should this be an error?
358                return Ok(Resource::None);
359            }
360            _ => {}
361        }
362
363        Ok(Resource::Font(bytes, std::mem::take(&mut self.overrides)))
364    }
365}
366
367#[allow(clippy::too_many_arguments)]
368pub(crate) fn fetch_font_face(
369    tx: Sender<DocumentEvent>,
370    doc_id: usize,
371    node_id: Option<NodeId>,
372    sheet: &Stylesheet,
373    network_provider: &Arc<dyn NetProvider>,
374    shell_provider: &Arc<dyn ShellProvider>,
375    read_guard: &SharedRwLockReadGuard,
376    abort_signal: Option<&AbortSignal>,
377) {
378    sheet
379        .contents(read_guard)
380        .rules(read_guard)
381        .iter()
382        .filter_map(|rule| match rule {
383            CssRule::FontFace(font_face) => {
384                let descriptor = &font_face.read_with(read_guard).descriptors;
385                let family = descriptor.font_family.as_ref()?;
386                let src = descriptor.src.as_ref()?;
387                // Capture the @font-face descriptors so parley can register
388                // the font under the CSS-declared family name (and weight /
389                // style) rather than whatever metadata the TTF reports.
390                let overrides = FontFaceOverrides {
391                    family_name: Some(family.name.to_string()),
392                    weight: descriptor
393                        .font_weight
394                        .as_ref()
395                        .and_then(|range| range.0.compute().map(|w| w.value())),
396                    style: descriptor.font_style.as_ref().map(stylo_to_fontique_style),
397                };
398                Some((src, overrides))
399            }
400            _ => None,
401        })
402        .for_each(|(source_list, overrides)| {
403            // Find the first font source in the source list that specifies a font of a type
404            // that we support.
405            let preferred_source = source_list
406                .0
407                .iter()
408                .filter_map(|source| match source {
409                    Source::Url(url_source) => Some(url_source),
410                    // TODO: support local fonts in @font-face
411                    Source::Local(_) => None,
412                })
413                .find_map(|url_source| {
414                    let mut format = match &url_source.format_hint {
415                        Some(FontFaceSourceFormat::Keyword(fmt)) => *fmt,
416                        Some(FontFaceSourceFormat::String(str)) => match str.as_str() {
417                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
418                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
419                            "otf" => FontFaceSourceFormatKeyword::Opentype,
420                            _ => FontFaceSourceFormatKeyword::None,
421                        },
422                        _ => FontFaceSourceFormatKeyword::None,
423                    };
424                    if format == FontFaceSourceFormatKeyword::None {
425                        let (_, end) = url_source.url.as_str().rsplit_once('.')?;
426                        format = match end {
427                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
428                            "woff" => FontFaceSourceFormatKeyword::Woff,
429                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
430                            "otf" => FontFaceSourceFormatKeyword::Opentype,
431                            "svg" => FontFaceSourceFormatKeyword::Svg,
432                            "eot" => FontFaceSourceFormatKeyword::EmbeddedOpentype,
433                            _ => FontFaceSourceFormatKeyword::None,
434                        }
435                    }
436
437                    if matches!(
438                        format,
439                        FontFaceSourceFormatKeyword::Svg
440                            | FontFaceSourceFormatKeyword::EmbeddedOpentype
441                    ) {
442                        #[cfg(feature = "tracing")]
443                        tracing::warn!("Skipping unsupported font of type {:?}", format);
444                        return None;
445                    }
446
447                    #[cfg(not(feature = "woff"))]
448                    if matches!(
449                        format,
450                        FontFaceSourceFormatKeyword::Woff | FontFaceSourceFormatKeyword::Woff2
451                    ) {
452                        #[cfg(feature = "tracing")]
453                        tracing::warn!("Skipping unsupported font of type {:?}", format);
454                        return None;
455                    }
456
457                    // A relative url with no base url to resolve against
458                    // yields None; skip the source instead of panicking
459                    let Some(url) = url_source.url.url() else {
460                        #[cfg(feature = "tracing")]
461                        tracing::warn!("Skipping @font-face source with unresolvable url");
462                        return None;
463                    };
464                    let url = url.as_ref().clone();
465                    Some((url, format))
466                });
467
468            if let Some((url, format)) = preferred_source {
469                network_provider.fetch(
470                    doc_id,
471                    stamped_request(url, abort_signal),
472                    ResourceHandler::boxed(
473                        tx.clone(),
474                        doc_id,
475                        node_id,
476                        shell_provider.clone(),
477                        FontFaceHandler { format, overrides },
478                    ),
479                );
480            }
481        })
482}
483
484/// Translate stylo's `@font-face` `font-style` descriptor into the fontique
485/// `FontStyle` enum used by parley. Stylo encodes Italic and Oblique-with-
486/// angle distinctly; CSS's bare `normal` is parsed as `Oblique(0deg, 0deg)`
487/// by stylo (see the `FontStyle::parse` impl in stylo's `font_face.rs`), so
488/// that pattern is treated as `Normal` here.
489fn stylo_to_fontique_style(style: &FontStyleRange) -> parley::fontique::FontStyle {
490    use parley::fontique::FontStyle as Fq;
491    match style {
492        FontStyleRange::Italic => Fq::Italic,
493        FontStyleRange::Oblique(min, max) => {
494            let angle = min.degrees();
495            // Stylo emits `Oblique(0deg, 0deg)` for the literal CSS `normal`
496            // keyword. Map that back to `Normal` so parley's font matching
497            // doesn't misclassify upright fonts.
498            if angle.is_none_or(|a| a == 0.0) && max.degrees().is_none_or(|a| a == 0.0) {
499                Fq::Normal
500            } else {
501                Fq::Oblique(angle)
502            }
503        }
504    }
505}
506
507/// Handles HTML fetched for an `<iframe>` element's `src`
508pub(crate) struct DocumentSrcHandler;
509
510impl NetHandler for ResourceHandler<DocumentSrcHandler> {
511    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
512        let html = String::from_utf8_lossy(&bytes).into_owned();
513        self.respond(resolved_url, Ok(Resource::DocumentSrc(html)));
514    }
515}
516
517pub struct ImageHandler {
518    kind: ImageType,
519}
520impl ImageHandler {
521    pub fn new(kind: ImageType) -> Self {
522        Self { kind }
523    }
524}
525
526impl NetHandler for ResourceHandler<ImageHandler> {
527    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
528        let result = self.data.parse(bytes);
529        self.respond(resolved_url, result)
530    }
531}
532
533impl ImageHandler {
534    fn parse(&self, bytes: Bytes) -> Result<Resource, String> {
535        let image_err = match image::ImageReader::new(Cursor::new(&bytes))
536            .with_guessed_format()
537            .expect("IO errors impossible with Cursor")
538            .decode()
539        {
540            Ok(image) => {
541                let raw_rgba8_data = image.clone().into_rgba8().into_raw();
542                return Ok(Resource::Image(
543                    self.kind,
544                    image.width(),
545                    image.height(),
546                    Arc::new(raw_rgba8_data),
547                ));
548            }
549            Err(e) => e.to_string(),
550        };
551
552        #[cfg(feature = "svg")]
553        let svg_err = {
554            use crate::util::parse_svg_image;
555            match parse_svg_image(&bytes) {
556                Ok(svg) => return Ok(Resource::Svg(self.kind, svg)),
557                Err(e) => e.to_string(),
558            }
559        };
560        #[cfg(not(feature = "svg"))]
561        let svg_err = "svg feature disabled";
562
563        Err(format!(
564            "Could not parse image ({} bytes): image-crate error: {image_err}; svg fallback error: {svg_err}",
565            bytes.len()
566        ))
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use parley::fontique::FontStyle as Fq;
574    use style::values::specified::Angle;
575
576    fn oblique(min_deg: f32, max_deg: f32) -> FontStyleRange {
577        FontStyleRange::Oblique(Angle::from_degrees(min_deg), Angle::from_degrees(max_deg))
578    }
579
580    #[test]
581    fn italic_maps_to_italic() {
582        assert_eq!(stylo_to_fontique_style(&FontStyleRange::Italic), Fq::Italic,);
583    }
584
585    #[test]
586    fn oblique_zero_zero_maps_to_normal() {
587        // Stylo parses bare CSS `normal` as `Oblique(0deg, 0deg)`; the
588        // helper must round-trip that back to `FontStyle::Normal` so
589        // parley's matching doesn't misclassify upright fonts.
590        assert_eq!(stylo_to_fontique_style(&oblique(0.0, 0.0)), Fq::Normal);
591    }
592
593    #[test]
594    fn oblique_single_angle_maps_to_oblique_with_min() {
595        assert_eq!(
596            stylo_to_fontique_style(&oblique(14.0, 14.0)),
597            Fq::Oblique(Some(14.0)),
598        );
599    }
600
601    #[test]
602    fn oblique_range_uses_min_angle() {
603        // For a range, fontique's single-angle representation takes the
604        // lower bound — confirm `min` (not `max`) is what gets through.
605        assert_eq!(
606            stylo_to_fontique_style(&oblique(10.0, 20.0)),
607            Fq::Oblique(Some(10.0)),
608        );
609    }
610}