Skip to main content

fop_render/pdf/document/
mod.rs

1//! PDF document structure
2//!
3//! Represents the internal structure of a PDF file.
4
5pub 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    /// Create a new PDF document
28    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    /// Set a raw XMP metadata packet to embed in the PDF catalog `/Metadata` stream.
47    ///
48    /// The packet will be reconciled (wrapped in `<?xpacket ...?>` if absent and
49    /// compliance identifiers spliced in) during `to_bytes()`.
50    pub fn set_xmp_metadata(&mut self, xmp: String) {
51        self.xmp_metadata = Some(xmp);
52    }
53
54    /// Set the PDF compliance mode
55    ///
56    /// # Errors
57    /// Returns an error if PDF/A compliance is requested together with encryption,
58    /// since PDF/A-1b (ISO 19005-1) forbids encryption.
59    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        // PDF/A requires PDF 1.4
67        if compliance.requires_pdfa() {
68            self.version = "1.4".to_string();
69        }
70        self.compliance = compliance;
71        Ok(())
72    }
73
74    /// Add a page to the document
75    pub fn add_page(&mut self, page: PdfPage) {
76        self.pages.push(page);
77    }
78
79    /// Add an image XObject to the document and return its index
80    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    /// Add a gradient shading pattern to the document and return its index
86    ///
87    /// The gradient will be registered as a PDF shading pattern resource.
88    /// Returns the index that can be used to reference this gradient.
89    pub fn add_gradient(&mut self, gradient: Gradient) -> usize {
90        self.gradients.push(PdfGradient {
91            gradient,
92            object_id: 0, // Will be assigned during PDF generation
93        });
94        self.gradients.len() - 1
95    }
96
97    /// Add an ExtGState for opacity/transparency and return its index
98    ///
99    /// Creates an Extended Graphics State dictionary with the specified opacity values.
100    /// Returns the index that can be used to reference this graphics state.
101    ///
102    /// # Arguments
103    /// * `fill_opacity` - Opacity for fill operations (0.0 = transparent, 1.0 = opaque)
104    /// * `stroke_opacity` - Opacity for stroke operations (0.0 = transparent, 1.0 = opaque)
105    pub fn add_ext_g_state(&mut self, fill_opacity: f64, stroke_opacity: f64) -> usize {
106        // Check if this opacity combination already exists
107        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        // Add new graphics state
116        self.ext_g_states.push(PdfExtGState {
117            fill_opacity,
118            stroke_opacity,
119            object_id: 0, // Will be assigned during PDF generation
120        });
121        self.ext_g_states.len() - 1
122    }
123
124    /// Set the document outline (bookmarks)
125    pub fn set_outline(&mut self, outline: PdfOutline) {
126        self.outline = Some(outline);
127    }
128
129    /// Set encryption for the document
130    ///
131    /// When encryption is set, `to_bytes()` will encrypt all content streams
132    /// and string objects, and include the /Encrypt dictionary in the trailer.
133    ///
134    /// # Errors
135    /// Returns an error if PDF/A compliance mode is active, since PDF/A-1b
136    /// (ISO 19005-1 §6.1.1) forbids encryption.
137    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    /// Encrypt data for a specific PDF object (if encryption is enabled)
150    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    /// Embed a TrueType font and return its index
159    ///
160    /// # Arguments
161    /// * `font_data` - Raw bytes of the TTF/OTF font file
162    ///
163    /// # Returns
164    /// Font index that can be used with `add_text_with_font`
165    pub fn embed_font(&mut self, font_data: Vec<u8>) -> Result<usize> {
166        self.font_manager.embed_font(font_data)
167    }
168
169    /// Generate PDF bytes
170    pub fn to_bytes(&self) -> Result<Vec<u8>> {
171        let mut bytes = Vec::new();
172        let mut xref_offsets = Vec::new();
173
174        // PDF header
175        bytes.extend_from_slice(format!("%PDF-{}\n", self.version).as_bytes());
176        bytes.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n"); // Binary marker
177
178        // Object 0 is always free
179        xref_offsets.push(0);
180
181        // Calculate outline object count
182        let outline_obj_count = if let Some(ref outline) = self.outline {
183            count_outline_objects(outline)
184        } else {
185            0
186        };
187
188        // Calculate encryption object count (1 if encryption is set)
189        let encrypt_obj_count = if self.encryption.is_some() { 1 } else { 0 };
190
191        // Calculate compliance object IDs / counts
192        // Compliance objects are placed after the encryption dict (if present):
193        //   xmp_obj_id       : XMP metadata stream (if any compliance mode)
194        //   output_intent_id : OutputIntent dict   (if PDF/A)
195        //   icc_profile_id   : ICC profile stream  (if PDF/A)
196        //   struct_tree_id   : StructTreeRoot       (if PDF/UA)
197        let font_obj_id = 3;
198        let first_outline_obj_id = 4;
199        let num_embedded_fonts = self.font_manager.font_count();
200        // Encryption dict goes after outline objects
201        let encrypt_obj_id = first_outline_obj_id + outline_obj_count;
202
203        // Compliance objects follow immediately after the encryption dict slot
204        let compliance_base_id = encrypt_obj_id + encrypt_obj_count;
205        let needs_compliance = self.compliance != PdfCompliance::Standard;
206        // needs_xmp is true whenever we have a user-supplied XMP packet OR a non-standard
207        // compliance mode (PDF/A-1b or PDF/UA-1 both require an /Metadata stream).
208        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; // only valid when needs_xmp
211        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; // only valid when pdfa
217        let icc_profile_obj_id = output_intent_obj_id + 1; // only valid when pdfa
218        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; // only valid when pdfua
224        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        // Object 1: Catalog (root)
229        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        // Add outline reference if present
236        if self.outline.is_some() {
237            bytes.extend_from_slice(b"/Outlines 4 0 R\n");
238        }
239
240        // PDF/A, PDF/UA, and user-XMP catalog entries
241        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            // Add /Lang to catalog even without PDF/UA when xml:lang is specified
260            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; // 6 objects per font: descriptor, stream, CIDFont, Type0, ToUnicode, CIDToGIDMap
266        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; // 2 objects per gradient
270        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        // Count total annotations and build annotation ranges per page
274        #[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        // Object 2: Pages (page tree root)
279        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        // Build kids array
285        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        // Object 3: Font resource (Type 1 Helvetica)
299        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        // Generate outline objects if present
309        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        // Generate encryption dictionary object if encryption is enabled
320        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        // Generate XMP metadata stream (for compliance modes and/or user-supplied XMP)
327        if needs_xmp {
328            let xmp_content = if let Some(ref raw_xmp) = self.xmp_metadata {
329                // User-supplied packet: reconcile (wrap in <?xpacket?>, splice compliance IDs)
330                reconcile_xmp(raw_xmp, self.compliance)
331            } else {
332                // Compliance-only mode: generate a default XMP packet
333                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            // OutputIntent dictionary referencing the ICC profile stream
351            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            // ICC profile stream (sRGB)
364            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"); // 3 colour components for RGB
369            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            // StructTreeRoot — minimal structure tree required by PDF/UA-1
377            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        // Generate embedded font objects (6 objects per font: descriptor, stream, CIDFont, Type0, ToUnicode, CIDToGIDMap)
385        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                // Font descriptor object
409                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                // Font stream object (the actual TTF data)
415                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                // CIDFont dictionary object (CIDFontType2 - TrueType descendant)
424                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                // Type 0 font dictionary object (composite font)
432                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                // ToUnicode CMap object
440                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                // CIDToGIDMap stream object
450                // Get the subsetter for this font to find used characters
451                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        // Generate image XObject objects
474        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            // Write XObject dictionary and stream header
479            let stream_header = xobject.to_pdf_stream(obj_id as u32);
480            bytes.extend_from_slice(stream_header.as_bytes());
481
482            // Write binary stream data
483            bytes.extend_from_slice(xobject.stream_data());
484
485            // Write stream end
486            bytes.extend_from_slice(ImageXObject::stream_end().as_bytes());
487        }
488
489        // Generate gradient shading objects (2 objects per gradient: function + shading)
490        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            // Generate the gradient objects
495            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        // Generate ExtGState objects for transparency
505        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        // Generate page objects and content streams in order
518        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            // Page object first
524            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            // Font resources: F1 is Helvetica, F2+ are embedded fonts
540            bytes.extend_from_slice(b"  /Font <<\n");
541            bytes.extend_from_slice(format!("    /F1 {} 0 R\n", font_obj_id).as_bytes());
542
543            // Add embedded fonts as F2, F3, F4, etc.
544            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; // Type0 dictionary is 4th object (6 objects per font)
547                    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            // Add XObject resources if there are any images
555            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            // Add Shading resources if there are any gradients
567            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; // Shading is 2nd object
571                    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            // Add ExtGState resources if there are any transparency settings
579            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            // Add /Annots array if this page has link annotations
594            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            // Content stream object second (encrypt if needed)
609            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        // Generate link annotation objects
622        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"); // No border
642
643                    // Add destination based on type
644                    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                            // For internal destinations, we would need to resolve the ID to a page
656                            // For now, use a named destination
657                            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        // Cross-reference table
673        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"); // Object 0 is free
677        for offset in xref_offsets.iter().skip(1) {
678            bytes.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
679        }
680
681        // Trailer
682        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        // Add /Encrypt reference if encryption is enabled
688        if self.encryption.is_some() {
689            bytes.extend_from_slice(format!("/Encrypt {} 0 R\n", encrypt_obj_id).as_bytes());
690        }
691
692        // Add /ID array (required for encryption, recommended for all PDFs)
693        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        // Add document info if any metadata is present
702        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")); // Begin text
784        assert!(content_str.contains("ET")); // End text
785    }
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        // Add a page with content
803        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        // Set encryption
816        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        // Verify encrypted PDF structure
831        assert!(content.contains("%PDF-"));
832        assert!(content.contains("/Filter /Standard"));
833        assert!(content.contains("/V 2")); // Version 2 (RC4-128)
834        assert!(content.contains("/R 3")); // Revision 3
835        assert!(content.contains("/Length 128"));
836        assert!(content.contains("/Encrypt")); // Trailer has /Encrypt
837        assert!(content.contains("/ID [<")); // Trailer has /ID
838
839        // Content stream should be encrypted (not contain plaintext)
840        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        // Without encryption, text should be visible in the PDF
862        assert!(content.contains("Visible Text"));
863        // Should NOT have encryption entries
864        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        // Should have filled rectangle
927        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        // PDF/A uses PDF 1.4
938        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")); // AES-256 version
977        assert!(content.contains("/R 6")); // Revision 6
978        assert!(content.contains("/OE <")); // owner encrypted key
979    }
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        // Add pages for the outline to reference
1003        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        // Should have line drawing content
1028        assert!(!content.is_empty());
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests_document_comprehensive {
1034    use super::*;
1035    use fop_types::Length;
1036
1037    // ── PdfDocument::new() ────────────────────────────────────────────────────
1038
1039    #[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    // ── PDF version header ────────────────────────────────────────────────────
1071
1072    #[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    // ── Page count in catalog ─────────────────────────────────────────────────
1088
1089    #[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    // ── Info dictionary fields ────────────────────────────────────────────────
1119
1120    #[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        // A fresh document with no metadata should have no /Info entry
1167        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    // ── Cross-reference table structure ───────────────────────────────────────
1189
1190    #[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        // Object 0 must be the free-object entry
1204        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        // At least one in-use entry must be present
1213        assert!(s.contains(" 00000 n "));
1214    }
1215
1216    // ── Trailer dictionary ────────────────────────────────────────────────────
1217
1218    #[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        // Trailer must contain a /Size entry
1232        assert!(s.contains("/Size "));
1233    }
1234
1235    // ── startxref offset ─────────────────────────────────────────────────────
1236
1237    #[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        // Find startxref and read the number after it
1251        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    // ── PdfInfo struct ────────────────────────────────────────────────────────
1267
1268    #[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    // ── Document ID in trailer ────────────────────────────────────────────────
1289
1290    #[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        // Set file_id directly (without encryption)
1296        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    // ── add_ext_g_state deduplication ─────────────────────────────────────────
1303
1304    #[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    // ── add_gradient ─────────────────────────────────────────────────────────
1323
1324    #[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    // ── Catalog structure ─────────────────────────────────────────────────────
1342
1343    #[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    // ── PdfPage ───────────────────────────────────────────────────────────────
1360
1361    #[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        // Color set (rg) and rectangle drawn (re f)
1396        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        // rect: [x, y, x+w, y+h]
1425        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    // ── PdfObject / PdfValue ──────────────────────────────────────────────────
1452
1453    #[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    // ── set_compliance errors ─────────────────────────────────────────────────
1501
1502    #[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        // The raw string r"\(parenthesised\)" is the PDF-escaped form
1522        assert!(
1523            content.contains(r"/Title (\(parenthesised\))"),
1524            "Expected PDF-escaped title with backslash-escaped parentheses; got:\n{}",
1525            content
1526        );
1527        // The unescaped form must NOT appear
1528        assert!(!content.contains("/Title ((parenthesised))"));
1529    }
1530}