diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 7cb534e82..d7debb5b2 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2787,6 +2787,48 @@ fn frames_document_text() -> String { ) } +/// The text document of vertical type (key 1 of what each frame carries at 2) as an editing copy +/// keeps it: point type in two columns with a Tate-chu-yoko run (key 35 at 3), area type and type +/// on a path. +fn vertical_document_text() -> String { + let area = "<< /0 << /0 [ 0 0 ] /1 << /0 [ 8151.5 8171.5 8151.5 8171.5 8201.5 8171.5 8201.5 8171.5 8201.5 8171.5 8201.5 8171.5 8201.5 8221.5 8201.5 8221.5 8201.5 8221.5 8201.5 8221.5 8151.5 8221.5 8151.5 8221.5 8151.5 8221.5 8151.5 8221.5 8151.5 8171.5 8151.5 8171.5 ] >> /2 << /0 1 /1 2 >> >> >>"; + let path = "<< /0 << /0 [ 0 0 ] /1 << /0 [ 8151.5 8171.5 8151.5 8171.5 8191.5 8161.5 8191.5 8171.5 8191.5 8171.5 8211.5 8181.5 8231.5 8171.5 8231.5 8171.5 ] >> /2 << /0 2 /1 2 /6 [ 0.5 2.0 ] >> >> >>"; + let point = "<< /0 << /0 [ 0 0 ] /2 << /1 2 /2 [ 1 0 0 1 3 4 ] >> >> >>"; + let run = |n: usize, own: &str| format!("<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 12.0 {own}>> >> >> /1 {n} >>"); + let story = |text: &str, frame: usize, runs: &str, layout: &str| { + format!("<< /0 << /0 ({text}) /6 << /0 [ {runs} ] >> >> /1 << /0 [ << /0 {frame} >> ] {layout}>> >>") + }; + let columns = "/2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8200.0 8180.0 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ \ + << /99 /L /6 [ << /99 /S /0 << /0 [ 0.0 0.0 ] >> >> ] >> << /99 /L /0 << /0 [ -6.0 14.4 ] >> /6 [ << /99 /S /0 << /0 [ 0.0 0.0 ] >> >> ] >> \ + ] >> ] >> ] >> ] >> ] "; + format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {point} {area} {path} ] >> >>\n\ + /1 << /1 [ {} {} {} ] /2 << /1 12.0 /35 1 /47 0 /48 0 >> >>\n", + story("Day 12\\rnext\\r", 0, &format!("{} {} {}", run(4, ""), run(2, "/35 3 /47 -2 /48 3 "), run(6, "/35 2 ")), columns), + story("Vertical area type wraps\\r", 1, &run(25, ""), ""), + story("On a path\\r", 2, &run(10, "/35 3 "), ""), + ) +} + +/// The text objects of [`vertical_document_text`]: one per story. +const VERTICAL_OBJECTS: &str = "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n1 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n2 /StoryIndex ,\n;\n"; + +/// The fuzzed vertical fixture, as it is, reads as vertical point, area and path type, with the +/// Tate-chu-yoko run marked. +#[test] +fn the_vertical_fixture_reads_as_vertical_type() { + let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&vertical_document_text(), VERTICAL_OBJECTS)).unwrap(); + let mut found = vec![]; + l.doc.walk(|n| { + if let vectorcraft_doc::NodeKind::Text(t) = &n.kind { + let tcy: Vec<&str> = t.runs.iter().filter(|r| r.style.tate_chu_yoko).map(|r| r.text.as_str()).collect(); + found.push((t.plain_text(), t.vertical, tcy.join("|"))); + } + }); + let want = [("Day 12\nnext", true, "12"), ("Vertical area type wraps", true, ""), ("On a path", true, "On a path")]; + assert_eq!(found, want.map(|(a, b, c)| (a.to_string(), b, c.to_string())), "{:?}", l.warnings); +} + /// The text objects of [`frames_document_text`]: story 0 in its two frames, story 1 in its one (a /// text object names its frame among its story's). const FRAME_OBJECTS: &str = "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n1 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n1 /StoryIndex ,\n;\n"; @@ -3027,6 +3069,15 @@ proptest! { survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// The same for vertical type and Tate-chu-yoko runs: orientations and baseline directions of + /// any value, type or size. + #[test] + fn eps_vertical_text_never_panics(cut in 0usize..2_000, edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1', '2', '3'])), 0..12)) { + let text = mutate_text(&vertical_document_text(), cut, &edits); + let bytes = text_eps_of(&text, VERTICAL_OBJECTS); + survive("mutated EPS vertical text", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?; + } + /// 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 1a5c17413..cb4c2594b 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -362,6 +362,9 @@ pub(super) struct Story { runs: Vec<(usize, Style)>, /// Type on a path: the spacing its first frame's layout implies ([`path_spacing`]). path_spacing: f64, + /// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to + /// left. Its layout's lines run down from the anchor, each column further left. + vertical: bool, } #[derive(Clone, Debug)] @@ -379,6 +382,8 @@ struct Style { stroke_width: f64, /// Key 12, the case: 1 Small Caps, 2 All Caps. caps: u8, + /// Key 35 at 3: Tate-chu-yoko, the characters set across the column as one block. + tate_chu_yoko: bool, } /// The text document of an editing copy. @@ -486,25 +491,35 @@ impl Texts { } } + /// Is frame `i` vertical type? Key 1 of what it carries: 2 vertical, 0 or none horizontal. Any + /// other value is ignored: horizontal. + fn frame_vertical(&self, i: usize) -> bool { + let orientation = self.frames.get(i).and_then(|f| f.get("0")).and_then(|f| f.get("2")).and_then(|c| c.get("1")); + orientation.and_then(Val::num) == Some(2.0) + } + /// Story `i`, if its frames are of kinds this reads. pub(super) fn story(&self, i: usize) -> Option { let s = self.stories.get(i)?; let text = s.get("0")?.get("0")?.str().filter(|t| t.len() <= MAX_STORY)?.to_string(); let view = s.get("1")?; - let frames: Vec = view - .get("0")? - .list() - .iter() - .take(MAX_FRAMES) - .map(|f| f.get("0").and_then(Val::num).and_then(|n| self.frame(n as usize))) - .collect::>()?; + let indices: Vec = + view.get("0")?.list().iter().take(MAX_FRAMES).map(|f| f.get("0").and_then(Val::num).map(|n| n as usize)).collect::>()?; + let frames: Vec = indices.iter().map(|n| self.frame(*n)).collect::>()?; if frames.is_empty() { return None; } + // Threaded frames share one orientation: frames that differ aren't read. + let vertical = indices.first().is_some_and(|n| self.frame_vertical(*n)); + if indices.iter().any(|n| self.frame_vertical(*n) != vertical) { + return None; + } // The first frame's layout, when the file keeps one: where its lines start. let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0); if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) { - if matches!(frames.first(), Some(FrameKind::OnPath { .. })) { + // Vertical type on a path turns its glyphs across the path: their matrices don't give + // the spacing. + if matches!(frames.first(), Some(FrameKind::OnPath { .. })) && !vertical { path_spacing = self::path_spacing(f); } if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums) @@ -537,7 +552,7 @@ impl Texts { if runs.is_empty() { runs.push((text.chars().count(), self.style(None))); } - Some(Story { text, anchor, lines, frames, paras, runs, path_spacing }) + Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical }) } /// A paragraph's attributes: its own keys, else the document's. @@ -585,6 +600,7 @@ impl Texts { stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None }, stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0), caps: num("12").filter(|v| *v == 1.0 || *v == 2.0).map_or(0, |v| v as u8), + tate_chu_yoko: num("35") == Some(3.0), } } } @@ -752,7 +768,8 @@ impl Story { .iter() .enumerate() .map(|(i, (lx, ly))| { - let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly); + // A vertical layout's lines run down the canvas, and each next one is further left. + let (x, y) = if self.vertical { (self.anchor.0 - ly, self.anchor.1 + lx) } else { (self.anchor.0 + lx, self.anchor.1 + ly) }; (text.get(i).copied().unwrap_or_default().to_string(), to_art((a * x + c * y + e, b * x + d * y + f), template)) }) .collect() @@ -780,6 +797,8 @@ impl Story { let along = to_doc * Point::new(m[0], -m[1]) - to_doc * Point::ZERO; let along = if along.hypot() > 1e-9 && along.is_finite() { along / along.hypot() } else { kurbo::Vec2::new(1.0, 0.0) }; let across = kurbo::Vec2::new(-along.y, along.x); + // Vertical type: its lines run down the page, a quarter turn from the baseline. + let (along, across) = if self.vertical { (across, along) } else { (along, across) }; let mut r = Rect::from_points(first, first); for p in points.iter().chain([&first]) { for (t, n) in [(-reach, -1.5), (-reach, 1.5), (reach, -1.5), (reach, 1.5)] { @@ -873,6 +892,7 @@ impl Story { if let Some(FrameKind::Area(_, options)) = self.frames.get(frame) { options.apply(&mut t.area, scale); } + t.vertical = self.vertical; t.kind = kind; t.xf = xf; Some(Node::new(id, NodeKind::Text(Box::new(t)))) @@ -903,13 +923,18 @@ impl Story { return false; } let Some((_, (x, y))) = self.line_starts(template).into_iter().next() else { return false }; - let want = (to_doc * Point::new(x, y)).y; + let at = to_doc * Point::new(x, y); let db = vectorcraft_text::FontDb::global(); // A frame too short for the line lays nothing out: as the line needs it (see `fit_first_line`). let mut probe = TextObject::clone(t); vectorcraft_text::fit_first_line(db, &mut probe); let laid = vectorcraft_text::layout(db, &probe); let Some(first) = laid.lines.first().filter(|l| l.region == 0) else { return false }; + // Where the first baseline goes in the layout's line space. Vertical type's line space is a + // quarter turn from the document (a line's height is how far left of the frame's right edge + // its column is), and the file's line is the column's centre line, the middle of the line + // (as `TextLayout::baselines` has it): the baseline is that far from it. + let want = if t.vertical { -at.x - (first.descent - first.ascent) / 2.0 } else { at.y }; let min = first.first_baseline + (want - first.baseline); if (want - first.baseline).abs() <= SAME || !min.is_finite() || min <= 0.0 { return false; @@ -1025,6 +1050,7 @@ fn char_style(s: &Style, scale: f64, leading: Option) -> CharStyle { all_caps: s.caps == 2, // At the default Small Caps size of Document Setup: the file's own isn't read. small_caps: (s.caps == 1).then(|| vectorcraft_doc::DocSetup::default().small_caps_size), + tate_chu_yoko: s.tate_chu_yoko, ..CharStyle::default() } } @@ -1081,6 +1107,66 @@ mod tests { s.frames = vec![FrameKind::Point([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])]; let (w, h) = size(&s); assert!(h > 3.0 * w, "turned: {w} × {h}"); + // Vertical type lies down the page without a turn. + s.frames = vec![FrameKind::Point([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; + s.vertical = true; + let (w, h) = size(&s); + assert!(h > 3.0 * w, "vertical: {w} × {h}"); + } + + /// A vertical layout's lines run down the canvas from the anchor, each next one further left: + /// the second column starts one line pitch left of the first, and a centred column (its start + /// before the anchor) starts higher up. + #[test] + fn vertical_lines_start_down_the_canvas_each_further_left() { + let mut s = story("ab\rc\rde\r", &[0.0, 14.4, 28.8], &Para::default()); + s.vertical = true; + s.lines[2].0 = -12.0; + s.anchor = (CANVAS_CENTRE + 100.0, CANVAS_CENTRE - 50.0); + let starts: Vec<(f64, f64)> = s.line_starts((0.0, 0.0)).into_iter().map(|(_, p)| p).collect(); + let [a, b, c] = starts.as_slice() else { panic!("{starts:?}") }; + assert!((a.0 - 100.0).abs() < 1e-9 && (a.1 - 50.0).abs() < 1e-9, "the first column at the anchor: {a:?}"); + assert!((b.0 - 85.6).abs() < 1e-9 && (b.1 - 50.0).abs() < 1e-9, "the next one to the left: {b:?}"); + assert!((c.0 - 71.2).abs() < 1e-9 && (c.1 - 62.0).abs() < 1e-9, "a centred one higher (the art is y up): {c:?}"); + } + + /// Key 1 of what a frame carries is its orientation: 2 vertical; 0, none or any other value + /// horizontal. + #[test] + fn a_frame_is_vertical_only_at_orientation_two() { + let frame = |carries: &str| read(format!("/0 << /2 << /0 0 {carries} >> >>").as_bytes()).unwrap(); + let cases = ["/1 2", "/1 0", "", "/1 1", "/1 2.5", "/1 -2", "/1 (2)", "/1 1e300", "/1 true", "/1 [ 2 ]"]; + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![], + frames: cases.iter().map(|c| frame(c)).collect(), + }; + let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); + assert_eq!(vertical, [true, false, false, false, false, false, false, false, false, false, false]); + } + + /// Key 35 of a character style at 3 is Tate-chu-yoko; its other values (1 and 2, how half-width + /// characters stand in a column) and values that aren't that number aren't. + #[test] + fn baseline_direction_three_is_tate_chu_yoko() { + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: read(b"/35 1").unwrap(), + para_defaults: Val::Dict(BTreeMap::new()), + char_sheets: vec![read(b"/0 (Block) /6 << /35 3 >>").unwrap()], + frames: vec![], + }; + let tcy = |run: &[u8]| texts.style(Some(&read(run).unwrap())).tate_chu_yoko; + assert!(tcy(b"/6 << /35 3 >>"), "the run's own"); + assert!(tcy(b"/5 0 /6 << >>"), "its character style's"); + assert!(!tcy(b"/5 0 /6 << /35 1 >>"), "the run's own wins"); + for other in [&b"/6 << >>"[..], b"/6 << /35 2 >>", b"/6 << /35 3.5 >>", b"/6 << /35 (3) >>", b"/6 << /35 -3 >>", b"/6 << /35 1e308 >>"] { + assert!(!tcy(other), "{}", String::from_utf8_lossy(other)); + } } /// A right-to-left paragraph is laid out right to left even when it has no right-to-left @@ -1227,9 +1313,11 @@ mod tests { stroke: None, stroke_width: 1.0, caps: 0, + tate_chu_yoko: false, }, )], path_spacing: 0.0, + vertical: false, } } @@ -1556,4 +1644,52 @@ mod tests { 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); } + + /// Vertical area type: the file's first line is its first column's centre line, so far left of + /// the frame's right edge; the column goes there. + #[test] + fn vertical_area_type_s_first_column_goes_where_the_file_lays_it_out() { + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + let frame = 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)), + ]; + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + // Whether the first line was placed, the first column's centre line (its x on the document) + // and the first baseline setting. + let first = |s: &Story, place: bool| { + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = place && s.place_first_line(&mut node, 0, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + assert!(t.vertical); + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + (placed, l.baselines().first().map(|b| b.0.x), t.area.first_baseline) + }; + let mut s = story("§§\r", &[], &Para::default()); + s.frames = vec![FrameKind::Area(frame, AreaFrame::default())]; + s.vertical = true; + s.anchor = (210.0, 20.0); + s.lines = vec![(0.0, 0.0)]; + let (_, Some(own), _) = first(&s, false) else { panic!("a column") }; + assert!(own < 210.0 && own > 190.0, "near the right edge: {own}"); + // The file's column where this lays it out on its own (so far left of the anchor, the + // frame's top right): nothing changes. + s.lines = vec![(0.0, 210.0 - own)]; + assert_eq!(first(&s, true), (false, Some(own), vectorcraft_doc::FirstBaseline::Ascent)); + // Further right in the file: there. + s.lines = vec![(0.0, 210.0 - own - 3.0)]; + let (placed, at, fb) = first(&s, true); + assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}"); + assert!(at.is_some_and(|x| (x - (own + 3.0)).abs() < 1e-6), "{at:?} for {}", own + 3.0); + // Scaled characters (a column of another width) go there too. + s.runs[0].1.v_scale = 2.0; + s.runs[0].1.h_scale = 2.0; + s.lines = vec![(0.0, 0.0)]; + let (_, Some(own), _) = first(&s, false) else { panic!("a column") }; + s.lines = vec![(0.0, 210.0 - own - 3.0)]; + let (placed, at, _) = first(&s, true); + assert!(placed && at.is_some_and(|x| (x - (own + 3.0)).abs() < 1e-6), "{at:?} for {}", own + 3.0); + } } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 2d6cc6a35..23ef7d822 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1697,6 +1697,149 @@ fn type_from_the_text_document_keeps_its_case_setting() { assert_eq!(case(2), [(false, None)]); } +/// `doc` (from [`text_document`]) with its frames vertical (key 1 of what a frame carries at 2) and +/// its first story's layout given a second line one pitch on, `line` from its start. +fn vertical_text_document(doc: &str, line: f64) -> String { + doc.replace("/2 << /2 [", "/2 << /1 2 /2 [").replacen( + "] >> ] >> ] >> ] >> ] >>", + &format!("] >> << /99 /L /0 << /0 [ {line} 16.8 ] >> /6 [ << /99 /S /0 << /0 [ 0.0 0.0 ] >> >> ] >> ] >> ] >> ] >> ] >>"), + 1, + ) +} + +/// `doc` with the text of its stories `from` set to `to` (a string of the text document: `\247` is +/// `§`, which stands upright in a column in the bundled fonts). +fn with_text(doc: &str, from: &str, to: &str) -> String { + let (from, to) = (format!("({from})"), format!("({to})")); + assert!(doc.contains(&from)); + doc.replace(&from, &to) +} + +/// The centres of `t`'s glyphs that draw, as it is laid out. +fn glyph_centres(t: &vectorcraft_doc::TextObject) -> Vec { + use kurbo::Shape; + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + l.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| g.outline.bounding_box().center()).collect() +} + +#[test] +fn vertical_point_type_reads_as_columns_from_right_to_left() { + let doc = vertical_text_document(&text_document(&[("SS\rS\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]), 0.0); + let doc = with_text(&doc, "SS\rS\r", "\\247\\247\r\\247\r"); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + assert!(t.vertical, "vertical type"); + assert_eq!(t.plain_text(), "§§\n§"); + // The anchor is the top of the first column, where horizontal type would start its line. + let [.., e, f] = t.xf.as_coeffs(); + assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", t.xf); + let c = glyph_centres(t); + assert_eq!(c.len(), 3); + assert!(c[1].y > c[0].y + 5.0 && (c[0].x - c[1].x).abs() < 0.5, "down the first column: {c:?}"); + assert!(c[2].x < c[0].x - 5.0, "the second column is left of the first: {c:?}"); +} + +#[test] +fn vertical_area_type_keeps_its_orientation() { + let doc = vertical_text_document(&text_document(&[("SSS SSS SSS SSS\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]), 0.0); + let doc = with_text(&doc, "SSS SSS SSS SSS\r", &format!("{}\r", ["\\247\\247\\247"; 4].join(" "))) + // An area frame 60 points wide and 35 tall (its top right at the anchor): two of the + // 14-point `§` fit in a column, not three. + .replace("/0 << /0 [ 0 0 ] /2 << /1 2", "/0 << /0 [ 0 0 ] /1 << /0 [ 8140 8180 8140 8180 8200 8180 8200 8180 8200 8180 8200 8180 8200 8215 8200 8215 8200 8215 8200 8215 8140 8215 8140 8215 8140 8215 8140 8215 8140 8180 8140 8180 ] >> /2 << /0 1 /1 2") + // The file's first column 8 points in from the frame's right edge, the next 16.8 further. + .replacen("/99 /L /6 [", "/99 /L /0 << /0 [ 0.0 8.0 ] >> /6 [", 1) + .replacen("/0 << /0 [ 0 16.8 ] >>", "/0 << /0 [ 0 24.8 ] >>", 1); + assert!(doc.contains("[ 0.0 8.0 ]") && doc.contains("[ 0 24.8 ]")); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let t = texts[0]; + assert!(matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }), "{:?}", t.kind); + assert!(t.vertical); + let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { panic!("area") }; + let frame = frame.bounds().unwrap(); + let c = glyph_centres(t); + assert!(!c.is_empty()); + let columns: std::collections::BTreeSet = c.iter().map(|p| (p.x * 2.0).round() as i64).collect(); + assert!(columns.len() >= 2, "the text wraps into more than one column: {c:?}"); + assert!(c.first().unwrap().x > c.last().unwrap().x, "the first column is the rightmost: {c:?}"); + assert!(c.iter().all(|p| p.x > frame.x0 - 1.0 && p.x < frame.x1 + 1.0), "inside the frame {frame:?}: {c:?}"); + // The first column where the file has it: its centre line 8 points left of the right edge. + let first = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).baselines()[0].0.x; + assert!((first - (frame.x1 - 8.0)).abs() < 1e-6, "{first} in {frame:?}"); +} + +#[test] +fn a_tate_chu_yoko_run_is_marked() { + // Three runs over "§12§": the middle one, the two digits, has key 35 at 3. + let doc = vertical_text_document(&text_document(&[("S12S\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]), 0.0); + let doc = with_text(&doc, "S12S\r", "\\24712\\247\r"); + let run = |n: u32, own: &str| { + format!( + "<< /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 {own}/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 {n} >>" + ) + }; + let one = run(5, ""); + assert!(doc.contains(&one)); + let doc = doc.replacen(&one, &format!("{} {} {}", run(1, ""), run(2, "/35 3 "), run(2, "")), 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + let marked: Vec<(String, bool)> = texts[0].runs.iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect(); + assert_eq!(marked, [("§".to_string(), false), ("12".to_string(), true), ("§".to_string(), false)]); +} + +#[test] +fn orientation_and_tate_chu_yoko_values_it_cant_use_are_ignored() { + for bad in ["(2)", "2.5", "1e300", "-2", "1", "true", "[ 2 ]"] { + let doc = vertical_text_document(&text_document(&[("x1\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]), 0.0) + .replace("/2 << /1 2 /2 [", &format!("/2 << /1 {bad} /2 [")) + .replace("/1 14.0 /53", &format!("/1 14.0 /35 {bad} /53")); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{bad}: {:?}", r.warnings); + assert!(!texts[0].vertical, "{bad}"); + assert!(texts[0].runs.iter().all(|r| !r.style.tate_chu_yoko), "{bad}"); + } +} + +#[test] +fn threaded_frames_of_different_orientations_are_not_read() { + // The story flows through two point frames, the second horizontal. + let doc = vertical_text_document(&text_document(&[("xx\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]), 0.0) + .replacen( + "/8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /1 2 /2 [ 1 0 0 1 0 0 ] >> >> >> ]", + "/8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /1 2 /2 [ 1 0 0 1 0 0 ] >> >> >> << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ]", + 1, + ) + .replacen("/1 << /0 [ << /0 0 >> ]", "/1 << /0 [ << /0 0 >> << /0 1 >> ]", 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert!(text_of(&r.document.layers[1]).is_empty(), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings); +} + +#[test] +fn horizontal_type_stays_horizontal() { + let doc = with_text(&text_document(&[("SS\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]), "SS\r", "\\247\\247\r"); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{:?}", r.warnings); + assert!(!texts[0].vertical); + assert_eq!(texts[0].plain_text(), "§§"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 58.5).abs() < 1e-6 && (f - 38.5).abs() < 1e-6, "{:?}", texts[0].xf); + let c = glyph_centres(texts[0]); + assert!(c[1].x > c[0].x + 5.0 && (c[0].y - c[1].y).abs() < 0.5, "along a line: {c:?}"); +} + #[test] fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() { // A story whose frame is of a kind this doesn't know isn't guessed at. diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index 966807bb8..63168025b 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -28,22 +28,30 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout { /// Area type in a frame too short for its first line draws nothing (type that doesn't fit its frame /// isn't drawn). For importers whose source shows that line: the frame is made taller, from its -/// top, by what the first line needs. Whether it was. +/// top, by what the first line needs (vertical type's frame wider, from its right edge, where its +/// first column is). Whether it was. pub fn fit_first_line(db: &FontDb, t: &mut TextObject) -> bool { let TextKind::Area { frame } = &t.kind else { return false }; - let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6) else { return false }; + let across = |b: &Rect| if t.vertical { b.width() } else { b.height() }; + let Some(b) = frame.bounds().filter(|b| across(b) > 1e-6) else { return false }; if !layout(db, t).lines.is_empty() { return false; } - // The height of its first line: the line's as point type. + // The height of its first line (a column's width): the line's as point type, its baseline at + // least Area Type Options › First Baseline's Min from the frame's top. let point = TextObject { kind: TextKind::Point, ..t.clone() }; - let Some(need) = layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false }; + let min = if t.area.first_baseline_min.is_finite() { t.area.first_baseline_min.max(0.0) } else { 0.0 }; + let Some(need) = layout(db, &point).lines.first().map(|l| l.ascent.max(min) + l.descent) else { return false }; // (A little more than it, for the rounding of the layout.) - let by = (need + 0.05) / b.height(); + let by = (need + 0.05) / across(&b); 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 taller = if t.vertical { + frame.transformed(Affine::translate((b.x1, 0.0)) * Affine::scale_non_uniform(by, 1.0) * Affine::translate((-b.x1, 0.0))) + } else { + 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 }); // Something else keeps the line out (a frame narrower than a letter): as the source has it. if layout(db, t).lines.is_empty() { diff --git a/crates/text/src/tests/vertical.rs b/crates/text/src/tests/vertical.rs index 1010ed84a..d2b7f0b4e 100644 --- a/crates/text/src/tests/vertical.rs +++ b/crates/text/src/tests/vertical.rs @@ -117,6 +117,31 @@ fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() { assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame"); } +/// A vertical frame too narrow for its first column is made wider from its right edge, where that +/// column is; its height stays. +#[test] +fn fit_first_line_widens_a_vertical_frame_from_its_right_edge() { + let mut t = vertical("§§"); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(90.0, 0.0, 100.0, 105.0).to_path(0.1)) }; + assert!(layout(FontDb::global(), &t).lines.is_empty(), "a column doesn't fit"); + assert!(fit_first_line(FontDb::global(), &mut t)); + let TextKind::Area { frame } = &t.kind else { panic!("area") }; + let b = frame.bounds().unwrap(); + assert!((b.x1 - 100.0).abs() < 1e-6 && b.x0 < 90.0 && b.x0 > 60.0 && (b.y0, b.y1) == (0.0, 105.0), "{b:?}"); + let l = layout(FontDb::global(), &t); + assert_eq!(l.lines.len(), 1); + assert!(ink(&l, 0).x1 <= 100.0 + 1e-6, "the column at the right edge: {:?}", ink(&l, 0)); + // A fixed first baseline further in than the line's ascent: wide enough for it too. + let mut t = vertical("§§"); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(90.0, 0.0, 100.0, 105.0).to_path(0.1)) }; + t.area.first_baseline = crate::FirstBaseline::Fixed; + t.area.first_baseline_min = 40.0; + assert!(fit_first_line(FontDb::global(), &mut t)); + let TextKind::Area { frame } = &t.kind else { panic!("area") }; + assert!(frame.bounds().unwrap().x0 < 60.0, "{:?}", frame.bounds()); + assert_eq!(layout(FontDb::global(), &t).lines.len(), 1); +} + /// `baselines`: one per line that holds characters, along it for horizontal type and down the /// column's centre line for vertical type (where point type's anchor is); none on a path. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bffad7f78..830555cb7 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. 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. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. 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. 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.