Skip to main content

pdfrum_page/
page.rs

1//! The page-object graph: what the interpreter produces
2//! (ISO 32000-1 §8.2, §9).
3//!
4//! Every object is a small record — geometry or payload, a graphics-state
5//! snapshot, and the marks in force at its creation. No behaviour lives
6//! inside them: `pdfrum-render` walks the graph and `pdfrum-text` reads it,
7//! and neither re-derives semantics.
8//!
9//! # Box derivation is this crate's job
10//!
11//! The parser hands over inherited attributes; turning them into a page's
12//! geometry is here, and three of the rules bite:
13//!
14//! - **An empty `/MediaBox` becomes US Letter**, `(0, 0, 612, 792)`. Empty
15//!   means non-positive width or height after normalization.
16//! - **A `/CropBox` is intersected with the media box**, and an intersection
17//!   that comes out empty is *kept* empty — a zero-by-zero page.
18//! - **`/Rotate` is `((n / 90) % 4 + 4) % 4`**, so `45` is no rotation at
19//!   all, `-90` is three quarter-turns, and `450` is one.
20
21use crate::image::ImageData;
22use crate::names;
23use crate::shading::Shading;
24use crate::state::{ContentMarks, GraphicsState};
25use crate::transparency::Transparency;
26use kurbo::{Affine, BezPath, Rect};
27use pdfrum_common::{DiagKind, Diagnostics, Severity};
28use pdfrum_font::Font;
29use pdfrum_object::{Dict, Resolve};
30use std::collections::{BTreeMap, BTreeSet};
31use std::sync::Arc;
32
33/// The default page size when `/MediaBox` is missing or empty: US Letter.
34pub const DEFAULT_MEDIA_BOX: Rect = Rect::new(0.0, 0.0, 612.0, 792.0);
35
36/// A page's `/Rotate`, normalized to one of four quarter turns
37/// (ISO 32000-1 §7.7.3.3).
38///
39/// The value is always clockwise and always a multiple of 90 degrees: a
40/// document writing `/Rotate 450` means [`Rotation::Quarter`], and one
41/// writing `-90` means [`Rotation::ThreeQuarter`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
43pub enum Rotation {
44    /// Upright.
45    #[default]
46    None,
47    /// Ninety degrees clockwise.
48    Quarter,
49    /// Half a turn.
50    Half,
51    /// Two hundred and seventy degrees clockwise.
52    ThreeQuarter,
53}
54
55impl Rotation {
56    /// The rotation `/Rotate n` names.
57    ///
58    /// The division comes **first**, so `45` truncates to zero quarter turns
59    /// rather than rounding to one.
60    #[must_use]
61    pub fn from_degrees(n: i64) -> Self {
62        let quarters = ((n / 90) % 4 + 4) % 4;
63        match quarters {
64            1 => Self::Quarter,
65            2 => Self::Half,
66            3 => Self::ThreeQuarter,
67            _ => Self::None,
68        }
69    }
70
71    /// The rotation in degrees clockwise: 0, 90, 180 or 270.
72    #[must_use]
73    pub fn degrees(self) -> u32 {
74        match self {
75            Self::None => 0,
76            Self::Quarter => 90,
77            Self::Half => 180,
78            Self::ThreeQuarter => 270,
79        }
80    }
81
82    /// Quarter turns clockwise.
83    #[must_use]
84    pub fn quarters(self) -> u8 {
85        match self {
86            Self::None => 0,
87            Self::Quarter => 1,
88            Self::Half => 2,
89            Self::ThreeQuarter => 3,
90        }
91    }
92
93    /// The matrix mapping the crop box onto a `width` by `height` device
94    /// rectangle.
95    ///
96    /// Width and height are **swapped** for the quarter and three-quarter
97    /// turns, which is what makes a rotated page's device box the right way
98    /// round.
99    #[must_use]
100    pub fn display_matrix(self, box_rect: Rect) -> Affine {
101        let (left, bottom, right, top) = (box_rect.x0, box_rect.y0, box_rect.x1, box_rect.y1);
102        match self {
103            Self::None => Affine::new([1.0, 0.0, 0.0, 1.0, -left, -bottom]),
104            Self::Quarter => Affine::new([0.0, -1.0, 1.0, 0.0, -bottom, right]),
105            Self::Half => Affine::new([-1.0, 0.0, 0.0, -1.0, right, top]),
106            Self::ThreeQuarter => Affine::new([0.0, 1.0, -1.0, 0.0, top, -left]),
107        }
108    }
109}
110
111/// The error [`Rotation`]'s [`FromStr`](std::str::FromStr) returns: the
112/// string named no quarter turn.
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114#[error("not a quarter turn: {0}")]
115pub struct NotAQuarterTurn(String);
116
117impl std::fmt::Display for Rotation {
118    /// The degrees clockwise as a bare number: `0`, `90`, `180` or `270`.
119    ///
120    /// Round-trips through [`FromStr`](std::str::FromStr).
121    ///
122    /// ```
123    /// assert_eq!(pdfrum_page::Rotation::Quarter.to_string(), "90");
124    /// ```
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        std::fmt::Display::fmt(&self.degrees(), f)
127    }
128}
129
130impl std::str::FromStr for Rotation {
131    type Err = NotAQuarterTurn;
132
133    /// The inverse of [`Display`](std::fmt::Display): `"0"`, `"90"`, `"180"`
134    /// and `"270"`, and nothing else.
135    ///
136    /// Not `"450"` and not `"-90"`. Normalizing a document's out-of-range
137    /// `/Rotate` is [`Rotation::from_degrees`]'s job; doing it here would
138    /// make a caller's round trip lossy.
139    ///
140    /// # Errors
141    ///
142    /// [`NotAQuarterTurn`] when the string is not one of those four.
143    ///
144    /// ```
145    /// assert_eq!("270".parse(), Ok(pdfrum_page::Rotation::ThreeQuarter));
146    /// assert!("45".parse::<pdfrum_page::Rotation>().is_err());
147    /// ```
148    fn from_str(s: &str) -> core::result::Result<Rotation, NotAQuarterTurn> {
149        match s {
150            "0" => Ok(Rotation::None),
151            "90" => Ok(Rotation::Quarter),
152            "180" => Ok(Rotation::Half),
153            "270" => Ok(Rotation::ThreeQuarter),
154            other => Err(NotAQuarterTurn(other.to_owned())),
155        }
156    }
157}
158
159/// A path, as painted.
160#[derive(Debug, Clone, PartialEq)]
161pub struct PathObject {
162    /// The path in its own coordinate space.
163    pub path: BezPath,
164    /// The matrix taking it to the page.
165    pub matrix: Affine,
166    /// How the interior is filled.
167    pub fill_rule: crate::ops::FillRule,
168    /// Whether the outline is stroked.
169    pub stroke: bool,
170}
171
172/// One run of glyphs sharing a position and a font.
173///
174/// Equality compares the font by identity, as [`TextState`] does.
175///
176/// [`TextState`]: crate::state::TextState
177#[derive(Debug, Clone)]
178pub struct TextObject {
179    /// The character codes, split into runs by the adjustments between them.
180    pub segments: Box<[TextSegment]>,
181    /// Where the run starts, in page space.
182    pub position: kurbo::Point,
183    /// The matrix glyphs are drawn with, translation excluded.
184    pub matrix: Affine,
185    /// The font and size.
186    pub font: Option<(Arc<Font>, f32)>,
187    /// The `/Font` resource the font came from, for a regenerated stream to
188    /// name. `None` for a font loaded from an inline dictionary.
189    pub font_source: Option<pdfrum_object::ObjRef>,
190    /// How the glyphs are painted.
191    pub render_mode: crate::ops::TextRenderMode,
192    /// For a Type 3 font only: what each shown character's glyph procedure
193    /// declares about itself, keyed by character code.
194    ///
195    /// A Type 3 glyph has no program to measure — its advance and box come
196    /// from the `d0`/`d1` operator inside its content stream — so a consumer
197    /// that needs either has no way to get them from the font alone. The
198    /// interpreter has already opened those streams, so it records the answer
199    /// here rather than making every consumer re-interpret them.
200    pub type3_metrics: BTreeMap<u32, crate::type3::Type3Metrics>,
201}
202
203impl PartialEq for TextObject {
204    fn eq(&self, other: &Self) -> bool {
205        let same_font = match (&self.font, &other.font) {
206            (Some((a, sa)), Some((b, sb))) => a.id() == b.id() && sa == sb,
207            (None, None) => true,
208            _ => false,
209        };
210        same_font
211            && self.segments == other.segments
212            && self.position == other.position
213            && self.matrix == other.matrix
214            && self.render_mode == other.render_mode
215            && self.type3_metrics == other.type3_metrics
216    }
217}
218
219/// One string within a text object, and the adjustment that followed it.
220#[derive(Debug, Clone, PartialEq)]
221pub struct TextSegment {
222    /// The character codes.
223    pub codes: Box<[u8]>,
224    /// The adjustment following this string, in thousandths of a text-space
225    /// unit. Adjacent adjustments **accumulate**, so `[(A) 5 5 (B)]` records
226    /// ten.
227    pub kerning: f32,
228}
229
230/// An image, decoded.
231#[derive(Debug, Clone, PartialEq)]
232pub struct ImageObject {
233    /// The pixels, shared with the session cache.
234    pub image: Arc<ImageData>,
235    /// The matrix taking the unit square onto the image's place on the page.
236    pub matrix: Affine,
237    /// Whether the image is a stencil painted with the fill colour.
238    pub is_mask: bool,
239    /// The `/OC` entry from the image `XObject`'s own dictionary.
240    ///
241    /// An image carries optional-content membership on the `XObject` rather
242    /// than through a marked-content sequence, so this is a second, separate
243    /// place visibility is declared and not a cache of the first. An inline
244    /// image has no dictionary of its own to declare it in.
245    pub oc: Option<Arc<Dict>>,
246    /// The `XObject` this image was drawn from, when it was a named resource.
247    ///
248    /// The pixels here are decoded, and a regenerated stream must name the
249    /// *undecoded* stream a `Do` can reach — so the reference travels with the
250    /// object. `None` for an inline image, which has no indirect object to
251    /// name and is therefore dropped when its stream is rewritten.
252    pub source: Option<pdfrum_object::ObjRef>,
253}
254
255/// A shading painted directly by `sh`.
256#[derive(Debug, Clone, PartialEq)]
257pub struct ShadingObject {
258    /// The shading.
259    pub shading: Arc<Shading>,
260    /// The matrix taking it to the page.
261    pub matrix: Affine,
262    /// The area it paints, which the clip and, for meshes, the mesh's own
263    /// extent bound.
264    pub bounds: Rect,
265}
266
267/// A form `XObject`'s contents, already interpreted.
268#[derive(Debug, Clone, PartialEq)]
269pub struct FormObject {
270    /// The objects the form produced.
271    pub objects: Vec<PageObject>,
272    /// The matrix taking the form's space to the page's.
273    pub matrix: Affine,
274    /// The form's own bounding box, when it declared one. **A missing
275    /// `/BBox` means no clip at all** — the form is unbounded.
276    pub bbox: Option<Rect>,
277    /// The form's transparency group, when it declared one.
278    pub transparency: Transparency,
279    /// The `/OC` entry from the form `XObject`'s own dictionary.
280    ///
281    /// Like an image's, this is where a form declares optional-content
282    /// membership, alongside and independently of any marked-content
283    /// sequence enclosing the `Do` that drew it.
284    pub oc: Option<Arc<Dict>>,
285    /// The `XObject` this form was drawn from, when it was a named resource.
286    ///
287    /// As an image's: the objects here are already interpreted, so a
288    /// regenerated stream names the stream rather than re-emitting them.
289    pub source: Option<pdfrum_object::ObjRef>,
290    /// Whether this form is a **live edit's** appearance — a field the user is
291    /// currently typing in, rather than anything the file itself carries.
292    ///
293    /// It is a fact about *where the object came from*, not an instruction to
294    /// a renderer, which is why a page crate can hold it: the file's own
295    /// appearance streams and a form session's regenerated ones are both
296    /// `false`, and only the appearance a session produces for the field it is
297    /// editing is `true`.
298    ///
299    /// A renderer needs it because the oracle draws that one form differently
300    /// from every other object on the page: the widget's editor builds its own
301    /// local render options with subpixel antialiasing forced on, which no
302    /// page-level flag ever clears, so the text of a live edit — and no other
303    /// text — is drawn that way. The distinction has to travel with the
304    /// object because by the time anything rasterizes, this form is one entry
305    /// in the page's object list among all the others, and the whole page is
306    /// rendered under a single set of options.
307    ///
308    /// Defaults to `false`, so every existing producer keeps its meaning.
309    pub live_edit: bool,
310}
311
312/// One thing to paint.
313///
314/// Deliberately **not** `#[non_exhaustive]`: a new variant must fail every
315/// match site to compile, and a downstream renderer that
316/// silently ignored a new kind of page object would silently stop drawing it.
317#[derive(Debug, Clone, PartialEq)]
318pub enum PageObject {
319    /// A filled or stroked path.
320    Path(Box<Content<PathObject>>),
321    /// A run of glyphs.
322    Text(Box<Content<TextObject>>),
323    /// An image.
324    Image(Box<Content<ImageObject>>),
325    /// A shading painted by `sh`.
326    Shading(Box<Content<ShadingObject>>),
327    /// A form `XObject`'s contents.
328    Form(Box<Content<FormObject>>),
329}
330
331/// A page object with the state and marks it was created under.
332#[derive(Debug, Clone, PartialEq)]
333pub struct Content<T> {
334    /// The object itself.
335    pub object: T,
336    /// The graphics state in force at its creation.
337    pub state: GraphicsState,
338    /// The marked-content sequence enclosing it.
339    pub marks: ContentMarks,
340    /// Which `/Contents` element it came from, for the editor, or `None` for
341    /// an object that was created rather than parsed.
342    ///
343    /// `None` sorts before `Some(0)` — `Option`'s own `Ord` — which is what
344    /// gives a brand-new object the lowest free `/Contents` index in the
345    /// regenerator's ordered walk rather than one past the end.
346    pub content_stream: Option<usize>,
347    /// Whether the object has been changed since it was parsed, so its
348    /// content stream must be written again on save
349    /// (see [`PageObject::set_active`]).
350    pub dirty: bool,
351    /// Whether the object is painted. An inactive object keeps its place in
352    /// the list but contributes nothing to a regenerated stream.
353    pub active: bool,
354}
355
356/// The fields every page object carries whatever it paints.
357///
358/// A borrowed view rather than a shared base struct: the five variants keep
359/// their own records, and this is how a function that only cares about the
360/// bookkeeping reaches it without matching five times.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub(crate) struct Common {
363    pub(crate) content_stream: Option<usize>,
364    pub(crate) dirty: bool,
365    pub(crate) active: bool,
366}
367
368/// A mutable view of the same, so a mutation writes through one match.
369pub(crate) struct CommonMut<'a> {
370    pub(crate) content_stream: &'a mut Option<usize>,
371    pub(crate) dirty: &'a mut bool,
372    pub(crate) active: &'a mut bool,
373}
374
375impl<T> Content<T> {
376    /// A newly created object, under `state` and enclosed by no marks.
377    ///
378    /// It arrives dirty and streamless, which is what a *created* object is:
379    /// it describes no bytes yet, so the stream it lands in has to be written
380    /// for it to exist at all. An object the interpreter produced from bytes
381    /// is built field-by-field instead, because it is neither.
382    #[must_use]
383    pub fn new(object: T, state: GraphicsState) -> Self {
384        Self {
385            object,
386            state,
387            marks: ContentMarks::new(),
388            content_stream: None,
389            dirty: true,
390            active: true,
391        }
392    }
393
394    fn common(&self) -> Common {
395        Common {
396            content_stream: self.content_stream,
397            dirty: self.dirty,
398            active: self.active,
399        }
400    }
401
402    fn common_mut(&mut self) -> CommonMut<'_> {
403        CommonMut {
404            content_stream: &mut self.content_stream,
405            dirty: &mut self.dirty,
406            active: &mut self.active,
407        }
408    }
409}
410
411impl PageObject {
412    pub(crate) fn common(&self) -> Common {
413        match self {
414            Self::Path(c) => c.common(),
415            Self::Text(c) => c.common(),
416            Self::Image(c) => c.common(),
417            Self::Shading(c) => c.common(),
418            Self::Form(c) => c.common(),
419        }
420    }
421
422    pub(crate) fn common_mut(&mut self) -> CommonMut<'_> {
423        match self {
424            Self::Path(c) => c.common_mut(),
425            Self::Text(c) => c.common_mut(),
426            Self::Image(c) => c.common_mut(),
427            Self::Shading(c) => c.common_mut(),
428            Self::Form(c) => c.common_mut(),
429        }
430    }
431
432    /// The graphics state the object was created under.
433    #[must_use]
434    pub fn state(&self) -> &GraphicsState {
435        match self {
436            Self::Path(c) => &c.state,
437            Self::Text(c) => &c.state,
438            Self::Image(c) => &c.state,
439            Self::Shading(c) => &c.state,
440            Self::Form(c) => &c.state,
441        }
442    }
443
444    /// The marks enclosing the object.
445    #[must_use]
446    pub fn marks(&self) -> &ContentMarks {
447        match self {
448            Self::Path(c) => &c.marks,
449            Self::Text(c) => &c.marks,
450            Self::Image(c) => &c.marks,
451            Self::Shading(c) => &c.marks,
452            Self::Form(c) => &c.marks,
453        }
454    }
455}
456
457/// An interpreted page.
458#[derive(Debug, Clone, PartialEq)]
459pub struct Page {
460    /// The objects, in painting order.
461    pub objects: Vec<PageObject>,
462    /// The page's own extent.
463    pub media_box: Rect,
464    /// The visible region, already intersected with the media box.
465    pub crop_box: Rect,
466    /// How the page is displayed.
467    pub rotate: Rotation,
468    /// The page's transparency group. A page is **always isolated**,
469    /// whatever its `/Group` says.
470    pub transparency: Transparency,
471    /// The page's resource dictionary, for a caller that needs to re-resolve
472    /// a name.
473    pub resources: Option<Dict>,
474    /// `/Contents` elements that must be written again because objects were
475    /// removed from them (see [`PageObject::set_active`]).
476    ///
477    /// Only removals need recording here: a modified or hidden object still
478    /// carries its own [`Content::dirty`], but a removed one leaves nothing
479    /// behind to say its stream lost something.
480    pub dirty_streams: BTreeSet<Option<usize>>,
481    /// The transform each `/Contents` element leaves in force at its end,
482    /// keyed by element index.
483    ///
484    /// A content stream can leave the transform changed for the ones after it
485    /// — an unbalanced `q`/`cm` is legal and common — so a stream rewritten on
486    /// its own must first undo what it inherited and then restate what it
487    /// passes on. Only streams that actually changed the transform have an
488    /// entry.
489    pub stream_ctms: BTreeMap<usize, Affine>,
490}
491
492impl Page {
493    /// An empty page of the default size.
494    ///
495    /// A page with no `/Contents` parses **successfully with zero objects**;
496    /// it is never an error.
497    #[must_use]
498    pub fn empty() -> Self {
499        Self {
500            objects: Vec::new(),
501            media_box: DEFAULT_MEDIA_BOX,
502            crop_box: DEFAULT_MEDIA_BOX,
503            rotate: Rotation::None,
504            transparency: Transparency {
505                isolated: true,
506                ..Transparency::default()
507            },
508            resources: None,
509            dirty_streams: BTreeSet::new(),
510            stream_ctms: BTreeMap::new(),
511        }
512    }
513
514    /// The size the page displays at, with width and height swapped for a
515    /// quarter or three-quarter turn.
516    #[must_use]
517    pub fn display_size(&self) -> (f64, f64) {
518        let (w, h) = (self.crop_box.width(), self.crop_box.height());
519        match self.rotate {
520            Rotation::Quarter | Rotation::ThreeQuarter => (h, w),
521            _ => (w, h),
522        }
523    }
524}
525
526/// A page's displayed size, read from its dictionary rather than from a built
527/// [`Page`].
528///
529/// The same crop box and the same `/Rotate` swap [`Page::display_size`]
530/// applies, for a caller that has to know how large a page will draw *before*
531/// building it — which is what choosing a decode target needs, since the
532/// build is the thing that decodes the images.
533#[must_use]
534pub fn display_size_from_dict<R: Resolve>(
535    dict: &Dict,
536    inherited: impl Fn(&pdfrum_object::Name) -> Option<pdfrum_object::Object>,
537    r: &R,
538    diags: &mut Diagnostics,
539) -> (f64, f64) {
540    let (_, crop_box) = derive_boxes(dict, &inherited, r, diags);
541    let rotate = Rotation::from_degrees(
542        dict.int(crate::names::ROTATE, r)
543            .or_else(|| inherited(crate::names::ROTATE).and_then(|o| o.as_int()))
544            .unwrap_or(0),
545    );
546    let (w, h) = (crop_box.width(), crop_box.height());
547    match rotate {
548        Rotation::Quarter | Rotation::ThreeQuarter => (h, w),
549        Rotation::None | Rotation::Half => (w, h),
550    }
551}
552
553/// Derive a page's boxes from its inherited attributes.
554///
555/// See the module docs for the three rules. Returns `(media, crop)`.
556#[must_use]
557pub fn derive_boxes<R: Resolve>(
558    dict: &Dict,
559    inherited: impl Fn(&pdfrum_object::Name) -> Option<pdfrum_object::Object>,
560    r: &R,
561    diags: &mut Diagnostics,
562) -> (Rect, Rect) {
563    let read = |key: &pdfrum_object::Name| -> Option<Rect> {
564        let obj = dict.raw(key).cloned().or_else(|| inherited(key))?;
565        let resolved = obj.resolve(r).ok()?;
566        let array = resolved.as_array()?;
567        (array.len() == 4).then(|| normalize(array.as_rect()))
568    };
569
570    let media = match read(names::MEDIA_BOX) {
571        // "Empty" is non-positive width or height, not merely absent.
572        Some(rect) if rect.width() > 0.0 && rect.height() > 0.0 => rect,
573        _ => {
574            diags.record(Severity::Recovered, DiagKind::MediaBoxDefaulted, None);
575            DEFAULT_MEDIA_BOX
576        }
577    };
578    let crop = match read(names::CROP_BOX) {
579        Some(rect) if rect.width() > 0.0 && rect.height() > 0.0 => {
580            // An intersection that comes out empty is *kept* empty, giving a
581            // zero-by-zero page.
582            rect.intersect(media)
583        }
584        _ => media,
585    };
586    (media, crop)
587}
588
589/// Sort a rectangle's corners so its width and height are non-negative.
590fn normalize(rect: Rect) -> Rect {
591    Rect::new(
592        rect.x0.min(rect.x1),
593        rect.y0.min(rect.y1),
594        rect.x0.max(rect.x1),
595        rect.y0.max(rect.y1),
596    )
597}
598
599#[cfg(test)]
600mod tests {
601    // Test fixtures quote the oracle's own vectors, compare floats exactly
602    // where the behaviour being pinned is exact, and index arrays whose
603    // length the fixture itself fixes.
604    #![allow(
605        clippy::unreadable_literal,
606        clippy::float_cmp,
607        clippy::indexing_slicing,
608        clippy::cast_precision_loss,
609        clippy::cast_possible_truncation,
610        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
611    )]
612
613    use super::{DEFAULT_MEDIA_BOX, Page, Rotation, derive_boxes};
614    use pdfrum_common::{DiagKind, Diagnostics};
615    use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
616
617    fn boxes(pairs: Vec<(Name, Object)>) -> (kurbo::Rect, kurbo::Rect, Diagnostics) {
618        let mut diags = Diagnostics::default();
619        let (m, c) = derive_boxes(&Dict::from_pairs(pairs), |_| None, &NoResolve, &mut diags);
620        (m, c, diags)
621    }
622
623    fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Object {
624        Object::Array(Array::of([x0, y0, x1, y1].map(|v| {
625            #[expect(clippy::cast_possible_truncation, reason = "test fixtures are small")]
626            Object::Real(v as f32)
627        })))
628    }
629
630    #[test]
631    fn rotation_divides_before_taking_the_remainder() {
632        assert_eq!(Rotation::from_degrees(0), Rotation::None);
633        assert_eq!(Rotation::from_degrees(90), Rotation::Quarter);
634        assert_eq!(Rotation::from_degrees(180), Rotation::Half);
635        assert_eq!(Rotation::from_degrees(270), Rotation::ThreeQuarter);
636        // 45 / 90 truncates to zero, so it is no rotation at all.
637        assert_eq!(Rotation::from_degrees(45), Rotation::None);
638        // Negatives wrap forward.
639        assert_eq!(Rotation::from_degrees(-90), Rotation::ThreeQuarter);
640        // And so do multiples past a full turn.
641        assert_eq!(Rotation::from_degrees(450), Rotation::Quarter);
642        assert_eq!(Rotation::from_degrees(720), Rotation::None);
643    }
644
645    #[test]
646    fn an_empty_media_box_becomes_us_letter() {
647        // Absent.
648        let (media, crop, diags) = boxes(vec![]);
649        assert_eq!(media, DEFAULT_MEDIA_BOX);
650        assert_eq!(crop, DEFAULT_MEDIA_BOX);
651        assert!(diags.contains(&DiagKind::MediaBoxDefaulted));
652
653        // Zero-area.
654        let (media, _, _) = boxes(vec![(Name::from("MediaBox"), rect(0.0, 0.0, 0.0, 0.0))]);
655        assert_eq!(media, DEFAULT_MEDIA_BOX);
656
657        // The wrong number of elements.
658        let (media, _, _) = boxes(vec![(
659            Name::from("MediaBox"),
660            Object::Array(Array::of([Object::Int(0), Object::Int(0)])),
661        )]);
662        assert_eq!(media, DEFAULT_MEDIA_BOX);
663    }
664
665    #[test]
666    fn a_reversed_media_box_is_normalized() {
667        let (media, _, _) = boxes(vec![(Name::from("MediaBox"), rect(100.0, 200.0, 0.0, 0.0))]);
668        assert!((media.width() - 100.0).abs() < 1e-6);
669        assert!((media.height() - 200.0).abs() < 1e-6);
670    }
671
672    #[test]
673    fn the_crop_box_is_intersected_with_the_media_box() {
674        let (_, crop, _) = boxes(vec![
675            (Name::from("MediaBox"), rect(0.0, 0.0, 100.0, 100.0)),
676            // Sticking out past the media box on two sides.
677            (Name::from("CropBox"), rect(50.0, 50.0, 200.0, 200.0)),
678        ]);
679        assert!((crop.x1 - 100.0).abs() < 1e-6);
680        assert!((crop.x0 - 50.0).abs() < 1e-6);
681    }
682
683    #[test]
684    fn a_disjoint_crop_box_leaves_a_zero_area_page() {
685        let (_, crop, _) = boxes(vec![
686            (Name::from("MediaBox"), rect(0.0, 0.0, 100.0, 100.0)),
687            (Name::from("CropBox"), rect(500.0, 500.0, 600.0, 600.0)),
688        ]);
689        assert!(crop.area() <= 0.0, "got {crop:?}");
690    }
691
692    #[test]
693    fn a_missing_crop_box_is_the_media_box() {
694        let (media, crop, _) = boxes(vec![(Name::from("MediaBox"), rect(0.0, 0.0, 200.0, 300.0))]);
695        assert_eq!(media, crop);
696    }
697
698    #[test]
699    fn a_rotated_page_swaps_its_display_size() {
700        let page = Page {
701            crop_box: kurbo::Rect::new(0.0, 0.0, 200.0, 100.0),
702            rotate: Rotation::Quarter,
703            ..Page::empty()
704        };
705        assert_eq!(page.display_size(), (100.0, 200.0));
706        let page = Page {
707            rotate: Rotation::Half,
708            ..page
709        };
710        assert_eq!(page.display_size(), (200.0, 100.0));
711    }
712
713    #[test]
714    fn an_empty_page_is_letter_sized_and_isolated() {
715        let page = Page::empty();
716        assert!(page.objects.is_empty());
717        assert_eq!(page.media_box, DEFAULT_MEDIA_BOX);
718        assert!(page.transparency.isolated);
719    }
720}