Skip to main content

blitz_dom/
net.rs

1#[path = "web_fonts.rs"]
2pub mod web_fonts;
3
4use blitz_traits::node_id::NodeId;
5use selectors::context::QuirksMode;
6use std::sync::atomic::Ordering as Ao;
7use std::{
8    io::Cursor,
9    sync::{
10        Arc,
11        atomic::{AtomicBool, AtomicUsize},
12        mpsc::Sender,
13    },
14};
15use style::{
16    font_face::{FontFaceSourceFormat, FontFaceSourceFormatKeyword, FontStyleRange, Source},
17    media_queries::MediaList,
18    servo_arc::Arc as ServoArc,
19    shared_lock::SharedRwLock,
20    shared_lock::{Locked, SharedRwLockReadGuard},
21    stylesheets::{
22        AllowImportRules, CssRule, DocumentStyleSheet, ImportRule, Origin, Stylesheet,
23        StylesheetInDocument, StylesheetLoader as ServoStylesheetLoader, UrlExtraData,
24        import_rule::{ImportLayer, ImportSheet, ImportSupportsCondition},
25    },
26    values::{CssUrl, SourceLocation},
27};
28
29use blitz_traits::net::{AbortSignal, Bytes, NetHandler, NetProvider, Request, RequestDestination};
30use blitz_traits::shell::ShellProvider;
31
32use url::Url;
33
34use crate::{document::DocumentEvent, util::ImageType};
35
36pub(crate) fn stamped_request(url: Url, signal: Option<&AbortSignal>) -> Request {
37    let mut req = Request::get(url);
38    if let Some(sig) = signal {
39        req = req.signal(sig.clone());
40    }
41    req
42}
43
44/// Add the `Referer` a browser sends for a subresource under the default
45/// `strict-origin-when-cross-origin` policy: the full URL (minus fragment and
46/// credentials) to the same origin, only the origin across origins, nothing
47/// on an HTTPS to HTTP downgrade or from a non-HTTP document. CDNs commonly
48/// refuse image requests without one.
49pub(crate) fn with_referrer(mut req: Request, document: &Url) -> Request {
50    if !matches!(document.scheme(), "http" | "https") {
51        return req;
52    }
53    if document.scheme() == "https" && req.url.scheme() == "http" {
54        return req;
55    }
56    let referrer = if document.origin() == req.url.origin() {
57        let mut full = document.clone();
58        full.set_fragment(None);
59        let _ = full.set_username("");
60        let _ = full.set_password(None);
61        full.to_string()
62    } else {
63        format!("{}/", document.origin().ascii_serialization())
64    };
65    if let Ok(value) = blitz_traits::net::http::HeaderValue::from_str(&referrer) {
66        req.headers
67            .insert(blitz_traits::net::http::header::REFERER, value);
68    }
69    req
70}
71
72/// Carries `@font-face` descriptors from CSS parsing through to font
73/// registration so `parley::Collection::register_fonts` can alias the bytes
74/// under the `font-family` declared in CSS rather than whatever family name
75/// the TTF's own `name` table reports.
76///
77/// All fields are `Option` because each descriptor is independently optional
78/// at the CSS level. Missing fields fall back to the values parley reads
79/// from the font's own metadata.
80#[derive(Clone, Debug, Default)]
81pub struct FontFaceOverrides {
82    /// `font-family` descriptor (the alias the rest of the stylesheet uses).
83    pub family_name: Option<String>,
84    /// `font-weight` descriptor as a single CSS weight (100–900). Stylo
85    /// parses this as a range; we record the lower bound, which equals the
86    /// upper bound in the common single-value case.
87    pub weight: Option<f32>,
88    /// `font-style` descriptor mapped to fontique's `FontStyle`.
89    pub style: Option<parley::fontique::FontStyle>,
90}
91
92#[derive(Clone, Debug)]
93pub enum Resource {
94    Image(ImageType, u32, u32, Arc<Vec<u8>>),
95    #[cfg(feature = "svg")]
96    Svg(ImageType, crate::node::SvgImageData),
97    Css(DocumentStyleSheet),
98    Font(Bytes, FontFaceOverrides),
99    TrackedFont(Bytes, FontFaceOverrides, Arc<web_fonts::WebFont>),
100    /// HTML fetched for an `<iframe>` element's `src`
101    DocumentSrc(String),
102    /// A stylesheet fetched for an `@import` rule, and the rule it belongs to.
103    ///
104    /// Carried back rather than attached where it was parsed. Attaching means
105    /// taking stylo's document-wide `SharedRwLock` for writing, and this
106    /// arrives on a network worker while the document thread may be holding
107    /// the same lock for reading to resolve style. That lock is an
108    /// `AtomicRefCell`, so the two do not queue: the writer panics with
109    /// "already immutably borrowed" and takes the page with it.
110    ImportSheet(ServoArc<Locked<ImportRule>>, ServoArc<Stylesheet>),
111    None,
112}
113
114pub(crate) struct ResourceHandler<T: Send + Sync + 'static> {
115    doc_id: usize,
116    request_id: usize,
117    node_id: Option<NodeId>,
118    tx: Sender<DocumentEvent>,
119    shell_provider: Arc<dyn ShellProvider>,
120    /// Set once `respond` has delivered a result. A fetch that never calls
121    /// `bytes` drops the handler; without this the critical-resource id stays
122    /// registered and painting never starts.
123    settled: AtomicBool,
124    data: T,
125}
126
127impl<T: Send + Sync + 'static> ResourceHandler<T> {
128    pub(crate) fn new(
129        tx: Sender<DocumentEvent>,
130        doc_id: usize,
131        node_id: Option<NodeId>,
132        shell_provider: Arc<dyn ShellProvider>,
133        data: T,
134    ) -> Self {
135        static REQUEST_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
136        Self {
137            request_id: REQUEST_ID_COUNTER.fetch_add(1, Ao::Relaxed),
138            doc_id,
139            node_id,
140            tx,
141            shell_provider,
142            settled: AtomicBool::new(false),
143            data,
144        }
145    }
146
147    pub(crate) fn boxed(
148        tx: Sender<DocumentEvent>,
149        doc_id: usize,
150        node_id: Option<NodeId>,
151        shell_provider: Arc<dyn ShellProvider>,
152        data: T,
153    ) -> Box<dyn NetHandler>
154    where
155        ResourceHandler<T>: NetHandler,
156    {
157        Box::new(Self::new(tx, doc_id, node_id, shell_provider, data)) as _
158    }
159
160    pub(crate) fn request_id(&self) -> usize {
161        self.request_id
162    }
163
164    fn respond(&self, resolved_url: String, result: Result<Resource, String>) {
165        self.settled.store(true, Ao::Release);
166        let response = ResourceLoadResponse {
167            request_id: self.request_id,
168            node_id: self.node_id,
169            resolved_url: Some(resolved_url),
170            result,
171        };
172        let _ = self.tx.send(DocumentEvent::ResourceLoad(response));
173        self.shell_provider.request_redraw();
174    }
175}
176
177impl<T: Send + Sync + 'static> Drop for ResourceHandler<T> {
178    fn drop(&mut self) {
179        if self.settled.load(Ao::Acquire) {
180            return;
181        }
182        let _ = self
183            .tx
184            .send(DocumentEvent::ResourceLoad(ResourceLoadResponse {
185                request_id: self.request_id,
186                node_id: self.node_id,
187                resolved_url: None,
188                result: Err(String::from("resource fetch failed")),
189            }));
190        self.shell_provider.request_redraw();
191    }
192}
193
194#[allow(unused)]
195pub struct ResourceLoadResponse {
196    pub request_id: usize,
197    pub node_id: Option<NodeId>,
198    pub resolved_url: Option<String>,
199    pub result: Result<Resource, String>,
200}
201
202pub struct StylesheetHandler {
203    pub source_url: Url,
204    pub guard: SharedRwLock,
205    pub net_provider: Arc<dyn NetProvider>,
206    pub abort_signal: Option<AbortSignal>,
207}
208
209impl NetHandler for ResourceHandler<StylesheetHandler> {
210    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
211        let Ok(css) = std::str::from_utf8(&bytes) else {
212            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
213        };
214
215        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
216        // let escaped_css = html_escape::decode_html_entities(css);
217
218        let sheet = Stylesheet::from_str(
219            css,
220            self.data.source_url.clone().into(),
221            Origin::Author,
222            ServoArc::new(self.data.guard.wrap(MediaList::empty())),
223            self.data.guard.clone(),
224            Some(&StylesheetLoader {
225                tx: self.tx.clone(),
226                doc_id: self.doc_id,
227                net_provider: self.data.net_provider.clone(),
228                shell_provider: self.shell_provider.clone(),
229                abort_signal: self.data.abort_signal.clone(),
230            }),
231            None, // error_reporter
232            QuirksMode::NoQuirks,
233            AllowImportRules::Yes,
234        );
235
236        self.respond(
237            resolved_url,
238            Ok(Resource::Css(DocumentStyleSheet(ServoArc::new(sheet)))),
239        );
240    }
241}
242
243#[derive(Clone)]
244pub(crate) struct StylesheetLoader {
245    pub(crate) tx: Sender<DocumentEvent>,
246    pub(crate) doc_id: usize,
247    pub(crate) net_provider: Arc<dyn NetProvider>,
248    pub(crate) shell_provider: Arc<dyn ShellProvider>,
249    pub(crate) abort_signal: Option<AbortSignal>,
250}
251impl ServoStylesheetLoader for StylesheetLoader {
252    fn request_stylesheet(
253        &self,
254        url: CssUrl,
255        location: SourceLocation,
256        lock: &SharedRwLock,
257        media: ServoArc<Locked<MediaList>>,
258        supports: Option<ImportSupportsCondition>,
259        layer: ImportLayer,
260    ) -> ServoArc<Locked<ImportRule>> {
261        if !supports.as_ref().is_none_or(|s| s.enabled) {
262            return ServoArc::new(lock.wrap(ImportRule {
263                url,
264                stylesheet: ImportSheet::new_refused(),
265                supports,
266                layer,
267                source_location: location,
268            }));
269        }
270
271        let import = ImportRule {
272            url,
273            stylesheet: ImportSheet::new_pending(),
274            supports,
275            layer,
276            source_location: location,
277        };
278
279        let url = import.url.url().unwrap().clone();
280        let import = ServoArc::new(lock.wrap(import));
281        self.net_provider.fetch(
282            self.doc_id,
283            stamped_request(url.as_ref().clone(), self.abort_signal.as_ref())
284                .destination(RequestDestination::Style),
285            ResourceHandler::boxed(
286                self.tx.clone(),
287                self.doc_id,
288                None, // node_id
289                self.shell_provider.clone(),
290                NestedStylesheetHandler {
291                    url: url.clone(),
292                    loader: self.clone(),
293                    lock: lock.clone(),
294                    media,
295                    import_rule: import.clone(),
296                },
297            ),
298        );
299
300        import
301    }
302}
303
304struct NestedStylesheetHandler {
305    loader: StylesheetLoader,
306    lock: SharedRwLock,
307    url: ServoArc<Url>,
308    media: ServoArc<Locked<MediaList>>,
309    import_rule: ServoArc<Locked<ImportRule>>,
310}
311
312impl NetHandler for ResourceHandler<NestedStylesheetHandler> {
313    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
314        let Ok(css) = std::str::from_utf8(&bytes) else {
315            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
316        };
317
318        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
319        // let escaped_css = html_escape::decode_html_entities(css);
320
321        let sheet = ServoArc::new(Stylesheet::from_str(
322            css,
323            UrlExtraData(self.data.url.clone()),
324            Origin::Author,
325            self.data.media.clone(),
326            self.data.lock.clone(),
327            Some(&self.data.loader),
328            None, // error_reporter
329            QuirksMode::NoQuirks,
330            AllowImportRules::Yes,
331        ));
332
333        // `@font-face` is scanned by the document thread too, for the same
334        // reason the attach is: reading the sheet needs this lock, the document
335        // thread writes it while styling, and an `AtomicRefCell` panics rather
336        // than waiting. Doing it here left a read on a network worker racing a
337        // write on the document thread — the same defect one line further down,
338        // and it would have panicked the same way.
339        let import_rule = self.data.import_rule.clone();
340        self.respond(resolved_url, Ok(Resource::ImportSheet(import_rule, sheet)))
341    }
342}
343
344struct FontFaceHandler {
345    format: FontFaceSourceFormatKeyword,
346    overrides: FontFaceOverrides,
347    ticket: Option<Arc<web_fonts::WebFont>>,
348    queued: bool,
349}
350impl Drop for FontFaceHandler {
351    fn drop(&mut self) {
352        if !self.queued {
353            if let Some(ticket) = &self.ticket {
354                ticket.finish(false);
355            }
356        }
357    }
358}
359impl NetHandler for ResourceHandler<FontFaceHandler> {
360    fn bytes(mut self: Box<Self>, resolved_url: String, bytes: Bytes) {
361        let result = self.data.parse(bytes);
362        let result = match (result, self.data.ticket.as_ref()) {
363            (Ok(Resource::Font(bytes, overrides)), Some(ticket)) => {
364                self.data.queued = true;
365                Ok(Resource::TrackedFont(bytes, overrides, Arc::clone(ticket)))
366            }
367            (Ok(Resource::None), Some(_)) => Err(String::from("Unsupported font data")),
368            (result, _) => result,
369        };
370        self.respond(resolved_url, result)
371    }
372}
373impl FontFaceHandler {
374    fn parse(&mut self, bytes: Bytes) -> Result<Resource, String> {
375        if self.format == FontFaceSourceFormatKeyword::None && bytes.len() >= 4 {
376            self.format = match &bytes.as_ref()[0..4] {
377                // WOFF (v1) files begin with 0x774F4646 ('wOFF' in ascii)
378                // See: <https://w3c.github.io/woff/woff1/spec/Overview.html#WOFFHeader>
379                #[cfg(feature = "woff")]
380                b"wOFF" => FontFaceSourceFormatKeyword::Woff,
381                // WOFF2 files begin with 0x774F4632 ('wOF2' in ascii)
382                // See: <https://w3c.github.io/woff/woff2/#woff20Header>
383                #[cfg(feature = "woff")]
384                b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
385                // Opentype fonts with CFF data begin with 0x4F54544F ('OTTO' in ascii)
386                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
387                b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
388                // Opentype fonts truetype outlines begin with 0x00010000
389                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
390                &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
391                // Truetype fonts begin with 0x74727565 ('true' in ascii)
392                // See: <https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6.html#ScalerTypeNote>
393                b"true" => FontFaceSourceFormatKeyword::Truetype,
394                _ => FontFaceSourceFormatKeyword::None,
395            }
396        }
397
398        // Satisfy rustc's mutability linting with woff feature both enabled/disabled
399        #[cfg(feature = "woff")]
400        let mut bytes = bytes;
401
402        match self.format {
403            #[cfg(feature = "woff")]
404            FontFaceSourceFormatKeyword::Woff => {
405                #[cfg(feature = "tracing")]
406                tracing::info!("Decompressing woff1 font");
407
408                // Use wuff crate to decompress font
409                let decompressed = wuff::decompress_woff1(&bytes).ok();
410
411                if let Some(decompressed) = decompressed {
412                    bytes = Bytes::from(decompressed);
413                } else {
414                    #[cfg(feature = "tracing")]
415                    tracing::warn!("Failed to decompress woff1 font");
416                }
417            }
418            #[cfg(feature = "woff")]
419            FontFaceSourceFormatKeyword::Woff2 => {
420                #[cfg(feature = "tracing")]
421                tracing::info!("Decompressing woff2 font");
422
423                // Use wuff crate to decompress font
424                let decompressed = wuff::decompress_woff2(&bytes).ok();
425
426                if let Some(decompressed) = decompressed {
427                    bytes = Bytes::from(decompressed);
428                } else {
429                    #[cfg(feature = "tracing")]
430                    tracing::warn!("Failed to decompress woff2 font");
431                }
432            }
433            FontFaceSourceFormatKeyword::None => {
434                // Should this be an error?
435                return Ok(Resource::None);
436            }
437            _ => {}
438        }
439
440        Ok(Resource::Font(bytes, std::mem::take(&mut self.overrides)))
441    }
442}
443
444#[allow(clippy::too_many_arguments)]
445pub(crate) fn fetch_font_face(
446    tx: Sender<DocumentEvent>,
447    doc_id: usize,
448    node_id: Option<NodeId>,
449    sheet: &Stylesheet,
450    network_provider: &Arc<dyn NetProvider>,
451    shell_provider: &Arc<dyn ShellProvider>,
452    read_guard: &SharedRwLockReadGuard,
453    abort_signal: Option<&AbortSignal>,
454    fonts: &mut Vec<Arc<web_fonts::WebFont>>,
455    epoch: usize,
456) {
457    sheet
458        .contents(read_guard)
459        .rules(read_guard)
460        .iter()
461        .filter_map(|rule| match rule {
462            CssRule::FontFace(font_face) => {
463                let descriptor = &font_face.read_with(read_guard).descriptors;
464                let family = descriptor.font_family.as_ref()?;
465                let src = descriptor.src.as_ref()?;
466                // Capture the @font-face descriptors so parley can register
467                // the font under the CSS-declared family name (and weight /
468                // style) rather than whatever metadata the TTF reports.
469                let overrides = FontFaceOverrides {
470                    family_name: Some(family.name.to_string()),
471                    weight: descriptor
472                        .font_weight
473                        .as_ref()
474                        .and_then(|range| range.0.compute().map(|w| w.value())),
475                    style: descriptor.font_style.as_ref().map(stylo_to_fontique_style),
476                };
477                Some((src, overrides))
478            }
479            _ => None,
480        })
481        .for_each(|(source_list, overrides)| {
482            // Find the first font source in the source list that specifies a font of a type
483            // that we support.
484            let preferred_source = source_list
485                .0
486                .iter()
487                .filter_map(|source| match source {
488                    Source::Url(url_source) => Some(url_source),
489                    // TODO: support local fonts in @font-face
490                    Source::Local(_) => None,
491                })
492                .find_map(|url_source| {
493                    let mut format = match &url_source.format_hint {
494                        Some(FontFaceSourceFormat::Keyword(fmt)) => *fmt,
495                        Some(FontFaceSourceFormat::String(str)) => match str.as_str() {
496                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
497                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
498                            "otf" => FontFaceSourceFormatKeyword::Opentype,
499                            _ => FontFaceSourceFormatKeyword::None,
500                        },
501                        _ => FontFaceSourceFormatKeyword::None,
502                    };
503                    if format == FontFaceSourceFormatKeyword::None {
504                        let (_, end) = url_source.url.as_str().rsplit_once('.')?;
505                        format = match end {
506                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
507                            "woff" => FontFaceSourceFormatKeyword::Woff,
508                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
509                            "otf" => FontFaceSourceFormatKeyword::Opentype,
510                            "svg" => FontFaceSourceFormatKeyword::Svg,
511                            "eot" => FontFaceSourceFormatKeyword::EmbeddedOpentype,
512                            _ => FontFaceSourceFormatKeyword::None,
513                        }
514                    }
515
516                    if matches!(
517                        format,
518                        FontFaceSourceFormatKeyword::Svg
519                            | FontFaceSourceFormatKeyword::EmbeddedOpentype
520                    ) {
521                        #[cfg(feature = "tracing")]
522                        tracing::warn!("Skipping unsupported font of type {:?}", format);
523                        return None;
524                    }
525
526                    #[cfg(not(feature = "woff"))]
527                    if matches!(
528                        format,
529                        FontFaceSourceFormatKeyword::Woff | FontFaceSourceFormatKeyword::Woff2
530                    ) {
531                        #[cfg(feature = "tracing")]
532                        tracing::warn!("Skipping unsupported font of type {:?}", format);
533                        return None;
534                    }
535
536                    // A relative url with no base url to resolve against
537                    // yields None; skip the source instead of panicking
538                    let Some(url) = url_source.url.url() else {
539                        #[cfg(feature = "tracing")]
540                        tracing::warn!("Skipping @font-face source with unresolvable url");
541                        return None;
542                    };
543                    let url = url.as_ref().clone();
544                    Some((url, format))
545                });
546
547            if let Some((url, format)) = preferred_source {
548                let ticket = web_fonts::WebFont::new(
549                    overrides.clone(),
550                    Some(url.clone()),
551                    None,
552                    true,
553                    epoch,
554                    shell_provider.clone(),
555                );
556                fonts.push(Arc::clone(&ticket));
557                network_provider.fetch(
558                    doc_id,
559                    stamped_request(url, abort_signal).destination(RequestDestination::Font),
560                    ResourceHandler::boxed(
561                        tx.clone(),
562                        doc_id,
563                        node_id,
564                        shell_provider.clone(),
565                        FontFaceHandler {
566                            format,
567                            overrides,
568                            ticket: Some(ticket),
569                            queued: false,
570                        },
571                    ),
572                );
573            }
574        })
575}
576
577/// Translate stylo's `@font-face` `font-style` descriptor into the fontique
578/// `FontStyle` enum used by parley. Stylo encodes Italic and Oblique-with-
579/// angle distinctly; CSS's bare `normal` is parsed as `Oblique(0deg, 0deg)`
580/// by stylo (see the `FontStyle::parse` impl in stylo's `font_face.rs`), so
581/// that pattern is treated as `Normal` here.
582fn load_script_font(doc: &mut crate::BaseDocument, face: &Arc<web_fonts::WebFont>) {
583    if !face.start() {
584        return;
585    }
586    let mut handler = FontFaceHandler {
587        format: FontFaceSourceFormatKeyword::None,
588        overrides: face.overrides.clone(),
589        ticket: Some(Arc::clone(face)),
590        queued: false,
591    };
592    if let Some(bytes) = &face.data {
593        match handler.parse(bytes.clone()) {
594            Ok(Resource::Font(bytes, overrides)) => {
595                handler.queued = true;
596                doc.load_resource(ResourceLoadResponse {
597                    request_id: usize::MAX,
598                    node_id: None,
599                    resolved_url: None,
600                    result: Ok(Resource::TrackedFont(bytes, overrides, Arc::clone(face))),
601                });
602            }
603            _ => face.finish(false),
604        }
605    } else if let Some(url) = &face.source {
606        doc.net_provider.fetch(
607            crate::Document::id(doc),
608            stamped_request(url.clone(), doc.abort_signal.as_ref())
609                .destination(RequestDestination::Font),
610            ResourceHandler::boxed(
611                doc.tx.clone(),
612                crate::Document::id(doc),
613                None,
614                doc.shell_provider.clone(),
615                handler,
616            ),
617        );
618    } else {
619        face.finish(false);
620    }
621}
622
623fn stylo_to_fontique_style(style: &FontStyleRange) -> parley::fontique::FontStyle {
624    use parley::fontique::FontStyle as Fq;
625    match style {
626        FontStyleRange::Italic => Fq::Italic,
627        FontStyleRange::Oblique(min, max) => {
628            let angle = min.degrees();
629            // Stylo emits `Oblique(0deg, 0deg)` for the literal CSS `normal`
630            // keyword. Map that back to `Normal` so parley's font matching
631            // doesn't misclassify upright fonts.
632            if angle.is_none_or(|a| a == 0.0) && max.degrees().is_none_or(|a| a == 0.0) {
633                Fq::Normal
634            } else {
635                Fq::Oblique(angle)
636            }
637        }
638    }
639}
640
641/// Handles HTML fetched for an `<iframe>` element's `src`
642pub(crate) struct DocumentSrcHandler;
643
644impl NetHandler for ResourceHandler<DocumentSrcHandler> {
645    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
646        let html = String::from_utf8_lossy(&bytes).into_owned();
647        self.respond(resolved_url, Ok(Resource::DocumentSrc(html)));
648    }
649}
650
651pub struct ImageHandler {
652    kind: ImageType,
653}
654impl ImageHandler {
655    pub fn new(kind: ImageType) -> Self {
656        Self { kind }
657    }
658}
659
660impl NetHandler for ResourceHandler<ImageHandler> {
661    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
662        let result = self.data.parse(bytes);
663        self.respond(resolved_url, result)
664    }
665}
666
667/// A JPEG XL codestream starts with FF 0A; the container form starts with the 12-byte "JXL " box.
668#[cfg(feature = "image-formats")]
669pub fn is_jxl(bytes: &[u8]) -> bool {
670    const CONTAINER: [u8; 12] = [
671        0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A,
672    ];
673    bytes.starts_with(&[0xFF, 0x0A]) || bytes.starts_with(&CONTAINER)
674}
675
676/// The first keyframe of a JPEG XL image in sRGB, as RGBA8. Embedders decode
677/// script and canvas images through this too, so there is one JXL path.
678#[cfg(feature = "image-formats")]
679pub fn decode_jxl(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
680    use jxl_oxide::{EnumColourEncoding, JxlImage, NullCms, RenderingIntent};
681
682    // The same decode budget as the canvas path: refuse before rendering anything.
683    const ALLOCATION_LIMIT: u64 = 64 * 1024 * 1024;
684    let mut image = JxlImage::builder()
685        .read_bytes(Arc::from(bytes))
686        .map_err(|error| error.to_string())?;
687    let (width, height) = (image.width(), image.height());
688    if u64::from(width) * u64::from(height) * 4 > ALLOCATION_LIMIT {
689        return Err(format!("{width}x{height} exceeds the decode budget"));
690    }
691    image.set_cms(NullCms);
692    image.request_color_encoding(EnumColourEncoding::srgb(RenderingIntent::Relative));
693    let render = image.render_frame(0).map_err(|error| error.to_string())?;
694    let mut stream = render.stream();
695    let (width, height, channels) = (stream.width(), stream.height(), stream.channels() as usize);
696    let mut samples = vec![0f32; width as usize * height as usize * channels];
697    stream.write_to_buffer(&mut samples);
698    let to_byte = |value: f32| (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
699    let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
700    for pixel in samples.chunks_exact(channels) {
701        let (rgb, alpha) = match channels {
702            1 => ([pixel[0]; 3], 1.0),
703            2 => ([pixel[0]; 3], pixel[1]),
704            3 => ([pixel[0], pixel[1], pixel[2]], 1.0),
705            _ => ([pixel[0], pixel[1], pixel[2]], pixel[3]),
706        };
707        rgba.extend_from_slice(&[
708            to_byte(rgb[0]),
709            to_byte(rgb[1]),
710            to_byte(rgb[2]),
711            to_byte(alpha),
712        ]);
713    }
714    Ok((width, height, rgba))
715}
716
717impl ImageHandler {
718    fn parse(&self, bytes: Bytes) -> Result<Resource, String> {
719        #[cfg(feature = "image-formats")]
720        if is_jxl(&bytes) {
721            return decode_jxl(&bytes)
722                .map(|(width, height, rgba)| {
723                    Resource::Image(self.kind, width, height, Arc::new(rgba))
724                })
725                .map_err(|error| {
726                    format!(
727                        "Could not parse JPEG XL image ({} bytes): {error}",
728                        bytes.len()
729                    )
730                });
731        }
732
733        let image_err = match image::ImageReader::new(Cursor::new(&bytes))
734            .with_guessed_format()
735            .expect("IO errors impossible with Cursor")
736            .decode()
737        {
738            Ok(image) => {
739                let raw_rgba8_data = image.clone().into_rgba8().into_raw();
740                return Ok(Resource::Image(
741                    self.kind,
742                    image.width(),
743                    image.height(),
744                    Arc::new(raw_rgba8_data),
745                ));
746            }
747            Err(e) => e.to_string(),
748        };
749
750        #[cfg(feature = "svg")]
751        let svg_err = {
752            use crate::util::parse_svg_image;
753            match parse_svg_image(&bytes) {
754                Ok(svg) => return Ok(Resource::Svg(self.kind, svg)),
755                Err(e) => e.to_string(),
756            }
757        };
758        #[cfg(not(feature = "svg"))]
759        let svg_err = "svg feature disabled";
760
761        Err(format!(
762            "Could not parse image ({} bytes): image-crate error: {image_err}; svg fallback error: {svg_err}",
763            bytes.len()
764        ))
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771    use parley::fontique::FontStyle as Fq;
772    use style::values::specified::Angle;
773
774    fn oblique(min_deg: f32, max_deg: f32) -> FontStyleRange {
775        FontStyleRange::Oblique(Angle::from_degrees(min_deg), Angle::from_degrees(max_deg))
776    }
777
778    #[test]
779    fn italic_maps_to_italic() {
780        assert_eq!(stylo_to_fontique_style(&FontStyleRange::Italic), Fq::Italic,);
781    }
782
783    #[test]
784    fn oblique_zero_zero_maps_to_normal() {
785        // Stylo parses bare CSS `normal` as `Oblique(0deg, 0deg)`; the
786        // helper must round-trip that back to `FontStyle::Normal` so
787        // parley's matching doesn't misclassify upright fonts.
788        assert_eq!(stylo_to_fontique_style(&oblique(0.0, 0.0)), Fq::Normal);
789    }
790
791    #[test]
792    fn oblique_single_angle_maps_to_oblique_with_min() {
793        assert_eq!(
794            stylo_to_fontique_style(&oblique(14.0, 14.0)),
795            Fq::Oblique(Some(14.0)),
796        );
797    }
798
799    #[test]
800    fn oblique_range_uses_min_angle() {
801        // For a range, fontique's single-angle representation takes the
802        // lower bound — confirm `min` (not `max`) is what gets through.
803        assert_eq!(
804            stylo_to_fontique_style(&oblique(10.0, 20.0)),
805            Fq::Oblique(Some(10.0)),
806        );
807    }
808}