diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 0f97a5862..f1daa7b57 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -1023,6 +1023,46 @@ fn an_empty_page_with_type_as_outlines_outlines_the_editing_datas_type() { assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers"); } +/// A story of a `.ai` file's text document with no character or paragraph runs is set in +/// `[Normal Character Style]` and `[Normal Paragraph Style]`; the base attributes give only what +/// those styles leave out. +#[test] +fn an_ai_story_without_runs_takes_the_normal_styles() { + let story = "<< /0 << /0 (Hi\r) >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8191.5 8191.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \ + << /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 3 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>"; + let char_sheets = "/5 << /0 [ << /0 << /0 ([Normal Character Style]) /6 << /0 0 /1 9.0 \ + /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 1.0 ] >> >> >> >> >> ] >>"; + let para_sheets = "/6 << /0 [ << /0 << /0 ([Normal Paragraph Style]) /5 << /1 6.0 /9 false >> >> >> ] >>"; + let document = format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> {char_sheets} {para_sheets} \ + /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\ + /1 << /1 [ {story} ] /2 << /1 12.0 /53 << /99 /SimplePaint /0 << /0 0 /1 [ 1.0 1.0 ] >> >> >> /3 << /1 2.0 /9 true >> >>\n" + ); + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n2 /TextAntialiasing ,\n;\n"; + let data = 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_NumLayers: 1\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", + ai::layer("Art", text), + lines.join("\n") + ); + let mut s = Session::new(); + let r = open(&mut s, "plain.ai", &ai::ai(&compressed(data.as_bytes()), ""), json!({})).unwrap(); + let mut texts = vec![]; + s.doc().unwrap().doc.walk(|c| { + if let NodeKind::Text(t) = &c.kind { + texts.push((**t).clone()); + } + }); + let [t] = texts.as_slice() else { panic!("one text object: {r}") }; + assert_eq!(t.plain_text(), "Hi"); + let style = &t.runs[0].style; + assert_eq!(style.size, 9.0, "{r}"); + assert_eq!(style.fill, vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + assert_eq!((t.para.first_line_indent, t.para.hyphenate), (6.0, false)); +} + /// Type asked for as outlines in a `.ai` saved without PDF compatibility (its page only a /// placeholder): the editing data's type, outlined, as for an empty page. #[test] diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index cb4c2594b..ac5f38d6b 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -396,6 +396,9 @@ pub(super) struct Texts { /// The document's character styles (`[Normal Character Style]` first), each a name, the /// character style it is based on (key 5, an index into these) and its attributes (key 6). char_sheets: Vec, + /// The document's paragraph styles (`[Normal Paragraph Style]` first), each a name, its + /// attributes (key 5) and the paragraph style it is based on (key 6, an index into these). + para_sheets: Vec, frames: Vec, } @@ -434,21 +437,17 @@ impl Texts { .map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string()) .collect(); let main = doc.get("1")?; + let sheets = |key: &str| -> Vec { + let list = doc.get("0").and_then(|d| d.get(key)).and_then(|s| s.get("0")).map(Val::list).unwrap_or_default(); + list.iter().take(MAX_SHEETS).map(|s| s.get("0").cloned().unwrap_or(Val::Dict(BTreeMap::new()))).collect() + }; Some(Texts { stories: main.get("1")?.list().to_vec(), fonts, defaults: main.get("2")?.clone(), para_defaults: main.get("3").cloned().unwrap_or(Val::Dict(BTreeMap::new())), - char_sheets: doc - .get("0")? - .get("5") - .and_then(|s| s.get("0")) - .map(Val::list) - .unwrap_or_default() - .iter() - .take(MAX_SHEETS) - .map(|s| s.get("0").cloned().unwrap_or(Val::Dict(BTreeMap::new()))) - .collect(), + char_sheets: sheets("5"), + para_sheets: sheets("6"), frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(), }) } @@ -541,8 +540,10 @@ impl Texts { let mut paras = vec![]; for run in s.get("0")?.get("5").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() { let n = run.get("1").and_then(Val::num)? as usize; - let props = run.get("0").and_then(|r| r.get("0")).and_then(|r| r.get("5")); - paras.push((n, self.para(props))); + paras.push((n, self.para(run.get("0").and_then(|r| r.get("0"))))); + } + if paras.is_empty() { + paras.push((text.chars().count(), self.para(None))); } let mut runs = vec![]; for run in s.get("0")?.get("6").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() { @@ -555,9 +556,19 @@ impl Texts { Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical }) } - /// A paragraph's attributes: its own keys, else the document's. - fn para(&self, p: Option<&Val>) -> Para { - let pick = |k: &str| p.and_then(|p| p.get(k)).or_else(|| self.para_defaults.get(k)); + /// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based + /// on, key 6): its own keys, else those of its paragraph style and the ones that is based on, + /// else those of `[Normal Paragraph Style]`, else the document's. + fn para(&self, run: Option<&Val>) -> Para { + let mut chain = vec![]; + let mut at = run; + for _ in 0..=MAX_SHEETS_BASED_ON { + let Some(style) = at else { break }; + chain.extend(style.get("5")); + at = style.get("6").and_then(Val::num).filter(|i| i.is_finite() && *i >= 0.0).and_then(|i| self.para_sheets.get(i as usize)); + } + chain.extend(self.para_sheets.first().and_then(|s| s.get("5"))); + let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.para_defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0); Para { justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8), @@ -573,7 +584,7 @@ impl Texts { /// A run's character style (`run`: its attributes, key 6, and the character style it is based /// on, key 5): its own keys, else those of its character style and the ones that is based on, - /// else the document's. + /// else those of `[Normal Character Style]`, else the document's. fn style(&self, run: Option<&Val>) -> Style { // The run's attributes, then its character style's, then the styles each is based on. let mut chain = vec![]; @@ -583,6 +594,7 @@ impl Texts { chain.extend(style.get("6")); at = style.get("5").and_then(Val::num).filter(|i| i.is_finite() && *i >= 0.0).and_then(|i| self.char_sheets.get(i as usize)); } + chain.extend(self.char_sheets.first().and_then(|s| s.get("6"))); let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num); let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); @@ -1074,9 +1086,10 @@ mod tests { defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), char_sheets: vec![], + para_sheets: vec![], frames: vec![], }; - let justify = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).justify; + let justify = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), 1.0).justify; // Right to left from the document's defaults, or the paragraph's own. assert_eq!(justify(b"/0 0"), Justify::Right); assert_eq!(justify(b"/0 1 /33 1"), Justify::Left); @@ -1096,6 +1109,7 @@ mod tests { defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), char_sheets: vec![], + para_sheets: vec![], frames: vec![], } .para(None); @@ -1142,6 +1156,7 @@ mod tests { defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), char_sheets: vec![], + para_sheets: vec![], frames: cases.iter().map(|c| frame(c)).collect(), }; let vertical: Vec = (0..cases.len() + 1).map(|i| texts.frame_vertical(i)).collect(); @@ -1157,13 +1172,14 @@ mod tests { 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()], + char_sheets: vec![read(b"/0 ([Normal Character Style]) /6 << >>").unwrap(), read(b"/0 (Block) /6 << /35 3 >>").unwrap()], + para_sheets: vec![], 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"); + assert!(tcy(b"/5 1 /6 << >>"), "its character style's"); + assert!(!tcy(b"/5 1 /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)); } @@ -1180,9 +1196,10 @@ mod tests { defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), char_sheets: vec![], + para_sheets: vec![], frames: vec![], }; - let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction; + let direction = |own: &[u8]| para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).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 }; @@ -1221,6 +1238,7 @@ mod tests { defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), char_sheets: vec![], + para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], }; texts.frame(0) @@ -1520,6 +1538,7 @@ mod tests { defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), char_sheets: vec![], + para_sheets: vec![], frames: vec![read(doc.as_bytes()).unwrap()], }; texts.frame(0).unwrap() diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index a57177426..cb1529edb 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -2358,6 +2358,53 @@ fn type_from_the_text_document_takes_its_character_styles_attributes() { assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour"); } +/// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character +/// Style]` and `[Normal Paragraph Style]` before the text document's base attributes. A Large +/// Canvas copy (1:10) writes the styles at the art's scale (1.2 for 12 points, 0.6 for a 6-point +/// indent) and the base attributes at the page's, so the type comes in at 12 points with a 6-point +/// indent. +#[test] +fn a_story_without_runs_takes_the_normal_styles() { + let own_style = "/5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >>"; + let doc = text_document(&[("Plain\r", 0, (8191.5, 8191.5), (0.0, 0.0), 0.0), ("Runs\r", 0, (8191.5, 8190.5), (0.0, -1.0), 0.0)]) + // The first story has no runs. + .replacen(&format!("/6 << /0 [ << /0 << /0 << /0 () {own_style} >> >> /1 6 >> ] >> "), "", 1) + .replacen("/5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 6 >> ] >> ", "", 1) + // The second story's runs name no style. + .replacen(own_style, "/6 << /0 0 >>", 1) + .replacen("/5 << /0 0 >> /6 0", "/5 << /0 0 >>", 1) + // The document's base attributes: 12 points, white, a 60-point indent, hyphenated. + .replacen("/2 << /1 12.0 >>", "/2 << /1 12.0 /53 << /99 /SimplePaint /0 << /0 0 /1 [ 1.0 1.0 ] >> >> >> /3 << /1 60.0 /9 true >>", 1) + // The normal styles: 1.2 art units, blue; a 0.6 art unit indent, not hyphenated. + .replacen( + "/0 << /1 <<", + "/0 << /5 << /0 [ << /0 << /0 ([Normal Character Style]) /6 << /0 0 /1 1.2 \ + /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 1.0 ] >> >> >> >> >> ] >> \ + /6 << /0 [ << /0 << /0 ([Normal Paragraph Style]) /5 << /1 0.6 /9 false >> >> >> ] >> /1 <<", + 1, + ); + assert!(!doc.contains("/1 14.0") && !doc.contains("/6 0 "), "{doc}"); + // A 10 × 10 unit copy of the page's 100 × 100 points, its template box centered at (5, 5). + let art = native_with_text( + &(layer("Back", true, "0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf") + &layer("Spare", false, &(text_object(0) + &text_object(1)))), + &doc, + ) + .replace("0 0 100 100", "0 0 10 10") + .replace("%AI3_TemplateBox: 50 50 50 50\n", "%AI3_TemplateBox: 5 5 5 5\n%AI24_LargeCanvasScale: 10\n"); + let r = import(&eps(&page_square(50, 50), &art)).unwrap(); + assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Plain", "Runs"], "{:?}", r.warnings); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 50.0).abs() < 1e-6 && (f - 50.0).abs() < 1e-6, "{:?}", texts[0].xf); + for t in texts { + let style = &t.runs[0].style; + assert!((style.size - 12.0).abs() < 1e-9, "{}: {}", t.plain_text(), style.size); + assert_eq!(style.fill, vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)), "{}", t.plain_text()); + assert!((t.para.first_line_indent - 6.0).abs() < 1e-9 && !t.para.hyphenate, "{}: {:?}", t.plain_text(), t.para); + } +} + #[test] fn type_from_the_text_document_keeps_its_case_setting() { // Key 12 of a character style is its case: 1 Small Caps, 2 All Caps. The characters stay as