diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 87e2d48be..6789a2116 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -61,6 +61,8 @@ vectorcraft-testkit = { workspace = true } proptest = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } +# How many times an open reads a PDF. +vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7aa436f4e..eca365ca1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -165,14 +165,18 @@ pub fn load(name: &str, bytes: &[u8]) -> Result { /// of its art, which it carries: its layers and hidden objects as they were, and the art outside /// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read. /// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its -/// first page's `UserUnit`) → (document, warnings, whether its layers were read). -fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec, unit: f64) -> (Document, Vec, bool) { - if opts.pages.is_some() || !opts.layers || !opts.editing_data { - return (doc, warnings, false); - } - let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings, false) }; +/// first page's `UserUnit`) → (document, warnings, whether its layers were read). `private`: the +/// editor's copy ([`editing_copy`]). +fn through_editing_data( + private: Option<&[u8]>, + opts: &LoadOptions, + doc: Document, + warnings: Vec, + unit: f64, +) -> (Document, Vec, bool) { + let Some(private) = private else { return (doc, warnings, false) }; let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines; - let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit); + let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(private, doc, warnings, outlined, opts.trust_editing_data, unit); // The layers weren't read (or couldn't be): the notes of reading the PDF part still hold. if !read { return (doc, warnings, false); @@ -189,18 +193,33 @@ fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warning (doc, warnings, true) } -/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page) from the editor's -/// own copy of its art alone: `None` when the file has none (or pages are picked, or layers or the -/// editing data are off), an error when it can't be read. -fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option)>> { - if opts.pages.is_some() || !opts.layers || !opts.editing_data { +/// A `.ai` saved without PDF compatibility (its PDF part is a placeholder page), `opened`, from the +/// editor's own copy of its art alone: `None` when the file has none (or the pages picked don't +/// name every page, or layers or the editing data are off), an error when it can't be read. +fn from_editing_data_alone(opened: &vectorcraft_pdf::Opened, opts: &LoadOptions) -> Option)>> { + if !every_page(opened.page_count(), opts) || !opts.layers || !opts.editing_data { return None; } - let private = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref())?; - Some( - vectorcraft_eps::ai_alone(&private) - .map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})"))), - ) + let private = opened.illustrator_data()?; + Some(editing_copy_alone(&private)) +} + +/// [`from_editing_data_alone`] of `private`, the editor's copy already read. +fn editing_copy_alone(private: &[u8]) -> Result<(Document, Vec)> { + vectorcraft_eps::ai_alone(private) + .map_err(|why| err(format!("this file was saved without PDF compatibility, and its editing data can't be read ({why})"))) +} + +/// Does a file opened with `opts` read the editor's own copy of its art: no pages picked, and +/// layers and the editing data on? +fn reads_editing_copy(opts: &LoadOptions) -> bool { + opts.pages.is_none() && opts.layers && opts.editing_data +} + +/// The editor's own copy of its art that `opened` carries (as [`vectorcraft_pdf::illustrator_data`] +/// reads it), when `opts` read it ([`reads_editing_copy`]). +fn editing_copy(opened: &vectorcraft_pdf::Opened, opts: &LoadOptions) -> Option> { + reads_editing_copy(opts).then(|| opened.illustrator_data()).flatten() } /// A `.ai`, or a PDF saved with the editing data, whose PDF part can't be read (`e`, the import's @@ -266,10 +285,10 @@ pub(crate) fn is_placeholder(e: &EngineError) -> bool { e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() } -/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one? -fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool { +/// Do `opts` read every page of a PDF of `count` pages: no `pages`, or a range that names each one? +fn every_page(count: usize, opts: &LoadOptions) -> bool { let Some(range) = &opts.pages else { return true }; - vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len())) + parse_range(range, count).is_ok_and(|p| p.len() == count) } /// Page `page_index` (0-based) read from a PDF or `.ai` (`read`, as [`super::page_document`] reads @@ -306,6 +325,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result None => err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")), })?; let title = file_name(name); + // The File Info a PDF, `.ai` or `.ait` gives. + let mut file_info = None; let mut converted = false; let mut preview_only = false; let mut editing_data = false; @@ -332,13 +353,22 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result restore_or_import(editing, || import_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { + // The file is read and checked once, for the document it carries, its pages, the + // editor's copy and its File Info. + let opened = vectorcraft_pdf::open_pdf(bytes, opts.password.as_deref()); // Saved with Preserve Editing: the document it carries, unless some pages are picked. let editing = (opts.pages.is_none() && opts.native) - .then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref())) + .then(|| opened.as_ref().ok().and_then(|o| o.editing())) .flatten() .map(|e| (e.intact, move || Some(e.data))); - restore_or_import(editing, || match super::pdfimport::import(bytes, opts, format.id != "pdf") { + let imported = || match &opened { + Ok(o) => super::pdfimport::import(bytes, o, opts, format.id != "pdf"), + Err(e) => Err(err(e)), + }; + let read = restore_or_import(editing, || match imported() { Ok(r) => { + // The editor's copy, read once the import is done with the pages. + let private = opened.as_ref().ok().and_then(|o| editing_copy(o, opts)); // Pages of text alone: the editing copy alone where it reads, as for a file // saved without its PDF part (#1387: else the page, which is the art). So too // pages that draw nothing at all: there is no page art to check the layers @@ -347,30 +377,35 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // decoded draws nothing either): its notes are at most the page's own. let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w)); let alone = (r.only_text || empty) - .then(|| from_editing_data_alone(bytes, opts)) + .then(|| private.as_deref().map(editing_copy_alone)) .flatten() .and_then(|read| read.ok()) .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)); let (doc, warnings, read) = match alone { Some((doc, warnings)) => (doc, warnings, true), - None => through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit), + None => through_editing_data(private.as_deref(), opts, r.document, r.warnings, r.user_unit), }; editing_data = read; Ok((doc, warnings)) } // Saved without its PDF part: the editing copy is all the file has (`pages` that // name every page read it as the whole file). - Err(e) if is_placeholder(&e) => { - let whole = LoadOptions { pages: None, ..opts.clone() }; - from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }) - .unwrap_or(Err(e)) - .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)) - .inspect(|_| editing_data = true) - } + Err(e) if is_placeholder(&e) => opened + .as_ref() + .ok() + .and_then(|o| from_editing_data_alone(o, opts)) + .unwrap_or(Err(e)) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)) + .inspect(|_| editing_data = true), // A PDF part that can't be read (a stream the import refuses): the editing copy // alone where it reads, else the error. Err(e) => without_pdf_part(bytes, opts, &e).inspect(|_| editing_data = true).ok_or(e), - })? + })?; + // The File Info of a file read from its pages or its editing copy. + if !read.2 { + file_info = opened.as_ref().ok().map(|o| o.file_info()); + } + read } "dxf" => { let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?; @@ -392,7 +427,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // it carries, opens with the File Info it gives (a PostScript `.ai` gives none). if matches!(format.id, "pdf" | "ai" | "ait") && !restored - && let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref()) + && let Some(info) = file_info { super::super::fileinfo::read_pdf(&mut doc.metadata, info); } diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index e646c43f8..5d2663910 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -108,15 +108,12 @@ impl LoadOptions { /// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible /// `.ai`, or a PDF saved with its editing data, asked for its crop box opens on its trim boxes, /// its editor's artboards, with the bleed round them (its crop box takes the bleed in). - fn import_options(&self, bytes: &[u8], editor_document: bool) -> Result { + fn import_options(&self, opened: &vectorcraft_pdf::Opened, editor_document: bool) -> Result { let pages = match &self.pages { None => None, - Some(range) => { - let count = vectorcraft_pdf::info(bytes, self.password.as_deref()).map_err(err)?.pages.len(); - Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?) - } + Some(range) => Some(parse_range(range, opened.page_count()).map_err(|e| err(format!("pages: {e}")))?), }; - let is_ai = self.crop == CropTo::Crop && vectorcraft_pdf::has_illustrator_data(bytes, self.password.as_deref()); + let is_ai = self.crop == CropTo::Crop && opened.has_illustrator_data(); Ok(ImportOptions { pages, crop: if is_ai { CropTo::Trim } else { self.crop }, @@ -140,24 +137,33 @@ fn choice(cmd: &str, key: &str, id: Option<&str>) -> Result .ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", ")))) } -/// Import the pages `o` pick → the document, import notes and what else the import tells. +/// Import the pages `o` pick of `opened`, the PDF `bytes` already read +/// ([`vectorcraft_pdf::open_pdf`]) → the document, import notes and what else the import tells. /// `editor_document`: a `.ai` file. -pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Result { - vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes, editor_document)?).map_err(err) +pub(super) fn import( + bytes: &[u8], + opened: &vectorcraft_pdf::Opened, + o: &LoadOptions, + editor_document: bool, +) -> Result { + let options = o.import_options(opened, editor_document)?; + vectorcraft_pdf::import_opened(bytes, opened, &options).map_err(err) } /// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one /// layer of what it shows, with the same text choice as Open, and the import notes. pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { - let opts = ImportOptions { - pages: Some(vec![page]), - crop: o.crop, - password: o.password.clone(), - text_as: o.text_as, - layers: false, - ..Default::default() - }; - vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err) + vectorcraft_pdf::import_with_report(bytes, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err) +} + +/// [`page_document`] of `opened`, the PDF `bytes` already read. +fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec)> { + vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err) +} + +/// The import options of [`page_document`]. +fn page_options(page: usize, o: &LoadOptions) -> ImportOptions { + ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: o.text_as, layers: false, ..Default::default() } } const INFO: &str = "document.pdfInfo"; @@ -170,13 +176,15 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result { if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) { return postscript_info(&bytes, p); } - let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) { - Ok(i) => i, + // The file is read once, for its pages and the thumbnail. + let opened = match vectorcraft_pdf::open_pdf(&bytes, o.password.as_deref()) { + Ok(opened) => opened, Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => { return Ok(json!({"pages": 0, "needsPassword": true, "wrongPassword": e == PdfError::WrongPassword, "pageInfo": []})); } Err(e) => return Err(err(e)), }; + let info = opened.info(); let r4 = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); let pages: Vec = info .pages @@ -188,7 +196,7 @@ pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result { .collect(); let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages}); if let Some(page) = thumbnail_page(p, info.pages.len(), "PDF")? { - let (doc, _) = page_document(&bytes, page, &o)?; + let (doc, _) = opened_page_document(&bytes, &opened, page, &o)?; out["thumbnail"] = json!(thumbnail(&doc, p)?); } Ok(out) diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index e0f6600d9..66b6d01df 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -82,6 +82,25 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +/// Opening a `.ai` or a PDF whose PDF part reads, or a `.ai` saved without PDF compatibility, +/// reads and checks the file once, on any thread, for the document it may carry, its pages, its +/// editing data and its File Info, with pages picked or not. +#[test] +fn an_ai_or_pdf_file_is_read_once_when_it_opens() { + let ai_file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()); + let pdf_file = pdf_with(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &[], None); + let placeholder = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER); + for (name, file) in [("art.ai", &ai_file), ("art.pdf", &pdf_file), ("placeholder.ai", &placeholder)] { + for opts in [LoadOptions::default(), LoadOptions { pages: Some("1".into()), ..Default::default() }] { + let before = vectorcraft_pdf::reads_of(file); + let loaded = load_with(name, file, &opts).unwrap(); + assert_eq!(vectorcraft_pdf::reads_of(file) - before, 1, "{name}, pages {:?}", opts.pages); + let editing_data = name == "placeholder.ai" || (name == "art.ai" && opts.pages.is_none()); + assert_eq!(loaded.editing_data, editing_data, "{name}, pages {:?}", opts.pages); + } + } +} + #[test] fn an_ai_file_opens_with_its_artboard_options_raster_settings_and_author() { let document = "%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n%_150 /Int (dpi.) ,\n%_1 /Bool (mask) ,\n%_; (AI Auto Rasterize) ,\n\ diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index f56aecf72..6bd298a07 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -8,6 +8,10 @@ description = "VectorCraft PDF export and import" [lints] workspace = true +[features] +# `vectorcraft_pdf::reads_of` for other crates' tests (dev-dependencies only). +read-count = [] + [dependencies] vectorcraft-geom = { workspace = true } vectorcraft-doc = { workspace = true } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index b04593101..ff04da781 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -40,7 +40,7 @@ mod state; pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; pub use entry::{ INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_with_report, is_glyph_outlines, is_outlines_note, + import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(test)] pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read}; diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index bc4996d58..3f521c026 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -200,6 +200,12 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result { + vectorcraft_doc::depth::with_deep_stack(|| read_opened(bytes, opts, &crate::pages::open_options(), &opened.pdf)) +} + /// [`import_with_report`] on the thread that asks. pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result { read_with_reader_options(bytes, opts, &crate::pages::open_options()) @@ -212,10 +218,21 @@ pub(crate) fn import_with_reader_options(bytes: &[u8], opts: &ImportOptions, rea fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions) -> Result { let original = crate::pages::open_with_options(bytes, opts.password.as_deref(), reader)?; + read_opened(bytes, opts, reader, &original) +} + +/// [`read_with_reader_options`] of `original`, the PDF `bytes` read with `reader`. +fn read_opened( + bytes: &[u8], + opts: &ImportOptions, + reader: &craft_pdf::OpenOptions, + original: &craft_pdf::syntax::Pdf, +) -> Result { let picked = crate::pages::picked(opts, original.pages().len())?; - let mut ocgs = Ocgs::read(&original); + let mut ocgs = Ocgs::read(original); let route = opts.layers && !ocgs.list.is_empty(); - let (pdf, all_on, notes) = reopen_with_groups_on(bytes, opts, reader, original, &picked, &mut ocgs, route)?; + let (turned_on, all_on, notes) = reopen_with_groups_on(bytes, opts, reader, original, &picked, &mut ocgs, route)?; + let pdf = turned_on.as_ref().unwrap_or(original); let pages = pdf.pages(); let sink: Arc>> = Arc::default(); @@ -227,7 +244,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p doc.artboards.clear(); doc.layers.clear(); let taken: Vec = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect(); - let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken.clone()), opts.text_as, route); + let mut b = Builder::new(doc.peek_next_id(), Colors::new(pdf, taken.clone()), opts.text_as, route); // The art of a group that is off (drawn as `all_on` turned it on) or doesn't print mustn't // show or print on its page's layer. b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print); @@ -440,7 +457,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p } warnings.extend(depth_notes); let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit())); - Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit }) + Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(pdf), only_text, user_unit }) } /// The page a PDF-compatible `.ai` paints under its layers is the editor's page, not art: an opaque @@ -487,17 +504,17 @@ fn is_only_text(nodes: &[Arc]) -> bool { } /// Opens the file again with every layer group turned on when the pages hide art in groups that are -/// off, and tells whether it did. Gives the file to read, whether it is the turned-on copy, and the -/// notes for the import's report. +/// off, and tells whether it did. Gives the turned-on copy (`None`: read `original`), whether there +/// is one, and the notes for the import's report. fn reopen_with_groups_on( bytes: &[u8], opts: &ImportOptions, reader: &craft_pdf::OpenOptions, - original: craft_pdf::syntax::Pdf, + original: &craft_pdf::syntax::Pdf, picked: &[usize], ocgs: &mut Ocgs, route: bool, -) -> Result<(craft_pdf::syntax::Pdf, bool, Vec), PdfError> { +) -> Result<(Option, bool, Vec), PdfError> { // Content that is off imports as hidden layers: an update of the file turns every group on // (unless a form is hidden by its own group: its art couldn't be told apart). let hide = route && ocgs.skips_any(); @@ -511,7 +528,7 @@ fn reopen_with_groups_on( } } let turned_on = (hide && !hidden_form) - .then(|| all_on(bytes, &original)) + .then(|| all_on(bytes, original)) .flatten() .and_then(|b| crate::pages::open_with_options(&b, opts.password.as_deref(), reader).ok()) .filter(|p| Ocgs::read(p).list.is_empty()); @@ -520,7 +537,7 @@ fn reopen_with_groups_on( notes.push("the art of hidden layers couldn't be read and was left out".to_string()); } let all_on = turned_on.is_some(); - Ok((turned_on.unwrap_or(original), all_on, notes)) + Ok((turned_on, all_on, notes)) } /// The interpreter's settings, its warnings noted once each in `sink` (truncated content in `incomplete`). diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9c52e1999..d39fc0b95 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -78,9 +78,14 @@ pub use export::{export, export_with_report, page_areas}; pub use file_info::{FileInfo, file_info}; pub use import::{ INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_with_report, is_glyph_outlines, is_outlines_note, + import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, +}; +#[cfg(any(test, feature = "read-count"))] +pub use pages::reads_of; +pub use pages::{ + IllustratorData, Opened, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info, is_postscript, + open_pdf, }; -pub use pages::{IllustratorData, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info, is_postscript}; pub use piece_info::{PRIVATE_CHUNK, PrivateData}; pub use post::{THUMBNAIL_SIZE, Thumbnail}; pub use presets::*; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 1468d6d8d..b5cee4b1b 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -234,6 +234,54 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result) -> Result { + vectorcraft_doc::depth::with_deep_stack(|| open(bytes, password)).map(|pdf| Opened { pdf }) +} + +impl Opened { + /// The pages and their boxes, as [`info`] lists them. + pub fn info(&self) -> PdfInfo { + info_in(&self.pdf) + } + + /// The native document the PDF carries, as [`editing`](crate::editing) reads it. + pub fn editing(&self) -> Option { + crate::editing::editing_in(&self.pdf) + } + + /// The private data of an Illustrator `.ai`, as [`illustrator_data`] reads it. + pub fn illustrator_data(&self) -> Option> { + private_data(&self.pdf) + } + + /// Does the first page carry an editor's copy of the art, as [`has_illustrator_data`] says? + pub fn has_illustrator_data(&self) -> bool { + has_private_data_keys(&self.pdf) + } + + /// The File Info, as [`crate::file_info`] reads it. + pub fn file_info(&self) -> crate::FileInfo { + crate::file_info::file_info_in(&self.pdf) + } + + /// How many pages the PDF has, as [`info`] lists them. + pub fn page_count(&self) -> usize { + self.pdf.pages().len() + } +} + /// Preserve the application's existing accepted file sizes. Ordinary stream decoding still /// uses the shared reader's immutable per-document limits, and its input copy is fallible. pub(crate) fn open_options() -> craft_pdf::OpenOptions { @@ -249,10 +297,25 @@ pub(crate) fn open_with_options(bytes: &[u8], password: Option<&str>, options: & }) } +/// How many times each buffer has been read as a PDF, by its address (tests). +#[cfg(any(test, feature = "read-count"))] +static READS: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + +/// How many times `bytes`, at its address, has been read as a PDF on any thread (tests). +#[cfg(any(test, feature = "read-count"))] +pub fn reads_of(bytes: &[u8]) -> usize { + READS.lock().ok().and_then(|r| r.get(&(bytes.as_ptr() as usize)).copied()).unwrap_or(0) +} + /// [`open_with_options`] without looking at the file's objects ([`hostile`]): the reader builds its /// table of objects and its page tree, and nothing on a page is read. For reading one object that /// is checked on its own ([`illustrator_data_without_pdf_part`]), never for importing a page. fn open_structural_with_options(bytes: &[u8], password: Option<&str>, options: &craft_pdf::OpenOptions) -> Result { + #[cfg(any(test, feature = "read-count"))] + if let Ok(mut reads) = READS.lock() { + let n = reads.entry(bytes.as_ptr() as usize).or_default(); + *n = n.saturating_add(1); + } if is_postscript(bytes) { return Err(PdfError::PostScript); } @@ -282,7 +345,11 @@ fn open_structural_with_options(bytes: &[u8], password: Option<&str>, options: & /// The pages and their boxes. An encrypted PDF needs its `password` /// ([`PdfError::NeedsPassword`] / [`PdfError::WrongPassword`]). pub fn info(bytes: &[u8], password: Option<&str>) -> Result { - let pdf = open(bytes, password)?; + Ok(info_in(&open(bytes, password)?)) +} + +/// [`info`] of a parsed PDF. +fn info_in(pdf: &Pdf) -> PdfInfo { let pages = pdf .pages() .iter() @@ -299,7 +366,7 @@ pub fn info(bytes: &[u8], password: Option<&str>) -> Result { PageInfo { width: w as f64, height: h as f64, rotation, boxes } }) .collect(); - Ok(PdfInfo { pages }) + PdfInfo { pages } } /// The 0-based pages `opts` pick out of `count`. @@ -419,7 +486,12 @@ fn private_data(pdf: &Pdf) -> Option> { /// (`AIPrivateData` keys) or a PDF saved with its editing data (`AIPDFPrivateData`, #1345)? Without /// reading the data. Either's trim boxes are its editor's artboards. pub fn has_illustrator_data(bytes: &[u8], password: Option<&str>) -> bool { - open(bytes, password).is_ok_and(|pdf| pdf.pages().first().is_some_and(|p| piece_info_has(p, &[b"AIPrivateData", b"AIPDFPrivateData"]))) + open(bytes, password).is_ok_and(|pdf| has_private_data_keys(&pdf)) +} + +/// [`has_illustrator_data`] of a parsed PDF. +fn has_private_data_keys(pdf: &Pdf) -> bool { + pdf.pages().first().is_some_and(|p| piece_info_has(p, &[b"AIPrivateData", b"AIPDFPrivateData"])) } /// `stream` decoded, up to `most` bytes: the decoder is run here rather than by the PDF reader's diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index eb30028b0..9a2223744 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -122,11 +122,13 @@ pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option 1 => (i.pages.len(), false), + let (pages, locked) = match opened.map(|o| o.page_count()) { + Ok(count) if count > 1 => (count, false), Err(PdfError::NeedsPassword) => (0, true), _ => return false, };