diff --git a/ROADMAP.md b/ROADMAP.md index 43571db..77866f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f401c49..eb44b88 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -35,6 +35,7 @@ static BUNDLED: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"), ]; +#[derive(Clone)] enum FontBytes { Static(&'static [u8]), Owned(Arc>), @@ -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 { - 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> { + 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> { + 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> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); if cands.is_empty() { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 3fe821c..b2bddf5 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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] diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8837b77..71bd86e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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::>().join(","); + let _ = app.run("type.char", json!({"attrs": {"fontStyle": format!("{base} {{{spec}}}")}})); + } +} diff --git a/docs/parity.md b/docs/parity.md index 38eb8df..cdc6475 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. -**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 |