mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 14:33:34 +00:00
Merge pull request #5677 from sadkotheguest/feat/find-font-replacement-rows
Find Font: Replace With Font lists family and style rows
This commit is contained in:
@@ -136,5 +136,10 @@ pub(super) fn style_italic(style: &str) -> bool {
|
||||
/// slant. The penalty is less than the weight gap between Regular and Bold (and Bold and Black),
|
||||
/// so Bold Italic asked of a family with Bold and Italic faces only gets the Bold (#2491).
|
||||
pub(super) fn style_distance(face: &FontFace, weight: f32, italic: bool) -> f32 {
|
||||
(face.weight - weight).abs() + if face.italic != italic { 250.0 } else { 0.0 }
|
||||
weight_distance(face.weight, face.italic, weight, italic)
|
||||
}
|
||||
|
||||
/// [`style_distance`] for a face known only by its weight and slant (a cataloged face).
|
||||
pub(super) fn weight_distance(face_weight: f32, face_italic: bool, weight: f32, italic: bool) -> f32 {
|
||||
(face_weight - weight).abs() + if face_italic != italic { 250.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
@@ -218,6 +218,30 @@ impl FontDb {
|
||||
v.into_iter().map(|t| t.2).collect()
|
||||
}
|
||||
|
||||
/// The style a style-less request gets for `family`: its face nearest Regular upright, by the
|
||||
/// catalog's or the loaded faces' metadata. Unlike [`FontDb::face`] it reads no font files, so
|
||||
/// the closest styles of many families cost little.
|
||||
pub fn closest_style(&self, family: &str) -> Option<String> {
|
||||
let (family, _) = self.canonical(family, "");
|
||||
let family = family.as_str();
|
||||
let mut best: Option<(f32, String)> = None;
|
||||
let consider = |best: &mut Option<(f32, String)>, weight: f32, italic: bool, style: &str| {
|
||||
let d = super::build::weight_distance(weight, italic, 400.0, false);
|
||||
if best.as_ref().is_none_or(|(b, _)| d < *b) {
|
||||
*best = Some((d, style.to_string()));
|
||||
}
|
||||
};
|
||||
for f in self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)) {
|
||||
consider(&mut best, f.weight, f.italic, &f.style);
|
||||
}
|
||||
if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) {
|
||||
for cf in &c.faces {
|
||||
consider(&mut best, cf.weight, cf.italic, &cf.style);
|
||||
}
|
||||
}
|
||||
best.map(|(_, style)| style)
|
||||
}
|
||||
|
||||
/// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or
|
||||
/// every face was already present).
|
||||
pub fn add_font(&self, bytes: Vec<u8>) -> usize {
|
||||
|
||||
+443
-41
@@ -6,10 +6,10 @@
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, font_menu};
|
||||
|
||||
/// Open the dialog.
|
||||
pub fn open(app: &mut VectorcraftApp) {
|
||||
@@ -26,9 +26,14 @@ pub fn open_on_missing(app: &mut VectorcraftApp) {
|
||||
/// Open the dialog with the document's font `selected` (an index into `text.fonts`) chosen, and
|
||||
/// System's font ([`system_family`]) to change to.
|
||||
fn open_at(app: &mut VectorcraftApp, selected: usize) {
|
||||
let used = fonts(app);
|
||||
let source = "system";
|
||||
let rows = replacement_rows(app, source, &used);
|
||||
let family = system_family();
|
||||
let style = rows.iter().find(|r| r.family.eq_ignore_ascii_case(&family)).map(|r| r.style.clone()).unwrap_or_default();
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"findFont",
|
||||
json!({ "selected": selected, "family": system_family(), "style": "", "selectionOnly": false, "replaceFrom": "system" }),
|
||||
json!({ "selected": selected, "family": family, "style": style, "selectionOnly": false, "replaceFrom": source, "rows": rows_json(&rows) }),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -71,6 +76,95 @@ fn replacement_families(app: &VectorcraftApp, source: &str, used: &[Value]) -> O
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A row of Replace With Font: a family and one of its styles (`""` is the family's closest style),
|
||||
/// so one click picks both.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Row {
|
||||
family: String,
|
||||
style: String,
|
||||
}
|
||||
|
||||
/// What the row says: the family and its style, or the family alone for its closest style.
|
||||
fn row_label(family: &str, style: &str) -> String {
|
||||
if style.is_empty() { family.to_string() } else { format!("{family} {style}") }
|
||||
}
|
||||
|
||||
/// Does a row match the search `query` (lower case): its family and style, as typed.
|
||||
fn row_matches(family: &str, style: &str, query: &str) -> bool {
|
||||
query.is_empty() || format!("{family} {style}").to_lowercase().contains(query)
|
||||
}
|
||||
|
||||
/// Replace With Font's rows for `source` ([`SOURCES`]). Document lists the family and style pairs
|
||||
/// `text.fonts` names, once each, in its order; Recent and System list their families with each
|
||||
/// family's closest style and then its styles.
|
||||
fn replacement_rows(app: &VectorcraftApp, source: &str, used: &[Value]) -> Vec<Row> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut rows: Vec<Row> = vec![];
|
||||
match source {
|
||||
"document" => {
|
||||
for f in used {
|
||||
let (family, style) = (f["family"].as_str().unwrap_or_default(), f["style"].as_str().unwrap_or_default());
|
||||
if rows.iter().any(|r| r.family.eq_ignore_ascii_case(family) && r.style.eq_ignore_ascii_case(style)) {
|
||||
continue;
|
||||
}
|
||||
rows.push(Row { family: family.to_string(), style: style.to_string() });
|
||||
}
|
||||
}
|
||||
"recent" => {
|
||||
for family in replacement_families(app, "recent", used).unwrap_or_default() {
|
||||
push_family(&mut rows, db, &family);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for family in db.menu_family_list().iter() {
|
||||
push_family(&mut rows, db, family);
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Add `family` to `rows`: each style once, the closest style first, every row naming its style.
|
||||
fn push_family(rows: &mut Vec<Row>, db: &vectorcraft_text::FontDb, family: &str) {
|
||||
if family.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut styles = db.styles(family);
|
||||
// The style a style-less request picks comes first, by its own name, not again without one. Its
|
||||
// name comes from the catalog's metadata: the rows of every family would otherwise have to read
|
||||
// the family's font files.
|
||||
if let Some(closest) = db.closest_style(family)
|
||||
&& let Some(i) = styles.iter().position(|s| s.eq_ignore_ascii_case(&closest))
|
||||
{
|
||||
let style = styles.remove(i);
|
||||
styles.insert(0, style);
|
||||
}
|
||||
if styles.is_empty() {
|
||||
rows.push(Row { family: family.to_string(), style: String::new() });
|
||||
return;
|
||||
}
|
||||
for style in styles {
|
||||
rows.push(Row { family: family.to_string(), style });
|
||||
}
|
||||
}
|
||||
|
||||
/// `rows` as the dialog stores them (`{"family": …, "style": …}`).
|
||||
fn rows_json(rows: &[Row]) -> Value {
|
||||
Value::Array(rows.iter().map(|r| json!({ "family": r.family, "style": r.style })).collect())
|
||||
}
|
||||
|
||||
/// Can the replacement name that very face: `family` and `style` both exist, no stand-in. A row of
|
||||
/// an unavailable style stays listed, but only a font with that style is chosen as the replacement.
|
||||
fn exact(family: &str, style: &str) -> bool {
|
||||
!family.is_empty() && vectorcraft_text::FontDb::global().resolve(family, style).is_some_and(|(_, m)| m == vectorcraft_text::FontMatch::Exact)
|
||||
}
|
||||
|
||||
/// Can a row be chosen as the replacement: its family is installed and the style is the font's own
|
||||
/// (or the family names no styles, and its closest style stands for them).
|
||||
fn listed(row: &Row) -> bool {
|
||||
installed(&row.family) && (row.style.is_empty() || exact(&row.family, &row.style))
|
||||
}
|
||||
|
||||
/// Is `family` installed: it resolves to a face of its own, as `text.replaceFont` requires.
|
||||
fn installed(family: &str) -> bool {
|
||||
!family.is_empty()
|
||||
@@ -81,6 +175,10 @@ fn installed(family: &str) -> bool {
|
||||
/// and it is one of the list Replace With Font From shows (System's list is the font menu).
|
||||
fn can_replace(d: &Dialog, families: Option<&[String]>) -> bool {
|
||||
let family = d.str("family");
|
||||
let style = d.str("style");
|
||||
if !style.is_empty() && !exact(&family, &style) {
|
||||
return false;
|
||||
}
|
||||
match families {
|
||||
Some(list) => can_replace_in(&family, installed, list),
|
||||
None => can_replace_in(&family, installed, &vectorcraft_text::FontDb::global().menu_family_list()),
|
||||
@@ -97,16 +195,19 @@ fn can_replace_in(family: &str, is_installed: impl Fn(&str) -> bool, list: &[Str
|
||||
/// its closest style. System chooses [`system_family`].
|
||||
fn set_source(app: &VectorcraftApp, d: &mut Dialog, source: &str, used: &[Value]) {
|
||||
d.fields.insert("replaceFrom".into(), json!(source));
|
||||
let families = replacement_families(app, source, used);
|
||||
if can_replace(d, families.as_deref()) {
|
||||
let rows = replacement_rows(app, source, used);
|
||||
d.fields.insert("rows".into(), rows_json(&rows));
|
||||
// The chosen font stays only when the new list has that family and style; otherwise the family
|
||||
// and the style both come from a row the list shows, so Change replaces with what is listed.
|
||||
let (family, style) = (d.str("family"), d.str("style"));
|
||||
if rows.iter().any(|r| r.family.eq_ignore_ascii_case(&family) && r.style.eq_ignore_ascii_case(&style) && listed(r)) {
|
||||
return;
|
||||
}
|
||||
let family = match families {
|
||||
Some(l) => l.into_iter().find(|f| installed(f)),
|
||||
None => Some(system_family()),
|
||||
};
|
||||
d.fields.insert("family".into(), json!(family.unwrap_or_default()));
|
||||
d.fields.insert("style".into(), json!(""));
|
||||
let system = if source == "system" { system_family() } else { String::new() };
|
||||
let pick =
|
||||
rows.iter().find(|r| !system.is_empty() && r.family.eq_ignore_ascii_case(&system) && listed(r)).or_else(|| rows.iter().find(|r| listed(r)));
|
||||
d.fields.insert("family".into(), json!(pick.map(|r| r.family.clone()).unwrap_or_default()));
|
||||
d.fields.insert("style".into(), json!(pick.map(|r| r.style.clone()).unwrap_or_default()));
|
||||
}
|
||||
|
||||
fn fonts(app: &mut VectorcraftApp) -> Vec<Value> {
|
||||
@@ -114,10 +215,22 @@ fn fonts(app: &mut VectorcraftApp) -> Vec<Value> {
|
||||
}
|
||||
|
||||
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
if let Some(ctx) = app.ui_ctx.clone() {
|
||||
clear_search(&ctx);
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Forget the replacement search: the next open starts with an empty one.
|
||||
fn clear_search(ctx: &egui::Context) {
|
||||
let (source_key, query_id) = (egui::Id::unique("ff-search-source"), egui::Id::unique("ff-search"));
|
||||
ctx.data_mut(|m| {
|
||||
m.remove::<String>(source_key);
|
||||
m.remove::<String>(query_id);
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the selected document font with the chosen one (`all`: everywhere, else the selection).
|
||||
fn change(app: &mut VectorcraftApp, d: &Dialog, from: &Value, all: bool) -> Result<Value, String> {
|
||||
let style = d.str("style");
|
||||
@@ -129,13 +242,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let list = fonts(app);
|
||||
let sample = font_menu::sample_text(app);
|
||||
// Find in Folder… shows when the document misses fonts, fonts used only in its symbols included.
|
||||
let offer_folder = crate::picks::can(app, &crate::picks::PickRequest::Folder)
|
||||
&& app.session.active().is_some_and(|st| !vectorcraft_engine::cmd::fontfiles::missing_fonts(&st.doc).is_empty());
|
||||
let mut close = false;
|
||||
let mut find_in_folder = false;
|
||||
let mut act: Option<&str> = None;
|
||||
// The replacement-font search, kept in egui memory: cleared when the dialog closes, and when
|
||||
// the source changes.
|
||||
let source_key = egui::Id::unique("ff-search-source");
|
||||
let query_id = egui::Id::unique("ff-search");
|
||||
crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::unique("dialog-find-font"), -40.0, 22, |ui: &mut Ui| {
|
||||
ui.set_width(380.0);
|
||||
crate::dialogs::modal::heading(ui, tl!("Find Font"));
|
||||
@@ -145,7 +261,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
ui.set_width(ui.available_width());
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("ff-document-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (i, f) in list.iter().enumerate() {
|
||||
let (note, color) = font_note(f);
|
||||
let label = format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"]);
|
||||
@@ -172,34 +288,59 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
let families = replacement_families(app, &d.str("replaceFrom"), &list);
|
||||
let fam = d.str("family");
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(families) = &families {
|
||||
// Font names are shown as they are.
|
||||
let names: Vec<&str> = families.iter().map(String::as_str).collect();
|
||||
if let Some(i) = widgets::dropdown_names_named(ui, "ff-family-list", Some(tl!("Replace With Font")), &fam, &names, 220.0) {
|
||||
d.fields.insert("family".into(), json!(families[i]));
|
||||
d.fields.insert("style".into(), json!(""));
|
||||
// The rows Replace With Font lists for the source: one click picks the family and its style.
|
||||
let rows: Vec<(String, String)> = d
|
||||
.fields
|
||||
.get("rows")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|r| (r["family"].as_str().unwrap_or_default().to_string(), r["style"].as_str().unwrap_or_default().to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let (fam, style) = (d.str("family"), d.str("style"));
|
||||
// A search like the Character panel's font menu: the family and style are filtered as typed.
|
||||
// A new source starts with an empty search.
|
||||
let source = d.str("replaceFrom");
|
||||
if ui.data(|m| m.get_temp::<String>(source_key).as_deref() != Some(source.as_str())) {
|
||||
ui.data_mut(|m| {
|
||||
m.insert_temp(source_key, source.clone());
|
||||
m.insert_temp(query_id, String::new());
|
||||
});
|
||||
}
|
||||
let query = widgets::search_field(ui, query_id, tl!("Search")).to_lowercase();
|
||||
let shown: Vec<&(String, String)> = rows.iter().filter(|(f, s)| row_matches(f, s, &query)).collect();
|
||||
let mut picked: Option<(String, String, bool)> = None;
|
||||
// Ten rows at a time, whichever height the rows get from the style.
|
||||
let row = (ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Body)) + ui.spacing().item_spacing.y).max(18.0);
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
let area = egui::ScrollArea::vertical()
|
||||
.id_salt("ff-rows-list")
|
||||
// Ten rows at most, and no taller than the rows there are: a search of three fonts
|
||||
// leaves a list three rows tall.
|
||||
.max_height((shown.len() as f32 * row).clamp(row, 10.0 * row))
|
||||
.auto_shrink([false, true])
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
|
||||
area.show_rows(ui, row, shown.len(), |ui, range| {
|
||||
for i in range {
|
||||
let Some((f, s)) = shown.get(i) else { continue };
|
||||
let is_selected = f.eq_ignore_ascii_case(&fam) && s.eq_ignore_ascii_case(&style);
|
||||
let resp = ui.selectable_label(is_selected, row_label(f, s));
|
||||
if resp.clicked() {
|
||||
picked = Some((f.clone(), s.clone(), resp.double_clicked()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Picks the font only: nothing is previewed on the document.
|
||||
let pick = font_menu::font_menu(ui, "ff-family", &fam, 220.0, sample.as_deref(), font_menu::MenuLook::of(app));
|
||||
if let Some((f, style)) = font_menu::picked(app, pick) {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(style.unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&d.str("family"));
|
||||
// "(closest)" is ours; the font's style names are shown as they are.
|
||||
let closest = tl!("(closest)");
|
||||
let mut opts: Vec<&str> = vec![closest];
|
||||
opts.extend(styles.iter().map(String::as_str));
|
||||
let style = d.str("style");
|
||||
let cur = if style.is_empty() { closest } else { style.as_str() };
|
||||
if let Some(i) = widgets::dropdown_names(ui, "ff-style", cur, &opts, 120.0) {
|
||||
d.fields.insert("style".into(), json!(if i == 0 { String::new() } else { styles[i - 1].clone() }));
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some((f, s, twice)) = picked {
|
||||
d.fields.insert("family".into(), json!(f));
|
||||
d.fields.insert("style".into(), json!(s));
|
||||
if twice && can_replace(&d, families.as_deref()) {
|
||||
act = Some("change");
|
||||
}
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
// An empty list (Recent before any font was applied) leaves nothing to change to.
|
||||
let replace = sel < list.len() && can_replace(&d, families.as_deref());
|
||||
@@ -228,12 +369,20 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
"change" => change(app, &d, from, false),
|
||||
_ => change(app, &d, from, true),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e;
|
||||
match r {
|
||||
// The rows are a snapshot: the document's fonts and the recent fonts have moved on.
|
||||
Ok(_) if a != "find" => {
|
||||
let used = fonts(app);
|
||||
d.fields.insert("rows".into(), rows_json(&replacement_rows(app, &d.str("replaceFrom"), &used)));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
}
|
||||
if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.dialog = None;
|
||||
// The next open starts with an empty search.
|
||||
clear_search(ctx);
|
||||
} else if app.ui.dialog.is_some() {
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
@@ -370,6 +519,259 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog: every text it painted, with where.
|
||||
fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> Vec<(String, egui::Rect)> {
|
||||
fn collect(s: &egui::epaint::Shape, out: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::epaint::Shape::Text(t) => out.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let raw = egui::RawInput { events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui.ctx()));
|
||||
let mut texts = vec![];
|
||||
for c in &out.shapes {
|
||||
collect(&c.shape, &mut texts);
|
||||
}
|
||||
out.textures_delta.clear();
|
||||
texts
|
||||
}
|
||||
|
||||
/// The texts of a [`frame`] that are in `labels`, in order.
|
||||
fn painted(frame: &[(String, egui::Rect)], labels: &[String]) -> Vec<String> {
|
||||
frame.iter().map(|(t, _)| t.clone()).filter(|t| labels.contains(t)).collect()
|
||||
}
|
||||
|
||||
/// A click at `at`: the press on one frame, the release on the next.
|
||||
fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: egui::Pos2) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
frame(ctx, app, vec![button(false)]);
|
||||
frame(ctx, app, vec![]);
|
||||
}
|
||||
|
||||
/// A document set in `families`, one text each.
|
||||
fn many_font_document(families: &[&str]) -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
for (i, family) in families.iter().enumerate() {
|
||||
app.session.execute("text.create", &json!({"x": 10, "y": 10.0 + i as f64 * 20.0, "text": "Hi", "font": family})).unwrap();
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
/// The catalog's closest style is the face a style-less request resolves to, and finding it reads
|
||||
/// no font file.
|
||||
#[test]
|
||||
fn the_closest_style_is_the_face_a_style_less_request_gets() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
for family in ["Source Sans 3", "Inter"] {
|
||||
let closest = db.closest_style(family).unwrap_or_else(|| panic!("{family} is installed"));
|
||||
let face = db.face(family, "").unwrap_or_else(|| panic!("{family} resolves"));
|
||||
assert_eq!(closest, face.style, "{family}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document font drawn in a substitute style stays listed, but it is not chosen as the
|
||||
/// replacement and Change stays off for it: only a style the font has can be the target.
|
||||
#[test]
|
||||
fn a_substituted_style_is_listed_but_not_chosen() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
// A style Source Sans 3 has none of: its closest style stands in.
|
||||
app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
|
||||
app.session.execute("text.create", &json!({"x": 10, "y": 70, "text": "Hi", "font": "Inter"})).unwrap();
|
||||
open(&mut app);
|
||||
let used = fonts(&mut app);
|
||||
assert!(used.iter().any(|f| f["family"] == "Source Sans 3" && f["style"] == "Black Wide" && f["status"] == "substitute"), "{used:?}");
|
||||
let rows = replacement_rows(&app, "document", &used);
|
||||
let stand_in = Row { family: "Source Sans 3".into(), style: "Black Wide".into() };
|
||||
assert!(rows.contains(&stand_in), "the row stays listed: {rows:?}");
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
set_source(&app, &mut d, "document", &used);
|
||||
assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Inter", "Regular"), "the first font whose style it has");
|
||||
d.fields.insert("family".into(), json!("Source Sans 3"));
|
||||
d.fields.insert("style".into(), json!("Black Wide"));
|
||||
assert!(!can_replace(&d, replacement_families(&app, "document", &used).as_deref()), "Change stays off for the stand-in style");
|
||||
}
|
||||
|
||||
/// A new source picks the family and the style from a row its list shows, so Change replaces with
|
||||
/// what is listed rather than a closest style of its own choosing.
|
||||
#[test]
|
||||
fn a_new_source_picks_a_row_its_list_shows() {
|
||||
let mut app = two_font_document();
|
||||
let used = fonts(&mut app);
|
||||
let mut d = Dialog::new("findFont", json!({"family": "Inter", "style": "", "selected": 0, "replaceFrom": "system"}));
|
||||
set_source(&app, &mut d, "document", &used);
|
||||
assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Inter", "Regular"), "the document's own row");
|
||||
d.fields.insert("family".into(), json!("Inter"));
|
||||
d.fields.insert("style".into(), json!("Black Wide"));
|
||||
set_source(&app, &mut d, "document", &used);
|
||||
assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Inter", "Regular"), "a pair the list lacks gives way");
|
||||
}
|
||||
|
||||
/// Confirming the dialog with Enter forgets the replacement search, as Done and Escape do.
|
||||
#[test]
|
||||
fn confirming_the_dialog_clears_the_replacement_search() {
|
||||
let mut app = two_font_document();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
app.ui_ctx = Some(ctx.clone());
|
||||
open(&mut app);
|
||||
let query_id = egui::Id::unique("ff-search");
|
||||
ctx.data_mut(|m| m.insert_temp(query_id, "times".to_string()));
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "the dialog closes");
|
||||
assert_eq!(ctx.data(|m| m.get_temp::<String>(query_id)), None, "the search text is gone");
|
||||
}
|
||||
|
||||
/// The rows follow a replacement: Change All in the Document source leaves the list showing the
|
||||
/// document's fonts as they are now.
|
||||
#[test]
|
||||
fn the_rows_refresh_after_a_change() {
|
||||
let mut app = two_font_document();
|
||||
open(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let used = fonts(&mut app);
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
set_source(&app, &mut d, "document", &used);
|
||||
d.fields.insert("family".into(), json!("Source Serif 4"));
|
||||
d.fields.insert("style".into(), json!(""));
|
||||
app.ui.dialog = Some(d);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
let change_all = texts.iter().find(|(t, _)| t == "Change All").map(|(_, r)| r.center()).expect("Change All is shown");
|
||||
click(&ctx, &mut app, change_all);
|
||||
assert!(fonts(&mut app).iter().all(|f| f["family"] == "Source Serif 4"), "the document changed");
|
||||
let rows = app.ui.dialog.as_ref().expect("the dialog stays open").fields["rows"].as_array().cloned().unwrap_or_default();
|
||||
let families: Vec<&str> = rows.iter().filter_map(|r| r["family"].as_str()).collect();
|
||||
assert!(
|
||||
!families.is_empty() && families.iter().all(|f| *f == "Source Serif 4"),
|
||||
"the rows list the document's fonts after the change: {families:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Closing the dialog clears the replacement search: the next open starts with an empty one.
|
||||
#[test]
|
||||
fn closing_the_dialog_clears_the_replacement_search() {
|
||||
let mut app = two_font_document();
|
||||
open(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let query_id = egui::Id::unique("ff-search");
|
||||
let source_id = egui::Id::unique("ff-search-source");
|
||||
ctx.data_mut(|m| {
|
||||
m.insert_temp(query_id, "times".to_string());
|
||||
m.insert_temp(source_id, "system".to_string());
|
||||
});
|
||||
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
frame(&ctx, &mut app, vec![escape]);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
assert!(app.ui.dialog.is_none(), "Escape closes the dialog");
|
||||
assert_eq!(ctx.data(|m| m.get_temp::<String>(query_id)), None, "the search text is gone");
|
||||
assert_eq!(ctx.data(|m| m.get_temp::<String>(source_id)), None, "the source it was typed under is gone");
|
||||
}
|
||||
|
||||
/// The wheel over the rows scrolls them, and leaves the document's font list where it was: two
|
||||
/// lists, two scrollbars.
|
||||
#[test]
|
||||
fn the_rows_list_scrolls_with_the_wheel_alone() {
|
||||
let families = ["Arial", "Georgia", "Verdana", "Tahoma", "Courier New", "Times New Roman", "Consolas", "Impact"];
|
||||
let mut app = many_font_document(&families);
|
||||
open(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
let used = fonts(&mut app);
|
||||
let doc_labels: Vec<String> = used
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let (note, _) = font_note(f);
|
||||
format!("{} {}{note} ({})", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or(""), f["runs"])
|
||||
})
|
||||
.collect();
|
||||
let rows = replacement_rows(&app, "system", &used);
|
||||
let labels: Vec<String> = rows.iter().map(|r| row_label(&r.family, &r.style)).collect();
|
||||
let at = |texts: &[(String, egui::Rect)], text: &str| texts.iter().find(|(t, _)| t == text).map(|(_, r)| *r);
|
||||
let start = at(&texts, &labels[0]).expect("the rows start at their top");
|
||||
let (doc_before, rows_before) = (painted(&texts, &doc_labels), painted(&texts, &labels));
|
||||
assert!(!doc_before.is_empty(), "the document's fonts are listed: {doc_before:?}");
|
||||
assert!(!rows_before.is_empty(), "{rows_before:?}");
|
||||
let wheel = egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -20.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
frame(&ctx, &mut app, vec![egui::Event::PointerMoved(start.center()), wheel]);
|
||||
for _ in 0..30 {
|
||||
frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let texts = frame(&ctx, &mut app, vec![]);
|
||||
assert!(at(&texts, &labels[0]).is_none(), "the wheel scrolls the first rows out of view");
|
||||
assert_ne!(painted(&texts, &labels), rows_before, "the wheel scrolls the rows");
|
||||
assert_eq!(painted(&texts, &doc_labels), doc_before, "the document's font list stays where it was");
|
||||
}
|
||||
/// The search filters the rows by family and style, whatever case was typed.
|
||||
#[test]
|
||||
fn the_search_filters_rows_by_family_and_style() {
|
||||
assert!(row_matches("Arial", "Bold", ""), "no search lists every row");
|
||||
assert!(row_matches("Arial", "Bold", "arial"));
|
||||
assert!(row_matches("Arial", "Bold", "rial b"));
|
||||
assert!(row_matches("Source Sans 3", "Semibold", "sans 3 sem"));
|
||||
assert!(!row_matches("Arial", "Bold", "times"));
|
||||
assert!(!row_matches("Arial", "Bold", "bold italic"));
|
||||
}
|
||||
|
||||
/// Replace With Font's rows: Document lists the family and style pairs the document uses, once
|
||||
/// each; Recent and System list each family with its closest style first and then its styles.
|
||||
#[test]
|
||||
fn replacement_rows_pick_a_family_and_its_style() {
|
||||
let mut app = two_font_document();
|
||||
let used = fonts(&mut app);
|
||||
let document = replacement_rows(&app, "document", &used);
|
||||
assert_eq!(
|
||||
document,
|
||||
[Row { family: "Inter".into(), style: "Regular".into() }, Row { family: "Source Serif 4".into(), style: "Regular".into() }],
|
||||
"one row per family and style the document uses"
|
||||
);
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let closest = db.closest_style("Inter").expect("Inter is installed");
|
||||
let recent = replacement_rows(&app, "recent", &used);
|
||||
assert_eq!(recent.first(), Some(&Row { family: "Inter".into(), style: closest.clone() }), "the closest style comes first, by name");
|
||||
assert!(recent.iter().all(|r| r.family == "Inter"), "{recent:?}");
|
||||
assert_eq!(recent.len(), db.styles("Inter").len(), "each style once, the closest not repeated");
|
||||
assert!(recent.iter().all(|r| !r.style.is_empty()), "every row names its style: {recent:?}");
|
||||
let system = replacement_rows(&app, "system", &used);
|
||||
assert!(system.iter().any(|r| r.family == "Source Sans 3" && r.style == "Regular"), "{system:?}");
|
||||
assert!(!system.iter().any(|r| r.family.starts_with('.')), "the font menu's hidden families stay out");
|
||||
assert!(system.iter().all(|r| !r.style.is_empty()), "no nameless rows: {system:?}");
|
||||
let menu = db.menu_family_list();
|
||||
let listed: std::collections::BTreeSet<&str> = system.iter().map(|r| r.family.as_str()).collect();
|
||||
assert_eq!(listed.len(), menu.len(), "every family the font menu shows is listed");
|
||||
let mut unique = system.clone();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), system.len(), "no family and style is listed twice");
|
||||
// The dialog stores the rows when it opens and when the source changes, and a picked row
|
||||
// sets both fields Change then uses.
|
||||
open(&mut app);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["rows"].as_array().map(Vec::len), Some(system.len()));
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
set_source(&app, &mut d, "document", &used);
|
||||
assert_eq!(d.fields["rows"].as_array().map(Vec::len), Some(document.len()));
|
||||
d.fields.insert("family".into(), json!("Inter"));
|
||||
d.fields.insert("style".into(), json!("Regular"));
|
||||
let from = used.iter().find(|f| f["family"] == "Source Serif 4").unwrap().clone();
|
||||
change(&mut app, &d, &from, true).unwrap();
|
||||
assert!(fonts(&mut app).iter().all(|f| f["family"] == "Inter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_all_replaces_with_a_font_from_the_document() {
|
||||
let mut app = two_font_document();
|
||||
@@ -485,7 +887,7 @@ mod tests {
|
||||
set_source(&app, &mut d, "recent", &used);
|
||||
assert_eq!(d.str("family"), "");
|
||||
set_source(&app, &mut d, "system", &used);
|
||||
assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Source Sans 3", ""));
|
||||
assert_eq!((d.str("family").as_str(), d.str("style").as_str()), ("Source Sans 3", "Regular"), "the family's first listed style");
|
||||
assert!(can_replace(&d, None));
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | M7: Find Font's Replace With Font lists family and style rows with a search, and the list and the document's font list scroll on their own (#4972); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Selection Tools add-on: Select by Artboards selects the objects overlapping, fully inside or fully outside several chosen artboards, from a dialog that lists, searches and multi-selects the artboards and fits one in the window on double-click (#5525, #5548, #5549, a VectorCraft addition); scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen preset; Recent and Saved cards keep the units their document or preset was made in, and the built-in tabs still follow the chosen units (#2078). Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. |
|
||||
|
||||
@@ -35,6 +35,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
| 2026-11-03 | minor | Correct the Type core missing list and remove duplicate target and Type core entries: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Correct the Type core missing list: Hyphenation and Justification options are complete; the remaining Every-line Composer fidelity limits stay listed. Record the Kashidas paragraph lengths and per-character switch in the feature history (#5404). Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Record Find Font's replacement rows: family and style together, a search over them, and a scroll state of their own (#4972); scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. |
|
||||
| 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Type-range Copy keeps formatting when pasted as separate text art after editing; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Find Font's Replace With Font lists family and style rows, searchable, with its own scroll state and a refresh after each replacement; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
|
||||
| Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. |
|
||||
| OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet |
|
||||
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) |
|
||||
| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux) Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). |
|
||||
| Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG |
|
||||
| Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending |
|
||||
| Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` |
|
||||
@@ -78,6 +78,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | A Type-range Copy or Cut publishes styled art alongside plain text, so a later canvas Paste keeps its character formatting after editing ends (#5636). Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Glyphs panel adds Alternates for Current Selection. A chosen alternate changes one character's drawn glyph while keeping its Unicode text; character-style capture does not copy that per-character choice. Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Find Font's Replace With Font lists family and style rows, one click setting both, with a search over them; the list scrolls apart from the document's font list and refreshes after each replacement (#4972); scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | The OpenType panel lists the selected font's available Stylistic Sets with its custom names or numbered labels; sets can be combined and deselected. Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Character-menu Kashidas controls selected characters separately from the paragraph's Insert Kashidas length. Existing text allows Kashidas by default; scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Type on a Path Options has a Preview checkbox: the type follows the fields while it is on, Cancel restores it, OK is one undo step (#5360, as public tutorials of the dialog show); scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user