diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index ea0838d0..c88b4d10 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -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 { 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 { /// `transparent`. pub(super) fn export_for_office(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index e02ad9ac..9068c696 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -356,6 +356,9 @@ fn without_non_printing(layers: &[Arc]) -> Option>> { } fn export_pdf(s: &mut Session, p: &Value) -> Result { + 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)?; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a5ecac06..2561797a 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -109,6 +109,9 @@ fn to_postscript(p: &Value) -> Result { } fn print(s: &mut Session, p: &Value) -> Result { + 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)?; diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 033d541e..83623a50 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -523,6 +523,10 @@ struct Meta { format: String, /// When the copy was written (Unix seconds; none without a clock). saved: Option, + /// 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, + import_losses: Vec, } fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { @@ -651,7 +655,14 @@ pub fn jobs(s: &mut Session, store: &Arc) -> Result<(Vec, 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. diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index 3637624e..80922774 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -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()); diff --git a/crates/engine/tests/lossy_overwrite.rs b/crates/engine/tests/lossy_overwrite.rs index 45820902..5e1b06e3 100644 --- a/crates/engine/tests/lossy_overwrite.rs +++ b/crates/engine/tests/lossy_overwrite.rs @@ -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(); +} diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index c5d7d8ab..dedfb280 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -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 { 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 = 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() { diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 1fd8100c..1d63c1d6 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -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; diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 1f25a2a8..a322c480 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -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 { 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 { 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 { 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 { fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { let text_of = |n: &Arc| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }; let texts: Vec = parts.iter().enumerate().filter(|(_, n)| matches!(n.kind, NodeKind::Text(_))).map(|(i, _)| i).collect(); - let mut taken: Vec = 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, Vec)> = 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 = 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 = 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>, lines: &[(String, Point)]) { joins.push((group[0], group[1..].to_vec(), gaps)); } } - for (first, rest, gaps) in joins.iter().rev() { + let mut gone: BTreeSet = BTreeSet::new(); + for (first, rest, gaps) in &joins { let mut runs: Vec> = [first] .into_iter() .chain(rest) @@ -803,14 +830,15 @@ fn join_pieces(parts: &mut Vec>, lines: &[(String, Point)]) { t.cached_bounds = None; t.cached_baselines.clear(); } - let mut gone: Vec = 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]) -> Option { /// the page's type in painting order. fn plan( infos: &[SlotInfo], - stories: &[Option], + stories: &[Option>], template: Option<(f64, f64)>, to_doc: Affine, pages: [&[Vec>]; 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> = 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>> = Default::default(); + let stories: Vec>> = 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::() > 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, 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>) -> 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 { + 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 = parts.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect(); + assert_eq!(texts, ["Hello", "abcd"]); + } +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 75642387..20a3ff61 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -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); +} diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 11764026..88359e91 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -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> let mut data = vec![]; for n in 1..=100_000u32 { let Some(stream) = private.get::>(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> { + use std::io::Read; + if stream.dict().get::>(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 { diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index 6a1e4b3f..937a65d9 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -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()); } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d6e1cc43..068cd35f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ebdd3e0a..2add225c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ffc74232..ec309b0e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8496a894..c13dbad0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 его данные для редактирования повреждены