.ai import: ASCII85 data without its end does not hide the section's end

Data with no `~>` before the section's end marker was lexed as tokens, so a `(` on a `%_` line of it opened a string that ran past the marker and the section did not end. The scan now skips that data up to the section's end marker, or up to the start of a section of the same kind nested after it. A `%` on the key's own line starts no marker, and a marker line with bytes that are not UTF-8 counts, as the scan reads comments.

When versioned content does not end, the scan for swatch names goes back to where its dictionaries stood at the start of that content, so data in the range it reads again is skipped.
This commit is contained in:
dotCooCoo
2026-11-07 05:15:06 +07:00
committed by bflatastic
parent babbbb9eb0
commit 3e1893d399
4 changed files with 96 additions and 24 deletions
+6 -2
View File
@@ -281,6 +281,8 @@ fn swatch_names(data: &[u8]) -> ShownNames {
let mut head = false;
// ASCII85 data in comment lines is skipped past, as the reader skips it.
let mut dictionaries = ops::Dictionaries::default();
// The scan's state where the versioned content being skipped starts, to go back to.
let mut versioned = None;
loop {
let before = lex.offset();
let Some(t) = lex.next_token() else {
@@ -288,13 +290,14 @@ fn swatch_names(data: &[u8]) -> ShownNames {
match skip.take() {
Some((.., Some(at))) => {
lex.seek(at);
dictionaries = versioned.take().unwrap_or_default();
continue;
}
_ => break,
}
};
let end = skip.map(|s| s.1).or(palette_at.map(|_| "EndPalette")).or(section.map(|s| s.1));
dictionaries.pass(&t.tok, &mut lex, end);
let markers = skip.map(|s| (s.0, s.1)).or(palette_at.map(|_| ("", "EndPalette"))).or(section.map(|s| (s.0, s.1)));
dictionaries.pass(&t.tok, &mut lex, markers);
match &t.tok {
Tok::Comment(c) => {
let line = String::from_utf8_lossy(c);
@@ -310,6 +313,7 @@ fn swatch_names(data: &[u8]) -> ShownNames {
}
} else if m.starts_with("Alternate_Content") && section.is_none_or(|s| s.3) {
skip = Some(("Begin_Content_if_version_gt", "End_Versioned_Content", 0, Some(lex.offset())));
versioned = Some(dictionaries.clone());
} else if m == "%BeginProlog" {
skip = Some(("", "%EndProlog", 0, None));
} else if m == "%BeginResource" {
+1 -1
View File
@@ -342,7 +342,7 @@ impl<'a> Reader<'a> {
loop {
let before = self.lex.offset();
let Some(t) = self.lex.next_token() else { break };
dictionaries.pass(&t.tok, &mut self.lex, Some(end));
dictionaries.pass(&t.tok, &mut self.lex, Some((begin, end)));
if let Tok::Comment(c) = t.tok {
let line = String::from_utf8_lossy(c);
let m = marker(&line);
+40 -21
View File
@@ -1133,7 +1133,7 @@ pub(super) fn ascii85_follows(ty: &str, key: Option<&str>) -> bool {
/// The dictionaries (`/Type :` … `;`) a scan of tokens passes that the reader doesn't read, kept to
/// tell where an entry's ASCII85 data follows ([`ascii85_follows`]), so the scan can skip past it
/// as the reader does: a `(` on a `%_` line of the data would otherwise open a string.
#[derive(Default)]
#[derive(Clone, Default)]
pub(super) struct Dictionaries<'s> {
/// The open dictionaries' types, innermost last, up to [`MAX_DEPTH`].
types: Vec<&'s [u8]>,
@@ -1143,38 +1143,43 @@ pub(super) struct Dictionaries<'s> {
name: Option<&'s [u8]>,
/// Whether the string just before the token taken is a key that ASCII85 data follows.
string_key: bool,
/// Data that starts before this offset has no `~>`, so it isn't searched for again.
no_end_before: usize,
/// The rest of the data has no `~>` or marker to stop at, so they aren't searched for again.
no_end: bool,
}
/// The offset just past the `~>` of ASCII85 data at the start of `rest`. `Err` holds the offset of
/// the comment with marker `end` that comes first, or the end of `rest`. A line of the data that
/// reads as that marker counts as the marker.
fn data_end(rest: &[u8], end: Option<&str>) -> Result<usize, usize> {
/// Where the ASCII85 data at the start of `rest` stops: just past its `~>`, or at the comment that
/// comes first with a marker of `section`, the begin and end markers of the section being scanned
/// (no begin marker when it's empty). A line of the data that reads as one of them counts as the
/// marker. `None` when `rest` has neither.
fn data_end(rest: &[u8], section: Option<(&str, &str)>) -> Option<usize> {
let mut at = 0;
for line in rest.split(|b| matches!(b, b'\r' | b'\n')) {
let comment = line.strip_prefix(b"%").and_then(|c| std::str::from_utf8(c).ok());
if comment.is_some_and(|c| Some(super::marker(c)) == end) {
return Err(at);
// The first line is what is left of the key's line, where a `%` starts no marker.
if at > 0
&& let Some((comment, (begin, end))) = line.strip_prefix(b"%").zip(section)
{
let comment = String::from_utf8_lossy(comment);
let marker = super::marker(&comment);
if marker == end || (!begin.is_empty() && marker == begin) {
return Some(at);
}
}
if let Some(i) = line.windows(2).position(|w| w == b"~>") {
return Ok(at + i + 2);
return Some(at + i + 2);
}
at += line.len() + 1;
}
Err(rest.len())
None
}
impl<'s> Dictionaries<'s> {
/// Take `tok`, the token `lex` just gave, and skip past the ASCII85 data that follows it. Data
/// without its `~>` before `end`, the end marker of the section being scanned ([`data_end`]),
/// isn't skipped, so the scan still finds that marker.
pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, end: Option<&str>) {
let at = lex.offset();
if self.data_follows(tok) && at >= self.no_end_before {
match data_end(lex.rest(), end) {
Ok(end) => lex.seek(at + end),
Err(stop) => self.no_end_before = at + stop,
/// Take `tok`, the token `lex` just gave, and skip the ASCII85 data that follows it, up to where
/// [`data_end`] stops for `section`, so the scan still finds that section's markers.
pub(super) fn pass(&mut self, tok: &super::Tok<'s>, lex: &mut super::Lexer<'s>, section: Option<(&str, &str)>) {
if self.data_follows(tok) && !self.no_end {
match data_end(lex.rest(), section) {
Some(stop) => lex.seek(lex.offset() + stop),
None => self.no_end = true,
}
}
}
@@ -1221,6 +1226,20 @@ mod tests {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(fill, Paint::None, 0.0)))
}
#[test]
fn data_stops_at_its_end_or_a_marker_of_the_section() {
let section = Some(("BeginTextDocument", "EndTextDocument"));
assert_eq!(data_end(b"\n%_(a~>\n%AI11_EndTextDocument\n", section), Some(7));
assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument\n", section), Some(6));
assert_eq!(data_end(b"\n%_(a\n%AI11_BeginTextDocument\n", section), Some(6));
// Bytes that aren't UTF-8 after the marker, as the scan reads a comment.
assert_eq!(data_end(b"\n%_(a\n%AI11_EndTextDocument \xff\n", section), Some(6));
// Right after the key a `%` starts no marker, and an empty begin marker matches no comment.
assert_eq!(data_end(b"%AI11_EndTextDocument\n%_(a\n", section), None);
assert_eq!(data_end(b"\n%\n", Some(("", "EndPalette"))), None);
assert_eq!(data_end(b"\n%AI11_EndTextDocument\n", None), None);
}
#[test]
fn only_outlines_without_gradients_move_alike() {
// GUARDS: an image's pixels and a placed gradient stay behind when an effect moves the outline.
+49
View File
@@ -4583,6 +4583,55 @@ fn data_without_its_end_does_not_hide_the_sections_end() {
}
}
/// Data without its `~>` is skipped up to the section's end marker: its `%_` line with a `(` opens
/// no string, so the section ends there and the art after it comes in. A `%` right after the key
/// starts no marker, so it doesn't end the data.
#[test]
fn data_without_its_end_holding_a_string_ends_at_the_sections_end() {
let art_layer = layer(&(rect_path(0.0, 0.0, 1.0, 1.0) + "f\n"));
for document in [
"%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%_(]<[%\n%AI11_EndTextDocument\n",
"%AI11_BeginTextDocument\r\n/AI11TextDocument : /ASCII85Decode ,\r\n%_(]<[%\r\n%AI11_EndTextDocument\r\n",
"%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,%AI11_EndTextDocument\n%_(]<[%\n%AI11_EndTextDocument\n",
] {
let s = read_ok(&format!("{document}{art_layer}"));
assert_eq!(art(&s.doc).len(), 1, "{document}");
}
}
/// Data without its `~>` stops at the start of a section of the same kind nested after it too, so
/// the outer section still ends at its own end marker and not at the nested one's.
#[test]
fn data_without_its_end_stops_at_a_nested_sections_start() {
let square = rect_path(0.0, 0.0, 1.0, 1.0) + "f\n";
let body = format!(
"%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n/Binary : /ASCII85Decode ,\n%_(]<[%\n\
%AI17_Begin_Content_if_version_gt:23 1\nfrobnicate\n%AI17_End_Versioned_Content\n{square}%AI17_End_Versioned_Content\n{}",
layer(&square)
);
let s = read_ok(&body);
assert_eq!(art(&s.doc).len(), 1);
assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings);
}
/// Versioned content that doesn't end is scanned again for the swatches' names: data with its `~>`
/// is still skipped, though data with none comes after it, and the dictionary open where the
/// content starts still counts.
#[test]
fn swatch_names_are_found_past_data_in_versioned_content_that_does_not_end() {
let entries = "1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
let alternate = "%AI17_Begin_Content_if_version_gt:24 4\n%AI17_Alternate_Content\n";
let sea = format!("0 1 1 0 (Sea) 0 x\n{}f\n", rect_path(0.0, 0.0, 5.0, 5.0));
let unended = "%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n%,u@!!/MSk8\n%AI11_EndTextDocument\n";
for art_text in [format!("{alternate}/ForeignObject :\n{entries}{sea}{unended}"), format!("/ForeignObject :\n{alternate}{entries}{sea}{unended}")]
{
let body = format!("{}{}", DOTS.replace("(Dots)", "(Sea\\n1)"), layer(&art_text));
let d = read(stream(&body).as_bytes()).unwrap().doc;
let names: Vec<&str> = d.patterns.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Sea 2"], "{art_text}");
}
}
/// The names the swatches take are found past the same data: a named color after it still takes
/// its name first, and the pattern named after it is numbered.
#[test]