Skip to content

Commit 2aa0aff

Browse files
authored
Merge pull request #157 from shift-editor/glyph-compatibility-diagnostics
Add structured glyph compatibility diagnostics
2 parents 40f63c2 + 1f78c6f commit 2aa0aff

4 files changed

Lines changed: 462 additions & 54 deletions

File tree

crates/shift-font/docs/DOCS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ crates/shift-font/src/
4444
- `GlyphLayer` is authored editable data for one glyph at one source.
4545
- `InterpolationBasis` is coordinate-independent variation math for an ordered source set. It contains normalized supports and source coefficient rows, never glyph coordinates or metrics.
4646
- `GlyphInterpolation` combines a reusable basis with one glyph's compatible authored source values. Its default-source template owns topology.
47+
- `LayerCompatibility` records every hard structural difference between an interpolation reference layer and another source layer. `LayerDifference` retains ordered path, node, anchor, and component evidence for diagnostics.
4748
- `GlyphProjection` is a compact location-independent glyph payload: fallback shape, optional compatible interpolation, exact-source shapes for incompatible topology, and component identities.
4849
- `FontProjection` is a read-only, location-bound view that reuses resolved component layers across one or many glyph requests.
4950
- `ResolvedGlyph` is derived, flattened geometry plus x advance. An existing blank glyph resolves to an empty contour list; a missing glyph resolves to `None`.
@@ -72,6 +73,10 @@ Stable IDs are identity. Names and Unicode values are editable metadata.
7273

7374
`Font::glyph_interpolation(glyph_id)` builds compatible source values over an `InterpolationBasis`. The default source defines structural topology. The basis depends only on axes and ordered source locations, so the same mechanism can interpolate other numeric domains without copying glyph concepts into them.
7475

76+
`GlyphLayer::interpolation_compatibility_with(source)` is the source of truth for hard structural compatibility. The receiver is the interpolation reference. It compares paths, nodes, anchors, and components in authored order and never sorts them. OpenType requires corresponding outlines to have the same contour and point structure, and `gvar` addresses composite components by their ordered component index. Shift therefore treats component identity and order as structural. See the [OpenType Font Variations overview](https://learn.microsoft.com/en-us/typography/opentype/spec/otvaroverview), the [`gvar` composite processing rules](https://learn.microsoft.com/en-us/typography/opentype/spec/gvar#point-numbers-and-processing-for-composite-glyphs), and [fontTools interpolatability diagnostics](https://fonttools.readthedocs.io/en/latest/varLib/interpolatable.html).
77+
78+
Coordinates, advance width, smooth flags, anchor positions, and component transforms are interpolated values, not structural compatibility. Matching anchor count, names, and order is currently a Shift-specific restriction because anchor positions share the ordered glyph interpolation vector; OpenType `gvar` and fontTools do not define source-anchor compatibility. Variable component scale or matrix transforms need a separate export diagnostic because `gvar` varies component placement rather than those transforms. Correspondence and quality warnings such as contour order, wrong start point, and kinks are separate from this hard structural result.
79+
7580
`Font::glyph_projection(glyph_id)` preserves the preferred fallback, compatible interpolation, and incompatible authored source shapes without resolving a location. The fallback is normally the default-source layer and uses the glyph's preferred layer when the default source has no layer for that glyph. A renderer can retain this compact payload and combine its basis with current authored source signals. No arbitrary location result is persisted.
7681

7782
`Font::source_metric_interpolation()` combines the same coordinate-independent basis with complete master-source metric vectors. Optional technical fields participate only when every master authors them, so interpolation does not invent sparse values.

crates/shift-font/src/interpolation.rs

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ use fontdrasil::variations::VariationModel;
99

1010
use crate::{Axis, AxisId, CoreError, CoreResult, Font, GlyphId, GlyphLayer, Location, SourceId};
1111

12+
mod compatibility;
1213
mod metrics;
1314
mod values;
1415

16+
pub use compatibility::{LayerCompatibility, LayerDifference};
1517
pub use metrics::{
1618
ResolvedSourceMetrics, SourceMetricField, SourceMetricInterpolation, SourceMetricValues,
1719
};
@@ -253,7 +255,10 @@ impl Font {
253255
let Some(layer) = glyph.layer_for_source(source.id()) else {
254256
continue;
255257
};
256-
if !layers_are_compatible(default_layer, layer) {
258+
if !default_layer
259+
.interpolation_compatibility_with(layer)
260+
.is_compatible()
261+
{
257262
continue;
258263
}
259264

@@ -358,46 +363,6 @@ fn normalized_location(location: &Location, axes: &[Axis]) -> NormalizedLocation
358363
.collect()
359364
}
360365

361-
/// Returns whether two layers can share structure-ordered interpolation values.
362-
pub(crate) fn layers_are_compatible(template: &GlyphLayer, candidate: &GlyphLayer) -> bool {
363-
if template.contours().len() != candidate.contours().len()
364-
|| template.anchors().len() != candidate.anchors().len()
365-
|| template.components().len() != candidate.components().len()
366-
{
367-
return false;
368-
}
369-
370-
for (template, candidate) in template.contours_iter().zip(candidate.contours_iter()) {
371-
if template.is_closed() != candidate.is_closed()
372-
|| template.points().len() != candidate.points().len()
373-
{
374-
return false;
375-
}
376-
377-
if template
378-
.points()
379-
.iter()
380-
.zip(candidate.points())
381-
.any(|(template, candidate)| template.is_on_curve() != candidate.is_on_curve())
382-
{
383-
return false;
384-
}
385-
}
386-
387-
if template
388-
.anchors_iter()
389-
.zip(candidate.anchors_iter())
390-
.any(|(template, candidate)| template.name() != candidate.name())
391-
{
392-
return false;
393-
}
394-
395-
!template
396-
.components_iter()
397-
.zip(candidate.components_iter())
398-
.any(|(template, candidate)| template.base_glyph_id() != candidate.base_glyph_id())
399-
}
400-
401366
fn region_scalar(
402367
region: &InterpolationRegion,
403368
location: &Location,
@@ -520,7 +485,9 @@ mod tests {
520485
.unwrap();
521486
point.set_smooth(!point.is_smooth());
522487

523-
assert!(super::layers_are_compatible(template, &candidate));
488+
assert!(template
489+
.interpolation_compatibility_with(&candidate)
490+
.is_compatible());
524491
}
525492

526493
#[test]

0 commit comments

Comments
 (0)