From 6dd86b337a31ba40938734ffe06d829cd0cf7c9d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 00:50:28 -0800 Subject: [PATCH] .ai/EPS import: type that names no style takes the normal styles Type in the text document of a .ai or EPS file's editing data that names no character or paragraph style, a story without style runs included, is set in [Normal Character Style] and [Normal Paragraph Style]. The reader took what such type leaves out from the text document's base attributes instead, or built-in defaults for a story without paragraph runs, and it did not read paragraph styles. A Large Canvas file (1:10) holds the normal styles' lengths at the art's scale (1.2 for 12 pt) and the base attributes' at the page's (12). The reader scaled the base attributes' 12 by 10, so the type came in at 120 pt. In an EPS the page comparison then rejected the layers, and the file opened as its printed page without its linked images. In any file whose normal styles differ from the base attributes, such type now takes what the normal styles set, such as font, size, colors, stroke, alignment, indents, space before and after, and hyphenation. --- .../engine/src/cmd/fileio/tests_aiimport.rs | 40 ++++++++++++ crates/eps/src/import/ate.rs | 61 ++++++++++++------- crates/eps/src/import/tests_native.rs | 47 ++++++++++++++ 3 files changed, 127 insertions(+), 21 deletions(-) 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