1pub 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 #[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 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 #[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 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 #[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 #[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 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 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 #[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, ®, 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 assert!(s.contains("My Doc"), "missing title");
293 assert!(s.contains("Someone"), "missing author");
294 }
295
296 #[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 #[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 #[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 #[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()) .page(|_| {})
404 .build();
405 let s = String::from_utf8_lossy(&pdf);
406 assert!(!s.contains("Outlines"), "empty outline should not appear in catalog");
408 }
409
410 #[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 #[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 #[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}