Y: variable font axis values (sliders, instances on demand)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:17:50 -04:00
parent 0f3852671c
commit 912ed60767
5 changed files with 109 additions and 7 deletions

View File

@ -66,6 +66,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name.
- Word/RTF Import Options (File ▸ Place with Import Options…, `place.styles`, `file.place {styleMap, styleConflicts}`): map imported styles onto the document's, use existing / redefine / auto-rename on conflicts, or remove formatting.
- Custom anchored objects (`anchored.insert/options {position: custom}`): placed relative to the anchor, frame, column, page margins or page edge with reference points and offsets, no space in the text, keep within column; IDML Anchored position.
- Variable font axes: Character panel sliders; any axis values (`fontStyle: "Bold {wght:650}"`) make that instance for layout, screen and PDF.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.

View File

@ -35,6 +35,7 @@ static BUNDLED: &[&[u8]] = &[
include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"),
];
#[derive(Clone)]
enum FontBytes {
Static(&'static [u8]),
Owned(Arc<Vec<u8>>),
@ -344,6 +345,11 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
})
}
/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`).
pub fn base_style(style: &str) -> &str {
style.split('{').next().unwrap_or(style).trim()
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
}
@ -427,8 +433,12 @@ impl FontDb {
/// Style names available for `family` (Regular first, then by weight).
pub fn styles(&self, family: &str) -> Vec<String> {
let mut v: Vec<(bool, f32, String)> =
self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect();
let mut v: Vec<(bool, f32, String)> = self
.read_faces()
.iter()
.filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{'))
.map(|f| (f.italic, f.weight, f.style.clone()))
.collect();
#[cfg(not(target_arch = "wasm32"))]
for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() {
if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) {
@ -548,7 +558,57 @@ impl FontDb {
self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family))
}
/// A variable font's axes: (tag, name, min, default, max), empty for static fonts.
pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> {
let face = self.face(family, base_style(style));
let Some(f) = face.skrifa() else { return vec![] };
f.axes()
.iter()
.map(|a| {
let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string();
let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone());
(tag, name, a.min_value(), a.default_value(), a.max_value())
})
.collect()
}
/// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use.
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
let k = k.trim().as_bytes();
let v: f32 = v.trim().parse().ok()?;
if k.len() != 4 {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
}
}
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
let f = Arc::new(f);
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
Some(f)
}
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
if style.contains('{') {
{
let faces = self.read_faces();
if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) {
return Some(f.clone());
}
}
return self.instance(family, style);
}
let faces = self.read_faces();
let cands: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect();
if cands.is_empty() {

View File

@ -8,7 +8,7 @@
mod fontdb;
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, bundled};
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, base_style, bundled};
pub use harfrust::Feature;
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
pub use kurbo::BezPath;
@ -156,6 +156,15 @@ mod tests {
((b.area() * 100.0).round() as i64, g[0].x_advance)
};
assert_ne!(ink(light), ink(heavy), "instances draw differently");
// Free axis values: a style with settings makes that instance.
let axes = db.axes(&fam, &light.style);
let wght = axes.iter().find(|a| a.0 == "wght").expect("a weight axis");
let mid = (wght.2 + wght.4) / 2.0;
let custom = db.face(&fam, &format!("{} {{wght:{mid}}}", light.style));
assert!(custom.is_variable() && custom.coords.iter().any(|(t, v)| t == b"wght" && *v == mid));
assert!(!db.styles(&fam).iter().any(|s| s.contains('{')), "instances aren't listed");
let static_face = db.face(DEFAULT_FAMILY, "Regular {wght:700}");
assert_eq!(static_face.style, "Regular", "static fonts ignore axis settings");
}
#[test]

View File

@ -1150,6 +1150,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
ui.label(egui::RichText::new("Character").strong());
super::font_family_picker(app, ui, &fam, 220.0);
super::font_style_picker(app, ui, &fam, &sty, 220.0);
variable_font_axes(app, ui, &fam, &sty);
egui::Grid::new("chargrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
caption(ui, "Size");
if let Some(v) = number(ui, "pcs", c["size"].as_f64(), " pt", 60.0, 2) {
@ -1840,3 +1841,34 @@ pub fn attributes_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
});
}
/// Variable Font sliders: one per axis; the style becomes `Named {tag:value,…}`.
fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, style: &str) {
if family.is_empty() {
return;
}
let db = designcraft_fonts::FontDb::global();
let axes = db.axes(family, style);
if axes.is_empty() {
return;
}
let base = designcraft_fonts::base_style(style).to_string();
let face = db.face(family, style);
let cur = |tag: &str, default: f32| face.coords.iter().find(|(t, _)| t == tag.as_bytes()).map_or(default, |c| c.1);
let mut values: Vec<(String, f32)> = axes.iter().map(|a| (a.0.clone(), cur(&a.0, a.3))).collect();
let mut changed = false;
ui.label(egui::RichText::new("Variable Font").size(11.0));
egui::Grid::new("vf_axes").num_columns(2).spacing(vec2(6.0, 2.0)).show(ui, |ui| {
for (i, (_, name, min, _, max)) in axes.iter().enumerate() {
caption(ui, name);
if ui.add(egui::Slider::new(&mut values[i].1, *min..=*max).step_by(1.0)).drag_stopped() {
changed = true;
}
ui.end_row();
}
});
if changed {
let spec = values.iter().map(|(t, v)| format!("{t}:{}", v.round())).collect::<Vec<_>>().join(",");
let _ = app.run("type.char", json!({"attrs": {"fontStyle": format!("{base} {{{spec}}}")}}));
}
}

View File

@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 89%** over 275 features.
**Weighted breadth parity: 90%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 88 | 8 | 1 | 95% |
| P1 | 97 | 89 | 7 | 1 | 95% |
| P2 | 76 | 23 | 13 | 40 | 39% |
| Area | Parity |
@ -24,7 +24,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Effects & transparency | 71% |
| Placing & links | 82% |
| Type & text frames | 94% |
| Typography | 96% |
| Typography | 98% |
| Styles | 97% |
| Tables | 92% |
| Long documents | 88% |
@ -218,7 +218,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Typography | Character attributes | P0 | D |
| Typography | Underline/Strikethrough options | P1 | D |
| Typography | OpenType features | P0 | D |
| Typography | Variable fonts | P1 | P |
| Typography | Variable fonts | P1 | D |
| Typography | Paragraph attributes | P0 | D |
| Typography | Paragraph Composer (total-fit) | P0 | D |
| Typography | Single-line Composer | P0 | D |