From a176ffae468f251ccf9ed9855bab32cb77e1c6b4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 23:16:33 -0800 Subject: [PATCH] PDF/.ai import: the file is read and checked once when it opens Opening a .ai or a PDF read and checked the whole file up to five times: for the document it carries, for whether it holds the editor's private data, for the import, for the editor's copy and for its File Info. Each pass parses the file and walks every object, about 190 ms on a 15 MB .ai. - vectorcraft_pdf::open_pdf reads and checks the file once, on the import's deep-stack thread, and import_opened imports pages from that reading. document.open takes the carried document, the page count, the editor's copy and the File Info from it. The editor's copy is read after the page import and only when the file is not restored from a carried document; so is the File Info. - A .ai saved without PDF compatibility opened with pages that name every page reads its editor's copy from the same reading. - document.pdfInfo reads the file once for its pages and the thumbnail. - The Open and Place dialogs read the file once to decide whether to ask for pages, instead of twice. A file whose PDF part fails the check still reads its editing data separately, as before. --- crates/engine/Cargo.toml | 2 + crates/engine/src/cmd/fileio/load.rs | 101 ++++++++++++------ crates/engine/src/cmd/fileio/pdfimport.rs | 50 +++++---- .../engine/src/cmd/fileio/tests_aiimport.rs | 19 ++++ crates/pdf/Cargo.toml | 4 + crates/pdf/src/import.rs | 2 +- crates/pdf/src/import/entry.rs | 37 +++++-- crates/pdf/src/lib.rs | 9 +- crates/pdf/src/pages.rs | 78 +++++++++++++- crates/ui-egui/src/dialogs/import_pdf.rs | 8 +- 10 files changed, 237 insertions(+), 73 deletions(-) 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, };