Skip to main content

apace_dom/
net.rs

1use apace_traits::node_id::NodeId;
2use selectors::context::QuirksMode;
3use std::{io::Cursor, sync::Arc};
4use style::{
5    font_face::{FontFaceSourceFormat, FontFaceSourceFormatKeyword, FontStyleRange, Source},
6    media_queries::MediaList,
7    servo_arc::Arc as ServoArc,
8    shared_lock::SharedRwLock,
9    shared_lock::{Locked, SharedRwLockReadGuard},
10    stylesheets::{
11        AllowImportRules, CssRule, DocumentStyleSheet, ImportRule, Origin, Stylesheet,
12        StylesheetInDocument, StylesheetLoader as ServoStylesheetLoader, UrlExtraData,
13        import_rule::{ImportLayer, ImportSheet, ImportSupportsCondition},
14    },
15    values::{CssUrl, SourceLocation},
16};
17
18use crate::resource_queue::{DocumentSender, Ticket};
19use apace_traits::net::{AbortSignal, Bytes, NetHandler, NetProvider, NetReady, Request};
20use apace_traits::shell::ShellProvider;
21use std::task::Poll;
22
23use url::Url;
24
25use crate::{document::DocumentEvent, util::ImageType};
26
27pub(crate) fn stamped_request(url: Url, signal: Option<&AbortSignal>) -> Request {
28    let mut req = Request::get(url);
29    if let Some(sig) = signal {
30        req = req.signal(sig.clone());
31    }
32    req
33}
34
35/// Carries `@font-face` descriptors from CSS parsing through to font
36/// registration so `parley::Collection::register_fonts` can alias the bytes
37/// under the `font-family` declared in CSS rather than whatever family name
38/// the TTF's own `name` table reports.
39///
40/// All fields are `Option` because each descriptor is independently optional
41/// at the CSS level. Missing fields fall back to the values parley reads
42/// from the font's own metadata.
43#[derive(Clone, Debug, Default)]
44pub struct FontFaceOverrides {
45    /// `font-family` descriptor (the alias the rest of the stylesheet uses).
46    pub family_name: Option<String>,
47    /// `font-weight` descriptor as a single CSS weight (100–900). Stylo
48    /// parses this as a range; we record the lower bound, which equals the
49    /// upper bound in the common single-value case.
50    pub weight: Option<f32>,
51    /// `font-style` descriptor mapped to fontique's `FontStyle`.
52    pub style: Option<parley::fontique::FontStyle>,
53}
54
55#[derive(Clone, Debug)]
56pub enum Resource {
57    Image(ImageType, u32, u32, Arc<Vec<u8>>),
58    #[cfg(feature = "svg")]
59    Svg(ImageType, crate::node::SvgImageData),
60    Css(DocumentStyleSheet),
61    /// Stylesheet loaded for an `@import` rule, to be attached to the rule on the document thread
62    ImportedCss(ServoArc<Locked<ImportRule>>, ServoArc<Stylesheet>),
63    Font(Bytes, FontFaceOverrides),
64    /// HTML fetched for an `<iframe>` element's `src`
65    DocumentSrc(String),
66    None,
67}
68
69pub(crate) trait Payload: Send + Sync + 'static {
70    fn weight(&self, bytes: &Bytes) -> Result<usize, String> {
71        Ok(bytes.len())
72    }
73}
74pub(crate) struct ResourceHandler<T: Payload> {
75    ticket: Ticket,
76    doc_id: usize,
77    request_id: usize,
78    node_id: Option<NodeId>,
79    tx: DocumentSender,
80    shell_provider: Arc<dyn ShellProvider>,
81    data: T,
82}
83
84impl<T: Payload> ResourceHandler<T> {
85    pub(crate) fn new(
86        tx: DocumentSender,
87        doc_id: usize,
88        node_id: Option<NodeId>,
89        shell_provider: Arc<dyn ShellProvider>,
90        data: T,
91    ) -> Option<Self> {
92        let ticket = tx.pool.register()?;
93        Some(Self::with_ticket(
94            tx,
95            doc_id,
96            node_id,
97            shell_provider,
98            data,
99            ticket,
100        ))
101    }
102    fn with_ticket(
103        tx: DocumentSender,
104        doc_id: usize,
105        node_id: Option<NodeId>,
106        shell_provider: Arc<dyn ShellProvider>,
107        data: T,
108        ticket: Ticket,
109    ) -> Self {
110        Self {
111            request_id: ticket.id(),
112            ticket,
113            doc_id,
114            node_id,
115            tx,
116            shell_provider,
117            data,
118        }
119    }
120
121    pub(crate) fn boxed(
122        tx: DocumentSender,
123        doc_id: usize,
124        node_id: Option<NodeId>,
125        shell_provider: Arc<dyn ShellProvider>,
126        data: T,
127    ) -> Option<Box<dyn NetHandler>>
128    where
129        ResourceHandler<T>: NetHandler,
130    {
131        Some(Box::new(Self::new(tx, doc_id, node_id, shell_provider, data)?) as _)
132    }
133
134    pub(crate) fn request_id(&self) -> usize {
135        self.request_id
136    }
137
138    fn cost(&self, bytes: &Bytes) -> Result<usize, String> {
139        if !self.ticket.live() {
140            return Err("retired resource".into());
141        }
142        if bytes.len() > self.ticket.limits().encoded_bytes {
143            self.ticket.reject();
144            return Err("encoded resource budget".into());
145        }
146        self.data.weight(bytes)
147    }
148    fn prepare_bytes(&self, bytes: &Bytes) -> NetReady<'static> {
149        match self.cost(bytes) {
150            Ok(bytes) => Box::pin(self.ticket.wait(bytes)),
151            Err(error) => Box::pin(std::future::ready(Err(error))),
152        }
153    }
154    fn admit(&self, bytes: &Bytes) -> Result<(), String> {
155        let bytes = self.cost(bytes)?;
156        match self.ticket.reserve(bytes, None) {
157            Poll::Ready(result) => result,
158            Poll::Pending => {
159                self.ticket.reject();
160                Err("resource completion capacity; await prepare".into())
161            }
162        }
163    }
164    fn respond(&self, mut resolved_url: String, mut result: Result<Resource, String>) {
165        if resolved_url.len() > 4096 {
166            cut(&mut resolved_url, 4096);
167            result = Err("resource response URL budget".into());
168        }
169        if let Err(error) = &mut result {
170            cut(error, 512);
171        }
172        // CSS/SVG charge their encoded input; raster/font/HTML charge the actual
173        // retained payload. Opaque AST/decoder scratch are not an RSS budget.
174        let weight = match &result {
175            Ok(Resource::Image(_, _, _, bytes)) => Some(bytes.len()),
176            Ok(Resource::Font(bytes, overrides)) => Some(
177                bytes
178                    .len()
179                    .saturating_add(overrides.family_name.as_ref().map_or(0, String::len)),
180            ),
181            Ok(Resource::DocumentSrc(html)) => Some(html.len()),
182            Ok(Resource::None) | Err(_) => Some(0),
183            _ => None,
184        };
185        if let Some(weight) = weight {
186            if !matches!(self.ticket.reserve(weight, None), Poll::Ready(Ok(()))) {
187                result = Err("resource completion byte budget".into());
188                let _ = self.ticket.reserve(0, None);
189            }
190        }
191        let Some(notify) = self.ticket.publish() else {
192            return;
193        };
194        let response = ResourceLoadResponse {
195            ticket: Some(self.ticket.clone()),
196            request_id: self.request_id,
197            node_id: self.node_id,
198            resolved_url: Some(resolved_url),
199            result,
200        };
201        let _ = self.tx.send(DocumentEvent::ResourceLoad(response));
202        if notify {
203            self.shell_provider.request_redraw();
204        }
205    }
206}
207
208fn cut(text: &mut String, limit: usize) {
209    let mut end = text.len().min(limit);
210    while !text.is_char_boundary(end) {
211        end -= 1;
212    }
213    // Replace rather than truncate: large original capacities must not survive
214    // in a bounded diagnostic. Request count bounds this fixed metadata too.
215    if text.len() > end || text.capacity() > limit {
216        *text = text[..end].to_owned();
217    }
218}
219
220#[allow(unused)]
221pub struct ResourceLoadResponse {
222    pub request_id: usize,
223    pub node_id: Option<NodeId>,
224    pub resolved_url: Option<String>,
225    pub result: Result<Resource, String>,
226    // Rust drops struct fields in declaration order. Drop the actual resource
227    // before refunding its bytes/waking producers (also on receiver close).
228    pub(crate) ticket: Option<Ticket>,
229}
230
231pub struct StylesheetHandler {
232    pub guard: SharedRwLock,
233    pub net_provider: Arc<dyn NetProvider>,
234    pub abort_signal: Option<AbortSignal>,
235}
236impl Payload for StylesheetHandler {}
237
238impl NetHandler for ResourceHandler<StylesheetHandler> {
239    fn prepare<'a>(&'a mut self, _url: &'a str, bytes: &'a Bytes) -> NetReady<'a> {
240        self.prepare_bytes(bytes)
241    }
242    fn error(self: Box<Self>, resolved_url: String, error: String) {
243        self.respond(resolved_url, Err(error));
244    }
245
246    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
247        if let Err(error) = self.admit(&bytes) {
248            return self.respond(resolved_url, Err(error));
249        }
250        let Ok(css) = std::str::from_utf8(&bytes) else {
251            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
252        };
253
254        let Ok(source_url) = Url::parse(&resolved_url) else {
255            return self.respond(resolved_url, Err(String::from("Invalid stylesheet URL")));
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 = Stylesheet::from_str(
262            css,
263            source_url.into(),
264            Origin::Author,
265            ServoArc::new(self.data.guard.wrap(MediaList::empty())),
266            self.data.guard.clone(),
267            Some(&StylesheetLoader {
268                tx: self.tx.clone(),
269                doc_id: self.doc_id,
270                net_provider: self.data.net_provider.clone(),
271                shell_provider: self.shell_provider.clone(),
272                abort_signal: self.data.abort_signal.clone(),
273                import_depth: 0,
274            }),
275            None, // error_reporter
276            QuirksMode::NoQuirks,
277            AllowImportRules::Yes,
278        );
279
280        self.respond(
281            resolved_url,
282            Ok(Resource::Css(DocumentStyleSheet(ServoArc::new(sheet)))),
283        );
284    }
285}
286
287/// Maximum depth of nested `@import` rules. Imports nested deeper than this
288/// are refused, preventing unbounded recursion (e.g. a stylesheet whose
289/// resolved `@import` URL yields another stylesheet importing in turn, with
290/// the URL growing geometrically at each level).
291const MAX_IMPORT_DEPTH: usize = 16;
292
293#[derive(Clone)]
294pub(crate) struct StylesheetLoader {
295    pub(crate) tx: DocumentSender,
296    pub(crate) doc_id: usize,
297    pub(crate) net_provider: Arc<dyn NetProvider>,
298    pub(crate) shell_provider: Arc<dyn ShellProvider>,
299    pub(crate) abort_signal: Option<AbortSignal>,
300    /// Depth of `@import` nesting for the stylesheet this loader is parsing
301    /// (0 for a top-level stylesheet)
302    pub(crate) import_depth: usize,
303}
304impl ServoStylesheetLoader for StylesheetLoader {
305    fn request_stylesheet(
306        &self,
307        url: CssUrl,
308        location: SourceLocation,
309        lock: &SharedRwLock,
310        media: ServoArc<Locked<MediaList>>,
311        supports: Option<ImportSupportsCondition>,
312        layer: ImportLayer,
313    ) -> ServoArc<Locked<ImportRule>> {
314        let ticket = self.tx.pool.register();
315        if self.import_depth >= MAX_IMPORT_DEPTH
316            || !supports.as_ref().is_none_or(|s| s.enabled)
317            || ticket.is_none()
318        {
319            return ServoArc::new(lock.wrap(ImportRule {
320                url,
321                stylesheet: ImportSheet::new_refused(),
322                supports,
323                layer,
324                source_location: location,
325            }));
326        }
327
328        let import = ImportRule {
329            url,
330            stylesheet: ImportSheet::new_pending(),
331            supports,
332            layer,
333            source_location: location,
334        };
335
336        let url = import.url.url().unwrap().clone();
337        let import = ServoArc::new(lock.wrap(import));
338        self.net_provider.fetch(
339            self.doc_id,
340            stamped_request(url.as_ref().clone(), self.abort_signal.as_ref()),
341            Box::new(ResourceHandler::with_ticket(
342                self.tx.clone(),
343                self.doc_id,
344                None, // node_id
345                self.shell_provider.clone(),
346                NestedStylesheetHandler {
347                    loader: StylesheetLoader {
348                        import_depth: self.import_depth + 1,
349                        ..self.clone()
350                    },
351                    lock: lock.clone(),
352                    media,
353                    import_rule: import.clone(),
354                    net_provider: self.net_provider.clone(),
355                },
356                ticket.expect("admitted stylesheet"),
357            )),
358        );
359
360        import
361    }
362}
363
364struct NestedStylesheetHandler {
365    loader: StylesheetLoader,
366    lock: SharedRwLock,
367    media: ServoArc<Locked<MediaList>>,
368    import_rule: ServoArc<Locked<ImportRule>>,
369    net_provider: Arc<dyn NetProvider>,
370}
371
372impl Payload for NestedStylesheetHandler {}
373impl NetHandler for ResourceHandler<NestedStylesheetHandler> {
374    fn prepare<'a>(&'a mut self, _url: &'a str, bytes: &'a Bytes) -> NetReady<'a> {
375        self.prepare_bytes(bytes)
376    }
377    fn error(self: Box<Self>, resolved_url: String, error: String) {
378        self.respond(resolved_url, Err(error));
379    }
380
381    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
382        if let Err(error) = self.admit(&bytes) {
383            return self.respond(resolved_url, Err(error));
384        }
385        let Ok(css) = std::str::from_utf8(&bytes) else {
386            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
387        };
388
389        let Ok(source_url) = Url::parse(&resolved_url) else {
390            return self.respond(resolved_url, Err(String::from("Invalid stylesheet URL")));
391        };
392
393        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
394        // let escaped_css = html_escape::decode_html_entities(css);
395
396        let sheet = ServoArc::new(Stylesheet::from_str(
397            css,
398            UrlExtraData(ServoArc::new(source_url)),
399            Origin::Author,
400            self.data.media.clone(),
401            self.data.lock.clone(),
402            Some(&self.data.loader),
403            None, // error_reporter
404            QuirksMode::NoQuirks,
405            AllowImportRules::Yes,
406        ));
407
408        // Fetch @font-face fonts
409        fetch_font_face(
410            self.tx.clone(),
411            self.doc_id,
412            self.node_id,
413            &sheet,
414            &self.data.net_provider,
415            &self.shell_provider,
416            &self.data.lock.read(),
417            self.data.loader.abort_signal.as_ref(),
418        );
419
420        let import_rule = self.data.import_rule.clone();
421        self.respond(resolved_url, Ok(Resource::ImportedCss(import_rule, sheet)))
422    }
423}
424
425struct FontFaceHandler {
426    format: FontFaceSourceFormatKeyword,
427    overrides: FontFaceOverrides,
428}
429impl Payload for FontFaceHandler {
430    fn weight(&self, bytes: &Bytes) -> Result<usize, String> {
431        bytes
432            .len()
433            .checked_add(self.overrides.family_name.as_ref().map_or(0, String::len))
434            .ok_or_else(|| "font size overflow".into())
435    }
436}
437impl NetHandler for ResourceHandler<FontFaceHandler> {
438    fn prepare<'a>(&'a mut self, _url: &'a str, bytes: &'a Bytes) -> NetReady<'a> {
439        self.prepare_bytes(bytes)
440    }
441    fn error(self: Box<Self>, resolved_url: String, error: String) {
442        self.respond(resolved_url, Err(error));
443    }
444
445    fn bytes(mut self: Box<Self>, resolved_url: String, bytes: Bytes) {
446        if let Err(error) = self.admit(&bytes) {
447            return self.respond(resolved_url, Err(error));
448        }
449        let result = self.data.parse(bytes);
450        self.respond(resolved_url, result)
451    }
452}
453impl FontFaceHandler {
454    fn parse(&mut self, bytes: Bytes) -> Result<Resource, String> {
455        if self.format == FontFaceSourceFormatKeyword::None && bytes.len() >= 4 {
456            self.format = match &bytes.as_ref()[0..4] {
457                // WOFF (v1) files begin with 0x774F4646 ('wOFF' in ascii)
458                // See: <https://w3c.github.io/woff/woff1/spec/Overview.html#WOFFHeader>
459                #[cfg(feature = "woff")]
460                b"wOFF" => FontFaceSourceFormatKeyword::Woff,
461                // WOFF2 files begin with 0x774F4632 ('wOF2' in ascii)
462                // See: <https://w3c.github.io/woff/woff2/#woff20Header>
463                #[cfg(feature = "woff")]
464                b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
465                // Opentype fonts with CFF data begin with 0x4F54544F ('OTTO' in ascii)
466                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
467                b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
468                // Opentype fonts truetype outlines begin with 0x00010000
469                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
470                &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
471                // Truetype fonts begin with 0x74727565 ('true' in ascii)
472                // See: <https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6.html#ScalerTypeNote>
473                b"true" => FontFaceSourceFormatKeyword::Truetype,
474                _ => FontFaceSourceFormatKeyword::None,
475            }
476        }
477
478        // Satisfy rustc's mutability linting with woff feature both enabled/disabled
479        #[cfg(feature = "woff")]
480        let mut bytes = bytes;
481
482        match self.format {
483            #[cfg(feature = "woff")]
484            FontFaceSourceFormatKeyword::Woff => {
485                #[cfg(feature = "tracing")]
486                tracing::info!("Decompressing woff1 font");
487
488                // Use wuff crate to decompress font
489                let decompressed = wuff::decompress_woff1(&bytes).ok();
490
491                if let Some(decompressed) = decompressed {
492                    bytes = Bytes::from(decompressed);
493                } else {
494                    #[cfg(feature = "tracing")]
495                    tracing::warn!("Failed to decompress woff1 font");
496                }
497            }
498            #[cfg(feature = "woff")]
499            FontFaceSourceFormatKeyword::Woff2 => {
500                #[cfg(feature = "tracing")]
501                tracing::info!("Decompressing woff2 font");
502
503                // Use wuff crate to decompress font
504                let decompressed = wuff::decompress_woff2(&bytes).ok();
505
506                if let Some(decompressed) = decompressed {
507                    bytes = Bytes::from(decompressed);
508                } else {
509                    #[cfg(feature = "tracing")]
510                    tracing::warn!("Failed to decompress woff2 font");
511                }
512            }
513            FontFaceSourceFormatKeyword::None => {
514                // Should this be an error?
515                return Ok(Resource::None);
516            }
517            _ => {}
518        }
519
520        Ok(Resource::Font(bytes, std::mem::take(&mut self.overrides)))
521    }
522}
523
524/// Fetch the fonts of all `@font-face` rules in `sheet`
525#[allow(clippy::too_many_arguments)]
526pub(crate) fn fetch_font_face(
527    tx: DocumentSender,
528    doc_id: usize,
529    node_id: Option<NodeId>,
530    sheet: &Stylesheet,
531    network_provider: &Arc<dyn NetProvider>,
532    shell_provider: &Arc<dyn ShellProvider>,
533    read_guard: &SharedRwLockReadGuard,
534    abort_signal: Option<&AbortSignal>,
535) {
536    fetch_font_face_rules(
537        sheet.contents(read_guard).rules(read_guard).iter(),
538        tx,
539        doc_id,
540        node_id,
541        network_provider,
542        shell_provider,
543        read_guard,
544        abort_signal,
545    )
546}
547
548/// Fetch the fonts of the `@font-face` rules among `rules`
549#[allow(clippy::too_many_arguments)]
550pub(crate) fn fetch_font_face_rules<'a>(
551    rules: impl Iterator<Item = &'a CssRule>,
552    tx: DocumentSender,
553    doc_id: usize,
554    node_id: Option<NodeId>,
555    network_provider: &Arc<dyn NetProvider>,
556    shell_provider: &Arc<dyn ShellProvider>,
557    read_guard: &SharedRwLockReadGuard,
558    abort_signal: Option<&AbortSignal>,
559) {
560    rules
561        .filter_map(|rule| match rule {
562            CssRule::FontFace(font_face) => {
563                let descriptor = &font_face.read_with(read_guard).descriptors;
564                let family = descriptor.font_family.as_ref()?;
565                let src = descriptor.src.as_ref()?;
566                // Capture the @font-face descriptors so parley can register
567                // the font under the CSS-declared family name (and weight /
568                // style) rather than whatever metadata the TTF reports.
569                let overrides = FontFaceOverrides {
570                    family_name: Some(family.name.to_string()),
571                    weight: descriptor
572                        .font_weight
573                        .as_ref()
574                        .and_then(|range| range.0.compute().map(|w| w.value())),
575                    style: descriptor.font_style.as_ref().map(stylo_to_fontique_style),
576                };
577                Some((src, overrides))
578            }
579            _ => None,
580        })
581        .for_each(|(source_list, overrides)| {
582            // Find the first font source in the source list that specifies a font of a type
583            // that we support.
584            let preferred_source = source_list
585                .0
586                .iter()
587                .filter_map(|source| match source {
588                    Source::Url(url_source) => Some(url_source),
589                    // TODO: support local fonts in @font-face
590                    Source::Local(_) => None,
591                })
592                .find_map(|url_source| {
593                    let mut format = match &url_source.format_hint {
594                        Some(FontFaceSourceFormat::Keyword(fmt)) => *fmt,
595                        Some(FontFaceSourceFormat::String(str)) => match str.as_str() {
596                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
597                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
598                            "otf" => FontFaceSourceFormatKeyword::Opentype,
599                            _ => FontFaceSourceFormatKeyword::None,
600                        },
601                        _ => FontFaceSourceFormatKeyword::None,
602                    };
603                    // A url with no extension, such as a `data:` url, is
604                    // sniffed from its bytes once fetched.
605                    if format == FontFaceSourceFormatKeyword::None
606                        && let Some((_, end)) = url_source.url.as_str().rsplit_once('.')
607                    {
608                        format = match end {
609                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
610                            "woff" => FontFaceSourceFormatKeyword::Woff,
611                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
612                            "otf" => FontFaceSourceFormatKeyword::Opentype,
613                            "svg" => FontFaceSourceFormatKeyword::Svg,
614                            "eot" => FontFaceSourceFormatKeyword::EmbeddedOpentype,
615                            _ => FontFaceSourceFormatKeyword::None,
616                        }
617                    }
618
619                    if matches!(
620                        format,
621                        FontFaceSourceFormatKeyword::Svg
622                            | FontFaceSourceFormatKeyword::EmbeddedOpentype
623                    ) {
624                        #[cfg(feature = "tracing")]
625                        tracing::warn!("Skipping unsupported font of type {:?}", format);
626                        return None;
627                    }
628
629                    #[cfg(not(feature = "woff"))]
630                    if matches!(
631                        format,
632                        FontFaceSourceFormatKeyword::Woff | FontFaceSourceFormatKeyword::Woff2
633                    ) {
634                        #[cfg(feature = "tracing")]
635                        tracing::warn!("Skipping unsupported font of type {:?}", format);
636                        return None;
637                    }
638
639                    // A relative url with no base url to resolve against
640                    // yields None; skip the source instead of panicking
641                    let Some(url) = url_source.url.url() else {
642                        #[cfg(feature = "tracing")]
643                        tracing::warn!("Skipping @font-face source with unresolvable url");
644                        return None;
645                    };
646                    let url = url.as_ref().clone();
647                    Some((url, format))
648                });
649
650            if let Some((url, format)) = preferred_source {
651                let Some(handler) = ResourceHandler::boxed(
652                    tx.clone(),
653                    doc_id,
654                    node_id,
655                    shell_provider.clone(),
656                    FontFaceHandler { format, overrides },
657                ) else {
658                    return;
659                };
660                network_provider.fetch(doc_id, stamped_request(url, abort_signal), handler);
661            }
662        })
663}
664
665/// Translate stylo's `@font-face` `font-style` descriptor into the fontique
666/// `FontStyle` enum used by parley. Stylo encodes Italic and Oblique-with-
667/// angle distinctly; CSS's bare `normal` is parsed as `Oblique(0deg, 0deg)`
668/// by stylo (see the `FontStyle::parse` impl in stylo's `font_face.rs`), so
669/// that pattern is treated as `Normal` here.
670fn stylo_to_fontique_style(style: &FontStyleRange) -> parley::fontique::FontStyle {
671    use parley::fontique::FontStyle as Fq;
672    match style {
673        FontStyleRange::Italic => Fq::Italic,
674        FontStyleRange::Oblique(min, max) => {
675            let angle = min.degrees();
676            // Stylo emits `Oblique(0deg, 0deg)` for the literal CSS `normal`
677            // keyword. Map that back to `Normal` so parley's font matching
678            // doesn't misclassify upright fonts.
679            if angle.is_none_or(|a| a == 0.0) && max.degrees().is_none_or(|a| a == 0.0) {
680                Fq::Normal
681            } else {
682                Fq::Oblique(angle)
683            }
684        }
685    }
686}
687
688/// Handles HTML fetched for an `<iframe>` element's `src`
689pub(crate) struct DocumentSrcHandler;
690
691impl Payload for DocumentSrcHandler {}
692impl NetHandler for ResourceHandler<DocumentSrcHandler> {
693    fn prepare<'a>(&'a mut self, _url: &'a str, bytes: &'a Bytes) -> NetReady<'a> {
694        self.prepare_bytes(bytes)
695    }
696    fn error(self: Box<Self>, resolved_url: String, error: String) {
697        self.respond(resolved_url, Err(error));
698    }
699
700    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
701        if let Err(error) = self.admit(&bytes) {
702            return self.respond(resolved_url, Err(error));
703        }
704        let html = String::from_utf8_lossy(&bytes).into_owned();
705        self.respond(resolved_url, Ok(Resource::DocumentSrc(html)));
706    }
707}
708
709pub struct ImageHandler {
710    kind: ImageType,
711    limits: crate::ResourceLimits,
712}
713impl ImageHandler {
714    pub fn with_limits(kind: ImageType, limits: crate::ResourceLimits) -> Self {
715        Self { kind, limits }
716    }
717    pub fn new(kind: ImageType) -> Self {
718        Self {
719            kind,
720            limits: crate::ResourceLimits::default(),
721        }
722    }
723}
724
725impl Payload for ImageHandler {
726    fn weight(&self, bytes: &Bytes) -> Result<usize, String> {
727        match image::ImageReader::new(Cursor::new(bytes))
728            .with_guessed_format()
729            .map_err(|e| e.to_string())?
730            .into_dimensions()
731        {
732            Ok((w, h)) => {
733                let bytes = u128::from(w) * u128::from(h) * 4;
734                if w > self.limits.image_dimension
735                    || h > self.limits.image_dimension
736                    || bytes > u128::from(self.limits.image_decoded_bytes)
737                {
738                    return Err("decoded image exceeds resource limits".into());
739                }
740                usize::try_from(bytes).map_err(|_| "image size overflow".into())
741            }
742            Err(error) => {
743                #[cfg(feature = "svg")]
744                {
745                    let _ = error;
746                    Ok(bytes.len())
747                }
748                #[cfg(not(feature = "svg"))]
749                {
750                    Err(error.to_string())
751                }
752            }
753        }
754    }
755}
756impl NetHandler for ResourceHandler<ImageHandler> {
757    fn prepare<'a>(&'a mut self, _url: &'a str, bytes: &'a Bytes) -> NetReady<'a> {
758        self.prepare_bytes(bytes)
759    }
760    fn error(self: Box<Self>, resolved_url: String, error: String) {
761        self.respond(resolved_url, Err(error));
762    }
763
764    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
765        if let Err(error) = self.admit(&bytes) {
766            return self.respond(resolved_url, Err(error));
767        }
768        let result = self.data.parse(bytes);
769        self.respond(resolved_url, result)
770    }
771}
772
773impl ImageHandler {
774    fn parse(&self, bytes: Bytes) -> Result<Resource, String> {
775        // Validate dimensions before decoding or expanding RGB into RGBA.
776        // A small encoded body may otherwise allocate an enormous image.
777        let dimensions = image::ImageReader::new(Cursor::new(&bytes))
778            .with_guessed_format()
779            .map_err(|error| error.to_string())?
780            .into_dimensions();
781        if let Ok((width, height)) = dimensions {
782            if width > self.limits.image_dimension
783                || height > self.limits.image_dimension
784                || u128::from(width) * u128::from(height) * 4
785                    > u128::from(self.limits.image_decoded_bytes)
786            {
787                return Err("decoded image exceeds resource limits".into());
788            }
789        }
790        let image_err = match image::ImageReader::new(Cursor::new(&bytes))
791            .with_guessed_format()
792            .expect("IO errors impossible with Cursor")
793            .decode()
794        {
795            Ok(image) => {
796                let width = image.width();
797                let height = image.height();
798                let raw_rgba8_data = image.into_rgba8().into_raw();
799                return Ok(Resource::Image(
800                    self.kind,
801                    width,
802                    height,
803                    Arc::new(raw_rgba8_data),
804                ));
805            }
806            Err(e) => e.to_string(),
807        };
808
809        #[cfg(feature = "svg")]
810        let svg_err = {
811            use crate::util::parse_svg_image;
812            match parse_svg_image(&bytes) {
813                Ok(svg) => return Ok(Resource::Svg(self.kind, svg)),
814                Err(e) => e.to_string(),
815            }
816        };
817        #[cfg(not(feature = "svg"))]
818        let svg_err = "svg feature disabled";
819
820        Err(format!(
821            "Could not parse image ({} bytes): image-crate error: {image_err}; svg fallback error: {svg_err}",
822            bytes.len()
823        ))
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use parley::fontique::FontStyle as Fq;
831    use style::values::specified::Angle;
832
833    #[test]
834    fn response_diagnostics_are_bounded_utf8_and_do_not_retain_large_capacities() {
835        let doc = crate::BaseDocument::new(Default::default());
836        let handler = ResourceHandler::new(
837            doc.tx.clone(),
838            doc.id(),
839            None,
840            doc.shell_provider.clone(),
841            ImageHandler::new(ImageType::Image),
842        )
843        .unwrap();
844        Box::new(handler).error("🙂".repeat(10000), "🙂".repeat(10000));
845        let DocumentEvent::ResourceLoad(response) = doc.rx.as_ref().unwrap().try_recv().unwrap()
846        else {
847            panic!("resource")
848        };
849        let url = response.resolved_url.as_ref().unwrap();
850        assert!(url.len() <= 4096 && url.capacity() <= 4096);
851        let error = response.result.as_ref().unwrap_err();
852        assert!(error.len() <= 512 && error.capacity() <= 512);
853        assert_eq!(doc.resource_stats().requests, 1);
854        drop(response);
855        assert_eq!(doc.resource_stats().requests, 0);
856    }
857
858    fn oblique(min_deg: f32, max_deg: f32) -> FontStyleRange {
859        FontStyleRange::Oblique(Angle::from_degrees(min_deg), Angle::from_degrees(max_deg))
860    }
861
862    #[test]
863    fn italic_maps_to_italic() {
864        assert_eq!(stylo_to_fontique_style(&FontStyleRange::Italic), Fq::Italic,);
865    }
866
867    #[test]
868    fn oblique_zero_zero_maps_to_normal() {
869        // Stylo parses bare CSS `normal` as `Oblique(0deg, 0deg)`; the
870        // helper must round-trip that back to `FontStyle::Normal` so
871        // parley's matching doesn't misclassify upright fonts.
872        assert_eq!(stylo_to_fontique_style(&oblique(0.0, 0.0)), Fq::Normal);
873    }
874
875    #[test]
876    fn oblique_single_angle_maps_to_oblique_with_min() {
877        assert_eq!(
878            stylo_to_fontique_style(&oblique(14.0, 14.0)),
879            Fq::Oblique(Some(14.0)),
880        );
881    }
882
883    #[test]
884    fn oblique_range_uses_min_angle() {
885        // For a range, fontique's single-angle representation takes the
886        // lower bound — confirm `min` (not `max`) is what gets through.
887        assert_eq!(
888            stylo_to_fontique_style(&oblique(10.0, 20.0)),
889            Fq::Oblique(Some(10.0)),
890        );
891    }
892}