use crate::image::ImageData;
use crate::names;
use crate::shading::Shading;
use crate::state::{ContentMarks, GraphicsState};
use crate::transparency::Transparency;
use kurbo::{Affine, BezPath, Rect};
use pdfrum_common::{DiagKind, Diagnostics, Severity};
use pdfrum_font::Font;
use pdfrum_object::{Dict, Resolve};
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
pub const DEFAULT_MEDIA_BOX: Rect = Rect::new(0.0, 0.0, 612.0, 792.0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Rotation {
#[default]
None,
Quarter,
Half,
ThreeQuarter,
}
impl Rotation {
#[must_use]
pub fn from_degrees(n: i64) -> Self {
let quarters = ((n / 90) % 4 + 4) % 4;
match quarters {
1 => Self::Quarter,
2 => Self::Half,
3 => Self::ThreeQuarter,
_ => Self::None,
}
}
#[must_use]
pub fn degrees(self) -> u32 {
match self {
Self::None => 0,
Self::Quarter => 90,
Self::Half => 180,
Self::ThreeQuarter => 270,
}
}
#[must_use]
pub fn quarters(self) -> u8 {
match self {
Self::None => 0,
Self::Quarter => 1,
Self::Half => 2,
Self::ThreeQuarter => 3,
}
}
#[must_use]
pub fn display_matrix(self, box_rect: Rect) -> Affine {
let (left, bottom, right, top) = (box_rect.x0, box_rect.y0, box_rect.x1, box_rect.y1);
match self {
Self::None => Affine::new([1.0, 0.0, 0.0, 1.0, -left, -bottom]),
Self::Quarter => Affine::new([0.0, -1.0, 1.0, 0.0, -bottom, right]),
Self::Half => Affine::new([-1.0, 0.0, 0.0, -1.0, right, top]),
Self::ThreeQuarter => Affine::new([0.0, 1.0, -1.0, 0.0, top, -left]),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("not a quarter turn: {0}")]
pub struct NotAQuarterTurn(String);
impl std::fmt::Display for Rotation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.degrees(), f)
}
}
impl std::str::FromStr for Rotation {
type Err = NotAQuarterTurn;
fn from_str(s: &str) -> core::result::Result<Rotation, NotAQuarterTurn> {
match s {
"0" => Ok(Rotation::None),
"90" => Ok(Rotation::Quarter),
"180" => Ok(Rotation::Half),
"270" => Ok(Rotation::ThreeQuarter),
other => Err(NotAQuarterTurn(other.to_owned())),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PathObject {
pub path: BezPath,
pub matrix: Affine,
pub fill_rule: crate::ops::FillRule,
pub stroke: bool,
}
#[derive(Debug, Clone)]
pub struct TextObject {
pub segments: Box<[TextSegment]>,
pub position: kurbo::Point,
pub matrix: Affine,
pub font: Option<(Arc<Font>, f32)>,
pub font_source: Option<pdfrum_object::ObjRef>,
pub render_mode: crate::ops::TextRenderMode,
pub type3_metrics: BTreeMap<u32, crate::type3::Type3Metrics>,
}
impl PartialEq for TextObject {
fn eq(&self, other: &Self) -> bool {
let same_font = match (&self.font, &other.font) {
(Some((a, sa)), Some((b, sb))) => a.id() == b.id() && sa == sb,
(None, None) => true,
_ => false,
};
same_font
&& self.segments == other.segments
&& self.position == other.position
&& self.matrix == other.matrix
&& self.render_mode == other.render_mode
&& self.type3_metrics == other.type3_metrics
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextSegment {
pub codes: Box<[u8]>,
pub kerning: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageObject {
pub image: Arc<ImageData>,
pub matrix: Affine,
pub is_mask: bool,
pub oc: Option<Arc<Dict>>,
pub source: Option<pdfrum_object::ObjRef>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ShadingObject {
pub shading: Arc<Shading>,
pub matrix: Affine,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormObject {
pub objects: Vec<PageObject>,
pub matrix: Affine,
pub bbox: Option<Rect>,
pub transparency: Transparency,
pub oc: Option<Arc<Dict>>,
pub source: Option<pdfrum_object::ObjRef>,
pub live_edit: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PageObject {
Path(Box<Content<PathObject>>),
Text(Box<Content<TextObject>>),
Image(Box<Content<ImageObject>>),
Shading(Box<Content<ShadingObject>>),
Form(Box<Content<FormObject>>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Content<T> {
pub object: T,
pub state: GraphicsState,
pub marks: ContentMarks,
pub content_stream: Option<usize>,
pub dirty: bool,
pub active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Common {
pub(crate) content_stream: Option<usize>,
pub(crate) dirty: bool,
pub(crate) active: bool,
}
pub(crate) struct CommonMut<'a> {
pub(crate) content_stream: &'a mut Option<usize>,
pub(crate) dirty: &'a mut bool,
pub(crate) active: &'a mut bool,
}
impl<T> Content<T> {
#[must_use]
pub fn new(object: T, state: GraphicsState) -> Self {
Self {
object,
state,
marks: ContentMarks::new(),
content_stream: None,
dirty: true,
active: true,
}
}
fn common(&self) -> Common {
Common {
content_stream: self.content_stream,
dirty: self.dirty,
active: self.active,
}
}
fn common_mut(&mut self) -> CommonMut<'_> {
CommonMut {
content_stream: &mut self.content_stream,
dirty: &mut self.dirty,
active: &mut self.active,
}
}
}
impl PageObject {
pub(crate) fn common(&self) -> Common {
match self {
Self::Path(c) => c.common(),
Self::Text(c) => c.common(),
Self::Image(c) => c.common(),
Self::Shading(c) => c.common(),
Self::Form(c) => c.common(),
}
}
pub(crate) fn common_mut(&mut self) -> CommonMut<'_> {
match self {
Self::Path(c) => c.common_mut(),
Self::Text(c) => c.common_mut(),
Self::Image(c) => c.common_mut(),
Self::Shading(c) => c.common_mut(),
Self::Form(c) => c.common_mut(),
}
}
#[must_use]
pub fn state(&self) -> &GraphicsState {
match self {
Self::Path(c) => &c.state,
Self::Text(c) => &c.state,
Self::Image(c) => &c.state,
Self::Shading(c) => &c.state,
Self::Form(c) => &c.state,
}
}
#[must_use]
pub fn marks(&self) -> &ContentMarks {
match self {
Self::Path(c) => &c.marks,
Self::Text(c) => &c.marks,
Self::Image(c) => &c.marks,
Self::Shading(c) => &c.marks,
Self::Form(c) => &c.marks,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Page {
pub objects: Vec<PageObject>,
pub media_box: Rect,
pub crop_box: Rect,
pub rotate: Rotation,
pub transparency: Transparency,
pub resources: Option<Dict>,
pub dirty_streams: BTreeSet<Option<usize>>,
pub stream_ctms: BTreeMap<usize, Affine>,
}
impl Page {
#[must_use]
pub fn empty() -> Self {
Self {
objects: Vec::new(),
media_box: DEFAULT_MEDIA_BOX,
crop_box: DEFAULT_MEDIA_BOX,
rotate: Rotation::None,
transparency: Transparency {
isolated: true,
..Transparency::default()
},
resources: None,
dirty_streams: BTreeSet::new(),
stream_ctms: BTreeMap::new(),
}
}
#[must_use]
pub fn display_size(&self) -> (f64, f64) {
let (w, h) = (self.crop_box.width(), self.crop_box.height());
match self.rotate {
Rotation::Quarter | Rotation::ThreeQuarter => (h, w),
_ => (w, h),
}
}
}
#[must_use]
pub fn display_size_from_dict<R: Resolve>(
dict: &Dict,
inherited: impl Fn(&pdfrum_object::Name) -> Option<pdfrum_object::Object>,
r: &R,
diags: &mut Diagnostics,
) -> (f64, f64) {
let (_, crop_box) = derive_boxes(dict, &inherited, r, diags);
let rotate = Rotation::from_degrees(
dict.int(crate::names::ROTATE, r)
.or_else(|| inherited(crate::names::ROTATE).and_then(|o| o.as_int()))
.unwrap_or(0),
);
let (w, h) = (crop_box.width(), crop_box.height());
match rotate {
Rotation::Quarter | Rotation::ThreeQuarter => (h, w),
Rotation::None | Rotation::Half => (w, h),
}
}
#[must_use]
pub fn derive_boxes<R: Resolve>(
dict: &Dict,
inherited: impl Fn(&pdfrum_object::Name) -> Option<pdfrum_object::Object>,
r: &R,
diags: &mut Diagnostics,
) -> (Rect, Rect) {
let read = |key: &pdfrum_object::Name| -> Option<Rect> {
let obj = dict.raw(key).cloned().or_else(|| inherited(key))?;
let resolved = obj.resolve(r).ok()?;
let array = resolved.as_array()?;
(array.len() == 4).then(|| normalize(array.as_rect()))
};
let media = match read(names::MEDIA_BOX) {
Some(rect) if rect.width() > 0.0 && rect.height() > 0.0 => rect,
_ => {
diags.record(Severity::Recovered, DiagKind::MediaBoxDefaulted, None);
DEFAULT_MEDIA_BOX
}
};
let crop = match read(names::CROP_BOX) {
Some(rect) if rect.width() > 0.0 && rect.height() > 0.0 => {
rect.intersect(media)
}
_ => media,
};
(media, crop)
}
fn normalize(rect: Rect) -> Rect {
Rect::new(
rect.x0.min(rect.x1),
rect.y0.min(rect.y1),
rect.x0.max(rect.x1),
rect.y0.max(rect.y1),
)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{DEFAULT_MEDIA_BOX, Page, Rotation, derive_boxes};
use pdfrum_common::{DiagKind, Diagnostics};
use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
fn boxes(pairs: Vec<(Name, Object)>) -> (kurbo::Rect, kurbo::Rect, Diagnostics) {
let mut diags = Diagnostics::default();
let (m, c) = derive_boxes(&Dict::from_pairs(pairs), |_| None, &NoResolve, &mut diags);
(m, c, diags)
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Object {
Object::Array(Array::of([x0, y0, x1, y1].map(|v| {
#[expect(clippy::cast_possible_truncation, reason = "test fixtures are small")]
Object::Real(v as f32)
})))
}
#[test]
fn rotation_divides_before_taking_the_remainder() {
assert_eq!(Rotation::from_degrees(0), Rotation::None);
assert_eq!(Rotation::from_degrees(90), Rotation::Quarter);
assert_eq!(Rotation::from_degrees(180), Rotation::Half);
assert_eq!(Rotation::from_degrees(270), Rotation::ThreeQuarter);
assert_eq!(Rotation::from_degrees(45), Rotation::None);
assert_eq!(Rotation::from_degrees(-90), Rotation::ThreeQuarter);
assert_eq!(Rotation::from_degrees(450), Rotation::Quarter);
assert_eq!(Rotation::from_degrees(720), Rotation::None);
}
#[test]
fn an_empty_media_box_becomes_us_letter() {
let (media, crop, diags) = boxes(vec![]);
assert_eq!(media, DEFAULT_MEDIA_BOX);
assert_eq!(crop, DEFAULT_MEDIA_BOX);
assert!(diags.contains(&DiagKind::MediaBoxDefaulted));
let (media, _, _) = boxes(vec![(Name::from("MediaBox"), rect(0.0, 0.0, 0.0, 0.0))]);
assert_eq!(media, DEFAULT_MEDIA_BOX);
let (media, _, _) = boxes(vec![(
Name::from("MediaBox"),
Object::Array(Array::of([Object::Int(0), Object::Int(0)])),
)]);
assert_eq!(media, DEFAULT_MEDIA_BOX);
}
#[test]
fn a_reversed_media_box_is_normalized() {
let (media, _, _) = boxes(vec![(Name::from("MediaBox"), rect(100.0, 200.0, 0.0, 0.0))]);
assert!((media.width() - 100.0).abs() < 1e-6);
assert!((media.height() - 200.0).abs() < 1e-6);
}
#[test]
fn the_crop_box_is_intersected_with_the_media_box() {
let (_, crop, _) = boxes(vec![
(Name::from("MediaBox"), rect(0.0, 0.0, 100.0, 100.0)),
(Name::from("CropBox"), rect(50.0, 50.0, 200.0, 200.0)),
]);
assert!((crop.x1 - 100.0).abs() < 1e-6);
assert!((crop.x0 - 50.0).abs() < 1e-6);
}
#[test]
fn a_disjoint_crop_box_leaves_a_zero_area_page() {
let (_, crop, _) = boxes(vec![
(Name::from("MediaBox"), rect(0.0, 0.0, 100.0, 100.0)),
(Name::from("CropBox"), rect(500.0, 500.0, 600.0, 600.0)),
]);
assert!(crop.area() <= 0.0, "got {crop:?}");
}
#[test]
fn a_missing_crop_box_is_the_media_box() {
let (media, crop, _) = boxes(vec![(Name::from("MediaBox"), rect(0.0, 0.0, 200.0, 300.0))]);
assert_eq!(media, crop);
}
#[test]
fn a_rotated_page_swaps_its_display_size() {
let page = Page {
crop_box: kurbo::Rect::new(0.0, 0.0, 200.0, 100.0),
rotate: Rotation::Quarter,
..Page::empty()
};
assert_eq!(page.display_size(), (100.0, 200.0));
let page = Page {
rotate: Rotation::Half,
..page
};
assert_eq!(page.display_size(), (200.0, 100.0));
}
#[test]
fn an_empty_page_is_letter_sized_and_isolated() {
let page = Page::empty();
assert!(page.objects.is_empty());
assert_eq!(page.media_box, DEFAULT_MEDIA_BOX);
assert!(page.transparency.isolated);
}
}