From 16ffab9b03960572e822514ce837671c3a948fde Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 16:09:08 -0500 Subject: [PATCH 1/4] M12: add a 3D model (U3D or PRC) to a page with a poster (#1297) A new `model_add` automation tool and `Edit::AddModel3d` embed a U3D or PRC file as a 3D annotation (ISO 32000-2 13.6.2) in a chosen rectangle: - The model goes into a Flate-compressed 3D stream (13.6.3) with /Type /3D and /Subtype U3D or PRC, read from the file's header (`U3D` and a zero byte, or `PRC`); anything else is refused before the document changes. Models over 256 MiB (MAX_MODEL_BYTES) are refused. model_add checks the file's size (from the file system) and its header before reading the rest, so a large file that is not a model is never loaded; the path goes through the read root, and `read_model` joins the helpers the file-access test knows take a resolved path. - /3DD references the stream, so the annotation has its own instance. - /3DA states /A: click (XA, the standard's default and the tool's), page_open (PO) or page_visible (PV). - /AP /N is the poster readers print and show before activation: a chosen image or PDF page fitted inside the box keeping its shape and centred (the custom-stamp picture path: `mark_source`, `fit_picture`, and the stamp's placement matrix now shared as `appearance::picture_place`), or, with no picture, a light frame labelled "3D model". It is drawn in user space and not counter-rotated, so on a page with /Rotate it turns with the page, as the model does. - /Contents carries the name (the file stem by default) as the alternate description (12.5.2). - A document older than the model needs gets /Version in its catalog (7.7.2): 1.6 for U3D, 1.7 for PRC, as the optimizer does for JBIG2. Adobe's extension level for PRC in 1.7 is not written. - The 3D rectangle has its own MAX_MODEL_SIDE (14400 pt). The edit is undoable ("Add 3D model"), needs the comments permission and rebuilds only the comments view. A new parity entry, misc.3d-add, is partial: no poster rendered from the model, no views (/VA, /3DV), no replacing the poster, no UI command, no /Extensions. misc.3d-view is unchanged. ROADMAP.md progress log line. Adapted from pdfcer (MIT, same author), commit 1ca5ece, rewritten to this repository's types and rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- ROADMAP.md | 2 + crates/annot/src/appearance.rs | 29 +- crates/annot/src/lib.rs | 8 + crates/annot/src/tests.rs | 60 ++++ crates/annot/src/threed.rs | 258 ++++++++++++++++++ crates/automation/src/comments.rs | 2 +- crates/automation/src/dispatch.rs | 1 + crates/automation/src/file_access.rs | 14 +- crates/automation/src/lib.rs | 1 + crates/automation/src/model3d.rs | 114 ++++++++ crates/automation/src/tools.rs | 14 + .../automation/tests/it/automation_roots.rs | 4 + crates/automation/tests/it/main.rs | 1 + crates/automation/tests/it/model_3d.rs | 120 ++++++++ crates/engine/src/edit.rs | 13 +- crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 13 +- crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 7 +- crates/engine/src/pictures.rs | 9 + crates/engine/src/tests.rs | 60 ++++ parity/acrobat-features.toml | 12 + 22 files changed, 727 insertions(+), 18 deletions(-) create mode 100644 crates/annot/src/threed.rs create mode 100644 crates/automation/src/model3d.rs create mode 100644 crates/automation/tests/it/model_3d.rs diff --git a/ROADMAP.md b/ROADMAP.md index a39d6f8b6c..adf6bdb5cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged. + - **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 909250ef26..89c0f70f48 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option { Some(res) } -fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { +pub(crate) fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); @@ -288,6 +288,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { Stream::flate(d, content) } +/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose +/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing). +pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option { + let [x0, y0, x1, y1] = at; + let (w, h) = (x1 - x0, y1 - y0); + if image { + return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))); + } + let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?; + Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0))) +} + /// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written /// when the signature is added to a turned page): 0, 90, 180 or 270. pub(crate) fn picture_turn(d: &Dict) -> i64 { @@ -325,7 +337,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { } /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. -fn helvetica_resource() -> Object { +pub(crate) fn helvetica_resource() -> Object { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); @@ -786,15 +798,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; + let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() { + [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)), + _ => None, + }); + let place = picture_place(rect, image, size)?; c.push_str(&format!("q {place} /Pic Do Q\n")); let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..5b7492bea2 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -23,6 +23,7 @@ mod draw; mod info; mod lookup; mod modify; +mod threed; mod types; #[cfg(test)] @@ -41,6 +42,7 @@ pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; +pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, @@ -70,6 +72,12 @@ pub enum AnnotError { /// with a font that has those characters. The message names the character. #[error("{0}")] Undrawable(String), + /// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`). + #[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")] + NotAModel, + /// A 3D model larger than `MAX_MODEL_BYTES`. + #[error("the 3D model is {size} bytes; the largest accepted is {max}")] + ModelTooLarge { size: usize, max: usize }, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index cf800fa98a..c31ef19b17 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2250,3 +2250,63 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); } } + +/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's +/// catalog says so, and a newer one is left as it is. +#[test] +fn a_3d_model_raises_an_older_document_to_its_version() { + let catalog_version = |doc: &Document| { + let root = doc.root().unwrap(); + doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec) + }; + 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"U3D\0rest"); + assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D"); + for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] { + let mut doc = fixture(); + doc.set_version("1.4"); + add(&mut doc, model); + assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version)); + } + let mut doc = fixture(); + doc.set_version("2.0"); + add(&mut doc, b"PRC rest"); + assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is"); +} + +#[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]; + assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square"); + assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm")); + for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] { + assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}"); + } +} + +/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by. +#[test] +fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { + let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at }; + for (poster, why) in [ + (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"), + (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"), + (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"), + ] { + let mut doc = fixture(); + let new = NewModel3d { + page: 0, + rect: [100.0, 100.0, 300.0, 300.0], + model: b"PRC rest", + activation: Activation3d::default(), + poster: Some(poster), + contents: "", + }; + assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}"); + assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added"); + } +} diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs new file mode 100644 index 0000000000..be632337cb --- /dev/null +++ b/crates/annot/src/threed.rs @@ -0,0 +1,258 @@ +//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page, +//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated. + +use super::create::{finite, normalize, num_array}; +use super::lookup::{annots, page_ref, set_annots}; +use super::types::{FLAG_PRINT, Meta}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n}; + +/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the +/// model is held and compressed in memory. +pub const MAX_MODEL_BYTES: usize = 256 << 20; + +/// What the poster says when there is no picture. +const PLACEHOLDER_LABEL: &str = "3D model"; + +/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelFormat { + U3d, + Prc, +} + +impl ModelFormat { + /// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file + /// header block type), PRC files start `PRC`. + /// + /// # Errors + /// + /// [`AnnotError::NotAModel`] for anything else. + pub fn detect(bytes: &[u8]) -> Result { + if bytes.starts_with(b"U3D\0") { + Ok(Self::U3d) + } else if bytes.starts_with(b"PRC") { + Ok(Self::Prc) + } else { + Err(AnnotError::NotAModel) + } + } + + /// The 3D stream's `/Subtype`. + pub fn subtype(self) -> &'static str { + match self { + Self::U3d => "U3D", + Self::Prc => "PRC", + } + } + + /// 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). + pub fn version(self) -> &'static str { + match self { + Self::U3d => "1.6", + Self::Prc => "1.7", + } + } +} + +/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's +/// `/A`). The standard's default is [`Self::Click`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Activation { + /// `/XA`: when the user clicks it; it prints as its poster. + #[default] + Click, + /// `/PO`: when the page opens. + PageOpen, + /// `/PV`: when the page becomes visible. + PageVisible, +} + +impl Activation { + /// Every activation, with the word a tool argument names it by. + pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")]; + + /// The `/A` name. + pub fn name(self) -> &'static str { + match self { + Self::Click => "XA", + Self::PageOpen => "PO", + Self::PageVisible => "PV", + } + } +} + +/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it +/// to `size` points) drawn into `at`, a box inside the annotation's rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PosterPicture { + pub xobject: ObjRef, + pub image: bool, + pub size: (f64, f64), + pub at: [f64; 4], +} + +/// A 3D model to add on `page` (0-based) in `rect` (user space). +#[derive(Clone, Debug, PartialEq)] +pub struct NewModel3d<'a> { + pub page: usize, + pub rect: [f64; 4], + pub model: &'a [u8], + pub activation: Activation, + /// The poster's picture; `None` draws a labelled frame. + pub poster: Option, + /// `/Contents`: the annotation's alternate description (§12.5.2). + pub contents: &'a str, +} + +/// Check a model before anything is added: its size and its format. +/// +/// # Errors +/// +/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`]. +pub fn check_model(model: &[u8]) -> Result { + check_model_size(model, MAX_MODEL_BYTES)?; + ModelFormat::detect(model) +} + +fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> { + if model.len() > max { + return Err(AnnotError::ModelTooLarge { size: model.len(), max }); + } + Ok(()) +} + +/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the +/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`. +/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document +/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`. +/// +/// # Errors +/// +/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no +/// area or a poster box outside it. +pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result { + let format = check_model(new.model)?; + let page = page_ref(doc, new.page)?; + let rect = normalize(new.rect); + if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into())); + } + let poster = match &new.poster { + Some(p) => picture_poster(rect, p)?, + None => placeholder_poster(rect), + }; + let mut stream = Dict::new(); + stream.set(b"Type".to_vec(), Object::name("3D")); + stream.set(b"Subtype".to_vec(), Object::name(format.subtype())); + let stream = doc.add(Object::Stream(Stream::flate(stream, new.model))); + let poster = doc.add(Object::Stream(poster)); + let d = annotation_dict(new, rect, page, stream, poster, meta); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, page); + let index = list.len(); + list.push(Object::Ref(r)); + set_annots(doc, page, list)?; + at_least_version(doc, format.version())?; + Ok(index) +} + +/// 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) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?; + } + Ok(()) +} + +fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, meta: &Meta) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("3D")); + d.set(b"Rect".to_vec(), num_array(&rect)); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); + if !new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(new.contents)); + } + if !meta.id.is_empty() { + d.set(b"NM".to_vec(), PdfString::text(&meta.id)); + } + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + d.set(b"3DD".to_vec(), Object::Ref(stream)); + let mut activation = Dict::new(); + activation.set(b"A".to_vec(), Object::name(new.activation.name())); + d.set(b"3DA".to_vec(), Object::Dict(activation)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(poster)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d +} + +/// The poster drawing `p` into its box, which must lie within `rect`. +fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result { + let at = normalize(p.at); + let inside = finite(&p.at) && at[0] >= rect[0] && at[1] >= rect[1] && at[2] <= rect[2] && at[3] <= rect[3] && at[2] > at[0] && at[3] > at[1]; + if !inside { + return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into())); + } + let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?; + let mut xobjects = Dict::new(); + xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources)) +} + +/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't). +fn placeholder_poster(rect: [f64; 4]) -> Stream { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let mut c = + format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0)); + let mut resources = Dict::new(); + let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0); + let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 }; + if size >= 2.0 { + let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0); + c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty))); + resources.set(b"Font".to_vec(), appearance::helvetica_resource()); + } + appearance::form(rect, c.as_bytes(), resources) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_size_is_capped() { + assert_eq!(check_model_size(&[0; 8], 8), Ok(())); + assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 })); + } + + #[test] + fn the_format_is_read_from_the_header() { + assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d)); + assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc)); + for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] { + assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}"); + } + } + + #[test] + fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() { + let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap(); + assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}"); + let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]); + assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj")); + assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none())); + } +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 5dbd5b8542..0817613865 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -75,7 +75,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { [ux as f64, uy as f64] } -fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { +pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3])); [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 49d4e6fd4d..e5d84fd28e 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -768,6 +768,7 @@ impl Automation { "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, "stamp_custom" => self.stamp_custom(&a)?, + "model_add" => self.model_add(&a)?, "comment_reply" => self.comment_reply(&a)?, "comment_set_status" => self.comment_set_status(&a)?, "sign_list" => self.sign_list(&a)?, diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 067a593888..dab2760f5d 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -394,8 +394,18 @@ mod tests { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every // discovered file again before `process` reads it or `publish` writes it. - const RESOLVED_BY_CALLER: &[&str] = - &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; + const RESOLVED_BY_CALLER: &[&str] = &[ + "write_atomic", + "write_atomic_with", + "read_input", + "read_input_bytes", + "read_model", + "read_review_sidecar", + "process", + "linked", + "walk", + "publish", + ]; let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut files = vec![]; while let Some(dir) = dirs.pop() { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1910867c9..cd75302d5a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -26,6 +26,7 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; +mod model3d; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model3d.rs b/crates/automation/src/model3d.rs new file mode 100644 index 0000000000..2d89e4ae60 --- /dev/null +++ b/crates/automation/src/model3d.rs @@ -0,0 +1,114 @@ +//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3). + +use std::path::Path; + +use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat}; +use serde_json::Value; + +use crate::comments::rect_to_user; +use crate::{Args, Automation, Result, ToolError, failed}; + +/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C). +const MAX_MODEL_SIDE: f64 = 14_400.0; + +impl Automation { + /// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an + /// image) fitted inside the box, or a labelled frame. + pub(crate) fn model_add(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?; + let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?; + let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs()); + if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) { + return Err(ToolError::InvalidArgs(format!( + "rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt" + ))); + } + let activation = match a.opt_str("activation")? { + None => Activation3d::default(), + Some(word) => Activation3d::ALL + .iter() + .find(|(_, w)| *w == word) + .map(|(act, _)| *act) + .ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?, + }; + let path = self.resolve(a.str("path")?, false)?; + let model = read_model(&path, MAX_3D_MODEL_BYTES)?; + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); + let poster = match a.opt_str("poster")? { + None => None, + Some(p) => { + let path = self.resolve(p, false)?; + let bytes = crate::read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1); + Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page }) + } + }; + let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name }; + self.apply(a, edit) + } +} + +/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first +/// bytes are checked before the rest is read, so a large file that is not a model is never loaded. +fn read_model(path: &Path, max: usize) -> Result> { + use std::io::Read as _; + let io = |e: std::io::Error| failed(format!("{}: {e}", path.display())); + let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}")); + let cap = u64::try_from(max).unwrap_or(u64::MAX); + let mut file = std::fs::File::open(path).map_err(io)?; + if let Ok(meta) = file.metadata() + && meta.is_file() + && meta.len() > cap + { + return Err(too_large(meta.len())); + } + let mut bytes = Vec::new(); + (&mut file).take(4).read_to_end(&mut bytes).map_err(io)?; + Model3dFormat::detect(&bytes).map_err(failed)?; + let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64); + file.take(rest).read_to_end(&mut bytes).map_err(io)?; + if bytes.len() > max { + return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted"))); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path + } + + #[test] + fn a_model_is_read_whole_when_it_fits() { + let dir = tempfile::tempdir().unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[7; 40]); + assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc); + assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x"); + } + + #[test] + fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() { + let dir = tempfile::tempdir().unwrap(); + let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]); + let err = read_model(&path, 4).unwrap_err().to_string(); + assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}"); + } + + #[test] + fn a_file_that_is_not_a_model_is_refused_by_its_header() { + let dir = tempfile::tempdir().unwrap(); + for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] { + let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string(); + assert!(err.contains("not a 3D model"), "{junk:?}: {err}"); + } + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7cafcb4a37..b37cb54429 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -461,6 +461,20 @@ pub fn tools() -> Vec { }), &["doc", "page", "path"], )), + t("model_add", "Add a 3D model", "Add a 3D model (Acrobat: Rich Media ▸ Add 3D): the U3D or PRC file at path, recognised by its first bytes, as a 3D annotation in rect [x0, y0, x1, y1] (points from the top-left of the displayed page; each side 1–14400 pt; at most 256 MiB). Its poster, what prints and what readers show until the model is activated, is the picture at poster (a PDF page, poster_page default 1, or an image) fitted inside rect keeping its shape and centred; without poster, a frame labelled \"3D model\". activation: click (default), page_open or page_visible. name is its description (default: the file name). No views are added: readers open the model on its own default view. Undoable.") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "path": { "type": "string" }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "poster": { "type": "string" }, + "poster_page": { "type": "integer", "minimum": 1 }, + "activation": { "type": "string", "enum": ["click", "page_open", "page_visible"] }, + "name": { "type": "string" }, + }), + &["doc", "page", "path", "rect"], + )), t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( diff --git a/crates/automation/tests/it/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs index a96870ca02..e65c582a73 100644 --- a/crates/automation/tests/it/automation_roots.rs +++ b/crates/automation/tests/it/automation_roots.rs @@ -145,6 +145,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin"))); let out = s(&base.join("out/result.pdf")); + std::fs::write(base.join("in/m.prc"), b"PRC model").unwrap(); + let model = s(&base.join("in/m.prc")); for (tool, args) in [ ("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})), ("page_replace", json!({"doc": 1, "pages": [1], "path": secret})), @@ -153,6 +155,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ("doc_create", json!({"from": "images", "paths": [pic]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})), + ("model_add", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 100, 100]})), + ("model_add", json!({"doc": 1, "page": 1, "path": model, "rect": [10, 10, 100, 100], "poster": pic})), ("page_add_image", json!({"doc": 1, "page": 1, "path": pic})), ("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})), ("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})), diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d36dbe93a9..f396601edb 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,6 +8,7 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; +mod model_3d; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model_3d.rs b/crates/automation/tests/it/model_3d.rs new file mode 100644 index 0000000000..179192f421 --- /dev/null +++ b/crates/automation/tests/it/model_3d.rs @@ -0,0 +1,120 @@ +//! `model_add` embeds a U3D or PRC model as a 3D annotation (ISO 32000-2 §13.6.2) with a poster, +//! end to end: the tool, the saved file, undo, and the refusals. + +use pdfcraft_automation::{Automation, Content, ToolError}; +use pdfcraft_cos::{Dict, Document, Object, Stream}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn png(w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, w, h); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&vec![90; (w * h * 3) as usize]).unwrap(); + out +} + +fn stream(doc: &Document, obj: &Object) -> Stream { + match doc.resolve(obj).as_ref() { + Object::Stream(s) => s.clone(), + other => panic!("expected a stream: {other:?}"), + } +} + +/// The 3D annotations on the first page of the saved file. +fn saved_3d(dir: &std::path::Path) -> (Document, Vec) { + let doc = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out.pdf")).unwrap())).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let list = page.dict.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap()).unwrap_or_default(); + let found = list.iter().map(|o| doc.resolve(o).as_dict().unwrap().clone()).filter(|d| d.name(b"Subtype") == Some(&b"3D"[..])).collect(); + (doc, found) +} + +#[test] +fn model_add_embeds_the_model_with_a_fitted_poster() { + let dir = tempfile::tempdir().unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[7; 200]); + std::fs::write(dir.path().join("bracket.prc"), &prc).unwrap(); + std::fs::write(dir.path().join("poster.png"), png(80, 40)).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + // A 300 × 300 box from the top-left of the page: user space [100, 400, 400, 700]. + let call = + json!({ "doc": doc, "page": 1, "path": "bracket.prc", "rect": [100, 100, 400, 400], "poster": "poster.png", "activation": "page_open" }); + tool(&mut a, "model_add", call); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + + let (saved, found) = saved_3d(dir.path()); + let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") }; + let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(rect, [100.0, 400.0, 400.0, 700.0]); + assert_eq!(annot.get(b"Contents").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("bracket")); + let model = stream(&saved, annot.get(b"3DD").unwrap()); + assert_eq!(model.dict.name(b"Type"), Some(&b"3D"[..])); + assert_eq!(model.dict.name(b"Subtype"), Some(&b"PRC"[..])); + assert_eq!(model.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(model.decoded().unwrap().to_vec(), prc, "the model is stored unchanged"); + let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone(); + assert_eq!(activation.name(b"A"), Some(&b"PO"[..])); + // The 80 × 40 picture fitted in the 300 × 300 box: 300 × 150, centred top to bottom. + let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone(); + let poster = stream(&saved, ap.get(b"N").unwrap()); + let content = String::from_utf8(poster.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("300 0 0 150 100 475 cm /Pic Do"), "{content}"); + + assert_eq!(tool(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add 3D model"); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + assert!(saved_3d(dir.path()).1.is_empty(), "undo removes it"); +} + +#[test] +fn model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click() { + let dir = tempfile::tempdir().unwrap(); + let mut u3d = b"U3D\0".to_vec(); + u3d.extend_from_slice(&[0; 60]); + std::fs::write(dir.path().join("part.u3d"), &u3d).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + tool(&mut a, "model_add", json!({ "doc": doc, "page": 1, "path": "part.u3d", "rect": [50, 50, 250, 200], "name": "Part" })); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + + let (saved, found) = saved_3d(dir.path()); + let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") }; + assert_eq!(stream(&saved, annot.get(b"3DD").unwrap()).dict.name(b"Subtype"), Some(&b"U3D"[..])); + let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone(); + assert_eq!(activation.name(b"A"), Some(&b"XA"[..]), "the standard's default, stated"); + let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone(); + let content = String::from_utf8(stream(&saved, ap.get(b"N").unwrap()).decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("re S") && content.contains("(3D model) Tj"), "{content}"); +} + +#[test] +fn model_add_refuses_what_is_not_a_model_and_adds_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"PR not a model").unwrap(); + std::fs::write(dir.path().join("part.prc"), b"PRC....").unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + let not_a_model = a.call("model_add", &json!({ "doc": doc, "page": 1, "path": "notes.txt", "rect": [10, 10, 100, 100] })); + assert!(matches!(¬_a_model, Err(ToolError::Failed(m)) if m.contains("U3D") && m.contains("PRC")), "{not_a_model:?}"); + for (args, why) in [ + (json!({ "rect": [10, 10, 10, 100] }), "zero width"), + (json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"), + (json!({}), "no rect"), + (json!({ "rect": [10, 10, 100, 100], "activation": "now" }), "an unknown activation"), + ] { + let mut call = json!({ "doc": doc, "page": 1, "path": "part.prc" }); + call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone()); + assert!(matches!(a.call("model_add", &call), Err(ToolError::InvalidArgs(_))), "{why}"); + } + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + assert!(saved_3d(dir.path()).1.is_empty()); +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..60d2c46a3d 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -6,7 +6,7 @@ use crate::{ AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -157,6 +157,17 @@ pub enum Edit { file: MarkFile, author: String, }, + /// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named + /// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an + /// image) fitted inside `rect`, keeping its shape and centred, or a labelled frame without one. + AddModel3d { + page: usize, + rect: [f64; 4], + model: Arc>, + poster: Option, + activation: pdfcraft_annot::Activation3d, + name: String, + }, /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. DeleteAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5343804ef3..cb5187fdcc 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -68,6 +68,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Add inserted text", "Add file attachment", "Add stamp", + "Add 3D model", "Delete comment", "Edit comment", "Reply", @@ -205,6 +206,7 @@ impl Edit { Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::AddModel3d { .. } => Label::of("Add 3D model"), Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), Edit::ReplyToAnnotation { .. } => Label::of("Reply"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 5ed70aed7a..658013775d 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,6 +1,6 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; @@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } + | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } @@ -509,6 +510,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; } + Edit::AddModel3d { page, rect, model, poster, activation, name } => { + // A file that is not a model is refused before its poster is brought in. + pdfcraft_annot::check_model(model)?; + let poster = match poster { + Some(file) => Some(model_poster(doc, *rect, file)?), + None => None, + }; + let new = pdfcraft_annot::NewModel3d { page: *page, rect: *rect, model, activation: *activation, poster, contents: name }; + pdfcraft_annot::add_3d_model(doc, &new, &cx.meta())?; + } Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, Edit::ReplyToAnnotation { page, index, text, author } => { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..8869e5d1a7 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -54,6 +54,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } + | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..4865281fb7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,9 +108,10 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, + Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, + Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, }; +pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; @@ -140,7 +141,7 @@ use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::{MarkFile, image_dimensions}; -use pictures::{embed_image, fit_picture, mark_source}; +use pictures::{embed_image, fit_picture, mark_source, model_poster}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index 49d76f07c0..29d0d47768 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -60,6 +60,15 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res } } +/// A 3D model's poster: the picture in `file` brought into `doc` and fitted inside `rect` +/// (user space), keeping its shape, and centred. It is drawn in user space and not counter-rotated +/// (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)); + Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) }) +} + /// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster. pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { let format = image::guess_format(bytes).ok()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25a185e835..7d76cc7c85 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -549,6 +549,66 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() { assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}"); } +/// #1297: a 3D model on a page keeps the whole box as its rectangle and draws a PDF poster as a +/// form XObject fitted inside it, keeping its shape and centred. +#[test] +fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_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 mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[1; 64]); + let add = |model: Vec| Edit::AddModel3d { + page: 0, + rect: [56.0, 400.0, 539.0, 700.0], + model: Arc::new(model), + poster: Some(MarkFile { name: "poster.pdf".into(), bytes: Arc::new(half_red_page()), page: 0 }), + activation: Activation3d::default(), + name: "Bracket".into(), + }; + assert!(session.apply(id, add(b"%PDF-1.7 not a model".to_vec())).is_err()); + session.apply(id, add(prc)).unwrap(); + + let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()); + let [only] = annots.as_array().unwrap().as_slice() else { panic!("the refused model added nothing: {annots:?}") }; + let annot = doc.get(only.as_ref().unwrap()); + let annot = annot.as_dict().unwrap(); + assert_eq!(annot.name(b"Subtype"), Some(&b"3D"[..])); + let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect(); + assert_eq!(rect, [56.0, 400.0, 539.0, 700.0], "the whole box, not the fitted picture"); + assert_eq!(doc.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().name(b"A"), Some(&b"XA"[..])); + let ap = doc.resolve(annot.get(b"AP").unwrap()); + let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else { + panic!("an appearance stream") + }; + let pic = doc.resolve(normal.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let pic = doc.resolve(pic.get(b"XObject").unwrap()).as_dict().unwrap().reference(b"Pic").unwrap(); + let pdfcraft_cos::Object::Stream(picture) = doc.get(pic).as_ref().clone() else { panic!("the picture is a stream") }; + assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject"); + // The 80 × 40 page in the 483 × 300 box: 483 × 241.5 from y 429.25. + let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}"); +} + +/// Adding a 3D model is a comment change: refused without the comments permission, and only the +/// comments are rebuilt after it. +#[test] +fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() { + let edit = Edit::AddModel3d { + page: 0, + rect: [0.0, 0.0, 100.0, 100.0], + model: Arc::new(b"PRC rest".to_vec()), + poster: None, + activation: Activation3d::default(), + name: String::new(), + }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments"))); + assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments); +} + fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 290a410e14..714599338c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9896,6 +9896,18 @@ tools = [] evidence = [] notes = "" +[[feature]] +id = "misc.3d-add" +title = "Add a 3D model (U3D/PRC) to a page" +area = "N" +tier = "P3" +milestone = "M12" +status = "partial" +commands = [] +tools = ["model_add"] +evidence = ["crates/automation/tests/it/model_3d.rs::model_add_embeds_the_model_with_a_fitted_poster", "crates/automation/tests/it/model_3d.rs::model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click", "crates/automation/tests/it/model_3d.rs::model_add_refuses_what_is_not_a_model_and_adds_nothing", "crates/engine/src/tests.rs::a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused", "crates/annot/src/tests.rs::a_3d_model_raises_an_older_document_to_its_version"] +notes = "Embeds a U3D or PRC file (detected from its header) as a 3D annotation with an activation choice and a poster: a chosen image or PDF page fitted in the box, or a labelled frame. Not yet: a poster rendered from the model, views (/VA, /3DV), replacing the poster later, a UI command. The poster is drawn in user space and not counter-rotated, so on a page with /Rotate it turns with the page, as the model does. An older file's catalog /Version is raised to 1.6 (U3D) or 1.7 (PRC); Adobe's extension level for PRC in 1.7 is not written." + [[feature]] id = "misc.legacy-multimedia" title = "Legacy multimedia (screen annotations, renditions)" From 9da253ebee1fc10a6f7cc6551119527cffe506fc Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 17:24:42 -0500 Subject: [PATCH 2/4] fix(annot): a 3D poster fitted to its rectangle with rounding is taken A poster box fitted to the annotation rectangle in floating point can end a few ulps outside it (a 7x3 picture centred in 0 100 3.6 110.8 spans -2.2e-16 to 3.6000000000000005), and the exact containment check refused it. The check now allows a slack of 1e-9 of the rectangle's largest coordinate, clamps the box to the rectangle, and only then requires it to have width and height, so a box wholly within the slack outside the rectangle is still refused. Tests: a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken fails without the slack (Invalid); the new case in a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused fails when the size check runs before the clamp. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/annot/src/tests.rs | 20 ++++++++++++++++++++ crates/annot/src/threed.rs | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index c31ef19b17..aae69ddb7a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2296,6 +2296,7 @@ fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"), (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"), (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"), + (picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"), ] { let mut doc = fixture(); let new = NewModel3d { @@ -2310,3 +2311,22 @@ fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added"); } } + +/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture +/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the +/// rectangle. +#[test] +fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() { + let mut doc = fixture(); + let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858]; + let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at }; + let new = NewModel3d { + page: 0, + rect: [0.0, 100.0, 3.6, 110.8], + model: b"PRC rest", + activation: Activation3d::default(), + poster: Some(poster), + contents: "", + }; + assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1)); +} diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs index be632337cb..cea6568bda 100644 --- a/crates/annot/src/threed.rs +++ b/crates/annot/src/threed.rs @@ -199,8 +199,11 @@ fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: O /// The poster drawing `p` into its box, which must lie within `rect`. fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result { let at = normalize(p.at); - let inside = finite(&p.at) && at[0] >= rect[0] && at[1] >= rect[1] && at[2] <= rect[2] && at[3] <= rect[3] && at[2] > at[0] && at[3] > at[1]; - if !inside { + // A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp. + let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9; + let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack; + let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])]; + if !(near && at[2] > at[0] && at[3] > at[1]) { return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into())); } let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?; From 4c087336aa68fdeedc9f71602ee95fa79d734b2a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 18:45:41 -0500 Subject: [PATCH 3/4] M12: 3D models: declare PRC's Adobe extension before 2.0; refuse a poster with no area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before PDF 2.0, PRC in a 3D stream is an Adobe extension to PDF 1.7, so a PRC model added to a file below 2.0 now declares /Extensions /ADBE at BaseVersion 1.7, ExtensionLevel 1, keeping other extensions and a later ADBE level, and leaving an /ADBE that is not a dictionary (an array of them) alone (ISO 32000-2 §7.12). U3D and 2.0 files are unchanged. A PDF poster whose page box has no area was drawn as nothing behind a size forced to 1; it is now refused with EditError::Source. Both found by CodeRabbit. Tests a_prc_model_declares_the_adobe_extension_before_pdf_2 and a_3d_poster_page_without_area_is_refused fail with their fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/annot/src/tests.rs | 55 +++++++++++++++++++++++++++++++++++ crates/annot/src/threed.rs | 41 ++++++++++++++++++++++---- crates/engine/src/pictures.rs | 5 +++- crates/engine/src/tests.rs | 19 ++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) 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] From eaf06f9cb9a91a5d283c5f6dc58c746d3d957efa Mon Sep 17 00:00:00 2001 From: KenM76 Date: Mon, 2 Nov 2026 20:28:06 -0500 Subject: [PATCH 4/4] fix(cos): a save after undo keeps objects packed in a dropped object stream When the working snapshot is restored from an earlier revision (undo after a save), the next save appends to the last saved file through overlay_changes_from. An object stream of that file that the restored graph frees or reuses the number of took its unchanged members with it: they stayed listed in the dropped stream, and the file reread with no pages. Such members are now written again on their own. Adding a PRC model now edits the catalog (the ADBE extension), which made the model_3d automation test (save, undo, save) reach this case on CI. The same change is part of #3305 (skylartaylor), which also covers security changes. Test: cos a_restored_revision_keeps_what_the_saved_file_held_in_object_streams, failing with the fix reverted (the reread catalog's /Pages resolves to nothing). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 21 +++++++++++++++++++-- crates/cos/src/document/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ac18cff03..3fba3fd395 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -682,8 +682,9 @@ impl Document { /// /// Used when the working snapshot was restored from an earlier revision and the next /// incremental save must still append to this file (the last saved revision). Objects - /// that already match are left untouched. Reads go through private readers so neither - /// document's editing cache is filled. + /// that already match are left untouched, except members of an object stream of this file + /// whose stream number `current` frees or reuses: those are written again on their own. + /// Reads go through private readers so neither document's editing cache is filled. pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { let saved_nums = self.object_numbers(); let current_nums = current.object_numbers(); @@ -707,6 +708,22 @@ impl Document { } } } + // An object stream of this file that is freed or replaced takes its members with it, so + // a member that is otherwise unchanged is written again on its own. + let gone = |n: u32| freed.binary_search(&n).is_ok() || changed.binary_search(&n).is_ok(); + let orphaned: Vec = current_nums + .iter() + .copied() + .filter(|&num| changed.binary_search(&num).is_err()) + .filter(|&num| { + let packed_in = match self.xref_entry(num) { + Some(XrefEntry::InStream { stream, .. }) => Some(stream), + _ => None, + }; + packed_in.is_some_and(gone) + }) + .collect(); + changed.extend(orphaned); for num in freed { let generation = self.generation(num); self.free(ObjRef::new(num, generation)); diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index cca4b87326..044a410862 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1701,3 +1701,29 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { }); rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } + +#[test] +fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() { + // The earlier revision: an empty document read back from a file whose catalog and pages + // sit in an object stream. + let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap(); + // The saved revision adds an object the catalog links to, so a full save numbers its objects + // anew; an undo then returns to the earlier graph. + let mut later = earlier.clone(); + let extra = later.add(Object::Int(7)); + let root = later.root().unwrap(); + later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap(); + let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap(); + let mut base = Document::open(Arc::new(saved)).unwrap(); + assert!( + base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))), + "the saved file holds objects in a stream" + ); + base.overlay_changes_from(&earlier).unwrap(); + let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap(); + let reread = Document::open(Arc::new(file)).unwrap(); + let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone(); + assert!(catalog.get(b"Extra").is_none(), "the restored catalog"); + let pages = reread.resolve(catalog.get(b"Pages").unwrap()); + assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}"); +}