//! Flattening, read back from the saved file. //! //! The unit tests in `flatten.rs` check the placement arithmetic. These //! check the **document**: that the appearance really is in the page's //! content, that the annotation really is gone, and that a reader sees //! both. //! //! Flattening is irreversible, so getting it wrong destroys the user's //! data in a way no later step can recover. The failure modes are all //! quiet: the annotation is removed but nothing is drawn (content lost), //! or it is drawn in the wrong place (content moved), or it is drawn and //! also still an annotation (drawn twice). None of them raises an error, //! so each has an assertion here. use nigig_pdf_cos::object::{PdfDict, PdfObj}; use nigig_pdf_document::flatten::{flatten_page, FlattenScope, SkipReason}; use nigig_pdf_document::PdfDocument; /// A one-page document with annotations, built so each is identifiable. /// /// `annots` supplies the extra dictionary entries for each annotation, so /// a test can vary flags, rect and appearance without a new fixture. fn document_with_annotations(annots: &[(&str, &str)]) -> Vec { document_with_catalog_extra(annots, "") } /// As above, with extra entries spliced into the catalogue dictionary. /// /// The extras go in *before* the xref offsets are computed. Patching the /// bytes afterwards shifts every object and leaves the table pointing at /// the wrong places — which is exactly what the first version of the /// /AcroForm tests did, and the file then failed to parse. fn document_with_catalog_extra(annots: &[(&str, &str)], catalog_extra: &str) -> Vec { let mut out = String::from("%PDF-1.7\n"); let mut offsets: Vec = Vec::new(); let push = |out: &mut String, offsets: &mut Vec, body: &str| { offsets.push(out.len()); out.push_str(body); }; // 1 catalog, 2 pages, 3 page, 4 content, then annotation/appearance // pairs from 5. let annot_refs: Vec = (0..annots.len()) .map(|i| format!("{} 0 R", 5 + i * 2)) .collect(); push( &mut out, &mut offsets, &format!("1 0 obj\n<< /Type /Catalog /Pages 2 0 R {catalog_extra}>>\nendobj\n"), ); push( &mut out, &mut offsets, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n", ); let page = format!( "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \ /Contents 4 0 R /Annots [{}] >>\nendobj\n", annot_refs.join(" ") ); push(&mut out, &mut offsets, &page); let content = "BT (page body) Tj ET"; push( &mut out, &mut offsets, &format!( "4 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\n", content.len() ), ); for (i, (annot_entries, appearance_content)) in annots.iter().enumerate() { let annot_num = 5 + i * 2; let ap_num = annot_num + 1; let annot = if appearance_content.is_empty() { format!("{annot_num} 0 obj\n<< /Type /Annot {annot_entries} >>\nendobj\n") } else { format!( "{annot_num} 0 obj\n<< /Type /Annot {annot_entries} \ /AP << /N {ap_num} 0 R >> >>\nendobj\n" ) }; push(&mut out, &mut offsets, &annot); let ap = format!( "{ap_num} 0 obj\n<< /Type /XObject /Subtype /Form \ /BBox [0 0 10 10] /Length {} >>\nstream\n{appearance_content}\nendstream\nendobj\n", appearance_content.len() ); push(&mut out, &mut offsets, &ap); } let startxref = out.len(); let total = 5 + annots.len() * 2; out.push_str(&format!("xref\n0 {total}\n0000000000 65535 f \n")); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); out.into_bytes() } fn parse(bytes: &[u8]) -> PdfDocument<'_> { PdfDocument::parse(bytes).expect("parses") } fn page_content(bytes: &[u8]) -> String { let mut doc = parse(bytes); let page = doc.page(0).expect("page 0"); String::from_utf8_lossy(&page.content_data).to_string() } fn annotation_count(bytes: &[u8]) -> usize { let mut doc = parse(bytes); doc.page_annotations(0).map(|a| a.len()).unwrap_or(0) } #[test] fn the_fixture_reads_back_with_its_annotation() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); assert_eq!(annotation_count(&bytes), 1); assert!(page_content(&bytes).contains("page body")); } /// The core round trip: the appearance lands in the page, the annotation /// goes away, and the original content survives. #[test] fn flattening_moves_the_appearance_into_the_page_and_removes_the_annotation() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); assert_eq!(report.flattened.len(), 1, "one annotation was flattened"); assert!(report.skipped.is_empty(), "nothing should be skipped"); assert_eq!( annotation_count(&saved), 0, "the annotation must be gone from /Annots" ); let content = page_content(&saved); assert!( content.contains("page body"), "the original page content was lost: {content}" ); assert!( content.contains(" Do"), "the appearance is not painted into the page: {content}" ); assert!( content.contains("cm"), "no placement matrix was written: {content}" ); } /// The appearance must be reachable as a page resource. /// /// A `Do` naming an XObject the page does not declare draws *nothing*, and /// nothing errors — the annotation would simply vanish. This is the exact /// failure ADR 0017 is about, in a new place. #[test] fn the_flattened_appearance_is_declared_in_the_page_resources() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); let mut saved_doc = parse(&saved); let page = saved_doc.page(0).expect("page 0"); assert!( !page.xobjects.is_empty(), "the page declares no XObject, so the painted appearance draws nothing" ); // The name in the content stream must be one the page declares. let content = String::from_utf8_lossy(&page.content_data).to_string(); let declared: Vec<&String> = page.xobjects.keys().collect(); assert!( declared.iter().any(|name| content.contains(name.as_str())), "the content paints a name the page does not declare: \ content {content:?}, declared {declared:?}" ); } /// The placement matrix must put the appearance at the annotation's `/Rect`. /// /// A stamp drawn at the origin instead of its rectangle is the commonest /// flatten bug, and the page still renders. #[test] fn the_appearance_is_placed_at_the_annotation_rectangle() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 200 300 250]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); let content = page_content(&saved); // /BBox is 10x10 onto a 200x50 rect: scale 20 and 5, offset 100,200. assert!( content.contains("20 0 0 5 100 200 cm"), "the placement matrix is wrong; content is: {content}" ); } /// An annotation with no appearance is left alone and reported. /// /// Dropping it would lose it; inventing an appearance would draw something /// the producer never specified. Neither is acceptable silently. #[test] fn an_annotation_without_an_appearance_is_kept_and_reported() { let bytes = document_with_annotations(&[("/Subtype /Square /Rect [10 10 20 20]", "")]); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op"); assert!(report.flattened.is_empty()); assert_eq!(report.skipped.len(), 1); assert_eq!(report.skipped[0].1, SkipReason::NoAppearance); assert_eq!( annotation_count(&saved), 1, "an unflattenable annotation must survive, not disappear" ); assert_eq!(saved, bytes, "a no-op flatten must not rewrite the file"); } /// A hidden annotation is not drawn on screen, so burning it in would add /// ink the user never saw. #[test] fn a_hidden_annotation_is_not_burned_in() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [10 10 20 20] /F 2", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op"); assert!(report.flattened.is_empty(), "hidden must not be flattened"); assert_eq!(report.skipped[0].1, SkipReason::NotVisible); assert_eq!(annotation_count(&saved), 1); } /// A degenerate `/Rect` has nowhere to put the appearance. #[test] fn a_zero_area_rectangle_is_skipped() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [10 10 10 20]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (_, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op"); assert_eq!(report.skipped[0].1, SkipReason::DegenerateRect); } /// Scope must select: finalising a form should not destroy the reviewer's /// comments. #[test] fn widgets_only_leaves_other_annotations_editable() { let bytes = document_with_annotations(&[ ( "/Subtype /Widget /Rect [10 10 100 30] /FT /Tx", "0 g BT (field) Tj ET", ), ( "/Subtype /Square /Rect [200 200 300 300]", "1 0 0 rg 0 0 10 10 re f", ), ]); let mut doc = parse(&bytes); assert_eq!(annotation_count(&bytes), 2); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::WidgetsOnly).expect("flattens"); assert_eq!(report.flattened.len(), 1, "only the widget is flattened"); assert_eq!( annotation_count(&saved), 1, "the square annotation must survive" ); let content = page_content(&saved); assert!(content.contains(" Do"), "the widget was painted"); } #[test] fn annotations_only_leaves_the_form_fillable() { let bytes = document_with_annotations(&[ ( "/Subtype /Widget /Rect [10 10 100 30] /FT /Tx", "0 g BT (field) Tj ET", ), ( "/Subtype /Square /Rect [200 200 300 300]", "1 0 0 rg 0 0 10 10 re f", ), ]); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::AnnotationsOnly).expect("flattens"); assert_eq!(report.flattened.len(), 1); assert_eq!(annotation_count(&saved), 1, "the widget must survive"); } /// Several annotations must all be painted, each in its own place. #[test] fn every_annotation_is_flattened_and_isolated() { let bytes = document_with_annotations(&[ ( "/Subtype /Square /Rect [0 0 10 10]", "1 0 0 rg 0 0 10 10 re f", ), ( "/Subtype /Square /Rect [100 100 120 120]", "0 1 0 rg 0 0 10 10 re f", ), ( "/Subtype /Square /Rect [200 200 240 240]", "0 0 1 rg 0 0 10 10 re f", ), ]); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); assert_eq!(report.flattened.len(), 3); assert_eq!(annotation_count(&saved), 0); let content = page_content(&saved); assert_eq!( content.matches(" Do").count(), 3, "each appearance must be painted once: {content}" ); // Each must be isolated, or one appearance's colour leaks into the // next. Three appearances plus the outer wrapper is four q/Q pairs. assert!( content.matches('q').count() >= 4 && content.matches('Q').count() >= 4, "appearances are not isolated in q/Q: {content}" ); } /// A page with no annotations must be returned untouched. #[test] fn a_page_with_no_annotations_is_unchanged() { let bytes = document_with_annotations(&[]); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("no-op"); assert!(!report.did_anything()); assert_eq!(saved, bytes); } /// An incremental save appends: the original revision stays byte-identical. #[test] fn the_original_revision_is_left_untouched() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); assert!(saved.len() > bytes.len()); assert_eq!( &saved[..bytes.len()], &bytes[..], "flattening rewrote the original revision" ); } /// Flattening twice must not paint the appearance twice. /// /// The second pass has no annotations left to flatten, so it must be a /// no-op. A `Do` count that grows on each save is how a "flatten" button /// pressed twice doubles every stamp's opacity. #[test] fn flattening_twice_is_idempotent() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (once, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); let mut doc2 = parse(&once); let (twice, report) = flatten_page(&mut doc2, &once, 0, FlattenScope::All).expect("second pass"); assert!(!report.did_anything(), "the second flatten found nothing"); assert_eq!(twice, once, "a second flatten must not change the file"); assert_eq!( page_content(&twice).matches(" Do").count(), 1, "the appearance was painted twice" ); } /// A checkbox's `/AP /N` is a dictionary of states; `/AS` picks one. #[test] fn an_appearance_state_dictionary_is_resolved_through_as() { // Built by hand: the fixture helper only writes a plain /AP /N stream. let mut out = String::from("%PDF-1.7\n"); let mut offsets = Vec::new(); let push = |out: &mut String, offsets: &mut Vec, s: &str| { offsets.push(out.len()); out.push_str(s); }; push( &mut out, &mut offsets, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", ); push( &mut out, &mut offsets, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \ /Contents 4 0 R /Annots [5 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n", ); push( &mut out, &mut offsets, "5 0 obj\n<< /Type /Annot /Subtype /Widget /FT /Btn /Rect [10 10 30 30] \ /AS /On /AP << /N << /On 6 0 R /Off 7 0 R >> >> >>\nendobj\n", ); for (num, body) in [(6, "0 0 1 rg 0 0 5 5 re f"), (7, "1 1 1 rg 0 0 5 5 re f")] { push( &mut out, &mut offsets, &format!( "{num} 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] \ /Length {} >>\nstream\n{body}\nendstream\nendobj\n", body.len() ), ); } let startxref = out.len(); out.push_str("xref\n0 8\n0000000000 65535 f \n"); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); let bytes = out.into_bytes(); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); assert_eq!( report.flattened.len(), 1, "the /On state should have been resolved and painted: {:?}", report.skipped ); assert_eq!(annotation_count(&saved), 0); } /// Existing page resources must survive: flattening adds an XObject, it /// does not replace the page's fonts. #[test] fn existing_page_resources_are_preserved() { let mut out = String::from("%PDF-1.7\n"); let mut offsets = Vec::new(); let push = |out: &mut String, offsets: &mut Vec, s: &str| { offsets.push(out.len()); out.push_str(s); }; push( &mut out, &mut offsets, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", ); push( &mut out, &mut offsets, "2 0 obj\n<< /Type /Pages /Count 1 /Kids [3 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \ /Contents 4 0 R /Annots [5 0 R] \ /Resources << /Font << /F1 7 0 R >> >> >>\nendobj\n", ); push( &mut out, &mut offsets, "4 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n", ); push( &mut out, &mut offsets, "5 0 obj\n<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] \ /AP << /N 6 0 R >> >>\nendobj\n", ); push( &mut out, &mut offsets, "6 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] /Length 21 >>\n\ stream\n0 0 1 rg 0 0 5 5 re f\nendstream\nendobj\n", ); push( &mut out, &mut offsets, "7 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n", ); let startxref = out.len(); out.push_str("xref\n0 8\n0000000000 65535 f \n"); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); let bytes = out.into_bytes(); let mut doc = parse(&bytes); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); assert_eq!(report.flattened.len(), 1); let mut saved_doc = parse(&saved); let page = saved_doc.page(0).expect("page"); assert!( page.fonts.contains_key("F1"), "flattening dropped the page's existing font resource: {:?}", page.fonts.keys().collect::>() ); assert!(!page.xobjects.is_empty(), "and it added the appearance"); } /// Flattening a page that does not exist is refused. #[test] fn flattening_a_missing_page_is_refused() { let bytes = document_with_annotations(&[]); let mut doc = parse(&bytes); assert!(flatten_page(&mut doc, &bytes, 9, FlattenScope::All).is_err()); } /// The generated resource name must not collide with one the page already /// declares, or flattening silently replaces an existing image. #[test] fn the_generated_resource_name_does_not_collide() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); let mut saved_doc = parse(&saved); let page = saved_doc.page(0).expect("page"); // The name is generated, so assert the property rather than the string: // every declared XObject resolves, and the content names one of them. for (name, resource) in &page.xobjects { assert!( !name.is_empty(), "an empty resource name cannot be referenced" ); assert_eq!( resource.subtype, "Form", "a flattened appearance must be a form XObject" ); } } /// A `PdfDict` round trip through the writer, as a sanity check that the /// appearance object itself is reused rather than copied. #[test] fn a_shared_appearance_is_referenced_not_duplicated() { let bytes = document_with_annotations(&[ ( "/Subtype /Square /Rect [0 0 10 10]", "1 0 0 rg 0 0 10 10 re f", ), ( "/Subtype /Square /Rect [50 50 60 60]", "1 0 0 rg 0 0 10 10 re f", ), ]); let mut doc = parse(&bytes); let before = doc.max_object_number(); let (saved, report) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); assert_eq!(report.flattened.len(), 2); let saved_doc = parse(&saved); // Only the new content stream and the rewritten page are added; the // two appearance streams already existed as indirect objects and are // referenced in place. assert!( saved_doc.max_object_number() <= before + 2, "flattening duplicated appearance objects: {} -> {}", before, saved_doc.max_object_number() ); } /// The page dictionary must not keep a dangling `/Annots` entry. #[test] fn a_fully_flattened_page_has_no_annots_key() { let bytes = document_with_annotations(&[( "/Subtype /Square /Rect [100 100 200 200]", "1 0 0 rg 0 0 10 10 re f", )]); let mut doc = parse(&bytes); let (saved, _) = flatten_page(&mut doc, &bytes, 0, FlattenScope::All).expect("flattens"); let mut saved_doc = parse(&saved); let page_ref = saved_doc.page_object_ref(0).expect("page ref"); let page = saved_doc.resolve_ref(page_ref).expect("page object"); let dict: PdfDict = page.as_dict().cloned().expect("dict"); match dict.get("Annots") { None => {} Some(PdfObj::Array(a)) => assert!( a.is_empty(), "/Annots still lists {} annotations after a full flatten", a.len() ), Some(other) => panic!("/Annots is a {other:?}"), } } // ------------------------------------------------- whole-document flatten use nigig_pdf_document::flatten::flatten_document; /// Flattening every field must also remove `/AcroForm`. /// /// Leaving it behind means a reader still reports the document as a form: /// poppler's `pdfinfo` printed `Form: AcroForm` on a file with no fields /// left, and a viewer may still offer to fill it in. Found by running the /// flattened sample through poppler rather than by reading the code. #[test] fn flattening_every_field_removes_the_acroform_entry() { let with_form = document_with_catalog_extra( &[( "/Subtype /Widget /Rect [10 10 100 30] /FT /Tx", "0 g BT (field) Tj ET", )], "/AcroForm << /Fields [5 0 R] >> ", ); let mut doc = parse(&with_form); assert!( doc.acroform().ok().flatten().is_some(), "the fixture must start with a form" ); drop(doc); let (saved, report) = flatten_document(&with_form, FlattenScope::All).expect("flattens"); assert_eq!(report.flattened.len(), 1); assert!( report.acroform_removed, "the form entry must be removed once no field survives" ); let mut saved_doc = parse(&saved); let form = saved_doc.acroform().ok().flatten(); assert!( form.is_none() || form.is_some_and(|f| f.field_count() == 0), "the document still declares a form after every field was flattened" ); } /// A form with a field still live on another page must keep `/AcroForm`. /// /// Removing it because *this* page is clean would break the fields that /// remain, which is a far worse outcome than a stale entry. #[test] fn a_partly_flattened_form_keeps_its_acroform() { let with_form = document_with_catalog_extra( &[( "/Subtype /Widget /Rect [10 10 100 30] /FT /Tx", "0 g BT (field) Tj ET", )], "/AcroForm << /Fields [5 0 R] >> ", ); // Flatten *annotations only*, so the widget survives. let (saved, report) = flatten_document(&with_form, FlattenScope::AnnotationsOnly).expect("flattens"); assert!( !report.acroform_removed, "a surviving widget must keep the form entry" ); assert_eq!(annotation_count(&saved), 1); } /// The whole-document helper must flatten every page, not only the first. #[test] fn flatten_document_covers_every_page() { // Two pages, each with one annotation. let mut out = String::from("%PDF-1.7\n"); let mut offsets = Vec::new(); let push = |out: &mut String, offsets: &mut Vec, s: &str| { offsets.push(out.len()); out.push_str(s); }; push( &mut out, &mut offsets, "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", ); push( &mut out, &mut offsets, "2 0 obj\n<< /Type /Pages /Count 2 /Kids [3 0 R 4 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \ /Contents 5 0 R /Annots [6 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "4 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] \ /Contents 5 0 R /Annots [8 0 R] >>\nendobj\n", ); push( &mut out, &mut offsets, "5 0 obj\n<< /Length 4 >>\nstream\nq Q\nendstream\nendobj\n", ); for (annot, ap) in [(6, 7), (8, 9)] { push( &mut out, &mut offsets, &format!( "{annot} 0 obj\n<< /Type /Annot /Subtype /Square /Rect [10 10 30 30] \ /AP << /N {ap} 0 R >> >>\nendobj\n" ), ); push( &mut out, &mut offsets, &format!( "{ap} 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 5 5] \ /Length 21 >>\nstream\n0 0 1 rg 0 0 5 5 re f\nendstream\nendobj\n" ), ); } let startxref = out.len(); out.push_str("xref\n0 10\n0000000000 65535 f \n"); for off in &offsets { out.push_str(&format!("{off:010} 00000 n \n")); } out.push_str(&format!( "trailer\n<< /Size 10 /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" )); let bytes = out.into_bytes(); let doc = parse(&bytes); assert_eq!(doc.page_count(), 2, "the fixture must have two pages"); drop(doc); let (saved, report) = flatten_document(&bytes, FlattenScope::All).expect("flattens"); assert_eq!( report.flattened.len(), 2, "both pages' annotations must be flattened" ); let mut saved_doc = parse(&saved); for page in 0..2 { assert_eq!( saved_doc .page_annotations(page) .map(|a| a.len()) .unwrap_or(0), 0, "page {page} still has annotations" ); } }