Merge pull request #5777 from dotCooCoo/fix/large-canvas-eps-type

.ai/EPS import: type that names no style takes the normal styles
This commit is contained in:
BFlat
2026-11-05 16:48:56 +07:00
committed by GitHub
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