Skip to content
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

Merged
merged 2 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions fontra2fontir/Cargo.toml

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
261 changes: 247 additions & 14 deletions fontra2fontir/src/fontra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand All @@ -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>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this String has any specific meaning (is it supposed to be a tag, or a human readable name, or what?) a comment would be useful.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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. HashMap<AxisName, f64>, much like the <Tag, f64> version. Aside: I wish it was the tag.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
Expand All @@ -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 {
Expand All @@ -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>,
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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:#?}");
}
}
Loading
Loading