diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 1e2347249..da698f3ec 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -24,7 +24,9 @@ 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, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{ + CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun, +}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; /// Longest story read (bytes). @@ -343,6 +345,13 @@ 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%). `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, } /// The text of one story, in the units of the text document. @@ -588,6 +597,24 @@ 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, + }, + } + }, } } @@ -882,7 +909,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; @@ -893,13 +919,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; @@ -930,6 +956,15 @@ 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()) + } + /// 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 @@ -968,38 +1003,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(); @@ -1049,21 +1052,29 @@ 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() }, // 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 { +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. + leading: s.leading.map(|l| l * scale), tracking: s.tracking, baseline_shift: s.baseline_shift * scale, h_scale: s.h_scale * 100.0, @@ -1111,6 +1122,63 @@ 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); + } + /// 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] @@ -1411,25 +1479,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]); @@ -1438,24 +1535,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 @@ -1499,6 +1590,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 bac7108ac..4e1e0a004 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) = (0, 0); let made = infos .iter() .enumerate() @@ -79,6 +79,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(); @@ -87,6 +92,11 @@ 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" + )); + } made } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c36514eef..74a25b4ff 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -36,7 +36,7 @@ pub(crate) use bidi::{is_rtl, para_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; -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}; const EPS: f64 = 1e-6; diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 01fef1765..0c44fee58 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 at most a line's descent, 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/lib.rs b/crates/text/src/lib.rs index 9763ea4b6..d4ccdcdef 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -44,7 +44,7 @@ use std::sync::Arc; pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; 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 031d35bdc..e7526849e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -304,6 +304,38 @@ 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 at most a line's descent, 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); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f60d46f62..8f3eb92b8 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-06 · **Change:** minor (EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; 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 auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; 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 @@ -49,6 +49,10 @@ private editing data. VectorCraft: clipped by a copy of the mask's path. 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. + 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, and the paragraph's hyphenation options. 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) gets a frame taller from its bottom, with a warning. 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. @@ -212,6 +216,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 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 whose last lines the file sets in too short a frame for them here gets a taller frame. 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%, the rest its Myriad Pro type in the fallback font). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. | | 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged |