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