//! End-to-end export tests: structure, determinism, round-tripped text and //! the page furniture of a rendered note (SPEC item 16, acceptance row //! "PDF"). mod support; use notescraft_pdf::{AssetSource, ExportOptions, MAX_PAGES, NoAssets, PageSize, export, page_count}; use support::{fonts, options, structural_check, text}; /// A tiny in-memory vault: images by target, notes by target. struct Vault { /// Image targets and their bytes. images: Vec<(String, Vec)>, /// Embedded note targets and their markdown. notes: Vec<(String, String)>, } impl Vault { /// A vault with nothing in it. fn empty() -> Self { Self { images: Vec::new(), notes: Vec::new() } } /// Adds an image target. fn image(mut self, target: &str, bytes: &[u8]) -> Self { self.images.push((target.to_owned(), bytes.to_vec())); self } /// Adds an embedded note target. fn note(mut self, target: &str, source: &str) -> Self { self.notes.push((target.to_owned(), source.to_owned())); self } } impl AssetSource for Vault { fn image(&self, target: &str) -> Option> { self.images.iter().find(|(name, _)| name == target).map(|(_, bytes)| bytes.clone()) } fn embedded_note(&self, target: &str) -> Option { self.notes.iter().find(|(name, _)| name == target).map(|(_, source)| source.clone()) } } /// The ten fixture documents every round-trip test walks. fn fixtures() -> Vec<(&'static str, String)> { vec![ ("headings", "# One\n\n## Two\n\n### Three\n\nBody after headings.\n".to_owned()), ("emphasis", "Plain, **bold**, *italic*, ***both***, ~~gone~~ and `code`.\n".to_owned()), ("links", "See [the site](https://example.com/a) and [[Some Note]] and #tag.\n".to_owned()), ("lists", "- one\n- two\n - nested\n- [x] done\n- [ ] open\n\n1. first\n2. second\n".to_owned()), ("code", "```rust\nfn main() { println!(\"hi\"); }\n```\n\n indented code\n".to_owned()), ("quote", "> A quote\n> over two lines.\n\n> [!warning] Careful\n> Body of the callout.\n".to_owned()), ("table", "| Name | Value |\n| --- | --- |\n| alpha | 1 |\n| beta | 22 |\n".to_owned()), ("math", "Inline $a^2 + b^2$ and display:\n\n$$\\int_0^1 x\\,dx$$\n".to_owned()), ("embeds", "![[pic.png]]\n\n![[Other Note]]\n\n![[missing.file]]\n".to_owned()), ("mixed", "---\ntitle: Front\n---\n\n# Title\n\n==marked== text.\n\n---\n\nFootnotes[^a] here.\n\n[^a]: The note body.\n".to_owned()), ] } /// Exports a fixture and checks the document is structurally sound. fn render(source: &str, opts: &ExportOptions, assets: &dyn AssetSource) -> Vec { let pdf = export(source, opts, assets); assert!(pdf.is_ok(), "{source:?} must export: {:?}", pdf.err()); let pdf = pdf.unwrap_or_default(); structural_check(&pdf); pdf } /// Every fixture exports, is structurally sound, and carries its own words /// back out of its own content streams in order. #[test] fn every_fixture_round_trips_its_own_text() { let opts = options(); for (name, source) in fixtures() { let pdf = render(&source, &opts, &NoAssets); assert!(page_count(&pdf) >= 1, "{name} has at least one page"); let found = text(&pdf); for word in words(&source) { assert!(found.contains(&word), "{name}: the word `{word}` survives the export\nfound: {found}"); } } } /// The words of a document that must survive the round trip, in order. /// /// Fence info strings (` ```rust `) are skipped: the exporter prints code /// without syntax colours and never prints the language tag. The frontmatter /// is skipped too, because it is not rendered unless it is asked for. fn words(source: &str) -> Vec { let mut lines = source.lines(); let mut in_frontmatter = source.starts_with("---\n"); if in_frontmatter { let _ = lines.next(); } let mut out = Vec::new(); for line in lines { if in_frontmatter { in_frontmatter = line.trim_end() != "---"; continue; } let trimmed = line.trim_start(); let fence = trimmed.starts_with("```") || trimmed.starts_with("~~~"); for word in line.split_whitespace().skip(usize::from(fence)) { let word = word.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '\''); if word.len() > 2 && word.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '\'') { out.push(word.to_owned()); } } } out } /// The extracted words appear in the same order as the source words. #[test] fn the_extracted_text_keeps_the_reading_order() { let opts = options(); let source = "# Reading order\n\nFirst paragraph.\n\nSecond paragraph.\n\n- bullet one\n- bullet two\n"; let pdf = render(source, &opts, &NoAssets); let found = text(&pdf); let at = |needle: &str| found.find(needle).unwrap_or_else(|| panic!("`{needle}` missing from {found:?}")); assert!(at("First") < at("Second"), "paragraphs keep their order: {found:?}"); assert!(at("Second") < at("bullet one"), "the list comes after the paragraphs"); } /// The same input gives the same bytes, and a different title does not. #[test] fn output_is_deterministic() { let source = "# Deterministic\n\nBody with **bold** text.\n"; let opts = options(); let first = render(source, &opts, &NoAssets); let second = render(source, &opts, &NoAssets); assert_eq!(first, second, "same input, same bytes"); let mut other = opts.clone(); other.title = Some(String::from("Other")); assert_ne!(render(source, &other, &NoAssets), first, "the title is part of the document"); } /// Page size and margins reach the media box and the page furniture. #[test] fn page_size_margins_and_footer_follow_the_options() { let source = "# Title\n\nBody.\n"; let mut opts = options(); opts.title = Some(String::from("My note")); let a4 = render(source, &opts, &NoAssets); let a4_text = text(&a4); assert!(a4_text.contains("My note — page 1 of 1"), "the footer carries the title and the page numbers: {a4_text:?}"); assert!(String::from_utf8_lossy(&a4).contains("595.28 841.89"), "A4 media box"); opts.page = PageSize::Letter; let letter = render(source, &opts, &NoAssets); assert!(String::from_utf8_lossy(&letter).contains("612.00 792.00"), "Letter media box"); assert_ne!(letter, a4, "the page size changes the file"); opts.margins_mm = 5.0; let wide = render(source, &opts, &NoAssets); assert_ne!(wide, letter, "a clamped margin changes the layout"); } /// `http(s)` links become real annotations; wikilinks stay text. #[test] fn web_links_are_annotated_and_wikilinks_are_not() { let source = "Go to [docs](https://example.com/docs) but not to [[Local Note]].\n"; let pdf = render(source, &options(), &NoAssets); let links = support::pages(&pdf); let uris: Vec = links.iter().flat_map(|page| page.links.clone()).collect(); assert_eq!(uris, vec![String::from("https://example.com/docs")], "one annotation, the wikilink is text only"); let found = text(&pdf); assert!(found.contains("docs"), "the label is drawn"); assert!(found.contains("Local Note"), "the wikilink prints its target"); } /// Images embed: a PNG travels as a flate image with a colour space, a broken /// one as a placeholder box, and both cost nothing but a name. #[test] fn images_embed_and_broken_ones_become_placeholders() { let png = include_bytes!("fixtures/png-rgba.png"); let vault = Vault::empty().image("pic.png", png).image("broken.png", b"\x89PNG\r\n\x1a\nnonsense"); let source = "![[pic.png]]\n\n![[broken.png]]\n"; let pdf = render(source, &options(), &vault); let raw = String::from_utf8_lossy(&pdf).to_string(); assert!(raw.contains("/Subtype /Image"), "the image XObject is embedded"); assert!(raw.contains("/SMask"), "the alpha plane became a soft mask"); let drawn = support::contents(&pdf).join("\n"); assert!(drawn.contains("/X1 Do"), "the image is drawn on the page: {drawn}"); let found = text(&pdf); assert!(found.contains("broken.png"), "the placeholder carries the file name: {found:?}"); } /// Embedded notes render inline; a cycle stops at the depth limit. #[test] fn embedded_notes_recurse_with_a_cycle_guard() { let vault = Vault::empty().note("Child.md", "## Child heading\n\nChild body.\n").note("Loop.md", "![[Loop.md]]\n\nLoop body.\n"); let pdf = render("![[Child.md]]\n\n![[Loop.md]]\n", &options(), &vault); let found = text(&pdf); assert!(found.contains("Child heading") && found.contains("Child body"), "the note renders: {found:?}"); assert!(found.contains("Loop"), "the cycle terminates instead of hanging"); } /// Task checkboxes are vector squares, not glyphs: the square is drawn with /// rectangles, never with text. #[test] fn task_checkboxes_are_vector_shapes() { let pdf = render("- [x] done\n- [ ] open\n", &options(), &NoAssets); let drawn = support::contents(&pdf).join("\n"); assert!(drawn.contains(" re S"), "a square is stroked as a vector path, not a glyph:\n{drawn}"); assert!(!drawn.contains("☑"), "no checkbox glyph is drawn with the font"); let found = text(&pdf); assert!(found.contains("done") && found.contains("open"), "the labels are drawn: {found:?}"); } /// A generated sixty-page document exports completely, and its page count is /// what the footer promises. #[test] fn a_sixty_page_document_completes() { let mut source = String::new(); for page in 0..60 { let _ = std::fmt::Write::write_fmt(&mut source, format_args!("## Page {page}\n\n")); for line in 0..40 { let _ = std::fmt::Write::write_fmt(&mut source, format_args!("Line {line} of page {page} with a few words to fill the measure.\n")); } source.push('\n'); } assert!(source.len() < 2 * 1024 * 1024, "the generated note stays inside the input cap"); let mut opts = options(); opts.title = Some(String::from("Long")); let pdf = render(&source, &opts, &NoAssets); let pages = page_count(&pdf); // Wrapping merges the short source lines, so the page count is below the // source line count but still well over thirty. assert!(pages > 30, "sixty chapters fill more than thirty pages, got {pages}"); assert!(pages <= MAX_PAGES, "and stay inside the cap"); assert!(text(&pdf).contains(&format!("page 1 of {pages}")), "the footer knows the total"); } /// Frontmatter is skipped unless it is asked for. #[test] fn frontmatter_is_hidden_by_default() { let source = "---\ntitle: Secret\n---\n\n# Body\n"; let hidden = render(source, &options(), &NoAssets); assert!(!text(&hidden).contains("Secret"), "frontmatter is not rendered by default"); let mut opts = options(); opts.include_frontmatter = true; let shown = render(source, &opts, &NoAssets); assert!(text(&shown).contains("Secret"), "and it is rendered when asked for"); } /// Unicode survives the round trip through the `ToUnicode` `CMap`: accents, /// dashes, Greek and Cyrillic all come back as text. Glyphs the font does /// not have (CJK, emoji) become the notdef box instead of text. #[test] fn unicode_text_stays_selectable() { let source = "Unicode: café — naïve — Ωμέγα — Привет done.\n"; let pdf = render(source, &options(), &NoAssets); let found = text(&pdf); for word in ["café", "naïve", "Ωμέγα", "Привет"] { assert!(found.contains(word), "`{word}` survives: {found:?}"); } } /// Every list item draws exactly one marker and its own text: the item /// ranges must start after each item's own marker, not after the list's. #[test] fn list_items_carry_one_marker_each() { let bullets = render("- one\n- two\n- three\n", &options(), &NoAssets); assert_eq!(text(&bullets).matches("•").count(), 3, "one bullet per item: {:?}", text(&bullets)); let numbers = render("1. first\n2. second\n", &options(), &NoAssets); assert!(text(&numbers).contains("1. first") && text(&numbers).contains("2. second"), "{:?}", text(&numbers)); let tasks = render("- [x] done\n- [ ] open\n", &options(), &NoAssets); let found = text(&tasks); assert_eq!(found.matches("done").count(), 1, "the task label appears once: {found:?}"); assert!(found.contains("open"), "{found:?}"); } /// A callout prints its type and title once, as the box title, and never /// repeats the `> [!type]` header as body text. #[test] fn a_callout_shows_its_title_once() { let pdf = render("> [!warning] Careful\n> A callout body.\n", &options(), &NoAssets); let found = text(&pdf); assert_eq!(found.matches("warning").count(), 1, "the type name is the title: {found:?}"); assert!(found.contains("A callout body."), "the body survives: {found:?}"); assert!(!found.contains("[!"), "the raw header is dropped: {found:?}"); } /// A font file that is not a font is refused, never guessed at. #[test] fn unusable_fonts_are_errors() { let mut set = fonts(); set.regular = std::sync::Arc::from(&b"not a font"[..]); let opts = ExportOptions::new(set); let result = export("# Title\n", &opts, &NoAssets); assert!(result.is_err(), "a broken font file is an error"); }