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};
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            ResourceHandler::boxed(
285                self.tx.clone(),
286                self.doc_id,
287                None, // node_id
288                self.shell_provider.clone(),
289                NestedStylesheetHandler {
290                    url: url.clone(),
291                    loader: self.clone(),
292                    lock: lock.clone(),
293                    media,
294                    import_rule: import.clone(),
295                },
296            ),
297        );
298
299        import
300    }
301}
302
303struct NestedStylesheetHandler {
304    loader: StylesheetLoader,
305    lock: SharedRwLock,
306    url: ServoArc<Url>,
307    media: ServoArc<Locked<MediaList>>,
308    import_rule: ServoArc<Locked<ImportRule>>,
309}
310
311impl NetHandler for ResourceHandler<NestedStylesheetHandler> {
312    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
313        let Ok(css) = std::str::from_utf8(&bytes) else {
314            return self.respond(resolved_url, Err(String::from("Invalid UTF8")));
315        };
316
317        // NOTE(Nico): I don't *think* external stylesheets should have HTML entities escaped
318        // let escaped_css = html_escape::decode_html_entities(css);
319
320        let sheet = ServoArc::new(Stylesheet::from_str(
321            css,
322            UrlExtraData(self.data.url.clone()),
323            Origin::Author,
324            self.data.media.clone(),
325            self.data.lock.clone(),
326            Some(&self.data.loader),
327            None, // error_reporter
328            QuirksMode::NoQuirks,
329            AllowImportRules::Yes,
330        ));
331
332        // `@font-face` is scanned by the document thread too, for the same
333        // reason the attach is: reading the sheet needs this lock, the document
334        // thread writes it while styling, and an `AtomicRefCell` panics rather
335        // than waiting. Doing it here left a read on a network worker racing a
336        // write on the document thread — the same defect one line further down,
337        // and it would have panicked the same way.
338        let import_rule = self.data.import_rule.clone();
339        self.respond(resolved_url, Ok(Resource::ImportSheet(import_rule, sheet)))
340    }
341}
342
343struct FontFaceHandler {
344    format: FontFaceSourceFormatKeyword,
345    overrides: FontFaceOverrides,
346    ticket: Option<Arc<web_fonts::WebFont>>,
347    queued: bool,
348}
349impl Drop for FontFaceHandler {
350    fn drop(&mut self) {
351        if !self.queued {
352            if let Some(ticket) = &self.ticket {
353                ticket.finish(false);
354            }
355        }
356    }
357}
358impl NetHandler for ResourceHandler<FontFaceHandler> {
359    fn bytes(mut self: Box<Self>, resolved_url: String, bytes: Bytes) {
360        let result = self.data.parse(bytes);
361        let result = match (result, self.data.ticket.as_ref()) {
362            (Ok(Resource::Font(bytes, overrides)), Some(ticket)) => {
363                self.data.queued = true;
364                Ok(Resource::TrackedFont(bytes, overrides, Arc::clone(ticket)))
365            }
366            (Ok(Resource::None), Some(_)) => Err(String::from("Unsupported font data")),
367            (result, _) => result,
368        };
369        self.respond(resolved_url, result)
370    }
371}
372impl FontFaceHandler {
373    fn parse(&mut self, bytes: Bytes) -> Result<Resource, String> {
374        if self.format == FontFaceSourceFormatKeyword::None && bytes.len() >= 4 {
375            self.format = match &bytes.as_ref()[0..4] {
376                // WOFF (v1) files begin with 0x774F4646 ('wOFF' in ascii)
377                // See: <https://w3c.github.io/woff/woff1/spec/Overview.html#WOFFHeader>
378                #[cfg(feature = "woff")]
379                b"wOFF" => FontFaceSourceFormatKeyword::Woff,
380                // WOFF2 files begin with 0x774F4632 ('wOF2' in ascii)
381                // See: <https://w3c.github.io/woff/woff2/#woff20Header>
382                #[cfg(feature = "woff")]
383                b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
384                // Opentype fonts with CFF data begin with 0x4F54544F ('OTTO' in ascii)
385                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
386                b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
387                // Opentype fonts truetype outlines begin with 0x00010000
388                // See: <https://learn.microsoft.com/en-us/typography/opentype/spec/otff#organization-of-an-opentype-font>
389                &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
390                // Truetype fonts begin with 0x74727565 ('true' in ascii)
391                // See: <https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6.html#ScalerTypeNote>
392                b"true" => FontFaceSourceFormatKeyword::Truetype,
393                _ => FontFaceSourceFormatKeyword::None,
394            }
395        }
396
397        // Satisfy rustc's mutability linting with woff feature both enabled/disabled
398        #[cfg(feature = "woff")]
399        let mut bytes = bytes;
400
401        match self.format {
402            #[cfg(feature = "woff")]
403            FontFaceSourceFormatKeyword::Woff => {
404                #[cfg(feature = "tracing")]
405                tracing::info!("Decompressing woff1 font");
406
407                // Use wuff crate to decompress font
408                let decompressed = wuff::decompress_woff1(&bytes).ok();
409
410                if let Some(decompressed) = decompressed {
411                    bytes = Bytes::from(decompressed);
412                } else {
413                    #[cfg(feature = "tracing")]
414                    tracing::warn!("Failed to decompress woff1 font");
415                }
416            }
417            #[cfg(feature = "woff")]
418            FontFaceSourceFormatKeyword::Woff2 => {
419                #[cfg(feature = "tracing")]
420                tracing::info!("Decompressing woff2 font");
421
422                // Use wuff crate to decompress font
423                let decompressed = wuff::decompress_woff2(&bytes).ok();
424
425                if let Some(decompressed) = decompressed {
426                    bytes = Bytes::from(decompressed);
427                } else {
428                    #[cfg(feature = "tracing")]
429                    tracing::warn!("Failed to decompress woff2 font");
430                }
431            }
432            FontFaceSourceFormatKeyword::None => {
433                // Should this be an error?
434                return Ok(Resource::None);
435            }
436            _ => {}
437        }
438
439        Ok(Resource::Font(bytes, std::mem::take(&mut self.overrides)))
440    }
441}
442
443#[allow(clippy::too_many_arguments)]
444pub(crate) fn fetch_font_face(
445    tx: Sender<DocumentEvent>,
446    doc_id: usize,
447    node_id: Option<NodeId>,
448    sheet: &Stylesheet,
449    network_provider: &Arc<dyn NetProvider>,
450    shell_provider: &Arc<dyn ShellProvider>,
451    read_guard: &SharedRwLockReadGuard,
452    abort_signal: Option<&AbortSignal>,
453    fonts: &mut Vec<Arc<web_fonts::WebFont>>,
454    epoch: usize,
455) {
456    sheet
457        .contents(read_guard)
458        .rules(read_guard)
459        .iter()
460        .filter_map(|rule| match rule {
461            CssRule::FontFace(font_face) => {
462                let descriptor = &font_face.read_with(read_guard).descriptors;
463                let family = descriptor.font_family.as_ref()?;
464                let src = descriptor.src.as_ref()?;
465                // Capture the @font-face descriptors so parley can register
466                // the font under the CSS-declared family name (and weight /
467                // style) rather than whatever metadata the TTF reports.
468                let overrides = FontFaceOverrides {
469                    family_name: Some(family.name.to_string()),
470                    weight: descriptor
471                        .font_weight
472                        .as_ref()
473                        .and_then(|range| range.0.compute().map(|w| w.value())),
474                    style: descriptor.font_style.as_ref().map(stylo_to_fontique_style),
475                };
476                Some((src, overrides))
477            }
478            _ => None,
479        })
480        .for_each(|(source_list, overrides)| {
481            // Find the first font source in the source list that specifies a font of a type
482            // that we support.
483            let preferred_source = source_list
484                .0
485                .iter()
486                .filter_map(|source| match source {
487                    Source::Url(url_source) => Some(url_source),
488                    // TODO: support local fonts in @font-face
489                    Source::Local(_) => None,
490                })
491                .find_map(|url_source| {
492                    let mut format = match &url_source.format_hint {
493                        Some(FontFaceSourceFormat::Keyword(fmt)) => *fmt,
494                        Some(FontFaceSourceFormat::String(str)) => match str.as_str() {
495                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
496                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
497                            "otf" => FontFaceSourceFormatKeyword::Opentype,
498                            _ => FontFaceSourceFormatKeyword::None,
499                        },
500                        _ => FontFaceSourceFormatKeyword::None,
501                    };
502                    if format == FontFaceSourceFormatKeyword::None {
503                        let (_, end) = url_source.url.as_str().rsplit_once('.')?;
504                        format = match end {
505                            "woff2" => FontFaceSourceFormatKeyword::Woff2,
506                            "woff" => FontFaceSourceFormatKeyword::Woff,
507                            "ttf" => FontFaceSourceFormatKeyword::Truetype,
508                            "otf" => FontFaceSourceFormatKeyword::Opentype,
509                            "svg" => FontFaceSourceFormatKeyword::Svg,
510                            "eot" => FontFaceSourceFormatKeyword::EmbeddedOpentype,
511                            _ => FontFaceSourceFormatKeyword::None,
512                        }
513                    }
514
515                    if matches!(
516                        format,
517                        FontFaceSourceFormatKeyword::Svg
518                            | FontFaceSourceFormatKeyword::EmbeddedOpentype
519                    ) {
520                        #[cfg(feature = "tracing")]
521                        tracing::warn!("Skipping unsupported font of type {:?}", format);
522                        return None;
523                    }
524
525                    #[cfg(not(feature = "woff"))]
526                    if matches!(
527                        format,
528                        FontFaceSourceFormatKeyword::Woff | FontFaceSourceFormatKeyword::Woff2
529                    ) {
530                        #[cfg(feature = "tracing")]
531                        tracing::warn!("Skipping unsupported font of type {:?}", format);
532                        return None;
533                    }
534
535                    // A relative url with no base url to resolve against
536                    // yields None; skip the source instead of panicking
537                    let Some(url) = url_source.url.url() else {
538                        #[cfg(feature = "tracing")]
539                        tracing::warn!("Skipping @font-face source with unresolvable url");
540                        return None;
541                    };
542                    let url = url.as_ref().clone();
543                    Some((url, format))
544                });
545
546            if let Some((url, format)) = preferred_source {
547                let ticket = web_fonts::WebFont::new(
548                    overrides.clone(),
549                    Some(url.clone()),
550                    None,
551                    true,
552                    epoch,
553                    shell_provider.clone(),
554                );
555                fonts.push(Arc::clone(&ticket));
556                network_provider.fetch(
557                    doc_id,
558                    stamped_request(url, abort_signal),
559                    ResourceHandler::boxed(
560                        tx.clone(),
561                        doc_id,
562                        node_id,
563                        shell_provider.clone(),
564                        FontFaceHandler {
565                            format,
566                            overrides,
567                            ticket: Some(ticket),
568                            queued: false,
569                        },
570                    ),
571                );
572            }
573        })
574}
575
576/// Translate stylo's `@font-face` `font-style` descriptor into the fontique
577/// `FontStyle` enum used by parley. Stylo encodes Italic and Oblique-with-
578/// angle distinctly; CSS's bare `normal` is parsed as `Oblique(0deg, 0deg)`
579/// by stylo (see the `FontStyle::parse` impl in stylo's `font_face.rs`), so
580/// that pattern is treated as `Normal` here.
581fn load_script_font(doc: &mut crate::BaseDocument, face: &Arc<web_fonts::WebFont>) {
582    if !face.start() {
583        return;
584    }
585    let mut handler = FontFaceHandler {
586        format: FontFaceSourceFormatKeyword::None,
587        overrides: face.overrides.clone(),
588        ticket: Some(Arc::clone(face)),
589        queued: false,
590    };
591    if let Some(bytes) = &face.data {
592        match handler.parse(bytes.clone()) {
593            Ok(Resource::Font(bytes, overrides)) => {
594                handler.queued = true;
595                doc.load_resource(ResourceLoadResponse {
596                    request_id: usize::MAX,
597                    node_id: None,
598                    resolved_url: None,
599                    result: Ok(Resource::TrackedFont(bytes, overrides, Arc::clone(face))),
600                });
601            }
602            _ => face.finish(false),
603        }
604    } else if let Some(url) = &face.source {
605        doc.net_provider.fetch(
606            crate::Document::id(doc),
607            stamped_request(url.clone(), doc.abort_signal.as_ref()),
608            ResourceHandler::boxed(
609                doc.tx.clone(),
610                crate::Document::id(doc),
611                None,
612                doc.shell_provider.clone(),
613                handler,
614            ),
615        );
616    } else {
617        face.finish(false);
618    }
619}
620
621fn stylo_to_fontique_style(style: &FontStyleRange) -> parley::fontique::FontStyle {
622    use parley::fontique::FontStyle as Fq;
623    match style {
624        FontStyleRange::Italic => Fq::Italic,
625        FontStyleRange::Oblique(min, max) => {
626            let angle = min.degrees();
627            // Stylo emits `Oblique(0deg, 0deg)` for the literal CSS `normal`
628            // keyword. Map that back to `Normal` so parley's font matching
629            // doesn't misclassify upright fonts.
630            if angle.is_none_or(|a| a == 0.0) && max.degrees().is_none_or(|a| a == 0.0) {
631                Fq::Normal
632            } else {
633                Fq::Oblique(angle)
634            }
635        }
636    }
637}
638
639/// Handles HTML fetched for an `<iframe>` element's `src`
640pub(crate) struct DocumentSrcHandler;
641
642impl NetHandler for ResourceHandler<DocumentSrcHandler> {
643    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
644        let html = String::from_utf8_lossy(&bytes).into_owned();
645        self.respond(resolved_url, Ok(Resource::DocumentSrc(html)));
646    }
647}
648
649pub struct ImageHandler {
650    kind: ImageType,
651}
652impl ImageHandler {
653    pub fn new(kind: ImageType) -> Self {
654        Self { kind }
655    }
656}
657
658impl NetHandler for ResourceHandler<ImageHandler> {
659    fn bytes(self: Box<Self>, resolved_url: String, bytes: Bytes) {
660        let result = self.data.parse(bytes);
661        self.respond(resolved_url, result)
662    }
663}
664
665/// A JPEG XL codestream starts with FF 0A; the container form starts with the 12-byte "JXL " box.
666#[cfg(feature = "image-formats")]
667pub fn is_jxl(bytes: &[u8]) -> bool {
668    const CONTAINER: [u8; 12] = [
669        0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A,
670    ];
671    bytes.starts_with(&[0xFF, 0x0A]) || bytes.starts_with(&CONTAINER)
672}
673
674/// The first keyframe of a JPEG XL image in sRGB, as RGBA8. Embedders decode
675/// script and canvas images through this too, so there is one JXL path.
676#[cfg(feature = "image-formats")]
677pub fn decode_jxl(bytes: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
678    use jxl_oxide::{EnumColourEncoding, JxlImage, NullCms, RenderingIntent};
679
680    // The same decode budget as the canvas path: refuse before rendering anything.
681    const ALLOCATION_LIMIT: u64 = 64 * 1024 * 1024;
682    let mut image = JxlImage::builder()
683        .read_bytes(Arc::from(&bytes[..]))
684        .map_err(|error| error.to_string())?;
685    let (width, height) = (image.width(), image.height());
686    if u64::from(width) * u64::from(height) * 4 > ALLOCATION_LIMIT {
687        return Err(format!("{width}x{height} exceeds the decode budget"));
688    }
689    image.set_cms(NullCms);
690    image.request_color_encoding(EnumColourEncoding::srgb(RenderingIntent::Relative));
691    let render = image.render_frame(0).map_err(|error| error.to_string())?;
692    let mut stream = render.stream();
693    let (width, height, channels) = (stream.width(), stream.height(), stream.channels() as usize);
694    let mut samples = vec![0f32; width as usize * height as usize * channels];
695    stream.write_to_buffer(&mut samples);
696    let to_byte = |value: f32| (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
697    let mut rgba = Vec::with_capacity(width as usize * height as usize * 4);
698    for pixel in samples.chunks_exact(channels) {
699        let (rgb, alpha) = match channels {
700            1 => ([pixel[0]; 3], 1.0),
701            2 => ([pixel[0]; 3], pixel[1]),
702            3 => ([pixel[0], pixel[1], pixel[2]], 1.0),
703            _ => ([pixel[0], pixel[1], pixel[2]], pixel[3]),
704        };
705        rgba.extend_from_slice(&[
706            to_byte(rgb[0]),
707            to_byte(rgb[1]),
708            to_byte(rgb[2]),
709            to_byte(alpha),
710        ]);
711    }
712    Ok((width, height, rgba))
713}
714
715impl ImageHandler {
716    fn parse(&self, bytes: Bytes) -> Result<Resource, String> {
717        #[cfg(feature = "image-formats")]
718        if is_jxl(&bytes) {
719            return decode_jxl(&bytes)
720                .map(|(width, height, rgba)| {
721                    Resource::Image(self.kind, width, height, Arc::new(rgba))
722                })
723                .map_err(|error| {
724                    format!(
725                        "Could not parse JPEG XL image ({} bytes): {error}",
726                        bytes.len()
727                    )
728                });
729        }
730
731        let image_err = match image::ImageReader::new(Cursor::new(&bytes))
732            .with_guessed_format()
733            .expect("IO errors impossible with Cursor")
734            .decode()
735        {
736            Ok(image) => {
737                let raw_rgba8_data = image.clone().into_rgba8().into_raw();
738                return Ok(Resource::Image(
739                    self.kind,
740                    image.width(),
741                    image.height(),
742                    Arc::new(raw_rgba8_data),
743                ));
744            }
745            Err(e) => e.to_string(),
746        };
747
748        #[cfg(feature = "svg")]
749        let svg_err = {
750            use crate::util::parse_svg_image;
751            match parse_svg_image(&bytes) {
752                Ok(svg) => return Ok(Resource::Svg(self.kind, svg)),
753                Err(e) => e.to_string(),
754            }
755        };
756        #[cfg(not(feature = "svg"))]
757        let svg_err = "svg feature disabled";
758
759        Err(format!(
760            "Could not parse image ({} bytes): image-crate error: {image_err}; svg fallback error: {svg_err}",
761            bytes.len()
762        ))
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use parley::fontique::FontStyle as Fq;
770    use style::values::specified::Angle;
771
772    fn oblique(min_deg: f32, max_deg: f32) -> FontStyleRange {
773        FontStyleRange::Oblique(Angle::from_degrees(min_deg), Angle::from_degrees(max_deg))
774    }
775
776    #[test]
777    fn italic_maps_to_italic() {
778        assert_eq!(stylo_to_fontique_style(&FontStyleRange::Italic), Fq::Italic,);
779    }
780
781    #[test]
782    fn oblique_zero_zero_maps_to_normal() {
783        // Stylo parses bare CSS `normal` as `Oblique(0deg, 0deg)`; the
784        // helper must round-trip that back to `FontStyle::Normal` so
785        // parley's matching doesn't misclassify upright fonts.
786        assert_eq!(stylo_to_fontique_style(&oblique(0.0, 0.0)), Fq::Normal);
787    }
788
789    #[test]
790    fn oblique_single_angle_maps_to_oblique_with_min() {
791        assert_eq!(
792            stylo_to_fontique_style(&oblique(14.0, 14.0)),
793            Fq::Oblique(Some(14.0)),
794        );
795    }
796
797    #[test]
798    fn oblique_range_uses_min_angle() {
799        // For a range, fontique's single-angle representation takes the
800        // lower bound — confirm `min` (not `max`) is what gets through.
801        assert_eq!(
802            stylo_to_fontique_style(&oblique(10.0, 20.0)),
803            Fq::Oblique(Some(10.0)),
804        );
805    }
806}