pub mod color;
pub mod content;
pub mod encrypt;
pub mod signature;
pub mod watermark;
pub mod document;
pub mod error;
pub mod flow;
pub mod font;
pub mod form;
pub mod image;
pub mod object;
pub mod outline;
pub mod page;
pub mod parser;
pub mod table;
pub mod writer;
pub mod prelude {
pub use crate::color::Color;
pub use crate::content::{ContentStream, LineCap, LineJoin, TextAlign};
pub use crate::document::{Document, LinkAnnotation};
pub use crate::error::{PdfError, PdfResult};
pub use crate::flow::{FlowStyle, Span, TextFlow};
pub use crate::font::BuiltinFont;
pub use crate::form::{AcroForm, FieldAlign, FieldRect, FormField};
pub use crate::image::{ColorSpace, ImageEncoding, PdfImage};
pub use crate::object::{ObjRef, PdfDict, PdfObject, PdfStream};
pub use crate::outline::{Destination, Outline, OutlineItem};
pub use crate::page::{PageBuilder, PageSize};
pub use crate::parser::ParsedDocument;
pub use crate::table::{Table, TableCell, TableRow, TableStyle};
pub use crate::encrypt::{Encryption, KeyLength, Permissions};
pub use crate::signature::{SignatureAppearance, SignatureField, SignaturePlaceholder};
pub use crate::watermark::{HeaderFooter, HFAlign, HFSlot, Watermark, WatermarkLayer};
pub use crate::image::{CropRect, DropShadow, ImageBorder, ImageOptions};
}
#[cfg(test)]
mod tests {
use super::prelude::*;
use crate::parser::ParsedDocument;
#[test]
fn test_pdf_object_serialize_null() {
assert_eq!(PdfObject::Null.serialize(), "null");
}
#[test]
fn test_pdf_object_serialize_bool() {
assert_eq!(PdfObject::Boolean(true).serialize(), "true");
assert_eq!(PdfObject::Boolean(false).serialize(), "false");
}
#[test]
fn test_pdf_object_serialize_integer() {
assert_eq!(PdfObject::Integer(42).serialize(), "42");
assert_eq!(PdfObject::Integer(-7).serialize(), "-7");
assert_eq!(PdfObject::Integer(0).serialize(), "0");
}
#[test]
fn test_pdf_object_serialize_real() {
let s = PdfObject::Real(1.5).serialize();
assert!(s.contains("1.5"), "got: {}", s);
let s2 = PdfObject::Real(1.0).serialize();
assert_eq!(s2, "1");
}
#[test]
fn test_pdf_object_serialize_name() {
assert_eq!(PdfObject::name("Font").serialize(), "/Font");
assert_eq!(PdfObject::name("XYZ").serialize(), "/XYZ");
}
#[test]
fn test_pdf_object_serialize_string() {
let s = PdfObject::string("Hello").serialize();
assert_eq!(s, "(Hello)");
}
#[test]
fn test_pdf_object_serialize_string_escapes() {
let s = PdfObject::string("He said (hi)").serialize();
assert_eq!(s, r"(He said \(hi\))");
}
#[test]
fn test_pdf_object_serialize_array() {
let arr = PdfObject::Array(vec![
PdfObject::Integer(1),
PdfObject::Integer(2),
PdfObject::Integer(3),
]);
assert_eq!(arr.serialize(), "[1 2 3]");
}
#[test]
fn test_pdf_dict_set_get() {
let mut dict = PdfDict::new();
dict.set("Type", PdfObject::name("Font"));
match dict.get("Type") {
Some(PdfObject::Name(n)) => assert_eq!(n, "Font"),
other => panic!("unexpected: {:?}", other),
}
}
#[test]
fn test_objref_serialize() {
let r = ObjRef::new(5);
assert_eq!(r.serialize(), "5 0 R");
}
#[test]
fn test_helvetica_string_width_nonempty() {
let w = BuiltinFont::Helvetica.string_width("Hello", 12.0);
assert!(w > 0.0, "width should be positive, got {}", w);
}
#[test]
fn test_courier_monospace() {
assert!(BuiltinFont::Courier.is_monospace());
assert!(!BuiltinFont::Helvetica.is_monospace());
let wa = BuiltinFont::Courier.char_width('A');
let wz = BuiltinFont::Courier.char_width('z');
assert_eq!(wa, wz);
}
#[test]
fn test_font_width_scales_with_size() {
let w10 = BuiltinFont::Helvetica.string_width("A", 10.0);
let w20 = BuiltinFont::Helvetica.string_width("A", 20.0);
let epsilon = 0.001;
assert!((w20 - 2.0 * w10).abs() < epsilon, "width should double with double size");
}
#[test]
fn test_color_from_hex() {
let c = Color::from_hex("#FF0000").unwrap();
if let Color::Rgb(r, g, b) = c {
assert!((r - 1.0).abs() < 0.01);
assert!(g.abs() < 0.01);
assert!(b.abs() < 0.01);
} else {
panic!("expected Rgb");
}
}
#[test]
fn test_color_from_hex_invalid() {
assert!(Color::from_hex("not-a-color").is_none());
assert!(Color::from_hex("#GGGGGG").is_none());
}
#[test]
fn test_color_fill_op() {
let op = Color::Rgb(1.0, 0.0, 0.5).fill_op();
assert!(op.ends_with("rg"), "got: {}", op);
let op2 = Color::Gray(0.5).fill_op();
assert!(op2.ends_with(" g"), "got: {}", op2);
}
#[test]
fn test_color_stroke_op() {
let op = Color::Rgb(0.0, 0.5, 1.0).stroke_op();
assert!(op.ends_with("RG"), "got: {}", op);
}
#[test]
fn test_color_rgb_u8() {
let c = Color::rgb_u8(255, 128, 0);
if let Color::Rgb(r, g, _) = c {
assert!((r - 1.0).abs() < 0.01);
assert!((g - 0.502).abs() < 0.01);
} else { panic!("expected Rgb"); }
}
#[test]
fn test_content_stream_produces_bytes() {
let mut cs = ContentStream::new();
cs.save().fill_color(Color::BLACK).rect(10.0, 10.0, 100.0, 50.0).fill().restore();
let bytes = cs.to_bytes();
let s = String::from_utf8(bytes).unwrap();
assert!(s.contains("re"), "missing rect op: {}", s);
assert!(s.contains('\n'), "ops should be newline-separated");
assert!(s.lines().any(|l| l.trim() == "f"), "missing fill op: {}", s);
assert!(s.contains("q"), "missing save op: {}", s);
assert!(s.contains("Q"), "missing restore op: {}", s);
}
#[test]
fn test_content_stream_text_ops() {
let mut cs = ContentStream::new();
cs.begin_text().set_font("F1Reg", 12.0).text_matrix(50.0, 700.0).show_text("hello").end_text();
let s = String::from_utf8(cs.to_bytes()).unwrap();
assert!(s.contains("BT"), "missing BT");
assert!(s.contains("ET"), "missing ET");
assert!(s.contains("Tf"), "missing Tf");
assert!(s.contains("Tm"), "missing Tm");
assert!(s.contains("(hello) Tj"), "missing text show");
}
#[test]
fn test_content_stream_line_op() {
let mut cs = ContentStream::new();
cs.line(0.0, 0.0, 100.0, 100.0, Color::BLACK, 1.0);
let s = String::from_utf8(cs.to_bytes()).unwrap();
assert!(s.contains(" m"), "missing moveto");
assert!(s.contains(" l"), "missing lineto");
assert!(s.lines().any(|l| l.trim() == "S"), "missing stroke op:\n{}", s);
}
#[test]
fn test_content_stream_circle() {
let mut cs = ContentStream::new();
cs.circle(100.0, 100.0, 50.0);
let s = String::from_utf8(cs.to_bytes()).unwrap();
assert!(s.contains(" c"), "missing bezier");
assert!(s.contains("h"), "missing close");
}
#[test]
fn test_document_builds_valid_pdf_signature() {
let pdf = Document::new().page(|_p| {}).build();
assert!(pdf.starts_with(b"%PDF-1.7"), "missing PDF header");
assert!(pdf.ends_with(b"%%EOF\n"), "missing EOF marker");
}
#[test]
fn test_document_contains_xref() {
let pdf = Document::new().page(|_p| {}).build();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("xref"), "missing xref");
assert!(s.contains("startxref"), "missing startxref");
assert!(s.contains("trailer"), "missing trailer");
}
#[test]
fn test_document_multipage() {
let pdf = Document::new()
.page(|_| {})
.page(|_| {})
.page(|_| {})
.build();
let doc = ParsedDocument::parse(pdf).expect("parse failed");
assert_eq!(doc.page_refs().unwrap().len(), 3);
}
#[test]
fn test_document_with_text_parses_back() {
let pdf = Document::new()
.title("Test")
.author("Tester")
.page(|p| {
let f = p.use_helvetica();
let reg = p.reg_key(&f);
p.text("Hello World", 50.0, 700.0, ®, 12.0, Color::BLACK);
})
.build();
let doc = ParsedDocument::parse(pdf).expect("parse failed");
assert_eq!(doc.page_refs().unwrap().len(), 1);
}
#[test]
fn test_document_metadata_in_info() {
let pdf = Document::new()
.title("My Doc")
.author("Someone")
.subject("Testing")
.page(|_| {})
.build();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("My Doc"), "missing title");
assert!(s.contains("Someone"), "missing author");
}
#[test]
fn test_parser_rejects_bad_signature() {
let bad = b"NOT A PDF AT ALL".to_vec();
assert!(ParsedDocument::parse(bad).is_err());
}
#[test]
fn test_parser_page_count_matches_built() {
for n in 1..=5 {
let mut doc = Document::new();
for _ in 0..n { doc = doc.page(|_| {}); }
let pdf = doc.build();
let parsed = ParsedDocument::parse(pdf).unwrap();
assert_eq!(parsed.page_refs().unwrap().len(), n,
"page count mismatch for n={}", n);
}
}
#[test]
fn test_parser_root_ref_exists() {
let pdf = Document::new().page(|_| {}).build();
let doc = ParsedDocument::parse(pdf).unwrap();
assert!(doc.root_ref().is_some(), "root ref should be Some");
}
#[test]
fn test_table_total_width() {
let t = Table::new(vec![100.0, 200.0, 50.0]);
assert!((t.total_width() - 350.0).abs() < 0.001);
}
#[test]
fn test_table_row_count() {
let mut t = Table::new(vec![100.0, 100.0]);
t.add_row(TableRow::header(vec![
TableCell::new("A"), TableCell::new("B"),
]));
t.add_row(TableRow::new(vec![
TableCell::new("1"), TableCell::new("2"),
]));
assert_eq!(t.rows.len(), 2);
}
#[test]
fn test_table_renders_into_document() {
let pdf = Document::new().page(|p| {
let _f = p.use_helvetica();
let mut t = Table::new(vec![200.0, 200.0]);
t.add_row(TableRow::header(vec![TableCell::new("H1"), TableCell::new("H2")]));
t.add_row(TableRow::new(vec![TableCell::new("A"), TableCell::new("B")]));
p.table(&t, 50.0, 750.0);
}).build();
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_image_from_rgb_dimensions() {
let data = vec![255u8; 10 * 10 * 3];
let img = PdfImage::from_rgb(10, 10, data);
assert_eq!(img.width, 10);
assert_eq!(img.height, 10);
}
#[test]
fn test_image_xobject_stream_has_required_keys() {
let img = PdfImage::from_rgb(4, 4, vec![0u8; 4 * 4 * 3]);
let stream = img.to_xobject_stream();
assert!(stream.dict.get("Width").is_some());
assert!(stream.dict.get("Height").is_some());
assert!(stream.dict.get("ColorSpace").is_some());
assert!(stream.dict.get("BitsPerComponent").is_some());
}
#[test]
fn test_jpeg_header_parser_rejects_bad_data() {
let result = PdfImage::parse_jpeg_header(b"not a jpeg");
assert!(result.is_err());
}
#[test]
fn test_outline_in_document_builds_ok() {
let ol = Outline::new()
.add(OutlineItem::new("Chapter 1", Destination::Page(0)))
.add(OutlineItem::new("Chapter 2", Destination::Page(1)).bold());
let pdf = Document::new()
.outline(ol)
.page(|_| {})
.page(|_| {})
.build();
assert!(pdf.starts_with(b"%PDF-1.7"));
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("Outlines"), "catalog missing /Outlines");
assert!(s.contains("Chapter 1"), "missing outline title");
}
#[test]
fn test_empty_outline_skipped() {
let pdf = Document::new()
.outline(Outline::new()) .page(|_| {})
.build();
let s = String::from_utf8_lossy(&pdf);
assert!(!s.contains("Outlines"), "empty outline should not appear in catalog");
}
#[test]
fn test_form_text_field_in_document() {
let form = AcroForm::new()
.add(FormField::text("name", FieldRect::new(50.0, 700.0, 200.0, 20.0), 0)
.value("Samuel")
.tooltip("Your name"));
let pdf = Document::new()
.form(form)
.page(|_| {})
.build();
assert!(pdf.starts_with(b"%PDF-1.7"));
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("AcroForm"), "missing /AcroForm in catalog");
assert!(s.contains("Widget"), "missing Widget annotation");
}
#[test]
fn test_form_checkbox() {
let form = AcroForm::new()
.add(FormField::checkbox("agree", FieldRect::new(50.0, 650.0, 15.0, 15.0), 0, true));
let pdf = Document::new().form(form).page(|_| {}).build();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("Btn"), "missing Btn field type");
}
#[test]
fn test_form_dropdown() {
let opts = vec!["Kenya".into(), "Japan".into(), "Sweden".into()];
let form = AcroForm::new()
.add(FormField::dropdown("country", opts, FieldRect::new(50.0, 600.0, 150.0, 20.0), 0));
let pdf = Document::new().form(form).page(|_| {}).build();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("Kenya"), "missing dropdown option");
assert!(s.contains("Sweden"), "missing dropdown option");
}
#[test]
fn test_textflow_single_page_short_content() {
let flow = TextFlow::new(FlowStyle::default())
.heading("Title", 1)
.paragraph("Short paragraph.");
let pages = flow.render(595.0, 842.0);
assert!(!pages.is_empty(), "should produce at least one page");
}
#[test]
fn test_textflow_many_paragraphs_creates_multiple_pages() {
let mut flow = TextFlow::new(FlowStyle::default());
for i in 0..50 {
flow = flow.paragraph(format!(
"Paragraph {} with enough text to take up vertical space on the page \
and eventually force a page break when enough of them accumulate.", i
));
}
let pages = flow.render(595.0, 842.0);
assert!(pages.len() >= 2, "50 paragraphs should span multiple pages, got {}", pages.len());
}
#[test]
fn test_textflow_integrates_with_document() {
let flow = TextFlow::new(FlowStyle::default())
.heading("Hello", 1)
.paragraph("World");
let pages = flow.render(595.276, 841.890);
let pdf = Document::new().add_pages(pages).build();
assert!(pdf.starts_with(b"%PDF-1.7"));
}
#[test]
fn test_textflow_code_block() {
let flow = TextFlow::new(FlowStyle::default())
.code("fn main() {\n println!(\"hello\");\n}");
let pages = flow.render(595.0, 842.0);
assert_eq!(pages.len(), 1);
}
#[test]
fn test_textflow_bullets() {
let flow = TextFlow::new(FlowStyle::default())
.bullets(vec!["Item one", "Item two", "Item three"]);
let pages = flow.render(595.0, 842.0);
assert!(!pages.is_empty());
}
#[test]
fn test_stream_compressed_has_flatedecode() {
use crate::object::PdfStream;
let s = PdfStream::new_compressed(b"hello world".to_vec());
match s.dict.get("Filter") {
Some(PdfObject::Name(n)) => assert_eq!(n, "FlateDecode"),
other => panic!("expected FlateDecode, got {:?}", other),
}
}
#[test]
fn test_stream_compressed_is_smaller_for_repetitive_data() {
use crate::object::PdfStream;
let data = vec![0xAAu8; 1000];
let raw = PdfStream::new(data.clone());
let comp = PdfStream::new_compressed(data);
assert!(comp.data.len() < raw.data.len(),
"compressed ({}) should be smaller than raw ({})",
comp.data.len(), raw.data.len());
}
}