//! Phase 4: creating documents, read back through our own parser. //! //! A writer can only be tested by reading what it wrote. Every test here //! generates a file and then parses it with `PdfDocument`, asserting //! **values** — page sizes, field values, destination targets — rather than //! that the bytes contain a substring. Checking for `/Type /Page` in the //! output proves nothing: the old builder emitted that and still discarded //! every page size it was given. //! //! See `REVIEWS/adr/0019-pdf-document-creation.md`. use std::collections::BTreeSet; use nigig_pdf_cos::object::{PdfDict, PdfObj, PdfStream}; use nigig_pdf_cos::writer::{ Attachment, DocumentMetadata, OutlineItem, PageLabelRange, PageLabelStyle, PageLayout, PageMode, PdfDocBuilder, ViewerPreferences, }; use nigig_pdf_document::form::{FieldType, FieldValue}; use nigig_pdf_document::PdfDocument; use nigig_pdf_graphics::content_writer::ContentWriter; use nigig_pdf_graphics::create::{DocumentCreator, NewField, NewFieldKind}; const FONT: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"; fn font_bytes() -> Option> { std::fs::read(FONT).ok() } // ------------------------------------------------------------- structure #[test] fn a_generated_document_parses_back_with_the_page_sizes_it_was_given() { // The regression: `add_page_with_content` took a width and a height, // named the parameters `_width` and `_height`, and wrote no /MediaBox // at all. Every page came back as US Letter whatever the caller asked // for, and nothing noticed because no test read the size back. let mut builder = PdfDocBuilder::new(); builder.add_page(200.0, 400.0, b"1 0 0 rg 0 0 10 10 re f"); builder.add_page(300.5, 500.25, b"0 0 1 rg 0 0 10 10 re f"); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("generated file must parse"); assert_eq!(doc.page_count(), 2); assert_eq!(doc.page(0).unwrap().media_box, [0.0, 0.0, 200.0, 400.0]); assert_eq!(doc.page(1).unwrap().media_box, [0.0, 0.0, 300.5, 500.25]); } #[test] fn page_content_survives_the_round_trip() { let mut builder = PdfDocBuilder::new(); let mut cw = ContentWriter::new(); cw.rgb_fill(1.0, 0.0, 0.0); cw.rectangle(10.0, 20.0, 30.0, 40.0); cw.fill(); builder.add_page(200.0, 200.0, &cw.build()); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let page = doc.page(0).unwrap(); let text = String::from_utf8_lossy(&page.content_data); assert!(text.contains("re"), "content was {text:?}"); assert!(text.contains("f"), "content was {text:?}"); assert!(text.contains("1 0 0 rg"), "content was {text:?}"); } #[test] fn an_uncompressed_document_is_still_readable() { let mut builder = PdfDocBuilder::new(); builder.set_compress(false); builder.add_page(100.0, 100.0, b"0 g 1 1 10 10 re f"); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); assert_eq!( doc.page(0).unwrap().content_data, b"0 g 1 1 10 10 re f".to_vec() ); } #[test] fn a_document_with_no_pages_still_produces_a_valid_file() { // An empty document is a legal thing to generate, and a writer that // emits a broken file for it fails at the worst moment. let pdf = PdfDocBuilder::new().finish(); let doc = PdfDocument::parse(&pdf).expect("an empty document must still parse"); assert_eq!(doc.page_count(), 0); } // -------------------------------------------------------------- metadata #[test] fn metadata_round_trips_through_the_info_dictionary() { let mut builder = PdfDocBuilder::new(); builder.add_page(100.0, 100.0, b""); builder.set_metadata(DocumentMetadata { title: Some("The Title".to_string()), author: Some("Ada Lovelace".to_string()), subject: Some("A subject".to_string()), keywords: Some("one two".to_string()), creator: Some("nigig".to_string()), producer: Some("nigig-pdf".to_string()), creation_date: Some("D:20260816120000Z".to_string()), mod_date: None, }); let pdf = builder.finish(); let doc = PdfDocument::parse(&pdf).expect("parses"); let info = doc.info(); assert_eq!(info.title.as_deref(), Some("The Title")); assert_eq!(info.author.as_deref(), Some("Ada Lovelace")); assert_eq!(info.subject.as_deref(), Some("A subject")); assert_eq!(info.keywords.as_deref(), Some("one two")); assert_eq!(info.producer.as_deref(), Some("nigig-pdf")); assert_eq!(info.creation_date.as_deref(), Some("D:20260816120000Z")); } #[test] fn a_title_containing_pdf_syntax_is_escaped_not_corrupted() { // Parentheses and backslashes end a literal string. Writing them raw // truncates the title and, worse, leaves the rest of the file being // parsed as string content. let mut builder = PdfDocBuilder::new(); builder.add_page(100.0, 100.0, b""); builder.set_metadata(DocumentMetadata { title: Some(r"A (tricky\ title) here".to_string()), ..Default::default() }); let pdf = builder.finish(); let doc = PdfDocument::parse(&pdf).expect("parses"); assert_eq!(doc.info().title.as_deref(), Some(r"A (tricky\ title) here")); } #[test] fn a_dictionary_key_needing_escapes_still_round_trips() { // Keys are names and must be escaped like names. Writing them raw // produced `<>`, which our own lexer rejects with // "expected number" because `Key` reads as the value. A generated file // that cannot be reparsed is the worst possible writer bug. // // A resource name reaches the output as a real dictionary key, unlike // an attachment name, which is written as a string. Only the key path // exercises the escaping. let mut builder = PdfDocBuilder::new(); let page = builder.add_page(100.0, 100.0, b"/Odd Name Do"); let mut xobject = PdfDict::new(); xobject.set("Type", PdfObj::Name("XObject".to_string())); xobject.set("Subtype", PdfObj::Name("Form".to_string())); xobject.set("BBox", PdfObj::Array(vec![PdfObj::Int(0); 4])); xobject.set("Length", PdfObj::Int(0)); let num = builder.add_object(PdfObj::Stream(PdfStream { dict: xobject, data: Vec::new(), })); // A space and a hash are both illegal bare in a name. builder.add_page_resource(page, "XObject", "Odd Name#1", num); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("a file with odd keys must reparse"); let page = doc.page(0).expect("page 0"); assert!( page.xobjects.contains_key("Odd Name#1"), "the escaped key must decode back to its original text, got {:?}", page.xobjects.keys().collect::>() ); } #[test] fn an_attachment_name_with_pdf_syntax_survives() { let mut builder = PdfDocBuilder::new(); builder.add_page(100.0, 100.0, b""); builder.add_attachment(Attachment { name: "a file (with) parens.txt".to_string(), data: b"body".to_vec(), description: None, mime_type: Some("text/plain".to_string()), }); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let attachments = doc.attachments().expect("attachments"); assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].name, "a file (with) parens.txt"); assert_eq!(attachments[0].data, b"body"); // A MIME type is a name, so its slash is written #2F and must decode. assert_eq!(attachments[0].mime_type.as_deref(), Some("text/plain")); } #[test] fn a_non_finite_number_does_not_corrupt_the_file() { // PDF has no syntax for NaN or infinity: `format!("{}", f64::NAN)` // emits `NaN`, which a parser reads as a keyword and chokes on. One // such value anywhere made the whole document unreadable. let mut builder = PdfDocBuilder::new(); builder.add_page(f64::NAN, f64::INFINITY, b""); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("must still parse"); let media = doc.page(0).expect("page 0").media_box; assert!( media.iter().all(|v| v.is_finite()), "a non-finite page size must be clamped, got {media:?}" ); } // --------------------------------------------------------------- outline #[test] fn an_outline_tree_is_linked_so_every_entry_is_reachable() { let mut builder = PdfDocBuilder::new(); for _ in 0..3 { builder.add_page(200.0, 200.0, b""); } builder.set_outline(vec![ OutlineItem::new("Chapter 1", 0) .opened() .with_children(vec![ OutlineItem::new("Section 1.1", 1).with_top(500.0), OutlineItem::new("Section 1.2", 2), ]), OutlineItem::new("Chapter 2", 2), ]); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let outlines = doc.outline().expect("an outline must be present"); assert_eq!(outlines.len(), 2, "two top-level entries"); assert_eq!(outlines[0].title, "Chapter 1"); assert_eq!(outlines[0].children.len(), 2); assert_eq!(outlines[0].children[0].title, "Section 1.1"); assert_eq!(outlines[1].title, "Chapter 2"); // The destinations must resolve to the pages they were given. assert_eq!(outlines[0].page_index, Some(0)); assert_eq!(outlines[0].children[0].page_index, Some(1)); assert_eq!(outlines[1].page_index, Some(2)); // /Count carries the expanded state in its *sign* (Table 153). A // writer that always emits a negative count produces a tree that is // structurally perfect and silently collapsed in every viewer. assert!( outlines[0].open, "Chapter 1 was created open and must read back open" ); assert!( !outlines[1].open, "Chapter 2 has no children and must not claim to be open" ); } // ---------------------------------------------------- named destinations #[test] fn named_destinations_resolve_to_their_pages() { let mut builder = PdfDocBuilder::new(); for _ in 0..3 { builder.add_page(200.0, 200.0, b""); } builder.add_named_destination("intro", 0, None); builder.add_named_destination("appendix", 2, Some(150.0)); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let names: Vec = doc .named_destinations() .into_iter() .map(|(n, _)| n) .collect(); assert_eq!(names, vec!["appendix".to_string(), "intro".to_string()]); let intro = doc.lookup_named_destination("intro").expect("intro"); assert_eq!(doc.resolve_destination(&intro), Some(0)); let appendix = doc.lookup_named_destination("appendix").expect("appendix"); assert_eq!(doc.resolve_destination(&appendix), Some(2)); } // ----------------------------------------------------------- page labels #[test] fn page_labels_are_written_as_a_number_tree() { let mut builder = PdfDocBuilder::new(); for _ in 0..6 { builder.add_page(200.0, 200.0, b""); } builder.set_page_labels(vec![ PageLabelRange { start_page: 0, style: PageLabelStyle::LowerRoman, prefix: None, first: None, }, PageLabelRange { start_page: 2, style: PageLabelStyle::Decimal, prefix: None, first: Some(1), }, PageLabelRange { start_page: 4, style: PageLabelStyle::UpperLetters, prefix: Some("App-".to_string()), first: None, }, ]); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let labels = doc.page_labels().expect("labels present"); // i, ii, then 1, 2, then App-A, App-B. assert_eq!(labels.label_for(0).as_deref(), Some("i")); assert_eq!(labels.label_for(1).as_deref(), Some("ii")); assert_eq!(labels.label_for(2).as_deref(), Some("1")); assert_eq!(labels.label_for(3).as_deref(), Some("2")); assert_eq!(labels.label_for(4).as_deref(), Some("App-A")); assert_eq!(labels.label_for(5).as_deref(), Some("App-B")); } // ------------------------------------------------------ viewer behaviour #[test] fn viewer_preferences_and_modes_round_trip() { let mut builder = PdfDocBuilder::new(); builder.add_page(100.0, 100.0, b""); builder.set_page_mode(PageMode::UseOutlines); builder.set_page_layout(PageLayout::TwoColumnLeft); builder.set_viewer_preferences(ViewerPreferences { display_doc_title: Some(true), hide_toolbar: Some(true), center_window: Some(false), ..Default::default() }); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let prefs = doc.viewer_preferences().expect("preferences present"); assert_eq!(prefs.display_doc_title, Some(true)); assert_eq!(prefs.hide_toolbar, Some(true)); // Explicitly false must survive as false, not be dropped as "unset". assert_eq!(prefs.center_window, Some(false)); assert_eq!(prefs.hide_menubar, None); assert_eq!(doc.page_mode().as_deref(), Some("UseOutlines")); assert_eq!(doc.page_layout().as_deref(), Some("TwoColumnLeft")); } // ------------------------------------------------------------ attachment #[test] fn an_attachment_can_be_read_back_byte_for_byte() { let mut builder = PdfDocBuilder::new(); builder.add_page(100.0, 100.0, b""); builder.add_attachment(Attachment { name: "notes.txt".to_string(), data: b"the quick brown fox".to_vec(), description: Some("A note".to_string()), mime_type: Some("text/plain".to_string()), }); builder.add_attachment(Attachment { name: "data.bin".to_string(), data: vec![0u8, 255, 128, 1], description: None, mime_type: None, }); let pdf = builder.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let attachments = doc.attachments().expect("attachments present"); assert_eq!(attachments.len(), 2); let notes = attachments .iter() .find(|a| a.name == "notes.txt") .expect("notes.txt"); assert_eq!(notes.data, b"the quick brown fox"); assert_eq!(notes.description.as_deref(), Some("A note")); // Binary data must survive unmangled, including a zero byte. let binary = attachments .iter() .find(|a| a.name == "data.bin") .expect("data.bin"); assert_eq!(binary.data, vec![0u8, 255, 128, 1]); } // ---------------------------------------------------------- font embedding #[test] fn an_embedded_subset_font_is_a_usable_type0_font() { let Some(font_data) = font_bytes() else { return; }; let mut creator = DocumentCreator::new(); let text = "Embedded"; let chars: BTreeSet = text.chars().collect(); let font = creator .embed_truetype("F1", &font_data, &chars) .expect("embeds"); let mut cw = ContentWriter::new(); cw.begin_text(); cw.set_font("F1", 18.0); cw.set_text_matrix(1.0, 0.0, 0.0, 1.0, 20.0, 100.0); cw.show_glyph_hex(&font.encode(text)); cw.end_text(); creator.builder().add_page(300.0, 150.0, &cw.build()); let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let page = doc.page(0).expect("page 0"); let resource = page.fonts.get("F1").expect("F1 must be in the resources"); assert_eq!(resource.subtype, "Type0"); assert!( resource.base_font.ends_with("DejaVuSans"), "the base font should name the real family, got {}", resource.base_font ); // A subset tag is six capitals and a plus. assert_eq!( resource.base_font.chars().nth(6), Some('+'), "missing subset tag in {}", resource.base_font ); assert_eq!( resource.descendant_fonts.len(), 1, "a Type0 font needs exactly one descendant CIDFont" ); } #[test] fn an_embedded_font_carries_a_to_unicode_map_so_text_is_extractable() { let Some(font_data) = font_bytes() else { return; }; let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Hi".chars().collect(); let font = creator .embed_truetype("F1", &font_data, &chars) .expect("embeds"); creator.builder().add_page(100.0, 100.0, b""); let pdf = creator.finish(); // Without /ToUnicode a subset renders correctly and copies out as // gibberish, because the content stream holds glyph ids. let text = String::from_utf8_lossy(&pdf); assert!(text.contains("/ToUnicode"), "no /ToUnicode in the output"); assert!(text.contains("beginbfchar"), "the CMap has no mappings"); assert!(text.contains("Identity-H")); assert!(text.contains("/CIDToGIDMap /Identity")); assert!( text.contains("/FontFile2"), "the font program is not embedded" ); // And the glyph ids used are the ones the encoder produces. assert_eq!(font.encode("Hi").len(), 4); } #[test] fn a_standard_font_needs_no_embedding() { let mut creator = DocumentCreator::new(); creator.add_standard_font("Helv", "Helvetica"); let mut cw = ContentWriter::new(); cw.begin_text(); cw.set_font("Helv", 12.0); cw.text_at(10.0, 50.0, "Plain text"); cw.end_text(); creator.builder().add_page(200.0, 100.0, &cw.build()); let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let page = doc.page(0).expect("page 0"); let font = page.fonts.get("Helv").expect("Helv present"); assert_eq!(font.subtype, "Type1"); assert_eq!(font.base_font, "Helvetica"); } // ------------------------------------------------------ AcroForm creation fn form_document() -> Vec { let mut creator = DocumentCreator::new(); creator.builder().add_page(400.0, 700.0, b""); creator.add_field(NewField { name: "fullname".to_string(), kind: NewFieldKind::Text { value: "Ada".to_string(), multiline: false, max_len: Some(40), }, rect: [50.0, 600.0, 300.0, 625.0], page: 0, read_only: false, required: true, default_appearance: None, }); creator.add_field(NewField { name: "agree".to_string(), kind: NewFieldKind::Checkbox { checked: true }, rect: [50.0, 560.0, 68.0, 578.0], page: 0, read_only: false, required: false, default_appearance: None, }); creator.add_field(NewField { name: "colour".to_string(), kind: NewFieldKind::Radio { options: vec![ ("red".to_string(), [50.0, 520.0, 68.0, 538.0]), ("blue".to_string(), [90.0, 520.0, 108.0, 538.0]), ], selected: Some("blue".to_string()), }, rect: [0.0; 4], page: 0, read_only: false, required: false, default_appearance: None, }); creator.add_field(NewField { name: "country".to_string(), kind: NewFieldKind::Choice { options: vec!["Kenya".to_string(), "Uganda".to_string()], selected: Some("Kenya".to_string()), combo: true, }, rect: [50.0, 470.0, 250.0, 495.0], page: 0, read_only: false, required: false, default_appearance: None, }); creator.add_field(NewField { name: "signature".to_string(), kind: NewFieldKind::Signature, rect: [50.0, 400.0, 250.0, 450.0], page: 0, read_only: false, required: false, default_appearance: None, }); creator.finish() } #[test] fn every_created_field_reads_back_with_its_type_and_value() { let pdf = form_document(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let form = doc.acroform().expect("no error").expect("a form"); assert_eq!(form.field_count(), 5); let name = form.find_field("fullname").expect("fullname"); assert_eq!(name.field_type, FieldType::Text); assert_eq!(name.value, FieldValue::Text("Ada".to_string())); assert!(name.is_required()); assert_eq!(name.max_len, Some(40)); let agree = form.find_field("agree").expect("agree"); assert_eq!(agree.field_type, FieldType::Checkbox); assert!(agree.is_checked(), "the checkbox was created checked"); let colour = form.find_field("colour").expect("colour"); assert_eq!(colour.field_type, FieldType::Radio); assert_eq!(colour.value, FieldValue::State("blue".to_string())); assert_eq!( colour.widgets.len(), 2, "a radio group has one widget per option" ); let country = form.find_field("country").expect("country"); assert_eq!(country.field_type, FieldType::ComboBox); assert_eq!(country.value, FieldValue::Text("Kenya".to_string())); assert_eq!( country.options, vec!["Kenya".to_string(), "Uganda".to_string()] ); let signature = form.find_field("signature").expect("signature"); assert_eq!(signature.field_type, FieldType::Signature); } #[test] fn radio_widgets_are_kids_of_one_field_not_separate_fields() { // Mutual exclusion comes from the widgets sharing a parent field: the // parent holds /V and each kid holds only its /AS. Widgets written // without a /Parent become orphans, so selecting one does not clear // the others and the group reads back as the wrong shape. let pdf = form_document(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let form = doc.acroform().expect("no error").expect("a form"); // Exactly one field named "colour", not one per option. let radios: Vec<_> = form .fields() .iter() .filter(|f| f.field_type == FieldType::Radio) .collect(); assert_eq!(radios.len(), 1, "a radio group is one field"); assert_eq!(radios[0].widgets.len(), 2); assert_eq!(radios[0].value, FieldValue::State("blue".to_string())); // The selected option's widget is the one whose on-state is "blue". let on_states: Vec> = radios[0].widgets.iter().map(|w| w.on_state()).collect(); assert!( on_states.contains(&Some("red")) && on_states.contains(&Some("blue")), "each widget needs its own export value, got {on_states:?}" ); } #[test] fn every_field_widget_is_an_annotation_on_its_page() { // A field that is in /Fields but not in the page's /Annots is invisible // and unclickable: the form data is there and the user cannot reach it. let pdf = form_document(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let annots = doc.page_annotations(0).expect("annotations"); // 4 single-widget fields plus 2 radio options = 6 widgets, minus the // signature which is also a widget: 4 + 2 = 6. assert_eq!( annots.len(), 6, "expected one annotation per widget, got {:?}", annots.iter().map(|a| &a.annot_type).collect::>() ); let form = doc.acroform().expect("no error").expect("a form"); for (field, widget) in form.widgets_on_page(0) { assert_eq!( widget.page_index, Some(0), "widget of {} is not on page 0", field.full_name ); } } #[test] fn created_fields_have_appearance_streams() { // Without an appearance a field renders as nothing in any viewer that // does not honour /NeedAppearances, which is most of them. let pdf = form_document(); let text = String::from_utf8_lossy(&pdf); assert!(text.contains("/AP"), "no appearance dictionaries emitted"); assert!(text.contains("/Tx BMC"), "no text field appearance"); assert!( text.contains("/Subtype /Form"), "appearances must be form XObjects" ); } #[test] fn a_checkbox_appearance_has_both_an_on_and_an_off_state() { // A checkbox with only an /Off state cannot show as ticked; one with // only the on state renders a permanent tick. let mut creator = DocumentCreator::new(); creator.builder().add_page(100.0, 100.0, b""); creator.add_field(NewField { name: "box".to_string(), kind: NewFieldKind::Checkbox { checked: false }, rect: [10.0, 10.0, 28.0, 28.0], page: 0, read_only: false, required: false, default_appearance: None, }); let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let form = doc.acroform().expect("no error").expect("a form"); let field = form.find_field("box").expect("box"); let widget = &field.widgets[0]; let mut states = widget.appearance_states.clone(); states.sort(); assert_eq!(states, vec!["Off".to_string(), "Yes".to_string()]); assert!(!field.is_checked(), "created unchecked"); } #[test] fn every_widget_carries_what_a_renderer_needs_to_draw_it() { // Found by rendering the sample through PDFium: the fields were // structurally perfect, pypdf read every value, and the page came out // blank. Three things were missing, none of which a structural reader // consults: // // /F with the Print bit - default is off, so the annotation is // skipped on the printable layer // /P naming its page - several renderers refuse a widget without // AP /Resources - the stream says `/Helv 12 Tf`, and a form // XObject with an undefined font is dropped // whole // // Plus /MK and /BS, without which a field has no visible boundary. let pdf = form_document(); let text = String::from_utf8_lossy(&pdf); assert!(text.contains("/FormType 1"), "appearances need /FormType"); assert!( text.contains("/Matrix"), "appearances need an identity /Matrix" ); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let annots = doc.page_annotations(0).expect("annotations"); assert!(!annots.is_empty()); for annot in &annots { assert!( annot.flags.print, "a widget without the Print flag is skipped by renderers" ); assert_eq!( annot.page_index, Some(0), "every widget must know which page it is on" ); // page_index is supplied by the *reader*, which knows the page it // was asked about, so it cannot witness a missing /P. The written // dictionary must carry the back-reference itself. assert!( annot.raw_dict.get("P").is_some(), "widget {:?} has no /P naming its page", annot.raw_dict.get_str("T").map(String::from_utf8_lossy) ); let dict = &annot.raw_dict; assert!(dict.get("MK").is_some(), "no /MK, so no visible border"); assert!(dict.get("BS").is_some(), "no /BS, so no border style"); assert!(dict.get("AP").is_some(), "no appearance at all"); } } #[test] fn a_text_appearance_stream_declares_the_font_it_uses() { // The appearance says `/Helv 12 Tf`. A form XObject that names a font // its /Resources does not define is invalid, and PDFium discards the // entire stream rather than substituting - the field drew nothing. let pdf = form_document(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let max = doc.max_object_number(); let mut checked = 0usize; for num in 1..=max { let Ok(obj) = doc.resolve_obj_num(num) else { continue; }; let Some(stream) = obj.as_stream() else { continue; }; if stream.dict.get_name("Subtype") != Some("Form") { continue; } let body = String::from_utf8_lossy(&stream.data); if !body.contains("Tf") { continue; // A border-only appearance needs no font. } let resources = stream .dict .get_dict("Resources") .expect("an appearance using a font needs /Resources"); let fonts = resources .get_dict("Font") .expect("/Resources must declare /Font"); assert!( fonts.map.contains_key("Helv"), "the appearance names /Helv but declares {:?}", fonts.map.keys().collect::>() ); checked += 1; } assert!( checked >= 2, "expected the text and choice appearances, checked {checked}" ); } #[test] fn a_radio_button_is_drawn_as_a_circle_not_a_tick() { // A radio group that renders as square ticks tells the reader they may // pick several, which is the opposite of what the field enforces. let pdf = form_document(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let form = doc.acroform().expect("no error").expect("a form"); let colour = form.find_field("colour").expect("colour"); assert_eq!(colour.widgets.len(), 2); // Curve operators mean a circle; a checkbox appearance is straight // lines only. let mut found_curve = false; let max = doc.max_object_number(); for num in 1..=max { let Ok(obj) = doc.resolve_obj_num(num) else { continue; }; let Some(stream) = obj.as_stream() else { continue; }; if stream.dict.get_name("Subtype") != Some("Form") { continue; } if String::from_utf8_lossy(&stream.data).contains(" c\n") { found_curve = true; } } assert!( found_curve, "no curved appearance was emitted, so the radios are not round" ); } #[test] fn a_read_only_field_reads_back_as_read_only() { let mut creator = DocumentCreator::new(); creator.builder().add_page(100.0, 100.0, b""); creator.add_field(NewField { name: "locked".to_string(), kind: NewFieldKind::Text { value: "fixed".to_string(), multiline: false, max_len: None, }, rect: [10.0, 10.0, 90.0, 30.0], page: 0, read_only: true, required: false, default_appearance: None, }); let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let form = doc.acroform().expect("no error").expect("a form"); let field = form.find_field("locked").expect("locked"); assert!(field.is_read_only()); } #[test] fn a_multiline_field_is_flagged_multiline() { let mut creator = DocumentCreator::new(); creator.builder().add_page(200.0, 200.0, b""); creator.add_field(NewField { name: "notes".to_string(), kind: NewFieldKind::Text { value: "line".to_string(), multiline: true, max_len: None, }, rect: [10.0, 10.0, 190.0, 100.0], page: 0, read_only: false, required: false, default_appearance: None, }); let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let form = doc.acroform().expect("no error").expect("a form"); assert!(form.find_field("notes").expect("notes").is_multiline()); } #[test] fn a_document_can_carry_a_form_and_an_embedded_font_together() { // The two features allocate object numbers independently. If they // collide, one silently overwrites the other. let Some(font_data) = font_bytes() else { return; }; let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Label".chars().collect(); let font = creator .embed_truetype("F1", &font_data, &chars) .expect("embeds"); let mut cw = ContentWriter::new(); cw.begin_text(); cw.set_font("F1", 12.0); cw.set_text_matrix(1.0, 0.0, 0.0, 1.0, 20.0, 150.0); cw.show_glyph_hex(&font.encode("Label")); cw.end_text(); creator.builder().add_page(300.0, 200.0, &cw.build()); creator.add_field(NewField { name: "field".to_string(), kind: NewFieldKind::Text { value: "value".to_string(), multiline: false, max_len: None, }, rect: [20.0, 100.0, 200.0, 125.0], page: 0, read_only: false, required: false, default_appearance: None, }); let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let page = doc.page(0).expect("page 0"); assert_eq!(page.fonts.get("F1").expect("F1").subtype, "Type0"); let form = doc.acroform().expect("no error").expect("a form"); assert_eq!( form.find_field("field").expect("field").value, FieldValue::Text("value".to_string()) ); } #[test] fn no_object_number_is_written_twice() { // Object numbers are allocated by three independent schemes: the fixed // page block, `add_object`/`reserve_object`, and the trees allocated in // `finish`. Twice these overlapped, and the symptom was not a crash: // the later object silently replaced the earlier one, so `/F1` in a // page's resources resolved to the name tree and `/ToUnicode` to a font // descriptor. Every reference pointed at a real object; each named the // wrong one. // // Parsing cannot detect this - both writes are well-formed - so the // check is on the bytes. let Some(font_data) = font_bytes() else { return; }; let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Collide".chars().collect(); creator .embed_truetype("F1", &font_data, &chars) .expect("embeds"); creator.add_standard_font("Helv", "Helvetica"); creator.builder().add_page(200.0, 200.0, b""); creator.add_field(NewField { name: "field".to_string(), kind: NewFieldKind::Text { value: "v".to_string(), multiline: false, max_len: None, }, rect: [10.0, 10.0, 100.0, 30.0], page: 0, read_only: false, required: false, default_appearance: None, }); { let builder = creator.builder(); builder.set_outline(vec![OutlineItem::new("Top", 0)]); builder.add_named_destination("here", 0, None); builder.add_attachment(Attachment { name: "a.txt".to_string(), data: b"x".to_vec(), description: None, mime_type: None, }); builder.set_metadata(DocumentMetadata { title: Some("T".to_string()), ..Default::default() }); } let pdf = creator.finish(); // Scan for line-anchored "N 0 obj" headers. let text = String::from_utf8_lossy(&pdf); let mut seen: std::collections::HashMap = std::collections::HashMap::new(); for line in text.lines() { let mut parts = line.split_whitespace(); let (Some(num), Some(gen), Some(kw)) = (parts.next(), parts.next(), parts.next()) else { continue; }; if kw != "obj" || gen != "0" { continue; } if let Ok(num) = num.parse::() { *seen.entry(num).or_insert(0) += 1; } } let mut duplicates: Vec = seen .iter() .filter(|(_, count)| **count > 1) .map(|(num, _)| *num) .collect(); duplicates.sort(); assert!( !seen.is_empty(), "the scanner found no objects at all, so it proves nothing" ); assert!( duplicates.is_empty(), "these object numbers were written more than once: {duplicates:?}" ); } #[test] fn a_font_resource_points_at_the_font_and_not_at_something_else() { // The collision above made /F1 resolve to the /Names tree. The page // parsed, the resource existed, and the font was not a font. let Some(font_data) = font_bytes() else { return; }; let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Aa".chars().collect(); creator .embed_truetype("F1", &font_data, &chars) .expect("embeds"); creator.add_standard_font("Helv", "Helvetica"); creator.builder().add_page(200.0, 200.0, b""); { let builder = creator.builder(); builder.set_outline(vec![OutlineItem::new("Top", 0)]); builder.add_named_destination("here", 0, None); } let pdf = creator.finish(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let page = doc.page(0).expect("page 0"); let f1 = page.fonts.get("F1").expect("F1 present"); assert_eq!(f1.subtype, "Type0", "F1 resolved to the wrong object"); assert_eq!( f1.descendant_fonts.len(), 1, "a Type0 font must name its descendant CIDFont" ); let helv = page.fonts.get("Helv").expect("Helv present"); assert_eq!(helv.subtype, "Type1"); assert_eq!(helv.base_font, "Helvetica"); } #[test] fn a_generated_document_has_no_dangling_references() { // Every reference in the file must resolve. A forward reference to an // object number that was reserved and never written is the failure mode // of out-of-order writing, and it makes a viewer reject the file. let pdf = form_document(); let mut doc = PdfDocument::parse(&pdf).expect("parses"); let max = doc.max_object_number(); for num in 1..=max { // Resolving must not error for an object the xref claims exists. if let Err(e) = doc.resolve_obj_num(num) { let message = e.to_string(); assert!( message.contains("not found in xref"), "object {num} is in the xref but unreadable: {message}" ); } } } // ------------------------------------------- images, headers and footers use nigig_pdf_graphics::stamp::{ attach_image_to_page, combine_content, embed_image, footer_content, header_content, image_stamp_content, Align, Banner, ImageSource, }; /// A minimal but structurally real JPEG: SOI, SOF0 declaring the size, EOI. fn tiny_jpeg(width: u16, height: u16, components: u8) -> Vec { let mut v = vec![0xFF, 0xD8, 0xFF, 0xC0]; let len = 8 + 3 * components as u16; v.extend_from_slice(&len.to_be_bytes()); v.push(8); v.extend_from_slice(&height.to_be_bytes()); v.extend_from_slice(&width.to_be_bytes()); v.push(components); for c in 0..components { v.push(c + 1); v.push(0x11); v.push(0); } v.extend_from_slice(&[0xFF, 0xD9]); v } /// The gap this closes: `ContentWriter::draw_image` has existed since /// Phase 2, but nothing could create the XObject it names, so every `Do` /// referred to a resource that did not exist. Reading the page back and /// finding the image in `xobjects` is the assertion that proves the /// resource is genuinely reachable — a substring check on `/Im0 Do` passed /// throughout the period when no image could be embedded at all. #[test] fn an_embedded_image_is_reachable_from_the_page_that_draws_it() { let mut builder = PdfDocBuilder::new(); let img = embed_image( &mut builder, "Im0", ImageSource::Jpeg(tiny_jpeg(640, 480, 3)), ) .expect("the image embeds"); let content = image_stamp_content(&img, 50.0, 100.0, 200.0, img.height_for_width(200.0)); let page = builder.add_page(612.0, 792.0, &content); assert!(attach_image_to_page(&mut builder, page, &img)); let bytes = builder.finish(); let mut doc = PdfDocument::parse(&bytes).expect("the generated file parses"); let page = doc.page(0).expect("page 0"); let xobject = page .xobjects .get("Im0") .expect("the page must deliver the XObject it declares"); assert_eq!( xobject.subtype, "Image", "the resource must be an image, not a form" ); } /// The image's own dimensions must survive into the file. A `/Width` that /// disagrees with the JPEG codestream renders as diagonal garbage in every /// viewer, and nothing in the file itself is invalid when it happens. #[test] fn an_embedded_jpeg_keeps_the_geometry_from_its_own_codestream() { let mut builder = PdfDocBuilder::new(); // Deliberately not square, so a transposition is visible. let img = embed_image( &mut builder, "Im0", ImageSource::Jpeg(tiny_jpeg(800, 200, 3)), ) .expect("embeds"); let page = builder.add_page(612.0, 792.0, b""); attach_image_to_page(&mut builder, page, &img); let bytes = builder.finish(); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let xref = page .xobjects .get("Im0") .expect("the image resource") .obj_ref; let obj = doc.resolve(&PdfObj::Ref(xref)).expect("the image object"); let dict = match &obj { PdfObj::Stream(s) => s.dict.clone(), PdfObj::Dict(d) => d.clone(), other => panic!("the image XObject is a {other:?}, not a stream"), }; assert_eq!( dict.get_int("Width"), Some(800), "width from the SOF marker" ); assert_eq!( dict.get_int("Height"), Some(200), "height from the SOF marker" ); assert_eq!(dict.get_name("Filter"), Some("DCTDecode")); assert_eq!(dict.get_name("ColorSpace"), Some("DeviceRGB")); } /// Raw samples embed as a lossless image with the colour space their /// component count implies. #[test] fn a_raw_image_embeds_losslessly() { let mut builder = PdfDocBuilder::new(); let img = embed_image( &mut builder, "Im0", ImageSource::Raw { width: 4, height: 2, components: 1, samples: vec![0, 32, 64, 96, 128, 160, 192, 224], }, ) .expect("embeds"); let page = builder.add_page(100.0, 100.0, b""); attach_image_to_page(&mut builder, page, &img); let bytes = builder.finish(); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let xref = page.xobjects.get("Im0").expect("resource").obj_ref; let obj = doc.resolve(&PdfObj::Ref(xref)).expect("object"); let PdfObj::Stream(stream) = obj else { panic!("a raw image must be a stream"); }; assert_eq!(stream.dict.get_int("Width"), Some(4)); assert_eq!(stream.dict.get_int("Height"), Some(2)); assert_eq!(stream.dict.get_name("ColorSpace"), Some("DeviceGray")); assert_eq!(stream.dict.get_int("BitsPerComponent"), Some(8)); } /// A header and a footer must land at opposite ends of the page. This /// reads the *content stream* back and compares the two y coordinates, /// rather than trusting the generator's arithmetic against itself. #[test] fn a_header_and_footer_land_at_opposite_ends_of_the_page() { let header = Banner::new("Report", "F1", 12.0) .aligned(Align::Left) .with_margin(40.0); let footer = Banner::new("Page 1", "F1", 12.0) .aligned(Align::Left) .with_margin(40.0); let content = combine_content(&[ &header_content(&header, 612.0, 792.0), &footer_content(&footer, 612.0), ]); let mut builder = PdfDocBuilder::new(); builder.add_page(612.0, 792.0, &content); builder.set_compress(false); let bytes = builder.finish(); let mut doc = PdfDocument::parse(&bytes).expect("parses"); let page = doc.page(0).expect("page 0"); let text = String::from_utf8_lossy(&page.content_data).to_string(); assert!( text.contains("752"), "the header baseline must be 792 - 40 = 752: {text}" ); assert!( text.contains(" 40 "), "the footer baseline must be the 40pt margin itself: {text}" ); } /// The whole Phase 4 exit criterion in one file: an embedded font, a /// header, a footer and an image, all read back from the parsed document. #[test] fn a_stamped_document_round_trips_with_every_piece_intact() { let mut builder = PdfDocBuilder::new(); let img = embed_image( &mut builder, "Logo", ImageSource::Jpeg(tiny_jpeg(120, 60, 3)), ) .expect("embeds"); let header = Banner::new("Quarterly report", "F1", 14.0); let footer = Banner::new("Confidential", "F1", 9.0).aligned(Align::Right); let content = combine_content(&[ &header_content(&header, 612.0, 792.0), &footer_content(&footer, 612.0), &image_stamp_content(&img, 72.0, 600.0, 120.0, 60.0), ]); let page = builder.add_page(612.0, 792.0, &content); attach_image_to_page(&mut builder, page, &img); builder.set_compress(false); let bytes = builder.finish(); let mut doc = PdfDocument::parse(&bytes).expect("parses"); assert_eq!(doc.page_count(), 1); let page = doc.page(0).expect("page 0"); assert_eq!(page.width(), 612.0); assert_eq!(page.height(), 792.0); assert!( page.xobjects.contains_key("Logo"), "the image resource survived the round trip" ); let text = String::from_utf8_lossy(&page.content_data).to_string(); assert!(text.contains("Quarterly report"), "header text"); assert!(text.contains("Confidential"), "footer text"); assert!(text.contains("/Logo Do"), "the image is painted"); assert!( text.contains('q') && text.contains('Q'), "the image stamp is isolated in a save/restore pair" ); } /// A banner naming an unregistered font produces a structurally valid PDF /// that renders no text. /// /// This is the failure mode the `Banner` docs warn about, pinned as a test /// so the warning cannot drift from the behaviour. Found by running the /// generated file through poppler, which reported `Unknown font tag 'F1'` /// while `qpdf --check` passed — the file is not malformed, it just does /// not say what the content stream needs. /// /// The assertion is on the *resources*, because that is the thing that is /// actually missing and the thing a caller can check. It deliberately does /// not assert "no text renders": that would need a rasteriser. #[test] fn a_banner_font_must_be_registered_or_the_page_lacks_the_resource() { let banner = Banner::new("Header text", "F1", 14.0); // Without add_font: the page has no /Font resource at all. let mut bare = PdfDocBuilder::new(); bare.add_page(612.0, 792.0, &header_content(&banner, 612.0, 792.0)); let bytes = bare.finish(); let mut doc = PdfDocument::parse(&bytes).expect("still a valid PDF"); let page = doc.page(0).expect("page 0"); assert!( page.fonts.is_empty(), "the content stream names /F1 but the page declares no fonts; \ this file opens cleanly and draws nothing" ); // With add_font: the resource is there and named F1. let mut wired = PdfDocBuilder::new(); let mut font = PdfDict::new(); font.set("Type", PdfObj::Name("Font".into())); font.set("Subtype", PdfObj::Name("Type1".into())); font.set("BaseFont", PdfObj::Name("Helvetica".into())); wired.add_font("F1", PdfObj::Dict(font)); wired.add_page(612.0, 792.0, &header_content(&banner, 612.0, 792.0)); let bytes = wired.finish(); let mut doc = PdfDocument::parse(&bytes).expect("valid"); let page = doc.page(0).expect("page 0"); assert!( page.fonts.contains_key("F1"), "add_font must put /F1 in every page's resources, got {:?}", page.fonts.keys().collect::>() ); } // -------------------------------------------------------- CFF embedding fn cff_font_bytes() -> Vec { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus/fonts/cff_sample.otf"); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// The subsetter must keep refusing CFF by name. /// /// This is the behaviour ADR 0015's reasoning protects: a CFF program run /// through a TrueType subsetter produces a font that is structurally a font /// and renders nothing. Embedding whole is the alternative, not a reason to /// loosen this. #[test] fn the_truetype_subsetter_still_refuses_cff_outlines() { let chars: BTreeSet = "Hello".chars().collect(); let err = nigig_pdf_graphics::subset::subset_truetype(&cff_font_bytes(), &chars) .expect_err("CFF must not go through the TrueType subsetter"); assert!( err.to_string().contains("CFF"), "the refusal must name the format, got: {err}" ); } /// A CFF font embeds whole, and says so. #[test] fn a_cff_font_embeds_whole_and_reports_that_it_is_not_subset() { let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Hello".chars().collect(); let font = creator .embed_opentype_whole("C1", &cff_font_bytes(), &chars) .expect("a CFF font embeds whole"); assert!(font.is_cff(), "the fixture is CFF-flavoured OpenType"); assert!( !font.is_subsetted(), "embedding whole must not claim to be a subset" ); assert!( !font.base_font.contains('+'), "a whole font must not carry a six-letter subset tag: {}", font.base_font ); } /// A TrueType font subset the normal way still reports itself as subset, /// so the two paths are distinguishable by the caller. #[test] fn a_subset_truetype_font_reports_that_it_is_subset() { let Some(data) = font_bytes() else { eprintln!("skipping: DejaVuSans.ttf not installed"); return; }; let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Hello".chars().collect(); let font = creator .embed_truetype("F1", &data, &chars) .expect("subsets"); assert!(font.is_subsetted()); assert!(!font.is_cff()); assert!( font.base_font.contains('+'), "a real subset must carry its tag: {}", font.base_font ); } /// The structural half: CFF must land in `/FontFile3` with `/Subtype /// /OpenType`, under a `CIDFontType0` descendant. /// /// Getting any one of these wrong yields a font that parses and renders /// nothing — the failure mode is silent, which is why each key is asserted /// by name rather than the file merely being checked for validity. #[test] fn a_cff_font_writes_fontfile3_and_a_type0_descendant() { let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Hello".chars().collect(); creator .embed_opentype_whole("C1", &cff_font_bytes(), &chars) .expect("embeds"); creator.builder().add_page(300.0, 200.0, b"BT ET"); let bytes = creator.finish(); let text = String::from_utf8_lossy(&bytes).to_string(); assert!(text.contains("/FontFile3"), "CFF goes in /FontFile3"); assert!( !text.contains("/FontFile2"), "a CFF program in /FontFile2 is rejected by every viewer" ); assert!( text.contains("/Subtype /OpenType"), "the stream names its subtype" ); assert!( text.contains("/CIDFontType0"), "CFF outlines need a CIDFontType0 descendant" ); assert!( !text.contains("/CIDToGIDMap"), "/CIDToGIDMap is defined for CIDFontType2 only" ); } /// TrueType keeps its own shape, so the CFF branch cannot have changed it. #[test] fn a_truetype_font_still_writes_fontfile2_and_a_type2_descendant() { let Some(data) = font_bytes() else { eprintln!("skipping: DejaVuSans.ttf not installed"); return; }; let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Hello".chars().collect(); creator .embed_truetype("F1", &data, &chars) .expect("subsets"); creator.builder().add_page(300.0, 200.0, b"BT ET"); let text = String::from_utf8_lossy(&creator.finish()).to_string(); assert!(text.contains("/FontFile2")); assert!(text.contains("/Length1"), "/FontFile2 requires /Length1"); assert!(text.contains("/CIDFontType2")); assert!(text.contains("/CIDToGIDMap")); } /// The embedded program must be the font's own bytes, unmodified. /// /// Asserting the bytes appear verbatim is what distinguishes "embedded /// whole" from "embedded something": a re-serialised font would still be a /// font, still parse, and no longer be the file the caller supplied. #[test] fn the_whole_cff_program_is_embedded_verbatim() { let data = cff_font_bytes(); let mut creator = DocumentCreator::new(); let chars: BTreeSet = "Hello".chars().collect(); creator .embed_opentype_whole("C1", &data, &chars) .expect("embeds"); creator.builder().add_page(300.0, 200.0, b"BT ET"); let pdf = creator.finish(); assert!( pdf.windows(data.len()).any(|w| w == data.as_slice()), "the CFF program must appear verbatim in the file" ); } /// Glyph ids are the font's own, so /Identity-H addresses them directly. #[test] fn a_whole_font_keeps_the_fonts_own_glyph_ids() { let data = cff_font_bytes(); let chars: BTreeSet = "Hello".chars().collect(); let subset = nigig_pdf_graphics::subset::embed_opentype_whole(&data, &chars).expect("embeds"); for (old, new) in &subset.glyph_map { assert_eq!( old, new, "a whole font must not renumber glyphs; {old} became {new}" ); } assert!( subset.to_unicode.len() >= 4, "the fixture covers H, e, l, o; got {} mappings", subset.to_unicode.len() ); } // ------------------------------------------------------------ cmap repair fn symbol_font_bytes() -> Vec { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../tests/corpus/fonts/symbol_sample.ttf"); std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display())) } /// `repair-cmap`: a symbol font's glyphs are reachable by their plain /// characters. /// /// The fixture declares only a `(3,0)` subtable mapping 0xF041 and 0xF042. /// A lookup of 'A' can therefore only succeed through the 0xF000 + low byte /// retry — there is no Unicode subtable for it to have come from, which is /// what makes this test prove the repair rather than merely exercise it. #[test] fn a_symbol_font_maps_plain_characters_through_the_f000_retry() { let data = symbol_font_bytes(); let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses"); let a = nigig_pdf_graphics::subset::glyph_index(&font, 'A') .expect("'A' must resolve via the 0xF000 retry"); let b = nigig_pdf_graphics::subset::glyph_index(&font, 'B') .expect("'B' must resolve via the 0xF000 retry"); assert_ne!(a, 0, "'A' must not land on .notdef"); assert_ne!(b, 0, "'B' must not land on .notdef"); assert_ne!(a, b, "the two characters are different glyphs"); } /// The private-use codepoint still works directly: the repair is an /// addition, not a replacement. #[test] fn a_symbol_font_still_maps_its_private_use_codepoints() { let data = symbol_font_bytes(); let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses"); let direct = nigig_pdf_graphics::subset::glyph_index(&font, '\u{F041}') .expect("the declared codepoint must resolve directly"); let repaired = nigig_pdf_graphics::subset::glyph_index(&font, 'A').expect("and via repair"); assert_eq!(direct, repaired, "both routes must reach the same glyph"); } /// A character the font genuinely lacks must still return None. The repair /// must not manufacture a glyph — that would turn a missing character into /// a wrong one, which is worse. #[test] fn the_cmap_repair_does_not_invent_missing_glyphs() { let data = symbol_font_bytes(); let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses"); assert_eq!( nigig_pdf_graphics::subset::glyph_index(&font, 'Z'), None, "the fixture maps only A and B; Z must not resolve" ); assert_eq!( nigig_pdf_graphics::subset::glyph_index(&font, '\u{4E2D}'), None ); } /// A normal Unicode font must not go anywhere near the repair path. #[test] fn a_unicode_font_is_unaffected_by_the_repair() { let Some(data) = font_bytes() else { eprintln!("skipping: DejaVuSans.ttf not installed"); return; }; let font = nigig_pdf_graphics::sfnt::SfntFont::parse(&data).expect("parses"); let a = nigig_pdf_graphics::subset::glyph_index(&font, 'A').expect("'A' resolves"); assert_ne!(a, 0); // U+F041 is unmapped in a normal text font, and the repair must not // make it resolve to 'A' by running in reverse. assert_ne!( nigig_pdf_graphics::subset::glyph_index(&font, '\u{F041}'), Some(a), "the repair must not fire on a font with a real Unicode cmap" ); } /// A symbol font subsets and embeds end to end, so the repair is reachable /// from the public API rather than only from `glyph_index`. #[test] fn a_symbol_font_subsets_through_the_public_api() { let mut creator = DocumentCreator::new(); let chars: BTreeSet = "AB".chars().collect(); let font = creator .embed_truetype("S1", &symbol_font_bytes(), &chars) .expect("a symbol font subsets"); assert_eq!( font.subset.to_unicode.len(), 2, "both requested characters must map to glyphs, got {:?}", font.subset.to_unicode ); assert!( !font.encode("AB").is_empty(), "the text encodes to glyph ids" ); }