Y: variable font axis values (sliders, instances on demand)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0f3852671c
commit
912ed60767
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}}}")}}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue