mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
Fix #813: type can name an installed version of its font
A font library can hold two versions of one family and style (a new Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept only the first one loaded, so type set in the other couldn't be matched when a PDF or .ai was opened: it came in as outlines. Now every version loads (the first stays the one family and style resolve to), PDF import tries the other versions when the glyphs differ, and type set in a version the family and style alone wouldn't pick names it in CharStyle::font_version (left out when None, so documents without it are unchanged). Layout, shaping, the substituted-glyph highlight, Find Font, Package and Document Info follow it; changing the font clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b43e633329
commit
562e09707a
@@ -27,6 +27,11 @@ pub struct CharStyle {
|
||||
pub font_family: String,
|
||||
#[serde(default = "regular")]
|
||||
pub font_style: String,
|
||||
/// Which installed version of the font (its version string) when several versions of the
|
||||
/// family and style are installed and the text was set in one the family and style alone
|
||||
/// wouldn't pick (an imported file's type matched to it); None = the one they pick.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub font_version: Option<String>,
|
||||
/// Size in points.
|
||||
pub size: f64,
|
||||
/// Leading in points; None = Auto (120% of size).
|
||||
@@ -175,6 +180,7 @@ impl Default for CharStyle {
|
||||
Self {
|
||||
font_family: "Source Sans 3".into(),
|
||||
font_style: "Regular".into(),
|
||||
font_version: None,
|
||||
size: 12.0,
|
||||
leading: None,
|
||||
tracking: 0.0,
|
||||
|
||||
@@ -97,7 +97,7 @@ type ImageRow = (String, u32, u32, Option<String>);
|
||||
|
||||
/// The Document Info categories of `d` (only `category` when given) from `info` (what
|
||||
/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns.
|
||||
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
|
||||
fn sections(d: &Document, info: &Value, fonts: &BTreeSet<super::fonts::UsedFont>, images: &[ImageRow], category: Option<&str>) -> Vec<Section> {
|
||||
let names = |k: &str| -> Vec<(String, String)> {
|
||||
info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect()
|
||||
};
|
||||
@@ -135,14 +135,18 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
|
||||
"patterns" => names("patternNames"),
|
||||
"gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(),
|
||||
"symbols" => names("symbols"),
|
||||
"fonts" => fonts.iter().map(|(f, s)| (format!("{f} {s}"), String::new())).collect(),
|
||||
"fonts" => fonts.iter().map(|f| (super::fonts::font_label(f), String::new())).collect(),
|
||||
"fontDetails" => {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
fonts
|
||||
.iter()
|
||||
.map(|(family, style)| {
|
||||
// Found by any of its names; a style the family lacks shows in another.
|
||||
let detail = match db.resolve(family, style) {
|
||||
.map(|used| {
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names (in the version the type names); a style the
|
||||
// family lacks shows in another.
|
||||
let resolved =
|
||||
db.resolve(family, style).map(|(f, m)| (db.face_version(family, style, version.as_deref()).unwrap_or(f), m));
|
||||
let detail = match resolved {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => {
|
||||
let file =
|
||||
f.path().and_then(|p| p.file_name()).map_or_else(|| "built in".into(), |n| n.to_string_lossy().into_owned());
|
||||
@@ -156,7 +160,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet<(String, String)>, imag
|
||||
Some((f, _)) => format!("missing: shown in {} {}", f.family, f.style),
|
||||
None => "missing".into(),
|
||||
};
|
||||
(format!("{family} {style}"), detail)
|
||||
(super::fonts::font_label(used), detail)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -243,7 +247,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
NodeKind::Compound { .. } => Some("compoundPaths"),
|
||||
NodeKind::Text(t) => {
|
||||
for run in &t.runs {
|
||||
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone()));
|
||||
fonts.insert((run.style.font_family.clone(), run.style.font_style.clone(), run.style.font_version.clone()));
|
||||
}
|
||||
Some("textObjects")
|
||||
}
|
||||
@@ -300,7 +304,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
}
|
||||
let font_names: Vec<String> = fonts.iter().map(|(f, s)| format!("{f} {s}")).collect();
|
||||
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -85,12 +85,23 @@ fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The fonts (family, style) of the type in the layers and symbols of `d`.
|
||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<(String, String)> {
|
||||
/// A font as type names it: family, style and, when it names one, the installed version.
|
||||
pub(super) type UsedFont = (String, String, Option<String>);
|
||||
|
||||
/// How a font is named in reports: family and style, then the version the type names, if any.
|
||||
pub(super) fn font_label((family, style, version): &UsedFont) -> String {
|
||||
match version {
|
||||
Some(v) => format!("{family} {style} ({v})"),
|
||||
None => format!("{family} {style}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut add = |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone())));
|
||||
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
|
||||
}
|
||||
};
|
||||
d.walk(&mut add);
|
||||
@@ -106,10 +117,10 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut missing: Vec<String> = used_fonts(d)
|
||||
.into_iter()
|
||||
.filter(|(family, style)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
|
||||
.map(|(family, _)| family)
|
||||
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
|
||||
.map(|(family, _, _)| family)
|
||||
.collect();
|
||||
// Sorted by family: its styles are neighbours.
|
||||
// Sorted by family: its styles and versions are neighbours.
|
||||
missing.dedup();
|
||||
(!missing.is_empty()).then(|| {
|
||||
format!(
|
||||
@@ -131,10 +142,10 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
@@ -144,20 +155,25 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style), (runs, objects, chars))| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
.map(|((family, style, version), (runs, objects, chars))| {
|
||||
// The face the type is set in: the version it names when that one is installed.
|
||||
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
json!({
|
||||
let mut row = json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
"missingGlyphs": lacking.len(),
|
||||
})
|
||||
});
|
||||
if let Some(v) = version {
|
||||
row["version"] = json!(v);
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(list))
|
||||
@@ -198,6 +214,8 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.unwrap_or_else(|| r.style.font_style.clone());
|
||||
r.style.font_family = tf.clone();
|
||||
r.style.font_style = style;
|
||||
// Another font: none of the old one's versions.
|
||||
r.style.font_version = None;
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
@@ -107,10 +107,14 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut files = HashSet::new();
|
||||
lines.push("FONTS".to_string());
|
||||
for (family, style) in super::fonts::used_fonts(&st.doc) {
|
||||
let font = format!("{family} {style}");
|
||||
// Found by any of its names, as the canvas draws it.
|
||||
let resolved = db.resolve(&family, &style).filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing);
|
||||
for used in super::fonts::used_fonts(&st.doc) {
|
||||
let font = super::fonts::font_label(&used);
|
||||
let (family, style, version) = used;
|
||||
// Found by any of its names, as the canvas draws it (in the version the type names).
|
||||
let resolved = db
|
||||
.resolve(&family, &style)
|
||||
.filter(|(_, m)| *m != vectorcraft_text::FontMatch::Missing)
|
||||
.map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
// A style the family lacks is shown in its closest style, whose file is copied.
|
||||
let shown = match &resolved {
|
||||
Some((f, vectorcraft_text::FontMatch::Style)) => format!(" (shown in {} {})", f.family, f.style),
|
||||
|
||||
@@ -316,6 +316,10 @@ impl CharChange {
|
||||
if let Some(v) = &self.style {
|
||||
st.font_style = v.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
if self.font.is_some() || self.style.is_some() {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
}
|
||||
@@ -388,7 +392,9 @@ pub(crate) fn protect_missing_glyphs(before: &[TextRun], runs: &mut Vec<TextRun>
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut faces = std::collections::HashMap::new();
|
||||
let mut covers = |st: &CharStyle, c: char| {
|
||||
let face = faces.entry((st.font_family.clone(), st.font_style.clone())).or_insert_with(|| db.face(&st.font_family, &st.font_style));
|
||||
let face = faces
|
||||
.entry((st.font_family.clone(), st.font_style.clone(), st.font_version.clone()))
|
||||
.or_insert_with(|| db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()));
|
||||
face.as_ref().map(|f| f.covers(c))
|
||||
};
|
||||
// Byte ranges that keep a font (family, style).
|
||||
|
||||
@@ -416,6 +416,10 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = &style {
|
||||
st.font_style = f.clone();
|
||||
}
|
||||
// Another font: none of the old one's versions.
|
||||
if font.is_some() || style.is_some() {
|
||||
st.font_version = None;
|
||||
}
|
||||
if let Some(v) = size {
|
||||
st.size = v.clamp(0.1, 1296.0);
|
||||
}
|
||||
|
||||
@@ -460,8 +460,12 @@ fn matching_face(
|
||||
return Some(face.clone());
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let others =
|
||||
// Other installed versions of its family and style (a font library keeps old ones), then the
|
||||
// family's other styles.
|
||||
let versions = db.versions(face).into_iter().skip(1).take(16);
|
||||
let styles =
|
||||
db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s));
|
||||
let others = versions.chain(styles);
|
||||
let mut best = own.map(|d| (d, face.clone()));
|
||||
for f in others {
|
||||
if let Some(d) = glyph_deviation(&f, text, o, vertical)
|
||||
@@ -636,6 +640,9 @@ struct Builder<'p> {
|
||||
/// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph
|
||||
/// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines.
|
||||
matched: HashMap<u128, Option<Arc<vectorcraft_text::FontFace>>>,
|
||||
/// Per font: the installed version its type names, when it isn't the one its family and style
|
||||
/// resolve to (see `CharStyle::font_version`).
|
||||
versions: HashMap<u128, Option<String>>,
|
||||
/// The soft mask of the graphics state, the art drawn through it so far, and the masks read.
|
||||
mask: Option<Arc<MaskSpec>>,
|
||||
masked: Vec<Arc<Node>>,
|
||||
@@ -784,6 +791,7 @@ impl<'p> Builder<'p> {
|
||||
cid_text: HashMap::new(),
|
||||
font_chars: HashMap::new(),
|
||||
matched: HashMap::new(),
|
||||
versions: HashMap::new(),
|
||||
mask: None,
|
||||
masked: vec![],
|
||||
masks: HashMap::new(),
|
||||
@@ -1356,12 +1364,25 @@ impl<'p> Builder<'p> {
|
||||
};
|
||||
info = FontInfo { family: f.family.clone(), style: f.style.clone(), face: Some(f) };
|
||||
}
|
||||
// A version of the family and style that the two alone don't resolve to: the type names it.
|
||||
// Kept per font once its face is decided (until then each glyph asks).
|
||||
let named = |f: &Option<Arc<vectorcraft_text::FontFace>>| {
|
||||
let f = f.as_ref()?;
|
||||
let default = vectorcraft_text::FontDb::global().face(&f.family, &f.style)?;
|
||||
(default.version != f.version).then(|| f.version.clone())
|
||||
};
|
||||
let version = match self.versions.get(&key) {
|
||||
Some(v) => v.clone(),
|
||||
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
|
||||
None => named(&info.face),
|
||||
};
|
||||
let (paint, opacity) = self.paint(paint, stroke.is_some());
|
||||
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
|
||||
let look = Look {
|
||||
font: key,
|
||||
family: info.family,
|
||||
style: info.style,
|
||||
version,
|
||||
size: at.size,
|
||||
h_scale: at.h_scale,
|
||||
fill: stroke.is_none().then_some(paint),
|
||||
|
||||
@@ -75,6 +75,8 @@ pub(crate) struct Look {
|
||||
pub font: u128,
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
/// The installed version of the font when it isn't the one family and style resolve to.
|
||||
pub version: Option<String>,
|
||||
pub size: f64,
|
||||
pub h_scale: f64,
|
||||
pub fill: Option<Paint>,
|
||||
@@ -97,6 +99,7 @@ impl Look {
|
||||
CharStyle {
|
||||
font_family: self.family.clone(),
|
||||
font_style: self.style.clone(),
|
||||
font_version: self.version.clone(),
|
||||
size: round(self.size),
|
||||
h_scale: round(self.h_scale),
|
||||
fill: self.fill.clone().unwrap_or(Paint::None),
|
||||
|
||||
@@ -260,6 +260,7 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
||||
font: 1,
|
||||
family: "Source Sans 3".into(),
|
||||
style: "Regular".into(),
|
||||
version: None,
|
||||
size: 20.0,
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
@@ -282,6 +283,7 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
||||
font: 1,
|
||||
family: "Source Sans 3".into(),
|
||||
style: "Regular".into(),
|
||||
version: None,
|
||||
size: 20.0,
|
||||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
|
||||
@@ -290,3 +290,26 @@ fn slanted_type_reopens_as_type_that_still_leans() {
|
||||
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type set in an installed font's older version (a library keeps both, one name) reopens as type
|
||||
/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type
|
||||
/// names the version, which the family and style alone don't pick.
|
||||
#[test]
|
||||
fn type_in_another_installed_version_reopens_in_that_version() {
|
||||
use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font};
|
||||
// The newer version first: the one the family and style resolve to.
|
||||
FontDb::global().add_font(twin_font(true).unwrap());
|
||||
FontDb::global().add_font(twin_font(false).unwrap());
|
||||
for version in [None, Some(TWIN_VERSIONS[0])] {
|
||||
let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) };
|
||||
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||||
let mut found = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
found.push((t.plain_text(), t.first_style().font_version.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,7 +1653,11 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
|
||||
Some((f, m)) => (m == vectorcraft_text::FontMatch::Missing, Some(f.id())),
|
||||
// The version the type names, when it's installed (see `FontDb::face_version`).
|
||||
Some((f, m)) => {
|
||||
let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f);
|
||||
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
|
||||
}
|
||||
None => (true, None),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -52,6 +52,9 @@ pub struct FontFace {
|
||||
pub family: String,
|
||||
/// Typographic style name (e.g. "Semibold", "Italic").
|
||||
pub style: String,
|
||||
/// The font's version string (name ID 5, e.g. "Version 7.200"; empty when it has none). Two
|
||||
/// installed versions of one family and style are told apart by it (see [`FontDb::face_version`]).
|
||||
pub version: String,
|
||||
/// usWeightClass-style weight (400 = regular).
|
||||
pub weight: f32,
|
||||
pub italic: bool,
|
||||
@@ -324,6 +327,10 @@ pub struct FontDb {
|
||||
/// scan, so what they find never depends on what ran before them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock<()>,
|
||||
/// Families (lowercased) whose installed files were all loaded to find other versions of their
|
||||
/// faces (see [`FontDb::versions`]); a rescan forgets them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
version_scanned: RwLock<std::collections::HashSet<String>>,
|
||||
/// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned.
|
||||
family_cache: Mutex<Option<Arc<[String]>>>,
|
||||
menu_cache: Mutex<Option<MenuFamilies>>,
|
||||
@@ -1032,6 +1039,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
};
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
|
||||
let version = name(&f, &[StringId::VERSION_STRING]).unwrap_or_default();
|
||||
// A named instance: outlines, metrics and advances at its axis settings.
|
||||
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
|
||||
let m = f.metrics(Size::unscaled(), &location);
|
||||
@@ -1046,6 +1054,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
version,
|
||||
weight: weight.unwrap_or(a.weight.value()),
|
||||
italic: italic || !matches!(a.style, skrifa::attribute::Style::Normal),
|
||||
variations,
|
||||
@@ -1150,6 +1159,8 @@ impl FontDb {
|
||||
aliases: RwLock::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
version_scanned: RwLock::new(std::collections::HashSet::new()),
|
||||
family_cache: Mutex::new(None),
|
||||
menu_cache: Mutex::new(None),
|
||||
generation: AtomicU64::new(0),
|
||||
@@ -1242,8 +1253,13 @@ impl FontDb {
|
||||
pub fn styles(&self, family: &str) -> Vec<String> {
|
||||
let (family, _) = self.canonical(family, "");
|
||||
let family = family.as_str();
|
||||
let mut v: Vec<(bool, f32, String)> =
|
||||
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
|
||||
let mut v: Vec<(bool, f32, String)> = Vec::new();
|
||||
// Each style once, as its first loaded version describes it (versions can disagree on weight).
|
||||
for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&f.style)) {
|
||||
v.push((f.italic, f.weight, f.style.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
||||
for cf in &c.faces {
|
||||
if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&cf.style)) {
|
||||
@@ -1267,7 +1283,14 @@ impl FontDb {
|
||||
let data = Arc::new(bytes);
|
||||
let mut added = 0;
|
||||
for (i, spec) in enumerate_faces(&data) {
|
||||
if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style)) {
|
||||
// Another version of a family and style is kept: a document may name it (the first one
|
||||
// loaded stays the one the family and style alone resolve to).
|
||||
let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default();
|
||||
if self
|
||||
.read_faces()
|
||||
.iter()
|
||||
.any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) {
|
||||
@@ -1329,6 +1352,8 @@ impl FontDb {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn scan_font_dirs(&self) -> usize {
|
||||
// Files may have been installed since: other versions are looked for again.
|
||||
self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
let mut catalog = Catalog::new();
|
||||
let mut aliases: HashMap<String, Vec<Alias>> = HashMap::new();
|
||||
let mut postscript = PostScriptNames::new();
|
||||
@@ -1520,6 +1545,49 @@ impl FontDb {
|
||||
.or_else(|| self.read_faces().first().cloned())
|
||||
}
|
||||
|
||||
/// [`FontDb::face`] in version `version` of the family and style (a [`FontFace::version`]), when
|
||||
/// that version is installed; otherwise (or without a version) the face [`FontDb::face`] gives.
|
||||
pub fn face_version(&self, family: &str, style: &str, version: Option<&str>) -> Option<Arc<FontFace>> {
|
||||
let face = self.face(family, style)?;
|
||||
let Some(version) = version.filter(|v| *v != face.version) else { return Some(face) };
|
||||
let same =
|
||||
|f: &&Arc<FontFace>| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.version == version;
|
||||
if let Some(f) = self.read_faces().iter().find(same) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
// Not loaded: the family's installed files may hold it (the catalog doesn't list versions).
|
||||
self.load_family_files(&face.family);
|
||||
let found = self.read_faces().iter().find(same).cloned();
|
||||
Some(found.unwrap_or(face))
|
||||
}
|
||||
|
||||
/// Every installed version of `face`'s family and style, `face` itself first.
|
||||
pub fn versions(&self, face: &Arc<FontFace>) -> Vec<Arc<FontFace>> {
|
||||
self.load_family_files(&face.family);
|
||||
let mut out = vec![face.clone()];
|
||||
out.extend(
|
||||
self.read_faces()
|
||||
.iter()
|
||||
.filter(|f| f.family.eq_ignore_ascii_case(&face.family) && f.style.eq_ignore_ascii_case(&face.style) && f.id != face.id)
|
||||
.cloned(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Load the installed files of `family` once (other versions of faces already loaded from
|
||||
/// elsewhere); a no-op on wasm, where no fonts are installed.
|
||||
fn load_family_files(&self, family: &str) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = family.to_ascii_lowercase();
|
||||
if self.version_scanned.write().unwrap_or_else(|e| e.into_inner()).insert(key) {
|
||||
self.load_cataloged(family);
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = family;
|
||||
}
|
||||
|
||||
/// Is `style` of `family` among the installed fonts?
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_cataloged(&self, family: &str, style: &str) -> bool {
|
||||
|
||||
@@ -263,7 +263,8 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout
|
||||
TextKind::OnPath { .. } => Affine::IDENTITY,
|
||||
TextKind::Point => {
|
||||
let first = cx.style_at(0);
|
||||
let centre = db.face(&first.font_family, &first.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
let centre =
|
||||
db.face_version(&first.font_family, &first.font_style, first.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN
|
||||
}
|
||||
_ => QUARTER_TURN,
|
||||
@@ -1392,7 +1393,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
|
||||
let st = cx.style_at(pr.start);
|
||||
let (cap, xh) = cap_x_heights(cx.db, st);
|
||||
let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
let centre =
|
||||
cx.db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 }
|
||||
};
|
||||
if pi > 0 {
|
||||
|
||||
@@ -522,6 +522,8 @@ mod tests_typo;
|
||||
#[cfg(test)]
|
||||
mod tests_variable;
|
||||
#[cfg(test)]
|
||||
mod tests_versions;
|
||||
#[cfg(test)]
|
||||
mod tests_vertical;
|
||||
#[cfg(test)]
|
||||
#[path = "../build/web_fonts.rs"]
|
||||
|
||||
@@ -221,7 +221,9 @@ pub(crate) fn is_cjk(c: char) -> bool {
|
||||
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
|
||||
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
||||
let vs = st.v_scale / 100.0;
|
||||
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) };
|
||||
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
|
||||
return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading());
|
||||
};
|
||||
let k = st.size / face.upem;
|
||||
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
|
||||
}
|
||||
@@ -229,7 +231,9 @@ pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
||||
/// Cap height and x height (points) of a style's resolved face.
|
||||
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
|
||||
let vs = st.v_scale / 100.0;
|
||||
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) };
|
||||
let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else {
|
||||
return (st.size * 0.7 * vs, st.size * 0.5 * vs);
|
||||
};
|
||||
let k = st.size / face.upem * vs;
|
||||
(face.cap_height * k, face.x_height * k)
|
||||
}
|
||||
@@ -291,12 +295,12 @@ pub(crate) fn shape_range(
|
||||
let a = bytes.start;
|
||||
let b = bytes.end;
|
||||
if let Some(art) = art {
|
||||
if let Some(face) = db.face(&st.font_family, &st.font_style).or_else(|| db.face_covering('a')) {
|
||||
if let Some(face) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()).or_else(|| db.face_covering('a')) {
|
||||
out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
|
||||
let Some(primary) = db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) else { continue };
|
||||
let pmap = primary.skrifa().map(|f| f.charmap());
|
||||
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
|
||||
let small_caps = st.small_caps.is_some() && !st.all_caps;
|
||||
|
||||
@@ -174,3 +174,30 @@ pub fn vertical_font(vorg: bool) -> Option<Vec<u8>> {
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`twin_font`]: two versions of one family and style.
|
||||
pub const TWIN_FAMILY: &str = "Twintest Sans";
|
||||
|
||||
/// The version strings of [`twin_font`]'s older and newer face.
|
||||
pub const TWIN_VERSIONS: [&str; 2] = ["Version 1.000", "Version 2.000"];
|
||||
|
||||
/// A face of family [`TWIN_FAMILY`], style Regular, in version [`TWIN_VERSIONS`]`[0]` (the bundled
|
||||
/// Source Sans 3 Regular) or, `newer`, `[1]` (the bundled Inter Regular): one name, two sets of
|
||||
/// glyphs, as a font library that keeps an old version beside a new one has. `None` when it can't
|
||||
/// be built.
|
||||
pub fn twin_font(newer: bool) -> Option<Vec<u8>> {
|
||||
const OLDER: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
const NEWER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(if newer { NEWER } else { OLDER }).ok()?;
|
||||
let version = TWIN_VERSIONS[usize::from(newer)];
|
||||
let name = name_table(&[(1, TWIN_FAMILY), (2, "Regular"), (4, "Twintest Sans Regular"), (5, version), (6, "TwintestSans-Regular")])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Two installed versions of one family and style (a font library keeps an old version beside a
|
||||
//! new one): both load, the family and style alone keep resolving to the first, and type that names
|
||||
//! the other version is set in it.
|
||||
|
||||
use kurbo::Point;
|
||||
use vectorcraft_doc::CharStyle;
|
||||
|
||||
use super::test_fonts::{TWIN_FAMILY as FAMILY, TWIN_VERSIONS as VERSIONS, twin_font};
|
||||
use super::*;
|
||||
|
||||
/// A database of the bundled fonts, the newer version of the test family loaded first.
|
||||
fn db() -> FontDb {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(twin_font(true).expect("the newer test font builds")), 1);
|
||||
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 1, "another version is kept");
|
||||
assert_eq!(db.add_font(twin_font(false).expect("the older test font builds")), 0, "the same version isn't added twice");
|
||||
db
|
||||
}
|
||||
|
||||
fn width(db: &FontDb, version: Option<&str>) -> f64 {
|
||||
let st = CharStyle { font_family: FAMILY.into(), font_version: version.map(Into::into), size: 100.0, ..CharStyle::default() };
|
||||
layout(db, &TextObject::point(Point::ZERO, "Hamburgefonstiv", st)).glyphs.iter().map(|g| g.advance).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_is_found_by_its_version_string() {
|
||||
let db = db();
|
||||
let face = |v: Option<&str>| db.face_version(FAMILY, "Regular", v).expect("a face");
|
||||
assert_eq!(db.face(FAMILY, "Regular").expect("a face").version, VERSIONS[1], "the first loaded stays the default");
|
||||
assert_eq!(face(None).version, VERSIONS[1]);
|
||||
assert_eq!(face(Some(VERSIONS[0])).version, VERSIONS[0]);
|
||||
assert_eq!(face(Some("Version 9.000")).version, VERSIONS[1], "a version not installed: the default");
|
||||
let all = db.versions(&face(None));
|
||||
assert_eq!(all.iter().map(|f| f.version.as_str()).collect::<Vec<_>>(), [VERSIONS[1], VERSIONS[0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_naming_a_version_is_set_in_it() {
|
||||
let db = db();
|
||||
let (newer, older) = (width(&db, None), width(&db, Some(VERSIONS[0])));
|
||||
assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}");
|
||||
assert_eq!(width(&db, Some(VERSIONS[1])), newer);
|
||||
}
|
||||
Reference in New Issue
Block a user