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                },
237            ),
238        );
239
240        import
241    }
242}
243
244struct NestedStylesheetHandler {
245    loader: StylesheetLoader,
246    lock: SharedRwLock,
247    url: ServoArc<Url>,
248    media: ServoArc<Locked<MediaList>>,
249    import_rule: ServoArc<Locked<ImportRule>>,
250}
251
252impl NetHandler for ResourceHandler<NestedStylesheetHandler> {
253    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
254        let Ok(css) = std::str::from_utf8(&bytes) else {
255            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
256        };
257
258        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
259        // let escaped_css = html_escape::decode_html_entities(css);
260
261        let sheet = ServoArc::new(Stylesheet::from_str(
262            css,
263            UrlExtraData(self.data.url.clone()),
264            Origin::Author,
265            self.data.media.clone(),
266            self.data.lock.clone(),
267            Some(&self.data.loader),
268            None, // error_reporter
269            QuirksMode::NoQuirks,
270            AllowImportRules::Yes,
271        ));
272
273        // `@font-face` is scanned by the document thread too, for the same
274        // reason the attach is: reading the sheet needs this lock, the document
275        // thread writes it while styling, and an `AtomicRefCell` panics rather
276        // than waiting. Doing it here left a read on a network worker racing a
277        // write on the document thread — the same defect one line further down,
278        // and it would have panicked the same way.
279        let import_rule = self.data.import_rule.clone();
280        self.respond(resolved_url, Ok(Resource::ImportSheet(import_rule, sheet)))
281    }
282}
283
284struct FontFaceHandler {
285    format: FontFaceSourceFormatKeyword,
286    overrides: FontFaceOverrides,
287}
288impl NetHandler for ResourceHandler<FontFaceHandler> {
289    fn bytes(mut self: Box<Self>, resolved_url: String, bytes: Bytes) {
290        let result = self.data.parse(bytes);
291        self.respond(resolved_url, result)
292    }
293}
294impl FontFaceHandler {
295    fn parse(&mut self, bytes: Bytes) -> Result<Resource, String> {
296        if self.format == FontFaceSourceFormatKeyword::None && bytes.len() >= 4 {
297            self.format = match &bytes.as_ref()[0..4] {
298                // WOFF (v1) files begin with 0x774F4646 ('wOFF' in ascii)
299                // See: <https://w3c.github.io/woff/woff1/spec/Overview.html#WOFFHeader>
300                #[cfg(feature = "woff")]
301                b"wOFF" => FontFaceSourceFormatKeyword::Woff,
302                // WOFF2 files begin with 0x774F4632 ('wOF2' in ascii)
303                // See: <https://w3c.github.io/woff/woff2/#woff20Header>
304                #[cfg(feature = "woff")]
305                b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
306                // Opentype fonts with CFF data begin with 0x4F54544F ('OTTO' in ascii)
307                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
308                b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
309                // Opentype fonts truetype outlines begin with 0x00010000
310                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
311                &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
312                // Truetype fonts begin with 0x74727565 ('true' in ascii)
313                // See: <https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6.html#ScalerTypeNote>
314                b"true" => FontFaceSourceFormatKeyword::Truetype,
315                _ => FontFaceSourceFormatKeyword::None,
316            }
317        }
318
319        // Satisfy rustc's mutability linting with woff feature both enabled/disabled
320        #[cfg(feature = "woff")]
321        let mut bytes = bytes;
322
323        match self.format {
324            #[cfg(feature = "woff")]
325            FontFaceSourceFormatKeyword::Woff => {
326                #[cfg(feature = "tracing")]
327                tracing::info!("Decompressing woff1 font");
328
329                // Use wuff crate to decompress font
330                let decompressed = wuff::decompress_woff1(&bytes).ok();
331
332                if let Some(decompressed) = decompressed {
333                    bytes = Bytes::from(decompressed);
334                } else {
335                    #[cfg(feature = "tracing")]
336                    tracing::warn!("Failed to decompress woff1 font");
337                }
338            }
339            #[cfg(feature = "woff")]
340            FontFaceSourceFormatKeyword::Woff2 => {
341                #[cfg(feature = "tracing")]
342                tracing::info!("Decompressing woff2 font");
343
344                // Use wuff crate to decompress font
345                let decompressed = wuff::decompress_woff2(&bytes).ok();
346
347                if let Some(decompressed) = decompressed {
348                    bytes = Bytes::from(decompressed);
349                } else {
350                    #[cfg(feature = "tracing")]
351                    tracing::warn!("Failed to decompress woff2 font");
352                }
353            }
354            FontFaceSourceFormatKeyword::None => {
355                // Should this be an error?
356                return Ok(Resource::None);
357            }
358            _ => {}
359        }
360
361        Ok(Resource::Font(bytes, std::mem::take(&mut self.overrides)))
362    }
363}
364
365#[allow(clippy::too_many_arguments)]
366pub(crate) fn fetch_font_face(
367    tx: Sender<DocumentEvent>,
368    doc_id: usize,
369    node_id: Option<NodeId>,
370    sheet: &Stylesheet,
371    network_provider: &Arc<dyn NetProvider>,
372    shell_provider: &Arc<dyn ShellProvider>,
373    read_guard: &SharedRwLockReadGuard,
374    abort_signal: Option<&AbortSignal>,
375) {
376    sheet
377        .contents(read_guard)
378        .rules(read_guard)
379        .iter()
380        .filter_map(|rule| match rule {
381            CssRule::FontFace(font_face) => {
382                let descriptor = &font_face.read_with(read_guard).descriptors;
383                let family = descriptor.font_family.as_ref()?;
384                let src = descriptor.src.as_ref()?;
385                // Capture the @font-face descriptors so parley can register
386                // the font under the CSS-declared family name (and weight /
387                // style) rather than whatever metadata the TTF reports.
388                let overrides = FontFaceOverrides {
389                    family_name: Some(family.name.to_string()),
390                    weight: descriptor
391                        .font_weight
392                        .as_ref()
393                        .and_then(|range| range.0.compute().map(|w| w.value())),
394                    style: descriptor.font_style.as_ref().map(stylo_to_fontique_style),
395                };
396                Some((src, overrides))
397            }
398            _ => None,
399        })
400        .for_each(|(source_list, overrides)| {
401            // Find the first font source in the source list that specifies a font of a type
402            // that we support.
403            let preferred_source = source_list
404                .0
405                .iter()
406                .filter_map(|source| match source {
407                    Source::Url(url_source) => Some(url_source),
408                    // TODO: support local fonts in @font-face
409                    Source::Local(_) => None,
410                })
411                .find_map(|url_source| {
412                    let mut format = match &url_source.format_hint {
413                        Some(FontFaceSourceFormat::Keyword(fmt)) => *fmt,
414                        Some(FontFaceSourceFormat::String(str)) => match str.as_str() {
415                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
416                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
417                            "otf" => FontFaceSourceFormatKeyword::Opentype,
418                            _ => FontFaceSourceFormatKeyword::None,
419                        },
420                        _ => FontFaceSourceFormatKeyword::None,
421                    };
422                    if format == FontFaceSourceFormatKeyword::None {
423                        let (_, end) = url_source.url.as_str().rsplit_once('.')?;
424                        format = match end {
425                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
426                            "woff" => FontFaceSourceFormatKeyword::Woff,
427                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
428                            "otf" => FontFaceSourceFormatKeyword::Opentype,
429                            "svg" => FontFaceSourceFormatKeyword::Svg,
430                            "eot" => FontFaceSourceFormatKeyword::EmbeddedOpentype,
431                            _ => FontFaceSourceFormatKeyword::None,
432                        }
433                    }
434
435                    if matches!(
436                        format,
437                        FontFaceSourceFormatKeyword::Svg
438                            | FontFaceSourceFormatKeyword::EmbeddedOpentype
439                    ) {
440                        #[cfg(feature = "tracing")]
441                        tracing::warn!("Skipping unsupported font of type {:?}", format);
442                        return None;
443                    }
444
445                    #[cfg(not(feature = "woff"))]
446                    if matches!(
447                        format,
448                        FontFaceSourceFormatKeyword::Woff | FontFaceSourceFormatKeyword::Woff2
449                    ) {
450                        #[cfg(feature = "tracing")]
451                        tracing::warn!("Skipping unsupported font of type {:?}", format);
452                        return None;
453                    }
454
455                    // A relative url with no base url to resolve against
456                    // yields None; skip the source instead of panicking
457                    let Some(url) = url_source.url.url() else {
458                        #[cfg(feature = "tracing")]
459                        tracing::warn!("Skipping @font-face source with unresolvable url");
460                        return None;
461                    };
462                    let url = url.as_ref().clone();
463                    Some((url, format))
464                });
465
466            if let Some((url, format)) = preferred_source {
467                network_provider.fetch(
468                    doc_id,
469                    stamped_request(url, abort_signal),
470                    ResourceHandler::boxed(
471                        tx.clone(),
472                        doc_id,
473                        node_id,
474                        shell_provider.clone(),
475                        FontFaceHandler { format, overrides },
476                    ),
477                );
478            }
479        })
480}
481
482/// Translate stylo's `@font-face` `font-style` descriptor into the fontique
483/// `FontStyle` enum used by parley. Stylo encodes Italic and Oblique-with-
484/// angle distinctly; CSS's bare `normal` is parsed as `Oblique(0deg, 0deg)`
485/// by stylo (see the `FontStyle::parse` impl in stylo's `font_face.rs`), so
486/// that pattern is treated as `Normal` here.
487fn stylo_to_fontique_style(style: &FontStyleRange) -> parley::fontique::FontStyle {
488    use parley::fontique::FontStyle as Fq;
489    match style {
490        FontStyleRange::Italic => Fq::Italic,
491        FontStyleRange::Oblique(min, max) => {
492            let angle = min.degrees();
493            // Stylo emits `Oblique(0deg, 0deg)` for the literal CSS `normal`
494            // keyword. Map that back to `Normal` so parley's font matching
495            // doesn't misclassify upright fonts.
496            if angle.is_none_or(|a| a == 0.0) && max.degrees().is_none_or(|a| a == 0.0) {
497                Fq::Normal
498            } else {
499                Fq::Oblique(angle)
500            }
501        }
502    }
503}
504
505/// Handles HTML fetched for an `<iframe>` element's `src`
506pub(crate) struct DocumentSrcHandler;
507
508impl NetHandler for ResourceHandler<DocumentSrcHandler> {
509    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
510        let html = String::from_utf8_lossy(&bytes).into_owned();
511        self.respond(resolved_url, Ok(Resource::DocumentSrc(html)));
512    }
513}
514
515pub struct ImageHandler {
516    kind: ImageType,
517}
518impl ImageHandler {
519    pub fn new(kind: ImageType) -> Self {
520        Self { kind }
521    }
522}
523
524impl NetHandler for ResourceHandler<ImageHandler> {
525    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
526        let result = self.data.parse(bytes);
527        self.respond(resolved_url, result)
528    }
529}
530
531impl ImageHandler {
532    fn parse(&self, bytes: Bytes) -> Result<Resource, String> {
533        let image_err = match image::ImageReader::new(Cursor::new(&bytes))
534            .with_guessed_format()
535            .expect("IO errors impossible with Cursor")
536            .decode()
537        {
538            Ok(image) => {
539                let raw_rgba8_data = image.clone().into_rgba8().into_raw();
540                return Ok(Resource::Image(
541                    self.kind,
542                    image.width(),
543                    image.height(),
544                    Arc::new(raw_rgba8_data),
545                ));
546            }
547            Err(e) => e.to_string(),
548        };
549
550        #[cfg(feature = "svg")]
551        let svg_err = {
552            use crate::util::parse_svg_image;
553            match parse_svg_image(&bytes) {
554                Ok(svg) => return Ok(Resource::Svg(self.kind, svg)),
555                Err(e) => e.to_string(),
556            }
557        };
558        #[cfg(not(feature = "svg"))]
559        let svg_err = "svg feature disabled";
560
561        Err(format!(
562            "Could not parse image ({} bytes): image-crate error: {image_err}; svg fallback error: {svg_err}",
563            bytes.len()
564        ))
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use parley::fontique::FontStyle as Fq;
572    use style::values::specified::Angle;
573
574    fn oblique(min_deg: f32, max_deg: f32) -> FontStyleRange {
575        FontStyleRange::Oblique(Angle::from_degrees(min_deg), Angle::from_degrees(max_deg))
576    }
577
578    #[test]
579    fn italic_maps_to_italic() {
580        assert_eq!(stylo_to_fontique_style(&FontStyleRange::Italic), Fq::Italic,);
581    }
582
583    #[test]
584    fn oblique_zero_zero_maps_to_normal() {
585        // Stylo parses bare CSS `normal` as `Oblique(0deg, 0deg)`; the
586        // helper must round-trip that back to `FontStyle::Normal` so
587        // parley's matching doesn't misclassify upright fonts.
588        assert_eq!(stylo_to_fontique_style(&oblique(0.0, 0.0)), Fq::Normal);
589    }
590
591    #[test]
592    fn oblique_single_angle_maps_to_oblique_with_min() {
593        assert_eq!(
594            stylo_to_fontique_style(&oblique(14.0, 14.0)),
595            Fq::Oblique(Some(14.0)),
596        );
597    }
598
599    #[test]
600    fn oblique_range_uses_min_angle() {
601        // For a range, fontique's single-angle representation takes the
602        // lower bound — confirm `min` (not `max`) is what gets through.
603        assert_eq!(
604            stylo_to_fontique_style(&oblique(10.0, 20.0)),
605            Fq::Oblique(Some(10.0)),
606        );
607    }
608}