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
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 ResourceHandler::boxed(
285 self.tx.clone(),
286 self.doc_id,
287 None, 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 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, QuirksMode::NoQuirks,
329 AllowImportRules::Yes,
330 ));
331
332 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 #[cfg(feature = "woff")]
379 b"wOFF" => FontFaceSourceFormatKeyword::Woff,
380 #[cfg(feature = "woff")]
383 b"wOF2" => FontFaceSourceFormatKeyword::Woff2,
384 b"OTTO" => FontFaceSourceFormatKeyword::Opentype,
387 &[0x00, 0x01, 0x00, 0x00] => FontFaceSourceFormatKeyword::Truetype,
390 b"true" => FontFaceSourceFormatKeyword::Truetype,
393 _ => FontFaceSourceFormatKeyword::None,
394 }
395 }
396
397 #[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 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 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 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 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 let preferred_source = source_list
484 .0
485 .iter()
486 .filter_map(|source| match source {
487 Source::Url(url_source) => Some(url_source),
488 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 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
576fn 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 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
639pub(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#[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#[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 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 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 assert_eq!(
802 stylo_to_fontique_style(&oblique(10.0, 20.0)),
803 Fq::Oblique(Some(10.0)),
804 );
805 }
806}