diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4a33c5f01..3d4498c07 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3239,6 +3239,50 @@ fn text_editing_of(document: &str, objects: &str) -> String { /// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first /// baseline, and keys besides, each with a value of any kind, in or out of range. +/// A paragraph's attributes as a text document might keep them: an alignment (key 0), auto leading +/// (7), hyphenation options (10 to 15) and tab stops (30) of any value or type, the stops a list of +/// any length whose entries may be anything. +fn arb_paragraph_attributes() -> impl Strategy { + let values = prop::sample::select(vec![ + "0", + "1", + "2", + "3", + "6", + "7", + "-1", + "2.5", + "1e400", + "-1e400", + "NaN", + "inf", + "1e9", + "(x)", + "true", + "false", + "/nil", + "[ 1 ]", + "<< /0 1 >>", + ]); + let keys = prop::sample::select(vec!["0", "7", "10", "11", "12", "13", "14", "15"]); + let stop = (values.clone(), values.clone(), 0usize..3).prop_map(|(at, align, shape)| match shape { + 0 => format!("<< /0 {at} /1 {align} >>"), + 1 => format!("<< /1 {align} >>"), + _ => at.to_string(), + }); + let tabs = (prop::collection::vec(stop, 0..40), 0usize..4).prop_map(|(stops, shape)| match shape { + 0 => format!("/30 << /0 [ {} ] >>", stops.join(" ")), + 1 => "/30 << /0 7 >>".to_string(), + 2 => "/30 [ 1 2 ]".to_string(), + _ => String::new(), + }); + (prop::collection::vec((keys, values), 0..8), tabs).prop_map(|(kv, tabs)| { + let mut attrs: Vec = kv.iter().map(|(k, v)| format!("/{k} {v}")).collect(); + attrs.push(tabs); + attrs.join(" ") + }) +} + fn arb_area_options() -> impl Strategy { let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]); let values = prop::sample::select(vec![ @@ -3463,6 +3507,18 @@ proptest! { survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// The same text document with hostile paragraph attributes: an alignment, auto leading, + /// hyphenation options and tab stops of any value, type or size. + #[test] + fn text_frames_with_hostile_paragraph_attributes_never_panic(attrs in arb_paragraph_attributes()) { + let text = frames_document_text().replace("/5 << /0 2 /1 4 /2 6 >>", &format!("/5 << /1 4 /2 6 {attrs} >>")); + let editing = text_editing_of(&text, FRAME_OBJECTS); + let bytes = editing_eps(&editing); + survive("EPS text frames with hostile paragraph attributes", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat(); + survive(".ai text frames with hostile paragraph attributes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// The type on a path of the same text document with hostile Type on a Path Options, opened as /// an EPS document and as a `.ai`'s editing data. #[test] diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index eae8bc138..2250dea96 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -25,7 +25,10 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{ + CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, PathEffect, TabAlign, + TabStop, TextKind, TextObject, TextRun, +}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; /// Longest story read (bytes). @@ -383,8 +386,33 @@ struct Para { space_before: f64, space_after: f64, hyphenate: bool, + /// Key 7: the share of its type size a line takes as its auto leading (1.2: 120%, the default; + /// Justification… sets 0 to 500%: + /// https://helpx.adobe.com/illustrator/using/line-character-spacing.html). `None`: the default. + auto_leading: Option, + /// Keys 10 to 15, in the order of Paragraph panel menu › Hyphenation…: Words Longer Than, + /// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized + /// Words. A value that can't be read, or is out of its range, keeps the default. + hyphenation: HyphenationOptions, + /// Key 30, its list (key 0): the tab stops, each its position from the frame's left edge (key + /// 0) and alignment (key 1: 0 left, the default, 1 center, 2 right, 3 decimal, in the order of + /// the Tabs panel's buttons), sorted. A stop that can't be read is left out, and so are those + /// past the first [`MAX_TABS`]. Leaders aren't read. + tabs: Vec, + /// The paragraph has more than [`MAX_TABS`] stops: those past them were left out. + tabs_truncated: bool, } +/// Most tab stops read for one paragraph. +const MAX_TABS: usize = 1024; +/// Largest tab stop position read (in the text document's units), as `text.tabs.set` takes it. +const MAX_TAB_POSITION: f64 = 100_000.0; +/// Most tab stops a story's paragraphs take in all: a paragraph style's stops are copied into +/// every paragraph that has them. The paragraphs past it take the default stops, with a warning. +const MAX_STORY_TABS: usize = 1 << 20; +/// The warning when [`MAX_STORY_TABS`] leaves tab stops out. +pub(crate) const TABS_LEFT_OUT: &str = "tab stops past VectorCraft's limits were left out"; + /// The text of one story, in the units of the text document. #[derive(Debug)] pub(super) struct Story { @@ -405,6 +433,20 @@ pub(super) struct Story { /// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to /// left. Its layout's lines run down from the anchor, each column further left. vertical: bool, + /// Tab stops past [`MAX_STORY_TABS`] were left out of its paragraphs. + tabs_left_out: std::cell::Cell, +} + +/// Spend `budget` on the tab stops `tabs`: when there aren't enough left, they're left out (the +/// default stops apply). Whether they were. +fn spend_tabs(tabs: &mut Vec, budget: &mut usize) -> bool { + if tabs.len() <= *budget { + *budget -= tabs.len(); + false + } else { + tabs.clear(); + true + } } #[derive(Clone, Debug)] @@ -607,7 +649,12 @@ impl Texts { if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } - Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical }) + let mut budget = MAX_STORY_TABS; + let mut tabs_left_out = false; + for (_, p) in &mut paras { + tabs_left_out |= p.tabs_truncated | spend_tabs(&mut p.tabs, &mut budget); + } + Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() }) } /// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based @@ -633,6 +680,46 @@ impl Texts { space_before: num("4"), space_after: num("5"), hyphenate: !matches!(pick("9"), Some(Val::Bool(false))), + auto_leading: pick("7").and_then(Val::num).filter(|v| v.is_finite() && *v > 0.0 && *v <= 5.0), + hyphenation: { + let d = HyphenationOptions::default(); + let count = |k: &str, lo: f64, hi: f64, or: u32| { + pick(k).and_then(Val::num).filter(|v| v.is_finite() && v.fract() == 0.0 && (lo..=hi).contains(v)).map_or(or, |v| v as u32) + }; + HyphenationOptions { + words_longer_than: count("10", 1.0, 25.0, d.words_longer_than), + after_first: count("11", 1.0, 15.0, d.after_first), + before_last: count("12", 1.0, 15.0, d.before_last), + hyphen_limit: count("13", 0.0, 25.0, d.hyphen_limit), + zone: pick("14").and_then(Val::num).filter(|v| v.is_finite() && (0.0..=1296.0).contains(v)).unwrap_or(d.zone), + capitalized: match pick("15") { + Some(Val::Bool(b)) => *b, + _ => d.capitalized, + }, + } + }, + tabs_truncated: pick("30").and_then(|t| t.get("0")).map(Val::list).is_some_and(|list| list.len() > MAX_TABS), + tabs: { + let list = pick("30").and_then(|t| t.get("0")).map(Val::list).unwrap_or_default(); + let mut tabs: Vec = list + .iter() + .take(MAX_TABS) + .filter_map(|t| { + let position = t.get("0").and_then(Val::num).filter(|p| (0.0..=MAX_TAB_POSITION).contains(p))?; + let align = match t.get("1").map(Val::num) { + None | Some(Some(0.0)) => TabAlign::Left, + Some(Some(1.0)) => TabAlign::Center, + Some(Some(2.0)) => TabAlign::Right, + Some(Some(3.0)) => TabAlign::Decimal, + _ => return None, + }; + Some(TabStop { position, align, leader: String::new(), align_on: '.' }) + }) + .collect(); + tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + tabs + }, } } @@ -1001,7 +1088,6 @@ impl Story { // The text document measures in the art's units too: the document's scale carries its // sizes and spacings onto the page (a Large Canvas copy is written at 1:10; #2031). let scale = scale * art_scale(to_doc); - let leading = self.line_leading(scale); let chars: Vec = text.chars().collect(); let mut runs = vec![]; let mut from = 0usize; @@ -1012,13 +1098,13 @@ impl Story { if piece.is_empty() { continue; } - runs.push(TextRun::new(piece, char_style(st, scale, leading))); + runs.push(TextRun::new(piece, char_style(st, scale))); } if from < chars.len() { let last = self.runs.last().map(|r| &r.1)?; - runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale, leading))); + runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::(), char_style(last, scale))); } - let first = self.runs.first().map(|r| char_style(&r.1, scale, leading))?; + let first = self.runs.first().map(|r| char_style(&r.1, scale))?; let mut t = TextObject::point(Point::ZERO, "", first); if !runs.is_empty() { t.runs = runs; @@ -1065,6 +1151,21 @@ impl Story { !self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t) } + /// Area type of one frame that the file lays more lines out in than fit here: in the fallback + /// font, or a last line whose descent the file lets pass the frame's bottom. The frame is made + /// taller from its bottom (`vectorcraft_text::fit_source_lines`) so the file's lines show. + /// Whether it was. + pub(super) fn fit_last_lines(&self, t: &mut TextObject) -> bool { + matches!(self.frames.as_slice(), [FrameKind::Area(..)]) + && vectorcraft_text::fit_source_lines(vectorcraft_text::FontDb::global(), t, self.lines.len()) + } + + /// Were tab stops past [`MAX_STORY_TABS`] left out of its paragraphs (so far: making its text + /// object counts them again)? + pub(super) fn tabs_left_out(&self) -> bool { + self.tabs_left_out.get() + } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the @@ -1103,38 +1204,6 @@ impl Story { true } - /// The leading the file's own line positions imply: the distance from one line to the next, less - /// the space between paragraphs that is part of it (a paragraph's space after and the next one's - /// space before; a layout's lines are where that space has put them, and the text object adds it - /// again). - fn line_leading(&self, scale: f64) -> Option { - let para_at = |c: usize| { - let mut end = 0usize; - for (n, p) in &self.paras { - end = end.saturating_add(*n); - if c < end { - return Some(p); - } - } - self.paras.last().map(|(_, p)| p) - }; - let chars: Vec = self.text.chars().collect(); - let breaks: Vec = chars.iter().enumerate().filter(|(_, c)| matches!(c, '\r' | '\x03')).map(|(i, _)| i).collect(); - // More lines than breaks: area type wraps, and which break ends which line is unknown. - let wraps = self.lines.len() > breaks.len() + 1; - self.lines.windows(2).enumerate().find_map(|(i, w)| { - let pitch = w[1].1 - w[0].1; - let between = match breaks.get(i) { - Some(&at) if !wraps && chars.get(at) == Some(&'\r') => { - para_at(at).map_or(0.0, |p| p.space_after) + para_at(at + 1).map_or(0.0, |p| p.space_before) - } - _ => 0.0, - }; - let l = (pitch - between) * scale; - (l.is_finite() && l > 0.0).then_some(l) - }) - } - /// The style of each paragraph of `text` (paragraphs split at `\n`). fn paragraph_styles(&self, text: &str, scale: f64) -> Vec { let count = text.split('\n').count(); @@ -1142,9 +1211,13 @@ impl Story { let mut runs = self.paras.iter(); let mut current = runs.next(); let mut left = current.map_or(usize::MAX, |r| r.0); + let mut budget = MAX_STORY_TABS; for p in text.split('\n') { - let para = current.map(|r| r.1.clone()).unwrap_or_default(); - out.push(para_style(¶, scale)); + let mut style = current.map_or_else(|| para_style(&Para::default(), scale), |r| para_style(&r.1, scale)); + if spend_tabs(&mut style.tabs, &mut budget) { + self.tabs_left_out.set(true); + } + out.push(style); // Each paragraph and its ending `\r` count. let mut used = p.chars().count() + 1; while used > 0 { @@ -1165,6 +1238,8 @@ impl Story { } } +/// The paragraph style of paragraph attributes `p`, its lengths (indents, spacing, the +/// hyphenation zone) scaled by `scale`, as sizes are. fn para_style(p: &Para, scale: f64) -> ParaStyle { ParaStyle { justify: match p.justify { @@ -1184,21 +1259,33 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { space_before: p.space_before * scale, space_after: p.space_after * scale, hyphenate: p.hyphenate, + hyphenation: HyphenationOptions { zone: p.hyphenation.zone * scale, ..p.hyphenation.clone() }, + tabs: p.tabs.iter().map(|t| TabStop { position: t.position * scale, ..t.clone() }).collect(), // A right-to-left paragraph lays its text out from the right whatever letters it has // (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter. direction: p.rtl.then_some(ParaDirection::RightToLeft), + justification: JustificationOptions { + // Rounded, so the file's 1.2 is the default 120% exactly. + auto_leading: p.auto_leading.map_or(JustificationOptions::default().auto_leading, |a| (a * 1e6).round() / 1e4), + ..JustificationOptions::default() + }, ..ParaStyle::default() } } -fn char_style(s: &Style, scale: f64, leading: Option) -> CharStyle { +/// The character style of character attributes `s`: its font from the fonts the app has, its +/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`. +fn char_style(s: &Style, scale: f64) -> CharStyle { let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); CharStyle { font_family: family, font_style: style, size: s.size * scale, - leading: s.leading.map(|l| l * scale).or(leading), + // Auto leading stays auto: the layout gives each line the paragraph's share of the largest + // type on it, as the file's own layout does ("The largest leading value in a line of type + // determines the leading for that line": https://helpx.adobe.com/illustrator/using/line-character-spacing.html). + leading: s.leading.map(|l| l * scale), tracking: s.tracking, baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, @@ -1247,6 +1334,130 @@ mod tests { assert_eq!(justify(b"/0 1 /33 0"), Justify::Right); } + /// Key 7 is the paragraph's auto leading, a share of the type size; one that can't be read + /// leaves the default, 120%. + #[test] + fn a_paragraph_keeps_its_auto_leading() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/7 1.2").unwrap(), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let auto = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justification.auto_leading; + assert_eq!(auto(b""), JustificationOptions::default().auto_leading); + assert_eq!(auto(b"/7 1.5"), 150.0); + assert_eq!(auto(b"/7 1.15"), 115.0); + for unreadable in [b"/7 0".as_slice(), b"/7 -1", b"/7 1e9", b"/7 /nil"] { + assert!((auto(unreadable) - 120.0).abs() < 1e-9, "{}", String::from_utf8_lossy(unreadable)); + } + } + + /// Keys 10 to 15 are the paragraph's hyphenation options; one that can't be read, or is out of + /// its range, keeps the default. The zone is in points of the text document, scaled as sizes are. + #[test] + fn a_paragraph_keeps_its_hyphenation_options() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: read(b"/10 6 /11 2 /12 2 /13 0 /14 36.0 /15 true").unwrap(), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let options = |own: &[u8], scale: f64| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale).hyphenation; + let want = HyphenationOptions { words_longer_than: 6, after_first: 2, before_last: 2, hyphen_limit: 0, zone: 36.0, capitalized: true }; + assert_eq!(options(b"", 1.0), want); + assert_eq!( + options(b"/10 8 /13 3 /15 false", 0.5), + HyphenationOptions { words_longer_than: 8, hyphen_limit: 3, zone: 18.0, capitalized: false, ..want.clone() } + ); + let d = HyphenationOptions::default(); + let bare = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let own = |own: &[u8]| para_style(&bare.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).hyphenation; + assert_eq!(own(b""), d); + assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d); + } + + /// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key + /// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is + /// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13. + #[test] + fn a_paragraph_keeps_its_tab_stops() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + para_sheets: vec![], + frames: vec![], + }; + let tabs = |own: &[u8], scale: f64| { + para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale) + .tabs + .iter() + .map(|t| (t.position, t.align)) + .collect::>() + }; + assert_eq!(tabs(b"", 1.0), []); + assert_eq!( + tabs(b"/30 << /0 [ << /0 272.12598 /1 0 >> << /0 269.29135 /1 2 >> ] >>", 1.0), + [(269.29135, TabAlign::Right), (272.12598, TabAlign::Left)] + ); + assert_eq!( + tabs(b"/30 << /0 [ << /0 10 /1 1 >> << /0 20 /1 3 >> << /0 30 >> ] >>", 0.5), + [(5.0, TabAlign::Center), (10.0, TabAlign::Decimal), (15.0, TabAlign::Left)] + ); + // Past MAX_TABS stops, the rest are left out, and the paragraph says so (the story's warning). + let many = |n: usize| { + let stops: String = (0..n).map(|i| format!("<< /0 {i} >> ")).collect(); + texts.para(Some(&read(format!("/5 << /30 << /0 [ {stops}] >> >>").as_bytes()).unwrap())) + }; + let p = many(MAX_TABS + 6); + assert!(p.tabs_truncated && p.tabs.len() == MAX_TABS); + let p = many(MAX_TABS); + assert!(!p.tabs_truncated && p.tabs.len() == MAX_TABS); + // Unreadable: a position out of range or not a number, an unknown alignment. + assert_eq!( + tabs(b"/30 << /0 [ << /0 -1 >> << /0 1e9 >> << /0 /nil >> << /0 5 /1 7 >> << /0 6 /1 /x >> << /0 7 /1 2 >> ] >>", 1.0), + [(7.0, TabAlign::Right)] + ); + } + + /// A story's paragraphs take at most [`MAX_STORY_TABS`] tab stops in all (a paragraph style's + /// are copied into each paragraph): those past it take the default stops, and the story says so. + #[test] + fn a_story_s_tab_stops_are_bounded() { + let mut budget = 3; + let mut tabs = vec![TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; 2]; + assert!(!spend_tabs(&mut tabs, &mut budget) && tabs.len() == 2 && budget == 1); + assert!(spend_tabs(&mut tabs, &mut budget) && tabs.is_empty() && budget == 1); + // Paragraphs of a style with MAX_TABS stops each, more than the story takes. + let stop = TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; + let para = Para { tabs: vec![stop; MAX_TABS], ..Para::default() }; + let count = MAX_STORY_TABS / MAX_TABS + 2; + let s = story(&"a\r".repeat(count), &[0.0], ¶); + assert!(!s.tabs_left_out()); + let styles = s.paragraph_styles(&format!("{}a", "a\n".repeat(count - 1)), 1.0); + assert_eq!(styles.len(), count); + assert!(s.tabs_left_out()); + assert_eq!(styles.iter().map(|p| p.tabs.len()).sum::(), MAX_STORY_TABS); + assert!(styles.last().is_some_and(|p| p.tabs.is_empty())); + } + /// Where point type lies runs along its baseline: a label turned a quarter (as a vertical /// dimension's is) lies in a tall box, not a wide one. #[test] @@ -1636,6 +1847,7 @@ mod tests { )], path_spacing: 0.0, vertical: false, + tabs_left_out: false.into(), } } @@ -1882,25 +2094,54 @@ mod tests { } } + /// The baselines of the text object `story` comes in as, laid out. + fn baselines_of(story: &Story) -> Vec { + let Some(node) = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY) else { return vec![] }; + let NodeKind::Text(t) = &node.kind else { return vec![] }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).baselines().iter().map(|b| b.0.y).collect() + } + + /// Auto leading stays auto: each line takes the paragraph's share of the largest type on it, as + /// the file's own layout does, not the distance between the first two lines for every line. A + /// 15 pt heading over 10 pt type at 120%: 18 pt between the heading's lines, then 12 pt. + #[test] + fn auto_leading_follows_the_size_of_each_line() { + let mut s = story("Big\x03Bigger\rsmall\r", &[0.0, 18.0, 30.0], &Para::default()); + let base = s.runs[0].1.clone(); + s.runs = vec![(11, Style { size: 15.0, ..base.clone() }), (6, Style { size: 10.0, ..base })]; + assert_eq!(leading_of(&s), None); + let ys = baselines_of(&s); + assert_eq!(ys.len(), 3, "{ys:?}"); + assert!(((ys[1] - ys[0]).abs() - 18.0).abs() < 1e-6, "{ys:?}"); + assert!(((ys[2] - ys[1]).abs() - 12.0).abs() < 1e-6, "{ys:?}"); + // At a share the paragraph sets (key 7) of 150%: 22.5 pt, then 15 pt. + let share = Para { auto_leading: Some(1.5), ..Para::default() }; + s.paras = s.paras.iter().map(|(n, _)| (*n, share.clone())).collect(); + let ys = baselines_of(&s); + assert!(((ys[1] - ys[0]).abs() - 22.5).abs() < 1e-6, "{ys:?}"); + assert!(((ys[2] - ys[1]).abs() - 15.0).abs() < 1e-6, "{ys:?}"); + } + #[test] fn leading_leaves_out_the_space_between_paragraphs_that_the_lines_already_have() { // Lines 10.6 apart that are three paragraphs with 5 less after each: auto leading (15.6) less 5. let para = Para { space_after: -5.0, ..Para::default() }; + // The text object keeps auto leading and the spacing, so its lines are where the file's are. let s = story("one\rtwo\rthree\r", &[0.0, 10.6, 21.2], ¶); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - assert!((leading_of(&s).unwrap() - 15.6).abs() < 1e-9); - // Space after and space before both count; the scale of the frame carries onto the leading. + assert_eq!(leading_of(&s), None); + let ys = baselines_of(&s); + assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 10.6).abs() < 1e-6), "{ys:?}"); + // Space after and space before both count. let para = Para { space_after: 4.0, space_before: 2.0, ..Para::default() }; - let s = story("one\rtwo\r", &[0.0, 20.0], ¶); - assert!((s.line_leading(1.0).unwrap() - 14.0).abs() < 1e-9); - assert!((s.line_leading(0.5).unwrap() - 7.0).abs() < 1e-9); + let ys = baselines_of(&story("one\rtwo\r", &[0.0, 21.6], ¶)); + assert!(((ys[1] - ys[0]).abs() - 21.6).abs() < 1e-6, "{ys:?}"); } /// #2031: a Large Canvas editing copy measures its text document in its art's units, a tenth /// of the page's: the story's size and leading reach the page through `to_doc`'s scale. #[test] fn a_story_in_a_scaled_art_space_keeps_its_size_and_leading() { - let s = story("one\rtwo\r", &[0.0, 20.0], &Para::default()); + let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default()); // A copy written at 1:10 (see `ai::read::ensure_space`): its art's units reach the // document, 100 points to the page, through `to_doc`'s scale. let to_doc = Affine::scale(10.0) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 100.0]); @@ -1909,24 +2150,18 @@ mod tests { _ => panic!("not text"), }; assert_eq!(t.runs[0].style.size, 130.0, "13 pt at the page's scale"); - assert_eq!(t.runs[0].style.leading, Some(200.0), "a 20-unit line pitch at the page's scale"); + assert_eq!(t.runs[0].style.leading, None, "auto leading: 120% of its size at the page's scale"); + assert_eq!(t.para.justification.auto_leading, 120.0); } #[test] fn leading_between_lines_of_a_paragraph_is_their_distance() { // A line break inside a paragraph has no paragraph spacing between its lines. let para = Para { space_after: -5.0, space_before: 3.0, ..Para::default() }; - let s = story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], ¶); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - // Paragraphs without spacing: the distance as it was. - let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default()); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - // Area type whose paragraph wraps: lines and breaks don't pair up, so nothing is taken off. - let para = Para { space_after: -5.0, ..Para::default() }; - let s = story("one two three\rfour\r", &[0.0, 15.6, 31.2, 41.8], ¶); - assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9); - // One line has no distance to read. - assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None); + let ys = baselines_of(&story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], ¶)); + assert!(ys.len() == 3 && ys.windows(2).all(|w| ((w[1] - w[0]).abs() - 15.6).abs() < 1e-6), "{ys:?}"); + // One line. + assert_eq!(baselines_of(&story("one\r", &[0.0], &Para::default())).len(), 1); } /// A `\x03` ends a line inside its paragraph: it comes in as a forced line break, and the @@ -1970,6 +2205,32 @@ mod tests { assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0); } + /// The file sets three lines in a frame whose third line's descent passes its bottom: the frame + /// grows from its bottom so the third shows. Only when the story's one frame is area type. + #[test] + fn the_last_lines_the_file_sets_in_its_frame_show() { + let db = vectorcraft_text::FontDb::global(); + let area = |height: f64| { + let mut t = TextObject::point(Point::ZERO, "one\ntwo\nthree", CharStyle { size: 13.0, ..CharStyle::default() }); + t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) }; + t + }; + let tall = vectorcraft_text::layout(db, &area(400.0)); + let third = &tall.lines[2]; + let mut t = area(third.baseline - 20.0 + third.descent / 2.0); + assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 2); + let mut s = story("one\rtwo\rthree\r", &[0.0, 15.6, 31.2], &Para::default()); + // A point-type frame (the helper's) isn't fitted. + assert!(!s.fit_last_lines(&mut t)); + s.frames = vec![FrameKind::Area(vec![], AreaFrame::default())]; + assert!(s.fit_last_lines(&mut t)); + assert_eq!(vectorcraft_text::layout(db, &t).lines.len(), 3); + // A story threaded through two frames: its lines are the first frame's alone. + let mut t = area(third.baseline - 20.0 + third.descent / 2.0); + s.frames.push(FrameKind::Area(vec![], AreaFrame::default())); + assert!(!s.fit_last_lines(&mut t)); + } + #[test] fn area_type_s_first_line_goes_where_the_file_lays_it_out() { // GUARDS: area type's first baseline was the font's ascent below the frame's top, where the diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index 3cfc3a6b5..5dd918fb5 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -59,7 +59,7 @@ pub(super) fn make_slots( to_doc: Affine, warn: &mut Vec, ) -> Vec> { - let mut taller = 0; + let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false); let made = infos .iter() .enumerate() @@ -67,6 +67,7 @@ pub(super) fn make_slots( let t = template?; let story = stories.get(k)?.as_ref()?; let mut node = story.node(NodeId(0), info.frame, t, to_doc)?; + tabs_left_out |= story.tabs_left_out(); let mut region = story.region(t, to_doc); // The first line where the file has it, in the text document's frame (that of a // copied object is its original's, as its lines are). @@ -80,6 +81,11 @@ pub(super) fn make_slots( { taller += 1; } + if let NodeKind::Text(text) = &mut node.kind + && story.fit_last_lines(text) + { + deeper += 1; + } Some((Box::new(node), region)) }) .collect(); @@ -88,6 +94,14 @@ pub(super) fn make_slots( "area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise" )); } + if deeper > 0 { + warn.push(format!( + "area type whose last lines the file sets in frames too short for them here got taller frames ({deeper}): those lines wouldn't show otherwise" + )); + } + if tabs_left_out { + warn.push(crate::import::ate::TABS_LEFT_OUT.into()); + } made } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e4289109b..9d96dca04 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -39,7 +39,7 @@ use breaking::tab_advance; use breaking::{break_line, candidates}; #[cfg(test)] pub(crate) use driver::layout_faces_per_call; -pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; +pub use driver::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; #[cfg(test)] diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 75796e265..805c4dead 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -62,6 +62,38 @@ pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { true } +/// Area type whose source lays out `lines` lines in its frame, more than fit here: a font that isn't +/// available shows in the fallback font, whose lines may reach lower, and the source may set a last +/// line whose descent passes the frame's bottom, which isn't drawn here. For importers: the frame is +/// made taller, from its bottom, by its lines' deepest descent plus 0.05 pt, when that lays out +/// more of the source's lines and no more than it has. Horizontal type only. Whether it was. +pub fn fit_source_lines(db: &FontDb, t: &mut TextObject, lines: usize) -> bool { + let TextKind::Area { frame } = &t.kind else { return false }; + if t.vertical { + return false; + } + let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false }; + let laid = layout(db, t); + let have = laid.lines.len(); + if !laid.overflow || have == 0 || have >= lines { + return false; + } + // The deepest descent of its lines (a little more, for the rounding of the layout). + let Some(descent) = laid.lines.iter().map(|l| l.descent).reduce(f64::max).filter(|d| d.is_finite() && *d > 0.0) else { return false }; + let by = (b.height() + descent + 0.05) / b.height(); + if !(by.is_finite() && by > 1.0) { + return false; + } + let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0))); + let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller }); + let now = layout(db, t).lines.len(); + if now > have && now <= lines { + return true; + } + t.kind = short; + false +} + /// The underline and strikethrough bars of `t` laid out as `layout` (#847): per run and line, a /// bar from the stretch's first glyph to its last one's advance, placed and sized by the font's /// own underline and strikeout metrics through the glyph's transform (so the size, baseline shift diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 652213940..e1d55eece 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -410,6 +410,9 @@ struct LineJustification { start_x: f64, after: Vec>, kashidas: Vec<(usize, f64)>, + /// Right-aligned area type: the line's last tab, which takes the text after it to the right + /// edge (the text before it sits at its stops, from the left edge). + last_tab: Option, } fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut paragraph: ParagraphData<'_, '_>) -> bool { @@ -464,6 +467,9 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut true } +/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it +/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or +/// stay out of justification, and whether it is the paragraph's last line. fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData<'_, '_>, position: LinePosition, index: usize) -> LineGeometry { let para = paragraph.para; let sg = ¶graph.sg; @@ -571,7 +577,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData // Justification adds nothing to the list marker's glyphs. let mut held = held; held.extend(index..paragraph.marker.min(body)); - let last_of_para = position.end >= n || position.end.checked_sub(1).and_then(|i| sg.get(i)).is_some_and(|g| g.ch == '\u{2028}'); + // A line a soft return ends is justified as a wrapped one is: only the paragraph's last line + // keeps the alignment of Justify Left, Center or Right. A paragraph that ends in a soft return + // ends with the empty line after it (`run_paragraph_lines`). + let soft_return = position.end > index && sg.get(position.end - 1).is_some_and(|g| g.ch == '\u{2028}'); + let last_of_para = position.end >= n && !soft_return; LineGeometry { position, index, @@ -631,6 +641,9 @@ fn flow_line_flex(glyph: usize, flex: &[Flex], held: &[usize], hung: &[usize]) - value } +/// How `line` of `paragraph` is aligned: where it starts, what justification adds to its spaces, +/// gaps and glyphs, its kashidas, and, in right-aligned area type, its last tab, which takes the +/// text after it to the right edge. fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry) -> LineJustification { let para = paragraph.para; let sg = ¶graph.sg; @@ -718,7 +731,14 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin } } let position = &line.position; - let start_x = if justify || pushed_in { + // Right-aligned area type with a tab: the text before the last tab sits at its stops, from the + // left edge, and the text after it against the right edge, as Illustrator's own layout in a + // user's `.ai` sets a price list. Centred and justified lines are aligned as a whole. + let last_tab = (para.justify == Justify::Right && !justify && !paragraph.rtl && paragraph.regions.is_some()) + // A tab ending the line counts: its text sits at the left edge, the tab up to the right one. + .then(|| (line.index..line.position.end).rev().find(|&j| sg.get(j).is_some_and(|g| g.ch == '\t' && g.len > 0))) + .flatten(); + let start_x = if justify || pushed_in || last_tab.is_some() { line.ax0 - line.opt_l } else if paragraph.regions.is_none() { match align { @@ -733,9 +753,43 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin _ => line.ax1 + line.opt_r - width, } }; - LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas } + LineJustification { per_space, per_gap, per_cjk, squeeze, fit, start_x, after, kashidas, last_tab } } +/// The advance and leader of tab `glyph_index` of `line`, at pen position `x`: up to its stop +/// ([`tab_advance`], measured from `tab_origin` less `tab_shift`), or, for the last tab of +/// right-aligned area type ([`LineJustification::last_tab`]), up to where the rest of the line +/// ends on the right edge. The tab after a list marker keeps the width `prepend_marker` gave it +/// (`advance`). +fn tab_glyph<'p>( + paragraph: &'p ParagraphData<'_, '_>, + line: &LineGeometry, + justification: &LineJustification, + glyph_index: usize, + x: f64, + (tab_origin, tab_shift): (f64, f64), + advance: f64, +) -> (f64, &'p str) { + let sg = ¶graph.sg; + // The tab of a list marker keeps the width `prepend_marker` gave it. + if !sg.get(glyph_index).is_some_and(|g| g.len > 0) { + return (advance, ""); + } + let (advance, leader) = tab_advance(¶graph.para.tabs, tab_origin, x - tab_shift, sg.get(glyph_index + 1..line.trimmed).unwrap_or_default()); + if justification.last_tab != Some(glyph_index) { + return (advance, leader); + } + // The width of what follows up to the line's end (nothing, when the tab ends the line), with + // the hyphen a hyphenated line ends with. + let after = sg.get(glyph_index + 1..line.trimmed).unwrap_or_default(); + let hyphen = line.hyphen.as_ref().map_or(0.0, |g| g.adv); + let rest = if after.is_empty() { 0.0 } else { after.iter().map(|g| g.adv).sum::() - line.end_trim + hyphen }; + ((line.ax1 + line.opt_r - rest - x).max(0.0), leader) +} + +/// Lay out `line` of `paragraph` as `justification` aligns it: each glyph placed along the +/// baseline (tabs as [`tab_glyph`] sets them), its hyphen, and the line's record, then the pen +/// moved on past it. fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData<'_, '_>, line: &LineGeometry, justification: &LineJustification) { let para = paragraph.para; let sg = ¶graph.sg; @@ -775,10 +829,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData let mut scale = 1.0; let mut leader = ""; if glyph.ch == '\t' { - // The tab of a list marker keeps the width `prepend_marker` gave it. - if glyph.len > 0 { - (advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]); - } + (advance, leader) = tab_glyph(paragraph, line, justification, glyph_index, x, (tab_origin, tab_shift), advance); } else if glyph_index < body { let flex = flow_line_flex(glyph_index, ¶graph.flex, &line.held, &line.hung); if is_word_space(glyph) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index dad7f0a6f..27d35c170 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -47,7 +47,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout}; use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; -pub use layout::{decorations, fit_first_line, layout, layout_with, scaled}; +pub use layout::{decorations, fit_first_line, fit_source_lines, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign}; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 1338e08db..68fe65528 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -306,6 +306,39 @@ fn area_alignment_and_justify() { assert!((l.lines[0].x1 - frame.x1).abs() < 1e-6); } +/// A source that sets a last line whose descent passes its frame's bottom: the frame grows from its +/// bottom by its lines' deepest descent plus 0.05 pt, so that line shows, and only as many lines as +/// the source has. +#[test] +fn fit_source_lines_keeps_the_last_lines_the_source_sets() { + let text = "one\ntwo\nthree"; + let tall = layout(db(), &area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left)); + let third = &tall.lines[2]; + // The third baseline is in the frame, its descent isn't. + let frame = Rect::new(0.0, 0.0, 200.0, third.baseline + third.descent / 2.0); + let mut t = area(text, style(12.0), frame, Justify::Left); + let short = layout(db(), &t); + assert!(short.overflow && short.lines.len() == 2); + // A source with no more lines than fit leaves the frame as it is. + assert!(!fit_source_lines(db(), &mut t, 2)); + assert_eq!(layout(db(), &t).lines.len(), 2); + assert!(fit_source_lines(db(), &mut t, 3)); + let fitted = layout(db(), &t); + assert!(!fitted.overflow && fitted.lines.len() == 3); + let TextKind::Area { frame: grown } = &t.kind else { panic!("area type") }; + let grown = grown.bounds().unwrap(); + assert!(grown.y0 == frame.y0 && grown.y1 > frame.y1 && grown.y1 <= frame.y1 + third.descent + 0.1, "{grown:?}"); + // Fitted, nothing more to do. + assert!(!fit_source_lines(db(), &mut t, 3)); + // More than a descent short: a line the source can't have set in its frame stays out. + let frame = Rect::new(0.0, 0.0, 200.0, tall.lines[1].baseline + tall.lines[1].descent + 1.0); + let mut t = area(text, style(12.0), frame, Justify::Left); + assert!(!fit_source_lines(db(), &mut t, 3)); + // Point type and vertical type aren't fitted. + let mut p = point(text, style(12.0)); + assert!(!fit_source_lines(db(), &mut p, 9)); +} + #[test] fn indents_and_paragraph_spacing() { let frame = Rect::new(0.0, 0.0, 200.0, 400.0); @@ -880,12 +913,67 @@ fn a_leading_tab_takes_its_width_in_the_line_and_the_justification() { } } +/// Right-aligned area type with tabs: the text before the last tab sits at its stops, measured from +/// the frame's left edge, and the text after the last tab against the right edge, as Illustrator's +/// own layout in a user's `.ai` (used locally, not committed) sets a list of an item and two +/// prices: with a right stop at 269.29 and a left one at 272.13, the item starts at 0, the first +/// price ends at 269.29 and the second ends on the edge. +#[test] +fn right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge() { + use vectorcraft_doc::{TabAlign, TabStop}; + let stop = |position: f64, align: TabAlign| TabStop { position, align, leader: String::new(), align_on: '.' }; + let frame = Rect::new(0.0, 0.0, 336.0, 100.0); + let mut t = area("Item\t12.50\t24.00", style(13.0), frame, Justify::Right); + t.para.tabs = vec![stop(269.29, TabAlign::Right), stop(272.13, TabAlign::Left)]; + let lay = layout(db(), &t); + let glyph = |byte: usize| lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let end = |byte: usize| glyph(byte).origin.x + glyph(byte).advance; + assert!(glyph(0).origin.x.abs() < 1e-6, "the item starts at the left edge: {}", glyph(0).origin.x); + let first_price_end = "Item\t12.50".len() - 1; + assert!((end(first_price_end) - 269.29).abs() < 1e-6, "the first price ends on its right stop: {}", end(first_price_end)); + let last = t.plain_text().len() - 1; + assert!((end(last) - 336.0).abs() < 1e-6, "the last price ends on the edge: {}", end(last)); + // One tab: the text after it is against the edge, whatever its stop. + let mut one = area("Another item\t8.00", style(13.0), frame, Justify::Right); + one.para.tabs = t.para.tabs.clone(); + let lay = layout(db(), &one); + let first = lay.glyphs.first().unwrap(); + let last = lay.glyphs.last().unwrap(); + assert!( + first.origin.x.abs() < 1e-6 && (last.origin.x + last.advance - 336.0).abs() < 1e-6, + "{} {}", + first.origin.x, + last.origin.x + last.advance + ); + // A tab ending the line (a heading in the user's file): the text before it at the left edge, + // the tab to the right edge. + let lay = layout(db(), &area("Heading\t\nNext", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x.abs() < 1e-6, "{}", lay.glyphs.first().unwrap().origin.x); + let tab = lay.glyphs.iter().find(|g| g.byte == 7).unwrap(); + assert!((tab.origin.x + tab.advance - 336.0).abs() < 1e-6, "the tab ends at {}", tab.origin.x + tab.advance); + // Text after the last tab that breaks with a hyphen: the hyphen ends on the edge too. + let text = "A\tinternationalization"; + let mut hyphenated = area(text, style(13.0), Rect::new(0.0, 0.0, 100.0, 200.0), Justify::Right); + hyphenated.para.hyphenate = true; + hyphenated.para.composer = vectorcraft_doc::Composer::SingleLine; + let lay = layout(db(), &hyphenated); + let line = &lay.lines[0]; + assert!(line.end > 2 && line.end < text.len(), "the first line breaks inside the word: {line:?}"); + let last = lay.glyphs.get(line.glyph_end - 1).unwrap(); + assert!((last.origin.x + last.advance - 100.0).abs() < 1e-6, "the hyphen ends at {}", last.origin.x + last.advance); + // Without a tab, the line is right aligned as a whole. + let lay = layout(db(), &area("Name", style(13.0), frame, Justify::Right)); + assert!(lay.glyphs.first().unwrap().origin.x > 200.0); +} + #[test] fn a_centred_or_right_aligned_line_sets_its_tabs_as_if_it_started_at_the_left() { use vectorcraft_doc::{TabAlign, TabStop}; // "AB" with a stop at 100 pt is 100 pt plus B wide whatever the alignment: the whole line // moves, the gap between A and B stays 100 pt, and a right aligned line ends on the edge (#2457). - for justify in [Justify::Center, Justify::Right, Justify::JustifyCenter, Justify::JustifyRight] { + // (Right aligned area type sets the text after its last tab against the edge instead: see + // `right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`.) + for justify in [Justify::Center, Justify::JustifyCenter, Justify::JustifyRight] { let mut t = area("A\tB", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), justify); t.para.tabs = vec![TabStop { position: 100.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; let lay = layout(db(), &t); diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 4791347d0..4b8bdbc09 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1970,14 +1970,28 @@ fn soft_return_breaks_line_without_new_paragraph() { assert_eq!(&t.plain_text()[l1.start..l1.end], "Line 2"); } +/// A line ending in a soft return is justified as a wrapped line is: only the paragraph's last +/// line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not +/// committed) spreads such lines to the frame's edge. #[test] -fn soft_return_in_justified_text_does_not_spread() { - let text = "Short\u{2028}A much longer second line of text inside the frame."; - let t = area(text, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::JustifyLeft); - let lay = layout(db(), &t); - assert!(lay.lines.len() >= 2); - let l0 = &lay.lines[0]; - assert!(l0.x1 - l0.x0 < 100.0, "short line ending with soft return is not justified: {}", l0.x1 - l0.x0); +fn soft_return_in_justified_text_spreads_like_a_wrapped_line() { + let frame = Rect::new(0.0, 0.0, 300.0, 200.0); + let text = "A line of a few words\u{2028}and the last line."; + for justify in [Justify::JustifyLeft, Justify::JustifyCenter, Justify::JustifyRight] { + let lay = layout(db(), &area(text, style(12.0), frame, justify)); + assert_eq!(lay.lines.len(), 2); + let (l0, l1) = (&lay.lines[0], &lay.lines[1]); + assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}: {l0:?}"); + assert!(l1.x1 - l1.x0 < 200.0, "{justify:?}: the last line isn't justified: {l1:?}"); + // A soft return ending the paragraph: the empty line after it is the last one, so the line + // it ends is justified too. + let lay = layout(db(), &area("A line of a few words\u{2028}", style(12.0), frame, justify)); + let l0 = lay.lines.first().unwrap(); + assert!((l0.x0 - frame.x0).abs() < 1e-6 && (l0.x1 - frame.x1).abs() < 1e-6, "{justify:?}, trailing soft return: {l0:?}"); + } + // Aligned type stays aligned. + let lay = layout(db(), &area(text, style(12.0), frame, Justify::Left)); + assert!(lay.lines[0].x1 < frame.x1 - 100.0); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cbd5c3788..71f595508 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -52,6 +52,11 @@ private editing data. VectorCraft: An opacity mask's art of one shown object, other than a guide or a clipping path, opens as that object in the editing data of a `.ai`, EPS or PDF; other art opens in a group. Art in the mask that sets no paint of its own paints with the paint before the mask's dictionary, as the writer leaves out paint that doesn't change. + Type from the text document keeps auto leading as auto, each line taking the paragraph's share + (Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), + and the paragraph's hyphenation options and tab stops. Area type of one + frame whose last lines the file sets but which don't fit here (the fallback font, or a descent + past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile open with different, unconverted RGB colours than before. @@ -315,6 +320,8 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. | +| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a path or compound path whose art style stacks several fills and strokes (solid colours and gradients, each with its own visibility, opacity and blend mode, under effects any object takes) comes in as itself with that appearance instead of its drawn look. Each gradient must be its own swatch, placed as the drawn look places it; a stack that repeats a swatch, or whose look doesn't paint its gradients, keeps the drawn look, as do fills and strokes with effects of their own. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes whose objects are in the older form (first number 1, one field fewer and 8 numbers at the end) come into the Brushes panel with their art instead of being left out with a note. In a survey of 302 users' EPS files (used locally, not committed), 652 Art brushes in 188 of the 197 files with the note were left out this way; afterwards 55 files have the note, all for Pattern brushes. Test `an_older_art_brush_comes_in_with_its_art` (crate `vectorcraft-eps`); scores unchanged | | 2026-11-06 | minor | EPS import: composite (Type 0) fonts with the 8/8 mapping (`FMapType` 2: PLRM 3rd ed., §5.10) draw a Type 42 descendant's glyphs from the TrueType outlines its `sfnts` carry (Adobe Technical Note #5012), spaced by their own widths or the show operator's numbers, and `charpath` and `stringwidth` read them; descendants found by name set their characters as type at the composite font's size instead of a thousand times it. EPS from `pdftops` of a PDF with embedded TrueType fonts showed nothing, or one black shape, where its type was. | diff --git a/docs/roadmap.md b/docs/roadmap.md index a5e5a3558..69fea5b1c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -40,6 +40,8 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops from the left edge (#6147). Scores and estimates unchanged. | +| 2026-11-07 | minor | M4/M7: `.ai`/EPS type from the text document keeps auto leading at the paragraph's share, the paragraph's hyphenation options and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified as a wrapped line is (#6137); a user's `.ai` that opened from its PDF part now opens from its layers. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | @@ -131,13 +133,13 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M2 | Drawing tools + smart guides | ✅ mostly done (Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537); no View submenu | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | | M2 | Drawing tools + smart guides | ✅ mostly done (Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending) | 12–18 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Fullwidth `!` / `?` keep an upright fallback in vertical type; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, a generated Basic Shapes library, User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, four generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16236c114..b9b8925ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -20,12 +20,14 @@ Spiral decay now applies after each full wind of four segments. A newly drawn sp Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. -**Feature evidence last updated:** 2026-11-05. +**Feature evidence last updated:** 2026-11-07. Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged. The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement. +- File formats and Type core (#6147): `.ai`/EPS type from the text document keeps its paragraphs' tab stops, and right-aligned area type sets the text before a line's last tab at its stops, from the left edge, and the text after it against the right edge. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`; scores and estimates unchanged +- File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading at the paragraph's share of each line's largest type, the paragraph's hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them; a line a soft return ends is justified as a wrapped line is. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently; scores and estimates unchanged - Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged - Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches (scores/hours unchanged) @@ -37,6 +39,8 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | +| 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | @@ -552,6 +556,8 @@ since 2026-10-01. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. - **PDF editable import:** Place from bytes or a path with `link: false`, and `clipboard.importPdf` followed by Paste, preserve live type on the selected page. Place still honors `textAs: "outlines"`. Synthetic two-page regressions: `crates/engine/src/cmd/fileio/tests_pdffidelity.rs`. No change to the PDF reader's existing fallback rules. +- **Tab stops in `.ai`/EPS type and right-aligned lines with tabs (#6147):** type from the text document keeps its paragraphs' tab stops (key 30: positions and left, center, right and decimal alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. In right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none when the tab ends the line, with its hyphen when the line is hyphenated) against the right edge; centred and justified lines, point type and right-to-left paragraphs are aligned as a whole as before. Evidence: `ate::tests::a_paragraph_keeps_its_tab_stops`, `ate::tests::a_story_s_tab_stops_are_bounded`, `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`, `import_fuzz::text_frames_with_hostile_paragraph_attributes_never_panic`. Scores unchanged. +- **`.ai`/EPS type laid out closer to the file's page (#6137):** type from the text document keeps auto leading as auto, each line taking the paragraph's Auto Leading share (key 7) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)); the paragraph's hyphenation options (keys 10 to 15) are read; area type of one frame whose last lines the file sets, but which don't fit here, may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines and no more than the file has (`vectorcraft_text::fit_source_lines`), with a warning when it does: the frame can stay as it is, or show only some of them; and a line a soft return ends is justified as a wrapped line is, a paragraph's last line keeping its alignment. Evidence: `ate::tests::auto_leading_follows_the_size_of_each_line`, `ate::tests::a_paragraph_keeps_its_auto_leading`, `ate::tests::a_paragraph_keeps_its_hyphenation_options`, `ate::tests::the_last_lines_the_file_sets_in_its_frame_show`, `tests::fit_source_lines_keeps_the_last_lines_the_source_sets`, `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores unchanged. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes, the distances of its later effects and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). Not yet: object effects after a Transform effect keep their distances. Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. @@ -1880,6 +1886,8 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. | +| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type from the text document lays its lines out closer to the file's page: auto leading per line at the paragraph's share, the paragraph's hyphenation options, possibly more of the last lines the file sets in an area frame of its own, and a line a soft return ends justified (#6137); evidence added; scores unchanged. | | 2026-11-06 | minor | `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; evidence updated; scores unchanged. | | 2026-11-08 | minor | Panel numeric wheel turns do not seed canvas zoom glides; corrected the disabled-field scroll description (#5949). Scores and estimates unchanged. | | 2026-11-07 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 8b2cf40de..6b68d3a45 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-08 · **Change:** minor (Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) 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 @@ -19,7 +19,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Feature | Status | Notes | |---|---|---| | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | -| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | +| Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; in justified text a line a soft return ends is justified as a wrapped line is, only the paragraph's last line keeping its alignment, as Illustrator's own layout in a user's `.ai` sets it (evidence: `tests::typo::soft_return_in_justified_text_spreads_like_a_wrapped_line`; first done the other way in #2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | | Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches back to the tool whose double-click on type chose the Type tool (kept until another tool is chosen or another document or window becomes active, so later edits with the Type tool return there too), or to the Selection tool when there is no such tool, as when the Type tool was chosen from the toolbar or after such a switch (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; Type › Type Orientation sets every frame of a thread, and a thread's frames share one writing direction; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | @@ -45,7 +45,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests` | | Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` | | Hyphenation… and Justification… options | done | Eleven Indian languages use attributed patterns and preserve syllables in both composers (#4947). Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Auto Leading also sets the Leading the Character panel shows for Auto type, the leading Alt+↑/↓ (`type.step`) steps from, per paragraph, and the lines Fill with Placeholder Text counts. Evidence: `crates/text/src/tests/justify.rs`, `dialogs::justification::tests`, `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading` | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877); Ctrl-dragging a stop (Command on macOS) moves all the stops together; in right-aligned area type the text before a line's last tab sits at its stops, from the left edge, and the text after it against the right edge, as Illustrator's own layout in a user's `.ai` sets a price list (evidence: `tests::right_aligned_area_type_sets_the_text_after_its_last_tab_against_the_edge`; centred and justified lines are still aligned as a whole) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | @@ -81,6 +81,8 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | Tabs: in right-aligned area type, the text before a line's last tab sits at its stops, measured from the left edge, and the text after it (none, when the tab ends the line) against the right edge; the line was aligned as a whole, which moved its start off the left edge. Illustrator's own layout in a user's `.ai` (used locally, not committed) sets its price lists so. Centred and justified lines keep their alignment as a whole. Scores and estimates unchanged. | +| 2026-11-07 | minor | Soft return: in Justify Left, Center or Right, the line a soft return ends is justified as a wrapped line is; only the paragraph's last line keeps its alignment. Illustrator's own layout in a user's `.ai` (used locally, not committed) justifies those lines. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-07 | minor | Alt + numeric-keypad codes with NumLock off: the keypad keys arrive as Insert, End, arrows, Clear, Home and Page Up/Down, which Windows still composes into the code; with Alt held they now count as digits and are taken out of the frame instead of moving the caret, while the separate navigation keys (extended-key flag) keep their meaning (follow-up to #5939). Scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |