#![cfg(feature = "pdf")]
extern crate text_document_io as document_io;
use common::long_operation::{LongOperationManager, OperationStatus};
use common::parser_tools::PdfExportOptions;
use document_io::{ExportPdfDto, document_io_controller};
use std::sync::Arc;
use test_harness::{EventHub, setup};
const TEST_FONT: &[u8] = include_bytes!("assets/DejaVuSerif.ttf");
fn pdf_options() -> PdfExportOptions {
PdfExportOptions {
font_family: "DejaVu Serif".to_string(),
font_bytes: vec![TEST_FONT.to_vec()],
..Default::default()
}
}
const RICH_DJOT: &str = "\
# Chapter One
Some *bold* and _italic_ prose about a #[stormy] night.
## A subsection
- bullet one
- bullet two
1. first
2. second
| A | B |
|---|---|
| 1 | 2 |
";
fn wait(mgr: &LongOperationManager, op_id: &str) {
while let Some(OperationStatus::Running) = mgr.get_operation_status(op_id) {
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
fn import_djot(db: &test_harness::DbContext, ev: &Arc<EventHub>, djot: &str) {
let mut mgr = LongOperationManager::new();
let op = document_io_controller::import_djot(
db,
ev,
&mut mgr,
&document_io::ImportDjotDto {
djot_text: djot.to_string(),
options: Default::default(),
},
)
.expect("import_djot");
wait(&mgr, &op);
assert_eq!(
mgr.get_operation_status(&op),
Some(OperationStatus::Completed),
"import of {djot:?} did not complete"
);
}
fn pdf_from_djot(djot: &str, options: PdfExportOptions) -> Vec<u8> {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, djot);
document_io_controller::build_pdf_document(
&db,
&ExportPdfDto {
output_path: String::new(),
options,
},
)
.expect("build_pdf_document")
}
fn count_pdf_pages(bytes: &[u8]) -> usize {
let re = regex::bytes::Regex::new(r"/Type\s*/Page\b").unwrap();
re.find_iter(bytes).count()
}
#[test]
fn plain_prose_fixture_exports_a_valid_pdf() {
let bytes = pdf_from_djot(RICH_DJOT, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"output must start with the PDF magic bytes"
);
assert!(
bytes.len() > 500,
"a document with headings/lists/a table must not compile to a trivially small PDF, got {} bytes",
bytes.len()
);
assert!(
count_pdf_pages(&bytes) >= 1,
"must report at least one page"
);
}
#[test]
fn plain_paragraph_exports_a_valid_pdf() {
let bytes = pdf_from_djot(
"Just a plain paragraph, no formatting at all.",
pdf_options(),
);
assert!(bytes.starts_with(b"%PDF-"));
assert!(count_pdf_pages(&bytes) >= 1);
}
#[test]
fn heading_levels_all_compile() {
for level in 1..=6 {
let hashes = "#".repeat(level);
let djot = format!("{hashes} Title level {level}\n\nSome body text.\n");
let bytes = pdf_from_djot(&djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"), "level {level} must compile");
}
}
#[test]
fn code_block_compiles_and_does_not_interpret_its_own_content_as_markup() {
let djot = "```rust\nlet s = \"a * b # c\\\\d\";\n```\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn rtl_hebrew_fixture_compiles() {
let djot =
"{direction=rtl}\n\u{05e9}\u{05dc}\u{05d5}\u{05dd} \u{05e2}\u{05d5}\u{05dc}\u{05dd}\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"an RTL Hebrew block must still compile even with a non-Hebrew-shaping font"
);
}
#[test]
fn rtl_arabic_fixture_compiles() {
let djot = "{direction=rtl}\n\u{0645}\u{0631}\u{062d}\u{0628}\u{0627} \u{0628}\u{0627}\u{0644}\u{0639}\u{0627}\u{0644}\u{0645}\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"an RTL Arabic block must still compile even with a non-Arabic-shaping font"
);
}
#[test]
fn mixed_ltr_and_rtl_blocks_compile_in_one_document() {
let djot = "English prose first.\n\n{direction=rtl}\n\u{05e9}\u{05dc}\u{05d5}\u{05dd}\n\nMore English prose after.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn rtl_heading_compiles() {
let djot = "{direction=rtl}\n# \u{05e9}\u{05dc}\u{05d5}\u{05dd} \u{05e2}\u{05d5}\u{05dc}\u{05dd}\n\nBody paragraph.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"an RTL heading must compile with the marker kept at block start"
);
}
#[test]
fn garbage_font_bytes_produce_a_clear_error_not_a_silent_success() {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, "Hello, world.");
let options = PdfExportOptions {
font_bytes: vec![vec![0u8; 16]], ..Default::default()
};
let err = document_io_controller::build_pdf_document(
&db,
&ExportPdfDto {
output_path: String::new(),
options,
},
)
.expect_err("corrupt font bytes must be rejected, not silently produce an empty-font PDF");
assert!(
err.to_string().contains("could not be parsed as a font"),
"got: {err}"
);
}
#[test]
fn no_fonts_at_all_produce_a_clear_error() {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, "Hello, world.");
let err = document_io_controller::build_pdf_document(
&db,
&ExportPdfDto {
output_path: String::new(),
options: PdfExportOptions::default(), },
)
.expect_err("an export with zero fonts must fail loudly");
assert!(err.to_string().contains("no fonts supplied"), "got: {err}");
}
#[test]
fn special_characters_round_trip_through_import_and_export() {
let djot = "\\#hashtag \\*star\\* \\_underscore\\_ \\[bracket\\] \\$dollar \\`tick\\` \\~tilde\\~ \\<lt\\> \\@at and a literal - hyphen / slash = equals + plus.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"prose containing every escaped-special character must still compile"
);
}
#[test]
fn leading_numbered_looking_prose_does_not_become_a_typst_list() {
let djot = "12\\. Go left at the fork.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn a_page_break_actually_opens_a_new_page() {
let one = count_pdf_pages(&pdf_from_djot("Alpha.\n\nBeta.", pdf_options()));
let two = count_pdf_pages(&pdf_from_djot(
"Alpha.\n\n{page_break_before=true}\nBeta.",
pdf_options(),
));
assert_eq!(one, 1, "the control document must be a single page");
assert_eq!(two, 2, "the break must open a second page");
}
#[test]
fn a_break_on_the_first_block_does_not_produce_a_leading_blank_page() {
let pages = count_pdf_pages(&pdf_from_djot(
"{page_break_before=true}\n# Chapter One\n\nProse.",
pdf_options(),
));
assert_eq!(pages, 1, "expected no blank leading page");
}
#[test]
fn a_heading_after_a_page_break_is_still_a_heading() {
let bytes = pdf_from_djot(
"Body.\n\n{page_break_before=true}\n# Chapter Two\n\nMore.",
pdf_options(),
);
assert_eq!(count_pdf_pages(&bytes), 2);
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn a_page_break_opening_a_quotation_is_lifted_out_of_it() {
let bytes = pdf_from_djot(
"Body.\n\n> {page_break_before=true}\n> Quoted matter.\n\nAfter.",
pdf_options(),
);
assert!(bytes.starts_with(b"%PDF-"));
assert_eq!(count_pdf_pages(&bytes), 2, "the break must still break");
}
#[test]
fn a_page_break_opening_an_epigraph_is_lifted_out_of_it() {
let bytes = pdf_from_djot(
"Body.\n\n> {semantic_role=epigraph page_break_before=true}\n> All happy families.\n>\n > {alignment=right}\n> Tolstoy\n\nAfter.",
pdf_options(),
);
assert!(bytes.starts_with(b"%PDF-"));
assert_eq!(count_pdf_pages(&bytes), 2);
}
#[test]
fn rich_document_writes_a_real_pdf_file_to_disk() {
let (db, ev, _) = setup().expect("setup");
import_djot(&db, &ev, RICH_DJOT);
let dir = std::env::temp_dir();
let path = dir.join(format!("pdf_export_rich_{}.pdf", std::process::id()));
let path_str = path.to_string_lossy().to_string();
let mut mgr = LongOperationManager::new();
let op = document_io_controller::export_pdf(
&db,
&ev,
&mut mgr,
&ExportPdfDto {
output_path: path_str.clone(),
options: PdfExportOptions {
title: Some("Rich Book".to_string()),
author: Some("Test Author".to_string()),
..pdf_options()
},
},
)
.expect("export_pdf");
wait(&mgr, &op);
assert_eq!(
mgr.get_operation_status(&op),
Some(OperationStatus::Completed),
"export should complete"
);
let result_json = mgr.get_operation_result(&op).expect("result present");
let result: document_io::ExportPdfResultDto =
serde_json::from_str(&result_json).expect("result deserializes");
assert_eq!(result.file_path, path_str);
assert!(result.page_count >= 1);
let bytes = std::fs::read(&path).expect("output file exists");
assert!(!bytes.is_empty());
assert!(
bytes.starts_with(b"%PDF-"),
"the written file is a real PDF"
);
assert_eq!(count_pdf_pages(&bytes) as i64, result.page_count);
let _ = std::fs::remove_file(&path);
}
#[test]
fn per_block_spacing_overrides_compile() {
let djot = "Ordinary indented paragraph.\n\n\
{top_margin=24 text_indent=0}\nThe paragraph after a scene break.";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
assert!(count_pdf_pages(&bytes) >= 1);
}
#[test]
fn per_block_spacing_compiles_alongside_a_document_wide_indent() {
let mut options = pdf_options();
options.first_line_indent_mm = Some(5.0);
options.paragraph_spacing_pt = Some(6.0);
let djot = "First paragraph.\n\n{text_indent=0}\nFlush after the break.";
let bytes = pdf_from_djot(djot, options);
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn per_block_spacing_compiles_together_with_rtl_and_alignment() {
let djot = "{direction=rtl alignment=center top_margin=24 text_indent=0}\nنص عربي بعد الفاصل.";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
}
fn png_bytes() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut enc = png::Encoder::new(&mut buf, 4, 3);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[0u8, 128, 255, 255].repeat(12))
.unwrap();
}
buf
}
fn options_with_image() -> PdfExportOptions {
PdfExportOptions {
images: common::parser_tools::ExportImages::from_iter([(
"pic.png",
common::parser_tools::ExportImage::new(png_bytes(), "image/png"),
)]),
..pdf_options()
}
}
#[test]
fn an_inline_image_compiles_into_the_pdf() {
let pdf = pdf_from_djot(
"Before {width=64 height=48} after.\n",
options_with_image(),
);
assert_eq!(&pdf[..5], b"%PDF-", "not a PDF");
assert!(
pdf.len() > 1000,
"suspiciously small PDF: {} bytes",
pdf.len()
);
assert_eq!(count_pdf_pages(&pdf), 1);
}
#[test]
fn an_image_without_bytes_falls_back_to_its_description() {
let pdf = pdf_from_djot(
"Before  after.\n",
pdf_options(),
);
assert_eq!(&pdf[..5], b"%PDF-");
assert_eq!(count_pdf_pages(&pdf), 1);
}
#[test]
fn several_images_each_resolve() {
let mut images = common::parser_tools::ExportImages::new();
for name in ["a.png", "b.png"] {
images.insert(
name,
common::parser_tools::ExportImage::new(png_bytes(), "image/png"),
);
}
let pdf = pdf_from_djot(
" and \n",
PdfExportOptions {
images,
..pdf_options()
},
);
assert_eq!(&pdf[..5], b"%PDF-");
assert_eq!(count_pdf_pages(&pdf), 1);
}
#[test]
fn a_footnote_compiles_into_the_pdf() {
let djot = "The lighthouse stood alone.[^a]\n\n[^a]: Decommissioned in 1961.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"a document carrying a footnote must still compile"
);
assert!(count_pdf_pages(&bytes) >= 1);
}
#[test]
fn a_footnote_with_no_body_still_compiles() {
let bytes = pdf_from_djot("Orphaned here.[^gone]\n", pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
}
#[test]
fn a_repeat_footnote_citation_reuses_one_note_and_compiles() {
let djot = "First[^n1] and second[^n1] citation.\n\n[^n1]: The note body.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(
bytes.starts_with(b"%PDF-"),
"a repeat citation of one label must still compile"
);
assert!(count_pdf_pages(&bytes) >= 1);
}
#[test]
fn independently_repeated_footnotes_all_compile() {
let djot = "One[^a] two[^a] three[^b] four[^b].\n\n[^a]: Body A.\n\n[^b]: Body B.\n";
let bytes = pdf_from_djot(djot, pdf_options());
assert!(bytes.starts_with(b"%PDF-"));
assert!(count_pdf_pages(&bytes) >= 1);
}