Skip to main content

pdfox/
lib.rs

1//! # pdfox 🦊
2//!
3//! A pure-Rust PDF library. No C deps. No unsafe. Just bytes.
4
5pub mod color;
6pub mod content;
7pub mod encrypt;
8pub mod signature;
9pub mod watermark;
10pub mod document;
11pub mod error;
12pub mod flow;
13pub mod font;
14pub mod form;
15pub mod image;
16pub mod object;
17pub mod outline;
18pub mod page;
19pub mod parser;
20pub mod table;
21pub mod writer;
22
23pub mod prelude {
24    pub use crate::color::Color;
25    pub use crate::content::{ContentStream, LineCap, LineJoin, TextAlign};
26    pub use crate::document::{Document, LinkAnnotation};
27    pub use crate::error::{PdfError, PdfResult};
28    pub use crate::flow::{FlowStyle, Span, TextFlow};
29    pub use crate::font::BuiltinFont;
30    pub use crate::form::{AcroForm, FieldAlign, FieldRect, FormField};
31    pub use crate::image::{ColorSpace, ImageEncoding, PdfImage};
32    pub use crate::object::{ObjRef, PdfDict, PdfObject, PdfStream};
33    pub use crate::outline::{Destination, Outline, OutlineItem};
34    pub use crate::page::{PageBuilder, PageSize};
35    pub use crate::parser::ParsedDocument;
36    pub use crate::table::{Table, TableCell, TableRow, TableStyle};
37    pub use crate::encrypt::{Encryption, KeyLength, Permissions};
38    pub use crate::signature::{SignatureAppearance, SignatureField, SignaturePlaceholder};
39    pub use crate::watermark::{HeaderFooter, HFAlign, HFSlot, Watermark, WatermarkLayer};
40    pub use crate::image::{CropRect, DropShadow, ImageBorder, ImageOptions};
41}
42
43#[cfg(test)]
44mod tests {
45    use super::prelude::*;
46    use crate::parser::ParsedDocument;
47
48    // ── Object model ─────────────────────────────────────────────────────────
49
50    #[test]
51    fn test_pdf_object_serialize_null() {
52        assert_eq!(PdfObject::Null.serialize(), "null");
53    }
54
55    #[test]
56    fn test_pdf_object_serialize_bool() {
57        assert_eq!(PdfObject::Boolean(true).serialize(),  "true");
58        assert_eq!(PdfObject::Boolean(false).serialize(), "false");
59    }
60
61    #[test]
62    fn test_pdf_object_serialize_integer() {
63        assert_eq!(PdfObject::Integer(42).serialize(),   "42");
64        assert_eq!(PdfObject::Integer(-7).serialize(),   "-7");
65        assert_eq!(PdfObject::Integer(0).serialize(),    "0");
66    }
67
68    #[test]
69    fn test_pdf_object_serialize_real() {
70        // Trailing zeros are stripped
71        let s = PdfObject::Real(1.5).serialize();
72        assert!(s.contains("1.5"), "got: {}", s);
73        let s2 = PdfObject::Real(1.0).serialize();
74        assert_eq!(s2, "1");
75    }
76
77    #[test]
78    fn test_pdf_object_serialize_name() {
79        assert_eq!(PdfObject::name("Font").serialize(), "/Font");
80        assert_eq!(PdfObject::name("XYZ").serialize(),  "/XYZ");
81    }
82
83    #[test]
84    fn test_pdf_object_serialize_string() {
85        let s = PdfObject::string("Hello").serialize();
86        assert_eq!(s, "(Hello)");
87    }
88
89    #[test]
90    fn test_pdf_object_serialize_string_escapes() {
91        let s = PdfObject::string("He said (hi)").serialize();
92        assert_eq!(s, r"(He said \(hi\))");
93    }
94
95    #[test]
96    fn test_pdf_object_serialize_array() {
97        let arr = PdfObject::Array(vec![
98            PdfObject::Integer(1),
99            PdfObject::Integer(2),
100            PdfObject::Integer(3),
101        ]);
102        assert_eq!(arr.serialize(), "[1 2 3]");
103    }
104
105    #[test]
106    fn test_pdf_dict_set_get() {
107        let mut dict = PdfDict::new();
108        dict.set("Type", PdfObject::name("Font"));
109        match dict.get("Type") {
110            Some(PdfObject::Name(n)) => assert_eq!(n, "Font"),
111            other => panic!("unexpected: {:?}", other),
112        }
113    }
114
115    #[test]
116    fn test_objref_serialize() {
117        let r = ObjRef::new(5);
118        assert_eq!(r.serialize(), "5 0 R");
119    }
120
121    // ── Font metrics ─────────────────────────────────────────────────────────
122
123    #[test]
124    fn test_helvetica_string_width_nonempty() {
125        let w = BuiltinFont::Helvetica.string_width("Hello", 12.0);
126        assert!(w > 0.0, "width should be positive, got {}", w);
127    }
128
129    #[test]
130    fn test_courier_monospace() {
131        assert!(BuiltinFont::Courier.is_monospace());
132        assert!(!BuiltinFont::Helvetica.is_monospace());
133        // All chars same width
134        let wa = BuiltinFont::Courier.char_width('A');
135        let wz = BuiltinFont::Courier.char_width('z');
136        assert_eq!(wa, wz);
137    }
138
139    #[test]
140    fn test_font_width_scales_with_size() {
141        let w10 = BuiltinFont::Helvetica.string_width("A", 10.0);
142        let w20 = BuiltinFont::Helvetica.string_width("A", 20.0);
143        let epsilon = 0.001;
144        assert!((w20 - 2.0 * w10).abs() < epsilon, "width should double with double size");
145    }
146
147    // ── Color ─────────────────────────────────────────────────────────────────
148
149    #[test]
150    fn test_color_from_hex() {
151        let c = Color::from_hex("#FF0000").unwrap();
152        if let Color::Rgb(r, g, b) = c {
153            assert!((r - 1.0).abs() < 0.01);
154            assert!(g.abs() < 0.01);
155            assert!(b.abs() < 0.01);
156        } else {
157            panic!("expected Rgb");
158        }
159    }
160
161    #[test]
162    fn test_color_from_hex_invalid() {
163        assert!(Color::from_hex("not-a-color").is_none());
164        assert!(Color::from_hex("#GGGGGG").is_none());
165    }
166
167    #[test]
168    fn test_color_fill_op() {
169        let op = Color::Rgb(1.0, 0.0, 0.5).fill_op();
170        assert!(op.ends_with("rg"), "got: {}", op);
171        let op2 = Color::Gray(0.5).fill_op();
172        assert!(op2.ends_with(" g"), "got: {}", op2);
173    }
174
175    #[test]
176    fn test_color_stroke_op() {
177        let op = Color::Rgb(0.0, 0.5, 1.0).stroke_op();
178        assert!(op.ends_with("RG"), "got: {}", op);
179    }
180
181    #[test]
182    fn test_color_rgb_u8() {
183        let c = Color::rgb_u8(255, 128, 0);
184        if let Color::Rgb(r, g, _) = c {
185            assert!((r - 1.0).abs() < 0.01);
186            assert!((g - 0.502).abs() < 0.01);
187        } else { panic!("expected Rgb"); }
188    }
189
190    // ── Content stream operators ──────────────────────────────────────────────
191
192    #[test]
193    fn test_content_stream_produces_bytes() {
194        let mut cs = ContentStream::new();
195        cs.save().fill_color(Color::BLACK).rect(10.0, 10.0, 100.0, 50.0).fill().restore();
196        let bytes = cs.to_bytes();
197        let s = String::from_utf8(bytes).unwrap();
198        assert!(s.contains("re"), "missing rect op: {}", s);
199        assert!(s.contains('\n'), "ops should be newline-separated");
200        // 'f' is the fill op - it appears as a standalone line
201        assert!(s.lines().any(|l| l.trim() == "f"), "missing fill op: {}", s);
202        assert!(s.contains("q"), "missing save op: {}", s);
203        assert!(s.contains("Q"), "missing restore op: {}", s);
204    }
205
206    #[test]
207    fn test_content_stream_text_ops() {
208        let mut cs = ContentStream::new();
209        cs.begin_text().set_font("F1Reg", 12.0).text_matrix(50.0, 700.0).show_text("hello").end_text();
210        let s = String::from_utf8(cs.to_bytes()).unwrap();
211        assert!(s.contains("BT"),    "missing BT");
212        assert!(s.contains("ET"),    "missing ET");
213        assert!(s.contains("Tf"),    "missing Tf");
214        assert!(s.contains("Tm"),    "missing Tm");
215        assert!(s.contains("(hello) Tj"), "missing text show");
216    }
217
218    #[test]
219    fn test_content_stream_line_op() {
220        let mut cs = ContentStream::new();
221        cs.line(0.0, 0.0, 100.0, 100.0, Color::BLACK, 1.0);
222        let s = String::from_utf8(cs.to_bytes()).unwrap();
223        assert!(s.contains(" m"), "missing moveto");
224        assert!(s.contains(" l"), "missing lineto");
225        // 'S' is the stroke op - appears as a standalone line
226        assert!(s.lines().any(|l| l.trim() == "S"), "missing stroke op:\n{}", s);
227    }
228
229    #[test]
230    fn test_content_stream_circle() {
231        let mut cs = ContentStream::new();
232        cs.circle(100.0, 100.0, 50.0);
233        let s = String::from_utf8(cs.to_bytes()).unwrap();
234        assert!(s.contains(" c"), "missing bezier");
235        assert!(s.contains("h"),  "missing close");
236    }
237
238    // ── Document round-trip ───────────────────────────────────────────────────
239
240    #[test]
241    fn test_document_builds_valid_pdf_signature() {
242        let pdf = Document::new().page(|_p| {}).build();
243        assert!(pdf.starts_with(b"%PDF-1.7"), "missing PDF header");
244        assert!(pdf.ends_with(b"%%EOF\n"),    "missing EOF marker");
245    }
246
247    #[test]
248    fn test_document_contains_xref() {
249        let pdf = Document::new().page(|_p| {}).build();
250        let s = String::from_utf8_lossy(&pdf);
251        assert!(s.contains("xref"),       "missing xref");
252        assert!(s.contains("startxref"),  "missing startxref");
253        assert!(s.contains("trailer"),    "missing trailer");
254    }
255
256    #[test]
257    fn test_document_multipage() {
258        let pdf = Document::new()
259            .page(|_| {})
260            .page(|_| {})
261            .page(|_| {})
262            .build();
263        let doc = ParsedDocument::parse(pdf).expect("parse failed");
264        assert_eq!(doc.page_refs().unwrap().len(), 3);
265    }
266
267    #[test]
268    fn test_document_with_text_parses_back() {
269        let pdf = Document::new()
270            .title("Test")
271            .author("Tester")
272            .page(|p| {
273                let f = p.use_helvetica();
274                let reg = p.reg_key(&f);
275                p.text("Hello World", 50.0, 700.0, &reg, 12.0, Color::BLACK);
276            })
277            .build();
278        let doc = ParsedDocument::parse(pdf).expect("parse failed");
279        assert_eq!(doc.page_refs().unwrap().len(), 1);
280    }
281
282    #[test]
283    fn test_document_metadata_in_info() {
284        let pdf = Document::new()
285            .title("My Doc")
286            .author("Someone")
287            .subject("Testing")
288            .page(|_| {})
289            .build();
290        let s = String::from_utf8_lossy(&pdf);
291        // Info dict is written as a PDF literal string
292        assert!(s.contains("My Doc"),  "missing title");
293        assert!(s.contains("Someone"), "missing author");
294    }
295
296    // ── Parser ────────────────────────────────────────────────────────────────
297
298    #[test]
299    fn test_parser_rejects_bad_signature() {
300        let bad = b"NOT A PDF AT ALL".to_vec();
301        assert!(ParsedDocument::parse(bad).is_err());
302    }
303
304    #[test]
305    fn test_parser_page_count_matches_built() {
306        for n in 1..=5 {
307            let mut doc = Document::new();
308            for _ in 0..n { doc = doc.page(|_| {}); }
309            let pdf = doc.build();
310            let parsed = ParsedDocument::parse(pdf).unwrap();
311            assert_eq!(parsed.page_refs().unwrap().len(), n,
312                "page count mismatch for n={}", n);
313        }
314    }
315
316    #[test]
317    fn test_parser_root_ref_exists() {
318        let pdf = Document::new().page(|_| {}).build();
319        let doc = ParsedDocument::parse(pdf).unwrap();
320        assert!(doc.root_ref().is_some(), "root ref should be Some");
321    }
322
323    // ── Table ─────────────────────────────────────────────────────────────────
324
325    #[test]
326    fn test_table_total_width() {
327        let t = Table::new(vec![100.0, 200.0, 50.0]);
328        assert!((t.total_width() - 350.0).abs() < 0.001);
329    }
330
331    #[test]
332    fn test_table_row_count() {
333        let mut t = Table::new(vec![100.0, 100.0]);
334        t.add_row(TableRow::header(vec![
335            TableCell::new("A"), TableCell::new("B"),
336        ]));
337        t.add_row(TableRow::new(vec![
338            TableCell::new("1"), TableCell::new("2"),
339        ]));
340        assert_eq!(t.rows.len(), 2);
341    }
342
343    #[test]
344    fn test_table_renders_into_document() {
345        let pdf = Document::new().page(|p| {
346            let _f = p.use_helvetica();
347            let mut t = Table::new(vec![200.0, 200.0]);
348            t.add_row(TableRow::header(vec![TableCell::new("H1"), TableCell::new("H2")]));
349            t.add_row(TableRow::new(vec![TableCell::new("A"), TableCell::new("B")]));
350            p.table(&t, 50.0, 750.0);
351        }).build();
352        assert!(pdf.starts_with(b"%PDF-1.7"));
353    }
354
355    // ── Image ─────────────────────────────────────────────────────────────────
356
357    #[test]
358    fn test_image_from_rgb_dimensions() {
359        let data = vec![255u8; 10 * 10 * 3];
360        let img = PdfImage::from_rgb(10, 10, data);
361        assert_eq!(img.width,  10);
362        assert_eq!(img.height, 10);
363    }
364
365    #[test]
366    fn test_image_xobject_stream_has_required_keys() {
367        let img = PdfImage::from_rgb(4, 4, vec![0u8; 4 * 4 * 3]);
368        let stream = img.to_xobject_stream();
369        assert!(stream.dict.get("Width").is_some());
370        assert!(stream.dict.get("Height").is_some());
371        assert!(stream.dict.get("ColorSpace").is_some());
372        assert!(stream.dict.get("BitsPerComponent").is_some());
373    }
374
375    #[test]
376    fn test_jpeg_header_parser_rejects_bad_data() {
377        let result = PdfImage::parse_jpeg_header(b"not a jpeg");
378        assert!(result.is_err());
379    }
380
381    // ── Outline ───────────────────────────────────────────────────────────────
382
383    #[test]
384    fn test_outline_in_document_builds_ok() {
385        let ol = Outline::new()
386            .add(OutlineItem::new("Chapter 1", Destination::Page(0)))
387            .add(OutlineItem::new("Chapter 2", Destination::Page(1)).bold());
388        let pdf = Document::new()
389            .outline(ol)
390            .page(|_| {})
391            .page(|_| {})
392            .build();
393        assert!(pdf.starts_with(b"%PDF-1.7"));
394        let s = String::from_utf8_lossy(&pdf);
395        assert!(s.contains("Outlines"), "catalog missing /Outlines");
396        assert!(s.contains("Chapter 1"), "missing outline title");
397    }
398
399    #[test]
400    fn test_empty_outline_skipped() {
401        let pdf = Document::new()
402            .outline(Outline::new()) // empty
403            .page(|_| {})
404            .build();
405        let s = String::from_utf8_lossy(&pdf);
406        // Empty outline should not add /Outlines to catalog
407        assert!(!s.contains("Outlines"), "empty outline should not appear in catalog");
408    }
409
410    // ── AcroForm ─────────────────────────────────────────────────────────────
411
412    #[test]
413    fn test_form_text_field_in_document() {
414        let form = AcroForm::new()
415            .add(FormField::text("name", FieldRect::new(50.0, 700.0, 200.0, 20.0), 0)
416                .value("Samuel")
417                .tooltip("Your name"));
418        let pdf = Document::new()
419            .form(form)
420            .page(|_| {})
421            .build();
422        assert!(pdf.starts_with(b"%PDF-1.7"));
423        let s = String::from_utf8_lossy(&pdf);
424        assert!(s.contains("AcroForm"), "missing /AcroForm in catalog");
425        assert!(s.contains("Widget"),   "missing Widget annotation");
426    }
427
428    #[test]
429    fn test_form_checkbox() {
430        let form = AcroForm::new()
431            .add(FormField::checkbox("agree", FieldRect::new(50.0, 650.0, 15.0, 15.0), 0, true));
432        let pdf = Document::new().form(form).page(|_| {}).build();
433        let s = String::from_utf8_lossy(&pdf);
434        assert!(s.contains("Btn"), "missing Btn field type");
435    }
436
437    #[test]
438    fn test_form_dropdown() {
439        let opts = vec!["Kenya".into(), "Japan".into(), "Sweden".into()];
440        let form = AcroForm::new()
441            .add(FormField::dropdown("country", opts, FieldRect::new(50.0, 600.0, 150.0, 20.0), 0));
442        let pdf = Document::new().form(form).page(|_| {}).build();
443        let s = String::from_utf8_lossy(&pdf);
444        assert!(s.contains("Kenya"),  "missing dropdown option");
445        assert!(s.contains("Sweden"), "missing dropdown option");
446    }
447
448    // ── TextFlow ─────────────────────────────────────────────────────────────
449
450    #[test]
451    fn test_textflow_single_page_short_content() {
452        let flow = TextFlow::new(FlowStyle::default())
453            .heading("Title", 1)
454            .paragraph("Short paragraph.");
455        let pages = flow.render(595.0, 842.0);
456        assert!(!pages.is_empty(), "should produce at least one page");
457    }
458
459    #[test]
460    fn test_textflow_many_paragraphs_creates_multiple_pages() {
461        let mut flow = TextFlow::new(FlowStyle::default());
462        for i in 0..50 {
463            flow = flow.paragraph(format!(
464                "Paragraph {} with enough text to take up vertical space on the page \
465                 and eventually force a page break when enough of them accumulate.", i
466            ));
467        }
468        let pages = flow.render(595.0, 842.0);
469        assert!(pages.len() >= 2, "50 paragraphs should span multiple pages, got {}", pages.len());
470    }
471
472    #[test]
473    fn test_textflow_integrates_with_document() {
474        let flow = TextFlow::new(FlowStyle::default())
475            .heading("Hello", 1)
476            .paragraph("World");
477        let pages = flow.render(595.276, 841.890);
478        let pdf = Document::new().add_pages(pages).build();
479        assert!(pdf.starts_with(b"%PDF-1.7"));
480    }
481
482    #[test]
483    fn test_textflow_code_block() {
484        let flow = TextFlow::new(FlowStyle::default())
485            .code("fn main() {\n    println!(\"hello\");\n}");
486        let pages = flow.render(595.0, 842.0);
487        assert_eq!(pages.len(), 1);
488    }
489
490    #[test]
491    fn test_textflow_bullets() {
492        let flow = TextFlow::new(FlowStyle::default())
493            .bullets(vec!["Item one", "Item two", "Item three"]);
494        let pages = flow.render(595.0, 842.0);
495        assert!(!pages.is_empty());
496    }
497
498    // ── PdfStream compression ─────────────────────────────────────────────────
499
500    #[test]
501    fn test_stream_compressed_has_flatedecode() {
502        use crate::object::PdfStream;
503        let s = PdfStream::new_compressed(b"hello world".to_vec());
504        match s.dict.get("Filter") {
505            Some(PdfObject::Name(n)) => assert_eq!(n, "FlateDecode"),
506            other => panic!("expected FlateDecode, got {:?}", other),
507        }
508    }
509
510    #[test]
511    fn test_stream_compressed_is_smaller_for_repetitive_data() {
512        use crate::object::PdfStream;
513        let data = vec![0xAAu8; 1000];
514        let raw  = PdfStream::new(data.clone());
515        let comp = PdfStream::new_compressed(data);
516        assert!(comp.data.len() < raw.data.len(),
517            "compressed ({}) should be smaller than raw ({})",
518            comp.data.len(), raw.data.len());
519    }
520}