diff --git a/.typos.toml b/.typos.toml index 60c3d8ae..04f9d678 100644 --- a/.typos.toml +++ b/.typos.toml @@ -16,6 +16,7 @@ wdth = "wdth" # Match Inside a Word - Case Insensitive [default.extend-words] +wdth = "wdth" [files] # Include .github, .cargo, etc. diff --git a/src/designspace.rs b/src/designspace.rs index 40c0efe6..d482a6fc 100644 --- a/src/designspace.rs +++ b/src/designspace.rs @@ -18,15 +18,30 @@ use crate::Name; #[serde(from = "RawDesignSpaceDocument", into = "RawDesignSpaceDocument")] pub struct DesignSpaceDocument { /// Design space format version. + /// + /// This is the version that was declared by the document that was loaded. + /// When saving, the *minimum* format version required to represent the + /// contents of this document is written out instead, matching the + /// behaviour of fontTools. pub format: f32, /// One or more axes. pub axes: Vec, /// Optional avar2-style axis mappings. pub axis_mappings: Option, + /// The style name to use when all STAT labels of a location are elided. + /// + /// This is the `elidedfallbackname` attribute of the `` element. + pub elided_fallback_name: Option, + /// Labels for freestanding locations, the analogue of STAT format 4 entries. + pub location_labels: Vec, /// One or more rules. pub rules: Rules, /// One or more sources. pub sources: Vec, + /// The variable fonts that can be built from this designspace. + /// + /// If this is empty, the whole designspace describes a single output. + pub variable_fonts: Vec, /// One or more instances. pub instances: Vec, /// Additional arbitrary user data @@ -37,37 +52,361 @@ pub struct DesignSpaceDocument { /// /// [axis]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#axis-element #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename = "axis")] +#[serde(from = "RawAxis", into = "RawAxis")] pub struct Axis { /// Name of the axis that is used in the location elements. - #[serde(rename = "@name")] pub name: String, /// 4 letters. Some axis tags are registered in the OpenType Specification. - #[serde(rename = "@tag")] pub tag: String, /// The default value for this axis, in user space coordinates. - #[serde(rename = "@default")] pub default: f64, /// Records whether this axis needs to be hidden in interfaces. - #[serde(default, rename = "@hidden", skip_serializing_if = "is_false")] pub hidden: bool, /// The minimum value for a continuous axis, in user space coordinates. - #[serde(rename = "@minimum", skip_serializing_if = "Option::is_none")] pub minimum: Option, /// The maximum value for a continuous axis, in user space coordinates. - #[serde(rename = "@maximum", skip_serializing_if = "Option::is_none")] pub maximum: Option, /// The possible values for a discrete axis, in user space coordinates. - #[serde(rename = "@values", skip_serializing_if = "Option::is_none")] pub values: Option>, /// Mapping between user space coordinates and design space coordinates. - #[serde(skip_serializing_if = "Option::is_none")] pub map: Option>, /// Localised UI strings for the axis name. + pub label_names: Vec, + /// The STAT ordering of this axis (``). + pub axis_ordering: Option, + /// The STAT labels for this axis. + pub axis_labels: Vec, +} + +/// Internal struct matching the XML structure of an `` element. +/// +/// [`Axis`] hoists the contents of the nested `` element up into the +/// axis itself; this type mirrors the on-disk nesting. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename = "axis")] +struct RawAxis { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@tag")] + tag: String, + #[serde(rename = "@default")] + default: f64, + #[serde(default, rename = "@hidden", skip_serializing_if = "is_false")] + hidden: bool, + #[serde(rename = "@minimum", skip_serializing_if = "Option::is_none")] + minimum: Option, + #[serde(rename = "@maximum", skip_serializing_if = "Option::is_none")] + maximum: Option, + #[serde(rename = "@values", skip_serializing_if = "Option::is_none")] + values: Option>, + #[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")] + label_names: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + map: Option>, + #[serde(default, skip_serializing_if = "RawAxisLabels::is_empty")] + labels: RawAxisLabels, +} + +/// Internal struct matching the `` element of an ``. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +struct RawAxisLabels { + #[serde(rename = "@ordering", default, skip_serializing_if = "Option::is_none")] + ordering: Option, + #[serde(rename = "label", default)] + labels: Vec, +} + +impl RawAxisLabels { + fn is_empty(&self) -> bool { + self.ordering.is_none() && self.labels.is_empty() + } +} + +impl From for Axis { + fn from(raw: RawAxis) -> Self { + Axis { + name: raw.name, + tag: raw.tag, + default: raw.default, + hidden: raw.hidden, + minimum: raw.minimum, + maximum: raw.maximum, + values: raw.values, + map: raw.map, + label_names: raw.label_names, + axis_ordering: raw.labels.ordering, + axis_labels: raw.labels.labels, + } + } +} + +impl From for RawAxis { + fn from(axis: Axis) -> Self { + RawAxis { + name: axis.name, + tag: axis.tag, + default: axis.default, + hidden: axis.hidden, + minimum: axis.minimum, + maximum: axis.maximum, + values: axis.values, + label_names: axis.label_names, + map: axis.map, + labels: RawAxisLabels { ordering: axis.axis_ordering, labels: axis.axis_labels }, + } + } +} + +/// A STAT [axis label]. +/// +/// ```xml +/// +/// +/// +/// +/// +/// ``` +/// +/// [axis label]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#label-element-axis +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct AxisLabel { + /// The name of this label. + #[serde(rename = "@name")] + pub name: String, + /// The value of this label, in user coordinates. + #[serde(rename = "@uservalue")] + pub user_value: f64, + /// The lower end of the range this label applies to, in user coordinates. + #[serde(rename = "@userminimum", default, skip_serializing_if = "Option::is_none")] + pub user_minimum: Option, + /// The upper end of the range this label applies to, in user coordinates. + #[serde(rename = "@usermaximum", default, skip_serializing_if = "Option::is_none")] + pub user_maximum: Option, + /// Whether this label can be omitted from the style name. + #[serde(rename = "@elidable", default, skip_serializing_if = "is_false")] + pub elidable: bool, + /// Whether this label should sort before its siblings in the STAT table. + #[serde(rename = "@oldersibling", default, skip_serializing_if = "is_false")] + pub older_sibling: bool, + /// The user value of the label this one is linked to, e.g. by bold linking. + #[serde(rename = "@linkeduservalue", default, skip_serializing_if = "Option::is_none")] + pub linked_user_value: Option, + /// Localised UI strings for this label's name. #[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")] pub label_names: Vec, } +/// A [location label], which names a freestanding location. +/// +/// These are the analogue of STAT format 4 entries. +/// +/// ```xml +/// +/// +/// +/// ``` +/// +/// [location label]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#label-element-top-level +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LocationLabel { + /// The name of this label. + #[serde(rename = "@name")] + pub name: String, + /// Whether this label can be omitted from the style name. + #[serde(rename = "@elidable", default, skip_serializing_if = "is_false")] + pub elidable: bool, + /// Whether this label should sort before its siblings in the STAT table. + #[serde(rename = "@oldersibling", default, skip_serializing_if = "is_false")] + pub older_sibling: bool, + /// Localised UI strings for this label's name. + #[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")] + pub label_names: Vec, + /// The location this label names, in user coordinates (`uservalue`). + #[serde(default, with = "serde_impls::location", skip_serializing_if = "Vec::is_empty")] + pub location: Vec, +} + +/// A [variable font] that can be built from this designspace. +/// +/// ```xml +/// +/// +/// +/// +/// +/// +/// +/// +/// ... +/// +/// +/// +/// ``` +/// +/// [variable font]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#variable-font-element +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct VariableFont { + /// The name of this variable font. + #[serde(rename = "@name")] + pub name: String, + /// The file name of this variable font, if it differs from the name. + #[serde(rename = "@filename", default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + /// The subset of the designspace's axes that this font covers. + #[serde( + rename = "axis-subsets", + default, + with = "serde_impls::axis_subsets", + skip_serializing_if = "Vec::is_empty" + )] + pub axis_subsets: Vec, + /// Additional arbitrary user data. + #[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")] + pub lib: Dictionary, +} + +/// An [axis subset] of a variable font. +/// +/// An `` element covers either a range of the axis (by default +/// the whole axis) or a single value on it, distinguished by its attributes; +/// see [`SubsetValue`]. +/// +/// ```xml +/// +/// +/// +/// +/// ``` +/// +/// [axis subset]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#axis-subset-element +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "RawAxisSubset", into = "RawAxisSubset")] +pub struct AxisSubset { + /// The name of the axis being subset. + pub name: String, + /// How much of the axis is included in the variable font. + pub value: SubsetValue, +} + +/// The portion of an axis included in a variable font. +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum SubsetValue { + /// A range of the axis, in user coordinates. + /// + /// This is the `userminimum`/`userdefault`/`usermaximum` form. Each value + /// is optional, and missing values are taken from the parent axis; if all + /// are missing, the subset is the whole axis (see [`SubsetValue::is_full`]). + Range { + /// The lower end of the range, in user coordinates. + /// + /// If missing, this is the axis minimum. + minimum: Option, + /// The default value, in user coordinates. + /// + /// If missing, this is the axis default, clamped to the range. + default: Option, + /// The upper end of the range, in user coordinates. + /// + /// If missing, this is the axis maximum. + maximum: Option, + }, + /// A single point on the axis, in user coordinates (`uservalue`). + Discrete(f64), +} + +impl Default for SubsetValue { + fn default() -> Self { + SubsetValue::Range { minimum: None, default: None, maximum: None } + } +} + +impl SubsetValue { + /// Returns `true` if this subset covers the whole axis. + /// + /// This is the case when it is a [`SubsetValue::Range`] with no values + /// set, which is written as an `` with only a name. + pub fn is_full(&self) -> bool { + *self == SubsetValue::default() + } +} + +/// Internal struct matching the XML structure of an `` element. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +struct RawAxisSubset { + #[serde(rename = "@name")] + name: String, + #[serde(rename = "@userminimum", default, skip_serializing_if = "Option::is_none")] + user_minimum: Option, + #[serde(rename = "@userdefault", default, skip_serializing_if = "Option::is_none")] + user_default: Option, + #[serde(rename = "@usermaximum", default, skip_serializing_if = "Option::is_none")] + user_maximum: Option, + #[serde(rename = "@uservalue", default, skip_serializing_if = "Option::is_none")] + user_value: Option, +} + +/// An error encountered while interpreting an `` element. +#[derive(Clone, Debug, PartialEq)] +struct AxisSubsetError(&'static str); + +impl std::fmt::Display for AxisSubsetError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + +impl TryFrom for AxisSubset { + type Error = AxisSubsetError; + + fn try_from(raw: RawAxisSubset) -> Result { + let RawAxisSubset { name, user_minimum, user_default, user_maximum, user_value } = raw; + let value = match user_value { + Some(user_value) => { + if user_minimum.is_some() || user_default.is_some() || user_maximum.is_some() { + return Err(AxisSubsetError( + "axis-subset element with uservalue must not have \ + userminimum/userdefault/usermaximum", + )); + } + SubsetValue::Discrete(user_value) + } + None => SubsetValue::Range { + minimum: user_minimum, + default: user_default, + maximum: user_maximum, + }, + }; + Ok(AxisSubset { name, value }) + } +} + +impl From for RawAxisSubset { + fn from(subset: AxisSubset) -> Self { + let AxisSubset { name, value } = subset; + match value { + SubsetValue::Range { minimum, default, maximum } => RawAxisSubset { + name, + user_minimum: minimum, + user_default: default, + user_maximum: maximum, + user_value: None, + }, + SubsetValue::Discrete(user_value) => { + RawAxisSubset { name, user_value: Some(user_value), ..Default::default() } + } + } + } +} + fn is_false(value: &bool) -> bool { !(*value) } @@ -271,8 +610,11 @@ pub struct Source { /// If no layer attribute is given assume the foreground layer should be used. #[serde(rename = "@layer", skip_serializing_if = "Option::is_none")] pub layer: Option, + /// Localised UI strings for the family name of the source font. + #[serde(rename = "familyname", default, skip_serializing_if = "Vec::is_empty")] + pub localised_family_names: Vec, /// Location in designspace coordinates. - #[serde(with = "serde_impls::location")] + #[serde(default, with = "serde_impls::location", skip_serializing_if = "Vec::is_empty")] pub location: Vec, } @@ -280,38 +622,141 @@ pub struct Source { /// /// [instance]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#instance-element #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename = "instance")] +#[serde(try_from = "RawInstance", into = "RawInstance")] pub struct Instance { // per @anthrotype, contrary to spec, filename, familyname and stylename are optional /// The family name of the instance font. Corresponds with font.info.familyName - #[serde(rename = "@familyname", skip_serializing_if = "Option::is_none")] pub familyname: Option, /// The style name of the instance font. Corresponds with font.info.styleName - #[serde(rename = "@stylename", skip_serializing_if = "Option::is_none")] pub stylename: Option, /// A unique name that can be used to identify this font if it needs to be referenced elsewhere. - #[serde(rename = "@name", skip_serializing_if = "Option::is_none")] pub name: Option, /// A path to the instance file, relative to the root path of this document. The path can be at the same level as the document or lower. - #[serde(rename = "@filename", skip_serializing_if = "Option::is_none")] pub filename: Option, /// Corresponds with font.info.postscriptFontName - #[serde(rename = "@postscriptfontname", skip_serializing_if = "Option::is_none")] pub postscriptfontname: Option, /// Corresponds with styleMapFamilyName - #[serde(rename = "@stylemapfamilyname", skip_serializing_if = "Option::is_none")] pub stylemapfamilyname: Option, /// Corresponds with styleMapStyleName - #[serde(rename = "@stylemapstylename", skip_serializing_if = "Option::is_none")] pub stylemapstylename: Option, + /// The name of a [`LocationLabel`] that gives this instance's location. + /// + /// This is mutually exclusive with [`location`][Self::location]: loading a + /// document that specifies both is an error (as in fontTools), and when + /// saving, `location` is omitted if `location_label` is set. + pub location_label: Option, + /// Localised UI strings for the style name of the instance font. + pub localised_style_names: Vec, + /// Localised UI strings for the family name of the instance font. + pub localised_family_names: Vec, + /// Localised UI strings for the styleMapStyleName of the instance font. + pub localised_style_map_style_names: Vec, + /// Localised UI strings for the styleMapFamilyName of the instance font. + pub localised_style_map_family_names: Vec, /// Location in designspace. - #[serde(with = "serde_impls::location")] pub location: Vec, /// Arbitrary data about this instance - #[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")] pub lib: Dictionary, } +/// Internal (de)serialization helper for [`Instance`] that enforces the +/// exclusivity of the `location` attribute and the `` element. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename = "instance")] +struct RawInstance { + #[serde(rename = "@familyname", skip_serializing_if = "Option::is_none")] + familyname: Option, + #[serde(rename = "@stylename", skip_serializing_if = "Option::is_none")] + stylename: Option, + #[serde(rename = "@name", skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(rename = "@filename", skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(rename = "@postscriptfontname", skip_serializing_if = "Option::is_none")] + postscriptfontname: Option, + #[serde(rename = "@stylemapfamilyname", skip_serializing_if = "Option::is_none")] + stylemapfamilyname: Option, + #[serde(rename = "@stylemapstylename", skip_serializing_if = "Option::is_none")] + stylemapstylename: Option, + #[serde(rename = "@location", default, skip_serializing_if = "Option::is_none")] + location_label: Option, + #[serde(rename = "stylename", default, skip_serializing_if = "Vec::is_empty")] + localised_style_names: Vec, + #[serde(rename = "familyname", default, skip_serializing_if = "Vec::is_empty")] + localised_family_names: Vec, + #[serde(rename = "stylemapstylename", default, skip_serializing_if = "Vec::is_empty")] + localised_style_map_style_names: Vec, + #[serde(rename = "stylemapfamilyname", default, skip_serializing_if = "Vec::is_empty")] + localised_style_map_family_names: Vec, + #[serde(default, with = "serde_impls::location", skip_serializing_if = "Vec::is_empty")] + location: Vec, + #[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")] + lib: Dictionary, +} + +impl TryFrom for Instance { + type Error = InstanceError; + + fn try_from(raw: RawInstance) -> Result { + if raw.location_label.is_some() && !raw.location.is_empty() { + return Err(InstanceError( + "instance element must have at most one of the location attribute or the location element", + )); + } + Ok(Instance { + familyname: raw.familyname, + stylename: raw.stylename, + name: raw.name, + filename: raw.filename, + postscriptfontname: raw.postscriptfontname, + stylemapfamilyname: raw.stylemapfamilyname, + stylemapstylename: raw.stylemapstylename, + location_label: raw.location_label, + localised_style_names: raw.localised_style_names, + localised_family_names: raw.localised_family_names, + localised_style_map_style_names: raw.localised_style_map_style_names, + localised_style_map_family_names: raw.localised_style_map_family_names, + location: raw.location, + lib: raw.lib, + }) + } +} + +impl From for RawInstance { + fn from(instance: Instance) -> Self { + // Match fontTools: a location label takes precedence over an explicit + // location. + let location = + if instance.location_label.is_some() { Vec::new() } else { instance.location }; + RawInstance { + familyname: instance.familyname, + stylename: instance.stylename, + name: instance.name, + filename: instance.filename, + postscriptfontname: instance.postscriptfontname, + stylemapfamilyname: instance.stylemapfamilyname, + stylemapstylename: instance.stylemapstylename, + location_label: instance.location_label, + localised_style_names: instance.localised_style_names, + localised_family_names: instance.localised_family_names, + localised_style_map_style_names: instance.localised_style_map_style_names, + localised_style_map_family_names: instance.localised_style_map_family_names, + location, + lib: instance.lib, + } + } +} + +/// Error produced when an `` element is malformed. +#[derive(Debug)] +struct InstanceError(&'static str); + +impl std::fmt::Display for InstanceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + /// A design space dimension. /// /// [design space location]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#location-element-source @@ -371,6 +816,30 @@ impl DesignSpaceDocument { buf.push('\n'); // trailing newline Ok(buf) } + + /// The minimum format version required to represent this document. + /// + /// Mirrors fontTools' `_getEffectiveFormatTuple`: the declared format is + /// used unless the document contains features that require a newer one. + fn effective_format(&self) -> f32 { + let mut format = self.format; + let needs_v5 = self.axes.iter().any(|axis| { + axis.values.is_some() || axis.axis_ordering.is_some() || !axis.axis_labels.is_empty() + }) || !self.location_labels.is_empty() + || !self.variable_fonts.is_empty() + || self.sources.iter().any(|source| !source.localised_family_names.is_empty()) + || self.instances.iter().any(|instance| { + instance.location_label.is_some() + || instance.location.iter().any(|dim| dim.uservalue.is_some()) + }); + if needs_v5 { + format = format.max(5.0); + } + if self.axis_mappings.as_ref().is_some_and(|mappings| !mappings.is_empty()) { + format = format.max(5.1); + } + format + } } impl Rules { @@ -380,18 +849,37 @@ impl Rules { } } +/// Always write the format as `major.minor`, e.g. `5.0` rather than `5`. +fn serialize_format(format: &f32, serializer: S) -> Result { + serializer.serialize_str(&format!("{format:.1}")) +} + /// Internal struct matching the XML structure for (de)serialization. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename = "designspace")] struct RawDesignSpaceDocument { - #[serde(rename = "@format")] + #[serde(rename = "@format", serialize_with = "serialize_format")] format: f32, #[serde(default, skip_serializing_if = "RawAxes::is_empty")] axes: RawAxes, + #[serde( + rename = "labels", + 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")] sources: Vec, + #[serde( + rename = "variable-fonts", + default, + with = "serde_impls::variable_fonts", + skip_serializing_if = "Vec::is_empty" + )] + variable_fonts: Vec, #[serde(default, with = "serde_impls::instances", skip_serializing_if = "Vec::is_empty")] instances: Vec, #[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")] @@ -401,6 +889,8 @@ struct RawDesignSpaceDocument { /// Internal container for axes and their optional mappings. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] struct RawAxes { + #[serde(rename = "@elidedfallbackname", default, skip_serializing_if = "Option::is_none")] + elided_fallback_name: Option, #[serde(default, rename = "axis")] axis: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -409,7 +899,9 @@ struct RawAxes { impl RawAxes { fn is_empty(&self) -> bool { - self.axis.is_empty() && self.mappings.as_ref().is_none_or(|m| m.is_empty()) + self.elided_fallback_name.is_none() + && self.axis.is_empty() + && self.mappings.as_ref().is_none_or(|m| m.is_empty()) } } @@ -419,8 +911,11 @@ impl From for DesignSpaceDocument { format: raw.format, axes: raw.axes.axis, axis_mappings: raw.axes.mappings, + elided_fallback_name: raw.axes.elided_fallback_name, + location_labels: raw.location_labels, rules: raw.rules, sources: raw.sources, + variable_fonts: raw.variable_fonts, instances: raw.instances, lib: raw.lib, } @@ -429,11 +924,18 @@ impl From for DesignSpaceDocument { impl From for RawDesignSpaceDocument { fn from(doc: DesignSpaceDocument) -> Self { + let format = doc.effective_format(); Self { - format: doc.format, - axes: RawAxes { axis: doc.axes, mappings: doc.axis_mappings }, + format, + axes: RawAxes { + elided_fallback_name: doc.elided_fallback_name, + axis: doc.axes, + mappings: doc.axis_mappings, + }, + location_labels: doc.location_labels, rules: doc.rules, sources: doc.sources, + variable_fonts: doc.variable_fonts, instances: doc.instances, lib: doc.lib, } @@ -494,6 +996,14 @@ mod serde_impls { /// ``` macro_rules! serde_from_field { ($mod_name:ident, $field_name:ident, $inner:path) => { + serde_from_field!(@impl $mod_name, $field_name, $inner); + }; + // The `$field_name = "xml-name"` form is for element names that are not + // valid Rust identifiers, such as `variable-font`. + ($mod_name:ident, $field_name:ident = $xml_name:literal, $inner:path) => { + serde_from_field!(@impl $mod_name, $field_name, $inner, $xml_name); + }; + (@impl $mod_name:ident, $field_name:ident, $inner:path $(, $xml_name:literal)?) => { pub(super) mod $mod_name { pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where @@ -502,6 +1012,8 @@ mod serde_impls { use serde::Deserialize as _; #[derive(::serde::Deserialize)] struct Helper { + $(#[serde(rename = $xml_name)])? + #[serde(default)] $field_name: Vec<$inner>, } Helper::deserialize(deserializer).map(|x| x.$field_name) @@ -517,6 +1029,7 @@ mod serde_impls { use serde::Serialize as _; #[derive(::serde::Serialize)] struct Helper<'a> { + $(#[serde(rename = $xml_name)])? $field_name: &'a [$inner], } let helper = Helper { $field_name }; @@ -529,6 +1042,13 @@ 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!(location_labels, label, crate::designspace::LocationLabel); + serde_from_field!( + variable_fonts, + variable_font = "variable-font", + crate::designspace::VariableFont + ); + serde_from_field!(axis_subsets, axis_subset = "axis-subset", crate::designspace::AxisSubset); } #[cfg(test)] @@ -965,4 +1485,525 @@ mod tests { assert!(saved.contains(r#"xvalue="35.329171""#)); assert!(saved.contains(r#"xvalue="854.834192""#)); } + + fn dim_uservalue(name: &str, uservalue: f64) -> Dimension { + Dimension { name: name.to_string(), uservalue: Some(uservalue), xvalue: None, yvalue: None } + } + + fn lstr(lang: &str, s: &str) -> LocalizedString { + LocalizedString { language: lang.into(), string: s.into() } + } + + fn label(name: &str, user_value: f64) -> AxisLabel { + AxisLabel { name: name.into(), user_value, ..Default::default() } + } + + fn ranged(label: AxisLabel, min: f64, max: f64) -> AxisLabel { + AxisLabel { user_minimum: Some(min), user_maximum: Some(max), ..label } + } + + fn subset(name: &str, value: SubsetValue) -> AxisSubset { + AxisSubset { name: name.into(), value } + } + + #[test] + fn v5_axes_and_labels() { + let ds = DesignSpaceDocument::load("testdata/v5.designspace").unwrap(); + + assert_eq!(ds.elided_fallback_name.as_deref(), Some("Regular")); + assert_eq!(ds.format, 5.1); + + // Weight axis. + let weight = &ds.axes[0]; + assert_eq!( + weight.axis_labels, + vec![ + AxisLabel { + label_names: vec![lstr("de", "Extraleicht"), lstr("fr", "Extra léger")], + ..ranged(label("Extra Light", 200.), 200., 250.) + }, + ranged(label("Light", 300.), 250., 350.), + AxisLabel { elidable: true, ..ranged(label("Regular", 400.), 350., 450.) }, + ranged(label("Semi Bold", 600.), 450., 650.), + ranged(label("Bold", 700.), 650., 850.), + ranged(label("Black", 900.), 850., 900.), + AxisLabel { + elidable: true, + linked_user_value: Some(700.), + ..label("Regular", 400.) + }, + AxisLabel { linked_user_value: Some(400.), ..label("Bold", 700.) }, + ] + ); + assert_eq!(weight.map.as_ref().unwrap().len(), 6); + assert_eq!(weight.label_names, vec![lstr("en", "Wéíght"), lstr("fa-IR", "قطر")]); + assert_eq!(weight.axis_ordering, None); + + // Width axis. + let width = &ds.axes[1]; + assert!(width.hidden); + assert_eq!(width.axis_ordering, Some(1)); + assert_eq!( + width.axis_labels, + vec![ + label("Condensed", 50.), + AxisLabel { elidable: true, older_sibling: true, ..label("Normal", 100.) }, + label("Wide", 125.), + AxisLabel { user_minimum: Some(150.), ..label("Extra Wide", 150.) }, + ] + ); + + // Italic axis (discrete). + let italic = &ds.axes[2]; + assert_eq!(italic.values, Some(vec![0.0, 1.0])); + assert_eq!(italic.minimum, None); + assert_eq!(italic.maximum, None); + assert_eq!( + italic.axis_labels, + vec![ + AxisLabel { elidable: true, linked_user_value: Some(1.), ..label("Roman", 0.) }, + label("Italic", 1.), + ] + ); + + // avar2 axis mappings. + let mappings = ds.axis_mappings.as_ref().expect("should have axis_mappings"); + assert_eq!(mappings.mappings.len(), 1); + assert_eq!(mappings.mappings[0].description.as_deref(), Some("hello")); + assert_eq!(mappings.mappings[0].input.len(), 2); + assert_eq!(mappings.mappings[0].output, vec![dim_name_xvalue("Weight", 870.0)]); + } + + #[test] + fn v5_location_labels() { + let ds = DesignSpaceDocument::load("testdata/v5.designspace").unwrap(); + assert_eq!( + ds.location_labels, + vec![ + LocationLabel { + name: "Some Style".into(), + older_sibling: true, + label_names: vec![lstr("fr", "Un Style")], + location: vec![ + dim_uservalue("Weight", 300.0), + dim_uservalue("Width", 50.0), + dim_uservalue("Italic", 0.0), + ], + ..Default::default() + }, + LocationLabel { + name: "Other".into(), + elidable: true, + location: vec![ + dim_uservalue("Weight", 700.0), + dim_uservalue("Width", 100.0), + dim_uservalue("Italic", 1.0), + ], + ..Default::default() + }, + ] + ); + } + + #[test] + fn v5_variable_fonts() { + let ds = DesignSpaceDocument::load("testdata/v5.designspace").unwrap(); + + let got: Vec<(&str, Option<&str>, Vec)> = ds + .variable_fonts + .iter() + .map(|vf| (vf.name.as_str(), vf.filename.as_deref(), vf.axis_subsets.clone())) + .collect(); + assert_eq!( + got, + vec![ + ( + "Test_WghtWdth", + Some("Test_WghtWdth_different_from_name.ttf"), + vec![ + subset("Weight", SubsetValue::default()), + subset("Width", SubsetValue::default()) + ], + ), + ("Test_Wght", None, vec![subset("Weight", SubsetValue::default())]), + ( + "TestCd_Wght", + None, + vec![ + subset("Weight", SubsetValue::default()), + subset("Width", SubsetValue::Discrete(0.0)), + ], + ), + ( + "TestWd_Wght", + None, + vec![ + subset("Weight", SubsetValue::default()), + subset("Width", SubsetValue::Discrete(1000.0)), + ], + ), + ( + "TestItalic_Wght", + None, + vec![ + subset("Weight", SubsetValue::default()), + subset("Italic", SubsetValue::Discrete(1.0)), + ], + ), + ( + "TestRB_Wght", + None, + vec![ + subset( + "Weight", + SubsetValue::Range { + minimum: Some(400.0), + default: Some(400.0), + maximum: Some(700.0), + } + ), + subset("Italic", SubsetValue::Discrete(0.0)), + ], + ), + ] + ); + + // Libs, checked by key. + assert_eq!( + ds.variable_fonts[0].lib.get("com.vtt.source"), + Some(&Value::String("sources/vtt/Test_WghtWdth.vtt".into())) + ); + let font_info = ds.variable_fonts[0] + .lib + .get("public.fontInfo") + .and_then(Value::as_dictionary) + .expect("should have public.fontInfo dict"); + assert_eq!(font_info.get("familyName"), Some(&Value::String("My Font Narrow VF".into()))); + assert_eq!( + ds.variable_fonts[1].lib.get("com.vtt.source"), + Some(&Value::String("sources/vtt/Test_Wght.vtt".into())) + ); + for vf in &ds.variable_fonts[2..] { + assert!(vf.lib.is_empty(), "{}: {:?}", vf.name, vf.lib); + } + } + + #[test] + fn v5_sources_and_instances() { + let ds = DesignSpaceDocument::load("testdata/v5.designspace").unwrap(); + + // Sources. + assert_eq!(ds.sources.len(), 4); + assert_eq!( + ds.sources[0].localised_family_names, + vec![lstr("fr", "Montserrat"), lstr("ja", "モンセラート")] + ); + assert!(ds.sources[1].localised_family_names.is_empty()); + assert_eq!(ds.sources[2].layer.as_deref(), Some("supports")); + + // Instances. + assert_eq!(ds.instances.len(), 6); + + let instance0 = &ds.instances[0]; + assert_eq!( + instance0.localised_style_names, + vec![lstr("fr", "Demigras"), lstr("ja", "半ば")] + ); + assert_eq!( + instance0.localised_family_names, + vec![lstr("fr", "Montserrat"), lstr("ja", "モンセラート")] + ); + assert_eq!(instance0.localised_style_map_style_names, vec![lstr("de", "Standard")]); + assert_eq!( + instance0.localised_style_map_family_names, + vec![lstr("de", "Montserrat Halbfett"), lstr("ja", "モンセラート SemiBold")] + ); + assert_eq!( + instance0.lib.get("com.coolDesignspaceApp.binaryData"), + Some(&Value::Data(b"".to_vec())) + ); + + let instance1 = &ds.instances[1]; + assert_eq!(instance1.location[1].xvalue, Some(400.0)); + assert_eq!(instance1.location[1].yvalue, Some(300.0)); + + let instance2 = &ds.instances[2]; + assert_eq!(instance2.location_label.as_deref(), Some("Some Style")); + assert!(instance2.location.is_empty()); + + let instance4 = &ds.instances[4]; + assert_eq!( + instance4.location, + vec![ + dim_name_xvalue("Weight", 10.0), + dim_uservalue("Width", 100.0), + dim_name_xvalue("Italic", 0.0), + ] + ); + + let instance5 = &ds.instances[5]; + assert_eq!( + instance5.location, + vec![ + dim_uservalue("Weight", 300.0), + dim_uservalue("Width", 130.0), + dim_uservalue("Italic", 1.0), + ] + ); + let font_info = instance5 + .lib + .get("public.fontInfo") + .and_then(Value::as_dictionary) + .expect("should have public.fontInfo dict"); + let name_records = font_info.get("openTypeNameRecords").and_then(Value::as_array).unwrap(); + assert_eq!( + name_records[0] + .as_dictionary() + .and_then(|d| d.get("nameID")) + .and_then(Value::as_unsigned_integer), + Some(7) + ); + + // Top-level lib. + assert_eq!( + ds.lib.get("com.coolDesignspaceApp.previewSize"), + Some(&Value::Integer(30.into())) + ); + } + + #[test] + fn load_save_round_trip_v5() { + // Given + let dir = TempDir::new().unwrap(); + let ds_test_save_location = dir.path().join("v5.designspace"); + + // When + let ds_initial = DesignSpaceDocument::load("testdata/v5.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"); + + // Then + assert_eq!(ds_initial, ds_after); + + let saved_content = std::fs::read_to_string(&ds_test_save_location) + .expect("Failed to read saved designspace file"); + assert!(saved_content.contains(r#"format="5.1""#)); + assert!(saved_content.contains(r#"elidedfallbackname="Regular""#)); + assert!(saved_content.contains("")); + assert!(saved_content.contains( + r#""# + )); + assert!(saved_content.contains("")); + assert!(saved_content.contains( + r#""# + )); + assert!(saved_content.contains(r#""#)); + assert!(saved_content.contains(r#""#)); + assert!(saved_content.contains(r#"linkeduservalue="700""#)); + assert!(saved_content.contains(r#"oldersibling="true""#)); + assert!(saved_content.contains(r#"elidable="true""#)); + assert!(saved_content.contains(r#"values="0 1""#)); + assert!(saved_content.contains(r#""#)); + assert!(saved_content.contains(r#"モンセラート"#)); + assert!(saved_content.contains( + r#"Montserrat Halbfett"# + )); + + // Deprecated instance/source children are dropped on save. + assert!(!saved_content.contains("")); + assert!(!saved_content.contains(""#), "unexpected xml: {xml}"); + assert!( + xml.contains(r#""#), + "unexpected xml: {xml}" + ); + let full: Vec = vf.axis_subsets.iter().map(|subset| subset.value.is_full()).collect(); + assert_eq!(full, [true, false, false, false]); + + // An axis-subset can't mix uservalue with userminimum/userdefault/usermaximum. + let mixed = r#""#; + let err = quick_xml::de::from_str::(mixed).unwrap_err(); + assert!(err.to_string().contains("axis-subset"), "unexpected error: {err}"); + } + + #[test] + fn effective_format_on_save() { + fn fmt_of(doc: &DesignSpaceDocument) -> String { + let xml = quick_xml::se::to_string(doc).expect("should serialize"); + let needle = "format=\""; + let start = xml.find(needle).expect("should have format attr") + needle.len(); + let end = xml[start..].find('"').expect("unterminated format attr"); + xml[start..start + end].to_string() + } + + let axis = Axis { + name: "Weight".into(), + tag: "wght".into(), + default: 400.0, + ..Default::default() + }; + let source = Source { filename: "Test.ufo".into(), ..Default::default() }; + let dim = Dimension { name: "Weight".into(), xvalue: Some(400.0), ..Default::default() }; + + let base = DesignSpaceDocument { + format: 4.1, + axes: vec![axis.clone()], + sources: vec![source.clone()], + ..Default::default() + }; + assert_eq!(fmt_of(&base), "4.1"); + + // variable_fonts bumps to 5, axis_mappings further bumps to 5.1. + let mut doc = base.clone(); + doc.variable_fonts.push(VariableFont { name: "VF".into(), ..Default::default() }); + assert_eq!(fmt_of(&doc), "5.0"); + + doc.axis_mappings = Some(AxisMappings { + mappings: vec![AxisMappingEntry { + input: vec![dim.clone()], + output: vec![dim.clone()], + ..Default::default() + }], + ..Default::default() + }); + assert_eq!(fmt_of(&doc), "5.1"); + + // As in fontTools, elidedfallbackname alone does not bump the version. + let mut doc = base.clone(); + doc.elided_fallback_name = Some("Regular".into()); + assert_eq!(fmt_of(&doc), "4.1"); + + // Each of the other v5 triggers bumps 4.1 -> 5.0 in isolation. + let mut doc = base.clone(); + doc.axes[0].values = Some(vec![0.0, 1.0]); + assert_eq!(fmt_of(&doc), "5.0"); + + let mut doc = base.clone(); + doc.axes[0].axis_ordering = Some(1); + assert_eq!(fmt_of(&doc), "5.0"); + + let mut doc = base.clone(); + doc.axes[0].axis_labels = + vec![AxisLabel { name: "Foo".into(), user_value: 1.0, ..Default::default() }]; + assert_eq!(fmt_of(&doc), "5.0"); + + let mut doc = base.clone(); + doc.location_labels = vec![LocationLabel { name: "Foo".into(), ..Default::default() }]; + assert_eq!(fmt_of(&doc), "5.0"); + + let mut doc = base.clone(); + doc.sources[0].localised_family_names = + vec![LocalizedString { language: "fr".into(), string: "Foo".into() }]; + assert_eq!(fmt_of(&doc), "5.0"); + + let mut doc = base.clone(); + doc.instances = vec![Instance { location_label: Some("Foo".into()), ..Default::default() }]; + assert_eq!(fmt_of(&doc), "5.0"); + + let mut doc = base.clone(); + doc.instances = vec![Instance { + location: vec![Dimension { + name: "Weight".into(), + uservalue: Some(1.0), + ..Default::default() + }], + ..Default::default() + }]; + assert_eq!(fmt_of(&doc), "5.0"); + + // A doc already at 5.1 with no v5 data isn't lowered. + let stayed_51 = DesignSpaceDocument { + format: 5.1, + axes: vec![axis], + sources: vec![source], + ..Default::default() + }; + assert_eq!(fmt_of(&stayed_51), "5.1"); + } + + #[test] + fn instance_with_both_location_forms_is_rejected() { + let xml = r#" + + + + "#; + let err = DesignSpaceDocument::load_from_reader(xml.as_bytes()).unwrap_err(); + assert!(err.to_string().contains("at most one of the location"), "{err}"); + } + + #[test] + fn location_label_wins_over_location_on_save() { + let doc = DesignSpaceDocument { + format: 5.0, + instances: vec![Instance { + location_label: Some("Some Style".into()), + location: vec![dim_name_xvalue("Weight", 400.)], + ..Default::default() + }], + ..Default::default() + }; + let xml = quick_xml::se::to_string(&doc).unwrap(); + assert!(xml.contains(r#""#), "{xml}"); + assert!(!xml.contains(""), "{xml}"); + } + + #[test] + fn labels_and_sources_without_location_load() { + // fontTools omits an empty element for both of these. + let xml = r#" + + + "#; + let ds = DesignSpaceDocument::load_from_reader(xml.as_bytes()).unwrap(); + assert_eq!(ds.location_labels[0].name, "Bare"); + assert!(ds.location_labels[0].location.is_empty()); + assert!(ds.sources[0].location.is_empty()); + let saved = quick_xml::se::to_string(&ds).unwrap(); + assert!(!saved.contains(""#; + let ds = DesignSpaceDocument::load_from_reader(xml.as_bytes()).unwrap(); + assert!(ds.instances[0].location.is_empty()); + assert_eq!(ds.instances[0].location_label, None); + } } diff --git a/testdata/v5.designspace b/testdata/v5.designspace new file mode 100644 index 00000000..b115a1f3 --- /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 + + +