From 05a242ff772de6a5f8c4505ea2d3f701c6fdd90b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:36:18 +0100 Subject: [PATCH 1/3] Scale the EPS operation budget with the program's length Fixes #5363 Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/interp.rs | 12 +++++++++--- crates/eps/src/import/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index fe0c639193..13382e5333 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -15,8 +15,11 @@ use super::graphics::{GState, Out}; use super::lex::{IMMEDIATE, Lexer, number}; use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; -/// Most operations one file may run. +/// Most operations one file may run, whatever its length. const MAX_OPS: u64 = 50_000_000; +/// More operations a file may run for each byte of its program: a large file that draws a lot +/// runs proportionally more, and stays bounded by its length. +const OPS_PER_BYTE: u64 = 64; /// Deepest procedure nesting. const MAX_DEPTH: u32 = 400; /// Most operands on the stack. @@ -55,6 +58,8 @@ pub(crate) struct Interp<'a> { resources: DictRef, depth: u32, ops: u64, + /// Most operations this program may run: [`MAX_OPS`] and [`OPS_PER_BYTE`] for each byte. + max_ops: u64, /// Bytes asked for so far (see [`MAX_MEMORY`]). allocated: usize, seed: u32, @@ -177,6 +182,7 @@ impl<'a> Interp<'a> { resources, depth: 0, ops: 0, + max_ops: MAX_OPS.saturating_add((src.len() as u64).saturating_mul(OPS_PER_BYTE)), allocated: 0, seed: 1, saves: vec![], @@ -360,11 +366,11 @@ impl<'a> Interp<'a> { self.spend((n / 256) as u64) } - /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs + /// Count `ops` operations' worth of work against the budget (`max_ops`): what an operator that costs /// far more than one operation (laying out type) does in a loop has to end too. pub fn spend(&mut self, ops: u64) -> Res { self.ops = self.ops.saturating_add(ops); - if self.ops > MAX_OPS { + if self.ops > self.max_ops { return Err(PsError::Limit("the program runs too long")); } Ok(()) diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 2bda9c2307..80fe1125c5 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1131,6 +1131,23 @@ fn runs_out_of_budget(body: &str) { assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings); } +/// A large file that runs far more operations than a small one may, but only a few hundred for +/// each object it draws, reads to its end: the operation budget grows with the program's length. +#[test] +fn a_large_file_with_many_objects_reads_to_its_end() { + let n = 200_000; + let mut body = String::from( + "/sq { 4 dict begin /y exch def /x exch def 0 1 200 { pop } for \ + x y moveto x 1 add y lineto x y 1 add lineto closepath fill end } def\n", + ); + for i in 0..n { + body.push_str(&format!("{} {} sq\n", i % 100, i / 2000)); + } + let r = read_within(&body, 300); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(objects(&r.document).len(), n); +} + /// A loop of operators whose work grows with the path they make runs out of the operation budget /// (PLRM 3rd ed., chapter 8): `arc` and `arct` with a huge radius, which make thousands of curve /// segments, `strokepath` with a dash far finer than the line, dashes of length zero included, and From e3817d6b5059ce6dac7a7503c5b0873fb5bfb7ba Mon Sep 17 00:00:00 2001 From: VladimirCeric <215285963+VladimirCeric@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:23:16 +0700 Subject: [PATCH 2/3] =?UTF-8?q?Typographer's=20Quotes:=20a=20lone=20typed?= =?UTF-8?q?=20=E2=80=9D=20opens=20like=20a=20straight=20quote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #5459 onto main. --- crates/doc/src/setup.rs | 21 +++++++ crates/engine/src/cmd/addons/cleanup/text.rs | 1 - crates/engine/src/cmd/fonts.rs | 1 - crates/engine/src/lib.rs | 3 + crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/docsetup.rs | 65 +++++++++++++++++++- crates/engine/src/tooling.rs | 22 +++++++ crates/tools/src/context.rs | 4 ++ crates/tools/src/lib.rs | 1 + crates/tools/src/text.rs | 25 ++++++-- crates/tools/src/text/tool.rs | 9 ++- crates/ui-egui/src/shortcuts/typing.rs | 10 +-- docs/mcp.md | 2 +- docs/type-parity.md | 3 +- 14 files changed, 150 insertions(+), 18 deletions(-) diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 99eee0336f..781bec84ea 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 4b76562e91..d8f2b0289f 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 efe9786287..f648a00a35 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 79aa068910..dc19b9edbd 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 d9ef5740be..ab258887fe 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 c1c70ac07e..cf2e4a510f 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 fa389583fc..8ecc738f2d 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 deb305b65b..054db258d4 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 f16969907a..b9f88c03b7 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 6e48945e3b..50afa31c2c 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 696ed1c0e5..5d79cceb24 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 a3127added..cee164c7f8 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 3f62f7b956..a147d59d2c 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 f4658814da..d40ec6f766 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. | From 2ead0afaa0b941cecbb3b46f2c7ff7f060d22c0f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 02:42:44 +0000 Subject: [PATCH 3/3] affinity import: share one work and name budget across repeated links of shared objects --- crates/affinity/src/model/read.rs | 42 +++++++++++++++++++++++++++++-- crates/affinity/src/paint.rs | 9 ++++--- crates/affinity/src/raster.rs | 9 ++++++- crates/affinity/src/text.rs | 19 +++++++++++--- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs index 00910baabd..80f4e0993b 100644 --- a/crates/affinity/src/model/read.rs +++ b/crates/affinity/src/model/read.rs @@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18; /// Points of all strokes' pressure curves together (a stroke style may be shared by many layers). const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20; +/// Child references inspected, page rectangles produced, and outline segments retained, in all +/// (shared objects are visited once per link, so each visit is charged). Real documents stay +/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that. +const MAX_DOCUMENT_WORK: usize = 1 << 24; + +/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy). +const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20; + /// What a whole read may still use. Shared objects can be linked from many layers, and embedded /// documents are read with what their embedding document left, so linking or embedding the /// same content many times can't multiply the work. @@ -44,6 +52,10 @@ pub(crate) struct Budget { pub(crate) cells: usize, /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all). pub(crate) pressure: usize, + /// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all). + pub(crate) work: usize, + /// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all). + pub(crate) name_chars: usize, } impl Budget { @@ -53,7 +65,15 @@ impl Budget { chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS, pressure: MAX_DOCUMENT_PRESSURE_POINTS, + work: MAX_DOCUMENT_WORK, + name_chars: MAX_DOCUMENT_NAME_CHARS, }; + + /// Take `n` units of work, or fail the read. + pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> { + self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?; + Ok(()) + } } /// Read a document: archive, `doc.dat` and the model. @@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { return Ok(None); } let pages = pages(s, spread); + self.budget.spend(pages.len() + 1)?; let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { x0: 0.0, @@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let s = self.s; let mut out = Vec::new(); let mut mask = None; - for c in s.objs(parent, b"Chld") { + let kids = s.objs(parent, b"Chld"); + // Invisible masks and skipped children cost no node, so the visit is charged here. + self.budget.spend(kids.len())?; + for c in kids { if s.is(c, b"MRst") { // A mask layer inside a group or layer masks that container. if s.bool(c, b"Visi") == Some(false) { @@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. - let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect(); + self.budget.name_chars -= name.chars().count(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn(format!("a {} that could not be read", describe(class))); return Ok(Kind::Unsupported); }; + self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?; let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } @@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + + #[test] + fn shared_work_is_bounded() { + let mut b = Budget::FULL; + assert!(b.spend(MAX_DOCUMENT_WORK).is_ok()); + assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data"))); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index 12102581e1..c7b2acbbd3 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option> = match s.field(grad, b"Posn") { - Some(Value::Array(a)) => a.iter().filter_map(floats).collect(), - _ => Vec::new(), + // Convert only the positions that pair with a colour, as shared attributes repeat this per run. + let posn: &[Value] = match s.field(grad, b"Posn") { + Some(Value::Array(a)) => a, + _ => &[], }; let mut stops = Vec::new(); - for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) { + for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) { let color = color(r, *c)?; let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0); diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index d458c08a7d..bb8c1c88b8 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl if let Some(name) = s.entry(bitmap, b"Bckg") { let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + // The whole source is decoded for every layer that reads it, so charge it all, not only the crop. + let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else { + return Err(Error::Limit("embedded image pixels")); + }; + r.budget.pixels = left; match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) { Ok(pixels) => Some((width as usize, pixels)), Err(Error::Limit(e)) => return Err(Error::Limit(e)), @@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + // A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference. + let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE)); let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), _ => Vec::new(), }; (tw, th, states) diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 1a3b6e5442..d2bb55ca0c 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096; /// Cells a table may have (rows × columns): each becomes a text object with its edge lines. const MAX_TABLE_CELLS: usize = 1 << 16; +/// Work charged for each copy of a run's formatting a table cell keeps. +const TABLE_RUN_COST: usize = 16; + /// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; @@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option
{ let mut parts = run.text.split(CELL_BREAK).peekable(); while let Some(part) = parts.next() { if !part.is_empty() { + // Each cell keeps its own copy of the run's formatting, so each copy is charged. match target.and_then(|i| cells.get_mut(i)) { - Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), - None => extra = true, + Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + _ => extra = true, } } if parts.peek().is_some() { @@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec (Vec = Vec::new(); let mut open: Option = None; - for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + for run in paragraph_runs { let Some(attrs) = s.obj(run, b"Item") else { continue }; let a = paragraph(r, attrs); first_align.get_or_insert(a.align); @@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec