297 lines
13 KiB
Rust
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 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");
|
|
}
|