mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
* Stamps and added images follow a JPEG's EXIF orientation (part of #434) Phones store a portrait photo as landscape pixels plus an EXIF Orientation tag. A JPEG went into the PDF as it was (DCTDecode), and PDF image XObjects have no such tag, so a photo used as a custom stamp, an added or replaced image, a watermark or background, or a button image showed up turned 90 degrees. Before these images are embedded, a JPEG whose Orientation is not 1 is decoded, turned the way the tag says and re-encoded (quality 92). Its JFIF resolution is kept, swapped for quarter turns, so the natural size on the page stays right. Other images, JPEGs without the tag, JPEGs that fail to decode and photos over 64 megapixels are embedded unchanged, as before. Fill & Sign image signatures already applied the tag. * Image signatures stay upright and anchored on rotated pages (part of #434) On a page with /Rotate 90, 180 or 270 an image signature turned with the page, because its picture was drawn along user-space axes while the click or the "at" argument is a displayed point. It was also anchored at the wrong point: its left edge and vertical centre were taken in user space, so on a 90 degree page the image centre ended up elsewhere. The signature's appearance now carries the counter-rotation as its form /Matrix, which viewers fit to /Rect, so the picture reads upright as displayed. The page's rotation is kept in /PCPictureRotate, so restyling regenerates the same matrix and moving and resizing keep it. The box of the signature runs along the displayed axes, so its left edge and vertical centre are where they were asked for. The page view matrix in pdfcraft-model is split out as view_matrix_for. The Fill & Sign preview and the live move/resize preview draw the picture the way the appearance does, and the resize handles keep the image's proportions as displayed. comment_image_preview reports image_rotation, how far the image is shown turned (0 for one placed upright). Custom image stamps are left to #473. * Use is_multiple_of for the quarter-turn checks (clippy) (#434) * Probe the pointer preview away from egui_kittest's drawn cursor (#434) The kittest render paints a mouse cursor (a 16 px triangle with a black outline) at the hover point, so the 2 pt probe beside the pointer landed on its outline when zoom and view rotation made 2 pt only a pixel or two. Put the stroke's block at the picture's right end and probe there and at its far margin instead.
170 lines
6.9 KiB
Rust
170 lines
6.9 KiB
Rust
//! pdfcraft-model — typed views over the PDF object graph (L2). See the README.
|
|
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
|
|
|
/// Attributes a page inherits from its ancestors (ISO 32000-2 §7.7.3.4).
|
|
pub const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"];
|
|
|
|
/// A leaf page and its effective (inherited) attributes.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Page {
|
|
pub obj: ObjRef,
|
|
/// The page dictionary with inherited attributes filled in.
|
|
pub dict: Dict,
|
|
}
|
|
|
|
fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
|
|
let o = doc.resolve(o?);
|
|
let v: Vec<f64> = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
|
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
|
}
|
|
|
|
impl Page {
|
|
/// The visible region (crop box clipped to the media box), in user space.
|
|
pub fn crop(&self, doc: &Document) -> [f64; 4] {
|
|
let media = rect(doc, self.dict.get(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]);
|
|
match rect(doc, self.dict.get(b"CropBox")) {
|
|
Some(c) => {
|
|
let r = [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])];
|
|
if r[2] > r[0] && r[3] > r[1] { r } else { media }
|
|
}
|
|
None => media,
|
|
}
|
|
}
|
|
|
|
/// Clockwise rotation when displayed: 0, 90, 180 or 270.
|
|
pub fn rotation(&self, doc: &Document) -> i64 {
|
|
self.dict.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).rem_euclid(360) / 90 * 90
|
|
}
|
|
|
|
/// The displayed size (width, height) in points.
|
|
pub fn display_size(&self, doc: &Document) -> (f64, f64) {
|
|
let c = self.crop(doc);
|
|
let (w, h) = (c[2] - c[0], c[3] - c[1]);
|
|
if self.rotation(doc) % 180 == 0 { (w, h) } else { (h, w) }
|
|
}
|
|
|
|
/// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as
|
|
/// shown, y up, after `/Rotate`) to user space.
|
|
pub fn view_matrix(&self, doc: &Document) -> [f64; 6] {
|
|
view_matrix_for(self.rotation(doc), self.crop(doc))
|
|
}
|
|
}
|
|
|
|
/// [`Page::view_matrix`] for a region `crop` (`[x0, y0, x1, y1]`) shown turned clockwise by
|
|
/// `rotation` (0, 90, 180 or 270): the matrix from the turned region's own display space
|
|
/// (origin at its bottom-left, y up) to the region's space. For a rectangle in user space it
|
|
/// puts an upright picture into that rectangle as the page displays it.
|
|
pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
|
|
let [x0, y0, x1, y1] = crop;
|
|
match rotation {
|
|
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
|
|
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
|
|
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
|
|
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
|
|
}
|
|
}
|
|
|
|
/// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep
|
|
/// trees are cut off rather than followed.
|
|
pub fn pages(doc: &Document) -> Vec<Page> {
|
|
let Some(root) = doc.root() else { return Vec::new() };
|
|
let Some(top) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() };
|
|
let mut out = Vec::new();
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut stack: Vec<(ObjRef, Dict)> = vec![(top, Dict::new())];
|
|
while let Some((node, inherited)) = stack.pop() {
|
|
if !seen.insert(node) || seen.len() > 1_000_000 {
|
|
continue;
|
|
}
|
|
let obj = doc.get(node);
|
|
let Some(d) = obj.as_dict() else { continue };
|
|
let mut attrs = inherited.clone();
|
|
for k in INHERITABLE {
|
|
if let Some(v) = d.get(k) {
|
|
attrs.set(k.to_vec(), v.clone());
|
|
}
|
|
}
|
|
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
|
|
if is_pages {
|
|
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
|
for k in kids.iter().rev() {
|
|
if let Some(r) = k.as_ref() {
|
|
stack.push((r, attrs.clone()));
|
|
}
|
|
}
|
|
} else {
|
|
let mut dict = d.clone();
|
|
for k in INHERITABLE {
|
|
if !dict.contains(k)
|
|
&& let Some(v) = attrs.get(k)
|
|
{
|
|
dict.set(k.to_vec(), v.clone());
|
|
}
|
|
}
|
|
out.push(Page { obj: node, dict });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::*;
|
|
|
|
fn doc(rotate: i64) -> Document {
|
|
let objs = [
|
|
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
|
format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> >>"),
|
|
"<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] >>".to_string(),
|
|
];
|
|
let mut out = b"%PDF-1.7\n".to_vec();
|
|
let mut offs = Vec::new();
|
|
for (i, o) in objs.iter().enumerate() {
|
|
offs.push(out.len());
|
|
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
|
}
|
|
let x = out.len();
|
|
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
|
for o in offs {
|
|
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
|
Document::open(Arc::new(out)).unwrap()
|
|
}
|
|
|
|
fn apply(m: [f64; 6], x: f64, y: f64) -> [f64; 2] {
|
|
[m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
|
}
|
|
|
|
#[test]
|
|
fn inherited_attributes_and_display_geometry() {
|
|
for rotate in [0, 90, 180, 270, 450] {
|
|
let d = doc(rotate);
|
|
let p = &pages(&d)[0];
|
|
assert!(p.dict.contains(b"Resources") && p.dict.contains(b"MediaBox"));
|
|
assert_eq!(p.crop(&d), [10.0, 20.0, 210.0, 320.0]);
|
|
let (w, h) = p.display_size(&d);
|
|
let m = p.view_matrix(&d);
|
|
// The displayed page's corners map onto the crop box's corners.
|
|
let corners: Vec<[f64; 2]> = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|(x, y)| apply(m, *x, *y)).collect();
|
|
for c in &corners {
|
|
assert!([10.0, 210.0].contains(&c[0]) && [20.0, 320.0].contains(&c[1]), "{rotate}: {c:?}");
|
|
}
|
|
// The displayed top-left corner is where a reader sees it.
|
|
let tl = apply(m, 0.0, h);
|
|
let expect = match rotate % 360 {
|
|
0 => [10.0, 320.0],
|
|
90 => [10.0, 20.0],
|
|
180 => [210.0, 20.0],
|
|
_ => [210.0, 320.0],
|
|
};
|
|
assert_eq!(tl, expect, "rotation {rotate}");
|
|
}
|
|
}
|
|
}
|