Skip to content

Commit

Permalink
Add the ability to parse Fontra components
Browse files Browse the repository at this point in the history
  • Loading branch information
rsheeter committed Feb 3, 2024
1 parent 0a2d01b commit ec807c1
Show file tree
Hide file tree
Showing 12 changed files with 2,378 additions and 45 deletions.
2 changes: 0 additions & 2 deletions fontra2fontir/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ fontir = { version = "0.0.1", path = "../fontir" }

write-fonts.workspace = true

percent-encoding = "2.3.1"

log.workspace = true
env_logger.workspace = true

Expand Down
248 changes: 234 additions & 14 deletions fontra2fontir/src/fontra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,105 @@
//! 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 {
let c = c as u32;
match c {
_ if c < 32 => true,
_ if c == 0x7F => true,
_ if c == SEPARATOR_CHAR as u32 => true,
_ => false,
}
}

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 mut code_digits: Vec<_> = (0..string.len())
.step_by(5)
.map(|i| {
let mut digit = 0;
let mut bit = 1;
for c in string[i..(i + 5).min(string.len())].chars() {
if c.is_ascii_uppercase() {
digit |= bit
}
bit <<= 1;
}
digit
})
.collect();
while let Some(0) = code_digits.last() {
code_digits.pop();
}

let mut filename = Vec::new();
for (i, c) in string.chars().enumerate() {
if i == 0 && c == '.' {
filename.extend("%2E".chars());
} else if !is_reserved_char(c) {
filename.push(c);
} else {
let c = c as u32;
for c in format!("{c:02X}").chars() {
filename.push(c);
}
}
}

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.into_iter().collect()
}

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>
Expand Down Expand Up @@ -66,8 +150,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>
Expand All @@ -78,7 +180,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>,
// TODO: locationBase
#[serde(default)]
pub(crate) inactive: bool,
Expand All @@ -100,6 +202,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 {
Expand All @@ -113,6 +217,7 @@ impl FontraGlyph {
#[derive(Default, Debug, Clone, Deserialize)]
#[allow(dead_code)] // TEMPORARY
pub(crate) struct FontraPath {
#[serde(default)]
pub(crate) contours: Vec<FontraContour>,
}

Expand Down Expand Up @@ -173,6 +278,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;
Expand Down Expand Up @@ -200,6 +350,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 =
Expand Down Expand Up @@ -238,9 +404,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,
Expand All @@ -249,10 +413,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();
Expand All @@ -278,4 +456,46 @@ 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"),
];
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:#?}");
}
}
Loading

0 comments on commit ec807c1

Please sign in to comment.