Files
2026-10-15 02:24:15 -03:00

297 lines
13 KiB
Rust

//! 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<u8>)>,
/// 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<Vec<u8>> {
self.images.iter().find(|(name, _)| name == target).map(|(_, bytes)| bytes.clone())
}
fn embedded_note(&self, target: &str) -> Option<String> {
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<u8> {
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<String> {
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<String> = 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");
}