diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 99eee0336..781bec84e 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -120,6 +120,21 @@ impl Quotes { prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p))) } + /// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else. + /// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with + /// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening + /// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single + /// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian + /// wants, and `’90s` keeps its apostrophe. + pub fn typed_as_straight(c: char) -> Option { + (c == '”').then_some('"') + } + + /// Does a quote typed after `prev` (the character before it, `None` at the start) open? + pub fn opens_after(&self, prev: Option) -> bool { + self.opens(prev, None) + } + /// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by /// context (an apostrophe is a closing single quote). `prev` is the character before `text`, /// and is updated to its last character, so text can be converted in pieces. @@ -420,6 +435,12 @@ mod tests { } let mut prev = None; assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“"); + // Only a typed `”` stands for a straight quote; the other curly quotes stay as typed. + assert_eq!(Quotes::typed_as_straight('”'), Some('"')); + assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none())); + // A quote opens at the start and after a space, and closes after a letter. + assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' '))); + assert!(!Quotes::ENGLISH.opens_after(Some('a'))); // Bulgarian: „…“ at either level, as CLDR has it. let mut prev = None; assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“"); diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 4b76562e9..d8f2b0289 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -1004,7 +1004,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace); - drop(replace); Ok(runs) })?; Ok(json!({ "runs": runs, "font": family })) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index efe978628..f648a00a3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -689,7 +689,6 @@ fn replace(s: &mut Session, p: &Value) -> Result { } } edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in); - drop(replace_in); Ok(n) })? }; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79aa06891..dc19b9edb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -249,6 +249,9 @@ pub struct Session { /// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not /// saved. pub global_edit_options: cmd::globaledit::Options, + /// Set while [`Session::tool_typed_text`] passes keyboard input to the active tool + /// ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). + keyboard_text: bool, } impl Default for Session { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d9ef5740b..ab258887f 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -110,6 +110,7 @@ impl Session { link_stamps: Default::default(), link_scan: None, global_edit_options: Default::default(), + keyboard_text: false, } } diff --git a/crates/engine/src/tests/docsetup.rs b/crates/engine/src/tests/docsetup.rs index c1c70ac07..cf2e4a510 100644 --- a/crates/engine/src/tests/docsetup.rs +++ b/crates/engine/src/tests/docsetup.rs @@ -28,16 +28,28 @@ fn create_text(s: &mut Session, t: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) } -/// Click with the Type tool at (x, y) and type `keys` one at a time; returns the new text object. +/// Click with the Type tool at (x, y) and type `keys` one at a time on the keyboard; returns the +/// new text object. fn type_at(s: &mut Session, x: f64, y: f64, keys: &[&str]) -> NodeId { + enter_at(s, x, y, keys, true) +} + +/// Click with the Type tool at (x, y) and enter `texts` one at a time, typed on the keyboard +/// ([`Session::tool_typed_text`]) or inserted as the Glyphs panel and pastes do +/// ([`Session::tool_text`]); returns the new text object. +fn enter_at(s: &mut Session, x: f64, y: f64, texts: &[&str], keyboard: bool) -> NodeId { let v = ViewInfo::default(); s.select_tool("type", v).unwrap(); for k in [PointerKind::Down, PointerKind::Up] { s.pointer(&PointerEvent::new(k, x, y), v).unwrap(); } let id = s.doc().unwrap().selection.objects[0]; - for k in keys { - s.tool_text(k, v).unwrap(); + for t in texts { + if keyboard { + s.tool_typed_text(t, v).unwrap(); + } else { + s.tool_text(t, v).unwrap(); + } } s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); id @@ -177,6 +189,53 @@ fn typographers_quotes_setting_changes_typed_quotes() { assert_eq!(text(&s, id).plain_text(), "\"b'"); } +/// A lone typed `”` is set like a straight quote where a quote opens: the macOS Serbian Cyrillic +/// layout has no straight quote and types `”` on Shift+2 (#5247), so Serbian text still gets +/// „…”. Where a quote closes it stays `”`. `“ „` and the single quotes stay as typed, so `’90s` +/// keeps its apostrophe. Pasted text and the Glyphs panel keep their quotes, and with the option +/// off `”` stays as typed. +#[test] +fn a_typed_closing_double_quote_opens_where_a_quote_opens() { + let mut s = session(); + s.execute("document.setup", &json!({"language": "Serbian: Cyrillic"})).unwrap(); + let id = type_at(&mut s, 50.0, 50.0, &["”", "П", "р", "о", "б", "а", "”"]); + assert_eq!(text(&s, id).plain_text(), "„Проба”"); + // The other curly quotes stay as typed. + let id = type_at(&mut s, 50.0, 100.0, &["„", "a", " ", "’", "b", "’", "“"]); + assert_eq!(text(&s, id).plain_text(), "„a ’b’“"); + // In English a `”` that opens is `“`; the apostrophes stay. + s.execute("document.setup", &json!({"language": "English: USA"})).unwrap(); + let id = type_at(&mut s, 50.0, 150.0, &["”", "i", "t", "’", "s", "”"]); + assert_eq!(text(&s, id).plain_text(), "“it’s”"); + // An apostrophe after a space stays an apostrophe, and a typed `‘` stays `‘`. + let id = type_at(&mut s, 50.0, 200.0, &["t", "h", "e", " ", "’", "9", "0", "s", " ", "‘", "a"]); + assert_eq!(text(&s, id).plain_text(), "the ’90s ‘a"); + // Where a quote closes, `”` stays `”` even if the document's closing quote is another. + s.execute("document.setup", &json!({"language": "German"})).unwrap(); + let id = type_at(&mut s, 50.0, 250.0, &["”", "a", "”"]); + assert_eq!(text(&s, id).plain_text(), "„a”"); + // More than one character is pasted text, not a typed quote. + let id = type_at(&mut s, 50.0, 300.0, &["”a”"]); + assert_eq!(text(&s, id).plain_text(), "”a”"); + // An IME commit of a lone `”` that will open is not verbatim (it is rewritten); from the Glyphs + // panel it would be inserted as it is. + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 50.0, 312.0), v).unwrap(); + } + assert!(!s.tool_typed_text_is_verbatim("”", v)); + assert!(s.tool_text_is_verbatim("”", v)); + s.tool_key(ToolKey::Escape, Default::default(), v).unwrap(); + // A `”` inserted from the Glyphs panel or pasted alone stays as inserted. + let id = enter_at(&mut s, 50.0, 325.0, &["”", "a"], false); + assert_eq!(text(&s, id).plain_text(), "”a"); + // With the option off a typed `”` stays as typed. + s.execute("document.setup", &json!({"typographersQuotes": false})).unwrap(); + let id = type_at(&mut s, 50.0, 350.0, &["”", "b", "”"]); + assert_eq!(text(&s, id).plain_text(), "”b”"); +} + #[test] fn smart_punctuation_uses_the_document_quotes() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index fa389583f..8ecc738f2 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -333,6 +333,7 @@ impl Session { unit, stroke_unit, paste_plain_text: self.prefs.paste_text_formatting == "plain", + typed_from_keyboard: self.keyboard_text, slices_hidden: self.menu.slices_hidden, slices_locked: self.menu.slices_locked, auto_add_delete: !self.prefs.disable_auto_add_delete, @@ -616,6 +617,17 @@ impl Session { self.apply_actions(acts) } + /// Text typed on the keyboard (a key or an IME commit) for the active tool: as + /// [`Session::tool_text`], and Typographer's Quotes may also set a lone typed `”` as an opening + /// quote ([`vectorcraft_tools::ToolContext::typed_from_keyboard`]). The Glyphs panel and pastes + /// use [`Session::tool_text`], so their quotes stay as inserted. + pub fn tool_typed_text(&mut self, text: &str, view: ViewInfo) -> Result> { + self.keyboard_text = true; + let r = self.tool_text(text, view); + self.keyboard_text = false; + r + } + /// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering, /// newline normalization or document formatting such as Typographer's Quotes)? An IME commit /// the application rewrites is an application edit, not an accepted IME insertion. @@ -623,6 +635,16 @@ impl Session { self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text)) } + /// [`Session::tool_text_is_verbatim`] for text typed on the keyboard, as + /// [`Session::tool_typed_text`] would insert it: a lone typed `”` that Typographer's Quotes + /// turns into an opening quote is not verbatim. + pub fn tool_typed_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool { + self.keyboard_text = true; + let v = self.tool_text_is_verbatim(text, view); + self.keyboard_text = false; + v + } + /// Does the active tool's open typing session still match the document? An IME commit after /// an external edit starts a fresh session (an application-side reset), so its receipt must /// not be accepted as an unchanged IME insertion. diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index deb305b65..054db258d 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -188,6 +188,10 @@ pub struct ToolContext<'a> { /// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes /// the style at the caret, even the text the Type tool copied with its formatting. pub paste_plain_text: bool, + /// The text given to [`crate::Tool::text_input`] was typed on the keyboard (a key or an IME + /// commit), not inserted from the Glyphs panel or a paste: a lone typed `”` may then take the + /// document's opening quote ([`vectorcraft_doc::Quotes::typed_as_straight`]). + pub typed_from_keyboard: bool, /// View → Hide Slices: the Slice Selection tool can't pick hidden slices. pub slices_hidden: bool, /// View → Lock Slices: the Slice Selection tool leaves locked slices alone. diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f16969907..b9f88c03b 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -511,6 +511,7 @@ pub(crate) mod testutil { unit: Unit::Points, stroke_unit: Unit::Points, paste_plain_text: false, + typed_from_keyboard: false, slices_hidden: false, slices_locked: false, auto_add_delete: true, diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 6e48945e3..50afa31c2 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -36,7 +36,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; -use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_text::{FontDb, TextLayout, edit}; @@ -328,11 +328,26 @@ impl TypeTool { return None; } // Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's - // opening or closing quote, by the character before it. + // opening or closing quote, by the character before it. A `”` typed alone (the macOS Serbian + // Cyrillic layout's only double quote) becomes the opening quote where one opens, and stays + // `”` where one closes. let setup = &cx.doc.setup; - if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") { - let mut prev = self.char_before(cx, self.caret.min(self.anchor)); - return Some(setup.quotes.apply(&s, &mut prev)); + if setup.typographers_quotes { + let mut chars = s.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + if matches!(c, '"' | '\'') { + let mut prev = self.char_before(cx, self.caret.min(self.anchor)); + return Some(setup.quotes.apply(&s, &mut prev)); + } + if cx.typed_from_keyboard + && let Some(q) = Quotes::typed_as_straight(c) + { + let mut prev = self.char_before(cx, self.caret.min(self.anchor)); + if setup.quotes.opens_after(prev) { + return Some(setup.quotes.apply(&q.to_string(), &mut prev)); + } + } + } } Some(s) } diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 696ed1c0e..5d79cceb2 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -164,7 +164,7 @@ impl Tool for TypeTool { // commit this check still sees the pre-edit at the caret, while `text_input` replaces it // from its start; with a custom pair whose opening quote is straight, that can look // verbatim here and then be rewritten. Never claim verbatim for one. - if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") { + if cx.doc.setup.typographers_quotes && (matches!(s, "\"" | "'") || (cx.typed_from_keyboard && is_one_typed_curly_quote(s))) { return false; } let Some(normalized) = self.input_text(cx, s) else { return false }; @@ -716,3 +716,10 @@ impl Tool for TypeTool { } } } + +/// Is `s` one curly quote that Typographer's Quotes may set like a straight one +/// ([`vectorcraft_doc::Quotes::typed_as_straight`]: a lone `”`)? +fn is_one_typed_curly_quote(s: &str) -> bool { + let mut chars = s.chars(); + matches!((chars.next(), chars.next()), (Some(c), None) if vectorcraft_doc::Quotes::typed_as_straight(c).is_some()) +} diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index a3127adde..cee164c7f 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -27,7 +27,7 @@ pub(crate) fn keep_marked_text(app: &mut VectorcraftApp) { app.ime_discard = true; if app.session.tool_composing() { // Errors are already reported by the engine's interaction. - let _ = app.session.tool_text(&m, app.view_info()); + let _ = app.session.tool_typed_text(&m, app.view_info()); } } @@ -260,7 +260,7 @@ fn apply_type_event( Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { let target_before = crate::ime::target(&mut app.session, view); let before = crate::ime::stamp(&mut app.session, view); - let r = app.session.tool_text(&t, view); + let r = app.session.tool_typed_text(&t, view); if r.is_ok() { crate::ime::note_application_edit(app, ctx, view, target_before, before); } @@ -283,7 +283,7 @@ fn apply_type_event( let composing = app.session.tool_composing(); let verbatim = app.session.tool_ime_session_matches(view) && match app.ime_marked.as_deref() { - Some(m) => app.session.tool_text_is_verbatim(m, view), + Some(m) => app.session.tool_typed_text_is_verbatim(m, view), None => true, }; keep_marked_text(app); @@ -306,8 +306,8 @@ fn apply_type_event( let before = crate::ime::stamp(&mut app.session, view); // A document edit since the composition means this commit starts a fresh typing // session: that reset is application-side, so it cannot earn `InputMethod`. - let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); - let r = app.session.tool_text(&t, view); + let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_typed_text_is_verbatim(&t, view); + let r = app.session.tool_typed_text(&t, view); if r.is_ok() { if verbatim { crate::ime::note_ime_apply(app, ctx, view, target_before, before); diff --git a/docs/mcp.md b/docs/mcp.md index 3f62f7b95..a147d59d2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4140,7 +4140,7 @@ field with text clears it, and Escape in the empty field closes the dialog. `document.setup` with no params reports the document setup: units, the bleed ([top, bottom, left, right] in pt, drawn as a red outline around each artboard), the transparency grid (size and two colours; the first is also the simulated paper colour), the flattener preset and Discard White Overprint (Overprint Preview keeps white overprints visible while -it is on), the type options (language and its quotes, Use Typographer's Quotes for typed quotes, superscript, subscript +it is on), the type options (language and its quotes, Use Typographer's Quotes for typed straight quotes and a typed `”` where a quote opens, superscript, subscript and small caps proportions, SVG text export) and the background contents (white: raster exports are white behind the art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. A file whose bleed (0 to 72 pt a side), superscript and subscript sizes (1 to 500%) and positions (-500 to 500%) or diff --git a/docs/type-parity.md b/docs/type-parity.md index f4658814d..d40ec6f76 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; previously minor: Show Snap to Glyph Options scrolls to the options, with a Snap to Glyph checkbox, #5273; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Typographer's Quotes sets a lone typed `”` as the opening quote where one opens, for the macOS Serbian Cyrillic layout, which has no straight quote, #5247; Indian-language hyphenation review and license allowlist, #4947; Bulgarian among the languages, with its quote marks and the fonts' Bulgarian forms, #5945; 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 @@ -87,6 +87,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-11-06 | minor | Snap to Glyph › Glyph Bounds: each glyph's box gives four corners a drawn point lands on, also on a curved side (`B`), and its box and edges turn with rotated type (#5112); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | | 2026-11-05 | minor | Release Type on a Path joins the Type Tools add-on: type on a path splits into point type and its path (#5532, beyond Illustrator). Scores and estimates unchanged. | +| 2026-11-04 | minor | Typographer's Quotes: a lone typed `”` (U+201D) is set like a straight `"` where a quote opens (at the start, after a space), taking the document's opening quote, and stays `”` where one closes: the macOS Serbian Cyrillic layout has no straight quote and types `”` on Shift+2, which now gives „…” (#5247). `“ „` and the single quotes `‘ ’ ‚` stay as typed (that layout's apostrophe key types `’`, and `’90s` keeps its apostrophe); pasted text keeps its quotes; with the option off `”` stays as typed. Evidence: `docsetup::a_typed_closing_double_quote_opens_where_a_quote_opens`; scores unchanged | | 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. |