mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
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:
24 files changed
+903
-20
No files matched your search
@@ -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-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-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.
|
- **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.
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
|
|||||||
Some(res)
|
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();
|
let mut d = Dict::new();
|
||||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
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)
|
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
|
/// 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.
|
/// when the signature is added to a turned page): 0, 90, 180 or 270.
|
||||||
pub(crate) fn picture_turn(d: &Dict) -> i64 {
|
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.
|
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
|
||||||
fn helvetica_resource() -> Object {
|
pub(crate) fn helvetica_resource() -> Object {
|
||||||
let mut font = Dict::new();
|
let mut font = Dict::new();
|
||||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
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
|
// 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.
|
// 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) {
|
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 image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
|
||||||
let place = if image {
|
let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() {
|
||||||
format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
|
[w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
|
||||||
} else {
|
_ => None,
|
||||||
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 place = picture_place(rect, image, size)?;
|
||||||
};
|
|
||||||
c.push_str(&format!("q {place} /Pic Do Q\n"));
|
c.push_str(&format!("q {place} /Pic Do Q\n"));
|
||||||
let mut xo = Dict::new();
|
let mut xo = Dict::new();
|
||||||
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ mod draw;
|
|||||||
mod info;
|
mod info;
|
||||||
mod lookup;
|
mod lookup;
|
||||||
mod modify;
|
mod modify;
|
||||||
|
mod threed;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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,
|
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,
|
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::{
|
pub use types::{
|
||||||
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
||||||
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
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.
|
/// with a font that has those characters. The message names the character.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Undrawable(String),
|
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}")]
|
#[error("{0}")]
|
||||||
Cos(#[from] pdfcraft_cos::CosError),
|
Cos(#[from] pdfcraft_cos::CosError),
|
||||||
}
|
}
|
||||||
@@ -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"U3D | ||||||