script/dom/css/fontfaceset.rs
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use cssparser::{Parser, UnicodeRange};
6use dom_struct::dom_struct;
7use fonts::FontFaceRuleInfo;
8use js::context::JSContext;
9use js::gc::Handle;
10use js::jsapi::Value;
11use js::realm::CurrentRealm;
12use js::rust::HandleObject;
13use layout_api::{QueryMsg, ReflowGoal};
14use script_bindings::cell::DomRefCell;
15use script_bindings::codegen::GenericBindings::FontFaceBinding::{
16 FontFaceLoadStatus, FontFaceMethods,
17};
18use script_bindings::like::Setlike;
19use script_bindings::reflector::reflect_dom_object_with_proto;
20use servo_arc::Arc as ServoArc;
21use style::font_face::FamilyName;
22use style::properties::shorthands::font;
23use style::stylesheets::CssRuleType;
24use style::values::computed::font::{FontFamilyList, SingleFontFamily};
25use style::values::specified::font as specified_font;
26use style_traits::ParsingMode;
27
28use crate::css::css::parser_context_for_document;
29use crate::dom::bindings::codegen::Bindings::FontFaceSetBinding::FontFaceSetMethods;
30use crate::dom::bindings::codegen::Bindings::WindowBinding::WindowMethods;
31use crate::dom::bindings::error::{Error, Fallible};
32use crate::dom::bindings::refcounted::{Trusted, TrustedPromise};
33use crate::dom::bindings::reflector::DomGlobal;
34use crate::dom::bindings::root::{Dom, DomRoot};
35use crate::dom::bindings::str::DOMString;
36use crate::dom::document::Document;
37use crate::dom::eventtarget::EventTarget;
38use crate::dom::fontface::FontFace;
39use crate::dom::globalscope::GlobalScope;
40use crate::dom::promise::{Promise, RootedPromise, TracedPromise};
41use crate::dom::promisenativehandler::Callback;
42use crate::dom::types::PromiseNativeHandler;
43use crate::dom::window::Window;
44use crate::realms::enter_auto_realm;
45
46/// <https://drafts.csswg.org/css-font-loading/#FontFaceSet-interface>
47#[dom_struct]
48pub(crate) struct FontFaceSet {
49 target: EventTarget,
50
51 /// <https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-readypromise-slot>
52 promise: DomRefCell<TracedPromise>,
53
54 set_entries: DomRefCell<Vec<Dom<FontFace>>>,
55}
56
57impl FontFaceSet {
58 fn new_inherited(promise: &RootedPromise) -> Self {
59 FontFaceSet {
60 target: EventTarget::new_inherited(),
61 promise: DomRefCell::new(promise.to_traced()),
62 set_entries: Default::default(),
63 }
64 }
65
66 pub(crate) fn new(
67 cx: &mut JSContext,
68 global: &GlobalScope,
69 proto: Option<HandleObject>,
70 ) -> DomRoot<Self> {
71 let promise = Promise::new_rooted(cx, global);
72 reflect_dom_object_with_proto(
73 cx,
74 Box::new(FontFaceSet::new_inherited(&promise)),
75 global,
76 proto,
77 )
78 }
79
80 pub(super) fn handle_font_face_status_changed(&self, cx: &mut JSContext, font_face: &FontFace) {
81 match font_face.Status() {
82 FontFaceLoadStatus::Loading => {
83 self.switch_to_loading(cx);
84 },
85 FontFaceLoadStatus::Loaded => {
86 let Some(window) = DomRoot::downcast::<Window>(self.global()) else {
87 return;
88 };
89
90 let (family_name, template) = font_face
91 .template()
92 .expect("A loaded web font should have a template");
93 window
94 .font_context()
95 .add_template_to_font_context(family_name, template);
96 window.Document().dirty_all_nodes(cx.no_gc());
97 },
98 _ => {},
99 }
100 }
101
102 /// Fulfill the font ready promise, returning true if it was not already fulfilled beforehand.
103 pub(crate) fn fulfill_ready_promise_if_needed(&self, cx: &mut JSContext) -> bool {
104 let promise = self.promise.borrow();
105 if promise.is_fulfilled() {
106 return false;
107 }
108 promise.resolve_native(cx, self);
109 true
110 }
111
112 pub(crate) fn waiting_to_fullfill_promise(&self) -> bool {
113 !self.promise.borrow().is_fulfilled()
114 }
115
116 fn contains_face(&self, target: &FontFace) -> bool {
117 self.set_entries
118 .borrow()
119 .iter()
120 .any(|face| &**face == target)
121 }
122
123 /// Removes a face from the set's set entries.
124 fn delete_face(&self, target: &FontFace) -> bool {
125 let mut set_entries = self.set_entries.borrow_mut();
126 let Some(index) = set_entries.iter().position(|face| &**face == target) else {
127 return false;
128 };
129 set_entries.remove(index);
130 true
131 }
132
133 /// <https://drafts.csswg.org/css-font-loading/#switch-the-fontfaceset-to-loading>
134 pub(crate) fn switch_to_loading(&self, cx: &mut JSContext) {
135 // Step 1. Let font face set be the given FontFaceSet.
136 // Note: This is self.
137
138 // Step 2. Set the status attribute of font face set to "loading".
139 // TODO: Implement the FontFaceSet status attribute.
140
141 // Step 3. If font face set’s [[ReadyPromise]] slot currently holds a fulfilled
142 // promise, replace it with a fresh pending promise.
143 if self.promise.borrow().is_fulfilled() {
144 let promise = Promise::new_rooted(cx, &self.global());
145 *self.promise.borrow_mut() = promise.to_traced()
146 }
147
148 // Step 4. Queue a task to fire a font load event named loading at font face set.
149 // TODO: Implement support for font loading events.
150 }
151
152 /// Runs the CSS cascade to ensure that new `@font-face` rules have
153 /// an entry in this set.
154 fn flush_author_font_set(&self, cx: &mut JSContext) {
155 // FIXME: Use a new sort of ReflowGoal that only runs the CSS cascade without
156 // building a new box tree or running any sort of layout really.
157 // We query for the box area here, but we're not interested in the result.
158 // FIXME: Figure out what to do for worker scopes.
159 if let Some(window) = DomRoot::downcast::<Window>(self.global()) {
160 let document = window.Document();
161 if document.stylesheets_changed_since_last_reflow() {
162 window.reflow(cx, ReflowGoal::LayoutQuery(QueryMsg::BoxArea));
163 }
164 }
165 }
166
167 /// Marks the entries corresponding to removed `@font-face` rules as not [css-connected].
168 ///
169 /// [css-connected]: https://drafts.csswg.org/css-font-loading/#css-connected
170 pub(crate) fn notify_font_face_rules_removed(
171 &self,
172 removed_font_face_rules: &[ServoArc<FontFaceRuleInfo>],
173 ) {
174 let entries = self.set_entries.borrow_mut();
175 for removed_font_face_rule in removed_font_face_rules {
176 let Some(matching_font_face_object) = entries
177 .iter()
178 .find(|entry| entry.is_connected_to_font_face_rule(removed_font_face_rule))
179 else {
180 if cfg!(debug_assertions) {
181 unreachable!("Removed @font-face that was not previously present");
182 }
183 log::warn!("Removed @font-face that was not previously present");
184 continue;
185 };
186
187 // https://drafts.csswg.org/css-font-loading/#font-face-css-connection:
188 // > If a @font-face rule is removed from the document, its corresponding FontFace object is no longer CSS-connected.
189 // > The connection is not restorable by any means.
190 matching_font_face_object.disconnect_from_css();
191 }
192 }
193
194 /// Uses the font matching rules to select font faces within `self` that can be used to
195 /// render the provided text.
196 ///
197 /// This is used to implement Step 6 of
198 /// <https://drafts.csswg.org/css-font-loading/#find-the-matching-font-faces>.
199 fn query_fonts(&self, target_family: &FamilyName, sample_text: &str) -> Vec<DomRoot<FontFace>> {
200 let mut matching_fonts = Vec::default();
201 for font in self.set_entries.borrow().iter() {
202 let font_face_rule = font.css_font_face_rule();
203 let Some(font_face_rule) = font_face_rule.as_ref() else {
204 // FIXME: Don't ignore font faces that are not css-connected here.
205 continue;
206 };
207
208 if font_face_rule
209 .descriptors
210 .font_family
211 .as_ref()
212 .is_none_or(|family| family != target_family)
213 {
214 continue;
215 }
216
217 if font_face_rule
218 .descriptors
219 .unicode_range
220 .as_ref()
221 .is_some_and(|ranges| !any_character_in_any_unicode_range(sample_text, ranges))
222 {
223 continue;
224 }
225
226 // FIXME: Check other fields (weight, style, ...) here too. We need to investigate what other
227 // browsers are doing, because at this point the font isn't actually loaded yet,
228 // so the full descriptor is not available.
229 matching_fonts.push(font.as_rooted());
230 }
231
232 matching_fonts
233 }
234
235 /// <https://drafts.csswg.org/css-font-loading/#find-the-matching-font-faces>
236 fn find_the_matching_font_faces(
237 &self,
238 document: &Document,
239 font: &str,
240 sample_text: &str,
241 ) -> Result<Vec<DomRoot<FontFace>>, FontQuerySyntaxError> {
242 // Step 1. (Parse "font") and Step 2. (Unpack font shorthand) are implemented
243 // in FontQueryParameters::parse.
244 let parameters = FontQueryParameters::parse(document, font)?;
245
246 // Step 2. If text was not explicitly provided, let it be a string containing a
247 // single space character (U+0020 SPACE).
248 // Note: "text" is not optional in our implementation yet.
249
250 // Step 4. Let available font faces be the available font faces within source.
251 // If the allow system fonts flag is specified, add all system fonts to available font faces.
252
253 // Step 5. Let matched font faces initially be an empty list.
254 let mut matched_faces = vec![];
255
256 // Step 6. For each family in font family list, use the font matching rules to select the font faces
257 // from available font faces that match the font style, and add them to matched font faces.
258 // The use of the unicodeRange attribute means that this may be more than just a single font face.
259 // Step 7. If matched font faces is empty, set the found faces flag to false. Otherwise, set it to true.
260 // Note We don't need this yet.
261 // Step 8. For each font face in matched font faces, if its defined unicode-range does not include the
262 // codepoint of at least one character in text, remove it from the list.
263 for family in parameters.families.list.iter() {
264 let SingleFontFamily::FamilyName(target_family) = family else {
265 continue; // Skip generic font faces
266 };
267
268 matched_faces.extend_from_slice(&self.query_fonts(target_family, sample_text));
269 }
270
271 // Step 9. Return matched font faces and the found faces flag.
272 Ok(matched_faces)
273 }
274}
275
276impl FontFaceSetMethods<crate::DomTypeHolder> for FontFaceSet {
277 /// <https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-ready>
278 fn Ready(&self, cx: &mut JSContext) -> RootedPromise {
279 if self.promise.borrow().is_fulfilled() {
280 // There may be pending style changes that cause new web fonts to start loading,
281 // re-initializing document.fonts.ready.
282 self.flush_author_font_set(cx);
283 }
284 self.promise.borrow().root(cx)
285 }
286
287 /// <https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-add>
288 fn Add(&self, cx: &mut JSContext, font_face: &FontFace) -> Fallible<DomRoot<FontFaceSet>> {
289 // Step 1. If font is already in the FontFaceSet’s set entries,
290 // skip to the last step of this algorithm immediately.
291 if self.contains_face(font_face) {
292 return Ok(DomRoot::from_ref(self));
293 }
294
295 // Step 2. If font is CSS-connected, throw an InvalidModificationError
296 // exception and exit this algorithm immediately.
297 if font_face.is_css_connected() {
298 return Err(Error::InvalidModification(Some(
299 "Cannot add CSS-connected FontFace to FontFaceSet".to_owned(),
300 )));
301 }
302
303 // Step 3. Add the font argument to the FontFaceSet’s set entries.
304 self.set_entries.borrow_mut().push(Dom::from_ref(font_face));
305 font_face.set_associated_font_face_set(self);
306
307 // Step 4. If font’s status attribute is "loading":
308 // Step 4.1 If the FontFaceSet’s [[LoadingFonts]] list is empty, switch the FontFaceSet to loading.
309 // Step 4.2 Append font to the FontFaceSet’s [[LoadingFonts]] list.
310 self.handle_font_face_status_changed(cx, font_face);
311
312 // Step 5. Return the FontFaceSet.
313 Ok(DomRoot::from_ref(self))
314 }
315
316 /// <https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-delete>
317 fn Delete(&self, to_delete: &FontFace) -> bool {
318 // Step 1. If font is CSS-connected, return false and exit this algorithm immediately.
319 if to_delete.is_css_connected() {
320 return false;
321 }
322
323 // Step 2. Let deleted be the result of removing font from the FontFaceSet’s set entries.
324 // TODO: Step 3. If font is present in the FontFaceSet’s [[LoadedFonts]], or [[FailedFonts]] lists, remove it.
325 // TODO: Step 4. If font is present in the FontFaceSet’s [[LoadingFonts]] list, remove it. If font was the last
326 // item in that list (and so the list is now empty), switch the FontFaceSet to loaded.
327 // Step 5. Return deleted.
328 self.delete_face(to_delete)
329 }
330
331 /// <https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-clear>
332 fn Clear(&self, cx: &mut JSContext) {
333 self.flush_author_font_set(cx);
334
335 // Step 1. Remove all non-CSS-connected items from the FontFaceSet’s set entries,
336 // its [[LoadedFonts]] list, and its [[FailedFonts]] list.
337 self.set_entries.borrow_mut().clear();
338
339 // TODO Step 2. If the FontFaceSet’s [[LoadingFonts]] list is non-empty, remove all items from it,
340 // then switch the FontFaceSet to loaded.
341 }
342
343 /// <https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-load>
344 fn Load(&self, cx: &mut JSContext, font: DOMString, text: DOMString) -> RootedPromise {
345 // Step 1. Let font face set be the FontFaceSet object this method was called on. Let
346 // promise be a newly-created promise object.
347 let load_promise = Promise::new_rooted(cx, &self.global());
348
349 // Step 2. Return promise. Complete the rest of these steps asynchronously.
350 #[derive(MallocSizeOf, JSTraceable)]
351 #[cfg_attr(crown, crown::unrooted_must_root_lint::must_root)]
352 struct LoadPromiseFulfillmentHandler {
353 /// The font faces that this should wait on.
354 ///
355 /// (Our current implementation waits for `document.fonts.ready` instead)
356 font_face_objects: Vec<Dom<FontFace>>,
357
358 load_promise: TracedPromise,
359 }
360 impl Callback for LoadPromiseFulfillmentHandler {
361 fn callback(&self, cx: &mut CurrentRealm, _: Handle<Value>) {
362 let font_face_objects: Vec<DomRoot<FontFace>> = self
363 .font_face_objects
364 .iter()
365 .map(|font_face| font_face.as_rooted())
366 .collect();
367 self.load_promise.resolve_native(cx, &font_face_objects);
368 }
369 }
370
371 // Step 4. Queue a task to run the following steps synchronously:
372 let trusted_this = Trusted::new(self);
373 let trusted_load_promise = TrustedPromise::from(&load_promise);
374 let font = font.to_string();
375 let text = text.to_string();
376 self.global()
377 .task_manager()
378 .font_loading_task_source()
379 .queue(task!(resolve_font_face_set_load_task: move |cx| {
380 let load_promise = trusted_load_promise.root(cx);
381 let this = trusted_this.root();
382
383 // This will need adjustments once FontFaceSet is exposed to workers.
384 let Some(window) = DomRoot::downcast::<Window>(this.global()) else {
385 log::error!("FontFaceSet should not be exposed to non-window globals");
386 return;
387 };
388 let document = window.Document();
389
390 // Step 3. Find the matching font faces from font face set using the font and text
391 // arguments passed to the function, and let font face list be the return value (ignoring
392 // the found faces flag). If a syntax error was returned, reject promise with a SyntaxError
393 // exception and terminate these steps.
394 let Ok(font_face_objects) = this.find_the_matching_font_faces(&document, &font, &text) else {
395 load_promise.reject_error(cx, Error::Syntax(Some("Failed to parse font query".into())));
396 return;
397 };
398
399 // Step 4.1. For all of the font faces in the font face list, call their load()
400 // method.
401 // Step 4.2. Resolve promise with the result of waiting for all of the
402 // [[FontStatusPromise]]s of each font face in the font face list, in order.
403 //
404 // TODO: These steps are not implemented. Instead we wait until all fonts
405 // are loaded by resolving the returned promise when
406 // `document.fonts.ready` is resolved. The return list of fonts will not
407 // be correct, but any code that waits on the promise will have
408 // conservatively consistent behavior. This is important for preventing
409 // intermittent results in WPT tests.
410 let global = this.global();
411 let handler = PromiseNativeHandler::new(
412 cx,
413 &global,
414 Some(Box::new(LoadPromiseFulfillmentHandler {
415 font_face_objects: font_face_objects.into_iter().map(|font_face| font_face.as_traced()).collect(),
416 load_promise: load_promise.to_traced(),
417 })),
418 None,
419 );
420
421 let ready_promise = this.Ready(cx);
422 let mut realm = enter_auto_realm(cx, &*global);
423 ready_promise.append_native_handler(&mut realm.current_realm(), &handler);
424 }));
425
426 // Step 2. Return promise. Complete the rest of these steps asynchronously.
427 load_promise
428 }
429
430 /// <https://html.spec.whatwg.org/multipage/#customstateset>
431 fn Size(&self, cx: &mut JSContext) -> u32 {
432 self.size(cx)
433 }
434}
435
436impl Setlike for FontFaceSet {
437 type Key = DomRoot<FontFace>;
438
439 #[inline(always)]
440 fn get_index(&self, cx: &mut JSContext, index: u32) -> Option<Self::Key> {
441 self.flush_author_font_set(cx);
442 self.set_entries
443 .borrow()
444 .get(index as usize)
445 .map(|face| face.as_rooted())
446 }
447
448 #[inline(always)]
449 fn size(&self, cx: &mut JSContext) -> u32 {
450 self.flush_author_font_set(cx);
451 self.set_entries.borrow().len() as u32
452 }
453
454 #[inline(always)]
455 fn add(&self, _cx: &mut JSContext, face: Self::Key) {
456 self.set_entries.borrow_mut().push(face.as_traced());
457 }
458
459 #[inline(always)]
460 fn has(&self, cx: &mut JSContext, target: Self::Key) -> bool {
461 self.flush_author_font_set(cx);
462 self.contains_face(&target)
463 }
464
465 #[inline(always)]
466 fn clear(&self, cx: &mut JSContext) {
467 self.flush_author_font_set(cx);
468 self.set_entries.borrow_mut().clear();
469 }
470
471 #[inline(always)]
472 fn delete(&self, cx: &mut JSContext, to_delete: Self::Key) -> bool {
473 self.flush_author_font_set(cx);
474 self.delete_face(&to_delete)
475 }
476}
477
478/// Represents a parsed query for [`FontFaceSet::load`] and [`FontFaceSet::check`].
479///
480/// [`FontFaceSet::load`]: https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-load
481/// [`FontFaceSet::check`]: https://drafts.csswg.org/css-font-loading/#dom-fontfaceset-check
482struct FontQueryParameters {
483 families: FontFamilyList,
484 // TODO: Store a font descriptor here once we actually use that for matching.
485}
486
487/// Returned from <https://drafts.csswg.org/css-font-loading/#find-the-matching-font-faces> to indicate failure.
488struct FontQuerySyntaxError;
489
490impl FontQueryParameters {
491 /// Implements Steps 1 and 3 of <https://drafts.csswg.org/css-font-loading/#find-the-matching-font-faces>.
492 fn parse(document: &Document, font: &str) -> Result<Self, FontQuerySyntaxError> {
493 // Step 1. Parse font using the CSS value syntax of the font property.
494 // If a syntax error occurs, return a syntax error.
495 // If the parsed value is a CSS-wide keyword, return a syntax error.
496 // Absolutize all relative lengths against the initial values of the corresponding properties.
497 // (For example, a relative font weight like bolder is evaluated against the initial value normal.)
498 // Step 3. Let font family list be the list of font families parsed from font,
499 // and font style be the other font style attributes parsed from font.
500 let font_family;
501
502 let urlextradata = document.url().into_url().into();
503 let parser_context = parser_context_for_document(
504 document,
505 CssRuleType::FontFace,
506 ParsingMode::DEFAULT,
507 &urlextradata,
508 );
509
510 let mut parser = Parser::new(font);
511 let Ok(font_shorthand) =
512 parser.parse_entirely(|parser| font::parse_value(&parser_context, parser))
513 else {
514 return Err(FontQuerySyntaxError);
515 };
516
517 match font_shorthand.font_family {
518 specified_font::FontFamily::Values(family_list) => font_family = family_list,
519 specified_font::FontFamily::System(_) => return Err(FontQuerySyntaxError),
520 }
521
522 Ok(Self {
523 families: font_family,
524 })
525 }
526}
527
528fn any_character_in_any_unicode_range(text: &str, unicode_ranges: &[UnicodeRange]) -> bool {
529 for character in text.chars() {
530 for unicode_range in unicode_ranges {
531 if (unicode_range.start..=unicode_range.end).contains(&(character as u32)) {
532 return true;
533 }
534 }
535 }
536 false
537}