From ae1b28cf1dc9d8e133344ff43161111a4b685555 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 21:44:55 -0500 Subject: [PATCH 1/4] M12: model3d_render, a 3D annotation's PRC model drawn to a PNG pdfcraft-annot reads a page's 3D annotations and the model each shows: the /3DD stream, or the stream a /3DRef names (at most four deep, so a cycle ends), decoded within 256 MB and typed PRC or U3D by /Subtype. pdfcraft-engine exposes both on Document. The automation tool model3d_render picks a page's Nth 3D model, parses PRC with pdfcraft-prc, assembles and frames it with pdfcraft-prc-render from a chosen direction (default an isometric-style view, Z up), and returns the image as a PNG. U3D gives an error until a decoder lands. parity misc.3d-view moves to partial. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 2 + crates/annot/src/lib.rs | 2 + crates/annot/src/model3d.rs | 86 ++++++++++++++ crates/annot/src/model3d/tests.rs | 84 ++++++++++++++ crates/automation/Cargo.toml | 2 + crates/automation/src/dispatch.rs | 1 + crates/automation/src/lib.rs | 1 + crates/automation/src/model3d_render.rs | 106 ++++++++++++++++++ crates/automation/src/tools.rs | 7 ++ crates/automation/tests/it/main.rs | 1 + crates/automation/tests/it/model3d_render.rs | 112 +++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/model3d.rs | 19 ++++ parity/acrobat-features.toml | 8 +- 14 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 crates/annot/src/model3d.rs create mode 100644 crates/annot/src/model3d/tests.rs create mode 100644 crates/automation/src/model3d_render.rs create mode 100644 crates/automation/tests/it/model3d_render.rs create mode 100644 crates/engine/src/model3d.rs diff --git a/Cargo.lock b/Cargo.lock index bc76adfde5..72be32bc2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4277,6 +4277,8 @@ dependencies = [ "pdfcraft-model", "pdfcraft-organize", "pdfcraft-platform", + "pdfcraft-prc", + "pdfcraft-prc-render", "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..0c83538ff8 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -22,6 +22,7 @@ mod create; mod draw; mod info; mod lookup; +mod model3d; mod modify; mod types; @@ -36,6 +37,7 @@ use lookup::{annots, page_ref}; pub use lookup::{ has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, }; +pub use model3d::{MAX_MODEL_BYTES, Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs new file mode 100644 index 0000000000..66a755b862 --- /dev/null +++ b/crates/annot/src/model3d.rs @@ -0,0 +1,86 @@ +//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a +//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares. + +use super::lookup::{annots, page_ref}; +use super::{AnnotError, Dict, Document, Object}; + +/// The largest decoded 3D stream [`model_3d`] reads, in bytes. +pub const MAX_MODEL_BYTES: usize = 256 << 20; + +/// How many `/3DRef` dictionaries [`model_3d`] follows before giving up: the standard allows one, +/// so a longer chain is damage, and a cycle ends here too. +const MAX_REF_DEPTH: usize = 4; + +/// The format of a 3D stream, from its `/Subtype`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Model3dKind { + /// Product Representation Compact (ISO 14739-1). + Prc, + /// Universal 3D (ECMA-363). + U3d, +} + +/// A 3D annotation's model: its format and the decoded stream. +#[derive(Debug, Clone, PartialEq)] +pub struct Model3d { + pub kind: Model3dKind, + pub data: Vec, +} + +/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. +/// +/// # Errors +/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`]. +pub fn model_3d_annotations(doc: &Document, page: usize) -> Result, AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect()) +} + +/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded. +/// +/// # Errors +/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`] +/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or +/// U3D, or decodes past [`MAX_MODEL_BYTES`]. +pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result { + let list = annots(doc, page_ref(doc, page)?); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1)); + if annot.name(b"Subtype") != Some(b"3D") { + return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at()))); + } + let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?; + let Object::Stream(s) = stream.as_ref() else { + return Err(AnnotError::Invalid(format!("{} has no 3D stream", at()))); + }; + let kind = match s.dict.name(b"Subtype") { + Some(b"PRC") => Model3dKind::Prc, + Some(b"U3D") => Model3dKind::U3d, + other => { + let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned()); + return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at()))); + } + }; + let data = doc + .with_direct_parms(s) + .decoded_within(MAX_MODEL_BYTES) + .map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?; + Ok(Model3d { kind, data }) +} + +/// The 3D stream an annotation's `/3DD` names, through at most [`MAX_REF_DEPTH`] `/3DRef`s. +fn stream_of(doc: &Document, annot: &Dict) -> Option> { + let mut obj = doc.resolve(annot.get(b"3DD")?); + for _ in 0..=MAX_REF_DEPTH { + match obj.as_ref() { + Object::Stream(_) => return Some(obj), + Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => obj = doc.resolve(d.get(b"3DD")?), + _ => return None, + } + } + None +} + +#[cfg(test)] +mod tests; diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs new file mode 100644 index 0000000000..a58e24c30c --- /dev/null +++ b/crates/annot/src/model3d/tests.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use super::*; + +/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on. +fn doc(annots: &str, extra: &[Vec]) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(), + ]; + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +fn stream(dict: &str, body: &[u8]) -> Vec { + let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes(); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn annot(dd: &str) -> Vec { + format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes() +} + +#[test] +fn a_plain_3d_stream_is_read_through_its_filters() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]); + assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() })); +} + +#[test] +fn a_3dref_leads_to_the_stream_it_names() { + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec())); +} + +#[test] +fn a_u3d_stream_is_read_and_says_so() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d)); +} + +#[test] +fn a_3d_annotation_without_a_stream_is_invalid() { + let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn an_unknown_format_is_invalid() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)"))); +} + +#[test] +fn a_cycle_of_3drefs_ends() { + let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3DD 6 0 R >>".to_vec(), b"<< /Type /3DRef /3DD 5 0 R >>".to_vec()]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn another_annotation_is_not_a_3d_one() { + let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation"))); + assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 })); + assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1])); + assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1))); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index a6e43de1f0..a825b1fe52 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-organize = { workspace = true } pdfcraft-platform = { workspace = true } +pdfcraft-prc = { workspace = true } +pdfcraft-prc-render = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index f3c51d22e0..8269963598 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -69,6 +69,7 @@ impl Automation { self.apply(&a, edit)? } "page_render" => return self.page_render(&a), + "model3d_render" => return self.model3d_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 5bcb032fce..eb58b52e75 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -27,6 +27,7 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; +mod model3d_render; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model3d_render.rs b/crates/automation/src/model3d_render.rs new file mode 100644 index 0000000000..7f789118a5 --- /dev/null +++ b/crates/automation/src/model3d_render.rs @@ -0,0 +1,106 @@ +//! `model3d_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. + +use pdfcraft_engine::model3d::Model3dKind; +use pdfcraft_prc::PrcFile; +use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render}; + +use super::Automation; +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::pages::encode_png; + +/// Default and largest side of the image, in pixels. +pub(crate) const DEFAULT_SIDE: u32 = 512; +pub(crate) const MAX_SIDE: u32 = 4096; + +/// The default view: from the front, right and above, as an isometric view looks. +const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0]; + +impl Automation { + pub(crate) fn model3d_render(&mut self, a: &Args) -> Result> { + let page = self.page(a)?; + let width = side(a, "width")?; + let height = side(a, "height")?; + let direction = direction(a)?; + let perspective = a.opt_bool("perspective")?.unwrap_or(true); + let doc = self.doc(a)?; + let found = doc.model_3d_annotations(page).map_err(failed)?; + let index = match a.opt_int("model")? { + None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?, + Some(n) => { + let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?; + *found.get(pos).ok_or_else(|| { + ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())) + })? + } + }; + let model = doc.model_3d(page, index).map_err(failed)?; + if model.kind == Model3dKind::U3d { + return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can")); + } + let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?; + let meshes = assemble(&prc).map_err(failed)?; + let aspect = f64::from(width) / f64::from(height); + let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?; + let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?; + let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?; + Ok(vec![Content::Png { data, width: image.width, height: image.height }]) + } +} + +fn side(a: &Args, key: &str) -> Result { + match a.opt_int(key)? { + None => Ok(DEFAULT_SIDE), + Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))), + } +} + +/// `direction`: three finite numbers, not all zero, from the eye towards the model. +fn direction(a: &Args) -> Result<[f64; 3]> { + let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) }; + let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero"); + let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?; + let mut out = [0.0; 3]; + for (o, x) in out.iter_mut().zip(items) { + *o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?; + } + if out.iter().all(|c| *c == 0.0) { + return Err(wrong()); + } + Ok(out) +} + +/// Z is up, as CAD models are drawn, unless the view looks along it. +fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { + if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] } +} + +/// Straight RGBA to the premultiplied RGBA `encode_png` takes. +fn premultiplied(mut rgba: Vec) -> Vec { + for px in rgba.chunks_exact_mut(4) { + if let [r, g, b, alpha] = px { + let a = u16::from(*alpha); + for c in [r, g, b] { + *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); + } + } + } + rgba +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn premultiplying_scales_colour_by_alpha() { + assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]); + } + + #[test] + fn a_view_along_z_keeps_y_up() { + assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]); + assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]); + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4672e49886..0d8a3a6c97 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -181,6 +181,13 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), + t("model3d_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + .ro() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, + "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, + "perspective": { "type": "boolean" } + }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d36dbe93a9..d4946b6d73 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 model3d_render; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model3d_render.rs b/crates/automation/tests/it/model3d_render.rs new file mode 100644 index 0000000000..47e757906a --- /dev/null +++ b/crates/automation/tests/it/model3d_render.rs @@ -0,0 +1,112 @@ +//! `model3d_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// One triangle at z = 0 facing +z, with corners (-1,-1), (1,-1) and (0,1), as a PRC file of one +/// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`. +const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001"; + +/// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`. +fn pdf(subtype: &str, hex: &str) -> Vec { + let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] >> 4 0 R] >>".into(), + "<< /Type /Annot /Subtype /3D /Rect [20 20 180 180] /3DD 5 0 R >>".into(), + stream, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A fresh folder with `prc.pdf` and `u3d.pdf` in `in`, `outside.pdf` beside it, and an +/// automation rooted at `in`. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-model3d-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in")).unwrap(); + std::fs::write(dir.join("in/prc.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + std::fs::write(dir.join("in/u3d.pdf"), pdf("U3D", "553344")).unwrap(); + std::fs::write(dir.join("outside.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + let a = Automation::new().with_root(dir.join("in")).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +/// The RGB of pixel (x, y) of a PNG. +fn rgb(data: &[u8], x: usize, y: usize) -> [u8; 3] { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.line_size / info.width as usize; + let at = y * info.line_size + x * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +#[test] +fn a_prc_model_is_drawn_over_the_background() { + let (_dir, mut a) = setup("prc"); + let doc = open(&mut a, "prc.pdf"); + let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); + let out = a.call("model3d_render", &args).unwrap(); + let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") }; + assert_eq!((*width, *height), (64, 48)); + assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); + assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); + // In perspective the headlight meets the face at a slight angle, so only not-background holds. + let out = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too"); + // The same model named explicitly, and the default isometric view, both draw. + assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); +} + +#[test] +fn a_u3d_model_or_a_missing_one_is_a_clear_error() { + let (_dir, mut a) = setup("u3d"); + let doc = open(&mut a, "u3d.pdf"); + let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); + assert!(err.contains("U3D"), "{err}"); + let doc = open(&mut a, "prc.pdf"); + let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); + assert!(err.contains("has 1 3D models, not 2"), "{err}"); + for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] { + let mut args = json!({ "doc": doc, "page": 1 }); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("model3d_render", &args).is_err(), "{bad}"); + } +} + +#[test] +fn a_model_outside_the_root_is_never_opened_to_be_drawn() { + let (dir, mut a) = setup("root"); + let outside = dir.join("outside.pdf"); + for path in [Value::from("../outside.pdf"), Value::from(outside.to_string_lossy().into_owned())] { + assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root"); + } + // The tool takes no path of its own: only a document opened inside the root reaches it. + assert!(a.call("model3d_render", &json!({ "doc": 1, "page": 1 })).is_err()); + let doc = open(&mut a, "prc.pdf"); + assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).is_ok()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe139164be..3359bad37c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -35,6 +35,7 @@ mod history; pub mod js; mod layers; pub mod links; +pub mod model3d; pub mod ocr; mod open; pub mod optimizer; diff --git a/crates/engine/src/model3d.rs b/crates/engine/src/model3d.rs new file mode 100644 index 0000000000..b95a332a22 --- /dev/null +++ b/crates/engine/src/model3d.rs @@ -0,0 +1,19 @@ +//! The 3D models on a page (ISO 32000-2 §13.6), read so a renderer can draw them. + +pub use pdfcraft_annot::{MAX_MODEL_BYTES, Model3d, Model3dKind}; + +use crate::Document; + +impl Document { + /// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. + pub fn model_3d_annotations(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d_annotations(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// The decoded model of the 3D annotation at `/Annots` position `index` on 0-based `page`. + pub fn model_3d(&self, page: usize, index: usize) -> Result { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d(&editor.cos, page, index).map_err(|e| e.to_string()) + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8b7ac909a0..21e208c003 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9896,11 +9896,11 @@ title = "View 3D (U3D/PRC) content" area = "N" tier = "P3" milestone = "M12" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["model3d_render"] +evidence = ["crates/automation/tests/it/model3d_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] +notes = "headless only: model3d_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.legacy-multimedia" From fb05b733f98fa3c8e9f14d6a5782766abdc9d387 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 22:53:15 -0500 Subject: [PATCH 2/4] M12: model_render, and a 3D reference's stream under /3D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 3D reference dictionary holds the shared stream under /3D (ISO 32000-2 §13.6.3), not /3DD; it is followed once, and the caller refuses anything that is not a stream, so a chain or cycle of references ends. The tool is renamed model_render, since MCP tool names here are lowercase letters and underscores only, and to match model_add. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/annot/src/model3d.rs | 19 ++++++------------- crates/annot/src/model3d/tests.rs | 10 +++++++--- crates/automation/README.md | 9 +++++++++ crates/automation/src/dispatch.rs | 2 +- crates/automation/src/lib.rs | 2 +- .../{model3d_render.rs => model_render.rs} | 10 +++++----- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/main.rs | 2 +- .../it/{model3d_render.rs => model_render.rs} | 18 +++++++++--------- parity/acrobat-features.toml | 6 +++--- 10 files changed, 43 insertions(+), 37 deletions(-) rename crates/automation/src/{model3d_render.rs => model_render.rs} (91%) rename crates/automation/tests/it/{model3d_render.rs => model_render.rs} (86%) diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs index 66a755b862..60d2df0f2a 100644 --- a/crates/annot/src/model3d.rs +++ b/crates/annot/src/model3d.rs @@ -7,10 +7,6 @@ use super::{AnnotError, Dict, Document, Object}; /// The largest decoded 3D stream [`model_3d`] reads, in bytes. pub const MAX_MODEL_BYTES: usize = 256 << 20; -/// How many `/3DRef` dictionaries [`model_3d`] follows before giving up: the standard allows one, -/// so a longer chain is damage, and a cycle ends here too. -const MAX_REF_DEPTH: usize = 4; - /// The format of a 3D stream, from its `/Subtype`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Model3dKind { @@ -69,17 +65,14 @@ pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result Option> { - let mut obj = doc.resolve(annot.get(b"3DD")?); - for _ in 0..=MAX_REF_DEPTH { - match obj.as_ref() { - Object::Stream(_) => return Some(obj), - Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => obj = doc.resolve(d.get(b"3DD")?), - _ => return None, - } + let obj = doc.resolve(annot.get(b"3DD")?); + match obj.as_ref() { + Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)), + _ => Some(obj), } - None } #[cfg(test)] diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs index a58e24c30c..90addb1bb6 100644 --- a/crates/annot/src/model3d/tests.rs +++ b/crates/annot/src/model3d/tests.rs @@ -46,7 +46,7 @@ fn a_plain_3d_stream_is_read_through_its_filters() { #[test] fn a_3dref_leads_to_the_stream_it_names() { - let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec())); } @@ -69,8 +69,12 @@ fn an_unknown_format_is_invalid() { } #[test] -fn a_cycle_of_3drefs_ends() { - let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3DD 6 0 R >>".to_vec(), b"<< /Type /3DRef /3DD 5 0 R >>".to_vec()]); +fn a_3dref_is_followed_once_and_only_to_a_stream() { + // A reference naming another reference, or one that names itself, is damage: no stream. + let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); + // The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key. + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); } diff --git a/crates/automation/README.md b/crates/automation/README.md index 8ffa2d0b3e..d218fbcda6 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -250,6 +250,15 @@ choices and values over 4096 UTF-8 bytes return an error. Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. +## Rendering a 3D model + +`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D +reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1); +`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye +(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true. +The model is framed to fill the image and shaded by a headlight on white. U3D models are refused +with an error, and the annotation's own views, lighting and render modes are not applied yet. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 8269963598..f694506adf 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -69,7 +69,7 @@ impl Automation { self.apply(&a, edit)? } "page_render" => return self.page_render(&a), - "model3d_render" => return self.model3d_render(&a), + "model_render" => return self.model_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index eb58b52e75..ddbfd45786 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -27,7 +27,7 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; -mod model3d_render; +mod model_render; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model3d_render.rs b/crates/automation/src/model_render.rs similarity index 91% rename from crates/automation/src/model3d_render.rs rename to crates/automation/src/model_render.rs index 7f789118a5..145103ffe9 100644 --- a/crates/automation/src/model3d_render.rs +++ b/crates/automation/src/model_render.rs @@ -1,4 +1,4 @@ -//! `model3d_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. +//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. use pdfcraft_engine::model3d::Model3dKind; use pdfcraft_prc::PrcFile; @@ -18,7 +18,7 @@ pub(crate) const MAX_SIDE: u32 = 4096; const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0]; impl Automation { - pub(crate) fn model3d_render(&mut self, a: &Args) -> Result> { + pub(crate) fn model_render(&mut self, a: &Args) -> Result> { let page = self.page(a)?; let width = side(a, "width")?; let height = side(a, "height")?; @@ -30,9 +30,9 @@ impl Automation { None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?, Some(n) => { let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?; - *found.get(pos).ok_or_else(|| { - ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())) - })? + *found + .get(pos) + .ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))? } }; let model = doc.model_3d(page, index).map_err(failed)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0d8a3a6c97..7944896b27 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -181,7 +181,7 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), - t("model3d_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d4946b6d73..85524f13a2 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,7 +8,7 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; -mod model3d_render; +mod model_render; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model3d_render.rs b/crates/automation/tests/it/model_render.rs similarity index 86% rename from crates/automation/tests/it/model3d_render.rs rename to crates/automation/tests/it/model_render.rs index 47e757906a..615a5346a9 100644 --- a/crates/automation/tests/it/model3d_render.rs +++ b/crates/automation/tests/it/model_render.rs @@ -1,4 +1,4 @@ -//! `model3d_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. +//! `model_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. use std::path::PathBuf; @@ -69,32 +69,32 @@ fn a_prc_model_is_drawn_over_the_background() { let (_dir, mut a) = setup("prc"); let doc = open(&mut a, "prc.pdf"); let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); - let out = a.call("model3d_render", &args).unwrap(); + let out = a.call("model_render", &args).unwrap(); let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") }; assert_eq!((*width, *height), (64, 48)); assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); // In perspective the headlight meets the face at a slight angle, so only not-background holds. - let out = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); + let out = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too"); // The same model named explicitly, and the default isometric view, both draw. - assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); } #[test] fn a_u3d_model_or_a_missing_one_is_a_clear_error() { let (_dir, mut a) = setup("u3d"); let doc = open(&mut a, "u3d.pdf"); - let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); assert!(err.contains("U3D"), "{err}"); let doc = open(&mut a, "prc.pdf"); - let err = a.call("model3d_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); assert!(err.contains("has 1 3D models, not 2"), "{err}"); for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] { let mut args = json!({ "doc": doc, "page": 1 }); args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); - assert!(a.call("model3d_render", &args).is_err(), "{bad}"); + assert!(a.call("model_render", &args).is_err(), "{bad}"); } } @@ -106,7 +106,7 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() { assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root"); } // The tool takes no path of its own: only a document opened inside the root reaches it. - assert!(a.call("model3d_render", &json!({ "doc": 1, "page": 1 })).is_err()); + assert!(a.call("model_render", &json!({ "doc": 1, "page": 1 })).is_err()); let doc = open(&mut a, "prc.pdf"); - assert!(a.call("model3d_render", &json!({ "doc": doc, "page": 1 })).is_ok()); + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok()); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 21e208c003..26ca5e9df8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9898,9 +9898,9 @@ tier = "P3" milestone = "M12" status = "partial" commands = [] -tools = ["model3d_render"] -evidence = ["crates/automation/tests/it/model3d_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] -notes = "headless only: model3d_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" +tools = ["model_render"] +evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] +notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.legacy-multimedia" From d3821f35747a25c8822e307501f2c46e12127a6b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 23:47:10 -0500 Subject: [PATCH 3/4] M12: model_render premultiplies through as_chunks_mut CI's clippy (1.99) flags chunks_exact_mut with a constant size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/src/model_render.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/model_render.rs b/crates/automation/src/model_render.rs index 145103ffe9..dbfabc6708 100644 --- a/crates/automation/src/model_render.rs +++ b/crates/automation/src/model_render.rs @@ -78,12 +78,10 @@ fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { /// Straight RGBA to the premultiplied RGBA `encode_png` takes. fn premultiplied(mut rgba: Vec) -> Vec { - for px in rgba.chunks_exact_mut(4) { - if let [r, g, b, alpha] = px { - let a = u16::from(*alpha); - for c in [r, g, b] { - *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); - } + for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 { + let a = u16::from(*alpha); + for c in [r, g, b] { + *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); } } rgba From 52d68d7844243cd04e0b1deac38f1492e5212c20 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:52:51 +0100 Subject: [PATCH 4/4] annot: share threed's MAX_MODEL_BYTES with model3d main's 3D-embed change (#5808) added crates/annot/src/threed.rs with its own pub MAX_MODEL_BYTES (same 256 MiB value), so both were re-exported from the crate root and pdfcraft-annot no longer compiled after merging main. model3d now uses threed's constant. --- crates/annot/src/lib.rs | 2 +- crates/annot/src/model3d.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index ea7b0c227f..4f24d04b08 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -38,7 +38,7 @@ use lookup::{annots, page_ref}; pub use lookup::{ has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, }; -pub use model3d::{MAX_MODEL_BYTES, Model3d, Model3dKind, model_3d, model_3d_annotations}; +pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; pub(crate) use modify::n; pub use modify::{ add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs index 60d2df0f2a..b50342898e 100644 --- a/crates/annot/src/model3d.rs +++ b/crates/annot/src/model3d.rs @@ -2,11 +2,9 @@ //! 3D stream, or a 3D reference dictionary naming the stream another annotation shares. use super::lookup::{annots, page_ref}; +use super::threed::MAX_MODEL_BYTES; use super::{AnnotError, Dict, Document, Object}; -/// The largest decoded 3D stream [`model_3d`] reads, in bytes. -pub const MAX_MODEL_BYTES: usize = 256 << 20; - /// The format of a 3D stream, from its `/Subtype`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Model3dKind {