Merge main into fix/plugin-grant-module-hash

This commit is contained in:
dotCooCoo
2026-11-08 11:16:26 -08:00
20 changed files with 244 additions and 32 deletions
+40 -2
View File
@@ -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")));
}
}
+5 -4
View File
@@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
};
let grad = s.obj(fill, b"Grad")?;
let cols = s.objs(grad, b"Cols");
let posn: Vec<Vec<f64>> = 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);
+8 -1
View File
@@ -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<u8> = 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)
+15 -4
View File
@@ -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<Table> {
let s = r.s;
@@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
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<Te
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
// Shared blocks and run lists are visited once per link, so every visit is charged, empty or not.
let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default();
let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default();
if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() {
r.warn("text beyond the work one document may hold (truncated)");
break;
}
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
@@ -209,7 +220,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
// Paragraph formats by end index; a run without an index covers the rest of the block.
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
let mut open: Option<Paragraph> = 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<Te
}
};
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
for run in attribute_runs {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
+21
View File
@@ -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<char> {
(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<char>) -> 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“");
@@ -1004,7 +1004,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
}
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
drop(replace);
Ok(runs)
})?;
Ok(json!({ "runs": runs, "font": family }))
-1
View File
@@ -689,7 +689,6 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
}
}
edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in);
drop(replace_in);
Ok(n)
})?
};
+3
View File
@@ -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 {
+1
View File
@@ -110,6 +110,7 @@ impl Session {
link_stamps: Default::default(),
link_scan: None,
global_edit_options: Default::default(),
keyboard_text: false,
}
}
+62 -3
View File
@@ -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();
+22
View File
@@ -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<Vec<UiRequest>> {
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.
+9 -3
View File
@@ -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(())
+17
View File
@@ -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
+4
View File
@@ -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.
+1
View File
@@ -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,
+20 -5
View File
@@ -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)
}
+8 -1
View File
@@ -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())
}
+5 -5
View File
@@ -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);
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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. |