//! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. use std::collections::{HashMap, HashSet}; use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream}; use craft_pdf::syntax::page::Page; use craft_pdf::syntax::xref::XRef; use craft_pdf::syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; use vectorcraft_doc::setup::MAX_BLEED; use crate::import_color::{direct, items, readable, values}; use crate::streams::{Refusal, Refused, refused}; use crate::{Choice, CropTo, ImportOptions, PdfError}; /// What `info` tells about one page. #[derive(Clone, Debug, PartialEq)] pub struct PageInfo { /// The page's size in points as viewers show it (its crop box, rotated, times the `UserUnit` /// the PDF reader applies to the page). pub width: f64, pub height: f64, /// The page's rotation in degrees (0, 90, 180 or 270). pub rotation: u16, /// Every box but Bounding, in PDF user space (y up), clipped to the media box, times the same /// `UserUnit` (in points, as the size). A missing bleed, trim or art box is the crop box. pub boxes: Vec<(CropTo, Rect)>, } /// The pages of a PDF ([`info`]). #[derive(Clone, Debug, PartialEq)] pub struct PdfInfo { pub pages: Vec, } /// Is this a PostScript file (plain, or an EPS with a binary header) rather than a PDF? pub fn is_postscript(bytes: &[u8]) -> bool { bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) } /// The application's stricter refusal policy for functions and colour spaces built on one /// another (a function stitching functions together, an indexed space on an alternate). /// The shared interpreter also has function depth and node budgets; this preflight rejects /// cyclic or excessively linked objects before interpretation. Files usually have two or three. const MAX_LINKS: usize = 16; /// The application's calculator-procedure depth policy, layered over the shared reader's limits. const MAX_PROCEDURES: usize = 64; /// What the function or colour space `o` is built on: the functions a stitching function joins; /// the base, alternate and tint of a colour space; the alternate of a colour profile. Nothing for /// any other object. fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec>> { const SPACES: [&[u8]; 6] = [b"Indexed", b"I", b"Separation", b"DeviceN", b"Pattern", b"ICCBased"]; if let Object::Array(a) = o { let mut items = items(a); let kind = match items.next() { Some(MaybeRef::NotRef(Object::Name(n))) => Some(n), Some(MaybeRef::Ref(r)) => xref.get::>(r.into()), _ => None, }; return if kind.is_some_and(|n| SPACES.contains(&n.as_ref())) { items.collect() } else { vec![] }; } let Some((d, _)) = dict_or_stream(o) else { return vec![] }; let functions = d.get::>(b"Functions").map(|a| items(&a).collect::>()).unwrap_or_default(); functions.into_iter().chain(d.get_raw::>(b"Alternate")).collect() } /// How many of its kind the function or colour space `o` is built on, one on the next (0 for any /// other object); `None` past `room`, or when it is built on itself. `known`: the objects looked /// at so far (`None` while one is being looked at, too). One `None` refuses the file, so that an /// object is `None` only for the room there was where it was first met doesn't matter. fn built_on<'a>(o: MaybeRef>, xref: &'a XRef, known: &mut HashMap>, room: usize) -> Option { let (id, o) = match o { MaybeRef::Ref(r) => { let id = ObjectIdentifier::from(r); if let Some(found) = known.get(&id) { return *found; } let Some(o) = xref.get::>(id) else { return Some(0) }; known.insert(id, None); (Some(id), o) } MaybeRef::NotRef(o) => (None, o), }; let links = links(&o, xref); let most = match room.checked_sub(1) { _ if links.is_empty() => Some(0), Some(room) => links.into_iter().try_fold(0, |most, on| built_on(on, xref, known, room).map(|n| most.max(n + 1))), None => None, }; // (What it is built on may be known already, and deeper than there is room for here.) let most = most.filter(|n| *n <= room); if let Some(id) = id { known.insert(id, most); } most } /// Is `o` a calculator function whose procedures nest deeper than [`MAX_PROCEDURES`]? fn procedures_too_deep(o: &Object<'_>) -> bool { let Object::Stream(s) = o else { return false }; if s.dict().get::(b"FunctionType") != Some(4) { return false; } let Ok(program) = s.decoded() else { return false }; let mut depth = 0usize; program.iter().any(|c| { match c { b'{' => depth += 1, b'}' => depth = depth.saturating_sub(1), _ => {} } depth > MAX_PROCEDURES }) } /// The error of a file with an object or cross-reference stream the reader would fail on as it /// opens the file ([`crate::streams::unsafe_file_streams`]); [`stream_note`] names any other. const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or its decode parameters can't be applied"; /// The error of a file with functions or color spaces built on one another past [`MAX_LINKS`] /// (or on themselves), or a calculator function nested past [`MAX_PROCEDURES`]. const NESTED_NOTE: &str = "its functions or colour spaces are built on one another without end"; /// The error of a file with an array whose items can't all be read (a damaged file). const UNREADABLE_NOTE: &str = "an array in it is damaged and can't be read"; /// Why `pdf` is refused, if it is: a stream the reader would fail on ([`stream_note`]), or /// functions, color spaces or calculator procedures nested too deep ([`NESTED_NOTE`]). Every /// object is looked at, and every array, dictionary and stream written inside one, wherever it /// is used from: the interpreter builds functions and color spaces from resources, shadings, /// images, patterns, masks and graphics states alike. fn hostile(pdf: &Pdf) -> Option { let xref = pdf.xref(); let mut known = HashMap::new(); // The objects of the file, then what each holds directly (64 deep at most: the reader's cap). let mut pending: Vec> = pdf.objects().into_iter().collect(); while let Some(o) = pending.pop() { // A stream's own checks come first: a calculator function is decoded below. if let Object::Stream(s) = &o && let Some(r) = refused(s) { return Some(stream_note(pdf, s, r)); } if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() { return Some(NESTED_NOTE.into()); } match &o { // An array with an item that can't be read can't be looked at whole, and the // interpreter would still read its leading items: the file is refused. Object::Array(a) if !readable(a) => return Some(UNREADABLE_NOTE.into()), Object::Array(a) => pending.extend(items(a).filter_map(direct)), _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| values(d)).filter_map(direct)), } } None } /// The error of a file with the stream `s` the reader would fail on, as [`refused`] says why: /// which object it is and what it holds, so a report of a file that can't be shared still says /// which stream it is (#4366). fn stream_note(pdf: &Pdf, s: &Stream<'_>, r: Refusal) -> String { let id = s.obj_id(); // A stream written inside another object goes by that object; 0 is a stream the reader can't // place. let object = (id.obj_number > 0).then(|| format!("object {}", id.obj_number)); let what = match (r.inline_image, object) { (true, Some(object)) => format!("an inline image in the content of {object}"), (true, None) => "an inline image in it".to_string(), (false, Some(object)) => format!("{} ({object})", stream_kind(pdf, id, s.dict())), (false, None) => stream_kind(pdf, id, s.dict()), }; match r.why { Refused::TooBig(most) => format!("{what} would decode to more than {}", decoded_size(most)), Refused::Parameters => format!("{what} has decode parameters that can't be applied"), } } /// What the stream `id` with dictionary `dict` holds, for [`stream_note`]: an image (and its size /// in pixels), a form, a page's content, the editing data an `.ai` keeps in its first page's /// `PieceInfo`, or another stream. fn stream_kind(pdf: &Pdf, id: ObjectIdentifier, dict: &Dict<'_>) -> String { let subtype = dict.get::>(b"Subtype"); let subtype_is = |v: &[u8]| subtype.as_ref().is_some_and(|n| n.as_ref() == v); if subtype_is(b"Image") { let side = |key: &[u8]| dict.get::(key).unwrap_or(0); return format!("an image of {} × {} px in it", side(b"Width"), side(b"Height")); } if subtype_is(b"Form") { return "a form in it".to_string(); } let is = |m: Option>>| matches!(m, Some(MaybeRef::Ref(r)) if ObjectIdentifier::from(r) == id); let pages = pdf.pages(); // `Contents` is a stream or an array of them, the array in the page or an object of its own. // Each object is read once however many pages share it, so a file can't make this quadratic. let mut read = HashSet::new(); let content = pages.iter().any(|page| match page.raw().get_raw::>(b"Contents") { Some(MaybeRef::NotRef(Object::Array(a))) => items(&a).any(|m| is(Some(m))), Some(MaybeRef::Ref(r)) => { let at = ObjectIdentifier::from(r); read.insert(at) && page.raw().get::>(b"Contents").map_or(at == id, |a| items(&a).any(|m| is(Some(m)))) } _ => false, }); if content { return "a page's content in it".to_string(); } let private = pages.first().and_then(|p| p.raw().get::>(b"PieceInfo")?.get::>(PRIVATE_DATA_OWNER)?.get::>(b"Private")); if private.is_some_and(|d| d.entries().any(|(key, m)| key.as_ref().starts_with(b"AI") && is(Some(m)))) { return "its editing data".to_string(); } "a stream in it".to_string() } /// `bytes` for a message, in whole megabytes, or gigabytes when it is a whole number of them. fn decoded_size(bytes: usize) -> String { const GB: usize = 1 << 30; if bytes >= GB && bytes.is_multiple_of(GB) { format!("{} GB", bytes / GB) } else { format!("{} MB", bytes >> 20) } } /// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions /// password opens a file too. /// /// What a hostile file nests or loops is the shared reader's to refuse (it reads arrays and /// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the /// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams /// once). What it reads beyond that is left out, as an object it can't read. The application also /// rejects overly linked functions and colour spaces, oversized streams and unsupported /// predictors before interpretation ([`hostile`], [`crate::streams`]), retaining its stricter /// refusal policy alongside the shared backend's bounded decoding and construction. pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { open_with_options(bytes, password, &open_options()) } /// 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 { craft_pdf::OpenOptions { max_input_bytes: usize::MAX, ..Default::default() } } pub(crate) fn open_with_options(bytes: &[u8], password: Option<&str>, options: &craft_pdf::OpenOptions) -> Result { if is_postscript(bytes) { return Err(PdfError::PostScript); } if crate::streams::unsafe_file_streams(bytes) { return Err(PdfError::Parse(STREAM_NOTE.into())); } let password = password.unwrap_or_default(); let read = |pw: &str| craft_pdf::open_slice_with_options(bytes, Some(pw), options); read(password) .or_else(|e| match e { // The reader takes only the open password of RC4 and 128-bit AES files: the // permissions password gives it. craft_pdf::OpenError::Load(LoadPdfError::Decryption(DecryptionError::PasswordProtected)) if !password.is_empty() => { crate::encrypt::user_password(bytes, password).map_or(Err(e), |user| read(&user)) } e => Err(e), }) .map_err(|e| match e { craft_pdf::OpenError::Load(LoadPdfError::Decryption(DecryptionError::PasswordProtected)) if password.is_empty() => { PdfError::NeedsPassword } craft_pdf::OpenError::Load(LoadPdfError::Decryption(DecryptionError::PasswordProtected)) => PdfError::WrongPassword, e => PdfError::Parse(e.to_string()), }) // Twice: looking at every object makes the reader repair a damaged table of objects (once // only), and the objects are other ones after that. .and_then(|pdf| match (0..2).find_map(|_| hostile(&pdf)) { Some(why) => Err(PdfError::Parse(why)), None => Ok(pdf), }) } /// 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)?; let pages = pdf .pages() .iter() .map(|page| { let (w, h) = page.render_dimensions(); let rotation = match page.rotation() { craft_pdf::syntax::page::Rotation::None => 0, craft_pdf::syntax::page::Rotation::Horizontal => 90, craft_pdf::syntax::page::Rotation::Flipped => 180, craft_pdf::syntax::page::Rotation::FlippedHorizontal => 270, }; let unit = f64::from(page.user_unit()); let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c).scale_from_origin(unit))).collect(); PageInfo { width: w as f64, height: h as f64, rotation, boxes } }) .collect(); Ok(PdfInfo { pages }) } /// The 0-based pages `opts` pick out of `count`. pub(crate) fn picked(opts: &ImportOptions, count: usize) -> Result, PdfError> { let mut v = match &opts.pages { Some(p) => { if let Some(bad) = p.iter().find(|i| **i >= count) { return Err(PdfError::BadPage(bad.saturating_add(1), count)); } p.clone() } None => (0..count).collect(), }; if let Some(m) = opts.max_pages { v.truncate(m); } if v.is_empty() { return Err(PdfError::NoPages); } Ok(v) } fn rect(r: craft_pdf::syntax::object::Rect) -> Rect { Rect::new(r.x0, r.y0, r.x1, r.y1).abs() } /// Box `which` of `page` in PDF user space, clipped to the media box (Bounding: the crop box, /// which the art's bounds replace after import). A missing or empty box is the crop box. pub(crate) fn page_box(page: &Page<'_>, which: CropTo) -> Rect { let media = rect(page.media_box()); let crop = rect(page.intersected_crop_box()); let key: &[u8] = match which { CropTo::Bounding | CropTo::Crop => return crop, CropTo::Media if media.area() > 0.0 => return media, CropTo::Media => return crop, CropTo::Bleed => b"BleedBox", CropTo::Trim => b"TrimBox", CropTo::Art => b"ArtBox", }; page.raw().get::(key).map(|r| rect(r).intersect(media)).filter(|r| r.area() > 0.0).unwrap_or(crop) } /// Where `page` draws on its artboard: the page transform (PDF user space → y-down page space /// with the crop box at the origin, rotation applied) and `which` box in that space (a page with /// no area gets the size viewers give it). pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) { let init = Affine::new(page.initial_transform(true).as_coeffs()); let b = init.transform_rect_bbox(page_box(page, which)); let (w, h) = page.render_dimensions(); (init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) }) } /// How far `page`'s bleed box reaches past its trim box as the page shows (rotated): the bleed /// `[top, bottom, left, right]` in points, kept to 0–[`MAX_BLEED`]. pub(crate) fn bleed(page: &Page<'_>) -> [f64; 4] { let init = Affine::new(page.initial_transform(true).as_coeffs()); let (trim, bleed) = (init.transform_rect_bbox(page_box(page, CropTo::Trim)), init.transform_rect_bbox(page_box(page, CropTo::Bleed))); [trim.y0 - bleed.y0, bleed.y1 - trim.y1, trim.x0 - bleed.x0, bleed.x1 - trim.x1] .map(|b| if b.is_finite() { b.clamp(0.0, MAX_BLEED) } else { 0.0 }) } /// The `PieceInfo` key such files keep their private data under. 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 = 256 << 20; /// The private data of an Illustrator `.ai` (its first page's `PieceInfo`): the `AIPrivateData` /// streams in order, joined. A PDF saved with the editing data has its streams under /// `AIPDFPrivateData1`, `AIPDFPrivateData2`, … instead: those are read when there is no /// `AIPrivateData1`. `None` for a PDF without either (or with a stream that can't be read). /// What it holds is the editor's own copy of the art (see `vectorcraft_eps::layered_ai`). pub fn illustrator_data(bytes: &[u8], password: Option<&str>) -> Option> { let pdf = open(bytes, password).ok()?; let page = pdf.pages().first()?; let private = page.raw().get::>(b"PieceInfo")?.get::>(PRIVATE_DATA_OWNER)?.get::>(b"Private")?; let key = if private.contains_key(b"AIPrivateData1") { "AIPrivateData" } else { "AIPDFPrivateData" }; let mut data = vec![]; for n in 1..=100_000u32 { let Some(stream) = private.get::>(format!("{key}{n}").as_bytes()) else { break }; data.extend_from_slice(&inflated(&stream, MAX_PRIVATE - data.len())?); } (!data.is_empty()).then_some(data) } /// Does the PDF's first page carry an editor's copy of the art: a PDF-compatible `.ai` /// (`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"]))) } /// `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 { piece_info_has(page, &[b"AIPrivateData"]) } /// Does `page` state where its art is: an art, trim or bleed box (ISO 32000-1 §14.11.2, the /// extent of the page's meaningful content and of the finished page)? A page written from an /// artboard has them; the notice a file saved without its PDF part shows instead of its art is /// the same page whatever the document, with no art to state the place of. pub(crate) fn states_art(page: &Page<'_>) -> bool { [&b"ArtBox"[..], b"TrimBox", b"BleedBox"].iter().any(|k| page.raw().get::<[f32; 4]>(*k).is_some()) } /// Does `page`'s `PieceInfo` hold a key starting with one of `prefixes`, up to four dictionaries in? fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool { fn walk(d: &Dict<'_>, prefixes: &[&[u8]], depth: u32) -> bool { depth < 4 && d.keys().any(|k| { prefixes.iter().any(|p| k.starts_with(p)) || d.get::>(k.as_ref()).is_some_and(|sub| walk(&sub, prefixes, depth + 1)) }) } page.raw().get::>(b"PieceInfo").is_some_and(|d| walk(&d, prefixes, 0)) } #[cfg(test)] mod tests { #[test] fn decoded_sizes_read_in_whole_units() { assert_eq!(super::decoded_size(256 << 20), "256 MB"); assert_eq!(super::decoded_size(418 << 20), "418 MB"); assert_eq!(super::decoded_size(1 << 30), "1 GB"); assert_eq!(super::decoded_size((1 << 30) + (1 << 20)), "1025 MB"); } }