Skip to content
Merged
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
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,31 @@ Shift aims to redefine font editing by combining the power of Rust for performan
## Architecture

```
┌─────────────────────────────────────────────────────────┐
│ Frontend │
│ React UI ←→ Editor ←→ Canvas 2D Renderer │
└────────────────────────┬────────────────────────────────┘
│ IPC / NAPI
┌────────────────────────┴────────────────────────────────┐
│ Backend │
│ shift-node (N-API bindings) ←→ shift-core (Rust) │
└─────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Desktop App │
│ Electron shell ←→ React UI ←→ TypeScript Editor │
└───────────────────────────────┬──────────────────────────────┘
│ IPC / native bridge
┌───────────────────────────────┴──────────────────────────────┐
│ Rust Crates │
│ shift-bridge transport adapter │
│ shift-workspace open working state │
│ shift-font live font authoring model │
│ shift-store SQLite working store │
│ shift-source .shift source package IO │
└──────────────────────────────────────────────────────────────┘
```

The frontend handles UI and rendering via Electron, while all font data and editing operations live in Rust. Communication happens through native Node.js bindings, keeping performance-critical work off the main thread.
The desktop app owns shell and editor interaction. Rust owns the live font authoring model, durable working state, source package IO, and native transport boundary.

`shift-font` is the core Rust object model:

- `Font` owns glyphs, sources, axes, metadata, and font-level data.
- `Source` is an editable designspace position with a name and location.
- `Glyph` is a glyph concept identified by `GlyphId`.
- `GlyphLayer` is authored editable data for one glyph at one source.

Stable IDs are identity. Names and Unicode values are editable metadata.

## Getting Started

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/lib/model/Font.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export class Font {
this.#bridge.setXAdvance(
{
glyphHandle: handle,
layerId: this.defaultSource.layerId,
sourceId: this.defaultSource.id,
},
500,
);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/lib/model/Glyph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ class GlyphEditSession {
#glyphRef(): GlyphLayerRef {
return {
glyphHandle: this.#handle,
layerId: this.#source.layerId,
sourceId: this.#source.id,
};
}

Expand Down
11 changes: 7 additions & 4 deletions crates/shift-backends/src/binary/reader.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::errors::{FormatBackendError, FormatBackendResult};
use shift_font::{Contour, Font, Glyph, GlyphLayer, PointType};
use shift_font::{Contour, Font, Glyph, GlyphLayer, LayerId, PointType};
use skrifa::{
outline::{DrawSettings, OutlinePen},
prelude::{LocationRef, Size},
Expand Down Expand Up @@ -125,7 +125,9 @@ fn font_from_skrifa(font: &FontRef<'_>) -> Font {

let metrics = font.metrics(Size::unscaled(), LocationRef::default());
let mut ir_font = Font::new();
let default_layer_id = ir_font.default_layer_id();
let default_source_id = ir_font
.default_source_id()
.expect("new font should have a default source");

ir_font.metrics_mut().units_per_em = metrics.units_per_em as f64;
ir_font.metrics_mut().ascender = metrics.ascent as f64;
Expand Down Expand Up @@ -154,13 +156,14 @@ fn font_from_skrifa(font: &FontRef<'_>) -> Font {
.unwrap_or_else(|| format!("uni{unicode:04X}"));

let mut glyph = Glyph::with_unicode(glyph_name, unicode);
let mut layer = GlyphLayer::with_width(advance_width as f64);
let mut layer =
GlyphLayer::with_width(LayerId::new(), default_source_id, advance_width as f64);
let mut contours = pen.contours();
detect_smooth_points(&mut contours);
for contour in contours {
layer.add_contour(contour);
}
glyph.set_layer(default_layer_id, layer);
glyph.set_layer(layer);
ir_font.insert_glyph(glyph);
}

Expand Down
7 changes: 4 additions & 3 deletions crates/shift-backends/src/designspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub use writer::DesignspaceWriter;
mod tests {
use super::*;
use crate::traits::{FontReader, FontWriter};
use shift_font::{Contour, Font, Glyph, GlyphLayer, PointType};
use shift_font::{Contour, Font, Glyph, GlyphLayer, LayerId, PointType};
use std::fs;

fn test_font() -> Font {
Expand All @@ -20,15 +20,16 @@ mod tests {
font.metrics_mut().units_per_em = 1000.0;

let mut glyph = Glyph::with_unicode("o".to_string(), 'o' as u32);
let mut layer = GlyphLayer::with_width(520.0);
let mut layer =
GlyphLayer::with_width(LayerId::new(), font.default_source_id().unwrap(), 520.0);
let mut contour = Contour::new();
contour.add_point(100.0, 0.0, PointType::OnCurve, false);
contour.add_point(420.0, 0.0, PointType::OnCurve, false);
contour.add_point(420.0, 500.0, PointType::OnCurve, false);
contour.add_point(100.0, 500.0, PointType::OnCurve, false);
contour.close();
layer.add_contour(contour);
glyph.set_layer(font.default_layer_id(), layer);
glyph.set_layer(layer);
font.insert_glyph(glyph);

font
Expand Down
143 changes: 96 additions & 47 deletions crates/shift-backends/src/designspace/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::ufo::UfoReader;
use norad::designspace::DesignSpaceDocument;
use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;
use shift_font::{Axis, Font, Layer, LayerId, Location, Source};
use shift_font::{Axis, Font, Layer, LayerId, Location, Source, SourceId};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
Expand Down Expand Up @@ -77,6 +77,9 @@ impl DesignspaceReader {
path: default_ufo_path.clone(),
details: source.to_string(),
})?;
let default_ufo_source_id = font
.default_source_id()
.ok_or(DesignspaceError::NoSources)?;
font.clear_sources();

if let Some(ref family) = default_ds_source.familyname {
Expand All @@ -98,16 +101,15 @@ impl DesignspaceReader {
}

// Register the default source.
let default_layer_id = font.default_layer_id();
let default_location = location_from_dimensions(&default_ds_source.location, &doc);
let default_name = source_name(default_ds_source, default_idx);
let default_source_id = font.add_source(Source::with_filename(
default_name,
default_location,
default_layer_id,
default_ds_source.filename.clone(),
));
font.set_default_source_id(default_source_id);
move_glyph_layers_to_source(&mut font, default_ufo_source_id, default_source_id);

// Cache loaded UFO fonts so we don't re-read the same file for support layers.
let mut ufo_cache: HashMap<String, Font> = HashMap::new();
Expand Down Expand Up @@ -142,40 +144,38 @@ impl DesignspaceReader {
};

// Determine which layer from the source UFO to read.
let source_layer_id = match &ds_source.layer {
Some(layer_name) => {
find_layer_by_name(source_font, layer_name).ok_or_else(|| {
DesignspaceError::MissingLayer {
layer: layer_name.clone(),
filename: ds_source.filename.clone(),
}
})?
}
None => source_font.default_layer_id(),
let source_source_id = match &ds_source.layer {
Some(layer_name) => find_source_by_external_layer_name(source_font, layer_name)
.ok_or_else(|| DesignspaceError::MissingLayer {
layer: layer_name.clone(),
filename: ds_source.filename.clone(),
})?,
None => source_font
.default_source_id()
.ok_or(DesignspaceError::NoSources)?,
};

let name = source_name(ds_source, idx);
let layer = Layer::new(name.clone());
let layer_id = font.add_layer(layer);
font.add_layer(Layer::new(name.clone()));
let location = location_from_dimensions(&ds_source.location, &doc);
let source_id = font.add_source(Source::with_filename(
name,
location,
ds_source.filename.clone(),
));

// Copy glyphs from the resolved layer into the new layer.
for (glyph_name, source_glyph) in source_font.glyphs() {
if let Some(source_layer) = source_glyph.layer(source_layer_id) {
if let Some(source_layer) = source_glyph.layer_for_source(source_source_id) {
if let Some(existing_glyph) = font.glyph_mut(glyph_name) {
existing_glyph.set_layer(layer_id, source_layer.clone());
existing_glyph
.set_layer(source_layer.clone_with_identity(LayerId::new(), source_id));
}
}
}

let location = location_from_dimensions(&ds_source.location, &doc);
font.add_source(Source::with_filename(
name,
location,
layer_id,
ds_source.filename.clone(),
));
}

remove_glyph_layers_without_source(&mut font);
Ok(font)
}
}
Expand Down Expand Up @@ -227,6 +227,9 @@ fn load_axisless_designspace(
path: default_ufo_path.clone(),
details: source.to_string(),
})?;
let default_ufo_source_id = font
.default_source_id()
.ok_or(DesignspaceError::NoSources)?;
font.clear_sources();

if let Some(family) = &default_source.familyname {
Expand All @@ -236,10 +239,10 @@ fn load_axisless_designspace(
let default_source_id = font.add_source(Source::with_filename(
axisless_source_name(default_source, 0),
Location::new(),
font.default_layer_id(),
default_source.filename.clone(),
));
font.set_default_source_id(default_source_id);
move_glyph_layers_to_source(&mut font, default_ufo_source_id, default_source_id);

let mut ufo_cache: HashMap<String, Font> = HashMap::new();
for (idx, ds_source) in sources.iter().enumerate().skip(1) {
Expand All @@ -266,36 +269,36 @@ fn load_axisless_designspace(
}
};

let source_layer_id = match &ds_source.layer {
Some(layer_name) => find_layer_by_name(source_font, layer_name).ok_or_else(|| {
DesignspaceError::MissingLayer {
let source_source_id = match &ds_source.layer {
Some(layer_name) => find_source_by_external_layer_name(source_font, layer_name)
.ok_or_else(|| DesignspaceError::MissingLayer {
layer: layer_name.clone(),
filename: ds_source.filename.clone(),
}
})?,
None => source_font.default_layer_id(),
})?,
None => source_font
.default_source_id()
.ok_or(DesignspaceError::NoSources)?,
};

let name = axisless_source_name(ds_source, idx);
let layer = Layer::new(name.clone());
let layer_id = font.add_layer(layer);
font.add_layer(Layer::new(name.clone()));
let source_id = font.add_source(Source::with_filename(
name,
Location::new(),
ds_source.filename.clone(),
));

for (glyph_name, source_glyph) in source_font.glyphs() {
if let Some(source_layer) = source_glyph.layer(source_layer_id) {
if let Some(source_layer) = source_glyph.layer_for_source(source_source_id) {
if let Some(existing_glyph) = font.glyph_mut(glyph_name) {
existing_glyph.set_layer(layer_id, source_layer.clone());
existing_glyph
.set_layer(source_layer.clone_with_identity(LayerId::new(), source_id));
}
}
}

font.add_source(Source::with_filename(
name,
Location::new(),
layer_id,
ds_source.filename.clone(),
));
}

remove_glyph_layers_without_source(&mut font);
Ok(font)
}

Expand Down Expand Up @@ -415,11 +418,57 @@ fn find_default_source_index(doc: &DesignSpaceDocument) -> usize {
0
}

fn find_layer_by_name(font: &Font, name: &str) -> Option<LayerId> {
font.layers()
fn find_source_by_external_layer_name(font: &Font, name: &str) -> Option<SourceId> {
font.sources()
.iter()
.find(|(_, layer)| layer.name() == name)
.map(|(&id, _)| id)
.find(|source| source.name() == name)
.map(Source::id)
}

fn move_glyph_layers_to_source(font: &mut Font, from_source_id: SourceId, to_source_id: SourceId) {
let glyph_names: Vec<_> = font.glyphs().keys().cloned().collect();

for glyph_name in glyph_names {
let Some(glyph) = font.glyph_mut(&glyph_name) else {
continue;
};
let Some(layer) = glyph
.layer_for_source(from_source_id)
.map(|layer| layer.clone_with_identity(LayerId::new(), to_source_id))
else {
continue;
};
let old_layer_ids: Vec<_> = glyph
.layers()
.values()
.filter(|layer| layer.source_id() == from_source_id)
.map(|layer| layer.id())
.collect();
for old_layer_id in old_layer_ids {
glyph.remove_layer(old_layer_id);
}
glyph.set_layer(layer);
}
}

fn remove_glyph_layers_without_source(font: &mut Font) {
let source_ids: Vec<_> = font.sources().iter().map(Source::id).collect();
let glyph_names: Vec<_> = font.glyphs().keys().cloned().collect();

for glyph_name in glyph_names {
let Some(glyph) = font.glyph_mut(&glyph_name) else {
continue;
};
let orphan_layer_ids: Vec<_> = glyph
.layers()
.values()
.filter(|layer| !source_ids.contains(&layer.source_id()))
.map(|layer| layer.id())
.collect();
for layer_id in orphan_layer_ids {
glyph.remove_layer(layer_id);
}
}
}

/// Derive (minimum, maximum) for an axis from norad's parsed designspace.
Expand Down
12 changes: 4 additions & 8 deletions crates/shift-backends/src/designspace/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,10 @@ impl DesignspaceWriter {
}

fn source(source: &Source, font: &Font, filename: &str, axes: &[Axis]) -> DsSource {
let layer = if source.layer_id() == font.default_layer_id() {
let layer = if Some(source.id()) == font.default_source_id() {
None
} else {
font.layers()
.get(&source.layer_id())
.map(|layer| layer.name().to_string())
Some(source.name().to_string())
};

DsSource {
Expand All @@ -69,12 +67,10 @@ impl DesignspaceWriter {
}

fn source_layer(source: &Source, font: &Font) -> Option<String> {
if source.layer_id() == font.default_layer_id() {
if Some(source.id()) == font.default_source_id() {
None
} else {
font.layers()
.get(&source.layer_id())
.map(|layer| layer.name().to_string())
Some(source.name().to_string())
}
}

Expand Down
Loading
Loading