#![deny(rustdoc::broken_intra_doc_links)]
use serde::{Deserialize, Serialize};
use std::{fs::File, io::BufReader, path::Path};
use plist::Dictionary;
use crate::error::{DesignSpaceLoadError, DesignSpaceSaveError};
use crate::serde_xml_plist as serde_plist;
use crate::Name;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "RawDesignSpaceDocument", into = "RawDesignSpaceDocument")]
pub struct DesignSpaceDocument {
pub format: f32,
pub axes: Vec<Axis>,
pub axis_mappings: Option<AxisMappings>,
pub rules: Rules,
pub sources: Vec<Source>,
pub instances: Vec<Instance>,
pub lib: Dictionary,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename = "axis")]
pub struct Axis {
#[serde(rename = "@name")]
pub name: String,
#[serde(rename = "@tag")]
pub tag: String,
#[serde(rename = "@default")]
pub default: f32,
#[serde(default, rename = "@hidden", skip_serializing_if = "is_false")]
pub hidden: bool,
#[serde(rename = "@minimum", skip_serializing_if = "Option::is_none")]
pub minimum: Option<f32>,
#[serde(rename = "@maximum", skip_serializing_if = "Option::is_none")]
pub maximum: Option<f32>,
#[serde(rename = "@values", skip_serializing_if = "Option::is_none")]
pub values: Option<Vec<f32>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub map: Option<Vec<AxisMapping>>,
#[serde(rename = "labelname", default, skip_serializing_if = "Vec::is_empty")]
pub label_names: Vec<LocalizedString>,
}
fn is_false(value: &bool) -> bool {
!(*value)
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct LocalizedString {
#[serde(rename = "@xml:lang", alias = "@lang")]
pub language: String,
#[serde(rename = "$text")]
pub string: String,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename = "map")]
pub struct AxisMapping {
#[serde(rename = "@input")]
pub input: f32,
#[serde(rename = "@output")]
pub output: f32,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct AxisMappings {
#[serde(rename = "@description", skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, rename = "mapping")]
pub mappings: Vec<AxisMappingEntry>,
}
impl AxisMappings {
pub fn is_empty(&self) -> bool {
self.mappings.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct AxisMappingEntry {
#[serde(rename = "@description", skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(with = "serde_impls::location")]
pub input: Vec<Dimension>,
#[serde(with = "serde_impls::location")]
pub output: Vec<Dimension>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Rules {
#[serde(default, rename = "@processing")]
pub processing: RuleProcessing,
#[serde(default, rename = "rule")]
pub rules: Vec<Rule>,
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuleProcessing {
#[default]
First,
Last,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "RawRule", into = "RawRule")]
pub struct Rule {
pub name: Option<String>,
pub condition_sets: Vec<ConditionSet>,
pub substitutions: Vec<Substitution>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
struct RawRule {
#[serde(rename = "@name", skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(rename = "conditionset", default, skip_serializing_if = "Vec::is_empty")]
condition_sets: Vec<ConditionSet>,
#[serde(rename = "condition", default, skip_serializing_if = "Vec::is_empty")]
conditions: Vec<Condition>,
#[serde(rename = "sub", default, skip_serializing_if = "Vec::is_empty")]
substitutions: Vec<Substitution>,
}
impl From<RawRule> for Rule {
fn from(raw: RawRule) -> Self {
let mut condition_sets = raw.condition_sets;
if !raw.conditions.is_empty() {
condition_sets.push(ConditionSet { conditions: raw.conditions });
}
Rule { name: raw.name, condition_sets, substitutions: raw.substitutions }
}
}
impl From<Rule> for RawRule {
fn from(rule: Rule) -> Self {
RawRule {
name: rule.name,
condition_sets: rule.condition_sets,
conditions: Vec::new(),
substitutions: rule.substitutions,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Substitution {
#[serde(rename = "@name")]
pub name: Name,
#[serde(rename = "@with")]
pub with: Name,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ConditionSet {
#[serde(rename = "condition", default)]
pub conditions: Vec<Condition>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Condition {
#[serde(rename = "@name")]
pub name: String,
#[serde(rename = "@minimum", default, skip_serializing_if = "Option::is_none")]
pub minimum: Option<f32>,
#[serde(rename = "@maximum", default, skip_serializing_if = "Option::is_none")]
pub maximum: Option<f32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename = "source")]
pub struct Source {
#[serde(rename = "@familyname", skip_serializing_if = "Option::is_none")]
pub familyname: Option<String>,
#[serde(rename = "@stylename", skip_serializing_if = "Option::is_none")]
pub stylename: Option<String>,
#[serde(rename = "@name", skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "@filename")]
pub filename: String,
#[serde(rename = "@layer", skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
#[serde(with = "serde_impls::location")]
pub location: Vec<Dimension>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename = "instance")]
pub struct Instance {
#[serde(rename = "@familyname", skip_serializing_if = "Option::is_none")]
pub familyname: Option<String>,
#[serde(rename = "@stylename", skip_serializing_if = "Option::is_none")]
pub stylename: Option<String>,
#[serde(rename = "@name", skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(rename = "@filename", skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(rename = "@postscriptfontname", skip_serializing_if = "Option::is_none")]
pub postscriptfontname: Option<String>,
#[serde(rename = "@stylemapfamilyname", skip_serializing_if = "Option::is_none")]
pub stylemapfamilyname: Option<String>,
#[serde(rename = "@stylemapstylename", skip_serializing_if = "Option::is_none")]
pub stylemapstylename: Option<String>,
#[serde(with = "serde_impls::location")]
pub location: Vec<Dimension>,
#[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")]
pub lib: Dictionary,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename = "dimension")]
pub struct Dimension {
#[serde(rename = "@name")]
pub name: String,
#[serde(rename = "@uservalue", skip_serializing_if = "Option::is_none")]
pub uservalue: Option<f32>,
#[serde(rename = "@xvalue", skip_serializing_if = "Option::is_none")]
pub xvalue: Option<f32>,
#[serde(rename = "@yvalue", skip_serializing_if = "Option::is_none")]
pub yvalue: Option<f32>,
}
impl DesignSpaceDocument {
pub fn load<P: AsRef<Path>>(path: P) -> Result<DesignSpaceDocument, DesignSpaceLoadError> {
let reader = BufReader::new(File::open(path).map_err(DesignSpaceLoadError::Io)?);
quick_xml::de::from_reader(reader).map_err(DesignSpaceLoadError::DeError)
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<(), DesignSpaceSaveError> {
let mut buf = String::from("<?xml version='1.0' encoding='UTF-8'?>\n");
let mut xml_writer = quick_xml::se::Serializer::new(&mut buf);
xml_writer.indent(' ', 2);
self.serialize(xml_writer)?;
buf.push('\n'); close_already::fs::write(path, buf)?;
Ok(())
}
}
impl Rules {
fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename = "designspace")]
struct RawDesignSpaceDocument {
#[serde(rename = "@format")]
format: f32,
#[serde(default, skip_serializing_if = "RawAxes::is_empty")]
axes: RawAxes,
#[serde(default, skip_serializing_if = "Rules::is_empty")]
rules: Rules,
#[serde(default, with = "serde_impls::sources", skip_serializing_if = "Vec::is_empty")]
sources: Vec<Source>,
#[serde(default, with = "serde_impls::instances", skip_serializing_if = "Vec::is_empty")]
instances: Vec<Instance>,
#[serde(default, with = "serde_plist", skip_serializing_if = "Dictionary::is_empty")]
lib: Dictionary,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
struct RawAxes {
#[serde(default, rename = "axis")]
axis: Vec<Axis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mappings: Option<AxisMappings>,
}
impl RawAxes {
fn is_empty(&self) -> bool {
self.axis.is_empty() && self.mappings.as_ref().is_none_or(|m| m.is_empty())
}
}
impl From<RawDesignSpaceDocument> for DesignSpaceDocument {
fn from(raw: RawDesignSpaceDocument) -> Self {
Self {
format: raw.format,
axes: raw.axes.axis,
axis_mappings: raw.axes.mappings,
rules: raw.rules,
sources: raw.sources,
instances: raw.instances,
lib: raw.lib,
}
}
}
impl From<DesignSpaceDocument> for RawDesignSpaceDocument {
fn from(doc: DesignSpaceDocument) -> Self {
Self {
format: doc.format,
axes: RawAxes { axis: doc.axes, mappings: doc.axis_mappings },
rules: doc.rules,
sources: doc.sources,
instances: doc.instances,
lib: doc.lib,
}
}
}
mod serde_impls {
macro_rules! serde_from_field {
($mod_name:ident, $field_name:ident, $inner:path) => {
pub(super) mod $mod_name {
pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Vec<$inner>, D::Error>
where
D: ::serde::Deserializer<'de>,
{
use serde::Deserialize as _;
#[derive(::serde::Deserialize)]
struct Helper {
$field_name: Vec<$inner>,
}
Helper::deserialize(deserializer).map(|x| x.$field_name)
}
pub(crate) fn serialize<S>(
$field_name: &[$inner],
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: ::serde::Serializer,
{
use serde::Serialize as _;
#[derive(::serde::Serialize)]
struct Helper<'a> {
$field_name: &'a [$inner],
}
let helper = Helper { $field_name };
helper.serialize(serializer)
}
}
};
}
serde_from_field!(location, dimension, crate::designspace::Dimension);
serde_from_field!(instances, instance, crate::designspace::Instance);
serde_from_field!(sources, source, crate::designspace::Source);
}
#[cfg(test)]
mod tests {
use std::path::Path;
use plist::Value;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use crate::designspace::{AxisMapping, Dimension};
use super::*;
fn dim_name_xvalue(name: &str, xvalue: f32) -> Dimension {
Dimension { name: name.to_string(), uservalue: None, xvalue: Some(xvalue), yvalue: None }
}
#[test]
fn read_single_wght() {
let ds = DesignSpaceDocument::load(Path::new("testdata/single_wght.designspace")).unwrap();
assert_eq!(1, ds.axes.len());
let axis = &ds.axes[0];
assert_eq!(axis.minimum, Some(400.));
assert_eq!(axis.maximum, Some(600.));
assert_eq!(axis.default, 500.);
assert_eq!(
&vec![AxisMapping { input: 400., output: 100. }],
ds.axes[0].map.as_ref().unwrap()
);
assert_eq!(1, ds.sources.len());
let weight_100 = dim_name_xvalue("Weight", 100.);
assert_eq!(vec![weight_100.clone()], ds.sources[0].location);
assert_eq!(1, ds.instances.len());
assert_eq!(vec![weight_100], ds.instances[0].location);
}
#[test]
fn read_wght_variable() {
let ds = DesignSpaceDocument::load("testdata/wght.designspace").unwrap();
assert_eq!(1, ds.axes.len());
assert!(ds.axes[0].map.is_none());
assert_eq!(
vec![
("TestFamily-Regular.ufo".to_string(), vec![dim_name_xvalue("Weight", 400.)]),
("TestFamily-Bold.ufo".to_string(), vec![dim_name_xvalue("Weight", 700.)]),
],
ds.sources
.into_iter()
.map(|s| (s.filename, s.location))
.collect::<Vec<(String, Vec<Dimension>)>>()
);
assert!(ds.axes[0].label_names.is_empty());
}
#[test]
fn read_label_names() {
let ds = DesignSpaceDocument::load("testdata/labelname_wght.designspace").unwrap();
assert_eq!(1, ds.axes.len());
assert!(!ds.axes[0].label_names.is_empty());
assert_eq!(ds.axes[0].label_names[0].language, "fa-IR");
assert_eq!(ds.axes[0].label_names[0].string, "قطر");
assert_eq!(ds.axes[0].label_names[1].language, "en");
assert_eq!(ds.axes[0].label_names[1].string, "Weight");
}
#[test]
fn load_with_no_instances() {
DesignSpaceDocument::load("testdata/no_instances.designspace").unwrap();
}
#[test]
fn load_with_no_source_name() {
let ds = DesignSpaceDocument::load("testdata/optional_source_names.designspace").unwrap();
assert!(ds.sources[0].name.is_none());
assert_eq!(ds.sources[1].name.as_deref(), Some("Test Family Bold"));
}
#[test]
fn load_with_no_instance_name() {
let ds = DesignSpaceDocument::load("testdata/optional_instance_names.designspace").unwrap();
assert_eq!(ds.instances[0].name.as_deref(), Some("Test Family Regular"));
assert!(ds.instances[1].name.is_none());
}
#[test]
fn load_lib() {
let loaded = DesignSpaceDocument::load("testdata/wght.designspace").unwrap();
assert_eq!(
loaded.lib.get("org.linebender.hasLoadedLibCorrectly"),
Some(&Value::String("Absolutely!".into()))
);
let params = loaded.instances[0]
.lib
.get("com.schriftgestaltung.customParameters")
.and_then(Value::as_array)
.unwrap();
assert_eq!(params[0].as_array().unwrap()[0].as_string(), Some("xHeight"));
assert_eq!(params[0].as_array().unwrap()[1].as_string(), Some("536"));
assert_eq!(
params[1].as_array().unwrap()[1].as_array().unwrap()[0].as_unsigned_integer(),
Some(2)
);
}
#[test]
fn do_not_serialize_empty_lib() {
let ds_initial = DesignSpaceDocument::load("testdata/single_wght.designspace").unwrap();
let serialized = quick_xml::se::to_string(&ds_initial).expect("should serialize");
assert!(!serialized.contains("<lib>"));
assert!(!serialized.contains("<lib/>"));
}
#[test]
fn load_save_round_trip() {
let dir = TempDir::new().unwrap();
let ds_test_save_location = dir.path().join("wght.designspace");
let ds_initial = DesignSpaceDocument::load("testdata/wght.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");
assert_eq!(ds_initial, ds_after);
}
#[test]
fn load_save_round_trip_mutatorsans() {
let dir = TempDir::new().unwrap();
let ds_test_save_location = dir.path().join("MutatorSans.designspace");
let ds_initial = DesignSpaceDocument::load("testdata/MutatorSans.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");
assert_eq!(
&ds_after.rules,
&Rules {
processing: RuleProcessing::Last,
rules: vec![
Rule {
name: Some("fold_I_serifs".into()),
condition_sets: vec![ConditionSet {
conditions: vec![Condition {
name: "width".into(),
minimum: Some(0.0),
maximum: Some(328.0),
}],
}],
substitutions: vec![Substitution {
name: "I".into(),
with: "I.narrow".into()
}],
},
Rule {
name: Some("fold_S_terminals".into()),
condition_sets: vec![ConditionSet {
conditions: vec![
Condition {
name: "width".into(),
minimum: Some(0.0),
maximum: Some(1000.0),
},
Condition {
name: "weight".into(),
minimum: Some(0.0),
maximum: Some(500.0),
},
],
}],
substitutions: vec![Substitution {
name: "S".into(),
with: "S.closed".into()
}],
},
]
}
);
assert_eq!(ds_initial, ds_after);
}
#[test]
fn load_save_round_trip_label_names() {
let dir = TempDir::new().unwrap();
let ds_test_save_location = dir.path().join("labelname_wght.designspace");
let ds_initial = DesignSpaceDocument::load("testdata/labelname_wght.designspace").unwrap();
ds_initial.save(&ds_test_save_location).expect("failed to save designspace");
let ds_after = DesignSpaceDocument::load(ds_test_save_location.clone())
.expect("failed to load saved designspace");
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("xml:lang=\"fa-IR\""));
assert!(saved_content.contains("xml:lang=\"en\""),);
}
#[test]
fn accept_bare_conditions_in_rule() {
let designspace = DesignSpaceDocument::load("testdata/BareConditions.designspace").unwrap();
assert_eq!(
&designspace.rules,
&Rules {
processing: RuleProcessing::Last,
rules: vec![
Rule {
name: Some("fold_I_serifs".into()),
condition_sets: vec![ConditionSet {
conditions: vec![Condition {
name: "width".into(),
minimum: Some(0.0),
maximum: Some(328.0),
}],
}],
substitutions: vec![Substitution {
name: "I".into(),
with: "I.narrow".into()
}],
},
Rule {
name: Some("fold_S_terminals".into()),
condition_sets: vec![ConditionSet {
conditions: vec![
Condition {
name: "width".into(),
minimum: Some(0.0),
maximum: Some(1000.0),
},
Condition {
name: "weight".into(),
minimum: Some(0.0),
maximum: Some(500.0),
},
],
}],
substitutions: vec![Substitution {
name: "S".into(),
with: "S.closed".into()
}],
},
]
}
);
}
#[test]
fn accept_always_on_rules() {
let designspace =
DesignSpaceDocument::load("testdata/MutatorSansAlwaysOnRules.designspace").unwrap();
assert_eq!(
&designspace.rules,
&Rules {
processing: RuleProcessing::Last,
rules: vec![
Rule {
name: Some("fold_I_serifs".into()),
condition_sets: vec![ConditionSet { conditions: vec![] }],
substitutions: vec![Substitution {
name: "I".into(),
with: "I.narrow".into()
}],
},
Rule {
name: Some("fold_S_terminals".into()),
condition_sets: vec![ConditionSet { conditions: vec![] }],
substitutions: vec![Substitution {
name: "S".into(),
with: "S.closed".into()
}],
},
]
}
);
}
#[test]
fn load_axis_mappings() {
let ds = DesignSpaceDocument::load("testdata/with_mappings.designspace").unwrap();
let mappings = ds.axis_mappings.as_ref().expect("should have axis_mappings");
assert_eq!(mappings.description.as_deref(), Some("Test avar2 mappings"));
assert_eq!(mappings.mappings.len(), 2);
let m1 = &mappings.mappings[0];
assert_eq!(m1.description.as_deref(), Some("Heavy at wide gets less heavy"));
assert_eq!(m1.input.len(), 2);
assert_eq!(m1.input[0].name, "Weight");
assert_eq!(m1.input[0].xvalue, Some(700.0));
assert_eq!(m1.input[1].name, "Width");
assert_eq!(m1.input[1].xvalue, Some(125.0));
assert_eq!(m1.output.len(), 1);
assert_eq!(m1.output[0].name, "Weight");
assert_eq!(m1.output[0].xvalue, Some(680.0));
assert!(mappings.mappings[1].description.is_none());
}
#[test]
fn load_save_round_trip_with_mappings() {
let dir = TempDir::new().unwrap();
let ds_test_save_location = dir.path().join("with_mappings.designspace");
let ds_initial = DesignSpaceDocument::load("testdata/with_mappings.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");
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("<mappings"));
assert!(saved_content.contains("<mapping"));
assert!(saved_content.contains("<input>"));
assert!(saved_content.contains("<output>"));
}
#[test]
fn designspace_without_mappings_has_none() {
let ds = DesignSpaceDocument::load("testdata/wght.designspace").unwrap();
assert!(ds.axis_mappings.is_none());
}
}