//! Flattening annotations and form fields into page content. //! //! Phase 5 of `NIGIG_PDF_FEATURE_PARITY_PLAN.md`, matching dart-pdf's //! `flatten_test.dart`. //! //! ## What flattening is, and what it is for //! //! An annotation draws itself from its `/AP /N` appearance stream, which //! lives *beside* the page rather than in it. A reader that does not //! understand annotations — or one told not to print them — shows the page //! without them. Flattening moves that appearance into the page's own //! content stream and removes the annotation, so what is drawn is part of //! the page and cannot be turned off, edited or extracted as a field. //! //! That is exactly what "print to PDF", "finalise this form" and "make //! these comments permanent" mean, and it is irreversible by design. //! //! ## The transform is the whole problem //! //! An appearance stream draws in its **own** coordinate space, described by //! its `/BBox` and optional `/Matrix`. The annotation's `/Rect` says where //! on the page that space should land. Getting from one to the other is //! PDF 32000-1 §12.5.5, and it is the step every naive implementation skips: //! //! 1. Transform the `/BBox` corners by `/Matrix`. //! 2. Take the bounding box of the result — the *transformed* appearance //! box, which is generally not the original box. //! 3. Compute the scale and offset that map that box onto `/Rect`. //! //! Skip it and the stamp lands at the origin, or at the right place in the //! wrong size. Nothing errors: the page renders, with the annotation //! somewhere else. //! //! ## What is refused //! //! An annotation with no appearance stream cannot be flattened — there is //! nothing to draw. Rather than dropping it silently (which loses it) or //! inventing an appearance (which draws something the producer never //! specified), it is **left alone** and reported. A caller wanting it gone //! can delete it explicitly; a caller wanting it drawn must generate an //! appearance first, which `appearance.rs` does for the types it can. //! //! Hidden and `/NoView` annotations are skipped for the same reason in //! reverse: they are not drawn on screen, so burning them into the page //! would *add* ink that was never visible. use crate::annotations::PdfAnnotation; use crate::document::PdfDocument; use nigig_pdf_cos::incremental::IncrementalUpdate; use nigig_pdf_cos::object::{ObjRef, PdfDict, PdfObj, PdfStream}; /// Write the four operators flattening needs, without reaching for /// `pdf-graphics`. /// /// The crate boundary is `cos -> document -> graphics` and it is strict: /// `pdf-document` cannot depend on the graphics layer, and inverting that /// to reuse `content_edit::write_ops` would be a far worse trade than /// emitting `q`, `cm`, `Do` and `Q` here. These four are the entire /// vocabulary of placing an XObject. /// /// The number formatting is the same rule `content_edit::write_real` /// applies and for the same reason: PDF has no exponent syntax, and a /// coordinate written `1e-7` reads as the number 1 followed by an unknown /// operator. The precision is the shortest that parses back to the /// identical `f64`. fn write_real(value: f64) -> String { if !value.is_finite() { return "0".to_string(); } if value == value.trunc() && value.abs() < 1e15 { let as_int = value as i64; return if as_int == 0 { "0".to_string() } else { as_int.to_string() }; } let mut s = String::new(); for decimals in 1..=17usize { s = format!("{value:.decimals$}"); if s.parse::() == Ok(value) { break; } } if s.contains('.') { while s.ends_with('0') { s.pop(); } if s.ends_with('.') { s.pop(); } } if s == "-0" || s.is_empty() { s = "0".to_string(); } s } /// Paint one appearance XObject under a placement matrix, isolated in its /// own `q`/`Q` so it cannot leak state into the next one. fn paint_xobject(out: &mut Vec, name: &str, m: &Matrix) { out.extend_from_slice( b"q ", ); for v in m { out.extend_from_slice(write_real(*v).as_bytes()); out.push(b' '); } out.extend_from_slice( b"cm /", ); // A resource name generated here is always alphanumeric, so it needs // no `#` escaping; the assertion is in the test rather than a runtime // check that could never fire. out.extend_from_slice(name.as_bytes()); out.extend_from_slice( b" Do Q ", ); } /// Annotation flags (PDF 32000-1 table 165) that decide visibility. mod flags { pub const HIDDEN: i64 = 1 << 1; pub const NO_VIEW: i64 = 1 << 5; } /// Why one annotation was not flattened. #[derive(Clone, Debug, PartialEq, Eq)] pub enum SkipReason { /// No `/AP /N` stream, so there is nothing to draw. NoAppearance, /// `/AP /N` is a sub-dictionary of states and `/AS` names none of them, /// so which one to draw is undefined. AmbiguousAppearanceState, /// `/Hidden` or `/NoView` is set: it is not drawn, so it must not be /// burned in. NotVisible, /// A `/Popup` is the pop-up *window* of another annotation, never /// drawn on the page itself. PopupWindow, /// The appearance stream could not be read or decoded. UnreadableAppearance, /// `/Rect` is missing or degenerate, so there is nowhere to put it. DegenerateRect, } impl std::fmt::Display for SkipReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let text = match self { Self::NoAppearance => "no /AP /N appearance stream", Self::AmbiguousAppearanceState => "/AS names no state in /AP /N", Self::NotVisible => "hidden or /NoView", Self::PopupWindow => "a pop-up window is not drawn on the page", Self::UnreadableAppearance => "the appearance stream could not be read", Self::DegenerateRect => "/Rect is missing or has no area", }; f.write_str(text) } } /// What flattening did. #[derive(Clone, Debug, Default)] pub struct FlattenReport { /// Object numbers of the annotations burned into the page. pub flattened: Vec, /// Annotations left in place, with why. pub skipped: Vec<(u32, SkipReason)>, /// Whether the page's `/Annots` was rewritten. pub annots_rewritten: bool, /// Whether `/AcroForm` was removed, which happens only when every /// field in the document has been flattened. pub acroform_removed: bool, } impl FlattenReport { pub fn did_anything(&self) -> bool { !self.flattened.is_empty() } } /// Which annotations to flatten. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FlattenScope { /// Every annotation that can be flattened. All, /// Only form fields (`/Widget`), leaving comments and markup editable. /// /// This is what "finalise the form" means, and it is the common case: /// a filled form should stop being fillable without also destroying /// the reviewer's comments. WidgetsOnly, /// Everything except form fields. AnnotationsOnly, } impl FlattenScope { fn includes(&self, annot: &PdfAnnotation) -> bool { let is_widget = matches!(annot.annot_type, crate::annotations::AnnotationType::Widget); match self { Self::All => true, Self::WidgetsOnly => is_widget, Self::AnnotationsOnly => !is_widget, } } } /// A 3x2 affine matrix in PDF order: `[a b c d e f]`. type Matrix = [f64; 6]; const IDENTITY: Matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; fn apply(m: &Matrix, x: f64, y: f64) -> (f64, f64) { (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) } /// The appearance-to-page transform of §12.5.5. /// /// Returns the matrix that maps the form XObject's space onto the /// annotation's `/Rect`. /// /// The steps are the specification's and the order matters: the `/BBox` is /// transformed by `/Matrix` *first*, and the resulting box — not the /// original — is what gets fitted to `/Rect`. A rotated appearance has a /// larger transformed box than its own, so fitting the untransformed one /// scales it wrongly. pub fn appearance_matrix(bbox: [f64; 4], matrix: Matrix, rect: [f64; 4]) -> Matrix { // Normalise: either box may arrive with its corners in any order. let (bx0, bx1) = (bbox[0].min(bbox[2]), bbox[0].max(bbox[2])); let (by0, by1) = (bbox[1].min(bbox[3]), bbox[1].max(bbox[3])); let (rx0, rx1) = (rect[0].min(rect[2]), rect[0].max(rect[2])); let (ry0, ry1) = (rect[1].min(rect[3]), rect[1].max(rect[3])); // Step 1-2: the bounding box of the transformed /BBox corners. let corners = [ apply(&matrix, bx0, by0), apply(&matrix, bx1, by0), apply(&matrix, bx1, by1), apply(&matrix, bx0, by1), ]; let tx0 = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min); let tx1 = corners .iter() .map(|c| c.0) .fold(f64::NEG_INFINITY, f64::max); let ty0 = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min); let ty1 = corners .iter() .map(|c| c.1) .fold(f64::NEG_INFINITY, f64::max); // Step 3: scale the transformed box onto /Rect. // // A zero-width transformed box would divide by zero. It means the // appearance collapses to a line, which cannot be scaled to fill a // rectangle, so the scale stays 1 and only the translation applies — // the appearance lands in the right place at its own size rather than // producing NaN coordinates that render as nothing. let sx = if (tx1 - tx0).abs() > f64::EPSILON { (rx1 - rx0) / (tx1 - tx0) } else { 1.0 }; let sy = if (ty1 - ty0).abs() > f64::EPSILON { (ry1 - ry0) / (ty1 - ty0) } else { 1.0 }; // The full transform is A = Matrix * Scale * Translate, expressed as a // single `cm` operand list. [ matrix[0] * sx, matrix[1] * sy, matrix[2] * sx, matrix[3] * sy, matrix[4] * sx + rx0 - tx0 * sx, matrix[5] * sy + ry0 - ty0 * sy, ] } /// Read a `[f64; 4]` rectangle from a dictionary entry. fn rect_of(dict: &PdfDict, key: &str) -> Option<[f64; 4]> { let arr = dict.get_array(key)?; if arr.len() < 4 { return None; } let mut out = [0.0; 4]; for (i, slot) in out.iter_mut().enumerate() { *slot = arr[i].as_f64()?; } Some(out) } fn matrix_of(dict: &PdfDict) -> Matrix { let Some(arr) = dict.get_array("Matrix") else { return IDENTITY; }; if arr.len() < 6 { return IDENTITY; } let mut m = IDENTITY; for (i, slot) in m.iter_mut().enumerate() { match arr[i].as_f64() { Some(v) => *slot = v, None => return IDENTITY, } } m } /// The appearance stream to draw for one annotation. /// /// `/AP /N` is either a stream or a dictionary of named states, in which /// case `/AS` picks one. A state dictionary with exactly one entry and no /// `/AS` is resolved to that entry: it is unambiguous, and refusing it /// would skip a great many real checkboxes. fn appearance_of( doc: &mut PdfDocument, annot_dict: &PdfDict, ) -> Result<(PdfStream, PdfObj), SkipReason> { let ap = annot_dict .get("AP") .and_then(|o| doc.resolve_shallow_public(o)) .and_then(|o| o.as_dict().cloned()) .ok_or(SkipReason::NoAppearance)?; let normal = ap.get("N").cloned().ok_or(SkipReason::NoAppearance)?; let resolved = doc .resolve_shallow_public(&normal) .ok_or(SkipReason::UnreadableAppearance)?; match resolved { PdfObj::Stream(s) => Ok((s, normal)), PdfObj::Dict(states) => { let wanted = annot_dict.get_name("AS").map(|s| s.to_string()); let chosen = match wanted { Some(name) => states.get(&name).cloned(), None if states.map.len() == 1 => states.map.values().next().cloned(), None => None, } .ok_or(SkipReason::AmbiguousAppearanceState)?; let stream = doc .resolve_shallow_public(&chosen) .and_then(|o| match o { PdfObj::Stream(s) => Some(s), _ => None, }) .ok_or(SkipReason::UnreadableAppearance)?; Ok((stream, chosen)) } _ => Err(SkipReason::UnreadableAppearance), } } fn is_visible(annot_dict: &PdfDict) -> bool { let f = annot_dict.get_int("F").unwrap_or(0); f & flags::HIDDEN == 0 && f & flags::NO_VIEW == 0 } /// Remove `/AcroForm` from the catalogue when no widget survives. /// /// Flattening every field leaves the form *entry* behind, and a reader /// takes that at face value: poppler still reports `Form: AcroForm` on a /// document with no fields left, and a viewer may still show a "fill in /// this form" prompt for a form that no longer exists. Verified against /// poppler before and after. /// /// Only removed when the whole document is free of widget annotations — /// flattening one page of a three-page form must not strip the fields /// still live on the others. pub fn remove_acroform_if_empty( doc: &mut PdfDocument, source: &[u8], ) -> Result<(Vec, bool), FlattenError> { let trailer = doc.trailer().clone(); let Some(root_ref) = trailer.get("Root").and_then(|o| o.as_ref().copied()) else { return Ok((source.to_vec(), false)); }; let Some(root) = doc .resolve_ref(root_ref) .ok() .and_then(|o| o.as_dict().cloned()) else { return Ok((source.to_vec(), false)); }; if root.get("AcroForm").is_none() { return Ok((source.to_vec(), false)); } for page in 0..doc.page_count() { let annots = doc.page_annotations(page).unwrap_or_default(); if annots .iter() .any(|a| matches!(a.annot_type, crate::annotations::AnnotationType::Widget)) { return Ok((source.to_vec(), false)); } } let mut new_root = root; new_root.map.remove("AcroForm"); let mut update = IncrementalUpdate::new(); update.set_object(root_ref, PdfObj::Dict(new_root)); let bytes = update .append_to(source, &trailer) .map_err(|_| FlattenError::SaveFailed)?; Ok((bytes, true)) } /// Flatten every page, then drop `/AcroForm` if nothing is left to fill. /// /// The convenience entry point, and the one that matches what "flatten /// this document" means to a user. pub fn flatten_document( source: &[u8], scope: FlattenScope, ) -> Result<(Vec, FlattenReport), FlattenError> { let mut current = source.to_vec(); let mut combined = FlattenReport::default(); let page_count = { let doc = PdfDocument::parse(¤t).map_err(|_| FlattenError::SaveFailed)?; doc.page_count() }; for page in 0..page_count { // Re-parsed each round: the previous page's flatten appended a // revision, and the next must read the document as it now stands. let mut doc = PdfDocument::parse(¤t).map_err(|_| FlattenError::SaveFailed)?; let (next, report) = flatten_page(&mut doc, ¤t, page, scope)?; combined.flattened.extend(report.flattened); combined.skipped.extend(report.skipped); combined.annots_rewritten |= report.annots_rewritten; current = next; } if matches!(scope, FlattenScope::All | FlattenScope::WidgetsOnly) { let mut doc = PdfDocument::parse(¤t).map_err(|_| FlattenError::SaveFailed)?; let (next, removed) = remove_acroform_if_empty(&mut doc, ¤t)?; combined.acroform_removed = removed; current = next; } Ok((current, combined)) } /// Flatten a page's annotations into its content stream. /// /// Returns the updated file bytes and a report. The original revision is /// appended to, never rewritten, so earlier signatures stay valid over the /// bytes they covered. pub fn flatten_page( doc: &mut PdfDocument, source: &[u8], page_index: usize, scope: FlattenScope, ) -> Result<(Vec, FlattenReport), FlattenError> { let mut report = FlattenReport::default(); let page_ref = doc .page_object_ref(page_index) .ok_or(FlattenError::NoSuchPage(page_index))?; let page_dict = doc .resolve_ref(page_ref) .ok() .and_then(|o| o.as_dict().cloned()) .ok_or(FlattenError::NoSuchPage(page_index))?; let annotations = doc .page_annotations(page_index) .map_err(|_| FlattenError::NoSuchPage(page_index))?; if annotations.is_empty() { return Ok((source.to_vec(), report)); } // Existing page content, which the burned-in appearances follow. let existing = doc .page(page_index) .map(|p| p.content_data.clone()) .unwrap_or_default(); let mut xobjects: Vec<(String, PdfObj)> = Vec::new(); let mut painted: Vec = Vec::new(); let mut kept: Vec = Vec::new(); let mut update = IncrementalUpdate::new(); let mut next_name = 0usize; for annot in &annotations { let Some(obj_ref) = annot.obj_ref else { // A directly-embedded annotation has no reference to keep or // drop; leave it exactly as it is. continue; }; let annot_dict = annot.raw_dict.clone(); let keep = |kept: &mut Vec| kept.push(PdfObj::Ref(obj_ref)); if !scope.includes(annot) { keep(&mut kept); continue; } if matches!(annot.annot_type, crate::annotations::AnnotationType::Popup) { report.skipped.push((obj_ref.num, SkipReason::PopupWindow)); keep(&mut kept); continue; } if !is_visible(&annot_dict) { report.skipped.push((obj_ref.num, SkipReason::NotVisible)); keep(&mut kept); continue; } let Some(rect) = rect_of(&annot_dict, "Rect") else { report .skipped .push((obj_ref.num, SkipReason::DegenerateRect)); keep(&mut kept); continue; }; if (rect[2] - rect[0]).abs() < f64::EPSILON || (rect[3] - rect[1]).abs() < f64::EPSILON { report .skipped .push((obj_ref.num, SkipReason::DegenerateRect)); keep(&mut kept); continue; } let (stream, reference) = match appearance_of(doc, &annot_dict) { Ok(v) => v, Err(reason) => { report.skipped.push((obj_ref.num, reason)); keep(&mut kept); continue; } }; let bbox = rect_of(&stream.dict, "BBox").unwrap_or([0.0, 0.0, 1.0, 1.0]); let matrix = matrix_of(&stream.dict); let placement = appearance_matrix(bbox, matrix, rect); // The appearance is painted as an XObject rather than having its // operators spliced in. Splicing would need the stream's resources // merged into the page's, with every name collision renamed — far // more code, and it loses the /BBox clip that an XObject applies // for free. let name = loop { let candidate = format!("nigigFlat{next_name}"); next_name += 1; if !page_has_xobject(doc, &page_dict, &candidate) { break candidate; } }; // Reuse the appearance object itself when it is already indirect: // two annotations sharing one appearance stay sharing it. let xobject_ref = match reference.as_ref().copied() { Some(r) => r, None => { let r = ObjRef { num: doc.max_object_number() + 1 + update.len() as u32, gen: 0, }; update.set_stream(r, stream.clone()); r } }; xobjects.push((name.clone(), PdfObj::Ref(xobject_ref))); paint_xobject(&mut painted, &name, &placement); report.flattened.push(obj_ref.num); } if report.flattened.is_empty() { return Ok((source.to_vec(), report)); } // The new page content: the original, then the appearances. // // Wrapped in its own q/Q even though each appearance already is: the // *existing* content may leave the graphics state unbalanced, and // inheriting that would place every flattened annotation wrongly. let mut content = existing; if !content.is_empty() && !content.ends_with(b"\n") { content.push(b'\n'); } content.extend_from_slice(b"q\n"); content.extend_from_slice(&painted); content.extend_from_slice(b"Q\n"); let content_ref = ObjRef { num: doc.max_object_number() + 1 + update.len() as u32, gen: 0, }; let mut content_dict = PdfDict::new(); content_dict.set("Length", PdfObj::Int(content.len() as i64)); update.set_stream( content_ref, PdfStream { dict: content_dict, data: content, }, ); // The page: new content, merged XObject resources, surviving /Annots. let mut new_page = page_dict.clone(); new_page.set("Contents", PdfObj::Ref(content_ref)); let mut resources = page_dict .get("Resources") .and_then(|o| doc.resolve_shallow_public(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_default(); let mut xobject_dict = resources .get("XObject") .and_then(|o| doc.resolve_shallow_public(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_default(); for (name, value) in xobjects { xobject_dict.set(&name, value); } resources.set("XObject", PdfObj::Dict(xobject_dict)); new_page.set("Resources", PdfObj::Dict(resources)); if kept.is_empty() { new_page.map.remove("Annots"); } else { new_page.set("Annots", PdfObj::Array(kept)); } report.annots_rewritten = true; update.set_object(page_ref, PdfObj::Dict(new_page)); let trailer = doc.trailer().clone(); let bytes = update .append_to(source, &trailer) .map_err(|_| FlattenError::SaveFailed)?; Ok((bytes, report)) } fn page_has_xobject(doc: &mut PdfDocument, page: &PdfDict, name: &str) -> bool { page.get("Resources") .and_then(|o| doc.resolve_shallow_public(o)) .and_then(|o| o.as_dict().cloned()) .and_then(|r| r.get("XObject").cloned()) .and_then(|o| doc.resolve_shallow_public(&o)) .and_then(|o| o.as_dict().cloned()) .is_some_and(|x| x.get(name).is_some()) } /// Flattening failed outright. #[derive(Clone, Debug, PartialEq, Eq)] pub enum FlattenError { NoSuchPage(usize), SaveFailed, } impl std::fmt::Display for FlattenError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NoSuchPage(i) => write!(f, "page {i} does not exist"), Self::SaveFailed => write!(f, "the flattened document could not be written"), } } } impl std::error::Error for FlattenError {} #[cfg(test)] mod tests { use super::*; fn close(a: f64, b: f64) -> bool { (a - b).abs() < 1e-9 } fn assert_matrix(got: Matrix, want: Matrix) { for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() { assert!( close(*g, *w), "component {i}: got {g}, want {w} (full: {got:?})" ); } } /// The simplest case: a unit box onto a rectangle at the origin. #[test] fn a_unit_box_scales_to_the_rectangle() { let m = appearance_matrix([0.0, 0.0, 1.0, 1.0], IDENTITY, [0.0, 0.0, 100.0, 50.0]); assert_matrix(m, [100.0, 0.0, 0.0, 50.0, 0.0, 0.0]); } /// A rectangle away from the origin must translate as well as scale. #[test] fn an_offset_rectangle_translates() { let m = appearance_matrix([0.0, 0.0, 1.0, 1.0], IDENTITY, [10.0, 20.0, 110.0, 70.0]); assert_matrix(m, [100.0, 0.0, 0.0, 50.0, 10.0, 20.0]); // The box's corners must land on the rectangle's corners. assert_eq!(apply(&m, 0.0, 0.0), (10.0, 20.0)); assert_eq!(apply(&m, 1.0, 1.0), (110.0, 70.0)); } /// A `/BBox` that does not start at the origin must be shifted, not /// just scaled. This is the case that puts a stamp in the wrong place. #[test] fn a_bbox_away_from_the_origin_is_shifted_onto_the_rectangle() { let m = appearance_matrix( [100.0, 100.0, 200.0, 150.0], IDENTITY, [0.0, 0.0, 200.0, 100.0], ); let (x0, y0) = apply(&m, 100.0, 100.0); let (x1, y1) = apply(&m, 200.0, 150.0); assert!(close(x0, 0.0) && close(y0, 0.0), "lower-left: {x0},{y0}"); assert!( close(x1, 200.0) && close(y1, 100.0), "upper-right: {x1},{y1}" ); } /// §12.5.5 step 1: the `/BBox` is transformed by `/Matrix` *first*, and /// the transformed box is what gets fitted. A 90-degree rotation swaps /// the box's width and height, so fitting the untransformed box scales /// it wrongly — the classic "rotated stamp is squashed" bug. #[test] fn a_rotated_appearance_fits_its_transformed_box() { // Rotate 90 degrees: [0 1 -1 0 0 0]. let rotate: Matrix = [0.0, 1.0, -1.0, 0.0, 0.0, 0.0]; let m = appearance_matrix([0.0, 0.0, 100.0, 50.0], rotate, [0.0, 0.0, 50.0, 100.0]); // Every corner of the original box must land inside /Rect. for (x, y) in [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)] { let (px, py) = apply(&m, x, y); assert!( (-1e-6..=50.0 + 1e-6).contains(&px) && (-1e-6..=100.0 + 1e-6).contains(&py), "corner ({x},{y}) landed at ({px},{py}), outside the 50x100 rect" ); } // And the transformed box must fill it, not sit in a corner. let (ax, ay) = apply(&m, 0.0, 0.0); let (bx, by) = apply(&m, 100.0, 50.0); assert!(close((ax - bx).abs(), 50.0), "width {}", (ax - bx).abs()); assert!(close((ay - by).abs(), 100.0), "height {}", (ay - by).abs()); } #[test] fn a_scaling_matrix_is_accounted_for() { let scale: Matrix = [2.0, 0.0, 0.0, 2.0, 0.0, 0.0]; let m = appearance_matrix([0.0, 0.0, 50.0, 50.0], scale, [0.0, 0.0, 100.0, 100.0]); // The /Matrix already doubles it to 100x100, so fitting a 100x100 // rect needs no further scaling. let (x1, y1) = apply(&m, 50.0, 50.0); assert!(close(x1, 100.0) && close(y1, 100.0), "{x1},{y1}"); } /// A degenerate box collapses to a line and cannot be scaled to fill a /// rectangle. It must not produce NaN, which renders as nothing. #[test] fn a_degenerate_bbox_does_not_produce_nan() { let m = appearance_matrix([0.0, 0.0, 0.0, 100.0], IDENTITY, [0.0, 0.0, 50.0, 50.0]); for (i, v) in m.iter().enumerate() { assert!(v.is_finite(), "component {i} is {v}"); } } /// Reversed corners are legal in both boxes and must be normalised. #[test] fn reversed_corners_are_normalised() { let forward = appearance_matrix([0.0, 0.0, 1.0, 1.0], IDENTITY, [0.0, 0.0, 100.0, 50.0]); let reversed = appearance_matrix([1.0, 1.0, 0.0, 0.0], IDENTITY, [100.0, 50.0, 0.0, 0.0]); assert_matrix(reversed, forward); } #[test] fn scope_selects_the_right_annotations() { use crate::annotations::AnnotationType; let make = |kind: AnnotationType| PdfAnnotation { annot_type: kind, rect: [0.0, 0.0, 10.0, 10.0], flags: Default::default(), page_index: None, obj_ref: None, raw_dict: PdfDict::new(), }; let widget = make(AnnotationType::Widget); let text = make(AnnotationType::Text { contents: None }); assert!(FlattenScope::All.includes(&widget)); assert!(FlattenScope::All.includes(&text)); assert!(FlattenScope::WidgetsOnly.includes(&widget)); assert!(!FlattenScope::WidgetsOnly.includes(&text)); assert!(!FlattenScope::AnnotationsOnly.includes(&widget)); assert!(FlattenScope::AnnotationsOnly.includes(&text)); } #[test] fn hidden_and_noview_annotations_are_not_visible() { let mut d = PdfDict::new(); assert!(is_visible(&d), "no /F means visible"); d.set("F", PdfObj::Int(flags::HIDDEN)); assert!(!is_visible(&d), "/Hidden must not be burned in"); d.set("F", PdfObj::Int(flags::NO_VIEW)); assert!(!is_visible(&d), "/NoView must not be burned in"); d.set("F", PdfObj::Int(4)); // /Print assert!(is_visible(&d), "/Print alone is visible"); } #[test] fn skip_reasons_describe_themselves() { assert!(SkipReason::NoAppearance.to_string().contains("/AP")); assert!(SkipReason::NotVisible.to_string().contains("hidden")); assert!(SkipReason::PopupWindow.to_string().contains("pop-up")); assert!(SkipReason::DegenerateRect.to_string().contains("/Rect")); } #[test] fn a_matrix_is_read_or_defaults_to_identity() { let mut d = PdfDict::new(); assert_eq!(matrix_of(&d), IDENTITY, "absent /Matrix is the identity"); d.set( "Matrix", PdfObj::Array(vec![ PdfObj::Int(2), PdfObj::Int(0), PdfObj::Int(0), PdfObj::Int(2), PdfObj::Int(5), PdfObj::Int(5), ]), ); assert_eq!(matrix_of(&d), [2.0, 0.0, 0.0, 2.0, 5.0, 5.0]); // A short or malformed matrix falls back rather than reading past // the end. d.set("Matrix", PdfObj::Array(vec![PdfObj::Int(1)])); assert_eq!(matrix_of(&d), IDENTITY); } }