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
44pub(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#[derive(Clone, Debug, Default)]
81pub struct FontFaceOverrides {
82 pub family_name: Option<String>,
84 pub weight: Option<f32>,
88 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 DocumentSrc(String),
102 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 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 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, 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, 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 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, QuirksMode::NoQuirks,
330 AllowImportRules::Yes,
331 ));
332
333 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 #[cfg(feature = "woff")]
380 b"wOFF" => FontFaceSourceFormatKeyword::Woff,
381 #[cfg(feature = "woff")]
384 b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
385 b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
388 &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
391 b"true" => FontFaceSourceFormatKeyword::Truetype,
394 _ => FontFaceSourceFormatKeyword::None,
395 }
396 }
397
398 #[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 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 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 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 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 let preferred_source = source_list
485 .0
486 .iter()
487 .filter_map(|source| match source {
488 Source::Url(url_source) => Some(url_source),
489 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 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
577fn 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 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
641pub(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#[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#[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 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 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 assert_eq!(
804 stylo_to_fontique_style(&oblique(10.0, 20.0)),
805 Fq::Oblique(Some(10.0)),
806 );
807 }
808}