.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.
This commit is contained in:
dotCooCoo
2026-11-05 16:37:13 +07:00
committed by bflatastic
parent 413446fdc1
commit 6dd86b337a
3 changed files with 127 additions and 21 deletions
@@ -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<String> = 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]
+40 -21
View File
@@ -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<Val>,
/// 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<Val>,
frames: Vec<Val>,
}
@@ -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<Val> {
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<bool> = (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()
+47
View File
@@ -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::<Vec<_>>(), ["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