diff --git a/src/designspace.rs b/src/designspace.rs index db417368..059d465e 100644 --- a/src/designspace.rs +++ b/src/designspace.rs @@ -23,8 +23,18 @@ pub struct DesignSpaceDocument { pub axes: Vec, /// Optional avar2-style axis mappings. pub axis_mappings: Option, + /// Optional freestanding location labels. + pub location_labels: Vec, /// One or more rules. pub rules: Rules, + /// Zero or more variable fonts. + #[serde( + rename = "variable-fonts", + with = "serde_impls::variable_fonts", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub variable_fonts: Vec, /// One or more sources. pub sources: Vec, /// One or more instances. @@ -66,8 +76,63 @@ pub struct Axis { /// Localised UI strings for the axis name. #[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")] pub label_names: Vec, + /// UI strings for axis stops. + // TODO: Get rid of AxisLabels and hoist content into this struct. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option, +} + +/// ... +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AxisLabels { + #[serde(rename = "ordering", default, skip_serializing_if = "Option::is_none")] + pub axis_ordering: Option, + #[serde(with = "serde_impls::axis_label", default, skip_serializing_if = "Vec::is_empty")] + pub labels: Vec, } +/// ... +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AxisLabel { + /// ... + #[serde(rename = "@name")] + pub name: String, + /// ... + #[serde(rename = "@elidable", default, skip_serializing_if = "is_false")] + pub elidable: bool, + /// ... + #[serde(rename = "@oldersibling", default, skip_serializing_if = "is_false")] + pub older_sibling: bool, + /// ... + #[serde(rename = "@uservalue")] + pub user_value: f32, + /// ... + #[serde(rename = "@userminimum", default)] + pub user_minimum: Option, + /// ... + #[serde(rename = "@usermaximum", default)] + pub user_maximum: Option, + /// ... + #[serde(rename = "@linkeduservalue", default)] + pub linked_user_value: Option, + /// ... + #[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")] + pub label_names: Vec, +} + +/// ... +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LocationLabel { + /// ... + #[serde(rename = "@name")] + pub name: String, + /// ... + #[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")] + pub label_names: Vec, + /// ... + #[serde(with = "serde_impls::location")] + pub location: Vec, +} fn is_false(value: &bool) -> bool { !(*value) } @@ -246,6 +311,53 @@ pub struct Condition { pub maximum: Option, } +/// Describes a single variable font. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct VariableFont { + /// The name of the variable font. + #[serde(rename = "@name")] + pub name: String, + /// The optional file name of the variable font. + #[serde(rename = "@filename", default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + /// The axis subset the variable font represents. + #[serde(rename = "axis-subsets", with = "serde_impls::axis_subsets")] + pub axis_subsets: Vec, + /// Additional arbitrary user data + #[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")] + pub lib: Dictionary, +} + +/// Describes a single axis subset for a variable font. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum AxisSubset { + /// Describes a range of an axis. + Range { + /// The name of the axis under consideration. + #[serde(rename = "@name")] + name: String, + /// Optionally, the lower end of the range, in user coordinates + #[serde(rename = "@userminimum", default, skip_serializing_if = "Option::is_none")] + user_minimum: Option, + /// Optionally, the upper end of the range, in user coordinates + #[serde(rename = "@usermaximum", default, skip_serializing_if = "Option::is_none")] + user_maximum: Option, + /// Optionally, the new default value of subset axis, in user coordinates. + #[serde(rename = "@userdefault", default, skip_serializing_if = "Option::is_none")] + user_default: Option, + }, + /// Describes a single point of an axis. + Discrete { + /// The name of the axis under consideration. + #[serde(rename = "@name")] + name: String, + /// The single point of the axis. + #[serde(rename = "@uservalue")] + user_value: f64, + }, +} + /// A [source]. /// /// [source]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#id25 @@ -274,6 +386,8 @@ pub struct Source { /// Location in designspace coordinates. #[serde(with = "serde_impls::location")] pub location: Vec, + #[serde(rename = "familyname", skip_serializing_if = "Vec::is_empty")] + pub localised_familynames: Vec, } /// An [instance]. @@ -310,6 +424,8 @@ pub struct Instance { /// Arbitrary data about this instance #[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")] pub lib: Dictionary, + #[serde(rename = "@location", skip_serializing_if = "Option::is_none")] + pub named_location: Option, } /// A design space dimension. @@ -388,6 +504,8 @@ struct RawDesignSpaceDocument { format: f32, #[serde(default, skip_serializing_if = "RawAxes::is_empty")] axes: RawAxes, + #[serde(default, with = "serde_impls::location_labels", skip_serializing_if = "Vec::is_empty")] + location_labels: Vec, #[serde(default, skip_serializing_if = "Rules::is_empty")] rules: Rules, #[serde(default, with = "serde_impls::sources", skip_serializing_if = "Vec::is_empty")] @@ -401,6 +519,8 @@ struct RawDesignSpaceDocument { /// Internal container for axes and their optional mappings. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] struct RawAxes { + #[serde(default, rename = "elidedfallbackname", skip_serializing_if = "Option::is_none")] + elided_fallback_name: Option, #[serde(default, rename = "axis")] axis: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -501,6 +621,7 @@ mod serde_impls { { use serde::Deserialize as _; #[derive(::serde::Deserialize)] + #[serde(rename_all = "kebab-case")] struct Helper { $field_name: Vec<$inner>, } @@ -516,6 +637,7 @@ mod serde_impls { { use serde::Serialize as _; #[derive(::serde::Serialize)] + #[serde(rename_all = "kebab-case")] struct Helper<'a> { $field_name: &'a [$inner], } @@ -529,6 +651,11 @@ mod serde_impls { serde_from_field!(location, dimension, crate::designspace::Dimension); serde_from_field!(instances, instance, crate::designspace::Instance); serde_from_field!(sources, source, crate::designspace::Source); + serde_from_field!(variable_fonts, variable_font, crate::designspace::VariableFont); + serde_from_field!(axis_subsets, axis_subset, crate::designspace::AxisSubset); + serde_from_field!(labels, label, crate::designspace::AxisLabels); + serde_from_field!(axis_label, label, crate::designspace::AxisLabel); + serde_from_field!(location_labels, label, crate::designspace::LocationLabel); } #[cfg(test)] @@ -886,4 +1013,79 @@ mod tests { let ds = DesignSpaceDocument::load("testdata/wght.designspace").unwrap(); assert!(ds.axis_mappings.is_none()); } + + #[test] + fn load_save_round_trip_mutatorsans2() { + // Given + let dir = TempDir::new().unwrap(); + let ds_test_save_location = dir.path().join("MutatorSans2.designspace"); + + // When + let ds_initial = DesignSpaceDocument::load("testdata/MutatorSans2.designspace").unwrap(); + ds_initial.save(&ds_test_save_location).expect("failed to save designspace"); + let ds_after = DesignSpaceDocument::load(ds_test_save_location) + .expect("failed to load saved designspace"); + + let mut vf_lib = Dictionary::new(); + let mut vf_fontinfo = Dictionary::new(); + vf_fontinfo.insert("familyName".into(), "My Font Narrow VF".into()); + vf_fontinfo.insert("styleName".into(), "Regular".into()); + vf_fontinfo.insert("postscriptFontName".into(), "MyFontNarrVF-Regular".into()); + vf_fontinfo + .insert("trademark".into(), "My Font Narrow VF is a registered trademark...".into()); + vf_lib.insert("public.fontInfo".into(), vf_fontinfo.into()); + + // Then + assert_eq!( + &ds_after.axes, + &[ + Axis { + name: "width".into(), + tag: "wdth".into(), + default: 0.0, + hidden: false, + minimum: Some(0.0), + maximum: Some(1000.0), + values: None, + map: None, + label_names: vec![], + labels: vec![] + }, + Axis { + name: "weight".into(), + tag: "wght".into(), + default: 0.0, + hidden: false, + minimum: Some(0.0), + maximum: Some(1000.0), + values: None, + map: None, + label_names: vec![ + LabelName { lang: "fa-IR".into(), name: "قطر".into() }, + LabelName { lang: "en".into(), name: "Wéíght".into() }, + ], + labels: vec![] + } + ] + ); + + assert_eq!( + &ds_after.variable_fonts, + &[VariableFont { + name: "MyFontNarrVF".into(), + filename: None, + axis_subsets: vec![ + AxisSubset::Range { + name: "Weight".into(), + user_minimum: None, + user_maximum: None, + user_default: None + }, + AxisSubset::Discrete { name: "Width".into(), user_value: 75.0 }, + ], + lib: vf_lib + }] + ); + assert_eq!(ds_initial, ds_after); + } } diff --git a/src/fontinfo.rs b/src/fontinfo.rs index 2377ec49..3a362709 100644 --- a/src/fontinfo.rs +++ b/src/fontinfo.rs @@ -490,6 +490,8 @@ struct FontInfoV1 { year: Option, // Does not appear in spec but ufoLib. } +// TODO: add from_bytes method for loading fontinfo from designspace libs + impl FontInfo { pub(crate) fn from_bytes( data: &[u8], diff --git a/testdata/v5.designspace b/testdata/v5.designspace new file mode 100644 index 00000000..e14b99fc --- /dev/null +++ b/testdata/v5.designspace @@ -0,0 +1,365 @@ + + + + + Wéíght + قطر + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Montserrat + モンセラート + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + com.vtt.source + sources/vtt/Test_WghtWdth.vtt + public.fontInfo + + familyName + My Font Narrow VF + styleName + Regular + postscriptFontName + MyFontNarrVF-Regular + trademark + My Font Narrow VF is a registered trademark... + + + + + + + + + + + + com.vtt.source + sources/vtt/Test_Wght.vtt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Demigras + 半ば + Montserrat + モンセラート + Standard + Montserrat Halbfett + モンセラート SemiBold + + + + + + + + + + + + + + + + + com.coolDesignspaceApp.binaryData + + PGJpbmFyeSBndW5rPg== + + com.coolDesignspaceApp.specimenText + Hamburgerwhatever + + + + + + + + + + + + + + + + A note about this glyph + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + public.fontInfo + + openTypeNameWWSFamilyName + My Font Text + openTypeNameWWSSubfamilyName + Light + openTypeOS2WeightClass + 300 + trademark + My Font Text Light is a registered trademark... + openTypeNameRecords + + + encodingID + 1 + languageID + 1031 + nameID + 7 + platformID + 3 + string + Meine Schrift Text Leicht ist eine registrierte Marke... + + + + + + + + + + + com.coolDesignspaceApp.previewSize + 30 + + +