1pub mod gradient;
6pub mod outline;
7pub mod page;
8pub mod types;
9
10pub use types::{
11 LinkAnnotation, LinkDestination, PdfDocument, PdfExtGState, PdfGradient, PdfInfo, PdfObject,
12 PdfOutline, PdfOutlineItem, PdfPage, PdfValue,
13};
14
15use fop_types::{FopError, Gradient, Result};
16
17use crate::pdf::compliance::{
18 generate_xmp_metadata, reconcile_xmp, PdfCompliance, SRGB_ICC_PROFILE,
19};
20use crate::pdf::image::ImageXObject;
21use crate::pdf::security::EncryptionDict;
22
23use gradient::write_gradient_objects;
24use outline::{count_outline_objects, write_outline_objects};
25
26impl PdfDocument {
27 pub fn new() -> Self {
29 Self {
30 version: "1.4".to_string(),
31 objects: Vec::new(),
32 pages: Vec::new(),
33 info: PdfInfo::default(),
34 image_xobjects: Vec::new(),
35 gradients: Vec::new(),
36 ext_g_states: Vec::new(),
37 outline: None,
38 font_manager: crate::pdf::font::FontManager::new(),
39 encryption: None,
40 file_id: None,
41 compliance: PdfCompliance::Standard,
42 xmp_metadata: None,
43 }
44 }
45
46 pub fn set_xmp_metadata(&mut self, xmp: String) {
51 self.xmp_metadata = Some(xmp);
52 }
53
54 pub fn set_compliance(&mut self, compliance: PdfCompliance) -> Result<()> {
60 if compliance.requires_pdfa() && self.encryption.is_some() {
61 return Err(FopError::Generic(
62 "PDF/A-1b compliance is incompatible with encryption (ISO 19005-1 §6.1.1)"
63 .to_string(),
64 ));
65 }
66 if compliance.requires_pdfa() {
68 self.version = "1.4".to_string();
69 }
70 self.compliance = compliance;
71 Ok(())
72 }
73
74 pub fn add_page(&mut self, page: PdfPage) {
76 self.pages.push(page);
77 }
78
79 pub fn add_image_xobject(&mut self, xobject: ImageXObject) -> usize {
81 self.image_xobjects.push(xobject);
82 self.image_xobjects.len() - 1
83 }
84
85 pub fn add_gradient(&mut self, gradient: Gradient) -> usize {
90 self.gradients.push(PdfGradient {
91 gradient,
92 object_id: 0, });
94 self.gradients.len() - 1
95 }
96
97 pub fn add_ext_g_state(&mut self, fill_opacity: f64, stroke_opacity: f64) -> usize {
106 for (idx, gs) in self.ext_g_states.iter().enumerate() {
108 if (gs.fill_opacity - fill_opacity).abs() < f64::EPSILON
109 && (gs.stroke_opacity - stroke_opacity).abs() < f64::EPSILON
110 {
111 return idx;
112 }
113 }
114
115 self.ext_g_states.push(PdfExtGState {
117 fill_opacity,
118 stroke_opacity,
119 object_id: 0, });
121 self.ext_g_states.len() - 1
122 }
123
124 pub fn set_outline(&mut self, outline: PdfOutline) {
126 self.outline = Some(outline);
127 }
128
129 pub fn set_encryption(&mut self, encryption: EncryptionDict, file_id: Vec<u8>) -> Result<()> {
138 if self.compliance.requires_pdfa() {
139 return Err(FopError::Generic(
140 "PDF/A-1b compliance is incompatible with encryption (ISO 19005-1 §6.1.1)"
141 .to_string(),
142 ));
143 }
144 self.encryption = Some(encryption);
145 self.file_id = Some(file_id);
146 Ok(())
147 }
148
149 fn encrypt_stream(&self, data: &[u8], obj_num: u32) -> Vec<u8> {
151 if let Some(ref enc) = self.encryption {
152 enc.encrypt_data(data, obj_num, 0)
153 } else {
154 data.to_vec()
155 }
156 }
157
158 pub fn embed_font(&mut self, font_data: Vec<u8>) -> Result<usize> {
166 self.font_manager.embed_font(font_data)
167 }
168
169 pub fn to_bytes(&self) -> Result<Vec<u8>> {
171 let mut bytes = Vec::new();
172 let mut xref_offsets = Vec::new();
173
174 bytes.extend_from_slice(format!("%PDF-{}\n", self.version).as_bytes());
176 bytes.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n"); xref_offsets.push(0);
180
181 let outline_obj_count = if let Some(ref outline) = self.outline {
183 count_outline_objects(outline)
184 } else {
185 0
186 };
187
188 let encrypt_obj_count = if self.encryption.is_some() { 1 } else { 0 };
190
191 let font_obj_id = 3;
198 let first_outline_obj_id = 4;
199 let num_embedded_fonts = self.font_manager.font_count();
200 let encrypt_obj_id = first_outline_obj_id + outline_obj_count;
202
203 let compliance_base_id = encrypt_obj_id + encrypt_obj_count;
205 let needs_compliance = self.compliance != PdfCompliance::Standard;
206 let needs_xmp = needs_compliance || self.xmp_metadata.is_some();
209 let xmp_obj_count = if needs_xmp { 1 } else { 0 };
210 let xmp_obj_id = compliance_base_id; let oi_obj_count = if self.compliance.requires_pdfa() {
212 2
213 } else {
214 0
215 };
216 let output_intent_obj_id = compliance_base_id + xmp_obj_count; let icc_profile_obj_id = output_intent_obj_id + 1; let struct_tree_obj_count = if self.compliance.requires_pdfua() {
219 1
220 } else {
221 0
222 };
223 let struct_tree_obj_id = compliance_base_id + xmp_obj_count + oi_obj_count; let total_compliance_obj_count = xmp_obj_count + oi_obj_count + struct_tree_obj_count;
225
226 let first_embedded_font_obj_id = compliance_base_id + total_compliance_obj_count;
227
228 xref_offsets.push(bytes.len());
230 bytes.extend_from_slice(b"1 0 obj\n");
231 bytes.extend_from_slice(b"<<\n");
232 bytes.extend_from_slice(b"/Type /Catalog\n");
233 bytes.extend_from_slice(b"/Pages 2 0 R\n");
234
235 if self.outline.is_some() {
237 bytes.extend_from_slice(b"/Outlines 4 0 R\n");
238 }
239
240 if needs_xmp {
242 bytes.extend_from_slice(format!("/Metadata {} 0 R\n", xmp_obj_id).as_bytes());
243 }
244
245 if self.compliance.requires_pdfa() {
246 bytes.extend_from_slice(
247 format!("/OutputIntents [{} 0 R]\n", output_intent_obj_id).as_bytes(),
248 );
249 }
250 if self.compliance.requires_pdfua() {
251 bytes.extend_from_slice(b"/MarkInfo <<\n/Marked true\n>>\n");
252 let lang = self.info.lang.as_deref().unwrap_or("en-US");
253 bytes.extend_from_slice(format!("/Lang ({})\n", lang).as_bytes());
254 bytes.extend_from_slice(
255 format!("/StructTreeRoot {} 0 R\n", struct_tree_obj_id).as_bytes(),
256 );
257 bytes.extend_from_slice(b"/ViewerPreferences <<\n/DisplayDocTitle true\n>>\n");
258 } else if let Some(ref lang) = self.info.lang {
259 bytes.extend_from_slice(format!("/Lang ({})\n", lang).as_bytes());
261 }
262
263 bytes.extend_from_slice(b">>\n");
264 bytes.extend_from_slice(b"endobj\n");
265 let first_image_obj_id = first_embedded_font_obj_id + num_embedded_fonts * 6; let num_images = self.image_xobjects.len();
267 let first_gradient_obj_id = first_image_obj_id + num_images;
268 let num_gradients = self.gradients.len();
269 let first_ext_g_state_obj_id = first_gradient_obj_id + num_gradients * 2; let num_ext_g_states = self.ext_g_states.len();
271 let first_page_obj_id = first_ext_g_state_obj_id + num_ext_g_states;
272
273 #[allow(unused_variables)]
275 let total_annotations: usize = self.pages.iter().map(|p| p.link_annotations.len()).sum();
276 let first_annotation_obj_id = first_page_obj_id + self.pages.len() * 2;
277
278 xref_offsets.push(bytes.len());
280 bytes.extend_from_slice(b"2 0 obj\n");
281 bytes.extend_from_slice(b"<<\n");
282 bytes.extend_from_slice(b"/Type /Pages\n");
283
284 let page_obj_ids: Vec<usize> = (0..self.pages.len())
286 .map(|i| first_page_obj_id + i * 2)
287 .collect();
288
289 bytes.extend_from_slice(b"/Kids [");
290 for page_id in &page_obj_ids {
291 bytes.extend_from_slice(format!("{} 0 R ", page_id).as_bytes());
292 }
293 bytes.extend_from_slice(b"]\n");
294 bytes.extend_from_slice(format!("/Count {}\n", self.pages.len()).as_bytes());
295 bytes.extend_from_slice(b">>\n");
296 bytes.extend_from_slice(b"endobj\n");
297
298 xref_offsets.push(bytes.len());
300 bytes.extend_from_slice(format!("{} 0 obj\n", font_obj_id).as_bytes());
301 bytes.extend_from_slice(b"<<\n");
302 bytes.extend_from_slice(b"/Type /Font\n");
303 bytes.extend_from_slice(b"/Subtype /Type1\n");
304 bytes.extend_from_slice(b"/BaseFont /Helvetica\n");
305 bytes.extend_from_slice(b">>\n");
306 bytes.extend_from_slice(b"endobj\n");
307
308 if let Some(ref outline) = self.outline {
310 write_outline_objects(
311 outline,
312 &mut bytes,
313 &mut xref_offsets,
314 first_outline_obj_id,
315 &page_obj_ids,
316 );
317 }
318
319 if let Some(ref enc) = self.encryption {
321 xref_offsets.push(bytes.len());
322 let enc_dict_str = enc.to_pdf_dict(encrypt_obj_id);
323 bytes.extend_from_slice(enc_dict_str.as_bytes());
324 }
325
326 if needs_xmp {
328 let xmp_content = if let Some(ref raw_xmp) = self.xmp_metadata {
329 reconcile_xmp(raw_xmp, self.compliance)
331 } else {
332 let title_ref = self.info.title.as_deref();
334 let creator_tool = format!("fop-rs {}", env!("CARGO_PKG_VERSION"));
335 generate_xmp_metadata(title_ref, &creator_tool, self.compliance)
336 };
337 let xmp_bytes = xmp_content.as_bytes();
338 xref_offsets.push(bytes.len());
339 bytes.extend_from_slice(format!("{} 0 obj\n", xmp_obj_id).as_bytes());
340 bytes.extend_from_slice(b"<<\n");
341 bytes.extend_from_slice(b"/Type /Metadata\n");
342 bytes.extend_from_slice(b"/Subtype /XML\n");
343 bytes.extend_from_slice(format!("/Length {}\n", xmp_bytes.len()).as_bytes());
344 bytes.extend_from_slice(b">>\nstream\n");
345 bytes.extend_from_slice(xmp_bytes);
346 bytes.extend_from_slice(b"\nendstream\nendobj\n");
347 }
348
349 if self.compliance.requires_pdfa() {
350 xref_offsets.push(bytes.len());
352 bytes.extend_from_slice(format!("{} 0 obj\n", output_intent_obj_id).as_bytes());
353 bytes.extend_from_slice(b"<<\n");
354 bytes.extend_from_slice(b"/Type /OutputIntent\n");
355 bytes.extend_from_slice(b"/S /GTS_PDFA1\n");
356 bytes.extend_from_slice(b"/OutputConditionIdentifier (sRGB)\n");
357 bytes.extend_from_slice(b"/RegistryName (http://www.color.org)\n");
358 bytes.extend_from_slice(
359 format!("/DestOutputProfile {} 0 R\n", icc_profile_obj_id).as_bytes(),
360 );
361 bytes.extend_from_slice(b">>\nendobj\n");
362
363 let icc_data = SRGB_ICC_PROFILE;
365 xref_offsets.push(bytes.len());
366 bytes.extend_from_slice(format!("{} 0 obj\n", icc_profile_obj_id).as_bytes());
367 bytes.extend_from_slice(b"<<\n");
368 bytes.extend_from_slice(b"/N 3\n"); bytes.extend_from_slice(format!("/Length {}\n", icc_data.len()).as_bytes());
370 bytes.extend_from_slice(b">>\nstream\n");
371 bytes.extend_from_slice(icc_data);
372 bytes.extend_from_slice(b"\nendstream\nendobj\n");
373 }
374
375 if self.compliance.requires_pdfua() {
376 xref_offsets.push(bytes.len());
378 bytes.extend_from_slice(format!("{} 0 obj\n", struct_tree_obj_id).as_bytes());
379 bytes.extend_from_slice(b"<<\n");
380 bytes.extend_from_slice(b"/Type /StructTreeRoot\n");
381 bytes.extend_from_slice(b">>\nendobj\n");
382 }
383
384 if num_embedded_fonts > 0 {
386 use crate::pdf::font::{
387 generate_cidfont_dict, generate_font_descriptor, generate_font_dictionary,
388 generate_font_stream_header, generate_to_unicode_cmap,
389 };
390
391 let font_objects = self
392 .font_manager
393 .generate_font_objects(first_embedded_font_obj_id)?;
394
395 for (
396 font_idx,
397 (
398 descriptor_id,
399 stream_id,
400 cidfont_id,
401 type0_dict_id,
402 to_unicode_id,
403 cidtogidmap_id,
404 font,
405 ),
406 ) in font_objects.iter().enumerate()
407 {
408 xref_offsets.push(bytes.len());
410 bytes.extend_from_slice(format!("{} 0 obj\n", descriptor_id).as_bytes());
411 bytes.extend_from_slice(generate_font_descriptor(font, *stream_id).as_bytes());
412 bytes.extend_from_slice(b"\nendobj\n");
413
414 xref_offsets.push(bytes.len());
416 bytes.extend_from_slice(format!("{} 0 obj\n", stream_id).as_bytes());
417 bytes.extend_from_slice(generate_font_stream_header(font).as_bytes());
418 bytes.extend_from_slice(b"\nstream\n");
419 bytes.extend_from_slice(&font.font_data);
420 bytes.extend_from_slice(b"\nendstream\n");
421 bytes.extend_from_slice(b"endobj\n");
422
423 xref_offsets.push(bytes.len());
425 bytes.extend_from_slice(format!("{} 0 obj\n", cidfont_id).as_bytes());
426 bytes.extend_from_slice(
427 generate_cidfont_dict(font, *descriptor_id, *cidtogidmap_id).as_bytes(),
428 );
429 bytes.extend_from_slice(b"\nendobj\n");
430
431 xref_offsets.push(bytes.len());
433 bytes.extend_from_slice(format!("{} 0 obj\n", type0_dict_id).as_bytes());
434 bytes.extend_from_slice(
435 generate_font_dictionary(font, *cidfont_id, Some(*to_unicode_id)).as_bytes(),
436 );
437 bytes.extend_from_slice(b"\nendobj\n");
438
439 let cmap_content = generate_to_unicode_cmap(font);
441 xref_offsets.push(bytes.len());
442 bytes.extend_from_slice(format!("{} 0 obj\n", to_unicode_id).as_bytes());
443 bytes.extend_from_slice(b"<<\n/Length ");
444 bytes.extend_from_slice(cmap_content.len().to_string().as_bytes());
445 bytes.extend_from_slice(b"\n>>\nstream\n");
446 bytes.extend_from_slice(cmap_content.as_bytes());
447 bytes.extend_from_slice(b"\nendstream\nendobj\n");
448
449 let used_chars = if let Some(subsetter) = self.font_manager.get_subsetter(font_idx)
452 {
453 subsetter.used_chars()
454 } else {
455 &std::collections::BTreeSet::new()
456 };
457
458 let cidtogidmap_data = crate::pdf::cidfont::generate_cidtogidmap_stream(
459 &font.char_to_glyph,
460 used_chars,
461 );
462
463 xref_offsets.push(bytes.len());
464 bytes.extend_from_slice(format!("{} 0 obj\n", cidtogidmap_id).as_bytes());
465 bytes.extend_from_slice(b"<<\n/Length ");
466 bytes.extend_from_slice(cidtogidmap_data.len().to_string().as_bytes());
467 bytes.extend_from_slice(b"\n>>\nstream\n");
468 bytes.extend_from_slice(&cidtogidmap_data);
469 bytes.extend_from_slice(b"\nendstream\nendobj\n");
470 }
471 }
472
473 for (img_idx, xobject) in self.image_xobjects.iter().enumerate() {
475 let obj_id = first_image_obj_id + img_idx;
476 xref_offsets.push(bytes.len());
477
478 let stream_header = xobject.to_pdf_stream(obj_id as u32);
480 bytes.extend_from_slice(stream_header.as_bytes());
481
482 bytes.extend_from_slice(xobject.stream_data());
484
485 bytes.extend_from_slice(ImageXObject::stream_end().as_bytes());
487 }
488
489 for (grad_idx, pdf_gradient) in self.gradients.iter().enumerate() {
491 let function_obj_id = first_gradient_obj_id + grad_idx * 2;
492 let shading_obj_id = function_obj_id + 1;
493
494 write_gradient_objects(
496 &pdf_gradient.gradient,
497 function_obj_id,
498 shading_obj_id,
499 &mut bytes,
500 &mut xref_offsets,
501 );
502 }
503
504 for (gs_idx, ext_g_state) in self.ext_g_states.iter().enumerate() {
506 let obj_id = first_ext_g_state_obj_id + gs_idx;
507 xref_offsets.push(bytes.len());
508 bytes.extend_from_slice(format!("{} 0 obj\n", obj_id).as_bytes());
509 bytes.extend_from_slice(b"<<\n");
510 bytes.extend_from_slice(b"/Type /ExtGState\n");
511 bytes.extend_from_slice(format!("/ca {:.3}\n", ext_g_state.fill_opacity).as_bytes());
512 bytes.extend_from_slice(format!("/CA {:.3}\n", ext_g_state.stroke_opacity).as_bytes());
513 bytes.extend_from_slice(b">>\n");
514 bytes.extend_from_slice(b"endobj\n");
515 }
516
517 let mut current_annotation_obj_id = first_annotation_obj_id;
519 for (page_idx, page) in self.pages.iter().enumerate() {
520 let page_obj_id = first_page_obj_id + page_idx * 2;
521 let content_obj_id = page_obj_id + 1;
522
523 xref_offsets.push(bytes.len());
525 bytes.extend_from_slice(format!("{} 0 obj\n", page_obj_id).as_bytes());
526 bytes.extend_from_slice(b"<<\n");
527 bytes.extend_from_slice(b"/Type /Page\n");
528 bytes.extend_from_slice(b"/Parent 2 0 R\n");
529 bytes.extend_from_slice(
530 format!(
531 "/MediaBox [0 0 {} {}]\n",
532 page.width.to_pt(),
533 page.height.to_pt()
534 )
535 .as_bytes(),
536 );
537 bytes.extend_from_slice(b"/Resources <<\n");
538
539 bytes.extend_from_slice(b" /Font <<\n");
541 bytes.extend_from_slice(format!(" /F1 {} 0 R\n", font_obj_id).as_bytes());
542
543 if num_embedded_fonts > 0 {
545 for font_idx in 0..num_embedded_fonts {
546 let type0_dict_obj_id = first_embedded_font_obj_id + font_idx * 6 + 3; bytes.extend_from_slice(
548 format!(" /F{} {} 0 R\n", font_idx + 2, type0_dict_obj_id).as_bytes(),
549 );
550 }
551 }
552 bytes.extend_from_slice(b" >>\n");
553
554 if !self.image_xobjects.is_empty() {
556 bytes.extend_from_slice(b" /XObject <<\n");
557 for img_idx in 0..self.image_xobjects.len() {
558 let obj_id = first_image_obj_id + img_idx;
559 bytes.extend_from_slice(
560 format!(" /Im{} {} 0 R\n", img_idx, obj_id).as_bytes(),
561 );
562 }
563 bytes.extend_from_slice(b" >>\n");
564 }
565
566 if !self.gradients.is_empty() {
568 bytes.extend_from_slice(b" /Shading <<\n");
569 for grad_idx in 0..self.gradients.len() {
570 let shading_obj_id = first_gradient_obj_id + grad_idx * 2 + 1; bytes.extend_from_slice(
572 format!(" /Sh{} {} 0 R\n", grad_idx, shading_obj_id).as_bytes(),
573 );
574 }
575 bytes.extend_from_slice(b" >>\n");
576 }
577
578 if !self.ext_g_states.is_empty() {
580 bytes.extend_from_slice(b" /ExtGState <<\n");
581 for gs_idx in 0..self.ext_g_states.len() {
582 let gs_obj_id = first_ext_g_state_obj_id + gs_idx;
583 bytes.extend_from_slice(
584 format!(" /GS{} {} 0 R\n", gs_idx, gs_obj_id).as_bytes(),
585 );
586 }
587 bytes.extend_from_slice(b" >>\n");
588 }
589
590 bytes.extend_from_slice(b">>\n");
591 bytes.extend_from_slice(format!("/Contents {} 0 R\n", content_obj_id).as_bytes());
592
593 if !page.link_annotations.is_empty() {
595 bytes.extend_from_slice(b"/Annots [");
596 for annot_idx in 0..page.link_annotations.len() {
597 bytes.extend_from_slice(
598 format!("{} 0 R ", current_annotation_obj_id + annot_idx).as_bytes(),
599 );
600 }
601 bytes.extend_from_slice(b"]\n");
602 current_annotation_obj_id += page.link_annotations.len();
603 }
604
605 bytes.extend_from_slice(b">>\n");
606 bytes.extend_from_slice(b"endobj\n");
607
608 let stream_data = self.encrypt_stream(&page.content, content_obj_id as u32);
610 xref_offsets.push(bytes.len());
611 bytes.extend_from_slice(format!("{} 0 obj\n", content_obj_id).as_bytes());
612 bytes.extend_from_slice(b"<<\n");
613 bytes.extend_from_slice(format!("/Length {}\n", stream_data.len()).as_bytes());
614 bytes.extend_from_slice(b">>\n");
615 bytes.extend_from_slice(b"stream\n");
616 bytes.extend_from_slice(&stream_data);
617 bytes.extend_from_slice(b"\nendstream\n");
618 bytes.extend_from_slice(b"endobj\n");
619 }
620
621 if total_annotations > 0 {
623 let mut annot_obj_id = first_annotation_obj_id;
624 for (page_idx, page) in self.pages.iter().enumerate() {
625 let page_obj_id = first_page_obj_id + page_idx * 2;
626
627 for annot in &page.link_annotations {
628 xref_offsets.push(bytes.len());
629 bytes.extend_from_slice(format!("{} 0 obj\n", annot_obj_id).as_bytes());
630 bytes.extend_from_slice(b"<<\n");
631 bytes.extend_from_slice(b"/Type /Annot\n");
632 bytes.extend_from_slice(b"/Subtype /Link\n");
633 bytes.extend_from_slice(
634 format!(
635 "/Rect [{:.2} {:.2} {:.2} {:.2}]\n",
636 annot.rect[0], annot.rect[1], annot.rect[2], annot.rect[3]
637 )
638 .as_bytes(),
639 );
640 bytes.extend_from_slice(format!("/P {} 0 R\n", page_obj_id).as_bytes());
641 bytes.extend_from_slice(b"/Border [0 0 0]\n"); match &annot.destination {
645 LinkDestination::External(url) => {
646 bytes.extend_from_slice(b"/A <<\n");
647 bytes.extend_from_slice(b" /S /URI\n");
648 bytes.extend_from_slice(
649 format!(" /URI ({})\n", outline::escape_pdf_string(url))
650 .as_bytes(),
651 );
652 bytes.extend_from_slice(b">>\n");
653 }
654 LinkDestination::Internal(dest_id) => {
655 bytes.extend_from_slice(
658 format!("/Dest ({})\n", outline::escape_pdf_string(dest_id))
659 .as_bytes(),
660 );
661 }
662 }
663
664 bytes.extend_from_slice(b">>\n");
665 bytes.extend_from_slice(b"endobj\n");
666
667 annot_obj_id += 1;
668 }
669 }
670 }
671
672 let xref_offset = bytes.len();
674 bytes.extend_from_slice(b"xref\n");
675 bytes.extend_from_slice(format!("0 {}\n", xref_offsets.len()).as_bytes());
676 bytes.extend_from_slice(b"0000000000 65535 f \n"); for offset in xref_offsets.iter().skip(1) {
678 bytes.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
679 }
680
681 bytes.extend_from_slice(b"trailer\n");
683 bytes.extend_from_slice(b"<<\n");
684 bytes.extend_from_slice(format!("/Size {}\n", xref_offsets.len()).as_bytes());
685 bytes.extend_from_slice(b"/Root 1 0 R\n");
686
687 if self.encryption.is_some() {
689 bytes.extend_from_slice(format!("/Encrypt {} 0 R\n", encrypt_obj_id).as_bytes());
690 }
691
692 if let Some(ref file_id) = self.file_id {
694 let hex = file_id
695 .iter()
696 .map(|b| format!("{:02X}", b))
697 .collect::<String>();
698 bytes.extend_from_slice(format!("/ID [<{}> <{}>]\n", hex, hex).as_bytes());
699 }
700
701 if self.info.title.is_some()
703 || self.info.author.is_some()
704 || self.info.subject.is_some()
705 || self.info.creation_date.is_some()
706 {
707 bytes.extend_from_slice(b"/Info <<\n");
708
709 if let Some(ref title) = self.info.title {
710 bytes.extend_from_slice(
711 format!(" /Title ({})\n", outline::escape_pdf_string(title)).as_bytes(),
712 );
713 }
714
715 if let Some(ref author) = self.info.author {
716 bytes.extend_from_slice(
717 format!(" /Author ({})\n", outline::escape_pdf_string(author)).as_bytes(),
718 );
719 }
720
721 if let Some(ref subject) = self.info.subject {
722 bytes.extend_from_slice(
723 format!(" /Subject ({})\n", outline::escape_pdf_string(subject)).as_bytes(),
724 );
725 }
726
727 if let Some(ref creation_date) = self.info.creation_date {
728 bytes.extend_from_slice(
729 format!(
730 " /CreationDate ({})\n",
731 outline::escape_pdf_string(creation_date)
732 )
733 .as_bytes(),
734 );
735 }
736
737 bytes.extend_from_slice(b">>\n");
738 }
739
740 bytes.extend_from_slice(b">>\n");
741 bytes.extend_from_slice(b"startxref\n");
742 bytes.extend_from_slice(format!("{}\n", xref_offset).as_bytes());
743 bytes.extend_from_slice(b"%%EOF\n");
744
745 Ok(bytes)
746 }
747}
748
749impl Default for PdfDocument {
750 fn default() -> Self {
751 Self::new()
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn test_pdf_document_creation() {
761 let doc = PdfDocument::new();
762 assert_eq!(doc.version, "1.4");
763 assert_eq!(doc.pages.len(), 0);
764 }
765
766 #[test]
767 fn test_pdf_page() {
768 let mut page = PdfPage::new(
769 fop_types::Length::from_mm(210.0),
770 fop_types::Length::from_mm(297.0),
771 );
772
773 page.add_text(
774 "Hello World",
775 fop_types::Length::from_pt(100.0),
776 fop_types::Length::from_pt(700.0),
777 fop_types::Length::from_pt(12.0),
778 );
779
780 assert!(!page.content.is_empty());
781 let content_str = String::from_utf8_lossy(&page.content);
782 assert!(content_str.contains("Hello World"));
783 assert!(content_str.contains("BT")); assert!(content_str.contains("ET")); }
786
787 #[test]
788 fn test_pdf_bytes() {
789 let doc = PdfDocument::new();
790 let bytes = doc.to_bytes().expect("test: should succeed");
791
792 let header = String::from_utf8_lossy(&bytes[..8]);
793 assert!(header.starts_with("%PDF-"));
794 }
795
796 #[test]
797 fn test_pdf_encrypted_bytes() {
798 use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
799
800 let mut doc = PdfDocument::new();
801
802 let mut page = PdfPage::new(
804 fop_types::Length::from_mm(210.0),
805 fop_types::Length::from_mm(297.0),
806 );
807 page.add_text(
808 "Secret Text",
809 fop_types::Length::from_pt(100.0),
810 fop_types::Length::from_pt(700.0),
811 fop_types::Length::from_pt(12.0),
812 );
813 doc.add_page(page);
814
815 let permissions = PdfPermissions {
817 allow_print: false,
818 allow_copy: false,
819 ..Default::default()
820 };
821 let security = PdfSecurity::new("owner123", "user456", permissions);
822 let file_id = generate_file_id("test-encrypted");
823 let encryption_dict = security.compute_encryption_dict(&file_id);
824 doc.set_encryption(encryption_dict, file_id)
825 .expect("test: should succeed");
826
827 let bytes = doc.to_bytes().expect("test: should succeed");
828 let content = String::from_utf8_lossy(&bytes);
829
830 assert!(content.contains("%PDF-"));
832 assert!(content.contains("/Filter /Standard"));
833 assert!(content.contains("/V 2")); assert!(content.contains("/R 3")); assert!(content.contains("/Length 128"));
836 assert!(content.contains("/Encrypt")); assert!(content.contains("/ID [<")); assert!(!content.contains("Secret Text"));
841 }
842
843 #[test]
844 fn test_pdf_without_encryption_has_plaintext() {
845 let mut doc = PdfDocument::new();
846 let mut page = PdfPage::new(
847 fop_types::Length::from_mm(210.0),
848 fop_types::Length::from_mm(297.0),
849 );
850 page.add_text(
851 "Visible Text",
852 fop_types::Length::from_pt(100.0),
853 fop_types::Length::from_pt(700.0),
854 fop_types::Length::from_pt(12.0),
855 );
856 doc.add_page(page);
857
858 let bytes = doc.to_bytes().expect("test: should succeed");
859 let content = String::from_utf8_lossy(&bytes);
860
861 assert!(content.contains("Visible Text"));
863 assert!(!content.contains("/Encrypt"));
865 assert!(!content.contains("/Filter /Standard"));
866 }
867}
868
869#[cfg(test)]
870mod tests_extended {
871 use super::*;
872 use crate::pdf::compliance::PdfCompliance;
873 use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
874 use fop_types::Length;
875
876 #[test]
877 fn test_pdf_document_default() {
878 let doc = PdfDocument::default();
879 assert_eq!(doc.version, "1.4");
880 assert!(doc.pages.is_empty());
881 }
882
883 #[test]
884 fn test_pdf_document_add_multiple_pages() {
885 let mut doc = PdfDocument::new();
886 for _ in 0..3 {
887 let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
888 doc.add_page(page);
889 }
890 assert_eq!(doc.pages.len(), 3);
891 }
892
893 #[test]
894 fn test_pdf_page_new_has_correct_dimensions() {
895 let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
896 assert_eq!(page.width, Length::from_mm(210.0));
897 assert_eq!(page.height, Length::from_mm(297.0));
898 assert!(page.content.is_empty());
899 }
900
901 #[test]
902 fn test_pdf_page_add_text_generates_bt_et() {
903 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
904 page.add_text(
905 "Test",
906 Length::from_pt(72.0),
907 Length::from_pt(700.0),
908 Length::from_pt(12.0),
909 );
910 let content = String::from_utf8_lossy(&page.content);
911 assert!(content.contains("BT"));
912 assert!(content.contains("ET"));
913 }
914
915 #[test]
916 fn test_pdf_page_add_background() {
917 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
918 page.add_background(
919 Length::ZERO,
920 Length::ZERO,
921 Length::from_pt(595.0),
922 Length::from_pt(842.0),
923 fop_types::Color::WHITE,
924 );
925 let content = String::from_utf8_lossy(&page.content);
926 assert!(content.contains("re f"));
928 }
929
930 #[test]
931 fn test_pdf_compliance_pdfa1b_adds_version_info() {
932 let mut doc = PdfDocument::new();
933 doc.set_compliance(PdfCompliance::PdfA1b)
934 .expect("test: should succeed");
935 let bytes = doc.to_bytes().expect("test: should succeed");
936 let content = String::from_utf8_lossy(&bytes);
937 assert!(content.contains("%PDF-1.4"));
939 }
940
941 #[test]
942 fn test_pdf_document_to_bytes_starts_with_header() {
943 let doc = PdfDocument::new();
944 let bytes = doc.to_bytes().expect("test: should succeed");
945 assert!(bytes.starts_with(b"%PDF-"));
946 }
947
948 #[test]
949 fn test_pdf_document_to_bytes_ends_with_eof() {
950 let doc = PdfDocument::new();
951 let bytes = doc.to_bytes().expect("test: should succeed");
952 let content = String::from_utf8_lossy(&bytes);
953 assert!(content.contains("%%EOF"));
954 }
955
956 #[test]
957 fn test_pdf_document_aes256_encryption() {
958 let mut doc = PdfDocument::new();
959 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
960 page.add_text(
961 "Private",
962 Length::from_pt(72.0),
963 Length::from_pt(700.0),
964 Length::from_pt(12.0),
965 );
966 doc.add_page(page);
967
968 let sec = PdfSecurity::new_aes256("owner", "user", PdfPermissions::default());
969 let file_id = generate_file_id("aes-doc");
970 let dict = sec.compute_encryption_dict(&file_id);
971 doc.set_encryption(dict, file_id)
972 .expect("test: should succeed");
973
974 let bytes = doc.to_bytes().expect("test: should succeed");
975 let content = String::from_utf8_lossy(&bytes);
976 assert!(content.contains("/V 5")); assert!(content.contains("/R 6")); assert!(content.contains("/OE <")); }
980
981 #[test]
982 fn test_pdf_outline_structure() {
983 let mut doc = PdfDocument::new();
984 let outline = PdfOutline {
985 items: vec![
986 PdfOutlineItem {
987 title: "Chapter 1".to_string(),
988 page_index: Some(0),
989 external_destination: None,
990 children: vec![],
991 },
992 PdfOutlineItem {
993 title: "Chapter 2".to_string(),
994 page_index: Some(1),
995 external_destination: None,
996 children: vec![],
997 },
998 ],
999 };
1000 doc.set_outline(outline);
1001
1002 for _ in 0..2 {
1004 let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1005 doc.add_page(page);
1006 }
1007
1008 let bytes = doc.to_bytes().expect("test: should succeed");
1009 let content = String::from_utf8_lossy(&bytes);
1010 assert!(content.contains("Chapter 1"));
1011 assert!(content.contains("Chapter 2"));
1012 assert!(content.contains("/Outlines"));
1013 }
1014
1015 #[test]
1016 fn test_pdf_page_add_rule() {
1017 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1018 page.add_rule(
1019 Length::from_pt(50.0),
1020 Length::from_pt(400.0),
1021 Length::from_pt(400.0),
1022 Length::from_pt(2.0),
1023 fop_types::Color::BLACK,
1024 "solid",
1025 );
1026 let content = String::from_utf8_lossy(&page.content);
1027 assert!(!content.is_empty());
1029 }
1030}
1031
1032#[cfg(test)]
1033mod tests_document_comprehensive {
1034 use super::*;
1035 use fop_types::Length;
1036
1037 #[test]
1040 fn test_new_produces_non_empty_output() {
1041 let doc = PdfDocument::new();
1042 let bytes = doc.to_bytes().expect("test: should succeed");
1043 assert!(!bytes.is_empty());
1044 }
1045
1046 #[test]
1047 fn test_new_version_is_1_4() {
1048 let doc = PdfDocument::new();
1049 assert_eq!(doc.version, "1.4");
1050 }
1051
1052 #[test]
1053 fn test_new_has_no_pages() {
1054 let doc = PdfDocument::new();
1055 assert_eq!(doc.pages.len(), 0);
1056 }
1057
1058 #[test]
1059 fn test_new_has_no_images() {
1060 let doc = PdfDocument::new();
1061 assert_eq!(doc.image_xobjects.len(), 0);
1062 }
1063
1064 #[test]
1065 fn test_new_has_no_outline() {
1066 let doc = PdfDocument::new();
1067 assert!(doc.outline.is_none());
1068 }
1069
1070 #[test]
1073 fn test_pdf_header_starts_with_pdf_1_4() {
1074 let doc = PdfDocument::new();
1075 let bytes = doc.to_bytes().expect("test: should succeed");
1076 assert!(bytes.starts_with(b"%PDF-1.4"));
1077 }
1078
1079 #[test]
1080 fn test_pdf_header_present_in_output() {
1081 let doc = PdfDocument::new();
1082 let bytes = doc.to_bytes().expect("test: should succeed");
1083 let s = String::from_utf8_lossy(&bytes);
1084 assert!(s.contains("%PDF-"));
1085 }
1086
1087 #[test]
1090 fn test_page_count_zero_pages_in_catalog() {
1091 let doc = PdfDocument::new();
1092 let bytes = doc.to_bytes().expect("test: should succeed");
1093 let s = String::from_utf8_lossy(&bytes);
1094 assert!(s.contains("/Count 0"));
1095 }
1096
1097 #[test]
1098 fn test_page_count_one_page_in_catalog() {
1099 let mut doc = PdfDocument::new();
1100 doc.add_page(PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
1101 let bytes = doc.to_bytes().expect("test: should succeed");
1102 let s = String::from_utf8_lossy(&bytes);
1103 assert!(s.contains("/Count 1"));
1104 }
1105
1106 #[test]
1107 fn test_page_count_three_pages_in_catalog() {
1108 let mut doc = PdfDocument::new();
1109 for _ in 0..3 {
1110 doc.add_page(PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0)));
1111 }
1112 let bytes = doc.to_bytes().expect("test: should succeed");
1113 let s = String::from_utf8_lossy(&bytes);
1114 assert!(s.contains("/Count 3"));
1115 assert_eq!(doc.pages.len(), 3);
1116 }
1117
1118 #[test]
1121 fn test_info_title_appears_in_output() {
1122 let mut doc = PdfDocument::new();
1123 doc.info.title = Some("My Test Document".to_string());
1124 let bytes = doc.to_bytes().expect("test: should succeed");
1125 let s = String::from_utf8_lossy(&bytes);
1126 assert!(s.contains("/Title (My Test Document)"));
1127 }
1128
1129 #[test]
1130 fn test_info_author_appears_in_output() {
1131 let mut doc = PdfDocument::new();
1132 doc.info.author = Some("Jane Doe".to_string());
1133 let bytes = doc.to_bytes().expect("test: should succeed");
1134 let s = String::from_utf8_lossy(&bytes);
1135 assert!(s.contains("/Author (Jane Doe)"));
1136 }
1137
1138 #[test]
1139 fn test_info_subject_appears_in_output() {
1140 let mut doc = PdfDocument::new();
1141 doc.info.subject = Some("Unit Testing".to_string());
1142 let bytes = doc.to_bytes().expect("test: should succeed");
1143 let s = String::from_utf8_lossy(&bytes);
1144 assert!(s.contains("/Subject (Unit Testing)"));
1145 }
1146
1147 #[test]
1148 fn test_info_creation_date_appears_in_output() {
1149 let mut doc = PdfDocument::new();
1150 doc.info.creation_date = Some("D:20260220120000".to_string());
1151 let bytes = doc.to_bytes().expect("test: should succeed");
1152 let s = String::from_utf8_lossy(&bytes);
1153 assert!(s.contains("/CreationDate (D:20260220120000)"));
1154 }
1155
1156 #[test]
1157 fn test_info_lang_field_roundtrip() {
1158 let mut info = PdfInfo::default();
1159 assert!(info.lang.is_none());
1160 info.lang = Some("ja".to_string());
1161 assert_eq!(info.lang.as_deref(), Some("ja"));
1162 }
1163
1164 #[test]
1165 fn test_info_no_metadata_omits_info_dict() {
1166 let doc = PdfDocument::new();
1168 let bytes = doc.to_bytes().expect("test: should succeed");
1169 let s = String::from_utf8_lossy(&bytes);
1170 assert!(!s.contains("/Info <<"));
1171 }
1172
1173 #[test]
1174 fn test_info_all_fields_set() {
1175 let mut doc = PdfDocument::new();
1176 doc.info.title = Some("Full Meta".to_string());
1177 doc.info.author = Some("Author A".to_string());
1178 doc.info.subject = Some("Subject S".to_string());
1179 doc.info.creation_date = Some("D:20260101".to_string());
1180 let bytes = doc.to_bytes().expect("test: should succeed");
1181 let s = String::from_utf8_lossy(&bytes);
1182 assert!(s.contains("/Title (Full Meta)"));
1183 assert!(s.contains("/Author (Author A)"));
1184 assert!(s.contains("/Subject (Subject S)"));
1185 assert!(s.contains("/CreationDate (D:20260101)"));
1186 }
1187
1188 #[test]
1191 fn test_xref_table_present() {
1192 let doc = PdfDocument::new();
1193 let bytes = doc.to_bytes().expect("test: should succeed");
1194 let s = String::from_utf8_lossy(&bytes);
1195 assert!(s.contains("xref\n"));
1196 }
1197
1198 #[test]
1199 fn test_xref_free_object_zero() {
1200 let doc = PdfDocument::new();
1201 let bytes = doc.to_bytes().expect("test: should succeed");
1202 let s = String::from_utf8_lossy(&bytes);
1203 assert!(s.contains("0000000000 65535 f "));
1205 }
1206
1207 #[test]
1208 fn test_xref_entries_use_n_type() {
1209 let doc = PdfDocument::new();
1210 let bytes = doc.to_bytes().expect("test: should succeed");
1211 let s = String::from_utf8_lossy(&bytes);
1212 assert!(s.contains(" 00000 n "));
1214 }
1215
1216 #[test]
1219 fn test_trailer_has_root_reference() {
1220 let doc = PdfDocument::new();
1221 let bytes = doc.to_bytes().expect("test: should succeed");
1222 let s = String::from_utf8_lossy(&bytes);
1223 assert!(s.contains("/Root 1 0 R"));
1224 }
1225
1226 #[test]
1227 fn test_trailer_has_size_entry() {
1228 let doc = PdfDocument::new();
1229 let bytes = doc.to_bytes().expect("test: should succeed");
1230 let s = String::from_utf8_lossy(&bytes);
1231 assert!(s.contains("/Size "));
1233 }
1234
1235 #[test]
1238 fn test_startxref_keyword_present() {
1239 let doc = PdfDocument::new();
1240 let bytes = doc.to_bytes().expect("test: should succeed");
1241 let s = String::from_utf8_lossy(&bytes);
1242 assert!(s.contains("startxref\n"));
1243 }
1244
1245 #[test]
1246 fn test_startxref_offset_is_nonzero() {
1247 let doc = PdfDocument::new();
1248 let bytes = doc.to_bytes().expect("test: should succeed");
1249 let s = String::from_utf8_lossy(&bytes);
1250 let idx = s.find("startxref\n").expect("test: should succeed");
1252 let after = &s[idx + "startxref\n".len()..];
1253 let offset_str: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
1254 let offset: usize = offset_str.parse().expect("test: should succeed");
1255 assert!(offset > 0);
1256 }
1257
1258 #[test]
1259 fn test_eof_marker_present() {
1260 let doc = PdfDocument::new();
1261 let bytes = doc.to_bytes().expect("test: should succeed");
1262 let s = String::from_utf8_lossy(&bytes);
1263 assert!(s.contains("%%EOF"));
1264 }
1265
1266 #[test]
1269 fn test_pdfinfo_default_all_none() {
1270 let info = PdfInfo::default();
1271 assert!(info.title.is_none());
1272 assert!(info.author.is_none());
1273 assert!(info.subject.is_none());
1274 assert!(info.creation_date.is_none());
1275 assert!(info.lang.is_none());
1276 }
1277
1278 #[test]
1279 fn test_pdfinfo_clone() {
1280 let info = PdfInfo {
1281 title: Some("Clone Me".to_string()),
1282 ..Default::default()
1283 };
1284 let cloned = info.clone();
1285 assert_eq!(cloned.title.as_deref(), Some("Clone Me"));
1286 }
1287
1288 #[test]
1291 fn test_file_id_appears_in_trailer_as_id_array() {
1292 use crate::pdf::security::generate_file_id;
1293 let mut doc = PdfDocument::new();
1294 let fid = generate_file_id("id-test");
1295 doc.file_id = Some(fid);
1297 let bytes = doc.to_bytes().expect("test: should succeed");
1298 let s = String::from_utf8_lossy(&bytes);
1299 assert!(s.contains("/ID [<"));
1300 }
1301
1302 #[test]
1305 fn test_add_ext_g_state_deduplication() {
1306 let mut doc = PdfDocument::new();
1307 let idx1 = doc.add_ext_g_state(0.5, 0.5);
1308 let idx2 = doc.add_ext_g_state(0.5, 0.5);
1309 assert_eq!(idx1, idx2);
1310 assert_eq!(doc.ext_g_states.len(), 1);
1311 }
1312
1313 #[test]
1314 fn test_add_ext_g_state_different_values_creates_two() {
1315 let mut doc = PdfDocument::new();
1316 let idx1 = doc.add_ext_g_state(0.3, 0.3);
1317 let idx2 = doc.add_ext_g_state(0.7, 0.7);
1318 assert_ne!(idx1, idx2);
1319 assert_eq!(doc.ext_g_states.len(), 2);
1320 }
1321
1322 #[test]
1325 fn test_add_gradient_returns_index() {
1326 use fop_types::{Color, ColorStop, Gradient, Length, Point};
1327 let mut doc = PdfDocument::new();
1328 let gradient = Gradient::linear(
1329 Point::new(Length::from_pt(0.0), Length::from_pt(0.0)),
1330 Point::new(Length::from_pt(100.0), Length::from_pt(0.0)),
1331 vec![
1332 ColorStop::new(0.0, Color::BLACK),
1333 ColorStop::new(1.0, Color::WHITE),
1334 ],
1335 );
1336 let idx = doc.add_gradient(gradient);
1337 assert_eq!(idx, 0);
1338 assert_eq!(doc.gradients.len(), 1);
1339 }
1340
1341 #[test]
1344 fn test_catalog_type_present() {
1345 let doc = PdfDocument::new();
1346 let bytes = doc.to_bytes().expect("test: should succeed");
1347 let s = String::from_utf8_lossy(&bytes);
1348 assert!(s.contains("/Type /Catalog"));
1349 }
1350
1351 #[test]
1352 fn test_catalog_pages_reference_present() {
1353 let doc = PdfDocument::new();
1354 let bytes = doc.to_bytes().expect("test: should succeed");
1355 let s = String::from_utf8_lossy(&bytes);
1356 assert!(s.contains("/Pages 2 0 R"));
1357 }
1358
1359 #[test]
1362 fn test_pdfpage_new_empty_content() {
1363 let page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1364 assert!(page.content.is_empty());
1365 assert!(page.link_annotations.is_empty());
1366 }
1367
1368 #[test]
1369 fn test_pdfpage_add_text_with_spacing_produces_tc_tw() {
1370 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1371 page.add_text_with_spacing(
1372 "Hello",
1373 Length::from_pt(72.0),
1374 Length::from_pt(700.0),
1375 Length::from_pt(12.0),
1376 Some(Length::from_pt(1.0)),
1377 Some(Length::from_pt(2.0)),
1378 );
1379 let content = String::from_utf8_lossy(&page.content);
1380 assert!(content.contains("Tc"));
1381 assert!(content.contains("Tw"));
1382 }
1383
1384 #[test]
1385 fn test_pdfpage_add_background_generates_rg_and_re() {
1386 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1387 page.add_background(
1388 Length::from_pt(10.0),
1389 Length::from_pt(10.0),
1390 Length::from_pt(200.0),
1391 Length::from_pt(100.0),
1392 fop_types::Color::rgb(255, 0, 0),
1393 );
1394 let content = String::from_utf8_lossy(&page.content);
1395 assert!(content.contains("rg"));
1397 assert!(content.contains("re f"));
1398 }
1399
1400 #[test]
1401 fn test_pdfpage_add_link_annotation_stores_annotation() {
1402 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1403 page.add_link_annotation(
1404 Length::from_pt(50.0),
1405 Length::from_pt(700.0),
1406 Length::from_pt(100.0),
1407 Length::from_pt(12.0),
1408 LinkDestination::External("https://example.com".to_string()),
1409 );
1410 assert_eq!(page.link_annotations.len(), 1);
1411 }
1412
1413 #[test]
1414 fn test_pdfpage_link_annotation_rect_values() {
1415 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1416 page.add_link_annotation(
1417 Length::from_pt(10.0),
1418 Length::from_pt(20.0),
1419 Length::from_pt(80.0),
1420 Length::from_pt(14.0),
1421 LinkDestination::Internal("section-1".to_string()),
1422 );
1423 let ann = &page.link_annotations[0];
1424 assert!((ann.rect[0] - 10.0).abs() < 0.01);
1426 assert!((ann.rect[1] - 20.0).abs() < 0.01);
1427 assert!((ann.rect[2] - 90.0).abs() < 0.01);
1428 assert!((ann.rect[3] - 34.0).abs() < 0.01);
1429 }
1430
1431 #[test]
1432 fn test_pdfpage_multiple_texts_accumulate_in_content() {
1433 let mut page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
1434 page.add_text(
1435 "First",
1436 Length::from_pt(72.0),
1437 Length::from_pt(700.0),
1438 Length::from_pt(12.0),
1439 );
1440 page.add_text(
1441 "Second",
1442 Length::from_pt(72.0),
1443 Length::from_pt(680.0),
1444 Length::from_pt(12.0),
1445 );
1446 let content = String::from_utf8_lossy(&page.content);
1447 assert!(content.contains("First"));
1448 assert!(content.contains("Second"));
1449 }
1450
1451 #[test]
1454 fn test_pdf_value_boolean() {
1455 let v = PdfValue::Boolean(true);
1456 if let PdfValue::Boolean(b) = v {
1457 assert!(b);
1458 } else {
1459 panic!("Expected Boolean");
1460 }
1461 }
1462
1463 #[test]
1464 fn test_pdf_value_integer() {
1465 let v = PdfValue::Integer(42);
1466 if let PdfValue::Integer(n) = v {
1467 assert_eq!(n, 42);
1468 } else {
1469 panic!("Expected Integer");
1470 }
1471 }
1472
1473 #[test]
1474 #[allow(clippy::approx_constant)]
1475 fn test_pdf_value_real() {
1476 let v = PdfValue::Real(3.14);
1477 if let PdfValue::Real(f) = v {
1478 assert!((f - 3.14).abs() < f64::EPSILON);
1479 } else {
1480 panic!("Expected Real");
1481 }
1482 }
1483
1484 #[test]
1485 fn test_pdf_value_name() {
1486 let v = PdfValue::Name("Font".to_string());
1487 if let PdfValue::Name(s) = v {
1488 assert_eq!(s, "Font");
1489 } else {
1490 panic!("Expected Name");
1491 }
1492 }
1493
1494 #[test]
1495 fn test_pdf_value_null() {
1496 let v = PdfValue::Null;
1497 assert!(matches!(v, PdfValue::Null));
1498 }
1499
1500 #[test]
1503 fn test_set_compliance_pdfa_with_encryption_returns_error() {
1504 use crate::pdf::compliance::PdfCompliance;
1505 use crate::pdf::security::{generate_file_id, PdfPermissions, PdfSecurity};
1506 let mut doc = PdfDocument::new();
1507 let sec = PdfSecurity::new("owner", "user", PdfPermissions::default());
1508 let fid = generate_file_id("enc");
1509 let dict = sec.compute_encryption_dict(&fid);
1510 doc.set_encryption(dict, fid).expect("test: should succeed");
1511 let result = doc.set_compliance(PdfCompliance::PdfA1b);
1512 assert!(result.is_err());
1513 }
1514
1515 #[test]
1516 fn test_info_escapes_parentheses_in_title() {
1517 let mut doc = PdfDocument::new();
1518 doc.info.title = Some("(parenthesised)".to_string());
1519 let bytes = doc.to_bytes().expect("test: should succeed");
1520 let content = String::from_utf8_lossy(&bytes);
1521 assert!(
1523 content.contains(r"/Title (\(parenthesised\))"),
1524 "Expected PDF-escaped title with backslash-escaped parentheses; got:\n{}",
1525 content
1526 );
1527 assert!(!content.contains("/Title ((parenthesised))"));
1529 }
1530}