diff --git a/crates/eps/src/import/ai/lex.rs b/crates/eps/src/import/ai/lex.rs index 071176bb4..c7904948b 100644 --- a/crates/eps/src/import/ai/lex.rs +++ b/crates/eps/src/import/ai/lex.rs @@ -92,6 +92,11 @@ impl<'a> Lexer<'a> { self.pos } + /// Look for the next token at `pos` again (the end of the data at most). + pub fn seek(&mut self, pos: usize) { + self.pos = pos.min(self.src.len()); + } + /// The data from `from` to `to` (empty where that isn't in it). pub fn slice(&self, from: usize, to: usize) -> &'a [u8] { self.src.get(from..to).unwrap_or_default() diff --git a/crates/eps/src/import/ai/palette.rs b/crates/eps/src/import/ai/palette.rs index bf659cfe9..43955d5fc 100644 --- a/crates/eps/src/import/ai/palette.rs +++ b/crates/eps/src/import/ai/palette.rs @@ -69,19 +69,25 @@ pub(super) fn palette(data: &[u8]) -> Palette { let mut cell: Option = None; // Inside a gradient's `Bb` … `BB`. let mut in_gradient = false; - // Inside versioned content's alternate, which repeats what came before it. - let mut alternate = false; + // How deep in versioned content, and the depth of the block whose later alternatives (which + // repeat its first) are being skipped, nested blocks in them included. + let (mut depth, mut skipping) = (0usize, None::); while let Some(t) = lex.next_token() { if let Tok::Comment(c) = t.tok { let line = String::from_utf8_lossy(c); - if line.contains("_Alternate_Content") { - alternate = true; - } else if line.contains("_End_Versioned_Content") || line.contains("_Begin_Content_if_version") { - alternate = false; + if line.contains("_Begin_Content_if_version") { + depth = depth.saturating_add(1); + } else if line.contains("_Alternate_Content") { + skipping = skipping.or(Some(depth)); + } else if line.contains("_End_Versioned_Content") { + if skipping == Some(depth) { + skipping = None; + } + depth = depth.saturating_sub(1); } continue; } - if alternate { + if skipping.is_some() { continue; } let w = match t.tok { @@ -211,6 +217,19 @@ mod tests { assert_eq!(p.swatches.len(), 1); } + /// Three alternatives, and versioned content nested in a later one: still one swatch. + #[test] + fn versioned_swatches_with_more_alternatives_are_read_once() { + let p = palette( + b"0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\ + %AI17_Alternate_Content_if_version_gt:24 10\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\ + %AI17_Begin_Content_if_version_gt:23 1\n0 0 0 1 k\n(Black)\nPc\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\ + 0 0 1 0 k\n(Yellow)\nPc\n\ + %AI17_Alternate_Content\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n%AI17_End_Versioned_Content\n0 0 0 1 k\n(After)\nPc\nPB\n", + ); + assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::>(), ["Red ink", "After"]); + } + #[test] fn what_it_doesnt_know_leaves_the_other_swatches() { let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n"); diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index bba345254..50b4211b9 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -79,7 +79,7 @@ pub(crate) use document::{slot_frame, slot_of}; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster}; #[cfg(test)] -pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD}; +pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED}; pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 47eef88dd..d2a4b6cc5 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -1,5 +1,5 @@ use super::art::{four, sane, two}; -use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED}; +use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED}; use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker}; impl<'a> Reader<'a> { @@ -69,6 +69,18 @@ impl<'a> Reader<'a> { if m == "BeginEncoding" { self.type_state.encoding_comment(line); } + // Versioned content holds alternatives of the same art, the newest first: the first is + // read, and the next one (`%AI17_Alternate_Content`, or `…_if_version_gt:` for a version + // in between) starts what is skipped, up to the end of the block, nested blocks included. + if m.starts_with("Alternate_Content") { + let at = self.lex.offset(); + if self.skip_to("End_Versioned_Content").is_err() { + // A block that doesn't end: the rest of the file is read rather than lost. + self.lex.seek(at); + self.warn(VERSIONED_UNENDED); + } + return Ok(()); + } if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) { return self.skip_to(end); } diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index 309061f3c..e4a404697 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -244,9 +244,12 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[ ("BeginSymbolList", "EndSymbolList"), ("BeginTextDocument", "EndTextDocument"), ("BeginEncoding", "EndEncoding"), - ("Alternate_Content", "End_Versioned_Content"), ]; +/// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every +/// alternative after the first is read too, so some art may come in twice. +pub const VERSIONED_UNENDED: &str = "versioned content that doesn't end was read whole: some art may come in twice"; + /// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor /// shows but doesn't edit) whose PDF can't be read. pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out"; diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 721365c25..5077a30f2 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -953,6 +953,66 @@ fn sections_it_skips() { assert_eq!(art(&doc).len(), 1); } +/// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart. +fn alt_square(i: u32) -> String { + let x = f64::from(i) * 10.0; + rect_path(x, 0.0, x + 5.0, 5.0) + "f\n" +} + +/// The left edges of the layer's art, in order: which [`alt_square`]s came in. +fn squares(doc: &Document) -> Vec { + art(doc).iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect() +} + +/// Versioned content holds alternatives of the same art for newer and older versions (newer +/// files write a Dimension tool group, then the same group again for a version in between, then +/// plain art): only the first alternative is read, so the art comes in once, however many +/// alternatives follow and whatever versioned content is nested in them. +#[test] +fn versioned_content_reads_one_alternative() { + let (begin, between, alternate, end) = ( + "%AI17_Begin_Content_if_version_gt:24 11\n", + "%AI17_Alternate_Content_if_version_gt:24 10\n", + "%AI17_Alternate_Content\n", + "%AI17_End_Versioned_Content\n", + ); + let (a, b, c) = (alt_square(1), alt_square(2), alt_square(3)); + let cases = [ + ("one alternative", format!("{begin}{a}{end}"), vec![10.0]), + ("two alternatives", format!("{begin}{a}{alternate}{b}{end}"), vec![10.0]), + ("three alternatives", format!("{begin}{a}{between}{b}{alternate}{c}{end}"), vec![10.0]), + // Blocks nested in the first alternative read their own first; those in a later one aren't read. + ( + "nested", + format!( + "{begin}{a}{begin}{b}{alternate}{c}{end}{between}{}{begin}{}{alternate}{}{end}{alternate}{}{end}", + alt_square(4), + alt_square(5), + alt_square(6), + alt_square(7) + ), + vec![10.0, 20.0], + ), + // Art after the block is read as usual. + ("art after it", format!("{begin}{a}{between}{b}{alternate}{c}{end}{}", alt_square(8)), vec![10.0, 80.0]), + ]; + for (case, body, want) in cases { + let s = read_ok(&layer(&body)); + assert_eq!(squares(&s.doc), want, "{case}"); + assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{case}: {:?}", s.warnings); + } +} + +/// Versioned content whose end is missing is read whole, with a note, rather than losing the rest +/// of the file. +#[test] +fn versioned_content_that_does_not_end_is_read_with_a_note() { + let body = format!("%AI17_Begin_Content_if_version_gt:24 11\n{}%AI17_Alternate_Content_if_version_gt:24 10\n{}", alt_square(1), alt_square(2)); + let s = read_ok(&layer(&body)); + assert_eq!(squares(&s.doc), [10.0, 20.0]); + assert!(s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings); +} + /// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after /// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden /// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 24a3a2736..7c90b15aa 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -24,7 +24,7 @@ use std::collections::BTreeMap; use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; /// Longest story read (bytes). @@ -618,7 +618,7 @@ impl Story { let r = match self.frames.first()? { FrameKind::Area(s) => bounds(s)?, FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5), - FrameKind::Point(_) => { + FrameKind::Point(m) => { let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64; let reach = longest * size + size; let points: Vec = self.line_starts(template).into_iter().map(|(_, (x, y))| to_doc * Point::new(x, y)).collect(); @@ -626,7 +626,19 @@ impl Story { let (x, y) = self.place(template); to_doc * Point::new(x, y) }); - points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)).inflate(reach, size * 1.5) + // Along the baseline as far as a line reaches, across it a line and a half: turned + // with the type (a dimension's label turned a quarter runs down the page). + // (The canvas is y down, the art y up: see [`to_art`].) + 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); + 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)] { + r = r.union_pt(*p + along * t + across * (n * size)); + } + } + r } }; r.is_finite().then_some(r) @@ -790,6 +802,9 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { space_before: p.space_before * scale, space_after: p.space_after * scale, hyphenate: p.hyphenate, + // A right-to-left paragraph lays its text out from the right whatever letters it has + // (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter. + direction: p.rtl.then_some(ParaDirection::RightToLeft), ..ParaStyle::default() } } @@ -838,6 +853,37 @@ mod tests { assert_eq!(justify(b"/0 1 /33 0"), Justify::Right); } + /// Where point type lies runs along its baseline: a label turned a quarter (as a vertical + /// dimension's is) lies in a tall box, not a wide one. + #[test] + fn point_type_lies_along_its_baseline() { + let para = + Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] } + .para(None); + let mut s = story(" 80 mm ", &[0.0], ¶); + let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap(); + let (w, h) = size(&s); + assert!(w > 3.0 * h, "level: {w} × {h}"); + // Turned −90° on the canvas (y down): the baseline runs down the page. + 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}"); + } + + /// A right-to-left paragraph is laid out right to left even when it has no right-to-left + /// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one + /// that doesn't say, goes by its letters. + #[test] + fn right_to_left_paragraphs_set_their_direction() { + let texts = + Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] }; + let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction; + assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults"); + assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own"); + let plain = Texts { para_defaults: Val::Dict(BTreeMap::new()), ..texts }; + assert_eq!(para_style(&plain.para(None), 1.0).direction, None); + } + #[test] fn values_read_nested_dictionaries_lists_and_utf16_strings() { let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap(); diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index a89ad0dd9..cc2772f8e 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -1,5 +1,5 @@ use super::layers::closed_where_ends_meet; -use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type}; +use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type, words}; use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind}; /// Match page objects to the nearest slot whose story describes them, including hidden slots. @@ -103,28 +103,28 @@ pub(super) fn plan( // with its words? (A copied object's lines are where its original's are, so they don't say.) let hidden_type = |object: &[Arc]| { let Some(r) = extent(object) else { return false }; - let words: Vec = - object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect(); + let texts: Vec> = + object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text())) } else { None }).collect(); hidden_at.iter().any(|(k, place)| { place.intersect(r).area() >= 0.5 * r.area().max(1e-9) - && lines - .get(*k) - .and_then(Option::as_ref) - .is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + && lines.get(*k).and_then(Option::as_ref).is_some_and(|l| { + !texts.is_empty() + && texts.iter().all(|ws| !ws.is_empty() && ws.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str())))) + }) }) }; // The letters of each slot's story, in lower case. let spelled: Vec = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect(); - // For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in - // its story? All are false unless such slots have the letters of every text between them. + // For each slot: is its made type where `object` is, with the words of one of `object`'s texts in + // its story? All are false unless such slots have the words of every text between them. let spelling = |object: &[Arc], content: &[Content]| -> Vec { let r = extent(object); - let words: Vec = object + let texts: Vec> = object .iter() - .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None }) - .filter(|w| !w.is_empty()) + .filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_ascii_lowercase())) } else { None }) + .filter(|ws| !ws.is_empty()) .collect(); - let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str()))); + let has = |k: usize| spelled.get(k).is_some_and(|s| texts.is_empty() || texts.iter().any(|ws| ws.iter().all(|w| s.contains(w.as_str())))); let near: Vec = content .iter() .enumerate() @@ -133,7 +133,7 @@ pub(super) fn plan( _ => false, }) .collect(); - let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); + let every = texts.iter().flatten().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str()))); if every { near } else { vec![] } }; let mut spare: Vec>> = vec![]; @@ -150,7 +150,7 @@ pub(super) fn plan( Some(c) => *c = Content::Page(object.clone()), None => {} }, - // What lies where made type is and has that type's letters is that type as the page draws it. + // What lies where made type is and has that type's words is that type as the page draws it. None if covered(object, &content, spells) => { let r = extent(object).unwrap_or(Rect::ZERO); for (k, c) in content.iter_mut().enumerate() { diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index 524fbcc81..1548a2e9a 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -323,6 +323,13 @@ pub(super) fn letters(s: &str) -> String { s.chars().filter(char::is_ascii_alphanumeric).collect() } +/// The [`letters`] of each word of `s`, the empty ones left out. Type is compared word by word: +/// the page draws a right-to-left paragraph's words in the order they show (" 120.5 mm " as +/// "mm 120.5"), its text object keeps them in the order they are typed. +pub(super) fn words(s: &str) -> Vec { + s.split_whitespace().map(letters).filter(|w| !w.is_empty()).collect() +} + /// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it /// (the page splits a line where its kerning or its styles change), on that line's baseline and /// after its start? How far from where the line starts it is, if so. diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7d804142c..c0fa58c22 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -779,6 +779,25 @@ fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() { } } +/// A right-to-left paragraph's page type has its words in the order they show, not the order they +/// are typed (a dimension's label " 120.5 mm " is drawn "mm 120.5"): it is still that text +/// object's type as the page draws it, not a second copy of it with the nearest text object. +#[test] +fn type_of_the_page_with_the_words_of_made_type_in_another_order_is_that_type() { + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let art = native_with_text(&layer("Art", true, &text_object(0)), &doc); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (there Hi) show"; + let r = import(&eps(page, &art)).unwrap(); + let mut all = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + all.push(t.plain_text()); + } + }); + assert_eq!(all, ["Hi there"], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings); +} + /// Page type of no text object that lies where the made type of two text objects is, with the /// letters of one of them in each of its pieces, is the type of both as the page draws it. #[test]