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

Add a basic designspace parser #272

Merged
merged 10 commits into from
Nov 12, 2022
Merged
Show file tree
Hide file tree
Changes from 9 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
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ features = ["kurbo"]
[dependencies]
plist = { version = "1.3.1", features = ["serde"] }
uuid = { version = "1.2", features = ["v4"] }
serde = { version = "1.0", features = ["rc"] }
serde = { version = "1.0", features = ["rc", "derive"] }
serde_derive = "1.0"
serde_repr = "0.1"
quick-xml = "0.26.0"
quick-xml = { version = "0.26.0", features = ["serialize"] }
rayon = { version = "1.3.0", optional = true }
kurbo = { version = "0.8.1", optional = true }
thiserror = "1.0"
Expand Down
277 changes: 277 additions & 0 deletions src/designspace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
//! Reading and writing designspace files.

#![deny(rustdoc::broken_intra_doc_links)]

use std::{fs::File, io::BufReader, path::Path};

use crate::error::DesignSpaceLoadError;

/// A [designspace]].
///
/// [designspace]: https://fonttools.readthedocs.io/en/latest/designspaceLib/index.html
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(from = "DesignSpaceDocumentXmlRepr")]
pub struct DesignSpaceDocument {
/// Design space format version.
pub format: f32,
/// One or more axes.
pub axes: Vec<Axis>,
/// One or more sources.
pub sources: Vec<Source>,
/// One or more instances.
pub instances: Vec<Instance>,
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#overview
#[derive(Deserialize)]
#[serde(rename = "designspace")]
struct DesignSpaceDocumentXmlRepr {
pub format: f32,
pub axes: AxesXmlRepr,
pub sources: SourcesXmlRepr,
pub instances: InstancesXmlRepr,
}

impl From<DesignSpaceDocumentXmlRepr> for DesignSpaceDocument {
fn from(xml_form: DesignSpaceDocumentXmlRepr) -> Self {
DesignSpaceDocument {
format: xml_form.format,
axes: xml_form.axes.axis,
sources: xml_form.sources.source,
instances: xml_form.instances.instance,
}
}
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#axes-element
#[derive(Deserialize)]
#[serde(rename = "axes")]
pub struct AxesXmlRepr {
/// One or more axis definitions.
pub axis: Vec<Axis>,
}

/// A [axis]].
///
/// [axis]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#axis-element
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename = "axis")]
pub struct Axis {
/// Name of the axis that is used in the location elements.
pub name: String,
/// 4 letters. Some axis tags are registered in the OpenType Specification.
pub tag: String,
rsheeter marked this conversation as resolved.
Show resolved Hide resolved
/// The default value for this axis, in user space coordinates.
pub default: f32,
/// Records whether this axis needs to be hidden in interfaces.
#[serde(default)]
pub hidden: bool,
/// The minimum value for a continuous axis, in user space coordinates.
pub minimum: Option<f32>,
/// The maximum value for a continuous axis, in user space coordinates.
pub maximum: Option<f32>,
/// The possible values for a discrete axis, in user space coordinates.
pub values: Option<Vec<f32>>,
Copy link
Member

Choose a reason for hiding this comment

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

do we want to distinguish between a None and an empty vec?

Copy link
Collaborator Author

@rsheeter rsheeter Nov 12, 2022

Choose a reason for hiding this comment

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

I wish there was a way to write Option<NonEmptyVec>.

/// Mapping between user space coordinates and design space coordinates.
pub map: Option<Vec<AxisMapping>>,
}

/// Maps one input value (user space coord) to one output value (design space coord).
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(rename = "map")]
pub struct AxisMapping {
/// user space coordinate
pub input: f32,
/// designspace coordinate
pub output: f32,
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#sources-element
#[derive(Deserialize)]
#[serde(rename = "sources")]
struct SourcesXmlRepr {
/// One or more sources.
pub source: Vec<Source>,
}
rsheeter marked this conversation as resolved.
Show resolved Hide resolved

/// A [source]].
///
/// [source]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#id25
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(from = "SourceXmlRepr")]
pub struct Source {
/// The family name of the source font.
pub familyname: Option<String>,
/// The style name of the source font.
pub stylename: Option<String>,
/// A unique name that can be used to identify this font if it needs to be referenced elsewhere.
pub name: String,
/// A path to the source file, relative to the root path of this document. The path can be at the same level as the document or lower.
pub filename: String,
/// The name of the layer in the source file. If no layer attribute is given assume the foreground layer should be used.
pub layer: Option<String>,
/// Location in designspace coordinates.
pub location: Vec<Dimension>,
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#source-element
#[derive(Deserialize)]
#[serde(rename = "source")]
struct SourceXmlRepr {
pub familyname: Option<String>,
pub stylename: Option<String>,
pub name: String,
pub filename: String,
pub layer: Option<String>,
pub location: LocationXmlRepr,
}

impl From<SourceXmlRepr> for Source {
fn from(xml_form: SourceXmlRepr) -> Self {
Source {
familyname: xml_form.familyname,
stylename: xml_form.stylename,
name: xml_form.name,
filename: xml_form.filename,
layer: xml_form.layer,
location: xml_form.location.dimension,
}
}
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#instances-element
#[derive(Deserialize)]
#[serde(rename = "instances")]
struct InstancesXmlRepr {
/// One or more instances located somewhere in designspace.
pub instance: Vec<Instance>,
}

/// An [instance]].
///
/// [instance]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#instance-element
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[serde(from = "InstanceXmlRepr")]
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
pub familyname: Option<String>,
/// The style name of the instance font. Corresponds with font.info.styleName
pub stylename: Option<String>,
/// A unique name that can be used to identify this font if it needs to be referenced elsewhere.
pub name: String,
/// 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.
pub filename: Option<String>,
/// Corresponds with font.info.postscriptFontName
pub postscriptfontname: Option<String>,
/// Corresponds with styleMapFamilyName
pub stylemapfamilyname: Option<String>,
/// Corresponds with styleMapStyleName
pub stylemapstylename: Option<String>,
/// Location in designspace.
pub location: Vec<Dimension>,
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#instance-element
#[derive(Deserialize)]
struct InstanceXmlRepr {
pub familyname: Option<String>,
pub stylename: Option<String>,
pub name: String,
pub filename: Option<String>,
pub postscriptfontname: Option<String>,
pub stylemapfamilyname: Option<String>,
pub stylemapstylename: Option<String>,
pub location: LocationXmlRepr,
}

impl From<InstanceXmlRepr> for Instance {
fn from(instance_xml: InstanceXmlRepr) -> Self {
Instance {
familyname: instance_xml.familyname,
stylename: instance_xml.stylename,
name: instance_xml.name,
filename: instance_xml.filename,
postscriptfontname: instance_xml.postscriptfontname,
stylemapfamilyname: instance_xml.stylemapfamilyname,
stylemapstylename: instance_xml.stylemapstylename,
location: instance_xml.location.dimension,
}
}
}

/// https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#location-element-top-level-stat-label
#[derive(Deserialize)]
struct LocationXmlRepr {
pub dimension: Vec<Dimension>,
}

/// A [design space dimension]].
///
/// [design space location]: https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#location-element-source
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct Dimension {
/// Name of the axis, e.g. Weight.
pub name: String,
/// Value on the axis in user coordinates.
pub uservalue: Option<f32>,
/// Value on the axis in designcoordinates.
pub xvalue: Option<f32>,
/// Separate value for anisotropic interpolations.
pub yvalue: Option<f32>,
}

impl DesignSpaceDocument {
/// Load a designspace.
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)
}
}

#[cfg(test)]
mod tests {
use std::path::Path;

use pretty_assertions::assert_eq;

use crate::designspace::{AxisMapping, Dimension};

use super::DesignSpaceDocument;

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();
rsheeter marked this conversation as resolved.
Show resolved Hide resolved
assert_eq!(1, ds.axes.len());
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(Path::new("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>)>>()
);
}
}
15 changes: 14 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@ use std::path::PathBuf;

use plist::Error as PlistError;
use quick_xml::events::attributes::AttrError;
use quick_xml::Error as XmlError;
use quick_xml::{DeError, Error as XmlError};
use thiserror::Error;

pub use crate::shared_types::ColorError;
use crate::write::CustomSerializationError;
use crate::Name;

/// An error that occurs while attempting to read a designspace file from disk.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DesignSpaceLoadError {
/// An [`std::io::Error`].
#[error("failed to read file")]
Io(#[from] IoError),

/// A parse error.
#[error("failed to deserialize")]
DeError(#[from] DeError),
}

/// An error representing a failure to (re)name something.
#[derive(Debug, Error)]
pub enum NamingError {
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ extern crate serde_repr;

mod data_request;
pub mod datastore;
pub mod designspace;
pub mod error;
mod font;
pub mod fontinfo;
Expand Down
26 changes: 26 additions & 0 deletions testdata/single_wght.designspace
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version='1.0' encoding='UTF-8'?>
<designspace format="5.0">
<axes>
<axis tag="wght" name="Weight" minimum="400" maximum="400" default="400">
<map input="400" output="100"/>
</axis>
</axes>
<sources>
<source filename="Demo-Regular.ufo" name="Demo Regular" familyname="Demo" stylename="Regular">
<lib copy="1"/>
<groups copy="1"/>
<features copy="1"/>
<info copy="1"/>
<location>
<dimension name="Weight" xvalue="100"/>
</location>
</source>
</sources>
<instances>
<instance name="Demo Regular" familyname="Demo" stylename="Regular" filename="../instance_ufo/Demo-Regular.ufo" postscriptfontname="Demo" stylemapfamilyname="Demo" stylemapstylename="regular">
<location>
<dimension name="Weight" xvalue="100"/>
</location>
</instance>
</instances>
</designspace>
Loading