diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index aae69ddb7a..32a04d201f 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2278,6 +2278,61 @@ fn a_3d_model_raises_an_older_document_to_its_version() { assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is"); } +/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping +/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing. +#[test] +fn a_prc_model_declares_the_adobe_extension_before_pdf_2() { + let adbe = |doc: &Document| { + let root = doc.root().unwrap(); + let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone()); + ext.map(|e| { + let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone()); + (e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int)))) + }) + }; + let add = |doc: &mut Document, model: &[u8]| { + let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" }; + add_3d_model(doc, &new, &meta("m")).unwrap(); + }; + let mut doc = fixture(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1)))))); + let mut doc = fixture(); + add(&mut doc, b"U3Drest"); + assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1"); + let mut doc = fixture(); + doc.set_version("2.0"); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2"); + + let mut doc = fixture(); + let root = doc.root().unwrap(); + let mut level3 = Dict::new(); + level3.set(b"BaseVersion".to_vec(), Object::name("1.7")); + level3.set(b"ExtensionLevel".to_vec(), Object::Int(3)); + let mut ext = Dict::new(); + ext.set(b"ADBE".to_vec(), Object::Dict(level3)); + ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new())); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept"); + ext.remove(b"ADBE"); + let mut doc = fixture(); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept"); + + let mut doc = fixture(); + let mut ext = Dict::new(); + ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())])); + let ext = doc.add(Object::Dict(ext)); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap(); + add(&mut doc, b"PRC rest"); + let doc = reopen(&doc); + let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap()); + assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone"); +} + #[test] fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() { let at = [10.0, 20.0, 110.0, 70.0]; diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs index cea6568bda..cf828388c0 100644 --- a/crates/annot/src/threed.rs +++ b/crates/annot/src/threed.rs @@ -46,7 +46,7 @@ impl ModelFormat { } /// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream - /// 1.7 (with Adobe's extension level 1, which is not written; PDF 2.0 takes PRC as standard). + /// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard). pub fn version(self) -> &'static str { match self { Self::U3d => "1.6", @@ -154,17 +154,48 @@ pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Re list.push(Object::Ref(r)); set_annots(doc, page, list)?; at_least_version(doc, format.version())?; + if format == ModelFormat::Prc && !at_least(doc, "2.0") { + declare_prc_extension(doc)?; + } Ok(index) } +/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2). +fn at_least(doc: &Document, min: &str) -> bool { + let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes(); + let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned())); + let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached) +} + +/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares +/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry +/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone. +fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> { + let Some(root) = doc.root() else { return Ok(()) }; + let catalog = doc.get(root); + // `None` when present but not a dictionary; an empty one when absent. + let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) { + None => Some(Dict::new()), + Some(o) => o.as_dict().cloned(), + }; + let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) }; + let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) }; + if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) { + return Ok(()); + } + adbe.set(b"BaseVersion".to_vec(), Object::name("1.7")); + adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1)); + extensions.set(b"ADBE".to_vec(), Object::Dict(adbe)); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?; + Ok(()) +} + /// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in /// its catalog (§7.7.2: the later of the two applies). fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> { - let below = |v: &[u8]| v.len() < 3 || v < min.as_bytes(); let Some(root) = doc.root() else { return Ok(()) }; - let catalog = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); - let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); - if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + if !at_least(doc, min) { doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?; } Ok(()) diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index 29d0d47768..d8b8835dc7 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -65,7 +65,10 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res /// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does. pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result { let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let (sw, sh) = src.size; + if !(sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0) { + return Err(EditError::Source(format!("{}: the poster has no area", file.name))); + } Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7d76cc7c85..eee46c7b3e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -592,6 +592,25 @@ fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused( assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}"); } +/// A PDF poster whose page has no area is refused rather than drawn as nothing. +#[test] +fn a_3d_poster_page_without_area_is_refused() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(595.0, 842.0, 1).unwrap(); + let id = session.open_new("model.pdf", blank).unwrap(); + let flat = String::from_utf8(half_red_page()).unwrap().replace("[0 0 80 40]", "[0 0 80 0 ]"); + let add = Edit::AddModel3d { + page: 0, + rect: [56.0, 400.0, 539.0, 700.0], + model: Arc::new(b"PRC rest".to_vec()), + poster: Some(MarkFile { name: "flat.pdf".into(), bytes: Arc::new(flat.into_bytes()), page: 0 }), + activation: Activation3d::default(), + name: "Bracket".into(), + }; + assert!(matches!(session.apply(id, add), Err(EditError::Source(_)))); + assert!(session.get(id).unwrap().info.annotations.is_empty(), "nothing was added"); +} + /// Adding a 3D model is a comment change: refused without the comments permission, and only the /// comments are rebuilt after it. #[test]