-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Start to parse Fontra components #701
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,21 +3,116 @@ | |
//! See <https://github.com/googlefonts/fontra/blob/main/src/fontra/core/classes.py> | ||
|
||
use std::{ | ||
collections::HashMap, | ||
collections::{BTreeMap, HashMap}, | ||
fs, | ||
path::{Path, PathBuf}, | ||
}; | ||
|
||
use fontdrasil::types::GlyphName; | ||
use fontir::error::Error; | ||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; | ||
use serde::Deserialize; | ||
use write_fonts::types::Tag; | ||
|
||
const SEPARATOR_CHAR: char = '^'; | ||
|
||
fn is_reserved_char(c: char) -> bool { | ||
matches!( | ||
c, | ||
'\0'..='\x1F' | ||
| '\x7F' | ||
| SEPARATOR_CHAR | ||
| '>' | ||
| '|' | ||
| '[' | ||
| '?' | ||
| '+' | ||
| '\\' | ||
| '"' | ||
| ':' | ||
| '/' | ||
| '<' | ||
| '%' | ||
| ']' | ||
| '*' | ||
) | ||
} | ||
|
||
fn is_reserved_filename(name: &str) -> bool { | ||
matches!( | ||
name.to_ascii_uppercase().as_str(), | ||
"CON" | ||
| "PRN" | ||
| "AUX" | ||
| "CLOCK$" | ||
| "NUL" | ||
| "COM1" | ||
| "LPT1" | ||
| "LPT2" | ||
| "LPT3" | ||
| "COM2" | ||
| "COM3" | ||
| "COM4" | ||
) | ||
} | ||
|
||
const BASE_32_CHARS: [char; 32] = [ | ||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', | ||
'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', | ||
]; | ||
|
||
/// Matches <https://github.com/googlefonts/fontra/blob/15bc0b8401054390484cfb86d509d633d29657a1/src/fontra/backends/filenames.py#L40-L64> | ||
fn string_to_filename(string: &str, suffix: &str) -> String { | ||
let string_bytes = string.as_bytes(); | ||
let mut code_digits: Vec<_> = (0..string_bytes.len()) | ||
.step_by(5) | ||
.map(|i| { | ||
let mut digit = 0; | ||
let mut bit = 1; | ||
let string = string.as_bytes(); | ||
for byte in string[i..(i + 5).min(string.len())].iter() { | ||
if byte.is_ascii_uppercase() { | ||
digit |= bit | ||
} | ||
bit <<= 1; | ||
} | ||
digit | ||
}) | ||
.collect(); | ||
while let Some(0) = code_digits.last() { | ||
code_digits.pop(); | ||
} | ||
|
||
let mut filename = String::new(); | ||
for (i, c) in string.chars().enumerate() { | ||
if i == 0 && c == '.' { | ||
filename.push_str("%2E"); | ||
} else if !is_reserved_char(c) { | ||
filename.push(c); | ||
} else { | ||
filename.push_str(format!("%{:02X}", c as u32).as_str()); | ||
} | ||
} | ||
|
||
if code_digits.is_empty() && is_reserved_filename(string) { | ||
code_digits.push(0); | ||
} | ||
|
||
if !code_digits.is_empty() { | ||
filename.push(SEPARATOR_CHAR); | ||
for d in code_digits { | ||
assert!(d < 32, "We've made a terrible mistake"); | ||
filename.push(BASE_32_CHARS[d]); | ||
} | ||
} | ||
|
||
for c in suffix.chars() { | ||
filename.push(c); | ||
} | ||
filename | ||
} | ||
|
||
pub(crate) fn glyph_file(glyph_dir: &Path, glyph: GlyphName) -> PathBuf { | ||
// TODO(https://github.com/googlefonts/fontc/issues/688): fontra string => filename algorithm | ||
let filename = utf8_percent_encode(glyph.as_str(), NON_ALPHANUMERIC); | ||
glyph_dir.join(filename.to_string() + ".json") | ||
glyph_dir.join(string_to_filename(glyph.as_str(), ".json")) | ||
} | ||
|
||
fn from_file<T>(p: &Path) -> Result<T, Error> | ||
|
@@ -66,8 +161,26 @@ pub(crate) struct FontraAxis { | |
#[allow(dead_code)] // TEMPORARY | ||
pub(crate) struct FontraGlyph { | ||
pub(crate) name: GlyphName, | ||
/// Variable component, or glyph-local, axes | ||
#[serde(default)] | ||
pub(crate) axes: Vec<FontraGlyphAxis>, | ||
pub(crate) sources: Vec<FontraSource>, | ||
pub(crate) layers: HashMap<String, FontraLayer>, | ||
pub(crate) layers: BTreeMap<String, FontraLayer>, | ||
} | ||
|
||
/// An axis specific to a glyph meant to be used as a variable component | ||
/// | ||
/// <https://github.com/googlefonts/fontra/blob/15bc0b8401054390484cfb86d509d633d29657a1/src/fontra/core/classes.py#L96-L101> | ||
#[derive(Debug, Clone, Deserialize)] | ||
#[allow(dead_code)] // TEMPORARY | ||
pub(crate) struct FontraGlyphAxis { | ||
pub(crate) name: String, | ||
#[serde(rename = "minValue")] | ||
pub(crate) min_value: f64, | ||
#[serde(rename = "defaultValue")] | ||
pub(crate) default_value: f64, | ||
#[serde(rename = "maxValue")] | ||
pub(crate) max_value: f64, | ||
} | ||
|
||
/// <https://github.com/googlefonts/fontra/blob/a4edd06837118e583804fd963c22ed806a315b04/src/fontra/core/classes.py#L119-L126> | ||
|
@@ -78,7 +191,7 @@ pub(crate) struct FontraSource { | |
#[serde(rename = "layerName")] | ||
pub(crate) layer_name: String, | ||
#[serde(default)] | ||
pub(crate) location: HashMap<Tag, f64>, | ||
pub(crate) location: HashMap<String, f64>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it'll help if I newtype some of the name fields that are used as identifiers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will do this in a followon |
||
// TODO: locationBase | ||
#[serde(default)] | ||
pub(crate) inactive: bool, | ||
|
@@ -100,6 +213,8 @@ pub(crate) struct FontraGlyphInstance { | |
// TODO: Fontra has two representations, packed and unpacked. This only covers one. | ||
#[serde(default)] | ||
pub(crate) path: FontraPath, | ||
#[serde(default)] | ||
pub(crate) components: Vec<FontraComponent>, | ||
} | ||
|
||
impl FontraGlyph { | ||
|
@@ -113,6 +228,7 @@ impl FontraGlyph { | |
#[derive(Default, Debug, Clone, Deserialize)] | ||
#[allow(dead_code)] // TEMPORARY | ||
pub(crate) struct FontraPath { | ||
#[serde(default)] | ||
pub(crate) contours: Vec<FontraContour>, | ||
} | ||
|
||
|
@@ -173,6 +289,51 @@ impl PointType { | |
} | ||
} | ||
|
||
/// <https://github.com/googlefonts/fontra/blob/a4edd06837118e583804fd963c22ed806a315b04/src/fontra/core/classes.py#L154-L158> | ||
#[derive(Debug, Clone, Deserialize)] | ||
#[allow(dead_code)] // TEMPORARY | ||
pub(crate) struct FontraComponent { | ||
pub(crate) name: GlyphName, | ||
#[serde(default)] | ||
pub(crate) transformation: FontraTransform, | ||
// This location is in terms of axes defined by the referenced glyph | ||
#[serde(default)] | ||
pub(crate) location: HashMap<String, f64>, | ||
} | ||
|
||
/// What FontTools calls a DecomposedTransform | ||
/// | ||
/// <https://github.com/fonttools/fonttools/blob/0572f7871823bdef3ceceaf41dedd0a6bd100995/Lib/fontTools/misc/transform.py#L410-L424> | ||
#[derive(Default, Debug, Clone, Deserialize)] | ||
#[allow(dead_code)] // TEMPORARY | ||
pub(crate) struct FontraTransform { | ||
#[serde(rename = "translateX", default)] | ||
translate_x: f64, | ||
#[serde(rename = "translateY", default)] | ||
translate_y: f64, | ||
/// in degrees counter-clockwise in font coordinate space | ||
#[serde(default)] | ||
rotation: f64, | ||
#[serde(rename = "scaleX", default = "float_one")] | ||
scale_x: f64, | ||
#[serde(rename = "scaleY", default = "float_one")] | ||
scale_y: f64, | ||
/// in degrees clockwise in font coordinate space | ||
#[serde(rename = "skewX", default)] | ||
skew_x: f64, | ||
/// in degrees counter-clockwise in font coordinate space | ||
#[serde(rename = "skewY", default)] | ||
skew_y: f64, | ||
#[serde(rename = "tCenterX", default)] | ||
t_center_x: f64, | ||
#[serde(rename = "tCenterY", default)] | ||
t_center_y: f64, | ||
} | ||
|
||
fn float_one() -> f64 { | ||
1.0 | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use std::collections::HashSet; | ||
|
@@ -200,6 +361,22 @@ mod tests { | |
.collect::<Vec<_>>() | ||
} | ||
|
||
fn glyph_axis_tuples(glyph: &FontraGlyph) -> Vec<(&str, f64, f64, f64)> { | ||
glyph | ||
.axes | ||
.iter() | ||
.map(|a| (a.name.as_str(), a.min_value, a.default_value, a.max_value)) | ||
.collect::<Vec<_>>() | ||
} | ||
|
||
fn read_test_glyph(fontra_dir: &str, glyph_name: &str) -> FontraGlyph { | ||
let file = testdata_dir() | ||
.join(fontra_dir) | ||
.join("glyphs") | ||
.join(string_to_filename(glyph_name, ".json")); | ||
FontraGlyph::from_file(&file).unwrap_or_else(|e| panic!("Unable to read {file:?}: {e}")) | ||
} | ||
|
||
#[test] | ||
fn fontdata_of_minimal() { | ||
let font_data = | ||
|
@@ -238,9 +415,7 @@ mod tests { | |
|
||
#[test] | ||
fn read_notdef() { | ||
let glyph = | ||
FontraGlyph::from_file(&testdata_dir().join("minimal.fontra/glyphs/%2Enotdef.json")) | ||
.unwrap(); | ||
let glyph = read_test_glyph("minimal.fontra", ".notdef"); | ||
assert_eq!(GlyphName::new(".notdef"), glyph.name, "{glyph:#?}"); | ||
assert_eq!( | ||
1000.0, glyph.layers["foreground"].glyph.x_advance, | ||
|
@@ -249,10 +424,24 @@ mod tests { | |
} | ||
|
||
#[test] | ||
fn read_u20089() { | ||
let glyph = | ||
FontraGlyph::from_file(&testdata_dir().join("2glyphs.fontra/glyphs/u20089.json")) | ||
.unwrap(); | ||
fn read_varc_axes() { | ||
let glyph = read_test_glyph("component.fontra", "VG_4E00_00"); | ||
assert_eq!( | ||
vec![ | ||
("width", 200.0, 1000.0, 1000.0), | ||
("weight", 10.0, 33.0, 50.0), | ||
("serif_height", -50.0, 0.0, 100.0), | ||
("serif_width", -100.0, 0.0, 100.0), | ||
("serif_top_moveH", -50.0, 0.0, 50.0), | ||
("H_L_length", -800.0, 0.0, 800.0), | ||
], | ||
glyph_axis_tuples(&glyph), | ||
) | ||
} | ||
|
||
#[test] | ||
fn read_simple_contours() { | ||
let glyph = read_test_glyph("2glyphs.fontra", "u20089"); | ||
assert_eq!(GlyphName::new("u20089"), glyph.name, "{glyph:#?}"); | ||
let mut layer_names: Vec<_> = glyph.layers.keys().map(|n| n.as_str()).collect(); | ||
layer_names.sort(); | ||
|
@@ -278,4 +467,48 @@ mod tests { | |
contour.points[2].point_type().unwrap() | ||
); | ||
} | ||
|
||
#[test] | ||
fn read_simple_component() { | ||
let glyph = read_test_glyph("component.fontra", "uni4E00"); | ||
assert_eq!(GlyphName::new("uni4E00"), glyph.name, "{glyph:#?}"); | ||
assert_eq!( | ||
vec![ | ||
("foreground".to_string(), GlyphName::new("VG_4E00_00")), | ||
("wght=1".to_string(), GlyphName::new("VG_4E00_00")) | ||
], | ||
glyph | ||
.layers | ||
.iter() | ||
.flat_map(|(n, l)| l | ||
.glyph | ||
.components | ||
.iter() | ||
.map(|c| (n.clone(), c.name.clone()))) | ||
.collect::<Vec<_>>(), | ||
); | ||
} | ||
|
||
#[test] | ||
fn match_python_string_to_filename() { | ||
// expected is as observed in Python with .json appended | ||
let exemplars = vec![ | ||
("AUX", "AUX^7.json"), | ||
(".notdef", "%2Enotdef.json"), | ||
("4E00", "4E00^2.json"), | ||
("VG_4E00_01", "VG_4E00_01^J.json"), | ||
("duck:goose/mallard", "duck%3Agoose%2Fmallard.json"), | ||
("Hi ❤️🔥 hru", "Hi ❤️\u{200d}🔥 hru^1.json"), | ||
]; | ||
let mut errors = Vec::new(); | ||
for (input, expected) in exemplars { | ||
let actual = string_to_filename(input, ".json"); | ||
if expected != actual { | ||
errors.push(format!( | ||
"\"{input}\" should convert to \"{expected}\" not \"{actual}\"" | ||
)); | ||
} | ||
} | ||
assert_eq!(0, errors.len(), "{errors:#?}"); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank