Merge pull request #5808 from KenM76/feat/3d-embed

M12: add a 3D model (U3D or PRC) to a page with a poster (#1297)
This commit is contained in:
FredApps
2026-11-04 18:53:39 +01:00
committed by GitHub
24 changed files with 903 additions and 20 deletions
+2
View File
@@ -231,6 +231,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
- **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-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
- **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.
+19 -10
View File
@@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
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<String> {
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));
+8
View File
@@ -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),
}
+135
View File
@@ -2251,6 +2251,141 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() {
}
}
/// 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");
}
/// 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];
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"),
(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 {
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");
}
}
/// 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));
}
#[test]
fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
let mut doc = fixture();
+292
View File
@@ -0,0 +1,292 @@
//! 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<Self, AnnotError> {
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 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",
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<PosterPicture>,
/// `/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<ModelFormat, AnnotError> {
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<usize, AnnotError> {
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())?;
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 Some(root) = doc.root() else { return Ok(()) };
if !at_least(doc, min) {
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<Stream, AnnotError> {
let at = normalize(p.at);
// 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()))?;
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()));
}
}
+1 -1
View File
@@ -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])]
}
+1
View File
@@ -772,6 +772,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)?,
+12 -2
View File
@@ -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() {
+1
View File
@@ -27,6 +27,7 @@ mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
mod measure;
mod model3d;
mod printing;
mod reading;
mod redact;
+114
View File
@@ -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<Value> {
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<Vec<u8>> {
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}");
}
}
}
+14
View File
@@ -513,6 +513,20 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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(
@@ -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})),
+1
View File
@@ -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;
+120
View File
@@ -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<u8> {
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<Dict>) {
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<f64> = 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!(&not_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());
}
+19 -2
View File
@@ -679,8 +679,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();
@@ -704,6 +705,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<u32> = 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));
+26
View File
@@ -1846,6 +1846,32 @@ 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:?}");
}
/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has
/// to measure the cache again on every read (#5873).
#[test]
+12 -1
View File
@@ -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;
@@ -166,6 +166,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<Vec<u8>>,
poster: Option<MarkFile>,
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,
+2
View File
@@ -69,6 +69,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Add inserted text",
"Add file attachment",
"Add stamp",
"Add 3D model",
"Delete comment",
"Edit comment",
"Reply",
@@ -207,6 +208,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"),
+12 -1
View File
@@ -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};
@@ -50,6 +50,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 { .. }
@@ -513,6 +514,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 } => {
+1
View File
@@ -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 { .. }
+4 -3
View File
@@ -111,9 +111,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;
@@ -143,7 +144,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;
+12
View File
@@ -60,6 +60,18 @@ 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<pdfcraft_annot::PosterPicture, EditError> {
let src = mark_source(doc, file)?;
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) })
}
/// 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()?;
+79
View File
@@ -549,6 +549,85 @@ 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<u8>| 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<f64> = 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}");
}
/// 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]
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<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
+12
View File
@@ -9914,6 +9914,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)"