mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 17:34:06 -06:00
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
115 lines
5.3 KiB
Rust
115 lines
5.3 KiB
Rust
//! 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}");
|
|
}
|
|
}
|
|
}
|