diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs index f53a48857..515f52627 100644 --- a/crates/doc/src/text/area.rs +++ b/crates/doc/src/text/area.rs @@ -141,6 +141,10 @@ pub struct AreaOptions { pub columns: usize, /// Gutter between rows/columns in points. pub gutter: f64, + /// Gutter between rows in points when it differs from [`Self::gutter`], which is then the + /// gutter between columns (left out when saved while unset). + #[serde(skip_serializing_if = "Option::is_none")] + pub row_gutter: Option, /// Inset from the frame edges in points. pub inset: f64, pub first_baseline: FirstBaseline, @@ -162,6 +166,7 @@ impl Default for AreaOptions { rows: 1, columns: 1, gutter: 18.0, + row_gutter: None, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 002851b70..58496ce81 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -87,7 +87,7 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", + "{ids?|id?, width?: pt, height?: pt, rows?, columns?, gutter?: pt (between rows and columns), rowGutter?: pt|null (between rows when it differs from gutter, which is then between columns; gutter given alone sets both; the reply gives the rows' gutter either way), inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, flow?: columns|rows (Text Flow: down each column's rows, then across, the default; or across each row's columns, then down), fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100, …the keys of its own reply (it can be passed back; the values it only reports are ignored)} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)", has_selection, area_options ), @@ -1007,6 +1007,8 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { if let Some(o) = v.as_object_mut() { o.insert("fit".into(), json!(t.area.fit.id())); o.insert("flow".into(), json!(t.area.flow)); + // The gutter between rows, the gutter's when the rows have none of their own. + o.insert("rowGutter".into(), json!(t.area.row_gutter.unwrap_or(t.area.gutter))); let min = match t.area.fit { AreaFit::ShrinkText { min_percent } => min_percent, _ => AreaFit::DEFAULT_MIN_PERCENT, @@ -1023,7 +1025,7 @@ pub(crate) fn area_options_json(t: &TextObject) -> Result { } /// The [`vectorcraft_doc::AreaOptions`] keys `text.areaOptions` merges as they are. -const AREA_KEYS: [&str; 8] = ["rows", "columns", "gutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign", "flow"]; +const AREA_KEYS: [&str; 9] = ["rows", "columns", "gutter", "rowGutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign", "flow"]; fn area_options(s: &mut Session, p: &Value) -> Result { const C: &str = "text.areaOptions"; @@ -1057,6 +1059,11 @@ fn area_options(s: &mut Session, p: &Value) -> Result { opts.columns = opts.columns.clamp(1, vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS); let finite = |x: f64| if x.is_finite() { x } else { 0.0 }; opts.gutter = finite(opts.gutter).clamp(0.0, 10_000.0); + // A gutter given alone is the rows' and the columns'. + if p.get("gutter").is_some() && p.get("rowGutter").is_none() { + opts.row_gutter = None; + } + opts.row_gutter = opts.row_gutter.map(|g| finite(g).clamp(0.0, 10_000.0)).filter(|g| *g != opts.gutter); opts.inset = finite(opts.inset).clamp(0.0, 10_000.0); opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0); opts.fit = fit.unwrap_or(cur.fit); diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 4a1200c25..115a3bd13 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -1636,3 +1636,43 @@ fn area_options_columns_change_layout() { assert_eq!(lay(&s).frames.len(), 1); assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err()); } + +/// `rowGutter` sets a gutter between rows of its own, the reply passes it back, and a gutter +/// given alone is the rows' and the columns' again. +#[test] +fn area_options_set_a_gutter_between_rows_of_its_own() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "word ".repeat(80), "area": {"width": 300, "height": 120}})).unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Without one of its own, the rows' is the gutter, and passing the reply back keeps it unset. + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["gutter"].as_f64(), q["rowGutter"].as_f64()), (Some(18.0), Some(18.0))); + s.execute("text.areaOptions", &q).unwrap(); + // Both given: the rows keep theirs even at the old gutter's value. + let r = s.execute("text.areaOptions", &json!({"gutter": 10, "rowGutter": 18})).unwrap(); + assert_eq!((r["gutter"].as_f64(), r["rowGutter"].as_f64()), (Some(10.0), Some(18.0))); + s.execute("text.areaOptions", &json!({"gutter": 18})).unwrap(); + let r = s.execute("text.areaOptions", &json!({"rows": 2, "gutter": 12, "rowGutter": 30})).unwrap(); + assert_eq!((r["gutter"].as_f64(), r["rowGutter"].as_f64()), (Some(12.0), Some(30.0))); + let area = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.area.clone(), + _ => panic!(), + }; + assert_eq!(area(&s).row_gutter, Some(30.0)); + // The reply passed back keeps it; out of range it is capped; the gutter's value is none of its own. + s.execute("text.areaOptions", &json!({"rowGutter": 12})).unwrap(); + assert_eq!(area(&s).row_gutter, None); + s.execute("text.areaOptions", &json!({"rowGutter": 30})).unwrap(); + s.execute("text.areaOptions", &r).unwrap(); + assert_eq!(area(&s).row_gutter, Some(30.0)); + s.execute("text.areaOptions", &json!({"rowGutter": -5})).unwrap(); + assert_eq!(area(&s).row_gutter, Some(0.0)); + s.execute("text.areaOptions", &json!({"gutter": 8})).unwrap(); + assert_eq!((area(&s).gutter, area(&s).row_gutter), (8.0, None)); + s.execute("text.areaOptions", &json!({"rowGutter": 30})).unwrap(); + s.execute("text.areaOptions", &json!({"rowGutter": null})).unwrap(); + assert_eq!(area(&s).row_gutter, None); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 5177b3ea7..7cb534e82 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2834,14 +2834,48 @@ fn text_eps(document: &str) -> Vec { /// An EPS with the text `objects` on a hidden layer, their stories in `document`. fn text_eps_of(document: &str, objects: &str) -> Vec { + editing_eps(&text_editing_of(document, objects)) +} + +/// The editing data of [`text_eps_of`]. +fn text_editing_of(document: &str, objects: &str) -> String { let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); - let editing = format!( + format!( "%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\ %AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n{objects}LB\n%AI5_EndLayer--\n\ %AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", lines.join("\n") - ); - editing_eps(&editing) + ) +} + +/// 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. +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![ + "0", + "1", + "2", + "3", + "2.5", + "-3", + "1e9", + "1e400", + "NaN", + "inf", + "-inf", + "(x)", + "true", + "/Name", + "[ 1 2 ]", + "<< /0 0 /1 30 >>", + "<< /0 3 /1 NaN >>", + "<< /0 4 /1 -inf >>", + "<< /0 99 /1 1e300 >>", + "<< /0 << /0 1 >> /1 [ ] >>", + "<< /1 30 >>", + ]); + prop::collection::vec((keys, values), 0..8).prop_map(|kv| kv.iter().map(|(k, v)| format!("/{k} {v}")).collect::>().join(" ")) } /// A text document of `kinds.len()` stories, story `i` in frame `i` of kind `kinds[i]` (0 point type, @@ -2981,6 +3015,18 @@ proptest! { survive("mutated EPS text frames", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; } + /// The threaded area type of the same text document with hostile Area Type Options in each of its + /// frames, opened as an EPS document and as a `.ai`'s editing data. + #[test] + fn text_frames_with_hostile_area_options_never_panic(first in arb_area_options(), second in arb_area_options()) { + let text = frames_document_text().replacen("/0 1 /7 18", &format!("/0 1 {first}"), 1).replacen("/0 1 /7 18", &format!("/0 1 {second}"), 1); + let editing = text_editing_of(&text, FRAME_OBJECTS); + let bytes = editing_eps(&editing); + survive("EPS text frames with hostile area options", || 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 area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page. #[test] fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) { diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 60273bb50..1a5c17413 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -245,17 +245,91 @@ const MAX_FRAMES: usize = 4096; const MAX_SEGMENTS: usize = 1 << 16; /// Where a frame puts its story's text. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] enum FrameKind { /// Point type: the matrix carries the layout (its anchor at the origin of its lines) onto the /// canvas. Point([f64; 6]), - /// Area type flowed inside a path: its cubic segments on the canvas, four points each. - Area(Vec<[f64; 8]>), + /// Area type flowed inside a path: its cubic segments on the canvas, four points each, and the + /// Area Type Options the frame keeps. + Area(Vec<[f64; 8]>, AreaFrame), /// Type on a path, from `start` to `end` (segment index and the place in that segment). OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64 }, } +/// The Area Type Options an area frame keeps, in the units of the text document: each `None` +/// where the frame leaves the key out or keeps a value that can't be read, so the option stays as +/// it was. +#[derive(Debug, Clone, Default, PartialEq)] +struct AreaFrame { + /// Key 3, the rows. + rows: Option, + /// Key 4, the columns. + columns: Option, + /// Key 7, the gutter between rows. + row_gutter: Option, + /// Key 8, the gutter between columns. + column_gutter: Option, + /// Key 10, the first baseline (`0` its kind, `1` its minimum). Without it the first baseline + /// is Ascent. + first_baseline: Option<(vectorcraft_doc::FirstBaseline, f64)>, +} + +/// Longest gutter or first-baseline minimum, as `text.areaOptions` caps them: read in the units of +/// the text document, and again in document points once scaled. +const MAX_AREA_LENGTH: f64 = 10_000.0; + +impl AreaFrame { + /// The options frame data `carries` keeps. + fn read(carries: &Val) -> AreaFrame { + // A whole number, at most `max`. + let whole = |v: &Val, max: f64| v.num().filter(|n| n.fract() == 0.0 && *n >= 0.0).map(|n| n.min(max) as usize); + let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS; + let count = |k: &str| carries.get(k).and_then(|v| whole(v, max as f64)).filter(|n| *n >= 1); + let length = |v: Option<&Val>| v.and_then(Val::num).filter(|n| *n >= 0.0).map(|n| n.min(MAX_AREA_LENGTH)); + let first_baseline = carries.get("10").and_then(|b| { + use vectorcraft_doc::FirstBaseline as F; + let kind = match whole(b.get("0")?, 99.0)? { + 0 => F::Fixed, + 2 => F::CapHeight, + 3 => F::Leading, + 4 => F::XHeight, + // Em Box Height (5) and Legacy (6) have no first baseline of their own here: the + // first baseline stays as it was. + _ => return None, + }; + Some((kind, length(b.get("1")).unwrap_or(0.0))) + }); + AreaFrame { + rows: count("3"), + columns: count("4"), + row_gutter: length(carries.get("7")), + column_gutter: length(carries.get("8")), + first_baseline, + } + } + + /// Sets the options it keeps on `area`, its lengths at `scale` (document points to a unit). + fn apply(&self, area: &mut vectorcraft_doc::AreaOptions, scale: f64) { + if let Some(n) = self.rows { + area.rows = n; + } + if let Some(n) = self.columns { + area.columns = n; + } + // A gutter the frame leaves out stays as it was. + let (row, column) = (area.row_gutter.unwrap_or(area.gutter), area.gutter); + let scaled = |g: f64| (g * scale).min(MAX_AREA_LENGTH); + let row = self.row_gutter.map_or(row, scaled); + area.gutter = self.column_gutter.map_or(column, scaled); + area.row_gutter = (row != area.gutter).then_some(row); + if let Some((kind, min)) = self.first_baseline { + area.first_baseline = kind; + area.first_baseline_min = scaled(min); + } + } +} + /// A paragraph's attributes, as the text document keeps them. #[derive(Clone, Debug, Default, PartialEq)] struct Para { @@ -390,7 +464,7 @@ impl Texts { None => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], Some(m) => m.nums()?.try_into().ok()?, })), - 1 => Some(FrameKind::Area(segments()?)), + 1 => Some(FrameKind::Area(segments()?, AreaFrame::read(carries))), 2 => { let range = carries.get("6").and_then(Val::nums).unwrap_or_default(); let segments = segments()?; @@ -690,7 +764,7 @@ impl Story { let size = self.runs.iter().map(|(_, s)| s.size).fold(0.0, f64::max) * art_scale(to_doc); let bounds = |segments: &[[f64; 8]]| path_of(segments, template, to_doc).bounds(); let r = match self.frames.first()? { - FrameKind::Area(s) => bounds(s)?, + FrameKind::Area(s, _) => bounds(s)?, FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5), FrameKind::Point(m) => { let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64; @@ -735,7 +809,7 @@ impl Story { let at = to_doc * Point::new(x, y); (scale, Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]), TextKind::Point) } - FrameKind::Area(s) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }), + FrameKind::Area(s, _) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }), FrameKind::OnPath { segments, start, end } => { let n = segments.len() as f64; // A closed path keeps its end bracket wherever it is (the same place as the start: a @@ -795,6 +869,10 @@ impl Story { if matches!(kind, TextKind::OnPath { .. }) && !threaded { t.path_spacing = self.path_spacing * scale; } + // Each frame of a thread keeps its own options. + if let Some(FrameKind::Area(_, options)) = self.frames.get(frame) { + options.apply(&mut t.area, scale); + } t.kind = kind; t.xf = xf; Some(Node::new(id, NodeKind::Text(Box::new(t)))) @@ -812,10 +890,14 @@ impl Story { /// `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 /// frame's top, the first baseline here, isn't where the app that wrote the file puts it for - /// every font. Whether it was. - pub(super) fn place_first_line(&self, node: &mut Node, template: (f64, f64), to_doc: Affine) -> bool { + /// every font. A first baseline its frame `frame` keeps (other than Ascent) stays as it is. + /// Whether it was. + pub(super) fn place_first_line(&self, node: &mut Node, frame: usize, template: (f64, f64), to_doc: Affine) -> bool { // Closer than this (points of the document) and the line is where the file has it. const SAME: f64 = 0.5; + if matches!(self.frames.get(frame), Some(FrameKind::Area(_, AreaFrame { first_baseline: Some(_), .. }))) { + return false; + } let NodeKind::Text(t) = &mut node.kind else { return false }; if !matches!(t.kind, TextKind::Area { .. }) || t.area.vertical_align != vectorcraft_doc::VerticalAlign::Top { return false; @@ -1314,13 +1396,13 @@ mod tests { let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); let first = |s: &Story| { let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); - let placed = s.place_first_line(&mut node, template, to_doc); + let placed = s.place_first_line(&mut node, 0, template, to_doc); let NodeKind::Text(t) = node.kind else { panic!("area type") }; let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); (placed, l.lines.first().map(|l| l.baseline), t.area.first_baseline) }; let mut s = story("Heading\r", &[], &Para::default()); - s.frames = vec![FrameKind::Area(frame)]; + s.frames = vec![FrameKind::Area(frame, AreaFrame::default())]; // Where this lays the line out on its own: the file agrees, and nothing changes. s.lines = vec![(10.0, 20.0)]; let (_, Some(own), _) = first(&s) else { panic!("a line") }; @@ -1332,4 +1414,146 @@ mod tests { assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}"); assert!(at.is_some_and(|y| (y - (own - 3.0)).abs() < 1e-6), "{at:?} for {}", own - 3.0); } + + /// A text document's area frame, the rectangle 10, 20 to 210, 120 of the canvas, whose frame + /// data has `options` besides its kind, written by hand. + fn area_frame(options: &str) -> FrameKind { + let line = |a: (f64, f64), b: (f64, f64)| format!("{} {} {} {} {} {} {} {}", a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1); + let segments = [ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ]; + let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 1 {options} >> >>", segments.join(" ")); + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + frames: vec![read(doc.as_bytes()).unwrap()], + }; + texts.frame(0).unwrap() + } + + /// The area options of the text object a story of `frames` comes in as at frame `frame`, the + /// art's units `scale` document points each. + fn area_of(frames: Vec, frame: usize, scale: f64) -> vectorcraft_doc::AreaOptions { + let mut s = story("Some area type\r", &[], &Para::default()); + s.frames = frames; + let to_doc = Affine::scale(scale) * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + let NodeKind::Text(t) = s.node(NodeId(0), frame, (CANVAS_CENTRE, -CANVAS_CENTRE), to_doc).unwrap().kind else { panic!("area type") }; + t.area + } + + #[test] + fn area_frame_options_are_read() { + // GUARDS: area type came in with one row, one column and the 18 pt gutter whatever its frame kept. + let a = area_of(vec![area_frame("/3 2 /4 3 /7 20 /8 24")], 0, 1.0); + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 3, 24.0, Some(20.0))); + // The same gutter between rows and columns is one gutter. + let a = area_of(vec![area_frame("/3 2 /4 3 /7 20 /8 20")], 0, 1.0); + assert_eq!((a.gutter, a.row_gutter), (20.0, None)); + // Lengths scale with the art's units (a Large Canvas copy measures in tenths of the page). + let a = area_of(vec![area_frame("/7 2 /8 2.4 /10 << /0 3 /1 3 >>")], 0, 10.0); + assert!((a.gutter - 24.0).abs() < 1e-9 && a.row_gutter == Some(20.0), "{a:?}"); + assert!((a.first_baseline_min - 30.0).abs() < 1e-9, "{a:?}"); + } + + #[test] + fn area_frame_first_baseline_kinds_and_minimum_are_read() { + use vectorcraft_doc::FirstBaseline as F; + for (code, kind) in [(0, F::Fixed), (2, F::CapHeight), (3, F::Leading), (4, F::XHeight)] { + let a = area_of(vec![area_frame(&format!("/10 << /0 {code} /1 30 >>"))], 0, 2.0); + assert_eq!((a.first_baseline, a.first_baseline_min), (kind, 60.0), "code {code}"); + } + // A kind without its own first baseline here, or none known, leaves Ascent. + for code in ["1", "5", "6", "7", "-1", "2.5", "(x)"] { + let a = area_of(vec![area_frame(&format!("/10 << /0 {code} /1 30 >>"))], 0, 1.0); + assert_eq!((a.first_baseline, a.first_baseline_min), (F::Ascent, 0.0), "code {code}"); + } + // A minimum that can't be read is none. + let a = area_of(vec![area_frame("/10 << /0 3 /1 -4 >>")], 0, 1.0); + assert_eq!((a.first_baseline, a.first_baseline_min), (F::Leading, 0.0)); + } + + /// A first baseline the frame keeps stays as it is: the first line isn't moved to where the + /// file lays it out. + #[test] + fn a_first_baseline_the_frame_keeps_is_not_moved() { + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + // Where this lays the first line out on its own. + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = vec![area_frame("")]; + let NodeKind::Text(t) = s.node(NodeId(0), 0, template, to_doc).unwrap().kind else { panic!("area type") }; + let own = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.first().map(|l| l.baseline).unwrap(); + let placed_in = |frames: &[&str]| { + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = frames.iter().map(|o| area_frame(o)).collect(); + // Higher in the file. + s.lines = vec![(10.0, own - 3.0)]; + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = s.place_first_line(&mut node, 0, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + (placed, t.area.first_baseline) + }; + let placed = |options: &str| placed_in(&[options]); + assert_eq!(placed("/10 << /0 2 /1 0 >>"), (false, vectorcraft_doc::FirstBaseline::CapHeight)); + // Without one (Ascent), or with one that isn't read, the line goes where the file has it. + assert_eq!(placed(""), (true, vectorcraft_doc::FirstBaseline::Fixed)); + assert_eq!(placed("/10 << /0 5 /1 0 >>"), (true, vectorcraft_doc::FirstBaseline::Fixed)); + // Another frame of the thread keeping one doesn't count for the first. + assert_eq!(placed_in(&["", "/10 << /0 2 /1 0 >>"]), (true, vectorcraft_doc::FirstBaseline::Fixed)); + } + + #[test] + fn area_frame_options_that_cannot_be_read_keep_their_defaults() { + let defaults = vectorcraft_doc::AreaOptions::default(); + let bad = [ + "/3 0 /4 2.5 /7 -1 /8 (x)", + "/3 NaN /4 inf /7 NaN /8 -inf /10 [ 1 2 ]", + "/3 (2) /4 [ 3 ] /7 << /0 1 >> /8 true /10 3", + "/10 << /1 30 >>", + ]; + for options in bad { + assert_eq!(area_of(vec![area_frame(options)], 0, 1.0), defaults, "{options}"); + } + // Too many rows and columns, and too long gutters, are capped. + let a = area_of(vec![area_frame("/3 1e9 /4 101 /7 1e300 /8 5e4")], 0, 1.0); + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (100, 100, MAX_AREA_LENGTH, None)); + // The cap holds after the lengths scale with the art's units. + let a = area_of(vec![area_frame("/7 1e4 /8 2e3 /10 << /0 0 /1 1e4 >>")], 0, 10.0); + assert_eq!((a.gutter, a.row_gutter, a.first_baseline_min), (MAX_AREA_LENGTH, None, MAX_AREA_LENGTH)); + // One that can be read is read beside one that can't. + let a = area_of(vec![area_frame("/3 2 /4 -3 /8 12 /7 NaN")], 0, 1.0); + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 1, 12.0, Some(18.0))); + } + + #[test] + fn area_frame_without_options_comes_in_as_before() { + assert_eq!(area_of(vec![area_frame("")], 0, 1.0), vectorcraft_doc::AreaOptions::default()); + assert_eq!(area_frame(""), FrameKind::Area(area_segments(), AreaFrame::default())); + } + + /// The segments [`area_frame`] writes. + fn area_segments() -> Vec<[f64; 8]> { + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + vec![ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ] + } + + #[test] + fn each_frame_of_a_thread_keeps_its_own_options() { + let frames = vec![area_frame("/3 2 /7 10 /8 10 /10 << /0 0 /1 12 >>"), area_frame("/4 3 /7 6 /8 8")]; + let (first, second) = (area_of(frames.clone(), 0, 1.0), area_of(frames, 1, 1.0)); + assert_eq!((first.rows, first.columns, first.gutter, first.row_gutter), (2, 1, 10.0, None)); + assert_eq!((first.first_baseline, first.first_baseline_min), (vectorcraft_doc::FirstBaseline::Fixed, 12.0)); + assert_eq!((second.rows, second.columns, second.gutter, second.row_gutter), (1, 3, 8.0, Some(6.0))); + assert_eq!(second.first_baseline, vectorcraft_doc::FirstBaseline::Ascent); + } } diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index 6c6cb82b4..82dd6b054 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -70,7 +70,7 @@ pub(super) fn make_slots( 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). - story.place_first_line(&mut node, t, to_doc); + story.place_first_line(&mut node, info.frame, t, to_doc); // Area type and type on a path in the object's own frame: the text document's frame // of a copied object keeps its original's path. frame_text_from_art(info, &mut node, &mut region); diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 02b7a613a..2d6cc6a35 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -2454,6 +2454,33 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() { assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); } +/// Area type keeps the rows, columns, gutters and first baseline its frame keeps, and so does +/// each frame of a thread; a frame without them keeps the defaults. +#[test] +fn area_type_keeps_its_area_type_options() { + let frame = |rect: (f64, f64, f64, f64), options: &str| { + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 {options} >> >> >>", canvas_rect(rect.0, rect.1, rect.2, rect.3)) + }; + let frames = [ + frame((10.0, 50.0, 90.0, 90.0), "/3 2 /4 3 /7 6 /8 4 /10 << /0 0 /1 9 >>"), + frame((10.0, 10.0, 40.0, 40.0), "/4 2 /7 5 /8 5 /10 << /0 2 /1 0 >>"), + frame((50.0, 10.0, 90.0, 40.0), ""), + ]; + let doc = document_of(&frames, &[story("Area type in cells\\r", &[0, 1], "/0 0", "/1 6.0"), story("Plain\\r", &[2], "/0 0", "/1 6.0")]); + let objects = frame_object(0, 0) + &frame_object(0, 1) + &text_object(1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &objects)), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 3, "{:?}", r.warnings); + let a = &texts[0].area; + assert_eq!((a.rows, a.columns, a.gutter, a.row_gutter), (2, 3, 4.0, Some(6.0))); + assert_eq!((a.first_baseline, a.first_baseline_min), (vectorcraft_doc::FirstBaseline::Fixed, 9.0)); + let b = &texts[1].area; + assert_eq!((b.rows, b.columns, b.gutter, b.row_gutter), (1, 2, 5.0, None)); + assert_eq!(b.first_baseline, vectorcraft_doc::FirstBaseline::CapHeight); + assert_eq!(texts[2].area, vectorcraft_doc::AreaOptions::default()); +} + /// A point type frame at the point (`x`, `y`) of the art. fn point_frame(x: f64, y: f64) -> String { let (cx, cy) = canvas(x, y); diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 58ca0a525..966807bb8 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -14,6 +14,7 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { rows: a.rows, columns: a.columns, gutter: a.gutter, + row_gutter: a.row_gutter, inset: a.inset, first_baseline: a.first_baseline, first_baseline_min: a.first_baseline_min, diff --git a/crates/text/src/layout/region.rs b/crates/text/src/layout/region.rs index e702c6eb6..9303d4c0f 100644 --- a/crates/text/src/layout/region.rs +++ b/crates/text/src/layout/region.rs @@ -139,8 +139,9 @@ impl Region { let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS; let (rows, cols) = (opts.rows.clamp(1, max), opts.columns.clamp(1, max)); let gutter = opts.gutter.max(0.0); + let row_gutter = opts.row_gutter.unwrap_or(opts.gutter).max(0.0); let cw = ((bbox.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.0); - let rh = ((bbox.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.0); + let rh = ((bbox.height() - row_gutter * (rows - 1) as f64) / rows as f64).max(0.0); let mut out = Vec::with_capacity(rows * cols); // Text Flow: down each column, then across (by columns, the default), or across each row, // then down (by rows). @@ -150,7 +151,7 @@ impl Region { for i in 0..inner { let (r, c) = if by_rows { (o, i) } else { (i, o) }; let x0 = bbox.x0 + c as f64 * (cw + gutter); - let y0 = bbox.y0 + r as f64 * (rh + gutter); + let y0 = bbox.y0 + r as f64 * (rh + row_gutter); let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 }); } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ac82b9008..70c432a3a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -62,6 +62,9 @@ pub struct LayoutOptions { pub columns: usize, /// Gutter between rows/columns in points. pub gutter: f64, + /// Gutter between rows in points when it differs from [`LayoutOptions::gutter`], which is then + /// the gutter between columns. + pub row_gutter: Option, /// Inset from the frame edges in points. pub inset: f64, pub first_baseline: FirstBaseline, @@ -85,6 +88,7 @@ impl Default for LayoutOptions { rows: 1, columns: 1, gutter: 18.0, + row_gutter: None, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0, diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 3c35136a6..4742806d1 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -477,6 +477,19 @@ fn text_flow_fills_the_cells_by_columns_or_by_rows() { assert_eq!(layout(db(), &by_rows).frames.get(1).map(|f| (f.x0, f.y0)), Some((220.0, 0.0))); } +/// A gutter between rows of its own: the rows are that far apart, and the columns keep the gutter. +#[test] +fn rows_can_have_a_gutter_of_their_own() { + let mut t = area(©.repeat(8), style(12.0), Rect::new(0.0, 0.0, 420.0, 300.0), Justify::Left); + (t.area.rows, t.area.columns, t.area.gutter, t.area.row_gutter) = (2, 2, 20.0, Some(40.0)); + let cells: Vec<(f64, f64, f64, f64)> = layout(db(), &t).frames.iter().map(|f| (f.x0, f.y0, f.x1, f.y1)).collect(); + // By columns: down the first column, then the second; (300 − 40) / 2 tall, (420 − 20) / 2 wide. + assert_eq!(cells, [(0.0, 0.0, 200.0, 130.0), (0.0, 170.0, 200.0, 300.0), (220.0, 0.0, 420.0, 130.0), (220.0, 170.0, 420.0, 300.0)]); + // Without one, the rows take the gutter too. + t.area.row_gutter = None; + assert_eq!(layout(db(), &t).frames.get(1).map(|f| f.y0), Some(160.0)); +} + // ---------- area flow ---------- #[test] diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 772833f4b..089ba61ea 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -18,7 +18,17 @@ pub(super) const SPEC: DialogSpec = DialogSpec { let rank = |k: &str| order.iter().position(|o| *o == k).unwrap_or(order.len()); // Area Type Options › Text Flow is a pair of radio buttons after the other options. let flow = if command == "text.areaOptions" { d.fields.remove("flow") } else { None }; + // Area Type Options: a Row Gutter still as it opened, on rows without a gutter of their own, + // follows the Gutter as it is changed. + let linked = d.fields.get("__rowGutterLinked").is_some() && d.fields.get("__rowGutterLinked") == d.fields.get("rowGutter"); form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &rank, app.session.general_unit()); + if linked + && d.fields.get("rowGutter") == d.fields.get("__rowGutterLinked") + && let Some(g) = d.fields.get("gutter").cloned() + { + d.fields.insert("rowGutter".into(), g.clone()); + d.fields.insert("__rowGutterLinked".into(), g); + } if let Some(v) = flow { let mut rows = v.as_str() == Some("rows"); ui.add_space(4.0); @@ -62,6 +72,13 @@ fn call(d: &Dialog) -> (String, Value) { if let (Some(o), Some(Value::Object(fixed))) = (params.as_object_mut(), d.fields.get("__params")) { o.extend(fixed.clone()); } + // Area Type Options: a Row Gutter left as it opened, on rows without a gutter of their own, + // isn't sent, so the rows take the Gutter. + if let (Some(o), Some(linked)) = (params.as_object_mut(), d.fields.get("__rowGutterLinked")) + && d.fields.get("rowGutter") == Some(linked) + { + o.remove("rowGutter"); + } (d.str("__command"), params) } @@ -78,7 +95,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "shape.flare" => &["diameter", "pathLength"], "perspective.grid.set" => &["cell", "distance"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], - "text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"], + "text.areaOptions" => &["width", "height", "gutter", "rowGutter", "inset", "firstBaselineMin"], "type.pathOptions" => &["spacing"], "object.pathfinder.trap" => &["thickness"], _ => &[], @@ -199,6 +216,48 @@ mod tests { assert_eq!(t.area.vertical_align, VerticalAlign::Center); } + /// Area Type Options shows the gutter between rows beside the gutter, and OK sets a gutter + /// between rows of its own. + #[test] + fn area_type_options_dialog_sets_the_gutter_between_rows() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 20, "text": "Hello", "area": {"width": 200, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + crate::menus::invoke(&mut app, "text.areaOptions", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("rowGutter"), Some(&json!(18.0))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + app.ui.dialog.as_mut().unwrap().fields.insert("rowGutter".into(), json!(30.0)); + crate::dialogs::confirm(&mut app).unwrap(); + let area = |app: &VectorcraftApp| { + let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") }; + (t.area.gutter, t.area.row_gutter) + }; + assert_eq!(area(&app), (18.0, Some(30.0))); + // Rows with a gutter of their own keep it when only the Gutter changes. + crate::menus::invoke(&mut app, "text.areaOptions", json!({})); + app.ui.dialog.as_mut().unwrap().fields.insert("gutter".into(), json!(12.0)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(area(&app), (12.0, Some(30.0))); + // Without one, a changed Gutter is the rows' too. + app.run("text.areaOptions", json!({"gutter": 18})).unwrap(); + crate::menus::invoke(&mut app, "text.areaOptions", json!({})); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + app.ui.dialog.as_mut().unwrap().fields.insert("gutter".into(), json!(10.0)); + // The Row Gutter shown follows it. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("rowGutter"), Some(&json!(10.0))); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(area(&app), (10.0, None)); + } + /// Area Type Options › Text Flow (#4978): two radio buttons, By Columns as new area type has /// it, and OK fills the cells by rows when By Rows is picked. #[test] diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 07f601e5d..2f47b3146 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -208,6 +208,13 @@ fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { Ok(mut fields) => { if let Some(o) = fields.as_object_mut() { o.retain(|k, _| !hidden.contains(&k.as_str())); + // Area type whose rows have no gutter of their own: its Row Gutter follows the + // Gutter unless it is changed (see `dialogs::command`). + if id == "text.areaOptions" + && let Some(g) = o.get("rowGutter").filter(|g| Some(*g) == o.get("gutter")).cloned() + { + o.insert("__rowGutterLinked".into(), g); + } } let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4eedad3dc..bffad7f78 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -99,6 +99,7 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or a graphic after the text object of a legacy wraparound group (`*w` … `*W`), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), guides, and every artboard where it is. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with an Outer Glow or Drop Shadow comes in as itself with them; one with more, other effects, a brush or dashes aligned to corners comes in as its drawn look (a group named after it). Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Transform (with copies), Drop Shadow, Outer Glow, Feather, Gaussian Blur and Bas Relief (its settings at their defaults), and a style with others keeps the type's drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); the page's drawing of it (often in pieces) is left out. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols, pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.