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}