Skip to main content

blitz_dom/
net.rs

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