Files
pdfcraft/crates/engine/src/pictures.rs
T
KenM76andClaude Opus 5.5 4c087336aa M12: 3D models: declare PRC's Adobe extension before 2.0; refuse a poster with no area
Before PDF 2.0, PRC in a 3D stream is an Adobe extension to PDF 1.7, so a
PRC model added to a file below 2.0 now declares /Extensions /ADBE at
BaseVersion 1.7, ExtensionLevel 1, keeping other extensions and a later
ADBE level, and leaving an /ADBE that is not a dictionary (an array of
them) alone (ISO 32000-2 §7.12). U3D and 2.0 files are unchanged.

A PDF poster whose page box has no area was drawn as nothing behind a
size forced to 1; it is now refused with EditError::Source.

Both found by CodeRabbit. Tests a_prc_model_declares_the_adobe_extension_before_pdf_2
and a_3d_poster_page_without_area_is_refused fail with their fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-11-02 18:48:12 -05:00

140 lines
5.7 KiB
Rust

//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds.
use std::sync::Arc;
use image::ImageDecoder;
use crate::EditError;
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
/// (0-based) of a PDF.
#[derive(Clone, Debug, PartialEq)]
pub struct MarkFile {
pub name: String,
pub bytes: Arc<Vec<u8>>,
pub page: usize,
}
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
pub(crate) fn embed_image(
doc: &mut pdfcraft_cos::Document,
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
pdfcraft_create::image_xobject(doc, name, bytes)
}
/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its
/// shape in a box of any other shape. A box that is already that shape (to within rounding) is
/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite
/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse.
pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] {
if !rect.iter().all(|v| v.is_finite()) {
return rect;
}
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]);
let usable = |v: f64| v.is_finite() && v > 0.0;
if !(usable(bw) && usable(bh) && usable(w) && usable(h)) {
return rect;
}
let k = (bw / w).min(bh / h);
let (fw, fh) = (w * k, h * k);
if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 {
return rect;
}
let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0);
[cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0]
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
let head = &f.bytes[..f.bytes.len().min(1024)];
if head.windows(5).any(|w| w == b"%PDF-") {
let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?;
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
} else {
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
}
}
/// 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()?;
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format);
let mut limits = image::Limits::default();
limits.max_alloc = Some(64_000_000 * 4);
reader.limits(limits);
let mut decoder = reader.into_decoder().ok()?;
let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder);
if w == 0 || h == 0 {
return None;
}
if format == image::ImageFormat::Jpeg
&& u64::from(w) * u64::from(h) <= (64 << 20)
&& let Ok(orientation) = decoder.orientation()
&& matches!(
orientation,
image::metadata::Orientation::Rotate90
| image::metadata::Orientation::Rotate270
| image::metadata::Orientation::Rotate90FlipH
| image::metadata::Orientation::Rotate270FlipH
)
{
std::mem::swap(&mut w, &mut h);
}
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::*;
fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg");
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() {
let turned_6 = phone_jpeg(60, 20, 6);
assert_eq!(image_dimensions(&turned_6), Some((20, 60)));
let turned_8 = phone_jpeg(60, 20, 8);
assert_eq!(image_dimensions(&turned_8), Some((20, 60)));
let upright_1 = phone_jpeg(60, 20, 1);
assert_eq!(image_dimensions(&upright_1), Some((60, 20)));
}
#[test]
fn invalid_inputs_return_none() {
assert_eq!(image_dimensions(&[]), None);
assert_eq!(image_dimensions(b"not an image"), None);
}
}