Merge pull request #7618 from dotCooCoo/fix/opentype-ligature-note-every-frame

OpenType panel: the tracked-ligatures note reads every selected story frame
This commit is contained in:
BFlat
2026-11-15 05:29:54 +07:00
committed by GitHub
3 changed files with 81 additions and 11 deletions
+27 -8
View File
@@ -143,33 +143,52 @@ fn shared_styles<T: PartialEq>(
app: &VectorcraftApp,
f: impl Fn(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle, bool) -> Option<T>,
) -> Option<T> {
let st = app.session.active()?;
let mut acc = Shared::None;
each_style(app, |t, para, style, inline| acc.add(f(t, para, style, inline)));
acc.value().flatten()
}
/// Whether `f` holds for any of the character styles [`shared`] reads.
pub(crate) fn any_style(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> bool) -> bool {
let mut any = false;
each_style(app, |_, _, style, _| any = any || f(style));
any
}
/// Call `f` with each character style the panels act on, as [`shared_styles`] describes: the
/// caret's style at a Type tool caret, else each piece of the runs the Type tool's selection
/// covers, else of every run of every frame of the selected stories.
fn each_style(app: &VectorcraftApp, mut f: impl FnMut(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle, bool)) {
let Some(st) = app.session.active() else {
return;
};
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
};
let mut acc = Shared::None;
if let Some((id, a, b)) = text_editing(app) {
let t = text(id)?;
let Some(t) = text(id) else {
return;
};
if b <= a {
let para = t.plain_text().get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count());
return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b), false);
f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b), false);
return;
}
match text_story(app) {
// After Select All in threaded text: all the text of every frame of the story.
Some(story) => {
for t in story.into_iter().filter_map(text) {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
each_paragraph_run(t, None, |para, style, inline| f(t, para, style, inline));
}
}
None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))),
None => each_paragraph_run(t, Some((a, b)), |para, style, inline| f(t, para, style, inline)),
}
} else {
for t in selected_story_texts(app) {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
each_paragraph_run(t, None, |para, style, inline| f(t, para, style, inline));
}
}
acc.value().flatten()
}
/// `f` of the paragraph and style of each piece of `t`'s runs that lies in one paragraph (a
+53 -3
View File
@@ -7,7 +7,7 @@ use serde_json::json;
use vectorcraft_doc::{CharStyle, NodeKind};
use vectorcraft_text::{EXTRA_FEATURES, FontDb, OtFeatures, StylisticSet};
use super::character::{shared, text_editing, text_style};
use super::character::{any_style, shared, text_editing, text_style};
use crate::VectorcraftApp;
use crate::widgets::{self, menu_item};
@@ -203,7 +203,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::dim_label(ui, tl!("Select text to set its OpenType features."));
return;
};
let cur = OtFeatures::from_tags(st.features.iter().map(String::as_str));
let mut change = None;
let fig = shown_figure(app);
ui.horizontal(|ui| {
@@ -251,7 +250,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
});
});
if cur.ligatures && vectorcraft_text::ligatures_suppressed_by(st.tracking) && !vectorcraft_text::explicit_ligatures(&st.features) {
if tracking_drops_ligatures(app) {
widgets::dim_label(ui, tl!("Ligatures are off while the text is tracked."));
}
if let Some(c) = change {
@@ -259,6 +258,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// Whether tracking turns off the standard ligatures of any character the panel acts on: one with
/// Standard Ligatures on, tracked past [`vectorcraft_text::ligatures_suppressed_by`]'s limit and
/// without an explicit `liga` or `clig` tag ([`vectorcraft_text::explicit_ligatures`]).
fn tracking_drops_ligatures(app: &VectorcraftApp) -> bool {
any_style(app, |s| {
OtFeatures::from_tags(s.features.iter().map(String::as_str)).ligatures
&& vectorcraft_text::ligatures_suppressed_by(s.tracking)
&& !vectorcraft_text::explicit_ligatures(&s.features)
})
}
/// The stylistic sets of the font `st` names. A font that is not available offers none, rather than
/// the sets of the face drawn in its place.
fn offered_sets(st: &vectorcraft_doc::CharStyle) -> Vec<StylisticSet> {
@@ -303,6 +313,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
#[cfg(test)]
mod tests {
use serde_json::Value;
use vectorcraft_doc::NodeId;
use super::*;
@@ -644,6 +655,45 @@ mod tests {
);
}
/// With the Selection tool on the first frame of a threaded story, the tracked-ligatures note
/// reads every frame: it shows when a later, locked frame is tracked with Standard Ligatures
/// on, and not once that frame's ligatures are explicit, off, or its tracking is back within
/// the limit.
#[test]
fn ligature_note_reads_every_frame_of_the_story() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(6);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
let a = app.session.execute("text.create", &area).unwrap()["id"].clone();
let b = app.session.execute("text.thread.add", &json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].clone();
assert!(!run_features(&mut app, &b, 4).is_empty(), "the second frame holds text");
app.session.execute("text.setStyle", &json!({"ids": [b], "tracking": 100})).unwrap();
app.session.execute("select.set", &json!({"ids": [b]})).unwrap();
app.session.execute("object.lock", &json!({})).unwrap();
app.session.execute("select.set", &json!({"ids": [a]})).unwrap();
assert_eq!(text_editing(&app), None);
assert_eq!(text_style(&app).unwrap().0.tracking, 0.0, "the panel reads its first run from the first frame");
assert!(tracking_drops_ligatures(&app), "the tracked second frame shows the note");
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
out.textures_delta.clear();
// Commands write `liga` as the default and leave it out; an opened file can keep it.
let b_id = NodeId(b.as_u64().unwrap());
for tags in [&["liga"][..], &["clig"][..], &["-liga"][..]] {
let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc);
let Some(NodeKind::Text(t)) = doc.node_mut(b_id).map(|n| &mut n.kind) else { panic!("no second frame") };
for r in &mut t.runs {
r.style.features = tags.iter().map(|s| s.to_string()).collect();
}
assert!(!tracking_drops_ligatures(&app), "{tags:?}");
}
app.session.execute("text.setStyle", &json!({"ids": [b], "features": [], "tracking": 20})).unwrap();
assert!(!tracking_drops_ligatures(&app), "tracking within the limit");
app.session.execute("text.setStyle", &json!({"ids": [b], "tracking": -100})).unwrap();
assert!(tracking_drops_ligatures(&app), "tight tracking shows the note too");
}
/// At a caret whose insertion style is already a denominator, Denominator still reaches the
/// characters the command styles at a caret (all of them) in one undo step; chosen again once
/// every character has it, it adds no undo step.
+1
View File
@@ -1371,6 +1371,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged.
The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.
The OpenType panel's Position menu lists Default Position, Superscript, Subscript, Numerator and Denominator, as [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) describes; Superscript, Subscript, Numerator and Denominator set `sups`, `subs`, `numr` and `dnom` for the selected type and turn the other three off, and Default Position turns all four off. The menu shows Mixed when the characters it acts on differ, and an entry chosen then applies to all of them. These two rules are VectorCraft's own, as is setting the feature on type whose font lacks the glyphs, which then draws unchanged. The panel's menus and checkboxes change only their own features on each selected character, so characters keep their other OpenType features (`text.setStyle` and `text.setRangeStyle` `{featureChanges}`). Tests: `panels::opentype::tests`, `features::tests::numerators_and_denominators_are_kept_and_shaped`.
The OpenType panel's note that ligatures are off while the text is tracked shows when any character the panel reads has Standard Ligatures on, tracking outside -20 to 55 thousandths of an em, and no explicit `liga` or `clig` tag. With the Selection tool, those characters include every frame of a selected threaded story, locked and hidden frames included; the panel's commands still change only the selected frames. The tracking limits and this rule are VectorCraft's own. Tests: `panels::opentype::tests::ligature_note_reads_every_frame_of_the_story`.
The Glyphs panel's Show menu and panel menu have Alternates for Current Selection, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes. The grid lists the selected character's alternate glyphs from `GSUB` single and alternate substitutions of `aalt`, then `salt`, `ss01` to `ss20`, `swsh`, `cv01` to `cv99` and a few other alternate features, each glyph once. A double-click applies the feature that draws the variant, or a glyph choice if no supported feature draws it, as detailed below. The list uses the font of the character's text even when the panel browses another font, and the panel shows a note when the character has no alternates, its font is missing or no character is selected; these rules are VectorCraft's own. Native files keep a glyph choice with the face it was made in; another font or version draws the default glyph. Tests: `panels::glyphs::tests`, `tests::alternates`, `tests::textedit::a_glyph_alternate_replaces_one_character_through_undo_and_reopen`.
The Glyphs panel's Alternates for Current Selection (#5514) applies the OpenType feature that draws the glyph to the selected character alone, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Alternates For Current Selection in the Show menu; double-click the glyph to replace the selected character). One deliberate difference from a literal reading: the text keeps the Unicode character and gains a character-style feature with a value (`salt=2`, `ss03`; `text.setRangeStyle {featureChanges}`) instead of taking an unencoded glyph in its place. The grid lists the alternates of the glyph the shaper makes for the character in its word (a medial letter shows its medial variants; the default glyph comes first and the current glyph is highlighted); a variant no supported feature draws there stays enabled as a replacement glyph (`text.setGlyphAlternate`, including positional alternates), and its tooltip says it may not join its neighbors; the default glyph takes the character's alternate features off again. Runs that differ only in their OpenType features are shaped as one piece of text, each run's features over its own characters, so the letters of an Arabic word keep their joining forms. Show › Entire Font also lists the glyphs no character maps, with the font's name for them (or `gid N`) and the feature that gives them. It applies the selected letter's contextual alternates the same way, and applies a glyph no feature reaches whose name does not belong to another letter as a glyph choice; other glyphs are dimmed with the reason. An Arabic presentation form picked there inserts its base letter. These rules are VectorCraft's own. Tests: `tests::glyph_alternates` in the text crate (a fixture font; Kufi Murabba PIIIX when it is installed), `panels::glyphs::tests` (including positional glyph choices, unlisted glyph name ownership, apply and Undo), `tests::textedit::feature_values_are_set_replaced_and_saved`.
With a Type tool and exactly one character selected, the canvas shows that character's alternate glyphs in a row beside it, and a click on one replaces the character's glyph, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes ("Replace a character with on-canvas alternate glyphs"). The row shows at most five alternates, and an arrow at its end opens the Glyphs panel on Alternates for Current Selection when the character has more, as the [reference manual](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479) describes. A click runs the command the panel's double-click runs, as one undo step, so a letter of an Arabic word keeps the word joined. These rules are VectorCraft's own: the row leaves out the glyph the character is drawn with and puts the default glyph first for a character drawn with an alternate; it stands under a character of horizontal type and on the right of a character of vertical type, or on the other side when the canvas ends there or the Contextual Task Bar is there; Preferences › Type › Show Character Alternates (`showCharacterAlternates`, on by default) turns it off. Tests: `canvas::alternates_bar::tests`, `tests::prefs::show_character_alternates_preference_is_on_by_default_and_saved`.