mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
236 lines
11 KiB
Rust
236 lines
11 KiB
Rust
use super::build::{style_italic, style_weight};
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use super::dirs::sfnt_of;
|
|
use super::face::{MAX_AXES, MAX_INSTANCES};
|
|
use super::names::{all_names, name, norm};
|
|
use super::traits::{FaceKeys, FaceStyle, FontTraits, face_styles};
|
|
use super::{FileRef, MetadataProvider, Path, StringId, Tag};
|
|
|
|
/// The named instances of a variable font face (its `fvar` table) as styles of the face's family,
|
|
/// named by their subfamily names, without the default instance (the face itself) and without
|
|
/// names already taken. The default instance's name, when it isn't the face's style name (a face
|
|
/// `Regular` whose default instance is `Book`), names the face too (`keys`). Damaged tables give
|
|
/// fewer instances or none; the counts read are capped.
|
|
pub(super) fn named_instances(
|
|
f: &skrifa::FontRef<'_>,
|
|
family: &str,
|
|
style: &str,
|
|
keys: &mut FaceKeys,
|
|
native_family: Option<String>,
|
|
) -> (Vec<FaceStyle>, Option<String>) {
|
|
let axes = f.axes();
|
|
if axes.is_empty() || axes.len() > MAX_AXES {
|
|
return (vec![], None);
|
|
}
|
|
let tags: Vec<Tag> = axes.iter().map(|a| a.tag()).collect();
|
|
let defaults: Vec<f32> = axes.iter().map(|a| a.default_value()).collect();
|
|
let is_default = |coords: &[f32]| coords.len() == defaults.len() && coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3);
|
|
// The face renders as its default instance. When that instance is another weight than the
|
|
// name table says (`Light` face, Regular default instance), the instance names the face.
|
|
let renamed = tags.iter().any(|t| t.to_be_bytes() == *b"wght").then(|| {
|
|
f.named_instances()
|
|
.iter()
|
|
.take(MAX_INSTANCES)
|
|
.filter(|inst| is_default(&inst.user_coords().take(MAX_AXES).collect::<Vec<f32>>()))
|
|
.find_map(|inst| name(f, &[inst.subfamily_name_id()]))
|
|
.filter(|d| style_weight(d) != style_weight(style))
|
|
});
|
|
let renamed = renamed.flatten();
|
|
// Instances without a PostScript name of their own are named `<prefix>-<style>` (Adobe
|
|
// Technical Note #5902), as PDF and `.ai` files name them.
|
|
let prefix = name(f, &[StringId::VARIATIONS_POSTSCRIPT_NAME_PREFIX]).unwrap_or_else(|| family.to_string());
|
|
if let Some(new) = &renamed {
|
|
let old = norm(style);
|
|
keys.styles.retain(|k| *k != old);
|
|
keys.styles.push(norm(new));
|
|
}
|
|
let mut taken = vec![norm(renamed.as_deref().unwrap_or(style))];
|
|
let mut out = Vec::new();
|
|
for inst in f.named_instances().iter().take(MAX_INSTANCES) {
|
|
let coords: Vec<f32> = inst.user_coords().take(MAX_AXES).collect();
|
|
if coords.len() != tags.len() || coords.iter().any(|c| !c.is_finite()) {
|
|
continue;
|
|
}
|
|
let Some(style) = name(f, &[inst.subfamily_name_id()]) else { continue };
|
|
let ns = norm(&style);
|
|
if ns.is_empty() || taken.contains(&ns) {
|
|
continue;
|
|
}
|
|
// The default instance is the face itself.
|
|
if coords.iter().zip(&defaults).all(|(c, d)| (c - d).abs() < 1e-3) {
|
|
keys.styles.push(ns.clone());
|
|
keys.postscript.push(norm(&format!("{prefix}{style}")));
|
|
taken.push(ns);
|
|
continue;
|
|
}
|
|
taken.push(ns);
|
|
let axis = |tag: &[u8; 4]| tags.iter().zip(&coords).find(|(t, _)| t.to_be_bytes() == *tag).map(|(_, v)| *v);
|
|
let weight = axis(b"wght").map(|w| w.clamp(1.0, 1000.0));
|
|
let italic = style_italic(&style) || axis(b"ital").is_some_and(|v| v >= 0.5) || axis(b"slnt").is_some_and(|v| v != 0.0);
|
|
let mut styles: Vec<String> = std::iter::once(style.clone()).chain(all_names(f, inst.subfamily_name_id())).map(|s| norm(&s)).collect();
|
|
let mut postscript: Vec<String> = inst.postscript_name_id().map(|id| all_names(f, id)).unwrap_or_default().iter().map(|n| norm(n)).collect();
|
|
postscript.push(norm(&format!("{prefix}{style}")));
|
|
for v in [&mut styles, &mut postscript] {
|
|
v.retain(|k| !k.is_empty());
|
|
v.sort();
|
|
v.dedup();
|
|
}
|
|
// Name ID 4 describes the default face, not every named instance of its variable font.
|
|
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], full_names: vec![], postscript };
|
|
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
|
|
out.push(FaceStyle {
|
|
family: family.to_string(),
|
|
native_family: native_family.clone(),
|
|
style,
|
|
keys,
|
|
variations,
|
|
weight,
|
|
italic,
|
|
traits: FontTraits::default(),
|
|
});
|
|
}
|
|
(out, renamed)
|
|
}
|
|
|
|
/// The most faces read from a (possibly damaged) collection.
|
|
pub(super) const MAX_FACES: u32 = 256;
|
|
|
|
/// Parse every face in `data` (a font file or collection) and the named instances of its variable
|
|
/// ones. Returns each style with the index of its face.
|
|
pub(super) fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> {
|
|
let count = match FileRef::new(data) {
|
|
Ok(FileRef::Font(_)) => 1,
|
|
Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES),
|
|
Err(_) => 0,
|
|
};
|
|
(0..count)
|
|
.filter_map(|i| Some((i, skrifa::FontRef::from_index(data, i).ok()?)))
|
|
.flat_map(|(i, f)| face_styles(&f).into_iter().map(move |s| (i, s)))
|
|
.collect()
|
|
}
|
|
|
|
/// The font files held by the file at `path`: its contents, or for a suitcase font the fonts in
|
|
/// its resource fork ([`crate::suitcase`]). None when it can't be read.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn font_files(path: &Path) -> Vec<Vec<u8>> {
|
|
if crate::suitcase::is_suitcase(path) {
|
|
return crate::suitcase::read_fonts(path);
|
|
}
|
|
std::fs::read(path).into_iter().collect()
|
|
}
|
|
|
|
/// The tables holding glyphs the font engine draws: TrueType, CFF, CFF2 and VARC outlines, and
|
|
/// `sbix` and `CBDT` colour bitmaps. A face with none of them (outlines in Apple's `hvgl` table,
|
|
/// monochrome bitmaps only) draws nothing.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const OUTLINE_TABLES: [&[u8; 4]; 6] = [b"glyf", b"CFF ", b"CFF2", b"VARC", b"sbix", b"CBDT"];
|
|
|
|
/// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table
|
|
/// directories and `name`, `fvar` and `OS/2` tables: a scan opens hundreds of font files, many of
|
|
/// them megabytes long. Faces without outlines the font engine draws (no [`OUTLINE_TABLES`] table)
|
|
/// are left out.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(super) fn file_face_names(path: &Path) -> Vec<FaceStyle> {
|
|
file_face_names_within(path, MAX_FACES, u64::MAX)
|
|
}
|
|
|
|
/// [`file_face_names`], reading at most `max_faces` faces and `max_read` bytes of the file in all.
|
|
/// A suitcase font's resource fork is read whole when it is at most `max_read` bytes long, and the
|
|
/// limits then apply to each of its fonts; a longer fork gives none.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(crate) fn file_face_names_within(path: &Path, max_faces: u32, max_read: u64) -> Vec<FaceStyle> {
|
|
if crate::suitcase::is_suitcase(path) {
|
|
if crate::suitcase::fork_len(path) > max_read {
|
|
return vec![];
|
|
}
|
|
return crate::suitcase::read_fonts(path)
|
|
.into_iter()
|
|
.flat_map(|font| reader_face_names_within(std::io::Cursor::new(font), max_faces, max_read))
|
|
.collect();
|
|
}
|
|
match std::fs::File::open(path) {
|
|
Ok(file) => reader_face_names_within(file, max_faces, max_read),
|
|
Err(_) => vec![],
|
|
}
|
|
}
|
|
|
|
/// [`file_face_names_within`] for the font file `file` reads.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn reader_face_names_within(mut file: impl std::io::Read + std::io::Seek, max_faces: u32, max_read: u64) -> Vec<FaceStyle> {
|
|
use std::io::SeekFrom;
|
|
/// The largest table read from a (possibly damaged) file.
|
|
const MAX_NAME_TABLE: u32 = 1 << 20;
|
|
let mut read = 0u64;
|
|
let mut read_at = |offset: u64, len: usize| -> Option<Vec<u8>> {
|
|
read = read.checked_add(u64::try_from(len).ok()?).filter(|r| *r <= max_read)?;
|
|
let mut buf = vec![0; len];
|
|
file.seek(SeekFrom::Start(offset)).ok()?;
|
|
file.read_exact(&mut buf).ok()?;
|
|
Some(buf)
|
|
};
|
|
let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes);
|
|
let Some(head) = read_at(0, 12) else { return vec![] };
|
|
// A collection lists where each face's table directory starts.
|
|
let starts: Vec<u32> = if head.starts_with(b"ttcf") {
|
|
let n = be32(&head, 8).unwrap_or(0).min(max_faces) as usize;
|
|
read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default()
|
|
} else {
|
|
vec![0]
|
|
};
|
|
starts
|
|
.into_iter()
|
|
.filter_map(|start| {
|
|
let dir = read_at(start.into(), 12)?;
|
|
let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize;
|
|
let records = read_at(u64::from(start) + 12, tables * 16)?;
|
|
if !records.as_chunks::<16>().0.iter().any(|r| OUTLINE_TABLES.iter().any(|t| r.starts_with(*t))) {
|
|
return None;
|
|
}
|
|
let mut table = |tag: &[u8; 4]| -> Option<Vec<u8>> {
|
|
let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(tag))?;
|
|
let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?);
|
|
if len > MAX_NAME_TABLE {
|
|
return None;
|
|
}
|
|
read_at(offset.into(), len as usize)
|
|
};
|
|
let name = table(b"name")?;
|
|
// A variable font's named instances are styles too.
|
|
let fvar = table(b"fvar");
|
|
// Its OS/2 table (the font's classification, for the menus' filters), when it has one.
|
|
let os2 = table(b"OS/2");
|
|
// A font holding just these tables, to read them as the font itself would.
|
|
let mut tables: Vec<(&[u8; 4], &[u8])> = vec![(b"name", &name)];
|
|
if let Some(fvar) = &fvar {
|
|
tables.push((b"fvar", fvar));
|
|
}
|
|
if let Some(os2) = &os2 {
|
|
tables.push((b"OS/2", os2));
|
|
}
|
|
let font = sfnt_of(&tables)?;
|
|
Some(face_styles(&skrifa::FontRef::new(&font).ok()?))
|
|
})
|
|
.flatten()
|
|
.collect()
|
|
}
|
|
|
|
/// Whether the file at `path` is a font file by its extension (`.ttf`, `.otf`, `.ttc` or `.otc`,
|
|
/// in any case), as the font scan reads the files in a folder.
|
|
pub fn is_font_file(path: &Path) -> bool {
|
|
path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["ttf", "otf", "ttc", "otc"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
|
}
|
|
|
|
/// Whether the file at `path` is a suitcase font ([`crate::suitcase`]): on macOS, a file with
|
|
/// nothing in its data and its fonts in its resource fork. Never on the web.
|
|
pub fn is_suitcase(path: &Path) -> bool {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
crate::suitcase::is_suitcase(path)
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let _ = path;
|
|
false
|
|
}
|
|
}
|