Skip to content
Open
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
162 changes: 158 additions & 4 deletions glyphs-reader/src/font.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,8 @@ struct RawShape {
pos: Vec<f64>, // v3
angle: Option<f64>, // v3
scale: Vec<f64>, // v3
/// Horizontal and vertical slant, in degrees (v3)
slant: Vec<f64>,

#[fromplist(alt_name = "attr")]
attributes: ShapeAttributes,
Expand Down Expand Up @@ -2976,23 +2978,37 @@ impl TryFrom<RawShape> for Shape {
let shape = if let Some(glyph_name) = from.glyph_name {
assert!(!glyph_name.is_empty(), "A pointless component");

// V3 vs v2: The transform entry has been replaced by angle, pos and scale entries.
// V3 vs v2: The transform entry has been replaced by angle, pos, scale and slant.
let mut transform = if let Some(transform) = from.transform {
Affine::parse_plist(&transform)?
} else {
Affine::IDENTITY
};

// Glyphs 3 gives us {angle, pos, scale}. Glyphs 2 gives us the standard 2x3 matrix.
// The matrix is more general and less ambiguous (what order do you apply the angle, pos, scale?)
// so convert Glyphs 3 to that. Order based on saving the same transformed comonent as
// Glyphs 3 gives us {angle, pos, scale, slant}. Glyphs 2 gives us the standard 2x3 matrix.
// The matrix is more general and less ambiguous (what order do you apply the angle, pos, scale, slant?)
// so convert Glyphs 3 to that. Order based on saving the same transformed component as
// Glyphs 2 and Glyphs 3 then trying to convert one to the other.
if !from.pos.is_empty() {
if from.pos.len() != 2 {
return Err(Error::StructuralError(format!("Bad pos: {:?}", from.pos)));
}
transform *= Affine::translate((from.pos[0], from.pos[1]));
}
if !from.slant.is_empty() {
if from.slant.len() != 2 {
return Err(Error::StructuralError(format!(
"Bad slant: {:?}",
from.slant
)));
}
// Glyphs stores slant as angles in degrees; Affine::skew wants the
// shear factors, i.e. the tangent of those angles.
transform *= Affine::skew(
from.slant[0].to_radians().tan(),
from.slant[1].to_radians().tan(),
);
}
if let Some(angle) = from.angle {
transform *= normalized_rotation(angle);
}
Expand Down Expand Up @@ -4505,6 +4521,144 @@ mod tests {
check_v2_to_v3_transform("Component.glyphs", "non_uniform_scale", expected);
}

/// A slanted component must load the same from a Glyphs 2 matrix and Glyphs 3 fields.
///
/// Both fixture files were written by Glyphs 3.4.1 (3436) from the same in-memory
/// font: v3 stores angle=20, scale=(0.8,0.8), slant=(10,0), while v2 stores the
/// equivalent raw `transform` matrix.
///
/// The rotation makes the case discriminate slant-then-rotate from rotate-then-slant.
#[test]
fn read_component_slant_2_and_3() {
let expected = Affine::new([0.8, 0.2736, -0.1411, 0.7518, 0.0, 0.0]);
check_v2_to_v3_transform("ComponentSlant.glyphs", "slanted_square", expected);
}

/// Parse a Glyphs 3 component plist fragment and return its transform.
fn component_transform(shape_plist: &str) -> Affine {
let shape: Shape = RawShape::parse_plist(shape_plist)
.unwrap()
.try_into()
.unwrap();
let Shape::Component(component) = shape else {
panic!("{shape:?} should be a component");
};
component.transform
}

#[test]
fn parse_component_slant_key() {
let raw = RawShape::parse_plist(
r#"
{
ref = A;
pos = (5,21);
scale = (1,0.94);
slant = (-0.7,0);
}
"#,
)
.unwrap();
assert_eq!(raw.slant, vec![-0.7, 0.0]);
}

// Slant is in degrees; the shear factor is its tangent. The component transform is
// translate * slant * rotate * scale, i.e. a point is scaled, then rotated, then
// slanted, then translated.
//
// Provenance of the expected coefficients: the first tuple comes from the linked
// glyphsLib#1047 issue thread; the rotated tuple is backed by a Glyphs 3.4.1 round-trip
// (see the ComponentSlant fixture pair exercised by read_component_slant_2_and_3).
#[test]
fn read_component_slant_x_with_scale() {
let transform = component_transform(
r#"
{
ref = A;
pos = (5,21);
scale = (1,0.94);
slant = (-0.7,0);
}
"#,
);
assert_eq!(
round(transform, 9),
round(
Affine::new([1.0, 0.0, -0.011484837902484654, 0.94, 5.0, 21.0]),
9
)
);
}

#[test]
fn read_component_slant_with_rotation_and_scale() {
let transform = component_transform(
r#"
{
ref = A;
angle = 20;
scale = (0.8,0.8);
slant = (10,0);
}
"#,
);
assert_eq!(
round(transform, 9),
round(
Affine::new([
0.8,
0.273616114660535,
-0.14106158456677195,
0.7517540966287268,
0.0,
0.0
]),
9
)
);
}

/// Vertical slant lands in the 'b' coefficient as tan(degrees).
///
/// Unlike the two cases above this expectation is derived from the documented
/// conversion rather than from a Glyphs.app-produced reference file.
#[test]
fn read_component_slant_y() {
let transform = component_transform(
r#"
{
ref = A;
slant = (0,5);
}
"#,
);
assert_eq!(
round(transform, 9),
round(
Affine::new([1.0, 5f64.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
9
)
);
}

#[test]
fn reject_malformed_component_slant() {
let raw = RawShape::parse_plist(
r#"
{
ref = A;
slant = (10);
}
"#,
)
.unwrap();
let result = Shape::try_from(raw);
assert!(
matches!(&result, Err(Error::StructuralError(msg)) if msg.starts_with("Bad slant")),
"{result:?}"
);
}

#[test]
fn upgrade_2_to_3_with_implicit_axes() {
let font = Font::load(&glyphs2_dir().join("WghtVar_ImplicitAxes.glyphs")).unwrap();
Expand Down
53 changes: 53 additions & 0 deletions resources/testdata/glyphs2/ComponentSlant.glyphs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
.appVersion = "3436";
familyName = "Component Slant";
fontMaster = (
{
ascender = 800;
capHeight = 700;
descender = -200;
id = m01;
xHeight = 500;
}
);
glyphs = (
{
glyphname = square;
layers = (
{
layerId = m01;
paths = (
{
closed = 1;
nodes = (
"100 0 LINE",
"400 0 LINE",
"400 300 LINE",
"100 300 LINE"
);
}
);
width = 600;
}
);
},
{
glyphname = slanted_square;
layers = (
{
components = (
{
name = square;
transform = "{0.8, 0.27362, -0.14106, 0.75175, 0, 0}";
}
);
layerId = m01;
width = 600;
}
);
}
);
unitsPerEm = 1000;
versionMajor = 1;
versionMinor = 0;
}
81 changes: 81 additions & 0 deletions resources/testdata/glyphs3/ComponentSlant.glyphs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
{
.appVersion = "3436";
.formatVersion = 3;
familyName = "Component Slant";
fontMaster = (
{
id = m01;
metricValues = (
{
pos = 800;
},
{
pos = 700;
},
{
pos = -200;
},
{
pos = 500;
}
);
name = Regular;
}
);
glyphs = (
{
glyphname = square;
layers = (
{
layerId = m01;
shapes = (
{
closed = 1;
nodes = (
(100,0,l),
(400,0,l),
(400,300,l),
(100,300,l)
);
}
);
width = 600;
}
);
},
{
glyphname = slanted_square;
layers = (
{
layerId = m01;
shapes = (
{
angle = 20;
ref = square;
scale = (0.8,0.8);
slant = (10,0);
}
);
width = 600;
}
);
}
);
metrics = (
{
type = ascender;
},
{
type = "cap height";
},
{
type = descender;
},
{
type = "x-height";
}
);
unitsPerEm = 1000;
versionMajor = 1;
versionMinor = 0;
}
Loading