mirror of
https://github.com/lexmount/moli.git
synced 2026-09-26 16:01:30 +00:00
fix(fonts): reject unrelated system font substitutions
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
name = "moli-layout"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "(MIT OR Apache-2.0) AND BSD-3-Clause"
|
||||
|
||||
[dependencies]
|
||||
euclid = "=0.22.14"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
The metric-compatible font family table in src/system_fonts/matching.rs is
|
||||
adapted from Skia's src/ports/SkFontConfigInterface_direct.cpp.
|
||||
Copyright 2009-2015 Google Inc.
|
||||
|
||||
Skia license:
|
||||
|
||||
Copyright (c) 2011 Google Inc. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
* Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+181
-87
@@ -6,6 +6,9 @@ use parley::{FontFamily, FontFamilyName, TextStyle, fontique::Collection};
|
||||
|
||||
use crate::stylo_to_parley::TextBrush;
|
||||
|
||||
#[cfg(any(test, target_os = "linux", target_os = "freebsd"))]
|
||||
mod matching;
|
||||
|
||||
pub(crate) struct SystemFontFamilyResolver {
|
||||
system_families: HashMap<String, String>,
|
||||
substitutions: HashMap<String, Option<String>>,
|
||||
@@ -77,13 +80,7 @@ impl SystemFontFamilyResolver {
|
||||
if let Some(cached) = self.substitutions.get(&key) {
|
||||
return cached.clone();
|
||||
}
|
||||
let substitution = platform::explicit_substitution_families(family)
|
||||
.into_iter()
|
||||
.find_map(|candidate| {
|
||||
self.system_families
|
||||
.get(&normalized_family_name(&candidate))
|
||||
.cloned()
|
||||
});
|
||||
let substitution = platform::match_family(family, &self.system_families);
|
||||
self.substitutions.insert(key, substitution.clone());
|
||||
substitution
|
||||
}
|
||||
@@ -93,40 +90,25 @@ fn normalized_family_name(name: &str) -> String {
|
||||
name.chars().flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "linux", target_os = "freebsd"))]
|
||||
fn explicit_substitution_prefix<'a>(
|
||||
requested: &'a [String],
|
||||
default_families: &[String],
|
||||
) -> &'a [String] {
|
||||
let common_suffix_len = requested
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(default_families.iter().rev())
|
||||
.take_while(|(left, right)| left.eq_ignore_ascii_case(right))
|
||||
.count();
|
||||
&requested[..requested.len().saturating_sub(common_suffix_len)]
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
mod platform {
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{CStr, CString},
|
||||
ptr::NonNull,
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use fontconfig_sys::{
|
||||
FcConfigSubstitute, FcDefaultSubstitute, FcInit, FcMatchPattern, FcPattern,
|
||||
FcPatternAddString, FcPatternCreate, FcPatternDestroy, FcPatternGetString, FcResultMatch,
|
||||
constants::FC_FAMILY,
|
||||
FcConfig, FcConfigSubstitute, FcDefaultSubstitute, FcFontSet, FcFontSetDestroy, FcFontSort,
|
||||
FcInit, FcMatchPattern, FcPattern, FcPatternAddBool, FcPatternAddString, FcPatternCreate,
|
||||
FcPatternDestroy, FcPatternGetString, FcResultMatch,
|
||||
constants::{FC_FAMILY, FC_SCALABLE},
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::explicit_substitution_prefix;
|
||||
use super::{matching::accepts_substitution, normalized_family_name};
|
||||
|
||||
const MISSING_FAMILY_SENTINEL: &str = "__moli_missing_font_family_7f41c8d2__";
|
||||
static FONTCONFIG_LOCK: Mutex<()> = Mutex::new(());
|
||||
static DEFAULT_FAMILIES: OnceLock<Option<Vec<String>>> = OnceLock::new();
|
||||
|
||||
struct Pattern(NonNull<FcPattern>);
|
||||
|
||||
@@ -136,26 +118,23 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn explicit_substitution_families(family: &str) -> Vec<String> {
|
||||
let Some(default_families) = DEFAULT_FAMILIES
|
||||
.get_or_init(|| substituted_families(MISSING_FAMILY_SENTINEL))
|
||||
.as_deref()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(requested) = substituted_families(family) else {
|
||||
return Vec::new();
|
||||
};
|
||||
explicit_substitution_prefix(&requested, default_families)
|
||||
.iter()
|
||||
.filter(|candidate| !candidate.eq_ignore_ascii_case(family))
|
||||
.cloned()
|
||||
.collect()
|
||||
struct FontSet(NonNull<FcFontSet>);
|
||||
|
||||
impl Drop for FontSet {
|
||||
fn drop(&mut self) {
|
||||
unsafe { FcFontSetDestroy(self.0.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
fn substituted_families(family: &str) -> Option<Vec<String>> {
|
||||
pub(super) fn match_family(
|
||||
family: &str,
|
||||
available_families: &HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
if family.len() > 2048 {
|
||||
return None;
|
||||
}
|
||||
let _guard = FONTCONFIG_LOCK.lock();
|
||||
let family = CString::new(family).ok()?;
|
||||
let requested = CString::new(family).ok()?;
|
||||
unsafe {
|
||||
if FcInit() == 0 {
|
||||
return None;
|
||||
@@ -164,38 +143,158 @@ mod platform {
|
||||
if FcPatternAddString(
|
||||
pattern.0.as_ptr(),
|
||||
FC_FAMILY.as_ptr(),
|
||||
family.as_ptr().cast(),
|
||||
requested.as_ptr().cast(),
|
||||
) == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if FcPatternAddBool(pattern.0.as_ptr(), FC_SCALABLE.as_ptr(), 1) == 0 {
|
||||
return None;
|
||||
}
|
||||
if FcConfigSubstitute(std::ptr::null_mut(), pattern.0.as_ptr(), FcMatchPattern) == 0 {
|
||||
return None;
|
||||
}
|
||||
FcDefaultSubstitute(pattern.0.as_ptr());
|
||||
|
||||
let mut families = Vec::new();
|
||||
for index in 0.. {
|
||||
let mut value = std::ptr::null_mut();
|
||||
if FcPatternGetString(pattern.0.as_ptr(), FC_FAMILY.as_ptr(), index, &mut value)
|
||||
!= FcResultMatch
|
||||
{
|
||||
break;
|
||||
match_pattern(std::ptr::null_mut(), family, &pattern, available_families)
|
||||
}
|
||||
}
|
||||
|
||||
// The config (or global config for null) must outlive the query. Taking a
|
||||
// configured pattern keeps native substitution separate from accepting the
|
||||
// resulting best match; tests can supply an isolated application font set.
|
||||
unsafe fn match_pattern(
|
||||
config: *mut FcConfig,
|
||||
family: &str,
|
||||
pattern: &Pattern,
|
||||
available_families: &HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
unsafe {
|
||||
let configured = family_names(pattern.0.as_ptr()).into_iter().next()?;
|
||||
let mut result = FcResultMatch;
|
||||
let matches = FontSet(NonNull::new(FcFontSort(
|
||||
config,
|
||||
pattern.0.as_ptr(),
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
&mut result,
|
||||
))?);
|
||||
let font_set = matches.0.as_ref();
|
||||
for index in 0..font_set.nfont {
|
||||
let names = family_names(*font_set.fonts.add(index as usize));
|
||||
let Some(available) = names
|
||||
.iter()
|
||||
.find_map(|name| available_families.get(&normalized_family_name(name)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Fontconfig always finds a best-effort font. Like Chromium's
|
||||
// SkFontConfigInterfaceDirect, reject an unrelated first match
|
||||
// so the next CSS family can be tried instead. Do not search
|
||||
// later matches for an arbitrary acceptable alias.
|
||||
return names
|
||||
.iter()
|
||||
.any(|name| accepts_substitution(family, &configured, name))
|
||||
.then(|| available.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// The caller retains the owning Pattern or FontSet for the entire read.
|
||||
unsafe fn family_names(pattern: *mut FcPattern) -> Vec<String> {
|
||||
let mut families = Vec::new();
|
||||
for index in 0..255 {
|
||||
let mut value = std::ptr::null_mut();
|
||||
if unsafe { FcPatternGetString(pattern, FC_FAMILY.as_ptr(), index, &mut value) }
|
||||
!= FcResultMatch
|
||||
|| value.is_null()
|
||||
{
|
||||
break;
|
||||
}
|
||||
families.push(
|
||||
unsafe { CStr::from_ptr(value.cast()) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
families
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fontconfig_sys::{FcConfigAppFontAddFile, FcConfigCreate, FcConfigDestroy};
|
||||
|
||||
struct Config(NonNull<FcConfig>);
|
||||
|
||||
impl Drop for Config {
|
||||
fn drop(&mut self) {
|
||||
unsafe { FcConfigDestroy(self.0.as_ptr()) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_font_match_rejects_appended_alias_but_accepts_configured_rename() {
|
||||
let _guard = FONTCONFIG_LOCK.lock();
|
||||
unsafe {
|
||||
assert_ne!(FcInit(), 0);
|
||||
let config = Config(NonNull::new(FcConfigCreate()).expect("fontconfig config"));
|
||||
let file = CString::new(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/moli-ahem.ttf"
|
||||
))
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
FcConfigAppFontAddFile(config.0.as_ptr(), file.as_ptr().cast()),
|
||||
0
|
||||
);
|
||||
let families = HashMap::from([("moli ahem".to_owned(), "Moli Ahem".to_owned())]);
|
||||
|
||||
for (requested, configured, expected) in [
|
||||
("Missing Font", ["Missing Font", "Moli Ahem"], None),
|
||||
(
|
||||
"Renamed Font",
|
||||
["Moli Ahem", "Renamed Font"],
|
||||
Some("Moli Ahem"),
|
||||
),
|
||||
(
|
||||
"Moli Ahem",
|
||||
["Different Override", "Moli Ahem"],
|
||||
Some("Moli Ahem"),
|
||||
),
|
||||
] {
|
||||
let pattern = Pattern(NonNull::new(FcPatternCreate()).expect("font pattern"));
|
||||
for name in configured {
|
||||
let name = CString::new(name).unwrap();
|
||||
assert_ne!(
|
||||
FcPatternAddString(
|
||||
pattern.0.as_ptr(),
|
||||
FC_FAMILY.as_ptr(),
|
||||
name.as_ptr().cast()
|
||||
),
|
||||
0
|
||||
);
|
||||
}
|
||||
FcDefaultSubstitute(pattern.0.as_ptr());
|
||||
assert_eq!(
|
||||
match_pattern(config.0.as_ptr(), requested, &pattern, &families).as_deref(),
|
||||
expected,
|
||||
"native best-match acceptance for {requested}"
|
||||
);
|
||||
}
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
families.push(CStr::from_ptr(value.cast()).to_string_lossy().into_owned());
|
||||
}
|
||||
(!families.is_empty()).then_some(families)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
|
||||
mod platform {
|
||||
pub(super) fn explicit_substitution_families(_family: &str) -> Vec<String> {
|
||||
Vec::new()
|
||||
pub(super) fn match_family(
|
||||
_family: &str,
|
||||
_available_families: &std::collections::HashMap<String, String>,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,33 +302,6 @@ mod platform {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_fontconfig_defaults_are_not_treated_as_named_aliases() {
|
||||
let defaults = [
|
||||
"missing".to_owned(),
|
||||
"DejaVu Sans".to_owned(),
|
||||
"Noto Sans".to_owned(),
|
||||
];
|
||||
let arial = [
|
||||
"Arial".to_owned(),
|
||||
"Arimo".to_owned(),
|
||||
"Liberation Sans".to_owned(),
|
||||
"DejaVu Sans".to_owned(),
|
||||
"Noto Sans".to_owned(),
|
||||
];
|
||||
let unknown = [
|
||||
"unknown".to_owned(),
|
||||
"DejaVu Sans".to_owned(),
|
||||
"Noto Sans".to_owned(),
|
||||
];
|
||||
|
||||
assert_eq!(explicit_substitution_prefix(&arial, &defaults), &arial[..3]);
|
||||
assert_eq!(
|
||||
explicit_substitution_prefix(&unknown, &defaults),
|
||||
&unknown[..1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_family_lookup_is_cached() {
|
||||
let mut collection = Collection::new(parley::fontique::CollectionOptions {
|
||||
@@ -257,6 +329,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_family_lookup_is_cached_without_replacing_the_css_fallback() {
|
||||
let mut collection = Collection::new(parley::fontique::CollectionOptions {
|
||||
shared: false,
|
||||
system_fonts: true,
|
||||
});
|
||||
let mut resolver = SystemFontFamilyResolver::new(&mut collection);
|
||||
let family = "__moli_missing_font_family_cache_3bb2__";
|
||||
for _ in 0..2 {
|
||||
let mut style = TextStyle {
|
||||
font_family: FontFamily::Single(FontFamilyName::Named(Cow::Borrowed(family))),
|
||||
..TextStyle::default()
|
||||
};
|
||||
resolver.resolve_text_style(&mut collection, &mut style);
|
||||
assert!(matches!(
|
||||
style.font_family,
|
||||
FontFamily::Single(FontFamilyName::Named(name)) if name == family
|
||||
));
|
||||
}
|
||||
assert_eq!(resolver.family_lookup_count, 1);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
||||
#[test]
|
||||
fn fontconfig_aliases_do_not_convert_an_unknown_family_to_default_sans() {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND BSD-3-Clause
|
||||
// Metric compatibility data adapted from Skia, Copyright 2009-2015 Google Inc.
|
||||
// See ../../LICENSE-Skia-BSD-3-Clause for the retained third-party notice.
|
||||
|
||||
// Chromium's Linux FCI path distinguishes metric-compatible replacements from
|
||||
// Fontconfig's best-effort fallbacks. See SkFontConfigInterface_direct.cpp,
|
||||
// GetFontEquivClass / MatchFont. Keep the order: some CJK replacement names
|
||||
// occur in multiple groups and Chromium uses the first matching group.
|
||||
const METRIC_COMPATIBLE_FAMILIES: &[&[&str]] = &[
|
||||
&["Arial", "Arimo", "Liberation Sans"],
|
||||
&["Times New Roman", "Tinos", "Liberation Serif"],
|
||||
&["Courier New", "Cousine", "Liberation Mono"],
|
||||
&["Symbol", "Symbol Neu"],
|
||||
&[
|
||||
"MS PGothic",
|
||||
"MS Pゴシック",
|
||||
"Noto Sans CJK JP",
|
||||
"IPAPGothic",
|
||||
"MotoyaG04Gothic",
|
||||
],
|
||||
&[
|
||||
"MS Gothic",
|
||||
"MS ゴシック",
|
||||
"Noto Sans Mono CJK JP",
|
||||
"IPAGothic",
|
||||
"MotoyaG04GothicMono",
|
||||
],
|
||||
&[
|
||||
"MS PMincho",
|
||||
"MS P明朝",
|
||||
"Noto Serif CJK JP",
|
||||
"IPAPMincho",
|
||||
"MotoyaG04Mincho",
|
||||
],
|
||||
&[
|
||||
"MS Mincho",
|
||||
"MS 明朝",
|
||||
"Noto Serif CJK JP",
|
||||
"IPAMincho",
|
||||
"MotoyaG04MinchoMono",
|
||||
],
|
||||
&[
|
||||
"Simsun",
|
||||
"宋体",
|
||||
"Noto Serif CJK SC",
|
||||
"MSung GB18030",
|
||||
"Song ASC",
|
||||
],
|
||||
&[
|
||||
"NSimsun",
|
||||
"新宋体",
|
||||
"Noto Serif CJK SC",
|
||||
"MSung GB18030",
|
||||
"N Song ASC",
|
||||
],
|
||||
&[
|
||||
"Simhei",
|
||||
"黑体",
|
||||
"Noto Sans CJK SC",
|
||||
"MYingHeiGB18030",
|
||||
"MYingHeiB5HK",
|
||||
],
|
||||
&["PMingLiU", "新細明體", "Noto Serif CJK TC", "MSung B5HK"],
|
||||
&["MingLiU", "細明體", "Noto Serif CJK TC", "MSung B5HK"],
|
||||
&[
|
||||
"PMingLiU_HKSCS",
|
||||
"新細明體_HKSCS",
|
||||
"Noto Serif CJK TC",
|
||||
"MSung B5HK",
|
||||
],
|
||||
&[
|
||||
"MingLiU_HKSCS",
|
||||
"細明體_HKSCS",
|
||||
"Noto Serif CJK TC",
|
||||
"MSung B5HK",
|
||||
],
|
||||
&["Cambria", "Caladea"],
|
||||
&["Calibri", "Carlito"],
|
||||
];
|
||||
|
||||
pub(super) fn accepts_substitution(requested: &str, configured: &str, matched: &str) -> bool {
|
||||
if ["", "sans", "serif", "monospace"]
|
||||
.iter()
|
||||
.any(|generic| requested.eq_ignore_ascii_case(generic))
|
||||
|| matched.eq_ignore_ascii_case(requested)
|
||||
|| matched.eq_ignore_ascii_case(configured)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
metric_group(requested).is_some_and(|group| Some(group) == metric_group(matched))
|
||||
}
|
||||
|
||||
fn metric_group(family: &str) -> Option<usize> {
|
||||
METRIC_COMPATIBLE_FAMILIES
|
||||
.iter()
|
||||
.position(|group| group.iter().any(|name| name.eq_ignore_ascii_case(family)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn best_effort_aliases_do_not_make_missing_named_fonts_available() {
|
||||
assert!(!accepts_substitution("Century", "Century", "C059"));
|
||||
assert!(!accepts_substitution(
|
||||
"Bitstream Vera Sans Mono",
|
||||
"Bitstream Vera Sans Mono",
|
||||
"DejaVu Sans Mono"
|
||||
));
|
||||
assert!(!accepts_substitution("missing", "missing", "DejaVu Sans"));
|
||||
assert!(!accepts_substitution("Arial", "Arial", "DejaVu Sans"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_renames_and_requested_family_matches_remain_available() {
|
||||
assert!(accepts_substitution("Alias", "Real Font", "Real Font"));
|
||||
assert!(accepts_substitution("Real Font", "Alias", "real font"));
|
||||
assert!(!accepts_substitution("Alias", "Real Font", "Unrelated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_compatible_replacements_are_case_insensitive() {
|
||||
for (requested, matched) in [
|
||||
("arial", "Liberation Sans"),
|
||||
("Times New Roman", "Tinos"),
|
||||
("Courier New", "Cousine"),
|
||||
("Cambria", "Caladea"),
|
||||
("Calibri", "Carlito"),
|
||||
("MS PGothic", "Noto Sans CJK JP"),
|
||||
("宋体", "Simsun"),
|
||||
] {
|
||||
assert!(accepts_substitution(requested, requested, matched));
|
||||
}
|
||||
assert!(!accepts_substitution("Arial", "Arial", "Liberation Serif"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_cjk_names_use_the_first_equivalence_group() {
|
||||
assert!(accepts_substitution(
|
||||
"MS PMincho",
|
||||
"MS PMincho",
|
||||
"Noto Serif CJK JP"
|
||||
));
|
||||
assert!(!accepts_substitution(
|
||||
"MS Mincho",
|
||||
"MS Mincho",
|
||||
"Noto Serif CJK JP"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_fallback_requests_allow_platform_defaults() {
|
||||
for family in ["", "sans", "serif", "MONOSPACE"] {
|
||||
assert!(accepts_substitution(family, family, "Platform Default"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user