EPS import: bound what hostile editing data can cost, and say when it can't be read

- a story is read once however many text objects name it; the text objects, their pairs with the
  page's type, the pieces of a line and a story's size are capped, and over a cap the layers aren't used
- the ASCII85 of the text document is bounded before it is decoded
- joining the pieces of lines that interleave no longer removes the wrong pieces
- a run length too large for the text saturates instead of overflowing
- a dictionary left open at the end of a layer, and layers nested deeper than are read, make the
  file one that comes in as its page (they were silently dropped)
- a file whose editing data can't be decoded says so, and the notes about art that was too much or
  too far away count as losses, so writing over the source asks first
- the private data of a .ai is inflated under a cap of its own (the PDF reader's decoder has none)
- the other writers (PDF, selection, office, print) ask before replacing a source that lost things,
  and a recovered copy remembers that

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew Farrow 2026-10-09 03:49:45 -04:00
parent e622b87ca2
commit 8fe0fd8394
16 changed files with 255 additions and 30 deletions

View File

@ -169,6 +169,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do
/// template layers are guides, not artwork, and are left out.
pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
if let Some(path) = str_param(p, "path") {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let expanded = expand_presets(s, C, p)?;
@ -184,6 +187,9 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
/// `transparent`.
pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportForOffice";
if let Some(path) = str_param(p, "path") {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let path = str_param(p, "path");
let ppi = f64_or(p, "ppi", 150.0);
let doc = &s.doc()?.doc;

View File

@ -356,6 +356,9 @@ fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_param(p, "path") {
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
}
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?;

View File

@ -109,6 +109,9 @@ fn to_postscript(p: &Value) -> Result<bool> {
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_param(p, "path") {
super::fileio::check_not_lossy_overwrite(s.doc()?, path, p, PRINT)?;
}
let postscript = to_postscript(p)?;
let doc = &s.doc()?.doc;
let mut set = settings(PRINT, doc, p)?;

View File

@ -523,6 +523,10 @@ struct Meta {
format: String,
/// When the copy was written (Unix seconds; none without a clock).
saved: Option<i64>,
/// The file it was read from when that import left things out of the document, and what
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
imported_from: Option<String>,
import_losses: Vec<String>,
}
fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta {
@ -651,7 +655,14 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
modified: None,
};
let save = fileio::job_for(s, st, plan)?;
let meta = Meta { title: title.clone(), path: st.path.clone(), format: st.format.to_string(), saved };
let meta = Meta {
title: title.clone(),
path: st.path.clone(),
format: st.format.to_string(),
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
};
out.push(RecoveryJob { uid: st.uid, file, title, meta, save });
}
Ok((out, skipped))
@ -839,6 +850,8 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
let index = s.add_document(doc, meta.path.clone());
let st = s.doc_mut()?;
st.recovered = true;
st.imported_from = meta.imported_from.clone();
st.import_losses = meta.import_losses.clone();
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
st.mark_unsaved();
// The copy stays until the document is saved or closed.

View File

@ -122,6 +122,22 @@ fn a_crash_leaves_copies_that_restore_modified_and_titled() {
assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
}
#[test]
fn a_restored_copy_still_refuses_to_replace_the_file_an_import_left_things_out_of() {
let (mut s, store) = session();
s.doc_mut().unwrap().path = Some("/art/Poster.eps".into());
s.doc_mut().unwrap().imported_from = Some("/art/Poster.eps".into());
s.doc_mut().unwrap().import_losses = vec!["hidden layers have art this can't read".into()];
s.execute("file.recovery.save", &json!({})).unwrap();
drop(s);
let mut s = relaunch(&store);
s.execute("file.recovery.restore", &json!({})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.imported_from.as_deref(), Some("/art/Poster.eps"));
assert_eq!(st.import_losses, ["hidden layers have art this can't read"]);
}
#[test]
fn another_running_apps_copies_are_never_offered() {
let store = Arc::new(MemoryStore::default());

View File

@ -84,3 +84,27 @@ fn a_file_the_import_read_whole_is_written_over_as_before() {
s.execute("document.export", &json!({ "path": src.to_string_lossy(), "format": "eps" })).unwrap();
std::fs::remove_dir_all(d).ok();
}
#[test]
fn the_other_ways_of_writing_a_file_ask_too() {
let d = dir("c");
let src = d.join("art.eps");
let original = lossy_eps();
std::fs::write(&src, &original).unwrap();
let mut s = Session::new();
s.execute("document.open", &json!({ "path": src.to_string_lossy() })).unwrap();
s.execute("select.all", &json!({})).unwrap();
for (cmd, params) in [
("document.exportPdf", json!({})),
("document.exportSelection", json!({ "format": "eps" })),
("document.exportForOffice", json!({})),
("file.print", json!({ "format": "pdf" })),
] {
let mut p = params;
p["path"] = json!(src.to_string_lossy());
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("left things out"), "{cmd}: {e}");
assert_eq!(std::fs::read(&src).unwrap(), original, "{cmd}");
}
std::fs::remove_dir_all(d).ok();
}

View File

@ -24,6 +24,8 @@ use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun};
use vectorcraft_geom::{Affine, Point};
/// Longest story read (bytes).
const MAX_STORY: usize = 1 << 20;
/// Biggest text document read (bytes, decoded).
const MAX_BYTES: usize = 64 << 20;
/// Deepest nesting read.
@ -289,6 +291,10 @@ impl Texts {
let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len();
let end = find(data, b"~>", from)? + 2;
// Each line after the first starts with a `%`.
// ASCII85 makes at most four bytes of a character (`z`): more than this would be over `MAX_BYTES`.
if end - from > MAX_BYTES / 4 {
return None;
}
let packed: String = String::from_utf8_lossy(data.get(from..end)?)
.split(['\r', '\n'])
.enumerate()
@ -321,7 +327,7 @@ impl Texts {
/// Story `i`, if it is one of point type this reads.
pub(super) fn story(&self, i: usize) -> Option<Story> {
let s = self.stories.get(i)?;
let text = s.get("0")?.get("0")?.str()?.to_string();
let text = s.get("0")?.get("0")?.str().filter(|t| t.len() <= MAX_STORY)?.to_string();
// The frame the story is in: a matrix and nothing else but its place.
let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?;
let carries = frame.get("2")?;
@ -425,6 +431,11 @@ fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) {
/// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives).
impl Story {
/// How many bytes of text the story has.
pub(super) fn len(&self) -> usize {
self.text.len()
}
/// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given
/// the centre `(tx, ty)` of the file's template box.
pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) {
@ -466,10 +477,10 @@ impl Story {
let leading = self.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0);
let chars: Vec<char> = text.chars().collect();
let mut runs = vec![];
let mut from = 0;
let mut from = 0usize;
let styles: Vec<&(usize, Style)> = self.runs.iter().collect();
for (n, st) in styles {
let to = (from + n).min(chars.len());
let to = from.saturating_add(*n).min(chars.len());
let piece: String = chars.get(from..to).unwrap_or_default().iter().collect();
from = to;
if piece.is_empty() {

View File

@ -35,8 +35,8 @@ const MAX_DRAWN: usize = 1 << 24;
/// Samples taken of a shading function that isn't a plain interpolation.
const SHADING_SAMPLES: usize = 32;
const FAR_AWAY: &str = "objects far outside the page were left out";
const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out";
pub(super) const FAR_AWAY: &str = "objects far outside the page were left out";
pub(super) const TOO_MUCH: &str = "the file draws more than VectorCraft reads: the rest was left out";
const NESTED_PATTERNS: &str = "patterns nested too deeply are filled with mid-grey";
/// Deepest patterns and glyph procedures drawn inside each other's art.
pub(crate) const MAX_APART: u32 = 8;

View File

@ -69,13 +69,22 @@ const LAYERS_UNREAD: &str = "the file's layers weren't read";
/// Does this import note say that the file has something the document doesn't (hidden text, art or
/// layers that could not be read)? Writing the document over the file would lose it for good.
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT) || [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD].iter().any(|n| note.starts_with(n))
note.ends_with(TEXT_LEFT_OUT)
|| [ART_LEFT_OUT, PART_LEFT_OUT, LAYERS_UNREAD, super::graphics::TOO_MUCH, super::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
}
/// Deepest nesting of layers read (deeper ones are left out).
/// Deepest nesting of layers read (a file with deeper ones comes in as its page).
const MAX_DEPTH: usize = 32;
/// Most layers read.
const MAX_LAYERS: usize = 4096;
/// Most text objects in the layers, and most pieces of type in one place on the page, that are matched
/// to each other (more than this and the layers aren't used).
const MAX_SLOTS: usize = 5000;
const MAX_PIECES: usize = 2000;
/// Most bytes of text in the text objects of the layers (a story named twice counts twice).
const MAX_TEXT: usize = 16 << 20;
/// Most pairs of a text object and a piece of the page's type compared.
const MAX_PAIRS: usize = 4_000_000;
/// What the operators of the format do, as PostScript: paths are the path operators, a fill or a
/// stroke uses the colour the last `k` `K` `g` `G` set (a file's objects each set their own).
@ -315,7 +324,8 @@ fn parse(data: &[u8]) -> Option<Parsed> {
match line {
"%AI5_BeginLayer" => {
count += 1;
if count > MAX_LAYERS {
// Too many, too deep, or in the middle of a dictionary: not a file this reads.
if count > MAX_LAYERS || stack.len() > MAX_DEPTH || dict_depth > 0 {
return None;
}
if let Some(parent) = stack.last_mut() {
@ -324,6 +334,9 @@ fn parse(data: &[u8]) -> Option<Parsed> {
stack.push(Layer { visible: true, ..Layer::default() });
}
"%AI5_EndLayer--" => {
if dict_depth > 0 {
return None;
}
let mut l = stack.pop()?;
l.flush();
match stack.last_mut() {
@ -425,6 +438,9 @@ fn parse(data: &[u8]) -> Option<Parsed> {
layer.code.push('\n');
}
}
if dict_depth > 0 {
return None;
}
while let Some(mut l) = stack.pop() {
l.flush();
match stack.last_mut() {
@ -737,20 +753,30 @@ fn same_type(n: &Node, lines: &[(String, Point)]) -> Option<f64> {
fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
let text_of = |n: &Arc<Node>| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None };
let texts: Vec<usize> = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect();
let mut taken: Vec<usize> = vec![];
// A page with this many pieces of type in one place isn't one of lines of a story.
if texts.len() > MAX_PIECES {
return;
}
let mut taken = vec![false; parts.len()];
// The first piece of each line to join, the others, and the spaces to put after each piece.
let mut joins: Vec<(usize, Vec<usize>, Vec<String>)> = vec![];
for &i in &texts {
if taken.contains(&i) {
if taken.get(i).copied().unwrap_or(true) {
continue;
}
let Some(line) = parts.get(i).and_then(|n| baseline(n)) else { continue };
let group: Vec<usize> = texts
.iter()
.copied()
.filter(|j| !taken.contains(j) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE))
.filter(|j| {
!taken.get(*j).copied().unwrap_or(true) && parts.get(*j).and_then(|n| baseline(n)).is_some_and(|b| (b - line).abs() <= SAME_LINE)
})
.collect();
taken.extend(&group);
for j in &group {
if let Some(t) = taken.get_mut(*j) {
*t = true;
}
}
let pieces: Vec<String> = group.iter().filter_map(|j| parts.get(*j).and_then(text_of)).collect();
let places: Vec<(f64, f64)> = group
.iter()
@ -783,7 +809,8 @@ fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
joins.push((group[0], group[1..].to_vec(), gaps));
}
}
for (first, rest, gaps) in joins.iter().rev() {
let mut gone: BTreeSet<usize> = BTreeSet::new();
for (first, rest, gaps) in &joins {
let mut runs: Vec<Vec<vectorcraft_doc::TextRun>> = [first]
.into_iter()
.chain(rest)
@ -803,14 +830,15 @@ fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
t.cached_bounds = None;
t.cached_baselines.clear();
}
let mut gone: Vec<usize> = rest.clone();
gone.sort_unstable_by(|a, b| b.cmp(a));
for j in gone {
if j < parts.len() {
parts.remove(j);
}
}
gone.extend(rest);
}
// The pieces that went into a text, removed once the joins are done: removing as each is made
// would move the places of the pieces that the others still name.
let mut keep = 0;
parts.retain(|_| {
keep += 1;
!gone.contains(&(keep - 1))
});
}
/// The box round `parts`.
@ -823,7 +851,7 @@ fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
/// the page's type in painting order.
fn plan(
infos: &[SlotInfo],
stories: &[Option<Story>],
stories: &[Option<Arc<Story>>],
template: Option<(f64, f64)>,
to_doc: Affine,
pages: [&[Vec<Arc<Node>>]; 2],
@ -1026,8 +1054,23 @@ fn build(data: &[u8], visible: &Document, base: Document, page: Page) -> Result<
if infos.iter().all(|i| !i.shown) && !shown.is_empty() {
return Err("its page has text, and its layers have no text objects to put it in".into());
}
// The page's type is compared with every text object of the layers: that has its limits.
if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS {
return Err("it has too many text objects".into());
}
let texts = Texts::read(data);
let stories: Vec<Option<Story>> = infos.iter().map(|i| texts.as_ref().and_then(|t| t.story(i.story? as usize))).collect();
// Each story is read once, however many text objects name it.
let mut read: std::collections::BTreeMap<u32, Option<Arc<Story>>> = Default::default();
let stories: Vec<Option<Arc<Story>>> = infos
.iter()
.map(|i| {
let n = i.story?;
read.entry(n).or_insert_with(|| texts.as_ref().and_then(|t| t.story(n as usize)).map(Arc::new)).clone()
})
.collect();
if stories.iter().flatten().map(|s| s.len()).sum::<usize>() > MAX_TEXT {
return Err("it has too much text".into());
}
let template = parsed.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let mut notes = vec![];
let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes);
@ -1138,8 +1181,15 @@ fn compare(page: &Document, layered: &Document) -> Result<Option<String>, String
/// hidden ones too), groups, compound paths and clipping groups, with the text and the strokes
/// around it of the page. When the file has no editing copy this reads, `visible` as it was; when
/// it has one that can't be used, `visible` with a warning that says why.
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
let Some(data) = decode(ps) else { return visible };
pub(super) fn layered(ps: &[u8], mut visible: Imported) -> Imported {
let Some(data) = decode(ps) else {
// A file that says it has the editing copy but has none that can be read is a loss too.
let has = |n: &[u8]| ps.windows(n.len()).any(|w| w == n);
if has(BEGIN) && (has(STREAM) || has(STREAM_ZLIB)) {
visible.warnings.push(format!("{LAYERS_UNREAD} (its editing data is damaged or too large): it comes in as its page, in one layer"));
}
return visible;
};
let Some(page) = visible.document.artboards.first().map(|a| a.rect) else { return visible };
let base = Document::new(page.width(), page.height());
let result = build(&data, &visible.document, base, Page::Art).and_then(|built| {
@ -1205,3 +1255,24 @@ fn finish_layers(out: Out, layers: Vec<Arc<Node>>) -> Document {
doc.layers = layers;
doc
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::{CharStyle, TextObject};
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
}
/// Lines that the page draws in pieces, the pieces of one between those of the other, are each
/// joined, and only their own pieces go.
#[test]
fn interleaved_lines_are_joined_without_taking_each_others_pieces() {
let mut parts = vec![piece("He", 0.0, 0.0), piece("ab", 0.0, -20.0), piece("cd", 16.0, -20.0), piece("llo", 20.0, 0.0)];
let lines = [("Hello".to_string(), Point::new(0.0, 0.0)), ("abcd".to_string(), Point::new(0.0, -20.0))];
join_pieces(&mut parts, &lines);
let texts: Vec<String> = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect();
assert_eq!(texts, ["Hello", "abcd"]);
}
}

View File

@ -188,6 +188,9 @@ fn a_damaged_editing_copy_leaves_the_page() {
let r = import(&bytes).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
}
// A file that has the data but not in a form that can be read says so (and a write over it asks).
let r = import(&plain("this is not ascii85 {{{~>")).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("damaged") && crate::is_loss(w)), "{:?}", r.warnings);
}
/// The `.ai`'s editing data: its marker, then the Zstandard stream.
@ -473,3 +476,48 @@ fn a_shown_text_object_on_the_page_that_the_page_doesnt_draw_is_left_out() {
assert_eq!(all, ["Hi there"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
}
#[test]
fn pieces_of_two_lines_the_page_draws_alternately_go_each_to_its_own_text() {
// "Hello" is drawn as "He" and "llo", with "abcd" ("ab", "cd") drawn between them.
let doc = text_document(&[("Hello\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("abcd\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (He) show 58.5 81.5 moveto (ab) show \
74.1 81.5 moveto (cd) show 76.4 61.5 moveto (llo) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
all.sort();
assert_eq!(all, ["Hello", "abcd"], "{:?}", r.warnings);
}
#[test]
fn a_run_longer_than_any_text_doesnt_overflow() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replace("/1 9 >>", "/1 1e30 >>");
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Spare"], "{:?}", r.warnings);
}
#[test]
fn more_text_objects_than_are_matched_leave_the_page() {
let many = text_object(0).repeat(5001);
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("too many text objects") && crate::is_loss(w)), "{:?}", r.warnings);
}
#[test]
fn a_dictionary_left_open_at_the_end_of_a_layer_is_a_damaged_file() {
let open = "/AI11Text :\n0 /FreeUndo ,\n";
let art = native(&(layer("A", true, &(square(10, 10) + open)) + &layer("B", true, &square(20, 20))));
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Layer 1"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| crate::is_loss(w)), "{:?}", r.warnings);
}

View File

@ -132,7 +132,7 @@ pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
const PRIVATE_DATA_OWNER: &[u8] = b"Illustrator"; // brand-ok: the key the files use
/// Most bytes of an editor's private data read.
const MAX_PRIVATE: usize = 512 << 20;
const MAX_PRIVATE: usize = 256 << 20;
/// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData`
/// streams in order, joined. `None` for a PDF without them (or with a stream that can't be read).
@ -144,14 +144,33 @@ pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option<Vec<u8>>
let mut data = vec![];
for n in 1..=100_000u32 {
let Some(stream) = private.get::<Stream<'_>>(format!("AIPrivateData{n}").as_bytes()) else { break };
data.extend_from_slice(&stream.decoded().ok()?);
if data.len() > MAX_PRIVATE {
return None;
}
data.extend_from_slice(&inflated(&stream, MAX_PRIVATE - data.len())?);
}
(!data.is_empty()).then_some(data)
}
/// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's
/// `decoded()`, which has no limit, so a stream of zeros can't grow to the size of the memory. Only a
/// stream that is plain or only deflated is read (the private data is no other); else, or when it
/// is larger once decoded, `None`.
fn inflated(stream: &Stream<'_>, most: usize) -> Option<Vec<u8>> {
use std::io::Read;
if stream.dict().get::<Dict<'_>>(b"DecodeParms").is_some() {
return None;
}
let raw = stream.raw_data();
let mut out = vec![];
let limit = u64::try_from(most).ok()?.checked_add(1)?;
match stream.filters().len() {
0 => out.extend_from_slice(&raw),
1 => {
flate2::read::ZlibDecoder::new(&*raw).take(limit).read_to_end(&mut out).ok()?;
}
_ => return None,
}
(out.len() <= most).then_some(out)
}
/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)?
/// A file saved without PDF compatibility keeps its art there and shows a placeholder page.
pub(crate) fn has_private_data(page: &Page<'_>) -> bool {

View File

@ -385,4 +385,7 @@ fn an_ai_files_private_data_is_its_streams_joined() {
// A PDF without them has none; neither has bytes that aren't a PDF.
assert!(illustrator_data(&pdf_with_catalog(&[page(false)], &[&one, &two], "", None), None).is_none());
assert!(illustrator_data(b"not a pdf", None).is_none());
// Only a plain or a deflated stream is read: a chain of filters is refused, not run unbounded.
let chained = stream("/Filter [/ASCIIHexDecode /ASCIIHexDecode]", "61>");
assert!(illustrator_data(&pdf_with_catalog(&[page(true)], &[&chained, &two], "", None), None).is_none());
}

View File

@ -3121,6 +3121,8 @@ axis Both Ambos
@msg a layer couldn't be read no se pudo leer una capa
@msg it has more than one artboard tiene más de una mesa de trabajo
@msg it has no page no tiene página
@msg it has too many text objects tiene demasiados objetos de texto
@msg it has too much text tiene demasiado texto
@msg its editing data doesn't say where its artboard is sus datos de edición no indican dónde está su mesa de trabajo
@msg its editing data has no layers sus datos de edición no tienen capas
@msg its editing data is damaged sus datos de edición están dañados

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3118,6 +3118,8 @@ axis Both Les deux
@msg a layer couldn't be read une couche n’a pas pu être lue
@msg it has more than one artboard il a plus d'un plan de travail
@msg it has no page il n’a pas de page
@msg it has too many text objects il a trop d’objets texte
@msg it has too much text il a trop de texte
@msg its editing data doesn't say where its artboard is ses données de modification n’indiquent pas où se trouve son plan de travail
@msg its editing data has no layers ses données de modification n’ont pas de couches
@msg its editing data is damaged ses données de modification sont endommagées

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3121,6 +3121,8 @@ axis Both Entrambi
@msg a layer couldn't be read non è stato possibile leggere un livello
@msg it has more than one artboard ha più di una tavola da disegno
@msg it has no page non ha pagine
@msg it has too many text objects ha troppi oggetti di testo
@msg it has too much text ha troppo testo
@msg its editing data doesn't say where its artboard is i suoi dati di modifica non indicano dove si trova la tavola da disegno
@msg its editing data has no layers i suoi dati di modifica non hanno livelli
@msg its editing data is damaged i suoi dati di modifica sono danneggiati

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3117,6 +3117,8 @@ axis Both Оба
@msg a layer couldn't be read не удалось прочитать слой
@msg it has more than one artboard в нём несколько монтажных областей
@msg it has no page в нём нет страницы
@msg it has too many text objects в нём слишком много текстовых объектов
@msg it has too much text в нём слишком много текста
@msg its editing data doesn't say where its artboard is его данные для редактирования не указывают, где находится монтажная область
@msg its editing data has no layers в его данных для редактирования нет слоёв
@msg its editing data is damaged его данные для редактирования повреждены

Can't render this file because it contains an unexpected character in line 2 and column 73.