mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
Fix glyph browser reset, variation and insertion behavior
This commit is contained in:
@@ -74,7 +74,6 @@ impl FontFace {
|
||||
pub fn is_unlisted_alternate(&self, c: char, gid: u32) -> bool {
|
||||
self.unencoded_glyphs().binary_search(&gid).is_ok()
|
||||
&& self.feature_reaching(gid).is_none()
|
||||
&& self.glyph_origin(gid).is_none()
|
||||
&& self.name_owner(gid).is_none_or(|owner| owner == c)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,41 @@ pub fn variable_font() -> Option<Vec<u8>> {
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`unlisted_variable_font`].
|
||||
pub const UNLISTED_VARIABLE_FAMILY: &str = "Unlisted Varitest Sans";
|
||||
|
||||
/// A variable face with no GSUB: `a.a` is an unencoded alternate named for `a`, but the Glyphs
|
||||
/// panel has no listed alternates. The weight axis comes from [`variable_font`].
|
||||
pub fn unlisted_variable_font() -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::cmap::Cmap;
|
||||
|
||||
let bytes = variable_font()?;
|
||||
let font = skrifa::FontRef::new(&bytes).ok()?;
|
||||
let mappings = [' ', 'a', 'l']
|
||||
.into_iter()
|
||||
.map(|c| Some((c, write_fonts::types::GlyphId::new(font.charmap().map(c)?.to_u32()))))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let cmap = Cmap::from_mappings(mappings).ok()?;
|
||||
let name = name_table(&[
|
||||
(1, UNLISTED_VARIABLE_FAMILY),
|
||||
(2, "Regular"),
|
||||
(4, "Unlisted Varitest Sans Regular"),
|
||||
(5, "Version 1.000"),
|
||||
(6, "UnlistedVaritestSans-Regular"),
|
||||
(256, "Weight"),
|
||||
])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_table(&cmap).ok()?;
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if tag != Tag::new(b"GSUB") && !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`two_axis_font`].
|
||||
pub const TWO_AXIS_FAMILY: &str = "Biaxtest Sans";
|
||||
|
||||
@@ -920,6 +955,41 @@ pub fn joining_alternates_font() -> Option<Vec<u8>> {
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`aliased_joining_font`].
|
||||
pub const ALIASED_JOINING_FAMILY: &str = "Alias Jointest Sans";
|
||||
|
||||
/// [`joining_alternates_font`] with Latin `B` and the Arabic initial beh presentation form
|
||||
/// U+FE91 both mapped to beh's initial glyph. Only the presentation form should insert beh.
|
||||
pub fn aliased_joining_font() -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::cmap::Cmap;
|
||||
|
||||
let bytes = joining_alternates_font()?;
|
||||
let font = skrifa::FontRef::new(&bytes).ok()?;
|
||||
let source = skrifa::FontRef::new(SOURCE_SANS).ok()?;
|
||||
let initial = write_fonts::types::GlyphId::new(source.charmap().map('B')?.to_u32());
|
||||
let mut mappings =
|
||||
font.charmap().mappings().filter_map(|(c, g)| Some((char::from_u32(c)?, write_fonts::types::GlyphId::new(g.to_u32())))).collect::<Vec<_>>();
|
||||
mappings.extend([('B', initial), ('\u{FE91}', initial)]);
|
||||
let cmap = Cmap::from_mappings(mappings).ok()?;
|
||||
let name = name_table(&[
|
||||
(1, ALIASED_JOINING_FAMILY),
|
||||
(2, "Regular"),
|
||||
(4, "Alias Jointest Sans Regular"),
|
||||
(5, "Version 1.000"),
|
||||
(6, "AliasJointestSans-Regular"),
|
||||
])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_table(&cmap).ok()?;
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`ligature_caret_font`].
|
||||
pub const LIGATURE_CARET_FAMILY: &str = "Carettest Sans";
|
||||
|
||||
|
||||
@@ -241,7 +241,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
font_row(app, ui, &family, &style, w);
|
||||
return;
|
||||
}
|
||||
let Some(face) = db.face(&family, &style) else {
|
||||
// The same family and style can have a different face id at the selection's version or axes.
|
||||
// Use that instance for both the previews and Fit's applicability check.
|
||||
let face = selected_target(app)
|
||||
.filter(|t| t.style.font_family.eq_ignore_ascii_case(&family) && t.style.font_style.eq_ignore_ascii_case(&style))
|
||||
.and_then(|t| db.style_face(t.style))
|
||||
.or_else(|| db.face(&family, &style));
|
||||
let Some(face) = face else {
|
||||
ui.label(egui::RichText::new(tl!("No fonts are available.")).color(t.text_dim));
|
||||
return;
|
||||
};
|
||||
@@ -447,7 +453,7 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
|
||||
let (features, has_choice) = (target.style.features.clone(), target.style.glyph_alternate.is_some());
|
||||
let found = alternates_found(app, ui, id, byte, face.id());
|
||||
let font = format!("{} {}", face.family, face.style);
|
||||
let Some(found) = found.filter(|f| !f.alternates.is_empty()) else {
|
||||
let Some(found) = found.filter(|f| has_choice || !f.alternates.is_empty()) else {
|
||||
note(ui, tl!("No alternates for the selected character."));
|
||||
ui.label(egui::RichText::new(font).size(11.0).color(t.text_dim));
|
||||
return;
|
||||
@@ -679,6 +685,9 @@ fn glyph_label(face: &vectorcraft_text::FontFace, gid: u32) -> String {
|
||||
/// them; the letter takes the form its place in the word asks for, so the word stays joined.
|
||||
/// `None` when `c` is not such a form in this font.
|
||||
fn base_letter(face: &vectorcraft_text::FontFace, c: char, gid: u32) -> Option<char> {
|
||||
if !matches!(c, '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}') {
|
||||
return None;
|
||||
}
|
||||
let origin = face.glyph_origin(gid)?;
|
||||
origin.position()?;
|
||||
(origin.ch != c).then_some(origin.ch)
|
||||
@@ -1567,4 +1576,82 @@ mod tests {
|
||||
assert_eq!(glyph_ids(&app, &id), typed, "shaped as the word typed with beh");
|
||||
assert!(app.ui.status.starts_with("Inserted ب (U+0628)"), "{}", app.ui.status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlisted_choice_without_gsub_can_be_reset_from_the_default_cell() {
|
||||
use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font};
|
||||
let db = FontDb::global();
|
||||
db.add_font(unlisted_variable_font().expect("the test font builds"));
|
||||
let face = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face");
|
||||
let glyph = gid_named(&face, "a.a");
|
||||
let (mut app, id) = editing("a");
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY})).unwrap();
|
||||
select(&mut app, 0, 1);
|
||||
let plain = glyph_ids(&app, &id);
|
||||
app.run("text.setGlyphAlternate", json!({"id": id, "start": 0, "gid": glyph})).unwrap();
|
||||
assert_eq!(glyph_ids(&app, &id), [glyph]);
|
||||
let ctx = egui::Context::default();
|
||||
choose(&ctx, &mut app, "Alternates for Current Selection");
|
||||
let cells = squares(&shapes(&ctx, &mut app, vec![]));
|
||||
assert_eq!(cells.len(), 1, "the default remains available without any GSUB alternates");
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
double_click(&ctx, &mut app, cells[0].center());
|
||||
assert_eq!(glyph_ids(&app, &id), plain);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(glyph_ids(&app, &id), [glyph]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entire_font_applies_an_unlisted_glyph_at_the_selections_variable_axes() {
|
||||
use vectorcraft_text::test_fonts::{UNLISTED_VARIABLE_FAMILY, unlisted_variable_font};
|
||||
let db = FontDb::global();
|
||||
db.add_font(unlisted_variable_font().expect("the test font builds"));
|
||||
let base = db.face(UNLISTED_VARIABLE_FAMILY, "Regular").expect("the test face");
|
||||
let glyph = gid_named(&base, "a.a");
|
||||
let (mut app, id) = editing("a");
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": UNLISTED_VARIABLE_FAMILY, "fontAxes": {"wght": 650}})).unwrap();
|
||||
select(&mut app, 0, 1);
|
||||
let target = selected_target(&app).expect("the selected character");
|
||||
let face = db.style_face(target.style).expect("the variable instance");
|
||||
assert_ne!(face.id(), base.id(), "a non-default setting has its own face id");
|
||||
let ctx = egui::Context::default();
|
||||
let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).count();
|
||||
let at = chars + face.unencoded_glyphs().iter().position(|g| *g == glyph).expect("the unencoded glyph");
|
||||
let cells = inks(&tall_shapes(&ctx, &mut app));
|
||||
assert_eq!(cells[at].1, Tokens::get(&ctx).text_strong, "the instance's alternate is enabled");
|
||||
double_click_in(&ctx, &mut app, cells[at].0.center(), TALL);
|
||||
assert_eq!(glyph_ids(&app, &id), [glyph]);
|
||||
assert_eq!(selected_target(&app).unwrap().style.font_axes["wght"], 650.0);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_ne!(glyph_ids(&app, &id), [glyph]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_characters_normalize_only_arabic_presentation_forms() {
|
||||
use vectorcraft_text::test_fonts::{ALIASED_JOINING_FAMILY, aliased_joining_font};
|
||||
let db = FontDb::global();
|
||||
db.add_font(aliased_joining_font().expect("the test font builds"));
|
||||
let face = db.face(ALIASED_JOINING_FAMILY, "Regular").expect("the test face");
|
||||
let chars = face.chars().into_iter().filter(|(c, _)| !c.is_control() && !c.is_whitespace()).collect::<Vec<_>>();
|
||||
for (c, expected) in [('B', "B"), ('\u{FE91}', "\u{0628}")] {
|
||||
let (mut app, id) = editing("a");
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": ALIASED_JOINING_FAMILY})).unwrap();
|
||||
select(&mut app, 0, 1);
|
||||
let at = chars.iter().position(|(ch, _)| *ch == c).expect("an encoded character");
|
||||
let origin = face.glyph_origin(chars[at].1).expect("the shared positional glyph");
|
||||
assert_eq!(origin.ch, '\u{0628}');
|
||||
assert!(origin.position().is_some());
|
||||
let ctx = egui::Context::default();
|
||||
let cells = inks(&tall_shapes(&ctx, &mut app));
|
||||
double_click_in(&ctx, &mut app, cells[at].0.center(), TALL);
|
||||
let state = app.session.active().unwrap();
|
||||
let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(text.plain_text(), expected, "the encoded {c} cell inserts the intended character");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
let state = app.session.active().unwrap();
|
||||
let NodeKind::Text(text) = &state.doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(text.plain_text(), "a");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Scores and estimates unchanged.
|
||||
Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter. Scores and estimates unchanged.
|
||||
|
||||
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
|
||||
@@ -84,6 +84,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | Glyphs panel (#5514): Alternates for Current Selection lists the alternates of the selected character's glyph in its word and a double-click applies the OpenType feature that draws one to that letter alone (`salt=2`; `features` and `featureChanges` take `tag=N`), so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, applies the ones that are the selected letter's alternates, or that no feature reaches and the font's names do not give to another letter, and dims, with the reason, those that are not. Runs that differ only in their features shape as one piece of text. Scores and estimates unchanged. |
|
||||
| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user